浏览代码

fix: 预付款审批增加会签或签结构预留。

lanjianrong 1 天之前
父节点
当前提交
e06b99d48b

+ 4 - 1
app/middleware/advance_check.js

@@ -60,7 +60,7 @@ module.exports = () => {
                 const auditList = yield this.service.advanceAudit.getAllDataByCondition({ where: { vid: advance.id, times: advance.times }, orders: [['order', 'asc']] });
                 const auditIdList = _.map(auditList, 'audit_id');
                 if (shenpi_status === shenpiConst.sp_status.gdspl) {
-                    const shenpiList = yield this.service.shenpiAudit.getAllDataByCondition({ where: { tid: advance.tid, sp_type: shenpiConst.sp_type.advance, sp_status: shenpi_status } });
+                    const shenpiList = yield this.service.shenpiAudit.getAllDataByCondition({ where: { tid: advance.tid, sp_type: shenpiConst.sp_type.advance, sp_status: shenpi_status }, orders: [['audit_order', 'asc'], ['id', 'asc']] });
                     const shenpiIdList = _.map(shenpiList, 'audit_id');
                     // 判断2个id数组是否相同,不同则删除原审批流,切换成固定的审批流
                     if (!_.isEqual(auditIdList, shenpiIdList)) {
@@ -77,6 +77,9 @@ module.exports = () => {
                     }
                 }
             }
+            // 固定流程/终审同步后,供控制器使用的审批人信息也应重新读取。
+            advance.auditors = yield this.service.advanceAudit.getAuditors(advance.id, advance.times);
+            advance.curAuditor = yield this.service.advanceAudit.getCurAuditor(advance.id, advance.times);
             yield next;
         } catch (err) {
             this.helper.log(err);

+ 3 - 3
app/public/js/advance_audit.js

@@ -59,7 +59,7 @@ $(document).ready(function () {
                 if (i === 0) return
                 group.forEach((auditor, j) => {
                     html.push('<tr>')
-                    html.push(`<td class="text-left">${j === 0 ? i + '审' : ''}</td>`)
+                    html.push(`<td class="text-left">${j === 0 ? auditor.audit_order + '审' : ''}</td>`)
                     html.push(`<td>${auditor.name} <small class="text-muted">${auditor.role || ''}</small></td>`)
                     const statusText = auditor.status === auditConst.status.uncheck ? '待审批' : (auditConst.auditString[auditor.status] || '')
                     html.push(`<td class="text-center"><span class="${auditConst.auditStringClass[auditor.status] || ''}">${statusText}</span></td>`)
@@ -223,7 +223,7 @@ $(document).ready(function () {
                             html.push('<a href="javascript: void(0)" class="text-danger pull-right">移除</a>');
                         }
                         html.push('<span>');
-                        html.push(data.order + ' ');
+                        html.push(data.audit_order + ' ');
                         html.push(data.name + ' ');
                         html.push('</span>');
                         html.push('<small class="text-muted">');
@@ -270,7 +270,7 @@ $(document).ready(function () {
             li.remove();
             for (const rst of result) {
                 const aLi = $('li[auditorId=' + rst.audit_id + ']');
-                $('span', aLi).text(rst.order + ' ' + rst.name + ' ')
+                $('span', aLi).text(rst.audit_order + ' ' + rst.name + ' ')
             }
             // 删除左边审核人
             $(`#auditors2 li[data-auditorid='${data.auditorId}']`).remove();

+ 1 - 1
app/service/advance.js

@@ -156,7 +156,7 @@ module.exports = app => {
             for (let idx = 0; idx < auditors.length; idx++) {
                 const { audit_id } = auditors[idx];
                 await ctx.service.advanceAudit.db.insert(ctx.service.advanceAudit.tableName, {
-                    tid: latestOrder.tid, audit_id, type: latestOrder.type, vid: record.insertId, times: 1, order: idx + 1, status: 1, create_time: new Date(),
+                    tid, audit_id, type, vid: record.insertId, times: 1, order: idx + 1, audit_order: idx + 1, audit_type: 1, status: 1, create_time: new Date(),
                 });
             }
             // auditors.forEach(async (auditor, idx) => {

+ 71 - 39
app/service/advance_audit.js

@@ -15,6 +15,13 @@ module.exports = app => {
             this.tableName = 'advance_audit';
         }
 
+        async checkAuditNodes(vid, times) {
+            const rows = await this.getAllDataByCondition({ where: { vid, times } });
+            if (rows.some(x => !(x.audit_order > 0) || x.audit_type !== auditType.key.common)) {
+                throw '请先回填当前预付款的审批节点序号和普通审批类型';
+            }
+        }
+
         /**
          * 获取审核人流程列表
          * @param {Number} vid 预付款记录id
@@ -22,12 +29,9 @@ module.exports = app => {
          * @return {Promise<Array>} 查询结果集
          */
         async getAuditGroupByList(vid, times = 1) {
-            const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, la.`times`, la.`vid`, la.`order` ' +
-                '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id`' +
-                '  WHERE la.`vid` = ? and la.`times` = ? GROUP BY la.`audit_id` ORDER BY la.`order`';
-            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, times];
-            return await this.db.query(sql, sqlParam);
+            const auditors = await this.getAuditors(vid, times);
+            // 一个节点可因退回、重审产生多条过程记录,流程展示取该节点最新记录。
+            return this._.sortBy(this._.uniqBy(auditors.slice().reverse(), 'audit_order'), 'audit_order');
         }
 
         /**
@@ -39,7 +43,7 @@ module.exports = app => {
         async getAuditorsWithOwner(vid, times = 1) {
             const result = await this.getAuditGroupByList(vid, times);
             const sql =
-                'SELECT pa.`id` As audit_id, pa.`name`, pa.`company`, pa.`role`, ? As times, ? As vid, 0 As `order`' +
+                'SELECT pa.`id` As audit_id, pa.`name`, pa.`company`, pa.`role`, ? As times, ? As vid, 0 As `order`, 0 As audit_order, 1 As audit_type' +
                 '  FROM ' + this.ctx.service.advance.tableName + ' As s' +
                 '  LEFT JOIN ' + this.ctx.service.projectAccount.tableName + ' As pa' +
                 '  ON s.uid = pa.id' +
@@ -72,12 +76,18 @@ module.exports = app => {
          * @return {Boolean} 是否插入成功
          */
         async addAuditor(tid, vid, audit_id, times = 1, type, is_gdzs = 0) {
+            await this.checkAuditNodes(vid, times);
             const transaction = await this.db.beginTransaction();
             try {
-                let newOrder = await this.getNewOrder(vid, times);
+                const audits = await transaction.select(this.tableName, { where: { vid, times }, orders: [['order', 'asc']] });
+                let newOrder = audits.length ? audits[audits.length - 1].order + 1 : 1;
+                let newAuditOrder = audits.length ? Math.max(...audits.map(x => x.audit_order)) + 1 : 1;
                 // 判断是否存在固定终审,存在则newOrder - 1并使终审order+1
-                newOrder = is_gdzs === 1 ? newOrder - 1 : newOrder;
-                if (is_gdzs) await this._syncOrderByDelete(transaction, vid, newOrder, times, '+');
+                if (is_gdzs && audits.length) {
+                    newOrder--;
+                    newAuditOrder--;
+                    await this._syncOrderByDelete(transaction, vid, newOrder, times, '+', newAuditOrder);
+                }
                 const record = {
                     tid,
                     vid,
@@ -85,6 +95,8 @@ module.exports = app => {
                     audit_id,
                     times,
                     order: newOrder,
+                    audit_order: newAuditOrder,
+                    audit_type: auditType.key.common,
                     status: auditConst.status.uncheck,
                 };
                 const result = await transaction.insert(this.tableName, record);
@@ -105,6 +117,7 @@ module.exports = app => {
          * @return {Promise<boolean>}
          */
         async deleteAuditor(vid, audit_id, times = 1) {
+            await this.checkAuditNodes(vid, times);
             const transaction = await this.db.beginTransaction();
             try {
                 const condition = { vid, audit_id, times };
@@ -112,7 +125,7 @@ module.exports = app => {
                 if (!auditor) {
                     throw '该审核人不存在';
                 }
-                await this._syncOrderByDelete(transaction, vid, auditor.order, times);
+                await this._syncOrderByDelete(transaction, vid, auditor.order, times, '-', auditor.audit_order);
                 await transaction.delete(this.tableName, condition);
                 await transaction.commit();
             } catch (err) {
@@ -131,7 +144,7 @@ module.exports = app => {
          * @return {Promise<*>} 查询结果集
          * @private
          */
-        async _syncOrderByDelete(transaction, vid, order, times, selfOperate = '-') {
+        async _syncOrderByDelete(transaction, vid, order, times, selfOperate = '-', auditOrder = order) {
             this.initSqlBuilder();
             this.sqlBuilder.setAndWhere('vid', {
                 value: this.db.escape(vid),
@@ -151,6 +164,8 @@ module.exports = app => {
             });
             const [sql, sqlParam] = this.sqlBuilder.build(this.tableName, 'update');
             const data = await transaction.query(sql, sqlParam);
+            // 节点序号与过程序号分开移动,退回上一审后两者不再相等。
+            await transaction.query('UPDATE ?? SET audit_order = audit_order ' + (selfOperate === '+' ? '+' : '-') + ' 1 WHERE vid = ? AND times = ? AND audit_order >= ?', [this.tableName, vid, times, auditOrder]);
             return data;
         }
 
@@ -162,7 +177,7 @@ module.exports = app => {
          */
         async getCurAuditor(vid, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
+                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`audit_order`, la.`audit_type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
                 '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id` ' +
                 '  WHERE la.`vid` = ? and la.`status` = ? and la.`times` = ?';
             const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, auditConst.status.checking, times];
@@ -177,12 +192,12 @@ module.exports = app => {
          */
         async getAuditors(vid, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time`, g.`sort` ' +
-                'FROM ?? AS la, ?? AS pa, (SELECT t1.`audit_id`,(@i:=@i+1) as `sort` FROM (SELECT t.`audit_id`, t.`order` FROM (select `audit_id`, `order` from ?? WHERE `vid` = ? AND `times` = ? ORDER BY `order` LIMIT 200) t GROUP BY t.`audit_id` ORDER BY t.`order`) t1, (select @i:=0) as it) as g ' +
-                'WHERE la.`vid` = ? and la.`times` = ? and la.`audit_id` = pa.`id` and g.`audit_id` = la.`audit_id` order by la.`order`';
-            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, this.tableName, vid, times, vid, times];
+                'SELECT la.*, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`audit_order` AS `sort` ' +
+                'FROM ?? AS la LEFT JOIN ?? AS pa ON la.audit_id = pa.id ' +
+                'WHERE la.vid = ? AND la.times = ? ORDER BY la.`order`, la.id';
+            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, times];
             const result = await this.db.query(sql, sqlParam);
-            const sql2 = 'SELECT COUNT(a.`audit_id`) as num FROM (SELECT `audit_id` FROM ?? WHERE `vid` = ? AND `times` = ? GROUP BY `audit_id`) as a';
+            const sql2 = 'SELECT MAX(audit_order) AS num FROM ?? WHERE vid = ? AND times = ?';
             const sqlParam2 = [this.tableName, vid, times];
             const count = await this.db.queryOne(sql2, sqlParam2);
             for (const i in result) {
@@ -200,7 +215,7 @@ module.exports = app => {
          */
         async getAuditor(vid, audit_id, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
+                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`audit_order`, la.`audit_type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
                 '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id` ' +
                 '  WHERE la.`vid` = ? and la.`audit_id` = ? and la.`times` = ?';
             const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, audit_id, times];
@@ -214,6 +229,7 @@ module.exports = app => {
          * @param {Object} data - 载荷
          */
         async start(vid, times = 1, data) {
+            await this.checkAuditNodes(vid, times);
             const audit = await this.getDataByCondition({ vid, times, order: 1 });
             if (!audit) {
                 if(this.ctx.tender.info.shenpi.advance === shenpiConst.sp_status.gdspl) {
@@ -367,15 +383,15 @@ module.exports = app => {
             if (!audit) {
                 throw '审核数据错误';
             }
-            const sql = 'SELECT `tid`, `vid`, `audit_id`, `order` FROM ?? WHERE `vid` = ? and `times` = ? GROUP BY `audit_id` ORDER BY `id` ASC';
+            // 按流程节点去重,保留节点序号和审批类型;退回、重审产生的过程记录不重复复制。
+            const sql = 'SELECT `tid`, `vid`, `type`, `audit_id`, `audit_order`, `audit_type` FROM ?? WHERE `vid` = ? and `times` = ? ' +
+                'GROUP BY `tid`, `vid`, `type`, `audit_id`, `audit_order`, `audit_type` ORDER BY `audit_order` ASC';
             const sqlParam = [this.tableName, advanceId, times];
             const auditors = await this.db.query(sql, sqlParam);
-            let order = 1;
             for (const a of auditors) {
                 a.times = times + 1;
-                a.order = order;
+                a.order = a.audit_order;
                 a.status = auditConst.status.uncheck;
-                order++;
             }
             const transaction = await this.db.beginTransaction();
             try {
@@ -425,7 +441,7 @@ module.exports = app => {
             const time = new Date();
             // 整理当前流程审核人状态更新
             const audit = await this.getDataByCondition({ vid: advanceId, times, status: auditConst.status.checking });
-            if (!audit || audit.order <= 1) {
+            if (!audit || audit.audit_order <= 1) {
                 throw '审核数据错误';
             }
             // 添加重新审批后,不能用order-1,取groupby值里的上一个才对
@@ -448,6 +464,7 @@ module.exports = app => {
                 // 顺移气候审核人流程顺序
                 this.initSqlBuilder();
                 this.sqlBuilder.setAndWhere('vid', { value: advanceId, operate: '=' });
+                this.sqlBuilder.setAndWhere('times', { value: times, operate: '=' });
                 this.sqlBuilder.setAndWhere('order', { value: audit.order, operate: '>' });
                 this.sqlBuilder.setUpdateData('order', { value: 2, selfOperate: '+' });
                 const [sql, sqlParam] = this.sqlBuilder.build(this.tableName, 'update');
@@ -459,6 +476,8 @@ module.exports = app => {
                     audit_id: preAuditor.audit_id,
                     times: audit.times,
                     order: audit.order + 1,
+                    audit_order: preAuditor.audit_order,
+                    audit_type: preAuditor.audit_type,
                     status: auditConst.status.checking,
                     create_time: time,
                 };
@@ -470,6 +489,8 @@ module.exports = app => {
                     audit_id: audit.audit_id,
                     times: audit.times,
                     order: audit.order + 2,
+                    audit_order: audit.audit_order,
+                    audit_type: audit.audit_type,
                     status: auditConst.status.uncheck,
                 };
                 await transaction.insert(this.tableName, uncheckNewAuditors);
@@ -514,6 +535,7 @@ module.exports = app => {
          * @return {Promise<void>}
          */
         async check(advanceId, checkData, times = 1, type) {
+            await this.checkAuditNodes(advanceId, times);
             if (checkData.checkType !== auditConst.status.checked && checkData.checkType !== auditConst.status.checkNo && checkData.checkType !== auditConst.status.checkNoPre) {
                 throw '提交数据错误';
             }
@@ -539,6 +561,7 @@ module.exports = app => {
          * @return {Promise<*>} - 可用的变更令列表
          */
         async checkAgain(advance) {
+            await this.checkAuditNodes(advance.id, advance.times);
             const accountId = this.ctx.session.sessionUser.accountId;
             // 初始化事务
             const time = new Date();
@@ -556,6 +579,7 @@ module.exports = app => {
                     checkAgainAuditors.push({
                         tid: advance.tid, vid: advance.id, audit_id: x.audit_id, type: advance.type,
                         times: x.times, order: maxOrder + 1,
+                        audit_order: x.audit_order, audit_type: x.audit_type,
                         status: auditConst.status.checkAgain,
                         create_time: time, end_time: time, opinion: '',
                     });
@@ -565,6 +589,7 @@ module.exports = app => {
                     checkingAuditors.push({
                         tid: advance.tid, vid: advance.id, audit_id: x.audit_id, type: advance.type,
                         times: x.times, order: maxOrder + 2,
+                        audit_order: x.audit_order, audit_type: x.audit_type,
                         status: auditConst.status.checking,
                         create_time: time,
                     });
@@ -748,6 +773,7 @@ module.exports = app => {
                     newAuditors.push({
                         tid: advance.tid, vid: advance.id, type: advance.type, audit_id: aid,
                         times: advance.times, order, status: auditConst.status.uncheck,
+                        audit_order: order, audit_type: auditType.key.common,
                     });
                     order++;
                 }
@@ -769,7 +795,7 @@ module.exports = app => {
                     await transaction.delete(this.tableName, { vid: advance.id, times: advance.times, audit_id: lastId });
                     const audit = this._.find(auditList, { 'audit_id': lastId });
                     // 顺移之后审核人流程顺序
-                    await this._syncOrderByDelete(transaction, advance.id, audit.order, advance.times);
+                    await this._syncOrderByDelete(transaction, advance.id, audit.order, advance.times, '-', audit.audit_order);
                     order = order - 1;
                 }
 
@@ -777,6 +803,7 @@ module.exports = app => {
                 const newAuditor = {
                     tid: advance.tid, vid: advance.id, type: advance.type, audit_id: lastId,
                     times: advance.times, order, status: auditConst.status.uncheck,
+                    audit_order: order, audit_type: auditType.key.common,
                 };
                 await transaction.insert(this.tableName, newAuditor);
                 await transaction.commit();
@@ -791,13 +818,7 @@ module.exports = app => {
          */
         async getAdminAuditGroups(vid, times) {
             const ownerAndAuditors = await this.getAuditorsWithOwner(vid, times);
-            const owner = ownerAndAuditors[0];
-            const auditors = [owner].concat(await this.getAuditors(vid, times));
-            return auditors.map((item, index) => [{
-                ...item,
-                audit_type: auditType.key.common,
-                audit_order: index,
-            }]);
+            return ownerAndAuditors.map(item => [item]);
         }
 
         /**
@@ -810,12 +831,20 @@ module.exports = app => {
                     where: { vid: advance.id, times: advance.times },
                     orders: [['order', 'asc'], ['id', 'asc']],
                 });
-                const current = audits.find(item => item.audit_id === Number(data.old_aid));
+                const current = audits.slice().reverse().find(item => item.audit_id === Number(data.old_aid));
                 if (!current) throw '审批人不存在';
+                if (audits.some(x => !(x.audit_order > 0) || x.audit_type !== auditType.key.common)) {
+                    throw '请先回填当前预付款的审批节点序号和普通审批类型';
+                }
+                if (data.operate !== 'add' && audits.some(x => x.id !== current.id && x.audit_order === current.audit_order)) {
+                    throw '该节点已有历史审批记录,无法移除或更换';
+                }
 
                 if (data.operate !== 'del') {
                     const newId = Number(data.new_aid);
                     if (!newId) throw '请选择审批人';
+                    if (newId === Number(advance.uid)) throw '原报人不能添加为审批人';
+                    if (data.operate === 'add' && newId === current.audit_id) throw '该审核人已存在,请勿重复添加';
                     if (audits.some(item => item.audit_id === newId && item.id !== current.id)) {
                         throw '该审核人已存在,请勿重复添加';
                     }
@@ -826,7 +855,7 @@ module.exports = app => {
 
                     if (data.operate === 'add') {
                         if ([auditConst.status.uncheck, auditConst.status.checking].indexOf(current.status) < 0) throw '当前节点后无法新增';
-                        await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '+');
+                        await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '+', current.audit_order + 1);
                         await transaction.insert(this.tableName, {
                             tid: advance.tid,
                             vid: advance.id,
@@ -834,6 +863,8 @@ module.exports = app => {
                             audit_id: newId,
                             times: advance.times,
                             order: current.order + 1,
+                            audit_order: current.audit_order + 1,
+                            audit_type: auditType.key.common,
                             status: auditConst.status.uncheck,
                         });
                     } else if (data.operate === 'change') {
@@ -845,20 +876,21 @@ module.exports = app => {
                 } else {
                     if (current.status !== auditConst.status.uncheck) throw '当前审批人无法移除';
                     await transaction.delete(this.tableName, { id: current.id });
-                    await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times);
+                    await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '-', current.audit_order + 1);
                 }
 
                 // 同步固定审批配置,避免中间件再次用旧配置覆盖本次调整。
                 const shenpiStatus = this.ctx.tender.info.shenpi.advance;
-                const newAudits = await transaction.select(this.tableName, {
+                const allNewAudits = await transaction.select(this.tableName, {
                     where: { vid: advance.id, times: advance.times },
-                    orders: [['order', 'asc'], ['id', 'asc']],
+                    orders: [['audit_order', 'asc'], ['order', 'desc']],
                 });
+                const newAudits = this._.uniqBy(allNewAudits, 'audit_order');
                 if (shenpiStatus === shenpiConst.sp_status.gdspl) {
-                    const groups = newAudits.map((item, index) => [{
+                    const groups = newAudits.map(item => [{
                         ...item,
                         audit_type: auditType.key.common,
-                        audit_order: index + 1,
+                        audit_order: item.audit_order,
                     }]);
                     await this.ctx.service.shenpiAudit.updateAuditListWithAuditType(
                         transaction, this.ctx.tender.id, shenpiStatus, shenpiConst.sp_type.advance, groups, advance.sp_group || 0

+ 84 - 0
db_script/bak/advance_audit_order.js

@@ -0,0 +1,84 @@
+'use strict';
+
+// 先手动增加 audit_order、audit_type 字段。
+// node db_script/bak/advance_audit_order.js uat [--tid=标段ID] [--vid=预付款ID] [--apply]
+// 默认预览,--apply 写入;逐期逐轮按 order 排序、按人员去重回填。
+const common = require('../../app/const/audit').auditType.key.common;
+
+function buildUpdates(rows) {
+    const sorted = rows.slice().sort((a, b) => a.order - b.order || a.id - b.id);
+    const ids = [];
+    sorted.forEach(row => { if (!ids.includes(row.audit_id)) ids.push(row.audit_id); });
+    if (sorted.some(row => (row.audit_order > 0 && row.audit_order !== ids.indexOf(row.audit_id) + 1) ||
+        (row.audit_type > 0 && row.audit_type !== common))) {
+        return { skip: '已有节点序号或审批类型冲突,请人工核对' };
+    }
+    return { updates: sorted.filter(row => !(row.audit_order > 0) || !(row.audit_type > 0)).map(row => ({
+        id: row.id, audit_id: row.audit_id,
+        before: { audit_order: row.audit_order, audit_type: row.audit_type },
+        audit_order: ids.indexOf(row.audit_id) + 1, audit_type: common,
+    })) };
+}
+
+async function main() {
+    const env = process.argv[2];
+    if (!['local', 'uat', 'default'].includes(env)) throw new Error('环境仅支持 local / uat / default');
+    const filters = {};
+    let apply = false;
+    for (const arg of process.argv.slice(3)) {
+        if (arg === '--apply') { apply = true; continue; }
+        const match = /^--(vid|tid)=(\d+)$/.exec(arg);
+        if (!match || !Number.isSafeInteger(Number(match[2])) || Number(match[2]) <= 0) throw new Error('无效参数:' + arg);
+        filters[match[1]] = Number(match[2]);
+    }
+    const mysql = require('mysql');
+    // 使用与 baseUtils 相同的配置入口,独立连接确保每轮更新处于同一事务。
+    const path = require('path');
+    const root = path.resolve(__dirname, '../..');
+    const config = require(path.join(root, 'config/config.' + env))({ baseDir: root, root, name: 'calc' });
+    const connection = mysql.createConnection(config.mysql.client);
+    const query = (sql, params = []) => new Promise((resolve, reject) => {
+        connection.query(sql, params, (err, rows) => err ? reject(err) : resolve(rows));
+    });
+    const log = data => {
+        console.log(JSON.stringify(data));
+    };
+    try {
+        const columns = await query('SHOW COLUMNS FROM zh_advance_audit');
+        if (!['audit_order', 'audit_type'].every(name => columns.some(x => x.Field === name))) {
+            throw new Error('请先为 zh_advance_audit 增加 audit_order 和 audit_type 字段');
+        }
+        log({ env, apply, filters });
+        const where = ['1 = 1'], params = [];
+        for (const key of ['tid', 'vid']) {
+            if (filters[key]) { where.push(key + ' = ?'); params.push(filters[key]); }
+        }
+        const rounds = await query('SELECT DISTINCT tid, vid, times FROM zh_advance_audit WHERE ' + where.join(' AND ') + ' ORDER BY tid, vid, times', params);
+        let updated = 0;
+        for (const round of rounds) {
+            await query('START TRANSACTION');
+            try {
+                const rows = await query('SELECT id, audit_id, `order`, audit_order, audit_type FROM zh_advance_audit WHERE tid = ? AND vid = ? AND times = ? ORDER BY `order`, id FOR UPDATE', [round.tid, round.vid, round.times]);
+                const plan = buildUpdates(rows);
+                log({ phase: 'plan', ...round, ...plan });
+                if (!apply || plan.skip) { await query('ROLLBACK'); continue; }
+                for (const row of plan.updates) {
+                    const result = await query('UPDATE zh_advance_audit SET audit_order = ?, audit_type = ? WHERE id = ?', [row.audit_order, row.audit_type, row.id]);
+                    if (result.affectedRows !== 1) throw new Error('回填失败:' + row.id);
+                }
+                await query('COMMIT');
+                updated += plan.updates.length;
+                log({ phase: 'committed', ...round, updated: plan.updates.length });
+            } catch (err) {
+                await query('ROLLBACK');
+                throw err;
+            }
+        }
+        log({ phase: 'done', rounds: rounds.length, updated });
+    } finally {
+        connection.end();
+    }
+}
+
+module.exports = { buildUpdates };
+if (require.main === module) main().catch(err => { console.error(err); process.exitCode = 1; });

+ 3 - 0
sql/update.sql

@@ -119,6 +119,9 @@ ALTER TABLE `zh_ledger_attachment`
   ADD COLUMN `drawing_file_id` varchar(36) DEFAULT NULL COMMENT '纵横图纸文件ID',
   ADD COLUMN `drawing_access` text NULL COMMENT '纵横图纸只读访问凭证';
 
+ALTER TABLE `zh_advance_audit`
+ADD COLUMN `audit_type` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '审批类型(1个人,2会签,3或签)' AFTER `audit_id`,
+ADD COLUMN `audit_order` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '审批顺序' AFTER `audit_type`;
 ------------------------------------
 -- 表数据
 ------------------------------------

+ 103 - 0
test/app/service/advance_audit_order.test.js

@@ -0,0 +1,103 @@
+'use strict';
+
+// 独立回归测试:node --test test/app/service/advance_audit_order.test.js(无需连接数据库)
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const _ = require('lodash');
+const status = require('../../../app/const/audit').advance.status;
+const Service = require('../../../app/service/advance_audit')({ BaseService: class {} });
+const { buildUpdates } = require('../../../db_script/bak/advance_audit_order');
+
+function fixture(rows) {
+    const service = new Service();
+    service._ = _;
+    service.initSqlBuilder = () => { service.sqlBuilder = new (require('../../../app/lib/sql_builder'))(); };
+    const advance = { id: 1, tid: 2, times: 1, type: 1, uid: 99, order: 1, cur_amount: 0 };
+    let records = rows.map(x => ({ vid: 1, tid: 2, times: 1, type: 1, audit_type: 1, ...x }));
+    const tx = {
+        select: async (table, options) => _.orderBy(records.filter(x => _.isMatch(x, options.where)), options.orders.map(x => x[0]), options.orders.map(x => x[1])).map(x => ({ ...x })),
+        insert: async (table, data) => {
+            if (table === 'advance_audit') for (const row of [].concat(data)) records.push({ id: Math.max(0, ...records.map(x => x.id)) + 1, ...row });
+            return { affectedRows: 1 };
+        },
+        update: async (table, row) => { if (table === 'advance_audit') Object.assign(records.find(x => x.id === row.id), row); },
+        delete: async (table, where) => { records = records.filter(x => !_.isMatch(x, where)); },
+        query: async (sql, params) => { assert(params.includes('times'), '退回顺移必须限定本轮'); },
+        commit: async () => {}, rollback: async () => {},
+    };
+    service.db = { beginTransaction: async () => tx, query: async sql => {
+        assert(sql.includes('`audit_order`') && sql.includes('`audit_type`'));
+        return _.uniqBy(_.sortBy(records, 'audit_order'), 'audit_order').map(x => _.pick(x, ['tid', 'vid', 'type', 'audit_id', 'audit_order', 'audit_type']));
+    } };
+    service.getAllDataByCondition = async options => records.filter(x => _.isMatch(x, options.where));
+    service.getDataByCondition = async where => records.find(x => _.isMatch(x, where));
+    service.getAuditors = async () => _.sortBy(records, 'order').map(x => ({ ...x }));
+    service._syncOrderByDelete = async (transaction, vid, order, times, operation, auditOrder) => {
+        for (const x of records.filter(x => x.vid === vid && x.times === times)) {
+            if (x.order >= order) x.order += operation === '+' ? 1 : -1;
+            if (x.audit_order >= auditOrder) x.audit_order += operation === '+' ? 1 : -1;
+        }
+    };
+    service.getNoticeContent = async () => '';
+    service.ctx = { advance, tender: { id: 2, info: { shenpi: { advance: 1 } } }, session: { sessionProject: { id: 1 }, sessionUser: { accountId: 30 } },
+        helper: { urlToShort: async () => '', sendWechat: async () => {} },
+        service: { projectAccount: { getDataById: async () => ({ enable: 1, project_id: 1 }) },
+            advance: { tableName: 'advance', getDataById: async () => advance }, noticePush: { tableName: 'notice' },
+            noticeAgain: { stopNoticeAgain: async () => {}, addNoticeAgain: async () => {} }, specMsg: { addAdvanceMsg: async () => {} } } };
+    return { service, advance, records: () => records };
+}
+
+test('中途插人后退回原报,保留插入位置和原终审', async () => {
+    const f = fixture([{ id: 1, audit_id: 10, order: 1, audit_order: 1, status: status.checking }, { id: 2, audit_id: 30, order: 2, audit_order: 2, status: status.uncheck }]);
+    await f.service.saveAudit(f.advance, { operate: 'add', old_aid: 10, new_aid: 20 });
+    assert.deepEqual(_.sortBy(f.records(), 'order').map(x => [x.audit_id, x.audit_order]), [[10, 1], [20, 2], [30, 3]]);
+    await f.service._checkNo(1, 1, { checkType: status.checkNo, opinion: '' }, 1);
+    assert.deepEqual(f.records().filter(x => x.times === 2).map(x => [x.audit_id, x.order, x.audit_order, x.audit_type]), [[10, 1, 1, 1], [20, 2, 2, 1], [30, 3, 3, 1]]);
+});
+
+test('过程顺序与节点不同,插人分别移动两种序号,管理员取最新节点', async () => {
+    const f = fixture([{ id: 1, audit_id: 10, order: 1, audit_order: 1, status: status.checked }, { id: 2, audit_id: 10, order: 4, audit_order: 1, status: status.checking }, { id: 3, audit_id: 30, order: 5, audit_order: 2, status: status.uncheck }]);
+    await f.service.saveAudit(f.advance, { operate: 'add', old_aid: 10, new_aid: 20 });
+    assert.deepEqual(_.sortBy(f.records(), 'order').map(x => [x.order, x.audit_order]), [[1, 1], [4, 1], [5, 2], [6, 3]]);
+    assert.deepEqual((await f.service.getAuditGroupByList(1, 1)).map(x => x.audit_id), [10, 20, 30]);
+});
+
+test('顺移SQL使用独立边界并限定轮次', async () => {
+    const s = new Service(), calls = [];
+    s.db = { escape: x => x };
+    s.initSqlBuilder = () => { s.sqlBuilder = new (require('../../../app/lib/sql_builder'))(); };
+    await s._syncOrderByDelete({ query: async (...args) => calls.push(args) }, 1, 7, 2, '+', 3);
+    assert(calls[0][0].includes('>= 7') && calls[0][0].includes('= 2'));
+    assert.deepEqual(calls[1][1], ['advance_audit', 1, 2, 3]);
+});
+
+test('退回上一审只追加过程记录,节点序号保持不变', async () => {
+    const f = fixture([{ id: 1, audit_id: 10, order: 1, audit_order: 1, status: status.checked }, { id: 2, audit_id: 30, order: 2, audit_order: 2, status: status.checking }]);
+    await f.service._checkNoPre(1, 1, { checkType: status.checkNoPre, opinion: '' }, 1, 1);
+    assert.deepEqual(f.records().slice(2).map(x => [x.audit_id, x.order, x.audit_order, x.audit_type]), [[10, 3, 1, 1], [30, 4, 2, 1]]);
+});
+
+test('终审重新审批,新增两条过程记录沿用终审节点', async () => {
+    const f = fixture([{ id: 1, audit_id: 10, order: 1, audit_order: 1, status: status.checked }, { id: 2, audit_id: 30, order: 6, audit_order: 2, status: status.checked }]);
+    f.advance.auditors = f.records();
+    assert.equal(await f.service.checkAgain(f.advance), true);
+    assert.deepEqual(f.records().slice(2).map(x => [x.order, x.audit_order, x.audit_type]), [[7, 2, 1], [8, 2, 1]]);
+});
+
+test('历史回填去重且可重复运行,冲突轮次跳过', () => {
+    const rows = [{ id: 1, audit_id: 10, order: 1 }, { id: 2, audit_id: 30, order: 3 }, { id: 3, audit_id: 20, order: 2 }, { id: 4, audit_id: 10, order: 4 }];
+    const updates = buildUpdates(rows).updates;
+    assert.deepEqual(updates.map(x => x.audit_order), [1, 2, 3, 1]);
+    assert.equal(buildUpdates(rows.map(x => ({ ...x, ...updates.find(y => y.id === x.id) }))).updates.length, 0);
+    assert(buildUpdates([{ ...rows[0], audit_order: 5 }]).skip);
+});
+
+test('普通增加固定终审前人员、删除及替换同步节点', async () => {
+    const f = fixture([{ id: 1, audit_id: 10, order: 1, audit_order: 1, status: status.uncheck }, { id: 2, audit_id: 30, order: 2, audit_order: 2, status: status.uncheck }]);
+    await f.service.addAuditor(2, 1, 20, 1, 1, 1);
+    assert.deepEqual(_.sortBy(f.records(), 'audit_order').map(x => x.audit_id), [10, 20, 30]);
+    await f.service.saveAudit(f.advance, { operate: 'change', old_aid: 20, new_aid: 40 });
+    assert.equal(f.records().find(x => x.audit_id === 40).audit_order, 2);
+    await f.service.deleteAuditor(1, 40, 1);
+    assert.deepEqual(_.sortBy(f.records(), 'audit_order').map(x => [x.audit_id, x.order, x.audit_order]), [[10, 1, 1], [30, 2, 2]]);
+});