Sfoglia il codice sorgente

查询助手,查询文件相关

MaiXinRong 1 giorno fa
parent
commit
c44c2307d4

+ 8 - 21
app/controller/deal_bills_controller.js

@@ -82,27 +82,14 @@ module.exports = app => {
                 const fileName = this.app.config.filePath + '/cache/deal_bills/uploads/' + 'deal_bills' + create_time + fileInfo.ext;
                 // 保存文件
                 await ctx.helper.saveStreamFile(stream, fileName);
-                if (loadType === loadExcelType.display) {
-                    const wb = xlsx.readFile(fileName);
-                    const name = wb.SheetNames[0];
-                    const sheetData = {
-                        rows: xlsx.utils.sheet_to_json(wb.Sheets[name], {header: 1}),
-                        merge: wb.Sheets[name]["!merges"],
-                    };
-                    const result = await ctx.service.dealBills.importDataJsXlsx(sheetData, ctx.tender.id);
-                    if (!result) {
-                        throw '导入数据失败';
-                    }
-                } else {
-                    // 读取文件
-                    const sheet = excel.parse(fileName);
-                    if (!sheet || sheet.length === 0 || sheet[0] === undefined || sheet[0].data === undefined) {
-                        throw 'excel没有对应数据';
-                    }
-                    const result = await ctx.service.dealBills.importData(sheet[0], ctx.tender.id);
-                    if (!result) {
-                        throw '导入数据失败';
-                    }
+                // 读取文件
+                const sheet = excel.parse(fileName, { raw: loadType === loadExcelType.display ? true : false });
+                if (!sheet || sheet.length === 0 || sheet[0] === undefined || sheet[0].data === undefined) {
+                    throw 'excel没有对应数据';
+                }
+                const result = await ctx.service.dealBills.importData(sheet[0], ctx.tender.id);
+                if (!result) {
+                    throw '导入数据失败';
                 }
 
                 const dealBills = await this.ctx.service.dealBills.getAllDataByCondition({

+ 2 - 1
app/extend/helper.js

@@ -1538,7 +1538,8 @@ module.exports = {
 
     async ossFileGet(path) {
         // 判断开头是否带app,否则加上
-        if (!_.includes(path, 'app/') && !_.includes(path, 'sp/contract/') && !_.includes(path, 'sp/financial/')) {
+        if (!_.includes(path, 'app/') && !_.includes(path, 'sp/contract/') && !_.includes(path, 'sp/financial/') &&
+            !_.includes(path, 'sp/file/')) {
             path = 'app/' + path;
         }
         const result = await this.ctx.app.fujianOss.get(this.ctx.app.config.fujianOssFolder + path);

+ 38 - 3
app/public/css/main.css

@@ -3082,7 +3082,8 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
   white-space: normal;
 }
 
-.global-assistant-results a {
+.global-assistant-results > a,
+.global-assistant-result {
   display: flex;
   align-items: center;
   justify-content: space-between;
@@ -3097,7 +3098,8 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
   text-decoration: none;
 }
 
-.global-assistant-results a > div {
+.global-assistant-results > a > div,
+.global-assistant-result-info {
   min-width: 0;
 }
 
@@ -3119,12 +3121,45 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
   font-size: 11px;
 }
 
-.global-assistant-results a > span {
+.global-assistant-results > a > span,
+.global-assistant-result-value {
   flex: none;
   color: #397fc7;
   font-size: 12px;
 }
 
+.global-assistant-result {
+  flex-wrap: wrap;
+}
+
+.global-assistant-result-info {
+  flex: 1;
+}
+
+.global-assistant-result-actions {
+  display: flex;
+  flex: none;
+  gap: 0;
+  margin-left: auto;
+}
+
+.global-assistant-result-actions a {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  border: 0;
+  border-radius: 4px;
+  color: #397fc7;
+  font-size: 12px;
+}
+
+.global-assistant-result-actions a:hover {
+  background: #edf5fd;
+}
+
 .global-assistant-more {
   margin-top: 8px;
   color: #8b95a1;

+ 18 - 0
app/public/js/assistant.js

@@ -151,6 +151,24 @@
             if (!items.length) return;
             const list = $('<div class="global-assistant-results"></div>');
             items.forEach(function(item) {
+                if (item.pageUrl) {
+                    const row = $('<div class="global-assistant-result"></div>');
+                    const info = $('<div class="global-assistant-result-info"></div>');
+                    info.append($('<strong></strong>').text(item.title || '未命名记录'));
+                    if (item.description) info.append($('<small></small>').text(item.description));
+                    row.append(info);
+                    if (item.value) row.append($('<span class="global-assistant-result-value"></span>').text(item.value));
+                    const actions = $('<div class="global-assistant-result-actions"></div>');
+                    actions.append($('<a></a>').attr({ href: item.pageUrl, title: '前往页面', 'aria-label': '前往页面' })
+                        .append($('<i class="fa fa-external-link" aria-hidden="true"></i>')));
+                    if (item.url) {
+                        actions.append($('<a></a>').attr({ href: item.url, title: '下载', 'aria-label': '下载' })
+                            .append($('<i class="fa fa-download" aria-hidden="true"></i>')));
+                    }
+                    row.append(actions);
+                    list.append(row);
+                    return;
+                }
                 const isExternal = /^https?:\/\//i.test(item.url || '');
                 const row = $('<a></a>')
                     .attr('href', item.url || 'javascript:void(0)')

+ 5 - 4
app/public/js/filesjs.js

@@ -187,10 +187,11 @@ $(document).ready(function() {
             SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
             this.resizeSpreadColumns();
 
-            // 恢复上次选中的节点
-            const curCache = getLocalCache(this.curFilingKey);
-            if (curCache) {
-                const curNode = this.filingTree.nodes.find(x => x.id === curCache);
+            // 查询助手指定的目录优先于本地缓存。
+            const queryFilingId = new URLSearchParams(window.location.search).get('filing_id');
+            const curFilingId = queryFilingId || getLocalCache(this.curFilingKey);
+            if (curFilingId) {
+                const curNode = this.filingTree.nodes.find(x => String(x.id) === String(curFilingId));
                 if (curNode) {
                     this.setCurFiling(curNode);
                     SpreadJsObj.locateTreeNode(this.sheet, curNode.tree_id, true);

+ 237 - 22
app/service/assistant.js

@@ -1607,9 +1607,10 @@ module.exports = app => {
                 return;
             }
             if (query.module === 'attachment') {
-                if (!['current', 'named'].includes(tender.scope) || period.type !== 'none' || period.scope !== 'none' ||
-                    query.aggregation !== 'current' || filters.status || filters.contract_type || filters.chapter || filters.bill_code) {
-                    throw '附件查询一次只支持一个标段,请进入标段页面或指定标段名称';
+                if (!['current', 'named', 'none'].includes(tender.scope) || query.operation !== 'list' || query.metrics.length ||
+                    period.type !== 'none' || period.scope !== 'none' || query.aggregation !== 'current' ||
+                    filters.status || filters.contract_type || filters.chapter || filters.bill_code) {
+                    throw '附件查询范围不正确,请进入项目或标段页面后重新查询';
                 }
                 return;
             }
@@ -1643,6 +1644,25 @@ module.exports = app => {
 
         async _loadParameterScope(query, context) {
             const scope = await this._loadScope('', {});
+            if (query.module === 'attachment' && query.tender.scope === 'none') {
+                const path = String(context.path || '').split(/[?#]/)[0];
+                const subProjectMatch = path.match(/^\/sp\/([^/]+)(?:\/|$)/);
+                const tenderMatch = path.match(/^\/(?:wap\/)?tender\/([1-9]\d*)(?:\/|$)/);
+                let subProjectId = subProjectMatch && subProjectMatch[1];
+                if (!subProjectId && tenderMatch) {
+                    const tender = scope.tenders.find(item => String(item.id) === tenderMatch[1]);
+                    subProjectId = tender && String(tender.subProjectId || tender.spid);
+                }
+                if (!subProjectId) throw '无法确定当前项目,请进入项目或标段页面后查询资料管理文件';
+                const subProjects = scope.subProjects.filter(item => String(item.id) === String(subProjectId));
+                if (subProjects.length !== 1) throw '无法确定当前项目,或您无权查看该项目';
+                return {
+                    subProjects,
+                    tenders: scope.tenders.filter(item => String(item.subProjectId || item.spid) === String(subProjectId)),
+                    tenderOnly: false,
+                    projectAttachment: true,
+                };
+            }
             const subProjectMatch = query.filters.status === 'pending_me' && ['none', 'all'].includes(query.tender.scope)
                 ? String(context.path || '').match(/^\/sp\/([^/?#]+)(?:\/|$)/) : null;
             if (subProjectMatch) {
@@ -1839,21 +1859,27 @@ module.exports = app => {
 
         /** 汇总单个标段分散在各业务模块中的附件。 */
         async _queryAttachments(scope, filters) {
+            if (scope.projectAttachment) return await this._queryDataManagementAttachments(scope, filters);
             if (scope.tenders.length !== 1) throw '附件查询一次只支持一个标段,请补充完整标段名称';
             const tenderId = Number(scope.tenders[0].id);
-            const keyword = filters.keyword.trim().toLowerCase();
+            const keyword = this._normalizeAttachmentFilenameKeyword(filters.keyword);
             const result = [];
             const contractIds = new Set((await this._loadContracts(scope)).map(item => String(item.id)));
+            const stages = await this.ctx.service.stage.getAllDataByCondition({ where: { tid: tenderId } });
+            const stageOrderMap = new Map(stages.map(item => [String(item.id), item.order]));
+            const stageOrderSet = new Set(stages.map(item => String(item.order)));
             for (const source of this._attachmentSources()) {
+                if (source.projectScoped) continue;
                 let rows = [];
                 if (source.parent) {
                     const parents = source.parent.permission === 'financialPay'
                         ? await this.ctx.service.financialPay.getListByStatus(scope.subProjects[0].id, null, null, 0, [tenderId])
                         : await this.ctx.service[source.parent.service].getAllDataByCondition({ where: { tid: tenderId } });
                     for (const parent of parents) {
-                        rows.push(...await this.ctx.service[source.service].getAllDataByCondition({
+                        const childRows = await this.ctx.service[source.service].getAllDataByCondition({
                             where: { [source.parent.foreignKey]: parent.id },
-                        }));
+                        });
+                        rows.push(...childRows.map(row => Object.assign({}, row, { assistantParent: parent })));
                     }
                 } else {
                     const where = { [source.tenderField]: tenderId };
@@ -1862,6 +1888,8 @@ module.exports = app => {
                 }
                 for (const row of rows) {
                     if (source.contractScoped && !contractIds.has(String(row.cid))) continue;
+                    const attachmentStageOrder = this._getAttachmentStageOrder(source.key, row, stageOrderMap, stageOrderSet);
+                    if (attachmentStageOrder === null) continue;
                     let files = [row];
                     if (source.embedded) {
                         try {
@@ -1889,6 +1917,7 @@ module.exports = app => {
                             module: source.label,
                             uploadTime,
                             filesize: file.filesize || file.file_size || '',
+                            pageUrl: this._getAttachmentPageUrl(source.key, scope.subProjects[0].id, tenderId, file, row, attachmentStageOrder),
                         });
                     }
                 }
@@ -1904,9 +1933,10 @@ module.exports = app => {
                 ],
                 items: result.slice(0, this.maxItems).map(item => ({
                     title: item.filename,
-                    description: [item.module, item.uploadTime ? this.ctx.moment(item.uploadTime).format('YYYY-MM-DD HH:mm') : ''].filter(Boolean).join(' · '),
+                    description: [item.module, this._formatAttachmentTime(item.uploadTime)].filter(Boolean).join(' · '),
                     value: this._formatAttachmentSize(item.filesize),
                     url: `/assistant/attachment/${encodeURIComponent(item.source)}/${encodeURIComponent(item.id)}`,
+                    pageUrl: item.pageUrl,
                 })),
                 truncated: result.length > this.maxItems,
                 suggestions: [],
@@ -1919,7 +1949,30 @@ module.exports = app => {
             if (!source || !id) throw '附件参数不正确';
             let file;
             let tenderId;
-            if (source.embedded) {
+            if (source.projectScoped) {
+                file = await this.ctx.service[source.service].getDataById(id);
+                if (!file || Number(file.is_deleted) !== 0 || !file.filepath) throw '附件不存在或已删除';
+                const scope = await this._loadScope('', {});
+                const subProject = scope.subProjects.find(item => String(item.id) === String(file.spid));
+                if (!subProject) throw '您无权查看该附件';
+                this.ctx.subProject = subProject;
+                const filing = await this.ctx.service.filing.getDataById(file.filing_id);
+                if (!filing || Number(filing.is_deleted) !== 0 || String(filing.spid) !== String(subProject.id)) {
+                    throw '附件所在资料目录不存在';
+                }
+                const isAdmin = Number(this.ctx.session.sessionUser.is_admin) === 1;
+                const permission = await this.ctx.service.subProjectFilingPermission.getResolvedPermission(
+                    subProject.id,
+                    this.ctx.session.sessionUser.accountId,
+                    filing.id,
+                    filing.filing_type,
+                    subProject.permission.file_permission,
+                    subProject.permission.filing_type,
+                    isAdmin
+                );
+                if (this._hasDataManagementConfigPermission(subProject)) permission.can_view = 1;
+                if (!permission.can_view) throw '您无权查看该附件';
+            } else if (source.embedded) {
                 const separator = id.indexOf(':');
                 if (separator <= 0) throw '附件参数不正确';
                 const row = await this.ctx.service[source.service].getDataById(id.slice(0, separator));
@@ -1945,18 +1998,20 @@ module.exports = app => {
                 }
             }
             if (!file || (source.notDeleted && Number(file.is_deleted) !== 0) || !file.filepath) throw '附件不存在或已删除';
-            const scope = await this._loadScope('', {});
-            const tender = scope.tenders.find(item => String(item.id) === tenderId);
-            if (!tenderId || !tender) throw '您无权查看该附件';
-            if (source.contractScoped) {
-                const subProjectId = String(tender.subProjectId || tender.spid);
-                const attachmentScope = {
-                    subProjects: scope.subProjects.filter(item => String(item.id) === subProjectId),
-                    tenders: [tender],
-                    tenderOnly: true,
-                };
-                const contractIds = new Set((await this._loadContracts(attachmentScope)).map(item => String(item.id)));
-                if (!contractIds.has(String(file.cid))) throw '您无权查看该附件';
+            if (!source.projectScoped) {
+                const scope = await this._loadScope('', {});
+                const tender = scope.tenders.find(item => String(item.id) === tenderId);
+                if (!tenderId || !tender) throw '您无权查看该附件';
+                if (source.contractScoped) {
+                    const subProjectId = String(tender.subProjectId || tender.spid);
+                    const attachmentScope = {
+                        subProjects: scope.subProjects.filter(item => String(item.id) === subProjectId),
+                        tenders: [tender],
+                        tenderOnly: true,
+                    };
+                    const contractIds = new Set((await this._loadContracts(attachmentScope)).map(item => String(item.id)));
+                    if (!contractIds.has(String(file.cid))) throw '您无权查看该附件';
+                }
             }
             const filename = String(file.file_name || file.filename || '附件').trim();
             const fileext = String(file.fileext || '').trim();
@@ -1967,9 +2022,10 @@ module.exports = app => {
             };
         }
 
-        /** 统一附件查询白名单;仅包含能直接归属到标段且具有独立文件记录的数据源。 */
+        /** 统一附件查询白名单;标段附件按业务来源查询,资料管理文件按项目及目录权限查询。 */
         _attachmentSources() {
             return [
+                { key: 'data_management', service: 'file', label: '资料管理', projectScoped: true, notDeleted: true },
                 { key: 'ledger', service: 'ledgerAtt', tenderField: 'tid', label: '台账' },
                 { key: 'stage', service: 'stageAtt', tenderField: 'tid', label: '计量' },
                 { key: 'stage_detail', service: 'stageDetailAtt', tenderField: 'tid', label: '计量清单附件', embedded: true },
@@ -1993,7 +2049,6 @@ module.exports = app => {
                 { key: 'quality_inspection', service: 'qualityInspectionAtt', tenderField: 'tid', label: '质量巡检' },
                 { key: 'safe_inspection', service: 'safeInspectionAtt', tenderField: 'tid', label: '安全巡检' },
                 { key: 'safe_stage', service: 'safeStageFile', tenderField: 'tid', label: '安全计量', notDeleted: true },
-                { key: 'temp', service: 'tempFile', tenderField: 'tid', label: '临时附件' },
             ];
         }
 
@@ -2007,6 +2062,166 @@ module.exports = app => {
             if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
             return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
         }
+
+        /** 查询当前项目资料管理中有权查看的文件。 */
+        async _queryDataManagementAttachments(scope, filters) {
+            if (scope.subProjects.length !== 1) throw '资料管理文件查询一次只支持一个项目';
+            const subProject = scope.subProjects[0];
+            this.ctx.subProject = subProject;
+            const filings = await this.ctx.service.filing.getValidFiling(subProject.id, 'all');
+            const sessionUser = this.ctx.session.sessionUser;
+            const permissionMap = await this.ctx.service.subProjectFilingPermission.getPermissionMap(
+                subProject.id,
+                sessionUser.accountId,
+                filings,
+                subProject.permission.filing_type,
+                subProject.permission.file_permission,
+                Number(sessionUser.is_admin) === 1
+            );
+            if (this._hasDataManagementConfigPermission(subProject)) {
+                Object.keys(permissionMap).forEach(filingId => { permissionMap[filingId].can_view = 1; });
+            }
+            const visibleFilings = filings.filter(filing => permissionMap[filing.id] && permissionMap[filing.id].can_view);
+            const visibleFilingIds = visibleFilings.map(filing => filing.id);
+            const filingMap = new Map(filings.map(filing => [String(filing.id), filing]));
+            const pathCache = new Map();
+            const getFilingPath = filing => {
+                const cacheKey = String(filing.id);
+                if (pathCache.has(cacheKey)) return pathCache.get(cacheKey);
+                const names = [];
+                const visited = new Set();
+                let current = filing;
+                while (current && !visited.has(String(current.id))) {
+                    visited.add(String(current.id));
+                    if (current.name) names.unshift(current.name);
+                    current = filingMap.get(String(current.tree_pid));
+                }
+                const filingPath = names.join(' / ');
+                pathCache.set(cacheKey, filingPath);
+                return filingPath;
+            };
+            const rows = visibleFilingIds.length ? await this.ctx.service.file.getAllDataByCondition({
+                where: { spid: subProject.id, filing_id: visibleFilingIds, is_deleted: 0 },
+                orders: [['update_time', 'desc']],
+            }) : [];
+            const keyword = filters.keyword.trim().toLowerCase();
+            const filenameKeyword = this._normalizeAttachmentFilenameKeyword(filters.keyword);
+            const result = rows.map(file => {
+                const filing = filingMap.get(String(file.filing_id));
+                const directory = filing ? getFilingPath(filing) : '';
+                const filename = String(file.filename || '').trim();
+                const fileext = String(file.fileext || '').trim();
+                const fullName = filename && fileext && !filename.toLowerCase().endsWith(fileext.toLowerCase())
+                    ? filename + fileext : filename;
+                return {
+                    source: 'data_management',
+                    id: String(file.id),
+                    filename: fullName,
+                    directory,
+                    filingId: String(file.filing_id),
+                    uploadTime: file.create_time || file.update_time || '',
+                    filesize: file.filesize || '',
+                };
+            }).filter(file => {
+                if (!file.filename || !keyword) return !!file.filename;
+                const filenameMatched = filenameKeyword && file.filename.toLowerCase().indexOf(filenameKeyword) !== -1;
+                const directoryMatched = file.directory.toLowerCase().indexOf(keyword) !== -1;
+                return filenameMatched || directoryMatched;
+            });
+            result.sort((a, b) => new Date(b.uploadTime || 0).getTime() - new Date(a.uploadTime || 0).getTime());
+            const directories = new Set(result.map(item => item.directory).filter(Boolean));
+            return {
+                answer: `资料管理中找到${result.length}个文件或者目录。`,
+                summary: [
+                    { label: '文件数量', value: result.length + '个' },
+                    { label: '文件所属目录', value: directories.size + '个' },
+                ],
+                items: result.slice(0, this.maxItems).map(item => ({
+                    title: item.filename,
+                    description: ['资料管理', item.directory, this._formatAttachmentTime(item.uploadTime)]
+                        .filter(Boolean).join(' · '),
+                    value: this._formatAttachmentSize(item.filesize),
+                    url: `/assistant/attachment/${encodeURIComponent(item.source)}/${encodeURIComponent(item.id)}`,
+                    pageUrl: `/sp/${encodeURIComponent(subProject.id)}/filesjs?filing_id=${encodeURIComponent(item.filingId)}`,
+                })),
+                truncated: result.length > this.maxItems,
+                suggestions: [],
+            };
+        }
+
+        /** 资料管理目录配置人员与管理员和页面保持一致,可以查看全部目录。 */
+        _hasDataManagementConfigPermission(subProject) {
+            if (Number(this.ctx.session.sessionUser.is_admin) === 1) return true;
+            const permissions = subProject && subProject.permission && subProject.permission.file_permission || [];
+            const constants = this.ctx.service.subProjPermission.PermissionConst.file;
+            return [constants.manage_dir.value, constants.auth_user.value].some(value => permissions.indexOf(value) >= 0);
+        }
+
+        /** 查文件名时去掉句式两端的“文件/附件”,目录匹配仍使用原关键词。 */
+        _normalizeAttachmentFilenameKeyword(value) {
+            return String(value || '').trim().toLowerCase()
+                .replace(/^(?:文件|附件)\s*|\s*(?:文件|附件)$/g, '').trim();
+        }
+
+        /** 附件结果跳转到所属业务页;无法稳定定位单条记录时返回模块入口。 */
+        _getAttachmentPageUrl(sourceKey, subProjectId, tenderId, file, row, attachmentStageOrder) {
+            const spid = encodeURIComponent(subProjectId);
+            const tid = encodeURIComponent(tenderId);
+            const data = Object.assign({}, row || {}, file || {});
+            const value = key => data[key] === undefined || data[key] === null ? '' : encodeURIComponent(data[key]);
+            const encodedStageOrder = attachmentStageOrder === undefined || attachmentStageOrder === null
+                ? '' : encodeURIComponent(attachmentStageOrder);
+            const contractType = contractConst.typeMap[data.contract_type];
+            const urls = {
+                ledger: `/tender/${tid}/ledger`,
+                stage: encodedStageOrder ? `/tender/${tid}/measure/stage/${encodedStageOrder}` : `/tender/${tid}/measure/stage`,
+                stage_detail: encodedStageOrder ? `/tender/${tid}/measure/stage/${encodedStageOrder}/detail` : `/tender/${tid}/measure/stage`,
+                stage_shoufang: encodedStageOrder ? `/tender/${tid}/measure/stage/${encodedStageOrder}` : `/tender/${tid}/measure/stage`,
+                material: data.s_order ? `/tender/${tid}/measure/material/${value('s_order')}` : `/tender/${tid}/measure/material`,
+                advance: data.vid ? `/tender/${tid}/advance/${value('vid')}/detail` : `/tender/${tid}/advance/start`,
+                payment: data.tr_id && data.td_id ? `/sp/${spid}/payment/${value('tr_id')}/detail/${value('td_id')}` : `/sp/${spid}/payment`,
+                stage_pay: encodedStageOrder ? `/tender/${tid}/measure/stage/${encodedStageOrder}/pay` : `/tender/${tid}/measure/stage`,
+                phase_pay: `/tender/${tid}/pay`,
+                change: data.cid ? `/tender/${tid}/change/${value('cid')}/information` : `/tender/${tid}/change`,
+                change_project: data.cpid ? `/tender/${tid}/change/project/${value('cpid')}/information` : `/tender/${tid}/change/project`,
+                change_apply: data.caid ? `/tender/${tid}/change/apply/${value('caid')}/information` : `/tender/${tid}/change/apply`,
+                change_plan: data.cpid ? `/tender/${tid}/change/plan/${value('cpid')}/information` : `/tender/${tid}/change/plan`,
+                contract: `/sp/${spid}/contract/tender/${tid}/detail${contractType ? '/' + contractType : ''}`,
+                contract_supplement: `/sp/${spid}/contract/tender/${tid}/detail${contractType ? '/' + contractType : ''}`,
+                contract_pay: `/sp/${spid}/contract/tender/${tid}/detail${contractType ? '/' + contractType : ''}`,
+                construction: data.log_id ? `/sp/${spid}/construction/${tid}/log/${value('log_id')}` : `/sp/${spid}/construction/${tid}/list`,
+                financial_pay: data.assistantParent && data.assistantParent.id
+                    ? `/sp/${spid}/financial/pay/${encodeURIComponent(data.assistantParent.id)}/detail` : `/sp/${spid}/financial/pay/tender`,
+                cost: `/sp/${spid}/cost/tender/${tid}/ledger`,
+                quality: `/sp/${spid}/quality/tender/${tid}/info`,
+                quality_inspection: data.qiid ? `/sp/${spid}/quality/tender/${tid}/inspection/${value('qiid')}/information` : `/sp/${spid}/quality/tender/${tid}/inspection`,
+                safe_inspection: data.qiid ? `/sp/${spid}/safe/tender/${tid}/inspection/${value('qiid')}/information` : `/sp/${spid}/safe/tender/${tid}/inspection`,
+                safe_stage: `/sp/${spid}/safe/tender/${tid}/stage`,
+            };
+            return urls[sourceKey] || `/tender/${tid}`;
+        }
+
+        /** 解析计量类附件所属期;返回null表示关联计量期已不存在,应过滤该附件。 */
+        _getAttachmentStageOrder(sourceKey, row, stageOrderMap, stageOrderSet) {
+            if (sourceKey === 'stage') {
+                return stageOrderSet.has(String(row.sid)) ? row.sid : null;
+            }
+            if (sourceKey === 'stage_detail') {
+                return stageOrderSet.has(String(row.sorder)) ? row.sorder : null;
+            }
+            if (sourceKey === 'stage_shoufang' || sourceKey === 'stage_pay') {
+                return stageOrderMap.has(String(row.sid)) ? stageOrderMap.get(String(row.sid)) : null;
+            }
+            return undefined;
+        }
+
+        /** 格式化附件时间;计量附件等旧表的in_time使用秒级时间戳。 */
+        _formatAttachmentTime(value) {
+            if (value === undefined || value === null || value === '') return '';
+            const text = String(value).trim();
+            const time = /^\d{9,10}$/.test(text) ? this.ctx.moment.unix(Number(text)) : this.ctx.moment(value);
+            return time.isValid() ? time.format('YYYY-MM-DD HH:mm') : '';
+        }
     }
 
     return Assistant;

+ 32 - 36
app/service/deal_bills.js

@@ -66,44 +66,28 @@ module.exports = app => {
                         }
                     } else {
                         const code = this.ctx.helper._.trim(row[iCode]);
-                        if (this.ctx.helper.validBillsCode(code)) {
-                            const data = {
-                                deal_id: bills.length + 1,
-                                tender_id: tenderId,
-                                code: code,
-                                name: row[iName],
-                                unit: row[iUnit],
-                                unit_price: row[iUp],
-                                quantity: row[iQty],
-                            };
-                            if ((data.unit_price && !this._.isNumber(data.unit_price)) || (data.quantity && !this._.isNumber(data.quantity))) {
-                                throw '导入的Excel的数据类型有误,请检查第' + (iRow + 1) + '行';
-                            }
-                            bills.push(data);
+                        if (!code) continue;
+                        if (!this.ctx.helper.validBillsCode(code)) continue;
+                        const data = {
+                            id: this.uuid.v4(),
+                            order: bills.length + 1,
+                            tender_id: tenderId,
+                            code: code,
+                            name: row[iName],
+                            unit: row[iUnit],
+                            unit_price: this.normalizeNumber(row[iUp]),
+                            quantity: this.normalizeNumber(row[iQty]),
+                            total_price:  this.normalizeNumber(row[iTp]),
+                        };
+                        if (this.isInvalidNumber(data.unit_price) || this.isInvalidNumber(data.quantity) || this.isInvalidNumber(data.total_price)) {
+                            throw '导入的Excel的数据类型有误,请检查第' + (iRow + 1) + '行';
                         }
+                        bills.push(data);
                     } 
                 }
                 if (!bCheckCol) {
                     throw '导入的Excel表头定义有误,请下载示例检查';
                 }
-                // 固定列,从第一行开始导入
-                // let iCode = 0, iName = 1, iUnit = 2, iUp = 4, iQty = 3;
-                // for (let iRow = 1; iRow < sheet.data.length; iRow++) {
-                //     const data = {
-                //         deal_id: bills.length + 1,
-                //         tender_id: tenderId,
-                //         code: row[iCode],
-                //         name: row[iName],
-                //         unit: row[iUnit],
-                //         unit_price: row[iUp],
-                //         quantity: row[iQty],
-                //     };
-                //     if (!data.code || data.code === '' || !data.name || data.name === '') continue;
-                //     if ((data.unit_price && !this._.isNumber(data.unit_price)) || (data.quantity && !this._.isNumber(data.quantity))) {
-                //         throw '导入的Excel的数据类型有误,请检查第' + (iRow + 1) + '行';
-                //     }
-                //     bills.push(data);
-                // }
                 if (bills.length > 0) {
                     await transaction.delete(this.tableName, {tender_id: tenderId});
                     const billsResult = await transaction.insert(this.tableName, bills);
@@ -168,11 +152,11 @@ module.exports = app => {
                         code: code,
                         name: this.ctx.helper.replaceReturn(row[iName]),
                         unit: this.ctx.helper.replaceReturn(row[iUnit]),
-                        unit_price: (row[iUp] === undefined || row[iUp] === null) ? 0 : this._.toNumber(row[iUp]),
-                        quantity: (row[iQty] === undefined || row[iQty] === null) ? 0 : this._.toNumber(row[iQty]),
-                        total_price: (row[iTp] === undefined || row[iTp] === null) ? 0 : this._.toNumber(row[iTp]),
+                        unit_price: this.normalizeNumber(row[iUp]),
+                        quantity: this.normalizeNumber(row[iQty]),
+                        total_price:  this.normalizeNumber(row[iTp]),
                     };
-                    if (this._.isNaN(data.unit_price) || this._.isNaN(data.quantity) || this._.isNaN(data.total_price)) {
+                    if (this.isInvalidNumber(data.unit_price) || this.isInvalidNumber(data.quantity) || this.isInvalidNumber(data.total_price)) {
                         throw '导入的Excel的数据类型有误,请检查第' + (iRow + 1) + '行';
                     }
                     bills.push(data);
@@ -316,6 +300,18 @@ module.exports = app => {
                 return result;
             }
         }
+
+        normalizeNumber(value) {
+            if (value === undefined || value === null) return 0;
+            if (typeof value !== 'string') return value;
+            const text = this._.trim(value);
+            return text ? this._.toNumber(text) : 0;
+        }
+
+        isInvalidNumber(value) {
+            if (value === undefined || value === null || value === '') return false;
+            return !this._.isNumber(value) || this._.isNaN(value) || !Number.isFinite(value);
+        }
     }
 
     return DealBills;