瀏覽代碼

业主控制价相关

MaiXinRong 1 年之前
父節點
當前提交
949b24064a

+ 1 - 0
app/const/tender_info.js

@@ -19,6 +19,7 @@ const defaultInfo = {
         projectType: '',
         dealType: '',
         finalCode: '',
+        budgetApprovalCode: '',
     },
     // 参建单位
     construction_unit: {

+ 198 - 0
app/controller/ctrl_price_controller.js

@@ -0,0 +1,198 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date 2021/10/27
+ * @version
+ */
+const stdDataAddType = {
+    withParent: 1,
+    child: 2,
+    next: 3,
+};
+const LzString = require('lz-string');
+module.exports = app => {
+    class CtrlPriceController extends app.BaseController {
+
+        _getSpreadSetting() {
+            const spreadSetting = {
+                cols: [
+                    {title: '项目节编号', colSpan: '1', rowSpan: '2', field: 'code', hAlign: 0, width: 180, formatter: '@', cellType: 'tree'},
+                    {title: '清单编号', colSpan: '1', rowSpan: '2', field: 'b_code', hAlign: 0, width: 120, formatter: '@'},
+                    {title: '名称', colSpan: '1', rowSpan: '2', field: 'name', hAlign: 0, width: 230, formatter: '@'},
+                    {title: '单位', colSpan: '1', rowSpan: '2', field: 'unit', hAlign: 1, width: 50, formatter: '@', cellType: 'unit', comboEdit: true},
+                    {title: '设计数量|数量1', colSpan: '2|1', rowSpan: '1|1', field: 'dgn_qty1', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '|数量2', colSpan: '|1', rowSpan: '|1', field: 'dgn_qty2', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '经济指标', colSpan: '1', rowSpan: '2', field: 'dgn_price', hAlign: 2, width: 80, type: 'Number', readOnly: true},
+                    {title: '清单数量', colSpan: '1', rowSpan: '2', field: 'quantity', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '单价', colSpan: '1', rowSpan: '2', field: 'unit_price', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '金额', colSpan: '1', rowSpan: '2', field: 'total_price', hAlign: 2, width: 80, type: 'Number'},
+                    {title: '图册号', colSpan: '1', rowSpan: '2', field: 'drawing_code', hAlign: 0, width: 100, formatter: '@'},
+                    {title: '备注', colSpan: '1', rowSpan: '2', field: 'memo', hAlign: 0, width: 100, formatter: '@'},
+                ],
+                emptyRows: 3,
+                headRows: 2,
+                headRowHeight: [25, 25],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                localCache: { key: 'budget', colWidth: true },
+            };
+            return spreadSetting;
+        }
+
+        async index(ctx) {
+            try {
+                await this.ctx.service.controlPrice.checkInit(ctx.tender);
+                const [stdBills, stdChapters] = await this.ctx.service.valuation.getValuationStdList(
+                    ctx.tender.data.valuation, ctx.tender.data.measure_type);
+
+                const renderData = {
+                    spreadSetting: this._getSpreadSetting(),
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.tender.ctrlPrice),
+                    stdBills,
+                    stdChapters,
+                };
+                await this.layout('tender/ctrl_price.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        async load(ctx) {
+            try {
+                ctx.body = {
+                    err: 0, msg: '',
+                    data: await this.ctx.service.controlPrice.getData(ctx.tender.id),
+                }
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '获取数据错误');
+            }
+        }
+
+        async _billsBase(relaService, type, data) {
+            if (isNaN(data.id) || data.id <= 0) throw '数据错误';
+            if (type !== 'add') {
+                if (isNaN(data.count) || data.count <= 0) data.count = 1;
+            }
+            switch (type) {
+                case 'add':
+                    return await relaService.addNodeBatch(this.ctx.tender.id, data.id, {}, data.count);
+                case 'delete':
+                    return await relaService.delete(this.ctx.tender.id, data.id, data.count);
+                case 'up-move':
+                    return await relaService.upMoveNode(this.ctx.tender.id, data.id, data.count);
+                case 'down-move':
+                    return await relaService.downMoveNode(this.ctx.tender.id, data.id, data.count);
+                case 'up-level':
+                    return await relaService.upLevelNode(this.ctx.tender.id, data.id, data.count);
+                case 'down-level':
+                    return await relaService.downLevelNode(this.ctx.tender.id, data.id, data.count);
+            }
+        }
+        async _addStd(relaService, data) {
+            if ((isNaN(data.id) || data.id <= 0) || !data.stdType || !data.stdNode) throw '参数错误';
+
+            let stdLib, addType;
+            switch (data.stdType) {
+                case 'xmj':
+                    stdLib = this.ctx.service.stdXmj;
+                    addType = stdDataAddType.withParent;
+                    break;
+                case 'gcl':
+                    stdLib = this.ctx.service.stdGcl;
+                    const selectNode = await relaService.getDataByKid(this.ctx.tender.id, data.id);
+                    addType = selectNode.b_code ? stdDataAddType.next : stdDataAddType.child;
+                    break;
+                default:
+                    throw '未知标准库';
+            }
+            const stdData = await stdLib.getDataByDataId(data.stdLibId, data.stdNode);
+            switch (addType) {
+                case stdDataAddType.child:
+                    return await relaService.addStdNodeAsChild(this.ctx.tender.id, data.id, stdData);
+                case stdDataAddType.next:
+                    return await relaService.addStdNode(this.ctx.tender.id, data.id, stdData);
+                case stdDataAddType.withParent:
+                    return await relaService.addStdNodeWithParent(this.ctx.tender.id, stdData, stdLib);
+                default:
+                    throw '未知添加方式';
+            }
+        }
+        async _pasteBlock(relaService, data) {
+            if ((isNaN(data.id) || data.id <= 0) ||
+                (!data.tid && data.tid <= 0) ||
+                (!data.block || data.block.length <= 0)) throw '参数错误';
+            return await relaService.pasteBlockData(this.ctx.tender.id, data.id, data.block);
+        }
+        async update(ctx) {
+            try {
+                if (!ctx.tender) throw '项目数据错误';
+
+                const relaService = this.ctx.service.controlPrice;
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.postType || !data.postData) throw '数据错误';
+                const responseData = { err: 0, msg: '', data: {} };
+
+                switch (data.postType) {
+                    case 'add':
+                    case 'delete':
+                    case 'up-move':
+                    case 'down-move':
+                    case 'up-level':
+                    case 'down-level':
+                        responseData.data = await this._billsBase(relaService, data.postType, data.postData);
+                        break;
+                    case 'update':
+                        ctx.helper.checkDgnQtyPrecision(data.postData);
+                        responseData.data = await relaService.updateCalc(ctx.tender.id, data.postData);
+                        break;
+                    case 'add-std':
+                        responseData.data = await this._addStd(relaService, data.postData);
+                        break;
+                    case 'paste-block':
+                        responseData.data = await this._pasteBlock(relaService, data.postData);
+                        break;
+                    default:
+                        throw '未知操作';
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+
+        async uploadExcel(ctx) {
+            try {
+                if (!ctx.tender) throw '项目数据错误';
+
+                const ueType = ctx.params.ueType;
+                const compressData = ctx.request.body.data;
+                const data = JSON.parse(LzString.decompressFromUTF16(compressData));
+                const responseData = { err: 0, msg: '', data: {} };
+                switch (ueType) {
+                    case 'tz':
+                        const templateId = await this.ctx.service.valuation.getValuationTemplate(
+                            this.ctx.tender.data.valuation, this.ctx.tender.data.measure_type);
+                        responseData.data = await this.ctx.service.controlPrice.importExcel(templateId, data.sheet, data.filter);
+                        break;
+                    case 'gcl2xmj':
+                        responseData.data = await this.ctx.service.controlPrice.importGclExcel(data.id, data.sheet);
+                        break;
+                    default:
+                        throw '数据错误';
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+    }
+
+    return CtrlPriceController;
+};

+ 50 - 0
app/controller/settle_controller.js

@@ -0,0 +1,50 @@
+'use strict';
+
+/**
+ * 过程结算相关控制器
+ *
+ * @author Mai
+ * @date 2023/10/27
+ * @version
+ */
+
+
+module.exports = app => {
+
+    class SettleController extends app.BaseController {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            ctx.showProject = true;
+            ctx.showTender = true;
+            ctx.showTitle = true;
+        }
+
+        /**
+         * 期列表(Get)
+         * @param ctx
+         * @return {Promise<void>}
+         */
+        async list(ctx) {
+            try {
+                const renderData = {
+                    tender: ctx.tender.data,
+                    preUrl: `/tender/${ctx.tender.id}/measure/stage`,
+                };
+                renderData.settles = await ctx.service.settle.getValidSettle(ctx.tender.id);
+                await this.layout('settle/list.ejs', renderData, 'settle/list_modal.ejs');
+            } catch (err) {
+                this.log(err);
+                ctx.redirect(this.menu.menu.dashboard.url);
+            }
+        }
+    }
+
+    return SettleController;
+};

+ 786 - 0
app/public/js/ctrl_price.js

@@ -0,0 +1,786 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const invalidFields = {
+    parent: ['quantity', 'total_price', 'unit_price'],
+    gcl: ['dgn_qty1', 'dgn_qty2', 'total_price'],
+    xmj: ['quantity', 'unit_price'],
+};
+$(document).ready(() => {
+    const copyBlockTag = 'zh.calc.copyBlock';
+    let stdXmj, stdGcl, searchCtrlPrice;
+    autoFlashHeight();
+    const priceSpread = SpreadJsObj.createNewSpread($('#ctrl-price-spread')[0]);
+    const priceSheet = priceSpread.getActiveSheet();
+    sjsSettingObj.setFxTreeStyle(spreadSetting, sjsSettingObj.FxTreeStyle.jz);
+    spreadSetting.readOnly = readOnly;
+    SpreadJsObj.initSheet(priceSheet, spreadSetting);
+
+    $.subMenu({
+        menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
+        toMenu: '#to-menu', toMiniMenu: '#to-mini-menu',
+        key: 'menu.1.0.0',
+        miniHint: '#sub-mini-hint', hintKey: 'menu.hint.1.0.1',
+        callback: function (info) {
+            if (info.mini) {
+                $('.panel-title').addClass('fluid');
+                $('#sub-menu').removeClass('panel-sidebar');
+            } else {
+                $('.panel-title').removeClass('fluid');
+                $('#sub-menu').addClass('panel-sidebar');
+            }
+            autoFlashHeight();
+            priceSpread.refresh();
+        }
+    });
+    const priceTree = createNewPathTree('ledger', {
+        id: 'tree_id',
+        pid: 'tree_pid',
+        order: 'order',
+        level: 'level',
+        rootId: -1,
+        keys: ['id', 'tid', 'tree_id'],
+        autoExpand: 3,
+        markExpandKey: 'price-expand',
+        markExpandSubKey: window.location.pathname.split('/')[2],
+        calcFields: ['total_price'],
+        calcFun: function (node) {
+            node.dgn_price = ZhCalc.div(node.total_price, node.dgn_qty1, 2);
+        },
+    });
+
+    $.divResizer({
+        select: '#right-spr',
+        callback: function () {
+            priceSpread.refresh();
+            if (stdXmj) stdXmj.spread.refresh();
+            if (stdGcl) stdGcl.spread.refresh();
+            if (searchCtrlPrice) searchCtrlPrice.spread.refresh();
+        }
+    });
+
+    const priceTreeOpr = {
+        refreshTree: function (sheet, data) {
+            SpreadJsObj.massOperationSheet(sheet, function () {
+                const tree = sheet.zh_tree;
+                // 处理删除
+                if (data.delete) {
+                    data.delete.sort(function (a, b) {
+                        return b.deleteIndex - a.deleteIndex;
+                    });
+                    for (const d of data.delete) {
+                        sheet.deleteRows(d.deleteIndex, 1);
+                    }
+                }
+                // 处理新增
+                if (data.create) {
+                    const newNodes = data.create;
+                    if (newNodes) {
+                        newNodes.sort(function (a, b) {
+                            return a.index - b.index;
+                        });
+
+                        for (const node of newNodes) {
+                            sheet.addRows(node.index, 1);
+                            SpreadJsObj.reLoadRowData(sheet, tree.nodes.indexOf(node), 1);
+                        }
+                    }
+                }
+                // 处理更新
+                if (data.update) {
+                    const rows = [];
+                    for (const u of data.update) {
+                        rows.push(tree.nodes.indexOf(u));
+                    }
+                    SpreadJsObj.reLoadRowsData(sheet, rows);
+                }
+                // 处理展开
+                if (data.expand) {
+                    const expanded = [];
+                    for (const e of data.expand) {
+                        if (expanded.indexOf(e) === -1) {
+                            const posterity = tree.getPosterity(e);
+                            for (const p of posterity) {
+                                sheet.setRowVisible(tree.nodes.indexOf(p), p.visible);
+                                expanded.push(p);
+                            }
+                        }
+                    }
+                }
+            });
+        },
+        getDefaultSelectInfo: function (sheet) {
+            const tree = sheet.zh_tree;
+            if (!tree) return;
+            const sel = sheet.getSelections()[0];
+            const node = sheet.zh_tree.nodes[sel.row];
+            if (!node) return;
+            let count = 1;
+            if (sel.rowCount > 1) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = sheet.zh_tree.nodes[sel.row + r];
+                    if (rNode.level > node.level) continue;
+                    if ((rNode.level < node.level) || (rNode.level === node.level && rNode.pid !== node.pid)) {
+                        toastr.warning('请选择同一节点下的节点,进行该操作');
+                        return;
+                    }
+                    count += 1;
+                }
+            }
+            return [tree, node, count];
+        },
+        /**
+         * 刷新顶部按钮是否可用
+         * @param sheet
+         * @param selections
+         */
+        refreshOperationValid: function (sheet, selection) {
+            const setObjEnable = function (obj, enable) {
+                if (enable) {
+                    obj.removeClass('disabled');
+                } else {
+                    obj.addClass('disabled');
+                }
+            };
+            const invalidAll = function () {
+                setObjEnable($('a[name=base-opr][type=add]'), false);
+                setObjEnable($('a[name=base-opr][type=delete]'), false);
+                setObjEnable($('a[name=base-opr][type=up-move]'), false);
+                setObjEnable($('a[name=base-opr][type=down-move]'), false);
+                setObjEnable($('a[name=base-opr][type=up-level]'), false);
+                setObjEnable($('a[name=base-opr][type=down-level]'), false);
+            };
+            const sel = selection ? selection[0] : sheet.getSelections()[0];
+            const row = sel ? sel.row : -1;
+            const tree = sheet.zh_tree;
+            if (!tree) {
+                invalidAll();
+                return;
+            }
+            const first = sheet.zh_tree.nodes[row];
+            if (!first) {
+                invalidAll();
+                return;
+            }
+            let last = first, sameParent = true;
+            if (sel.rowCount > 1 && first) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = tree.nodes[sel.row + r];
+                    if (!rNode) {
+                        sameParent = false;
+                        break;
+                    }
+                    if (rNode.level > first.level) continue;
+                    if ((rNode.level < first.level) || (rNode.level === first.level && rNode.pid !== first.pid)) {
+                        sameParent = false;
+                        break;
+                    }
+                    last = rNode;
+                }
+            }
+            const preNode = tree.getPreSiblingNode(first);
+            const valid = !readOnly;
+
+            setObjEnable($('a[name=base-opr][type=add]'), valid && first && first.level > 1);
+            setObjEnable($('a[name=base-opr][type=delete]'), valid && first && sameParent && first.level > 1);
+            setObjEnable($('a[name=base-opr][type=up-move]'), valid && first && sameParent && first.level > 1 && preNode);
+            setObjEnable($('a[name=base-opr][type=down-move]'), valid && first && sameParent && first.level > 1 && !tree.isLastSibling(last));
+            setObjEnable($('a[name=base-opr][type=up-level]'), valid && first && sameParent && tree.getParent(first) && first.level > 2 && tree.isLastSibling(last));
+            setObjEnable($('a[name=base-opr][type=down-level]'), valid && first && sameParent && first.level > 1 && preNode);
+        },
+        selectionChanged: function (e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    priceTreeOpr.refreshOperationValid(info.sheet);
+                }
+            }
+        },
+        /**
+         * 新增节点
+         * @param spread
+         */
+        baseOpr: function (sheet, type, addCount = 1) {
+            const self = this;
+            const [tree, node, count] = this.getDefaultSelectInfo(sheet);
+            if (!tree || !node || !count) return;
+
+            if (type === 'delete') {
+                deleteAfterHint(function () {
+                    postData(window.location.pathname + '/update', {
+                        postType: type,
+                        postData: { id: node.tree_id, count: type === 'add' ? addCount : count }
+                    }, function (result) {
+                        const refreshData = tree.loadPostData(result);
+                        self.refreshTree(sheet, refreshData);
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(sel.row, sel.col, 1, sel.colCount);
+                        }
+                        self.refreshOperationValid(sheet);
+                    });
+                });
+            } else {
+                postData(window.location.pathname + '/update', {
+                    postType: type,
+                    postData: { id: node.tree_id, count: type === 'add' ? addCount : count }
+                }, function (result) {
+                    const refreshData = tree.loadPostData(result);
+                    self.refreshTree(sheet, refreshData);
+                    if (['up-move', 'down-move'].indexOf(type) > -1) {
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(node), sel.col, sel.rowCount, sel.colCount);
+                            SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, tree.nodes.indexOf(node)]);
+                        }
+                    } else if (type === 'add') {
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(refreshData.create[0]), sel.col, sel.rowCount, sel.colCount);
+                            SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, tree.nodes.indexOf(refreshData.create[0])]);
+                        }
+                    }
+                    self.refreshOperationValid(sheet);
+                });
+            }
+        },
+        editStarting(e, info) {
+            if (!info.sheet.zh_setting || !info.sheet.zh_tree) return;
+            const col = info.sheet.zh_setting.cols[info.col];
+            const node = info.sheet.zh_tree.nodes[info.row];
+            if (!node) {
+                info.cancel = true;
+                return;
+            }
+            switch (col.field) {
+                case 'unit_price':
+                case 'quantity':
+                    info.cancel = (node.children && node.children.length > 0) || !node.b_code;
+                    break;
+                case 'total_price':
+                    info.cancel = (node.children && node.children.length > 0) || node.b_code;
+                    break;
+                case 'dgn_price':
+                    info.cance = false;
+                    break;
+                case 'dgn_qty1':
+                case 'dgn_qty2':
+                    info.cancel = !_.isEmpty(node.b_code);
+                    break;
+            }
+        },
+        /**
+         * 编辑单元格响应事件
+         * @param {Object} e
+         * @param {Object} info
+         */
+        editEnded: function (e, info) {
+            if (info.sheet.zh_setting) {
+                const col = info.sheet.zh_setting.cols[info.col];
+                const sortData = info.sheet.zh_tree.nodes;
+                const node = sortData[info.row];
+                const data = { id: node.id, tid: node.tid, tree_id: node.tree_id };
+                // 未改变值则不提交
+                const orgValue = node[col.field];
+                const newValue = trimInvalidChar(info.editingText);
+                if (orgValue == info.editingText || ((!orgValue || orgValue === '') && (newValue === ''))) return;
+
+                if (node.b_code && invalidFields.gcl.indexOf(col.field) >=0) {
+                    toastr.error('工程量清单请勿输入设计数量、金额');
+                    return;
+                }
+
+                // 获取更新数据
+                if (newValue) {
+                    if (col.type === 'Number') {
+                        const num = _.toNumber(newValue);
+                        if (_.isNaN(num)) {
+                            toastr.error('请输入数字');
+                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                            return;
+                        }
+                        data[col.field] = num;
+                    } else {
+                        data[col.field] = newValue;
+                    }
+                } else {
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                }
+                console.log(data);
+                // 更新至服务器
+                postData(window.location.pathname + '/update', {postType: 'update', postData: data}, function (result) {
+                    const refreshNode = priceTree.loadPostData(result);
+                    priceTreeOpr.refreshTree(info.sheet, refreshNode);
+                });
+            }
+        },
+        clipboardPasting: function (e, info) {
+            const tree = info.sheet.zh_tree, setting = info.sheet.zh_setting;
+            info.cancel = true;
+            if (!setting || !tree) return;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const hint = {
+                invalidNum: {type: 'warning', msg: '粘贴的数字非法'},
+                parent: {type: 'warning', msg: `含有子项,不可粘贴${needGcl ? '数量、单价、' : ''}金额`},
+                gcl: {type: 'warning', msg: '工程量清单,不可粘贴项目节数量、金额'},
+                xmj: {type: 'warning', msg: '项目节,不可粘贴数量、单价'},
+            };
+            const datas = [], filterNodes = [];
+
+            let filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = tree.nodes[curRow];
+                if (!node) continue;
+
+                let bPaste = false;
+                const data = info.sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    const value = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+                    if (node.children && node.children.length > 0 && invalidFields.parent.indexOf(colSetting.field) >= 0) {
+                        toastMessageUniq(hint.parent);
+                        continue;
+                    }
+                    if (!_.isEmpty(node.b_code) && invalidFields.gcl.indexOf(colSetting.field) >= 0) {
+                        toastMessageUniq(hint.gcl);
+                        continue;
+                    }
+                    if (_.isEmpty(node.b_code) && invalidFields.xmj.indexOf(colSetting.field) >= 0) {
+                        toastMessageUniq(hint.xmj);
+                        continue;
+                    }
+
+                    if (colSetting.type === 'Number') {
+                        if (colSetting.type === 'Number') {
+                            const num = _.toNumber(value);
+                            if (_.isNaN(num)) {
+                                toastMessageUniq(hint.invalidExpr);
+                                continue;
+                            }
+                            data[colSetting.field] = num;
+                        } else {
+                            data[colSetting.field] = value;
+                        }
+                    } else {
+                        data[colSetting.field] = value;
+                    }
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData(window.location.pathname + '/update', {postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = tree.loadPostData(result);
+                    if (refreshNode.update) refreshNode.update = refreshNode.update.concat(filterNodes);
+                    priceTreeOpr.refreshTree(info.sheet, refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        },
+        deletePress: function (sheet) {
+            const tree = sheet.zh_tree, setting = sheet.zh_setting;
+            if (!setting || !tree) return;
+
+            const sortData = sheet.zh_tree.nodes;
+            const datas = [];
+            const sel = sheet.getSelections()[0];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                const node = sortData[iRow];
+                if (node) {
+                    let bDel = false;
+                    const data = tree.getNodeKeyData(node);
+                    for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                        const style = sheet.getStyle(iRow, iCol);
+                        if (!style.locked) {
+                            const colSetting = setting.cols[iCol];
+                            data[colSetting.field] = colSetting.type === 'Number' ? 0 : '';
+                            bDel = true;
+                        }
+                    }
+                    if (bDel) datas.push(data);
+                }
+            }
+            if (datas.length > 0) {
+                postData(window.location.pathname + '/update', {postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = tree.loadPostData(result);
+                    priceTreeOpr.refreshTree(sheet, refreshNode);
+                });
+            }
+        },
+        pasteBlock: function (sheet, copyInfo) {
+            const self = this;
+            const [tree, node] = this.getDefaultSelectInfo(sheet);
+
+            postData(window.location.pathname + '/update', {
+                postType: 'paste-block',
+                postData: {
+                    id: tree.getNodeKey(node),
+                    tid: copyInfo.tid,
+                    block: copyInfo.block,
+                }
+            }, function (data) {
+                const result = tree.loadPostData(data);
+                self.refreshTree(sheet, result);
+                const sel = sheet.getSelections()[0];
+                if (sel) {
+                    sheet.setSelection(tree.nodes.indexOf(result.create[0]), sel.col, sel.rowCount, sel.colCount);
+                    SpreadJsObj.reloadRowsBackColor(sheet, [sel.row, tree.nodes.indexOf(result.create[0])]);
+                }
+                self.refreshOperationValid(sheet);
+                removeLocalCache(copyBlockTag);
+            }, null, true);
+        }
+    };
+    priceSpread.bind(spreadNS.Events.SelectionChanged, priceTreeOpr.selectionChanged);
+    if (!readOnly) {
+        // 增删上下移升降级
+        $('a[name="base-opr"]').click(function () {
+            priceTreeOpr.baseOpr(priceSheet, this.getAttribute('type'));
+        });
+        priceSpread.bind(spreadNS.Events.EditStarting, priceTreeOpr.editStarting);
+        priceSpread.bind(spreadNS.Events.EditEnded, priceTreeOpr.editEnded);
+        priceSpread.bind(spreadNS.Events.ClipboardPasting, priceTreeOpr.clipboardPasting);
+        SpreadJsObj.addDeleteBind(priceSpread, priceTreeOpr.deletePress);
+        $.contextMenu({
+            selector: '#budget-spread',
+            build: function ($trigger, e) {
+                const target = SpreadJsObj.safeRightClickSelection($trigger, e, priceSpread);
+                return target.hitTestType === spreadNS.SheetArea.viewport || target.hitTestType === spreadNS.SheetArea.rowHeader;
+            },
+            items: {
+                copyBlock: {
+                    name: '复制整块',
+                    icon: 'fa-files-o',
+                    callback: function (key, opt) {
+                        const copyBlockList = [];
+                        const sheet = priceSheet;
+                        const sel = sheet.getSelections()[0];
+                        let iRow = sel.row;
+                        const pid = sheet.zh_tree.nodes[iRow].tree_pid;
+                        while (iRow < sel.row + sel.rowCount) {
+                            const node = sheet.zh_tree.nodes[iRow];
+                            if (node.tree_pid !== pid) {
+                                toastr.error('仅可同时选中同层节点');
+                                return;
+                            }
+                            const posterity = sheet.zh_tree.getPosterity(node);
+                            iRow += posterity.length + 1;
+                            posterity.unshift(node);
+                            copyBlockList.push(sheet.zh_tree.getDefaultData(posterity));
+                        }
+                        setLocalCache(copyBlockTag, JSON.stringify({ block: copyBlockList }));
+                    },
+                    visible: function (key, opt) {
+                        const select = SpreadJsObj.getSelectObject(priceSheet);
+                        return !!select;
+                    },
+                    disabled: function (key, opt) {
+                        const select = SpreadJsObj.getSelectObject(priceSheet);
+                        return !!select && select.level <= 1;
+                    }
+                },
+                copyBlockXmj: {
+                    name: '复制整块(只复制项目节)',
+                    icon: 'fa-files-o',
+                    callback: function (key, opt) {
+                        const copyBlockList = [];
+                        const sheet = priceSheet;
+                        const sel = sheet.getSelections()[0];
+                        let iRow = sel.row;
+                        const pid = sheet.zh_tree.nodes[iRow].ledger_pid;
+                        while (iRow < sel.row + sel.rowCount) {
+                            const node = sheet.zh_tree.nodes[iRow];
+                            if (node.ledger_pid !== pid) {
+                                toastr.error('仅可同时选中同层节点');
+                                return;
+                            }
+                            const posterity = sheet.zh_tree.getPosterity(node);
+                            iRow += posterity.length + 1;
+                            const copyPosterity = posterity.filter(x => { return !x.b_code; });
+                            copyPosterity.unshift(node);
+                            const copyData = sheet.zh_tree.getDefaultData(copyPosterity);
+                            for (const p of copyData) {
+                                const children = copyData.filter(y => {return y.tree_pid === p.tree_id}) || [];
+                                p.is_leaf = children.length === 0;
+                            }
+                            copyBlockList.push(copyData);
+                        }
+                        setLocalCache(copyBlockTag, JSON.stringify({ block: copyBlockList }));
+                    },
+                    visible: function (key, opt) {
+                        const select = SpreadJsObj.getSelectObject(priceSheet);
+                        return needGcl && !!select;
+                    },
+                    disabled: function (key, opt) {
+                        const select = SpreadJsObj.getSelectObject(priceSheet);
+                        return !!select && select.level <= 1;
+                    }
+                },
+                pasteBlock: {
+                    name: '粘贴整块',
+                    icon: 'fa-clipboard',
+                    disabled: function (key, opt) {
+                        const copyInfo = JSON.parse(getLocalCache(copyBlockTag));
+                        return !(copyInfo && copyInfo.block && copyInfo.block.length > 0);
+                    },
+                    callback: function (key, opt) {
+                        const copyInfo = JSON.parse(getLocalCache(copyBlockTag));
+                        if (copyInfo.block.length > 0) {
+                            priceTreeOpr.pasteBlock(priceSheet, copyInfo);
+                        } else {
+                            document.execCommand('paste');
+                        }
+                    },
+                    visible: function (key, opt) {
+                        return !readOnly;
+                    }
+                }
+            }
+        });
+    }
+    postData(window.location.pathname + '/load', {}, function (result) {
+        priceTree.loadDatas(result);
+        treeCalc.calculateAll(priceTree);
+        SpreadJsObj.loadSheetData(priceSheet, SpreadJsObj.DataType.Tree, priceTree);
+        priceTreeOpr.refreshOperationValid(priceSheet);
+    });
+
+    const stdLibCellDoubleClick = function (e, info) {
+        const stdSheet = info.sheet;
+        if (!stdSheet.zh_setting || !stdSheet.zh_tree || !priceSheet.zh_tree) return;
+
+        const stdTree = stdSheet.zh_tree;
+        const stdNode = stdTree.nodes[info.row];
+        if (!stdNode) return;
+
+        const priceTree = priceSheet.zh_tree;
+        const sel = priceSheet.getSelections()[0];
+        const mainNode = priceTree.nodes[sel.row];
+        if (info.sheet.zh_setting.stdType === 'gcl') {
+            if (mainNode.code && mainNode.code !== '' && !priceTree.isLeafXmj(mainNode)) {
+                toastr.warning('非最底层项目下,不应添加清单');
+                return;
+            }
+        }
+
+        postData(window.location.pathname + '/update', {
+            postType: 'add-std',
+            postData: {
+                id: priceTree.getNodeKey(mainNode),
+                tender_id: mainNode.tender_id,
+                stdType: info.sheet.zh_setting.stdType,
+                stdLibId: stdNode.list_id,
+                stdNode: stdTree.getNodeKey(stdNode)
+            }
+        }, function (result) {
+            const refreshNode = priceTree.loadPostData(result);
+            priceTreeOpr.refreshTree(priceSheet, refreshNode);
+            if (refreshNode.create && refreshNode.create.length > 0) {
+                priceSheet.setSelection(refreshNode.create[refreshNode.create.length - 1].index, sel.col, sel.rowCount, sel.colCount);
+                SpreadJsObj.reloadRowsBackColor(priceSheet, [sel.row, priceTree.nodes.indexOf(mainNode), refreshNode.create[refreshNode.create.length - 1].index]);
+            } else {
+                const node = _.find(priceTree.nodes, {code: stdNode.code, name: stdNode.name});
+                if (node) {
+                    priceSheet.setSelection(priceTree.nodes.indexOf(node), sel.col, sel.rowCount, sel.colCount);
+                    SpreadJsObj.reloadRowsBackColor(priceSheet, [sel.row, priceTree.nodes.indexOf(node)]);
+                }
+            }
+            priceTreeOpr.refreshOperationValid(priceSheet);
+            priceSpread.focus();
+        });
+    };
+    const stdXmjSetting = {
+        selector: '#std-xmj',
+        stdType: 'xmj',
+        libs: stdChapters,
+        treeSetting: {
+            id: 'chapter_id',
+            pid: 'pid',
+            order: 'order',
+            level: 'level',
+            rootId: -1,
+            keys: ['id', 'list_id', 'chapter_id'],
+        },
+        spreadSetting: {
+            cols: [
+                {title: '项目节编号', field: 'code', hAlign: 0, width: 120, formatter: '@', cellType: 'tree'},
+                {title: '名称', field: 'name', hAlign: 0, width: 150, formatter: '@'},
+                {title: '单位', field: 'unit', hAlign: 1, width: 50, formatter: '@'}
+            ],
+            treeCol: 0,
+            emptyRows: 0,
+            headRows: 1,
+            headRowHeight: [32],
+            defaultRowHeight: 21,
+            headerFont: '12px 微软雅黑',
+            font: '12px 微软雅黑',
+            headColWidth: [30],
+            selectedBackColor: '#fffacd',
+            readOnly: true,
+        },
+        cellDoubleClick: !readOnly ? stdLibCellDoubleClick : null,
+        page: 'ctrlPrice',
+        tid: window.location.pathname.split('/')[2],
+    };
+    const stdGclSetting = {
+        selector: '#std-gcl',
+        stdType: 'gcl',
+        libs: stdBills,
+        treeSetting: {
+            id: 'bill_id',
+            pid: 'pid',
+            order: 'order',
+            level: 'level',
+            rootId: -1,
+            keys: ['id', 'list_id', 'bill_id']
+        },
+        spreadSetting: {
+            cols: [
+                {title: '清单编号', field: 'b_code', hAlign: 0, width: 120, formatter: '@', cellType: 'tree'},
+                {title: '名称', field: 'name', hAlign: 0, width: 150, formatter: '@'},
+                {title: '单位', field: 'unit', hAlign: 1, width: 50, formatter: '@'}
+            ],
+            treeCol: 0,
+            emptyRows: 0,
+            headRows: 1,
+            headRowHeight: [32],
+            defaultRowHeight: 21,
+            headerFont: '12px 微软雅黑',
+            font: '12px 微软雅黑',
+            headColWidth: [30],
+            selectedBackColor: '#fffacd',
+            readOnly: true,
+        },
+        cellDoubleClick: !readOnly ? stdLibCellDoubleClick : null,
+        page: 'ctrlPrice',
+        tid: window.location.pathname.split('/')[2],
+    };
+    // 展开收起标准清单
+    $('a', 'ul.right-nav').bind('click', function (e) {
+        e.preventDefault();
+        const tab = $(this), tabPanel = $(tab.attr('content'));
+        // 展开工具栏、切换标签
+        if (!tab.hasClass('active')) {
+            $('a', '.side-menu').removeClass('active');
+            $('.tab-content .tab-select-show.tab-pane.active').removeClass('active');
+            tab.addClass('active');
+            tabPanel.addClass('active');
+            showSideTools(tab.hasClass('active'));
+            if (tab.attr('content') === '#std-xmj') {
+                if (!stdXmj) stdXmj = $.stdLib(stdXmjSetting);
+                stdXmj.spread.refresh();
+            } else if (tab.attr('content') === '#std-gcl') {
+                if (!stdGcl) stdGcl = $.stdLib(stdGclSetting);
+                stdGcl.spread.refresh();
+            } else if (tab.attr('content') === '#search') {
+                if (!searchCtrlPrice) {
+                    const searchSetting = {
+                        selector: '#search',
+                        searchSpread: priceSpread,
+                        keyId: 'tree_id',
+                        resultSpreadSetting: {
+                            cols: [
+                                {title: '项目节编号', field: 'code', hAlign: 0, width: 120, formatter: '@'},
+                                {title: '清单编号', field: 'b_code', hAlign: 0, width: 80, formatter: '@'},
+                                {title: '名称', field: 'name', width: 150, hAlign: 0, formatter: '@'},
+                                {title: '单位', field: 'unit', width: 50, hAlign: 1, formatter: '@'},
+                            ],
+                            emptyRows: 0,
+                            headRows: 1,
+                            headRowHeight: [32],
+                            headColWidth: [30],
+                            defaultRowHeight: 21,
+                            headerFont: '12px 微软雅黑',
+                            font: '12px 微软雅黑',
+                            selectedBackColor: '#fffacd',
+                            readOnly: true,
+                        },
+                    };
+                    if (!needGcl) {
+                        searchSetting.resultSpreadSetting.cols = searchSetting.resultSpreadSetting.cols.filter(x => {
+                            return ['b_code', 'quantity', 'unit_price'].indexOf(x.field) < 0;
+                        });
+                        searchSetting.searchRangeStr = '项目节编号/名称';
+                    } else {
+                        searchSetting.searchRangeStr = '项目节编号/清单编号/名称';
+                    }
+                    searchCtrlPrice = $.billsSearch(searchSetting);
+                }
+                searchCtrlPrice.spread.refresh();
+            }
+        } else { // 收起工具栏
+            tab.removeClass('active');
+            tabPanel.removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        }
+        priceSpread.refresh();
+    });
+    // 显示层次
+    (function (select, sheet) {
+        $(select).click(function () {
+            if (!sheet.zh_tree) return;
+            const tag = $(this).attr('tag');
+            const tree = sheet.zh_tree;
+            setTimeout(() => {
+                showWaitingView();
+                switch (tag) {
+                    case "1":
+                    case "2":
+                    case "3":
+                    case "4":
+                    case "5":
+                        tree.expandByLevel(parseInt(tag));
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                    case "last":
+                        tree.expandByCustom(() => { return true; });
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                    case "leafXmj":
+                        tree.expandToLeafXmj();
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                }
+                closeWaitingView();
+            }, 100);
+        });
+    })('a[name=showLevel]', priceSheet);
+    // 导入
+    $('#budget-import').click(() => {
+        importExcel.doImport({
+            template: {
+                hint: '导入Excel',
+                url: '/template/估概预算示例EXCEL格式.xlsx',
+            },
+            filter: needGcl,
+            callback: function (sheet, filter) {
+                postDataCompress(window.location.pathname + '/upload-excel/tz', {sheet, filter}, function (result) {
+                    priceTree.loadDatas(result);
+                    treeCalc.calculateAll(priceTree);
+                    SpreadJsObj.reLoadSheetData(priceSheet);
+                    checkShowLast(result.length);
+                }, null);
+            },
+        });
+    });
+});

