فهرست منبع

Merge branch 'dev' of http://192.168.1.41:3000/maixinrong/Calculation into dev

Tony Kang 3 ماه پیش
والد
کامیت
009a685782
86فایلهای تغییر یافته به همراه12271 افزوده شده و 302 حذف شده
  1. 12 0
      app/base/base_bills_service.js
  2. 1 0
      app/base/base_controller.js
  3. 3 0
      app/base/base_tree_service.js
  4. 6 2
      app/const/audit.js
  5. 16 0
      app/const/shenpi.js
  6. 3 0
      app/const/tender_info.js
  7. 827 0
      app/controller/cost_controller.js
  8. 43 0
      app/controller/dashboard_controller.js
  9. 2 0
      app/controller/ledger_controller.js
  10. 15 0
      app/controller/sub_proj_controller.js
  11. 39 0
      app/controller/template_controller.js
  12. 76 53
      app/controller/tender_controller.js
  13. 62 0
      app/middleware/cost_stage_check.js
  14. 42 0
      app/public/css/main.css
  15. 163 0
      app/public/js/cost_stage.js
  16. 932 0
      app/public/js/cost_stage_analysis.js
  17. 781 0
      app/public/js/cost_stage_book.js
  18. 1395 0
      app/public/js/cost_stage_ledger.js
  19. 68 0
      app/public/js/cost_tender.js
  20. 65 0
      app/public/js/cost_tmpl.js
  21. 221 14
      app/public/js/ledger.js
  22. 1 1
      app/public/js/measure_stage.js
  23. 33 1
      app/public/js/path_tree.js
  24. 557 117
      app/public/js/pos_calc_tmpl.js
  25. 1 1
      app/public/js/revise.js
  26. 1 1
      app/public/js/settle_ledger.js
  27. 17 13
      app/public/js/shares/cs_tools.js
  28. 6 6
      app/public/js/shares/new_tag.js
  29. 53 8
      app/public/js/shares/tools_att.js
  30. 0 17
      app/public/js/spreadjs_rela/spreadjs_zh.js
  31. 1 1
      app/public/js/sr_detail.js
  32. 1 1
      app/public/js/stage.js
  33. 28 2
      app/router.js
  34. 95 25
      app/service/calc_tmpl.js
  35. 49 0
      app/service/calc_tmpl_folder.js
  36. 321 0
      app/service/cost_stage.js
  37. 697 0
      app/service/cost_stage_analysis.js
  38. 160 0
      app/service/cost_stage_analysis_detail.js
  39. 1197 0
      app/service/cost_stage_audit.js
  40. 193 0
      app/service/cost_stage_book.js
  41. 231 0
      app/service/cost_stage_book_detail.js
  42. 371 0
      app/service/cost_stage_detail.js
  43. 94 0
      app/service/cost_stage_file.js
  44. 531 0
      app/service/cost_stage_ledger.js
  45. 95 0
      app/service/cost_stage_tag.js
  46. 1 1
      app/service/notice_push.js
  47. 127 0
      app/service/pos.js
  48. 1 0
      app/service/pos_calc_detail.js
  49. 2 2
      app/service/safe_stage_bills.js
  50. 9 1
      app/service/tender_permission.js
  51. 84 0
      app/view/cost/analysis.ejs
  52. 82 0
      app/view/cost/analysis_list.ejs
  53. 72 0
      app/view/cost/analysis_list_modal.ejs
  54. 3 0
      app/view/cost/analysis_menu_list.ejs
  55. 30 0
      app/view/cost/analysis_modal.ejs
  56. 41 0
      app/view/cost/audit_btn.ejs
  57. 898 0
      app/view/cost/audit_modal.ejs
  58. 75 0
      app/view/cost/book.ejs
  59. 78 0
      app/view/cost/book_list.ejs
  60. 72 0
      app/view/cost/book_list_modal.ejs
  61. 3 0
      app/view/cost/book_menu_list.ejs
  62. 4 0
      app/view/cost/book_modal.ejs
  63. 91 0
      app/view/cost/ledger.ejs
  64. 92 0
      app/view/cost/ledger_list.ejs
  65. 68 0
      app/view/cost/ledger_list_modal.ejs
  66. 3 0
      app/view/cost/ledger_menu_list.ejs
  67. 54 0
      app/view/cost/ledger_modal.ejs
  68. 14 0
      app/view/cost/list_menu.ejs
  69. 9 0
      app/view/cost/list_menu_list.ejs
  70. 16 0
      app/view/cost/list_mini_menu.ejs
  71. 17 0
      app/view/cost/stage_memu.ejs
  72. 18 0
      app/view/cost/stage_mini_menu.ejs
  73. 27 0
      app/view/cost/tender.ejs
  74. 54 0
      app/view/cost/tender_modal.ejs
  75. 72 1
      app/view/dashboard/index.ejs
  76. 61 2
      app/view/dashboard/workspace.ejs
  77. 1 0
      app/view/ledger/explode.ejs
  78. 18 0
      app/view/ledger/explode_modal.ejs
  79. 13 13
      app/view/phase_pay/index.ejs
  80. 35 15
      app/view/template/pos_calc.ejs
  81. 27 1
      app/view/template/pos_calc_modal.ejs
  82. 2 2
      app/view/tender/shenpi.ejs
  83. 2 0
      app/view/tender/shenpi_modal.ejs
  84. 8 0
      config/menu.js
  85. 121 1
      config/web.js
  86. 361 0
      sql/update.sql

+ 12 - 0
app/base/base_bills_service.js

@@ -759,6 +759,18 @@ class BaseBillsSerivce extends TreeService {
                                     newDetail.num_g = cd.num_g || 0;
                                     newDetail.num_h = cd.num_h || 0;
                                     newDetail.num_i = cd.num_i || 0;
+                                    newDetail.num_j = cd.num_j || 0;
+                                    newDetail.num_k = cd.num_k || 0;
+                                    newDetail.num_l = cd.num_l || 0;
+                                    newDetail.num_m = cd.num_m || 0;
+                                    newDetail.num_n = cd.num_n || 0;
+                                    newDetail.num_o = cd.num_o || 0;
+                                    newDetail.num_p = cd.num_p || 0;
+                                    newDetail.num_q = cd.num_q || 0;
+                                    newDetail.num_r = cd.num_r || 0;
+                                    newDetail.num_s = cd.num_s || 0;
+                                    newDetail.num_t = cd.num_t || 0;
+                                    newDetail.num_u = cd.num_u || 0;
                                     newDetail.qty = cd.qty || 0;
                                     newDetail.expr = cd.expr || '';
                                     newDetail.spec = cd.spec || '';

+ 1 - 0
app/base/base_controller.js

@@ -52,6 +52,7 @@ class BaseController extends Controller {
             menuList.budget.children.find(item => item.msg === 'schedule').display = ctx.subProject.page_show.xxjd || false;
             menuList.financial.display = ctx.subProject.page_show.openFinancial || false;
             menuList.payment.display = ctx.subProject.page_show.openPayment || false;
+            menuList.cost.display = ctx.subProject.page_show.cost || false;
             for (const index in menuList) {
                 const im = menuList[index];
                 if (!im.url) {

+ 3 - 0
app/base/base_tree_service.js

@@ -399,6 +399,8 @@ class TreeService extends Service {
         return data;
     }
 
+    _getDefaultData(data) {
+    }
     /**
      * 新增数据(新增为selectData的后项,该方法不可单独使用)
      *
@@ -422,6 +424,7 @@ class TreeService extends Service {
         data[this.setting.order] = select ? select[this.setting.order] + 1 : 1;
         data[this.setting.fullPath] = data[this.setting.level] > 1 ? select[this.setting.fullPath].replace('-' + select[this.setting.kid], '-' + data[this.setting.kid]) : data[this.setting.kid] + '';
         data[this.setting.isLeaf] = true;
+        this._getDefaultData(data);
         const result = await this.transaction.insert(this.tableName, data);
 
         this._cacheMaxLid(mid, maxId + 1);

+ 6 - 2
app/const/audit.js

@@ -11,8 +11,8 @@
 const auditType = (function () {
     const types = [
         { key: 'common', name: '个人', value: 1, short: '', long: '', class: '', },
-        { key: 'and', name: '会签', value: 2, short: '会', long: '多人会签', class: 'primary', valid: ['ledger', 'revise', 'stage', 'change', 'material', 'financial', 'phasePay'], setValid: ['stage'] },
-        { key: 'or', name: '或签', value: 3, short: '或', long: '多人或签', class: 'success', valid: ['ledger', 'revise', 'stage', 'change', 'material', 'financial', 'phasePay'], setValid: ['stage'] },
+        { key: 'and', name: '会签', value: 2, short: '会', long: '多人会签', class: 'primary', valid: ['ledger', 'revise', 'stage', 'change', 'material', 'financial', 'phasePay', 'cost_stage_ledger', 'cost_stage_book', 'cost_stage_analysis'], setValid: ['stage'] },
+        { key: 'or', name: '或签', value: 3, short: '或', long: '多人或签', class: 'success', valid: ['ledger', 'revise', 'stage', 'change', 'material', 'financial', 'phasePay', 'cost_stage_ledger', 'cost_stage_book', 'cost_stage_analysis'], setValid: ['stage'] },
         { key: 'union', name: '协同', value: 4, short: '协', long: '多人协同', class: 'warning', valid: ['stage']},
         { key: 'multi', name: '分组审批', value: 5, short: '组', long: '分组审批', class: 'danger', valid: ['stage'] },
     ];
@@ -1535,6 +1535,9 @@ const pushType = {
     inspection: 13,
     safeInspection: 14,
     safeStage: 15,
+    costStageLedger: 16,
+    costStageBook: 17,
+    costStageAnalysis: 18,
 };
 
 module.exports = {
@@ -1545,6 +1548,7 @@ module.exports = {
     stage,
     phasePay,
     safeStage: phasePay,
+    costStage: phasePay,
     settle,
     revise,
     material,

+ 16 - 0
app/const/shenpi.js

@@ -21,10 +21,19 @@ const sp_type = {
     inspection: 10,
     safe_inspection: 11,
     safe_payment: 12,
+    // 成本报审相关审批流程,占用13-15,请勿重复使用
+    // cost_stage_ledger: 13,
+    // cost_stage_book: 14,
+    // cost_stage_analysis: 15,
 };
 const sp_other_type = {
     financial: 8,
 };
+const cost_sp_type = {
+    cost_stage_ledger: 13,
+    cost_stage_book: 14,
+    cost_stage_analysis: 15,
+};
 // const sp_name = [];
 // sp_name[sp_type.advance] = '预付款审批';
 // sp_name[sp_type.ledger] = '台账审批';
@@ -47,6 +56,11 @@ const sp_lc = [
     { code: 'safe_payment', type: sp_type.safe_payment, name: '安全计量审批' },
     { code: 'safe_inspection', type: sp_type.safe_inspection, name: '安全巡检审批' },
 ];
+const cost_sp_lc = [
+    { code: 'cost_stage_ledger', type: cost_sp_type.cost_stage_ledger, name: '成本报审' },
+    { code: 'cost_stage_book', type: cost_sp_type.cost_stage_book, name: '财务账面' },
+    { code: 'cost_stage_analysis', type: cost_sp_type.cost_stage_analysis, name: '成本分析' },
+];
 
 const sp_status = {
     sqspr: 1, // 授权审批人
@@ -74,7 +88,9 @@ const change_type_list = [
 module.exports = {
     sp_type,
     sp_other_type,
+    cost_sp_type,
     sp_lc,
+    cost_sp_lc,
     sp_status,
     sp_status_list,
     change_type,

+ 3 - 0
app/const/tender_info.js

@@ -197,6 +197,9 @@ const defaultInfo = {
         inspection: 1,
         safe_payment: 1,
         safe_inspection: 1,
+        cost_stage_ledger: 1,
+        cost_stage_book: 1,
+        cost_stage_analysis: 1,
     },
     ledger_check: {
         same_code: true,

+ 827 - 0
app/controller/cost_controller.js

@@ -0,0 +1,827 @@
+'use strict';
+
+/**
+ *
+ *  成本管理
+ * @author Mai
+ * @date
+ * @version
+ */
+const audit = require('../const/audit');
+const shenpiConst = require('../const/shenpi');
+const moment = require('moment');
+const sendToWormhole = require('stream-wormhole');
+const fs = require('fs');
+const path = require('path');
+
+module.exports = app => {
+    class CostController extends app.BaseController {
+        constructor(ctx) {
+            super(ctx);
+            ctx.showProject = true;
+            // ctx.showTitle = true;
+        }
+
+        loadMenu(ctx) {
+            super.loadMenu(ctx);
+            // 虚拟menu,以保证标题显示正确
+            ctx.menu = {
+                name: '成本管理',
+                display: false,
+                caption: '成本管理',
+                controller: 'cost',
+            };
+        }
+
+        async tender(ctx) {
+            try {
+                if (!ctx.subProject.page_show.cost) throw '该功能已关闭';
+
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.cost.tender),
+                };
+
+                const accountList = await ctx.service.projectAccount.getAllSubProjectAccount(ctx.subProject);
+                renderData.accountList = accountList;
+                const unitList = await ctx.service.constructionUnit.getAllDataByCondition({ where: { pid: ctx.session.sessionProject.id } });
+                const accountGroupList = unitList.map(item => {
+                    const groupList = accountList.filter(item1 => item1.company === item.name);
+                    return { groupName: item.name, groupList };
+                }).filter(x => { return x.groupList.length > 0; });
+                renderData.accountGroup = accountGroupList;
+                renderData.accountInfo = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
+                renderData.tenderList = await ctx.service.tender.getSpecList(ctx.service.tenderPermission, 'cost', ctx.session.sessionUser.is_admin ? 'all' : '');
+                renderData.categoryData = await this.ctx.service.category.getAllCategory(this.ctx.subProject);
+                renderData.selfCategoryLevel = this.ctx.subProject.permission.self_category_level;
+                renderData.permissionConst = ctx.service.tenderPermission.partPermissionConst('cost');
+                renderData.permissionBlock = ctx.service.tenderPermission.partPermissionBlock('cost');
+
+                renderData.costLedgerTemplates = await ctx.app.mysql.select('zh_bills_template_list', { where: { sub_type: 1} });
+                renderData.costAnalysisTemplates = await ctx.app.mysql.select('zh_bills_template_list', { where: { sub_type: 2} });
+                renderData.costCalcTemplates = await ctx.service.calcTmpl.getAllTemplate(this.ctx.session.sessionProject.id, 'cost');
+                await this.layout('cost/tender.ejs', renderData, 'cost/tender_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看成本管理数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/dashboard`);
+            }
+        }
+
+        async member(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const result = await ctx.service.tenderPermission.getPartsPermission(data.tid, data.parts);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '查询标段权限错误');
+            }
+        }
+        async memberSave(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                await ctx.service.tenderPermission.savePermission(data.tid, data.member, data.permissionBlock);
+                ctx.body = { err: 0, msg: '', data: '' };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存标段权限错误');
+            }
+        }
+
+        async getTypeStages(ctx, stage_type) {
+            const stages = await this.ctx.service.costStage.getAllStages(ctx.tender.id, stage_type, 'DESC');
+            for (const s of stages) {
+                if (s.audit_status !== audit.common.status.checked) {
+                    await this.ctx.service.costStage.loadUser(s);
+                    s.is_join = s.userIds.indexOf(this.ctx.session.sessionUser.accountId) >= 0;
+                } else {
+                    const auditors = await this.ctx.service.costStageAudit.getAuditors(s.id, s.audit_times);
+                    s.is_join = auditors.findIndex(a => { return a.audit_id === this.ctx.session.sessionUser.accountId }) >= 0;
+                }
+                s.can_del = (s.create_user_id === ctx.session.sessionUser.accountId || ctx.session.sessionUser.is_admin)
+                    && (s.audit_status === audit.common.status.uncheck || s.audit_status === audit.common.status.checkNo);
+            }
+            if (this.ctx.session.sessionUser.is_admin || this.ctx.permission.cost.visitor || this.ctx.permission.cost[stage_type + '_add']) return stages;
+            return stages.filter(s => { return s.is_join; });
+        }
+        async ledger(ctx) {
+            try {
+                const stage_type = this.ctx.service.costStage.stageType.ledger.key;
+                const stages = await this.getTypeStages(ctx, stage_type);
+                const renderData = {
+                    stage_type,
+                    auditType: audit.auditType,
+                    stages,
+                    auditConst: audit.common,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.cost.cost_stage)
+                };
+                await this.layout('cost/ledger_list.ejs', renderData, 'cost/ledger_list_modal.ejs');
+            } catch(err) {
+                ctx.log(err);
+                ctx.redirect(ctx.request.header.referer);
+            }
+        }
+        async book(ctx) {
+            try {
+                const stage_type = this.ctx.service.costStage.stageType.book.key;
+                const stages = await this.getTypeStages(ctx, stage_type);
+                const validLedgerStages = await this.ctx.service.costStage.getAllCheckedStages(ctx.tender.id, 'ledger', 'DESC');
+                const renderData = {
+                    stage_type,
+                    auditType: audit.auditType,
+                    stages,
+                    validLedgerStages,
+                    auditConst: audit.common,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.cost.cost_stage)
+                };
+                await this.layout('cost/book_list.ejs', renderData, 'cost/book_list_modal.ejs');
+            } catch(err) {
+                ctx.log(err);
+                ctx.redirect(ctx.request.header.referer);
+            }
+        }
+        async analysis(ctx) {
+            try {
+                const stage_type = this.ctx.service.costStage.stageType.analysis.key;
+                const stages = await this.getTypeStages(ctx, stage_type);
+                // todo 兼容以ledger做关联的情况,届时只需在标段或者项目上设置这个类型即可。
+                const analysisType = 'book';
+                let validRelaStages = await this.ctx.service.costStage.getAllCheckedStages(ctx.tender.id, analysisType, 'DESC');
+                if (stages.length > 0) {
+                    const checkedStage = stages.find(x => { return x.audit_status === audit.common.status.checked; });
+                    if (checkedStage) validRelaStages = validRelaStages.filter( x => { return x.stage_order > checkedStage.rela_stage.sorder; });
+                }
+                const renderData = {
+                    stage_type,
+                    auditType: audit.auditType,
+                    stages,
+                    validRelaStages,
+                    auditConst: audit.common,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.cost.cost_stage)
+                };
+                await this.layout('cost/analysis_list.ejs', renderData, 'cost/analysis_list_modal.ejs');
+            } catch(err) {
+                ctx.log(err);
+                ctx.redirect(ctx.request.header.referer);
+            }
+        }
+
+        async addStage(ctx) {
+            const stage_type = ctx.request.body.stage_type;
+            try {
+                if (!ctx.permission.cost[stage_type + '_add']) throw '您无权创建期';
+                const stage_date = ctx.request.body.date;
+                if (stage_type === 'ledger' && !stage_date) throw '请选择年月';
+
+                const stages = await ctx.service.costStage.getAllStages(ctx.tender.id, stage_type, 'DESC');
+                const unCompleteCount = stages.filter(s => { return s.status !== audit.common.status.checked; }).length;
+                if (unCompleteCount.length > 0) throw '最新一期未审批通过,请审批通过后再新增';
+
+                let newStage;
+                if (stage_type === 'ledger') {
+                    newStage = await ctx.service.costStage.add(ctx.tender.id, stage_type, stage_date);
+                } else if (stage_type === 'book') {
+                    const stage_order = ctx.request.body.stage;
+                    if (!stage_order) throw '请选择关联成本';
+                    const ledgerStage = await ctx.service.costStage.getStageByOrder(ctx.tender.id, 'ledger', stage_order);
+                    if (!ledgerStage) throw '选择的关联成本不存在';
+                    newStage = await ctx.service.costStage.add(ctx.tender.id, stage_type, ledgerStage.stage_date, { sid: ledgerStage.id, sorder: ledgerStage.stage_order });
+                } else if (stage_type === 'analysis') {
+                    const stage_order = ctx.request.body.stage;
+                    if (!stage_order) throw '请选择关联期';
+                    // todo 兼容以ledger做关联的情况,届时只需在标段或者项目上设置这个类型即可。
+                    const analysisType = 'book';
+                    const relaStage = await ctx.service.costStage.getStageByOrder(ctx.tender.id, analysisType, stage_order);
+                    if (!relaStage) throw '选择的关联期不存在';
+                    newStage = await ctx.service.costStage.add(ctx.tender.id, stage_type, relaStage.stage_date, { sid: relaStage.id, sorder: relaStage.stage_order });
+                    await this.ctx.service.costStageAnalysis.reCalcProfit(newStage.id);
+                }
+
+                if (!newStage) throw '新增期失败';
+                ctx.redirect(`/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/${stage_type}/${newStage.stage_order}/stage`);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '新增期失败');
+                ctx.redirect(`/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/${stage_type}`);
+            }
+        }
+        async delStage(ctx) {
+            try {
+                const stage_id = ctx.request.body.stage_id;
+                const stage = await ctx.service.costStage.getDataById(stage_id);
+                if (!stage) throw '删除的期不存在,请刷新页面';
+                if (!ctx.session.sessionUser.is_admin && stage.create_user_id !== ctx.session.sessionUser.accountId) throw '您无权删除本期';
+                // 获取最新的期数
+                const stages = await ctx.service.costStage.getAllStages(ctx.tender.id, stage.stage_type, 'DESC');
+                if (stage.id !== stages[0].id) throw '非最新一期,不可删除';
+                if (stage.stage_type === 'ledger') {
+                    const bookStages = await ctx.service.costStage.getAllStages(ctx.tender.id, 'book', 'DESC');
+                    if (bookStages.findIndex(x => { return x.rela_stage && x.rela_stage.sid === stage.id; })) throw '该期已关联财务账面,请勿删除';
+                    const analysisStages = await ctx.service.costStage.getAllStages(ctx.tender.id, 'analysis', 'DESC');
+                    if (analysisStages.findIndex(x => { return x.rela_stage && x.rela_stage.sid === stage.id; })) throw '该期已关联成本分析,请勿删除';
+                }
+                if (stage.stage_type === 'book') {
+                    const analysisStages = await ctx.service.costStage.getAllStages(ctx.tender.id, 'analysis', 'DESC');
+                    if (analysisStages.findIndex(x => { return x.rela_stage && x.rela_stage.sid === stage.id; })) throw '该期已关联成本分析,请勿删除';
+                }
+
+                await ctx.service.costStage.delete(stage_id);
+                ctx.redirect(`/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/${stage.stage_type}`);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '删除期失败');
+                ctx.redirect(ctx.request.header.referer);
+            }
+        }
+
+        async _getStageAuditViewData(ctx) {
+            await this.ctx.service.costStage.loadAuditViewData(ctx.costStage);
+        }
+        async stage(ctx) {
+            const stageTypeInfo = ctx.service.costStage.stageType[ctx.costStage.stage_type];
+            try {
+                await this._getStageAuditViewData(ctx);
+                // 流程审批人相关数据
+                const accountList = await ctx.service.projectAccount.getAllSubProjectAccount(ctx.subProject);
+                const unitList = await ctx.service.constructionUnit.getAllDataByCondition({ where: { pid: ctx.session.sessionProject.id } });
+                const accountGroup = unitList.map(item => {
+                    const groupList = accountList.filter(item1 => item1.company === item.name);
+                    return { groupName: item.name, groupList };
+                }).filter(x => { return x.groupList.length > 0; });
+                // 是否已验证手机短信
+                const pa = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
+                const commonJson = ctx.subProject.common_json ? JSON.parse(ctx.subProject.common_json) : null;
+                const contractTypes = commonJson && commonJson.tender_contract_type ? commonJson.tender_contract_type : [];
+                let payStages = [];
+                if (ctx.subProject.page_show.phasePay) {
+                    const pstages = await this.ctx.service.phasePay.getAllDataByCondition({
+                        columns: ['phase_order', 'phase_date'],
+                        where: { tid: ctx.costStage.tid , audit_status: audit.common.status.checked },
+                        orders: [['create_time', 'DESC']],
+                    });
+                    payStages = pstages.map(x => { return { order: x.phase_order, date: x.phase_date}; });
+                } else {
+                    const stages = await this.ctx.service.stage.getAllDataByCondition({
+                        columns: ['order', 's_time'],
+                        where: { tid: ctx.costStage.tid, status: audit.stage.status.checked },
+                        orders: [['in_time', 'DESC']],
+                    });
+                    payStages = stages.map(x => { return { order: x.order, date: x.s_time }; });
+                }
+                const renderData = {
+                    auditConst: audit.common,
+                    auditType: audit.auditType,
+                    accountList,
+                    accountGroup,
+                    shenpiConst,
+                    authMobile: pa.auth_mobile,
+                    payStages,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.cost[`cost_stage_${stageTypeInfo.key}`]),
+                    shenpi_status: ctx.tender.info.shenpi[stageTypeInfo.shenpi_status] || 1,
+                    contractTypes,
+                };
+                await this.layout(`cost/${stageTypeInfo.key}.ejs`, renderData, `cost/${stageTypeInfo.key}_modal.ejs`);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '读取成本报审错误');
+                ctx.redirect(`/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/${stageTypeInfo.key}`);
+            }
+        }
+
+        async _ledgerLoad(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const filter = data.filter.split(';');
+                const responseData = { err: 0, msg: '', data: {}, hpack: [] };
+                for (const f of filter) {
+                    switch (f) {
+                        case 'bills':
+                            responseData.data.bills = ctx.costStage.readOnly
+                                ? await ctx.service.costStageLedger.getReadData(ctx.costStage)
+                                : await ctx.service.costStageLedger.getEditData(ctx.costStage);
+                            break;
+                        case 'billsCompare':
+                            responseData.data[f] = await ctx.service.costStageLedger.getCompareData(ctx.costStage);
+                            break;
+                        case 'detail':
+                            responseData.data.detail =  ctx.costStage.readOnly
+                                ? await ctx.service.costStageDetail.getReadData(ctx.costStage)
+                                : await ctx.service.costStageDetail.getEditData(ctx.costStage);
+                            break;
+                        case 'detailCompare':
+                            // todo
+                            responseData.data.detailCompare = await ctx.service.costStageDetail.getCompareData(ctx.costStage);
+                            break;
+                        case 'auditFlow':
+                            responseData.data[f] = await ctx.service.costStageAudit.getViewFlow(ctx.costStage);
+                            break;
+                        case 'att':
+                            responseData.data[f] = await ctx.service.costStageFile.getData(ctx.costStage.id, 'DESC');
+                            break;
+                        case 'tags':
+                            responseData.data[f] = await ctx.service.costStageTag.getDatas(ctx.costStage.id);
+                            break;
+                        case 'contract':
+                            const contractTree = await ctx.service.contractTree.getAllDataByCondition({
+                                columns: ['id', 'contract_id', 'contract_pid', 'order', 'level', 'is_leaf', 'full_path', 'code', 'name'],
+                                where: {tid: ctx.costStage.tid, contract_type: 1}
+                            });
+                            const contractData = await this.ctx.service.contract.getAllDataByCondition({
+                                columns: ['id', 'contract_id', 'contract_pid', 'order', 'level', 'is_leaf', 'full_path', 'c_code', 'name'],
+                                where:  {tid: ctx.costStage.tid, contract_type: 1}
+                            });
+                            responseData.data[f] = [...contractTree, ...contractData];
+                            break;
+                        default:
+                            responseData.data[f] = [];
+                            break;
+                    }
+                }
+
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+        async _bookLoad(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const filter = data.filter.split(';');
+                const responseData = { err: 0, msg: '', data: {}, hpack: [] };
+                for (const f of filter) {
+                    switch (f) {
+                        case 'bills':
+                            const ledger = await ctx.service.costStageLedger.getReadData(ctx.costStage.relaStage);
+                            ledger.forEach(l => { delete l.postil; delete l.memo; });
+                            const book = ctx.costStage.readOnly
+                                ? await ctx.service.costStageBook.getReadData(ctx.costStage)
+                                : await ctx.service.costStageBook.getEditData(ctx.costStage);
+                            ctx.helper.assignRelaData(ledger, [
+                                { data: book, fields: ['pre_in_tp', 'pre_in_excl_tax_tp', 'in_tp', 'in_excl_tax_tp', 'end_in_tp', 'end_in_excl_tax_tp', 'postil', 'memo'], prefix: '', relaId: 'ledger_id' },
+                                { data: book, fields: ['id'], prefix: 'book_', relaId: 'ledger_id' },
+                            ]);
+                            responseData.data[f] = ledger;
+                            break;
+                        case 'billsCompare':
+                            const ledgerC = await ctx.service.costStageLedger.getReadData(ctx.costStage.relaStage);
+                            ledgerC.forEach(l => { delete l.postil; delete l.memo; });
+                            const bookC = await ctx.service.costStageBook.getCompareData(ctx.costStage);
+                            ctx.helper.assignRelaData(ledgerC, [
+                                { data: bookC, fields: ['pre_in_tp', 'pre_in_excl_tax_tp', 'calc_his', 'postil', 'memo'], prefix: '', relaId: 'ledger_id' },
+                            ]);
+                            responseData.data[f] = ledgerC;
+                            break;
+                        case 'detail':
+                            const detail = await ctx.service.costStageDetail.getReadData(ctx.costStage.relaStage);
+                            detail.forEach(l => { delete l.postil; delete l.memo; });
+                            const bookDetail = ctx.costStage.readOnly
+                                ? await ctx.service.costStageBookDetail.getReadData(ctx.costStage)
+                                : await ctx.service.costStageBookDetail.getEditData(ctx.costStage);
+                            ctx.helper.assignRelaData(detail, [
+                                { data: bookDetail, fields: ['in_tp', 'in_excl_tax_tp', 'postil', 'memo'], prefix: '', relaId: 'detail_id' },
+                                { data: bookDetail, fields: ['id'], prefix: 'book_', relaId: 'detail_id' },
+                            ]);
+                            responseData.data[f] = detail;
+                            break;
+                        case 'detailCompare':
+                            const detailC = await ctx.service.costStageLedger.getReadData(ctx.costStage.relaStage);
+                            detailC.forEach(l => { delete l.postil; delete l.memo; });
+                            const bookDetailC = await ctx.service.costStageBook.getCompareData(ctx.costStage);
+                            ctx.helper.assignRelaData(detailC, [
+                                { data: bookDetailC, fields: ['calc_his', 'postil', 'memo'], prefix: '', relaId: 'detail_id' },
+                            ]);
+                            responseData.data[f] = detailC;
+                            break;
+                        case 'auditFlow':
+                            responseData.data[f] = await ctx.service.costStageAudit.getViewFlow(ctx.costStage);
+                            break;
+                        case 'att':
+                            responseData.data[f] = await ctx.service.costStageFile.getData(ctx.costStage.id, 'DESC');
+                            break;
+                        default:
+                            responseData.data[f] = [];
+                            break;
+                    }
+                }
+
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+        async _analysisLoad(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const filter = data.filter.split(';');
+                const responseData = { err: 0, msg: '', data: {}, hpack: [] };
+                for (const f of filter) {
+                    switch (f) {
+                        case 'bills':
+                            responseData.data.bills = ctx.costStage.readOnly
+                                ? await ctx.service.costStageAnalysis.getReadData(ctx.costStage)
+                                : await ctx.service.costStageAnalysis.getEditData(ctx.costStage);
+                            break;
+                        case 'billsCompare':
+                            responseData.data[f] = await ctx.service.costStageAnalysis.getCompareData(ctx.costStage);
+                            break;
+                        case 'detail':
+                            responseData.data.detail =  ctx.costStage.readOnly
+                                ? await ctx.service.costStageAnalysisDetail.getReadData(ctx.costStage)
+                                : await ctx.service.costStageAnalysisDetail.getEditData(ctx.costStage);
+                            break;
+                        case 'detailCompare':
+                            // todo
+                            responseData.data.detailCompare = await ctx.service.costStageAnalysisDetail.getCompareData(ctx.costStage);
+                            break;
+                        case 'auditFlow':
+                            responseData.data[f] = await ctx.service.costStageAudit.getViewFlow(ctx.costStage);
+                            break;
+                        default:
+                            responseData.data[f] = [];
+                            break;
+                    }
+                }
+
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+        async stageLoad(ctx) {
+            const updateFun = `_${ctx.costStage.stage_type}Load`;
+            if (this[updateFun]) {
+                await this[updateFun](ctx);
+            } else {
+                ctx.ajaxErrorBody('未知期数据类型', '加载数据错误');
+            }
+        }
+
+        async _billsBase(stage, type, data) {
+            if (isNaN(data.id) || data.id <= 0) throw '数据错误';
+            if (type !== 'add') {
+                if (isNaN(data.count) || data.count <= 0) data.count = 1;
+            }
+            switch (type) {
+                case 'add':
+                    return await this.ctx.service.costStageLedger.addLedgerNode(stage, data.id, data.count);
+                case 'delete':
+                    return await this.ctx.service.costStageLedger.delete(stage.id, data.id, data.count);
+                case 'up-move':
+                    return await this.ctx.service.costStageLedger.upMoveNode(stage.id, data.id, data.count);
+                case 'down-move':
+                    return await this.ctx.service.costStageLedger.downMoveNode(stage.id, data.id, data.count);
+                case 'up-level':
+                    return await this.ctx.service.costStageLedger.upLevelNode(stage.id, data.id, data.count);
+                case 'down-level':
+                    return await this.ctx.service.costStageLedger.downLevelNode(stage.id, data.id, data.count);
+            }
+        }
+        async _ledgerUpdate(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.target) throw '数据错误';
+                const responseData = { err: 0, msg: '', data: {} };
+
+                if (data.target === 'ledger') {
+                    if (!data.postType || !data.postData) throw '数据错误';
+                    switch (data.postType) {
+                        case 'add':
+                        case 'delete':
+                        case 'up-move':
+                        case 'down-move':
+                        case 'up-level':
+                        case 'down-level':
+                            responseData.data = await this._billsBase(ctx.costStage, data.postType, data.postData);
+                            break;
+                        case 'update':
+                            responseData.data = await this.ctx.service.costStageLedger.updateCalc(ctx.costStage, data.postData);
+                            break;
+                        default:
+                            throw '未知操作';
+                    }
+                } else if (data.target === 'detail') {
+                    responseData.data = await this.ctx.service.costStageDetail.updateDatas(data);
+                } else if (data.target === 'importContract') {
+                    if (!data.types && !data.ids) throw '数据错误';
+                    responseData.data = await this.ctx.service.costStageDetail.importContract(data);
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+        async _bookUpdate(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.target || !data.update) throw '数据错误';
+                const responseData = { err: 0, msg: '', data: {} };
+
+                switch (data.target) {
+                    case 'ledger':
+                        responseData.data = await this.ctx.service.costStageBook.updateDatas(data.update);
+                        break;
+                    case 'detail':
+                        responseData.data = await this.ctx.service.costStageBookDetail.updateDatas(data.update);
+                        break;
+                    default:
+                        throw '未知操作';
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+        async _analysisBillsBase(stage, type, data) {
+            if (isNaN(data.id) || data.id <= 0) throw '数据错误';
+            if (type !== 'add') {
+                if (isNaN(data.count) || data.count <= 0) data.count = 1;
+            }
+            switch (type) {
+                case 'add':
+                    return await this.ctx.service.costStageAnalysis.addAnalysisNode(stage, data.id, data.count);
+                case 'delete':
+                    return await this.ctx.service.costStageAnalysis.delete(stage.id, data.id, data.count);
+                case 'up-move':
+                    return await this.ctx.service.costStageAnalysis.upMoveNode(stage.id, data.id, data.count);
+                case 'down-move':
+                    return await this.ctx.service.costStageAnalysis.downMoveNode(stage.id, data.id, data.count);
+                case 'up-level':
+                    return await this.ctx.service.costStageAnalysis.upLevelNode(stage.id, data.id, data.count);
+                case 'down-level':
+                    return await this.ctx.service.costStageAnalysis.downLevelNode(stage.id, data.id, data.count);
+            }
+        }
+        async _analysisUpdate(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.target) throw '数据错误';
+                const responseData = { err: 0, msg: '', data: {} };
+
+                if (data.target === 'ledger') {
+                    if (!data.postType || !data.postData) throw '数据错误';
+                    switch (data.postType) {
+                        case 'add':
+                        case 'delete':
+                        case 'up-move':
+                        case 'down-move':
+                        case 'up-level':
+                        case 'down-level':
+                            responseData.data = await this._analysisBillsBase(ctx.costStage, data.postType, data.postData);
+                            break;
+                        case 'update':
+                            responseData.data = await this.ctx.service.costStageAnalysis.updateCalc(ctx.costStage, data.postData);
+                            break;
+                        default:
+                            throw '未知操作';
+                    }
+                } else if (data.target === 'detail') {
+                    responseData.data = await this.ctx.service.costStageAnalysisDetail.updateDatas(data.update);
+                } else if (data.target === 'importPay') {
+                    responseData.data = await this.ctx.service.costStageAnalysis.importPay(ctx.costStage, data.pay_order, data.pay_tax);
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+        async stageUpdate(ctx) {
+            const updateFun = `_${ctx.costStage.stage_type}Update`;
+            if (this[updateFun]) {
+                await this[updateFun](ctx);
+            } else {
+                ctx.ajaxErrorBody('未知期数据类型', '保存数据错误');
+            }
+        }
+
+        async uploadStageFile(ctx) {
+            let stream;
+            try {
+                const parts = ctx.multipart({ autoFields: true });
+
+                let index = 0;
+                const create_time = Date.parse(new Date()) / 1000;
+                let stream = await parts();
+                const user = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
+                const rela_id = parts.field.rela_id;
+                const rela_sub_id = parts.field.rela_sub_id || '';
+
+                const uploadfiles = [];
+                while (stream !== undefined) {
+                    if (!stream.filename) throw '未发现上传文件!';
+
+                    const fileInfo = path.parse(stream.filename);
+                    const filepath = `app/public/upload/${ctx.costStage.tid}/costStage/${ctx.moment().format('YYYYMMDD')}/${create_time + '_' + index + fileInfo.ext}`;
+
+                    // 保存文件
+                    await ctx.app.fujianOss.put(ctx.app.config.fujianOssFolder + filepath, stream);
+                    await sendToWormhole(stream);
+
+                    // 插入到stage_pay对应的附件列表中
+                    uploadfiles.push({
+                        rela_id, rela_sub_id,
+                        filename: fileInfo.name,
+                        fileext: fileInfo.ext,
+                        filesize: Array.isArray(parts.field.size) ? parts.field.size[index] : parts.field.size,
+                        filepath,
+                    });
+                    ++index;
+                    if (Array.isArray(parts.field.size) && index < parts.field.size.length) {
+                        stream = await parts();
+                    } else {
+                        stream = undefined;
+                    }
+                }
+
+                const result = await ctx.service.costStageFile.addFiles(ctx.costStage, uploadfiles, user);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (error) {
+                ctx.log(error);
+                // 失败需要消耗掉stream 以防卡死
+                if (stream) await sendToWormhole(stream);
+                ctx.body = this.ajaxErrorBody(error, '上传附件失败,请重试');
+            }
+        }
+        async deleteStageFile(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data && !data.id) throw '缺少参数';
+                const result = await ctx.service.costStageFile.delFiles(data.id);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (error) {
+                ctx.log(error);
+                ctx.ajaxErrorBody(error, '删除附件失败');
+            }
+        }
+
+        async tag(ctx) {
+            try {
+                const isRelaUser = ctx.costStage.userIds.indexOf(this.ctx.session.sessionUser.accountId) >= 0;
+                const isValidTourist = ctx.permission.cost.view && ctx.tender.touristPermission.tag;
+                if (!isRelaUser && !isValidTourist) throw '您无权进行该操作';
+                const data = JSON.parse(ctx.request.body.data);
+                const result = await ctx.service.costStageTag.update(data);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '书签数据错误');
+            }
+        }
+
+        /**
+         * 添加审批人
+         * @param ctx
+         * @return {Promise<void>}
+         */
+        async addStageAudit(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const id = this.app._.toInteger(data.auditorId);
+                if (isNaN(id) || id <= 0) throw '参数错误';
+
+                // 检查权限等
+                if (ctx.costStage.create_user_id !== ctx.session.sessionUser.accountId) throw '您无权添加审核人';
+                if (ctx.costStage.audit_status !== audit.common.status.uncheck && ctx.costStage.audit_status !== audit.common.status.checkNo) {
+                    throw '当前不允许添加审核人';
+                }
+
+                // 检查审核人是否已存在
+                const exist = await ctx.service.costStageAudit.getDataByCondition({ stage_id: ctx.costStage.id, audit_times: ctx.costStage.audit_times, audit_id: id });
+                if (exist) throw '该审核人已存在,请勿重复添加';
+
+                const auditorInfo = await this.ctx.service.projectAccount.getDataById(id);
+                if (!auditorInfo) throw '添加的审批人不存在';
+
+                const shenpiInfo = await ctx.service.shenpiAudit.getDataByCondition({ tid: ctx.tender.id, sp_type: shenpiConst.sp_type.safe_payment, sp_status: shenpiConst.sp_status.gdzs });
+                const is_gdzs = shenpiInfo && ctx.tender.info.shenpi.safe_payment === shenpiConst.sp_status.gdzs ? 1 : 0;
+                const result = await ctx.service.costStageAudit.addAuditor(ctx.costStage.id, auditorInfo, ctx.costStage.audit_times, is_gdzs);
+                if (!result) throw '添加审核人失败';
+
+                const auditors = await ctx.service.costStageAudit.getAuditorGroup(ctx.costStage.id, ctx.costStage.audit_times);
+                ctx.body = { err: 0, msg: '', data: auditors };
+            } catch (err) {
+                ctx.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+        /**
+         * 移除审批人
+         * @param ctx
+         * @return {Promise<void>}
+         */
+        async deleteStageAudit(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const id = data.auditorId instanceof Number ? data.auditorId : this.app._.toNumber(data.auditorId);
+                if (isNaN(id) || id <= 0) throw '参数错误';
+
+                const result = await ctx.service.costStageAudit.deleteAuditor(ctx.costStage.id, id, ctx.costStage.audit_times);
+                if (!result) throw '移除审核人失败';
+
+                const auditors = await ctx.service.costStageAudit.getAuditors(ctx.costStage.id, ctx.costStage.audit_times);
+                ctx.body = { err: 0, msg: '', data: auditors };
+            } catch (err) {
+                ctx.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+        async stageAuditStart(ctx) {
+            try {
+                if (ctx.costStage.create_user_id !== ctx.session.sessionUser.accountId) throw '您无权上报该期数据';
+                if (ctx.costStage.revising) throw '台账修订中,不可上报';
+                if (ctx.costStage.audit_status !== audit.common.status.uncheck && ctx.costStage.audit_status !== audit.common.status.checkNo) throw '该期数据当前无法上报';
+
+                await ctx.service.costStageAudit.start(ctx.costStage);
+                ctx.redirect(ctx.request.header.referer);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '上报失败');
+                ctx.redirect(`/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/${ctx.costStage.stage_type}/${ctx.costStage.stage_order}/stage`);
+            }
+        }
+        async stageAuditCheck(ctx) {
+            try {
+                if (!ctx.costStage || (ctx.costStage.audit_status !== audit.common.status.checking && ctx.costStage.audit_status !== audit.common.status.checkNoPre)) {
+                    throw '当前期数据有误';
+                }
+                if (ctx.costStage.curAuditorIds.indexOf(ctx.session.sessionUser.accountId) < 0) {
+                    throw '您无权进行该操作';
+                }
+                if (ctx.costStage.revising) throw '台账修订中,不可审批';
+
+                const checkType = parseInt(ctx.request.body.checkType);
+                const opinion = ctx.request.body.opinion.replace(/\r\n/g, '<br/>').replace(/\n/g, '<br/>').replace(/\s/g, ' ');
+                await ctx.service.costStageAudit.check(ctx.costStage, checkType, opinion);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '审批失败');
+            }
+            ctx.redirect(ctx.request.header.referer);
+        }
+        async stageAuditCheckAgain(ctx) {
+            try {
+                if (!ctx.costStage.isLatest) throw '非最新一期,不可重新审批';
+                if (ctx.costStage.audit_status !== audit.common.status.checked) throw '未审批完成,不可重新审批';
+                if (ctx.costStage.revising) throw '台账修订中,不可重审';
+
+                if (ctx.session.sessionUser.loginStatus === 0) {
+                    const user = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
+                    if (!user.auth_mobile) throw '未绑定手机号';
+
+                    const code = ctx.request.body.code;
+                    const cacheKey = 'smsCode:' + ctx.session.sessionUser.accountId;
+                    const cacheCode = await app.redis.get(cacheKey);
+                    if (cacheCode === null || code === undefined || cacheCode !== (code + pa.auth_mobile)) {
+                        throw '验证码不正确!';
+                    }
+                }
+
+                const adminCheckAgain = ctx.request.body.confirm === '确认设置终审审批' && ctx.session.sessionUser.is_admin;
+                if (ctx.costStage.finalAuditorIds.indexOf(ctx.session.sessionUser.accountId) < 0 && !adminCheckAgain) throw '您无权重新审批';
+
+                await ctx.service.costStageAudit.checkAgain(ctx.costStage, adminCheckAgain);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '重新审批失败');
+            }
+            ctx.redirect(ctx.request.header.referer);
+        }
+        async stageAuditCheckCancel(ctx) {
+            try {
+                if (ctx.costStage.revising) throw '台账修订中,不可撤回';
+                if (!ctx.costStage.cancancel) throw '您无权进行该操作';
+
+                await ctx.service.costStageAudit.checkCancel(ctx.costStage);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '撤回失败');
+            }
+            ctx.redirect(ctx.request.header.referer);
+        }
+        /**
+         * 期审批流程(POST)
+         * @param ctx
+         * @return {Promise<void>}
+         */
+        async loadAuditors(ctx) {
+            try {
+                const stageOrder = JSON.parse(ctx.request.body.data).order;
+                const stageType = ctx.params.stype;
+                const stage = await ctx.service.costStage.getStageByOrder(ctx.tender.id, stageType, stageOrder);
+                await ctx.service.costStage.loadUser(stage);
+                await ctx.service.costStage.loadAuditViewData(stage);
+                ctx.body = { err: 0, msg: '', data: stage };
+            } catch (error) {
+                ctx.log(error);
+                ctx.body = { err: 1, msg: error.toString(), data: null };
+            }
+        }
+    }
+
+    return CostController;
+};

+ 43 - 0
app/controller/dashboard_controller.js

@@ -44,6 +44,9 @@ module.exports = app => {
             const allAuditSafeInspections = await ctx.service.safeInspectionAudit.getAuditInspection(ctx.session.sessionUser.accountId);
             const allAuditSafeStage = await ctx.service.safeStageAudit.getAuditStage(ctx.session.sessionUser.accountId);
             const allAuditPhasePay = await ctx.service.phasePayAudit.getAuditStage(ctx.session.sessionUser.accountId);
+            const allAuditCostStageLedger = await ctx.service.costStageAudit.getAuditStage('ledger', ctx.session.sessionUser.accountId);
+            const allAuditCostStageBook = await ctx.service.costStageAudit.getAuditStage('book', ctx.session.sessionUser.accountId);
+            const allAuditCostStageAnalysis = await ctx.service.costStageAudit.getAuditStage('analysis', ctx.session.sessionUser.accountId);
             const pa = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
             const auditShenpiTenders = await ctx.service.ledgerAudit.getDonesByAudit(ctx.session.sessionUser.accountId);
             const auditShenpiStages = await ctx.service.stageAudit.getDonesByAudit(ctx.session.sessionUser.accountId);
@@ -60,6 +63,9 @@ module.exports = app => {
             const auditShenpiSafeInspection = await ctx.service.safeInspectionAudit.getDonesByAudit(ctx.session.sessionUser.accountId);
             const auditShenpiSafeStage = await ctx.service.safeStageAudit.getDonesByAudit(ctx.session.sessionUser.accountId);
             const auditShenpiPhasePay = await ctx.service.phasePayAudit.getDonesByAudit(ctx.session.sessionUser.accountId);
+            const auditShenpiCostStageLedger = await ctx.service.costStageAudit.getDonesByAudit('ledger', ctx.session.sessionUser.accountId);
+            const auditShenpiCostStageBook = await ctx.service.costStageAudit.getDonesByAudit('book', ctx.session.sessionUser.accountId);
+            const auditShenpiCostStageAnalysis = await ctx.service.costStageAudit.getDonesByAudit('analysis', ctx.session.sessionUser.accountId);
             const dashboardStatus = {
                 all: 0,
                 dashboard: 0,
@@ -81,6 +87,9 @@ module.exports = app => {
                     safeInspection: 0,
                     safeStage: 0,
                     phasePay: 0,
+                    costStageLedger: 0,
+                    costStageBook: 0,
+                    costStageAnalysis: 0,
                 },
             };
             let noticeList = [];
@@ -118,6 +127,12 @@ module.exports = app => {
                 noticeList.push(...ctx.helper.addKeyValue4ObjArray(spSafeStage, 'shenpi_type', 'safeStage'));
                 const spPhasePay = subProject.page_show.phasePay ? ctx.helper._.filter(auditShenpiPhasePay, { spid: subProject.id }) : [];
                 noticeList.push(...ctx.helper.addKeyValue4ObjArray(spPhasePay, 'shenpi_type', 'phasePay'));
+                const spCostStageLedger = subProject.page_show.cost ? ctx.helper._.filter(auditShenpiCostStageLedger, { spid: subProject.id }) : [];
+                noticeList.push(...ctx.helper.addKeyValue4ObjArray(spCostStageLedger, 'shenpi_type', 'costStageLedger'));
+                const spCostStageBook = subProject.page_show.cost ? ctx.helper._.filter(auditShenpiCostStageBook, { spid: subProject.id }) : [];
+                noticeList.push(...ctx.helper.addKeyValue4ObjArray(spCostStageBook, 'shenpi_type', 'costStageBook'));
+                const spCostStageAnalysis = subProject.page_show.cost ? ctx.helper._.filter(auditShenpiCostStageAnalysis, { spid: subProject.id }) : [];
+                noticeList.push(...ctx.helper.addKeyValue4ObjArray(spCostStageAnalysis, 'shenpi_type', 'costStageAnalysis'));
             }
             const noticeDayList = [];
             noticeList = ctx.helper._.orderBy(noticeList, ['shenpi_time'], ['desc']);
@@ -159,6 +174,12 @@ module.exports = app => {
             dashboardShenpis.push(...auditSafeStage);
             const auditPhasePay = await this.auditSet(ctx, allAuditPhasePay, subProjects, dashboardStatus, 'phasePay');
             dashboardShenpis.push(...auditPhasePay);
+            const auditCostStageLedger = await this.auditSet(ctx, allAuditCostStageLedger, subProjects, dashboardStatus, 'costStageLedger');
+            dashboardShenpis.push(...auditCostStageLedger);
+            const auditCostStageBook = await this.auditSet(ctx, allAuditCostStageBook, subProjects, dashboardStatus, 'costStageBook');
+            dashboardShenpis.push(...auditCostStageBook);
+            const auditCostStageAnalysis = await this.auditSet(ctx, allAuditCostStageAnalysis, subProjects, dashboardStatus, 'costStageAnalysis');
+            dashboardShenpis.push(...auditCostStageAnalysis);
             // console.log(ctx.helper._.orderBy(dashboardShenpis, ['start_audit', 'shenpi_time'], ['desc', 'desc']));
             const projectData = await ctx.service.project.getDataById(ctx.session.sessionProject.id);
             // 获取销售人员数据
@@ -192,6 +213,7 @@ module.exports = app => {
                 acInspection: auditConst.inspection,
                 acSafeStage: auditConst.safeStage,
                 acPhasePay: auditConst.phasePay,
+                acCostStage: auditConst.costStage,
                 noticeList,
                 noticeDayList,
                 pushType: auditConst.pushType,
@@ -310,6 +332,12 @@ module.exports = app => {
                             if (!sp.page_show.phasePay) closeType = true;
                             calcTime = t.create_time;
                             break;
+                        case 'costStageLedger':
+                        case 'costStageBook':
+                        case 'costStageAnalysis':
+                            if (!sp.page_show.cost) closeType = true;
+                            calcTime = t.create_time;
+                            break;
                         default:
                             closeType = true;
                     }
@@ -359,6 +387,9 @@ module.exports = app => {
             const auditSafeInspections = ctx.subProject.page_show.safeInspection ? await ctx.service.safeInspectionAudit.getAuditInspection(ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
             const auditSafeStage = ctx.subProject.page_show.safePayment ? await ctx.service.safeStageAudit.getAuditStage(ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
             const auditPhasePay = ctx.subProject.page_show.phasePay ? await ctx.service.phasePayAudit.getAuditStage(ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
+            const auditCostStageLedger = ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getAuditStage('ledger', ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
+            const auditCostStageBook = ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getAuditStage('book', ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
+            const auditCostStageAnalysis = ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getAuditStage('analysis', ctx.session.sessionUser.accountId, ctx.subProject.id) : [];
             const pa = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
             const noticeList = await ctx.service.noticePush.getNotice(ctx.session.sessionProject.id, pa.id, ctx.subProject.id);
             const projectData = await ctx.service.project.getDataById(ctx.session.sessionProject.id);
@@ -392,6 +423,11 @@ module.exports = app => {
             if (ctx.subProject.page_show.safeInspection) shenpi_count.push({ count: await ctx.service.safeInspectionAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '安全巡检' });
             if (ctx.subProject.page_show.safePayment) shenpi_count.push({ count: await ctx.service.safeStageAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '安全计量' });
             if (ctx.subProject.page_show.phasePay) shenpi_count.push({ count: await ctx.service.phasePayAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '合同支付' });
+            if (ctx.subProject.page_show.cost) {
+                shenpi_count.push({ count: await ctx.service.costStageAudit.getCountByChecked('ledger', ctx.session.sessionUser.accountId, ctx.subProject.id), name: '成本报审' });
+                shenpi_count.push({ count: await ctx.service.costStageAudit.getCountByChecked('book', ctx.session.sessionUser.accountId, ctx.subProject.id), name: '财务账面' });
+                shenpi_count.push({ count: await ctx.service.costStageAudit.getCountByChecked('analysis', ctx.session.sessionUser.accountId, ctx.subProject.id), name: '成本分析' });
+            }
             // shenpi_count.push({ count: await ctx.service.advanceAudit.getCountByChecked(ctx.session.sessionUser.accountId), name: '预付款' });
             const total_count = ctx.app._.sumBy(shenpi_count, 'count');
             const shenpi_lastime = [
@@ -409,6 +445,9 @@ module.exports = app => {
                 ctx.subProject.page_show.safeInspection ? await ctx.service.safeInspectionAudit.getLastEndTimeByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
                 ctx.subProject.page_show.safeInspection ? await ctx.service.safeStageAudit.getLastEndTimeByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
                 ctx.subProject.page_show.phasePay ? await ctx.service.phasePayAudit.getLastEndTimeByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
+                ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getLastEndTimeByChecked('ledger', ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
+                ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getLastEndTimeByChecked('book', ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
+                ctx.subProject.page_show.cost ? await ctx.service.costStageAudit.getLastEndTimeByChecked('analysis', ctx.session.sessionUser.accountId, ctx.subProject.id) : null,
             ];
             const last_time = ctx.app._.max(shenpi_lastime);
             // console.log(ctx.app._.max(shenpi_lastime), ctx.helper.calcDayNum(last_time));
@@ -429,6 +468,9 @@ module.exports = app => {
                 auditSafeInspections,
                 auditSafeStage,
                 auditPhasePay,
+                auditCostStageLedger,
+                auditCostStageBook,
+                auditCostStageAnalysis,
                 shenpi_count,
                 total_count,
                 last_day: ctx.helper.calcDayNum(last_time),
@@ -447,6 +489,7 @@ module.exports = app => {
                 acInspection: auditConst.inspection,
                 acSafeStage: auditConst.safeStage,
                 acPhasePay: auditConst.phasePay,
+                acCostStage: auditConst.costStage,
                 noticeList,
                 pushType: auditConst.pushType,
                 projectData,

+ 2 - 0
app/controller/ledger_controller.js

@@ -127,6 +127,7 @@ module.exports = app => {
                 tender.data.hasRevise = !!revise;
                 const stage = await this.ctx.service.stage.getDataByCondition({ tid: tender.id });
                 tender.data.hasStage = !!stage;
+                const templateFolder = await ctx.service.calcTmplFolder.getData(ctx.session.sessionProject.id + '-' + 'posCalc');
                 const posCalcTemplate = await this.ctx.service.calcTmpl.getAllTemplateDetail(ctx.session.sessionProject.id, 'posCalc');
                 tender.data.isAssUser = tender.isAssUser;
                 tender.data.assLedger = tender.assLedger;
@@ -156,6 +157,7 @@ module.exports = app => {
                     nodeType: stdConst.nodeType,
                     authMobile: pa.auth_mobile,
                     deleteFilePermission: PermissionCheck.delFile(this.ctx.session.sessionUser.permission),
+                    templateFolder,
                     posCalcTemplate,
                 };
                 if ((tender.data.ledger_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) ||

+ 15 - 0
app/controller/sub_proj_controller.js

@@ -537,6 +537,21 @@ module.exports = app => {
             }
         }
 
+        async templateSet(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const updateData = { id: ctx.subProject.id };
+                if (data.cost_ledger_template !== undefined) updateData.cost_ledger_template = data.cost_ledger_template;
+                if (data.cost_analysis_template !== undefined) updateData.cost_analysis_template = data.cost_analysis_template;
+                if (data.cost_calc_template !== undefined) updateData.cost_calc_template = data.cost_calc_template;
+                await ctx.service.subProject.defaultUpdate(updateData);
+                ctx.body = { err: 0, msg: '', data: null };
+            } catch(err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存模板设置失败');
+            }
+        }
+
         async listInfo(ctx) {
             try {
                 const renderData = {

+ 39 - 0
app/controller/template_controller.js

@@ -61,6 +61,7 @@ module.exports = app => {
                     validColInfo: ctx.service.calcTmpl.TemplateRela.posCalc.ValidColInfo,
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.template.posCalc),
                 };
+                renderData.folderList = await ctx.service.calcTmplFolder.getData(ctx.session.sessionProject.id + '-' + 'posCalc');
                 renderData.templateList = await ctx.service.calcTmpl.getAllTemplate(ctx.session.sessionProject.id, 'posCalc');
                 await ctx.service.calcTmpl.checkTemplateUsed(renderData.templateList, 'posCalc');
                 const specList = await ctx.service.stdExtraList.getList(0);
@@ -88,6 +89,44 @@ module.exports = app => {
             }
         }
 
+        async saveFolder(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const responseData = { err: 0, msg: '', data: {} };
+                if (!data.postType || !data.postData || !data.target) throw '数据错误';
+                const masterId = `${this.ctx.session.sessionProject.id}-${data.target}`;
+                switch (data.postType) {
+                    case 'add':
+                        responseData.data = await this.ctx.service.calcTmplFolder.addNode(masterId, data.postData.id);
+                        break;
+                    case 'delete':
+                        responseData.data = await this.ctx.service.calcTmplFolder.delete(masterId, data.postData.id, data.postData.count);
+                        break;
+                    case 'up-move':
+                        responseData.data = await this.ctx.service.calcTmplFolder.upMoveNode(masterId, data.postData.id, data.postData.count);
+                        break;
+                    case 'down-move':
+                        responseData.data = await this.ctx.service.calcTmplFolder.downMoveNode(masterId, data.postData.id, data.postData.count);
+                        break;
+                    case 'up-level':
+                        responseData.data = await this.ctx.service.calcTmplFolder.upLevelNode(masterId, data.postData.id, data.postData.count);
+                        break;
+                    case 'down-level':
+                        responseData.data = await this.ctx.service.calcTmplFolder.downLevelNode(masterId, data.postData.id, data.postData.count);
+                        break;
+                    case 'update':
+                        responseData.data = await this.ctx.service.calcTmplFolder.updateInfos(masterId, data.postData);
+                        break;
+                    default:
+                        throw '未知操作';
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+
         //  ------------ 以下方法为所有模板共用 --------------
         async load(ctx) {
             try {

+ 76 - 53
app/controller/tender_controller.js

@@ -998,65 +998,88 @@ module.exports = app => {
             ctx.body = responseData;
         }
 
+        _getSpecShenpiUrl(ctx) {
+            if (ctx.url === `/sp/${ctx.subProject.id}/cost/tender/${ctx.tender.id}/flow`) return 'cost';
+        }
         async shenpiSet(ctx) {
-            if (ctx.session.sessionUser.is_admin === 0) {
-                ctx.request.headers.referer ? ctx.redirect(ctx.request.headers.referer) : ctx.redirect(`/sp/${ctx.subProject.id}/list`);
-            }
-            // 获取所有项目参与者
-            const accountList = await ctx.service.projectAccount.getAllSubProjectAccount(ctx.subProject);
-            const unitList = await ctx.service.constructionUnit.getAllDataByCondition({ where: { pid: ctx.session.sessionProject.id } });
-            const accountGroupList = unitList.map(item => {
-                const groupList = accountList.filter(item1 => item1.company === item.name);
-                return { groupName: item.name, groupList };
-            }).filter(x => { return x.groupList.length > 0; });
-            // 获取固定审批流 or 固定终审
-            const spConst = ctx.helper._.cloneDeep(shenpiConst);
-            for (const sp of spConst.sp_lc) {
-                sp.status = ctx.tender.info.shenpi ? ctx.tender.info.shenpi[sp.code] : spConst.sp_status.sqspr;
-                if (sp.status === shenpiConst.sp_status.gdspl) {
-                    sp.groupList = await ctx.service.shenpiGroup.getGroupList(ctx.tender.id, sp.type) || [];
-                    if (sp.groupList && sp.groupList.length > 0) {
-                        for (const group of sp.groupList) {
-                            if (group.change_type) group.change_type = JSON.parse(group.change_type);
-                            group.auditGroupList = await ctx.service.shenpiAudit.getAuditGroupList(ctx.tender.id, sp.type, sp.status, group.id);
-                            if (group.is_select) sp.auditGroupList = group.auditGroupList;
+            try {
+                const specShenpiType = this._getSpecShenpiUrl(ctx);
+                // 获取所有项目参与者
+                const accountList = await ctx.service.projectAccount.getAllSubProjectAccount(ctx.subProject);
+                const unitList = await ctx.service.constructionUnit.getAllDataByCondition({ where: { pid: ctx.session.sessionProject.id } });
+                const accountGroupList = unitList.map(item => {
+                    const groupList = accountList.filter(item1 => item1.company === item.name);
+                    return { groupName: item.name, groupList };
+                }).filter(x => { return x.groupList.length > 0; });
+                /**
+                 * 如果是期审批流程配置,但是不跟期内审批流程使用同一页面,需:
+                 * 1. 在 this._getSpecShenpiUrl 内增加判断审批页面的类型,例如cost
+                 * 2. 在 const/shenpi.js 内定义对应的cost_sp_lc和cost_sp_type,
+                 * 3. 在 validSubMenu 内定义右侧菜单的ejs文件位置,文件位置基于view/tender路径,需提供相对路径
+                 */
+                const validSubMenu = {
+                    cost: { common: '../cost/list_menu.ejs', mini: '../cost/list_mini_menu.ejs'},
+                    default: { common: './tender_sub_menu.ejs', mini: './tender_sub_mini_menu.ejs'}
+                };
+                const subMenu = specShenpiType ? validSubMenu[specShenpiType] : validSubMenu.default;
+                // 获取固定审批流 or 固定终审
+                const spConst = ctx.helper._.cloneDeep(shenpiConst);
+                if (specShenpiType) {
+                    spConst.sp_lc = spConst[specShenpiType + '_sp_lc'];
+                    spConst.sp_type = spConst[specShenpiType + '_sp_type'];
+                }
+                for (const sp of spConst.sp_lc) {
+                    sp.status = ctx.tender.info.shenpi ? ctx.tender.info.shenpi[sp.code] : spConst.sp_status.sqspr;
+                    if (sp.status === shenpiConst.sp_status.gdspl) {
+                        sp.groupList = await ctx.service.shenpiGroup.getGroupList(ctx.tender.id, sp.type) || [];
+                        if (sp.groupList && sp.groupList.length > 0) {
+                            for (const group of sp.groupList) {
+                                if (group.change_type) group.change_type = JSON.parse(group.change_type);
+                                group.auditGroupList = await ctx.service.shenpiAudit.getAuditGroupList(ctx.tender.id, sp.type, sp.status, group.id);
+                                if (group.is_select) sp.auditGroupList = group.auditGroupList;
+                            }
+                        } else {
+                            sp.auditGroupList = await ctx.service.shenpiAudit.getAuditGroupList(ctx.tender.id, sp.type, sp.status);
                         }
-                    } else {
-                        sp.auditGroupList = await ctx.service.shenpiAudit.getAuditGroupList(ctx.tender.id, sp.type, sp.status);
+                    } else if (sp.status === shenpiConst.sp_status.gdzs) {
+                        sp.audit = await ctx.service.shenpiAudit.getAudit(ctx.tender.id, sp.type, sp.status);
                     }
-                } else if (sp.status === shenpiConst.sp_status.gdzs) {
-                    sp.audit = await ctx.service.shenpiAudit.getAudit(ctx.tender.id, sp.type, sp.status);
                 }
-            }
-            const tenders = await ctx.service.tender.getList('', null, 1);
-            const removeTenders = await ctx.service.shenpiAudit.getRemoveTenders(tenders);
-            if (removeTenders.length > 0) {
-                ctx.helper._.remove(tenders, function(n) {
-                    return removeTenders.indexOf(n.id) !== -1;
-                });
-            }
-            const categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
-            // 是否修订中
-            const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id);
-            const revising = (lastRevise && lastRevise.status !== auditConst.revise.status.checked) || false;
+                const tenders = await ctx.service.tender.getList('', null, 1);
+                const removeTenders = await ctx.service.shenpiAudit.getRemoveTenders(tenders);
+                if (removeTenders.length > 0) {
+                    ctx.helper._.remove(tenders, function(n) {
+                        return removeTenders.indexOf(n.id) !== -1;
+                    });
+                }
+                const categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
+                // 是否修订中
+                const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id);
+                const revising = (lastRevise && lastRevise.status !== auditConst.revise.status.checked) || false;
 
-            const cooperationNum = await ctx.service.ledgerCooperation.count({ tid: ctx.tender.id, status: 1 });
+                const cooperationNum = await ctx.service.ledgerCooperation.count({ tid: ctx.tender.id, status: 1 });
+
+                const renderData = {
+                    subMenu,
+                    shenpi: spConst,
+                    accountList,
+                    accountGroup: accountGroupList,
+                    tenders,
+                    categoryData,
+                    auditConst,
+                    revising,
+                    measureType,
+                    cooperationNum,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.tender.shenpi),
+                    auditType: auditConst.auditType,
+                    change_type_list: shenpiConst.change_type_list,
+                };
+                await this.layout('tender/shenpi.ejs', renderData, 'tender/shenpi_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+                ctx.redirect(ctx.request.header.referer || `/sp/${ctx.subProject.id}/list`);
+            }
 
-            const renderData = {
-                shenpi: spConst,
-                accountList,
-                accountGroup: accountGroupList,
-                tenders,
-                categoryData,
-                auditConst,
-                revising,
-                measureType,
-                cooperationNum,
-                jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.tender.shenpi),
-                auditType: auditConst.auditType,
-                change_type_list: shenpiConst.change_type_list,
-            };
-            await this.layout('tender/shenpi.ejs', renderData, 'tender/shenpi_modal.ejs');
         }
 
         async saveTenderInfoShenpi(ctx) {

+ 62 - 0
app/middleware/cost_stage_check.js

@@ -0,0 +1,62 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+module.exports = options => {
+    /**
+     * 期校验 中间件
+     * 1. 读取期数据
+     * 2. 检验用户是否参与期(不校验具体权限)
+     *
+     * 写入ctx.costStage数据
+     * 其中:
+     * costStage.user: 创建人
+     * costStage.auditors: 审批人列表(退回原报时,加载上一流程)
+     * costStage.curAuditors: 当前审批人(未上报为空,审批通过 or 退回原报时,为空)
+     *
+     * costStage.readOnly: 登录人,是否可操作
+     * costStage.curTimes: 当前登录人,操作、查阅数据times
+     * costStage.curOrder: 当前登录人,操作、查阅数据order
+     *
+     * 该方法为通用方法,如需costStage其他数据,请在controller中查询
+     *
+     * @param {function} next - 中间件继续执行的方法
+     * @return {void}
+     */
+    return function* costStageCheck(next) {
+        try {
+            // 读取标段数据
+            const stageOrder = parseInt(this.params.sorder);
+            if (stageOrder <= 0) throw '您访问的期不存在';
+
+            const stageType = this.params.stype;
+            const costStage = yield this.service.costStage.getStageByOrder(this.tender.id, stageType, stageOrder);
+            if (!costStage) throw '期数据错误';
+
+            // 读取原报、审核人数据
+            yield this.service.costStage.doCheckStage(costStage);
+            costStage.latestOrder = yield this.service.costStage.count({ tid: this.tender.id, stage_type: stageType });
+            costStage.isLatest = costStage.latestOrder === costStage.stage_order;
+            if (costStage.rela_stage && costStage.rela_stage.sid)
+                costStage.relaStage = yield this.service.costStage.getStage(costStage.rela_stage.sid);
+            if (costStage.calc_template) costStage.calcTemplate = yield this.service.calcTmpl.getTemplate(costStage.calc_template);
+            yield this.service.costStage.checkShenpi(costStage);
+            this.costStage = costStage;
+            yield next;
+        } catch (err) {
+            this.log(err);
+            if (this.helper.isAjax(this.request)) {
+                this.ajaxErrorBody(err, '读取期数据错误');
+            } else {
+                this.postError(err, '读取期数据错误');
+                this.redirect(this.request.headers.referer);
+            }
+        }
+    };
+};

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

@@ -1949,6 +1949,24 @@ overflow-y: auto;
 .bg-new-phasepay{
     background: rgba(200, 51, 6, 0.08) !important;
 }
+.bg-new-coststage{
+    background: rgba(0, 14, 126, 0.08) !important;
+}
+.bg-new-19{
+    background: rgba(23, 144, 180, 0.08) !important;
+}
+.bg-new-20{
+    background: rgba(120, 98, 83, 0.08) !important;
+}
+.bg-new-21{
+    background: rgba(196, 90, 101, 0.08) !important;
+}
+.bg-new-22{
+    background: rgba(211, 162, 55, 0.08) !important;
+}
+.bg-new-23{
+    background: rgba(46, 32, 29, 0.08) !important;
+}
 .text-new-advance{
   color: rgba(241, 82, 91, 1) !important;
 }
@@ -1988,6 +2006,30 @@ overflow-y: auto;
 .text-new-phasepay{
     color: rgba(200, 51, 6, 1) !important;
 }
+.text-new-coststage{
+    color: rgba(0, 14, 126, 1) !important;
+}
+/* 以下为待使用颜色, 使用时请修改名称 */
+.text-new-19{
+    /* 群青 */
+    color: rgba(23, 144, 180, 1) !important;
+}
+.text-new-20{
+    /* 沉香 */
+    color: rgba(120, 98, 83, 1) !important;
+}
+.text-new-21{
+    /* 梅红 */
+    color: rgba(196, 90, 101, 1) !important;
+}
+.text-new-22{
+    /* 鞠衣 */
+    color: rgba(211, 162, 55, 1) !important;
+}
+.text-new-23{
+    /* 玄天 */
+    color: rgba(46, 32, 29, 1) !important;
+}
 .text-width{
   width: 66px;
   text-align: center;

+ 163 - 0
app/public/js/cost_stage.js

@@ -0,0 +1,163 @@
+'use strict';
+
+$(document).ready(() => {
+    $('#audit-list').on('click', 'a', function() {
+        const type = $(this).data('target')
+        const auditCard = $(this).parent().parent()
+        if (type === 'show') {
+            $(this).data('target', 'hide')
+            auditCard.find('.fold-card').slideDown('swing', () => {
+                auditCard.find('#end-target').text($(this).data('idx') + '#')
+                auditCard.find('#fold-btn').text('收起历史审核记录')
+            })
+        } else {
+            $(this).data('target', 'show')
+            auditCard.find('.fold-card').slideUp('swing', () => {
+                auditCard.find('#end-target').text('1#')
+                auditCard.find('#fold-btn').text('展开历史审核记录')
+            })
+        }
+    });
+
+    const getGroupAuditHtml = function (group) {
+        return group.map(u => { return `<small class="d-inline-block text-dark mx-1" title="${u.role}" data-auditorId="${u.aid}">${u.name}</small>`; }).join('');
+    };
+    const getAuditTypeHtml = function (type) {
+        if (type === auditType.key.common) return '';
+        return `<div class="li-subscript"><span class="badge badge-pill badge-${auditType.info[type].class} p-1 badge-bg-small"><small>${auditType.info[type].short}</small></span></div>`;
+    };
+    const getAuditTypeText = function (type) {
+        if (type === auditType.key.common) return '';
+        return `<span class="text-${auditType.info[type].class}">${auditType.info[type].long}</span>`;
+    };
+    const getAuditorsHtml = function (auditors) {
+        const auditorsHTML = [];
+        auditors.forEach((group, idx) => {
+            if (idx === 0) {
+                auditorsHTML.push(`<li class="list-group-item d-flex justify-content-between align-items-center">
+                    <span class="mr-1"><i class="fa fa fa-play-circle fa-rotate-90"></i></span>
+                <span class="text-muted">${getGroupAuditHtml(group)}</span>
+                <span class="badge badge-light badge-pill ml-auto"><small>原报</small></span>
+                </li>`);
+            } else if(idx === auditors.length -1 && idx !== 0) {
+                auditorsHTML.push(`<li class="list-group-item d-flex justify-content-between align-items-center">
+                    <span class="mr-1"><i class="fa fa fa-stop-circle fa-rotate-90"></i></span>
+                <span class="text-muted">${getGroupAuditHtml(group)}</span>
+                <div class="d-flex ml-auto">
+                ${getAuditTypeHtml(group[0].audit_type)}
+                <span class="badge badge-light badge-pill ml-auto"><small>终审</small></span>
+                </div>
+                </li>`);
+            } else {
+                auditorsHTML.push(`<li class="list-group-item d-flex justify-content-between align-items-center">
+                    <span class="mr-1"><i class="fa fa fa-chevron-circle-down"></i></span>
+                <span class="text-muted">${getGroupAuditHtml(group)}</span>
+                <div class="d-flex ml-auto">
+                ${getAuditTypeHtml(group[0].audit_type)}
+                <span class="badge badge-light badge-pill"><small>${transFormToChinese(idx)}审</small></span>
+                </div>
+                </li>`);
+            }
+        });
+        return auditorsHTML;
+    };
+    const getAuditHistroyHtml = function (auditHistory) {
+        const historyHTML = [];
+        auditHistory.forEach((his, idx) => {
+            if (idx === auditHistory.length - 1 && auditHistory.length !== 1) {
+                historyHTML.push(`<div class="text-right"><a href="javascript: void(0);" id="fold-btn" data-target="show">展开历史审批流程</a></div>`);
+            }
+            historyHTML.push(`<div class="${idx < auditHistory.length - 1 ? 'fold-card' : ''}">`);
+            historyHTML.push(`<div class="text-center text-muted">${idx+1}#</div>`);
+            historyHTML.push(`<ul class="timeline-list list-unstyled mt-2 ${ idx === auditHistory.length - 1 && auditHistory.length !== 1 ? 'last-auditor-list' : '' }">`);
+            his.forEach((group) => {
+                historyHTML.push(`<li class="timeline-list-item pb-2 ${ group.audit_status === auditConst.status.uncheck && idx === auditHistory.length - 1 && auditHistory.length !== 1 ? 'is_uncheck' : ''}">`);
+                if (group.auditYear) {
+                    historyHTML.push(`<div class="timeline-item-date">${group.auditYear}<span>${group.auditDate}</span><span>${group.auditTime}</span></div>`);
+                }
+                if (group.audit_order < his.length - 1) {
+                    historyHTML.push('<div class="timeline-item-tail"></div>');
+                }
+                if (group.audit_status === auditConst.status.checked) {
+                    historyHTML.push('<div class="timeline-item-icon bg-success text-light"><i class="fa fa-check"></i></div>');
+                } else if (group.audit_status === auditConst.status.checkNo || group.audit_status === auditConst.status.checkNoPre || group.status === auditConst.status.checkCancel) {
+                    historyHTML.push('<div class="timeline-item-icon bg-warning text-light"><i class="fa fa-level-up"></i></div>');
+                } else if (group.audit_status === auditConst.status.checking) {
+                    historyHTML.push('<div class="timeline-item-icon bg-warning text-light"><i class="fa fa-ellipsis-h"></i></div>');
+                } else {
+                    historyHTML.push('<div class="timeline-item-icon bg-secondary text-light"></div>');
+                }
+
+                historyHTML.push('<div class="timeline-item-content">');
+                if (group.audit_order > 0) {
+                    const statuStr = group.audit_status !== auditConst.status.uncheck ?
+                        `<span class="pull-right ${auditConst.info[group.audit_status].class}">${auditConst.info[group.audit_status].title}</span>` : '';
+                    historyHTML.push(`<div class="py-1">
+                        <span class="text-black-50">
+                        ${ !group.is_final ? group.audit_order + '' : '终' }审 ${getAuditTypeText(group.audit_type)}
+                        </span>
+                        ${statuStr}
+                    </div>`);
+                } else {
+                    historyHTML.push(` <div class="py-1">
+                        <span class="text-black-50">原报</span>
+                        <span class="pull-right text-success">${idx !== 0 ? '重新' : '' }上报审批</span>
+                    </div>`);
+                }
+                historyHTML.push('<div class="card"><div class="card-body px-3 py-0">');
+                for (const [i, auditor] of group.auditors.entries()) {
+                    historyHTML.push(`<div class="card-text p-2 py-3 row ${ ( i > 0 ? 'border-top' : '') }">`);
+                    let companyRolePart = '';
+                    if (group.audit_order !== 0) {
+                        const rolePart = auditor.role ? ' - ' + auditor.role : '';
+                        companyRolePart = `<span class="text-muted ml-1">${auditor.company}${rolePart}</span>`;
+                    }
+
+                    historyHTML.push(`<div class="col-10"><span class="h6">${auditor.name}</span>${companyRolePart}</div>`);
+                    historyHTML.push('<div class="col">');
+                    if (auditor.audit_status === auditConst.status.checked) {
+                        historyHTML.push('<span class="pull-right text-success"><i class="fa fa-check-circle"></i></span>');
+                    } if (auditor.audit_status === auditConst.status.checkNo || auditor.audit_status === auditConst.status.checkNoPre || auditor.audit_status === auditConst.status.checkCancel) {
+                        historyHTML.push('<span class="pull-right text-warning"><i class="fa fa-share-square fa-rotate-270"></i></span>');
+                    }
+                    historyHTML.push('</div>');
+                    if (group.audit_order > 0 && auditor.opinion) {
+                        historyHTML.push(`<div class="col-12 py-1 bg-light"><i class="fa fa-commenting-o mr-1"></i>${auditor.opinion}</div>`);
+                    }
+                    historyHTML.push('</div>');
+                }
+                historyHTML.push('</div></div>');
+                historyHTML.push('</div>');
+                historyHTML.push('</li>');
+            });
+            historyHTML.push('</div>');
+            historyHTML.push('</ul>');
+        });
+        return historyHTML.join('');
+    };
+    // 获取审批流程
+    $('a[data-target="#sp-list" ]').on('click', function () {
+        postData(window.location.pathname + '/auditors', { order: $(this).attr('stage-order') }, function (result) {
+            $('#auditor-list').html(getAuditorsHtml(result.hisUserGroup));
+            $('#audit-list').html(getAuditHistroyHtml(result.auditHistory));
+        });
+    });
+
+
+    $.subMenu({
+        menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
+        toMenu: '#to-menu', toMiniMenu: '#to-mini-menu',
+        key: 'menu.1.0.0',
+        miniHint: '#sub-mini-hint', hintKey: 'menu.hint.1.0.1',
+        callback: function (info) {
+            if (info.mini) {
+                $('.panel-title').addClass('fluid');
+                $('#sub-menu').removeClass('panel-sidebar');
+            } else {
+                $('.panel-title').removeClass('fluid');
+                $('#sub-menu').addClass('panel-sidebar');
+            }
+            autoFlashHeight();
+        }
+    });
+});

+ 932 - 0
app/public/js/cost_stage_analysis.js

@@ -0,0 +1,932 @@
+function getTenderId() {
+    return window.location.pathname.split('/')[2];
+}
+const invalidFields = {
+    parent: ['cur_qty', 'cur_tp', 'unit_price'],
+};
+function transExpr(expr) {
+    return $.trim(expr).replace('\t', '').replace('=', '').replace('%', '/100');
+}
+
+$(document).ready(function() {
+    autoFlashHeight();
+    class BillsObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#bills-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.treeSetting = {
+                id: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                rootId: -1,
+                calcFields: ['yf_excl_tax_tp', 'in_excl_tax_tp', 'sf_excl_tax_tp', 'num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_h', 'num_i', 'num_g', 'num_k', 'num_l', 'num_m', 'num_o', 'num_p'],
+                keys: ['id', 'stage_id', 'tree_id'],
+                calcFun: function(node) {
+                    node.sf_percent = node.yf_excl_tax_tp ? ZhCalc.mul(ZhCalc.div(node.sf_excl_tax_tp, node.yf_excl_tax_tp), 100 , 2) : 0;
+                }
+            };
+            this.tree = createNewPathTree('ledger', this.treeSetting);
+            this.spreadSetting = bllsSpreadSetting;
+            sjsSettingObj.setFxTreeStyle(this.spreadSetting, sjsSettingObj.FxTreeStyle.phasePay);
+
+            this.ckBillsSpread = window.location.pathname + '-billsSelect';
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            this.spread.bind(spreadNS.Events.topRowChanged, this.topRowChanged);
+            this.spread.bind(spreadNS.Events.ClipboardChanging, function (e, info) {
+                const copyText = SpreadJsObj.getFilterCopyText(info.sheet);
+                SpreadJsObj.Clipboard.setCopyData(copyText);
+            });
+            if (readOnly) return;
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.EditStarting, this.editStarting);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            const self = this;
+            // 增删上下移升降级
+            $('a[name="base-opr"]').click(function () {
+                self.baseOpr(this.getAttribute('type'));
+            });
+        }
+        refreshOperationValid() {
+            const setObjEnable = function (obj, enable) {
+                if (enable) {
+                    obj.removeClass('disabled');
+                } else {
+                    obj.addClass('disabled');
+                }
+            };
+            const invalidAll = function () {
+                setObjEnable($('a[name=base-opr][type=add]'), false);
+                setObjEnable($('a[name=base-opr][type=delete]'), false);
+                setObjEnable($('a[name=base-opr][type=up-move]'), false);
+                setObjEnable($('a[name=base-opr][type=down-move]'), false);
+                setObjEnable($('a[name=base-opr][type=up-level]'), false);
+                setObjEnable($('a[name=base-opr][type=down-level]'), false);
+            };
+            const sel = this.sheet.getSelections()[0];
+            const row = sel ? sel.row : -1;
+            const tree = this.sheet.zh_tree;
+            if (!tree) {
+                invalidAll();
+                return;
+            }
+            const first = tree.nodes[row];
+            if (!first) {
+                invalidAll();
+                return;
+            }
+            let last = first, sameParent = true, nodeUsed = this.checkNodeUsed(tree, first);
+            if (sel.rowCount > 1 && first) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = tree.nodes[sel.row + r];
+                    if (!rNode) {
+                        sameParent = false;
+                        break;
+                    }
+                    nodeUsed = nodeUsed || this.checkNodeUsed(tree, rNode);
+                    if (rNode.tree_level > first.tree_level) continue;
+                    if ((rNode.tree_level < first.tree_level) || (rNode.tree_level === first.tree_level && rNode.tree_pid !== first.tree_pid)) {
+                        sameParent = false;
+                        break;
+                    }
+                    last = rNode;
+                }
+            }
+            const preNode = tree.getPreSiblingNode(first);
+            const valid = !this.sheet.zh_setting.readOnly;
+            const topParent = tree.getTopParent(first);
+            const isFixed = first.tree_level === 1 || [2,3,4].indexOf(topParent.node_type) >= 0;
+
+            setObjEnable($('a[name=base-opr][type=add]'), valid && first && !isFixed);
+            setObjEnable($('a[name=base-opr][type=delete]'), valid && first && sameParent && !isFixed && !first.node_type);
+            setObjEnable($('a[name=base-opr][type=up-move]'), valid && first && sameParent && preNode && !isFixed);
+            setObjEnable($('a[name=base-opr][type=down-move]'), valid && first && sameParent  && !tree.isLastSibling(last) && !isFixed);
+            setObjEnable($('a[name=base-opr][type=up-level]'), valid && first && sameParent && tree.getParent(first) && !nodeUsed && first.tree_level > 2 && !isFixed);
+            setObjEnable($('a[name=base-opr][type=down-level]'), valid && first && sameParent && preNode && !isFixed && !preNode.node_type);
+        }
+        loadRelaData() {
+            this.refreshOperationValid();
+            SpreadJsObj.saveTopAndSelect(this.sheet, this.ckBillsSpread);
+            const select = SpreadJsObj.getSelectObject(this.sheet);
+            detailObj.loadDetailData(select);
+        }
+        refreshTree(data) {
+            const sheet = this.sheet;
+            SpreadJsObj.massOperationSheet(sheet, function () {
+                const tree = sheet.zh_tree;
+                // 处理删除
+                if (data.delete) {
+                    data.delete.sort(function (a, b) {
+                        return b.deleteIndex - a.deleteIndex;
+                    });
+                    for (const d of data.delete) {
+                        sheet.deleteRows(d.deleteIndex, 1);
+                    }
+                }
+                // 处理新增
+                if (data.create) {
+                    const newNodes = data.create;
+                    if (newNodes) {
+                        newNodes.sort(function (a, b) {
+                            return a.index - b.index;
+                        });
+
+                        for (const node of newNodes) {
+                            sheet.addRows(node.index, 1);
+                            SpreadJsObj.reLoadRowData(sheet, tree.nodes.indexOf(node), 1);
+                        }
+                    }
+                }
+                // 处理更新
+                if (data.update) {
+                    const rows = [];
+                    for (const u of data.update) {
+                        rows.push(tree.nodes.indexOf(u));
+                    }
+                    SpreadJsObj.reLoadRowsData(sheet, rows);
+                }
+                // 处理展开
+                if (data.expand) {
+                    const expanded = [];
+                    for (const e of data.expand) {
+                        if (expanded.indexOf(e) === -1) {
+                            const posterity = tree.getPosterity(e);
+                            for (const p of posterity) {
+                                sheet.setRowVisible(tree.nodes.indexOf(p), p.visible);
+                                expanded.push(p);
+                            }
+                        }
+                    }
+                }
+            });
+        }
+        loadData(datas) {
+            this.tree.loadDatas(datas);
+            treeCalc.calculateAll(this.tree);
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.tree);
+            SpreadJsObj.loadTopAndSelect(this.sheet, this.ckBillsSpread);
+            this.refreshOperationValid();
+        }
+        getDefaultSelectInfo() {
+            if (!this.tree) return;
+            const sel = this.sheet.getSelections()[0];
+            const node = this.sheet.zh_tree.nodes[sel.row];
+            if (!node) return;
+            let count = 1;
+            if (sel.rowCount > 1) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = this.sheet.zh_tree.nodes[sel.row + r];
+                    if (rNode.tree_level > node.tree_level) continue;
+                    if ((rNode.tree_level < node.tree_level) || (rNode.tree_level === node.tree_level && rNode.tree_pid !== node.tree_pid)) {
+                        toastr.warning('请选择同一节点下的节点,进行该操作');
+                        return;
+                    }
+                    count += 1;
+                }
+            }
+            return [this.tree, node, count];
+        }
+        checkNodeUsed(tree, node) {
+            if (node.pre_pay_tp || node.pre_cut_tp || node.pre_yf_tp || node.pre_sf_tp) return true;
+            const posterity = tree.getPosterity(node);
+            for (const p of posterity) {
+                if (p.pre_pay_tp || p.pre_cut_tp || p.pre_yf_tp || p.pre_sf_tp) return true;
+            }
+            return false;
+        }
+        baseOpr(type, addCount = 1) {
+            const self = this;
+            const sheet = self.sheet;
+            const sel = sheet.getSelections()[0];
+            const [tree, node, count] = this.getDefaultSelectInfo();
+            if (!tree || !node || !count) return;
+
+            if (type === 'delete') {
+                const parent = tree.getParent(node);
+                const children = parent ? parent.children : tree.children;
+                const index = children.indexOf(node);
+                for (let i = 0; i < count; i++) {
+                    const child = children[i+index];
+                    if (this.checkNodeUsed(tree, child)) {
+                        toastr.warning('选中的节点已使用,不可删除');
+                        return;
+                    }
+                }
+            } else if (type === 'up-level') {
+                const parent = tree.getParent(node);
+                const children = parent ? parent.children : tree.children;
+                const index = children.indexOf(node);
+                for (let i = index; i < children.length; i++) {
+                    const child = children[index];
+                    if (this.checkNodeUsed(tree, child)) {
+                        if (i >= index + count) {
+                            toastr.warning('其后节点已使用,选中的节点不可升级');
+                        } else {
+                            toastr.warning('选中的节点已使用,不可升级');
+                        }
+                        return;
+                    }
+                }
+            } else if (type === 'down-level') {
+                const parent = tree.getParent(node);
+                const children = parent ? parent.children : tree.children;
+                const index = children.indexOf(node);
+                if (index > 0 && this.checkNodeUsed(tree, children[index-1])) {
+                    toastr.warning('其前节点已使用,选中的节点不可降级');
+                    return;
+                }
+                for (let i = index; i < count; i++) {
+                    const child = children[i+index];
+                    if (this.checkNodeUsed(tree, child)) {
+                        toastr.warning('选中的节点已使用,不可降级');
+                        return;
+                    }
+                }
+            }
+
+            const updateData = {
+                target: 'ledger', postType: type,
+                postData: {
+                    id: node.tree_id,
+                    count: type === 'add' ? addCount : count,
+                }
+            };
+            if (type === 'delete') {
+                deleteAfterHint(function () {
+                    postData('update', updateData, function (result) {
+                        const refreshData = tree.loadPostData(result);
+                        self.refreshTree(refreshData);
+                        if (sel) {
+                            sheet.setSelection(sel.row, sel.col, 1, sel.colCount);
+                        }
+                        self.refreshOperationValid();
+                    });
+                });
+            } else {
+                postData('update', updateData, function (result) {
+                    const refreshData = tree.loadPostData(result);
+                    self.refreshTree(refreshData);
+                    if (['up-move', 'down-move'].indexOf(type) > -1) {
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(node), sel.col, sel.rowCount, sel.colCount);
+                            // SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, tree.nodes.indexOf(node)]);
+                        }
+                    } else if (type === 'add') {
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(refreshData.create[0]), sel.col, sel.rowCount, sel.colCount);
+                            // SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, tree.nodes.indexOf(refreshData.create[0])]);
+                        }
+                    }
+                    self.refreshOperationValid();
+                });
+            }
+        }
+        // 事件
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    billsObj.loadRelaData();
+                }
+            }
+        }
+        topRowChanged(e, info) {
+            SpreadJsObj.saveTopAndSelect(info.sheet, billsObj.ckBillsSpread);
+        }
+        editStarting(e, info) {
+            if (!info.sheet.zh_setting || !info.sheet.zh_tree) return;
+
+            const tree = info.sheet.zh_tree;
+            const col = info.sheet.zh_setting.cols[info.col];
+            const node = info.sheet.zh_tree.nodes[info.row];
+            if (!node) {
+                info.cancel = true;
+                return;
+            }
+            const topParent = tree.getTopParent(node);
+
+            switch (col.field) {
+                case 'code':
+                case 'name':
+                case 'yf_excl_tax_tp':
+                case 'in_excl_tax_tp':
+                case 'sf_excl_tax_tp':
+                    info.cancel = node.tree_level === 1 || node.node_type > 0  || [2,3,4].indexOf(topParent.node_type) >= 0;
+                    break;
+                case 'num_a':
+                case 'num_b':
+                case 'num_c':
+                case 'num_d':
+                case 'num_e':
+                case 'num_f':
+                case 'num_g':
+                case 'num_h':
+                case 'num_i':
+                case 'num_j':
+                case 'num_k':
+                case 'num_l':
+                case 'num_m':
+                case 'num_n':
+                case 'num_o':
+                case 'num_p':
+                    info.cancel = node.children && node.children.length > 0;
+                    if (!info.cancel) {
+                        const detailRange = detailObj.data.getPartData(node.id);
+                        info.cancel = detailRange && detailRange.length > 0;
+                    }
+                    break;
+            }
+        }
+        editEnded(e, info) {
+            if (!info.sheet.zh_setting) return;
+
+            const node = SpreadJsObj.getSelectObject(info.sheet);
+            const data = { id: node.id, stage_id: node.stage_id, tree_id: node.tree_id };
+            // 未改变值则不提交
+            const col = info.sheet.zh_setting.cols[info.col];
+            const orgValue = node[col.field];
+            const newValue = trimInvalidChar(info.editingText);
+            if (orgValue == info.editingText || ((!orgValue || orgValue === '') && (newValue === ''))) return;
+
+            if (info.editingText) {
+                const text = newValue;
+                if (col.type === 'Number') {
+                    const num = _.toNumber(text);
+                    if (_.isFinite(num)) {
+                        data[col.field] = num;
+                    } else {
+                        try {
+                            data[col.field] = ZhCalc.mathCalcExpr(transExpr(text));
+                        } catch(err) {
+                            toastr.error('输入的表达式非法');
+                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                            return;
+                        }
+                    }
+                } else {
+                    data[col.field] = text;
+                }
+            } else {
+                data[col.field] = col.type === 'Number' ? 0 : '';
+            }
+            // 更新至服务器
+            postData('update', {target: 'ledger', postType: 'update', postData: data}, function (result) {
+                const refreshNode = billsObj.tree.loadPostData(result);
+                billsObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_tree.nodes[iRow];
+                const data = sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'ledger', postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = sheet.zh_tree.loadPostData(result);
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+            const tree = info.sheet.zh_tree, setting = info.sheet.zh_setting;
+            if (!setting || !tree) return;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const datas = [], filterNodes = [];
+
+            let level, filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = tree.nodes[curRow];
+                if (!node) continue;
+
+                if (!level) level = node.level;
+                if (node.level < level) break;
+
+                let bPaste = false;
+                const data = info.sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    data[colSetting.field] = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'ledger', postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = tree.loadPostData(result);
+                    if (refreshNode.update) refreshNode.update = refreshNode.update.concat(filterNodes);
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        }
+    }
+    const billsObj = new BillsObj();
+    class DetailObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#detail-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.data = createAncillaryGcl({ id: 'id', masterId: 'ledger_id', sort: [['d_order', 'asc']] });
+            this.dealSpreadSetting = dealSpreadSetting;
+            this.commonSpreadSetting = commonSpreadSetting;
+
+            this.initSpread();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.commonSpreadSetting);
+            this.spread.bind(spreadNS.Events.ClipboardChanging, function (e, info) {
+                const copyText = SpreadJsObj.getFilterCopyText(info.sheet);
+                SpreadJsObj.Clipboard.setCopyData(copyText);
+            });
+            if (readOnly) return;
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        loadData(datas) {
+            this.data.loadDatas(datas);
+            this.loadDetailData(SpreadJsObj.getSelectObject(billsObj.sheet));
+        }
+        refreshSheet() {
+            if (this.sheet.zh_data.length === 0) {
+                this.reloadDetailData();
+            } else {
+                SpreadJsObj.reLoadSheetData(this.sheet);
+            }
+        }
+        reloadDetailData() {
+            const data = this.billsNode ? this.data.getPartData(this.billsNode.id) || [] : [];
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, data);
+        }
+        loadDetailData(bills) {
+            detailObj.billsNode = bills;
+            const data = this.billsNode ? this.data.getPartData(this.billsNode.id) || [] : [];
+            const spreadSetting = data && data.length > 0 && data[0].is_deal ? this.dealSpreadSetting : this.commonSpreadSetting;
+            if (data.length === 0) {
+                spreadSetting.readOnly = true;
+            } else {
+                spreadSetting.readOnly = readOnly;
+            }
+            SpreadJsObj.initSheet(this.sheet, spreadSetting);
+            this.reloadDetailData();
+        }
+        // 事件
+        editEnded(e, info) {
+            const setting = info.sheet.zh_setting;
+            if (!setting) return;
+            const detailData = SpreadJsObj.getSelectObject(info.sheet);
+
+            const col = setting.cols[info.col];
+            const orgText = detailData ? detailData[col.field] : '', newText = trimInvalidChar(info.editingText);
+            if (orgText === newText || (!orgText && !newText)) return;
+
+            const relaBills = detailObj.billsNode;
+            if (!relaBills) {
+                toastr.error('数据错误,请选择台账节点后再试');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+
+            const data = { target: 'detail' };
+            if (detailData) {
+                const updateData = { id: detailData.id };
+                if (col.type === 'Number') {
+                    const num = _.toNumber(newText);
+                    if (!_.isFinite(num)) {
+                        toastr.error('输入的数字非法');
+                        SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                        return;
+                    }
+                    updateData[col.field] = num;
+                } else {
+                    updateData[col.field] = newText;
+                }
+                data.update = [ updateData ];
+            }
+            // 更新至服务器
+            postData('update', data, function (result) {
+                detailObj.data.updateDatas(result.detail);
+                detailObj.refreshSheet();
+                const refreshNode = billsObj.tree.loadPostData({ update: result.ledger });
+                billsObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const relaBills = detailObj.billsNode;
+            if (!relaBills) {
+                toastr.error('数据错误,请选择台账节点后再试');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_data[iRow];
+                const data = { id: node.id };
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'detail', update: datas}, function (result) {
+                    detailObj.data.updateDatas(result.detail);
+                    detailObj.refreshSheet();
+                    const refreshNode = billsObj.tree.loadPostData({ update: result.ledger });
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const hint = {
+                invalidExpr: {type: 'warning', msg: '粘贴的表达式非法'},
+            };
+            const datas = [], filterNodes = [];
+
+            // todo
+            let filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = info.sheet.zh_data[curRow];
+                if (!node) continue;
+
+                let bPaste = false;
+                const data = { id: node.id };
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    const value = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+                    if (colSetting.type === 'Number') {
+                        const num = _.toNumber(value);
+                        if (num) {
+                            data[colSetting.field] = num;
+                        } else {
+                            try {
+                                data[colSetting.field] = ZhCalc.mathCalcExpr(transExpr(value));
+                                bPaste = true;
+                            } catch (err) {
+                                toastMessageUniq(hint.invalidExpr);
+                                continue;
+                            }
+                        }
+                    } else {
+                        data[colSetting.field] = value;
+                    }
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'detail', update: datas}, function (result) {
+                    detailObj.data.updateDatas(result.detail);
+                    detailObj.refreshSheet();
+                    const refreshNode = billsObj.tree.loadPostData({ update: result.ledger });
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        }
+    }
+    const detailObj = new DetailObj();
+
+    // 清单右键菜单
+    const billsContextMenuOptions = {
+        selector: '#bills-spread',
+        build: function ($trigger, e) {
+            const target = SpreadJsObj.safeRightClickSelection($trigger, e, billsObj.spread);
+            billsObj.loadRelaData();
+            return target.hitTestType === spreadNS.SheetArea.viewport || target.hitTestType === spreadNS.SheetArea.rowHeader;
+        },
+        items: {}
+    };
+    if (!readOnly) {
+        $.contextMenu.types.batchInsert = function (item, opt, root) {
+            const self = this;
+            if ($.isFunction(item.icon)) {
+                item._icon = item.icon.call(this, this, $t, key, item);
+            } else {
+                if (typeof(item.icon) === 'string' && item.icon.substring(0, 3) === 'fa-') {
+                    // to enable font awesome
+                    item._icon = root.classNames.icon + ' ' + root.classNames.icon + '--fa fa ' + item.icon;
+                } else {
+                    item._icon = root.classNames.icon + ' ' + root.classNames.icon + '-' + item.icon;
+                }
+            }
+            this.addClass(item._icon);
+            const $obj = $('<div>' + item.name + '<input class="text-right ml-1 mr-1" type="tel" max="100" min="1" value="' + item.value + '" style="width: 30px; height: 18px; padding-right: 4px;">行</div>')
+                .appendTo(this);
+            const $input = $obj.find('input');
+            const event = () => {
+                if (self.hasClass('context-menu-disabled')) return;
+                item.batchInsert($input[0], root);
+            };
+            $obj.on('click', event).keypress(function (e) {if (e.keyCode === 13) { event(); }});
+            $input.click((e) => {e.stopPropagation();})
+                .keyup((e) => {if (e.keyCode === 13) item.batchInsert($input[0], root);})
+                .on('input', function () {this.value = this.value.replace(/[^\d]/g, '');});
+        };
+        billsContextMenuOptions.items.create = {
+            name: '新增',
+            icon: 'fa-sign-in',
+            callback: function (key, opt) {
+                billsObj.baseOpr('add');
+            },
+            disabled: function (key, opt) {
+                const sheet = billsObj.sheet;
+                const selection = sheet.getSelections();
+                const sel = selection ? selection[0] : sheet.getSelections()[0];
+                const row = sel ? sel.row : -1;
+                const tree = sheet.zh_tree;
+                if (!tree) return true;
+                const first = sheet.zh_tree.nodes[row];
+                const valid = !sheet.zh_setting.readOnly;
+                return !(valid && first);
+            }
+        };
+        billsContextMenuOptions.items.delete = {
+            name: '删除',
+            icon: 'fa-remove',
+            callback: function (key, opt) {
+                billsObj.baseOpr('delete');
+            },
+            disabled: function (key, opt) {
+                const sheet = billsObj.sheet;
+                const selection = sheet.getSelections();
+                const sel = selection ? selection[0] : sheet.getSelections()[0];
+                const row = sel ? sel.row : -1;
+                const tree = sheet.zh_tree;
+                if (!tree) return true;
+                const first = sheet.zh_tree.nodes[row];
+                let last = first, sameParent = true, nodeUsed = billsObj.checkNodeUsed(tree, first);
+                if (sel.rowCount > 1 && first) {
+                    for (let r = 1; r < sel.rowCount; r++) {
+                        const rNode = tree.nodes[sel.row + r];
+                        if (!rNode) {
+                            sameParent = false;
+                            break;
+                        }
+                        nodeUsed = nodeUsed || billsObj.checkNodeUsed(tree, rNode);
+                        if (rNode.tree_level > first.tree_level) continue;
+                        if ((rNode.tree_level < first.tree_level) || (rNode.tree_level === first.tree_level && rNode.tree_pid !== first.tree_pid)) {
+                            sameParent = false;
+                            break;
+                        }
+                        last = rNode;
+                    }
+                }
+                const valid = !sheet.zh_setting.readOnly;
+                return !(valid && first && sameParent && !nodeUsed);
+            }
+        };
+        billsContextMenuOptions.items.batchInsert = {
+            name: '批量插入',
+            type: 'batchInsert',
+            value: '2',
+            icon: 'fa-sign-in',
+            batchInsert: function (obj, root) {
+                if (_.toNumber(obj.value) > _.toNumber(obj.max)) {
+                    obj.value = obj.max;
+                    toastr.warning('批量插入不可多于' + obj.max);
+                } else if(_.toNumber(obj.value) < _.toNumber(obj.min)) {
+                    obj.value = obj.min;
+                    toastr.warning('批量插入不可少于' + obj.min);
+                } else {
+                    billsObj.baseOpr('add', parseInt(obj.value));
+                    root.$menu.trigger('contextmenu:hide');
+                }
+            },
+            disabled: function (key, opt) {
+                const sheet = billsObj.sheet;
+                const selection = sheet.getSelections();
+                const sel = selection ? selection[0] : sheet.getSelections()[0];
+                const row = sel ? sel.row : -1;
+                const tree = sheet.zh_tree;
+                if (!tree) return true;
+                const first = sheet.zh_tree.nodes[row];
+                const valid = !sheet.zh_setting.readOnly;
+                return !(valid && first);
+            },
+            visible: function (key, opt) {
+                return !readOnly;
+            }
+        };
+        billsContextMenuOptions.items.baseSpr = '----';
+        billsContextMenuOptions.items.importDealPay = {
+            name: '导入计量数据',
+            callback: function() {
+                const node = SpreadJsObj.getSelectObject(billsObj.sheet);
+                $('#pay-tax').val(node.tax || '');
+                $('#import-pay').modal('show');
+            },
+            disabled: function() {
+                const node = SpreadJsObj.getSelectObject(billsObj.sheet);
+                return !node || (node.children && node.children.length > 0) || node.node_type !== 1;
+            }
+        };
+        $('#import-pay-ok').click(function() {
+            const updateData = { target: 'importPay', pay_order: $('#pay-order').val(), pay_tax: parseFloat($('#pay-tax').val()) };
+            if (updateData.pay_tax < 0 || updateData.pay_tax > 100) {
+                toastr.warning('税率需在0-100以内');
+                return;
+            }
+            postData('update', updateData, function (result) {
+                const refreshNode = billsObj.tree.loadPostData(result);
+                billsObj.refreshTree(refreshNode);
+                $('#import-pay').modal('hide');
+            });
+        })
+    }
+    $.contextMenu(billsContextMenuOptions);
+
+    const searchObj = $.ledgerSearch({
+        selector: '#search',
+        searchRangeStr: '编号/名称',
+        ledger: {
+            billsTree: billsObj.tree,
+            getLedgerPos: function(node) { return detailObj.data.getPartData(node.id); },
+            posId: 'id',
+        },
+        resultSpreadSetting: {
+            cols: [
+                {title: '编号', field: 'code', hAlign: 0, width: 90, formatter: '@'},
+                {title: '名称', field: 'name', width: 150, hAlign: 0, formatter: '@'},
+                {title: '单位', field: 'unit', width: 50, hAlign: 1, formatter: '@'},
+            ],
+            emptyRows: 0,
+            headRows: 1,
+            headRowHeight: [32],
+            headColWidth: [30],
+            defaultRowHeight: 21,
+            headerFont: '12px 微软雅黑',
+            font: '12px 微软雅黑',
+            selectedBackColor: '#fffacd',
+            readOnly: true,
+        },
+        locate: function(cur) {
+            if (!cur.lid) return;
+
+            SpreadJsObj.locateTreeNode(billsObj.sheet, cur.lid, true);
+            billsObj.loadRelaData();
+            if (cur.pid) {
+                const pIndex = detailObj.sheet.zh_data.findIndex(x => { return x.id === cur.pid; });
+                SpreadJsObj.locateRow(detailObj.sheet, pIndex);
+            }
+        },
+    });
+    // 加载数据
+    postData('load', { filter: 'bills;detail;att;tags' }, function(result) {
+        billsObj.loadData(result.bills);
+        detailObj.loadData(result.detail);
+    });
+
+    // 展开收起标准清单
+    $('a', '#side-menu').bind('click', function (e) {
+        e.preventDefault();
+        const tab = $(this), tabPanel = $(tab.attr('content'));
+        // 展开工具栏、切换标签
+        if (!tab.hasClass('active')) {
+            // const close = $('.active', '#side-menu').length === 0;
+            $('a', '#side-menu').removeClass('active');
+            $('.tab-content .tab-select-show.tab-pane.active').removeClass('active');
+            tab.addClass('active');
+            tabPanel.addClass('active');
+            // $('.tab-content .tab-pane').removeClass('active');
+            showSideTools(tab.hasClass('active'));
+            if (tab.attr('content') === '#search') {
+                searchObj.spread.refresh();
+            }
+        } else { // 收起工具栏
+            tab.removeClass('active');
+            tabPanel.removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        }
+        billsObj.spread.refresh();
+        detailObj.spread.refresh();
+    });
+
+    $.divResizer({
+        select: '#detail-spr',
+        callback: function () {
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 工具栏spr
+    $.divResizer({
+        select: '#right-spr',
+        callback: function () {
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 导航Menu
+    $.subMenu({
+        menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
+        toMenu: '#to-menu', toMiniMenu: '#to-mini-menu',
+        key: 'menu.1.0.0',
+        miniHint: '#sub-mini-hint', hintKey: 'menu.hint.1.0.1',
+        callback: function (info) {
+            if (info.mini) {
+                $('.panel-title').addClass('fluid');
+                $('#sub-menu').removeClass('panel-sidebar');
+            } else {
+                $('.panel-title').removeClass('fluid');
+                $('#sub-menu').addClass('panel-sidebar');
+            }
+            autoFlashHeight();
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 显示层次
+    (function (select, sheet) {
+        $(select).click(function () {
+            if (!sheet.zh_tree) return;
+            const tag = $(this).attr('tag');
+            const tree = sheet.zh_tree;
+            setTimeout(() => {
+                showWaitingView();
+                switch (tag) {
+                    case "1":
+                    case "2":
+                    case "3":
+                    case "4":
+                        tree.expandByLevel(parseInt(tag));
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                    case "last":
+                        tree.expandByCustom(() => { return true; });
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                }
+                closeWaitingView();
+            }, 100);
+        });
+    })('a[name=showLevel]', billsObj.sheet);
+});

+ 781 - 0
app/public/js/cost_stage_book.js

@@ -0,0 +1,781 @@
+function getTenderId() {
+    return window.location.pathname.split('/')[2];
+}
+const invalidFields = {
+    parent: ['cur_qty', 'cur_tp', 'unit_price'],
+};
+function transExpr(expr) {
+    return $.trim(expr).replace('\t', '').replace('=', '').replace('%', '/100');
+}
+
+$(document).ready(function() {
+    autoFlashHeight();
+    class BillsObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#bills-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.treeSetting = {
+                id: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                rootId: -1,
+                calcFields: ['cur_tp', 'pre_tp', 'end_tp'],
+                keys: ['id', 'stage_id', 'tree_id'],
+            };
+            this.tree = createNewPathTree('ledger', this.treeSetting);
+            this.spreadSetting = {
+                cols: [
+                    {title: '编号', colSpan: '1', rowSpan: '1', field: 'code', hAlign: 0, width: 230, formatter: '@', cellType: 'tree', readOnly: true},
+                    {title: '名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 185, formatter: '@', cellType: 'autoTip', readOnly: true},
+                    {title: '单位', colSpan: '1', rowSpan: '1', field: 'unit', hAlign: 1, width: 50, formatter: '@', cellType: 'unit', readOnly: true},
+                    {title: '税率(%)', colSpan: '1', rowSpan: '1', field: 'tax', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '入账金额', colSpan: '1', rowSpan: '1', field: 'in_tp', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '入账金额(不含税)', colSpan: '1', rowSpan: '1', field: 'in_excl_tax_tp', hAlign: 2, width: 100, type: 'Number', readOnly: true},
+                    {title: '本期批注', colSpan: '1', rowSpan: '1', field: 'postil', hAlign: 0, width: 100, formatter: '@'},
+                    {title: '备注', colSpan: '1', rowSpan: '1', field: 'memo', hAlign: 0, width: 120, formatter: '@', cellType: 'ellipsisAutoTip'},
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                readOnly: readOnly,
+                localCache: {
+                    key: 'cost_book',
+                    colWidth: true,
+                },
+            };
+            sjsSettingObj.setFxTreeStyle(this.spreadSetting, sjsSettingObj.FxTreeStyle.phasePay);
+
+            this.ckBillsSpread = window.location.pathname + '-billsSelect';
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            this.spread.bind(spreadNS.Events.topRowChanged, this.topRowChanged);
+            this.spread.bind(spreadNS.Events.ClipboardChanging, function (e, info) {
+                const copyText = SpreadJsObj.getFilterCopyText(info.sheet);
+                SpreadJsObj.Clipboard.setCopyData(copyText);
+            });
+            if (readOnly) return;
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.EditStarting, this.editStarting);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            const self = this;
+            $('#bills-spread').focusin(this.focusIn);
+        }
+        refreshOperationValid() {
+            const setObjEnable = function (obj, enable) {
+                if (enable) {
+                    obj.removeClass('disabled');
+                } else {
+                    obj.addClass('disabled');
+                }
+            };
+            const invalidAll = function () {
+                setObjEnable($('a[name=base-opr][type=add]'), false);
+                setObjEnable($('a[name=base-opr][type=delete]'), false);
+                setObjEnable($('a[name=base-opr][type=up-move]'), false);
+                setObjEnable($('a[name=base-opr][type=down-move]'), false);
+                setObjEnable($('a[name=base-opr][type=up-level]'), false);
+                setObjEnable($('a[name=base-opr][type=down-level]'), false);
+            };
+            const sel = this.sheet.getSelections()[0];
+            const row = sel ? sel.row : -1;
+            const tree = this.sheet.zh_tree;
+            if (!tree) {
+                invalidAll();
+                return;
+            }
+            const first = tree.nodes[row];
+            if (!first) {
+                invalidAll();
+                return;
+            }
+            let last = first, sameParent = true, nodeUsed = this.checkNodeUsed(tree, first);
+            if (sel.rowCount > 1 && first) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = tree.nodes[sel.row + r];
+                    if (!rNode) {
+                        sameParent = false;
+                        break;
+                    }
+                    nodeUsed = nodeUsed || this.checkNodeUsed(tree, rNode);
+                    if (rNode.tree_level > first.tree_level) continue;
+                    if ((rNode.tree_level < first.tree_level) || (rNode.tree_level === first.tree_level && rNode.tree_pid !== first.tree_pid)) {
+                        sameParent = false;
+                        break;
+                    }
+                    last = rNode;
+                }
+            }
+            const preNode = tree.getPreSiblingNode(first);
+            const valid = !this.sheet.zh_setting.readOnly;
+
+            setObjEnable($('a[name=base-opr][type=add]'), valid && first);
+            setObjEnable($('a[name=base-opr][type=delete]'), valid && first && sameParent && !nodeUsed);
+            setObjEnable($('a[name=base-opr][type=up-move]'), valid && first && sameParent && preNode);
+            setObjEnable($('a[name=base-opr][type=down-move]'), valid && first && sameParent  && !tree.isLastSibling(last));
+            setObjEnable($('a[name=base-opr][type=up-level]'), valid && first && sameParent && tree.getParent(first) && !nodeUsed && first.tree_level > 1);
+            setObjEnable($('a[name=base-opr][type=down-level]'), valid && first && sameParent && preNode && !this.checkNodeUsed(tree, preNode));
+        }
+        loadRelaData(loadAtt = true) {
+            this.refreshOperationValid();
+            SpreadJsObj.saveTopAndSelect(this.sheet, this.ckBillsSpread);
+            const select = SpreadJsObj.getSelectObject(this.sheet);
+            detailObj.loadDetailData(select);
+            if (loadAtt) costFile.getCurAttHtml(select);
+        }
+        refreshTree(data) {
+            const sheet = this.sheet;
+            SpreadJsObj.massOperationSheet(sheet, function () {
+                const tree = sheet.zh_tree;
+                // 处理删除
+                if (data.delete) {
+                    data.delete.sort(function (a, b) {
+                        return b.deleteIndex - a.deleteIndex;
+                    });
+                    for (const d of data.delete) {
+                        sheet.deleteRows(d.deleteIndex, 1);
+                    }
+                }
+                // 处理新增
+                if (data.create) {
+                    const newNodes = data.create;
+                    if (newNodes) {
+                        newNodes.sort(function (a, b) {
+                            return a.index - b.index;
+                        });
+
+                        for (const node of newNodes) {
+                            sheet.addRows(node.index, 1);
+                            SpreadJsObj.reLoadRowData(sheet, tree.nodes.indexOf(node), 1);
+                        }
+                    }
+                }
+                // 处理更新
+                if (data.update) {
+                    const rows = [];
+                    for (const u of data.update) {
+                        rows.push(tree.nodes.indexOf(u));
+                    }
+                    SpreadJsObj.reLoadRowsData(sheet, rows);
+                }
+                // 处理展开
+                if (data.expand) {
+                    const expanded = [];
+                    for (const e of data.expand) {
+                        if (expanded.indexOf(e) === -1) {
+                            const posterity = tree.getPosterity(e);
+                            for (const p of posterity) {
+                                sheet.setRowVisible(tree.nodes.indexOf(p), p.visible);
+                                expanded.push(p);
+                            }
+                        }
+                    }
+                }
+            });
+        }
+        loadData(datas) {
+            this.tree.loadDatas(datas);
+            treeCalc.calculateAll(this.tree);
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.tree);
+            SpreadJsObj.loadTopAndSelect(this.sheet, this.ckBillsSpread);
+            this.refreshOperationValid();
+        }
+        getDefaultSelectInfo() {
+            if (!this.tree) return;
+            const sel = this.sheet.getSelections()[0];
+            const node = this.sheet.zh_tree.nodes[sel.row];
+            if (!node) return;
+            let count = 1;
+            if (sel.rowCount > 1) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = this.sheet.zh_tree.nodes[sel.row + r];
+                    if (rNode.tree_level > node.tree_level) continue;
+                    if ((rNode.tree_level < node.tree_level) || (rNode.tree_level === node.tree_level && rNode.tree_pid !== node.tree_pid)) {
+                        toastr.warning('请选择同一节点下的节点,进行该操作');
+                        return;
+                    }
+                    count += 1;
+                }
+            }
+            return [this.tree, node, count];
+        }
+        checkNodeUsed(tree, node) {
+            if (node.pre_pay_tp || node.pre_cut_tp || node.pre_yf_tp || node.pre_sf_tp) return true;
+            const posterity = tree.getPosterity(node);
+            for (const p of posterity) {
+                if (p.pre_pay_tp || p.pre_cut_tp || p.pre_yf_tp || p.pre_sf_tp) return true;
+            }
+            return false;
+        }
+        // 事件
+        focusIn() {
+            const select = SpreadJsObj.getSelectObject(billsObj.sheet);
+            costFile.getCurAttHtml(select);
+        }
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    billsObj.loadRelaData();
+                }
+            }
+        }
+        topRowChanged(e, info) {
+            SpreadJsObj.saveTopAndSelect(info.sheet, billsObj.ckBillsSpread);
+        }
+        editStarting(e, info) {
+            if (!info.sheet.zh_setting || !info.sheet.zh_tree) return;
+
+            const tree = info.sheet.zh_tree;
+            const col = info.sheet.zh_setting.cols[info.col];
+            const node = info.sheet.zh_tree.nodes[info.row];
+            if (!node) {
+                info.cancel = true;
+                return;
+            }
+
+            switch (col.field) {
+                case 'in_tp':
+                    info.cancel = (node.children && node.children.length > 0) || node.had_detail;
+                    if (!info.cancel) {
+                        const detailRange = detailObj.data.getPartData(node.id);
+                        if (detailRange && detailRange.length > 0) info.cancel = true;
+                    }
+                    break;
+            }
+        }
+        editEnded(e, info) {
+            if (!info.sheet.zh_setting) return;
+
+            const node = SpreadJsObj.getSelectObject(info.sheet);
+            const data = { book_id: node.book_id, ledger_id: node.id, cost_id: node.cost_id };
+            // 未改变值则不提交
+            const col = info.sheet.zh_setting.cols[info.col];
+            const orgValue = node[col.field];
+            const newValue = trimInvalidChar(info.editingText);
+            if (orgValue == info.editingText || ((!orgValue || orgValue === '') && (newValue === ''))) return;
+
+            if (info.editingText) {
+                const text = newValue;
+                if (col.type === 'Number') {
+                    const num = _.toNumber(text);
+                    if (_.isFinite(num)) {
+                        data[col.field] = num;
+                    } else {
+                        try {
+                            data[col.field] = ZhCalc.mathCalcExpr(transExpr(text));
+                        } catch(err) {
+                            toastr.error('输入的表达式非法');
+                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                            return;
+                        }
+                    }
+                } else {
+                    data[col.field] = text;
+                }
+            } else {
+                data[col.field] = col.type === 'Number' ? 0 : '';
+            }
+            // 更新至服务器
+            postData('update', {target: 'ledger', update: data}, function (result) {
+                const refreshNode = billsObj.tree.loadPostData(result);
+                billsObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_tree.nodes[iRow];
+                const data = { book_id: node.book_id, ledger_id: node.id, cost_id: node.cost_id };;
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'ledger', update: datas}, function (result) {
+                    const refreshNode = sheet.zh_tree.loadPostData(result);
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+            const tree = info.sheet.zh_tree, setting = info.sheet.zh_setting;
+            if (!setting || !tree) return;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const datas = [], filterNodes = [];
+
+            let level, filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = tree.nodes[curRow];
+                if (!node) continue;
+
+                if (!level) level = node.level;
+                if (node.level < level) break;
+
+                let bPaste = false;
+                const data = { book_id: node.book_id, ledger_id: node.id, cost_id: node.cost_id };
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    data[colSetting.field] = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'ledger', update: datas}, function (result) {
+                    const refreshNode = tree.loadPostData(result);
+                    if (refreshNode.update) refreshNode.update = refreshNode.update.concat(filterNodes);
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        }
+    }
+    const billsObj = new BillsObj();
+    class DetailObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#detail-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.data = createAncillaryGcl({ id: 'id', masterId: 'ledger_id', sort: [['d_order', 'asc']] });
+            this.dealSpreadSetting = {
+                cols: [
+                    {title: '合同编号', colSpan: '1', rowSpan: '1', field: 'code', hAlign: 0, width: 160, formatter: '@', readOnly: true},
+                    {title: '合同名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 160, formatter: '@', readOnly: true},
+                    {title: '乙方', colSpan: '1', rowSpan: '1', field: 'party_b', hAlign: 0, width: 150, formatter: '@', readOnly: true},
+                    {title: '合同金额', colSpan: '1', rowSpan: '1', field: 'deal_tp', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '税率(%)', colSpan: '1', rowSpan: '1', field: 'tax', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '实付金额', colSpan: '1', rowSpan: '1', field: 'sf_tp', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '入账金额', colSpan: '1', rowSpan: '1', field: 'in_tp', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '入账金额(不含税)', colSpan: '1', rowSpan: '1', field: 'in_excl_tax_tp', hAlign: 2, width: 100, type: 'Number', readOnly: true},
+                    {title: '本期批注', colSpan: '1', rowSpan: '1', field: 'postil', hAlign: 0, width: 100, formatter: '@'},
+                    {title: '备注', colSpan: '1', rowSpan: '1', field: 'memo', hAlign: 0, width: 120, formatter: '@', cellType: 'ellipsisAutoTip'},
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                readOnly: readOnly,
+                localCache: {
+                    key: 'cost_book_deal',
+                    colWidth: true,
+                },
+            };
+            this.commonSpreadSetting = {
+                cols: [
+                    {title: '编号', colSpan: '1', rowSpan: '1', field: 'code', hAlign: 0, width: 160, formatter: '@', readOnly: true},
+                    {title: '名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 160, formatter: '@', readOnly: true},
+                    {title: '税率(%)', colSpan: '1', rowSpan: '1', field: 'tax', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '实付金额', colSpan: '1', rowSpan: '1', field: 'sf_tp', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '入账金额', colSpan: '1', rowSpan: '1', field: 'in_tp', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '入账金额(不含税)', colSpan: '1', rowSpan: '1', field: 'in_excl_tax_tp', hAlign: 2, width: 100, type: 'Number', readOnly: true},
+                    {title: '本期批注', colSpan: '1', rowSpan: '1', field: 'postil', hAlign: 0, width: 100, formatter: '@'},
+                    {title: '备注', colSpan: '1', rowSpan: '1', field: 'memo', hAlign: 0, width: 120, formatter: '@', cellType: 'ellipsisAutoTip'},
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                readOnly: readOnly,
+                localCache: {
+                    key: 'cost_book_detail',
+                    colWidth: true,
+                },
+            };
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.commonSpreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            this.spread.bind(spreadNS.Events.ClipboardChanging, function (e, info) {
+                const copyText = SpreadJsObj.getFilterCopyText(info.sheet);
+                SpreadJsObj.Clipboard.setCopyData(copyText);
+            });
+            if (readOnly) return;
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            const self = this;
+            $('#detail-spread').focusin(this.focusIn);
+        }
+        loadData(datas) {
+            this.data.loadDatas(datas);
+            this.loadDetailData(SpreadJsObj.getSelectObject(billsObj.sheet));
+        }
+        refreshSheet() {
+            if (this.sheet.zh_data.length === 0) {
+                this.reloadDetailData();
+            } else {
+                SpreadJsObj.reLoadSheetData(this.sheet);
+            }
+        }
+        reloadDetailData() {
+            const data = this.billsNode ? this.data.getPartData(this.billsNode.id) || [] : [];
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, data);
+        }
+        loadDetailData(bills) {
+            detailObj.billsNode = bills;
+            const spreadSetting = bills && bills.is_deal ? this.dealSpreadSetting : this.commonSpreadSetting;
+            if (!bills || (bills.children && bills.children.length > 0)) {
+                spreadSetting.readOnly = true;
+            } else {
+                spreadSetting.readOnly = readOnly;
+            }
+            SpreadJsObj.initSheet(this.sheet, spreadSetting);
+            this.reloadDetailData();
+        }
+        loadRelaData() {
+            const select = SpreadJsObj.getSelectObject(this.sheet);
+            if (select) costFile.getCurAttHtml(this.billsNode, select);
+        }
+        // 事件
+        focusIn() {
+            detailObj.loadRelaData();
+        }
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    detailObj.loadRelaData();
+                }
+            }
+        }
+        editEnded(e, info) {
+            const setting = info.sheet.zh_setting;
+            if (!setting) return;
+            const detailData = SpreadJsObj.getSelectObject(info.sheet);
+
+            const col = setting.cols[info.col];
+            const orgText = detailData ? detailData[col.field] : '', newText = trimInvalidChar(info.editingText);
+            if (orgText === newText || (!orgText && !newText)) return;
+
+            const relaBills = detailObj.billsNode;
+            if (!relaBills) {
+                toastr.error('数据错误,请选择台账节点后再试');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+
+            const data = { target: 'detail' };
+            if (detailData) {
+                const updateData = { book_id: detailData.book_id, detail_id: detailData.id, ledger_id: detailData.ledger_id };
+                if (col.type === 'Number') {
+                    const num = _.toNumber(newText);
+                    if (!_.isFinite(num)) {
+                        toastr.error('输入的数字非法');
+                        SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                        return;
+                    }
+                    updateData[col.field] = num;
+                } else {
+                    updateData[col.field] = newText;
+                }
+                data.update = [ updateData ];
+            }
+            // 更新至服务器
+            postData('update', data, function (result) {
+                detailObj.data.updateDatas(result.detail);
+                detailObj.refreshSheet();
+                result.ledger.tree_id = relaBills.tree_id;
+                const refreshNode = billsObj.tree.loadPostData({ update: result.ledger });
+                billsObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_tree.nodes[iRow];
+                const data = { book_id: node.book_id, detail_id: node.id, ledger_id: node.ledger_id };
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'detail', update: datas}, function (result) {
+                    const refreshNode = sheet.zh_tree.loadPostData(result);
+                    billsObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const hint = {
+                invalidExpr: {type: 'warning', msg: '粘贴的表达式非法'},
+            };
+            const datas = [], filterNodes = [];
+
+            let filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = tree.nodes[curRow];
+                if (!node) continue;
+
+                let bPaste = false;
+                const data = { book_id: node.book_id, detail_id: node.id, ledger_id: node.ledger_id };
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    const value = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+
+                    if (colSetting.type === 'Number') {
+                        const num = _.toNumber(value);
+                        if (num) {
+                            data[colSetting.field] = num;
+                        } else {
+                            try {
+                                data[colSetting.field] = ZhCalc.mathCalcExpr(transExpr(value));
+                                bPaste = true;
+                            } catch (err) {
+                                toastMessageUniq(hint.invalidExpr);
+                                continue;
+                            }
+                        }
+                    } else {
+                        data[colSetting.field] = value;
+                    }
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData('update', {target: 'detail', update: datas}, function (result) {
+                    detailObj.data.updateDatas(result.detail);
+                    detailObj.refreshSheet();
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        }
+    }
+    const detailObj = new DetailObj();
+
+    const costFile = $.ledger_att({
+        selector: '#fujian',
+        key: 'id',
+        subKey: 'id',
+        masterKey: 'rela_id',
+        subMasterKey: 'rela_sub_id',
+        uploadUrl: 'file/upload',
+        deleteUrl: 'file/delete',
+        checked: false,
+        zipName: `附件.zip`,
+        readOnly: false,
+        fileIdType: 'string',
+        fileInfo: {
+            user_name: 'user_name',
+            user_id: 'user_id',
+            create_time: 'create_time',
+        },
+        getCurHint: function(node) {
+            return`${node.name || ''}`;
+        },
+        locate: function (att) {
+            if (!att) return;
+            SpreadJsObj.locateTreeNode(billsObj.sheet, att.node.tree_id, true);
+            detailObj.loadDetailData(att.node);
+            if (att.sub_node) SpreadJsObj.locateDataBy(detailObj.sheet, n => { return n.id === att.sub_node.id; });
+            costFile.getCurAttHtml(att.node, att.sub_node);
+        }
+    });
+    const searchObj = $.ledgerSearch({
+        selector: '#search',
+        searchRangeStr: '编号/名称',
+        ledger: {
+            billsTree: billsObj.tree,
+            getLedgerPos: function(node) { return detailObj.data.getPartData(node.id); },
+            posId: 'id',
+        },
+        resultSpreadSetting: {
+            cols: [
+                {title: '编号', field: 'code', hAlign: 0, width: 90, formatter: '@'},
+                {title: '名称', field: 'name', width: 150, hAlign: 0, formatter: '@'},
+                {title: '单位', field: 'unit', width: 50, hAlign: 1, formatter: '@'},
+            ],
+            emptyRows: 0,
+            headRows: 1,
+            headRowHeight: [32],
+            headColWidth: [30],
+            defaultRowHeight: 21,
+            headerFont: '12px 微软雅黑',
+            font: '12px 微软雅黑',
+            selectedBackColor: '#fffacd',
+            readOnly: true,
+        },
+        locate: function(cur) {
+            if (!cur.lid) return;
+
+            SpreadJsObj.locateTreeNode(billsObj.sheet, cur.lid, true);
+            billsObj.loadRelaData();
+            if (cur.pid) {
+                const pIndex = detailObj.sheet.zh_data.findIndex(x => { return x.id === cur.pid; });
+                SpreadJsObj.locateRow(detailObj.sheet, pIndex);
+            }
+        },
+    });
+    // 加载数据
+    postData('load', { filter: 'bills;detail;att' }, function(result) {
+        billsObj.loadData(result.bills);
+        detailObj.loadData(result.detail);
+
+        for (const f of result.att) {
+            f.node = billsObj.tree.datas.find(x => { return x.id === f.rela_id; });
+            f.sub_node = f.rela_sub_id ? detailObj.data.getItem(f.rela_sub_id) : null;
+        }
+        costFile.loadDatas(result.att);
+        costFile.getCurAttHtml(SpreadJsObj.getSelectObject(billsObj.sheet));
+    });
+
+    // 展开收起标准清单
+    $('a', '#side-menu').bind('click', function (e) {
+        e.preventDefault();
+        const tab = $(this), tabPanel = $(tab.attr('content'));
+        // 展开工具栏、切换标签
+        if (!tab.hasClass('active')) {
+            // const close = $('.active', '#side-menu').length === 0;
+            $('a', '#side-menu').removeClass('active');
+            $('.tab-content .tab-select-show.tab-pane.active').removeClass('active');
+            tab.addClass('active');
+            tabPanel.addClass('active');
+            // $('.tab-content .tab-pane').removeClass('active');
+            showSideTools(tab.hasClass('active'));
+            if (tab.attr('content') === '#search') {
+                searchObj.spread.refresh();
+            }
+        } else { // 收起工具栏
+            tab.removeClass('active');
+            tabPanel.removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        }
+        billsObj.spread.refresh();
+        detailObj.spread.refresh();
+    });
+
+    $.divResizer({
+        select: '#detail-spr',
+        callback: function () {
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 工具栏spr
+    $.divResizer({
+        select: '#right-spr',
+        callback: function () {
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 导航Menu
+    $.subMenu({
+        menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
+        toMenu: '#to-menu', toMiniMenu: '#to-mini-menu',
+        key: 'menu.1.0.0',
+        miniHint: '#sub-mini-hint', hintKey: 'menu.hint.1.0.1',
+        callback: function (info) {
+            if (info.mini) {
+                $('.panel-title').addClass('fluid');
+                $('#sub-menu').removeClass('panel-sidebar');
+            } else {
+                $('.panel-title').removeClass('fluid');
+                $('#sub-menu').addClass('panel-sidebar');
+            }
+            autoFlashHeight();
+            billsObj.spread.refresh();
+            detailObj.spread.refresh();
+        }
+    });
+    // 显示层次
+    (function (select, sheet) {
+        $(select).click(function () {
+            if (!sheet.zh_tree) return;
+            const tag = $(this).attr('tag');
+            const tree = sheet.zh_tree;
+            setTimeout(() => {
+                showWaitingView();
+                switch (tag) {
+                    case "1":
+                    case "2":
+                    case "3":
+                    case "4":
+                        tree.expandByLevel(parseInt(tag));
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                    case "last":
+                        tree.expandByCustom(() => { return true; });
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                }
+                closeWaitingView();
+            }, 100);
+        });
+    })('a[name=showLevel]', billsObj.sheet);
+});

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1395 - 0
app/public/js/cost_stage_ledger.js


+ 68 - 0
app/public/js/cost_tender.js

@@ -0,0 +1,68 @@
+'use strict';
+
+const tenderListSpec = (function(){
+    function getTenderNodeHtml(node, arr, pid) {
+        const html = [];
+        html.push('<tr pid="' + pid + '">');
+        // 名称
+        html.push('<td style="min-width: 200px" class="in-' + node.level + '">');
+        if (node.cid) {
+            html.push('<span onselectstart="return false" style="{-moz-user-select:none}" class="fold-switch mr-1" title="收起" cid="'+ node.sort_id +'"><i class="fa fa-minus-square-o"></i></span> <i class="fa fa-folder-o"></i> ', node.name);
+        } else {
+            html.push('<span class="text-muted mr-2">');
+            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="/sp/${spid}/cost/tender/${node.id}/ledger" name="name" style="min-width: 200px;word-break:break-all;" id="${node.id}">${node.name}</a>`);
+        }
+        html.push('</td>');
+
+        // 创建时间
+        html.push('<td style="width: 8%" class="text-center">');
+        html.push(node.create_time ? moment(node.create_time).format('YYYY-MM-DD HH:mm:ss') : '');
+        html.push('</td>');
+        // 设置
+        if (is_admin) {
+            html.push('<td style="width: 10%" class="text-center">');
+            if (!node.cid) {
+                html.push(`<a href="javascript:void(0);" data-toggle="modal" data-tid="${node.id}" class="btn btn-sm btn-outline-primary member-manage"> 成员管理 </a>`);
+            }
+            html.push('</td>');
+        }
+        html.push('</tr>');
+        return html.join('');
+    }
+    function getTenderTreeHeaderHtml() {
+        const html = [];
+        const left = $('#sub-menu').css('display') === 'none' ? 56 : 176;
+        html.push('<table class="table table-hover table-bordered">');
+        html.push('<thead style="position: sticky;left:'+ left +'px;top: 0;" class="text-center">', '<tr>');
+        html.push('<th style="min-width: 50%">',  '标段名称',  tenderListOrder.getOrderButton('name'), '</th>');
+        html.push('<th style="width: 15%">', '创建时间',  tenderListOrder.getOrderButton('create_time'), '</th>');
+        if (is_admin) {
+            html.push('<th style="width: 15%">', '操作', '</th>');
+        }
+        html.push('</tr>');
+        html.push('</thead>');
+        return html.join('');
+    }
+    return { getTenderNodeHtml, getTenderTreeHeaderHtml }
+})();
+
+$(document).ready(() => {
+    const memberPermission = MemberPermission();
+    $('body').on('click', '.member-manage', function() {
+        const tid = this.getAttribute('data-tid');
+        memberPermission.show({ data: { tid }, loadUrl: `/sp/${spid}/cost/member`, saveUrl: `/sp/${spid}/cost/memberSave`});
+    });
+    $('#set-template-ok').click(function() {
+        const data = {
+            cost_ledger_template: $('#cost_ledger_template').val(),
+            cost_analysis_template: $('#cost_analysis_template').val(),
+            cost_calc_template: $('#cost_calc_template').val(),
+        };
+        postData(`/sp/${spid}/cost/templateSet`, data, function(data) {
+            $('#set-template').modal('hide');
+        });
+    });
+});

+ 65 - 0
app/public/js/cost_tmpl.js

@@ -230,6 +230,71 @@ $(document).ready(() => {
                     return (target.hitTestType === GC.Spread.Sheets.SheetArea.viewport || target.hitTestType === GC.Spread.Sheets.SheetArea.rowHeader) && !self.readOnly && self.template;
                 },
                 items: {
+                    'add_code': {
+                        name: '新增编号列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('code', select);
+                        },
+                    },
+                    'add_name': {
+                        name: '新增名称列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('name', select);
+                        },
+                    },
+                    'add_party_b': {
+                        name: '新增乙方列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('party_b', select);
+                        },
+                    },
+                    'add_yf_excl_tax_tp': {
+                        name: '新增应付(不含税)列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('yf_excl_tax_tp', select);
+                        },
+                    },
+                    'add_in_excl_tax_tp': {
+                        name: '新增入账(不含税)列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('in_excl_tax_tp', select);
+                        },
+                    },
+                    'add_sf_excl_tax_tp': {
+                        name: '新增实付(不含税)列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('in_excl_tax_tp', select);
+                        },
+                    },
+                    'add_sf_percent': {
+                        name: '新增实付(不含税)列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('sf_percent', select);
+                        },
+                    },
+                    'add_postil': {
+                        name: '新增本期批注列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('postil', select);
+                        },
+                    },
+                    addSpecSpr: '----',
                     'add_str': {
                         name: '新增文本列',
                         icon: 'fa-plus',

+ 221 - 14
app/public/js/ledger.js

@@ -17,6 +17,7 @@ function getTenderId() {
 }
 
 const copyBlockTag = 'zh.calc.copyBlock';
+const copyBlockTagPos = 'zh.calc.copyBlockPos';
 const invalidFields = {
     parent: ['sgfh_qty', 'sgfh_tp', 'sjcl_qty', 'sjcl_tp', 'qtcl_qty', 'qtcl_tp', 'deal_qty', 'deal_tp', 'unit_price', 'ex_qty1'],
     gcl: ['dgn_qty1', 'dgn_qty2'],
@@ -187,7 +188,7 @@ $(document).ready(function() {
                     treeOperationObj.refreshOperationValid(ledgerSpread.getActiveSheet());
                 }
             },
-            baseOpr: function (type) {
+            baseOpr: function (type, addCount = 1) {
                 const data = {};
 
                 const detailRange = sheet.zh_data;
@@ -200,7 +201,10 @@ $(document).ready(function() {
                 const first = detailRange[row];
                 if (type === 'insert') {
                     const node = SpreadJsObj.getSelectObject(posSheet);
-                    data.add = [{ lid: node.lid, pid: node.id, pcd_order: detailRange.length + 1 }];
+                    data.add = [];
+                    for (let i = 1; i <= addCount; i++) {
+                        data.add.push({ lid: node.lid, pid: node.id, pcd_order: detailRange.length + i });
+                    }
                 } else if (type === 'delete') {
                     data.del = [];
                     for (let iRow = 0; iRow < count; iRow++) {
@@ -274,11 +278,16 @@ $(document).ready(function() {
                     if (col.type === 'Number') {
                         const num = _.toNumber(newText);
                         if (!_.isFinite(num)) {
-                            toastr.error('输入的数字非法');
-                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
-                            return;
+                            try {
+                                updateData[col.field] = ZhCalc.mathCalcExpr(transExpr(newText));
+                            } catch (err) {
+                                toastr.error('输入的表达式非法');
+                                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                                return;
+                            }
+                        } else {
+                            updateData[col.field] = num;
                         }
-                        updateData[col.field] = num;
                     } else {
                         updateData[col.field] = newText;
                     }
@@ -290,11 +299,16 @@ $(document).ready(function() {
                     if (col.type === 'Number') {
                         const num = _.toNumber(newText);
                         if (!_.isFinite(num)) {
-                            toastr.error('输入的数字非法');
-                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
-                            return;
+                            try {
+                                addData[col.field] = ZhCalc.mathCalcExpr(transExpr(newText));
+                            } catch (err) {
+                                toastr.error('输入的表达式非法');
+                                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                                return;
+                            }
+                        } else {
+                            addData[col.field] = num;
                         }
-                        addData[col.field] = num;
                     } else {
                         addData[col.field] = newText;
                     }
@@ -361,10 +375,16 @@ $(document).ready(function() {
                         if (col.type === 'Number') {
                             const num = _.toNumber(value);
                             if (!_.isFinite(num)) {
-                                toastMessageUniq(hint.num);
-                                return;
+                                try {
+                                    targetData[col.field] = ZhCalc.mathCalcExpr(transExpr(value));
+                                } catch (err) {
+                                    toastr.error('输入的表达式非法');
+                                    SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                                    return;
+                                }
+                            } else {
+                                targetData[col.field] = num;
                             }
-                            targetData[col.field] = num;
                         } else {
                             targetData[col.field] = value;
                         }
@@ -417,6 +437,28 @@ $(document).ready(function() {
                             ctrlObj.baseOpr('insert');
                         }
                     },
+                    'batchInsert': {
+                        name: '批量插入',
+                        type: 'batchInsert',
+                        value: '2',
+                        icon: 'fa-plus',
+                        disabled: function (key, opt) {
+                            const pos = SpreadJsObj.getSelectObject(posSheet);
+                            return !pos;
+                        },
+                        batchInsert: function (obj, root) {
+                            if (_.toNumber(obj.value) > _.toNumber(obj.max)) {
+                                obj.value = obj.max;
+                                toastr.warning('批量插入不可多于' + obj.max);
+                            } else if(_.toNumber(obj.value) < _.toNumber(obj.min)) {
+                                obj.value = obj.min;
+                                toastr.warning('批量插入不可少于' + obj.min);
+                            } else {
+                                ctrlObj.baseOpr('insert', parseInt(obj.value));
+                                root.$menu.trigger('contextmenu:hide');
+                            }
+                        },
+                    },
                     'delete': {
                         name: '删除',
                         icon: 'fa-remove',
@@ -470,7 +512,8 @@ $(document).ready(function() {
         relaPosSpread: posSpread,
         updateUrl: window.location.pathname + '/tag',
         afterModify: function (nodes) {
-            SpreadJsObj.repaintNodesRowHeader(ledgerSpread.getActiveSheet(), nodes);
+            ledgerSpread.getActiveSheet().repaint();
+            if (nodes[0].pos_id && posSpread) posSpread.getActiveSheet().repaint();
         },
         afterLocated:  function (lid, pos_id) {
             posOperationObj.loadCurPosData();
@@ -1746,6 +1789,78 @@ $(document).ready(function() {
         });
     }
 
+    const calcTemplateSelect = (function(){
+        let ctrlBills = {}, first = 1;
+        const tree = createNewPathTree('gather', {
+            id: 'tree_id', pid: 'tree_pid', order: 'tree_order',
+            level: 'tree_level', isLeaf: 'tree_is_leaf', fullPath: 'tree_full_path',
+            rootId: -1, calcFields: [], keys: ['id', 'master_id', 'tree_id'],
+        });
+        tree.loadDatas(templateFolder);
+        const templates = createAncillaryGcl({ id: 'id', masterId: 'folder_id', sort: [['create_time', 'desc']] });
+        templates.loadDatas(posCalcTemplate);
+        templates.eachPart(function(masterId, items) {
+            const parent = masterId ? tree.nodes.find(x => { return x.id === masterId; }) : null;
+            for (const i of items) {
+                i.is_calc_template = 1;
+                tree.addNode(i, parent, false);
+            }
+        });
+        tree.sortTreeNode();
+        const spread = SpreadJsObj.createNewSpread($('#sct-spread')[0]);
+        const sheet = spread.getActiveSheet();
+        SpreadJsObj.initSheet(sheet, {
+            cols: [
+                {title: '名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 300, formatter: '@', cellType: 'tree'},
+                // {title: '创建人', colSpan: '1', rowSpan: '1', field: 'user_name', hAlign: 0, width: 80, formatter: '@'},
+            ],
+            emptyRows: 0,
+            headRows: 1,
+            headRowHeight: [32],
+            defaultRowHeight: 21,
+            headerFont: '12px 微软雅黑',
+            font: '12px 微软雅黑',
+            readOnly: true,
+            getFont: function (sheet, data, row, col, defaultFont) {
+                return !data.is_calc_template ? defaultFont : 'bold ' + defaultFont;
+            },
+            getColor: function(sheet, data, row, col, defaultColor) {
+                return data && data.id === ctrlBills.calc_template ? spreadColor.common.invalid : defaultColor;
+            }
+        });
+        SpreadJsObj.loadSheetData(sheet, SpreadJsObj.DataType.Tree, tree);
+        const select = function(node) {
+            ctrlBills = node;
+            SpreadJsObj.reloadRowBackColor(sheet, 0, tree.nodes.length);
+            $('#select-calc-template').modal('show');
+        };
+        $('#select-calc-template-ok').click(function() {
+            const ct = SpreadJsObj.getSelectObject(sheet);
+            if (!ct) return;
+            if (!ct.is_calc_template) {
+                toastr.warning('请勿选择文件夹');
+                return;
+            }
+            if (ct.id === ctrlBills.calc_template) {
+                toastr.warning('选择的计算模板与之前相同');
+                return;
+            }
+            const updateData = {postType: 'extra', postData: { id: ctrlBills.id, tender_id: ctrlBills.tender_id, ledger_id: ctrlBills.ledger_id, calc_template: ct.id }};
+            postData(window.location.pathname + '/update', updateData, function (result) {
+                const refreshNode = ledgerTree.loadPostData(result);
+                treeOperationObj.refreshTree(ledgerSheet, refreshNode);
+                posCalcDetail.loadCurDetailData();
+                $('#select-calc-template').modal('hide');
+            });
+        });
+        $('#select-calc-template').on('shown.bs.modal', function() {
+            if (first) {
+                spread.refresh();
+                first = 0;
+            }
+        });
+        return { select }
+    })();
     let batchInsertObj, contractExprObj;
     $.contextMenu.types.batchInsert = function (item, opt, root) {
         const self = this;
@@ -1873,6 +1988,20 @@ $(document).ready(function() {
         billsContextMenuOptions.items.sprBase = '----';
     }
     if (!readOnly) {
+        billsContextMenuOptions.items.selectCalcTmpl = {
+            name: '选择计算模板',
+            callback: function(key, opt) {
+                const node = SpreadJsObj.getSelectObject(ledgerSheet);
+                calcTemplateSelect.select(node);
+            },
+            disabled: function(key, opt) {
+                const node = SpreadJsObj.getSelectObject(ledgerSheet);
+                return !node || !node.b_code;
+            },
+            visible: function(key, opt) {
+                return !readOnly;
+            }
+        };
         billsContextMenuOptions.items.batchInsert = {
             name: '批量插入',
             type: 'batchInsert',
@@ -2868,6 +2997,49 @@ $(document).ready(function() {
                 posOperationObj.loadCurPosData();
                 treeOperationObj.refreshOperationValid(ledgerSpread.getActiveSheet());
             });
+        },
+        getBlockData: function() {
+            const copyBlockList = [];
+            const sheet = posSpread.getActiveSheet();
+            const sel = sheet.getSelections()[0];
+            let iRow = sel.row;
+            const node = SpreadJsObj.getSelectObject(ledgerSheet);
+            while (iRow < sel.row + sel.rowCount) {
+                const p = sheet.zh_data[iRow];
+                if (!p) break;
+                copyBlockList.push(p);
+                if (node.calc_template) {
+                    const detailRange = posCalcDetail.detail.getPartData(p.id);
+                    p.calcDetail = detailRange;
+                }
+                iRow++;
+            }
+            return copyBlockList;
+        },
+        pasteBlock: function(copyInfo) {
+            const node = SpreadJsObj.getSelectObject(ledgerSheet);
+            if (!node) {
+                toastr.error('数据错误,请选择台账节点后再试');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            } else if (node.children && node.children.length > 0) {
+                toastr.error('父节点不可插入计量单元');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }else if (!node.b_code || node.b_code === '') {
+                toastr.error('项目节不可插入计量单元');
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+            const data = { updateType: 'paste-block', updateData: { lid: node.id, tid: copyInfo.tid, block: copyInfo.block } };
+            postData('/tender/' + getTenderId() + '/pos/update', data, function (result) {
+                pos.updateDatas(result.pos);
+                if (result.posCalcDetail) posCalcDetail.detail.updateDatas(result.posCalcDetail);
+                const ledgerResult = ledgerTree.loadPostData(data.ledger);
+                treeOperationObj.refreshTree(ledgerSheet, ledgerResult);
+                removeLocalCache(copyBlockTag);
+                posOperationObj.loadCurPosData();
+            });
         }
     };
     posSpread.bind(spreadNS.Events.SelectionChanged, posOperationObj.selectionChanged);
@@ -2969,6 +3141,41 @@ $(document).ready(function() {
                     return !readOnly;
                 }
             },
+            copyBlock: {
+                name: '复制整块',
+                icon: 'fa-files-o',
+                callback: function (key, opt) {
+                    posOperationObj.block = [];
+                    const copyBlockList = posOperationObj.getBlockData();
+                    setLocalCache(copyBlockTag, JSON.stringify({ block: copyBlockList, tag: 'pos' }));
+                },
+                disabled: function (key, opt) {
+                    const node = SpreadJsObj.getSelectObject(posSheet);
+                    return !node;
+                }
+            },
+            pasteBlock: {
+                name: '粘贴整块',
+                icon: 'fa-clipboard',
+                disabled: function (key, opt) {
+                    const node = SpreadJsObj.getSelectObject(ledgerSheet);
+                    if (!node || (node.children && node.children.length > 0) || node._invalid || node.code) return true;
+
+                    const copyInfo = JSON.parse(getLocalCache(copyBlockTag));
+                    return !(copyInfo && copyInfo.block && copyInfo.block.length > 0 && copyInfo.tag === 'pos');
+                },
+                callback: function (key, opt) {
+                    const copyInfo = JSON.parse(getLocalCache(copyBlockTag));
+                    if (copyInfo.block.length > 0) {
+                        posOperationObj.pasteBlock(copyInfo);
+                    } else {
+                        document.execCommand('paste');
+                    }
+                },
+                visible: function (key, opt) {
+                    return !readOnly;
+                }
+            },
             'merge-peg': {
                 name: '合并起讫桩号',
                 disabled: function (key, opt) {

+ 1 - 1
app/public/js/measure_stage.js

@@ -228,7 +228,7 @@ $(function () {
         onSelect: function (formattedDate, date, inst) {
             if (!date) return;
             $('#stage-period').val(`${formattedDate}-01`);
-            stagePeriod.clear();;
+            stagePeriod.clear();
             stagePeriod.selectDate(new Date(date.getFullYear(), date.getMonth(), 1));
         }
     }).data('datepicker');

+ 33 - 1
app/public/js/path_tree.js

@@ -1761,6 +1761,12 @@ const createNewPathTree = function (type, setting) {
             return this._maxId;
         }
 
+        loadDatas(datas){
+            super.loadDatas(datas);
+            const setting = this.setting;
+            this._maxId = datas.length > 0 ? datas.map(x => { return x[setting.id]; }).reduce((a, b) => { return Math.max(a, b)}) : 0;
+        }
+
         addNode(data, parent, checkSame = true) {
             data[this.setting.pid] = parent ? parent[this.setting.id] : this.setting.rootId;
             // let item = _.find(this.items, data);
@@ -1771,7 +1777,7 @@ const createNewPathTree = function (type, setting) {
             item[this.setting.id] = this.newId;
             const keyName = itemsPre + item[this.setting.id];
             item.children = [];
-            item.is_leaf = true;
+            item[this.setting.isLeaf] = true;
             item.expanded = true;
             item.visible = true;
             this.items[keyName] = item;
@@ -2250,11 +2256,31 @@ const createAncillaryGcl = function (setting) {
             }
         }
 
+        _moveDatas(data, resort) {
+            const datas = data instanceof Array ? data : [data];
+            for (const d of datas) {
+                const item = this.getItem(d[this.setting.id]);
+                if (!item) continue;
+
+                const oMasterKey = this.itemPre + item[this.setting.masterId];
+                if (resort.indexOf(oMasterKey) < 0) resort.push(oMasterKey);
+                this.masterIndex[oMasterKey].splice(this.masterIndex[oMasterKey].indexOf(item), 1);
+
+                item[this.setting.masterId] = d[this.setting.masterId];
+
+                const nMasterKey = this.itemPre + item[this.setting.masterId];
+                if (!this.masterIndex[nMasterKey]) this.masterIndex[nMasterKey] = [];
+                this.masterIndex[nMasterKey].push(item);
+                if (resort.indexOf(nMasterKey) < 0) resort.push(nMasterKey);
+            }
+        }
+
         updateDatas(data) {
             const resort = [];
             if (data.add) this._addDatas(data.add, resort);
             if (data.del) this._removeDatas(data.del, resort);
             if (data.update) this._updateDatas(data.update, resort);
+            if (data.move) this._moveDatas(data.move, resort);
             for (const s of resort) {
                 this.resortPart(this.masterIndex[s]);
                 this.filterPart(this.masterIndex[s]);
@@ -2299,6 +2325,12 @@ const createAncillaryGcl = function (setting) {
                 this.filterPart(this.masterIndex[key]);
             }
         }
+
+        eachPart(fun) {
+            for (const prop in this.masterIndex) {
+                fun(prop.substring(3, prop.length), this.masterIndex[prop]);
+            }
+        }
     }
 
     return new AncillaryGcl(setting);

+ 557 - 117
app/public/js/pos_calc_tmpl.js

@@ -1,5 +1,479 @@
 $(document).ready(() => {
     autoFlashHeight();
+    class FolderObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#folder-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.treeSetting = {
+                id: 'tree_id', pid: 'tree_pid', order: 'tree_order',
+                level: 'tree_level', isLeaf: 'tree_is_leaf', fullPath: 'tree_full_path',
+                rootId: -1, calcFields: [], keys: ['id', 'master_id', 'tree_id'],
+            };
+            this.tree = createNewPathTree('base', this.treeSetting);
+            this.spreadSetting = {
+                cols: [
+                    {title: '名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 330, formatter: '@', cellType: 'tree'},
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                readOnly: readOnly,
+            };
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            if (readOnly) return;
+
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            // 增删上下移升降级
+            $('a[name="base-opr"]').click(function () {
+                folderObj.baseOpr(this.getAttribute('type'));
+            });
+        }
+        refreshOperationValid() {
+            const setObjEnable = function (obj, enable) {
+                if (enable) {
+                    obj.removeClass('disabled');
+                } else {
+                    obj.addClass('disabled');
+                }
+            };
+            const invalidAll = function () {
+                setObjEnable($('a[name=base-opr][type=add]'), false);
+                setObjEnable($('a[name=base-opr][type=delete]'), false);
+                setObjEnable($('a[name=base-opr][type=up-move]'), false);
+                setObjEnable($('a[name=base-opr][type=down-move]'), false);
+                setObjEnable($('a[name=base-opr][type=up-level]'), false);
+                setObjEnable($('a[name=base-opr][type=down-level]'), false);
+            };
+            const sel = this.sheet.getSelections()[0];
+            const row = sel ? sel.row : -1;
+            const tree = this.sheet.zh_tree;
+            if (!tree) {
+                invalidAll();
+                return;
+            }
+            const first = tree.nodes[row];
+            let last = first, sameParent = true;
+            if (sel.rowCount > 1 && first) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = tree.nodes[sel.row + r];
+                    if (!rNode) {
+                        sameParent = false;
+                        break;
+                    }
+                    if (rNode.tree_level > first.tree_level) continue;
+                    if ((rNode.tree_level < first.tree_level) || (rNode.tree_level === first.tree_level && rNode.tree_pid !== first.tree_pid)) {
+                        sameParent = false;
+                        break;
+                    }
+                    last = rNode;
+                }
+            }
+            const preNode = tree.getPreSiblingNode(first);
+            const valid = !this.sheet.zh_setting.readOnly;
+
+            setObjEnable($('a[name=base-opr][type=add]'), valid);
+            setObjEnable($('a[name=base-opr][type=delete]'), valid && first && sameParent);
+            setObjEnable($('a[name=base-opr][type=up-move]'), valid && first && sameParent && preNode);
+            setObjEnable($('a[name=base-opr][type=down-move]'), valid && first && sameParent && !tree.isLastSibling(last));
+            setObjEnable($('a[name=base-opr][type=up-level]'), valid && first && sameParent && tree.getParent(first) && first.tree_level > 1);
+            setObjEnable($('a[name=base-opr][type=down-level]'), valid && first && sameParent && preNode);
+        }
+        loadRelaData() {
+            this.refreshOperationValid();
+            templateObj.loadDetailData(SpreadJsObj.getSelectObject(this.sheet));
+        }
+        refreshTree(data) {
+            const sheet = this.sheet;
+            SpreadJsObj.massOperationSheet(sheet, function () {
+                const tree = sheet.zh_tree;
+                // 处理删除
+                if (data.delete) {
+                    data.delete.sort(function (a, b) {
+                        return b.deleteIndex - a.deleteIndex;
+                    });
+                    for (const d of data.delete) {
+                        sheet.deleteRows(d.deleteIndex, 1);
+                    }
+                }
+                // 处理新增
+                if (data.create) {
+                    const newNodes = data.create;
+                    if (newNodes) {
+                        newNodes.sort(function (a, b) {
+                            return a.index - b.index;
+                        });
+
+                        for (const node of newNodes) {
+                            sheet.addRows(node.index, 1);
+                            SpreadJsObj.reLoadRowData(sheet, tree.nodes.indexOf(node), 1);
+                        }
+                    }
+                }
+                // 处理更新
+                if (data.update) {
+                    const rows = [];
+                    for (const u of data.update) {
+                        rows.push(tree.nodes.indexOf(u));
+                    }
+                    SpreadJsObj.reLoadRowsData(sheet, rows);
+                }
+                // 处理展开
+                if (data.expand) {
+                    const expanded = [];
+                    for (const e of data.expand) {
+                        if (expanded.indexOf(e) === -1) {
+                            const posterity = tree.getPosterity(e);
+                            for (const p of posterity) {
+                                sheet.setRowVisible(tree.nodes.indexOf(p), p.visible);
+                                expanded.push(p);
+                            }
+                        }
+                    }
+                }
+            });
+        }
+        loadData(datas) {
+            this.tree.loadDatas(datas);
+            treeCalc.calculateAll(this.tree);
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.tree);
+            this.refreshOperationValid();
+        }
+        getDefaultSelectInfo() {
+            if (!this.tree) return;
+            const sel = this.sheet.getSelections()[0];
+            const node = this.sheet.zh_tree.nodes[sel.row];
+            let count = 1;
+            if (!node) return [this.tree, null, count];
+            if (sel.rowCount > 1) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = this.sheet.zh_tree.nodes[sel.row + r];
+                    if (rNode.tree_level > node.tree_level) continue;
+                    if ((rNode.tree_level < node.tree_level) || (rNode.tree_level === node.tree_level && rNode.tree_pid !== node.tree_pid)) {
+                        toastr.warning('请选择同一节点下的节点,进行该操作');
+                        return;
+                    }
+                    count += 1;
+                }
+            }
+            return [this.tree, node, count];
+        }
+        baseOpr(type, addCount = 1) {
+            const self = this;
+            const sheet = self.sheet;
+            const sel = sheet.getSelections()[0];
+            const [tree, node, count] = this.getDefaultSelectInfo();
+            if (!tree || !count) return;
+
+            const updateData = {
+                target: 'posCalc',
+                postType: type,
+                postData: {
+                    count: type === 'add' ? addCount : count,
+                }
+            };
+            if (node) updateData.postData.id = node.tree_id;
+            if (type === 'delete') {
+                deleteAfterHint(function () {
+                    postData('folder', updateData, function (result) {
+                        const refreshData = tree.loadPostData(result);
+                        self.refreshTree(refreshData);
+                        if (sel) {
+                            sheet.setSelection(sel.row, sel.col, 1, sel.colCount);
+                        }
+                        self.refreshOperationValid();
+                        self.loadRelaData();
+                    });
+                }, '确认删除「当前分类」?');
+            } else {
+                postData('folder', updateData, function (result) {
+                    const refreshData = tree.loadPostData(result);
+                    self.refreshTree(refreshData);
+                    if (['up-move', 'down-move'].indexOf(type) > -1) {
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(node), sel.col, sel.rowCount, sel.colCount);
+                        }
+                    } else if (type === 'add') {
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(refreshData.create[0]), sel.col, sel.rowCount, sel.colCount);
+                        }
+                        self.loadRelaData();
+                    }
+                    self.refreshOperationValid();
+                });
+            }
+        }
+        // 事件
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    folderObj.loadRelaData();
+                }
+            }
+        }
+        editEnded(e, info) {
+            if (!info.sheet.zh_setting) return;
+
+            const tree = info.sheet.zh_tree;
+            const node = SpreadJsObj.getSelectObject(info.sheet);
+            const data = tree.getNodeKeyData(node);
+            // 未改变值则不提交
+            const col = info.sheet.zh_setting.cols[info.col];
+            if (col.field !== 'name') return;
+
+            const orgValue = node[col.field];
+            const newValue = trimInvalidChar(info.editingText);
+            if (orgValue == info.editingText || ((!orgValue || orgValue === '') && (newValue === ''))) return;
+
+            if (info.editingText) {
+                const text = newValue;
+                if (col.type === 'Number') {
+                    const num = _.toNumber(text);
+                    if (_.isFinite(num)) {
+                        data[col.field] = num;
+                    } else {
+                        try {
+                            data[col.field] = ZhCalc.mathCalcExpr(transExpr(text));
+                        } catch(err) {
+                            toastr.error('输入的表达式非法');
+                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                            return;
+                        }
+                    }
+                } else {
+                    data[col.field] = text;
+                }
+            } else {
+                data[col.field] = col.type === 'Number' ? 0 : '';
+            }
+            // 更新至服务器
+            postData('folder', {target: 'posCalc', postType: 'update', postData: data}, function (result) {
+                const refreshNode = folderObj.tree.loadPostData(result);
+                folderObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_tree.nodes[iRow];
+                const data = sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('folder', {target: 'posCalc', postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = sheet.zh_tree.loadPostData(result);
+                    folderObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+        }
+    }
+    const folderObj = new FolderObj();
+    folderObj.loadData(folderList);
+    class TemplateObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#template-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.data = createAncillaryGcl({ id: 'id', masterId: 'folder_id', sort: [['create_time', 'desc']] });
+            this.templateSpreadSetting = {
+                cols: [
+                    {title: '名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 240, formatter: '@'},
+                    {title: '创建人', colSpan: '1', rowSpan: '1', field: 'user_name', hAlign: 1, width: 80, formatter: '@', readOnly: true},
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                headColWidth: [50],
+                getColor: function(sheet, data, row, col, defaultColor) {
+                    return data && data.used_count > 0 ? spreadColor.common.invalid : defaultColor;
+                },
+                rowHeader:[
+                    {
+                        rowHeaderType: 'circle',
+                        setting: {
+                            size: 5,
+                            indent: 16,
+                            getColor: function (index, data) {
+                                if (!data) return;
+                                if (data.used_count > 0) {
+                                    return '#dc3545';
+                                }
+                            }
+                        },
+                    },
+                ],
+            };
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.templateSpreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            if (readOnly) return;
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            // 增删上下移升降级
+            $('a[name="template-opr"]').click(function () {
+                templateObj.baseOpr(this.getAttribute('type'));
+            });
+        }
+        loadData(datas) {
+            this.data.loadDatas(datas);
+            this.loadDetailData(SpreadJsObj.getSelectObject(folderObj.sheet));
+        }
+        refreshSheet() {
+            if (this.sheet.zh_data.length === 0) {
+                this.reloadDetailData();
+            } else {
+                SpreadJsObj.reLoadSheetData(this.sheet);
+            }
+        }
+        reloadDetailData() {
+            const data = this.folder ? this.data.getPartData(this.folder.id) || [] : [];
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, data);
+        }
+        loadDetailData(folder) {
+            this.folder = folder;
+            this.reloadDetailData();
+            this.loadTemplate();
+        }
+        baseOpr(type) {
+            const self = this;
+            const curTemplate = SpreadJsObj.getSelectObject(this.sheet);
+            if (type === 'delete') {
+                deleteAfterHint(function () {
+                    postData('save', {del: curTemplate.id, type: 'posCalc'}, function(result) {
+                        templateObj.data.updateDatas({ del: curTemplate.id });
+                        templateObj.refreshSheet();
+                        templateObj.loadTemplate();
+                    });
+                }, '确认删除「当前模板数据」?');
+            } else if (type === 'add') {
+                postData('save', {add: { name: '', folder_id: self.folder.id }, type: 'posCalc'}, function(result) {
+                    templateObj.data.updateDatas({ add: result.add });
+                    templateObj.refreshSheet();
+                });
+            }
+        }
+        moveTemplate(templateIds, target, fun) {
+            postData('save', {move: templateIds, folder_id: target.id, type: 'posCalc'}, function(result) {
+                templateObj.data.updateDatas(result);
+                templateObj.refreshSheet();
+                fun();
+            });
+        }
+        async loadTemplateDetail(template) {
+            const result = await postDataAsync('load', {filter: 'detail', id: template.id, type: 'posCalc'});
+            if (result && result.detail) {
+                template.col_set = result.detail.col_set;
+                template.user_name = result.detail.user_name;
+                template.create_time_str = result.detail.create_time_str;
+            }
+        }
+        async loadTemplate() {
+            const curTemplate = SpreadJsObj.getSelectObject(this.sheet);
+            if (!curTemplate) {
+                $('#detail-user-info').html('');
+                $('#detail-ctrl').hide();
+            } else {
+                $('#detail-ctrl').hide();
+                if (!curTemplate.col_set) await this.loadTemplateDetail(curTemplate);
+                detailObj.loadDetail(curTemplate);
+                if (curTemplate.user_name) {
+                    $('#detail-user-info').html(`新建人:${curTemplate.user_name}(${curTemplate.create_time_str})`);
+                } else {
+                    $('#detail-user-info').html('');
+                }
+            }
+        }
+        // 事件
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    templateObj.loadTemplate();
+                }
+            }
+        }
+        editEnded(e, info) {
+            const setting = info.sheet.zh_setting;
+            if (!setting) return;
+            const detailData = SpreadJsObj.getSelectObject(info.sheet);
+            if (detailData.create_user_id !== userID) {
+                toastr.warning('不可修改');
+                return;
+            }
+
+            const col = setting.cols[info.col];
+            if (col.field !== 'name') return;
+            const orgText = detailData ? detailData[col.field] : '', newText = trimInvalidChar(info.editingText);
+            if (orgText === newText || (!orgText && !newText)) return;
+
+            postData('save', { update: { id: detailData.id, name: newText }, type: 'posCalc'}, function(result){
+                templateObj.data.updateDatas(result);
+                templateObj.refreshSheet();
+            });
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const col = setting.cols[info.col];
+            if (col.field !== 'name') return;
+            const detailData = SpreadJsObj.getSelectObject(info.sheet);
+            if (detailData.create_user_id !== userID) {
+                toastr.warning('不可修改');
+                return;
+            }
+            if (datas.length > 0) {
+                postData('update',  { update: { id: detailData.id, name: '' }, type: 'posCalc'}, function (result) {
+                    detailObj.data.updateDatas(result.detail);
+                    detailObj.refreshSheet();
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+        }
+    }
+    const templateObj = new TemplateObj();
+    templateObj.loadData(templateList);
+
     class TemplateDetailObj {
         constructor() {
             const self = this;
@@ -81,7 +555,7 @@ $(document).ready(() => {
                 } else if (col.field === 'rela_col') {
                     const exist = self.colSetData.find(x => { return x.type === 'num' && x.calc_code === validText });
                     if (!exist) {
-                        toastr.warning(`暂无计代号为${validText}的列,请先配置该数值列`);
+                        toastr.warning(`暂无计代号为${validText}的列,请先配置该数值列`);
                     } else {
                         select.rela_col = validText;
                     }
@@ -90,6 +564,57 @@ $(document).ready(() => {
                 }
                 SpreadJsObj.reLoadRowData(info.sheet, info.row);
             });
+            this.spread.bind(spreadNS.Events.ClipboardPasting, function(e, info) {
+                info.cancel = true;
+
+                const setting = info.sheet.zh_setting;
+                const sortData = info.sheet.zh_data || [];
+                const pasteData = SpreadJsObj.analysisPasteText(info.pasteData.text);
+                const row = [];
+                for (let iRow = 0; iRow < pasteData.length; iRow++) {
+                    const curRow = iRow + info.cellRange.row;
+                    const select = sortData[curRow];
+                    if (!select) continue;
+
+                    row.push(curRow);
+                    const typeInfo = validColInfo.find(x => { return x.key === select.type; });
+                    pasteData[iRow].forEach((value, iCol) => {
+                        const col = setting.cols[info.cellRange.col + iCol];
+                        if (!col || typeInfo.valid.indexOf(col.field) < 0) return;
+
+                        if (col.field === 'spec_set') {
+                            return;
+                        } else if (col.field === 'calc_code') {
+                            const exist = self.colSetData.find(x => { return x.type === 'num' && x.calc_code === value && x.field !== select.field; });
+                            if (exist) {
+                                toastr.warning('请勿输入重复的计算代号');
+                            } else {
+                                select.calc_code = value;
+                                select.field = 'num_' + select.calc_code.toLowerCase();
+                            }
+                        } else if (col.field === 'width') {
+                            select.width = parseInt(value);
+                        } else if (col.field === 'decimal') {
+                            const num = parseInt(value);
+                            if (num < 0 || num > 6) {
+                                toastr.warning('小数位数仅可保留0-6位');
+                            } else {
+                                select.decimal = num;
+                            }
+                        } else if (col.field === 'rela_col') {
+                            const exist = self.colSetData.find(x => { return x.type === 'num' && x.calc_code === value });
+                            if (!exist) {
+                                toastr.warning(`暂无计量代号为${value}的列,请先配置该数值列`);
+                            } else {
+                                select.rela_col = value;
+                            }
+                        } else {
+                            select[col.field] = value;
+                        }
+                    });
+                }
+                SpreadJsObj.reLoadRowsData(info.sheet, row);
+            });
             SpreadJsObj.addDeleteBind(this.spread, function(sheet){
                 if (!sheet.zh_setting) return;
 
@@ -283,85 +808,6 @@ $(document).ready(() => {
         calcTemplatePreview.preview(data);
     });
 
-    const templateObj = (function(list){
-        const templates = list;
-        let curTemplate;
-
-        const loadTemplateDetail = async function(template) {
-            const result = await postDataAsync('load', {filter: 'detail', id: template.id, type: 'posCalc'});
-            if (result && result.detail) {
-                template.col_set = result.detail.col_set;
-                template.user_name = result.detail.user_name;
-                template.create_time_str = result.detail.create_time_str;
-            }
-        };
-        const refreshTemplate = async function() {
-            if (!curTemplate) {
-                $('#detail-user-info').html('');
-            } else {
-                $('dd[templateId]').removeClass('bg-warning');
-                $(`dd[templateId=${curTemplate.id}]`).addClass('bg-warning');
-                if (!curTemplate.col_set) await loadTemplateDetail(curTemplate);
-                detailObj.loadDetail(curTemplate);
-                if (curTemplate.user_name) {
-                    $('#detail-user-info').html(`新建人:${curTemplate.user_name}(${curTemplate.create_time_str})`);
-                } else {
-                    $('#detail-user-info').html('');
-                }
-            }
-        };
-        const setCurTemplate = function(template) {
-            curTemplate = template;
-            refreshTemplate();
-        };
-        const getCurTemplate = function() {
-            return curTemplate;
-        };
-        const getTemplateCaptionInnerHtml = function(template) {
-            const usedHtml = template.used_count > 0 ? '<i class="ml-1 fa fa-lock text-danger"></i>' : '';
-            return `<div>${template.name}${usedHtml}</div>` +
-                '    <div class="btn-group-table" style="display: none;">\n' +
-                '    <a href="javascript: void(0);" class="mr-1" data-toggle="tooltip" data-placement="bottom" data-original-title="编辑" name="renameTemplate"><i class="fa fa-pencil fa-fw"></i></a>\n' +
-                '    <a href="javascript: void(0);" class="mr-1" data-toggle="tooltip" data-placement="bottom" data-original-title="删除" name="delTemplate"><i class="fa fa-trash-o fa-fw text-danger"></i></a>\n' +
-                '</div>';
-        };
-        const getTemplateCaptionHtml = function(template) {
-            return `<div class="d-flex justify-content-between align-items-center table-file" templateId="${template.id}">` + getTemplateCaptionInnerHtml(template) + '</div>';
-        };
-        const getTemplateHtml = function(template) {
-            const html = [];
-            html.push(`<dd class="list-group-item" templateId="${template.id}">`, getTemplateCaptionHtml(template), '</dd>');
-            return html.join('');
-        };
-
-        const addTemplate = function() {
-            postData('save', {add: { name: '' }, type: 'posCalc'}, function(result) {
-                templates.push(result.add);
-                $('#template-list').append(getTemplateHtml(result.add));
-            });
-        };
-        const renameTemplate = function(id, name) {
-            postData('save', { update: { id, name }, type: 'posCalc'}, function(result){
-                const template = templates.find(x => { return x.id === result.update.id; });
-                template.name = result.update.name;
-                $(`dd[templateId=${template.id}]`).html(getTemplateCaptionHtml(template));
-            });
-        };
-        const delTemplate = function(id){
-            postData('save', {del: id, type: 'posCalc'}, function(result) {
-                $(`dd[templateId=${id}]`).remove();
-                const tIndex = templates.findIndex(x => { return x.id === id; });
-                templates.splice(tIndex, 1);
-                if (curTemplate && curTemplate.id === id) {
-                    curTemplate = null;
-                    refreshTemplate();
-                }
-            });
-        };
-        if (templates.length > 0) setCurTemplate(templates[0]);
-        return { setCurTemplate, getCurTemplate, addTemplate, delTemplate, renameTemplate, getTemplateCaptionHtml, getTemplateCaptionInnerHtml }
-    })(templateList);
-
     $('body').on('click', '.table-file', function(e) {
         if (this.getAttribute('renaming') === '1') return;
         if (e.target.tagName === 'A' || e.target.tagName === 'I' || e.target.tagName === 'INPUT') return;
@@ -376,41 +822,6 @@ $(document).ready(() => {
         $(this).children(".btn-group-table").css("display","none");
     });
 
-    $('body').on('click', 'a[name=renameTemplate]', function(e){
-        $(this).parents('.table-file').attr('renaming', '1');
-        $(`#${this.getAttribute('aria-describedby')}`).remove();
-        const templateId = $(this).parents('.table-file').attr('templateId');
-        const template = templateList.find(x => { return x.id === templateId; });
-        if (!template) return;
-
-        const html = [];
-        html.push(`<div><input type="text" class="form-control form-control-sm" style="width: 160px" value="${template.name}"/></div>`);
-        html.push('<div class="btn-group-table" style="display: none;">',
-            `<a href="javascript: void(0)" name="renameOk" class="mr-1"><i class="fa fa-check fa-fw"></i></a>`,
-            `<a href="javascript: void(0)" class="mr-1" name="renameCancel"><i class="fa fa-remove fa-fw text-danger"></i></a>`, '</div>');
-        $(`.table-file[templateId=${templateId}]`).html(html.join(''));
-        e.stopPropagation();
-    });
-    $('body').on('click', 'a[name=renameOk]', function(){
-        const templateId = $(this).parents('.table-file').attr('templateId');
-        const newName = $(this).parents('.table-file').find('input').val();
-        templateObj.renameTemplate(templateId, newName);
-        $(this).parents('.table-file').attr('renaming', '0');
-    });
-    $('body').on('click', 'a[name=renameCancel]', function() {
-        $(this).parents('.table-file').attr('renaming', '0');
-        const templateId = $(this).parents('.table-file').attr('templateId');
-        const template = templateList.find(x => { return x.id === templateId; });
-        if (!template) return;
-
-        $(`.table-file[templateId=${templateId}]`).html(templateObj.getTemplateCaptionInnerHtml(template));
-    });
-    $('body').on('click', 'a[name=delTemplate]', function(e) {
-        e.stopPropagation();
-        const templateId = $(this).parents('.table-file').attr('templateId');
-        templateObj.delTemplate(templateId);
-    });
-
     $('#export').click(function() {
         detailObj.export();
     });
@@ -418,7 +829,36 @@ $(document).ready(() => {
         detailObj.import();
     });
 
-    $('#addTemplate').click(function() {
-        templateObj.addTemplate();
+    $('#rela-no-folder').on('show.bs.modal', function() {
+        const html = [];
+        templateObj.data.eachPart(function(masterId, array) {
+            if (masterId && folderObj.tree.nodes.find(x => { return x.id === masterId; })) return;
+            for (const a of array) {
+                html.push('<tr>');
+                html.push(`<td class="text-center"><input type="checkbox" tmplid="${a.id}" ${(a.create_user_id === userID || !readOnly ? '' : 'readOnly')}></td>`);
+                html.push(`<td>${a.name}</td>`);
+                html.push(`<td class="text-center">${a.user_name}</td>`);
+                html.push('<tr/>');
+            }
+        });
+        $('#no-folder-list').html(html.join(''));
     });
+    $('#rela-no-folder-ok').click(function() {
+        const node = SpreadJsObj.getSelectObject(folderObj.sheet);
+        if (!node) {
+            toastr.warning('请创建或选择模板分类');
+            return;
+        }
+        const templateIds = [];
+        for (const c of $('input[tmplid]:checked')) {
+            templateIds.push(c.getAttribute('tmplId'));
+        }
+        if (!templateIds) {
+            toastr.warning('请选择模板');
+            return;
+        }
+        templateObj.moveTemplate(templateIds, node, function() {
+            $('#rela-no-folder').modal('hide');
+        });
+    })
 });

+ 1 - 1
app/public/js/revise.js

@@ -195,7 +195,7 @@ $(document).ready(() => {
         relaSpread: billsSpread,
         updateUrl: window.location.pathname + '/tag',
         afterModify: function (nodes) {
-            SpreadJsObj.repaintNodesRowHeader(billsSpread.getActiveSheet(), nodes);
+            billsSheet.repaint();
         },
         afterLocated:  function () {
             posSpreadObj.loadCurPosData();

+ 1 - 1
app/public/js/settle_ledger.js

@@ -201,7 +201,7 @@ $(document).ready(() => {
         key: 'lid',
         treeId: 'tree_id',
         afterModify: function (nodes) {
-            SpreadJsObj.repaintNodesRowHeader(slSheet, nodes);
+            slSheet.repaint();
         },
         afterLocated:  function () {
             settlePosObj.loadCurPosData();

+ 17 - 13
app/public/js/shares/cs_tools.js

@@ -1016,7 +1016,6 @@ const showSelectTab = function(select, spread, afterShow) {
         });
         return {spread: resultSpread};
     };
-
     $.xmjSearch = function (setting) {
         if (!setting.selector || !setting.searchSpread) return;
         if (!setting.searchRangeStr) setting.searchRangeStr = '输入项目节编号、细目、计量单元查找';
@@ -1130,6 +1129,11 @@ const showSelectTab = function(select, spread, afterShow) {
         ];
         if (!setting.key) setting.key = 'id';
         if (!setting.treeId) setting.treeId = 'ledger_id';
+        if (!setting.user_id) setting.user_id = 'uid';
+        if (!setting.user_name) setting.user_name = 'u_name';
+        if (!setting.lid) setting.lid = 'lid';
+        if (!setting.pos_id) setting.pos_id = 'pos_id';
+        if (!setting.posMasterId) setting.posMasterId = 'lid';
         const obj = $(setting.selector);
         const relaTree = setting.relaTree;
         const html = [], pageLength = 15;
@@ -1218,12 +1222,12 @@ const showSelectTab = function(select, spread, afterShow) {
                 tagHtml.push((tag.node.code || '') + (tag.node.b_code || ''), ' / ', tag.node.name || '', posHint);
             }
             if (tag.share) {
-                tagHtml.push(`<div class="pull-right"><i class="fa fa-users text-warning" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="所有参与台账审批管理的用户都可以看到这条书签"></i> <span>${tag.u_name}</span></div>`);
+                tagHtml.push(`<div class="pull-right"><i class="fa fa-users text-warning" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="所有参与台账审批管理的用户都可以看到这条书签"></i> <span>${tag[setting.user_name]}</span></div>`);
             }
             tagHtml.push('<div class="pull-right edit-tag-btn">');
             const lid = tag.node ? tag.node[setting.treeId] : -1;
-            tagHtml.push(`<a class="mr-1" name="bills-tag-locate" href="javascript: void(0);" lid="${lid}" pos_id="${tag.pos_id}"><i class="fa fa-crosshairs"></i> 定位</a>`);
-            if (tag.uid === userID && !setting.readOnly) tagHtml.push(`<a href="javascript: void(0);" name="bills-tag-edit" tag-id="${tag.id}"><i class="fa fa-edit"></i> 编辑</a>`);
+            tagHtml.push(`<a class="mr-1" name="bills-tag-locate" href="javascript: void(0);" lid="${lid}" pos_id="${tag[setting.pos_id]}"><i class="fa fa-crosshairs"></i> 定位</a>`);
+            if (tag[setting.user_id] === userID && !setting.readOnly) tagHtml.push(`<a href="javascript: void(0);" name="bills-tag-edit" tag-id="${tag.id}"><i class="fa fa-edit"></i> 编辑</a>`);
             tagHtml.push('</div>');
             if (tag.node && relaTree) {
                 const parents = relaTree.getAllParents(tag.node);
@@ -1269,8 +1273,8 @@ const showSelectTab = function(select, spread, afterShow) {
         const refreshPosTagView = function(pos) {
             const posRange = pos instanceof Array ? pos : [pos];
             for (const p of posRange) {
-                const bi = billsIndexes[p.lid] || [];
-                const pi = bi.filter(x => { return x.pos_id === p.id; });
+                const bi = billsIndexes[p[setting.lid]] || [];
+                const pi = bi.filter(x => { return x[setting.pos_id] === p.id; });
 
                 for (const tag of pi) {
                     refreshTagView(tag);
@@ -1313,10 +1317,10 @@ const showSelectTab = function(select, spread, afterShow) {
         };
 
         const _addToBillsIndex = function(data, isTop = false) {
-            let bi = billsIndexes[data.lid];
+            let bi = billsIndexes[data[setting.lid]];
             if (!bi) {
                 bi = [];
-                billsIndexes[data.lid] = bi;
+                billsIndexes[data[setting.lid]] = bi;
             }
             isTop ? bi.unshift(data) : bi.push(data);
         };
@@ -1394,12 +1398,12 @@ const showSelectTab = function(select, spread, afterShow) {
         };
         const getPosTagsColor = function (lid, pos_id) {
             const billsTags = billsIndexes[lid] || [];
-            const posTags = billsTags.filter(x => { return x.pos_id === pos_id; });
+            const posTags = billsTags.filter(x => { return x[setting.pos_id] === pos_id; });
             return posTags.length > 0 ? posTags.map(x => {return x.color}) : undefined;
         };
         const getPosTagsInfo = function(lid, pos_id) {
             const billsTags = billsIndexes[lid] || [];
-            const posTags = billsTags.filter(x => { return x.pos_id === pos_id; });
+            const posTags = billsTags.filter(x => { return x[setting.pos_id] === pos_id; });
             return posTags.length > 0 ? posTags.map(x => {
                 const tagClass = classIndexes.find(tc => {return tc.color === x.color}) || {};
                 return {color: x.color, comment: x.comment, tagClass: tagClass.tagClass};
@@ -1420,10 +1424,10 @@ const showSelectTab = function(select, spread, afterShow) {
         };
         const afterDeletePos = function (pos) {
             for (const p of pos) {
-                const bi = billsIndexes[p.lid];
+                const bi = billsIndexes[p[setting.posMasterId]];
                 if (!bi) continue;
 
-                const pi = bi.filter(x => { return x.pos_id === p.id; });
+                const pi = bi.filter(x => { return x[setting.pos_id] === p.id; });
                 for (const piTag of pi) {
                     const delTag = billsTags.find(x => {return x.id === piTag.id});
                     billsTags.splice(billsTags.indexOf(delTag), 1);
@@ -1518,7 +1522,7 @@ const showSelectTab = function(select, spread, afterShow) {
         $('#bills-tag-search').bind('click', () => {searchTagsAndShow();});
         $('#bills-tag-keyword').bind('keydown', e => {if (e.keyCode === 13) searchTagsAndShow();});
 
-        return { loadDatas, updateDatasAndShow, show,
+        return { setting: setting, loadDatas, updateDatasAndShow, show,
             getBillsTagsColor, getBillsTagsInfo, getPosTagsColor, getPosTagsInfo,
             refreshBillsTagView, refreshPosTagView,
             afterDeleteBills, afterDeletePos }

+ 6 - 6
app/public/js/shares/new_tag.js

@@ -7,9 +7,9 @@ const newTag = function (setting) {
     const addTag = function (node, pos) {
         relaNode = node;
         relaPos = pos;
-        const code = (node.code || '') + (node.b_code || '');
+        const nodeInfo = (node.code || '') + (node.b_code || '') + (node.name ? ' / ' + node.name : '');
         const posInfo = pos ? ' - ' + pos.name : '';
-        $('#addtag-info').html(code + ' / ' + node.name + posInfo);
+        $('#addtag-info').html(nodeInfo + posInfo);
         $('#addtag').modal('show');
         $('#addtag-content').val('');
     };
@@ -21,20 +21,20 @@ const newTag = function (setting) {
         const data = {
             add: {
                 color: $('.active[name=addtag-color]').attr('tag-color'),
-                lid: setting.key ? relaNode[setting.key] : relaNode.id,
-                pos_id: relaPos ? (setting.key ? relaPos[setting.key] : relaPos.id) : '',
                 share: $('#addtag-share')[0].checked,
                 comment: $('#addtag-content').val(),
             }
         };
+        data.add[billsTag.setting.lid] = setting.key ? relaNode[setting.key] : relaNode.id;
+        data.add[billsTag.setting.pos_id] = relaPos ? (setting.key ? relaPos[setting.key] : relaPos.id) : '';
         postData(window.location.pathname + '/tag', data, result => {
             if (result.add) {
                 result.add.node = relaNode;
                 result.add.pos = relaPos;
             }
             billsTag.updateDatasAndShow(result);
-            SpreadJsObj.repaintNodesRowHeader(billsSheet, relaNode);
-            if (relaPos) SpreadJsObj.repaintNodesRowHeader(billsSheet, relaPos);
+            billsSheet.repaint();
+            posSheet.repaint();
             $('#addtag').modal('hide');
         });
     });

+ 53 - 8
app/public/js/shares/tools_att.js

@@ -16,7 +16,7 @@
         const obj = $(setting.selector);
         const fileInfo = setting.fileInfo || { user_name: 'username', user_id: 'uid', create_time: 'in_time' };
         const pageLength = 20;
-        let curNode = null, curPage = 0;
+        let curNode = null, curSubNode = null, curPage = 0;
         let ctrlLength = 80;
         if (setting.saveUrl) ctrlLength = ctrlLength + 15;
         if (setting.moveUrl) ctrlLength = ctrlLength + 15;
@@ -69,7 +69,7 @@
         autoFlashHeight();
         $('#att-cur-button')[0].click();
 
-        let allAtts = [], nodeIndexes = {};
+        let allAtts = [], nodeIndexes = {}, subNodeIndexes = {};
 
         const getNodeHint = function(node) {
             return setting.getCurHint ? setting.getCurHint(node) : `${node.code || node.b_code || ''}/${node.name || ''}`;
@@ -99,7 +99,7 @@
         };
         const refreshCurAttHtml = function () {
             const html = [];
-            const atts = curNode ? (nodeIndexes[curNode[setting.key]]) || [] : [];
+            const atts = curSubNode ? (subNodeIndexes[curSubNode[setting.subKey]] || []) : (curNode ? (nodeIndexes[curNode[setting.key]]) || [] : []);
             for (const att of atts) {
                 html.push(getAttHtml(att));
             }
@@ -126,10 +126,16 @@
             $('#att-cur-page').text(curPage);
             refreshAllAttHtml();
         };
-        const getCurAttHtml = function (node) {
+        const getCurAttHtml = function (node, subNode, check) {
+            if (check && node === curNode && subNode === curSubNode) return;
             curNode = node;
+            curSubNode = subNode;
             if (curNode) {
-                $('#att-cur-hint').text(getNodeHint(curNode));
+                if (subNode) {
+                    $('#att-cur-hint').text(getNodeHint(curNode)+ '(' + getNodeHint(subNode) + ')');
+                } else {
+                    $('#att-cur-hint').text(getNodeHint(curNode));
+                }
             } else {
                 $('#att-cur-hint').text('');
             }
@@ -250,7 +256,7 @@
                 this.showMoveTree();
                 $('#move-att2').modal('show');
             },
-            moveoNodeIndex: function(data) {
+            moveNodeIndex: function(data) {
                 const oxi = nodeIndexes[this.file[setting.masterKey]];
                 if (oxi) {
                     const oxii = findFileIndex(oxi, this.file.id);
@@ -342,6 +348,7 @@
             const files = $('#upload-file')[0].files;
             const formData = new FormData();
             formData.append(setting.masterKey, curNode[setting.key]);
+            if (curSubNode && setting.subMasterKey) formData.append(setting.subMasterKey, curSubNode[setting.subKey]);
             for (const file of files) {
                 if (file === undefined) {
                     toastr.error('未选择上传文件!');
@@ -364,6 +371,7 @@
                 // 插入到attData中
                 data.forEach(d => {
                     d.node = curNode;
+                    d.sub_node = curSubNode;
                     allAtts.push(d);
                     _addToNodeIndex(d, true);
                 });
@@ -400,11 +408,13 @@
             }
             const data = {};
             data[setting.masterKey] = curNode[setting.key];
+            if (curSubNode && setting.subMasterKey) data[setting.subMasterKey] = curNode[setting.subKey];
             AliOss.uploadBigFile(file, setting.uploadBigUrl, data,
                 { progressObj: $('#upload-big-file-progress'), resumeObj: $('#add-big-file-resume'), stopObj: $('#add-big-file-stop') },
                 function(result) {
                     result.forEach(d => {
                         d.node = curNode;
+                        d.sub_node = curSubNode;
                         allAtts.push(d);
                         _addToNodeIndex(d, true);
                     });
@@ -429,6 +439,10 @@
                 allAtts.splice(att_index, 1);
                 const xi = nodeIndexes[att.node[setting.key]];
                 xi.splice(findFileIndex(xi, fid), 1);
+                if (setting.subMasterKey && att.sub_node) {
+                    const sxi = subNodeIndexes[att.sub_node[setting.subKey]];
+                    sxi.splice(findFileIndex(sxi, fid), 1);
+                }
                 // 重新生成List
                 if (allAtts.length === 1) {
                     getAllAttHtml();
@@ -505,7 +519,7 @@
             const updateData = { id: moveObj.file.id };
             updateData[setting.masterKey] = select[setting.key];
             postData(setting.moveUrl, updateData, function (result) {
-                moveObj.moveoNodeIndex(result);
+                moveObj.moveNodeIndex(result);
                 refreshCurAttHtml();
                 $('#move-att2').modal('hide');
             });
@@ -542,6 +556,15 @@
             }
             let xi = nodeIndexes[id];
             isTop ? xi.unshift(att) : xi.push(att);
+
+            if (setting.subMasterKey && att.sub_node) {
+                const sid = att[setting.subMasterKey];
+                if (!subNodeIndexes[sid]) {
+                    subNodeIndexes[sid] = [];
+                }
+                let sxi = subNodeIndexes[sid];
+                isTop ? sxi.unshift(att) : sxi.push(att);
+            }
         };
         const loadDatas = function (datas) {
             for (const d of datas) {
@@ -567,7 +590,29 @@
             }
             refreshCurAttHtml();
         };
+        const deleteFileBySubNodeId = function(subNodeIds) {
+            for (const id of subNodeIds) {
+                const subNodeIndex = subNodeIndexes[id];
+                if (!subNodeIndex || subNodeIndex.length === 0) continue;
+                let nodeIndex;
+                for (const att of subNodeIndex) {
+                    const att_index = findFileIndex(allAtts, att.id);
+                    allAtts.splice(att_index, 1);
+
+                    if (!nodeIndex) nodeIndex = nodeIndexes[att.node[setting.masterKey]];
+                    const att_node_index = findFileIndex(nodeIndex, att.id);
+                    nodeIndex.splice(att_node_index, 1);
+                }
+                delete nodeIndexes[id];
+            }
+            if (allAtts.length === 1) {
+                getAllAttHtml();
+            } else {
+                refreshAllAttHtml();
+            }
+            refreshCurAttHtml();
+        };
 
-        return { loadDatas, getCurAttHtml, deleteFileByNodeId }
+        return { loadDatas, getCurAttHtml, deleteFileByNodeId, deleteFileBySubNodeId }
     };
 })(jQuery);

+ 0 - 17
app/public/js/spreadjs_rela/spreadjs_zh.js

@@ -1130,23 +1130,6 @@ const SpreadJsObj = {
         }
         this.endMassOperation(sheet);
     },
-    repaintNodesRowHeader: function (sheet, nodes) {
-        nodes = nodes instanceof Array ? nodes : [nodes];
-        const sortData = sheet.zh_dataType === 'tree' ? sheet.zh_tree.nodes : sheet.zh_data;
-        const rowIndex = [];
-        for (const node of nodes) {
-            rowIndex.push(sortData.indexOf(node));
-        }
-        this.repaintRowsRowHeader(sheet, rowIndex);
-    },
-    repaintRowsRowHeader: function (sheet, rows) {
-        for (const r of rows) {
-            const cellRect = sheet.getCellRect(r, 0);
-            cellRect.width = cellRect.x;
-            cellRect.x = 0;
-            sheet.repaint(cellRect);
-        }
-    },
     /**
      * 根据data加载sheet数据,合并了一般数据和树结构数据的加载
      * @param {GC.Spread.Sheets.Worksheet} sheet

+ 1 - 1
app/public/js/sr_detail.js

@@ -340,7 +340,7 @@ $(document).ready(() => {
         updateUrl: window.location.pathname + '/tag',
         readOnly: true,
         afterModify: function (nodes) {
-            SpreadJsObj.repaintNodesRowHeader(slSpread.getActiveSheet(), nodes);
+            slSpread.getActiveSheet().repaint();
         },
         afterLocated:  function () {
             stagePosSpreadObj.loadCurPosData();

+ 1 - 1
app/public/js/stage.js

@@ -921,7 +921,7 @@ $(document).ready(() => {
         relaSpread: slSpread,
         updateUrl: window.location.pathname + '/tag',
         afterModify: function (nodes) {
-            SpreadJsObj.repaintNodesRowHeader(slSpread.getActiveSheet(), nodes);
+            slSheet.repaint();
         },
         afterLocated:  function () {
             stagePosSpreadObj.loadCurPosData();

+ 28 - 2
app/router.js

@@ -64,6 +64,7 @@ module.exports = app => {
     // 安全巡检中间件
     const safeInspectionCheck = app.middlewares.safeInspectionCheck();
     const safeStageCheck = app.middlewares.safeStageCheck();
+    const costStageCheck = app.middlewares.costStageCheck();
     // 登入登出相关
     app.get('/login', 'loginController.index');
     app.get('/login/:code', 'loginController.index');
@@ -530,6 +531,31 @@ module.exports = app => {
     app.post('/sp/:id/quality/tender/:tid/inspection/:qiid/information/file/delete', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, qualityInspectionCheck, 'qualityController.deleteInspectionFile');
     app.get('/sp/:id/quality/tender/:tid/inspection/:qiid/information/file/:fid/download', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, qualityInspectionCheck, 'qualityController.downloadInspectionFile');
 
+    // 成本管理
+    app.get('/sp/:id/cost', sessionAuth, subProjectCheck, 'costController.tender');
+    app.post('/sp/:id/cost/member', sessionAuth, subProjectCheck, projectManagerCheck, 'costController.member');
+    app.post('/sp/:id/cost/memberSave', sessionAuth, subProjectCheck, projectManagerCheck, 'costController.memberSave');
+    app.post('/sp/:id/cost/templateSet', sessionAuth, subProjectCheck, projectManagerCheck, 'subProjController.templateSet');
+    app.get('/sp/:id/cost/tender/:tid/ledger', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.ledger');
+    app.get('/sp/:id/cost/tender/:tid/book', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.book');
+    app.get('/sp/:id/cost/tender/:tid/analysis', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.analysis');
+    app.post('/sp/:id/cost/tender/:tid/addStage', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.addStage');
+    app.post('/sp/:id/cost/tender/:tid/delStage', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.delStage');
+    app.post('/sp/:id/cost/tender/:tid/:stype/auditors', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'costController.loadAuditors');
+    app.get('/sp/:id/cost/tender/:tid/:stype/:sorder/stage', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stage');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/load', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageLoad');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/update', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageUpdate');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/file/upload', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.uploadStageFile');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/file/delete', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.deleteStageFile');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/tag', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.tag');
+    app.get('/sp/:id/cost/tender/:tid/flow', sessionAuth, subProjectCheck, tenderCheck, projectManagerCheck, 'tenderController.shenpiSet');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/add', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.addStageAudit');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/delete', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.deleteStageAudit');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/start', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageAuditStart');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/check', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageAuditCheck');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/checkAgain', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageAuditCheckAgain');
+    app.post('/sp/:id/cost/tender/:tid/:stype/:sorder/audit/checkCancel', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, costStageCheck, 'costController.stageAuditCheckCancel');
+
     // 安全管理
     // 安全计量
     app.get('/sp/:id/safe', sessionAuth, subProjectCheck, 'safeController.tender');
@@ -578,7 +604,7 @@ module.exports = app => {
     app.post('/tender/:id/build', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.saveBuildStatus');
     app.post('/tender/rule', sessionAuth, 'tenderController.rule');
     app.post('/tender/:id/rule/first', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.ruleFirst');
-    app.get('/tender/:id/shenpi', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.shenpiSet');
+    app.get('/tender/:id/shenpi', sessionAuth, tenderCheck, subProjectCheck, projectManagerCheck, 'tenderController.shenpiSet');
     app.post('/tender/:id/shenpi/save', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.saveTenderInfoShenpi');
     app.post('/tender/:id/shenpi/audit/save', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.saveShenpiAudit');
     app.post('/tender/:id/shenpi/ass/load', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.loadAuditAss');
@@ -603,7 +629,6 @@ module.exports = app => {
     app.post('/tender/:id/expr/save', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.saveExpr');
     app.post('/tender/:id/expr/load', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.loadExpr');
 
-
     // 预付款
     app.get('/tender/:id/advance/:type', sessionAuth, tenderCheck, subProjectCheck, 'advanceController.index');
     // app.get('/tender/:id/advance/material', sessionAuth, tenderCheck, subProjectCheck, 'advanceController.materialList');
@@ -628,6 +653,7 @@ module.exports = app => {
 
     app.get('/template/posCalc', sessionAuth, 'templateController.posCalc');
     app.get('/template/cost', sessionAuth, 'templateController.cost');
+    app.post('/template/folder', sessionAuth, 'templateController.saveFolder');
     app.post('/template/save', sessionAuth, 'templateController.saveTemplate');
     app.post('/template/load', sessionAuth, 'templateController.load');
     app.post('/template/preview', sessionAuth, 'templateController.preview');

+ 95 - 25
app/service/calc_tmpl.js

@@ -34,7 +34,10 @@ const PosCalc = (function(){
     };
     const ValidColInfo = [
         { key: 'str', name: '文本', fields: ['str1', 'str2', 'str3', 'str4'], valid: ['title', 'width'], def: { title: '文字', width: 80, type: 'str'} },
-        { key: 'num', name: '数值', fields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i'], valid: ['title', 'width', 'calc_code', 'decimal', 'unit'], def: { title: '数值', width: 80, decimal: 2, type: 'num', unit: ''} },
+        {
+            key: 'num', name: '数值',
+            fields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i', 'num_j', 'num_k', 'num_l', 'num_m', 'num_n', 'num_o', 'num_p', 'num_q', 'num_r', 'num_s', 'num_t', 'num_u'], // 'num_v', 'num_w', 'num_x', 'num_y', 'num_z'],
+            valid: ['title', 'width', 'calc_code', 'decimal', 'unit'], def: { title: '数值', width: 80, decimal: 2, type: 'num', unit: ''} },
         { key: 'spec', name: '规格', fields: ['spec'], valid: ['title', 'width', 'rela_col', 'spec_set'], def: { title: '规格', width: 80, rela_col: '', type: 'spec'} },
         { key: 'qty', name: '数量', fields: ['qty'], valid: ['title', 'width', 'expr', 'decimal'], def: { title: '数量', width: 80, decimal: 2, expr: '', type: 'qty' } },
     ];
@@ -53,7 +56,7 @@ const PosCalc = (function(){
 const Cost = (function(){
     const EmptySpreadCache = {
         cols: [],
-        emptyRows: 3,
+        emptyRows: 0,
         headRows: 1,
         headRowHeight: [32],
         defaultRowHeight: 21,
@@ -62,21 +65,36 @@ const Cost = (function(){
         frozenLineColor: '#93b5e4',
     };
     const BaseSpreadColSetting = {
+        code: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 0, width: 80, formatter: '@' },
+        name: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 0, width: 80, formatter: '@' },
+        party_b: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 0, width: 80, formatter: '@' },
+        yf_excl_tax_tp: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 2, width: 80, type: 'Number' },
+        in_excl_tax_tp: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 2, width: 80, type: 'Number' },
+        sf_excl_tax_tp: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 2, width: 80, type: 'Number' },
         str: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 0, width: 80, formatter: '@' },
         num: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 2, width: 80, type: 'Number' },
+        postil: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 0, width: 80, formatter: '@' },
+        sf_percent: { title: '', colSpan: '1', rowSpan: '1', field: '', hAlign: 2, width: 80, type: 'Number' },
     };
     const ValidColInfo = [
-        { key: 'str', name: '文本', fields: ['str1', 'str2', 'str3', 'str4', 'str5', 'str6'], valid: ['title', 'width'], def: { title: '文本', width: 80, type: 'str'} },
+        { key: 'code', name: '编号', fields: ['code'], valid: ['title', 'width'], def: { title: '编号', width: 80, type: 'code'} },
+        { key: 'name', name: '名称', fields: ['name'], valid: ['title', 'width'], def: { title: '名称', width: 80, type: 'name'} },
+        { key: 'party_b', name: '乙方', fields: ['party_b'], valid: ['title', 'width'], def: { title: '乙方', width: 80, type: 'party_b'} },
+        { key: 'yf_excl_tax_tp', name: '应付(不含税)', fields: ['yf_excl_tax_tp'], valid: ['title', 'width', 'calc_code', 'decimal'], def: { title: '已结(不含税)', width: 80, type: 'yf_excl_tax_tp', decimal: 6} },
+        { key: 'in_excl_tax_tp', name: '入账(不含税)', fields: ['in_excl_tax_tp'], valid: ['title', 'width', 'calc_code', 'decimal'], def: { title: '入账(不含税)', width: 80, type: 'in_excl_tax_tp', decimal: 6} },
+        { key: 'sf_excl_tax_tp', name: '实付(不含税)', fields: ['sf_excl_tax_tp'], valid: ['title', 'width', 'calc_code', 'decimal'], def: { title: '实付(不含税)', width: 80, type: 'sf_excl_tax_tp', decimal: 6} },
+        { key: 'str', name: '文本', fields: ['str1', 'str2', 'str3', 'str4'], valid: ['title', 'width'], def: { title: '文本', width: 80, type: 'str'} },
         {
             key: 'num', name: '数值/计算',
-            fields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i', 'num_j', 'num_k', 'num_l', 'num_m', 'num_n', 'num_o', 'num_p'],
+            fields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i', 'num_j', 'num_k', 'num_l', 'num_m', 'num_n', 'num_o', 'num_p'], // 'num_q', 'num_r', 'num_s', 'num_t', 'num_u', 'num_v', 'num_w', 'num_x', 'num_y', 'num_z'],
             valid: ['title', 'width', 'calc_code', 'decimal', 'unit', 'expr'],
-            def: { title: '数值/计算', width: 80, decimal: 2, type: 'num', unit: ''}
+            def: { title: '数值/计算', width: 80, decimal: 6, type: 'num', unit: ''}
         },
-        { key: 'memo', name: '长文本', fields: ['memo1', 'memo2'], valid: ['title', 'width'], def: { title: '长文本', width: 120, type: 'memo'} },
+        { key: 'postil', name: '本期批注', fields: ['postil'], valid: ['title', 'width'], def: { title: '本期批注', width: 120, type: 'postil'} },
+        { key: 'sf_percent', name: '实付比例', fields: ['sf_percent'], valid: ['title', 'width', 'calc_code'], def: { title: '实付比例', width: 80, type: 'sf_percent', decimal: 2} },
     ];
     ValidColInfo.forEach(vci => { return vci.count = vci.fields.length; });
-    return { EmptySpreadCache, BaseSpreadColSetting, ValidColInfo, ValidCount: 10 };
+    return { EmptySpreadCache, BaseSpreadColSetting, ValidColInfo, ValidCount: 10, subSpreadReadOnly: ['code', 'name', 'party_b', 'settle_tp', 'in_excl_tax_tp'] };
 })();
 function randomWord(randomFlag, min, max){
     let str = "",
@@ -110,11 +128,14 @@ module.exports = app => {
         }
 
         async getAllTemplate(pid, type, sort = 'asc') {
-            return this.getAllDataByCondition({
-                columns: ['id', 'pid', 'name', 'create_user_id', 'is_locked'],
-                where: { pid, type },
-                orders: [['create_time', sort]]
-            });
+            // return this.getAllDataByCondition({
+            //     columns: ['id', 'pid', 'name', 'create_user_id', 'is_locked'],
+            //     where: { pid, type },
+            //     orders: [['create_time', sort]]
+            // });
+            const sql = 'SELECT ct.id, ct.pid, ct.folder_id, ct.name, ct.create_user_id, ct.is_locked, pa.name AS user_name' +
+                        `    FROM ${this.tableName} ct LEFT JOIN ${this.ctx.service.projectAccount.tableName} pa ON ct.create_user_id = pa.id WHERE ct.pid = ? and ct.type = ? ORDER BY ct.create_time ${sort}`;
+            return this.db.query(sql, [pid, type]);
         }
         async analysisTemplate(data) {
             const datas = data instanceof Array ? data : [data];
@@ -126,6 +147,7 @@ module.exports = app => {
                 x.multi_header = x.multi_header ? JSON.parse(x.multi_header) : undefined;
                 x.calc_expr = x.calc_expr ? JSON.parse(x.calc_expr) : [];
                 x.calc_order = x.calc_order ? x.calc_order.split(',') : [];
+                x.sub_spread_cache = x.sub_spread_cache ? JSON.parse(x.sub_spread_cache) : {};
             });
             for (const d of datas) {
                 if (d.spec_set) {
@@ -196,7 +218,7 @@ module.exports = app => {
 
             let newExpr = expr;
             colSet.forEach(x => {
-                if (x.type === 'num' && x.calc_code) newExpr = newExpr.replace(new RegExp(x.calc_code, 'gm'), x.field);
+                if (x.calc_code) newExpr = newExpr.replace(new RegExp(x.calc_code, 'gm'), x.field);
             });
             return newExpr;
         }
@@ -212,7 +234,7 @@ module.exports = app => {
                         spreadCol.title = col.title + '(' + col.unit + ')';
                     } else if (v === 'expr') {
                         spreadCol.expr = this._transExpr(col.expr, colSet);
-                        spreadCol.readOnly = true;
+                        spreadCol.readOnly = spreadCol.expr;
                     } else {
                         if (col[v] !== undefined) spreadCol[v] = col[v];
                     }
@@ -246,7 +268,7 @@ module.exports = app => {
                         colTitle = colTitle + '(' + col.unit + ')';
                     } else if (v === 'expr') {
                         spreadCol.expr = this._transExpr(col.expr, colSet);
-                        spreadCol.readOnly = true;
+                        spreadCol.readOnly = spreadCol.expr;
                     } else {
                         if (col[v] !== undefined) spreadCol[v] = col[v];
                     }
@@ -336,7 +358,7 @@ module.exports = app => {
         }
         // ----------------------------------------------------------------------
 
-        async _addTemplate(name, type) {
+        async _addTemplate(name, type, folder_id) {
             if (ValidTemplateType.indexOf(type) < 0) throw '新增的模板类型非法';
             const count = await this.count({pid: this.ctx.session.sessionProject.id, type});
             if (count > this.TemplateRela[type].ValidCount) throw '已达模板使用上限';
@@ -347,6 +369,7 @@ module.exports = app => {
                 pid: this.ctx.session.sessionProject.id,
                 // spid: this.ctx.subProject.id,
                 // tid: this.ctx.tender.id,
+                folder_id: folder_id || '',
                 name: name || '新增计算模板',
                 type,
                 create_user_id: this.ctx.session.sessionUser.accountId,
@@ -356,9 +379,12 @@ module.exports = app => {
                 spread_cache: JSON.stringify(relaConst.EmptySpreadCache),
                 field_cache: '{}',
                 decimal: JSON.stringify([{ def: 2 }]),
+                sub_spread_cache: '[]',
             };
             await this.db.insert(this.tableName, insertData);
-            return await this.getTemplate(insertData.id);
+            const result = await this.getTemplate(insertData.id);
+            result.user_name = this.ctx.session.sessionUser.name;
+            return result;
         }
         async checkTemplateEdit(id) {
             const template = await this.getDataById(id);
@@ -396,7 +422,7 @@ module.exports = app => {
             const calcOrder = [];
             for (const c of cols) {
                 if (c.type !== 'Number') continue;
-                calcOrder.push({ field: c.field, expr: c.expr || '', decimal: c.decimal });
+                calcOrder.push({ field: c.field, expr: c.expr || '', decimal: c.decimal, calc_code: c.calc_code });
             }
             for (const co of calcOrder) {
                 co.leaf = this._getCalcLeaf(co, calcOrder);
@@ -415,7 +441,28 @@ module.exports = app => {
                 updateData.col_set = JSON.stringify(data.col_set);
                 updateData.multi_header = JSON.stringify(data.multi_header);
                 const spread_cache = this.calcSpreadCache(org.type, data.col_set, data.multi_header);
-                updateData.spread_cache = JSON.stringify(spread_cache);
+                if (org.type === 'cost') {
+                    spread_cache.cols[0].cellType = 'tree';
+                    const treeSpreadCache = this.ctx.helper.clone(spread_cache);
+                    treeSpreadCache.cols.splice(treeSpreadCache.cols.findIndex(x => { return x.field === 'party_b';}), 1);
+                    updateData.spread_cache = JSON.stringify(treeSpreadCache);
+                    const treeCol = treeSpreadCache.cols.find(x => { return x.cellType === 'tree'});
+                    delete treeCol.cellType;
+                    for (const col of treeSpreadCache.cols) {
+                        if (this.TemplateRela.cost.subSpreadReadOnly.indexOf(col.field) >= 0) col.readOnly = true;
+                    }
+                    const sub_spread_cache = { common: treeSpreadCache };
+                    const dealSpreadCache = this.ctx.helper.clone(spread_cache);
+                    const treeDCol = dealSpreadCache.cols.find(x => { return x.cellType === 'tree'});
+                    delete treeDCol.cellType;
+                    sub_spread_cache.deal = dealSpreadCache;
+                    for (const col of dealSpreadCache.cols) {
+                        if (this.TemplateRela.cost.subSpreadReadOnly.indexOf(col.field) >= 0) col.readOnly = true;
+                    }
+                    updateData.sub_spread_cache = JSON.stringify(sub_spread_cache);
+                } else {
+                    updateData.spread_cache = JSON.stringify(spread_cache);
+                }
                 const field_cache = {};
                 data.col_set.forEach(x => { field_cache[x.field] = x.title; });
                 updateData.field_cache = JSON.stringify(field_cache);
@@ -442,11 +489,20 @@ module.exports = app => {
             await this.db.delete(this.tableName, { id });
             return template;
         }
+        async _moveTemplate(id, folder_id) {
+            const org = await this.getAllDataByCondition({ where: { id }});
+            if (org.length !== id.length) throw '选择的模板不存在,请刷新后重试';
+
+            const updateData = id.map(x => { return { id: x, folder_id }});
+            await this.db.updateRows(this.tableName, updateData);
+            return updateData;
+        }
         async saveTemplate(data) {
             const result = {};
-            if (data.add) result.add = await this._addTemplate(data.add.name, data.type);
+            if (data.add) result.add = await this._addTemplate(data.add.name, data.type, data.add.folder_id);
             if (data.del) result.del = await this._delTemplate(data.del);
             if (data.update) result.update = await this._saveTemplate(data.update.id, data.update);
+            if (data.move) result.move = await this._moveTemplate(data.move, data.folder_id);
             return result;
         }
         async reCalcTemplate(id, force) {
@@ -503,24 +559,38 @@ module.exports = app => {
                 return 0;
             }
         }
+
         calcByTemplate(data, updateData, orgData, calcExpr, updateExprInfo) {
             const codeReg = /num_[a-z]{1}/gm;
+            const specRegs = [];
+            for (const ce of calcExpr) {
+                if (!ce.field.match(codeReg)) specRegs.push(new RegExp(ce.field, 'gm'));
+            }
             for (const ce of calcExpr) {
                 if (ce.expr) {
                     let calcExpr = ce.expr;
+                    for (const sr of specRegs) {
+                        const specParam = calcExpr.match(sr);
+                        if (specParam) {
+                            for (const sp of specParam) {
+                                calcExpr = calcExpr.replace(new RegExp(sp, 'gm'), (data[sp] !== undefined ? data[sp] : orgData[sp]) || 0);
+                            }
+                        }
+                    }
                     const codeParam = calcExpr.match(codeReg);
-                    for (const cp of codeParam) {
-                        calcExpr = calcExpr.replace(new RegExp(cp, 'gm'), data[cp] || orgData[cp] || 0);
+                    if (codeParam) {
+                        for (const cp of codeParam) {
+                            calcExpr = calcExpr.replace(new RegExp(cp, 'gm'), (data[cp] !== undefined ? data[cp] : orgData[cp]) || 0);
+                        }
                     }
                     if (updateExprInfo && updateExprInfo[ce.field]) updateExprInfo[ce.field] = calcExpr;
                     try {
-                        data[ce.field] = this.ctx.helper.round(this._calcExpr(calcExpr.replace(new RegExp('%', 'gm'), '/100')), ce.decimal);
+                        data[ce.field] = this.ctx.helper.round(this._calcExpr(calcExpr.replace(new RegExp('%', 'gm'), '/100')), ce.decimal || 2);
                     } catch(err) {
                         data[ce.field] = 0;
                     }
                 } else {
-                    if (updateData[ce.field] === undefined) continue;
-                    data[ce.field] = this.ctx.helper.round(updateData[ce.field], ce.decimal || 2);
+                    if (updateData[ce.field] !== undefined) data[ce.field] = this.ctx.helper.round(updateData[ce.field], ce.decimal || 2);
                 }
             }
         }

+ 49 - 0
app/service/calc_tmpl_folder.js

@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ *
+ * 计算模板-分类树
+ * @author Mai
+ * @date
+ * @version
+ */
+
+module.exports = app => {
+
+    class CostStageLedger extends app.BaseTreeService {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx, {
+                mid: 'master_id',
+                kid: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                keyPre: 'calc_tmpl_maxTreeId:',
+                uuid: true,
+            });
+            this.tableName = 'calc_tmpl_folder';
+        }
+
+        _getDefaultData(data) {
+            data.pid = this.ctx.session.sessionProject.id;
+            data.type = data.master_id.split('-')[1];
+            data.add_user_id = this.ctx.session.sessionUser.accountId;
+            data.update_user_id = data.add_user_id;
+        }
+
+        async _deleteRelaData(mid, deleteData) {
+            await this.transaction.query(`UPDATE ${this.ctx.service.calcTmpl.tableName} SET folder_id = '' WHERE folder_id IN(${deleteData.map(x => { return `'${x.id}'`; }).join(', ')})`);
+        }
+    }
+
+    return CostStageLedger;
+};

+ 321 - 0
app/service/cost_stage.js

@@ -0,0 +1,321 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const audit = require('../const/audit').common;
+const auditType = require('../const/audit').auditType;
+const shenpiConst = require('../const/shenpi');
+
+module.exports = app => {
+    class CostStage extends app.BaseService {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage';
+            this.stageType = {
+                ledger: { key: 'ledger', decimal: { tp: 6, tax: 2 }, shenpi_status: 'cost_stage_ledger', push_type: 'costStageLedger', dataService: 'costStageLedger', detailService: 'costStageDetail' },
+                book: { key: 'book', decimal: { tp: 6 }, shenpi_status: 'cost_stage_book', push_type: 'costStageBook', dataService: 'costStageBook', detailService: 'costStageBookDetail' },
+                analysis: { key: 'analysis', decimal: { tp: 6 }, shenpi_status: 'cost_stage_analysis', push_type: 'costStageAnalysis', dataService: 'costStageAnalysis', detailService: 'costStageAnalysisDetail' },
+            };
+        }
+
+        _analysisstage(stage) {
+            if (!stage) return;
+            const stages = stage instanceof Array ? stage : [stage];
+            if (stage.length === 0) return;
+
+            const typeInfo = this.stageType[stages[0].stage_type];
+            stages.forEach(s => {
+                if (s.decimal) {
+                    s.decimal = this.ctx.helper._.assignIn(typeInfo.decimal, JSON.parse(s.decimal));
+                } else {
+                    s.decimal = typeInfo.decimal;
+                }
+                s.stage_tp = s.stage_tp ? JSON.parse(s.stage_tp) : {};
+                s.stage_pre_tp = s.stage_pre_tp ? JSON.parse(s.stage_pre_tp) : {};
+                s.stage_end_tp = {};
+                for (const prop in s.stage_tp) {
+                    s.stage_end_tp[prop] = this.ctx.helper.add(s.stage_tp[prop], s.stage_pre_tp[prop]);
+                }
+                s.typeInfo = this.stageType[s.stage_type];
+                s.rela_stage = s.rela_stage ? JSON.parse(s.rela_stage) : null;
+            });
+        }
+        /**
+         * 获取全部期
+         * @param tid
+         * @returns {Promise<*>}
+         */
+        async getAllStages(tid, stage_type, sort = 'ASC') {
+            const sql = `SELECT cls.*, pa.name AS user_name FROM ${this.tableName} cls LEFT JOIN ${this.ctx.service.projectAccount.tableName} pa ON cls.create_user_id = pa.id` +
+                        `  WHERE cls.tid = ? and cls.stage_type = ? ORDER BY cls.stage_order ${sort}`;
+            const result = await this.db.query(sql, [tid, stage_type]);
+            this._analysisstage(result);
+            return result;
+        }
+        async getAllCheckedStages(tid, stage_type, sort = 'ASC') {
+            const sql = `SELECT cls.*, pa.name AS user_name FROM ${this.tableName} cls LEFT JOIN ${this.ctx.service.projectAccount.tableName} pa ON cls.create_user_id = pa.id` +
+                `  WHERE cls.tid = ? AND cls.stage_type = ? AND audit_status = ? ORDER BY cls.stage_order ${sort}`;
+            const result = await this.db.query(sql, [tid, stage_type, audit.status.checked]);
+            this._analysisstage(result);
+            return result;
+        }
+        async getStage(id) {
+            const result = await this.getDataById(id);
+            this._analysisstage(result);
+            return result;
+        }
+        async getStageByOrder(tid, stage_type, stage_order) {
+            const result = await this.getDataByCondition({ tid, stage_type, stage_order });
+            this._analysisstage(result);
+            return result;
+        }
+
+        async getMaxOrder(tid, stage_type) {
+            const sql = 'SELECT Max(`stage_order`) As max_order FROM ' + this.tableName + ' Where `tid` = ? and stage_type = ?';
+            const sqlParam = [tid, stage_type];
+            const result = await this.db.queryOne(sql, sqlParam);
+            return result.max_order || 0;
+        }
+
+        async add(tid, stage_type, stage_date, rela_stage) {
+            const typeInfo = this.stageType[stage_type];
+            if (!tid) throw '数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const maxOrder = await this.getMaxOrder(tid, stage_type);
+            const data = {
+                id: this.uuid.v4(), tid: tid, create_user_id: user_id, update_user_id: user_id,
+                stage_order: maxOrder + 1, stage_date, stage_type,
+                audit_times: 1, audit_status: audit.status.uncheck,
+                decimal: JSON.stringify({ tp: 6 }),
+            };
+            if (stage_type === 'analysis') data.calc_template = this.ctx.subProject.cost_calc_template;
+            if (rela_stage) data.rela_stage = JSON.stringify(rela_stage);
+
+            const preStage = maxOrder > 0 ? await this.getStageByOrder(tid, stage_type, maxOrder) : null;
+            if (preStage) {
+                data.stage_pre_tp = JSON.stringify(preStage.stage_end_tp);
+            }
+            const transaction = await this.db.beginTransaction();
+            try {
+                const result = await transaction.insert(this.tableName, data);
+                if (result.affectedRows !== 1) throw '新增安全计量期失败';
+
+                await this.ctx.service[typeInfo.dataService].initStageData(transaction, data, preStage);
+                await this.ctx.service.costStageAudit.copyPreAuditors(transaction, preStage, data);
+                await transaction.commit();
+            } catch(err) {
+                console.log(err);
+                await transaction.rollback();
+                throw err;
+            }
+            return data;
+        }
+        async delete(id) {
+            const stage = await this.getDataById(id);
+            const typeInfo = this.stageType[stage.stage_type];
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.delete(this.tableName, { id });
+                await conn.delete(this.ctx.service[typeInfo.dataService].tableName, { stage_id: id });
+                await conn.delete(this.ctx.service[typeInfo.detailService].tableName, { stage_id: id });
+                const files = await this.ctx.service.costStageFile.getFiles({ where: { stage_id: id} });
+                for (const f of files) {
+                    this.ctx.app.fujianOss.delete(f.filepath);
+                }
+                await conn.delete(this.ctx.service.costStageFile.tableName, { stage_id: id });
+                await conn.delete(this.ctx.service.costStageAudit.tableName, { stage_id: id });
+                // 记录删除日志
+                // await this.ctx.service.projectLog.addProjectLog(conn, projectLogConst.type.stage, projectLogConst.status.delete, `第${info.stage_order}期`);
+                await conn.commit();
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+        async save(stage, data) {
+            await this.defaultUpdate({ id: stage.id, stage_date: data.stage_date, update_user_id: this.ctx.session.sessionUser.accountId });
+        }
+
+        async loadUser(stage) {
+            stage.user = await this.ctx.service.projectAccount.getAccountInfoById(stage.create_user_id);
+            stage.auditors = await this.ctx.service.costStageAudit.getAuditors(stage.id, stage.curTimes || stage.audit_times);
+            stage.auditorIds = this._.map(stage.auditors, 'audit_id');
+            stage.curAuditors = stage.auditors.filter(x => { return x.audit_status === audit.status.checking; });
+            stage.curAuditorIds = stage.curAuditors.map(x => { return x.audit_id; });
+            stage.flowAuditors = stage.curAuditors.length === 0 ? [] : stage.auditors.filter(x => { return x.active_order === stage.curAuditors[0].active_order; });
+            stage.flowAuditorIds = stage.flowAuditors.map(x => { return x.audit_id; });
+            stage.nextAuditors = stage.curAuditors.length > 0 ? stage.auditors.filter(x => { return x.active_order === stage.curAuditors[0].active_order + 1; }) : [];
+            stage.nextAuditorIds = this._.map(stage.nextAuditors, 'audit_id');
+            stage.auditorGroups = this.ctx.helper.groupAuditors(stage.auditors, 'active_order');
+            stage.userGroups = this.ctx.helper.groupAuditorsUniq(stage.auditorGroups);
+            stage.finalAuditorIds = stage.userGroups.length > 1 ? stage.userGroups[stage.userGroups.length - 1].map(x => { return x.audit_id; }) : [];
+            stage.userIds = stage.audit_status === audit.status.uncheck // 当前流程下全部参与人id
+                ? [stage.create_user_id]
+                : stage.auditorIds;
+            if (stage.audit_status === audit.status.checkNo) {
+                stage.checkNoAuditors = await this.ctx.service.costStageAudit.getAuditorsByStatus(stage.id, audit.status.checkNo, stage.audit_times-1);
+            }
+        }
+        async loadAuditViewData(stage) {
+            if (!stage.user) stage.user = await this.ctx.service.projectAccount.getAccountInfoById(stage.user_id);
+            const auditTimes = stage.audit_status === audit.status.checkNo ? stage.audit_times - 1 : stage.audit_times;
+            stage.auditHistory = await this.ctx.service.costStageAudit.getAuditorHistory(stage.id, auditTimes);
+            // 获取审批流程中左边列表
+            if (stage.audit_status === audit.status.checkNo && stage.create_user_id !== this.ctx.session.sessionUser.accountId) {
+                const auditors = await this.ctx.service.costStageAudit.getAuditors(stage.id, stage.audit_times - 1); // 全部参与的审批人
+                const auditorGroups = this.ctx.helper.groupAuditors(auditors);
+                stage.hisUserGroup = this.ctx.helper.groupAuditorsUniq(auditorGroups);
+            } else {
+                stage.hisUserGroup = stage.userGroups;
+            }
+        }
+        /**
+         * cancancel = 0 不可撤回
+         * cancancel = 1 原报撤回
+         * cancancel = 2 审批人撤回 审批通过
+         * cancancel = 3 审批人撤回 审批退回上一人
+         * cancancel = 4 审批人撤回 退回原报
+         * cancancel = 5 会签未全部审批通过时,审批人撤回 审批通过
+         *
+         * @param stage
+         * @returns {Promise<void>}
+         */
+        async doCheckCanCancel(stage) {
+            // 默认不可撤回
+            stage.cancancel = 0;
+            // 获取当前审批人的上一个审批人,判断是否是当前登录人,并赋予撤回功能,(当审批人存在有审批过时,上一人不允许再撤回)
+            const status = audit.status;
+            if (stage.audit_status === status.checked || stage.audit_status === status.uncheck) return;
+
+            const accountId = this.ctx.session.sessionUser.accountId;
+            if (stage.audit_status !== status.checkNo) {
+                // 找出当前操作人上一个审批人,包括审批完成的和退回上一个审批人的,同时当前操作人为第一人时,就是则为原报
+                if (stage.flowAuditors.find(x => { return x.audit_status !== status.checking}) && stage.flowAuditorIds.indexOf(accountId) < 0) return; // 当前流程存在审批人审批通过时,不可撤回
+                if (stage.curAuditorIds.indexOf(accountId) < 0 && stage.flowAuditorIds.indexOf(accountId) >= 0) {
+                    stage.cancancel = 5; // 会签未全部审批通过时,审批人撤回审批通过
+                    return;
+                }
+
+                const preAuditors = stage.curAuditors[0] && stage.curAuditors[0].active_order !== 1 ? stage.auditors.filter(x => { return x.active_order === stage.curAuditors[0].active_order - 1; }) : [];
+                const preAuditorCheckAgain = preAuditors.find(pa => { return pa.audit_status === status.checkAgain; });
+                const preAuditorCheckCancel = preAuditors.find(pa => { return pa.audit_status === status.checkCancel; });
+                const preAuditorHasOld = preAuditors.find(pa => { return pa.is_old === 1; });
+                const preAuditorIds = (preAuditorCheckAgain ? [] : preAuditors.map(x => { return x.audit_id })); // 重审不可撤回
+                if ((this._.isEqual(stage.flowAuditorIds, preAuditorIds) && preAuditorCheckCancel) || preAuditorHasOld) {
+                    return; // 不可以多次撤回
+                }
+
+                const preAuditChecked = preAuditors.find(pa => { return pa.audit_status === status.checked && pa.audit_id === accountId; });
+                const preAuditCheckNoPre = preAuditors.find(pa => { return pa.audit_status === status.checkNoPre && pa.audit_id === accountId; });
+                if (preAuditorIds.indexOf(accountId) >= 0) {
+                    if (preAuditChecked) {
+                        stage.cancancel = 2;// 审批人撤回审批通过
+                    } else if (preAuditCheckNoPre) {
+                        stage.cancancel = 3;// 审批人撤回审批退回上一人
+                    }
+                    stage.preAuditors = preAuditors;
+                } else if (preAuditors.length === 0 && accountId === stage.create_user_id) {
+                    stage.cancancel = 1;// 原报撤回
+                }
+            } else {
+                const lastAuditors = await this.ctx.service.costStageAudit.getAuditors(stage.id, stage.audit_times - 1);
+                const onAuditor = this._.findLast(lastAuditors, { audit_status: status.checkNo });
+                if (onAuditor.audit_id === accountId) {
+                    stage.cancancel = 4;// 审批人撤回退回原报
+                    stage.preAuditors = lastAuditors.filter(x => { return x.active_order === onAuditor.active_order });
+                }
+            }
+        }
+        async doCheckStage(stage) {
+            const accountId = this.ctx.session.sessionUser.accountId;
+            // 审批退回时,原报读取本轮流程,其他人读取上一轮流程
+            if (stage.audit_status === audit.status.checkNo) {
+                stage.curTimes = stage.create_user_id === accountId ? stage.audit_times : stage.audit_times - 1;
+            } else {
+                stage.curTimes = stage.audit_times;
+            }
+            // 加载参与人
+            await this.loadUser(stage);
+
+            if (stage.audit_status === audit.status.uncheck) {
+                stage.readOnly = accountId !== stage.create_user_id;
+                stage.curSort = 0;
+            } else if (stage.audit_status === audit.status.checkNo) {
+                stage.readOnly = accountId !== stage.create_user_id;
+                if (!stage.readOnly) {
+                    stage.curSort = 0;
+                } else {
+                    const checkNoAudit = await this.service.costStageAudit.getDataByCondition({
+                        stage_id: stage.id, audit_times: stage.audit_times - 1, audit_status: audit.status.checkNo,
+                    });
+                    stage.curSort = checkNoAudit.active_order;
+                }
+            } else if (stage.audit_status === audit.status.checked) {
+                stage.readOnly = true;
+                stage.curSort = stage.audit_max_sort;
+            } else {
+                // 会签,会签人部分审批通过时,只读,但是curSort需按原来的取值
+                stage.curSort = stage.flowAuditorIds.indexOf(accountId) >= 0 ? stage.curAuditors[0].active_order : stage.curAuditors[0].active_order - 1;
+                stage.readOnly = stage.curAuditorIds.indexOf(accountId) < 0;
+                stage.canCheck = stage.readOnly && stage.curAuditorIds.indexOf(accountId) > 0;
+            }
+            await this.doCheckCanCancel(stage);
+        }
+        async checkShenpi(stage) {
+            const status = audit.status;
+            const info = this.ctx.tender.info;
+            const typeInfo = this.stageType[stage.stage_type];
+            const shenpi_status = info.shenpi[typeInfo.shenpi_status];
+            if ((stage.audit_status === status.uncheck || stage.audit_status === status.checkNo) && shenpi_status !== shenpiConst.sp_status.sqspr) {
+                // 进一步比较审批流是否与审批流程设置的相同,不同则替换为固定审批流或固定的终审
+                const auditList = await this.ctx.service.costStageAudit.getAllDataByCondition({ where: { stage_id: stage.id, audit_times: stage.audit_times }, orders: [['audit_order', 'asc']] });
+                auditList.shift();
+                if (shenpi_status === shenpiConst.sp_status.gdspl) {
+                    const shenpiList = await this.ctx.service.shenpiAudit.getAllDataByCondition({ where: { tid: stage.tid, sp_type: shenpiConst.cost_sp_type[typeInfo.shenpi_status], sp_status: shenpi_status } });
+                    // 判断2个id数组是否相同,不同则删除原审批流,切换成固定的审批流
+                    let sameAudit = auditList.length === shenpiList.length;
+                    if (sameAudit) {
+                        for (const audit of auditList) {
+                            const shenpi = shenpiList.find(x => { return x.audit_id === audit.audit_id; });
+                            if (!shenpi || shenpi.audit_order !== audit.audit_order || shenpi.audit_type !== audit.audit_type) {
+                                sameAudit = false;
+                                break;
+                            }
+                        }
+                    }
+                    if (!sameAudit) {
+                        await this.ctx.service.costStageAudit.updateNewAuditList(stage, shenpiList);
+                        await this.loadUser(stage);
+                    }
+                } else if (shenpi_status === shenpiConst.sp_status.gdzs) {
+                    const shenpiInfo = await this.ctx.service.shenpiAudit.getDataByCondition({ tid: stage.tid, sp_type: shenpiConst.cost_sp_type[typeInfo.shenpi_status], sp_status: shenpi_status });
+                    // 判断最后一个id是否与固定终审id相同,不同则删除原审批流中如果存在的id和添加终审
+                    const lastAuditors = auditList.filter(x => { x.active_order === auditList.active_order; });
+                    if (shenpiInfo && (lastAuditors.length === 0 || (lastAuditors.length > 1 || shenpiInfo.audit_id !== lastAuditors[0].audit_id))) {
+                        await this.ctx.service.costStageAudit.updateLastAudit(stage, auditList, shenpiInfo.audit_id);
+                        await this.loadUser(stage);
+                    } else if (!shenpiInfo) {
+                        // 不存在终审人的状态下这里恢复为授权审批人
+                        this.ctx.tender.info.shenpi[typeInfo.shenpi_status] = shenpiConst.sp_status.sqspr;
+                    }
+                }
+            }
+        }
+    }
+
+    return CostStage;
+};

+ 697 - 0
app/service/cost_stage_analysis.js

@@ -0,0 +1,697 @@
+'use strict';
+
+/**
+ *
+ * 支付审批-安全生产
+ * @author Mai
+ * @date
+ * @version
+ */
+const Ledger = require('../lib/ledger');
+const costFields = {
+    textFields: ['code', 'name', 'unit', 'postil'],
+    selfTextFields: ['str1', 'str2', 'str3', 'str4'],
+    calcFields: ['yf_excl_tax_tp', 'in_excl_tax_tp', 'sf_excl_tax_tp', 'sf_percent'],
+    selfCalcFields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i', 'num_j', 'num_k', 'num_l', 'num_m', 'num_n', 'num_o', 'num_p'],
+    readFields: ['calc_read'],
+    hisFields: ['calc_his'],
+    taxFields: ['tax'],
+    treeFields: ['tree_id', 'tree_pid', 'tree_level', 'tree_order', 'tree_full_path', 'tree_is_leaf'],
+    baseFields: ['id', 'cost_id', 'tender_id', 'stage_id', 'node_type'],
+};
+costFields.sumFields = [...costFields.calcFields, ...costFields.selfCalcFields];
+costFields.preCopyFields = [...costFields.treeFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.calcFields, ...costFields.selfCalcFields, ...costFields.taxFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.calcFields, ...costFields.selfCalcFields, ...costFields.taxFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.readFields, ...costFields.taxFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.taxFields, ...costFields.hisFields];
+const calcType = { other: 0, in: 1, out: 2 };
+
+const auditConst = require('../const/audit').costStage;
+
+module.exports = app => {
+
+    class CostStageAnalysis extends app.BaseTreeService {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx, {
+                mid: 'stage_id',
+                kid: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                keyPre: 'cost_ledger_maxTreeId:',
+                uuid: true,
+            });
+            // this.depart = ctx.app.config.table_depart.light;
+            this.tableName = 'cost_stage_analysis';
+        }
+
+        // 继承方法
+        clearParentingData(data) {
+            for (const f of costFields.sumFields) {
+                data[f] = 0;
+            }
+        }
+
+        _getDefaultData(data, stage) {
+            data.id = this.uuid.v4();
+            data.cost_id = data.id;
+            data.tender_id = stage.tid;
+            data.stage_id = stage.id;
+            data.add_user_id = this.ctx.session.sessionUser.accountId;
+            data.update_user_id = data.add_user_id;
+        }
+
+        async getEditData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                x.calc_read = x.calc_read ? JSON.parse(x.calc_read) : {};
+                for(const prop of costFields.sumFields) {
+                    x[prop] = x.calc_read[prop] || 0;
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+
+        async getCostStageData(costStage) {
+            if (costStage.rela_stage.sid) costStage.relaStage = await this.ctx.service.costStage.getStage(costStage.rela_stage.sid);
+            const ledgerData = costStage.relaStage
+                ? await this.ctx.service.costStageLedger.getReadData(costStage.relaStage)
+                : await this.ctx.service.costStageLedger.getReadData(costStage);
+            if (costStage.relaStage) {
+                const bookData = await this.ctx.service.costStageBook.getReadData(costStage);
+                this.ctx.helper.assignRelaData(ledgerData, [
+                    { data: bookData, fields: ['end_in_tp', 'end_in_excl_tax_tp'], prefix: '', relaId: 'ledger_id' },
+                ]);
+            }
+            const detail = costStage.relaStage
+                ? await this.ctx.service.costStageBookDetail.getEndData(costStage.relaStage)
+                : await this.ctx.service.costStageDetail.getEndData(costStage);
+            const ledger = new Ledger.baseTree(this.ctx, {
+                id: 'tree_id', pid: 'tree_pid', order: 'tree_order',
+                level: 'tree_level', isLeaf: 'tree_is_leaf', fullPath: 'tree_full_path',
+                rootId: -1, calcField: [],
+            });
+            ledger.loadDatas(ledgerData);
+            ledger.nodes.forEach(x => {
+                if (x.tree_is_leaf) x.detail = detail.filter(d => { return x.cost_id === d.cost_id; });
+            });
+            return ledger;
+        }
+        async init(stage, transaction) {
+            if (!this.ctx.subProject.cost_analysis_template) throw '未设置成本分析模板,请联系管理员设置';
+            if (!stage || !transaction) throw '成本分析数据错误';
+            const userId = this.ctx.session.sessionUser.accountId;
+
+            const templateData = await this.ctx.service.tenderNodeTemplate.getData(this.ctx.subProject.cost_analysis_template);
+            if (templateData.length === 0) throw '台账模板无数据,连请联系管理员修改';
+            const calcTemplate = await this.ctx.service.calcTmpl.getTemplate(stage.calc_template);
+            if (!calcTemplate) throw '计算模板未定义,请联系管理员设置';
+
+            const insertTemplateData = [], insertLedgerData = [], insertDetailData = [];
+            let maxId = 0;
+            for (const b of templateData) {
+                const bills = {
+                    tree_id: b.template_id, tree_pid: b.pid, tree_order: b.order, tree_level: b.level, tree_full_path: b.full_path, tree_is_leaf: b.is_leaf,
+                    code: b.code || '', name: b.name || '', unit: b.unit || '', node_type: b.node_type || 0,
+                    calc_type: b.node_type === 2 ? 2 : ([3, 4].indexOf(b.node_type) >= 0 ? 0 : 1),
+                };
+
+                maxId = Math.max(maxId, bills.tree_id);
+                this._getDefaultData(bills, stage);
+                insertTemplateData.push(bills);
+            }
+            const relaStage = await this.service.costStage.getStage(JSON.parse(stage.rela_stage).sid);
+            const costStageLedger = await this.getCostStageData(relaStage);
+            const costNode = insertTemplateData.find(x => { return x.node_type === 2 && x.tree_level === 1; });
+            if (costStageLedger.nodes.length > 0) costNode.tree_is_leaf = 0;
+            for (const [i, node] of costStageLedger.nodes.entries()) {
+                const parent = costStageLedger.getParent(node);
+                const newParent = parent ? insertLedgerData.find(x => { return x.tree_id === parent.new_tree_id; }) : costNode;
+                const ln = { calc_type: 2 };
+                this._getDefaultData(ln, stage);
+                ln.node_type = 0;
+                ln.cost_id = node.cost_id;
+                ln.tree_id = maxId + i + 1;
+                node.new_tree_id = ln.tree_id;
+                ln.tree_pid = newParent.tree_id;
+                ln.tree_order = node.tree_order;
+                ln.tree_level = newParent.tree_level + 1;
+                ln.tree_full_path = newParent.tree_full_path + '-' + ln.tree_id;
+                ln.tree_is_leaf = node.tree_is_leaf;
+                ln.code = node.code;
+                ln.name = node.name;
+                ln.unit = node.unit;
+                ln.tax = 0;
+                for (const prop of costFields.sumFields) {
+                    ln[prop] = 0;
+                }
+                insertLedgerData.push(ln);
+                if (node.detail) {
+                    for (const [i, d] of node.detail.entries()) {
+                        const idd = {
+                            id: this.uuid.v4(), tender_id: stage.tid, stage_id: stage.id,
+                            add_user_id: userId, update_user_id: userId,
+                            ledger_id: ln.id, cost_id: ln.cost_id, source_id: d.source_cid,
+                            d_order: i+1,
+                            code: d.code, name: d.name, party_b: d.party_b || '', tax: d.tax || 0,
+                            yf_excl_tax_tp: d.yf_excl_tax_tp || 0, in_excl_tax_tp: d.in_excl_tax_tp || 0, sf_excl_tax_tp: d.sf_excl_tax_tp || 0,
+                        };
+                        this.ctx.service.calcTmpl.calcByTemplate(idd, idd, idd, calcTemplate.calc_expr);
+                        idd.sf_percent = idd.yf_excl_tax_tp ? this.ctx.helper.mul(this.ctx.helper.div(idd.sf_excl_tax_tp, idd.yf_excl_tax_tp), 100, 2) : 0;
+                        insertDetailData.push(idd);
+                        for (const prop of costFields.sumFields) {
+                            ln[prop] = this.ctx.helper.add(ln[prop], idd[prop]);
+                        }
+                    }
+                } else {
+                    ln.tax = node.tax;
+                    ln.yf_excl_tax_tp = node.end_yf_excl_tax_tp || 0;
+                    ln.in_excl_tax_tp = node.end_in_excl_tax_tp || 0;
+                    ln.sf_excl_tax_tp = node.end_sf_excl_tax_tp || 0;
+                    this.ctx.service.calcTmpl.calcByTemplate(ln, ln, ln, calcTemplate.calc_expr);
+                }
+                ln.sf_percent = ln.yf_excl_tax_tp ? this.ctx.helper.mul(this.ctx.helper.div(ln.sf_excl_tax_tp, ln.yf_excl_tax_tp), 100, 2) : 0;
+            }
+
+            await transaction.insert(this.tableName, insertTemplateData);
+            await transaction.insert(this.tableName, insertLedgerData);
+            if (insertDetailData.length > 0) await transaction.insert(this.ctx.service.costStageAnalysisDetail.tableName, insertDetailData);
+        }
+        async initByPre(stage, preStage, transaction) {
+            if (!stage || !preStage || !transaction) throw '成本分析数据错误';
+            const userId = this.ctx.session.sessionUser.accountId;
+            const insertData = [], insertDetailData = [];
+            let maxId = 0;
+
+            const preBills = await this.getReadData(preStage);
+            const preLedger = new Ledger.baseTree(this.ctx, {
+                id: 'tree_id', pid: 'tree_pid', order: 'tree_order',
+                level: 'tree_level', isLeaf: 'tree_is_leaf', fullPath: 'tree_full_path',
+                rootId: -1, calcField: [],
+            });
+            preLedger.loadDatas(preBills);
+            const preDetails = await this.ctx.service.costStageAnalysisDetail.getReadData(preStage);
+            for (const pl of preLedger.nodes) {
+                if (pl.tree_is_leaf) pl.detail = preDetails.filter(x => { return x.ledger_id === pl.id; });
+                if (pl.tree_level > 1) {
+                    const parent = preLedger.getTopParent(pl);
+                    if (parent.node_type === 2) continue;
+                }
+                const idata = {};
+                this._getDefaultData(idata, stage);
+                idata.cost_id = pl.cost_id;
+                idata.node_type = pl.node_type;
+                for (const prop of costFields.preCopyFields) {
+                    idata[prop] = pl[prop];
+                }
+                maxId = Math.max(maxId, idata.tree_id);
+                insertData.push(idata);
+            }
+
+            const relaStage = await this.service.costStage.getStage(JSON.parse(stage.rela_stage).sid);
+            const costStageLedger = await this.getCostStageData(relaStage);
+            const costNode = insertData.find(x => { return x.node_type === 2 && x.tree_level === 1; });
+            if (costStageLedger.nodes.length > 0) costNode.tree_is_leaf = 0;
+            for (const [i, node] of costStageLedger.nodes.entries()) {
+                const parent = costStageLedger.getParent(node);
+                const newParent = parent ? insertData.find(x => { return x.tree_id === parent.new_tree_id; }) : costNode;
+                const ln = {};
+                this._getDefaultData(ln, stage);
+                ln.node_type = 0;
+                ln.cost_id = node.cost_id;
+                ln.tree_id = maxId + i + 1;
+                node.new_tree_id = ln.tree_id;
+                ln.tree_pid = newParent.tree_id;
+                ln.tree_order = node.tree_order;
+                ln.tree_level = newParent.tree_level + 1;
+                ln.tree_full_path = newParent.tree_full_path + '-' + ln.tree_id;
+                ln.tree_is_leaf = node.tree_is_leaf;
+                ln.code = node.code;
+                ln.name = node.name;
+                ln.unit = node.unit;
+                ln.tax = node.tax;
+                ln.yf_excl_tax_tp = node.end_yf_excl_tax_tp || 0;
+                ln.in_excl_tax_tp = node.end_in_excl_tax_tp || 0;
+                ln.sf_excl_tax_tp = node.end_sf_excl_tax_tp || 0;
+                ln.postil = '';
+                ln.sf_percent = 0;
+                insertData.push(ln);
+                const preSource = preLedger.nodes.find(x => { return x.cost_id === node.cost_id; });
+                for (const prop of costFields.selfTextFields) {
+                    ln[prop] = preSource ? preSource[prop] : '';
+                }
+                if (!node.detail || node.detail.length === 0) {
+                    for (const prop of costFields.selfCalcFields) {
+                        ln[prop] = preSource ? preSource[prop] : 0;
+                    }
+                } else {
+                    for (const [i, d] of node.detail.entries()) {
+                        const ld = {
+                            id: this.uuid.v4(), tender_id: stage.tid, stage_id: stage.id,
+                            add_user_id: userId, update_user_id: userId,
+                            ledger_id: ln.id, cost_id: ln.cost_id, source_id: d.source_cid,
+                            d_order: i+1,
+                            code: d.code, name: d.name, party_b: d.party_b || '', tax: ln.tax || 0,
+                            yf_excl_tax_tp: d.yf_excl_tax_tp || 0, in_excl_tax_tp: d.in_excl_tax_tp || 0, sf_excl_tax_tp: d.sf_excl_tax_tp || 0,
+                            is_deal: d.is_deal,
+                        };
+                        const preDetailSource = preSource ? preSource.detail.find(x => { return x.source_id === d.source_cid; }) : undefined;
+                        for (const prop of costFields.selfTextFields) {
+                            ld[prop] = preDetailSource ? preDetailSource[prop] : '';
+                        }
+                        for (const prop of costFields.selfCalcFields) {
+                            ld[prop] = preDetailSource ? preDetailSource[prop] || 0 : 0;
+                            ln[prop] = this.ctx.helper.add(ln[prop], ld[prop]);
+                        }
+                        insertDetailData.push(ld);
+                    }
+                }
+            }
+
+            await transaction.insert(this.tableName, insertData);
+            if (insertDetailData.length > 0) await transaction.insert(this.ctx.service.costStageAnalysisDetail.tableName, insertDetailData);
+        }
+        async initStageData(transaction, stage, preStage) {
+            try {
+                if (preStage) {
+                    await this.initByPre(stage, preStage, transaction);
+                } else {
+                    await this.init(stage, transaction);
+                }
+            } catch(err) {
+                this.ctx.log(err);
+                throw '初始化数据错误';
+            }
+        }
+
+        /**
+         * 新增数据(供内部或其他service类调用, controller不可直接使用)
+         * @param {Array|Object} data - 新增数据
+         * @param {Number} stageId - 期id
+         * @param {Object} transaction - 新增事务
+         * @return {Promise<boolean>} - {Promise<是否正确新增成功>}
+         */
+        async innerAdd(data, stageId, transaction) {
+            const datas = data instanceof Array ? data : [data];
+            if (!stageId) {
+                throw '期id错误';
+            }
+            if (datas.length <= 0) {
+                throw '插入数据为空';
+            }
+            if (!transaction) {
+                throw '内部错误';
+            }
+            // 整理数据
+            const insertData = [];
+            for (const tmp of datas) {
+                tmp[this.setting.id] = tmp.template_id;
+                tmp[this.setting.pid] = tmp.pid;
+                tmp[this.setting.mid] = stageId;
+                delete tmp.template_id;
+                delete tmp.pid;
+                tmp.id = this.uuid.v4();
+                insertData.push(tmp);
+            }
+            const operate = await transaction.insert(this.tableName, insertData);
+            return operate.affectedRows === datas.length;
+        }
+        /**
+         * 新增数据
+         *
+         * @param {Object} data - 新增的数据(可批量)
+         * @param {Number} stageId - 安全计量期id
+         * @return {Boolean} - 返回新增的结果
+         */
+        async add(data, stageId) {
+            this.transaction = await this.db.beginTransaction();
+            let result = false;
+            try {
+                result = await this.innerAdd(data, stageId, this.transaction);
+                if (!result) {
+                    throw '新增数据错误';
+                }
+                await this.transaction.commit();
+            } catch (error) {
+                await this.transaction.rollback();
+                result = false;
+            }
+
+            return result;
+        }
+
+        /**
+         * 根据节点Id获取数据
+         *
+         * @param {Number} stageId - 安全计量期id
+         * @param {Number} nodeId - 项目节/工程量清单节点id
+         * @return {Object} - 返回查询到的节点数据
+         */
+        async getDataByNodeId(stageId, nodeId) {
+            if ((nodeId <= 0) || (stageId <= 0)) {
+                return undefined;
+            }
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.id] = nodeId;
+            const data = await this.db.getDataByCondition(where);
+
+            return data;
+        }
+        /**
+         * 根据节点Id获取数据
+         * @param {Number} stageId - 期Id
+         * @param {Array} nodesIds - 节点Id
+         * @return {Array}
+         */
+        async getDataByNodeIds(stageId, nodesIds) {
+            if (stageId <= 0) {
+                return [];
+            }
+
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.id] = nodesIds;
+            const data = await this.db.getAllDataByCondition({ where });
+
+            return this._.sortBy(data, function(d) {
+                return nodesIds.indexOf(d[this.setting.id]);
+            });
+        }
+        /**
+         * 根据主键id获取数据
+         * @param {Array|Number} id - 主键id
+         * @return {Promise<*>}
+         */
+        async getDataByIds(id) {
+            if (!id) return [];
+            const ids = id instanceof Array ? id : [id];
+            if (ids.length === 0) return [];
+
+            const data = await this.db.getAllDataByCondition({ where: { id: ids } });
+            return data;
+        }
+
+        /**
+         * 根据 父节点id 获取子节点
+         * @param stageId
+         * @param nodeId
+         * @return {Promise<*>}
+         */
+        async getChildrenByParentId(stageId, nodeId) {
+            if (stageId <= 0 || !nodeId) {
+                return undefined;
+            }
+            const nodeIds = nodeId instanceof Array ? nodeId : [nodeId];
+            if (nodeIds.length === 0) {
+                return [];
+            }
+
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.pid] = nodeIds;
+            const data = await this.getAllDataByCondition({ where, orders: [[this.setting.order, 'ASC']] });
+
+            return data;
+        }
+
+        async addAnalysisNode(stage, targetId, count) {
+            if (!stage) return null;
+
+            const select = targetId ? await this.getDataByKid(stage.id, targetId) : null;
+            if (targetId && !select) throw '新增节点数据错误';
+
+            this.transaction = await this.db.beginTransaction();
+            try {
+                if (select) await this._updateChildrenOrder(stage.id, select[this.setting.pid], select[this.setting.order] + 1, count);
+                const newDatas = [];
+                const maxId = await this._getMaxLid(stage.id);
+                for (let i = 1; i < count + 1; i++) {
+                    const newData = {};
+                    newData[this.setting.kid] = maxId + i;
+                    newData[this.setting.pid] = select ? select[this.setting.pid] : this.rootId;
+                    newData[this.setting.mid] = stage.id;
+                    newData[this.setting.level] = select ? select[this.setting.level] : 1;
+                    newData[this.setting.order] = select ? select[this.setting.order] + i : i;
+                    newData[this.setting.fullPath] = newData[this.setting.level] > 1
+                        ? select[this.setting.fullPath].replace('-' + select[this.setting.kid], '-' + newData[this.setting.kid])
+                        : newData[this.setting.kid] + '';
+                    newData[this.setting.isLeaf] = true;
+                    newData.calc_type = calcType.in;
+                    this._getDefaultData(newData, stage);
+                    newDatas.push(newData);
+                }
+                const insertResult = await this.transaction.insert(this.tableName, newDatas);
+                this._cacheMaxLid(stage.id, maxId + count);
+
+                if (insertResult.affectedRows !== count) throw '新增节点数据错误';
+                await this.transaction.commit();
+                this.transaction = null;
+            } catch (err) {
+                await this.transaction.rollback();
+                this.transaction = null;
+                throw err;
+            }
+
+            if (select) {
+                const createData = await this.getChildBetween(stage.id, select[this.setting.pid], select[this.setting.order], select[this.setting.order] + count + 1);
+                const updateData = await this.getNextsData(stage.id, select[this.setting.pid], select[this.setting.order] + count);
+                return {create: createData, update: updateData};
+            } else {
+                const createData = await this.getChildBetween(stage.id, -1, 0, count + 1);
+                return {create: createData};
+            }
+        }
+
+        async getProfitUpdateData(updateData) {
+            const leafNode = await this.getAllDataByCondition({ where: { stage_id: this.ctx.costStage.id, tree_is_leaf: 1 } });
+            const profit = leafNode.find(x => { return x.node_type === 3; });
+            if (!profit) throw '成本分析基础数据错误,找不到利润节点';
+            const profitRate = leafNode.find(x => { return x.node_type === 4; });
+            if (!profitRate) throw '成本分析基础数据错误,找不到利润率节点';
+
+            const profitUpdate = { id: profit.id, tree_id: profit.tree_id, update_user_id: this.ctx.session.sessionUser.accountId };
+            const profitRateUpdate = { id: profitRate.id, tree_id: profitRate.tree_id, update_user_id: this.ctx.session.sessionUser.accountId };
+            const inSum = {};
+            for (const node of leafNode) {
+                const ud = updateData.find(x => { return x.id === node.id; });
+                const calcData = ud || node;
+                if (node.calc_type === 1) {
+                    for (const prop of costFields.sumFields) {
+                        profitUpdate[prop] = this.ctx.helper.add(profitUpdate[prop], calcData[prop] !== undefined ? calcData[prop] : node[prop]);
+                        inSum[prop] = this.ctx.helper.add(inSum[prop], calcData[prop] !== undefined ? calcData[prop] : node[prop]);
+                    }
+                } else if (node.calc_type === 2) {
+                    for (const prop of costFields.sumFields) {
+                        profitUpdate[prop] = this.ctx.helper.sub(profitUpdate[prop], calcData[prop] !== undefined ? calcData[prop] : node[prop]);
+                    }
+                }
+            }
+            this.ctx.service.calcTmpl.calcByTemplate(profitUpdate, profitUpdate, profit, this.ctx.costStage.calcTemplate.calc_expr);
+            for (const prop of costFields.sumFields) {
+                if (prop !== 'sf_percent') {
+                    profitRateUpdate[prop] = inSum[prop] ? this.ctx.helper.mul(this.ctx.helper.div(profitUpdate[prop], inSum[prop]), 100, 2) : 0;
+                } else {
+                    profitRateUpdate[prop] = 0;
+                }
+            }
+            return [profitUpdate, profitRateUpdate];
+        }
+        async updateCalc(stage, data) {
+            const helper = this.ctx.helper;
+            // 简单验证数据
+            if (!stage) throw '成本分析不存在';
+            const decimal = stage.decimal;
+            if (!data) throw '提交数据错误';
+            const datas = data instanceof Array ? data : [data];
+            const ids = datas.map(x => { return x.id; });
+            const orgData = await this.getAllDataByCondition({ where: { id: ids }});
+            const updateData = [];
+            for (const row of datas) {
+                const oData = orgData.find(x => { return x.id === row.id });
+                if (!oData || oData.stage_id !== stage.id || oData.tree_id !== row.tree_id) throw '提交数据错误';
+
+                let nData = { id: oData.id, tree_id: oData.tree_id, update_user_id: this.ctx.session.sessionUser.accountId };
+
+                // calc
+                nData.tax = row.tax !== undefined ? helper.round(row.tax || 0, decimal.tax) : oData.tax;
+                for (const field of costFields.textFields) {
+                    if (row[field] !== undefined) nData[field] = row[field] || '';
+                }
+                for (const field of costFields.selfTextFields) {
+                    if (row[field] !== undefined) nData[field] = row[field] || '';
+                }
+                this.ctx.service.calcTmpl.calcByTemplate(nData, row, oData, stage.calcTemplate.calc_expr);
+                updateData.sf_percent = updateData.yf_excl_tax_tp ? this.ctx.helper.mul(this.ctx.helper.div(updateData.sf_excl_tax_tp, updateData.yf_excl_tax_tp), 100, 2) : 0;
+                updateData.push(nData);
+            }
+            const profitUpdate = await this.getProfitUpdateData(updateData);
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.updateRows(this.tableName, updateData);
+                await conn.updateRows(this.tableName, profitUpdate);
+                await conn.commit();
+            } catch(err) {
+                await conn.rollback();
+                throw '计算错误';
+            }
+
+            return { update: [...updateData, ...profitUpdate] };
+        }
+        async importPay(stage, payOrder, payTax) {
+            const payNode = await this.getDataByCondition({ stage_id: stage.id, node_type: 1 });
+            if (!payNode) throw '找不到计价收入节点';
+
+            const payUpdate = { id: payNode.id, tree_id: payNode.tree_id, update_user_id: this.ctx.session.sessionUser.accountId, tax: payTax || 0 };
+            const divNum = payUpdate.tax ? this.ctx.helper.add(1, this.ctx.helper.div(payUpdate.tax, 100)) : 1;
+            let payStage;
+            if (this.ctx.subProject.page_show.phasePay) {
+                payStage = await this.ctx.service.phasePay.getPhasePayByOrder(stage.tid, payOrder);
+                if (!payStage) throw '选择的合同支付期不存在';
+            } else {
+                payStage = await this.ctx.service.phasePay.getDataByCondition({ tid: stage.tid, order: payOrder });
+                if (!payStage) throw '选择的计量期不存在';
+            }
+            const yf = this.ctx.helper.add(payStage.yf_tp, payStage.pre_yf_tp);
+            const sf = this.ctx.helper.add(payStage.sf_tp, payStage.pre_sf_tp);
+            payUpdate.yf_excl_tax_tp = this.ctx.helper.div(yf, divNum, this.ctx.costStage.decimal.tp);
+            payUpdate.sf_excl_tax_tp = this.ctx.helper.div(sf, divNum, this.ctx.costStage.decimal.tp);
+            this.ctx.service.calcTmpl.calcByTemplate(payUpdate, payUpdate, payNode, stage.calcTemplate.calc_expr);
+
+            const profitUpdate = await this.getProfitUpdateData([payUpdate]);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.update(this.tableName, payUpdate);
+                await conn.updateRows(this.tableName, profitUpdate);
+                await conn.commit();
+            } catch(err) {
+                await conn.rollback();
+                throw '计算错误';
+            }
+            return { update: [payUpdate, ...profitUpdate] };
+        }
+        async reCalcProfit(stageId) {
+            if (!this.ctx.costStage) {
+                const costStage = await this.service.costStage.getStage(stageId);
+                if (costStage.calc_template) costStage.calcTemplate = await this.service.calcTmpl.getTemplate(costStage.calc_template);
+                this.ctx.costStage = costStage;
+            }
+            const profitUpdate = await this.getProfitUpdateData([]);
+            await this.db.updateRows(this.tableName, profitUpdate);
+        }
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId, tree_is_leaf: true} });
+            const updateData = [];
+            for (const l of leaf) {
+                l.calc_read = l.calc_read ? JSON.parse(l.calc_read) : {};
+                const diff = costFields.sumFields.find(x => {
+                    return l[x] !== l.calc_read[x];
+                });
+                if (diff) {
+                    const data = { id: l.id, calc_read: {} };
+                    // cache read
+                    for (const f of costFields.sumFields) {
+                        data.calc_read[f] = l[f];
+                    }
+                    data.calc_read = JSON.stringify(data.calc_read);
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.sumFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+
+        async setDecimal(decimal) {
+            if (!this.ctx.costStage) throw '读取数据错误';
+            if (this.ctx.costStage.stage_order !== this.ctx.costStage.latestOrder) throw '往期不可修改小数位数';
+            if (this.ctx.costStage.audit_status !== auditConst.status.uncheck && this.ctx.costStage.audit_status !== auditConst.status.checkNo) throw '仅原报可修改小数位数';
+            const orgDecimal = this.ctx.costStage.decimal;
+
+            const calcTp = decimal.tp < orgDecimal.tp;
+            this.ctx.costStage.decimal = { tp: decimal.tp, tax: decimal.tax || this.ctx.costStage.decimal.tax };
+
+            const updateData = [];
+            if (calcTp) {
+                const calcData = await this.getAllDataByCondition({ where: {stage_id: this.ctx.costStage.id, tree_is_leaf: 1 } });
+                for (const cd of calcData) {
+                    const nd = { id: cd.id, tree_id: cd.tree_id };
+                    for (const prop of costFields.calcFields) {
+                        nd[prop] = this.ctx.helper.round(nd[prop], decimal.tp);
+                    }
+                    updateData.push(nd);
+                }
+            }
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.update(this.ctx.service.costStage.tableName, { id: this.ctx.costStage.id, decimal: JSON.stringify(this.ctx.costStage.decimal)});
+                if (updateData.length > 0) await conn.updateRows(this.tableName, updateData);
+                await conn.commit();
+                return { calc: calcTp, update: updateData };
+            } catch(err) {
+                await conn.rollback();
+                return { calc: false, update: [] };
+            }
+
+        }
+
+        async getSum(stage) {
+            const field = 'num_h';
+            const topNode = await this.db.query(`SELECT * FROM ${this.tableName} where stage_id = ? and tree_level = 1`, [stage.id]);
+            const result = {};
+            for (const tn of topNode) {
+                if (topNode.node_type === 2) {
+                    result.out_tp = tn[field];
+                } else if (topNode.node_type === 3) {
+                    result.profit = tn[field];
+                } else if (topNode.node_type === 4) {
+                    result.profit_percent = tn[field];
+                } else {
+                    result.in_tp = tn[field];
+                }
+            }
+            return result;
+        }
+    }
+
+    return CostStageAnalysis;
+};

+ 160 - 0
app/service/cost_stage_analysis_detail.js

@@ -0,0 +1,160 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const costFields = {
+    textFields: ['code', 'name', 'party_b', 'postil'],
+    selfTextFields: ['str1', 'str2', 'str3', 'str4'],
+    calcFields: ['yf_excl_tax_tp', 'in_excl_tax_tp', 'sf_excl_tax_tp', 'sf_percent'],
+    selfCalcFields: ['num_a', 'num_b', 'num_c', 'num_d', 'num_e', 'num_f', 'num_g', 'num_h', 'num_i', 'num_j', 'num_k', 'num_l', 'num_m', 'num_n', 'num_o', 'num_p'],
+    readFields: ['calc_read'],
+    hisFields: ['calc_his'],
+    taxFields: ['tax'],
+    baseFields: ['id', 'cost_id', 'tender_id', 'stage_id', 'ledger_id', 'source_id', 'is_deal'],
+};
+costFields.sumFields = [...costFields.calcFields, ...costFields.selfCalcFields];
+costFields.preCopyFields = [...costFields.selfTextFields, ...costFields.calcFields, ...costFields.selfCalcFields, ...costFields.taxFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.calcFields, ...costFields.selfCalcFields, ...costFields.taxFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.readFields, ...costFields.taxFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.selfTextFields, ...costFields.taxFields, ...costFields.hisFields];
+
+module.exports = app => {
+    class CostStageDetail extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_analysis_detail';
+        }
+
+        async getEditData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                x.calc_read = x.calc_read ? JSON.parse(x.calc_read) : {};
+                for(const prop of costFields.calcFields) {
+                    x[prop] = x.calc_read[prop] || 0;
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+
+        async _getLedgerUpdateData(data, ledger_id, details) {
+            const detailDatas = details || await this.getAllDataByCondition({ columns: ['id', 'ledger_id', ...costFields.calcFields], where: { ledger_id, stage_id: this.ctx.costStage.id } });
+
+            const curCalcFields = costFields.selfCalcFields.filter(x => { return data[0][x] !== undefined; });
+            const updateData = { id: ledger_id, update_user_id: this.ctx.session.sessionUser.accountId };
+            for (const d of data) {
+                for (const prop of curCalcFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            for (const d of detailDatas) {
+                if (data.findIndex(x => { return x.id === d.id; }) >= 0) continue;
+                for (const prop of curCalcFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            return updateData;
+        }
+        async updateDatas(data) {
+            if (!data || data.length === 0) throw '提交数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const datas = data instanceof Array ? data : [data];
+            const orgDataIds = this.ctx.helper._.map(datas.filter(x => { return x.id }), 'id');
+            const orgDatas = orgDataIds.length > 0 ? await this.getAllDataByCondition({ where: { id: orgDataIds } }) : [];
+
+            const updateDetail = [];
+            for (const d of datas) {
+                const od = orgDatas.find(x => { return x.id === d.id; });
+                if (!od) throw '数据错误';
+
+                const nd = { id: od.id, update_user_id: user_id };
+                for (const prop of costFields.textFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop];
+                }
+                for (const prop of costFields.selfTextFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop];
+                }
+                this.ctx.service.calcTmpl.calcByTemplate(nd, d, od, this.ctx.costStage.calcTemplate.calc_expr, {});
+
+                updateDetail.push(nd);
+            }
+            const billsUpdate = await this._getLedgerUpdateData(updateDetail, orgDatas[0].ledger_id);
+            const bills = await this.ctx.service.costStageAnalysis.getDataById(billsUpdate.id);
+            billsUpdate.tree_id = bills.tree_id;
+            const profitUpdate = await this.ctx.service.costStageAnalysis.getProfitUpdateData([billsUpdate]);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.updateRows(this.tableName, updateDetail);
+                await conn.update(this.ctx.service.costStageAnalysis.tableName, billsUpdate);
+                await conn.updateRows(this.ctx.service.costStageAnalysis.tableName, profitUpdate);
+                await conn.commit();
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return { detail: { update: updateDetail }, ledger: [billsUpdate, ...profitUpdate] };
+        }
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId} });
+            const updateData = [];
+            for (const l of leaf) {
+                l.calc_read = l.calc_read ? JSON.parse(l.calc_read) : {};
+                const diff = costFields.sumFields.find(x => {
+                    return l[x] !== l.calc_read[x];
+                });
+                if (diff) {
+                    const data = { id: l.id, calc_read: {} };
+                    // cache read
+                    for (const f of costFields.sumFields) {
+                        data.calc_read[f] = l[f];
+                    }
+                    data.calc_read = JSON.stringify(data.calc_read);
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.sumFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+    }
+
+    return CostStageDetail;
+};
+

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1197 - 0
app/service/cost_stage_audit.js


+ 193 - 0
app/service/cost_stage_book.js

@@ -0,0 +1,193 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const costFields = {
+    textFields: ['postil', 'memo'],
+    preFields: ['pre_in_tp', 'pre_in_excl_tax_tp'],
+    curFields: ['in_tp', 'in_excl_tax_tp'],
+    readFields: ['read_in_tp', 'read_in_excl_tax_tp'],
+    baseFields: ['id', 'tender_id', 'stage_id', 'ledger_id', 'cost_id'],
+};
+costFields.calcFields = [...costFields.curFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.preFields, ...costFields.curFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.preFields, ...costFields.readFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.preFields, ...costFields.curFields, 'calc_his'];
+
+module.exports = app => {
+    class CostStageBook extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_book';
+        }
+
+        async getEditData(stage) {
+            const helper = this.ctx.helper;
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x['end_' + prop] = helper.add(x['pre_' + prop], x[prop]);
+                }
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const helper = this.ctx.helper;
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x[prop] = x['read_' + prop];
+                    x['end_' + prop] = helper.add(x['pre_' + prop], x[prop]);
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+
+        async initStageData(transaction, stage, preStage) {
+            if (!preStage) return;
+
+            if (!stage || !transaction) throw '财务账面数据错误';
+
+            const preBooks = await this.getAllDataByCondition({
+                where: { stage_id: preStage.id },
+                columns: costFields.editQueryFields
+            });
+            const insertData = [];
+            for (const b of preBooks) {
+                b.id = this.uuid.v4();
+                b.stage_id = stage.id;
+                delete b.postil;
+                for(const prop of costFields.curFields) {
+                    b['pre_' + prop] = this.ctx.helper.add(b['pre_' + prop], b[prop]);
+                    b[prop] = 0;
+                }
+                insertData.push(b);
+            }
+            const operate = await transaction.insert(this.tableName, insertData);
+            return operate.affectedRows === insertData.length;
+        }
+
+        async updateDatas(data) {
+            if (!data || data.length === 0) throw '提交数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const datas = data instanceof Array ? data : [data];
+            const orgLedger = await this.ctx.service.costStageLedger.getAllDataByCondition({
+                where: {
+                    stage_id: this.ctx.costStage.relaStage.id,
+                    id: this.ctx.helper._.map(datas, 'ledger_id')
+                }
+            });
+            const orgDataIds = this.ctx.helper._.map(datas.filter(x => { return x.book_id }), 'book_id');
+            const orgDatas = orgDataIds.length > 0 ? await this.getAllDataByCondition({ where: { id: orgDataIds } }) : [];
+
+            const updateDetail = [], insertDetail = [];
+            for (const d of datas) {
+                const od = orgDatas.find(x => { return x.id === d.book_id; });
+                const cl = orgLedger.find(x => { return x.id === d.ledger_id });
+                if (!cl) throw '关联期台账错误';
+
+                const nd = od
+                    ? { id: od.id, update_user_id: user_id }
+                    : { id: this.uuid.v4(), tender_id: this.ctx.costStage.tid, stage_id: this.ctx.costStage.id, ledger_id: d.ledger_id, cost_id: d.cost_id, add_user_id: user_id, update_user_id: user_id };
+                for (const prop of costFields.textFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop];
+                }
+                if (d.in_tp !== undefined ) {
+                    nd.in_tp = d.in_tp !== undefined ? this.ctx.helper.round(d.in_tp || 0, this.ctx.costStage.decimal.tp) : (od.in_tp ? od.in_tp : 0);
+                    const divNum = this.ctx.helper.add(1, this.ctx.helper.div(cl.tax, 100));
+                    nd.in_excl_tax_tp = this.ctx.helper.div(nd.in_tp, divNum, this.ctx.costStage.decimal.tp);
+                }
+                if (od) {
+                    updateDetail.push(nd);
+                } else {
+                    insertDetail.push(nd);
+                }
+            }
+
+            const conn = await this.db.beginTransaction();
+            try {
+                if (updateDetail.length > 0) await conn.updateRows(this.tableName, updateDetail);
+                if (insertDetail.length > 0) await conn.insert(this.tableName, insertDetail);
+                await conn.commit();
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            insertDetail.forEach(i => {
+                i.book_id = i.id;
+                delete i.id;
+                const cl = orgLedger.find(x => { return x.id === i.ledger_id });
+                i.tree_id = cl.tree_id;
+            });
+            updateDetail.forEach(u => {
+                u.book_id = u.id;
+                delete u.id;
+                const od = orgDatas.find(x => { return x.id === u.book_id; });
+                const cl = orgLedger.find(x => { return x.id === od.ledger_id; });
+                u.tree_id = cl.tree_id;
+            });
+            return { update: [...insertDetail, ...updateDetail] };
+        }
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId } });
+            const updateData = [];
+            for (const l of leaf) {
+                const diff = costFields.curFields.find(x => {
+                    return l[x] !== l['read_' + x];
+                });
+                if (diff) {
+                    const data = { id: l.id };
+                    // cache read
+                    for (const f of costFields.curFields) {
+                        data['read_' + f] = l[f];
+                    }
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.curFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+
+        async getSum(stage) {
+            const sumFields = costFields.curFields.map(f => { return `SUM(${f}) AS ${f}`});
+            const result = await this.db.queryOne(`SELECT ${sumFields.join(', ')} FROM ${this.tableName} where stage_id = ?`, [stage.id]);
+            return result;
+        }
+    }
+
+    return CostStageBook;
+};

+ 231 - 0
app/service/cost_stage_book_detail.js

@@ -0,0 +1,231 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const costFields = {
+    textFields: ['postil', 'memo'],
+    curFields: ['in_tp', 'in_excl_tax_tp'],
+    readFields: ['read_in_tp', 'read_in_excl_tax_tp'],
+    baseFields: ['id', 'tender_id', 'stage_id', 'detail_id'],
+};
+costFields.calcFields = [...costFields.curFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.curFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.readFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.curFields, 'calc_his'];
+
+module.exports = app => {
+    class CostStageBookDetail extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_book_detail';
+        }
+
+        async getEditData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x[prop] = x['read_' + prop];
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+        async getEndData(stage) {
+            const commonDetailSql =
+                'SELECT sd.cost_id, sd.code, sd.name, sd.tax, sd.is_deal, sd.id AS source_cid, sd.pay_tp, sd.cut_tp, sd.yf_tp, sd.sf_tp, sd.yf_excl_tax_tp, sd.sf_excl_tax_tp, sbd.in_tp, sbd.in_excl_tax_tp ' +
+                `  FROM ${this.ctx.service.costStageDetail.tableName} sd LEFT JOIN ${this.tableName} sbd ON sbd.detail_id = sd.id` +
+                `    LEFT JOIN ${this.ctx.service.costStage.tableName} cs ON sd.stage_id = cs.id` +
+                '  WHERE cs.tid = ? AND cs.stage_order <= ? AND sd.is_deal = 0';
+            const commonDetail = await this.db.query(commonDetailSql, [stage.tid, stage.stage_order]);
+            // 以下sql需要8.0的mysql才支持
+            // const dealDetailSql =
+            //     'SELECT * FROM ( ' +
+            //     '  SELECT sd.cost_id, sd.code, sd.name, sd.tax, sd.source_cid AS source_id,' +
+            //     '      sum(sd.pay_tp) OVER (PARTITION BY sd.source_cid) AS pay_tp,' +
+            //     '      sum(sd.cut_tp) OVER (PARTITION BY sd.source_cid) AS cut_tp,' +
+            //     '      sum(sd.yf_tp) OVER (PARTITION BY sd.source_cid) AS yf_tp,' +
+            //     '      sum(sd.sf_tp) OVER (PARTITION BY sd.source_cid) AS sf_tp,' +
+            //     '      sum(sd.yf_excl_tax_tp) OVER (PARTITION BY sd.source_cid) AS yf_excl_tax_tp,' +
+            //     '      sum(sd.sf_excl_tax_tp) OVER (PARTITION BY sd.source_cid) AS sf_excl_tax_tp,' +
+            //     '      sum(sbd.in_tp) OVER (PARTITION BY sd.source_cid) AS in_tp,' +
+            //     '      sum(sbd.in_excl_tax_tp) OVER (PARTITION BY sd.source_cid) AS in_excl_tax_tp,' +
+            //     '      ROW_NUMBER() OVER (PATITION BY sd.source_cid ORDER BY create_time DESC) AS rn' +
+            //     `    FROM ${this.ctx.service.costStageDetail.tableName} sd LEFT JOIN ${this.tableName} sbd ON sbd.detail_id = sd.id` +
+            //     `      LEFT JOIN ${this.ctx.service.costStage.tableName} cs ON sd.stage_id = cs.id` +
+            //     '    WHERE cs.tid = ? AND cs.stage_order <= ? AND sd.is_deal = 1' +
+            //     '  ) t WHERE t.rn = 1';
+            const dealDetailSql =
+                'SELECT a.code, a.name, a.tax, a.is_deaL, b.* FROM (' +
+                '  SELECT sd.cost_id, sd.code, sd.name, sd.tax, sd.is_deal, sd.source_cid' +
+                '    FROM (' +
+                '      SELECT cost_id, source_cid, max(add_time) AS latest_time FROM zh_cost_stage_detail WHERE tender_id = ? AND stage_order <= ? AND is_deal = 1 GROUP BY cost_id, source_cid' +
+                '    ) lastTable LEFT JOIN zh_cost_stage_detail sd ON lastTable.latest_time = sd.add_time AND sd.cost_id = lastTable.cost_id AND sd.source_cid = lastTable.source_cid' +
+                '  ) a' +
+                '  LEFT JOIN ( ' +
+                '    SELECT csd.cost_id, csd.source_cid, sum(csd.pay_tp) AS pay_tp, sum(csd.cut_tp) AS cut_tp, ' +
+                '        sum(csd.yf_tp) AS yf_tp, sum(csd.sf_tp) AS sf_tp, ' +
+                '        sum(csd.yf_excl_tax_tp) AS yf_excl_tax_tp, sum(csd.sf_excl_tax_tp) AS sf_excl_tax_tp,' +
+                '        sum(csbd.in_tp) AS in_tp, sum(csbd.in_excl_tax_tp) As in_excl_tax_tp' +
+                '      FROM zh_cost_stage_detail csd LEFT JOIN zh_cost_stage_book_detail csbd ON csd.id = csbd.detail_id' +
+                '      WHERE csd.tender_id = ? AND csd.stage_order <= ? AND csd.is_deal = 1 GROUP BY csd.cost_id, csd.source_cid' +
+                '  ) b ON a.cost_id = b.cost_id AND a.source_cid = b.source_cid';
+            const dealDetail = await this.db.query(dealDetailSql, [stage.tid, stage.stage_order, stage.tid, stage.stage_order]);
+            return [...commonDetail, ...dealDetail];
+        }
+
+        async _getBookUpdateData(data, ledger_id, details) {
+            const detailDatas = details || await this.getAllDataByCondition({ columns: ['id', 'ledger_id', ...costFields.curFields], where: { ledger_id, stage_id: this.ctx.costStage.id } });
+
+            const updateData = { id: ledger_id, update_user_id: this.ctx.session.sessionUser.accountId };
+            for (const d of data) {
+                for (const prop of costFields.curFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            for (const d of detailDatas) {
+                if (data.findIndex(x => { return x.id === d.id; }) >= 0) continue;
+                for (const prop of costFields.curFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            return updateData;
+        }
+        async updateDatas(data) {
+            if (!data || data.length === 0) throw '提交数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const datas = data instanceof Array ? data : [data];
+            const orgDetail = await this.ctx.service.costStageDetail.getAllDataByCondition({
+                where: {
+                    stage_id: this.ctx.costStage.relaStage.id,
+                    id: this.ctx.helper._.map(datas, 'detail_id')
+                }
+            });
+            const orgDataIds = this.ctx.helper._.map(datas.filter(x => { return x.book_id }), 'book_id');
+            const orgDatas = orgDataIds.length > 0 ? await this.getAllDataByCondition({ where: { id: orgDataIds } }) : [];
+
+            const updateDetail = [], insertDetail = [];
+            for (const d of datas) {
+                const od = orgDatas.find(x => { return x.id === d.book_id; });
+                const cd = orgDetail.find(x => { return x.id === d.detail_id });
+                if (!cd) throw '关联期明细错误';
+
+                const nd = od
+                    ? { id: od.id, update_user_id: user_id }
+                    : { id: this.uuid.v4(), tender_id: this.ctx.costStage.tid, stage_id: this.ctx.costStage.id, ledger_id: d.ledger_id, detail_id: d.detail_id, add_user_id: user_id, update_user_id: user_id };
+                for (const prop of costFields.textFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop];
+                }
+                if (d.in_tp !== undefined ) {
+                    nd.in_tp = d.in_tp !== undefined ? this.ctx.helper.round(d.in_tp || 0, this.ctx.costStage.decimal.tp) : (od.in_tp ? od.in_tp : 0);
+                    const divNum = this.ctx.helper.add(1, this.ctx.helper.div(cd.tax, 100));
+                    nd.in_excl_tax_tp = this.ctx.helper.div(nd.in_tp, divNum, this.ctx.costStage.decimal.tp);
+                }
+                if (od) {
+                    updateDetail.push(nd);
+                } else {
+                    insertDetail.push(nd);
+                }
+            }
+            const bookUpdate = await this._getBookUpdateData([...insertDetail, ...updateDetail], orgDetail[0].ledger_id);
+            const orgBook = await this.ctx.service.costStageBook.getDataByCondition({ ledger_id: orgDetail[0].ledger_id });
+            const orgLedger = await this.ctx.service.costStageLedger.getDataById(orgDetail[0].ledger_id);
+            if (!orgBook) {
+                bookUpdate.tender_id = this.ctx.costStage.tid;
+                bookUpdate.stage_id = this.ctx.costStage.id;
+                bookUpdate.ledger_id = orgLedger.id;
+                bookUpdate.cost_id = orgLedger.cost_id;
+                bookUpdate.add_user_id = user_id;
+            }
+
+            const conn = await this.db.beginTransaction();
+            try {
+                if (updateDetail.length > 0) await conn.updateRows(this.tableName, updateDetail);
+                if (insertDetail.length > 0) await conn.insert(this.tableName, insertDetail);
+                if (orgBook) {
+                    await conn.update(this.ctx.service.costStageBook.tableName, bookUpdate);
+                } else {
+                    await conn.insert(this.ctx.service.costStageBook.tableName, bookUpdate);
+                }
+                await conn.commit();
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+
+            insertDetail.forEach(i => {
+                i.book_id = i.id;
+                const cl = orgDetail.find(x => { return x.id === i.detail_id });
+                i.id = cl.id;
+            });
+            updateDetail.forEach(u => {
+                u.book_id = u.id;
+                const od = orgDatas.find(x => { return x.id === u.book_id; });
+                const cl = orgDetail.find(x => { return x.id === od.detail_id; });
+                u.id = cl.id;
+            });
+            bookUpdate.book_id = bookUpdate.id;
+            delete bookUpdate.id;
+            bookUpdate.tree_id = orgLedger.tree_id;
+            return { detail: { update: [...insertDetail, ...updateDetail] }, ledger: bookUpdate };
+        }
+
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId } });
+            const updateData = [];
+            for (const l of leaf) {
+                const diff = costFields.curFields.find(x => {
+                    return l[x] !== l['read_' + x];
+                });
+                if (diff) {
+                    const data = { id: l.id };
+                    // cache read
+                    for (const f of costFields.curFields) {
+                        data['read_' + f] = l[f];
+                    }
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.curFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+    }
+
+    return CostStageBookDetail;
+};

+ 371 - 0
app/service/cost_stage_detail.js

@@ -0,0 +1,371 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const costFields = {
+    textFields: ['code', 'name', 'party_b', 'postil', 'memo'],
+    curFields: ['pay_tp', 'cut_tp', 'yf_tp', 'sf_tp', 'yf_excl_tax_tp', 'sf_excl_tax_tp'],
+    readFields: ['read_pay_tp', 'read_cut_tp', 'read_yf_tp', 'read_sf_tp', 'read_yf_excl_tax_tp', 'read_sf_excl_tax_tp'],
+    taxFields: ['tax'],
+    baseFields: ['id', 'tender_id', 'stage_id', 'ledger_id', 'cost_id', 'd_order', 'is_deal', 'is_used'],
+};
+costFields.calcFields = [...costFields.curFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.curFields, ...costFields.taxFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.readFields, ...costFields.taxFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.textFields, ...costFields.curFields, ...costFields.taxFields, 'calc_his'];
+
+module.exports = app => {
+    class CostStageDetail extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_detail';
+        }
+
+        async getEditData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x[prop] = x['read_' + prop];
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+        async getEndData(stage) {
+            const commonDetailSql = 'SELECT sd.cost_id, sd.id AS source_id, sd.code, sd.name, sd.tax, sd.pay_tp, sd.cut_tp, sd.yf_tp, sd.sf_tp, sd.yf_excl_tax_tp, sd.sf_excl_tax_tp ' +
+                `  FROM ${this.tableName} sd LEFT JOIN ${this.ctx.service.costStage.tableName} cs ON sd.stage_id = cs.id` +
+                '  WHERE cs.tid = ? AND cs.stage_order <= ? AND sd.is_deal = 0';
+            const commonDetail = await this.db.query(commonDetailSql, [stage.tid, stage.stage_order]);
+            // 以下sql只有在mysqlV8.0以后才可使用
+            // const dealDetailSql =
+            //     'SELECT * FROM ( ' +
+            //     '  SELECT sd.cost_id, sd.code, sd.name, sd.tax, source_cid AS source_id,' +
+            //     '      sum(sd.pay_tp) OVER (PARTITION BY sd.source_cid) AS pay_tp,' +
+            //     '      sum(sd.cut_tp) OVER (PARTITION BY sd.source_cid) AS cut_tp,' +
+            //     '      sum(sd.yf_tp) OVER (PARTITION BY sd.source_cid) AS yf_tp,' +
+            //     '      sum(sd.sf_tp) OVER (PARTITION BY sd.source_cid) AS sf_tp,' +
+            //     '      sum(sd.yf_excl_tax_tp) OVER (PARTITION BY sd.source_cid) AS yf_excl_tax_tp,' +
+            //     '      sum(sd.sf_excl_tax_tp) OVER (PARTITION BY sd.source_cid) AS sf_excl_tax_tp,' +
+            //     '      ROW_NUMBER() OVER (PATITION BY sd.source_cid ORDER BY create_time DESC) AS rn' +
+            //     `    FROM ${this.tableName} sd LEFT JOIN ${this.ctx.service.costStage.tableName} cs ON sd.stage_id = cs.id` +
+            //     '    WHERE cs.tid = ? AND cs.stage_order <= ? AND sd.is_deal = 1' +
+            //     '  ) t WHERE t.rn = 1';
+            const dealDetailSql =
+                'SELECT a.code, a.name, a.tax, b.* FROM (' +
+                '  SELECT sd.cost_id, sd.code, sd.name, sd.tax, sd.source_cid' +
+                '    FROM (' +
+                '      SELECT source_cid, max(add_time) AS latest_time FROM zh_cost_stage_detail WHERE tender_id = ? AND stage_order <= ? AND is_deal = 1 GROUP BY cost_id, source_cid' +
+                '    ) lastTable LEFT JOIN zh_cost_stage_detail sd ON lastTable.latest_time = sd.add_time AND sd.cost_id = lastTable.cost_id AND sd.source_cid = lastTable.source_cid' +
+                '  ) a' +
+                '  LEFT JOIN ( ' +
+                '    SELECT cost_id, source_cid, sum(pay_tp) AS pay_tp, sum(cut_tp) AS cut_tp, ' +
+                '        sum(yf_tp) AS yf_tp, sum(sf_tp) AS sf_tp, ' +
+                '        sum(yf_excl_tax_tp) AS yf_excl_tax_tp, sum(sf_excl_tax_tp) AS sf_excl_tax_tp' +
+                '      FROM zh_cost_stage_detail WHERE tender_id = ? AND stage_order <= ? AND is_deal = 1 GROUP BY cost_id, source_cid' +
+                ') b ON a.cost_id = b.cost_id AND a.source_cid = b.source_cid';
+            const dealDetail = await this.db.query(dealDetailSql, [stage.tid, stage.stage_order, stage.tid, stage.stage_order]);
+            return [...commonDetail, ...dealDetail];
+        }
+
+        async _getLedgerUpdateData(data, ledger_id, details) {
+            const detailDatas = details || await this.getAllDataByCondition({ columns: ['id', 'ledger_id', ...costFields.curFields], where: { ledger_id, stage_id: this.ctx.costStage.id } });
+
+            const updateData = { id: ledger_id, update_user_id: this.ctx.session.sessionUser.accountId };
+            for (const d of data) {
+                for (const prop of costFields.curFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            for (const d of detailDatas) {
+                if (data.findIndex(x => { return x.id === d.id; }) >= 0) continue;
+                for (const prop of costFields.curFields) {
+                    updateData[prop] = this.ctx.helper.add(updateData[prop], d[prop] || 0);
+                }
+            }
+            return updateData;
+        }
+        async _addDatas(data) {
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const datas = data instanceof Array ? data : [data];
+            const insertData = [];
+            for (const d of datas) {
+                if (!d.ledger_id || !d.cost_id || !d.d_order) throw '新增明细数据,提交的数据错误';
+                const nd = {
+                    id: this.uuid.v4(), tender_id: this.ctx.costStage.tid,
+                    stage_id: this.ctx.costStage.id, stage_order: this.ctx.costStage.stage_order,
+                    ledger_id: d.ledger_id, cost_id: d.cost_id, d_order: d.d_order,
+                    add_user_id: user_id, update_user_id: user_id,
+                };
+                for (const prop of costFields.textFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop] || '';
+                }
+                if (d.pay_tp !== undefined || d.cut_tp !== undefined) {
+                    nd.pay_tp = d.pay_tp !== undefined ? this.ctx.helper.round(d.pay_tp || 0, this.ctx.costStage.decimal.tp) : 0;
+                    nd.cut_tp = d.cut_tp !== undefined ? this.ctx.helper.round(d.cut_tp || 0, this.ctx.costStage.decimal.tp) : 0;
+                    nd.yf_tp = this.ctx.helper.sub(nd.pay_tp, nd.cut_tp);
+                    nd.sf_tp = nd.yf_tp;
+                }
+                if (d.sf_tp !== undefined) nd.sf_tp = this.ctx.helper.round(d.sf_tp || 0, this.ctx.costStage.decimal.tp);
+                if (d.tax !== undefined || nd.sf_tp !== undefined || nd.yf_tp !== undefined) {
+                    nd.tax = d.tax !== undefined ? this.ctx.helper.round(d.tax || 0, this.ctx.costStage.decimal.tax) : 0;
+                    const divNum = nd.tax ? this.ctx.helper.add(1, this.ctx.helper.div(nd.tax, 100)) : 1;
+                    nd.yf_excl_tax_tp = this.ctx.helper.div(nd.yf_tp !== undefined ? nd.yf_tp : 0, divNum, this.ctx.costStage.decimal.tp);
+                    nd.sf_excl_tax_tp = this.ctx.helper.div(nd.sf_tp !== undefined ? nd.sf_tp : 0, divNum, this.ctx.costStage.decimal.tp);
+                }
+                insertData.push(nd);
+            }
+            const billsUpdate = await this._getLedgerUpdateData(insertData, insertData[0].ledger_id);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.insert(this.tableName, insertData);
+                await conn.update(this.ctx.service.costStageLedger.tableName, billsUpdate);
+                await conn.commit();
+            } catch(err) {
+                await conn.rollback();
+                throw err;
+            }
+            const addData = await this.getAllDataByCondition({
+                where: { id: this.ctx.helper._.map(insertData, 'id') }
+            });
+            const ledgerData = await this.ctx.service.costStageLedger.getDataById(addData[0].ledger_id);
+            return [addData, ledgerData]
+        }
+        async _delDatas (data) {
+            if (!data || data.length === 0) throw '提交数据错误';
+            const orgDatas = await this.getAllDataByCondition({ where: { id: data } });
+
+            if (!orgDatas || orgDatas.length === 0) throw '删除的明细数据不存在';
+
+            const bills = await this.ctx.service.costStageLedger.getDataById(orgDatas[0].ledger_id);
+            let detail = await this.getAllDataByCondition({ where: { stage_id: this.ctx.costStage.id, ledger_id: bills.id } });
+
+            detail = detail.filter(ba => {
+                return data.indexOf(ba.id) < 0;
+            });
+            detail.sort((x, y) => { return x.d_order - y.d_order; });
+
+            const updateData = [];
+            detail.forEach((x, i) => {
+                if (x.d_order !== i + 1) updateData.push({ id: x.id, d_order: i + 1});
+            });
+            const billsUpdate = await this._getLedgerUpdateData(orgDatas.map(x => { return { id: x.id }}), orgDatas[0].ledger_id);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.delete(this.tableName, { id: data });
+                if (updateData.length > 0) await conn.updateRows(this.tableName, updateData);
+                await conn.delete(this.ctx.service.costStageTag.tableName, { rela_sub_id: data });
+                await conn.delete(this.ctx.service.costStageFile.tableName, { rela_sub_id: data });
+                await conn.update(this.ctx.service.costStageLedger.tableName, billsUpdate);
+                await conn.commit();
+                return [data, updateData, billsUpdate];
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+        async _updateDatas (data) {
+            if (!data || data.length === 0) throw '提交数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+
+            const datas = data instanceof Array ? data : [data];
+            const orgDatas = await this.getAllDataByCondition({
+                where: { id: this.ctx.helper._.map(datas, 'id') }
+            });
+            if (!orgDatas || orgDatas.length === 0) throw '修改的明细不存在';
+
+            const uDatas = [];
+            for (const d of datas) {
+                const od = orgDatas.find(x => { return x.id === d.id; });
+                if (!od) continue;
+
+                const nd = { id: od.id, update_user_id: user_id };
+                for (const prop of costFields.textFields) {
+                    if (d[prop] !== undefined) nd[prop] = d[prop];
+                }
+                if (d.pay_tp !== undefined || d.cut_tp !== undefined) {
+                    nd.pay_tp = d.pay_tp !== undefined ? this.ctx.helper.round(d.pay_tp || 0, this.ctx.costStage.decimal.tp) : od.pay_tp;
+                    nd.cut_tp = d.cut_tp !== undefined ? this.ctx.helper.round(d.cut_tp || 0, this.ctx.costStage.decimal.tp) : od.cut_tp;
+                    nd.yf_tp = this.ctx.helper.sub(nd.pay_tp, nd.cut_tp);
+                    nd.sf_tp = od.yf_tp === od.sf_tp ? nd.yf_tp : od.sf_tp;
+                } else {
+                    nd.pay_tp = od.pay_tp;
+                    nd.cut_tp = od.cut_tp;
+                    nd.yf_tp = od.yf_tp;
+                    nd.sf_tp = od.sf_tp;
+                }
+                if (d.sf_tp !== undefined) nd.sf_tp = this.ctx.helper.round(d.sf_tp || 0, this.ctx.costStage.decimal.tp);
+                if (d.tax !== undefined || nd.sf_tp !== undefined || nd.yf_tp !== undefined) {
+                    nd.tax = d.tax !== undefined ? this.ctx.helper.round(d.tax || 0, this.ctx.costStage.decimal.tax) : od.tax;
+                    const divNum = nd.tax ? this.ctx.helper.add(1, this.ctx.helper.div(nd.tax, 100)) : 1;
+                    nd.yf_excl_tax_tp = this.ctx.helper.div(nd.yf_tp !== undefined ? nd.yf_tp : od.yf_tp, divNum, this.ctx.costStage.decimal.tp);
+                    nd.sf_excl_tax_tp = this.ctx.helper.div(nd.sf_tp !== undefined ? nd.sf_tp : od.sf_tp, divNum, this.ctx.costStage.decimal.tp);
+                }
+                uDatas.push(nd);
+            }
+            if (uDatas.length > 0) {
+                const billsUpdate = await this._getLedgerUpdateData(uDatas, orgDatas[0].ledger_id);
+                const conn = await this.db.beginTransaction();
+                try {
+                    await conn.updateRows(this.tableName, uDatas);
+                    await conn.update(this.ctx.service.costStageLedger.tableName, billsUpdate);
+                    await conn.commit();
+                    return [uDatas, billsUpdate];
+                } catch(err) {
+                    await conn.rollback();
+                    throw err;
+                }
+            } else {
+                return [];
+            }
+        }
+        async updateDatas(data) {
+            const result = { detail: { add: [], del: [], update: [] }, ledger: {} };
+            try {
+                if (data.add) {
+                    [result.detail.add, result.ledger] = await this._addDatas(data.add);
+                }
+                if (data.update) {
+                    [result.detail.update, result.ledger] = await this._updateDatas(data.update);
+                }
+                if (data.del) {
+                    [result.detail.del, result.detail.update, result.ledger] = await this._delDatas(data.del);
+                }
+                return result;
+            } catch (err) {
+                if (err.stack) {
+                    throw err;
+                } else {
+                    result.err = err.toString();
+                    return result;
+                }
+            }
+        }
+
+        async _importContractByIds(ledgerId, costId, ids, months) {
+            if (!ledgerId || !costId) throw '导入明细数据,提交的数据错误';
+            const user_id = this.ctx.session.sessionUser.accountId;
+            const times = months.split(' ~ ');
+            if (times.length < 0) throw '选择的时间无效';
+            const beginTime = this.ctx.moment(times[0], 'YYYY-MM').startOf('month').format('YYYY-MM-DD HH:mm:ss');
+            const endTime = this.ctx.moment(times[1] || times[0], 'YYYY-MM').endOf('month').format('YYYY-MM-DD HH:mm:ss');
+
+            const paySql = `SELECT cp.*, c.c_code, c.name, c.party_b FROM ${this.ctx.service.contractPay.tableName} cp LEFT JOIN ${this.ctx.service.contract.tableName} c ON cp.cid = c.id ` +
+                           `    WHERE cp.cid IN(${ids.map(x => { return `'${x}'`}).join(', ')}) AND cp.pay_time >= ? AND cp.pay_time <= ?`;
+            const validPays = await this.db.query(paySql, [beginTime, endTime]);
+
+            const insertDetails = [];
+            for (const pay of validPays) {
+                let idetail = insertDetails.find(x => { return x.source_cid === pay.cid; });
+                if (!idetail) {
+                    idetail = {
+                        id: this.uuid.v4(), tender_id: this.ctx.costStage.tid, stage_id: this.ctx.costStage.id,
+                        stage_order: this.ctx.costStage.stage_order, is_deal: 1,
+                        ledger_id: ledgerId, cost_id: costId, d_order: insertDetails.length + 1, source_cid: pay.cid,
+                        add_user_id: user_id, update_user_id: user_id,
+                        code: pay.c_code, name: pay.name, party_b: pay.party_b,
+                    };
+                    insertDetails.push(idetail);
+                }
+                idetail.tax = pay.tax || 0;
+                idetail.pay_tp = this.ctx.helper.add(idetail.pay_tp, pay.pay_price);
+                idetail.cut_tp = this.ctx.helper.add(idetail.cut_tp, pay.debit_price);
+                idetail.yf_tp = this.ctx.helper.add(idetail.sf_tp, pay.yf_price);
+                idetail.sf_tp = this.ctx.helper.add(idetail.yf_tp, pay.sf_price);
+                const divNum = idetail.tax ? this.ctx.helper.add(1, this.ctx.helper.div(idetail.tax, 100)) : 1;
+                idetail.yf_excl_tax_tp = this.ctx.helper.add(idetail.yf_excl_tax_tp, this.ctx.helper.div(pay.yf_price, divNum, this.ctx.costStage.decimal.tp));
+                idetail.sf_excl_tax_tp = this.ctx.helper.add(idetail.sf_excl_tax_tp, this.ctx.helper.div(pay.sf_price, divNum, this.ctx.costStage.decimal.tp));
+            }
+            const detailDatas = await this.getAllDataByCondition({ columns: ['id'], where: { ledger_id: ledgerId, stage_id: this.ctx.costStage.id } });
+            const billsUpdate = await this._getLedgerUpdateData(insertDetails, ledgerId, []);
+            billsUpdate.is_deal = 1;
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.delete(this.tableName, { stage_id: this.ctx.costStage.id, ledger_id: ledgerId });
+                await conn.insert(this.tableName, insertDetails);
+                await conn.update(this.ctx.service.costStageLedger.tableName, billsUpdate);
+                await conn.commit();
+            } catch(err) {
+                await conn.rollback();
+                throw err;
+            }
+            const addData = await this.getAllDataByCondition({
+                where: { id: this.ctx.helper._.map(insertDetails, 'id') }
+            });
+            const ledgerData = await this.ctx.service.costStageLedger.getDataById(addData[0].ledger_id);
+            return { detail: { add: addData, del: detailDatas.map(x => { return x.id; }) }, ledger: ledgerData };
+        }
+        async _importContractByTypes(ledgerId, costId, types, months) {
+            const contracts = await this.ctx.service.contract.getAllDataByCondition({ where: { tid: this.ctx.costStage.tid, contract_type: 1, type: types } });
+            return await this._importContractByIds(ledgerId, costId, contracts.map(x => { return x.id; }), months);
+        }
+        async importContract(data) {
+            if (data.types) return this._importContractByTypes(data.ledger_id, data.cost_id, data.types, data.months || [this.ctx.costStage.stage_date]);
+            if (data.ids) return this._importContractByIds(data.ledger_id, data.cost_id, data.ids, data.months || [this.ctx.costStage.stage_date]);
+        }
+
+        async deletePartData(transaction, tender_id, ledger_id) {
+            await transaction.delete(this.tableName, { tid, ledger_id });
+        }
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId} });
+            const updateData = [];
+            for (const l of leaf) {
+                const diff = costFields.curFields.find(x => {
+                    return l[x] !== l['read_' + x];
+                });
+                if (diff) {
+                    const data = { id: l.id };
+                    // cache read
+                    for (const f of costFields.curFields) {
+                        data['read_' + f] = l[f];
+                    }
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.curFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+    }
+
+    return CostStageDetail;
+};
+

+ 94 - 0
app/service/cost_stage_file.js

@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ *
+ *  附件
+ * @author Mai
+ * @date 2026/4/21
+ * @version
+ */
+
+
+module.exports = app => {
+    class CostStageFile extends app.BaseService {
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_file';
+        }
+
+        _analysisData(files) {
+            const helper = this.ctx.helper;
+            const userId = this.ctx.session.sessionUser.accountId;
+            const ossPath = this.ctx.app.config.fujianOssPath;
+            files.forEach(x => {
+                x.viewpath = helper.canPreview(x.fileext) ? ossPath + x.filepath : '';
+                x.filepath = ossPath + x.filepath;
+                x.fileext_str = helper.fileExtStr(x.fileext);
+                x.canEdit = x.user_id === userId;
+            });
+        }
+
+        async getData(stage_id) {
+            const data = await this.getAllDataByCondition({
+                where: { stage_id, is_deleted: 0 },
+                orders: [['create_time', 'desc']],
+            });
+            this._analysisData(data);
+            return data;
+        }
+
+        async getFiles(condition) {
+            condition.orders = [['create_time', 'desc']];
+            const result = await this.getAllDataByCondition(condition);
+            this._analysisData(result);
+            return result;
+        }
+
+        async addFiles(costStage, fileInfo, user) {
+            const conn = await this.db.beginTransaction();
+            const result = {};
+            try {
+                const insertData = fileInfo.map(x => {
+                    return {
+                        id: this.uuid.v4(), tid: costStage.tid, stage_id: costStage.id, stage_type: costStage.stage_type,
+                        rela_id: x.rela_id, rela_sub_id: x.rela_sub_id,
+                        user_id: user.id, user_name: user.name, user_company: user.company, user_role: user.role,
+                        filename: x.filename, fileext: x.fileext, filesize: x.filesize, filepath: x.filepath,
+                    };
+                });
+                await conn.insert(this.tableName, insertData);
+                await conn.commit();
+                result.files = { id: insertData.map(x => { return x.id; })};
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return await this.getFiles({ where: result.files });
+        }
+
+        async delFiles(files) {
+            const fileDatas = await this.getAllDataByCondition({ where: { id: files } });
+            const result = {};
+
+            const conn = await this.db.beginTransaction();
+            try {
+                const updateData = fileDatas.map(x => { return { id: x.id, is_deleted: 1 }; });
+                if (updateData.length > 0) await conn.updateRows(this.tableName, updateData);
+                await conn.commit();
+                result.del = files;
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return result;
+        }
+    }
+
+    return CostStageFile;
+};

+ 531 - 0
app/service/cost_stage_ledger.js

@@ -0,0 +1,531 @@
+'use strict';
+
+/**
+ *
+ * 支付审批-安全生产
+ * @author Mai
+ * @date
+ * @version
+ */
+const billsUtils = require('../lib/bills_utils');
+const costFields = {
+    textFields: ['code', 'name', 'unit', 'postil', 'memo'],
+    preFields: ['pre_pay_tp', 'pre_cut_tp', 'pre_yf_tp', 'pre_sf_tp', 'pre_yf_excl_tax_tp', 'pre_sf_excl_tax_tp'],
+    curFields: ['pay_tp', 'cut_tp', 'yf_tp', 'sf_tp', 'yf_excl_tax_tp', 'sf_excl_tax_tp'],
+    readFields: ['read_pay_tp', 'read_cut_tp', 'read_yf_tp', 'read_sf_tp', 'read_yf_excl_tax_tp', 'read_sf_excl_tax_tp'],
+    taxFields: ['tax'],
+    treeFields: ['tree_id', 'tree_pid', 'tree_level', 'tree_order', 'tree_full_path', 'tree_is_leaf'],
+    baseFields: ['id', 'cost_id', 'tender_id', 'stage_id', 'is_deal', 'is_used', 'had_detail'],
+};
+costFields.calcFields = [...costFields.curFields];
+costFields.editQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.preFields, ...costFields.curFields, ...costFields.taxFields];
+costFields.readQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.preFields, ...costFields.readFields, ...costFields.taxFields];
+costFields.compareQueryFields = [...costFields.baseFields, ...costFields.treeFields, ...costFields.textFields, ...costFields.preFields, ...costFields.curFields, ...costFields.taxFields, 'calc_his'];
+
+const auditConst = require('../const/audit').costStage;
+
+module.exports = app => {
+
+    class CostStageLedger extends app.BaseTreeService {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx, {
+                mid: 'stage_id',
+                kid: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                keyPre: 'cost_ledger_maxTreeId:',
+                uuid: true,
+            });
+            // this.depart = ctx.app.config.table_depart.light;
+            this.tableName = 'cost_stage_ledger';
+        }
+
+        // 继承方法
+        clearParentingData(data) {
+            for (const f of costFields.calcFields) {
+                data[f] = 0;
+            }
+        }
+
+        _getDefaultData(data, stage) {
+            data.id = this.uuid.v4();
+            data.cost_id = data.id;
+            data.tender_id = stage.tid;
+            data.stage_id = stage.id;
+            data.add_user_id = this.ctx.session.sessionUser.accountId;
+            data.update_user_id = data.add_user_id;
+        }
+
+        async getEditData(stage) {
+            const helper = this.ctx.helper;
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.editQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x['end_' + prop] = helper.add(x['pre_' + prop], x[prop]);
+                }
+            });
+            return result;
+        }
+        async getReadData(stage) {
+            const helper = this.ctx.helper;
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.readQueryFields
+            });
+            result.forEach(x => {
+                for(const prop of costFields.curFields) {
+                    x[prop] = x['read_' + prop];
+                    x['end_' + prop] = helper.add(x['pre_' + prop], x[prop]);
+                }
+            });
+            return result;
+        }
+        async getCompareData(stage) {
+            const result = await this.getAllDataByCondition({
+                where: { stage_id: stage.id },
+                columns: costFields.compareQueryFields
+            });
+            result.forEach(x => {
+                x.calc_his = x.calc_his ? JSON.parse(x.calc_his) : [];
+            });
+            return result;
+        }
+
+        async init(stage, transaction) {
+            if (!this.ctx.subProject.cost_ledger_template) throw '未设置台账模板,请联系管理员设置';
+            if (!stage || !transaction) throw '成本报审数据错误';
+
+            const templateData = await this.ctx.service.tenderNodeTemplate.getData(this.ctx.subProject.cost_ledger_template);
+            if (templateData.length === 0) throw '台账模板无数据,连请联系管理员修改';
+
+            const insertData = [];
+            for (const b of templateData) {
+                const bills = {
+                    tree_id: b.template_id, tree_pid: b.pid, tree_order: b.order, tree_level: b.level, tree_full_path: b.full_path, tree_is_leaf: b.is_leaf,
+                    code: b.code || '', name: b.name || '', unit: b.unit || '',
+                };
+                this._getDefaultData(bills, stage);
+                insertData.push(bills);
+            }
+
+            const operate = await transaction.insert(this.tableName, insertData);
+            return operate.affectedRows === insertData.length;
+        }
+        async initByPre(stage, preStage, transaction) {
+            if (!stage || !preStage || !transaction) throw '成本报审数据错误';
+
+            const preBills = await this.getAllDataByCondition({
+                where: { stage_id: preStage.id },
+                columns: costFields.editQueryFields
+            });
+            const preDetail = await this.ctx.service.costStageDetail.getReadData(preStage);
+            const insertData = [];
+            for (const bills of preBills) {
+                bills.id = this.uuid.v4();
+                bills.stage_id = stage.id;
+                if (!bills.had_detail) {
+                    bills.had_detail = preDetail.findIndex(x => { return x.cost_id === bills.cost_id; }) >= 0;
+                }
+                delete bills.postil;
+                for(const prop of costFields.curFields) {
+                    bills['pre_' + prop] = this.ctx.helper.add(bills['pre_' + prop], bills[prop]);
+                    bills[prop] = 0;
+                }
+                insertData.push(bills);
+            }
+            const operate = await transaction.insert(this.tableName, insertData);
+            return operate.affectedRows === insertData.length;
+        }
+        async initStageData(transaction, stage, preStage) {
+            if (preStage) {
+                await this.initByPre(stage, preStage, transaction);
+            } else {
+                await this.init(stage, transaction);
+            }
+        }
+
+        /**
+         * 新增数据(供内部或其他service类调用, controller不可直接使用)
+         * @param {Array|Object} data - 新增数据
+         * @param {Number} stageId - 期id
+         * @param {Object} transaction - 新增事务
+         * @return {Promise<boolean>} - {Promise<是否正确新增成功>}
+         */
+        async innerAdd(data, stageId, transaction) {
+            const datas = data instanceof Array ? data : [data];
+            if (!stageId) {
+                throw '期id错误';
+            }
+            if (datas.length <= 0) {
+                throw '插入数据为空';
+            }
+            if (!transaction) {
+                throw '内部错误';
+            }
+            // 整理数据
+            const insertData = [];
+            for (const tmp of datas) {
+                tmp[this.setting.id] = tmp.template_id;
+                tmp[this.setting.pid] = tmp.pid;
+                tmp[this.setting.mid] = stageId;
+                delete tmp.template_id;
+                delete tmp.pid;
+                tmp.id = this.uuid.v4();
+                insertData.push(tmp);
+            }
+            const operate = await transaction.insert(this.tableName, insertData);
+            return operate.affectedRows === datas.length;
+        }
+        /**
+         * 新增数据
+         *
+         * @param {Object} data - 新增的数据(可批量)
+         * @param {Number} stageId - 安全计量期id
+         * @return {Boolean} - 返回新增的结果
+         */
+        async add(data, stageId) {
+            this.transaction = await this.db.beginTransaction();
+            let result = false;
+            try {
+                result = await this.innerAdd(data, stageId, this.transaction);
+                if (!result) {
+                    throw '新增数据错误';
+                }
+                await this.transaction.commit();
+            } catch (error) {
+                await this.transaction.rollback();
+                result = false;
+            }
+
+            return result;
+        }
+
+        /**
+         * 根据节点Id获取数据
+         *
+         * @param {Number} stageId - 安全计量期id
+         * @param {Number} nodeId - 项目节/工程量清单节点id
+         * @return {Object} - 返回查询到的节点数据
+         */
+        async getDataByNodeId(stageId, nodeId) {
+            if ((nodeId <= 0) || (stageId <= 0)) {
+                return undefined;
+            }
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.id] = nodeId;
+            const data = await this.db.getDataByCondition(where);
+
+            return data;
+        }
+        /**
+         * 根据节点Id获取数据
+         * @param {Number} stageId - 期Id
+         * @param {Array} nodesIds - 节点Id
+         * @return {Array}
+         */
+        async getDataByNodeIds(stageId, nodesIds) {
+            if (stageId <= 0) {
+                return [];
+            }
+
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.id] = nodesIds;
+            const data = await this.db.getAllDataByCondition({ where });
+
+            return this._.sortBy(data, function(d) {
+                return nodesIds.indexOf(d[this.setting.id]);
+            });
+        }
+        /**
+         * 根据主键id获取数据
+         * @param {Array|Number} id - 主键id
+         * @return {Promise<*>}
+         */
+        async getDataByIds(id) {
+            if (!id) return [];
+            const ids = id instanceof Array ? id : [id];
+            if (ids.length === 0) return [];
+
+            const data = await this.db.getAllDataByCondition({ where: { id: ids } });
+            return data;
+        }
+
+        /**
+         * 根据 父节点id 获取子节点
+         * @param stageId
+         * @param nodeId
+         * @return {Promise<*>}
+         */
+        async getChildrenByParentId(stageId, nodeId) {
+            if (stageId <= 0 || !nodeId) {
+                return undefined;
+            }
+            const nodeIds = nodeId instanceof Array ? nodeId : [nodeId];
+            if (nodeIds.length === 0) {
+                return [];
+            }
+
+            const where = {};
+            where[this.setting.mid] = stageId;
+            where[this.setting.pid] = nodeIds;
+            const data = await this.getAllDataByCondition({ where, orders: [[this.setting.order, 'ASC']] });
+
+            return data;
+        }
+
+        async pasteBlockData(stage, targetId, pasteData, defaultData) {
+            const setting = this.setting;
+            if ((stageId <= 0) || (sid <= 0)) return [];
+
+            if (!pasteData || pasteData.length <= 0) throw '复制数据错误';
+            for (const pd of pasteData) {
+                if (!pd || pd.length <= 0) throw '复制数据错误';
+                pd.sort(function (x, y) {
+                    return x[setting.level] - y[setting.level]
+                });
+                if (pd[0][this.setting.pid] !== pasteData[0][0][this.setting.pid]) throw '复制数据错误:仅可操作同层节点';
+            }
+            this.newBills = false;
+            const targetData = await this.getDataByKid(stageId, targetId);
+            if (!targetData) throw '粘贴数据错误';
+
+            const newParentPath = targetData.full_path.replace(targetData.tree_id, '');
+            const tpDecimal = this.ctx.costStage.decimal;
+
+            const pasteBillsData = [], leafBillsId = [];
+            let maxId = await this._getMaxLid(this.ctx.tender.id);
+            for (const [i, pd] of pasteData.entries()) {
+                for (const d of pd) {
+                    d.children = pd.filter(function (x) {
+                        return x[setting.pid] === d[setting.id];
+                    });
+                }
+                const pbd = [];
+                for (const [j, d] of pd.entries()) {
+                    const newBills = {};
+                    this._getDefaultData(newBills, this.ctx.costStage);
+                    for (const prop of costFields.textFields) {
+                        newBills[prop] = d[prop] || '';
+                    }
+                    newBills[setting.mid] = stageId;
+                    newBills[setting.id] = maxId + j + 1;
+                    newBills[setting.pid] = j === 0 ? targetData[setting.pid] : d[setting.pid];
+                    newBills[setting.level] = d[setting.level] + targetData[setting.level] - pd[0][setting.level];
+                    newBills[setting.order] = j === 0 ? targetData[setting.order] + i + 1 : d[setting.order];
+                    newBills[setting.isLeaf] = d[setting.isLeaf];
+
+                    for (const c of d.children) {
+                        c[setting.pid] = newBills[setting.id];
+                    }
+                    for (const prop of costFields.curFields) {
+                        newBills[prop] = this.ctx.helper.round(d[prop] || 0, tpDecimal.tp);
+                        newBills['end_' + prop] = newBills[prop];
+                    }
+                    pbd.push(newBills);
+                }
+                for (const d of pbd) {
+                    const parent = pbd.find(function (x) {
+                        return x[setting.id] === d[setting.pid];
+                    });
+                    d[setting.fullPath] = parent
+                        ? parent[setting.fullPath] + '-' + d[setting.id]
+                        : newParentPath + d[setting.id];
+                    if (defaultData) this.ctx.helper._.assignIn(pbd, defaultData);
+                    pasteBillsData.push(d);
+                }
+                maxId = maxId + pbd.length;
+            }
+
+            this.transaction = await this.db.beginTransaction();
+            try {
+                // 选中节点的所有后兄弟节点,order+粘贴节点个数
+                await this._updateChildrenOrder(tid, targetData[setting.pid], targetData[setting.order] + 1, pasteData.length);
+                // 数据库创建新增节点数据
+                if (pasteBillsData.length > 0) {
+                    const newData = await this.transaction.insert(this.tableName, pasteBillsData);
+                }
+                this._cacheMaxLid(tid, maxId);
+                await this.transaction.commit();
+            } catch (err) {
+                await this.transaction.rollback();
+                throw err;
+            }
+
+            // 查询应返回的结果
+            const updateData = await this.getNextsData(targetData[setting.mid], targetData[setting.pid], targetData[setting.order] + pasteData.length);
+            return { create: pasteBillsData, update: updateData };
+        }
+
+        async addLedgerNode(stage, targetId, count) {
+            if (!stage) return null;
+
+            const select = targetId ? await this.getDataByKid(stage.id, targetId) : null;
+            if (targetId && !select) throw '新增节点数据错误';
+
+            this.transaction = await this.db.beginTransaction();
+            try {
+                if (select) await this._updateChildrenOrder(stage.id, select[this.setting.pid], select[this.setting.order] + 1, count);
+                const newDatas = [];
+                const maxId = await this._getMaxLid(stage.id);
+                for (let i = 1; i < count + 1; i++) {
+                    const newData = {};
+                    newData[this.setting.kid] = maxId + i;
+                    newData[this.setting.pid] = select ? select[this.setting.pid] : this.rootId;
+                    newData[this.setting.mid] = stage.id;
+                    newData[this.setting.level] = select ? select[this.setting.level] : 1;
+                    newData[this.setting.order] = select ? select[this.setting.order] + i : i;
+                    newData[this.setting.fullPath] = newData[this.setting.level] > 1
+                        ? select[this.setting.fullPath].replace('-' + select[this.setting.kid], '-' + newData[this.setting.kid])
+                        : newData[this.setting.kid] + '';
+                    newData[this.setting.isLeaf] = true;
+                    this._getDefaultData(newData, stage);
+                    newDatas.push(newData);
+                }
+                const insertResult = await this.transaction.insert(this.tableName, newDatas);
+                this._cacheMaxLid(stage.id, maxId + count);
+
+                if (insertResult.affectedRows !== count) throw '新增节点数据错误';
+                await this.transaction.commit();
+                this.transaction = null;
+            } catch (err) {
+                await this.transaction.rollback();
+                this.transaction = null;
+                throw err;
+            }
+
+            if (select) {
+                const createData = await this.getChildBetween(stage.id, select[this.setting.pid], select[this.setting.order], select[this.setting.order] + count + 1);
+                const updateData = await this.getNextsData(stage.id, select[this.setting.pid], select[this.setting.order] + count);
+                return {create: createData, update: updateData};
+            } else {
+                const createData = await this.getChildBetween(stage.id, -1, 0, count + 1);
+                return {create: createData};
+            }
+        }
+
+        async updateCalc(stage, data) {
+            const helper = this.ctx.helper;
+            // 简单验证数据
+            if (!stage) throw '成本报审不存在';
+            const decimal = stage.decimal;
+            if (!data) throw '提交数据错误';
+            const datas = data instanceof Array ? data : [data];
+            const ids = datas.map(x => { return x.id; });
+            const orgData = await this.getAllDataByCondition({ where: { id: ids }});
+            const updateData = [];
+            for (const row of datas) {
+                const oData = orgData.find(x => { return x.id === row.id });
+                if (!oData || oData.stage_id !== stage.id || oData.tree_id !== row.tree_id) throw '提交数据错误';
+
+                let nData = { id: oData.id, tree_id: oData.tree_id, update_user_id: this.ctx.session.sessionUser.accountId };
+
+                // calc
+                if (row.pay_tp !== undefined || row.cut_tp !== undefined) {
+                    nData.pay_tp = row.pay_tp !== undefined ? helper.round(row.pay_tp || 0, decimal.tp) : oData.pay_tp || 0;
+                    nData.cut_tp = row.cut_tp !== undefined ? helper.round(row.cut_tp || 0, decimal.tp) : oData.cut_tp || 0;
+                    nData.yf_tp = helper.sub(nData.pay_tp, nData.cut_tp);
+                    if (od.yf_tp === od.sf_tp) nData.sf_tp = nData.yf_tp;
+                }
+                if (row.sf_tp !== undefined) nData.sf_tp = helper.round(row.sf_tp || 0, decimal.tp);
+                if (row.tax !== undefined || nData.sf_tp !== undefined || nData.yf_tp !== undefined) {
+                    nData.tax = row.tax !== undefined ? helper.round(row.tax || 0, decimal.tax) : oData.tax;
+                    const divNum = nData.tax ? helper.add(1, helper.div(nData.tax, 100)) : 1;
+                    nData.yf_excl_tax_tp = helper.div(nData.yf_tp !== undefined ? nData.yf_tp : oData.yf_tp, divNum, decimal.tp);
+                    nData.sf_excl_tax_tp = helper.div(nData.sf_tp !== undefined ? nData.sf_tp : oData.sf_tp, divNum, decimal.tp);
+                }
+                for (const field of costFields.textFields) {
+                    if (row[field] !== undefined) nData[field] = row[field] || '';
+                }
+                updateData.push(nData);
+            }
+
+            await this.db.updateRows(this.tableName, updateData);
+            return { update: updateData };
+        }
+
+        async auditCache(transaction, stageId, auditInfo) {
+            const leaf = await this.getAllDataByCondition({ where: { stage_id: stageId, tree_is_leaf: true} });
+            const updateData = [];
+            for (const l of leaf) {
+                const diff = costFields.curFields.find(x => {
+                    return l[x] !== l['read_' + x];
+                });
+                if (diff) {
+                    const data = { id: l.id };
+                    // cache read
+                    for (const f of costFields.curFields) {
+                        data['read_' + f] = l[f];
+                    }
+                    // cache his
+                    const his = l.calc_his ? JSON.parse(l.calc_his) : [];
+                    const fi = his.find(x => { return x.audit_times === auditInfo.audit_times && x.active_order === auditInfo.active_order; });
+                    if (fi >= 0) his.splice(fi, 1);
+                    const newHis = { ...auditInfo };
+                    for (const f of costFields.curFields) {
+                        newHis[f] = data[f];
+                    }
+                    his.push(newHis);
+                    data.calc_his = JSON.stringify(his);
+                    updateData.push(data);
+                }
+            }
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+        }
+
+        async setDecimal(decimal) {
+            if (!this.ctx.costStage) throw '读取数据错误';
+            if (this.ctx.costStage.stage_order !== this.ctx.costStage.latestOrder) throw '往期不可修改小数位数';
+            if (this.ctx.costStage.audit_status !== auditConst.status.uncheck && this.ctx.costStage.audit_status !== auditConst.status.checkNo) throw '仅原报可修改小数位数';
+            const orgDecimal = this.ctx.costStage.decimal;
+
+            const calcTp = decimal.tp < orgDecimal.tp;
+            this.ctx.costStage.decimal = { up: decimal.up, tp: decimal.tp, qty: decimal.qty };
+
+            const updateData = [];
+            if (calcTp) {
+                const calcData = await this.getAllDataByCondition({ where: {stage_id: this.ctx.costStage.id, tree_is_leaf: 1 } });
+                for (const cd of calcData) {
+                    const nd = { id: cd.id, tree_id: cd.tree_id };
+                    for (const prop of costFields.curFields) {
+                        nd[prop] = this.ctx.helper.round(nd[prop]);
+                    }
+                    updateData.push(nd);
+                }
+            }
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.update(this.ctx.service.costStage.tableName, { id: this.ctx.costStage.id, decimal: JSON.stringify(this.ctx.costStage.decimal)});
+                if (updateData.length > 0) await conn.updateRows(this.tableName, updateData);
+                await conn.commit();
+                return { calc: calcTp, update: updateData };
+            } catch(err) {
+                await conn.rollback();
+                return { calc: false, update: [] };
+            }
+
+        }
+
+        async getSum(stage) {
+            const sumFields = costFields.curFields.map(f => { return `SUM(${f}) AS ${f}`});
+            const result = await this.db.queryOne(`SELECT ${sumFields.join(', ')} FROM ${this.tableName} where stage_id = ?`, [stage.id]);
+            return result;
+        }
+    }
+
+    return CostStageLedger;
+};

+ 95 - 0
app/service/cost_stage_tag.js

@@ -0,0 +1,95 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const validField = ['rela_id', 'rela_sub_id', 'share', 'color', 'comment'];
+
+module.exports = app => {
+
+    class LedgerTag extends app.BaseService {
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'cost_stage_tag';
+        }
+
+        /**
+         * 获取台账、期所有标段
+         * @param {Number} tid - 标段id
+         * @param {Number} sid - 期id(-1时查询台账分解全部标签)
+         * @returns {Promise<void>}
+         */
+        async getDatas(stageId) {
+            const sql = 'SELECT la.id, la.create_user_id, la.rela_id, la.rela_sub_id, la.share, la.color, la.comment, pa.name as user_name FROM ' + this.tableName + ' la ' +
+                '  LEFT JOIN ' + this.ctx.service.projectAccount.tableName + ' pa ON la.create_user_id = pa.id' +
+                '  WHERE la.stage_id = ? and (la.create_user_id = ? or la.share) ORDER BY la.create_time DESC';
+            return await this.db.query(sql, [stageId, this.ctx.session.sessionUser.accountId]);
+        }
+
+        /**
+         * 过滤无效字段,容错
+         * @param data
+         * @private
+         */
+        _filterInvalidField(data) {
+            for (const prop in data) {
+                if (validField.indexOf(prop) === -1) {
+                    delete data[prop];
+                }
+            }
+        }
+
+        async _addTag(data) {
+            this._filterInvalidField(data);
+            data.id = this.uuid.v4();
+            data.create_user_id = this.ctx.session.sessionUser.accountId;
+            data.update_user_id = data.create_user_id;
+            data.tender_id = this.ctx.costStage.tid;
+            data.stage_id = this.ctx.costStage.id;
+            data.stage_type = this.ctx.costStage.stage_type;
+            await this.db.insert(this.tableName, data);
+            data.user_name = this.ctx.session.sessionUser.name;
+            return data;
+        }
+
+        async _delTag(id) {
+            const tag = await this.getDataById(id);
+            if (tag.create_user_id !== this.ctx.session.sessionUser.accountId) throw '您无权删除该数据';
+
+            await this.deleteById(id);
+            return id;
+        }
+
+        async _updateTag(data) {
+            const tag = await this.getDataById(data.id);
+            if (tag.create_user_id !== this.ctx.session.sessionUser.accountId) throw '您无权修改该数据';
+
+            this._filterInvalidField(data);
+            data.id = tag.id;
+            data.update_user_id = this.ctx.session.sessionUser.accountId;
+            const result = await this.db.update(this.tableName, data);
+            if (result.affectedRows === 1) return data;
+        }
+
+        async update(data) {
+            const result = {};
+            if (data.add) result.add = await this._addTag(data.add);
+            if (data.del) result.del = await this._delTag(data.del);
+            if (data.update) result.update = await this._updateTag(data.update);
+            return result;
+        }
+    }
+
+    return LedgerTag;
+};

+ 1 - 1
app/service/notice_push.js

@@ -67,7 +67,7 @@ module.exports = app => {
             // });
             const uidSql = uid ? ' AND a.`uid` = ' + uid : '';
             const spidSql = spid ? ' AND (a.`spid` = "' + spid + '" OR t.`spid` = "' + spid + '")': '';
-            const sql = 'SELECT a.* FROM ?? AS a LEFT JOIN ?? AS t ON a.`tid` = t.`id` WHERE a.`pid` = ?' + uidSql + spidSql + ' ORDER BY a.`create_time` DESC LIMIT 0,30';
+            const sql = 'SELECT a.*, t.spid FROM ?? AS a LEFT JOIN ?? AS t ON a.`tid` = t.`id` WHERE a.`pid` = ?' + uidSql + spidSql + ' ORDER BY a.`create_time` DESC LIMIT 0,30';
             const params = [this.tableName, this.ctx.service.tender.tableName, pid];
             let notice = await this.db.query(sql, params);
             notice = notice.map(v => {

+ 127 - 0
app/service/pos.js

@@ -413,6 +413,131 @@ module.exports = app => {
             return { pos: update }
         }
 
+        _calcExpr(data, field, expr, defaultValue, precision) {
+            if (expr) {
+                try {
+                    data[field] = this.ctx.helper.round(this.ctx.helper.calcExpr(expr), precision.value);
+                } catch (err) {
+                }
+            } else {
+                data[field] = this.ctx.helper.round(defaultValue, precision.value);
+            }
+        }
+        async pasteBlockData(tid, data) {
+            if (!(data.block instanceof Array)) throw '提交数据错误';
+
+            const result = { ledger: {}, pos: null };
+
+            const bills = await this.ctx.service.ledger.getDataById(data.lid);
+            const le = await this.ctx.service.ledgerExtra.getDataById(data.lid);
+            const calcTemplate = le.calc_template ? await this.ctx.service.calcTmpl.getTemplate(le.calc_template) : null;
+            const precision = this.ctx.helper.findPrecision(this.ctx.tender.info.precision, bills.unit);
+            const existPos = await this.ctx.service.pos.getAllDataByCondition({ where : { lid: bills.id }, orders: [['porder', 'DESC']]});
+            const maxOrder = existPos.length > 0 ? existPos[0].porder : 0;
+            const updateBills = existPos.length > 0
+                ? { id: bills.id, sgfh_qty: bills.sgfh_qty, sjcl_qty: bills.sjcl_qty, qtcl_qty: bills.qtcl_qty, quantity: bills.quantity, ex_qty1: bills.ex_qty1 }
+                : { id: bills.id, sgfh_qty: 0, sjcl_qty: 0, qtcl_qty: 0, quantity: 0, ex_qty1: 0 };
+
+            const insertPos = [], insertDetail = [];
+            for (const [i, b] of data.block.entries()) {
+                const nip = {};
+                insertPos.push(nip);
+                this._completeInsertPosData(tid, nip);
+                nip.lid = b.lid;
+                nip.name = b.name || '';
+                nip.drawing_code = b.drawing_code || '';
+                nip.porder = maxOrder + i + 1;
+                nip.position = b.position;
+                nip.sgfh_expr = b.sgfh_expr ? b.sgfh_expr : '';
+                nip.sjcl_expr = b.sjcl_expr ? b.sjcl_expr : '';
+                nip.qtcl_expr = b.qtcl_expr ? b.qtcl_expr : '';
+                nip.ex_memo1 = b.ex_memo1;
+                nip.ex_memo2 = b.ex_memo2;
+                nip.ex_memo3 = b.ex_memo3;
+                if (calcTemplate) {
+                    let sumQty = 0;
+                    for (const cd of b.calcDetail) {
+                        const newDetail = {
+                            id: this.uuid.v4(), tid: tid, lid: bills.id, pid: nip.id,
+                            create_user_id: this.ctx.session.sessionUser.accountId,
+                            update_user_id: this.ctx.session.sessionUser.accountId,
+                            pcd_order: cd.pcd_order,
+                        };
+                        if (data.calc_template === le.calc_template) {
+                            newDetail.str1 = cd.str1 || '';
+                            newDetail.str2 = cd.str2 || '';
+                            newDetail.str3 = cd.str3 || '';
+                            newDetail.str4 = cd.str4 || '';
+                            newDetail.num_a = cd.num_a || 0;
+                            newDetail.num_b = cd.num_b || 0;
+                            newDetail.num_c = cd.num_c || 0;
+                            newDetail.num_d = cd.num_d || 0;
+                            newDetail.num_e = cd.num_e || 0;
+                            newDetail.num_f = cd.num_f || 0;
+                            newDetail.num_g = cd.num_g || 0;
+                            newDetail.num_h = cd.num_h || 0;
+                            newDetail.num_i = cd.num_i || 0;
+                            newDetail.num_j = cd.num_j || 0;
+                            newDetail.num_k = cd.num_k || 0;
+                            newDetail.num_l = cd.num_l || 0;
+                            newDetail.num_m = cd.num_m || 0;
+                            newDetail.num_n = cd.num_n || 0;
+                            newDetail.num_o = cd.num_o || 0;
+                            newDetail.num_p = cd.num_p || 0;
+                            newDetail.num_q = cd.num_q || 0;
+                            newDetail.num_r = cd.num_r || 0;
+                            newDetail.num_s = cd.num_s || 0;
+                            newDetail.num_t = cd.num_t || 0;
+                            newDetail.num_u = cd.num_u || 0;
+                            newDetail.qty = cd.qty || 0;
+                            newDetail.expr = cd.expr || '';
+                            newDetail.spec = cd.spec || '';
+                        } else {
+                            this.ctx.service.posCalcDetail._loadDataAndCalc(newDetail, cd, {}, calcTemplate);
+                        }
+                        sumQty = this.ctx.helper.add(sumQty, newDetail.qty);
+                        insertDetail.push(newDetail);
+                    }
+                    this._calcExpr(nip, 'sgfh_qty', '', sumQty, precision);
+                } else {
+                    this._calcExpr(nip, 'sgfh_qty', b.sgfh_expr, b.sgfh_qty, precision);
+                }
+                this._calcExpr(nip, 'sjcl_qty', b.sjcl_expr, b.sjcl_qty, precision);
+                this._calcExpr(nip, 'qtcl_qty', b.qtcl_expr, b.qtcl_qty, precision);
+                nip.quantity = this.ctx.helper.add(nip.sgfh_qty, this.ctx.helper.add(nip.sjcl_qty, nip.qtcl_qty));
+                nip.ex_qty1 = this.ctx.helper.round(b.ex_qty1, precision.value);
+
+                updateBills.sgfh_qty = this.ctx.helper.add(updateBills.sgfh_qty, nip.sgfh_qty);
+                updateBills.sjcl_qty = this.ctx.helper.add(updateBills.sjcl_qty, nip.sjcl_qty);
+                updateBills.qtcl_qty = this.ctx.helper.add(updateBills.qtcl_qty, nip.qtcl_qty);
+                updateBills.ex_qty1 = this.ctx.helper.add(updateBills.ex_qty1, nip.ex_qty1);
+            }
+
+            const info = this.ctx.tender.info;
+            updateBills.quantity = this.ctx.helper.add(updateBills.sgfh_qty, this.ctx.helper.add(updateBills.sjcl_qty, updateBills.qtcl_qty));
+            updateBills.sgfh_tp = this.ctx.helper.mul(updateBills.sgfh_qty, bills.unit_price, info.decimal.tp);
+            updateBills.sjcl_tp = this.ctx.helper.mul(updateBills.sjcl_qty, bills.unit_price, info.decimal.tp);
+            updateBills.qtcl_tp = this.ctx.helper.mul(updateBills.qtcl_qty, bills.unit_price, info.decimal.tp);
+            updateBills.total_price = this.ctx.helper.mul(updateBills.quantity, bills.unit_price, info.decimal.tp);
+            updateBills.ex_tp1 = this.ctx.helper.mul(updateBills.ex_qty1, bills.unit_price, info.decimal.tp);
+
+            const transaction = await this.db.beginTransaction();
+            try {
+                await transaction.insert(this.tableName, insertPos);
+                if (insertDetail.length > 0) await transaction.insert(this.ctx.service.posCalcDetail.tableName, insertDetail);
+                await transaction.update(this.ctx.service.ledger.tableName, updateBills);
+                await transaction.commit();
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+            result.pos = insertPos;
+            updateBills.ledger_id = bills.ledger_id;
+            result.ledger.update = [updateBills];
+            result.posCalcDetail = { add: insertDetail };
+            return result;
+        }
+
         /**
          * 保存部位明细数据
          * @param data
@@ -433,6 +558,8 @@ module.exports = app => {
                     return await this._deletePosData(tid, data.updateData);
                 case 'insert':
                     return await this._insertPosData(tid, data.updateData);
+                case 'paste-block':
+                    return await this.pasteBlockData(tid, data.updateData);
             }
         }
 

+ 1 - 0
app/service/pos_calc_detail.js

@@ -76,6 +76,7 @@ module.exports = app => {
                 const sv = template.specValue.find(x => { return x.spec === data.spec; });
                 data[template.spec_rela] = sv ? sv.value : 0;
             }
+            if (updateData[template.spec_rela] !== undefined) delete updateData[template.spec_rela];
             this.ctx.service.calcTmpl.calcByTemplate(data, updateData, orgData, template.calc_expr, { qty: 'expr' });
             return calc || (data.qty !== undefined && orgData.qty !== data.qty);
         }

+ 2 - 2
app/service/safe_stage_bills.js

@@ -134,9 +134,9 @@ module.exports = app => {
         }
         async initStageBills(transaction, stage, preStage) {
             if (preStage) {
-                this.initByPre(stage, preStage, transaction);
+                await this.initByPre(stage, preStage, transaction);
             } else {
-                this.init(stage, transaction);
+                await this.init(stage, transaction);
             }
         }
 

+ 9 - 1
app/service/tender_permission.js

@@ -45,7 +45,14 @@ module.exports = app => {
                 schedule: {
                     view: { title: '查看', value: 1, isDefault: 1 },
                     edit: { title: '修改', value: 2 },
-                }
+                },
+                cost: {
+                    view: { title: '查看', value: 1, isDefault: 1 },
+                    ledger_add: { title: '成本台账上报', value: 2 },
+                    book_add: { title: '财务账面上报', value: 3 },
+                    analysis_add: { title: '成本分析上报', value: 4 },
+                    visitor: { title: '游客', value: 5 },
+                },
             };
             this.PermissionBlock = [
                 { key: 'quality', name: '工程资料', field: 'quality' },
@@ -53,6 +60,7 @@ module.exports = app => {
                 { key: 'safe_inspection', name: '安全巡检', field: 'safe_inspection' },
                 { key: 'safe_payment', name: '安全计量', field: 'safe_payment' },
                 { key: 'schedule', name: '标段进度', field: 'schedule' },
+                { key: 'cost', name: '成本管理', field: 'schedule' },
             ];
             for (const p of this.PermissionBlock) {
                 if (p.children) {

+ 84 - 0
app/view/cost/analysis.ejs

@@ -0,0 +1,84 @@
+<% include ./stage_memu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./stage_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">
+                    <div class="dropdown">
+                        <button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <i class="fa fa-list-ol"></i> 显示层级
+                        </button>
+                        <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                            <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                            <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                            <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                            <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                            <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                        </div>
+                    </div>
+                </div>
+                <% if (ctx.costStage.create_user_id === ctx.session.sessionUser.accountId) { %>
+                <div class="d-inline-block">
+                    <a href="javascript: void(0);" name="base-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="升级"><i class="fa fa-arrow-left" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="降级"><i class="fa fa-arrow-right" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="下移"><i class="fa fa-arrow-down" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                </div>
+                <% } %>
+            </div>
+            <div class="ml-auto">
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row pr-46">
+        <div class="c-header p-0 col-12">
+        </div>
+        <!--核心内容(两栏)-->
+        <div class="row w-100 sub-content">
+            <!--左栏-->
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div class="sjs-height-1" style="overflow: hidden" id="bills-spread">
+                </div>
+                <div class="bcontent-wrap">
+                    <div class="resize-y" id="detail-spr" r-Type="height" div1=".sjs-height-1" div2=".bcontent-wrap" r-parent="div2" title="调整大小"><!--调整上下高度条--></div>
+                    <div class="bc-bar mb-1">
+                        <ul class="nav nav-tabs">
+                            <li class="nav-item">
+                                <a class="nav-link active" href="javascript:void(0)">明细数据</a>
+                            </li>
+                        </ul>
+                    </div>
+                    <div class="sp-wrap" id="detail-spread">
+                    </div>
+                </div>
+            </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"><!--调整左右高度条--></div>
+                <div class="tab-content">
+                    <div id="search" class="tab-pane tab-select-show">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li class="nav-item">
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+<script>
+    const readOnly = <%- ctx.costStage.readOnly %>;
+    const tenderId = parseInt('<%- ctx.tender.id %>');
+    const stageId = parseInt('<%- ctx.costStage.id %>');
+    const stageDate = '<%- ctx.costStage.stage_date %>';
+    const bllsSpreadSetting = JSON.parse(unescape('<%- escape(JSON.stringify( ctx.costStage.calcTemplate.spread_cache )) %>'));
+    const dealSpreadSetting = JSON.parse(unescape('<%- escape(JSON.stringify( ctx.costStage.calcTemplate.sub_spread_cache.deal )) %>'));
+    const commonSpreadSetting = JSON.parse(unescape('<%- escape(JSON.stringify( ctx.costStage.calcTemplate.sub_spread_cache.common )) %>'));
+</script>

+ 82 - 0
app/view/cost/analysis_list.ejs

@@ -0,0 +1,82 @@
+<% include ./list_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./list_mini_menu.ejs %>
+            <h2>
+                收支列表
+            </h2>
+            <% if (ctx.permission.cost.analysis_add && (stages.length === 0 || stages[0].audit_status === auditConst.status.checked) && validRelaStages.length > 0) { %>
+            <div class="ml-auto">
+                <a href="#add-qi" data-toggle="modal" data-target="#add-qi" class="btn btn-primary btn-sm">新建收支</a>
+            </div>
+            <% } %>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body">
+            <div class="sjs-height-0">
+                <table class="table table-bordered table-hover">
+                    <thead>
+                    <tr class="text-center">
+                        <th width="80px">期数</th>
+                        <th width="70px">报审月份</th>
+                        <th width="70px">创建人</th>
+                        <th>项目收入</th>
+                        <th>项目支出</th>
+                        <th>利润</th>
+                        <th>利润率</th>
+                        <th width="180px">审批进度</th>
+                        <th width="100px">操作</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <% for (const s of stages) { %>
+                    <tr>
+                        <td>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis/<%- s.stage_order %>/stage" target="_blank">第 <%- s.stage_order %> 期</a>
+                        </td>
+                        <td class="text-center"><%- s.stage_date %></td>
+                        <td class="text-center"><%- s.user_name %></td>
+                        <td class="text-right"><%- s.stage_tp.in_tp %></td>
+                        <td class="text-right"><%- s.stage_tp.out_tp %></td>
+                        <td class="text-right"><%- s.stage_tp.profit %></td>
+                        <td class="text-right"><%- s.stage_tp.profit_percent %></td>
+                        <td class="<%- auditConst.info[s.audit_status].class %>">
+                            <% if (s.audit_status === auditConst.status.checked && s.final_auditor_str) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.final_auditor_str %></a>
+                            <% } else { %>
+                            <% if (s.curAuditors.length > 0) { %>
+                            <% if (s.curAuditors[0].audit_type === auditType.key.common) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.curAuditors[0].name %><%if (s.curAuditors[0].role !== '' && s.curAuditors[0].role !== null) { %>-<%- s.curAuditors[0].role %><% } %></a>
+                            <% } else { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- ctx.helper.transFormToChinese(s.curAuditors[0].audit_order) + '审' %></a>
+                            <% } %>
+                            <% } %>
+                            <% } %>
+                            <%- auditConst.info[s.audit_status].title %>
+                        </td>
+                        <td class="text-center">
+                            <% if (s.audit_status === auditConst.status.uncheck && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if (s.audit_status === auditConst.status.checkNo && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if ((s.audit_status === auditConst.status.checking || s.audit_status === auditConst.status.checkNoPre) && s.curAuditors && s.curAuditors.findIndex(x => { return x.audit_id === ctx.session.sessionUser.accountId; }) >= 0) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else { %>
+                            <span class="<%- auditConst.info[s.audit_status].class %>"><%- auditConst.info[s.audit_status].title %></span>
+                            <% } %>
+                        </td>
+                    </tr>
+                    <% } %>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const stages = JSON.parse('<%- JSON.stringify(stages) %>');
+    const auditType = JSON.parse('<%- JSON.stringify(auditType) %>');
+    const auditConst = JSON.parse('<%- JSON.stringify(auditConst) %>');
+</script>

+ 72 - 0
app/view/cost/analysis_list_modal.ejs

@@ -0,0 +1,72 @@
+<div class="modal" id="add-qi" data-backdrop="static" aria-modal="true" role="dialog">
+    <div class="modal-dialog" role="document">
+        <form class="modal-content" action="addStage" method="POST" onsubmit="return checkAddValid();">
+            <div class="modal-header">
+                <h5 class="modal-title">新建报审</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group form-group-sm">
+                    <label>期数</label>
+                    <input class="form-control form-control-sm" value="第 <%- (stages.length + 1) %> 期" type="text" readonly="">
+                    <input type="hidden" value="<%- (stages.length + 1) %>" name="stage_order">
+                </div>
+                <div class="form-group form-group-sm">
+                    <label>关联成本</label>
+                    <select class="form-control form-control-sm" name="stage">
+                        <% for (const s of validRelaStages) { %>
+                        <option value="<%- s.stage_order %>">第 <%- s.stage_order %> 期</option>
+                        <% } %>
+                    </select>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <input type="hidden" name="stage_type" value="<%- stage_type %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="submit" class="btn btn-sm btn-primary">确定</button>
+            </div>
+        </form>
+    </div>
+</div>
+<!--审批流程/结果-->
+<div class="modal fade" id="sp-list" 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="row">
+                    <div class="col-4 modal-height-500" style="overflow: auto">
+                        <div class="card mt-3">
+                            <ul class="list-group list-group-flush" id="auditor-list">
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto" id="audit-list">
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    $('.datepicker-here').datepicker({
+        autoClose: true,
+    });
+    const checkAddValid = function() {
+        if ($('[name=date]', '#add-qi').val() == '') {
+            toastr.error('请选择计量年月');
+            return false;
+        }
+    }
+    const checkEditValid = function() {
+        if ($('[name=date]', '#edit-qi').val() == '') {
+            toastr.error('请选择计量年月');
+            return false;
+        }
+    }
+</script>

+ 3 - 0
app/view/cost/analysis_menu_list.ejs

@@ -0,0 +1,3 @@
+<nav-menu title="返回" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis" tclass="text-primary" ml="1" icon="fa-chevron-left"></nav-menu>
+<nav-menu title="成本分析" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis/<%- ctx.costStage.stage_order %>/stage" ml="3" active="<%= (ctx.url.indexOf('ledger') >= 0 ? 1 : -1) %>"></nav-menu>
+<% include ./audit_btn.ejs %>

+ 30 - 0
app/view/cost/analysis_modal.ejs

@@ -0,0 +1,30 @@
+<% include ../shares/delete_hint_modal.ejs %>
+<% include ./audit_modal.ejs %>
+<div class="modal" id="import-pay" data-backdrop="static" aria-modal="true" role="dialog">
+    <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 form-group-sm">
+                    <label>选择期数</label>
+                    <select class="form-control form-control-sm" id="pay-order">
+                        <% for (const ps of payStages) { %>
+                        <option value="<%- ps.order %>">第<%- ps.order %>期 (<%- ps.date %>)</option>
+                        <% } %>
+                    </select>
+                </div>
+                <div class="form-group form-group-sm">
+                    <label>税率</label>
+                    <input class="form-control form-control-sm" id="pay-tax" placeholder="请输入税率">
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="import-pay-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 41 - 0
app/view/cost/audit_btn.ejs

@@ -0,0 +1,41 @@
+<div class="contarl-box">
+    <% if (ctx.costStage.audit_status === auditConst.status.uncheck) { %>
+        <% if (ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+            <a id="sub-sp-btn" href="javascript: void(0);" data-toggle="modal" data-target="#sub-sp" class="btn btn-primary btn-sm btn-block">上报审批</a>
+        <% } else { %>
+            <a id="sub-sp-btn" href="javascript: void(0);" data-toggle="modal" data-target="#sub-sp" class="btn btn-outline-secondary btn-sm btn-block">上报中</a>
+        <% } %>
+    <% } %>
+
+    <% if (ctx.costStage.audit_status === auditConst.status.checking) { %>
+        <% if (ctx.costStage.curAuditorIds.indexOf(ctx.session.sessionUser.accountId) >= 0) { %>
+            <a id="sp-done-btn" href="javascript: void(0);" data-toggle="modal" data-target="#sp-done" class="btn btn-success btn-sm btn-block">审批通过</a>
+            <a href="#sp-back" data-toggle="modal" data-target="#sp-back" class="btn btn-warning btn-sm btn-block">审批退回</a>
+        <% } else { %>
+            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" class="btn btn-outline-secondary btn-sm btn-block">审批中</a>
+        <% } %>
+    <% } %>
+
+    <% if (ctx.costStage.audit_status === auditConst.status.checked) { %>
+        <a href="#sp-list" data-type="hide" data-toggle="modal" data-target="#sp-list" class="btn btn-outline-secondary btn-sm btn-block sp-list-btn">审批完成</a>
+    <% } %>
+
+    <% if (ctx.costStage.audit_status === auditConst.status.checkNo) { %>
+        <a href="#sp-list"  data-type="hide" data-toggle="modal" data-target="#sp-list" class="btn btn-outline-warning btn-sm btn-block text-muted sp-list-btn">审批退回</a>
+        <% if (ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+            <a href="#sp-list" data-type="show" data-toggle="modal" data-target="#sp-list"  class="btn btn-primary btn-sm btn-block sp-list-btn">重新上报</a>
+        <% } %>
+    <% } %>
+
+    <% if (ctx.costStage.finalAuditorIds.indexOf(ctx.session.sessionUser.accountId) >= 0 && ctx.costStage.audit_status === auditConst.status.checked && ctx.costStage.isLatest) { %>
+        <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-back" class="btn btn-warning btn-sm btn-block">重新审批</a>
+    <% } %>
+
+    <% if (ctx.costStage.cancancel) { %>
+        <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm btn-block">撤回</a>
+    <% } %>
+
+    <% if (ctx.costStage.create_user_id === ctx.session.sessionUser.accountId && ctx.costStage.isLatest && (ctx.costStage.audit_status === auditConst.status.checkNo || ctx.costStage.audit_status === auditConst.status.uncheck)) { %>
+        <a href="#del-qi" data-toggle="modal" data-target="#del-qi" class="btn btn-outline-danger btn-sm btn-block mt-5">删除本期</a>
+    <% } %>
+</div>

+ 898 - 0
app/view/cost/audit_modal.ejs

@@ -0,0 +1,898 @@
+<% if (ctx.costStage && (ctx.costStage.audit_status === auditConst.status.uncheck || ctx.costStage.audit_status === auditConst.status.checkNo) && ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+<!--上报审批-->
+<div class="modal fade" id="sub-sp" 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="dropdown text-right">
+                    <% if (shenpi_status !== shenpiConst.sp_status.gdspl && ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+                    <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="dropdownMenuButton"
+                            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                        添加审批流程
+                    </button>
+                    <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton" style="width:220px">
+                        <div class="mb-2 p-2"><input class="form-control form-control-sm" placeholder="姓名/手机 检索"
+                                                     id="gr-search" autocomplete="off"></div>
+                        <dl class="list-unstyled book-list" id="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 => { %>
+                                <% if (item.id !== ctx.session.sessionUser.accountId) { %>
+                                <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 class="card mt-3">
+                    <div class="card-header">
+                        审批流程
+                    </div>
+                    <div class="modal-height-500" style="overflow: auto">
+                        <ul class="list-group list-group-flush" id="auditors">
+                            <% for (let i = 0, iLen = ctx.costStage.auditorGroups.length; i < iLen; i++) { %>
+                            <% if (ctx.costStage.auditorGroups[i][0].audit_order === 0) continue; %>
+                            <li class="list-group-item d-flex" auditorId="<%- ctx.costStage.auditorGroups[i][0].audit_id %>">
+                                <div class="col-auto"><%- ctx.costStage.auditorGroups[i][0].audit_order %></div>
+                                <div class="col">
+                                    <% for (const auditor of ctx.costStage.auditorGroups[i]) { %>
+                                    <div class="d-inline-block mx-1" auditorId="<%- auditor.audit_id %>">
+                                        <i class="fa fa-user text-muted"></i> <%- auditor.name %> <small class="text-muted"><%- auditor.role %></small>
+                                    </div>
+                                    <% } %>
+                                </div>
+                                <div class="col-auto">
+                                    <% if (ctx.costStage.auditorGroups[i][0].audit_type !== auditType.key.common) { %>
+                                    <span class="badge badge-pill badge-<%- auditType.info[ctx.costStage.auditorGroups[i][0].audit_type].class %> badge-bg-small"><small><%- auditType.info[ctx.costStage.auditorGroups[i][0].audit_type].long%></small></span>
+                                    <% } %>
+                                    <% if ((shenpi_status === shenpiConst.sp_status.sqspr ||
+                                                    (shenpi_status === shenpiConst.sp_status.gdzs && i+1 !== iLen)) && ctx.session.sessionUser.accountId === ctx.costStage.create_user_id && !ctx.tender.isTourist) { %>
+                                    <a href="javascript: void(0)" class="text-danger pull-right">移除</a>
+                                    <% } %>
+                                </div>
+                            </li>
+                            <% } %>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+            <form class="modal-footer" method="post" action="audit/start" name="stage-start">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>">
+                <% if (ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+                <button class="btn btn-primary btn-sm" type="submit">确认上报</button>
+                <% } %>
+            </form>
+        </div>
+    </div>
+</div>
+<% } %>
+<% if(ctx.costStage && (ctx.costStage.audit_status !== auditConst.status.uncheck)) { %>
+<!--审批流程/结果-->
+<div class="modal fade" id="sp-list" 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="row">
+                    <div class="col-4">
+                        <% if(ctx.costStage.audit_status === auditConst.status.checkNo && ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+                        <a class="sp-list-item" href="#sub-sp" data-toggle="modal" data-target="#sub-sp"
+                           id="hideSp">修改审批流程</a>
+                        <% } else if(ctx.costStage.audit_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) { %>
+                        <a class="sp-list-item" href="#sub-sp2" data-toggle="modal" data-target="#sub-sp2"
+                           id="hideSp" style="display: none;">修改审批流程</a>
+                        <% } %>
+                        <div class="card modal-height-500 mt-3" style="overflow: auto">
+                            <ul class="list-group list-group-flush auditors-list" id="auditors-list">
+                                <% ctx.costStage.userGroups.forEach((item, idx) => { %>
+                                <% if (idx === 0) { %>
+                                <li class="list-group-item d-flex justify-content-between align-items-center" data-auditorId="<%- item[0].audit_id%>">
+                                    <span class="mr-1"><i class="fa fa fa-play-circle fa-rotate-90"></i></span>
+                                    <span class="text-muted">
+                                        <% for (const u of item) { %>
+                                        <small class="d-inline-block text-dark mx-1" title="<%- u.company %>" data-auditorId="<%- u.audit_id %>"><%- u.name %></small>
+                                        <% } %>
+                                    </span>
+                                    <span class="badge badge-light badge-pill ml-auto"><small>原报</small></span>
+                                </li>
+                                <% } else if(idx === ctx.costStage.userGroups.length -1 && idx !== 0) { %>
+                                <li class="list-group-item d-flex justify-content-between align-items-center" data-auditorId="<%- item[0].audit_id%>">
+                                    <span class="mr-1"><i class="fa fa fa-stop-circle"></i></span>
+                                    <span class="text-muted">
+                                        <% for (const u of item) { %>
+                                        <small class="d-inline-block text-dark mx-1" title="<%- u.company %>" data-auditorId="<%- u.audit_id %>"><%- u.name %></small>
+                                        <% } %>
+                                    </span>
+                                    <div class="d-flex ml-auto">
+                                        <% if (item[0].audit_type !== auditType.key.common) { %>
+                                        <span class="badge badge-pill badge-<%-  auditType.info[item[0].audit_type].class %> p-1"><small><%- auditType.info[item[0].audit_type].short %></small></span>
+                                        <% } %>
+                                        <span class="badge badge-light badge-pill"><small>终审</small></span>
+                                    </div>
+                                </li>
+                                <% } else {%>
+                                <li class="list-group-item d-flex justify-content-between align-items-center" data-auditorId="<%- item[0].audit_id%>">
+                                    <span class="mr-1"><i class="fa fa-chevron-circle-down"></i></span>
+                                    <span class="text-muted">
+                                        <% for (const u of item) { %>
+                                        <small class="d-inline-block text-dark mx-1" title="<%- u.company %>" data-auditorId="<%- u.audit_id %>"><%- u.name %></small>
+                                        <% } %>
+                                    </span>
+                                    <div class="d-flex ml-auto">
+                                        <% if (item[0].audit_type !== auditType.key.common) { %>
+                                        <span class="badge badge-pill badge-<%- auditType.info[item[0].audit_type].class %> p-1"><small><%- auditType.info[item[0].audit_type].short %></small></span>
+                                        <% } %>
+                                        <span class="badge badge-light badge-pill"><small><%= ctx.helper.transFormToChinese(idx) %>审</small></span>
+                                    </div>
+                                </li>
+                                <% } %>
+                                <% }) %>
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto">
+                        <% ctx.costStage.auditHistory.forEach((his, idx) => { %>
+                        <!-- 展开/收起历史流程 -->
+                        <% if(idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>
+                        <div class="text-right">
+                            <a href="javascript: void(0);" id="fold-btn" data-target="show" >展开历史审批流程</a>
+                        </div>
+                        <% } %>
+                        <div class="<%- idx < ctx.costStage.auditHistory.length - 1 ? 'fold-card' : '' %>">
+                            <div class="text-center text-muted"><%- idx+1 %>#</div>
+                            <ul class="timeline-list list-unstyled mt-2 <% if (idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>last-auditor-list<% } %>">
+                                <% his.forEach((group, index) => { %>
+                                <li class="timeline-list-item pb-2 <% if (group.audit_status === auditConst.status.uncheck && idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>is_uncheck<% } %>">
+                                    <% if (group.auditYear) { %>
+                                    <div class="timeline-item-date">
+                                        <%- group.auditYear %>
+                                        <span><%- group.auditDate %></span>
+                                        <span><%- group.auditTime %></span>
+                                    </div>
+                                    <% } %>
+                                    <% if (index < his.length - 1) { %>
+                                    <div class="timeline-item-tail"></div>
+                                    <% } %>
+                                    <% if (group.audit_order === 0) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-caret-down"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checked) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-check"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checkNo || group.audit_status === auditConst.status.checkNoPre || group.audit_status === auditConst.status.checkCancel) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-level-up"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checking) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-ellipsis-h"></i></div>
+                                    <% } else { %>
+                                    <div class="timeline-item-icon bg-secondary text-light"></div>
+                                    <% } %>
+                                    <div class="timeline-item-content">
+                                        <div class="py-1">
+                                            <span class="text-black-50">
+                                                <%- group.auditName %>
+                                                <% if (group.audit_type !== auditType.key.common) { %><span class="text-<%- auditType.info[group.audit_type].class %> "><%- auditType.info[group.audit_type].long %></span><% } %>
+                                            </span>
+                                            <% if (group.audit_order === 0) { %>
+                                            <span class="pull-right text-success"><%- idx !== 0 ? '重新' : '' %>上报审批</span>
+                                            <% } else if (group.audit_status !== auditConst.status.uncheck) { %>
+                                            <span class="pull-right <%- auditConst.info[group.audit_status].class %>"><%- auditConst.info[group.audit_status].title %></span>
+                                            <% } %>
+                                        </div>
+                                        <div class="card">
+                                            <div class="card-body px-3 py-0">
+                                                <% for (const [i, auditor] of group.auditors.entries()) { %>
+                                                <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
+                                                    <div class="col-10">
+                                                        <span class="h6"><%- auditor.name %></span>
+                                                        <% if (group.audit_order === 0) { %>
+                                                            <% if (auditor.role && auditor.role.trim()) { %>
+                                                                <span class="text-muted ml-1"><%- auditor.role %></span>
+                                                            <% } %>
+                                                        <% } else { %>
+                                                            <span class="text-muted ml-1">
+                                                                <%- auditor.company %>
+                                                                <% if (auditor.role) { %>
+                                                                    - <%- auditor.role %>
+                                                                <% } %>
+                                                            </span>
+                                                        <% } %>
+                                                    </div>
+                                                    <div class="col">
+                                                        <% if (auditor.audit_status === auditConst.status.checked) { %>
+                                                        <span class="pull-right text-success"><i class="fa fa-check-circle"></i></span>
+                                                        <% } if (auditor.audit_status === auditConst.status.checkNo || auditor.audit_status === auditConst.status.checkNoPre || auditor.audit_status === auditConst.status.checkCancel) { %>
+                                                        <span class="pull-right text-warning"><i class="fa fa-share-square fa-rotate-270"></i></span>
+                                                        <% } %>
+                                                    </div>
+                                                    <% if (auditor.opinion) { %>
+                                                    <div class="col-12 py-1 bg-light"><i class="fa fa-commenting-o mr-1"></i><%- auditor.opinion%></div>
+                                                    <% } %>
+                                                </div>
+                                                <% } %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </li>
+                                <% }) %>
+                            </ul>
+                        </div>
+                        <% }) %>
+                    </div>
+                </div>
+            </div>
+            <form class="modal-footer" method="post" action="audit/start" name="stage-start">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <% if(ctx.costStage.audit_status === auditConst.status.checkNo && ctx.session.sessionUser.accountId === ctx.costStage.create_user_id) { %>
+                <button class="btn btn-primary btn-sm sp-list-item" type="submit">确认上报</button>
+                <% } %>
+            </form>
+        </div>
+    </div>
+</div>
+<% } %>
+<% if (ctx.costStage && (ctx.costStage.audit_status === auditConst.status.checking || ctx.costStage.audit_status === auditConst.status.checkNoPre) && ctx.costStage.curAuditorIds.indexOf(ctx.session.sessionUser.accountId) >= 0) { %>
+<!--审批通过-->
+<div class="modal fade sp-location-list" id="sp-done" data-backdrop="static">
+    <div class="modal-dialog modal-lg" role="document">
+        <form class="modal-content" action="audit/check" method="post" id="audit-check">
+            <div class="modal-header">
+                <h5 class="modal-title">审批通过</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-4">
+                        <% if(ctx.costStage.audit_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) { %>
+                        <a class="sp-list-item" href="#sub-sp2" data-toggle="modal" data-target="#sub-sp2" id="hideSp">修改审批流程</a>
+                        <% } %>
+                        <div class="card modal-height-500 mt-3" style="overflow: auto">
+                            <ul class="list-group list-group-flush auditors-list">
+                                <% ctx.costStage.userGroups.forEach((item, idx) => { %>
+                                <li class="list-group-item d-flex justify-content-between align-items-center">
+                                    <% if (idx === 0) { %>
+                                    <span class="mr-1"><i class="fa fa fa-play-circle fa-rotate-90"></i></span>
+                                    <% } else if (idx === ctx.costStage.userGroups.length -1 && idx !== 0) { %>
+                                    <span class="mr-1"><i class="fa fa fa-stop-circle"></i></span>
+                                    <% } else { %>
+                                    <span class="mr-1"><i class="fa fa-chevron-circle-down"></i></span>
+                                    <% } %>
+                                    <span class="text-muted">
+                                        <% for (const u of item) { %>
+                                        <small class="d-inline-block text-dark mx-1" title="<%- u.company %>" data-auditorId="<%- u.audit_id %>"><%- u.name %></small>
+                                        <% } %>
+                                    </span>
+                                    <div class="d-flex ml-auto">
+                                        <% if (item[0].audit_type !== auditType.key.common) { %>
+                                        <span class="badge badge-pill badge-<%-  auditType.info[item[0].audit_type].class %> p-1"><small><%- auditType.info[item[0].audit_type].short %></small></span>
+                                        <% } %>
+                                        <span class="badge badge-light badge-pill"><small><%- item.auditName %></small></span>
+                                    </div>
+                                </li>
+                                <% }) %>
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto">
+                        <% ctx.costStage.auditHistory.forEach((his, idx) => { %>
+                        <!-- 展开/收起历史流程 -->
+                        <% if(idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>
+                        <div class="text-right"><a href="javascript: void(0);" id="fold-btn" data-target="show">展开历史审批流程</a></div>
+                        <% } %>
+                        <div class="<%- idx < ctx.costStage.auditHistory.length - 1 ? 'fold-card' : '' %>">
+                            <div class="text-center text-muted"><%- idx+1 %>#</div>
+                            <ul class="timeline-list list-unstyled mt-2 <% if (idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>last-auditor-list<% } %>">
+                                <% his.forEach((group, index) => { %>
+                                <li class="timeline-list-item pb-2 <% if (group.audit_status === auditConst.status.uncheck && idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>is_uncheck<% } %>">
+                                    <% if (group.auditYear) { %>
+                                    <div class="timeline-item-date">
+                                        <%- group.auditYear %>
+                                        <span><%- group.auditDate %></span>
+                                        <span><%- group.auditTime %></span>
+                                    </div>
+                                    <% } %>
+                                    <% if (index < his.length - 1) { %>
+                                    <div class="timeline-item-tail"></div>
+                                    <% } %>
+                                    <% if (group.audit_order === 0) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-caret-down"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checked) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-check"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checkNo || group.audit_status === auditConst.status.checkNoPre || group.audit_status === auditConst.status.checkCancel) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-level-up"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checking) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-ellipsis-h"></i></div>
+                                    <% } else { %>
+                                    <div class="timeline-item-icon bg-secondary text-light"></div>
+                                    <% } %>
+                                    <div class="timeline-item-content">
+                                        <div class="py-1">
+                                            <span class="text-black-50">
+                                                <%- group.auditName %>
+                                                <% if (group.audit_type !== auditType.key.common) { %><span class="text-<%- auditType.info[group.audit_type].class %> "><%- auditType.info[group.audit_type].long %></span><% } %>
+                                            </span>
+                                            <% if (group.audit_order === 0) { %>
+                                            <span class="pull-right text-success"><%- idx !== 0 ? '重新' : '' %>上报审批</span>
+                                            <% } else if (group.audit_status !== auditConst.status.uncheck) { %>
+                                            <span class="pull-right <%- auditConst.info[group.audit_status].class %>"><%- auditConst.info[group.audit_status].title %></span>
+                                            <% } %>
+                                        </div>
+                                        <div class="card">
+                                            <div class="card-body px-3 py-0">
+                                                <% for (const [i, auditor] of group.auditors.entries()) { %>
+                                                <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
+                                                    <div class="col-10">
+                                                        <span class="h6"><%- auditor.name %></span>
+                                                        <% if (group.audit_order === 0) { %>
+                                                            <% if (auditor.role && auditor.role.trim()) { %>
+                                                                <span class="text-muted ml-1"><%- auditor.role %></span>
+                                                            <% } %>
+                                                        <% } else { %>
+                                                            <span class="text-muted ml-1">
+                                                                <%- auditor.company %>
+                                                                <% if (auditor.role && auditor.role.trim()) { %>
+                                                                    - <%- auditor.role %>
+                                                                <% } %>
+                                                            </span>
+                                                        <% } %>
+                                                    </div>
+                                                    <div class="col">
+                                                        <% if (auditor.audit_status === auditConst.status.checked) { %>
+                                                        <span class="pull-right text-success"><i class="fa fa-check-circle"></i></span>
+                                                        <% } if (auditor.audit_status === auditConst.status.checkNo || auditor.audit_status === auditConst.status.checkNoPre || auditor.audit_status === auditConst.status.checkCancel) { %>
+                                                        <span class="pull-right text-warning"><i class="fa fa-share-square fa-rotate-270"></i></span>
+                                                        <% } else if (auditor.audit_status === auditConst.status.checking) { %>
+                                                        <span class="pull-right text-warning"><i class="fa fa-commenting"></i></span>
+                                                        <% } %>
+                                                    </div>
+                                                    <% if (auditor.audit_status !== auditConst.status.uncheck && auditor.opinion) { %>
+                                                    <div class="col-12 py-1 bg-light"><i class="fa fa-commenting-o mr-1"></i><%- auditor.opinion%></div>
+                                                    <% } %>
+                                                    <% if (auditor.audit_status === auditConst.status.checking && auditor.audit_id === ctx.session.sessionUser.accountId) { %>
+                                                    <div class="col-12 py-1 bg-light">
+                                                        <textarea class="form-control form-control-sm" name="opinion">同意</textarea>
+                                                    </div>
+                                                    <% } %>
+                                                </div>
+                                                <% } %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </li>
+                                <% }) %>
+                            </ul>
+                        </div>
+                        <% }) %>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <input type="hidden" name="checkType" value="<%= auditConst.status.checked %>" />
+                <button type="submit" class="btn btn-success btn-sm">确认通过</button>
+            </div>
+        </form>
+    </div>
+</div>
+<!--审批退回-->
+<div class="modal fade sp-location-list" id="sp-back" data-backdrop="static">
+    <div class="modal-dialog modal-lg" role="document">
+        <form class="modal-content modal-lg" action="audit/check" method="post" id="audit-check-no">
+            <div class="modal-header">
+                <h5 class="modal-title">审批退回</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-4">
+                        <% if(ctx.costStage.audit_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) { %>
+                        <a class="sp-list-item" href="#sub-sp2" data-toggle="modal" data-target="#sub-sp2" id="hideSp">修改审批流程</a>
+                        <% } %>
+                        <div class="card modal-height-500 mt-3" style="overflow: auto">
+                            <ul class="list-group list-group-flush auditors-list">
+                                <% ctx.costStage.userGroups.forEach((item, idx) => { %>
+                                <li class="list-group-item d-flex justify-content-between align-items-center">
+                                    <% if (idx === 0) { %>
+                                    <span class="mr-1"><i class="fa fa fa-play-circle fa-rotate-90"></i></span>
+                                    <% } else if (idx === ctx.costStage.userGroups.length -1 && idx !== 0) { %>
+                                    <span class="mr-1"><i class="fa fa fa-stop-circle"></i></span>
+                                    <% } else { %>
+                                    <span class="mr-1"><i class="fa fa-chevron-circle-down"></i></span>
+                                    <% } %>
+                                    <span class="text-muted">
+                                        <% for (const u of item) { %>
+                                        <small class="d-inline-block text-dark mx-1" title="<%- u.company %>" data-auditorId="<%- u.audit_id %>"><%- u.name %></small>
+                                        <% } %>
+                                    </span>
+                                    <div class="d-flex ml-auto">
+                                        <% if (item[0].audit_type !== auditType.key.common) { %>
+                                        <span class="badge badge-pill badge-<%-  auditType.info[item[0].audit_type].class %> p-1"><small><%- auditType.info[item[0].audit_type].short %></small></span>
+                                        <% } %>
+                                        <span class="badge badge-light badge-pill"><small><%- item.auditName %></small></span>
+                                    </div>
+                                </li>
+                                <% }) %>
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto">
+                        <% ctx.costStage.auditHistory.forEach((his, idx) => { %>
+                        <!-- 展开/收起历史流程 -->
+                        <% if(idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>
+                        <div class="text-right"><a href="javascript: void(0);" id="fold-btn" data-target="show" data-idx="<%- idx + 1 %>">展开历史审批流程</a></div>
+                        <% } %>
+                        <div class="<%- idx < ctx.costStage.auditHistory.length - 1 ? 'fold-card' : '' %>">
+                            <div class="text-center text-muted"><%- idx+1 %>#</div>
+                            <ul class="timeline-list list-unstyled mt-2 <% if (idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>last-auditor-list<% } %>">
+                                <% his.forEach((group, index) => { %>
+                                <li class="timeline-list-item pb-2 <% if (group.audit_status === auditConst.status.uncheck && idx === ctx.costStage.auditHistory.length - 1 && ctx.costStage.auditHistory.length !== 1) { %>is_uncheck<% } %>">
+                                    <% if (group.auditYear) { %>
+                                    <div class="timeline-item-date">
+                                        <%- group.auditYear %>
+                                        <span><%- group.auditDate %></span>
+                                        <span><%- group.auditTime %></span>
+                                    </div>
+                                    <% } %>
+                                    <% if (index < his.length - 1) { %>
+                                    <div class="timeline-item-tail"></div>
+                                    <% } %>
+                                    <% if (group.audit_order === 0) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-caret-down"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checked) { %>
+                                    <div class="timeline-item-icon bg-success text-light"><i class="fa fa-check"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checkNo || group.audit_status === auditConst.status.checkNoPre || group.audit_status === auditConst.status.checkCancel) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-level-up"></i></div>
+                                    <% } else if (group.audit_status === auditConst.status.checking) { %>
+                                    <div class="timeline-item-icon bg-warning text-light"><i class="fa fa-ellipsis-h"></i></div>
+                                    <% } else { %>
+                                    <div class="timeline-item-icon bg-secondary text-light"></div>
+                                    <% } %>
+                                    <div class="timeline-item-content">
+                                        <div class="py-1">
+                                            <span class="text-black-50">
+                                                <%- group.auditName %>
+                                                <% if (group.audit_type !== auditType.key.common) { %><span class="text-<%- auditType.info[group.audit_type].class %> "><%- auditType.info[group.audit_type].long %></span><% } %>
+                                            </span>
+                                            <% if (group.audit_order === 0) { %>
+                                            <span class="pull-right text-success"><%- idx !== 0 ? '重新' : '' %>上报审批</span>
+                                            <% } else if (group.audit_status !== auditConst.status.uncheck) { %>
+                                            <span class="pull-right <%- auditConst.info[group.audit_status].class %>"><%- auditConst.info[group.audit_status].title %></span>
+                                            <% } %>
+                                        </div>
+                                        <div class="card">
+                                            <div class="card-body px-3 py-0">
+                                                <% for (const [i, auditor] of group.auditors.entries()) { %>
+                                                <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
+                                                    <div class="col-10">
+                                                        <span class="h6"><%- auditor.name %></span>
+                                                        <% if (group.audit_order === 0) { %>
+                                                            <% if (auditor.role && auditor.role.trim()) { %>
+                                                                <span class="text-muted ml-1"><%- auditor.role %></span>
+                                                            <% } %>
+                                                        <% } else { %>
+                                                            <span class="text-muted ml-1">
+                                                                <%- auditor.company %>
+                                                                <% if (auditor.role && auditor.role.trim()) { %>
+                                                                    - <%- auditor.role %>
+                                                                <% } %>
+                                                            </span>
+                                                        <% } %>
+                                                    </div>
+                                                    <div class="col">
+                                                        <% if (auditor.audit_status === auditConst.status.checked) { %>
+                                                        <span class="pull-right text-success"><i class="fa fa-check-circle"></i></span>
+                                                        <% } if (auditor.audit_status === auditConst.status.checkNo || auditor.audit_status === auditConst.status.checkNoPre || auditor.audit_status === auditConst.status.checkCancel) { %>
+                                                        <span class="pull-right text-warning"><i class="fa fa-share-square fa-rotate-270"></i></span>
+                                                        <% } else if (auditor.audit_status === auditConst.status.checking) { %>
+                                                        <span class="pull-right text-warning"><i class="fa fa-commenting"></i></span>
+                                                        <% } %>
+                                                    </div>
+                                                    <% if (auditor.audit_status !== auditConst.status.uncheck && auditor.opinion) { %>
+                                                    <div class="col-12 py-1 bg-light"><i class="fa fa-commenting-o mr-1"></i><%- auditor.opinion%></div>
+                                                    <% } %>
+                                                    <% if (auditor.audit_status === auditConst.status.checking && auditor.audit_id === ctx.session.sessionUser.accountId) { %>
+                                                    <div class="col-12 py-1 bg-light">
+                                                        <textarea class="form-control form-control-sm" name="opinion">不同意</textarea>
+                                                        <div id="reject-process" class="alert alert-warning mt-1 mb-0 p-2">
+                                                            <div class="form-check form-check-inline">
+                                                                <input class="form-check-input" type="radio" name="checkType" id="inlineRadio1" value="<%- auditConst.status.checkNo %>">
+                                                                <label class="form-check-label" for="inlineRadio1">退回原报 <%- ctx.costStage.user.name %></label>
+                                                            </div>
+                                                            <% if (auditor.audit_order > 1) { %>
+                                                            <div class="form-check form-check-inline">
+                                                                <input class="form-check-input" type="radio" name="checkType" id="inlineRadio2" value="<%- auditConst.status.checkNoPre %>">
+                                                                <label class="form-check-label" for="inlineRadio2">退回上一审批人
+                                                                    <% const pre = his.find(x => { return x.audit_order === auditor.audit_order - 1}); %>
+                                                                    <%- ( pre ? pre.name : '') %>
+                                                                </label>
+                                                            </div>
+                                                            <% } %>
+                                                        </div>
+                                                    </div>
+                                                    <% } %>
+                                                </div>
+                                                <% } %>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </li>
+                                <% }) %>
+                            </ul>
+                        </div>
+                        <% }) %>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="submit" class="btn btn-warning btn-sm">确认退回</button>
+            </div>
+        </form>
+    </div>
+</div>
+<% } %>
+<% if (ctx.costStage && ctx.costStage.finalAuditorIds.indexOf(ctx.session.sessionUser.accountId) >= 0 && ctx.costStage.audit_status === auditConst.status.checked && ctx.costStage.isLatest) { %>
+<% if (!authMobile && ctx.session.sessionUser.loginStatus === 0) { %>
+<!--终审重新审批-->
+<div class="modal fade" id="sp-down-back" 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">
+                <h5>重新审批需要您的手机短信验证</h5>
+                <h5>您目前还没设置认证手机,请先设置。</h5>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <a href="/profile/sms" class="btn btn-sm btn-primary">去设置</a>
+            </div>
+        </div>
+    </div>
+</div>
+<% } else { %>
+<div class="modal fade" id="sp-down-back" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <form class="modal-content" method="post" action="audit/checkAgain" name="stage-checkAgain">
+            <div class="modal-header">
+                <h5 class="modal-title">重新审批</h5>
+            </div>
+            <div class="modal-body">
+                <h5>确认由「终审-<%= ctx.session.sessionUser.name %>」重新审批「第<%= ctx.costStage.stage_order %>期」?
+                </h5>
+                <% if (ctx.session.sessionUser.loginStatus === 0) { %>
+                <div class="form-group">
+                    <label>重审需要验证码确认,验证码将发送至尾号<%- authMobile.slice(-4) %>的手机</label>
+                    <div class="input-group input-group-sm mb-3">
+                        <input class="form-control" type="text" readonly="readonly" name="code"
+                               placeholder="输入短信中的6位验证码" />
+                        <div class="input-group-append">
+                            <button class="btn btn-outline-secondary" type="button" id="get-code">获取验证码</button>
+                        </div>
+                    </div>
+                </div>
+                <% } %>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <button <% if (ctx.session.sessionUser.loginStatus === 0) { %>disabled<% } %> id="re-shenpi-btn" class="btn btn-warning btn-sm" type="submit">确定重审</button>
+            </div>
+        </form>
+    </div>
+</div>
+<% } %>
+<% } %>
+<% if (ctx.costStage && ctx.costStage.create_user_id === ctx.session.sessionUser.accountId && ctx.costStage.isLatest && (ctx.costStage.audit_status === auditConst.status.checkNo || ctx.costStage.audit_status === auditConst.status.uncheck)) { %>
+<div class="modal fade" id="del-qi" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <form class="modal-content" action='/sp/<%- ctx.subProject.id %>/cost/tender/<%= ctx.tender.id %>/delStage' method="post">
+            <div class="modal-header">
+                <h5 class="modal-title">删除期</h5>
+            </div>
+            <div class="modal-body">
+                <h5>确认删除「第<%= ctx.costStage.stage_order %>期」?</h5>
+                <h5>删除后,数据无法恢复,请谨慎操作。</h5>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="stage_id" value="<%= ctx.costStage.id %>">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">取消</button>
+                <button type="submit" class="btn btn-danger btn-sm">确定删除</button>
+            </div>
+        </form>
+    </div>
+</div>
+<% } %>
+<% if (ctx.costStage && ctx.costStage.cancancel) { %>
+<div class="modal fade" id="sp-down-cancel" 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">
+                <h5>确定撤回?</h5>
+            </div>
+            <form class="modal-footer" action="audit/checkCancel" method="post">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">取消</button>
+                <button type="submit" class="btn btn-danger btn-sm" type="submit">确定撤回</button>
+            </form>
+        </div>
+    </div>
+</div>
+<% } %>
+<% if (ctx.costStage && ctx.costStage.audit_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) { %>
+<!--上报审批-->
+<div class="modal fade" id="sub-sp2" data-backdrop="static">
+    <div class="modal-dialog" style="max-width: 650px" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">修改审批流程</h5>
+            </div>
+            <div class="modal-body">
+                <div class="card mt-1">
+                    <div class="modal-height-500" style="overflow: auto">
+                        <style>
+                            #admin-edit-shenpi thead th {
+                                border-bottom: 0;
+                            }
+                            #admin-edit-shenpi td, #admin-edit-shenpi th {
+                                padding: 0.75rem;
+                            }
+                            #admin-edit-shenpi th {
+                                background: none;
+                                color: #212529;
+                                border-top: 0;
+                            }
+                        </style>
+                        <table class="table table-hover" id="admin-edit-shenpi">
+                            <thead>
+                            <tr class="card-header text-center">
+                                <th width="100px">审批流程</th>
+                                <th>审批人员</th>
+                                <th width="80" style="text-align: center">审批状态</th>
+                                <th width="200" style="text-align: center">操作</th>
+                            </tr>
+                            </thead>
+                            <tbody id="admin-edit-shenpi-list">
+                            <% for (const [i, group] of ctx.costStage.userGroups.entries()) { %>
+                            <% if (i === 0) continue; %>
+                            <% for (const [j, auditor] of group.entries()) { %>
+                            <tr>
+                                <td class="text-left d-flex">
+                                    <% if (j === 0) { %>
+                                    <%- i + '审' %>
+                                    <% if (auditor.audit_type !== auditType.key.common) { %>
+                                    <span class="ml-2 badge badge-pill badge-<%-  auditType.info[auditor.audit_type].class %> p-1"><small><%- auditType.info[auditor.audit_type].short %></small></span>
+                                    <% } %>
+                                    <% } %>
+                                </td>
+                                <td></span> <%- auditor.name %> <small class="text-muted"><%- auditor.role %></small></td>
+                                <td style="text-align: center"><span class="<%- auditConst.info[auditor.audit_status].class %>"><%- auditor.audit_status !== auditConst.status.uncheck ? auditConst.info[auditor.audit_status].title : '待审批'  %></span></td>
+                                <td style="text-align: center">
+                                    <% if (auditor.audit_status === auditConst.status.checking && j === group.length - 1) { %>
+                                    <span class="dropdown mr-2">
+                                    <a href="javascript: void(0)" class="add-audit" id="<%- auditor.audit_id %>_add_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">增加</a>
+                                    <div class="dropdown-menu dropdown-menu-right" id="<%- auditor.audit_id %>_add_dropdownMenu" aria-labelledby="<%- auditor.audit_id %>_add_dropdownMenuButton" style="width:220px">
+                                        <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search"
+                                                                     placeholder="姓名/手机 检索" autocomplete="off" data-code="<%- auditor.audit_id %>_add"></div>
+                                        <dl class="list-unstyled book-list" data-aid="<%- auditor.audit_id %>" data-operate="add">
+                                            <% 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 => { %>
+                                                    <% if (item.id !== ctx.costStage.create_user_id) { %>
+                                                    <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>
+                                    </span>
+                                    <% } %>
+                                    <% if (auditor.audit_status === auditConst.status.uncheck) { %>
+                                    <% if (j === group.length - 1) { %>
+                                    <span class="dropdown mr-2">
+                                    <a href="javascript: void(0)" class="add-audit" id="<%- auditor.audit_id %>_add_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">增加</a>
+                                        <div class="dropdown-menu dropdown-menu-right" id="<%- auditor.audit_id %>_add_dropdownMenu" aria-labelledby="<%- auditor.audit_id %>_add_dropdownMenuButton" style="width:220px">
+                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search"
+                                                                         placeholder="姓名/手机 检索" autocomplete="off" data-code="<%- auditor.audit_id %>_add"></div>
+                                            <dl class="list-unstyled book-list" data-aid="<%- auditor.audit_id %>" data-operate="add">
+                                                <% 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 => { %>
+                                                        <% if (item.id !== ctx.costStage.create_user_id) { %>
+                                                        <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>
+                                    </span>
+                                    <% if (auditor.audit_type !== auditType.key.common) { %>
+                                    <span class="dropdown mr-2">
+                                    <a href="javascript: void(0)" class="add-audit" id="<%- auditor.audit_id %>_add-sibling_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">平级</a>
+                                        <div class="dropdown-menu dropdown-menu-right" id="<%- auditor.audit_id %>_add-sibling_dropdownMenu" aria-labelledby="<%- auditor.audit_id %>_add-sibling_dropdownMenuButton" style="width:220px">
+                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search"
+                                                                         placeholder="姓名/手机 检索" autocomplete="off" data-code="<%- auditor.audit_id %>_add-sibling"></div>
+                                            <dl class="list-unstyled book-list" data-aid="<%- auditor.audit_id %>" data-operate="add-sibling">
+                                                <% 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 => { %>
+                                                        <% if (item.id !== ctx.costStage.create_user_id) { %>
+                                                        <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>
+                                    </span>
+                                    <% } %>
+                                    <% } %>
+                                    <span class="dropdown mr-2">
+                                        <a href="javascript: void(0)" class="change-audit" id="<%- auditor.audit_id %>_change_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">更换</a>
+                                        <div class="dropdown-menu dropdown-menu-right" id="<%- auditor.audit_id %>_change_dropdownMenu" aria-labelledby="<%- auditor.audit_id %>_change_dropdownMenuButton" style="width:220px">
+                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search"
+                                                                         placeholder="姓名/手机 检索" autocomplete="off" data-code="<%- auditor.audit_id %>_change"></div>
+                                            <dl class="list-unstyled book-list" data-aid="<%- auditor.audit_id %>" data-operate="change">
+                                                <% 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 => { %>
+                                                        <% if (item.id !== ctx.costStage.create_user_id) { %>
+                                                        <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>
+                                    </span>
+                                    <span class="dropdown">
+                                    <a href="javascript: void(0)" class="text-danger" title="移除" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">移除</a>
+                                    <div class="dropdown-menu">
+                                        <span class="dropdown-item" href="javascript:void(0);">确认移除审批人?</span>
+                                        <div class="dropdown-divider"></div>
+                                        <div class="px-2 py-1 text-center">
+                                            <button class="remove-audit btn btn-sm btn-danger" data-id="<%- auditor.audit_id %>">移除</button>
+                                            <button class="btn btn-sm btn-secondary">取消</button>
+                                        </div>
+                                    </div>
+                                    </span>
+                                    <% } %>
+                                </td>
+                            </tr>
+                            <% } %>
+                            <% } %>
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+            <form class="modal-footer">
+                <div class="mr-auto text-warning">
+                    <span class="mr-3">增加:后级审核人</span>
+                    <span class="">平级:会签/或签增加平级审核人</span>
+                </div>
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+            </form>
+        </div>
+    </div>
+</div>
+<% } %>
+<script>
+    const accountGroup = JSON.parse(unescape('<%- escape(JSON.stringify(accountGroup)) %>'));
+    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
+    const shenpi_status = <%- shenpi_status %>;
+    const shenpiConst =  JSON.parse('<%- JSON.stringify(shenpiConst) %>');
+    const createUserId = parseInt('<%= ctx.costStage.create_user_id %>');
+    const auditType = JSON.parse(unescape('<%- escape(JSON.stringify(auditType)) %>'));
+</script>
+<script>
+    $('[name=stage-start]').submit(function (e) {
+        if (checkAuditorFrom()) {
+            $(this).parent().parent().parent().modal('hide');
+            $('#hide-all').hide();
+        } else {
+            return false;
+        }
+    });
+    $('#audit-check-no').submit(function (e) {
+        const checkType = parseInt($('[name=checkType]:checked').val());
+        if ($('#warning-text').length) $('#warning-text').remove();
+        if (!checkType && !$('#warning-text').length) {
+            $('#reject-process').prepend('<p id="warning-text" style="color: red; margin: 0;">请选择退回流程</p>');
+            return false;
+        }
+        $('#hide-all').hide();
+    });
+
+    $('.sp-location-list').on('shown.bs.modal', function () {
+        const scrollBox = $(this).find('div[class="col-8 modal-height-500"]');
+        const bdiv = (scrollBox.offset() && scrollBox.offset().top) || 0;
+        scrollBox.scrollTop(0);
+        const hdiv = divSearch($(this).find('textarea')) ? $(this).find('textarea') : null;
+        const hdheight = hdiv ? hdiv.parents('.timeline-item-content').offset().top : null;
+        if (hdiv && scrollBox.length && scrollBox[0].scrollHeight > 200 && hdheight - bdiv > 200) {
+            scrollBox.scrollTop(hdheight - bdiv);
+        }
+    });
+    function divSearch(div) {
+        if (div.length > 0) {
+            return true;
+        }
+        return false;
+    }
+
+    // 展开历史审核记录
+    $('.modal-body #fold-btn').click(function () {
+        const type = $(this).data('target')
+        const auditCard = $(this).parent().parent()
+        if (type === 'show') {
+            $(this).data('target', 'hide')
+            auditCard.find('.fold-card').slideDown('swing', () => {
+                auditCard.find('#fold-btn').text('收起历史审核记录')
+            })
+        } else {
+            $(this).data('target', 'show')
+            auditCard.find('.fold-card').slideUp('swing', () => {
+                auditCard.find('#fold-btn').text('展开历史审核记录')
+            })
+        }
+    });
+
+    // 重新审批
+    $('[name=stage-checkAgain]').submit(function (e) {
+        <% if (ctx.session.sessionUser.loginStatus === 0) { %>
+        const code = $("#sp-down-back input[name='code']").val();
+        if ($(this).hasClass('disabled')) return false;
+        if (code.length < 6) {
+            toast('请填写正确的验证码', 'error');
+            return false;
+        }
+        <% } %>
+        $('#hide-all').hide();
+    });
+</script>

+ 75 - 0
app/view/cost/book.ejs

@@ -0,0 +1,75 @@
+<% include ./stage_memu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./stage_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">
+                    <div class="dropdown">
+                        <button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <i class="fa fa-list-ol"></i> 显示层级
+                        </button>
+                        <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                            <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                            <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                            <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                            <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                            <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="ml-auto">
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row pr-46">
+        <div class="c-header p-0 col-12">
+        </div>
+        <!--核心内容(两栏)-->
+        <div class="row w-100 sub-content">
+            <!--左栏-->
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div class="sjs-height-1" style="overflow: hidden" id="bills-spread">
+                </div>
+                <div class="bcontent-wrap">
+                    <div class="resize-y" id="detail-spr" r-Type="height" div1=".sjs-height-1" div2=".bcontent-wrap" r-parent="div2" title="调整大小"><!--调整上下高度条--></div>
+                    <div class="bc-bar mb-1">
+                        <ul class="nav nav-tabs">
+                            <li class="nav-item">
+                                <a class="nav-link active" href="javascript:void(0)">明细数据</a>
+                            </li>
+                        </ul>
+                    </div>
+                    <div class="sp-wrap" id="detail-spread">
+                    </div>
+                </div>
+            </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"><!--调整左右高度条--></div>
+                <div class="tab-content">
+                    <div id="fujian" class="tab-pane tab-select-show">
+                    </div>
+                    <div id="search" class="tab-pane tab-select-show">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li class="nav-item">
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" content="#fujian" href="javascript: void(0);">附件</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+<script>
+    const readOnly = <%- ctx.costStage.readOnly %>;
+    const tenderId = parseInt('<%- ctx.tender.id %>');
+    const stageId = parseInt('<%- ctx.costStage.id %>');
+</script>

+ 78 - 0
app/view/cost/book_list.ejs

@@ -0,0 +1,78 @@
+<% include ./list_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./list_mini_menu.ejs %>
+            <h2>
+                审批列表
+            </h2>
+            <% if (ctx.permission.cost.book_add && (stages.length === 0 || stages[0].audit_status === auditConst.status.checked) && validLedgerStages.length > 0) { %>
+            <div class="ml-auto">
+                <a href="#add-qi" data-toggle="modal" data-target="#add-qi" class="btn btn-primary btn-sm">新建报审</a>
+            </div>
+            <% } %>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body">
+            <div class="sjs-height-0">
+                <table class="table table-bordered table-hover">
+                    <thead>
+                    <tr class="text-center">
+                        <th width="80px">期数</th>
+                        <th width="70px">报审月份</th>
+                        <th width="70px">创建人</th>
+                        <th>本期入账金额(不含税)</th>
+                        <th>截止本期入账金额(不含税)</th>
+                        <th width="180px">审批进度</th>
+                        <th width="100px">操作</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <% for (const s of stages) { %>
+                    <tr>
+                        <td>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book/<%- s.stage_order %>/stage" target="_blank">第 <%- s.stage_order %> 期</a>
+                        </td>
+                        <td class="text-center"><%- s.stage_date %></td>
+                        <td class="text-center"><%- s.user_name %></td>
+                        <td class="text-right"><%- s.stage_tp.in_excl_tax_tp %></td>
+                        <td class="text-right"><%- s.stage_end_tp.in_excl_tax_tp %></td>
+                        <td class="<%- auditConst.info[s.audit_status].class %>">
+                            <% if (s.audit_status === auditConst.status.checked && s.final_auditor_str) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.final_auditor_str %></a>
+                            <% } else { %>
+                            <% if (s.curAuditors.length > 0) { %>
+                            <% if (s.curAuditors[0].audit_type === auditType.key.common) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.curAuditors[0].name %><%if (s.curAuditors[0].role !== '' && s.curAuditors[0].role !== null) { %>-<%- s.curAuditors[0].role %><% } %></a>
+                            <% } else { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- ctx.helper.transFormToChinese(s.curAuditors[0].audit_order) + '审' %></a>
+                            <% } %>
+                            <% } %>
+                            <% } %>
+                            <%- auditConst.info[s.audit_status].title %>
+                        </td>
+                        <td class="text-center">
+                            <% if (s.audit_status === auditConst.status.uncheck && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if (s.audit_status === auditConst.status.checkNo && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if ((s.audit_status === auditConst.status.checking || s.audit_status === auditConst.status.checkNoPre) && s.curAuditors && s.curAuditors.findIndex(x => { return x.audit_id === ctx.session.sessionUser.accountId; }) >= 0) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else { %>
+                            <span class="<%- auditConst.info[s.audit_status].class %>"><%- auditConst.info[s.audit_status].title %></span>
+                            <% } %>
+                        </td>
+                    </tr>
+                    <% } %>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const stages = JSON.parse('<%- JSON.stringify(stages) %>');
+    const auditType = JSON.parse('<%- JSON.stringify(auditType) %>');
+    const auditConst = JSON.parse('<%- JSON.stringify(auditConst) %>');
+</script>

+ 72 - 0
app/view/cost/book_list_modal.ejs

@@ -0,0 +1,72 @@
+<div class="modal" id="add-qi" data-backdrop="static" aria-modal="true" role="dialog">
+    <div class="modal-dialog" role="document">
+        <form class="modal-content" action="addStage" method="POST" onsubmit="return checkAddValid();">
+            <div class="modal-header">
+                <h5 class="modal-title">新建报审</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group form-group-sm">
+                    <label>期数</label>
+                    <input class="form-control form-control-sm" value="第 <%- (stages.length + 1) %> 期" type="text" readonly="">
+                    <input type="hidden" value="<%- (stages.length + 1) %>" name="stage_order">
+                </div>
+                <div class="form-group form-group-sm">
+                    <label>关联成本</label>
+                    <select class="form-control form-control-sm" name="stage">
+                        <% for (const s of validLedgerStages) { %>
+                        <option value="<%- s.stage_order %>">第 <%- s.stage_order %> 期</option>
+                        <% } %>
+                    </select>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <input type="hidden" name="stage_type" value="<%- stage_type %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="submit" class="btn btn-sm btn-primary">确定</button>
+            </div>
+        </form>
+    </div>
+</div>
+<!--审批流程/结果-->
+<div class="modal fade" id="sp-list" 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="row">
+                    <div class="col-4 modal-height-500" style="overflow: auto">
+                        <div class="card mt-3">
+                            <ul class="list-group list-group-flush" id="auditor-list">
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto" id="audit-list">
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    $('.datepicker-here').datepicker({
+        autoClose: true,
+    });
+    const checkAddValid = function() {
+        if ($('[name=date]', '#add-qi').val() == '') {
+            toastr.error('请选择计量年月');
+            return false;
+        }
+    }
+    const checkEditValid = function() {
+        if ($('[name=date]', '#edit-qi').val() == '') {
+            toastr.error('请选择计量年月');
+            return false;
+        }
+    }
+</script>

+ 3 - 0
app/view/cost/book_menu_list.ejs

@@ -0,0 +1,3 @@
+<nav-menu title="返回" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book" tclass="text-primary" ml="1" icon="fa-chevron-left"></nav-menu>
+<nav-menu title="财务账面" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book/<%- ctx.costStage.stage_order %>/stage" ml="3" active="<%= (ctx.url.indexOf('ledger') >= 0 ? 1 : -1) %>"></nav-menu>
+<% include ./audit_btn.ejs %>

+ 4 - 0
app/view/cost/book_modal.ejs

@@ -0,0 +1,4 @@
+<% include ../shares/delete_hint_modal.ejs %>
+<% include ./audit_modal.ejs %>
+<% include ../shares/upload_att.ejs %>
+<% include ../shares/new_tag_modal.ejs %>

+ 91 - 0
app/view/cost/ledger.ejs

@@ -0,0 +1,91 @@
+<% include ./stage_memu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./stage_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">
+                    <div class="dropdown">
+                        <button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <i class="fa fa-list-ol"></i> 显示层级
+                        </button>
+                        <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                            <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                            <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                            <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                            <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                            <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                        </div>
+                    </div>
+                </div>
+                <% if (ctx.costStage.create_user_id === ctx.session.sessionUser.accountId) { %>
+                <div class="d-inline-block">
+                    <a href="javascript: void(0);" name="base-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="升级"><i class="fa fa-arrow-left" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="降级"><i class="fa fa-arrow-right" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="下移"><i class="fa fa-arrow-down" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                </div>
+                <% } %>
+            </div>
+            <div class="ml-auto">
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row pr-46">
+        <div class="c-header p-0 col-12">
+        </div>
+        <!--核心内容(两栏)-->
+        <div class="row w-100 sub-content">
+            <!--左栏-->
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div class="sjs-height-1" style="overflow: hidden" id="bills-spread">
+                </div>
+                <div class="bcontent-wrap">
+                    <div class="resize-y" id="detail-spr" r-Type="height" div1=".sjs-height-1" div2=".bcontent-wrap" r-parent="div2" title="调整大小"><!--调整上下高度条--></div>
+                    <div class="bc-bar mb-1">
+                        <ul class="nav nav-tabs">
+                            <li class="nav-item">
+                                <a class="nav-link active" href="javascript:void(0)">明细数据</a>
+                            </li>
+                        </ul>
+                    </div>
+                    <div class="sp-wrap" id="detail-spread">
+                    </div>
+                </div>
+            </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"><!--调整左右高度条--></div>
+                <div class="tab-content">
+                    <div id="fujian" class="tab-pane tab-select-show">
+                    </div>
+                    <div id="search" class="tab-pane tab-select-show">
+                    </div>
+                    <div id="tag" class="tab-pane tab-select-show">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li class="nav-item">
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" content="#fujian" href="javascript: void(0);">附件</a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" content="#tag" href="javascript: void(0);">书签</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+<script>
+    const readOnly = <%- ctx.costStage.readOnly %>;
+    const tenderId = parseInt('<%- ctx.tender.id %>');
+    const stageId = parseInt('<%- ctx.costStage.id %>');
+    const stageDate = '<%- ctx.costStage.stage_date %>';
+</script>

+ 92 - 0
app/view/cost/ledger_list.ejs

@@ -0,0 +1,92 @@
+<% include ./list_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./list_mini_menu.ejs %>
+            <h2>
+                审批列表
+            </h2>
+            <% if (ctx.permission.cost.ledger_add && (stages.length === 0 || stages[0].audit_status === auditConst.status.checked)) { %>
+            <div class="ml-auto">
+                <a href="#add-qi" data-toggle="modal" data-target="#add-qi" class="btn btn-primary btn-sm">新建报审</a>
+            </div>
+            <% } %>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body">
+            <div class="sjs-height-0">
+                <table class="table table-bordered table-hover">
+                    <thead>
+                    <tr class="text-center">
+                        <th width="80px" rowspan="2">期数</th>
+                        <th width="70px" rowspan="2">报审月份</th>
+                        <th colspan="4">本期金额</th>
+                        <th colspan="4">截止本期金额</th>
+                        <th width="120px" rowspan="2">审批进度</th>
+                        <th width="100px" rowspan="2">操作</th>
+                    </tr>
+                    <tr class="text-center">
+                        <th>付款</th>
+                        <th>扣款</th>
+                        <th>应付</th>
+                        <th>实付</th>
+                        <th>付款</th>
+                        <th>扣款</th>
+                        <th>应付</th>
+                        <th>实付</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <% for (const s of stages) { %>
+                    <tr>
+                        <td>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger/<%- s.stage_order %>/stage" target="_blank">第 <%- s.stage_order %> 期</a>
+                        </td>
+                        <td class="text-center"><%- s.stage_date %></td>
+                        <td class="text-right"><%- s.stage_tp.pay_tp %></td>
+                        <td class="text-right"><%- s.stage_tp.cut_tp %></td>
+                        <td class="text-right"><%- s.stage_tp.yf_tp %></td>
+                        <td class="text-right"><%- s.stage_tp.sf_tp %></td>
+                        <td class="text-right"><%- s.stage_end_tp.pay_tp %></td>
+                        <td class="text-right"><%- s.stage_end_tp.cut_tp %></td>
+                        <td class="text-right"><%- s.stage_end_tp.yf_tp %></td>
+                        <td class="text-right"><%- s.stage_end_tp.sf_tp %></td>
+                        <td class="<%- auditConst.info[s.audit_status].class %>">
+                            <% if (s.audit_status === auditConst.status.checked && s.final_auditor_str) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.final_auditor_str %></a>
+                            <% } else { %>
+                            <% if (s.curAuditors.length > 0) { %>
+                            <% if (s.curAuditors[0].audit_type === auditType.key.common) { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- s.curAuditors[0].name %><%if (s.curAuditors[0].role !== '' && s.curAuditors[0].role !== null) { %>-<%- s.curAuditors[0].role %><% } %></a>
+                            <% } else { %>
+                            <a href="#sp-list" data-toggle="modal" data-target="#sp-list" stage-order="<%- s.stage_order %>"><%- ctx.helper.transFormToChinese(s.curAuditors[0].audit_order) + '审' %></a>
+                            <% } %>
+                            <% } %>
+                            <% } %>
+                            <%- auditConst.info[s.audit_status].title %>
+                        </td>
+                        <td class="text-center">
+                            <% if (s.audit_status === auditConst.status.uncheck && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if (s.audit_status === auditConst.status.checkNo && s.create_user_id === ctx.session.sessionUser.accountId) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else if ((s.audit_status === auditConst.status.checking || s.audit_status === auditConst.status.checkNoPre) && s.curAuditors && s.curAuditors.findIndex(x => { return x.audit_id === ctx.session.sessionUser.accountId; }) >= 0) { %>
+                            <a href="/sp/<%- ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger/<%- s.stage_order %>/stage" target="_blank" class="btn <%- auditConst.info[s.audit_status].btnClass %> btn-sm"><%- auditConst.info[s.audit_status].btnTitle %></a>
+                            <% } else { %>
+                            <span class="<%- auditConst.info[s.audit_status].class %>"><%- auditConst.info[s.audit_status].title %></span>
+                            <% } %>
+                        </td>
+                    </tr>
+                    <% } %>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const stages = JSON.parse('<%- JSON.stringify(stages) %>');
+    const auditType = JSON.parse('<%- JSON.stringify(auditType) %>');
+    const auditConst = JSON.parse('<%- JSON.stringify(auditConst) %>');
+</script>

+ 68 - 0
app/view/cost/ledger_list_modal.ejs

@@ -0,0 +1,68 @@
+<div class="modal" id="add-qi" data-backdrop="static" aria-modal="true" role="dialog">
+    <div class="modal-dialog" role="document">
+        <form class="modal-content" action="addStage" method="POST" onsubmit="return checkAddValid();">
+            <div class="modal-header">
+                <h5 class="modal-title">新建报审</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group form-group-sm">
+                    <label>期数</label>
+                    <input class="form-control form-control-sm" value="第 <%- (stages.length + 1) %> 期" type="text" readonly="">
+                    <input type="hidden" value="<%- (stages.length + 1) %>" name="stage_order">
+                </div>
+                <div class="form-group form-group-sm">
+                    <label>报审年月</label>
+                    <input class="datepicker-here form-control form-control-sm" autocomplete="off" name="date" placeholder="点击选择年月" data-view="months" data-min-view="months" data-date-format="yyyy-MM" data-language="zh" type="text">
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <input type="hidden" name="stage_type" value="<%- stage_type %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="submit" class="btn btn-sm btn-primary">确定</button>
+            </div>
+        </form>
+    </div>
+</div>
+<!--审批流程/结果-->
+<div class="modal fade" id="sp-list" 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="row">
+                    <div class="col-4 modal-height-500" style="overflow: auto">
+                        <div class="card mt-3">
+                            <ul class="list-group list-group-flush" id="auditor-list">
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="col-8 modal-height-500" style="overflow: auto" id="audit-list">
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    $('.datepicker-here').datepicker({
+        autoClose: true,
+    });
+    const checkAddValid = function() {
+        if ($('[name=date]', '#add-qi').val() == '') {
+            toastr.error('请选择报审年月');
+            return false;
+        }
+    }
+    const checkEditValid = function() {
+        if ($('[name=date]', '#edit-qi').val() == '') {
+            toastr.error('请选择报审年月');
+            return false;
+        }
+    }
+</script>

+ 3 - 0
app/view/cost/ledger_menu_list.ejs

@@ -0,0 +1,3 @@
+<nav-menu title="返回" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger" tclass="text-primary" ml="1" icon="fa-chevron-left"></nav-menu>
+<nav-menu title="成本审批" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger/<%- ctx.costStage.stage_order %>/stage" ml="3" active="<%= (ctx.url.indexOf('ledger') >= 0 ? 1 : -1) %>"></nav-menu>
+<% include ./audit_btn.ejs %>

+ 54 - 0
app/view/cost/ledger_modal.ejs

@@ -0,0 +1,54 @@
+<% include ../shares/delete_hint_modal.ejs %>
+<% include ./audit_modal.ejs %>
+<% include ../shares/upload_att.ejs %>
+<% include ../shares/new_tag_modal.ejs %>
+<div class="modal" id="import-deal-type" data-backdrop="static" aria-modal="true" role="dialog">
+    <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 form-group-sm">
+                    <label>选择类型</label>
+                    <select class="form-control form-control-sm" name="contract_type">
+                        <% for (const ct of contractTypes) { %>
+                        <option value="<%- ct %>"><%- ct %></option>
+                        <% } %>
+                    </select>
+                </div>
+                <div class="form-group form-group-sm">
+                    <label>支付年月</label>
+                    <input class="datepicker-here form-control form-control-sm" readonly autocomplete="off" id="type-pay-date" placeholder="点击选择年月" data-view="months" data-range="true"  data-multiple-dates-separator=" ~ " data-min-view="months" data-date-format="yyyy-MM" data-language="zh" type="text">
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="import-deal-type-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+<div class="modal fade" id="import-deal-select" data-backdrop="static">
+    <div class="modal-dialog" role="document" style="max-width: 600px">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">导入合同数据(自选)</h5>
+            </div>
+            <div class="modal-body">
+                <div class="modal-height-400" id="contract-spread"></div>
+                <div class="form-group form-group-sm">
+                    <label>支付年月</label>
+                    <input class="datepicker-here form-control form-control-sm" readonly autocomplete="off" id="select-pay-date" placeholder="点击选择年月" data-view="months" data-range="true"  data-multiple-dates-separator=" ~ " data-min-view="months" data-date-format="yyyy-MM" data-language="zh" type="text">
+                </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="import-deal-select-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+<link href="/public/css/bootstrap/select2.min.css" rel="stylesheet" />
+<script src="/public/js/bootstrap/select2.min.js"></script>

+ 14 - 0
app/view/cost/list_menu.ejs

@@ -0,0 +1,14 @@
+<div class="panel-sidebar" id="sub-menu">
+    <div class="sidebar-title" data-toggle="tooltip" data-placement="right" data-original-title="<%- ctx.tender.data.name %>">
+        <%- (ctx.tender.data.name.length > 15 ? ctx.tender.data.name.substring(0,15) + '...' : ctx.tender.data.name) %>
+    </div>
+    <div class="scrollbar-auto">
+        <% include ./list_menu_list.ejs %>
+        <div class="side-fold"><a href="javascript: void(0)" data-toggle="tooltip" data-placement="top" data-original-title="折叠侧栏" id="to-mini-menu"><i class="fa fa-upload fa-rotate-270"></i></a></div>
+    </div>
+    <script>
+        new Vue({
+            el: '.scrollbar-auto',
+        });
+    </script>
+</div>

+ 9 - 0
app/view/cost/list_menu_list.ejs

@@ -0,0 +1,9 @@
+<nav-menu title="返回" url="/sp/<%= ctx.subProject.id %>/cost" tclass="text-primary" ml="1" icon="fa-chevron-left"></nav-menu>
+<nav-menu title="成本报审" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/ledger" ml="3" active="<%= (ctx.url.indexOf('ledger') >= 0 ? 1 : -1) %>"></nav-menu>
+<nav-menu title="财务账面" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/book" ml="3" active="<%= (ctx.url.indexOf('book') >= 0 ? 1 : -1) %>"></nav-menu>
+<nav-menu title="成本分析" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/analysis" ml="3" active="<%= (ctx.url.indexOf('analysis') >= 0 ? 1 : -1) %>"></nav-menu>
+<nav-menu title="汇总分析" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/gather" ml="3" active="<%= (ctx.url.indexOf('gather') >= 0 ? 1 : -1) %>"></nav-menu>
+<nav-menu title="输出报表" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/report" ml="3" active="<%= (ctx.url.indexOf('report') >= 0 ? 1 : -1) %>"></nav-menu>
+<% if (ctx.session.sessionUser.is_admin) { %>
+<nav-menu title="审批流程" url="/sp/<%= ctx.subProject.id %>/cost/tender/<%- ctx.tender.id %>/flow" ml="3" active="<%= (ctx.url.indexOf('flow') >= 0 ? 1 : -1) %>"></nav-menu>
+<% } %>

+ 16 - 0
app/view/cost/list_mini_menu.ejs

@@ -0,0 +1,16 @@
+<!--折起的菜单-->
+<div class="min-side" id="sub-mini-menu" style="display: none;">
+    <div id="sub-mini-hint" class="side-switch" data-container="body" data-toggle="popover" data-placement="bottom" data-content="这里打开收起的菜单栏"></div>
+    <div class="side-switch">
+        <i class="fa fa-bars"></i>
+    </div>
+    <div class="side-menu" id="mini-menu-list" style="display: none">
+        <% include ./list_menu_list.ejs %>
+        <div class="side-fold"><a href="javascript: void(0);" data-toggle="tooltip" data-placement="top" data-original-title="展开侧栏" id="to-menu"><i class="fa fa-upload fa-rotate-90"></i></a></div>
+    </div>
+</div>
+<script>
+    new Vue({
+        el: '.side-menu',
+    });
+</script>

+ 17 - 0
app/view/cost/stage_memu.ejs

@@ -0,0 +1,17 @@
+<div class="panel-sidebar" id="sub-menu">
+    <div class="sidebar-title" data-toggle="tooltip" data-placement="right" data-original-title="<%- ctx.tender.data.name %>">
+        <%- `第${ctx.costStage.stage_order}期` %> -
+        <%- (ctx.tender.data.name.length > 11 ? ctx.tender.data.name.substring(0,11) + '...' : ctx.tender.data.name) %>
+    </div>
+    <div class="scrollbar-auto">
+        <% if (ctx.costStage.stage_type === 'ledger') { %><% include ./ledger_menu_list.ejs %><% } %>
+        <% if (ctx.costStage.stage_type === 'book') { %><% include ./book_menu_list.ejs %><% } %>
+        <% if (ctx.costStage.stage_type === 'analysis') { %><% include ./analysis_menu_list.ejs %><% } %>
+        <div class="side-fold"><a href="javascript: void(0)" data-toggle="tooltip" data-placement="top" data-original-title="折叠侧栏" id="to-mini-menu"><i class="fa fa-upload fa-rotate-270"></i></a></div>
+    </div>
+    <script>
+        new Vue({
+            el: '.scrollbar-auto',
+        });
+    </script>
+</div>

+ 18 - 0
app/view/cost/stage_mini_menu.ejs

@@ -0,0 +1,18 @@
+<!--折起的菜单-->
+<div class="min-side" id="sub-mini-menu" style="display: none;">
+    <div id="sub-mini-hint" class="side-switch" data-container="body" data-toggle="popover" data-placement="bottom" data-content="这里打开收起的菜单栏"></div>
+    <div class="side-switch">
+        <i class="fa fa-bars"></i>
+    </div>
+    <div class="side-menu" id="mini-menu-list" style="display: none">
+        <% if (ctx.costStage.stage_type === 'ledger') { %><% include ./ledger_menu_list.ejs %><% } %>
+        <% if (ctx.costStage.stage_type === 'book') { %><% include ./book_menu_list.ejs %><% } %>
+        <% if (ctx.costStage.stage_type === 'analysis') { %><% include ./analysis_menu_list.ejs %><% } %>
+        <div class="side-fold"><a href="javascript: void(0);" data-toggle="tooltip" data-placement="top" data-original-title="展开侧栏" id="to-menu"><i class="fa fa-upload fa-rotate-90"></i></a></div>
+    </div>
+</div>
+<script>
+    new Vue({
+        el: '.side-menu',
+    });
+</script>

+ 27 - 0
app/view/cost/tender.ejs

@@ -0,0 +1,27 @@
+<div class="panel-content">
+    <div class="panel-title fluid">
+        <div class="title-main  d-flex">
+            <div class="d-inline-block" id="show-level"></div>
+            <div class="ml-auto">
+                <% if (ctx.session.sessionUser.is_admin) { %>
+                <a class="btn btn-sm btn-primary mr-2" href="#set-template" data-toggle="modal" data-target="#set-template">设置模板</a>
+                <% } %>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="sjs-height-0" style="background-color: #fff">
+            <div class="c-body">
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const tenders = JSON.parse(unescape('<%- escape(JSON.stringify(tenderList)) %>'));
+    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
+    const selfCategoryLevel = '<%- (selfCategoryLevel || '') %>';
+    const is_admin = <%- ctx.session.sessionUser.is_admin %>;
+
+    const pid = '<%- ctx.session.sessionProject.id %>';
+    const uphlname = 'user_<%- ctx.session.sessionUser.accountId %>_pro_<% ctx.session.sessionProject.id %>_category_hide_list';
+</script>

+ 54 - 0
app/view/cost/tender_modal.ejs

@@ -0,0 +1,54 @@
+<% include ../shares/tender_permission_modal.ejs %>
+<div class="modal" id="set-template" 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">
+                <form>
+                    <h6>成本台账</h6>
+                    <div class="form-group row">
+                        <label for="text" class="col-sm-2 col-form-label col-form-label-sm"><h6>数据模板</h6></label>
+                        <div class="col-sm-10">
+                            <select id="cost_ledger_template" class="form-control form-control-sm">
+                                <option value="">请选择成本台账的数据模板</option>
+                                <% for( const t of costLedgerTemplates) { %>
+                                <option value="<%- t.id %>" <% if (ctx.subProject.cost_ledger_template === t.id) { %>selected<% } %>><%- t.name %></option>
+                                <% } %>
+                            </select>
+                        </div>
+                    </div>
+                    <h6>成本分析</h6>
+                    <div class="form-group row">
+                        <label for="text" class="col-sm-2 col-form-label col-form-label-sm"><h6>数据模板</h6></label>
+                        <div class="col-sm-10">
+                            <select id="cost_analysis_template" class="form-control form-control-sm">
+                                <option value="">请选择成本分析的数据模板</option>
+                                <% for( const t of costAnalysisTemplates) { %>
+                                <option value="<%- t.id %>" <% if (ctx.subProject.cost_analysis_template === t.id) { %>selected<% } %>><%- t.name %></option>
+                                <% } %>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="form-group row">
+                        <label for="text" class="col-sm-2 col-form-label col-form-label-sm"><h6>计算模板</h6></label>
+                        <div class="col-sm-10">
+                            <select id="cost_calc_template" class="form-control form-control-sm">
+                                <option value="">请选择成本台账的计算模板</option>
+                                <% for( const t of costCalcTemplates) { %>
+                                <option value="<%- t.id %>" <% if (ctx.subProject.cost_calc_template === t.id) { %>selected<% } %>><%- t.name %></option>
+                                <% } %>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="alert alert-warning p-1 w-100"><i class="fa fa-warning mr-1"></i>修改模板不会影响已经创建的数据。</div>
+                </form>
+            </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="set-template-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 72 - 1
app/view/dashboard/index.ejs


+ 61 - 2
app/view/dashboard/workspace.ejs

@@ -145,6 +145,15 @@
                                             <% if (dashboardStatus.shenpi.phasePay !== 0) { %>
                                             <option value="phasePay">合同支付(<%- dashboardStatus.shenpi.phasePay %>)</option>
                                             <% } %>
+                                            <% if (dashboardStatus.shenpi.costStageLedger !== 0) { %>
+                                            <option value="costStageLedger">成本报审(<%- dashboardStatus.shenpi.costStageLedger %>)</option>
+                                            <% } %>
+                                            <% if (dashboardStatus.shenpi.costStageBook !== 0) { %>
+                                            <option value="costStageBook">财务账面(<%- dashboardStatus.shenpi.costStageBook %>)</option>
+                                            <% } %>
+                                            <% if (dashboardStatus.shenpi.costStageAnalysis !== 0) { %>
+                                            <option value="costStageAnalysis">成本分析(<%- dashboardStatus.shenpi.costStageAnalysis %>)</option>
+                                            <% } %>
                                         </select>
                                     </div>
                                 </div>
@@ -472,6 +481,39 @@
                                                             <td><%- (am.begin_time ? ctx.moment(am.begin_time).format('YYYY/MM/DD HH:mm') : '') %></td>
                                                             <td><a href="/tender/<%- am.tid %>/pay/<%- am.phase_order %>/detail" class="btn btn-sm btn-table <%- (am.audit_order === 0 ? 'btn-outline-warning text-warning' : 'btn-outline-primary') %>" role="button"><%- (am.audit_order === 0 ? '重新上报' : '审批')%></a></td>
                                                         </tr>
+                                                    <% } else if (db.shenpi_type === 'costStageLedger') { %>
+                                                        <% const am = db; %>
+                                                        <tr data-type="costStageLedger">
+                                                            <% if (am.start_audit === 2) { %><td class="text-center text-danger"><i class="fa fa-exclamation-triangle"></i></td><% } else if (am.start_audit === 1) { %><td class="text-center text-warning"><i class="fa fa-bell"></i></td><% } else { %><td></td><% } %>
+                                                            <td><span class="bg-new-coststage text-new-coststage badge text-width">成本报审</span></td>
+                                                            <td><a href="/sp/<%- am.spid %>/dashboard"><%- am.sp_name %></a></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/ledger"><%- am.name %></a> <a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/ledger/<%- am.stage_order %>/stage">第<%- am.stage_order %>期</a></td>
+                                                            <td>第<%- am.stage_order %>期</td>
+                                                            <td><%- (am.begin_time ? ctx.moment(am.begin_time).format('YYYY/MM/DD HH:mm') : '') %></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/ledger/<%- am.stage_order %>/stage" class="btn btn-sm btn-table <%- (am.audit_order === 0 ? 'btn-outline-warning text-warning' : 'btn-outline-primary') %>" role="button"><%- (am.audit_order === 0 ? '重新上报' : '审批')%></a></td>
+                                                        </tr>
+                                                    <% } else if (db.shenpi_type === 'costStageBook') { %>
+                                                        <% const am = db; %>
+                                                        <tr data-type="costStageBook">
+                                                            <% if (am.start_audit === 2) { %><td class="text-center text-danger"><i class="fa fa-exclamation-triangle"></i></td><% } else if (am.start_audit === 1) { %><td class="text-center text-warning"><i class="fa fa-bell"></i></td><% } else { %><td></td><% } %>
+                                                            <td><span class="bg-new-coststage text-new-coststage badge text-width">财务账面</span></td>
+                                                            <td><a href="/sp/<%- am.spid %>/dashboard"><%- am.sp_name %></a></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/book"><%- am.name %></a> <a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/book/<%- am.stage_order %>/stage">第<%- am.stage_order %>期</a></td>
+                                                            <td>第<%- am.stage_order %>期</td>
+                                                            <td><%- (am.begin_time ? ctx.moment(am.begin_time).format('YYYY/MM/DD HH:mm') : '') %></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/book/<%- am.stage_order %>/stage" class="btn btn-sm btn-table <%- (am.audit_order === 0 ? 'btn-outline-warning text-warning' : 'btn-outline-primary') %>" role="button"><%- (am.audit_order === 0 ? '重新上报' : '审批')%></a></td>
+                                                        </tr>
+                                                    <% } else if (db.shenpi_type === 'costStageAnalysis') { %>
+                                                        <% const am = db; %>
+                                                        <tr data-type="costStageAnalysis">
+                                                            <% if (am.start_audit === 2) { %><td class="text-center text-danger"><i class="fa fa-exclamation-triangle"></i></td><% } else if (am.start_audit === 1) { %><td class="text-center text-warning"><i class="fa fa-bell"></i></td><% } else { %><td></td><% } %>
+                                                            <td><span class="bg-new-coststage text-new-coststage badge text-width">成本分析</span></td>
+                                                            <td><a href="/sp/<%- am.spid %>/dashboard"><%- am.sp_name %></a></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/analysis"><%- am.name %></a> <a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/analysis/<%- am.stage_order %>/stage">第<%- am.stage_order %>期</a></td>
+                                                            <td>第<%- am.stage_order %>期</td>
+                                                            <td><%- (am.begin_time ? ctx.moment(am.begin_time).format('YYYY/MM/DD HH:mm') : '') %></td>
+                                                            <td><a href="/sp/<%- am.spid %>/cost/tender/<%- am.tid %>/analysis/<%- am.stage_order %>/stage" class="btn btn-sm btn-table <%- (am.audit_order === 0 ? 'btn-outline-warning text-warning' : 'btn-outline-primary') %>" role="button"><%- (am.audit_order === 0 ? '重新上报' : '审批')%></a></td>
+                                                        </tr>
                                                     <% } %>
                                                 <% } %>
                                                 </tbody>
@@ -644,6 +686,8 @@
     const acFinancial = JSON.parse(unescape('<%- escape(JSON.stringify(acFinancial)) %>'));
     const acInspection = JSON.parse(unescape('<%- escape(JSON.stringify(acInspection)) %>'));
     const acPhasePay = JSON.parse(unescape('<%- escape(JSON.stringify(acPhasePay)) %>'));
+    const acSafeStage = JSON.parse(unescape('<%- escape(JSON.stringify(acSafeStage)) %>'));
+    const acCostStage = JSON.parse(unescape('<%- escape(JSON.stringify(acCostStage)) %>'));
     const typeColMap = JSON.parse(unescape('<%- escape(JSON.stringify(typeColMap)) %>'));
     const userMsgPermission = false;
 
@@ -907,7 +951,23 @@
                                         break;
                                     case 'phasePay':
                                         html.push(`<li class="list-group-item">
-                                            <span class="${acPhasePay.auditStringClass[n.status]}">${acPhasePay.auditString[n.status]}</span> <a href="/tender/${n.id}/pay/${n.phase_order}/detail" target="_blank">${n.name}</a> 合同支付 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
+                                            <span class="${acPhasePay.auditStringClass[n.audit_status]}">${acPhasePay.auditString[n.audit_status]}</span> <a href="/tender/${n.id}/pay/${n.phase_order}/detail" target="_blank">${n.name}</a> 合同支付 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
+                                        break;
+                                    case 'safeStage':
+                                        html.push(`<li class="list-group-item">
+                                            <span class="${acSafeStage.auditStringClass[n.audit_status]}">${acSafeStage.auditString[n.audit_status]}</span> <a href="/sp/${n.spid}/safe/tender/${n.id}/stage/${n.stage_order}/bills" target="_blank">${n.name}</a> 安全计量 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
+                                        break;
+                                    case 'costStageLedger':
+                                        html.push(`<li class="list-group-item">
+                                            <span class="${acCostStage.auditStringClass[n.audit_status]}">${acCostStage.auditString[n.audit_status]}</span> <a href="/sp/${n.spid}/cost/tender/${n.id}/ledger/${n.stage_order}/stage" target="_blank">${n.name}</a> 成本报审 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
+                                        break;
+                                    case 'costStageBook':
+                                        html.push(`<li class="list-group-item">
+                                            <span class="${acCostStage.auditStringClass[n.audit_status]}">${acCostStage.auditString[n.audit_status]}</span> <a href="/sp/${n.spid}/cost/tender/${n.id}/book/${n.stage_order}/stage" target="_blank">${n.name}</a> 财务账面 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
+                                        break;
+                                    case 'costStageAnalyssi':
+                                        html.push(`<li class="list-group-item">
+                                            <span class="${acCostStage.auditStringClass[n.audit_status]}">${acCostStage.auditString[n.audit_status]}</span> <a href="/sp/${n.spid}/cost/tender/${n.id}/analysis/${n.stage_order}/stage" target="_blank">${n.name}</a> 成本分析 ${n.name}<span class="float-right">${moment(new Date(n.shenpi_time)).format('HH:mm:ss')}</span></li>`);
                                         break;
                                     default: continue;
                                 }
@@ -980,7 +1040,6 @@
                 const params = _param && _param[0] ? _param[0] : null;
                 if (params !== null) {
                     const tender = tenders.filter(t => t.stage_count > 0)[params.dataIndex];
-                    console.log(tender);
                     window.open(`/sp/${tender.spid}/datacollect`);
                 }
             }

+ 1 - 0
app/view/ledger/explode.ejs

@@ -433,6 +433,7 @@
     });
     const syncLedgerUrl = '<%- syncLedgerUrl %>';
     const nodeType = JSON.parse('<%- JSON.stringify(nodeType) %>');
+    const templateFolder = JSON.parse(unescape('<%- escape(JSON.stringify(templateFolder)) %>'));
     const posCalcTemplate = JSON.parse(unescape('<%- escape(JSON.stringify(posCalcTemplate)) %>'));
 </script>
 <% if ((tender.ledger_status !== auditConst.status.checked && ctx.session.sessionUser.is_admin) || ((tender.ledger_status === auditConst.status.uncheck || tender.ledger_status === auditConst.status.checkNo) && ctx.session.sessionUser.accountId === tender.user_id)) { %>

+ 18 - 0
app/view/ledger/explode_modal.ejs

@@ -119,6 +119,24 @@
         </div>
     </div>
 </div>
+<div class="modal fade" id="select-calc-template" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <div class="d-flex">
+                    <h5 class="modal-title">选择计算模板</h5>
+                </div>
+            </div>
+            <div class="modal-body">
+                <div id="sct-spread" class="modal-height-500"></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="select-calc-template-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
 <!--批量添加清单部位-->
 <div class="modal fade" id="batch" data-backdrop="static">
     <div class="modal-dialog modal-xl" role="document">

+ 13 - 13
app/view/phase_pay/index.ejs

@@ -18,19 +18,19 @@
             <div class="sjs-height-0">
                 <table class="table table-bordered table-hover">
                     <thead>
-                    <tr>
-                        <th class="text-center" width="80px">支付期数</th>
-                        <th class="text-center" width="70px">支付月份</th>
-                        <th class="text-center" width="70px">计量期</th>
-                        <th class="text-center" width="100px">本期付款</th>
-                        <th class="text-center" width="100px">本期扣款</th>
-                        <th class="text-center" width="100px">本期应付</th>
-                        <th class="text-center" width="100px">本期实付</th>
-                        <th class="text-center" width="100px">截止本期应付</th>
-                        <th class="text-center" width="100px">截止本期实付</th>
-                        <th class="text-center" width="200px">审批进度</th>
-                        <th class="text-center" width="90px">操作</th>
-                        <th class="text-center" width="200px">备注</th>
+                    <tr class="text-center">
+                        <th width="80px">支付期数</th>
+                        <th width="70px">支付月份</th>
+                        <th width="70px">计量期</th>
+                        <th width="100px">本期付款</th>
+                        <th width="100px">本期扣款</th>
+                        <th width="100px">本期应付</th>
+                        <th width="100px">本期实付</th>
+                        <th width="100px">截止本期应付</th>
+                        <th width="100px">截止本期实付</th>
+                        <th width="200px">审批进度</th>
+                        <th width="90px">操作</th>
+                        <th width="200px">备注</th>
                     </tr>
                     </thead>
                     <tbody>

+ 35 - 15
app/view/template/pos_calc.ejs

@@ -3,7 +3,17 @@
     <div class="panel-title">
         <div class="title-main row">
             <div class="col-3">
-                <button class="btn btn-sm btn-light text-primary" id="addTemplate"><i class="fa fa-plus" aria-hidden="true"></i> 新增计算模板</button>
+                <div class="d-inline-block">模板分类</div>
+                <% if (ctx.session.sessionUser.is_admin) { %>
+                <div class="d-inline-block">
+                    <a href="javascript: void(0);" name="base-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="升级"><i class="fa fa-arrow-left" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="降级"><i class="fa fa-arrow-right" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="下移"><i class="fa fa-arrow-down" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                </div>
+                <% } %>
             </div>
             <div class="col-9">
                 <div class="d-flex">
@@ -13,6 +23,8 @@
                     <div id="detail-ctrl" style="display: none;">
                         <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="reset"> 重置</a>
                         <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="save"> 保存</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="export"> 导出</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="import"> 导入</a>
                     </div>
                     <div id="detail-user-info" class="ml-auto"></div>
                 </div>
@@ -23,20 +35,26 @@
         <div class="c-body">
             <div class="row">
                 <div class="col-3">
-                    <div class="sjs-height-0">
-                        <dl class="list-group mt-2 ml-2" id="template-list">
-                            <% for (const template of templateList) { %>
-                            <dd class="list-group-item" templateId="<%- template.id %>">
-                                <div class="d-flex justify-content-between align-items-center table-file" templateId="<%- template.id %>">
-                                    <div><%- template.name %><%if (template.used_count > 0) { %><i class="ml-1 fa fa-lock text-danger"></i><% } %></div>
-                                    <div class="btn-group-table" style="display: none;">
-                                        <a href="javascript: void(0);" class="mr-1" data-toggle="tooltip" data-placement="bottom" data-original-title="编辑" name="renameTemplate"><i class="fa fa-pencil fa-fw"></i></a>
-                                        <a href="javascript: void(0);" class="mr-1" data-toggle="tooltip" data-placement="bottom" data-original-title="删除" name="delTemplate"><i class="fa fa-trash-o fa-fw text-danger"></i></a>
-                                    </div>
-                                </div>
-                            </dd>
-                            <% } %>
-                        </dl>
+                    <div id="folder-spread" class="sjs-height-1"></div>
+                    <div class="bcontent-wrap" id="main-bottom" style="height: 600px;">
+                        <div class="bc-bar mb-1 d-flex">
+                            <div class="d-inline-block">
+                                <ul class="nav nav-tabs">
+                                    <li class="nav-item">
+                                        <a class="nav-link active" href="javascript:void(0)">模板列表</a>
+                                    </li>
+                                    <li class="nav-item mt-1" id="template-ctrl">
+                                        <a href="javascript: void(0);" name="template-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                                        <a href="javascript: void(0);" name="template-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                                        <button class="btn btn-sm btn-light text-primary" href="#rela-no-folder" data-toggle="modal" data-target="#rela-no-folder"> 导入未分类模板</button>
+                                    </li>
+                                </ul>
+                            </div>
+                        </div>
+                        <div class="sp-wrap" style="display: flex; flex-wrap: wrap;">
+                            <div class="c-body" id="template-spread" style="width: 100%">
+                            </div>
+                        </div>
                     </div>
                 </div>
                 <div class="col-9" style="height: 100%">
@@ -48,6 +66,8 @@
     </div>
 </div>
 <script>
+    const readOnly = <%- (!ctx.session.sessionUser.is_admin) %>;
+    const folderList = JSON.parse('<%- JSON.stringify(folderList) %>');
     const templateList = JSON.parse('<%- JSON.stringify(templateList) %>');
     const validColInfo = JSON.parse('<%- JSON.stringify(validColInfo) %>');
     const specList = JSON.parse('<%- JSON.stringify(specList) %>');

+ 27 - 1
app/view/template/pos_calc_modal.ejs

@@ -1,2 +1,28 @@
 <% include ./preview_modal.ejs %>
-<% include ../shares/import_file_modal.ejs %>
+<% include ../shares/import_file_modal.ejs %>
+<% include ../shares/delete_hint_modal.ejs %>
+<div class="modal fade" id="rela-no-folder" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <div class="d-flex">
+                    <h5 class="modal-title">导入未分类模板</h5>
+                </div>
+            </div>
+            <div class="modal-body modal-height-500 scroll-y">
+                <table class="table table-bordered">
+                    <tr><th>选择</th><th>模板名称</th><th>创建人</th></tr>
+                    <tbody id="no-folder-list">
+                    </tbody>
+                </table>
+            </div>
+            <div class="ml-3 mr-3">
+                <div class="alert alert-info p-1 mt-2">仅可导入本人创建的模板</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="rela-no-folder-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 2 - 2
app/view/tender/shenpi.ejs

@@ -1,8 +1,8 @@
-<% include ./tender_sub_menu.ejs %>
+<%- include(subMenu.common) %>
 <div class="panel-content">
     <div class="panel-title">
         <div class="title-main d-flex">
-            <% include ./tender_sub_mini_menu.ejs %>
+            <%- include(subMenu.mini) %>
             <h2>审批流程设置</h2>
         </div>
     </div>

+ 2 - 0
app/view/tender/shenpi_modal.ejs

@@ -88,6 +88,7 @@
     </div>
 </div>
 <!--多人协同-->
+<% if (shenpi.sp_type.stage) { %>
 <div class="modal fade" id="cooperation" data-backdrop="static">
     <div class="modal-dialog modal-xl" role="document">
         <div class="modal-content">
@@ -166,6 +167,7 @@
         </div>
     </div>
 </div>
+<% } %>
 <div class="modal fade" id="union" data-backdrop="static">
     <div class="modal-dialog modal-xl" role="document">
         <div class="modal-content">

+ 8 - 0
config/menu.js

@@ -237,6 +237,14 @@ const menu = {
         children: null,
         controller: 'financial',
     },
+    cost: {
+        name: '成本管理',
+        icon: 'fa-calculator',
+        display: true,
+        caption: '成本管理',
+        children: null,
+        controller: 'cost',
+    },
     // sum: {
     //     name: '总分包',
     //     icon: 'fa-sitemap',

+ 121 - 1
config/web.js

@@ -143,6 +143,7 @@ const JsFiles = {
                 files: [
                     '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
                     '/public/js/decimal.min.js',
+                    '/public/js/component/menu.js',
                 ],
                 mergeFiles: [
                     '/public/js/sub_menu.js',
@@ -2504,7 +2505,9 @@ const JsFiles = {
                     '/public/js/zh_calc.js',
                     '/public/js/spreadjs_rela/spreadjs_zh.js',
                     '/public/js/shares/ct_preview.js',
+                    '/public/js/shares/sjs_setting.js',
                     '/public/js/pos_calc_tmpl.js',
+                    '/public/js/path_tree.js',
                 ],
                 mergeFile: 'pos_calc_tmpl',
             },
@@ -2522,7 +2525,124 @@ const JsFiles = {
                 ],
                 mergeFile: 'cost_tmpl',
             }
-        }
+        },
+        cost: {
+            tender: {
+                files: [
+                    '/public/js/moment/moment.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/PinYinOrder.bundle.js',
+                    '/public/js/shares/tender_list_order.js',
+                    '/public/js/shares/show_level.js',
+                    '/public/js/shares/tender_permission.js',
+                    '/public/js/tender_showhide.js',
+                    '/public/js/tender_list_base.js',
+                    '/public/js/cost_tender.js',
+                ],
+                mergeFile: 'cost_tender',
+            },
+            cost_stage: {
+                files: [
+                    '/public/js/datepicker/datepicker.min.js',
+                    '/public/js/datepicker/datepicker.zh.js',
+                ],
+                mergeFiles: [
+                    '/public/js/component/menu.js',
+                    '/public/js/sub_menu.js',
+                    '/public/js/cost_stage.js',
+                ],
+                mergeFile: 'cost_stage',
+            },
+            cost_stage_ledger: {
+                files: [
+                    '/public/js/js-xlsx/xlsx.full.min.js',
+                    '/public/js/js-xlsx/xlsx.utils.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                    '/public/js/decimal.min.js',
+                    '/public/js/math.min.js',
+                    '/public/js/axios/axios.min.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/file-saver/FileSaver.js',
+                    '/public/js/component/menu.js',
+                    '/public/js/datepicker/datepicker.min.js',
+                    '/public/js/datepicker/datepicker.zh.js',
+                    '/public/js/moment/moment.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/div_resizer.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/sjs_setting.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/zh_calc.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/shares/new_tag.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/shares/tools_att.js',
+                    '/public/js/shares/common_audit.js',
+                    '/public/js/cost_stage_ledger.js',
+                ],
+                mergeFile: 'cost_stage_ledger',
+            },
+            cost_stage_book: {
+                files: [
+                    '/public/js/js-xlsx/xlsx.full.min.js',
+                    '/public/js/js-xlsx/xlsx.utils.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                    '/public/js/decimal.min.js',
+                    '/public/js/math.min.js',
+                    '/public/js/axios/axios.min.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/file-saver/FileSaver.js',
+                    '/public/js/component/menu.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/div_resizer.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/sjs_setting.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/zh_calc.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/shares/new_tag.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/shares/tools_att.js',
+                    '/public/js/shares/common_audit.js',
+                    '/public/js/cost_stage_book.js',
+                ],
+                mergeFile: 'cost_stage_book',
+            },
+            cost_stage_analysis: {
+                files: [
+                    '/public/js/js-xlsx/xlsx.full.min.js',
+                    '/public/js/js-xlsx/xlsx.utils.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                    '/public/js/decimal.min.js',
+                    '/public/js/math.min.js',
+                    '/public/js/axios/axios.min.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/file-saver/FileSaver.js',
+                    '/public/js/component/menu.js',
+                    '/public/js/datepicker/datepicker.min.js',
+                    '/public/js/datepicker/datepicker.zh.js',
+                    '/public/js/moment/moment.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/div_resizer.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/sjs_setting.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/zh_calc.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/shares/new_tag.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/shares/tools_att.js',
+                    '/public/js/shares/common_audit.js',
+                    '/public/js/cost_stage_analysis.js',
+                ],
+                mergeFile: 'cost_stage_analysis',
+            },
+        },
     },
 };
 

+ 361 - 0
sql/update.sql

@@ -34,6 +34,367 @@ ADD COLUMN `audit_group_limit` tinyint(4) NOT NULL DEFAULT 0 COMMENT '组内是
 ADD COLUMN `audit_checkno_valid` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否允许审批退回' AFTER `audit_group_limit`,
 ADD COLUMN `audit_group_need` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '组内最少审批人数' AFTER `audit_checkno_valid`;
 
+ALTER TABLE `zh_tender_permission`
+ADD COLUMN `cost` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '成本管理权限(,分隔,具体见代码定义)' AFTER `schedule`;
+
+ALTER TABLE `zh_bills_template_list`
+ADD COLUMN `sub_type` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '二级分类' AFTER `remark`;
+
+ALTER TABLE `zh_sub_project`
+ADD COLUMN `cost_ledger_template` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '成本台账模板id' AFTER `common_json`,
+ADD COLUMN `cost_analysis_template` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '成本分析模板id' AFTER `cost_ledger_template`,
+ADD COLUMN `cost_calc_template` varchar(36) NOT NULL DEFAULT '' COMMENT '成本分析,计算模板id' AFTER `cost_analysis_template`;
+
+CREATE TABLE `zh_cost_stage`  (
+  `id` varchar(36) NOT NULL COMMENT 'uuid',
+  `tid` int(11) UNSIGNED NOT NULL COMMENT '标段id(zh_tender.id)',
+  `stage_type` varchar(20) NULL COMMENT '期类型(台账ledger/账面book/分析analysis)',
+  `stage_order` int(11) UNSIGNED NOT NULL COMMENT '期序号',
+  `rela_stage` json NULL COMMENT '关联期信息(json)--[{sid: varchar, sorder: int}]',
+  `create_user_id` int(11) NOT NULL COMMENT '创建人id',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) UNSIGNED NOT NULL COMMENT '最后修改人id',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
+  `stage_date` varchar(20) NOT NULL COMMENT '报审月份',
+  `decimal` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '{"tp": 6}' COMMENT '小数位数',
+  `audit_times` tinyint(4) NOT NULL COMMENT '审批轮次',
+  `audit_status` tinyint(4) NOT NULL COMMENT '审批状态',
+  `audit_max_sort` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '最大审批排序',
+  `audit_begin_time` timestamp NULL COMMENT '开始审批时间',
+  `audit_end_time` timestamp NULL COMMENT '审批结束时间',
+  `final_auditor_str` varchar(255) NOT NULL DEFAULT '' COMMENT '终审缓存信息',
+  `stage_tp` json NOT NULL COMMENT '金额',
+  `stage_pre_tp` json NOT NULL COMMENT '截止上期金额',
+  `calc_template` varchar(36) NOT NULL DEFAULT '' COMMENT '计算模板',
+  PRIMARY KEY (`id`),
+  INDEX `idx_tid_type`(`tid`, `stage_type`) USING BTREE
+);
+
+CREATE TABLE `zh_cost_stage_audit`  (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `tid` int(11) UNSIGNED NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '期id',
+  `stage_type` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '期类型',
+  `audit_id` int(11) UNSIGNED NOT NULL COMMENT '流程参与人id(含原报)',
+  `name` varchar(30) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '参与人-姓名',
+  `company` varchar(60) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '参与人-单位',
+  `role` varchar(30) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '参与人-角色',
+  `mobile` varchar(15) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '参与人-电话',
+  `audit_times` int(11) UNSIGNED NOT NULL DEFAULT 1 COMMENT '审批次数',
+  `audit_order` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '预定流程顺序',
+  `audit_type` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '流程类型',
+  `active_order` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '实际流程顺序',
+  `audit_status` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '流程状态',
+  `audit_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '流程结束时间',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+  `opinion` varchar(1000) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '意见',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;
+
+CREATE TABLE `zh_cost_stage_file`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `tid` int(11) UNSIGNED NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '期stage_id(zh_cost_stage.id)',
+  `stage_type` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '类型(ledger/book/analysis/...)',
+  `rela_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid(zh_cost_stage_ledger.id/...)',
+  `rela_sub_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid(zh_cost_stage_detail.id/...)',
+  `filename` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件名',
+  `fileext` varchar(10) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件后缀',
+  `filesize` int(11) NOT NULL COMMENT '文件大小',
+  `filepath` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件存储路径',
+  `user_id` int(11) UNSIGNED NOT NULL COMMENT '用户id(zh_project_account.id)',
+  `user_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '用户名(缓存)',
+  `user_company` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '公司(缓存)',
+  `user_role` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '角色(缓存)',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+  `is_deleted` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否删除',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;
+
+CREATE TABLE `zh_cost_stage_ledger` (
+  `id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid',
+  `cost_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid(不同期保持统一)',
+  `tender_id` int(11) unsigned NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '期id',
+  `tree_id` int(11) NOT NULL COMMENT '节点id',
+  `tree_pid` int(11) NOT NULL COMMENT '父节点id',
+  `tree_level` tinyint(4) NOT NULL COMMENT '层级',
+  `tree_order` mediumint(4) NOT NULL COMMENT '同级排序',
+  `tree_full_path` varchar(255) COLLATE utf8_unicode_ci NOT NULL COMMENT '层级定位辅助字段parent.full_path-tree_id',
+  `tree_is_leaf` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '是否叶子节点,界面显示辅助字段',
+  `code` varchar(50) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '编号',
+  `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',
+  `unit` varchar(255) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '单位',
+  `pre_pay_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '截止上期-付款',
+  `pre_cut_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '截止上期-扣款',
+  `pre_yf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '截止上期-应付',
+  `pre_sf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '截止上期-实付',
+  `pre_yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-应付不含税',
+  `pre_sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-实付不含税',
+  `tax` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '税率(%)',
+  `pay_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '付款',
+  `cut_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '扣款',
+  `yf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '应付',
+  `sf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '实付',
+  `yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '应付不含税',
+  `sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付不含税',
+  `postil` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '本期批注',
+  `memo` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
+  `add_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后编辑人',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',
+  `read_pay_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期付款-只读',
+  `read_cut_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期扣款-只读',
+  `read_yf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期应付-只读',
+  `read_sf_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期实付-只读',
+  `read_yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期应付不含税-只读',
+  `read_sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期实付不含税-只读',
+  `calc_his` json DEFAULT NULL COMMENT '本期历史',
+  `is_used` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否已使用',
+  `is_deal` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否为合同节点',
+  `had_detail` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '往期是否有明细数据',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
+
+CREATE TABLE `zh_cost_stage_detail`  (
+  `id` varchar(36) NOT NULL COMMENT 'uuid',
+  `tender_id` int(11) NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '期id',
+  `stage_order` int(11) NOT NULL COMMENT '期序号',
+  `ledger_id` varchar(36) NOT NULL COMMENT '所属台账id(zh_stage_ledger.id)',
+  `cost_id` varchar(36) CHARACTER SET utf16le NOT NULL COMMENT '所属台账cost_id(不同期一致zh_stage_ledger.cost_id)',
+  `source_cid` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '导入的合同id',
+  `d_order` int(11) NOT NULL COMMENT '排序',
+  `code` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '编号',
+  `name` varchar(255) CHARACTER SET utf16 NOT NULL DEFAULT '' COMMENT '名称',
+  `party_b` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '乙方',
+  `pre_pay_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-付款',
+  `pre_cut_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-扣款',
+  `pre_yf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-应付',
+  `pre_sf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-实付',
+  `pre_yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-应付不含税',
+  `pre_sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '截止上期-实付不含税',
+  `tax` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '税率(%)',
+  `pay_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '付款',
+  `cut_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '扣款',
+  `yf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '应付',
+  `sf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付',
+  `yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '应付不含税',
+  `sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付不含税',
+  `postil` varchar(1000) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '本期批注',
+  `memo` varchar(1000) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
+  `add_user_id` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+  `add_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
+  `update_user_id` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后编辑人',
+  `update_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '最后编辑时间',
+  `read_pay_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期付款-只读',
+  `read_cut_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期扣款-只读',
+  `read_yf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期应付-只读',
+  `read_sf_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期实付-只读',
+  `read_yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期应付不含税-只读',
+  `read_sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '本期实付不含税-只读',
+  `calc_his` json NULL COMMENT '本期历史',
+  `is_used` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否已使用',
+  `is_deal` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否为合同节点',
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `zh_cost_stage_tag`  (
+  `id` varchar(36) NOT NULL COMMENT 'uuid',
+  `tender_id` int(11) NOT NULL COMMENT '标段id(zh_tender.id)',
+  `stage_id` varchar(11) NOT NULL COMMENT '期id(zh_cost_stage.id)',
+  `stage_type` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '期类型(ledger/book/analysis/...)',
+  `rela_id` varchar(36) NOT NULL COMMENT '关联id(zh_cost_stage_ledger.id/...)',
+  `rela_sub_id` varchar(36) NOT NULL COMMENT '次关联id(zh_cost_stage_detail.id/...)',
+  `create_user_id` int(11) NOT NULL COMMENT '创建人id(zh_project_accnount.id)',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) NOT NULL COMMENT '最后修改人id(zh_project_account.id)',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
+  `share` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否共享给其他参与人',
+  `color` varchar(7) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '' COMMENT '书签颜色',
+  `comment` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL COMMENT '批注',
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `zh_cost_stage_book` (
+  `id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid(zh_cost_stage_ledger.id)',
+  `tender_id` int(11) unsigned NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '期id',
+  `ledger_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '关联台账id(zh_stage_ledger.id)',
+  `cost_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '关联台账cost_id(不同期一致zh_stage_ledger.cost_id)',
+  `pre_in_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '截止上期-入账金额',
+  `pre_in_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '截止上期-入账金额不含税',
+  `in_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '入账金额',
+  `in_excl_tax_tp` decimal(24, 8) NOT NULL COMMENT '入账金额不含税',
+  `postil` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '本期批注',
+  `memo` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
+  `add_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后编辑人',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',
+  `read_in_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期入账-只读',
+  `read_in_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '本期入账不含税-只读',
+  `calc_his` json DEFAULT NULL COMMENT '本期历史',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
+
+CREATE TABLE `zh_cost_stage_book_detail` (
+  `id` varchar(36) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid(zh_cost_stage_ledger.id)',
+  `tender_id` int(11) unsigned NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL COMMENT '期id',
+  `ledger_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '关联台账id(zh_stage_detail.ledger_id)',
+  `detail_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '关联明细id(zh_stage_detail.id)',
+  `in_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '入账金额',
+  `in_excl_tax_tp` decimal(24, 8) NOT NULL COMMENT '入账金额不含税',
+  `postil` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '本期批注',
+  `memo` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
+  `add_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后编辑人',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',
+  `read_in_tp` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '本期入账-只读',
+  `read_in_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '本期入账不含税-只读',
+  `calc_his` json DEFAULT NULL COMMENT '本期历史',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
+
+CREATE TABLE `zh_cost_stage_analysis`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid',
+  `cost_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT 'uuid(不同期保持统一)',
+  `tender_id` int(11) UNSIGNED NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '期id',
+  `tree_id` int(11) NOT NULL COMMENT '节点id',
+  `tree_pid` int(11) NOT NULL COMMENT '父节点id',
+  `tree_level` tinyint(4) NOT NULL COMMENT '层级',
+  `tree_order` mediumint(4) NOT NULL COMMENT '同级排序',
+  `tree_full_path` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '层级定位辅助字段parent.full_path-tree_id',
+  `tree_is_leaf` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否叶子节点,界面显示辅助字段',
+  `node_type` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '节点类型',
+  `code` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '编号',
+  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',
+  `unit` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '单位',
+  `tax` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '税率(%)',
+  `yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '应付金额-不含税',
+  `in_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '入账金额-不含税',
+  `sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付金额-不含税',
+  `sf_percent` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付比例',
+  `postil` varchar(1000) NOT NULL DEFAULT '' COMMENT '本期批注',
+  `str1` varchar(255) NOT NULL DEFAULT '' COMMENT '文本',
+  `str2` varchar(255) NOT NULL DEFAULT '' COMMENT '文本',
+  `str3` varchar(255) NOT NULL DEFAULT '' COMMENT '文本',
+  `str4` varchar(255) NOT NULL DEFAULT '' COMMENT '文本',
+  `num_a` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_b` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_c` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_d` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_e` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_f` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_g` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_h` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_i` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_j` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_k` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_l` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_m` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_n` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_o` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `num_p` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '数值',
+  `calc_read` json DEFAULT NULL COMMENT '本期计算-只读',
+  `calc_his` json DEFAULT NULL COMMENT '本期历史',
+  `add_user_id` int(11) NOT NULL COMMENT '新增人id',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '新增时间',
+  `update_user_id` int(11) NOT NULL COMMENT '最后更新人id',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+  `calc_type` int(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '计算类型(收入1,支出2,其他0)',
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `zh_cost_stage_analysis_detail`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `tender_id` int(11) NOT NULL COMMENT '标段id',
+  `stage_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '期id',
+  `ledger_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '所属台账id(zh_stage_ledger.id)',
+  `cost_id` varchar(36) CHARACTER SET utf16le COLLATE utf16le_general_ci NOT NULL COMMENT '所属台账cost_id(不同期一致zh_stage_ledger.cost_id)',
+  `source_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '导入的合同id/明细id',
+  `d_order` int(11) NOT NULL COMMENT '排序',
+  `code` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '编号',
+  `name` varchar(255) CHARACTER SET utf16 COLLATE utf16_general_ci NOT NULL DEFAULT '' COMMENT '名称',
+  `party_b` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '乙方',
+  `tax` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '税率(%)',
+  `yf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '应付金额-不含税',
+  `in_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '入账金额-不含税',
+  `sf_excl_tax_tp` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付金额-不含税',
+  `sf_percent` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '实付比例',
+  `postil` varchar(1000) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '本期批注',
+  `str1` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '文本',
+  `str2` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '文本',
+  `str3` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '文本',
+  `str4` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '文本',
+  `num_a` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_b` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_c` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_d` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_e` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_f` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_g` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_h` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_i` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_j` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_k` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_l` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_m` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_n` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_o` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `num_p` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值',
+  `calc_read` json NULL COMMENT '本期计算-只读',
+  `calc_his` json NULL COMMENT '本期计算-历史',
+  `add_user_id` int(11) NOT NULL COMMENT '新增人id',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '新增时间',
+  `update_user_id` int(11) NOT NULL COMMENT '最后更新人id',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+  `is_deal` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否为合同',
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `zh_calc_tmpl_folder`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `pid` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '项目id(zh_project.id)',
+  `type` varchar(20) NOT NULL COMMENT '模板类型(posCalc/...)',
+  `master_id` varchar(36) NOT NULL COMMENT 'pid-type',
+  `name` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',
+  `tree_id` int(11) NOT NULL COMMENT '节点id',
+  `tree_pid` int(11) NOT NULL COMMENT '父节点id',
+  `tree_level` tinyint(4) NOT NULL COMMENT '层级',
+  `tree_order` mediumint(4) NOT NULL COMMENT '同级排序',
+  `tree_full_path` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '层级定位辅助字段parent.full_path-tree_id',
+  `tree_is_leaf` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否叶子节点,界面显示辅助字段',
+  `add_user_id` int(11) NOT NULL COMMENT '新增人id',
+  `add_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '新增时间',
+  `update_user_id` int(11) NOT NULL COMMENT '最后更新人id',
+  `update_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '最后更新时间',
+  PRIMARY KEY (`id`)
+);
+
+ALTER TABLE `zh_pos_calc_detail`
+ADD COLUMN `num_j` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值1' AFTER `num_i`,
+ADD COLUMN `num_k` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值2' AFTER `num_j`,
+ADD COLUMN `num_l` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值3' AFTER `num_k`,
+ADD COLUMN `num_m` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值4' AFTER `num_l`,
+ADD COLUMN `num_n` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值5' AFTER `num_m`,
+ADD COLUMN `num_o` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值6' AFTER `num_n`,
+ADD COLUMN `num_p` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值7' AFTER `num_o`,
+ADD COLUMN `num_q` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值8' AFTER `num_p`,
+ADD COLUMN `num_r` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值9' AFTER `num_q`,
+ADD COLUMN `num_s` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值5' AFTER `num_r`,
+ADD COLUMN `num_t` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值6' AFTER `num_s`,
+ADD COLUMN `num_u` decimal(24, 8) NOT NULL DEFAULT 0.00000000 COMMENT '数值7' AFTER `num_t`;
+
+ALTER TABLE `zh_calc_tmpl`
+ADD COLUMN `folder_id` varchar(36) NOT NULL DEFAULT '' COMMENT '分类id(zh_calc_tmpl_folder.id)' AFTER `tid`;
+
 ------------------------------------
 -- 表数据
 ------------------------------------