소스 검색

设计量细目相关

MaiXinRong 4 달 전
부모
커밋
1424c4a982

+ 71 - 6
app/base/base_bills_service.js

@@ -21,10 +21,12 @@ const billsUtils = require('../lib/bills_utils');
 
 class BaseBillsSerivce extends TreeService {
 
-    constructor (ctx, setting, relaPosName, relaAncGclName) {
+    constructor (ctx, setting, relaPosName, relaAncGclName, relaExtraName, relaPosDetailName) {
         super(ctx, setting);
         this.relaPosService = relaPosName;
         this.relaAncGclService = relaAncGclName;
+        this.relaExtraService = relaExtraName;
+        this.relaPosDetailService = relaPosDetailName;
     }
 
     async getFinalData(mid, columns) {
@@ -37,7 +39,6 @@ class BaseBillsSerivce extends TreeService {
     set relaPosService(posName) {
         this._posName = posName;
     }
-
     get relaPosService() {
         return this.ctx.service[this._posName];
     }
@@ -45,11 +46,24 @@ class BaseBillsSerivce extends TreeService {
     set relaAncGclService(ancGclName) {
         this._ancGclName = ancGclName;
     }
-
     get relaAncGclService() {
         return this._ancGclName ? this.ctx.service[this._ancGclName] : undefined;
     }
 
+    set relaExtraService(extraName) {
+        this._extraName = extraName
+    }
+    get relaExtraService() {
+        return this._extraName ? this.ctx.service[this._extraName] : undefined;
+    }
+
+    set relaPosDetailService(posDetailName) {
+        this._posDetailName = posDetailName
+    }
+    get relaPosDetailService() {
+        return this._posDetailName ? this.ctx.service[this._posDetailName] : undefined;
+    }
+
     // 继承方法
     clearParentingData(data) {
         data.unit_price = 0;
@@ -648,9 +662,10 @@ class BaseBillsSerivce extends TreeService {
         if (!selectData) throw '粘贴数据错误';
         const newParentPath = selectData.full_path.replace(selectData.ledger_id, '');
 
-        const pasteBillsData = [], pastePosData = [], pasteAncGclData = [], leafBillsId = [];
+        const pasteBillsData = [], pastePosData = [], pasteAncGclData = [], pasteBillsExtraData = [], pastePosDetailData = [], leafBillsId = [];
         const tpDecimal = this.ctx.tender.info.decimal.tp;
         let maxId = await this._getMaxLid(this.ctx.tender.id);
+        const calcTemplate = await this.ctx.service.calcTmpl.getAllTemplateDetail(this.ctx.subProject.id, 'posCalc');
         for (const [i, pd] of pasteData.entries()) {
             for (const d of pd) {
                 d.children = pd.filter(function (x) {
@@ -688,6 +703,12 @@ class BaseBillsSerivce extends TreeService {
                     dgn_qty2: d.dgn_qty2,
                     features: d.features || '',
                 };
+                let template;
+                if (d.calc_template) {
+                    template = calcTemplate.find(x => { return x.id === d.calc_template });
+                    if (!template) template = calcTemplate.find(x => { return x.name === d.calc_template_str; });
+                    if (template) pasteBillsExtraData.push({ id: newBills.id, tid, calc_template: template.id });
+                }
                 for (const c of d.children) {
                     c.ledger_pid = newBills.ledger_id;
                 }
@@ -715,7 +736,42 @@ class BaseBillsSerivce extends TreeService {
                             ex_memo2: pos.ex_memo2,
                             ex_memo3: pos.ex_memo3,
                         };
-                        this._calcExpr(newPos, 'sgfh_qty', pos.sgfh_expr, pos.sgfh_qty, precision);
+                        if (template && pos.calcDetail) {
+                            let sumQty = 0;
+                            for (const cd of pos.calcDetail) {
+                                const newDetail = {
+                                    id: this.uuid.v4(), tid: tid, lid: newBills.id, pid: newPos.id,
+                                    create_user_id: this.ctx.session.sessionUser.accountId,
+                                    update_user_id: this.ctx.session.sessionUser.accountId,
+                                    pcd_order: cd.pcd_order,
+                                };
+                                if (template.id === d.calc_template) {
+                                    newDetail.str1 = cd.str1 || '';
+                                    newDetail.str2 = cd.str2 || '';
+                                    newDetail.str3 = cd.str3 || '';
+                                    newDetail.str4 = cd.str4 || '';
+                                    newDetail.num1 = cd.num1 || 0;
+                                    newDetail.num2 = cd.num2 || 0;
+                                    newDetail.num3 = cd.num3 || 0;
+                                    newDetail.num4 = cd.num4 || 0;
+                                    newDetail.num5 = cd.num5 || 0;
+                                    newDetail.num6 = cd.num6 || 0;
+                                    newDetail.num7 = cd.num7 || 0;
+                                    newDetail.num8 = cd.num8 || 0;
+                                    newDetail.num9 = cd.num9 || 0;
+                                    newDetail.qty = cd.qty || 0;
+                                    newDetail.expr = cd.expr || '';
+                                    newDetail.spec = cd.spec || '';
+                                } else {
+                                    this.ctx.service.posCalcDetail._loadDataAndCalc(newDetail, cd, {}, template);
+                                }
+                                sumQty = this.ctx.helper.add(sumQty, newDetail.qty);
+                                pastePosDetailData.push(newDetail);
+                            }
+                            this._calcExpr(newPos, 'sgfh_qty', '', sumQty, precision);
+                        } else {
+                            this._calcExpr(newPos, 'sgfh_qty', pos.sgfh_expr, pos.sgfh_qty, precision);
+                        }
                         this._calcExpr(newPos, 'sjcl_qty', pos.sjcl_expr, pos.sjcl_qty, precision);
                         this._calcExpr(newPos, 'qtcl_qty', pos.qtcl_expr, pos.qtcl_qty, precision);
                         newPos.quantity = this.ctx.helper.add(newPos.sgfh_qty,
@@ -784,6 +840,8 @@ class BaseBillsSerivce extends TreeService {
                 await this.transaction.insert(this.relaPosService.tableName, pastePosData);
             }
             if (pasteAncGclData.length > 0 && this.relaAncGclService) await this.transaction.insert(this.relaAncGclService.tableName, pasteAncGclData);
+            if (pasteBillsExtraData.length > 0 && this.relaExtraService) await this.transaction.insert(this.relaExtraService.tableName, pasteBillsExtraData);
+            if (pastePosDetailData.length > 0 && this.relaPosDetailService) await this.transaction.insert(this.relaPosDetailService.tableName, pastePosDetailData);
             await this.transaction.commit();
         } catch (err) {
             await this.transaction.rollback();
@@ -793,9 +851,16 @@ class BaseBillsSerivce extends TreeService {
         // 查询应返回的结果
         const updateData = await this.getNextsData(selectData.tender_id, selectData.ledger_pid, selectData.order + pasteData.length);
         const ancGcl = this.relaAncGclService ? { add: pasteAncGclData } : undefined;
+        const posCalcDetail = this.relaPosDetailService ? { add: pastePosDetailData } : undefined;
+        if (pasteBillsExtraData.length > 0) {
+            for (const be of pasteBillsExtraData) {
+                const b = pasteBillsData.find(x => { return x.id === be.id; });
+                b.calc_template = be.calc_template;
+            }
+        }
         return {
             ledger: { create: pasteBillsData, update: updateData },
-            pos: pastePosData, ancGcl,
+            pos: pastePosData, ancGcl, posCalcDetail,
         };
     }
 

+ 2 - 0
app/const/sp_page_show.js

@@ -25,6 +25,7 @@ const managerPageControl = [
 ];
 // 标段功能开关
 const tenderPageControl = [
+    { title: '设计量明细', name: 'posCalcDetail', value: pageStatus.show, type: 'checkbox' },
     { title: '部位台帐', name: 'bwtz', value: pageStatus.show, type: 'checkbox', tip: '「部位台帐」显示在「0号台帐」以及「各期计量」侧栏菜单中', tipClass: '' },
     { title: '其他台账', name: 'stageExtra', value: pageStatus.show, type: 'checkbox' },
     { title: '合同支付独立审批', name: 'phasePay', value: pageStatus.show, type: 'checkbox' },
@@ -116,6 +117,7 @@ const defaultSetting = {
     qualityInspection: 1,
     safePayment: 1,
     safeInspection: 1,
+    posCalcDetail: 0,
 };
 
 module.exports = {

+ 0 - 5
app/const/spread.js

@@ -141,7 +141,6 @@ const BaseSetCol = {
         { key: 'ex_memo3', name: '备注3', fixed: [], bills: 1, pos: 1, },
         { key: 'ex_calc1', name: '计算1', fixed: [], bills: 1, pos: 1, },
         { key: 'from', name: '来源', fixed: ['alias'], bills: 1, pos: 1, },
-        { key: 'calc_template', name: '计算模板', fixed: [], bills: 1, pos: 0 },
     ],
     tz_stage_set: [
         { key: 'code', name: '项目节编号', fixed: ['move', 'valid', 'alias'], bills: 1, pos: 0 },
@@ -254,7 +253,6 @@ const glSpreadTemplate = {
         { key: 'ex_memo3', valid: 0 },
         { key: 'ex_calc1', valid: 0 },
         { key: 'from', valid: 0 },
-        { key: 'calc_template', valid: 0 },
     ],
     tz_stage_set: [
         { key: 'code', valid: 1 },
@@ -366,7 +364,6 @@ const szSpreadTemplate = {
         { key: 'ex_memo3', valid: 0 },
         { key: 'ex_calc1', valid: 0 },
         { key: 'from', valid: 0 },
-        { key: 'calc_template', valid: 0},
     ],
     tz_stage_set: [
         { key: 'code', valid: 1 },
@@ -478,7 +475,6 @@ const fjSpreadTemplate = {
         { key: 'ex_memo3', valid: 0 },
         { key: 'ex_calc1', valid: 0 },
         { key: 'from', valid: 0 },
-        { key: 'calc_template', valid: 0},
     ],
     tz_stage_set: [
         { key: 'code', valid: 1 },
@@ -615,7 +611,6 @@ const BaseSpreadColSetting = {
             ],
             from: [{title: '来源', colSpan: '1', rowSpan: '2', field: 'from', hAlign: 1, width: 50, formatter: '@'}],
             is_new_price: [{ title: '新增单价', colSpan: '1', rowSpan: '2', field: 'is_new_price', hAlign: 1, width: 60, cellType: 'checkbox' }],
-            calc_template: [{ title: '计算模板', colSpan: '1', rowSpan: '2', field: 'calc_template', hAlign: 1, width: 100, cellType: 'customizeCombo' }],
         },
         pos: {
             name: [{title: '计量单元', colSpan: '1', rowSpan: '2', field: 'name', hAlign: 0, width: 230, formatter: '@'}],

+ 4 - 1
app/controller/ledger_controller.js

@@ -102,6 +102,9 @@ module.exports = app => {
                 const tender = ctx.tender;
                 const [ledgerSpread, posSpread] = await spreadSetting.getLedgerSpreadSetting(ctx, tender.id,
                     this._ledgerReadOnly(tender.data));
+                if (ctx.subProject.page_show.posCalcDetail) {
+                    ledgerSpread.cols.push({ title: '计算模板', colSpan: '1', rowSpan: '2', field: 'calc_template', hAlign: 1, width: 100, cellType: 'customizeCombo' });
+                }
 
                 await ctx.service.ledgerAudit.loadLedgerUser(tender.data);
                 await ctx.service.ledgerAudit.loadLedgerAuditViewData(tender.data);
@@ -126,7 +129,7 @@ module.exports = app => {
                 tender.data.hasRevise = !!revise;
                 const stage = await this.ctx.service.stage.getDataByCondition({ tid: tender.id });
                 tender.data.hasStage = !!stage;
-                const posCalcTemplate = await this.ctx.service.calcTmpl.getAllTemplateDetail(tender.id, 'posCalc');
+                const posCalcTemplate = await this.ctx.service.calcTmpl.getAllTemplateDetail(ctx.subProject.id, 'posCalc');
                 const renderData = {
                     tender: tender.data,
                     tenderInfo: tender.info,

+ 1 - 0
app/controller/sub_proj_setting_controller.js

@@ -406,6 +406,7 @@ module.exports = app => {
                 this.ctx.subProject.page_show.openStageAudit = data.openStageAudit ? 1 : 0;
                 this.ctx.subProject.page_show.stageAuditEarly = data.stageAuditEarly || 0;
                 this.ctx.subProject.page_show.stageAuditWorry = data.stageAuditWorry || 0;
+                this.ctx.subProject.page_show.posCalcDetail = data.posCalcDetail ? 1 : 0;
                 const result2 = await ctx.service.subProject.updatePageshow(ctx.subProject.id);
                 if (!result2) throw '保存数据失败';
                 if (data.addFunSet) {

+ 23 - 4
app/controller/template_controller.js

@@ -56,12 +56,15 @@ module.exports = app => {
 
         async posCalc(ctx) {
             try {
-                // if (!ctx.subProject.page_show.posCalc) throw '该功能已关闭';
+                if (!ctx.subProject.page_show.posCalcDetail) throw '该功能已关闭';
                 const renderData = {
                     validColInfo: ctx.service.calcTmpl.TemplateRela.posCalc.ValidColInfo,
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.template.posCalc),
                 };
-                renderData.templateList = await ctx.service.calcTmpl.getAllTemplate(ctx.tender.id, 'posCalc');
+                renderData.templateList = await ctx.service.calcTmpl.getAllTemplate(ctx.subProject.id, 'posCalc');
+                await ctx.service.calcTmpl.checkTemplateUsed(renderData.templateList);
+                const specList = await ctx.service.stdExtraList.getList(0);
+                renderData.specList = specList.map(x => { return { value: x.id, text: x.name }; });
                 await this.layout('template/pos_calc.ejs', renderData, 'template/preview_modal.ejs');
             } catch (err) {
                 ctx.log(err);
@@ -69,6 +72,22 @@ module.exports = app => {
                 ctx.redirect(ctx.request.header.referer);
             }
         }
+        async cost(ctx) {
+            try {
+                // if (!ctx.subProject.page_show.cost) throw '该功能已关闭';
+                const renderData = {
+                    validColInfo: ctx.service.calcTmpl.TemplateRela.cost.ValidColInfo,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.template.cost),
+                };
+                renderData.templateList = await ctx.service.calcTmpl.getAllTemplate(ctx.subProject.id, 'cost');
+                // await ctx.service.calcTmpl.checkTemplateUsed(renderData.templateList);
+                await this.layout('template/cost.ejs', renderData, 'template/cost_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '查看模板数据错误');
+                ctx.redirect(ctx.request.header.referer);
+            }
+        }
 
         //  ------------ 以下方法为所有模板共用 --------------
         async load(ctx) {
@@ -104,8 +123,8 @@ module.exports = app => {
         async preview(ctx) {
             try {
                 const data = JSON.parse(ctx.request.body.data);
-                const spreadSetting = this.ctx.service.calcTmpl.calcSpreadCache(data.col_set, data.type);
-                const testData = this.ctx.service.calcTmpl.getCalcTestData(data.col_set, data.type);
+                const spreadSetting = this.ctx.service.calcTmpl.calcSpreadCache(data.type, data.col_set, data.multi_header);
+                const testData = await this.ctx.service.calcTmpl.getCalcTestData(data.col_set, data.type, 3);
                 ctx.body = { err: 0, msg: '', data: { spreadSetting, testData} };
             } catch (error) {
                 ctx.log(error);

BIN
app/public/images/config.png


BIN
app/public/images/setting-two.png


BIN
app/public/images/setting.png


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

@@ -0,0 +1,515 @@
+$(document).ready(() => {
+    autoFlashHeight();
+    class MultiHeaderObj {
+        constructor () {
+            const self = this;
+            this.firstShowDone = false;
+            this.spread = SpreadJsObj.createNewSpread($('#multi-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+
+            const vStyle = new spreadNS.Style();
+            vStyle.font = '12px 微软雅黑';
+            vStyle.hAlign = 1;
+            vStyle.vAlign = 1;
+            vStyle.locked = false;
+            this.sheet.setDefaultStyle(vStyle);
+
+            this.spread.bind(spreadNS.Events.EditStarting, function(e, info) {
+                if (info.row === info.sheet.getRowCount() - 1) {
+                    info.cancel = true;
+                }
+            });
+            this.spread.bind(spreadNS.Events.ColumnWidthChanging, function(e, info) {
+                info.cancel = true;
+            });
+            this.spread.bind(spreadNS.Events.RowHeightChanged, function(e, info) {
+                self.multiHeader.headRowHeight[info.row] = info.sheet.getRowHeight(info.row);
+            });
+            $('#multi-header').on('shown.bs.modal', function() {
+                if (!self.firstShowDone) {
+                    self.spread.refresh();
+                    self.firstShowDone = true;
+                }
+            });
+            $('#header-row-count').change(function() {
+                let count = parseInt($('#header-row-count').val());
+                if (count > 4 || count < 1) {
+                    toastr.warning('仅支持1-4层表头');
+                    return;
+                }
+                self.setHeaderRows(count);
+            });
+            $.contextMenu({
+                selector: '#multi-spread',
+                build: function ($trigger, e) {
+                    const target = SpreadJsObj.safeRightClickSelection($trigger, e, self.spread);
+                    return (target.hitTestType === GC.Spread.Sheets.SheetArea.viewport || target.hitTestType === GC.Spread.Sheets.SheetArea.rowHeader);
+                },
+                items: {
+                    'merge': {
+                        name: '合并单元格',
+                        callback: function (key, opt) {
+                            const sel = self.sheet.getSelections()[0];
+                            self.sheet.addSpan(sel.row, sel.col, sel.rowCount, sel.colCount);
+                        },
+                        disabled: function(key, opt) {
+                            const sel = self.sheet.getSelections()[0];
+                            return sel.row + sel.rowCount >= self.sheet.getRowCount();
+                        }
+                    },
+                    'mergeCancel': {
+                        name: '取消合并',
+                        callback: function (key, opt) {
+                            const sel = self.sheet.getSelections()[0];
+                            console.log(sel);
+                            self.sheet.removeSpan(sel.row, sel.col);
+                        },
+                        disabled: function(key, opt) {
+                            const sel = self.sheet.getSelections()[0];
+                            return sel.row + sel.rowCount === self.sheet.getRowCount();
+                        }
+                    },
+                }
+            });
+            $('#multi-header-ok').click(function() {
+                if (self.afterSet) self.afterSet(self.getMultiHeader());
+                $('#multi-header').modal('hide');
+            });
+        }
+        setHeaderRows(count) {
+            if (count === this.multiHeader.headRows) return;
+            this.multiHeader = { headRows: count, headRowHeight: new Array(count).fill(32) };
+            this.reloadRowHeaderData();
+        }
+        reloadRowHeaderData() {
+            this.sheet.setRowCount(0);
+            this.sheet.setRowCount(this.multiHeader.headRows);
+            this.sheet.setColumnCount(this.colSet.length);
+            for (const [i, height] of this.multiHeader.headRowHeight.entries()) {
+                this.sheet.setRowHeight(i, height);
+            }
+            for (const [i, col] of this.colSet.entries()) {
+                this.sheet.getCell(this.multiHeader.headRows - 1, i).text(col.title).hAlign(1).vAlign(1);
+                this.sheet.setColumnWidth(i, col.width);
+            }
+            if (this.multiHeader.headSpan) {
+                for (const span of this.multiHeader.headSpan) {
+                    this.sheet.addSpan(span.row, span.col, span.rowCount, span.colCount);
+                    this.sheet.getCell(span.row, span.col).text(span.title);
+                }
+            }
+        }
+        show(colSet, multiHeader, fun) {
+            this.afterSet = fun;
+            this.colSet = colSet;
+            this.multiHeader = multiHeader || { headRows: 1, headRowHeight: [32] };
+            $('#header-row-count').val(this.multiHeader.headRows);
+            this.reloadRowHeaderData();
+            $('#multi-header').modal('show');
+        }
+        getMultiHeader() {
+            const result = JSON.parse(JSON.stringify(this.multiHeader));
+            result.headSpan = [];
+            const spans = this.sheet.getSpans();
+            for (const s of spans) {
+                result.headSpan.push({col: s.col, row: s.row, colCount: s.colCount, rowCount: s.rowCount, title: this.sheet.getText(s.row, s.col) });
+            }
+            return result;
+        }
+    }
+    const multiHeaderObj = new MultiHeaderObj();
+    class TemplateDetailObj {
+        constructor() {
+            const self = this;
+            this.firstShowDone = false;
+            this.spread = SpreadJsObj.createNewSpread($('#col-set-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            const getTypeValue = function(data) {
+                const typeInfo = validColInfo.find(x => { return x.key === data.type; });
+                return typeInfo.name || '';
+            };
+            this.spreadSetting = {
+                cols: [
+                    { title: '类型', colSpan: '1', rowSpan: '1', field: 'type', hAlign: 1, width: 80, formatter: '@', readOnly: true, getValue: getTypeValue },
+                    { title: '列名', colSpan: '1', rowSpan: '1', field: 'title', hAlign: 0, width: 130, formatter: '@' },
+                    { title: '列宽', colSpan: '1', rowSpan: '1', field: 'width', hAlign: 1, width: 70, type: 'Number' },
+                    // { title: '单位', colSpan: '1', rowSpan: '1', field: 'unit', hAlign: 1, width: 60, cellType: 'unit' },
+                    {
+                        title: '计算代号', colSpan: '1', rowSpan: '1', field: 'calc_code', hAlign: 1, width: 80, cellType: 'customizeCombo',
+                        comboItems: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'],
+                    },
+                    { title: '小数位数', colSpan: '1', rowSpan: '1', field: 'decimal', hAlign: 1, width: 60, type: 'Number' },
+                    { title: '计算公式', colSpan: '1', rowSpan: '1', field: 'expr', hAlign: 0, width: 250 },
+                ],
+                emptyRows: 0,
+                headRows: 1,
+                headRowHeight: [32],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                frozenLineColor: '#93b5e4',
+                getColor: function(sheet, data, row, col, defaultColor) {
+                    if (!data) return defaultColor;
+                    const typeInfo = validColInfo.find(x => { return x.key === data.type; });
+                    if (!typeInfo) return defaultColor;
+                    return col && typeInfo.valid.indexOf(col.field) >= 0 ? defaultColor : '#f2f2f2';
+                }
+            };
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
+            this.spread.bind(spreadNS.Events.EditStarting, function(e, info) {
+                if (self.readOnly) {
+                    info.cancel = true;
+                    return;
+                }
+
+                const col = info.sheet.zh_setting.cols[info.col];
+                const select = SpreadJsObj.getSelectObject(info.sheet);
+                const typeInfo = validColInfo.find(x => { return x.key === select.type; });
+                if (typeInfo) {
+                    info.cancel = col && typeInfo.valid.indexOf(col.field) >= 0 ? false : true;
+                } else {
+                    info.cancel = true;
+                }
+            });
+            this.spread.bind(spreadNS.Events.EditEnded, function(e, info){
+                if (!info.sheet.zh_setting) return;
+
+                const select = SpreadJsObj.getSelectObject(info.sheet);
+                const col = info.sheet.zh_setting.cols[info.col];
+
+                const validText = col.field === 'spec_set' ? info.editingText : (info.editingText ? info.editingText.replace('\n', '') : '');
+                if (col.field === 'spec_set') {
+                    select.spec_set = validText;
+                } else if (col.field === 'calc_code') {
+                    const exist = self.colSetData.find(x => { return x.type === 'num' && x.calc_code === validText && x.field !== select.field; });
+                    if (exist) {
+                        toastr.warning('请勿输入重复的计算代号');
+                    } else {
+                        select.calc_code = validText;
+                        select.field = 'num_' + select.calc_code.toLowerCase();
+                    }
+                } else if (col.field === 'width') {
+                    select.width = parseInt(validText);
+                } else if (col.field === 'decimal') {
+                    const num = parseInt(validText);
+                    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 === validText });
+                    if (!exist) {
+                        toastr.warning(`暂无计量单号为${validText}的列,请先配置该数值列`);
+                    } else {
+                        select.rela_col = validText;
+                    }
+                } else {
+                    select[col.field] = validText;
+                }
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+            });
+            SpreadJsObj.addDeleteBind(this.spread, function(sheet){
+                if (!sheet.zh_setting) return;
+
+                const sel = sheet.getSelections()[0];
+                if (!sel) return;
+                const col = sheet.zh_setting.cols[sel.col];
+                if (col.readOnly) return;
+                if (sel.colCount > 1) toastr.warning('请勿同时删除多列数据');
+
+                for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow ++) {
+                    const node = sheet.zh_data[iRow];
+                    if (!node) continue;
+                    if (col.type === 'Number') {
+                        node[col.field] = 0
+                    } else {
+                        node[col.field] = '';
+                    }
+                }
+                SpreadJsObj.reLoadRowData(sheet, sel.row, sel.rowCount);
+            });
+            // 右键菜单
+            $.contextMenu({
+                selector: '#col-set-spread',
+                build: function ($trigger, e) {
+                    const target = SpreadJsObj.safeRightClickSelection($trigger, e, self.spread);
+                    return (target.hitTestType === GC.Spread.Sheets.SheetArea.viewport || target.hitTestType === GC.Spread.Sheets.SheetArea.rowHeader) && !self.readOnly && self.template;
+                },
+                items: {
+                    'add_str': {
+                        name: '新增文本列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('str', select);
+                        },
+                    },
+                    'add_num': {
+                        name: '新增数值/计算列',
+                        icon: 'fa-plus',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('num', select);
+                        },
+                    },
+                    addSpr: '----',
+                    upMove: {
+                        name: '上移',
+                        icon: 'fa-arrow-up',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.move('upMove', select);
+                        },
+                    },
+                    downMove: {
+                        name: '下移',
+                        icon: 'fa-arrow-down',
+                        callback: function (key, opt) {
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.move('downMove', select);
+                        },
+                    },
+                    moveSpr: '----',
+                    multiHeader: {
+                        name: '多行表头设置',
+                        callback: function(key, opt) {
+                            multiHeaderObj.show(self.getCurrentColSet(), self.multi_header, function(multiHeader) {
+                                self.multi_header = multiHeader;
+                            });
+                        }
+                    }
+                }
+            });
+
+            $('#reset').click(function() {
+                self.reset();
+            });
+            $('#save').click(function() {
+                self.save();
+            });
+        }
+        addCol(type, select) {
+            const colInfo = validColInfo.find(x => { return x.key === type; });
+            if (!colInfo) {
+                toastr.error('未知类型');
+                return;
+            }
+            const existCount = this.colSetData.filter(x => { return x.type === type; }).length;
+            if (existCount >= colInfo.count) {
+                toastr.error(`${colInfo.name}列仅支持${colInfo.count}个`);
+                return;
+            }
+            const nData = JSON.parse(JSON.stringify(colInfo.def));
+            for (const f of colInfo.fields) {
+                if (!this.colSetData.find(x => { return x.field === f; })) {
+                    nData.field = f;
+                    break;
+                }
+            }
+            if (select) {
+                const index = this.colSetData.findIndex(x => { return x.field === select.field; });
+                if (index < 0) {
+                    toastr.error('选择的列配置不存在');
+                    return;
+                }
+                this.colSetData.splice(index, 0, nData);
+            } else {
+                this.colSetData.push(nData);
+            }
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, this.colSetData);
+        }
+        move(type, select) {
+            const index = this.colSetData.findIndex(x => { return x.field === select.field; });
+            if (type === 'upMove' && index === 0) {
+                toastr.error('不可上移');
+                return;
+            }
+            if (type === 'downMove' && index === this.colSetData.length - 1) {
+                toastr.error('不可下移');
+                return;
+            }
+            this.colSetData.splice(index, 1);
+            this.colSetData.splice(type === 'upMove' ? index - 1 : index + 1, 0, select);
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, this.colSetData);
+        }
+        reset() {
+            this.colSetData = JSON.parse(JSON.stringify(this.template.col_set));
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Data, this.colSetData);
+            this.multi_header = this.template.multi_header ? JSON.parse(JSON.stringify(this.template.multi_header)) : null;
+        }
+        loadDetail(template) {
+            this.template = template;
+            this.readOnly = this.template.used_count > 0;
+            if (!this.firstShowDone) {
+                this.spread.refresh();
+                this.firstShowDone = true;
+            }
+            if (this.readOnly) {
+                $('#detail-ctrl').hide();
+            } else {
+                $('#detail-ctrl').show();
+            }
+            this.reset();
+        }
+        save(){
+            const self = this;
+            const update = { id: this.template.id, col_set: this.getCurrentColSet(), multi_header: this.multi_header || this.template.multi_header };
+            if (!update.col_set) return;
+
+            postData('save', { update }, function(result) {
+                self.template.col_set = result.update.col_set;
+            });
+        }
+        getCurrentColSet() {
+            for (const col of this.colSetData) {
+                if (col.type === 'num') {
+                    if (!col.calc_code) {
+                        toastr.error(`【${col.title}】未定义计算代号`);
+                        return null;
+                    }
+                }
+            }
+            return this.colSetData;
+        }
+    }
+    const detailObj = new TemplateDetailObj();
+
+    let previewSpreadSetting, previewData, previewSpread;
+    $('#preview-calc-grid').on('shown.bs.modal', function () {
+        if (!previewSpread) {
+            previewSpread = SpreadJsObj.createNewSpread($('#preview-spread')[0]);
+        }
+        const previewSheet = previewSpread.getActiveSheet();
+        SpreadJsObj.initSheet(previewSheet, previewSpreadSetting);
+    });
+    $('#preview').click(function() {
+        const data = { type: 'cost', col_set: detailObj.getCurrentColSet(), multi_header: detailObj.multi_header || detailObj.template.multi_header };
+        if (!data.col_set) return;
+
+        postData('preview', data, function(result) {
+            result.spreadSetting.readOnly = true;
+            previewSpreadSetting = result.spreadSetting;
+            previewData = result.testData;
+            $('#preview-calc-grid').modal('show');
+        })
+    });
+
+    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: 'cost' });
+            if (result && result.detail) {
+                template.col_set = result.detail.col_set;
+                template.multi_header = result.detail.multi_header;
+            }
+        };
+        const refreshTemplate = async function() {
+            if (!curTemplate) {
+                // todo 隐藏模板详细界面
+            } else {
+                $('dd[templateId]').removeClass('bg-warning');
+                $(`dd[templateId=${curTemplate.id}]`).addClass('bg-warning');
+                if (!curTemplate.col_set) await loadTemplateDetail(curTemplate);
+                detailObj.loadDetail(curTemplate);
+            }
+        };
+        const setCurTemplate = function(template) {
+            curTemplate = template;
+            refreshTemplate();
+        };
+        const getCurTemplate = function() {
+            return curTemplate;
+        };
+        const getTemplateCaptionHtml = function(template) {
+            const usedHtml = template.used_count > 0 ? '<i class="ml-1 fa fa-lock text-danger"></i>' : '';
+            return `<div class="d-flex justify-content-between align-items-center table-file" templateId="${template.id}"><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></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: 'cost'}, function(result) {
+                templates.push(result.add);
+                $('#template-list').append(getTemplateHtml(result.add));
+            });
+        };
+        const renameTemplate = function(id, name) {
+            postData('save', { update: { id, name }, type: 'cost'}, 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: 'cost'}, function(result) {
+                $(`dd[templateId=${result.del}]`).remove();
+                const tIndex = templates.findIndex(x => { return x.id === id; });
+                templates.splice(tIndex, 1);
+                if (curTemplate.id === id) {
+                    curTemplate = null;
+                    refreshTemplate();
+                }
+            });
+        };
+        if (templates.length > 0) setCurTemplate(templates[0]);
+        return { setCurTemplate, getCurTemplate, addTemplate, delTemplate, renameTemplate, getTemplateCaptionHtml }
+    })(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;
+        const templateId = this.getAttribute('templateId');
+        const template = templateList.find(x => { return x.id === templateId; });
+        templateObj.setCurTemplate(template);
+    });
+    $('body').on('mouseenter', ".table-file", function(){
+        $(this).children(".btn-group-table").css("display","block");
+    });
+    $('body').on('mouseleave', ".table-file", function(){
+        $(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.getTemplateCaptionHtml(template));
+    });
+
+    $('#addTemplate').click(function() {
+        templateObj.addTemplate();
+    });
+});

+ 53 - 13
app/public/js/ledger.js

@@ -157,20 +157,31 @@ $(document).ready(function() {
         };
         const loadCurDetailData = function() {
             const node = treeOperationObj.getSelectNode(ledgerSheet);
-            template = posCalcTemplate.find(x => { return x.id === node.calc_template });
-            SpreadJsObj.initSheet(sheet, template ? template.spread_cache : emptySetting);
+            template = node ? posCalcTemplate.find(x => { return x.id === node.calc_template }) : null;
+            if (template) {
+                const specCol = template.spread_cache.cols.find(x => { return x.field === 'spec'; });
+                if (specCol) {
+                    specCol.comboItems = template.specValue.map(x => { return x.spec; });
+                    specCol.cellType = 'customizeCombo';
+                    specCol.cellTypeKey = 'specSelect';
+                }
+                SpreadJsObj.initSheet(sheet, template.spread_cache);
+            } else {
+                SpreadJsObj.initSheet(sheet, emptySetting);
+            }
             reloadCurDetailData();
         };
         const ctrlObj = {
             afterPostData: function(result) {
                 detail.updateDatas(result.detail);
                 reloadCurDetailData();
-                const updateRst = pos.updateDatas(result.pos);
-                billsTag.refreshPosTagView(updateRst.update);
-                const loadResult = ledgerTree.loadPostData({ update: result.bills });
-                treeOperationObj.refreshTree(ledgerSpread.getActiveSheet(), loadResult);
-                posOperationObj.loadCurPosData();
-                treeOperationObj.refreshOperationValid(ledgerSpread.getActiveSheet());
+                if (result.pos) {
+                    pos.updateDatas(result.pos);
+                    const loadResult = ledgerTree.loadPostData({ update: result.bills });
+                    treeOperationObj.refreshTree(ledgerSpread.getActiveSheet(), loadResult);
+                    posOperationObj.loadCurPosData(false);
+                    treeOperationObj.refreshOperationValid(ledgerSpread.getActiveSheet());
+                }
             },
             baseOpr: function (type) {
                 const data = {};
@@ -443,7 +454,7 @@ $(document).ready(function() {
             }
         });
 
-        return { detail, spread, sheet, refresh, loadCurDetailData }
+        return { detail, spread, sheet, refresh, loadCurDetailData, reloadCurDetailData }
     })();
 
     const billsTag = $.billsTag({
@@ -972,6 +983,16 @@ $(document).ready(function() {
             postData(window.location.pathname + '/update', updateData, function (result) {
                 const refreshNode = ledgerTree.loadPostData(result);
                 treeOperationObj.refreshTree(info.sheet, refreshNode);
+                if (result.pos) {
+                    pos.updateDatas(result.pos);
+                    posOperationObj.loadCurPosData(false);
+                }
+                if (result.detail) {
+                    posCalcDetail.detail.updateDatas({ update: result.detail });
+                    posCalcDetail.reloadCurDetailData();
+                }
+            }, function() {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
             });
         },
         buttonClicked: function(e, info) {
@@ -992,6 +1013,8 @@ $(document).ready(function() {
             postData(window.location.pathname + '/update', {postType: 'update', postData: data}, function (result) {
                 const refreshNode = ledgerTree.loadPostData(result);
                 treeOperationObj.refreshTree(info.sheet, refreshNode);
+            }, function() {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
             });
         },
         /**
@@ -1276,12 +1299,14 @@ $(document).ready(function() {
             }, function (data) {
                 pos.updateDatas(data.pos);
                 if (data.ancGcl) ancGcl.updateDatas(data.ancGcl);
+                if (data.posCalcDetail) posCalcDetail.detail.updateDatas(data.posCalcDetail);
                 const result = tree.loadPostData(data.ledger);
                 self.refreshTree(sheet, result);
                 sheet.setSelection(result.create[0].index, sel.col, sel.rowCount, sel.colCount);
                 SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, result.create[0].index]);
                 self.refreshOperationValid(sheet);
                 removeLocalCache(copyBlockTag);
+                posOperationObj.loadCurPosData();
             }, null, true);
         },
         loadRelaData: function () {
@@ -1511,7 +1536,9 @@ $(document).ready(function() {
     //     {title: 'node_type', colSpan: '1', rowSpan: '2', field: 'node_type', hAlign: 2, width: 60, type: 'Number', readOnly: true}
     // );
     sjsSettingObj.setNodeTypeCol(ledgerSpreadSetting.cols, [{ field: 'node_type' }]);
-    sjsSettingObj.setCalcTemplateCol(ledgerSpreadSetting.cols, [{ field: 'calc_template' }]);
+    sjsSettingObj.setCalcTemplateCol(ledgerSpreadSetting.cols, [{ field: 'calc_template' }],
+        (typeof posCalcTemplate === 'undefined') ? [] : posCalcTemplate.map(x => { return { value: x.id, text: x.name }; }),
+        `/sp/${spid}/template/posCalc`);
     sjsSettingObj.setFromCol(posSpreadSetting.cols, [{field: 'from'}]);
     sjsSettingObj.setFromCol(ledgerSpreadSetting.cols, [{ field: 'from'}]);
     SpreadJsObj.initSheet(ledgerSpread.getActiveSheet(), ledgerSpreadSetting);
@@ -1886,6 +1913,14 @@ $(document).ready(function() {
                 for (const b of cbl) {
                     const posRange = pos.getLedgerPos(b.id);
                     if (posRange && posRange.length > 0) b.pos = posRange;
+                    if (b.calc_template) {
+                        const template = posCalcTemplate.find(x => { return x.id === b.calc_template; });
+                        b.calc_template_str = template ? template.name : '';
+                        for (const p of b.pos) {
+                            const detailRange = posCalcDetail.detail.getPartData(p.id);
+                            p.calcDetail = detailRange;
+                        }
+                    }
                     const gclRange = ancGcl.getPartData(b.id);
                     if (gclRange && gclRange.length > 0) b.ancGcl = gclRange;
                 }
@@ -2287,6 +2322,9 @@ $(document).ready(function() {
                     info.sheet.getCell(info.row, info.col).text(node[exprInfo.expr]);
                 }
             }
+            if (col.field === 'sgfh_qty' || col.field === 'sgfh_expr') {
+                info.cancel = posOperationObj.ledgerTreeNode.calc_template;
+            }
         },
         loadExprToInput: function () {
             const sheet = posSpread.getActiveSheet();
@@ -2301,8 +2339,10 @@ $(document).ready(function() {
                     const value = exprInfo
                         ? (data[exprInfo.expr] ? data[exprInfo.expr] : data[col.field])
                         : data[col.field];
+                    const billsNode = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
+                    const detailReadOnly = (col.field === 'sgfh_qty' || col.field === 'sgfh_expr') && !!billsNode.calc_template;
                     $('#pos-expr').val(value).attr('field', col.field).attr('org', data[col.field])
-                        .attr('readOnly', readOnly || cell.locked()).attr('data-row', sel.row);
+                        .attr('readOnly', readOnly || cell.locked() || detailReadOnly).attr('data-row', sel.row);
                 } else {
                     $('#pos-expr').val('').attr('readOnly', true);
                     $('#pos-expr').removeAttr('data-row');
@@ -2315,7 +2355,7 @@ $(document).ready(function() {
         /**
          * 加载计量单元 根据当前台账选择节点
          */
-        loadCurPosData: function () {
+        loadCurPosData: function (refreshDetail = true) {
             //spreadJsObj.reinitSheet(posSpread.getActiveSheet());
             const node = treeOperationObj.getSelectNode(ledgerSpread.getActiveSheet());
             if (node) {
@@ -2327,7 +2367,7 @@ $(document).ready(function() {
             }
             posOperationObj.loadExprToInput();
             posOperationObj.refreshOperationValid(posSpread.getActiveSheet());
-            posCalcDetail.loadCurDetailData();
+            if (refreshDetail) posCalcDetail.loadCurDetailData();
         },
         baseOpr: function (sheet, type) {
             const data = {

+ 47 - 19
app/public/js/pos_calc_tmpl.js

@@ -18,6 +18,7 @@ $(document).ready(() => {
                     { title: '单位', colSpan: '1', rowSpan: '1', field: 'unit', hAlign: 1, width: 60, cellType: 'unit' },
                     { title: '计算代号', colSpan: '1', rowSpan: '1', field: 'calc_code', hAlign: 1, width: 80, cellType: 'customizeCombo', comboItems: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J'], },
                     { title: '小数位数', colSpan: '1', rowSpan: '1', field: 'decimal', hAlign: 1, width: 60, type: 'Number' },
+                    { title: '规格类型', colSpan: '1', rowSpan: '1', field: 'spec_set', hAlign: 1, width: 80, cellType: 'customizeCombo', cellTypeKey: 'spec', comboItems: specList, },
                     { title: '关联列代号', colSpan: '1', rowSpan: '1', field: 'rela_col', hAlign: 1, width: 80, cellType: 'customizeCombo', comboItems: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J'], },
                     { title: '计算公式', colSpan: '1', rowSpan: '1', field: 'expr', hAlign: 0, width: 250 },
                 ],
@@ -36,7 +37,12 @@ $(document).ready(() => {
                 }
             };
             SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
-            this.spread.bind(spreadNS.Events.EditStarting, function(e, info){
+            this.spread.bind(spreadNS.Events.EditStarting, function(e, info) {
+                if (self.readOnly) {
+                    info.cancel = true;
+                    return;
+                }
+
                 const col = info.sheet.zh_setting.cols[info.col];
                 const select = SpreadJsObj.getSelectObject(info.sheet);
                 const typeInfo = validColInfo.find(x => { return x.key === select.type; });
@@ -52,8 +58,10 @@ $(document).ready(() => {
                 const select = SpreadJsObj.getSelectObject(info.sheet);
                 const col = info.sheet.zh_setting.cols[info.col];
 
-                const validText = info.editingText ? info.editingText.replace('\n', '') : '';
-                if (col.field === 'calc_code') {
+                const validText = col.field === 'spec_set' ? info.editingText : (info.editingText ? info.editingText.replace('\n', '') : '');
+                if (col.field === 'spec_set') {
+                    select.spec_set = validText;
+                } else if (col.field === 'calc_code') {
                     const exist = self.colSetData.find(x => { return x.type === 'num' && x.calc_code === validText && x.field !== select.field; });
                     if (exist) {
                         toastr.warning('请勿输入重复的计算代号');
@@ -81,12 +89,32 @@ $(document).ready(() => {
                 }
                 SpreadJsObj.reLoadRowData(info.sheet, info.row);
             });
+            SpreadJsObj.addDeleteBind(this.spread, function(sheet){
+                if (!sheet.zh_setting) return;
+
+                const sel = sheet.getSelections()[0];
+                if (!sel) return;
+                const col = sheet.zh_setting.cols[sel.col];
+                if (col.readOnly) return;
+                if (sel.colCount > 1) toastr.warning('请勿同时删除多列数据');
+
+                for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow ++) {
+                    const node = sheet.zh_data[iRow];
+                    if (!node) continue;
+                    if (col.type === 'Number') {
+                        node[col.field] = 0
+                    } else {
+                        node[col.field] = '';
+                    }
+                }
+                SpreadJsObj.reLoadRowData(sheet, sel.row, sel.rowCount);
+            });
             // 右键菜单
             $.contextMenu({
                 selector: '#col-set-spread',
                 build: function ($trigger, e) {
                     const target = SpreadJsObj.safeRightClickSelection($trigger, e, self.spread);
-                    return target.hitTestType === GC.Spread.Sheets.SheetArea.viewport || target.hitTestType === GC.Spread.Sheets.SheetArea.rowHeader;
+                    return (target.hitTestType === GC.Spread.Sheets.SheetArea.viewport || target.hitTestType === GC.Spread.Sheets.SheetArea.rowHeader) && !self.readOnly && self.template;
                 },
                 items: {
                     'add_str': {
@@ -109,9 +137,9 @@ $(document).ready(() => {
                         name: '新增规格列',
                         icon: 'fa-plus',
                         callback: function (key, opt) {
-                            toastr.warning('还没想好怎么做,别用!!');
-                            // const select = SpreadJsObj.getSelectObject(self.sheet);
-                            // self.addCol('spec', select);
+                            // toastr.warning('还没想好怎么做,别用!!');
+                            const select = SpreadJsObj.getSelectObject(self.sheet);
+                            self.addCol('spec', select);
                         },
                     },
                     'add_qty': {
@@ -161,7 +189,7 @@ $(document).ready(() => {
                 return;
             }
             const nData = JSON.parse(JSON.stringify(colInfo.def));
-            for (const f of colInfo.valid) {
+            for (const f of colInfo.fields) {
                 if (!this.colSetData.find(x => { return x.field === f; })) {
                     nData.field = f;
                     break;
@@ -199,10 +227,16 @@ $(document).ready(() => {
         }
         loadDetail(template) {
             this.template = template;
+            this.readOnly = this.template.used_count > 0;
             if (!this.firstShowDone) {
                 this.spread.refresh();
                 this.firstShowDone = true;
             }
+            if (this.readOnly) {
+                $('#detail-ctrl').hide();
+            } else {
+                $('#detail-ctrl').show();
+            }
             this.reset();
         }
         save(){
@@ -234,7 +268,7 @@ $(document).ready(() => {
                 });
                 x.calc_expr = calcExpr;
                 try {
-                    x.qty = ZhCalc.mathCalcExpr(calcExpr.replace(new RegExp('%', 'gm'), '/100'));
+                    x.qty = ZhCalc.round(ZhCalc.mathCalcExpr(calcExpr.replace(new RegExp('%', 'gm'), '/100')), qtyCol.decimal);
                 } catch {
                     x.qty = 0;
                 }
@@ -285,7 +319,8 @@ $(document).ready(() => {
             return curTemplate;
         };
         const getTemplateCaptionHtml = function(template) {
-            return `<div class="d-flex justify-content-between align-items-center table-file" templateId="${template.id}"><div>${template.name}</div>` +
+            const usedHtml = template.used_count > 0 ? '<i class="ml-1 fa fa-lock text-danger"></i>' : '';
+            return `<div class="d-flex justify-content-between align-items-center table-file" templateId="${template.id}"><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' +
@@ -322,7 +357,7 @@ $(document).ready(() => {
             });
         };
         if (templates.length > 0) setCurTemplate(templates[0]);
-        return { setCurTemplate, getCurTemplate, addTemplate, delTemplate, renameTemplate }
+        return { setCurTemplate, getCurTemplate, addTemplate, delTemplate, renameTemplate, getTemplateCaptionHtml }
     })(templateList);
 
     $('body').on('click', '.table-file', function(e) {
@@ -366,14 +401,7 @@ $(document).ready(() => {
         const template = templateList.find(x => { return x.id === templateId; });
         if (!template) return;
 
-        const html = [];
-        html.push(`<div>${template.name}</div>`);
-        html.push('<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="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>');
-        $(`.table-file[templateId=${templateId}]`).html(html.join(''));
+        $(`.table-file[templateId=${templateId}]`).html(templateObj.getTemplateCaptionHtml(template));
     });
 
     $('#addTemplate').click(function() {

+ 9 - 2
app/public/js/shares/sjs_setting.js

@@ -167,9 +167,16 @@ const sjsSettingObj = (function () {
             };
         });
     };
-    const setCalcTemplateCol = function(cols, rela) {
+    const setCalcTemplateCol = function(cols, rela, comboItems, url) {
         setRelaCols(cols, rela, function(col, r) {
-            col.comboItems = (typeof posCalcTemplate === 'undefined') ? [] : posCalcTemplate.map(x => { return { value: x.id, text: x.name }; });
+            col.headerCellType = 'button';
+            col.headerButtonPos = 'below';
+            col.headerNormalImg = '#ellipsis-icon';
+            col.headerButtonClick = function() {
+                window.open(url, '_blank');
+            };
+
+            col.comboItems = comboItems;
             col.cellType = 'specCombo';
             col.cellTypeKey = 'calc_temp';
             col.comboEdit = function (sheet, data) {

+ 152 - 1
app/public/js/spreadjs_rela/spreadjs_zh.js

@@ -313,7 +313,10 @@ const SpreadJsObj = {
         const setting = sheet.zh_setting;
         if (!setting || !setting.cols) { return; }
 
-        if (force) sheet.setColumnCount(0);
+        if (force) {
+            sheet.setColumnCount(0);
+            sheet.setRowCount(0, spreadNS.SheetArea.colHeader)
+        }
         sheet.setColumnCount(setting.cols.length);
         sheet.setRowCount(setting.headRows, spreadNS.SheetArea.colHeader);
         for (let iRow = 0; iRow < setting.headRowHeight.length; iRow ++) {
@@ -682,6 +685,12 @@ const SpreadJsObj = {
             }
             cell.cellType(sheet.extendColHeader.tip);
         }
+        if (colSetting.headerCellType === 'button') {
+            if (!sheet.extendColHeader.button) {
+                sheet.extendColHeader.button = this.ColHeader.getButtonHeader(colSetting);
+            }
+            cell.cellType(sheet.extendColHeader.button);
+        }
     },
     _defineColCellType: function (sheet, col, colSetting) {
         sheet.AcitveComboRefresh = false;
@@ -3221,6 +3230,148 @@ const SpreadJsObj = {
 
             return new TipCellType();
         },
+        getButtonHeader: function() {
+            const hover = 1, active = 2;
+            const ImageButtonCellType = function (){};
+            ImageButtonCellType.prototype = new spreadNS.CellTypes.ColumnHeader();
+            const getIndent = function(col) {
+                return col.headerIndent ? col.headerIndent : 3;
+            };
+            const proto = ImageButtonCellType.prototype;
+            proto.getImage = function (sheet, iRow, iCol) {
+                const col = sheet.zh_setting.cols[iCol];
+                let imgSource = col.headerNormalImg;
+                const cell = sheet.getCell(iRow, iCol, spreadNS.SheetArea.colHeader), tag = cell.tag();
+                if (tag === active) {
+                    imgSource = col.headerActiveImg ? col.headerActiveImg : (col.headerHoverImg ? col.headerHoverImg : col.headerNormalImg);
+                } else if (tag === hover) {
+                    imgSource = col.headerHoverImg ? col.headerHoverImg : col.headerNormalImg;
+                }
+                return $(imgSource)[0] ? $(imgSource)[0] : null;
+
+            };
+            proto.paint = function (canvas, value, x, y, w, h, style, options) {
+                // Drawing Text
+                const col = options.sheet.zh_setting.cols[options.col];
+                if (col.headerButtonPos === 'below') {
+                    spreadNS.CellTypes.ColumnHeader.prototype.paint.apply(this, [canvas, value+'\n', x, y, w, h, style, options]);
+                } else {
+                    spreadNS.CellTypes.ColumnHeader.prototype.paint.apply(this, [canvas, value, x, y, w, h, style, options]);
+                }
+                const img = this.getImage(options.sheet, options.row, options.col);
+
+                const indent = getIndent(col);
+                if (img) {
+                    if (col.headerButtonPos === 'below') {
+                        canvas.drawImage(img, x + w / 2 - img.width / 2, y + h / 2 + indent);
+                    } else {
+                        canvas.drawImage(img, x + w - indent - img.width - indent, y + (h - img.height) / 2);
+                    }
+                }
+            };
+            /**
+             * 获取点击信息
+             * @param {Number} x
+             * @param {Number} y
+             * @param {Object} cellStyle
+             * @param {Object} cellRect
+             * @param {Object} context
+             * @returns {{x: *, y: *, row: *, col: *|boolean|*[]|number|{}|UE.dom.dtd.col, cellStyle: *, cellRect: *, sheet: *|StyleSheet, sheetArea: *}}
+             */
+            proto.getHitInfo = function (x, y, cellStyle, cellRect, context) {
+                return {
+                    x: x,
+                    y: y,
+                    row: context.row,
+                    col: context.col,
+                    cellStyle: cellStyle,
+                    cellRect: cellRect,
+                    sheet: context.sheet,
+                    sheetArea: context.sheetArea
+                };
+            };
+            proto.processMouseEnter = function (hitinfo) {
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                // Drawing Image
+                if (col.hoverImg) {
+                    const cell = hitinfo.sheet.getCell(hitinfo.row, hitinfo.col, spreadNS.SheetArea.colHeader);
+                    cell.tag(hover);
+                    hitinfo.sheet.repaint(hitinfo.cellRect);
+                }
+            };
+            proto.processMouseLeave = function (hitinfo) {
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                // Drawing Image
+                const cell = hitinfo.sheet.getCell(hitinfo.row, hitinfo.col, spreadNS.SheetArea.colHeader);
+                cell.tag(null);
+                hitinfo.sheet.repaint(hitinfo.cellRect);
+            };
+            proto.processMouseDown = function (hitinfo) {
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                const img = this.getImage(hitinfo.sheet, hitinfo.row, hitinfo.col);
+                const halfX = img.width / 2, halfY = img.height / 2;
+                const indent = getIndent(col);
+                const centerX = col.headerButtonPos === 'below'
+                    ? hitinfo.cellRect.x + hitinfo.cellRect.width / 2
+                    : hitinfo.cellRect.x + hitinfo.cellRect.width - indent - halfX - indent;
+                const centerY = col.headerButtonPos === 'below'
+                    ? hitinfo.cellRect.y + hitinfo.cellRect.height / 2 + halfY + indent
+                    : hitinfo.cellRect.y + hitinfo.cellRect.height / 2;
+
+                if (Math.abs(hitinfo.x - centerX) < halfX && Math.abs(hitinfo.y - centerY) < halfY) {
+                    const cell = hitinfo.sheet.getCell(hitinfo.row, hitinfo.col, spreadNS.SheetArea.colHeader);
+                    cell.tag(active);
+                    hitinfo.sheet.repaint(hitinfo.cellRect);
+                }
+            };
+            proto.processMouseUp = function (hitinfo) {
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                const img = this.getImage(hitinfo.sheet, hitinfo.row, hitinfo.col);
+                const halfX = img.width / 2, halfY = img.height / 2;
+                const indent = getIndent(col);
+                const centerX = col.headerButtonPos === 'below'
+                    ? hitinfo.cellRect.x + hitinfo.cellRect.width / 2
+                    : hitinfo.cellRect.x + hitinfo.cellRect.width - indent - halfX - indent;
+                const centerY = col.headerButtonPos === 'below'
+                    ? hitinfo.cellRect.y + hitinfo.cellRect.height / 2 + halfY + indent
+                    : hitinfo.cellRect.y + hitinfo.cellRect.height / 2;
+
+                if (Math.abs(hitinfo.x - centerX) < halfX && Math.abs(hitinfo.y - centerY) < halfY) {
+                    const imageClick = col.headerButtonClick;
+                    if (imageClick && Object.prototype.toString.apply(imageClick) === "[object Function]") {
+                        const sortData = SpreadJsObj.getSortData(hitinfo.sheet);
+                        const data = sortData ? sortData[hitinfo.row] : null;
+                        imageClick(data, hitinfo);
+                        const cell = hitinfo.sheet.getCell(hitinfo.row, hitinfo.col, spreadNS.SheetArea.colHeader);
+                        cell.tag(null);
+                        hitinfo.sheet.repaint(hitinfo.cellRect);
+                    }
+                }
+            };
+            proto.processMouseMove = function (hitinfo) {
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                const img = this.getImage(hitinfo.sheet, hitinfo.row, hitinfo.col);
+                const halfX = img.width / 2, halfY = img.height / 2;
+                const indent = getIndent(col);
+                const centerX = hitinfo.cellStyle.hAlign === spreadNS.HorizontalAlign.right
+                    ? hitinfo.cellRect.x + hitinfo.cellRect.width - indent -halfX
+                    : hitinfo.cellRect.x + indent + halfX;
+                const centerY = hitinfo.cellRect.y + hitinfo.cellRect.height / 2;
+                const cell = hitinfo.sheet.getCell(hitinfo.row, hitinfo.col, spreadNS.SheetArea.colHeader);
+                if (Math.abs(hitinfo.x - centerX) < halfX && Math.abs(hitinfo.y - centerY) < halfY) {
+                    if (cell.tag() !== hover) {
+                        cell.tag(hover);
+                        hitinfo.sheet.repaint(hitinfo.cellRect);
+                    }
+                } else {
+                    if (cell.tag() === hover) {
+                        cell.tag(null);
+                        hitinfo.sheet.repaint(hitinfo.cellRect);
+                    }
+                }
+            };
+            return new ImageButtonCellType();
+        }
     },
 
     Formatter: {

+ 5 - 4
app/router.js

@@ -620,10 +620,11 @@ module.exports = app => {
     // 标段协作办公
     app.get('/tender/:id/cooperation', sessionAuth, tenderCheck, subProjectCheck, 'tenderController.tenderCooperation');
 
-    app.get('/tender/:id/template/posCalc', sessionAuth, tenderCheck, subProjectCheck, 'templateController.posCalc');
-    app.post('/tender/:id/template/save', sessionAuth, tenderCheck, subProjectCheck, 'templateController.saveTemplate');
-    app.post('/tender/:id/template/load', sessionAuth, tenderCheck, subProjectCheck, 'templateController.load');
-    app.post('/tender/:id/template/preview', sessionAuth, tenderCheck, subProjectCheck, 'templateController.preview');
+    app.get('/sp/:id/template/posCalc', sessionAuth, subProjectCheck, 'templateController.posCalc');
+    app.get('/sp/:id/template/cost', sessionAuth, subProjectCheck, 'templateController.cost');
+    app.post('/sp/:id/template/save', sessionAuth, subProjectCheck, 'templateController.saveTemplate');
+    app.post('/sp/:id/template/load', sessionAuth, subProjectCheck, 'templateController.load');
+    app.post('/sp/:id/template/preview', sessionAuth, subProjectCheck, 'templateController.preview');
     // 台账管理相关
     app.get('/tender/:id/ledger', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, ledgerAuditCheck, 'ledgerController.explode');
     app.post('/tender/:id/ledger/load', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.loadExplodeData');

+ 195 - 44
app/service/calc_tmpl.js

@@ -1,14 +1,16 @@
 'use strict';
 
 /**
- * 计量单元计算模板
+ * 计算模板
+ *
+ * 如需修改请联系作者,常量&私有方法(以下划线开头)勿动
  *
  * @author Mai
  * @date
  * @version
  */
 
-const ValidTemplateType = ['posCalc'];
+const ValidTemplateType = ['posCalc', 'cost'];
 const PosCalc = (function(){
     const EmptySpreadCache = {
         cols: [],
@@ -29,7 +31,7 @@ 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: ['num1', 'num2', 'num3', 'num4', 'num5', 'num6', 'num7', 'num8', 'num9'], 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'], def: { title: '规格', width: 80, rela_col: '', type: 'spec'} },
+        { 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' } },
     ];
     ValidColInfo.forEach(vci => { return vci.count = vci.fields.length; });
@@ -44,7 +46,34 @@ const PosCalc = (function(){
     };
     return { EmptySpreadCache, BaseSpreadColSetting, ValidColInfo, TestData };
 })();
-
+const Cost = (function(){
+    const EmptySpreadCache = {
+        cols: [],
+        emptyRows: 3,
+        headRows: 1,
+        headRowHeight: [32],
+        defaultRowHeight: 21,
+        headerFont: '12px 微软雅黑',
+        font: '12px 微软雅黑',
+        frozenLineColor: '#93b5e4',
+    };
+    const BaseSpreadColSetting = {
+        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' },
+    };
+    const ValidColInfo = [
+        { key: 'str', name: '文本', fields: ['str1', 'str2', 'str3', 'str4', 'str5', 'str6'], 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'],
+            valid: ['title', 'width', 'calc_code', 'decimal', 'unit', 'expr'],
+            def: { title: '数值/计算', width: 80, decimal: 2, type: 'num', unit: ''}
+        },
+        { key: 'memo', name: '长文本', fields: ['memo1', 'memo2'], valid: ['title', 'width'], def: { title: '长文本', width: 120, type: 'memo'} },
+    ];
+    ValidColInfo.forEach(vci => { return vci.count = vci.fields.length; });
+    return { EmptySpreadCache, BaseSpreadColSetting, ValidColInfo };
+})();
 function randomWord(randomFlag, min, max){
     let str = "",
         range = min,
@@ -73,57 +102,101 @@ module.exports = app => {
         constructor(ctx) {
             super(ctx);
             this.tableName = 'calc_tmpl';
-            this.TemplateRela = { posCalc: PosCalc };
+            this.TemplateRela = { posCalc: PosCalc, cost: Cost };
         }
 
-        async getAllTemplate(tid, type, sort = 'asc') {
+        async getAllTemplate(spid, type, sort = 'asc') {
             return this.getAllDataByCondition({
-                columns: ['id', 'name', 'create_user_id'],
-                where: { tid, type },
+                columns: ['id', 'spid', 'name', 'create_user_id'],
+                where: { spid, type },
                 orders: [['create_time', sort]]
             });
         }
-        analysisTemplate(data) {
+        async analysisTemplate(data) {
             const datas = data instanceof Array ? data : [data];
             datas.forEach(x => {
                 x.col_set = x.col_set ? JSON.parse(x.col_set) : [];
                 x.spread_cache = x.spread_cache ? JSON.parse(x.spread_cache) : {};
                 x.field_cache = x.field_cache ? JSON.parse(x.field_cache) : {};
                 x.decimal = x.decimal ? JSON.parse(x.decimal) : {};
+                x.multi_header = x.multi_header ? JSON.parse(x.multi_header) : undefined;
             });
+            for (const d of datas) {
+                if (d.spec_set) {
+                    d.specValue = await this.ctx.service.stdExtra.getAllDataByCondition({ columns: ['spec', 'value'], where: { list_id: d.spec_set }, orders:[['order', 'asc']]});
+                } else {
+                    d.specValue = [];
+                }
+            }
         }
-        async getAllTemplateDetail(tid, type) {
+        async getAllTemplateDetail(spid, type) {
             const result = await this.getAllDataByCondition({
-                where: { tid, type },
+                where: { spid, type },
                 orders: [['create_time', 'desc']]
             });
-            this.analysisTemplate(result);
+            await this.analysisTemplate(result);
             return result;
         }
         async getTemplate(id) {
             const result = await this.getDataById(id);
-            this.analysisTemplate(result);
-            return result;
-        }
-        async getLedgerCalcTemplate(tid, lid) {
-            const sql = `SELECT ct.*, l.id FROM ${this.ctx.service.ledgerExtra.departTableName(tid)} l LEFT JOIN ${this.tableName} ct ON l.calc_template = l.id WHERE l.tid = ? and lid = ?`;
-            const result = await this.db.query(sql, [tid, lid]);
-            this.analysisTemplate(result);
+            await this.analysisTemplate(result);
             return result;
         }
 
+        //-------------------------- 以下方法不可随意修改 --------------------------
         // 根据列设置计算spreadjs配置相关,方法命名须严格按照【方法_类型】命名
-        transExpr_posCalc(expr, colSet) {
+        _getFillRowCount(rowSpan) {
+            let count = 0;
+            rowSpan.forEach(x => {
+                count = count + x ? x : 1;
+            });
+            return count;
+        }
+        _calcMultiColHeader(iCol, spreadCol, colTitle, multiHeader) {
+            if (multiHeader) {
+                const colSpan = [], rowSpan = [], title = [];
+                for (let iRow = 0; iRow < multiHeader.headRows - 1; iRow ++) {
+                    const setSpan = multiHeader.headSpan.find(x => { return x.col === iCol && x.row === iRow; });
+                    if (setSpan) {
+                        colSpan.push(setSpan.colCount);
+                        rowSpan.push(setSpan.rowCount);
+                        title.push(setSpan.title);
+                    } else {
+                        const relaSpan = multiHeader.headSpan.find(x => { return iCol >= x.col && iCol < x.col + x.colCount && iRow >= x.row && iRow < x.row + x.rowCount; });
+                        if (relaSpan) {
+                            colSpan.push('');
+                            rowSpan.push('');
+                            title.push('');
+                        }
+                    }
+                }
+                if (colSpan.length === 0) {
+                    spreadCol.rowSpan = multiHeader.headRows + '';
+                    spreadCol.title = colTitle;
+                } else {
+                    colSpan.push(1);
+                    rowSpan.push(multiHeader.headRows - this._getFillRowCount(rowSpan));
+                    title.push(colTitle);
+                    spreadCol.colSpan = colSpan.join('|');
+                    spreadCol.rowSpan = rowSpan.join('|');
+                    spreadCol.title = title.join('|');
+                }
+            } else {
+                spreadCol.title = colTitle;
+            }
+        }
+        _transExpr(expr, colSet) {
+            if (!expr) return '';
+
             let newExpr = expr;
             colSet.forEach(x => {
                 if (x.type === 'num' && x.calc_code) newExpr = newExpr.replace(new RegExp(x.calc_code, 'gm'), x.field);
             });
             return newExpr;
         }
-        calcSpreadCache_posCalc(colSet) {
+        _calcSpreadCache_posCalc(colSet) {
             const relaConst = this.TemplateRela.posCalc;
             const result = JSON.parse(JSON.stringify(relaConst.EmptySpreadCache));
-            let specCol;
             for (const col of colSet) {
                 const info = relaConst.ValidColInfo.find(x => { return x.key === col.type });
                 const spreadCol = JSON.parse(JSON.stringify(relaConst.BaseSpreadColSetting[col.type]));
@@ -132,10 +205,8 @@ module.exports = app => {
                     if (v === 'unit' && col.unit) {
                         spreadCol.title = col.title + '(' + col.unit + ')';
                     } else if (v === 'expr') {
-                        spreadCol.expr = this.transExpr_posCalc(col.expr, colSet);
-                    } else if (v === 'spec') {
-                        // todo
-                        specCol = spreadCol;
+                        spreadCol.expr = this._transExpr(col.expr, colSet);
+                        spreadCol.readOnly = true;
                     } else {
                         if (col[v] !== undefined) spreadCol[v] = col[v];
                     }
@@ -144,6 +215,7 @@ module.exports = app => {
                 if (col.expr) spreadCol.title = spreadCol.title + '\n' + col.expr;
                 result.cols.push(spreadCol);
             }
+            const specCol = colSet.find(x => { return x.type === 'spec'; });
             if (specCol) {
                 const relaSet = colSet.find(x => { return x.calc_code === specCol.rela_col; });
                 const relaCol = result.cols.find(x => { return x.field === relaSet.field; });
@@ -151,28 +223,95 @@ module.exports = app => {
             }
             return result;
         }
-        calcSpreadCache(colSet, type) {
-            const funName = 'calcSpreadCache_' + type;
-            if (this[funName]) return this[funName](colSet);
-            return null;
+        _calcSpreadCache_cost(colSet, multiHeader) {
+            const relaConst = this.TemplateRela.cost;
+            const result = JSON.parse(JSON.stringify(relaConst.EmptySpreadCache));
+            if (multiHeader) {
+                result.headRows = multiHeader.headRows;
+                result.headRowHeight = multiHeader.headRowHeight;
+            }
+            for (const [iCol, col] of colSet.entries()) {
+                const info = relaConst.ValidColInfo.find(x => { return x.key === col.type });
+                const spreadCol = JSON.parse(JSON.stringify(relaConst.BaseSpreadColSetting[col.type]));
+                spreadCol.field = col.field;
+                let colTitle = col.title;
+                info.valid.forEach(v => {
+                    if (v === 'unit' && col.unit) {
+                        colTitle = colTitle + '(' + col.unit + ')';
+                    } else if (v === 'expr') {
+                        spreadCol.expr = this._transExpr(col.expr, colSet);
+                        spreadCol.readOnly = true;
+                    } else {
+                        if (col[v] !== undefined) spreadCol[v] = col[v];
+                    }
+                });
+                if (col.calc_code) {
+                    if (col.expr) {
+                        colTitle = colTitle + '\n' + col.calc_code + '(' + col.expr + ')';
+                    } else {
+                        colTitle = colTitle + '\n' + col.calc_code;
+                    }
+                }
+                this._calcMultiColHeader(iCol, spreadCol, colTitle, multiHeader);
+
+                result.cols.push(spreadCol);
+            }
+            return result;
+        }
+        calcSpreadCache(type, colSet, multiHeader) {
+            const funName = '_calcSpreadCache_' + type;
+            if (this[funName]) return this[funName](colSet, multiHeader);
+            return this.TemplateRela[type] ? this.TemplateRela[type].EmptySpreadCache : {};
         }
 
-        getCalcTestData_posCalc(colSet, count) {
+        async getCalcTestData_posCalc(colSet, count) {
             const result = [];
-            const testData = {};
-            colSet.forEach(x => {
-                if (x.type === 'num') testData[x.field] = Math.max(Math.floor(Math.random()*10), 1);
-                if (x.type === 'str') testData[x.field] = randomWord(true, 3, 6);
-            });
-            result.push(testData);
+            const specCol = colSet.find(x => { return x.type === 'spec'; });
+            const specSet = specCol && specCol.spec_set ? await this.ctx.service.stdExtra.getAllDataByCondition({ columns: ['spec', 'value'], where: { list_id: specCol.spec_set }, orders:[['order', 'asc']]}) : [];
+            const relaCol = specCol && specCol.rela_col ? colSet.find(x => { return x.calc_code === specCol.rela_col; }) : null;
+
+            for (let i = 0; i < count; i++) {
+                const testData = {};
+                colSet.forEach(x => {
+                    if (x.type === 'num') testData[x.field] = Math.max(Math.floor(Math.random()*10), 1);
+                    if (x.type === 'str') testData[x.field] = randomWord(true, 3, 6);
+                });
+                if (specCol && specSet.length > 0) {
+                    testData.spec = specSet[0].spec;
+                    if (relaCol) testData[relaCol.field] = specSet[0].value;
+                }
+                result.push(testData);
+            }
             return result;
         }
-        getCalcTestData(colSet, type, count = 1) {
+        async getCalcTestData(colSet, type, count = 1) {
             const funName = 'getCalcTestData_' + type;
-            if (this[funName]) return this[funName](colSet);
+            if (this[funName]) return await this[funName](colSet, count);
             return null;
         }
 
+        async _checkTemplateUsed_posCalc(template) {
+            const templates = template instanceof Array ? template : [template];
+            templates.forEach(x => { x.used = []; x.used_count = 0; });
+            const tender = await this.ctx.service.tender.getAllDataByCondition({ columns: ['id'], where: { spid: templates[0].spid }});
+            for (const t of tender) {
+                const used = await this.ctx.service.ledgerExtra.getUsedCalcTemplate(t.id);
+                templates.forEach(x => {
+                    const u = used.find(ut => { return ut.calc_template === x.id; });
+                    if (u) {
+                        x.used.push(t.id);
+                        x.used_count = this.ctx.helper.add(x.used_count + u.count);
+                    }
+                });
+            }
+        }
+        async checkTemplateUsed(template, type) {
+            const funName = '_checkTemplateUsed_' + type;
+            if (this[funName]) return await this[funName](template);
+            return false;
+        }
+        // -----------------------------------------------------------------------
+
         async _addTemplate(name, type) {
             if (ValidTemplateType.indexOf(type) < 0) throw '新增的模板类型非法';
 
@@ -180,7 +319,8 @@ module.exports = app => {
             const insertData = {
                 id: this.uuid.v4(),
                 pid: this.ctx.session.sessionProject.id,
-                tid: this.ctx.tender.id,
+                spid: this.ctx.subProject.id,
+                // tid: this.ctx.tender.id,
                 name: name || '新增计算模板',
                 type,
                 create_user_id: this.ctx.session.sessionUser.accountId,
@@ -198,7 +338,7 @@ module.exports = app => {
         async checkTemplateEdit(id) {
             const template = await this.getDataById(id);
             if (!template) throw '编辑的模板不存在';
-            if (template.tid !== this.ctx.tender.id) throw '模板不属于当前标段,请刷新后重试';
+            if (template.spid !== this.ctx.subProject.id) throw '模板不属于当前项目,请刷新后重试';
             if (template.create_user_id !== this.ctx.session.sessionUser.accountId) throw '非您创建的模板';
             return template;
         }
@@ -206,9 +346,13 @@ module.exports = app => {
             const org = await this.checkTemplateEdit(id);
             const updateData = { id };
             if (data.name !== undefined) updateData.name = data.name;
-            if (data.col_set !== undefined) {
+            if (data.col_set !== undefined || data.multi_header !== undefined) {
+                await this.checkTemplateUsed(org);
+                if (org.used.length > 0) throw '模板已使用,不可修改配置!';
+
                 updateData.col_set = JSON.stringify(data.col_set);
-                const spread_cache = this.calcSpreadCache(data.col_set, org.type);
+                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);
                 const field_cache = {};
                 data.col_set.forEach(x => { field_cache[x.field] = x.title; });
@@ -218,6 +362,14 @@ module.exports = app => {
                 updateData.decimal = JSON.stringify(decimal);
                 const qtyCol = spread_cache.cols.find(x => { return x.field === 'qty'; });
                 updateData.calc_expr = qtyCol ? qtyCol.expr : '';
+                const specCol = data.col_set.find(x => { return x.field === 'spec' });
+                if (specCol) {
+                    updateData.spec_set = specCol.spec_set;
+                    updateData.spec_rela = data.col_set.find(x => { return x.calc_code === specCol.rela_col; }).field;
+                } else {
+                    updateData.spec_set = 0;
+                    updateData.spec_rela = '';
+                }
             }
             await this.db.update(this.tableName, updateData);
             return await this.getTemplate(id);
@@ -227,7 +379,6 @@ module.exports = app => {
             await this.db.delete(this.tableName, { id });
             return template;
         }
-
         async saveTemplate(data) {
             const result = {};
             if (data.add) result.add = await this._addTemplate(data.add.name, data.type);

+ 11 - 5
app/service/ledger.js

@@ -51,7 +51,7 @@ module.exports = app => {
                 fullPath: 'full_path',
                 keyPre: 'ledger_bills_maxLid:',
                 uuid: true,
-            }, 'pos', 'ancillaryGcl');
+            }, 'pos', 'ancillaryGcl', 'ledgerExtra', 'posCalcDetail');
             this.depart = ctx.app.config.table_depart.heavy;
             this.tableName = 'ledger';
         }
@@ -292,6 +292,8 @@ module.exports = app => {
         async _deleteRelaData(mid, deleteData) {
             await this.ctx.service.pos.deletePosData(this.transaction, mid, this._.map(deleteData, 'id'));
             await this.ctx.service.ancillaryGcl.deletePartData(this.transaction, mid, this._.map(deleteData, 'id'));
+            await this.transaction.delete(this.ctx.service.ledgerExtra.tableName, { tid: mid, id: this._.map(deleteData, 'id') });
+            await this.ctx.service.posCalcDetail.deleteBillsPartData(this.transaction, mid, this._.map(deleteData, 'id'));
         }
 
         _checkField(data, field) {
@@ -336,8 +338,7 @@ module.exports = app => {
                 throw '复制数据错误:仅可操作同层节点';
             }
 
-            const pasteBillsData = [],
-                pastePosData = [];
+            const pasteBillsData = [], pastePosData = [];
             this.transaction = await this.db.beginTransaction();
             try {
                 // 选中节点的所有后兄弟节点,order+粘贴节点个数
@@ -879,13 +880,18 @@ module.exports = app => {
                 } else {
                     await conn.insert(this.service.ledgerExtra.tableName, { id: data.id, tid, calc_template: data.calc_template });
                 }
-                // todo 清理计算数据 / 重算所有数据
+                // 重算所有数据
+                await this.ctx.service.posCalcDetail.resetBillsCalcTemplate(conn, data.id, data.calc_template);
                 await conn.commit();
-                return { update: data };
             } catch (err) {
                 await conn.rollback();
                 throw err;
             }
+            const bills = await this.getDataById(data.id);
+            bills.calc_template = data.calc_template;
+            const pos = await this.ctx.service.pos.getAllDataByCondition({ where: { lid: data.id } });
+            const detail = await this.ctx.service.posCalcDetail.getAllDataByCondition({ where: { lid: data.id } });
+            return { update: bills, pos, detail };
         }
     }
 

+ 7 - 0
app/service/ledger_extra.js

@@ -37,6 +37,13 @@ module.exports = app => {
             return await this.db.query(sql, [tid]);
         }
 
+        async getUsedCalcTemplate(tid) {
+            const sql = `SELECT calc_template, count(id) as count FROM ${this.departTableName(tid)} WHERE tid = ? and calc_template <> '' GROUP BY calc_template`;
+            const result = await this.db.query(sql, [tid]);
+            return result;
+            // return result.map(x => { return x.calc_template; });
+        }
+
         async updateMultiLimit(id, multi_limit) {
             const exist = await this.getDataById(id);
             if (exist) {

+ 1 - 0
app/service/pos.js

@@ -356,6 +356,7 @@ module.exports = app => {
             try {
                 await transaction.delete(this.tableName, {tid: tid, id: data});
                 await transaction.update(this.ctx.service.ledger.tableName, updateBills);
+                await this.ctx.service.posCalcDetail.deletePosPartData(transaction, tid, data);
                 await transaction.commit();
                 updateBills.ledger_id = bills.ledger_id;
                 return { ledger: { update: [updateBills] }, pos: data };

+ 52 - 4
app/service/pos_calc_detail.js

@@ -54,16 +54,29 @@ module.exports = app => {
                 return 0;
             }
         }
+        _reCalcQty(data, template) {
+            const result = { id: data.id, expr: template.calc_expr };
+            for (const nf of numField) {
+                result[nf] = this.ctx.helper.round(data[nf] || 0, this.getDecimal(template.decimal, nf));
+                result.expr = result.expr.replace(new RegExp(nf, 'gm'), data[nf] || 0);
+            }
+            result.qty = this.ctx.helper.round(this._calcExpr(result.expr), this.getDecimal(template.decimal, 'qty'));
+            return result;
+        }
         // 后端计算
         _loadDataAndCalc(data, updateData, orgData, template) {
             let calc = false;
             for (const sf of strField) {
                 if (updateData[sf] !== undefined) data[sf] = updateData[sf] || '';
             }
-            if (updateData.spec !== undefined) data.spec = updateData.spec;
+            if (updateData.spec !== undefined) {
+                data.spec = updateData.spec;
+                const sv = template.specValue.find(x => { return x.spec === data.spec; });
+                data[template.spec_rela] = sv ? sv.value : 0;
+            }
             data.expr = template.calc_expr;
             for (const nf of numField) {
-                if (updateData[nf] !== undefined) {
+                if (updateData[nf] !== undefined && nf !== template.spec_rela) {
                     data[nf] = this.ctx.helper.round(updateData[nf] || 0, this.getDecimal(template.decimal, nf));
                     calc = true;
                 }
@@ -122,7 +135,6 @@ module.exports = app => {
                 bpd.total_price = this.ctx.helper.mul(bpd.quantity, ledgerData.unit_price, this.ctx.tender.info.decimal.tp);
                 return [ppd, bpd];
             } catch (err) {
-                console.log(err);
                 return [null, null];
             }
         }
@@ -245,7 +257,6 @@ module.exports = app => {
                 return [];
             }
         }
-
         async updateDatas(data) {
             const result = { detail: {add: [], del: [], update: []}, pos: null, bills: null };
             try {
@@ -269,6 +280,43 @@ module.exports = app => {
             }
         }
 
+        async resetBillsCalcTemplate(transaction, lid, templateId) {
+            const user_id = this.ctx.session.sessionUser.accountId;
+            const calcTemplate = await this.ctx.service.calcTmpl.getTemplate(templateId);
+            if (!calcTemplate) throw '计算模板不存在';
+
+            const ledgerData = await this.ctx.service.ledger.getDataById(lid);
+            const precision = this.ctx.helper.findPrecision(this.ctx.tender.info.precision, ledgerData.unit);
+            const posDatas = await this.ctx.service.pos.getAllDataByCondition({ where: { lid } });
+            const detailDatas = await this.getAllDataByCondition({ where: { lid } });
+
+            const posUpdateData = [], detailUpdateDatas = [];
+            let bQty = 0;
+            for (const p of posDatas) {
+                let pQty = 0;
+                const posDetail = detailDatas.filter(x => { return x.pid === p.id; });
+                for (const pd of posDetail) {
+                    const ud = this._reCalcQty(pd, calcTemplate);
+                    ud.update_user_id = user_id;
+                    detailUpdateDatas.push(ud);
+                    pQty = this.ctx.helper.add(ud.qty, pQty);
+                }
+                const up = { id: p.id, sgfh_qty: this.ctx.helper.round(pQty, precision.value) };
+                up.quantity = this.ctx.helper.sum([up.sgfh_qty, p.sjcl_qty, p.qtcl_qty]);
+                posUpdateData.push(up);
+                bQty = this.ctx.helper.add(bQty, up.sgfh_qty);
+            }
+            const ub = { id: lid, sgfh_qty: bQty };
+            ub.sgfh_tp = this.ctx.helper.mul(ub.sgfh_qty, ledgerData.unit_price, this.ctx.tender.info.decimal.tp);
+            ub.quantity = this.ctx.helper.sum([ub.sgfh_qty, ledgerData.sjcl_qty, ledgerData.qtcl_qty]);
+            ub.total_price = this.ctx.helper.mul(ub.quantity, ledgerData.unit_price, this.ctx.tender.info.decimal.tp);
+
+            if (detailUpdateDatas.length > 0) await transaction.updateRows(this.tableName, detailUpdateDatas);
+            if (posUpdateData.length > 0) await transaction.updateRows(this.ctx.service.pos.tableName, posUpdateData);
+            await transaction.update(this.ctx.service.ledger.tableName, ub);
+            return detailUpdateDatas.length > 0 || posUpdateData.length > 0;
+        }
+
         async deleteBillsPartData(transaction, tid, lid) {
             await transaction.delete(this.tableName, { tid: tid, lid: lid });
         }

+ 39 - 0
app/service/std_extra.js

@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * 标准项目节业务逻辑
+ *
+ * @author Mai
+ * @date 2018/3/13
+ * @version
+ */
+
+const StandardLib = require('./standard_lib');
+module.exports = app => {
+
+    class StdExtra extends StandardLib {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx, {
+                mid: 'list_id',
+                kid: 'extra_id',
+                pid: 'extra_pid',
+                order: 'order',
+                level: 'level',
+                isLeaf: 'is_leaf',
+                fullPath: 'full_path',
+                keyPre: 'std_extra_maxLid:'
+            }, 'std_extra');
+            this.stdType = 'extra';
+        }
+
+    }
+
+    return StdExtra;
+};

+ 40 - 0
app/service/std_extra_list.js

@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date 2023/7/3
+ * @version
+ */
+
+module.exports = app => {
+    class StdExtraList extends app.BaseService {
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'std_extra_list';
+        }
+
+        async getList(subType) {
+            return await this.getAllDataByCondition({ columns: ['id', 'name'], where: { sub_type: subType } });
+        }
+
+        async getSpecList() {
+            const result = await this.getAllDataByCondition({ columns: ['id', 'name'], where: { sub_type: 0 } });
+            for (const r of result) {
+                const detail = await this.ctx.service.stdExtra.getAllDataByCondition({ columns: ['spec', 'value'], where: { list_id: r.id }, orders:[['order', 'asc']]});
+                r.spec = detail.map(x => { return x.spec });
+                r.value = detail.map(x => { return x.value });
+            }
+            return result;
+        }
+    }
+
+    return StdExtraList;
+};

+ 2 - 2
app/service/std_gcl_list.js

@@ -9,7 +9,7 @@
  */
 
 module.exports = app => {
-    class SpecPull extends app.BaseService {
+    class StdGclList extends app.BaseService {
         /**
          * 构造函数
          *
@@ -26,5 +26,5 @@ module.exports = app => {
         }
     }
 
-    return SpecPull;
+    return StdGclList;
 };

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

@@ -135,9 +135,11 @@
                                     <a class="nav-link" content="#anc-gcl" href="javascript:void(0)">附属工程量</a>
                                 </li>
                                 <% } %>
+                                <% if (ctx.subProject.page_show.posCalcDetail) {%>
                                 <li class="nav-item">
                                     <a class="nav-link" content="#pos-detail" href="javascript:void(0)">设计量明细</a>
                                 </li>
+                                <% } %>
                             </ul>
                         </div>
                     </div>
@@ -370,6 +372,9 @@
             </ul>
         </div>
     </div>
+    <div style="display: none">
+        <img src="/public/images/setting-two.png" id="ellipsis-icon" />
+    </div>
 </div>
 <script src="/public/js/moment/moment.min.js"></script>
 <script type="text/javascript">

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

@@ -10,6 +10,25 @@
             <div class="sjs-height-0">
                 <div class="row m-0 mt-3">
                     <div class="col-8">
+                        <div class=row>
+                            <div class="col-6">
+                                <div class="card mb-3">
+                                    <div class="card-header d-flex justify-content-between">
+                                        <div>台账分解</div>
+                                    </div>
+                                    <div class="card-body">
+                                        <div class="form-group mb-1">
+                                            <div>
+                                                <div class="form-check">
+                                                    <input class="form-check-input" type="checkbox" id="pos_calc_detail" name="pos_calc_detail" <% if(ctx.subProject.page_show.posCalcDetail) { %>checked<% } %> onchange="updateSetting();">
+                                                    <label class="form-check-label" for="pos_calc_detail">设计量明细</label>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
                         <div class="row">
                             <div class="col-6">
                                 <div class="card mb-3">
@@ -632,6 +651,7 @@
             openStageAudit: $('#openStageAudit')[0].checked,
             stageAuditEarly: parseInt($('#stage-audit-early').val()),
             stageAuditWorry: parseInt($('#stage-audit-worry').val()),
+            posCalcDetail: $('#pos_calc_detail')[0].checked ? 1 : 0,
         }, function (result) {
             if ($('#openChangeState').is(':checked')) {
                 $('#openBgStatus').removeClass('text-secondary');

+ 52 - 0
app/view/template/cost.ejs

@@ -0,0 +1,52 @@
+<% include ./sub_menu.ejs %>
+<div class="panel-content">
+    <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>
+            <div class="col-9">
+                <div class="d-flex">
+                    <div>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="preview"> 预览</a>
+                    </div>
+                    <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>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <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>
+                </div>
+                <div class="col-9" style="height: 100%">
+                    <div id="col-set-spread" class="sjs-height-0">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const templateList = JSON.parse('<%- JSON.stringify(templateList) %>');
+    const validColInfo = JSON.parse('<%- JSON.stringify(validColInfo) %>');
+</script>

+ 2 - 0
app/view/template/cost_modal.ejs

@@ -0,0 +1,2 @@
+<% include ./multi_header_modal.ejs %>
+<% include ./preview_modal.ejs %>

+ 31 - 0
app/view/template/multi_header_modal.ejs

@@ -0,0 +1,31 @@
+<div class="modal fade" id="multi-header" data-backdrop="static">
+    <div class="modal-dialog modal-lgx" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <div class="d-flex">
+                    <h5 class="modal-title">多行表头设置</h5>
+                    <div class="alert alert-warning p-1 mb-0 ml-2"><i class="fa fa-exclamation-circle"></i> 请先设置好所有列后,确认无修改后,再设置多行表头</div>
+                </div>
+            </div>
+            <div class="modal-body">
+                <div class="d-flex">
+                    <div class="input-group input-group-sm ml-2 mt-1 w-25">
+                        <div class="input-group-prepend">
+                            <span class="input-group-text">表头行数</span>
+                        </div>
+                        <input type="text" class="form-control m-0" value="1" type="number" max="3" min="1" id="header-row-count">
+                    </div>
+                    <div class="alert alert-danger p-1 mb-0 ml-2"><i class="fa fa-exclamation-circle"></i> 修改行数将清除所有合并表头配置</div>
+                </div>
+                <div class="mt-2" style="height: 300px" id="multi-spread"></div>
+                <div>
+                    <div class="alert alert-info p-1 mt-2">多行表头时,单列表头会自动向上合并普通单元格,勿需设置</div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="multi-header-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 5
app/view/template/pos_calc.ejs

@@ -5,10 +5,16 @@
             <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>
-            <div class="col-9" id="detail-ctrl">
-                <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="preview"> 预览</a>
-                <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="save"> 保存</a>
+            <div class="col-9">
+                <div class="d-flex">
+                    <div>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-light text-primary" id="preview"> 预览</a>
+                    </div>
+                    <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>
+                    </div>
+                </div>
             </div>
         </div>
     </div>
@@ -21,7 +27,7 @@
                             <% 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 %>%></div>
+                                    <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>
@@ -43,4 +49,5 @@
 <script>
     const templateList = JSON.parse('<%- JSON.stringify(templateList) %>');
     const validColInfo = JSON.parse('<%- JSON.stringify(validColInfo) %>');
+    const specList = JSON.parse('<%- JSON.stringify(specList) %>');
 </script>

+ 8 - 1
app/view/template/sub_menu.ejs

@@ -5,9 +5,16 @@
     <div class="scrollbar-auto">
         <div class="nav-box">
             <ul class="nav-list list-unstyled">
+                <% if (ctx.subProject.page_show.posCalcDetail) { %>
                 <li <% if (ctx.url.indexOf('pos-calc') >= 0) { %>class="active"<% } %>>
-                    <a href="/tender/<%- ctx.tender.id %>/template/pos-calc"><span>计量单元计算</span></a>
+                    <a href="/sp/<%- ctx.subProject.id %>/template/posCalc"><span>设计量明细</span></a>
                 </li>
+                <% } %>
+                <% if (ctx.subProject.page_show.cost) { %>
+                <li <% if (ctx.url.indexOf('cost') >= 0) { %>class="active"<% } %>>
+                    <a href="/sp/<%- ctx.subProject.id %>/template/cost"><span>成本管理</span></a>
+                </li>
+                <% } %>
             </ul>
         </div>
     </div>

+ 14 - 0
config/web.js

@@ -2479,6 +2479,20 @@ const JsFiles = {
                     '/public/js/pos_calc_tmpl.js',
                 ],
                 mergeFile: 'pos_calc_tmpl',
+            },
+            cost: {
+                files: [
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                    '/public/js/decimal.min.js',
+                    '/public/js/math.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/zh_calc.js',
+                    '/public/js/sub_menu.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/cost_tmpl.js',
+                ],
+                mergeFile: 'cost_tmpl',
             }
         }
     },

+ 3 - 0
sql/update.sql

@@ -300,5 +300,8 @@ CREATE TABLE `zh_pos_calc_detail`  (
 -- 表数据
 ------------------------------------
 
+INSERT INTO `zh_permission` (`id`, `name`, `controller`, `action`, `pid`, `icon_class`, `create_time`, `isshow`) VALUES (78, '附加数据', 'std-extra', 'all', 44, '', 14, 1);
+
+