'use strict'; const crypto = require('crypto'); const audit = require('../const/audit'); const changeConst = require('../const/change'); const GclGather = require('./gcl_gather').gclGather; class ChangeAiImport { constructor(ctx) { this.ctx = ctx; } async getContext(scene, id, transaction, includeLedger = true) { const ctx = this.ctx; const configs = { apply: { service: 'changeApply', list: 'changeApplyList', att: 'changeApplyAtt', key: 'id', foreign: 'caid', feature: 'openChangeApply', }, plan: { service: 'changePlan', list: 'changePlanList', att: 'changePlanAtt', key: 'id', foreign: 'cpid', feature: 'openChangePlan', }, change: { service: 'change', list: 'changeAuditList', att: 'changeAtt', key: 'cid', foreign: 'cid', }, }; const config = configs[scene]; if (!config || !id) throw '变更识别参数有误'; if (config.feature && !ctx.subProject.page_show[config.feature]) throw '该功能已关闭'; const service = ctx.service[config.service]; const where = { [config.key]: id, tid: ctx.tender.id }; const change = transaction ? (await service.getAllDataForUpdate(where, transaction))[0] : await service.getDataByCondition(where); const status = audit[config.service].status; // 与详情页一致:只允许原报人在未上报、退回或修订时导入。 if (!change || String(change.uid) !== String(ctx.session.sessionUser.accountId) || ![status.uncheck, status.checkNo, status.revise].includes(change.status)) { throw '当前单据不可导入,请刷新页面检查权限或审批状态'; } const listService = ctx.service[config.list]; const detailWhere = { [config.foreign]: id, tid: ctx.tender.id }; const lists = transaction ? await listService.getAllDataForUpdate(detailWhere, transaction, 'id') : await listService.getAllDataByCondition({ where: detailWhere, orders: [['id', 'asc']] }); const attachments = await ctx.service[config.att].getAllDataByCondition({ where: detailWhere, columns: ['id'], orders: [['id', 'asc']], }, transaction); change.decimal = typeof change.decimal === 'string' ? JSON.parse(change.decimal) : change.decimal; change.decimal = change.decimal || { tp: ctx.tender.info.decimal.tp, up: ctx.tender.info.decimal.up, precision: ctx.tender.info.precision, }; ctx.change = change; const ledgerTargets = scene === 'change' && includeLedger ? await this._getLedgerTargets(transaction) : []; const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(ledgerTargets)).digest('hex'); const version = crypto.createHash('sha256').update(JSON.stringify({ change, lists, attachments })).digest('hex'); const fields = this._getFields(scene); const original = {}; for (const field of fields) { original[field.key] = change[field.key] === null || change[field.key] === undefined ? '' : String(change[field.key]); } const classes = (ctx.subProject.fun_set.change_class || []).filter(item => item.checked).map(item => ({ value: String(scene === 'change' ? item.value : item.new_name || item.name), name: item.new_name || item.name, aliases: [item.name, item.new_name].filter(Boolean), })); const qualities = Object.values(changeConst.quality).map(item => ({ value: String(scene === 'change' ? item.value : item.name), name: item.name, aliases: [item.name.replace('设计', '')], })); return { scene, id, config, change, fields, original, lists, ledgerTargets, ledgerVersion, version, precision: change.decimal.precision || ctx.tender.info.precision, upDecimal: change.up_decimal === null || change.up_decimal === undefined ? change.decimal.up : change.up_decimal, options: { class: classes, quality: qualities }, // AI 导入匹配不到已有清单和台账时,允许新增空白清单。 allowAdd: true, }; } _getFields(scene) { const relatedKeys = { apply: 'project_code', plan: 'apply_code', change: 'plan_code' }; const fields = [ ['code', '编号'], ['name', '变更名称'], ['peg', '桩号'], [relatedKeys[scene], '关联单据编号'], ['org_name', '原设计名称'], [scene === 'change' ? 'org_code' : 'new_code', '原设计图号'], [scene === 'change' ? 'new_name' : 'design_name', '变更设计名称'], [scene === 'change' ? 'new_code' : 'c_new_code', '变更设计图号'], ['class', '变更类别'], ['quality', '变更性质'], ]; const textFields = { apply: [['reason', '变更原因'], ['org_content', '原设计情况'], ['site_content', '现场情况'], ['content', '变更内容']], plan: [['reason', '拟变更方案'], ['content', '变更内容'], ['memo', '备注'], ['expr', '计算依据']], change: [['content', '变更内容'], ['basis', '变更依据'], ['expr', '计算依据'], ['memo', '备注']], }; const result = fields.map(item => ({ key: item[0], name: item[1], type: ['class', 'quality'].includes(item[0]) ? 'select' : 'text', })); return result.concat(textFields[scene].map(item => ({ key: item[0], name: item[1], type: 'textarea' }))); } async save(scene, id, payload, file) { if (!payload || !['all', 'main', 'items'].includes(payload.extract_scope) || !Array.isArray(payload.rows) || payload.rows.length > 500) { throw '导入数据格式有误'; } if (file && scene === 'apply' && !changeConst.file_type.some(item => String(item.key) === String(payload.attachment_type))) { throw '请选择附件资料类型'; } const ctx = this.ctx; const transaction = await ctx.service.change.beginTransaction(); try { // 加锁后重新检查权限和预览版本,所有写入使用同一个事务。 const needsLedger = payload.extract_scope !== 'main' && payload.rows.some(row => row.action === '台账插入'); const data = await this.getContext(scene, id, transaction, needsLedger); if (payload.version !== data.version) throw '单据已发生变化或本次导入已保存,请关闭窗口并重新识别'; if (needsLedger && payload.ledgerVersion !== data.ledgerVersion) throw '台账已发生变化,请返回第一步重新识别并核对台账目标'; const info = {}; if (payload.extract_scope !== 'items') { for (const field of data.fields) { if (!Object.prototype.hasOwnProperty.call(payload.main || {}, field.key)) continue; const value = payload.main[field.key]; if (typeof value !== 'string' || value.length > (field.type === 'textarea' ? 60000 : 2000)) { throw '基本信息字段格式有误'; } if (value === data.original[field.key]) continue; if (field.type === 'select' && !data.options[field.key].some(item => item.value === value)) { throw field.name + '不在系统选项内'; } if (['code', 'name'].includes(field.key) && !value.trim()) throw field.name + '不能为空'; info[field.key] = field.type === 'select' && scene === 'change' ? Number(value) : value; } } if (info.code && await ctx.service[data.config.service].isRepeat(id, info.code, ctx.tender.id)) { throw '该编号已存在'; } const updates = []; const inserts = []; const usedTargets = new Set(); const usedLedgerTargets = new Set(); const beforeKey = scene === 'change' ? 'oamount2' : 'oamount'; const selected = payload.extract_scope === 'main' ? [] : payload.rows; for (const row of selected) { if (row.action === '跳过') continue; if (!['新增', '更新', '台账插入'].includes(row.action)) throw '清单处理方式有误'; const target = row.action === '更新' ? data.lists.find(item => String(item.id) === String(row.target)) : null; const ledgerTarget = row.action === '台账插入' ? data.ledgerTargets.find(item => item.id === row.target) : null; if (row.action === '台账插入') { if (!ledgerTarget) throw '台账清单或部位已失效,请重新选择'; if (usedLedgerTargets.has(ledgerTarget.id) || data.lists.some(item => String(item.gcl_id) === String(ledgerTarget.gcl_id) && String(item.mx_id || '') === String(ledgerTarget.mx_id))) { throw '该台账清单部位已添加或被多行选中,请改为更新已有清单或重新选择'; } usedLedgerTargets.add(ledgerTarget.id); } if (row.action === '更新' && (!target || usedTargets.has(String(target.id)))) { throw '更新目标不存在或多行选择了同一清单,请重新核对'; } if (target) usedTargets.add(String(target.id)); if (!target && !ledgerTarget && !data.allowAdd) throw '空白清单添加功能未开启'; const item = {}; if (ledgerTarget) { Object.assign(item, ledgerTarget); delete item.id; delete item.source; } else if (!target) { for (const key of ['code', 'name', 'unit']) { if (typeof row[key] !== 'string' || row[key].length > 500) throw '清单文本格式有误'; item[key] = row[key].trim(); } if (!item.name || !item.unit) throw '新增清单名称、单位不能为空'; item.unit_price = this._parseNumber(row.price, true); } const before = this._parseNumber(row.before, false); const delta = this._parseNumber(row.delta, false); const unit = target ? target.unit : item.unit; const precision = data.change.decimal.precision || ctx.tender.info.precision; const unitSetting = Object.values(precision || {}).find(value => value.unit === unit); const digits = unitSetting ? unitSetting.value : precision && precision.other ? precision.other.value : 3; const syncOriginal = scene === 'change' && !this._hasLedgerReference(ledgerTarget || target); item[beforeKey] = ctx.helper.round(before, digits); if (syncOriginal) item.oamount = item[beforeKey]; item.camount = ctx.helper.round(delta, digits); const sameBefore = target && target[beforeKey] !== null && target[beforeKey] !== undefined && target[beforeKey] !== '' && Number(target[beforeKey]) === item[beforeKey] && (!syncOriginal || target.oamount !== null && target.oamount !== undefined && target.oamount !== '' && Number(target.oamount) === item.oamount); const sameDelta = target && target.camount !== null && target.camount !== undefined && target.camount !== '' && Number(target.camount) === item.camount; if (sameBefore && sameDelta) continue; if (!sameDelta) { item.spamount = item.camount; if (scene === 'change') item.camount_expr = ''; } if (target) { if (scene === 'change') await this._checkTarget(target, item); item.id = target.id; updates.push(item); } else { item.tid = ctx.tender.id; item[data.config.foreign] = id; if (item.unit_price !== null) item.unit_price = ctx.helper.round(item.unit_price, data.upDecimal); if (scene === 'change') { Object.assign(item, { lid: '0', bwmx: '', samount: '', detail: '', gcl_id: '', mx_id: '', is_valuation: 1, delimit: data.change.delimit === undefined || data.change.delimit === null ? 100 : data.change.delimit, order: data.change.order_by ? Math.max(0, ...data.lists.map(list => Number(list.order) || 0)) + inserts.length + 1 : null, }); if (ledgerTarget) { for (const key of ['lid', 'gcl_id', 'mx_id', 'bwmx', 'xmj_code', 'xmj_jldy', 'xmj_dwgc', 'xmj_fbgc', 'xmj_fxgc', 'oamount']) { item[key] = ledgerTarget[key]; } await this._checkTarget({ gcl_id: item.gcl_id, mx_id: item.mx_id }, item); } } inserts.push(item); } } if (!Object.keys(info).length && !inserts.length && !updates.length && !file) throw '没有需要保存的变更'; let attachment; if (file) { attachment = { tid: ctx.tender.id, [data.config.foreign]: id, uid: ctx.session.sessionUser.accountId, filename: scene === 'change' ? file.name : file.filename, fileext: file.ext, filepath: file.filepath, filesize: scene === 'change' ? file.size : ctx.helper.bytesToSize(file.size), extra_upload: false, }; if (scene === 'change') { attachment.in_time = Math.floor(Date.now() / 1000); } else { attachment.upload_time = new Date(); attachment.type = scene === 'apply' ? Number(payload.attachment_type) : 5; } } if (Object.keys(info).length) { await ctx.service[data.config.service].defaultUpdate(info, { where: { [data.config.key]: id, tid: ctx.tender.id }, }, transaction); } const listService = ctx.service[data.config.list]; if (inserts.length) { await listService.insertDatasSplit(transaction, listService.tableName, inserts); } if (updates.length) { await listService.defaultUpdateRows(updates, transaction); } if (inserts.length || updates.length) { await listService.calcCamountSum(transaction); } if (attachment) { const attachmentService = ctx.service[data.config.att]; await attachmentService.insertDatasSplit(transaction, attachmentService.tableName, [attachment]); } await transaction.commit(); return { added: inserts.length, updated: updates.length, fields: Object.keys(info).length }; } catch (err) { await transaction.rollback(); throw err; } } _parseNumber(value, optional) { if (value === '' || value === null || value === undefined) { if (optional) return null; throw '原设计数量和增减数量不能为空,请核对或跳过该行'; } if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(String(value)) || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > 1e15) { throw '清单数值格式有误'; } return Number(value); } async _checkTarget(target, values) { const ctx = this.ctx; const ready = await ctx.service.settle.getReadySettle(ctx.tender.id); if (ready && target.gcl_id) { const service = target.mx_id ? ctx.service.settlePos : ctx.service.settleBills; const where = { settle_id: ready.id, lid: target.gcl_id }; if (target.mx_id) where.pid = target.mx_id; const settled = await service.getDataByCondition(where); if (settled && settled.settle_status === ctx.service.settle.settleStatus.finish) throw '已结算清单不能修改'; } if (!target.id) return; const used = await ctx.service.stageChange.getAllFinalUsedData(ctx.tender.id, ctx.change.cid); let quantity = 0; for (const item of used.filter(item => String(item.cbid) === String(target.id))) { quantity = ctx.helper.add(quantity, item.qty || 0); } if ((quantity > 0 && values.camount < quantity) || (quantity < 0 && values.camount > quantity)) { throw '清单增减数量不能小于已调用数量范围'; } } async _getLedgerTargets(transaction) { const ctx = this.ctx; const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id); const includeChange = !lastRevise || lastRevise.status === audit.revise.status.checked; const ledgerCondition = { where: { tender_id: ctx.tender.id }, orders: [['ledger_id', 'asc']] }; const posCondition = { where: { tid: ctx.tender.id }, orders: [['porder', 'asc'], ['id', 'asc']] }; const bills = await ctx.service.ledger.getAllDataByCondition(ledgerCondition, transaction); const positions = await ctx.service.pos.getAllDataByCondition(posCondition, transaction); if (includeChange) { bills.push(...await ctx.service.changeLedger.getAllDataByCondition(ledgerCondition, transaction)); positions.push(...await ctx.service.changePos.getAllDataByCondition(posCondition, transaction)); } return this.buildLedgerContext(bills, positions).ledgerTargets; } buildLedgerContext(bills, positions) { const ctx = this.ctx; const gather = new GclGather(ctx); gather.billsTree.loadDatas(bills.slice().sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0) || String(a.id).localeCompare(String(b.id)))); const posByLedger = new Map(); for (const pos of positions.slice().sort((a, b) => (Number(a.porder) || 0) - (Number(b.porder) || 0) || String(a.id).localeCompare(String(b.id)))) { const key = String(pos.lid); if (!posByLedger.has(key)) posByLedger.set(key, []); posByLedger.get(key).push(pos); } const groups = new Map(); const targets = []; for (const node of gather.billsTree.nodes) { if (!node.b_code || node.children.length) continue; const groupKey = JSON.stringify([node.b_code, node.name || '', node.unit || '', Number(node.unit_price) || 0]); if (!groups.has(groupKey)) groups.set(groupKey, node.id); let section = node; while (section.b_code && gather.billsTree.getParent(section)) section = gather.billsTree.getParent(section); const peg = gather.getPegNode(section); const details = posByLedger.get(String(node.id)) || [node]; for (const detail of details) { const mxId = detail === node ? '' : String(detail.id); const quantity = Number(detail.quantity) || 0; targets.push({ id: JSON.stringify([String(node.id), mxId]), source: 'ledger', lid: groups.get(groupKey), gcl_id: node.id, mx_id: mxId, code: node.b_code, name: node.name || '', unit: node.unit || '', unit_price: node.unit_price === undefined ? null : node.unit_price, oamount: quantity, oamount2: quantity, camount: 0, bwmx: detail.name !== node.name ? detail.name || section.name || '' : section.name || '', xmj_code: section.code || '', xmj_jldy: section.name || '', xmj_dwgc: gather.getDwgc(peg, section), xmj_fbgc: gather.getFbgc(peg, section), xmj_fxgc: gather.getFxgc(peg, section), }); } } const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(targets)).digest('hex'); return { ledgerTargets: targets, ledgerVersion }; } _hasLedgerReference(item) { return !!item && [item.lid, item.gcl_id, item.mx_id].some(value => { const id = value === null || value === undefined ? '' : String(value).trim(); return id !== '' && id !== '0'; }); } } module.exports = ChangeAiImport;