+ 9 - 0
app/router.js

@@ -185,6 +185,12 @@ module.exports = app => {
     app.post('/tender/:id/load', sessionAuth, tenderCheck, 'tenderController.loadData');
     app.post('/tender/:id/saveRela', sessionAuth, tenderCheck, 'tenderController.saveRelaData');
 
+    app.get('/tender/:id/ctrl-price', sessionAuth, tenderCheck, 'ctrlPriceController.index');
+    app.post('/tender/:id/ctrl-price/load', sessionAuth, tenderCheck, 'ctrlPriceController.load');
+    app.post('/tender/:id/ctrl-price/update', sessionAuth, tenderCheck, 'ctrlPriceController.update');
+    app.post('/tender/:id/ctrl-price/upload-excel/:ueType', sessionAuth, tenderCheck, 'ctrlPriceController.uploadExcel');
+
+
     // 预付款
     app.get('/tender/:id/advance/:type', sessionAuth, tenderCheck, 'advanceController.index');
     // app.get('/tender/:id/advance/material', sessionAuth, tenderCheck, 'advanceController.materialList');
@@ -414,6 +420,9 @@ module.exports = app => {
     app.get('/tender/:id/measure/stage/:order/manager', sessionAuth, tenderCheck, uncheckTenderCheck, stageCheck, 'stageController.manager');
     app.post('/tender/:id/measure/stage/:order/manager/audit/delete', sessionAuth, tenderCheck, uncheckTenderCheck, stageCheck, tenderBuildCheck, 'stageController.managerAuditDelete');
 
+    // 过程结算
+    app.get('/tender/:id/settle', sessionAuth, tenderCheck, uncheckTenderCheck, 'settleController.list');
+
     // 报表
     app.get('/tender/:id/report', sessionAuth, tenderCheck, uncheckTenderCheck, 'reportController.index');
     app.get('/tender/:id/measure/stage/:order/report', sessionAuth, tenderCheck, uncheckTenderCheck, stageCheck, 'reportController.index');

+ 475 - 0
app/service/control_price.js

@@ -0,0 +1,475 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+
+const TreeService = require('../base/base_tree_service');
+const billsUtils = require('../lib/bills_utils');
+const readOnlyFields = ['id', 'tid', 'tree_id', 'tree_pid', 'order', 'level', 'full_path', 'is_leaf'];
+const calcFields = ['unit_price', 'quantity', 'total_price'];
+
+class ControlPrice extends TreeService {
+
+    /**
+     * 构造函数
+     *
+     * @param {Object} ctx - egg全局变量
+     * @param {String} tableName - 表名
+     * @return {void}
+     */
+    constructor(ctx) {
+        super(ctx, {
+            mid: 'tid',
+            kid: 'tree_id',
+            pid: 'tree_pid',
+            order: 'order',
+            level: 'level',
+            isLeaf: 'is_leaf',
+            fullPath: 'full_path',
+            keyPre: 'ctrl_price_maxLid:',
+            uuid: true,
+        });
+        this.tableName = 'control_price';
+    }
+
+    async checkInit(tender) {
+        const count = await this.count({ tid: tender.id });
+        if (count > 0) return;
+
+        const templateId = await this.ctx.service.valuation.getValuationTemplate(
+            this.ctx.tender.data.valuation, this.ctx.tender.data.measure_type);
+        await this.initByTemplate(tender.id, templateId);
+    }
+
+    async initByTemplate(tenderId, templateId){
+        if (tenderId <= 0) throw '标段数据错误';
+        const data = await this.ctx.service.tenderNodeTemplate.getData(templateId);
+        if (!data.length) throw '模板数据有误';
+
+        // 整理数据
+        const insertData = [];
+        for (const tmp of data) {
+            insertData.push({
+                id: this.uuid.v4(),
+                tid: tenderId,
+                tree_id: tmp.template_id,
+                tree_pid: tmp.pid,
+                level: tmp.level,
+                order: tmp.order,
+                full_path: tmp.full_path,
+                is_leaf: tmp.is_leaf,
+                code: tmp.code,
+                name: tmp.name,
+                unit: tmp.unit,
+                node_type: tmp.node_type,
+            });
+        }
+        const operate = await this.db.insert(this.tableName, insertData);
+        return operate.affectedRows === data.length;
+    }
+
+    async addChild(tenderId, selectId, data) {
+        if ((tenderId <= 0) || (selectId <= 0)) return [];
+        const selectData = await this.getDataByKid(tenderId, selectId);
+        if (!selectData) {
+            throw '新增节点数据错误';
+        }
+        const children = await this.getChildrenByParentId(tenderId, selectId);
+
+        const maxId = await this._getMaxLid(tenderId);
+
+        data.id = this.uuid.v4();
+        data.tid = tenderId;
+        data.tree_id = maxId + 1;
+        data.tree_pid = selectData.tree_id;
+        data.level = selectData.level + 1;
+        data.order = children.length + 1;
+        data.full_path = selectData.full_path + '-' + data.tree_id;
+        data.is_leaf = true;
+
+        this.transaction = await this.db.beginTransaction();
+        try {
+            const result = await this.transaction.insert(this.tableName, data);
+            if (children.length === 0) {
+                await this.transaction.update(this.tableName,
+                    { is_leaf: false, quantity: 0, unit_price: 0, total_price: 0 },
+                    { where: { tid: tenderId, tree_id: selectData.tree_id } });
+            }
+            await this.transaction.commit();
+        } catch(err) {
+            this.transaction.rollback();
+            throw err;
+        }
+
+        this._cacheMaxLid(tenderId, maxId + 1);
+
+        // 查询应返回的结果
+        const resultData = {};
+        resultData.create = await this.getDataByKid(tenderId, data.tree_id);
+        if (children.length === 0) resultData.update = await this.getDataByKid(tenderId, selectId);
+        return resultData;
+    }
+
+    _filterStdData(stdData) {
+        const result = {
+            name: stdData.name,
+            unit: stdData.unit,
+            node_type: stdData.node_type,
+        };
+        result.code = stdData.code ? stdData.code : '';
+        result.b_code = stdData.b_code ? stdData.b_code : '';
+        return result;
+    }
+
+    async _addChildNodeData(tenderId, parentData, data) {
+        if (tenderId <= 0) return undefined;
+        if (!data) data = {};
+        const pid = parentData ? parentData.tree_id : this.rootId;
+
+        const maxId = await this._getMaxLid(tenderId);
+
+        data.id = this.uuid.v4();
+        data.tid = tenderId;
+        data.tree_id = maxId + 1;
+        data.tree_pid = pid;
+        if (data.order === undefined) data.order = 1;
+        data.level = parentData ? parentData.level + 1 : 1;
+        data.full_path = parentData ? parentData.full_path + '-' + data.tree_id : '' + data.tree_id;
+        if (data.is_leaf === undefined) data.is_leaf = true;
+        const result = await this.transaction.insert(this.tableName, data);
+
+        this._cacheMaxLid(tenderId, maxId + 1);
+
+        return [result, data];
+    }
+
+    async _addChildAutoOrder(tenderId, parentData, data) {
+        const findPreData = function(list, a) {
+            if (!list || list.length === 0) { return null; }
+            for (let i = 0, iLen = list.length; i < iLen; i++) {
+                if (billsUtils.compareCode(list[i].code, a.code) > 0) {
+                    return i > 0 ? list[i - 1] : null;
+                }
+            }
+            return list[list.length - 1];
+        };
+
+        const pid = parentData ? parentData.tree_id : this.rootId;
+        const children = await this.getChildrenByParentId(tenderId, pid);
+        const preData = findPreData(children, data);
+        if (!preData || children.indexOf(preData) < children.length - 1) {
+            await this._updateChildrenOrder(tenderId, pid, preData ? preData.order + 1 : 1);
+        }
+        data.order = preData ? preData.order + 1 : 1;
+        const [addResult, node] = await this._addChildNodeData(tenderId, parentData, data);
+
+        return [addResult, node];
+    }
+
+    async addStdNodeWithParent(tenderId, stdData, stdLib) {
+        // 查询完整标准清单,并按层次排序
+        const fullLevel = await stdLib.getFullLevelDataByFullPath(stdData.list_id, stdData.full_path);
+        fullLevel.sort(function(x, y) {
+            return x.level - y.level;
+        });
+
+        let isNew = false,
+            node,
+            firstNew,
+            updateParent,
+            addResult;
+        const expandIds = [];
+        this.transaction = await this.db.beginTransaction();
+        try {
+            // 从最顶层节点依次查询是否存在,否则添加
+            for (let i = 0, len = fullLevel.length; i < len; i++) {
+                const stdNode = fullLevel[i];
+
+                if (isNew) {
+                    const newData = this._filterStdData(stdNode);
+                    newData.is_leaf = (i === len - 1);
+                    [addResult, node] = await this._addChildNodeData(tenderId, node, newData);
+                } else {
+                    const parent = node;
+                    node = await this.getDataByCondition({
+                        tid: tenderId,
+                        tree_pid: parent ? parent.tree_id : this.rootId,
+                        code: stdNode.code,
+                        name: stdNode.name,
+                    });
+                    if (!node) {
+                        isNew = true;
+                        const newData = this._filterStdData(stdNode);
+                        newData.is_leaf = (i === len - 1);
+                        [addResult, node] = await this._addChildAutoOrder(tenderId, parent, newData);
+                        if (parent && parent.is_leaf) {
+                            await this.transaction.update(this.tableName, { id: parent.id, is_leaf: false,
+                                unit_price: 0, quantity: 0, total_price: 0});
+                            updateParent = parent;
+                        }
+                        firstNew = node;
+                    } else {
+                        expandIds.push(node.tree_id);
+                    }
+                }
+            }
+            await this.transaction.commit();
+        } catch (err) {
+            await this.transaction.rollback();
+            throw err;
+        }
+
+        // 查询应返回的结果
+        let createData = [],
+            updateData = [];
+        if (firstNew) {
+            createData = await this.getDataByFullPath(tenderId, firstNew.full_path + '%');
+            updateData = await this.getNextsData(tenderId, firstNew.tree_pid, firstNew.order);
+            if (updateParent) {
+                updateData.push(await this.getDataByCondition({ id: updateParent.id }));
+            }
+        }
+        return { create: createData, update: updateData };
+    }
+
+    async addBillsNode(tenderId, selectId, data) {
+        return await this.addNode(tenderId, selectId, data ? data : {});
+    }
+
+    async addStdNode(tenderId, selectId, stdData) {
+        const newData = this._filterStdData(stdData);
+        const result = await this.addBillsNode(tenderId, selectId, newData);
+        return result;
+    }
+
+    async addStdNodeAsChild(tenderId, selectId, stdData) {
+        const newData = this._filterStdData(stdData);
+        const result = await this.addChild(tenderId, selectId, newData);
+        return result;
+    }
+
+    _filterUpdateInvalidField(id, data) {
+        const result = { id };
+        for (const prop in data) {
+            if (readOnlyFields.indexOf(prop) === -1) result[prop] = data[prop];
+        }
+        return result;
+    }
+    _checkCalcField(data) {
+        for (const prop in data) {
+            if (calcFields.indexOf(prop) >= 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    async updateCalc(tenderId, data) {
+        const helper = this.ctx.helper;
+        // 简单验证数据
+        if (tenderId <= 0 || !this.ctx.tender) throw '标段不存在';
+        if (!data) throw '提交数据错误';
+        const datas = data instanceof Array ? data : [data];
+        const ids = [];
+        for (const row of datas) {
+            if (tenderId !== row.tid) throw '提交数据错误';
+            ids.push(row.id);
+        }
+
+        const decimal = this.ctx.tender.info.decimal;
+        const conn = await this.db.beginTransaction();
+        try {
+            for (const row of datas) {
+                const updateNode = await this.getDataById(row.id);
+                if (!updateNode || tenderId !== updateNode.tid || row.tree_id !== updateNode.tree_id) throw '提交数据错误';
+                let updateData;
+
+                // 项目节、工程量清单相关
+                if (row.b_code) {
+                    row.dgn_qty1 = 0;
+                    row.dgn_qty2 = 0;
+                    row.code = '';
+                }
+                if (row.code) row.b_code = '';
+
+                if (this._checkCalcField(row)) {
+                    let calcData = JSON.parse(JSON.stringify(row));
+                    if (row.quantity !== undefined || row.unit_price !== undefined) {
+                        calcData.quantity = row.quantity === undefined ? updateNode.quantity : helper.round(row.quantity, decimal.qty);
+                        calcData.unit_price = row.unit_price === undefined ? updateNode.unit_price : helper.round(row.unit_price, decimal.up);
+                        calcData.total_price = helper.mul(calcData.quantity, calcData.unit_price, decimal.tp);
+                    } else if (row.total_price !== undefined ) {
+                        calcData.quantity = 0;
+                        calcData.unit_price = 0;
+                        calcData.total_price = helper.round(row.total_price, decimal.tp);
+                    }
+                    updateData = this._filterUpdateInvalidField(updateNode.id, this._.defaults(calcData, row));
+                } else {
+                    updateData = this._filterUpdateInvalidField(updateNode.id, row);
+                }
+                await conn.update(this.tableName, updateData);
+            }
+            await conn.commit();
+        } catch (err) {
+            await conn.rollback();
+            throw err;
+        }
+
+        return { update: await this.getDataById(ids) };
+    }
+    async pasteBlockData (tid, sid, pasteData, defaultData) {
+        if ((tid <= 0) || (sid <= 0)) return [];
+
+        if (!pasteData || pasteData.length <= 0) throw '复制数据错误';
+        for (const pd of pasteData) {
+            if (!pd || pd.length <= 0) throw '复制数据错误';
+            pd.sort(function (x, y) {
+                return x.level - y.level
+            });
+            if (pd[0].tree_pid !== pasteData[0][0].tree_pid) throw '复制数据错误:仅可操作同层节点';
+        }
+        const decimal = this.ctx.tender.info.decimal;
+        this.newBills = false;
+        const selectData = await this.getDataByKid(tid, sid);
+        if (!selectData) throw '粘贴数据错误';
+        const newParentPath = selectData.full_path.replace(selectData.tree_id, '');
+
+        const pasteBillsData = [];
+        let maxId = await this._getMaxLid(tid);
+        for (const [i, pd] of pasteData.entries()) {
+            for (const d of pd) {
+                d.children = pd.filter(function (x) {
+                    return x.tree_pid === d.tree_id;
+                });
+            }
+            const pbd = [];
+            for (const [j, d] of pd.entries()) {
+                const newBills = {
+                    id: this.uuid.v4(),
+                    tid: tid,
+                    tree_id: maxId + j + 1,
+                    tree_pid: j === 0 ? selectData.tree_pid : d.tree_pid,
+                    level: d.level + selectData.level - pd[0].level,
+                    order: j === 0 ? selectData.order + i + 1 : d.order,
+                    is_leaf: d.is_leaf,
+                    code: d.code,
+                    b_code: d.b_code,
+                    name: d.name,
+                    unit: d.unit,
+                    source: d.source,
+                    remark: d.remark,
+                    drawing_code: d.drawing_code,
+                    memo: d.memo,
+                    node_type: d.node_type,
+                };
+                for (const c of d.children) {
+                    c.tree_pid = newBills.tree_id;
+                }
+                if (d.b_code && d.is_leaf) {
+                    newBills.dgn_qty1 = 0;
+                    newBills.dgn_qty2 = 0;
+                    newBills.unit_price = this.ctx.helper.round(d.unit_price, decimal.up);
+                    newBills.quantity = this.ctx.helper.round(d.quantity, decimal.qty);
+                    newBills.total_price = this.ctx.helper.mul(newBills.quantity, newBills.unit_price, decimal.tp);
+                } else {
+                    newBills.dgn_qty1 = this.ctx.helper.round(d.dgn_qty1, decimal.qty);
+                    newBills.dgn_qty2 = this.ctx.helper.round(d.dgn_qty2, decimal.qty);
+                    newBills.unit_price = 0;
+                    newBills.quantity = 0;
+                    newBills.total_price = d.is_leaf ? this.ctx.helper.round(d.total_price, decimal.tp) : 0;
+                }
+                if (defaultData) this.ctx.helper._.assignIn(newBills, defaultData);
+                pbd.push(newBills);
+            }
+            for (const d of pbd) {
+                const parent = pbd.find(function (x) {
+                    return x.tree_id === d.tree_pid;
+                });
+                d.full_path = parent
+                    ? parent.full_path + '-' + d.tree_id
+                    : newParentPath + d.tree_id;
+                if (defaultData) this.ctx.helper._.assignIn(pbd, defaultData);
+                pasteBillsData.push(d);
+            }
+            maxId = maxId + pbd.length;
+        }
+
+        this.transaction = await this.db.beginTransaction();
+        try {
+            // 选中节点的所有后兄弟节点,order+粘贴节点个数
+            await this._updateChildrenOrder(tid, selectData.tree_pid, selectData.order + 1, pasteData.length);
+            // 数据库创建新增节点数据
+            if (pasteBillsData.length > 0) await this.transaction.insert(this.tableName, pasteBillsData);
+            this._cacheMaxLid(tid, maxId);
+            await this.transaction.commit();
+        } catch (err) {
+            await this.transaction.rollback();
+            throw err;
+        }
+
+        // 查询应返回的结果
+        const updateData = await this.getNextsData(selectData.tid, selectData.tree_pid, selectData.order + pasteData.length);
+        return { create: pasteBillsData, update: updateData };
+    }
+
+    async importExcel(templateId, excelData, needGcl, filter) {
+        const AnalysisExcel = require('../lib/analysis_excel').AnalysisExcelTree;
+        const analysisExcel = new AnalysisExcel(this.ctx, this.setting, needGcl ? ['code', 'b_code'] : ['code']);
+        const tempData = await this.ctx.service.tenderNodeTemplate.getData(templateId, true);
+        const cacheTree = analysisExcel.analysisData(excelData, tempData, {
+            filterZeroGcl: filter, filterPos: true, filterGcl: !needGcl, filterCalc: true,
+        });
+        const orgMaxId = await this._getMaxLid(this.ctx.tender.id);
+        const conn = await this.db.beginTransaction();
+        try {
+            await conn.delete(this.tableName, { tid: this.ctx.tender.id });
+            const datas = [];
+            for (const node of cacheTree.items) {
+                const data = {
+                    id: node.id,
+                    tid: this.ctx.tender.id,
+                    tree_id: node.tree_id,
+                    tree_pid: node.tree_pid,
+                    level: node.level,
+                    order: node.order,
+                    is_leaf: !node.children || node.children.length === 0,
+                    full_path: node.full_path,
+                    code: node.code || '',
+                    b_code: node.b_code || '',
+                    name: node.name || '',
+                    unit: node.unit || '',
+                    unit_price: !node.children || node.children.length === 0 ? node.unit_price || 0 : 0,
+                    dgn_qty1: node.dgn_qty1 || 0,
+                    dgn_qty2: node.dgn_qty2 || 0,
+                    memo: node.memo,
+                    drawing_code: node.drawing_code,
+                    node_type: node.node_type,
+                    quantity: !node.children || node.children.length === 0 ? node.quantity || 0 : 0,
+                    total_price: !node.children || node.children.length === 0 ? node.total_price || 0 : 0,
+                };
+                datas.push(data);
+            }
+            await conn.insert(this.tableName, datas);
+            await conn.commit();
+            if (orgMaxId) this._cacheMaxLid(this.ctx.tender.id, cacheTree.keyNodeId);
+            return datas;
+        } catch (err) {
+            await conn.rollback();
+            throw err;
+        }
+    }
+
+    async getSumTp(tid) {
+        const sql = 'SELECT Sum(total_price) As total_price FROM ' + this.tableName + ' Where tid = ? and is_leaf = true';
+        const result = await this.db.queryOne(sql, [tid]);
+        return result.total_price;
+    }
+}
+
+module.exports = ControlPrice;

+ 0 - 0
app/view/settle/list.ejs


+ 0 - 0
app/view/settle/list_modal.ejs


+ 96 - 0
app/view/tender/ctrl_price.ejs

@@ -0,0 +1,96 @@
+<% include ./tender_sub_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./tender_sub_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">控制价</div>
+                <div class="d-inline-block">
+                    <div class="dropdown">
+                        <button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <i class="fa fa-list-ol"></i> 显示层级
+                        </button>
+                        <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                            <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                            <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                            <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                            <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                            <a class="dropdown-item" name="showLevel" tag="5" href="javascript: void(0);">第五层</a>
+                            <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                        </div>
+                    </div>
+                </div>
+                <div class="d-inline-block">
+                    <a href="javascript: void(0);" name="base-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="升级"><i class="fa fa-arrow-left" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="降级"><i class="fa fa-arrow-right" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="下移"><i class="fa fa-arrow-down" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                </div>
+                <div class="d-inline-block ml-3">
+                    <a class="btn btn-sm btn-primary" href="javascript: void(0);" id="ctrl-price-import">导入</a>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap pr-46">
+        <div class="c-header p-0">
+        </div>
+        <div class="row w-100 sub-content">
+            <div id="left-view" class="c-body" style="width: 100%">
+                <div class="sjs-height-1" id="ctrl-price-spread">
+                </div>
+            </div>
+            <div id="right-view" class="c-body" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"></div>
+                <div class="tab-content">
+                    <div id="search" class="tab-pane tab-select-show">
+                        <div class="sjs-bar-1">
+                            <div class="input-group input-group-sm pb-1">
+                                <div class="input-group-prepend">
+                                    <div class="input-group-text">
+                                        <input type="radio" name="searchType" id="over"> 超计
+                                    </div>
+                                    <div class="input-group-text">
+                                        <input type="radio" name="searchType" id="empty"> 漏计
+                                    </div>
+                                </div>
+                                <input type="text" class="form-control form-control-sm" placeholder="可查找 项目节编号 / 清单编号 /名称" id="keyword">
+                                <div class="input-group-append">
+                                    <button class="btn btn-outline-secondary btn-sm" type="button" id="searchLedger">搜索</button>
+                                </div>
+                            </div>
+                        </div>
+                        <div id="search-result" class="sjs-sh-1">
+                        </div>
+                    </div>
+                    <div id="std-xmj" class="tab-pane tab-select-show">
+                    </div>
+                    <div id="std-gcl" class="tab-pane tab-select-show">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav">
+                <li class="nav-item">
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" content="#std-xmj" href="javascript: void(0);">项目节</a>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" content="#std-gcl" href="javascript: void(0);">工程量清单</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+<script>
+    const stdChapters = JSON.parse(unescape('<%- escape(JSON.stringify(stdChapters)) %>'));
+    const stdBills = JSON.parse(unescape('<%- escape(JSON.stringify(stdBills)) %>'));
+    const readOnly = false;
+    const spreadSetting = JSON.parse('<%- JSON.stringify(spreadSetting) %>');
+    let decimal = JSON.parse('<%- JSON.stringify(ctx.tender.info.decimal) %>');
+</script>

+ 12 - 1
app/view/tender/detail_modal.ejs

@@ -72,7 +72,7 @@
                                             <input type="text" class="form-control" value="" id="deal-type">
                                         </div>
                                     </div>
-                                    <div class="col-12">
+                                    <div class="col-12 mb-2">
                                         <div class="input-group input-group-sm">
                                             <div class="input-group-prepend">
                                                 <span class="input-group-text" style="width:90px">结算书编号</span>
@@ -80,6 +80,14 @@
                                             <input type="text" class="form-control" value="" id="final-code">
                                         </div>
                                     </div>
+                                    <div class="col-12 mb-2">
+                                        <div class="input-group input-group-sm">
+                                            <div class="input-group-prepend">
+                                                <span class="input-group-text" style="width:90px">概算批复编号</span>
+                                            </div>
+                                            <input type="text" class="form-control" value="" id="budget-approval-code">
+                                        </div>
+                                    </div>
                                 </div>
                             </div>
                         </div>
@@ -406,6 +414,7 @@
                                                 <span class="input-group-text">业主控制价</span>
                                             </div>
                                             <input type="number" class="form-control nospin" value="" id="control-price" onchange="checkNumberValid(this)">
+                                            <a class="ml-1 btn btn-sm btn-primary" href="/tender/<%= ctx.tender.id %>/ctrl-price"><i class="fa fa-list"></i></a>
                                         </div>
                                     </div>
                                     <div class="col-12 mb-2">
@@ -966,6 +975,7 @@
         $('#project-type').val(property.deal_info.projectType);
         $('#deal-type').val(property.deal_info.dealType);
         $('#final-code').val(property.deal_info.finalCode);
+        $('#budget-approval-code').val(property.deal_info.budgetApprovalCode);
 
         // 参建单位
         // 建设单位
@@ -1031,6 +1041,7 @@
                 projectType: $('#project-type').val(),
                 dealType: $('#deal-type').val(),
                 finalCode: $('#final-code').val(),
+                budgetApprovalCode: $('#budget-approval-code').val(),
             },
             construction_unit: {
                 build: {

+ 20 - 0
config/web.js

@@ -138,6 +138,26 @@ const JsFiles = {
                 ],
                 mergeFile: 'tender_shenpi',
             },
+            ctrlPrice: {
+                files: [
+                    '/public/js/js-xlsx/xlsx.full.min.js',
+                    '/public/js/js-xlsx/xlsx.utils.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                    '/public/js/decimal.min.js',
+                    '/public/js/math.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/div_resizer.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/sjs_setting.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/zh_calc.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/std_lib.js',
+                    '/public/js/ctrl_price.js',
+                ]
+            }
         },
         ledger: {
             explode: {

+ 30 - 0
sql/update.sql

@@ -40,6 +40,36 @@ ADD COLUMN `zb_dgn_qty`  varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci
 ADD COLUMN `zb_dgn_price`  decimal(24,8) NOT NULL DEFAULT 0.00000000 COMMENT '预算-经济指标' AFTER `zb_dgn_qty`,
 ADD COLUMN `zb_tp`  decimal(24,8) NOT NULL DEFAULT 0.00000000 COMMENT '预算-金额' AFTER `zb_dgn_price`;
 
+CREATE TABLE `zh_control_price` (
+  `id` varchar(36) NOT NULL COMMENT 'uuid',
+  `tid` int(10) NOT NULL COMMENT '标段id',
+  `tree_id` int(10) NOT NULL COMMENT '节点id',
+  `tree_pid` int(10) NOT NULL COMMENT '父节点id',
+  `level` tinyint(4) NOT NULL COMMENT '层级',
+  `order` mediumint(4) NOT NULL DEFAULT '0' COMMENT '同级排序',
+  `full_path` varchar(255) DEFAULT '' COMMENT '层级定位辅助字段parent.full_path-tree_id',
+  `is_leaf` tinyint(1) NOT NULL COMMENT '是否叶子节点,界面显示辅助字段',
+  `code` varchar(50) DEFAULT '' COMMENT '节点编号',
+  `b_code` varchar(50) DEFAULT NULL,
+  `name` varchar(255) DEFAULT NULL COMMENT '名称',
+  `unit` varchar(15) DEFAULT '' COMMENT '单位',
+  `unit_price` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '单价',
+  `quantity` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '数量',
+  `total_price` decimal(24,8) DEFAULT '0.00000000' COMMENT '金额',
+  `dgn_qty1` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '设计数量1',
+  `dgn_qty2` decimal(24,8) NOT NULL DEFAULT '0.00000000' COMMENT '设计数量2',
+  `drawing_code` varchar(255) DEFAULT NULL COMMENT '图册号',
+  `memo` varchar(1000) DEFAULT '' COMMENT '备注',
+  `node_type` int(4) unsigned DEFAULT '0' COMMENT '节点类别',
+  `source` varchar(30) DEFAULT '' COMMENT '添加源',
+  `remark` varchar(60) DEFAULT '' COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_tid` (`tid`),
+  KEY `idx_tree_pid` (`tree_pid`),
+  KEY `idx_level` (`level`),
+  KEY `idx_full_path` (`tid`,`full_path`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='招标控制价';
+
 ALTER TABLE `zh_material_list_notjoin`
 ADD COLUMN `type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1为本期不参与调差,2为数量变更不参与调差' AFTER `mx_id`;