Browse Source

接入纵横图纸相关

MaiXinRong 1 day atrás
parent
commit
2b5d5740e6

+ 36 - 0
app/controller/ledger_controller.js

@@ -903,6 +903,37 @@ module.exports = app => {
          * @param {Object} ctx - egg全局变量
          * @return {void}
          */
+        async createDrawing(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const attachment = await ctx.service.ledgerAtt.createDrawing(data.id);
+                ctx.body = { err: 0, msg: '', data: attachment };
+            } catch (err) {
+                ctx.body = { err: 1, msg: err.message || '图纸创建失败,请重试', data: null };
+            }
+        }
+
+        async createDrawingEditableAccess(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                ctx.body = { err: 0, msg: '', data: await ctx.service.ledgerAtt.createDrawingEditableAccess(data.id) };
+            } catch (err) {
+                ctx.body = { err: 1, msg: err.message || '获取图纸编辑凭证失败', data: null };
+            }
+        }
+
+        async findDrawing(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (typeof data.drawing_number !== 'string' || !data.drawing_number.trim() || data.drawing_number.length > 100) {
+                    throw new Error('图册号无效');
+                }
+                ctx.body = { err: 0, msg: '', data: await ctx.service.ledgerAtt.findDrawing(data.drawing_number) };
+            } catch (err) {
+                ctx.body = { err: 1, msg: '查询图册号失败', data: null };
+            }
+        }
+
         async uploadFile(ctx) {
             const responseData = {
                 err: 0,
@@ -1050,6 +1081,10 @@ module.exports = app => {
                 if (!fileInfo || !Object.keys(fileInfo).length) {
                     throw '该文件不存在';
                 }
+                if (Number(fileInfo.tid) !== Number(ctx.tender.id) ||
+                    Number(fileInfo.uid) !== Number(ctx.session.sessionUser.accountId) || ctx.tender.isTourist) {
+                    throw '无权限删除';
+                }
                 if (!fileInfo.extra_upload && ctx.tender.status === auditConst.status.checked) {
                     throw '无权限删除';
                 }
@@ -1059,6 +1094,7 @@ module.exports = app => {
                     await ctx.app.fujianOss.delete(ctx.app.config.fujianOssFolder + fileInfo.filepath);
                     // 再删除数据库
                     await ctx.service.ledgerAtt.deleteById(data.id);
+                    await ctx.service.ledgerAtt.syncDeletedDrawing(fileInfo);
                     responseData.data = '';
                 } else {
                     throw '不存在该文件';

+ 167 - 0
app/lib/zh_drawing.js

@@ -0,0 +1,167 @@
+'use strict';
+
+const axios = require('axios');
+
+const FILES_PATH = '/api/v1/files';
+const FILE_ACCESS_PATH = '/api/v1/files/access';
+const DEFAULT_TIMEOUT = 120000;
+
+/**
+ * 纵横图纸接口客户端。
+ *
+ * 推荐传入 app.config.pdfDrawing:
+ * new ZhDrawing(ctx.app.config.pdfDrawing)
+ */
+class ZhDrawing {
+    /**
+     * @param {Object} config 接口配置
+     * @param {String} config.baseUrl 纵横图纸访问地址
+     * @param {String} config.token 外部 API 访问凭证
+     * @param {Number} [config.timeout=120000] 请求超时时间,单位毫秒
+     * @param {Object} [httpClient=axios] HTTP 客户端,主要用于单元测试注入
+     */
+    constructor(config = {}, httpClient = axios) {
+        if (typeof config.baseUrl !== 'string' || !config.baseUrl.trim()) {
+            throw new TypeError('pdfDrawing.baseUrl 不能为空');
+        }
+        if (typeof config.token !== 'string' || !config.token.trim()) {
+            throw new TypeError('pdfDrawing.token 不能为空');
+        }
+        if (!httpClient || typeof httpClient.post !== 'function') {
+            throw new TypeError('httpClient 必须提供 post 方法');
+        }
+
+        this.baseUrl = config.baseUrl.trim().replace(/\/+$/, '');
+        this.token = config.token.trim();
+        this.timeout = Number(config.timeout) > 0 ? Number(config.timeout) : DEFAULT_TIMEOUT;
+        this.httpClient = httpClient;
+    }
+
+    /**
+     * 读取 PDF 图纸数据,返回图纸 ID 与访问地址等信息。
+     *
+     * @param {String} file 可下载的 PDF 文件地址
+     * @param {String} [filename] 文件名称;传入后以该名称为准
+     * @return {Promise<Object>} 接口响应数据
+     */
+    async createFile(file, filename) {
+        const payload = {
+            file: this._requiredString(file, 'file'),
+        };
+        const normalizedFilename = this._optionalString(filename, 'filename');
+        if (normalizedFilename) {
+            payload.filename = normalizedFilename;
+        }
+
+        return this._post(FILES_PATH, payload);
+    }
+
+    /**
+     * 为指定图纸签发短期可编辑访问凭证。
+     *
+     * @param {String} fileId 图纸 ID
+     * @return {Promise<Object>} 接口响应数据
+     */
+    async createEditableAccess(fileId) {
+        const payload = {
+            file_id: this._requiredString(fileId, 'fileId'),
+        };
+
+        return this._post(FILE_ACCESS_PATH, payload);
+    }
+
+    /** 按传入文件顺序查找包含图表号的首个文件;未找到返回 { file_id: null }。 */
+    async findDrawing(fileIds, drawingNumber) {
+        if (!Array.isArray(fileIds) || !fileIds.length || fileIds.length > 100) {
+            throw new TypeError('fileIds 必须为包含 1–100 个文件 ID 的数组');
+        }
+        return this._post('/api/v1/drawings/find', {
+            file_id: fileIds.map(id => this._requiredString(id, 'fileId')),
+            drawing_number: this._requiredString(drawingNumber, 'drawingNumber'),
+        });
+    }
+
+    /** 标记删除图纸,立即停止访问,保留30天后后台清理;调用方应先取得用户确认。 */
+    async deleteFile(fileId) {
+        return this._post('/api/v1/files/delete', {
+            file_id: this._requiredString(fileId, 'fileId'),
+        });
+    }
+
+    /**
+     * 将接口返回的相对 document_path 转换为可直接访问的完整地址。
+     *
+     * @param {String} documentPath 接口返回的 document_path
+     * @return {String} 完整访问地址
+     */
+    resolveDocumentUrl(documentPath) {
+        const path = this._requiredString(documentPath, 'documentPath');
+        if (/^https?:\/\//i.test(path)) {
+            return path;
+        }
+
+        return `${this.baseUrl}/${path.replace(/^\/+/, '')}`;
+    }
+
+    async _post(path, payload) {
+        try {
+            const response = await this.httpClient.post(
+                `${this.baseUrl}${path}`,
+                Object.assign({}, payload, { token: this.token }),
+                {
+                    headers: {
+                        'Content-Type': 'application/json',
+                    },
+                    timeout: this.timeout,
+                }
+            );
+            return response.data;
+        } catch (error) {
+            throw this._normalizeError(error);
+        }
+    }
+
+    _normalizeError(error) {
+        const response = error && error.response;
+        const responseData = response && response.data;
+        const detail = responseData && responseData.detail;
+        let message = error && error.message ? error.message : '纵横图纸接口请求失败';
+
+        if (typeof detail === 'string' && detail) {
+            message = detail;
+        } else if (detail !== undefined) {
+            try {
+                message = JSON.stringify(detail);
+            } catch (jsonError) {
+                message = '纵横图纸接口请求失败';
+            }
+        }
+
+        const normalizedError = new Error(message);
+        normalizedError.name = 'ZhDrawingError';
+        normalizedError.status = response && response.status;
+        normalizedError.detail = detail;
+        normalizedError.code = error && error.code;
+        normalizedError.originalError = error;
+        return normalizedError;
+    }
+
+    _requiredString(value, fieldName) {
+        if (typeof value !== 'string' || !value.trim()) {
+            throw new TypeError(`${fieldName} 不能为空`);
+        }
+        return value.trim();
+    }
+
+    _optionalString(value, fieldName) {
+        if (value === undefined || value === null || value === '') {
+            return '';
+        }
+        if (typeof value !== 'string') {
+            throw new TypeError(`${fieldName} 必须是字符串`);
+        }
+        return value.trim();
+    }
+}
+
+module.exports = ZhDrawing;

+ 3 - 3
app/public/js/global.js

@@ -316,7 +316,7 @@ function toast(message, type, icon) {
  * @param {function} successCallback - 返回成功回调
  * @param {function} errorCallBack - 返回失败回调
  */
-const postData = function (url, data, successCallback, errorCallBack, showWaiting = true) {
+const postData = function (url, data, successCallback, errorCallBack, showWaiting = true, errorMessage = '') {
     if (showWaiting) showWaitingView();
     $.ajax({
         type:"POST",
@@ -345,7 +345,7 @@ const postData = function (url, data, successCallback, errorCallBack, showWaitin
                     window.location.href = '/login';
                 },1000);
             } else {
-                toastr.error('error: ' + result.msg);
+                toastr.error(errorMessage || ('error: ' + result.msg));
                 if (errorCallBack) {
                     errorCallBack(result.msg);
                 }
@@ -353,7 +353,7 @@ const postData = function (url, data, successCallback, errorCallBack, showWaitin
             if (showWaiting) closeWaitingView();
         },
         error: function(jqXHR, textStatus, errorThrown){
-            toastr.error('error: ' + textStatus + " " + errorThrown);
+            toastr.error(errorMessage || ('error: ' + textStatus + " " + errorThrown));
             if (errorCallBack) {
                 errorCallBack();
             }

+ 135 - 1
app/public/js/ledger.js

@@ -2931,6 +2931,28 @@ $(document).ready(function() {
         };
     }
 
+    billsContextMenuOptions.items.viewDrawing = {
+        name: '查看图纸',
+        icon: 'fa-file-pdf-o',
+        disabled: function () {
+            return getLedgerDrawings().length === 0;
+        },
+        callback: function () {
+            const node = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
+            const drawings = getLedgerDrawings();
+            const number = String(node && node.drawing_code || '').trim();
+            if (!drawings.length) return;
+            if (!number) return openLedgerDrawing(drawings[0]);
+            postData('/tender/' + tender.id + '/ledger/drawing/find', { drawing_number: number }, function (data) {
+                const att = drawings.find(item => item.drawing_file_id === data.file_id);
+                if (!att) {
+                    toastr.warning('没有匹配的图表号');
+                    return;
+                }
+                openLedgerDrawing(att, number);
+            }, null, false, '查询图册号失败');
+        },
+    };
     $.contextMenu(billsContextMenuOptions);
 
     const posSearch = $.posSearch({selector: '#pos-search', searchSpread: posSpread});
@@ -6207,7 +6229,7 @@ $(document).ready(function() {
           // 附件uid等于当前用户id, 附件上传本人
           if (parseInt(userID) === att.uid) {
               $('#btn-att').show();
-              const showDel = tender.ledger_status === auditConst.status.checked ? Boolean(att.extra_upload) : true;
+              const showDel = canEditLedgerAttachment(att);
               if (showDel) $('#btn-att a').eq(3).show();
               // $('#btn-att a').eq(3).show();
               $('#btn-att a').eq(2).hide();
@@ -6222,18 +6244,52 @@ $(document).ready(function() {
           }
           $('#showAttachment').attr('file-id', fid);
           $('#showAttachment').show();
+          updateDrawingButton();
       } else {
           $('#showAttachment').hide();
           $('#showAttachment').attr('file-id', '');
           toastr.error('附件信息获取失败');
       }
   });
+    // 确认之前不提交请求,也不更改附件类型。
+    $('#att-drawing-confirm').appendTo('body').on('hidden.bs.modal', function () {
+        $(this).removeData('attachment-id');
+    });
+    $('body').on('click', '#att-drawing-btn', function () {
+        const id = Number($('#showAttachment').attr('file-id'));
+        const att = attData.find(item => item.id === id);
+        if (!att || Number(att.is_drawing) === 1 || drawingPendingIds.has(id)) return;
+        $('#att-drawing-confirm').data('attachment-id', id).modal('show');
+    });
+    $('body').on('click', '#att-drawing-confirm-ok', function () {
+        // 使用打开确认窗时的附件 ID,避免当前选中附件变化导致误提交。
+        const id = Number($('#att-drawing-confirm').data('attachment-id'));
+        const att = attData.find(item => item.id === id);
+        if (!att || Number(att.is_drawing) === 1 || drawingPendingIds.has(id)) return;
+        drawingPendingIds.add(id);
+        $('#att-drawing-confirm').modal('hide');
+        updateDrawingButton();
+        postData('/tender/' + tender.id + '/ledger/drawing/file', { id }, function (data) {
+            drawingPendingIds.delete(id);
+            const index = attData.findIndex(item => item.id === id);
+            if (index !== -1) attData[index] = data;
+            getAllList(Math.max(1, Number($('#currentPage').text()) || 1));
+            const node = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
+            if (node) getNodeList(node.id);
+            updateDrawingButton();
+            toastr.success('已提交图纸解析并保存图纸关联');
+        }, function () {
+            drawingPendingIds.delete(id);
+            updateDrawingButton();
+        }, false);
+    });
     // $('body').on('click', '.alllist-table a', handleFileList);
     $('body').on('click', '#btn-att a', function () {
       const content = $(this).attr('content');
       const fid = $('#showAttachment').attr('file-id');
       const node = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
       if (content === 'edit') {
+          $('#att-drawing-btn').hide();
           $('#btn-att a').eq(3).hide();
           $('#btn-att a').eq(2).show();
           $('#btn-att a').eq(4).show();
@@ -6250,6 +6306,7 @@ $(document).ready(function() {
           $('#edit-att .form-group').eq(2).find('input').val(att.in_time);
           $('#edit-att .form-group').eq(3).find('input').val(att.remark);
       } else if (content === 'cancel') {
+          updateDrawingButton();
           $('#show-att').show();
           $('#edit-att').hide();
           $('#btn-att a').eq(3).show();
@@ -6274,6 +6331,7 @@ $(document).ready(function() {
                   return item.id === parseInt(fid);
               });
               attData.splice(att_index, 1, data);
+              updateDrawingButton();
               // 重新生成List
               getAllList(parseInt($('#currentPage').text()));
               getNodeList(node.id);
@@ -6315,6 +6373,18 @@ $(document).ready(function() {
           });
       } else if (content === 'view') {
           const att = attData.find(item => item.id === parseInt(fid));
+          if (Number(att.is_drawing) === 1) {
+              if (canEditLedgerAttachment(att)) {
+                  openEditableDrawing(att);
+                  return;
+              }
+              if (!att.drawing_view_url) {
+                  toastr.error('图纸访问地址或只读凭证缺失,请联系管理员');
+                  return;
+              }
+              window.open(att.drawing_view_url, '_blank', 'noopener');
+              return;
+          }
           window.open(att.viewpath || att.orginpath);
       } else if (content === 'location') {
           const att = attData.find(item => item.id === parseInt(fid));
@@ -6530,6 +6600,70 @@ $(document).ready(function() {
         });
     });
 });
+const drawingPendingIds = new Set();
+// 仅缓存在当前台账页面内,不覆盖数据库中的长期只读 drawing_access。
+const drawingEditableAccessCache = new Map();
+const drawingAccessPending = new Set();
+function canEditLedgerAttachment(att) {
+    return Number(userID) === Number(att.uid) &&
+        (tender.ledger_status !== auditConst.status.checked || Boolean(att.extra_upload));
+}
+function getLedgerDrawings() {
+    // 使用当前台账分解页面的全部附件,不按选中节点筛选。
+    return attData.filter(att => Number(att.is_drawing) === 1 && att.drawing_file_id);
+}
+function drawingNumberUrl(url, drawingNumber) {
+    const target = new URL(url);
+    if (drawingNumber) target.searchParams.set('drawing_number', drawingNumber);
+    else target.searchParams.delete('drawing_number');
+    return target.toString();
+}
+function openDrawingTab(url) {
+    const tab = window.open(url, '_blank');
+    if (tab) { tab.opener = null; return; }
+    // 异步查询后可能被浏览器拦截,提供用户主动点击的链接,不预开空白页。
+    const link = $('<a>').attr({ href: url, target: '_blank', rel: 'noopener noreferrer' }).text('图纸已就绪,点击查看图纸');
+    toastr.info(link, '', { timeOut: 0, extendedTimeOut: 0, closeButton: true, escapeHtml: false });
+}
+function openLedgerDrawing(att, drawingNumber = '') {
+    if (canEditLedgerAttachment(att)) return openEditableDrawing(att, drawingNumber);
+    if (!att.drawing_view_url) {
+        toastr.error('图纸访问地址或只读凭证缺失'); return;
+    }
+    const url = drawingNumberUrl(att.drawing_view_url, drawingNumber);
+    openDrawingTab(url);
+}
+function openEditableDrawing(att, drawingNumber = '') {
+    const key = att.id + ':' + att.drawing_file_id;
+    const cached = drawingEditableAccessCache.get(key);
+    if (cached && cached.validUntil > Date.now()) {
+        const url = drawingNumberUrl(cached.url, drawingNumber);
+        openDrawingTab(url);
+        return;
+    }
+    if (drawingAccessPending.has(key)) {
+        return;
+    }
+    drawingAccessPending.add(key);
+    postData('/tender/' + tender.id + '/ledger/drawing/access', { id: att.id }, function (data) {
+        drawingAccessPending.delete(key);
+        drawingEditableAccessCache.set(key, {
+            access: data.access, expires_at: data.expires_at, url: data.url,
+            validUntil: Date.now() + Math.max(0, data.expires_in_ms - 30000),
+        });
+        openDrawingTab(drawingNumberUrl(data.url, drawingNumber));
+    }, function () {
+        drawingAccessPending.delete(key);
+    }, false);
+}
+function updateDrawingButton() {
+    const id = Number($('#showAttachment').attr('file-id'));
+    const att = attData.find(item => item.id === id);
+    const isDrawing = att && Number(att.is_drawing) === 1;
+    $('#att-drawing-btn').toggle(Boolean(att && String(att.fileext).toLowerCase() === '.pdf'))
+        .prop('disabled', Boolean(isDrawing || drawingPendingIds.has(id)))
+        .text(drawingPendingIds.has(id) ? '处理中…' : '图纸');
+}
 // 生成当前节点列表
 function getNodeList(node) {
   let html = '';

+ 3 - 0
app/router.js

@@ -750,6 +750,9 @@ module.exports = app => {
 
     // 台账附件
     app.post('/tender/:id/ledger/upload/file', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.uploadFile');
+    app.post('/tender/:id/ledger/drawing/file', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.createDrawing');
+    app.post('/tender/:id/ledger/drawing/access', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.createDrawingEditableAccess');
+    app.post('/tender/:id/ledger/drawing/find', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.findDrawing');
     app.get('/tender/:id/ledger/download/file/:fid', sessionAuth, 'ledgerController.downloadFile');
     app.post('/tender/:id/ledger/delete/file', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.deleteFile');
     app.post('/tender/:id/ledger/save/file', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'ledgerController.saveFile');

+ 120 - 15
app/service/ledger_att.js

@@ -10,7 +10,9 @@
 
 const archiver = require('archiver');
 const path = require('path');
-const fs = require('fs');
+const fs = require('fs');
+const ZhDrawing = require('../lib/zh_drawing');
+const ledgerAudit = require('../const/audit').ledger;
 module.exports = app => {
     class LedgerAtt extends app.BaseService {
         /**
@@ -49,14 +51,112 @@ module.exports = app => {
          * @param {int} uid - 上传者id
          * @return {void}
          */
-        async updateByID(postData, fileData) {
-            delete postData.size;
-            const data = {};
-            Object.assign(data, fileData);
-            Object.assign(data, postData);
-            const result = await this.db.update(this.tableName, data);
-            return result.affectedRows === 1;
-        }
+        async updateByID(postData, fileData) {
+            delete postData.size;
+            // 图纸关联仅由专用接口写入;替换附件文件后清除旧关联。
+            delete postData.is_drawing;
+            delete postData.drawing_file_id;
+            delete postData.drawing_access;
+            const data = {};
+            Object.assign(data, fileData);
+            Object.assign(data, postData);
+            if (fileData.filepath) {
+                Object.assign(data, { is_drawing: 0, drawing_file_id: null, drawing_access: null });
+            }
+            const result = await this.db.update(this.tableName, data);
+            return result.affectedRows === 1;
+        }
+
+        // 与附件“编辑”按钮相同:本人附件,审批完成后仅允许补传附件。
+        async createDrawingEditableAccess(id) {
+            const { ctx } = this;
+            id = Number(id);
+            if (!Number.isSafeInteger(id) || id <= 0 || ctx.tender.isTourist) throw new Error('无权编辑此附件');
+            const att = await this.db.get(this.tableName, { id, tid: ctx.tender.id, revising: 0, settle_id: -1 });
+            if (!att || Number(att.uid) !== Number(ctx.session.sessionUser.accountId) ||
+                (ctx.tender.data.ledger_status === ledgerAudit.status.checked && !att.extra_upload)) {
+                throw new Error('无权编辑此附件');
+            }
+            if (Number(att.is_drawing) !== 1 || !att.drawing_file_id) throw new Error('附件尚未关联图纸');
+            const client = new ZhDrawing(ctx.app.config.pdfDrawing);
+            const result = await client.createEditableAccess(att.drawing_file_id);
+            // 纵横图纸无时区的时间采用北京时间,与其服务端 now_local 保持一致。
+            const expiresAt = String(result.expires_at || '');
+            const expiry = Date.parse(/(?:Z|[+-]\d{2}:\d{2})$/i.test(expiresAt) ? expiresAt : expiresAt + '+08:00');
+            const remaining = expiry - Date.now();
+            if (result.permission !== 'editable' || typeof result.access !== 'string' || !result.access || !(remaining > 0)) {
+                throw new Error('未获取到有效的可编辑图纸凭证');
+            }
+            return {
+                access: result.access, expires_at: result.expires_at, expires_in_ms: remaining,
+                url: client.baseUrl + '/file?id=' + encodeURIComponent(att.drawing_file_id) + '&access=' + encodeURIComponent(result.access),
+            };
+        }
+
+        async findDrawing(drawingNumber) {
+            const attachments = await this.getDataByTenderId(this.ctx.tender.id);
+            const fileIds = [...new Set(attachments.filter(att => Number(att.is_drawing) === 1 && att.drawing_file_id)
+                .map(att => att.drawing_file_id))];
+            if (!fileIds.length) return { file_id: null };
+            const client = new ZhDrawing(this.ctx.app.config.pdfDrawing);
+            // 与全部附件显示顺序一致;超出单次接口上限时按顺序分批。
+            for (let i = 0; i < fileIds.length; i += 100) {
+                const result = await client.findDrawing(fileIds.slice(i, i + 100), drawingNumber);
+                if (result.file_id) return { file_id: result.file_id };
+            }
+            return { file_id: null };
+        }
+
+        // 附件已成功删除后尽力同步图纸软删除;远端失败不影响附件删除结果。
+        async syncDeletedDrawing(attachment) {
+            if (Number(attachment.is_drawing) !== 1 || !attachment.drawing_file_id) return;
+            try {
+                const config = Object.assign({}, this.ctx.app.config.pdfDrawing, { timeout: 3000 });
+                await new ZhDrawing(config).deleteFile(attachment.drawing_file_id);
+            } catch (err) {
+                // 不记录请求对象、Token 或 access,避免敏感凭证写入日志。
+                this.ctx.logger.warn('附件 %s 的图纸删除同步失败(状态 %s),附件删除结果不受影响',
+                    attachment.id, err.status || err.code || 'unknown');
+            }
+        }
+
+        // 仅处理当前标段中本人上传的 PDF;行锁避免重复点击并发创建关联。
+        async createDrawing(id) {
+            const { ctx } = this;
+            id = Number(id);
+            if (!Number.isSafeInteger(id) || id <= 0 || ctx.tender.isTourist) {
+                throw new Error('无权操作此附件');
+            }
+            const conn = await this.db.beginTransaction();
+            try {
+                const rows = await conn.query(
+                    'SELECT * FROM ?? WHERE id = ? AND tid = ? AND revising = 0 AND settle_id = -1 FOR UPDATE',
+                    [this.tableName, id, ctx.tender.id]
+                );
+                const att = rows[0];
+                if (!att || Number(att.uid) !== Number(ctx.session.sessionUser.accountId)) {
+                    throw new Error('无权操作此附件');
+                }
+                if (String(att.fileext).toLowerCase() !== '.pdf') throw new Error('仅支持 PDF 附件');
+                if (!(Number(att.is_drawing) === 1 && att.drawing_file_id && att.drawing_access)) {
+                    const file = (ctx.app.config.fujianOssPath + att.filepath).replace(/\\/g, '/');
+                    if (!/^https?:\/\//i.test(file)) throw new Error('附件下载地址无效');
+                    const result = await new ZhDrawing(ctx.app.config.pdfDrawing).createFile(file, att.filename + att.fileext);
+                    if (!result || result.accepted !== true || typeof result.file_id !== 'string' || !result.file_id.trim() ||
+                        typeof result.access !== 'string' || !result.access.trim() || result.permission !== 'readonly') {
+                        throw new Error('图纸接口未返回有效的图纸 ID 和只读凭证');
+                    }
+                    await conn.update(this.tableName, {
+                        id, is_drawing: 1, drawing_file_id: result.file_id, drawing_access: result.access,
+                    });
+                }
+                await conn.commit();
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return this.getDataByFid(id);
+        }
 
 
         /**
@@ -65,7 +165,7 @@ module.exports = app => {
          * @return {void}
          */
         async getDataByTenderId(tid) {
-            const sql = 'SELECT att.id, att.lid, att.uid, att.filepath, att.filename, att.fileext, att.filesize, att.extra_upload, att.remark, att.in_time,' +
+            const sql = 'SELECT att.is_drawing, att.drawing_file_id, att.drawing_access, att.id, att.lid, att.uid, att.filepath, att.filename, att.fileext, att.filesize, att.extra_upload, att.remark, att.in_time,' +
                 '     pa.name as `username`, leg.name as `lname`, leg.code as `code`, leg.ledger_id as `ledger_id`, leg.b_code as `b_code`' +
                 '  FROM ?? AS att ' +
                 '    LEFT JOIN ?? AS pa ON att.uid = pa.id ' +
@@ -84,7 +184,7 @@ module.exports = app => {
          */
         async getDataByFid(id) {
             const { ctx } = this;
-            const sql = 'SELECT att.id, att.lid, att.uid, att.filepath, att.filename, att.extra_upload, att.fileext, att.filesize, att.remark, att.in_time,' +
+            const sql = 'SELECT att.is_drawing, att.drawing_file_id, att.drawing_access, att.id, att.lid, att.uid, att.filepath, att.filename, att.extra_upload, att.fileext, att.filesize, att.remark, att.in_time,' +
                 ' pa.name as `username`, leg.name as `lname`, leg.code as `code`, leg.ledger_id as `ledger_id`,leg.b_code as `b_code`' +
                 ' FROM ?? AS att,?? AS pa,?? AS leg' +
                 ' WHERE leg.id = att.lid AND pa.id = att.uid AND att.id = ? ORDER BY att.in_time DESC';
@@ -144,11 +244,16 @@ module.exports = app => {
         }
 
         _analysisAtt(data) {
-            const datas = data instanceof Array ? data : [data];
+            const datas = Array.isArray(data) ? data : [data];
             for (const r of datas) {
                 r.orginpath = this.ctx.app.config.fujianOssPath + r.filepath;
                 r.filepath = this.ctx.app.config.fujianOssPath + r.filepath;
-                r.viewpath = this.ctx.helper.getPreviewPath(r.fileext, r.filepath);
+                r.viewpath = this.ctx.helper.getPreviewPath(r.fileext, r.filepath);
+                // 页面访问仅使用该文件的只读 access,不向浏览器传递 API token。
+                const baseUrl = String((this.ctx.app.config.pdfDrawing || {}).baseUrl || '').trim().replace(/\/+$/, '');
+                r.drawing_view_url = Number(r.is_drawing) === 1 && r.drawing_file_id && r.drawing_access && /^https?:\/\//i.test(baseUrl)
+                    ? `${baseUrl}/file?id=${encodeURIComponent(r.drawing_file_id)}&access=${encodeURIComponent(r.drawing_access)}`
+                    : '';
                 r.in_time = this.ctx.moment(r.in_time * 1000).format('YYYY-MM-DD');
             }
         }
@@ -183,8 +288,8 @@ module.exports = app => {
             return result;
         }
 
-        async getLedgerViewData(tid) {
-            const sql = 'SELECT att.id, att.lid, att.uid, att.filepath, att.filename, att.fileext, att.filesize, att.extra_upload, att.remark, att.in_time,' +
+        async getLedgerViewData(tid) {
+            const sql = 'SELECT att.is_drawing, att.drawing_file_id, att.drawing_access, att.id, att.lid, att.uid, att.filepath, att.filename, att.fileext, att.filesize, att.extra_upload, att.remark, att.in_time,' +
                 ' pa.name as `username`' +
                 ' FROM ' + this.tableName + ' att Left Join ' + this.ctx.service.projectAccount.tableName + ' pa On pa.id = att.uid' +
                 ' WHERE att.tid = ? AND att.revising = 0 AND att.settle_id = -1 ORDER BY att.id DESC';

+ 6 - 3
app/service/stage_audit.js

@@ -1354,7 +1354,7 @@ module.exports = app => {
             }
         }
         /**
-         * 审批人撤回审批退回上一人,插入两条数据
+         * 审批人撤回审批退回上一人,删除被退回人的审批流程,插入退回人的撤销流程
          *
          * 一审 1 A checked                   一审 1 A checked
          * 二审 2 B checked                   二审 2 B checked
@@ -1385,6 +1385,9 @@ module.exports = app => {
                 // 删除当前审批人
                 await transaction.delete(this.tableName, { id: stage.curAuditors.map(x => { return x.id; }) });
                 await this.ctx.service.noticeAgain.deleteNoticeAgain(transaction, this.tableName, this._.map(stage.curAuditors, 'id'));
+                // 修改原退回的锁定等状态
+                await transaction.update(this.tableName, { audit_locked: 0, audit_locked_lid: '' },
+                    { where: { sid: stage.id, times: selfAuditor.times, order: selfAuditor.order }});
                 // 添加撤回人到审批流程中
                 const newAuditors = [];
                 stage.preAuditors.forEach(x => {
@@ -1401,7 +1404,7 @@ module.exports = app => {
                     });
                 });
                 await transaction.insert(this.tableName, newAuditors);
-                // 更新上一个人,最新审批状态为审批中
+                // 更新上一个人,最新审批状态为审批中 // todo 协同时是否应该保持其他审批人的审批状态
                 await transaction.update(this.tableName,  { begin_time: time, status: auditConst.status.checking }, {
                     where: { sid: stage.id, times: selfAuditor.times, order: selfAuditor.order + 2 }
                 });
@@ -1566,7 +1569,7 @@ module.exports = app => {
          * @returns {Promise<void>}
          * @private
          */
-        async _auditCheckCancelAnd(stage) {
+            async _auditCheckCancelAnd(stage) {
             const accountId = this.ctx.session.sessionUser.accountId;
             const selfAuditor = stage.flowAuditors.find(x => { return x.aid === accountId; });
             const nextChecked = selfAuditor && selfAuditor.audit_group_order ? stage.flowAuditors.find(x => { return x.audit_group_order > selfAuditor.audit_group_order && x.status === auditConst.status.checked; }) : null;

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

@@ -293,6 +293,7 @@
                                           <!--编辑模式-->
                                           <a href="javascript:void(0);" content="save" class="btn btn-sm btn-outline-success mr-1" style="display: none; margin-right: 5px">保存</a>
                                           <a href="javascript:void(0);" content="cancel" class="btn btn-sm btn-outline-secondary" style="display: none; margin-right: 5px">取消</a>
+                                          <button type="button" id="att-drawing-btn" class="btn btn-sm btn-outline-primary" style="display: none">图纸</button>
                                       </div>
                                       <!--显示信息-->
                                       <table class="table table-sm table-bordered" id="show-att" style="word-break:break-all; table-layout: fixed">
@@ -360,6 +361,23 @@
             </div>
         </div>
         <!--右侧菜单-->
+        <!--图纸解析确认:与删除节点提示窗保持一致的 Bootstrap Modal 布局。-->
+        <div class="modal fade" id="att-drawing-confirm" data-backdrop="static" tabindex="-1" role="dialog" aria-labelledby="att-drawing-confirm-title" aria-hidden="true">
+            <div class="modal-dialog" role="document">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title" id="att-drawing-confirm-title">图纸解析确认</h5>
+                    </div>
+                    <div class="modal-body">
+                        <h6>将解析图纸并创建目录,是否继续?</h6>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm btn-primary" id="att-drawing-confirm-ok">确定</button>
+                        <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                    </div>
+                </div>
+            </div>
+        </div>
         <div class="side-menu">
             <ul class="nav flex-column right-nav" id="side-menu">
                 <li>

+ 4 - 0
config/config.default.js

@@ -290,5 +290,9 @@ module.exports = appInfo => {
             contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
+    config.pdfDrawing = {
+        baseUrl: 'https://drawing.smartcost.com.cn',
+        token: 'pdf_2OxDDrjENk5wFl_K-8_5dPDZ-9Rtq_AsixP4d7tvDJg',
+    };
     return config;
 };

+ 5 - 0
config/config.local.js

@@ -130,5 +130,10 @@ module.exports = appInfo => {
             contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
+
+    config.pdfDrawing = {
+        baseUrl: 'http://192.168.1.76:5171',
+        token: 'pdf_2OxDDrjENk5wFl_K-8_5dPDZ-9Rtq_AsixP4d7tvDJg',
+    };
     return config;
 };

+ 5 - 0
config/config.qa.js

@@ -105,5 +105,10 @@ module.exports = appInfo => {
             contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
+
+    config.pdfDrawing = {
+        baseUrl: 'http://192.168.1.76:5171',
+        token: 'pdf_2OxDDrjENk5wFl_K-8_5dPDZ-9Rtq_AsixP4d7tvDJg',
+    };
     return config;
 };

+ 4 - 0
config/config.uat.js

@@ -101,5 +101,9 @@ module.exports = appInfo => {
         heavy: 100,
         light: 10,
     };
+    config.pdfDrawing = {
+        baseUrl: 'https://drawing.smartcost.com.cn',
+        token: 'pdf_2OxDDrjENk5wFl_K-8_5dPDZ-9Rtq_AsixP4d7tvDJg',
+    };
     return config;
 };

+ 6 - 0
sql/update.sql

@@ -113,6 +113,12 @@ CREATE TABLE `zh_project_screen` (
   KEY `idx_project_screen_list` (`pid`, `create_time`)
 ) ENGINE=InnoDB COMMENT='项目大屏配置';
 
+-- 台账分解附件关联纵横图纸:执行一次,drawing_access 仅保存只读访问凭证。
+ALTER TABLE `zh_ledger_attachment`
+  ADD COLUMN `is_drawing` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否为图纸附件:0否,1是',
+  ADD COLUMN `drawing_file_id` varchar(36) DEFAULT NULL COMMENT '纵横图纸文件ID',
+  ADD COLUMN `drawing_access` text NULL COMMENT '纵横图纸只读访问凭证';
+
 ------------------------------------
 -- 表数据
 ------------------------------------