change_ai_import.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. 'use strict';
  2. const crypto = require('crypto');
  3. const audit = require('../const/audit');
  4. const changeConst = require('../const/change');
  5. const GclGather = require('./gcl_gather').gclGather;
  6. class ChangeAiImport {
  7. constructor(ctx) {
  8. this.ctx = ctx;
  9. }
  10. async getContext(scene, id, transaction, includeLedger = true) {
  11. const ctx = this.ctx;
  12. const configs = {
  13. apply: {
  14. service: 'changeApply',
  15. list: 'changeApplyList',
  16. att: 'changeApplyAtt',
  17. key: 'id',
  18. foreign: 'caid',
  19. feature: 'openChangeApply',
  20. },
  21. plan: {
  22. service: 'changePlan',
  23. list: 'changePlanList',
  24. att: 'changePlanAtt',
  25. key: 'id',
  26. foreign: 'cpid',
  27. feature: 'openChangePlan',
  28. },
  29. change: {
  30. service: 'change',
  31. list: 'changeAuditList',
  32. att: 'changeAtt',
  33. key: 'cid',
  34. foreign: 'cid',
  35. },
  36. };
  37. const config = configs[scene];
  38. if (!config || !id) throw '变更识别参数有误';
  39. if (config.feature && !ctx.subProject.page_show[config.feature]) throw '该功能已关闭';
  40. const service = ctx.service[config.service];
  41. const where = { [config.key]: id, tid: ctx.tender.id };
  42. const change = transaction
  43. ? (await service.getAllDataForUpdate(where, transaction))[0]
  44. : await service.getDataByCondition(where);
  45. const status = audit[config.service].status;
  46. // 与详情页一致:只允许原报人在未上报、退回或修订时导入。
  47. if (!change || String(change.uid) !== String(ctx.session.sessionUser.accountId)
  48. || ![status.uncheck, status.checkNo, status.revise].includes(change.status)) {
  49. throw '当前单据不可导入,请刷新页面检查权限或审批状态';
  50. }
  51. const listService = ctx.service[config.list];
  52. const detailWhere = { [config.foreign]: id, tid: ctx.tender.id };
  53. const lists = transaction
  54. ? await listService.getAllDataForUpdate(detailWhere, transaction, 'id')
  55. : await listService.getAllDataByCondition({ where: detailWhere, orders: [['id', 'asc']] });
  56. const attachments = await ctx.service[config.att].getAllDataByCondition({
  57. where: detailWhere,
  58. columns: ['id'],
  59. orders: [['id', 'asc']],
  60. }, transaction);
  61. change.decimal = typeof change.decimal === 'string' ? JSON.parse(change.decimal) : change.decimal;
  62. change.decimal = change.decimal || {
  63. tp: ctx.tender.info.decimal.tp,
  64. up: ctx.tender.info.decimal.up,
  65. precision: ctx.tender.info.precision,
  66. };
  67. ctx.change = change;
  68. const ledgerTargets = scene === 'change' && includeLedger ? await this._getLedgerTargets(transaction) : [];
  69. const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(ledgerTargets)).digest('hex');
  70. const version = crypto.createHash('sha256').update(JSON.stringify({ change, lists, attachments })).digest('hex');
  71. const fields = this._getFields(scene);
  72. const original = {};
  73. for (const field of fields) {
  74. original[field.key] = change[field.key] === null || change[field.key] === undefined ? '' : String(change[field.key]);
  75. }
  76. const classes = (ctx.subProject.fun_set.change_class || []).filter(item => item.checked).map(item => ({
  77. value: String(scene === 'change' ? item.value : item.new_name || item.name),
  78. name: item.new_name || item.name,
  79. aliases: [item.name, item.new_name].filter(Boolean),
  80. }));
  81. const qualities = Object.values(changeConst.quality).map(item => ({
  82. value: String(scene === 'change' ? item.value : item.name),
  83. name: item.name,
  84. aliases: [item.name.replace('设计', '')],
  85. }));
  86. return {
  87. scene,
  88. id,
  89. config,
  90. change,
  91. fields,
  92. original,
  93. lists,
  94. ledgerTargets,
  95. ledgerVersion,
  96. version,
  97. precision: change.decimal.precision || ctx.tender.info.precision,
  98. upDecimal: change.up_decimal === null || change.up_decimal === undefined ? change.decimal.up : change.up_decimal,
  99. options: { class: classes, quality: qualities },
  100. // AI 导入匹配不到已有清单和台账时,允许新增空白清单。
  101. allowAdd: true,
  102. };
  103. }
  104. _getFields(scene) {
  105. const relatedKeys = { apply: 'project_code', plan: 'apply_code', change: 'plan_code' };
  106. const fields = [
  107. ['code', '编号'],
  108. ['name', '变更名称'],
  109. ['peg', '桩号'],
  110. [relatedKeys[scene], '关联单据编号'],
  111. ['org_name', '原设计名称'],
  112. [scene === 'change' ? 'org_code' : 'new_code', '原设计图号'],
  113. [scene === 'change' ? 'new_name' : 'design_name', '变更设计名称'],
  114. [scene === 'change' ? 'new_code' : 'c_new_code', '变更设计图号'],
  115. ['class', '变更类别'],
  116. ['quality', '变更性质'],
  117. ];
  118. const textFields = {
  119. apply: [['reason', '变更原因'], ['org_content', '原设计情况'], ['site_content', '现场情况'], ['content', '变更内容']],
  120. plan: [['reason', '拟变更方案'], ['content', '变更内容'], ['memo', '备注'], ['expr', '计算依据']],
  121. change: [['content', '变更内容'], ['basis', '变更依据'], ['expr', '计算依据'], ['memo', '备注']],
  122. };
  123. const result = fields.map(item => ({
  124. key: item[0],
  125. name: item[1],
  126. type: ['class', 'quality'].includes(item[0]) ? 'select' : 'text',
  127. }));
  128. return result.concat(textFields[scene].map(item => ({ key: item[0], name: item[1], type: 'textarea' })));
  129. }
  130. async save(scene, id, payload, file) {
  131. if (!payload || !['all', 'main', 'items'].includes(payload.extract_scope)
  132. || !Array.isArray(payload.rows) || payload.rows.length > 500) {
  133. throw '导入数据格式有误';
  134. }
  135. if (file && scene === 'apply'
  136. && !changeConst.file_type.some(item => String(item.key) === String(payload.attachment_type))) {
  137. throw '请选择附件资料类型';
  138. }
  139. const ctx = this.ctx;
  140. const transaction = await ctx.service.change.beginTransaction();
  141. try {
  142. // 加锁后重新检查权限和预览版本,所有写入使用同一个事务。
  143. const needsLedger = payload.extract_scope !== 'main' && payload.rows.some(row => row.action === '台账插入');
  144. const data = await this.getContext(scene, id, transaction, needsLedger);
  145. if (payload.version !== data.version) throw '单据已发生变化或本次导入已保存,请关闭窗口并重新识别';
  146. if (needsLedger && payload.ledgerVersion !== data.ledgerVersion) throw '台账已发生变化,请返回第一步重新识别并核对台账目标';
  147. const info = {};
  148. if (payload.extract_scope !== 'items') {
  149. for (const field of data.fields) {
  150. if (!Object.prototype.hasOwnProperty.call(payload.main || {}, field.key)) continue;
  151. const value = payload.main[field.key];
  152. if (typeof value !== 'string' || value.length > (field.type === 'textarea' ? 60000 : 2000)) {
  153. throw '基本信息字段格式有误';
  154. }
  155. if (value === data.original[field.key]) continue;
  156. if (field.type === 'select' && !data.options[field.key].some(item => item.value === value)) {
  157. throw field.name + '不在系统选项内';
  158. }
  159. if (['code', 'name'].includes(field.key) && !value.trim()) throw field.name + '不能为空';
  160. info[field.key] = field.type === 'select' && scene === 'change' ? Number(value) : value;
  161. }
  162. }
  163. if (info.code && await ctx.service[data.config.service].isRepeat(id, info.code, ctx.tender.id)) {
  164. throw '该编号已存在';
  165. }
  166. const updates = [];
  167. const inserts = [];
  168. const usedTargets = new Set();
  169. const usedLedgerTargets = new Set();
  170. const beforeKey = scene === 'change' ? 'oamount2' : 'oamount';
  171. const selected = payload.extract_scope === 'main' ? [] : payload.rows;
  172. for (const row of selected) {
  173. if (row.action === '跳过') continue;
  174. if (!['新增', '更新', '台账插入'].includes(row.action)) throw '清单处理方式有误';
  175. const target = row.action === '更新' ? data.lists.find(item => String(item.id) === String(row.target)) : null;
  176. const ledgerTarget = row.action === '台账插入' ? data.ledgerTargets.find(item => item.id === row.target) : null;
  177. if (row.action === '台账插入') {
  178. if (!ledgerTarget) throw '台账清单或部位已失效,请重新选择';
  179. if (usedLedgerTargets.has(ledgerTarget.id) || data.lists.some(item =>
  180. String(item.gcl_id) === String(ledgerTarget.gcl_id) && String(item.mx_id || '') === String(ledgerTarget.mx_id))) {
  181. throw '该台账清单部位已添加或被多行选中,请改为更新已有清单或重新选择';
  182. }
  183. usedLedgerTargets.add(ledgerTarget.id);
  184. }
  185. if (row.action === '更新' && (!target || usedTargets.has(String(target.id)))) {
  186. throw '更新目标不存在或多行选择了同一清单,请重新核对';
  187. }
  188. if (target) usedTargets.add(String(target.id));
  189. if (!target && !ledgerTarget && !data.allowAdd) throw '空白清单添加功能未开启';
  190. const item = {};
  191. if (ledgerTarget) {
  192. Object.assign(item, ledgerTarget);
  193. delete item.id;
  194. delete item.source;
  195. } else if (!target) {
  196. for (const key of ['code', 'name', 'unit']) {
  197. if (typeof row[key] !== 'string' || row[key].length > 500) throw '清单文本格式有误';
  198. item[key] = row[key].trim();
  199. }
  200. if (!item.name || !item.unit) throw '新增清单名称、单位不能为空';
  201. item.unit_price = this._parseNumber(row.price, true);
  202. }
  203. const before = this._parseNumber(row.before, false);
  204. const delta = this._parseNumber(row.delta, false);
  205. const unit = target ? target.unit : item.unit;
  206. const precision = data.change.decimal.precision || ctx.tender.info.precision;
  207. const unitSetting = Object.values(precision || {}).find(value => value.unit === unit);
  208. const digits = unitSetting ? unitSetting.value : precision && precision.other ? precision.other.value : 3;
  209. const syncOriginal = scene === 'change' && !this._hasLedgerReference(ledgerTarget || target);
  210. item[beforeKey] = ctx.helper.round(before, digits);
  211. if (syncOriginal) item.oamount = item[beforeKey];
  212. item.camount = ctx.helper.round(delta, digits);
  213. const sameBefore = target && target[beforeKey] !== null && target[beforeKey] !== undefined
  214. && target[beforeKey] !== '' && Number(target[beforeKey]) === item[beforeKey]
  215. && (!syncOriginal || target.oamount !== null && target.oamount !== undefined
  216. && target.oamount !== '' && Number(target.oamount) === item.oamount);
  217. const sameDelta = target && target.camount !== null && target.camount !== undefined
  218. && target.camount !== '' && Number(target.camount) === item.camount;
  219. if (sameBefore && sameDelta) continue;
  220. if (!sameDelta) {
  221. item.spamount = item.camount;
  222. if (scene === 'change') item.camount_expr = '';
  223. }
  224. if (target) {
  225. if (scene === 'change') await this._checkTarget(target, item);
  226. item.id = target.id;
  227. updates.push(item);
  228. } else {
  229. item.tid = ctx.tender.id;
  230. item[data.config.foreign] = id;
  231. if (item.unit_price !== null) item.unit_price = ctx.helper.round(item.unit_price, data.upDecimal);
  232. if (scene === 'change') {
  233. Object.assign(item, {
  234. lid: '0',
  235. bwmx: '',
  236. samount: '',
  237. detail: '',
  238. gcl_id: '',
  239. mx_id: '',
  240. is_valuation: 1,
  241. delimit: data.change.delimit === undefined || data.change.delimit === null ? 100 : data.change.delimit,
  242. order: data.change.order_by ? Math.max(0, ...data.lists.map(list => Number(list.order) || 0)) + inserts.length + 1 : null,
  243. });
  244. if (ledgerTarget) {
  245. for (const key of ['lid', 'gcl_id', 'mx_id', 'bwmx', 'xmj_code', 'xmj_jldy', 'xmj_dwgc', 'xmj_fbgc', 'xmj_fxgc', 'oamount']) {
  246. item[key] = ledgerTarget[key];
  247. }
  248. await this._checkTarget({ gcl_id: item.gcl_id, mx_id: item.mx_id }, item);
  249. }
  250. }
  251. inserts.push(item);
  252. }
  253. }
  254. if (!Object.keys(info).length && !inserts.length && !updates.length && !file) throw '没有需要保存的变更';
  255. let attachment;
  256. if (file) {
  257. attachment = {
  258. tid: ctx.tender.id,
  259. [data.config.foreign]: id,
  260. uid: ctx.session.sessionUser.accountId,
  261. filename: scene === 'change' ? file.name : file.filename,
  262. fileext: file.ext,
  263. filepath: file.filepath,
  264. filesize: scene === 'change' ? file.size : ctx.helper.bytesToSize(file.size),
  265. extra_upload: false,
  266. };
  267. if (scene === 'change') {
  268. attachment.in_time = Math.floor(Date.now() / 1000);
  269. } else {
  270. attachment.upload_time = new Date();
  271. attachment.type = scene === 'apply' ? Number(payload.attachment_type) : 5;
  272. }
  273. }
  274. if (Object.keys(info).length) {
  275. await ctx.service[data.config.service].defaultUpdate(info, {
  276. where: { [data.config.key]: id, tid: ctx.tender.id },
  277. }, transaction);
  278. }
  279. const listService = ctx.service[data.config.list];
  280. if (inserts.length) {
  281. await listService.insertDatasSplit(transaction, listService.tableName, inserts);
  282. }
  283. if (updates.length) {
  284. await listService.defaultUpdateRows(updates, transaction);
  285. }
  286. if (inserts.length || updates.length) {
  287. await listService.calcCamountSum(transaction);
  288. }
  289. if (attachment) {
  290. const attachmentService = ctx.service[data.config.att];
  291. await attachmentService.insertDatasSplit(transaction, attachmentService.tableName, [attachment]);
  292. }
  293. await transaction.commit();
  294. return { added: inserts.length, updated: updates.length, fields: Object.keys(info).length };
  295. } catch (err) {
  296. await transaction.rollback();
  297. throw err;
  298. }
  299. }
  300. _parseNumber(value, optional) {
  301. if (value === '' || value === null || value === undefined) {
  302. if (optional) return null;
  303. throw '原设计数量和增减数量不能为空,请核对或跳过该行';
  304. }
  305. if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(String(value))
  306. || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > 1e15) {
  307. throw '清单数值格式有误';
  308. }
  309. return Number(value);
  310. }
  311. async _checkTarget(target, values) {
  312. const ctx = this.ctx;
  313. const ready = await ctx.service.settle.getReadySettle(ctx.tender.id);
  314. if (ready && target.gcl_id) {
  315. const service = target.mx_id ? ctx.service.settlePos : ctx.service.settleBills;
  316. const where = { settle_id: ready.id, lid: target.gcl_id };
  317. if (target.mx_id) where.pid = target.mx_id;
  318. const settled = await service.getDataByCondition(where);
  319. if (settled && settled.settle_status === ctx.service.settle.settleStatus.finish) throw '已结算清单不能修改';
  320. }
  321. if (!target.id) return;
  322. const used = await ctx.service.stageChange.getAllFinalUsedData(ctx.tender.id, ctx.change.cid);
  323. let quantity = 0;
  324. for (const item of used.filter(item => String(item.cbid) === String(target.id))) {
  325. quantity = ctx.helper.add(quantity, item.qty || 0);
  326. }
  327. if ((quantity > 0 && values.camount < quantity) || (quantity < 0 && values.camount > quantity)) {
  328. throw '清单增减数量不能小于已调用数量范围';
  329. }
  330. }
  331. async _getLedgerTargets(transaction) {
  332. const ctx = this.ctx;
  333. const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id);
  334. const includeChange = !lastRevise || lastRevise.status === audit.revise.status.checked;
  335. const ledgerCondition = { where: { tender_id: ctx.tender.id }, orders: [['ledger_id', 'asc']] };
  336. const posCondition = { where: { tid: ctx.tender.id }, orders: [['porder', 'asc'], ['id', 'asc']] };
  337. const bills = await ctx.service.ledger.getAllDataByCondition(ledgerCondition, transaction);
  338. const positions = await ctx.service.pos.getAllDataByCondition(posCondition, transaction);
  339. if (includeChange) {
  340. bills.push(...await ctx.service.changeLedger.getAllDataByCondition(ledgerCondition, transaction));
  341. positions.push(...await ctx.service.changePos.getAllDataByCondition(posCondition, transaction));
  342. }
  343. return this.buildLedgerContext(bills, positions).ledgerTargets;
  344. }
  345. buildLedgerContext(bills, positions) {
  346. const ctx = this.ctx;
  347. const gather = new GclGather(ctx);
  348. gather.billsTree.loadDatas(bills.slice().sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0) || String(a.id).localeCompare(String(b.id))));
  349. const posByLedger = new Map();
  350. for (const pos of positions.slice().sort((a, b) => (Number(a.porder) || 0) - (Number(b.porder) || 0) || String(a.id).localeCompare(String(b.id)))) {
  351. const key = String(pos.lid);
  352. if (!posByLedger.has(key)) posByLedger.set(key, []);
  353. posByLedger.get(key).push(pos);
  354. }
  355. const groups = new Map();
  356. const targets = [];
  357. for (const node of gather.billsTree.nodes) {
  358. if (!node.b_code || node.children.length) continue;
  359. const groupKey = JSON.stringify([node.b_code, node.name || '', node.unit || '', Number(node.unit_price) || 0]);
  360. if (!groups.has(groupKey)) groups.set(groupKey, node.id);
  361. let section = node;
  362. while (section.b_code && gather.billsTree.getParent(section)) section = gather.billsTree.getParent(section);
  363. const peg = gather.getPegNode(section);
  364. const details = posByLedger.get(String(node.id)) || [node];
  365. for (const detail of details) {
  366. const mxId = detail === node ? '' : String(detail.id);
  367. const quantity = Number(detail.quantity) || 0;
  368. targets.push({
  369. id: JSON.stringify([String(node.id), mxId]),
  370. source: 'ledger',
  371. lid: groups.get(groupKey),
  372. gcl_id: node.id,
  373. mx_id: mxId,
  374. code: node.b_code,
  375. name: node.name || '',
  376. unit: node.unit || '',
  377. unit_price: node.unit_price === undefined ? null : node.unit_price,
  378. oamount: quantity,
  379. oamount2: quantity,
  380. camount: 0,
  381. bwmx: detail.name !== node.name ? detail.name || section.name || '' : section.name || '',
  382. xmj_code: section.code || '',
  383. xmj_jldy: section.name || '',
  384. xmj_dwgc: gather.getDwgc(peg, section),
  385. xmj_fbgc: gather.getFbgc(peg, section),
  386. xmj_fxgc: gather.getFxgc(peg, section),
  387. });
  388. }
  389. }
  390. const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(targets)).digest('hex');
  391. return { ledgerTargets: targets, ledgerVersion };
  392. }
  393. _hasLedgerReference(item) {
  394. return !!item && [item.lid, item.gcl_id, item.mx_id].some(value => {
  395. const id = value === null || value === undefined ? '' : String(value).trim();
  396. return id !== '' && id !== '0';
  397. });
  398. }
  399. }
  400. module.exports = ChangeAiImport;