فهرست منبع

feat(approval): 新增审批流程设置页面及草稿编辑功能

- 新增 /sp/:id/setting/approval 路由及审批设置页面入口
- 实现 TenderApprovalSetting 类支持草稿加载、校验和事务保存
- 新增前端审批编辑器支持操作记录、版本冲突检测和未保存提醒
- 将审批流程渲染逻辑提取为 renderTenderApproval 公共函数
- manage 方法增加 approvalOnly 参数区分审批专用渲染模式
- 修复 defaultCheck 未 await 导致异步权限检查失效的问题
- 修复 shenpi_status.gdspl 引用错误及 auditGroupList 空值异常
caipin 6 روز پیش
والد
کامیت
c5ad4ff7b5

+ 24 - 8
app/controller/sub_proj_setting_controller.js

@@ -19,6 +19,7 @@ const path = require('path');
 const funSet = require('../const/fun_set');
 const projectSettingConst = require('../const/project_setting');
 const shenpiConst = require('../const/shenpi');
+const TenderApprovalSetting = require('../lib/tender_approval_setting');
 
 module.exports = app => {
 
@@ -608,15 +609,15 @@ module.exports = app => {
             }
         }
 
-        async manage(ctx) {
+        async manage(ctx, approvalOnly = false) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
                 const projectId = ctx.session.sessionProject.id;
                 const projectData = await ctx.service.project.getDataById(projectId);
                 if (projectData === null) throw '没有对应的项目数据';
                 if (ctx.session.sessionUser.is_admin === 0) throw '没有访问权限';
                 // 默认过滤完工的标段即显示build的标段
-                const { done = '1' } = ctx.query || {};
+                const done = approvalOnly ? '1' : (ctx.query.done || '1');
                 const buildStatusFilter = ctx.service.tender.db.format(' AND build_status = ?', [tenderConst.buildStatus.status.build]);
                 const tenderList = await ctx.service.tender.getList('', null, 1, done === '1' ? buildStatusFilter : '');
                 const removeTenders = await ctx.service.shenpiAudit.getRemoveTenders(tenderList);
@@ -644,8 +645,9 @@ module.exports = app => {
 
                 // 获取项目列表
                 // const subProjects = await ctx.service.subProject.getAllDataByCondition({ where: { project_id: projectId, is_folder: 0, is_delete: 0 } });
-                const subProjects = await ctx.service.subProject.getSubProject(projectId, ctx.session.sessionUser.accountId, ctx.session.sessionUser.is_admin);
+                const subProjects = approvalOnly ? [] : await ctx.service.subProject.getSubProject(projectId, ctx.session.sessionUser.accountId, ctx.session.sessionUser.is_admin);
                 const renderData = {
+                    approvalOnly,
                     projectData,
                     tenderList,
                     auditConst,
@@ -666,9 +668,10 @@ module.exports = app => {
                 };
                 const permissionKey = ['quality', 'inspection', 'safe_inspection', 'safe_payment', 'schedule'];
                 renderData.selfCategoryLevel = await this.ctx.service.projectAccount.getSelfCategoryLevel(this.ctx.session.sessionUser.accountId);
-                renderData.permissionConst = ctx.service.tenderPermission.partPermissionConst(permissionKey);
-                renderData.permissionBlock = ctx.service.tenderPermission.partPermissionBlock(permissionKey);
-                await this.layout('sp_setting/manage.ejs', renderData, 'sp_setting/manage_modal.ejs');
+                renderData.permissionConst = approvalOnly ? {} : ctx.service.tenderPermission.partPermissionConst(permissionKey);
+                renderData.permissionBlock = approvalOnly ? [] : ctx.service.tenderPermission.partPermissionBlock(permissionKey);
+                const page = approvalOnly ? 'approval' : 'manage';
+                await this.layout(`sp_setting/${page}.ejs`, renderData, `sp_setting/${page}_modal.ejs`);
             } catch (error) {
                 ctx.log(error);
                 ctx.redirect(`/sp/${ctx.subProject.id}/dashboard`);
@@ -677,7 +680,7 @@ module.exports = app => {
 
         async manageTenderSave(ctx) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
                 if (ctx.session.sessionUser.is_admin === 0) throw '没有设置权限';
                 const projectId = ctx.session.sessionProject.id;
                 const responseData = {
@@ -697,6 +700,15 @@ module.exports = app => {
                     safe: ['safe_inspection', 'safe_payment'],
                 };
                 switch (data.type) {
+                    case 'approval-draft':
+                        responseData.data = await new TenderApprovalSetting(ctx).loadDraft(data.tid, data.code);
+                        break;
+                    case 'approval-save':
+                        responseData.data = await new TenderApprovalSetting(ctx).saveDraft(data.tid, data.code, data.version, data.operations);
+                        break;
+                    case 'approval-msg':
+                        responseData.data = await new TenderApprovalSetting(ctx).load(data.tid);
+                        break;
                     case 'msg':
                         if (!data.tid) {
                             throw '参数有误';
@@ -768,6 +780,10 @@ module.exports = app => {
                 ctx.body = { err: 1, msg: err.toString(), data: null };
             }
         }
+
+        async approval(ctx) {
+            await this.manage(ctx, true);
+        }
     }
 
     return SettingController;

+ 128 - 0
app/lib/tender_approval_setting.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const auditConst = require('../const/audit');
+const shenpiConst = require('../const/shenpi');
+const crypto = require('crypto');
+const TenderApprovalDraft = require('../public/js/shares/tender_approval_draft');
+
+class TenderApprovalSetting {
+    constructor(ctx) {
+        this.ctx = ctx;
+    }
+
+    async load(tid) {
+        const ctx = this.ctx;
+        if (!ctx.session.sessionUser.is_admin) throw '没有设置权限';
+        const tenderId = Number(tid);
+        if (!Number.isSafeInteger(tenderId) || tenderId <= 0) throw '参数有误';
+        const tender = await ctx.service.tender.getDataById(tenderId);
+        if (!tender || tender.spid !== ctx.subProject.id || tender.project_id !== ctx.session.sessionProject.id) {
+            throw '标段不存在或不属于当前项目';
+        }
+        const tenderInfo = await ctx.service.tenderInfo.getTenderInfo(tender.id);
+        if (!tenderInfo) throw '标段信息不存在';
+        const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(tender.id);
+        return {
+            tender,
+            revising: Boolean(lastRevise && lastRevise.status !== auditConst.revise.status.checked),
+            shenpi: await ctx.service.shenpiAudit.getShenpi(tender.id, tenderInfo),
+        };
+    }
+
+    async loadDraft(tid, code) {
+        const current = await this.load(tid);
+        const spec = shenpiConst.sp_lc.find(x => x.code === code);
+        if (!spec) throw '审批栏目不存在';
+        const snapshot = await this.ctx.service.tenderApprovalSetting.getSnapshot(current.tender.id, spec.type, code === 'stage');
+        if (!snapshot.info) throw '标段信息不存在';
+        const options = await this.getOptions(current, spec);
+        return { snapshot: this.toState(snapshot, code, options.accounts), options, version: this.version(snapshot, code) };
+    }
+
+    async saveDraft(tid, code, version, operations) {
+        const current = await this.load(tid);
+        const spec = shenpiConst.sp_lc.find(x => x.code === code);
+        if (!spec || !Array.isArray(operations) || operations.length > 1000 || !operations.length) throw '保存参数有误';
+        const service = this.ctx.service.tenderApprovalSetting;
+        const transaction = await service.beginTransaction();
+        try {
+            const tenders = await this.ctx.service.tender.getAllDataForUpdate({ id: current.tender.id }, transaction);
+            if (!tenders.length || tenders[0].spid !== this.ctx.subProject.id || tenders[0].project_id !== this.ctx.session.sessionProject.id) throw '标段已变更,请刷新';
+            current.tender = tenders[0];
+            const snapshot = await service.getSnapshot(current.tender.id, spec.type, code === 'stage', transaction);
+            if (!snapshot.info || this.version(snapshot, code) !== version) throw '审批配置已被修改,请关闭弹窗后重新编辑';
+            const options = await this.getOptions(current, spec);
+            const before = this.toState(snapshot, code, options.accounts);
+            const draft = new TenderApprovalDraft(before, options);
+            for (const operation of operations) draft.apply(operation);
+            draft.validate();
+            const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+            shenpi[code] = draft.state.status;
+            await service.persist(transaction, current.tender.id, spec.type, {
+                before: this.databaseRows(before), after: this.databaseRows(draft.state), shenpi,
+                stage: code === 'stage', cooperation: draft.cooperation,
+            });
+            await transaction.commit();
+        } catch (error) {
+            await transaction.rollback();
+            throw error;
+        }
+        return { saved: true };
+    }
+
+    async getOptions(current, spec) {
+        const accounts = await this.ctx.service.projectAccount.getAllSubProjectAccount(this.ctx.subProject);
+        let ledgerIds = null;
+        if (spec.code === 'stage') {
+            const ledgers = await this.ctx.service.ledger.getAllDataByCondition({ columns: ['ledger_id', 'b_code'], where: { tender_id: current.tender.id } });
+            ledgerIds = ledgers.filter(x => !x.b_code).map(x => String(x.ledger_id));
+        }
+        return {
+            code: spec.code, spType: spec.type, name: spec.name, owner: current.tender.user_id,
+            canCooperate: !current.revising && current.tender.ledger_status === auditConst.ledger.status.checked,
+            accounts: accounts.map(x => ({ id: x.id, name: x.name, company: x.company, role: x.role })),
+            auditTypes: auditConst.auditType.types, ledgerIds,
+        };
+    }
+
+    toState(snapshot, code, accounts) {
+        const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+        const rows = this.databaseRows(snapshot);
+        for (const row of rows.groups) row.change_type = row.change_type ? JSON.parse(row.change_type) : null;
+        for (const row of rows.audits) {
+            const account = accounts.find(x => x.id === row.audit_id);
+            Object.assign(row, { name: account ? account.name : '用户' + row.audit_id, company: account ? account.company : '', role: account ? account.role : '' });
+        }
+        return Object.assign(rows, { status: shenpi[code] || 1 });
+    }
+
+    version(snapshot, code) {
+        const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+        const rows = this.databaseRows(snapshot);
+        for (const key of Object.keys(rows)) rows[key].sort((a, b) => a.id - b.id);
+        return crypto.createHash('sha256').update(JSON.stringify({ status: shenpi[code] || 1, rows })).digest('hex');
+    }
+
+    databaseRows(state) {
+        const fields = {
+            groups: ['id', 'name', 'is_select', 'change_type'],
+            audits: ['id', 'audit_id', 'sp_status', 'sp_group', 'audit_order', 'audit_type', 'audit_group', 'audit_group_order', 'audit_group_limit', 'audit_checkno_valid', 'audit_group_need', 'audit_ledger_id'],
+            assistants: ['id', 'user_id', 'ass_user_id', 'name', 'company', 'role', 'ass_ledger_id'],
+        };
+        const result = {};
+        for (const key of Object.keys(fields)) {
+            result[key] = state[key].map(row => {
+                const data = {};
+                for (const field of fields[key]) {
+                    if (row[field] !== undefined) data[field] = row[field];
+                }
+                if (key === 'groups' && data.change_type && typeof data.change_type !== 'string') data.change_type = JSON.stringify(data.change_type);
+                return data;
+            });
+        }
+        return result;
+    }
+
+}
+
+module.exports = TenderApprovalSetting;

+ 28 - 0
app/public/css/main.css

@@ -3960,3 +3960,31 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
     .project-screen-page .project-screen-module-tabs .nav-link { padding-left: 10px; padding-right: 10px; }
     .project-screen-page .project-screen-tender-columns { grid-template-columns: 38% minmax(0, 1fr); }
 }
+
+.setting-approval-page .approval-heading {
+    font-weight: 500;
+}
+.setting-approval-page .approval-layout {
+    background-color: #fff;
+    overflow: hidden;
+}
+.setting-approval-page .approval-tenders,
+.setting-approval-page .approval-content {
+    overflow: auto;
+}
+.setting-approval-page .tender-info {
+    cursor: pointer;
+}
+
+.setting-approval-page .approval-summary-table thead th { text-align: center; }
+.setting-approval-page .approval-column-name { width: 17%; min-width: 120px; }
+.setting-approval-page .approval-column-actions { width: 110px; }
+.setting-approval-page .approval-actions { text-align: center; white-space: nowrap; }
+.setting-approval-page .approval-mode { background: #ddd; font-weight: normal; font-size: 12px; }
+.setting-approval-page .approval-summary-table .dropdown-menu { min-width: 140px; }
+#approval-editor .modal-body { min-height: 300px; max-height: 70vh; overflow: auto; }
+#approval-editor .card { border: 0; margin-bottom: 0 !important; }
+#approval-editor .card-body { padding: 0; }
+#approval-editor .approval-saving { pointer-events: none; opacity: .65; }
+.approval-child-modal { z-index: 1060; }
+.modal-backdrop.approval-child-backdrop { z-index: 1055; }

+ 144 - 0
app/public/js/setting_approval.js

@@ -0,0 +1,144 @@
+'use strict';
+
+const tenderApprovalEditor = {
+    data: null,
+    active: null,
+    saving: false,
+    loading: false,
+    saved: false,
+
+    init() {
+        const self = this;
+        $('body').on('click', '.approval-edit', function () { self.open($(this).attr('data-code')); });
+        $('body').on('click', '.approval-copy', function () {
+            const code = $(this).attr('data-code'), kind = $(this).attr('data-kind');
+            $('#approval-card-store .' + code + '_div .' + kind).trigger('click');
+        });
+        $('#approval-save').on('click', function () { self.save(); });
+        $('#approval-editor').on('hide.bs.modal', function (event) {
+            if (self.saving || (self.active && self.active.operations.length && !self.saved && !window.confirm('有未保存的修改,确定放弃并关闭吗?'))) event.preventDefault();
+        }).on('hidden.bs.modal', function () {
+            const saved = self.saved, tid = self.active ? self.active.tid : cur_tenderid;
+            self.active = null; self.saved = false;
+            $('#approval-edit-body').empty();
+            if (self.data) self.setData(self.data);
+            if (saved) loadTenderApproval(tid);
+        });
+        const children = $('#batch, #batch2, #cooperation, #union, #multi, #spzsave, #spzdelete');
+        children.on('show.bs.modal', function () {
+            if ($('#approval-editor').hasClass('show')) $(this).addClass('approval-child-modal');
+        }).on('shown.bs.modal', function () {
+            if ($(this).hasClass('approval-child-modal')) $('.modal-backdrop').last().addClass('approval-child-backdrop');
+        }).on('hidden.bs.modal', function () {
+            $(this).removeClass('approval-child-modal');
+            if ($('#approval-editor').hasClass('show')) $('body').addClass('modal-open');
+        });
+    },
+
+    setData(data) {
+        this.data = data;
+        sp_lc = _.cloneDeep(data.shenpi.sp_lc);
+        this.render();
+        renderTenderApproval(Object.assign({}, data.shenpi, { sp_lc }), data.tender, data.revising, '#approval-card-store');
+    },
+
+    summary(flow) {
+        if (flow.status === 1) return '';
+        if (flow.status === 3) return flow.audit ? flow.audit.name : '未设置';
+        const groups = flow.auditGroupList || [];
+        const selected = (flow.groupList || []).find(x => x.is_select === 1);
+        const text = groups.map(group => {
+            const type = group.length && auditType.info[group[0].audit_type];
+            return group.map(x => x.name).join('/') + (type && type.value !== 1 ? '(' + type.name + ')' : '');
+        }).join('、') || '未设置';
+        return (selected ? selected.name + ':' : '') + text;
+    },
+
+    render() {
+        const html = ['<table class="table table-bordered table-sm approval-summary-table"><thead><tr><th class="approval-column-name">栏目</th><th>审批流程</th><th class="approval-column-actions">操作</th></tr></thead><tbody>'];
+        for (const flow of this.data.shenpi.sp_lc) {
+            html.push('<tr><td>', _.escape(flow.name), '</td><td><span class="badge badge-light approval-mode">', _.escape(sp_status_list[flow.status].name), '</span> ', _.escape(this.summary(flow)), '</td>');
+            html.push('<td class="approval-actions"><a href="javascript:void(0);" class="approval-edit" data-code="', flow.code, '">编辑</a> ');
+            html.push('<div class="dropdown d-inline-block ml-2"><a href="javascript:void(0);" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">更多</a><div class="dropdown-menu dropdown-menu-right">');
+            for (const item of [{ kind: 'set-otherShenpi', name: '设置其他流程' }, { kind: 'set-otherTender', name: '设置其他标段' }]) {
+                html.push('<a class="dropdown-item approval-copy" href="javascript:void(0);" data-code="', flow.code, '" data-kind="', item.kind, '">', item.name, '</a>');
+            }
+            html.push('</div></div></td></tr>');
+        }
+        html.push('</tbody></table>');
+        $('#splc').html(html.join(''));
+    },
+
+    open(code) {
+        if (this.loading || this.active || !this.data) return;
+        const self = this, tid = cur_tenderid;
+        self.loading = true;
+        postData(`/sp/${spid}/setting/manage/tender/save`, { type: 'approval-draft', tid, code }, function (result) {
+            self.loading = false;
+            if (tid !== cur_tenderid) return;
+            self.active = { tid, code, version: result.version, model: new TenderApprovalDraft(result.snapshot, result.options), operations: [] };
+            self.saved = false;
+            sp_lc = _.cloneDeep(self.data.shenpi.sp_lc);
+            const flow = self.active.model.view();
+            sp_lc[sp_lc.findIndex(x => x.code === code)] = flow;
+            $('#approval-card-store .' + code + '_div').closest('.card').remove();
+            renderTenderApproval(Object.assign({}, self.data.shenpi, { sp_lc: [flow] }), self.data.tender, self.data.revising, '#approval-edit-body');
+            $('#approval-editor').modal('show');
+            $('body').trigger('tender-approval-edit-open');
+        }, function () { self.loading = false; });
+    },
+
+    save() {
+        if (!this.active || this.saving) return;
+        if (!this.active.operations.length) { $('#approval-editor').modal('hide'); return; }
+        try { this.active.model.validate(); } catch (error) { toastr.warning(error.message); return; }
+        const self = this, draft = self.active;
+        self.saving = true;
+        $('#approval-save').prop('disabled', true);
+        $('#approval-edit-body').addClass('approval-saving');
+        postData(`/sp/${spid}/setting/manage/tender/save`, {
+            type: 'approval-save', tid: draft.tid, code: draft.code, version: draft.version, operations: draft.operations,
+        }, function () {
+            self.saving = false; self.saved = true;
+            $('#approval-save').prop('disabled', false);
+            $('#approval-edit-body').removeClass('approval-saving');
+            toastr.success('审批流程已保存');
+            $('#approval-editor').modal('hide');
+        }, function () {
+            self.saving = false;
+            $('#approval-save').prop('disabled', false);
+            $('#approval-edit-body').removeClass('approval-saving');
+        });
+    },
+
+    request(url, data, success, failure, waiting, message) {
+        const self = this, draft = self.active;
+        if (self.saving) { if (failure) failure('正在保存'); return; }
+        if (url.endsWith('/ass/load') || url.endsWith('/union/load')) {
+            postData(url, data, function (result) {
+                if (self.active !== draft) return;
+                if (url.endsWith('/ass/load')) result.auditAssList = _.cloneDeep(draft.model.state.assistants);
+                else result.unionAuditors = _.cloneDeep(draft.model.activeAudits(2).filter(x => x.audit_order === Number(data.audit_order)));
+                if (success) success(result);
+            }, failure, waiting, message);
+            return;
+        }
+        if (['copy2ot', 'copy2os'].includes(data.type)) {
+            if (draft.operations.length) { toastr.warning('请先保存当前修改,再设置其他流程或标段'); return; }
+            return postData(url, data, success, failure, waiting, message);
+        }
+        const kind = url.endsWith('/shenpi/save') ? 'status' : url.endsWith('/audit/save') ? 'audit' : url.endsWith('/multi/save') ? 'multi' : url.endsWith('/union/save') ? 'union' : '';
+        try {
+            const operation = { kind, data: _.cloneDeep(data) };
+            const result = draft.model.apply(operation);
+            draft.operations.push(operation);
+            if (success) success(result);
+            // Existing callbacks update the controls; keep every group's cached view in sync with the draft.
+            const index = sp_lc.findIndex(x => x.code === draft.code);
+            sp_lc[index] = draft.model.view();
+        } catch (error) {
+            toastr.warning(error.message || String(error));
+            if (failure) failure(error.message);
+        }
+    },
+};

+ 62 - 64
app/public/js/setting_manage.js

@@ -171,6 +171,11 @@ $(document).ready(() => {
     const cHeader = getObjHeight($(".c-header"));
     $('.tab-content').height($(window).height()-cHeader-90+53-46);
 
+    if (typeof approvalOnly !== 'undefined' && approvalOnly) {
+        initTenderApprovalPage();
+        return;
+    }
+
     const tabTypes = {
         tourist: '游客',
         schedule: '标段进度',
@@ -1054,17 +1059,21 @@ const tenderListSpec = (function(){
             html.push(arr.indexOf(node) === arr.length - 1 ? '└' : '├');
             html.push('</span>');
             //html.push('<a href="/tender/' + node.id + '">', node[c.field], '</a>');
-            html.push('<a href="javascript: void(0)" id="' + node.id + '" name="name">', node.name, '</a>');
+            const onlyApproval = typeof approvalOnly !== 'undefined' && approvalOnly;
+            html.push('<a href="javascript: void(0)" id="' + node.id + '"' + (onlyApproval ? '' : ' name="name"') + '>', node.name, '</a>');
             html.push('</div>');
-            html.push('<div class="btn-group-table" tid="' + node.id + '">');
-            html.push('<a href="javascript:void(0);" name="edit" class="mr-1"><i class="fa fa-pencil fa-fw"></i></a>');
-            const hasStage = node.progress ? (node.stage_count > 0 ? true : node.stage_status !== 0) : !!node.lastStage;
-            if (!hasStage) {
-                html.push('<a href="javascript:void(0);" name="del" class="mr-1"><i class="fa fa-trash-o fa-fw text-danger"></i></a>');
-            } else {
-                html.push('<a href="javascript:void(0);" data-toggle="tooltip" title="请先删除所有期" class="mr-1"><i class="fa fa-trash-o fa-fw text-secondary"></i></a>');
+            if (!onlyApproval) {
+                html.push('<div class="btn-group-table" tid="' + node.id + '">');
+                html.push('<a href="javascript:void(0);" name="edit" class="mr-1"><i class="fa fa-pencil fa-fw"></i></a>');
+                const hasStage = node.progress ? (node.stage_count > 0 ? true : node.stage_status !== 0) : !!node.lastStage;
+                if (!hasStage) {
+                    html.push('<a href="javascript:void(0);" name="del" class="mr-1"><i class="fa fa-trash-o fa-fw text-danger"></i></a>');
+                } else {
+                    html.push('<a href="javascript:void(0);" data-toggle="tooltip" title="请先删除所有期" class="mr-1"><i class="fa fa-trash-o fa-fw text-secondary"></i></a>');
+                }
+                html.push('</div>');
             }
-            html.push('</div></div>');
+            html.push('</div>');
         }
         html.push('</td>');
         // 创建人
@@ -1126,61 +1135,7 @@ function updateSelectedMarks(forcedType = null) {
 }
 
 function setShenpiHtml(shenpi, tender, revising) {
-    let html = '';
-    if (shenpi.sp_lc.length > 0) {
-        for (const sp of shenpi.sp_lc) {
-            html += `<div class="card mb-3">
-                                <div class="card-body ${sp.code}_div">
-                                    <a class="pull-right set-otherTender" data-name="${sp.name}" data-code="${sp.code}" href="#batch" data-toggle="modal" data-target="#batch">设置其他标段</a>
-                                    <a class="pull-right set-otherShenpi mr-3" data-name="${sp.name}" data-code="${sp.code}" href="javascript: void(0);">设置其他流程</a>`;
-            if (sp.code === 'stage' && !revising && tender.ledger_status === auditConst.ledger.status.checked) {
-                html += `<a class="pull-right mr-3" id="stage_cooperation" ${sp.status !== shenpi.sp_status.gdspl ? 'style="display: none"' : '' } data-name="${sp.name}" data-code="${sp.code}" href="#cooperation" data-toggle="modal" data-target="#cooperation">多人协同 <i class="fa fa-lock"></i></a>`;
-            }
-            html += `
-                                    <h5 class="card-title">${sp.name}</h5>
-                                    <div class="form-group">
-                                        <div class="form-group form-check">`;
-            for (const st in shenpi.sp_status_list) {
-                if (shenpi.sp_status_list[st]) {
-                    html += `<div class="custom-control custom-checkbox custom-control-inline">
-                                                    <input type="radio" class="custom-control-input" data-code="${sp.code}" value="${shenpi.sp_status_list[st].status}" name="${sp.code}" id="${sp.code}_${shenpi.sp_status_list[st].status}" ${sp.status === shenpi.sp_status_list[st].status ? 'checked' : ''}>
-                                                    <label class="custom-control-label" for="${sp.code}_${shenpi.sp_status_list[st].status}">${shenpi.sp_status_list[st].name}</label>
-                                                </div>`;
-                }
-            }
-            html += `
-                                        </div>
-                                    </div>
-                                    <div class="alert alert-warning">${shenpi.sp_status_list[sp.status].name}:${shenpi.sp_status_list[sp.status].msg}</div>
-                                    <div class="lc-show">`;
-            if (sp.status === sp_status.sqspr) {
-                html += ``;
-            } else if (sp.status === sp_status.gdspl) {
-                const flow = sp_lc.find(x => { return x.code === sp.code; });
-                flow.auditGroupList = sp.auditGroupList;
-                html += auditUtils.getGroupHtml(flow, sp.code);
-                let addhtml = '<ul class="list-unstyled">\n';
-                addhtml += auditUtils.getgdsplHtml(flow, sp.code);
-                addhtml += '</ul>\n';
-                html += addhtml;
-            } else if (sp.status === sp_status.gdzs) {
-                let addhtml = '<ul class="list-unstyled">\n' +
-                    '                                        <li class="d-flex justify-content-start mb-3">\n' +
-                    '                                            <span class="col-auto">授权审批人</span>\n' +
-                    '                                            <span class="col-7">\n' +
-                    '                                                <span class="d-inline-block"></span>\n' +
-                    '                                            </span>\n' +
-                    '                                        </li>\n';
-                addhtml += sp.audit ? makeAudit(sp.audit) : makeSelectAudit(sp.code);
-                addhtml += '</ul>\n';
-                html += addhtml;
-            }
-            html += `                </div>
-                                </div>
-                            </div>`;
-        }
-    }
-    $('#splc').html(html);
+    renderTenderApproval(shenpi, tender, revising);
 }
 
 let selectedUserIdsByType = {
@@ -1399,3 +1354,46 @@ const getUserPermissionHtml = function(user, key) {
     html.push('</tr>');
     return html.join('');
 };
+
+
+let tenderApprovalLoadId = 0;
+
+function initTenderApprovalPage() {
+    tenderApprovalEditor.init();
+    $('body').on('click', '.setting-approval-page .tender-info', function (event) {
+        event.preventDefault();
+        loadTenderApproval(Number($(this).attr('data-id')));
+    });
+    $('body').on('click', '.setting-approval-page .approval-retry', function () {
+        loadTenderApproval(Number($(this).attr('data-id')));
+    });
+    const firstTender = $('.setting-approval-page .tender-info').eq(0);
+    if (firstTender.length) loadTenderApproval(Number(firstTender.attr('data-id')));
+}
+
+function loadTenderApproval(tid) {
+    if (!Number.isSafeInteger(tid) || tid <= 0) return;
+    const loadId = ++tenderApprovalLoadId;
+    const rows = $('.setting-approval-page .tender-info');
+    rows.removeClass('table-warning');
+    rows.filter('[data-id="' + tid + '"]').addClass('table-warning');
+    // Loading or failed requests must not leave the previous tender editable.
+    cur_tenderid = 0;
+    cur_uid = null;
+    sp_lc = [];
+    $('#splc').html('<div class="text-muted p-3">正在加载审批流程…</div>');
+    postData(`/sp/${spid}/setting/manage/tender/save`, { type: 'approval-msg', tid }, function (result) {
+        if (loadId !== tenderApprovalLoadId) return;
+        sp_lc = result.shenpi.sp_lc;
+        sp_type = result.shenpi.sp_type;
+        sp_status = result.shenpi.sp_status;
+        sp_status_list = result.shenpi.sp_status_list;
+        cur_tenderid = result.tender.id;
+        cur_uid = result.tender.user_id;
+        tenderApprovalEditor.setData(result);
+    }, function () {
+        if (loadId !== tenderApprovalLoadId) return;
+        $('#splc').html('<div class="alert alert-warning">审批流程加载失败,请重试。' +
+            '<button type="button" class="btn btn-sm btn-outline-primary ml-2 approval-retry" data-id="' + tid + '">重新加载</button></div>');
+    });
+}

+ 230 - 0
app/public/js/shares/tender_approval_draft.js

@@ -0,0 +1,230 @@
+'use strict';
+
+// Both the editor and server replay the same draft operations; no database access here.
+class TenderApprovalDraft {
+    constructor(snapshot, options) {
+        this.state = JSON.parse(JSON.stringify(snapshot));
+        this.options = options;
+        this.nextId = -1;
+        this.cooperation = [];
+    }
+
+    member(id) {
+        const member = this.options.accounts.find(x => Number(x.id) === Number(id));
+        if (!member) throw new Error('审批人不属于当前项目');
+        return member;
+    }
+
+    currentGroup() {
+        const group = this.state.groups.find(x => x.is_select === 1);
+        return group ? group.id : 0;
+    }
+
+    activeAudits(status = this.state.status) {
+        return this.state.audits.filter(x => x.sp_status === status &&
+            (status !== 2 || Number(x.sp_group || 0) === this.currentGroup()));
+    }
+
+    view() {
+        const copy = value => JSON.parse(JSON.stringify(value));
+        const groupAudits = id => {
+            const list = this.state.audits.filter(x => x.sp_status === 2 && Number(x.sp_group || 0) === id)
+                .sort((a, b) => a.audit_order - b.audit_order || (a.id > 0 && b.id > 0 ? a.id - b.id : 0));
+            const groups = [];
+            for (const row of list) {
+                let group = groups.find(x => x[0].audit_order === row.audit_order);
+                if (!group) { group = []; groups.push(group); }
+                group.push(row);
+            }
+            return copy(groups);
+        };
+        return {
+            code: this.options.code, type: this.options.spType, name: this.options.name,
+            status: this.state.status,
+            groupList: this.state.groups.map(x => Object.assign(copy(x), { auditGroupList: groupAudits(x.id) })),
+            auditGroupList: groupAudits(this.currentGroup()),
+            audit: copy(this.state.audits.find(x => x.sp_status === 3) || null),
+        };
+    }
+
+    apply(operation) {
+        // Reject a failed operation without leaving a partially modified draft.
+        const previous = JSON.stringify(this.state), nextId = this.nextId, effects = this.cooperation.length;
+        try { return JSON.parse(JSON.stringify(this.change(operation))); } catch (error) {
+            this.state = JSON.parse(previous); this.nextId = nextId; this.cooperation.length = effects;
+            throw error;
+        }
+    }
+
+    change(operation) {
+        const data = operation.data || {};
+        if (operation.kind === 'status') {
+            if (data.code !== this.options.code || ![1, 2, 3].includes(Number(data.status))) throw new Error('审批模式有误');
+            this.state.status = Number(data.status);
+            if (this.options.code === 'stage') this.cooperation.push({ status: this.state.status === 2 ? 1 : 0 });
+            const flow = this.view();
+            return { auditList: this.state.status === 2 ? flow.auditGroupList : flow.audit, groupList: flow.groupList };
+        }
+        if (operation.kind === 'multi' || operation.kind === 'union') {
+            if (!Array.isArray(data) || this.options.code !== 'stage') throw new Error('审批设置参数有误');
+            for (const update of data) {
+                const row = this.activeAudits().find(x => x.id === Number(update.id));
+                if (!row) throw new Error('审批节点已不存在');
+                if (operation.kind === 'union') {
+                    if (row.audit_type !== 4) throw new Error('该节点不是协同审批');
+                    row.audit_ledger_id = this.ledgerIds(update.audit_ledger_id);
+                } else {
+                    if (![2, 3, 5].includes(row.audit_type)) throw new Error('该节点不支持分组设置');
+                    for (const key of ['audit_group_order', 'audit_group_limit', 'audit_checkno_valid', 'audit_group_need']) {
+                        row[key] = this.integer(update[key]);
+                    }
+                    row.audit_group = String(update.audit_group || '').trim();
+                    if (row.audit_group.length > 20 || row.audit_group_limit > 1 || row.audit_checkno_valid > 1) throw new Error('分组设置有误');
+                }
+            }
+            return data;
+        }
+        if (operation.kind !== 'audit') throw new Error('不支持的审批操作');
+        if (['save-group', 'change-group', 'delete-group'].includes(data.type)) return this.changeGroup(data);
+        if (data.type === 'audit-ass') return this.changeAssistant(data);
+        if (Number(data.code) !== this.options.spType || Number(data.status) !== this.state.status) throw new Error('审批流程不匹配');
+        const current = this.activeAudits();
+        if (data.type === 'add') {
+            const member = this.member(data.audit_id);
+            if (current.some(x => x.audit_id === member.id)) throw new Error('该审批人已存在');
+            if (this.state.status === 1 || (this.state.status === 3 && current.length)) throw new Error('请先移除原终审人');
+            if (member.id === this.options.owner && !['advance', 'ledger', 'revise', 'change', 'contract'].includes(this.options.code)) throw new Error('当前流程不能选择原报人');
+            const order = this.state.status === 2 ? this.integer(data.audit_order, 1) : 0;
+            const type = this.state.status === 2 ? this.integer(data.audit_type, 1) : 1;
+            this.checkType(type);
+            const row = { id: this.nextId--, audit_id: member.id, name: member.name, company: member.company || '', role: member.role || '',
+                sp_status: this.state.status, sp_group: this.state.status === 2 ? this.currentGroup() : 0,
+                audit_order: order, audit_type: type, audit_group: '', audit_group_order: 0, audit_group_limit: 0,
+                audit_checkno_valid: 1, audit_group_need: 1, audit_ledger_id: '' };
+            this.state.audits.push(row);
+            if (this.options.code === 'stage' && this.state.status === 2) this.cooperation.push({ user_id: member.id, status: 1 });
+            return row;
+        }
+        if (data.type === 'del') {
+            const row = current.find(x => x.audit_id === Number(data.audit_id));
+            if (!row) throw new Error('审批人已不存在');
+            this.state.audits.splice(this.state.audits.indexOf(row), 1);
+            if (!current.some(x => x !== row && x.audit_order === row.audit_order)) {
+                for (const next of current) if (next.audit_order > row.audit_order) next.audit_order--;
+            }
+            if (this.options.code === 'stage' && this.state.status === 2) this.cooperation.push({ user_id: row.audit_id, status: 0 });
+            return true;
+        }
+        if (data.type === 'audit-type') {
+            const type = this.integer(data.audit_type, 1);
+            this.checkType(type);
+            if (!Array.isArray(data.audit_id) || !data.audit_id.length) throw new Error('未选择审批人');
+            const rows = current.filter(x => data.audit_id.includes(x.audit_id));
+            if (rows.length !== data.audit_id.length || new Set(rows.map(x => x.audit_order)).size !== 1) throw new Error('审批节点有误');
+            if (type === 1 && rows.length !== 1) throw new Error('个人审批只能设置一人');
+            for (const row of rows) row.audit_type = type;
+            return true;
+        }
+        throw new Error('不支持的审批操作');
+    }
+
+    changeGroup(data) {
+        if (!['stage', 'change', 'contract'].includes(this.options.code) || this.state.status !== 2) throw new Error('该流程不支持审批组');
+        let group = this.state.groups.find(x => x.id === Number(data.groupId));
+        if (data.type !== 'save-group' && !group) throw new Error('审批组已不存在');
+        if (data.type === 'save-group') {
+            if (data.code !== this.options.code) throw new Error('审批流程不匹配');
+            const name = String(data.name || '').trim();
+            if (!name || name.length > 50 || this.state.groups.some(x => x !== group && x.name === name)) throw new Error('审批组名称为空、重复或过长');
+            if (data.groupId && !group) throw new Error('审批组已不存在');
+            let change_type = null;
+            if (this.options.code !== 'stage') {
+                const keys = this.options.code === 'change' ? ['change', 'changeProject', 'changeApply', 'changePlan'] : ['contract', 'expenses', 'income'];
+                change_type = {};
+                for (const key of keys) change_type[key] = !!(data.change_type && data.change_type[key]);
+                if (!Object.values(change_type).some(Boolean)) throw new Error('请至少选择一种显示模块');
+            }
+            if (!group) {
+                group = { id: this.nextId--, name, is_select: 1, change_type };
+                this.state.groups.forEach(x => { x.is_select = 0; });
+                this.state.groups.push(group);
+                for (const row of this.state.audits) if (row.sp_status === 2 && !row.sp_group) row.sp_group = group.id;
+            } else { group.name = name; group.change_type = change_type; }
+            return { group: this.view().groupList.find(x => x.id === group.id) };
+        }
+        if (data.type === 'delete-group') {
+            this.state.groups.splice(this.state.groups.indexOf(group), 1);
+            this.state.audits = this.state.audits.filter(x => x.sp_group !== group.id);
+            group = this.state.groups[0];
+        }
+        this.state.groups.forEach(x => { x.is_select = group && x.id === group.id ? 1 : 0; });
+        return true;
+    }
+
+    changeAssistant(data) {
+        if (this.options.code !== 'stage' || !this.options.canCooperate || this.state.status !== 2) throw new Error('当前流程不能设置多人协同');
+        const result = { update: [] };
+        if (data.add) {
+            const user = this.member(data.add.user_id), assistant = this.member(data.add.ass_user_id);
+            if (user.id === assistant.id || (user.id !== this.options.owner && !this.activeAudits().some(x => x.audit_id === user.id && x.audit_type === 1))) throw new Error('协同人员设置有误');
+            if (this.state.assistants.some(x => x.user_id === user.id && x.ass_user_id === assistant.id)) throw new Error('协同人已存在');
+            result.add = { id: this.nextId--, user_id: user.id, ass_user_id: assistant.id, name: assistant.name, company: assistant.company || '', role: assistant.role || '', ass_ledger_id: this.ledgerIds(data.add.ass_ledger_id, 1000) };
+            this.state.assistants.push(result.add);
+        }
+        for (const update of data.update || []) {
+            const row = this.state.assistants.find(x => x.id === Number(update.id));
+            if (!row) throw new Error('协同人已不存在');
+            row.ass_ledger_id = this.ledgerIds(update.ass_ledger_id, 1000); result.update.push({ id: row.id, ass_ledger_id: row.ass_ledger_id });
+        }
+        if (data.del) {
+            const index = this.state.assistants.findIndex(x => x.id === Number(data.del.id));
+            if (index < 0) throw new Error('协同人已不存在');
+            result.del = { id: this.state.assistants[index].id }; this.state.assistants.splice(index, 1);
+        }
+        return result;
+    }
+
+    integer(value, min = 0) {
+        const number = Number(value);
+        if (!Number.isSafeInteger(number) || number < min) throw new Error('审批参数必须为有效整数');
+        return number;
+    }
+
+    checkType(type) {
+        const info = this.options.auditTypes.find(x => x.value === type);
+        if (!info || (info.valid && !info.valid.includes(this.options.code))) throw new Error('当前栏目不支持此审批类型');
+    }
+
+    ledgerIds(value, maxLength = 5000) {
+        const ids = String(value || '').split(',').filter(Boolean);
+        if (String(value || '').length > maxLength || !Array.isArray(this.options.ledgerIds) || ids.some(x => !this.options.ledgerIds.includes(x))) throw new Error('协同台账不属于当前标段');
+        return Array.from(new Set(ids)).join(',');
+    }
+
+    validate() {
+        const active = this.activeAudits();
+        if (this.state.status !== 1 && !active.length) throw new Error('请先设置审批人');
+        if (this.state.status === 3 && active.length !== 1) throw new Error('固定终审只能设置一名终审人');
+        const flow = this.view();
+        const lists = flow.groupList.length ? flow.groupList.map(x => x.auditGroupList) : [flow.auditGroupList];
+        if (this.state.groups.length && this.state.groups.filter(x => x.is_select === 1).length !== 1) throw new Error('请选择一个审批组');
+        for (const list of lists) {
+            for (const group of list) {
+                const type = group[0].audit_type;
+                this.checkType(type);
+                if (group.some(x => x.audit_type !== type) || (type === 1 && group.length !== 1)) throw new Error('同一审批节点的类型或人数有误');
+                if (type === 5 && group.some(x => !x.audit_group || !x.audit_group_order)) throw new Error('分组审批的部门及顺序不能为空');
+                if (type === 2 && group.some(x => !!x.audit_group_order !== !!group[0].audit_group_order)) throw new Error('会签顺序必须全部设置或全部留空');
+                if (type === 3 && group.some(x => x.audit_group_need < 1 || x.audit_group_need > group.length)) throw new Error('或签人数设置有误');
+            }
+            const orders = list.map(x => x[0].audit_order);
+            if (orders.some((x, i) => x !== i + 1)) throw new Error('审批顺序必须从1开始连续');
+            const ids = [].concat(...list).map(x => x.audit_id);
+            if (new Set(ids).size !== ids.length) throw new Error('同一审批组的审批人不能重复');
+        }
+        if (new Set(active.map(x => x.audit_id)).size !== active.length) throw new Error('审批人不能重复');
+        return true;
+    }
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = TenderApprovalDraft;

+ 102 - 18
app/public/js/shenpi.js

@@ -465,7 +465,7 @@ $(document).ready(function () {
             status: this_status
         };
         const _self = $(this);
-        postData('/tender/' + cur_tenderid + '/shenpi/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/save', prop, function (data) {
             if (this_status === sp_status.sqspr) {
                 _self.parents('.form-group').siblings('.lc-show').html('');
             } else if (this_status === sp_status.gdspl) {
@@ -537,7 +537,7 @@ $(document).ready(function () {
                 prop.audit_order = $(this).parents('li').index() + 1;
             }
             const _self = $(this);
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
                 if (this_status === sp_status.gdspl) {
                     const auditGroup = auditUtils.addAudit(this_code, data, prop.audit_order - 1);
                     if (_self.parents('ul').find('.add-audit').length === 0) {
@@ -580,7 +580,7 @@ $(document).ready(function () {
             type: 'del',
         };
         const _self = $(this);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             if (this_status === sp_status.gdspl) {
                 const index = _self.parents('li').index();
                 const auditGroup = auditUtils.removeAudit(this_code, id, index);
@@ -626,6 +626,11 @@ $(document).ready(function () {
     initTenderTree2();
 
     $('body').on('click', '.set-otherTender', function () {
+        if (typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active && tenderApprovalEditor.active.operations.length) {
+            toastr.warning('请先保存当前修改,再设置其他流程或标段');
+            return false;
+        }
+
         const this_code = $(this).data('code');
         const this_status = parseInt($(this).siblings('.lc-show').siblings('.form-group').find('input:checked').val());
         const lis = $(this).siblings('.lc-show').find('li');
@@ -638,7 +643,7 @@ $(document).ready(function () {
 
             const select = $(li).find('select[class*="audit-type-key"]');
             const audit_type = select.length > 0 ? parseInt(select.val()) : 1;
-            const multiData = flow.auditGroupList.find(x => { return x[0].audit_order == i+1; });
+            const multiData = (flow.auditGroupList || []).find(x => { return x[0].audit_order == i+1; }) || [];
             for (const remove of removes) {
                 const auditData = { audit_id: parseInt(remove.getAttribute('data-id')), audit_type, audit_order: i + 1 };
                 const removeMultiData = multiData.find(x => { return x.audit_id === auditData.audit_id; });
@@ -677,7 +682,7 @@ $(document).ready(function () {
             status: parseInt($('#shenpi_status').val()),
             code: $('#shenpi_code').val(),
         };
-        if(data.status !== shenpi_status.gdspl) {
+        if(data.status !== sp_status.gdspl) {
             data.auditList = JSON.parse($('#shenpi_auditors').val());
         }
         // 获取已选中的标段
@@ -689,7 +694,7 @@ $(document).ready(function () {
             }
         }
         data.tidList = tenderList.join(',');
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
             toastr.success('设置成功');
             setTimeout(function () {
                 window.location.reload();
@@ -698,6 +703,11 @@ $(document).ready(function () {
     });
 
     $('body').on('click', '.set-otherShenpi', function () {
+        if (typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active && tenderApprovalEditor.active.operations.length) {
+            toastr.warning('请先保存当前修改,再设置其他流程或标段');
+            return false;
+        }
+
         let canSetOther = true;
         const this_code = $(this).data('code');
         // if (['stage', 'change'].indexOf(this_code) !== -1) {
@@ -748,7 +758,7 @@ $(document).ready(function () {
             status: parseInt($('#shenpi_status2').val()),
             code: $('#shenpi_code2').val(),
         };
-        if(data.status !== shenpi_status.gdspl) {
+        if(data.status !== sp_status.gdspl) {
             data.flowList = $('#shenpi_auditors2').val();
         }
         // 获取已选中的标段
@@ -758,7 +768,7 @@ $(document).ready(function () {
             shenpiList.push(code);
         }
         data.shenpiList = shenpiList.join(',');
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
             toastr.success('设置成功');
             setTimeout(function () {
                 window.location.reload();
@@ -789,7 +799,7 @@ $(document).ready(function () {
             return;
         }
         const _self = this;
-        postData('/tender/'+ cur_tenderid +'/shenpi/audit/save', prop, function () {
+        postTenderApprovalData('/tender/'+ cur_tenderid +'/shenpi/audit/save', prop, function () {
             _self.setAttribute('data-type', _self.value);
             const auditGroup = auditUtils.setAuditType(this_code, prop.audit_type, liParent.index());
             liParent.html(auditUtils.getAuditGroupInnerHtml(this_code, auditGroup, liParent.index() + 1));
@@ -909,7 +919,7 @@ $(document).ready(function () {
             this.spread.refresh();
             const self = this;
 
-            postData('/tender/' + cur_tenderid + '/shenpi/ass/load', {}, function (data) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/ass/load', {}, function (data) {
                 self.loaded = true;
                 self.tid = cur_tenderid;
                 self.assList = data.auditAssList;
@@ -965,14 +975,14 @@ $(document).ready(function () {
                     data.update.push({ id: orgAss.id, ass_ledger_id: rela_lid.join(',')});
                 }
             }
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function (result) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function (result) {
                 self.loadPostData(result);
                 self.refreshOperate();
             });
         }
         removeAuditAss(assistId) {
             const self = this;
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', { type: 'audit-ass', del: { id: assistId } }, function (result) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', { type: 'audit-ass', del: { id: assistId } }, function (result) {
                 self.loadPostData(result);
                 self.refreshOperate();
             });
@@ -1038,7 +1048,7 @@ $(document).ready(function () {
             });
             $('#union-ok').click(function() {
                 const data = self.getUnionAuditLedgerData();
-                postData(`/tender/${cur_tenderid}/shenpi/union/save`, data, function(result) {
+                postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/union/save`, data, function(result) {
                     $('#union').modal('hide');
                 });
             });
@@ -1087,7 +1097,7 @@ $(document).ready(function () {
             if (!this.loaded) data.ledger = 1;
 
             const self = this;
-            postData(`/tender/${cur_tenderid}/shenpi/union/load`, data, function(result) {
+            postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/union/load`, data, function(result) {
                 self.loaded = true;
                 if (result.ledgerList) {
                     self.tree.loadDatas(result.ledgerList);
@@ -1173,7 +1183,7 @@ $(document).ready(function () {
                         }
                     }
                 }
-                postData(`/tender/${cur_tenderid}/shenpi/multi/save`, data, function(result) {
+                postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/multi/save`, data, function(result) {
                     const flow = sp_lc.find(x => { return x.code === self.sp_type; });
                     const multiData = flow.auditGroupList.find(x => { return x[0].audit_order == self.audit_order});
                     for (const d of result) {
@@ -1244,6 +1254,15 @@ $(document).ready(function () {
         }
     }
     const auditMulti = new AuditMulti();
+    $('body').on('tender-approval-edit-open', function () {
+        auditAss.loaded = false;
+        auditAss.assList = [];
+        auditAss.tree.loadDatas([]);
+        SpreadJsObj.loadSheetData(auditAss.sheet, SpreadJsObj.DataType.Tree, auditAss.tree);
+        auditAss.uid = cur_uid;
+        auditAss.refreshOperate();
+        auditUnion.loaded = false;
+    });
     $('body').on('click', '[name=multi-set]', function() {
         auditMulti.loadData(auditType.key.multi, this.getAttribute('sp_type'), this.getAttribute('audit_order'), this);
     });
@@ -1465,7 +1484,7 @@ $(document).ready(function () {
             prop.change_type = change_type;
         }
         console.log(prop);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             flow.auditGroupList = data.group.auditGroupList || [];
             if (groupId) {
                 const index = flow.groupList.findIndex(x => { return x.id === parseInt(groupId); });
@@ -1497,7 +1516,7 @@ $(document).ready(function () {
             type: 'change-group',
             groupId,
         }
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             flow.groupList.forEach(function (item) {
                 item.is_select = 0;
             });
@@ -1540,7 +1559,7 @@ $(document).ready(function () {
             groupId: group.id,
         }
         console.log(prop);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             // const flow = findSplc(code);
             const index = flow.groupList.findIndex(x => { return x.id === group.id; });
             flow.groupList.splice(index, 1);
@@ -1617,3 +1636,68 @@ function makeSelectAudit(code, i = '终') {
         '                                        </li>';
     return html;
 }
+
+function renderTenderApproval(shenpi, tender, revising, target = '#splc') {
+    let html = '';
+    if (shenpi.sp_lc.length > 0) {
+        for (const sp of shenpi.sp_lc) {
+            html += `<div class="card mb-3">
+                                <div class="card-body ${sp.code}_div">
+                                    <a class="pull-right set-otherTender" data-name="${sp.name}" data-code="${sp.code}" href="#batch" data-toggle="modal" data-target="#batch">设置其他标段</a>
+                                    <a class="pull-right set-otherShenpi mr-3" data-name="${sp.name}" data-code="${sp.code}" href="javascript: void(0);">设置其他流程</a>`;
+            if (sp.code === 'stage' && !revising && tender.ledger_status === auditConst.ledger.status.checked) {
+                html += `<a class="pull-right mr-3" id="stage_cooperation" ${sp.status !== shenpi.sp_status.gdspl ? 'style="display: none"' : '' } data-name="${sp.name}" data-code="${sp.code}" href="#cooperation" data-toggle="modal" data-target="#cooperation">多人协同 <i class="fa fa-lock"></i></a>`;
+            }
+            html += `
+                                    <h5 class="card-title">${sp.name}</h5>
+                                    <div class="form-group">
+                                        <div class="form-group form-check">`;
+            for (const st in shenpi.sp_status_list) {
+                if (shenpi.sp_status_list[st]) {
+                    html += `<div class="custom-control custom-checkbox custom-control-inline">
+                                                    <input type="radio" class="custom-control-input" data-code="${sp.code}" value="${shenpi.sp_status_list[st].status}" name="${sp.code}" id="${sp.code}_${shenpi.sp_status_list[st].status}" ${sp.status === shenpi.sp_status_list[st].status ? 'checked' : ''}>
+                                                    <label class="custom-control-label" for="${sp.code}_${shenpi.sp_status_list[st].status}">${shenpi.sp_status_list[st].name}</label>
+                                                </div>`;
+                }
+            }
+            html += `
+                                        </div>
+                                    </div>
+                                    <div class="alert alert-warning">${shenpi.sp_status_list[sp.status].name}:${shenpi.sp_status_list[sp.status].msg}</div>
+                                    <div class="lc-show">`;
+            if (sp.status === sp_status.sqspr) {
+                html += ``;
+            } else if (sp.status === sp_status.gdspl) {
+                const flow = sp_lc.find(x => { return x.code === sp.code; });
+                flow.auditGroupList = sp.auditGroupList;
+                html += auditUtils.getGroupHtml(flow, sp.code);
+                let addhtml = '<ul class="list-unstyled">\n';
+                addhtml += auditUtils.getgdsplHtml(flow, sp.code);
+                addhtml += '</ul>\n';
+                html += addhtml;
+            } else if (sp.status === sp_status.gdzs) {
+                let addhtml = '<ul class="list-unstyled">\n' +
+                    '                                        <li class="d-flex justify-content-start mb-3">\n' +
+                    '                                            <span class="col-auto">授权审批人</span>\n' +
+                    '                                            <span class="col-7">\n' +
+                    '                                                <span class="d-inline-block"></span>\n' +
+                    '                                            </span>\n' +
+                    '                                        </li>\n';
+                addhtml += sp.audit ? makeAudit(sp.audit) : makeSelectAudit(sp.code);
+                addhtml += '</ul>\n';
+                html += addhtml;
+            }
+            html += `                </div>
+                                </div>
+                            </div>`;
+        }
+    }
+    $(target).html(html);
+}
+
+function postTenderApprovalData(url, data, success, failure, waiting = true, message = '') {
+    if (typeof approvalOnly !== 'undefined' && approvalOnly && typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active) {
+        return tenderApprovalEditor.request(url, data, success, failure, waiting, message);
+    }
+    return postData(url, data, success, failure, waiting, message);
+}

+ 1 - 0
app/router.js

@@ -249,6 +249,7 @@ module.exports = app => {
     app.get('/sp/:id/setting/datacollect', sessionAuth, subProjectCheck, 'subProjSettingController.dataCollect');
     app.post('/sp/:id/setting/datacollect/save', sessionAuth, subProjectCheck, 'subProjSettingController.dataCollectSave');
     // 标段管理
+    app.get('/sp/:id/setting/approval', sessionAuth, subProjectCheck, 'subProjSettingController.approval');
     app.get('/sp/:id/setting/manage', sessionAuth, subProjectCheck, 'subProjSettingController.manage');
     app.post('/sp/:id/setting/manage/tender/save', sessionAuth, subProjectCheck, 'subProjSettingController.manageTenderSave');
 

+ 56 - 0
app/service/tender_approval_setting.js

@@ -0,0 +1,56 @@
+'use strict';
+
+module.exports = app => {
+    class TenderApprovalSetting extends app.BaseService {
+        async getSnapshot(tid, spType, stage, transaction = null) {
+            const services = this.ctx.service;
+            const read = async (service, where) => transaction
+                ? await service.getAllDataForUpdate(where, transaction, 'id')
+                : await service.getAllDataByCondition({ where, orders: [['id', 'asc']] });
+            const infos = await read(services.tenderInfo, { tid });
+            return {
+                info: infos[0] || null,
+                groups: await read(services.shenpiGroup, { tid, sp_type: spType }),
+                audits: await read(services.shenpiAudit, { tid, sp_type: spType }),
+                assistants: stage ? await read(services.auditAss, { tid }) : [],
+            };
+        }
+
+        async persist(transaction, tid, spType, plan) {
+            const services = this.ctx.service;
+            const groups = await this.saveRows(transaction, services.shenpiGroup.tableName, plan.before.groups, plan.after.groups, { tid, sp_type: spType });
+            const audits = plan.after.audits.map(row => Object.assign({}, row, { sp_group: groups[row.sp_group] || row.sp_group }));
+            await this.saveRows(transaction, services.shenpiAudit.tableName, plan.before.audits, audits, { tid, sp_type: spType });
+            if (plan.stage) {
+                await this.saveRows(transaction, services.auditAss.tableName, plan.before.assistants, plan.after.assistants, { tid, pid: this.ctx.session.sessionProject.id });
+                for (const effect of plan.cooperation) {
+                    const where = { tid };
+                    if (effect.user_id) where.user_id = effect.user_id;
+                    await transaction.update(services.ledgerCooperation.tableName, { status: effect.status }, { where });
+                }
+            }
+            await transaction.update(services.tenderInfo.tableName, { shenpi: JSON.stringify(plan.shenpi) }, { where: { tid } });
+        }
+
+        async saveRows(transaction, table, before, after, scope) {
+            const ids = {};
+            for (const row of before) {
+                if (!after.some(x => x.id === row.id)) await transaction.delete(table, Object.assign({ id: row.id }, scope));
+            }
+            for (const row of after) {
+                const data = Object.assign({}, row, scope);
+                delete data.id;
+                if (row.id > 0) {
+                    const original = before.find(x => x.id === row.id);
+                    if (JSON.stringify(original) !== JSON.stringify(row)) await transaction.update(table, data, { where: Object.assign({ id: row.id }, scope) });
+                } else {
+                    if (table === this.ctx.service.shenpiGroup.tableName) data.create_time = new Date();
+                    const result = await transaction.insert(table, data);
+                    ids[row.id] = result.insertId;
+                }
+            }
+            return ids;
+        }
+    }
+    return TenderApprovalSetting;
+};

+ 24 - 0
app/view/sp_setting/approval.ejs

@@ -0,0 +1,24 @@
+<% include ./sub_menu.ejs %>
+<div class="panel-content setting-approval-page">
+    <div class="panel-title fluid">
+        <div class="title-main">
+            <div class="approval-heading">审批流程</div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="sjs-height-0 row approval-layout">
+            <div class="col-4">
+                <div class="c-body sjs-height-0 approval-tenders"></div>
+            </div>
+            <div class="col-8">
+                <div class="m-3 approval-content sjs-height-0">
+                    <div id="splc">
+                        <div class="text-muted p-3"><%= tenderList.length ? '请选择标段查看审批流程' : '暂无可配置审批流程的标段' %></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<div id="approval-card-store" class="d-none"></div>
+<% include ./manage_data.ejs %>

+ 12 - 0
app/view/sp_setting/approval_editor_modal.ejs

@@ -0,0 +1,12 @@
+<div class="modal fade" id="approval-editor" data-backdrop="static" data-keyboard="false">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header"><h5 class="modal-title">流程编辑</h5></div>
+            <div class="modal-body" id="approval-edit-body"></div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="approval-save">保存</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 297 - 0
app/view/sp_setting/approval_modal.ejs

@@ -0,0 +1,297 @@
+<!--设置其他标段-->
+<div class="modal fade" id="batch" data-backdrop="static">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">设置其他标段</h5>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning">将「<span id="shenpi-name"></span>」设置至其他标段</div>
+                <div class="input-group input-group-sm mb-2">
+                    <input class="form-control" placeholder="输入审批人名称搜索" type="text" name="audit-name" id="search-audit">
+                    <div class="input-group-append">
+                        <span class="input-group-text" id="search-result">0/0</span>
+                        <button class="btn btn-outline-secondary" id="up-search" disabled><i class="fa fa-arrow-up" aria-hidden="true"></i></button>
+                        <button class="btn btn-outline-secondary" id="down-search" disabled><i class="fa fa-arrow-down" aria-hidden="true"></i></button>
+                    </div>
+                </div>
+                <div class="modal-height-300" id="tender-list">
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="shenpi_auditors" value="">
+                <input type="hidden" id="shenpi_code" value="">
+                <input type="hidden" id="shenpi_status" value="">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-other-tender">确认</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--设置其他流程-->
+<div class="modal fade" id="batch2" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">设置其他流程</h5>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning">将「<span id="shenpi-name2"></span>」设置至其他流程</div>
+                <div class="modal-height-300" id="shenpi-list">
+                    <table class="table table-hover table-bordered">
+                        <thead>
+                        <th>名称</th>
+                        <th width="100">审批流程</th>
+                        <th width="40">选择</th>
+                        </thead>
+                        <tr>
+                            <td>预付款审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td></td>
+                        </tr>
+                        <tr>
+                            <td>台账审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>台账修订</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>计量期审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>工程变更审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>材料调差审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                    </table>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="shenpi_auditors2" value="">
+                <input type="hidden" id="shenpi_code2" value="">
+                <input type="hidden" id="shenpi_status2" value="">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-other-shenpi">确认</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--多人协同-->
+<div class="modal fade" id="cooperation" data-backdrop="static">
+    <div class="modal-dialog modal-xl" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">多人协同</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;">
+                            <table class="table table-hover table-bordered">
+                                <thead class="text-center">
+                                <tr><th colspan="4" id="stage_audit"></th></tr>
+                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
+                                </thead>
+                                <tbody id="coo_table">
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div class="col-6">
+                        <div class="row">
+                            <div class="col-6">
+                                <div class="mb-2">
+                                    <select class="form-control form-control-sm" id="stage_audits">
+                                    </select>
+                                </div>
+                            </div>
+                            <div class="col-6">
+                                <div class="text-right">
+                                    <div class="d-inline-block">
+                                        <button class="btn btn-outline-danger btn-sm" id="del-audit-ass">
+                                            移除协同人
+                                        </button>
+                                    </div>
+                                    <div class="dropdown d-inline-block" data-code="audit-ass">
+                                        <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="audit-ass_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                                            添加协同人
+                                        </button>
+                                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="audit-ass_dropdownMenuButton" style="width:220px" id="audit-ass_dropdownMenu">
+                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search" placeholder="姓名/手机 检索" autocomplete="off" data-code="audit-ass"></div>
+                                            <dl class="list-unstyled book-list">
+                                                <% accountGroup.forEach((group, idx) => { %>
+                                                <dt><a href="javascript: void(0);" class="acc-btn" data-groupid="<%- idx %>" data-type="hide"><i class="fa fa-plus-square"></i></a> <%- group.groupName %></dt>
+                                                <div class="dd-content" data-toggleid="<%- idx %>">
+                                                    <% group.groupList.forEach(item => { %>
+                                                    <dd class="border-bottom p-2 mb-0 " data-id="<%- item.id %>">
+                                                        <p class="mb-0 d-flex"><span class="text-primary"><%- item.name %></span><span
+                                                                    class="ml-auto"><%- item.mobile %></span></p>
+                                                        <span class="text-muted"><%- item.role %></span>
+                                                    </dd>
+                                                    <% });%>
+                                                </div>
+                                                <% }) %>
+                                            </dl>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="modal-height-500" style="overflow: auto;" id="ledger-spread">
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="union" data-backdrop="static">
+    <div class="modal-dialog modal-xl" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">协同审批</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;">
+                            <table class="table table-hover table-bordered">
+                                <thead class="text-center" >
+                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
+                                </thead>
+                                <tbody id="union_table">
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;" id="union-spread">
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="union-ok">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--存为审批组-->
+<div class="modal fade" id="spzsave" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">保存审批组</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group mb-2">
+                    <label>审批组名称<b class="text-danger">*</b></label>
+                    <input class="form-control form-control-sm" name="group_name" placeholder="输入名称" type="text">
+                </div>
+                <div class="form-group" id="show-change-type" style="display: none">
+                    <label>显示模块</label>
+                    <div>
+                        <% for (const c of change_type_list) { %>
+                            <div class="form-check form-check-inline">
+                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="change_type[]" id="<%- c.code %>_checkbox" disabled>
+                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
+                            </div>
+                        <% } %>
+                    </div>
+                </div>
+                <div class="form-group" id="show-contract-type" style="display: none">
+                    <label>显示模块</label>
+                    <div>
+                        <% for (const c of contract_type_list) { %>
+                            <div class="form-check form-check-inline">
+                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="contract_type[]" id="<%- c.code %>_checkbox" disabled>
+                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
+                            </div>
+                        <% } %>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="save-code">
+                <input type="hidden" id="save-group-id">
+                <button type="button" id="show-delete-group-btn" style="display: none;" class="btn btn-sm btn-danger mr-auto">删除审批组</button>
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-group-btn">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--删除审批组-->
+<div class="modal fade" id="spzdelete" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">删除审批组</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group mb-2">
+                    <h5>确认删除<span id="delete-group-name"></span>?</h5>
+                    <h5>删除后,该审批组已添加的人员也将一同移除,请谨慎操作。</h5>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="delete-code">
+                <input type="hidden" id="delete-group-id">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-danger" id="delete-group-btn">确定删除</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="multi" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="multi-title">分组审批</h5>
+            </div>
+            <div class="modal-body">
+                <div class="modal-height-500" style="overflow: auto;" id="multi-spread">
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.multi %>">
+                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。<br/>
+                    受限:受限时,前面所有的审批人都审批通过后,方可审批。
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.and %>">
+                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.or %>">
+                    最少审批:该审批人审批通过时,审批通过人数满足则结束本流程。
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="multi-ok">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<% if (approvalOnly) { %>
+<% include ./approval_editor_modal.ejs %>
+<% } %>

+ 1 - 20
app/view/sp_setting/manage.ejs

@@ -370,23 +370,4 @@
         </div>
     </div>
 </div>
-<script>
-    const accountGroup = JSON.parse(unescape('<%- escape(JSON.stringify(accountGroup)) %>'));
-    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
-    const auditConst = JSON.parse(unescape('<%- escape(JSON.stringify(auditConst)) %>'));
-    const tenders = JSON.parse(unescape('<%- escape(JSON.stringify(tenderList)) %>'));
-    const categoryType = JSON.parse('<%- JSON.stringify(settingConst.cType) %>');
-    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
-    const selfCategoryLevel = '<%- (selfCategoryLevel || '') %>';
-    const measureType = JSON.parse('<%- JSON.stringify(measureType) %>');
-    const auditType = JSON.parse(unescape('<%- escape(JSON.stringify(auditType)) %>'));
-    const scPermission = JSON.parse(unescape('<%- escape(JSON.stringify(scPermission)) %>'));
-    const subProjects = JSON.parse(unescape('<%- escape(JSON.stringify(subProjects)) %>'));
-    console.log(subProjects);
-    const uid = '<%- uid %>';
-    const pid = '<%- pid %>';
-    const uphlname = 'user_' + uid + '_pro_' + pid + '_tender_manage_list';
-    let sp_lc, sp_type, sp_status, sp_status_list, cur_tenderid, cur_uid;
-    const permissionConst = JSON.parse('<%- JSON.stringify(permissionConst) %>');
-    const permissionBlock = JSON.parse('<%- JSON.stringify(permissionBlock) %>');
-</script>
+<% include ./manage_data.ejs %>

+ 20 - 0
app/view/sp_setting/manage_data.ejs

@@ -0,0 +1,20 @@
+<script>
+    const approvalOnly = <%- JSON.stringify(approvalOnly) %>;
+    const accountGroup = JSON.parse(unescape('<%- escape(JSON.stringify(accountGroup)) %>'));
+    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
+    const auditConst = JSON.parse(unescape('<%- escape(JSON.stringify(auditConst)) %>'));
+    const tenders = JSON.parse(unescape('<%- escape(JSON.stringify(tenderList)) %>'));
+    const categoryType = JSON.parse('<%- JSON.stringify(settingConst.cType) %>');
+    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
+    const selfCategoryLevel = '<%- (selfCategoryLevel || '') %>';
+    const measureType = JSON.parse('<%- JSON.stringify(measureType) %>');
+    const auditType = JSON.parse(unescape('<%- escape(JSON.stringify(auditType)) %>'));
+    const scPermission = JSON.parse(unescape('<%- escape(JSON.stringify(scPermission)) %>'));
+    const subProjects = JSON.parse(unescape('<%- escape(JSON.stringify(subProjects)) %>'));
+    const uid = '<%- uid %>';
+    const pid = '<%- pid %>';
+    const uphlname = 'user_' + uid + '_pro_' + pid + (approvalOnly ? '_tender_approval_list' : '_tender_manage_list');
+    let sp_lc, sp_type, sp_status, sp_status_list, cur_tenderid, cur_uid;
+    const permissionConst = JSON.parse('<%- JSON.stringify(permissionConst) %>');
+    const permissionBlock = JSON.parse('<%- JSON.stringify(permissionBlock) %>');
+</script>

+ 6 - 287
app/view/sp_setting/manage_modal.ejs

@@ -1,33 +1,4 @@
-<!--设置其他标段-->
-<div class="modal fade" id="batch" data-backdrop="static">
-    <div class="modal-dialog modal-lg" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">设置其他标段</h5>
-            </div>
-            <div class="modal-body">
-                <div class="alert alert-warning">将「<span id="shenpi-name"></span>」设置至其他标段</div>
-                <div class="input-group input-group-sm mb-2">
-                    <input class="form-control" placeholder="输入审批人名称搜索" type="text" name="audit-name" id="search-audit">
-                    <div class="input-group-append">
-                        <span class="input-group-text" id="search-result">0/0</span>
-                        <button class="btn btn-outline-secondary" id="up-search" disabled><i class="fa fa-arrow-up" aria-hidden="true"></i></button>
-                        <button class="btn btn-outline-secondary" id="down-search" disabled><i class="fa fa-arrow-down" aria-hidden="true"></i></button>
-                    </div>
-                </div>
-                <div class="modal-height-300" id="tender-list">
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="shenpi_auditors" value="">
-                <input type="hidden" id="shenpi_code" value="">
-                <input type="hidden" id="shenpi_status" value="">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-other-tender">确认</button>
-            </div>
-        </div>
-    </div>
-</div>
+<% include ./approval_modal.ejs %>
 <div class="modal fade" id="filter-tender" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
         <div class="modal-content">
@@ -53,169 +24,7 @@
         </div>
     </div>
 </div>
-<!--设置其他流程-->
-<div class="modal fade" id="batch2" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">设置其他流程</h5>
-            </div>
-            <div class="modal-body">
-                <div class="alert alert-warning">将「<span id="shenpi-name2"></span>」设置至其他流程</div>
-                <div class="modal-height-300" id="shenpi-list">
-                    <table class="table table-hover table-bordered">
-                        <thead>
-                        <th>名称</th>
-                        <th width="100">审批流程</th>
-                        <th width="40">选择</th>
-                        </thead>
-                        <tr>
-                            <td>预付款审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td></td>
-                        </tr>
-                        <tr>
-                            <td>台账审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>台账修订</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>计量期审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>工程变更审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>材料调差审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                    </table>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="shenpi_auditors2" value="">
-                <input type="hidden" id="shenpi_code2" value="">
-                <input type="hidden" id="shenpi_status2" value="">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-other-shenpi">确认</button>
-            </div>
-        </div>
-    </div>
-</div>
-<!--多人协同-->
-<div class="modal fade" id="cooperation" data-backdrop="static">
-    <div class="modal-dialog modal-xl" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">多人协同</h5>
-            </div>
-            <div class="modal-body">
-                <div class="row">
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;">
-                            <table class="table table-hover table-bordered">
-                                <thead class="text-center">
-                                <tr><th colspan="4" id="stage_audit"></th></tr>
-                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
-                                </thead>
-                                <tbody id="coo_table">
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                    <div class="col-6">
-                        <div class="row">
-                            <div class="col-6">
-                                <div class="mb-2">
-                                    <select class="form-control form-control-sm" id="stage_audits">
-                                    </select>
-                                </div>
-                            </div>
-                            <div class="col-6">
-                                <div class="text-right">
-                                    <div class="d-inline-block">
-                                        <button class="btn btn-outline-danger btn-sm" id="del-audit-ass">
-                                            移除协同人
-                                        </button>
-                                    </div>
-                                    <div class="dropdown d-inline-block" data-code="audit-ass">
-                                        <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="audit-ass_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                                            添加协同人
-                                        </button>
-                                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="audit-ass_dropdownMenuButton" style="width:220px" id="audit-ass_dropdownMenu">
-                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search" placeholder="姓名/手机 检索" autocomplete="off" data-code="audit-ass"></div>
-                                            <dl class="list-unstyled book-list">
-                                                <% accountGroup.forEach((group, idx) => { %>
-                                                <dt><a href="javascript: void(0);" class="acc-btn" data-groupid="<%- idx %>" data-type="hide"><i class="fa fa-plus-square"></i></a> <%- group.groupName %></dt>
-                                                <div class="dd-content" data-toggleid="<%- idx %>">
-                                                    <% group.groupList.forEach(item => { %>
-                                                    <dd class="border-bottom p-2 mb-0 " data-id="<%- item.id %>">
-                                                        <p class="mb-0 d-flex"><span class="text-primary"><%- item.name %></span><span
-                                                                    class="ml-auto"><%- item.mobile %></span></p>
-                                                        <span class="text-muted"><%- item.role %></span>
-                                                    </dd>
-                                                    <% });%>
-                                                </div>
-                                                <% }) %>
-                                            </dl>
-                                        </div>
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <div class="modal-height-500" style="overflow: auto;" id="ledger-spread">
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-            </div>
-        </div>
-    </div>
-</div>
-<div class="modal fade" id="union" data-backdrop="static">
-    <div class="modal-dialog modal-xl" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">协同审批</h5>
-            </div>
-            <div class="modal-body">
-                <div class="row">
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;">
-                            <table class="table table-hover table-bordered">
-                                <thead class="text-center" >
-                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
-                                </thead>
-                                <tbody id="union_table">
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;" id="union-spread">
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="union-ok">保存</button>
-            </div>
-        </div>
-    </div>
-</div>
+
 <!--弹出编辑标段-->
 <div class="modal fade" id="edit-bd" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -243,6 +52,7 @@
         </div>
     </div>
 </div>
+
 <!--删除标段-->
 <div class="modal fade" id="del-bd" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -261,6 +71,7 @@
         </div>
     </div>
 </div>
+
 <!--移除账号-->
 <div class="modal fade" id="remove-user" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -281,6 +92,7 @@
         </div>
     </div>
 </div>
+
 <!--应用账号至其他标段-->
 <div class="modal fade" id="bdcopy" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
@@ -307,73 +119,7 @@
         </div>
     </div>
 </div>
-<!--存为审批组-->
-<div class="modal fade" id="spzsave" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">保存审批组</h5>
-            </div>
-            <div class="modal-body">
-                <div class="form-group mb-2">
-                    <label>审批组名称<b class="text-danger">*</b></label>
-                    <input class="form-control form-control-sm" name="group_name" placeholder="输入名称" type="text">
-                </div>
-                <div class="form-group" id="show-change-type" style="display: none">
-                    <label>显示模块</label>
-                    <div>
-                        <% for (const c of change_type_list) { %>
-                            <div class="form-check form-check-inline">
-                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="change_type[]" id="<%- c.code %>_checkbox" disabled>
-                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
-                            </div>
-                        <% } %>
-                    </div>
-                </div>
-                <div class="form-group" id="show-contract-type" style="display: none">
-                    <label>显示模块</label>
-                    <div>
-                        <% for (const c of contract_type_list) { %>
-                            <div class="form-check form-check-inline">
-                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="contract_type[]" id="<%- c.code %>_checkbox" disabled>
-                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
-                            </div>
-                        <% } %>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="save-code">
-                <input type="hidden" id="save-group-id">
-                <button type="button" id="show-delete-group-btn" style="display: none;" class="btn btn-sm btn-danger mr-auto">删除审批组</button>
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-group-btn">确定</button>
-            </div>
-        </div>
-    </div>
-</div>
-<!--删除审批组-->
-<div class="modal fade" id="spzdelete" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">删除审批组</h5>
-            </div>
-            <div class="modal-body">
-                <div class="form-group mb-2">
-                    <h5>确认删除<span id="delete-group-name"></span>?</h5>
-                    <h5>删除后,该审批组已添加的人员也将一同移除,请谨慎操作。</h5>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="delete-code">
-                <input type="hidden" id="delete-group-id">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
-                <button type="button" class="btn btn-sm btn-danger" id="delete-group-btn">确定删除</button>
-            </div>
-        </div>
-    </div>
-</div>
+
 <!--关联标段-->
 <div class="modal fade" id="select-tender" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
@@ -402,30 +148,3 @@
         </div>
     </div>
 </div>
-<div class="modal fade" id="multi" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="multi-title">分组审批</h5>
-            </div>
-            <div class="modal-body">
-                <div class="modal-height-500" style="overflow: auto;" id="multi-spread">
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.multi %>">
-                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。<br/>
-                    受限:受限时,前面所有的审批人都审批通过后,方可审批。
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.and %>">
-                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.or %>">
-                    最少审批:该审批人审批通过时,审批通过人数满足则结束本流程。
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="multi-ok">保存</button>
-            </div>
-        </div>
-    </div>
-</div>

+ 6 - 0
config/menu.js

@@ -525,6 +525,12 @@ const projectSettingMenu = {
         url: '/setting/user',
         caption: '项目成员',
     },
+    // approval: {
+    //     name: '标段审批流程',
+    //     display: true,
+    //     url: '/setting/approval',
+    //     caption: '标段审批流程',
+    // },
     fun: {
         name: '功能设置',
         display: true,

+ 2 - 0
config/web.js

@@ -1701,7 +1701,9 @@ const JsFiles = {
                     '/public/js/shares/tender_list_order.js',
                     '/public/js/shares/show_level.js',
                     '/public/js/tender_showhide.js',
+                    '/public/js/shares/tender_approval_draft.js',
                     '/public/js/shenpi.js',
+                    '/public/js/setting_approval.js',
                     '/public/js/popper/popper.min.js',
                     '/public/js/tender_list_base.js',
                     '/public/js/setting_manage.js',