Просмотр исходного кода

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

Tony Kang 3 недель назад
Родитель
Сommit
81c6f421ed
100 измененных файлов с 13830 добавлено и 808 удалено
  1. 49 0
      AGENTS.md
  2. 1 1
      app/base/base_bills_service.js
  3. 5 0
      app/base/base_controller.js
  4. 18 1
      app/const/account_permission.js
  5. 1 0
      app/const/fun_set.js
  6. 1 0
      app/const/page_show.js
  7. 33 0
      app/const/project_screen.js
  8. 9 0
      app/const/safe_inspection.js
  9. 114 0
      app/controller/assistant_controller.js
  10. 9 9
      app/controller/change_controller.js
  11. 3 11
      app/controller/contract_controller.js
  12. 3 1
      app/controller/dashboard_controller.js
  13. 8 21
      app/controller/deal_bills_controller.js
  14. 682 37
      app/controller/file_controller.js
  15. 36 0
      app/controller/ledger_controller.js
  16. 17 15
      app/controller/material_controller.js
  17. 67 0
      app/controller/project_screen_controller.js
  18. 1 1
      app/controller/report_controller.js
  19. 69 6
      app/controller/safe_controller.js
  20. 79 0
      app/controller/setting_controller.js
  21. 62 4
      app/controller/sub_proj_setting_controller.js
  22. 4 0
      app/controller/tender_controller.js
  23. 1 0
      app/controller/wap_controller.js
  24. 2 1
      app/extend/helper.js
  25. 52 0
      app/lib/ai_dify.js
  26. 1 2
      app/lib/analysis_excel.js
  27. 167 0
      app/lib/zh_drawing.js
  28. 4 1
      app/middleware/advance_check.js
  29. 2 2
      app/middleware/change_plan_check.js
  30. 8 2
      app/middleware/safe_inspection_check.js
  31. 8 0
      app/middleware/session_auth.js
  32. 895 0
      app/public/css/main.css
  33. BIN
      app/public/images/filesjs-folder.png
  34. BIN
      app/public/images/filesjs-leaf.png
  35. BIN
      app/public/images/filesjs-tree-closed.png
  36. BIN
      app/public/images/filesjs-tree-leaf.png
  37. BIN
      app/public/images/filesjs-tree-open.png
  38. BIN
      app/public/images/juecedaping08.png
  39. 3 3
      app/public/js/advance_audit.js
  40. 316 0
      app/public/js/assistant.js
  41. 21 8
      app/public/js/change_information.js
  42. 1 1
      app/public/js/change_plan.js
  43. 1 1
      app/public/js/change_plan_audit.js
  44. 1 1
      app/public/js/change_plan_information.js
  45. 1569 0
      app/public/js/config_dir.js
  46. 3 3
      app/public/js/file_detail.js
  47. 2560 0
      app/public/js/filesjs.js
  48. 41 19
      app/public/js/global.js
  49. 162 6
      app/public/js/ledger.js
  50. 5 6
      app/public/js/material_exponent.js
  51. 2 2
      app/public/js/phase_pay_detail.js
  52. 139 0
      app/public/js/project_screen_guangyuan.js
  53. 106 0
      app/public/js/project_screen_guangyuan_building.js
  54. 124 0
      app/public/js/project_screen_guangyuan_finished.js
  55. 169 0
      app/public/js/project_screen_guangyuan_planned.js
  56. 275 0
      app/public/js/project_screen_setting.js
  57. 27 2
      app/public/js/revise.js
  58. 2 2
      app/public/js/revise_price.js
  59. 210 25
      app/public/js/safe_inspection_information.js
  60. 80 0
      app/public/js/safe_inspection_overview.js
  61. 1 1
      app/public/js/safe_tender.js
  62. 1 1
      app/public/js/shares/drag_tree.js
  63. 1 1
      app/public/js/shares/tender_select.js
  64. 4 3
      app/public/js/shares/tools_att.js
  65. 2 4
      app/public/js/sp_data.js
  66. 353 0
      app/public/js/sp_setting_data_range.js
  67. 341 151
      app/public/js/spreadjs_rela/spreadjs_zh.js
  68. 2 1
      app/public/js/stage.js
  69. 1 1
      app/public/js/stage_pay.js
  70. 1 1
      app/public/js/sub_project.js
  71. 5 2
      app/public/js/tender_list_progress.js
  72. 6 2
      app/public/report/js/rpt_change_rela.js
  73. 35 0
      app/router.js
  74. 1 1
      app/service/advance.js
  75. 71 39
      app/service/advance_audit.js
  76. 2228 0
      app/service/assistant.js
  77. 77 0
      app/service/category.js
  78. 12 0
      app/service/change_audit_list.js
  79. 1 1
      app/service/change_plan.js
  80. 32 36
      app/service/deal_bills.js
  81. 143 13
      app/service/file.js
  82. 219 21
      app/service/filing.js
  83. 120 15
      app/service/ledger_att.js
  84. 41 118
      app/service/material_exponent.js
  85. 0 6
      app/service/material_exponent_node.js
  86. 11 9
      app/service/material_exponent_shard.js
  87. 113 0
      app/service/project_account.js
  88. 317 0
      app/service/project_screen.js
  89. 1 1
      app/service/quality_inspection_audit.js
  90. 223 137
      app/service/safe_inspection.js
  91. 315 26
      app/service/safe_inspection_audit.js
  92. 0 6
      app/service/stage_att.js
  93. 7 4
      app/service/stage_audit.js
  94. 24 6
      app/service/stage_change.js
  95. 1 0
      app/service/stage_stash.js
  96. 223 0
      app/service/sub_proj_data_range.js
  97. 35 7
      app/service/sub_proj_permission.js
  98. 37 0
      app/service/sub_project.js
  99. 597 0
      app/service/sub_project_filing_permission.js
  100. 0 0
      app/service/tender.js

+ 49 - 0
AGENTS.md

@@ -0,0 +1,49 @@
+# 项目工作规则
+
+以下规则适用于本项目的所有开发与修改。
+
+## SQL 文件命名
+
+- 生成或更新 SQL 脚本时,必须统一写入 `update.sql`。
+- 不得创建带日期后缀或日期标记的替代文件,例如 `updateYYYYMMDD.sql`、`update-YYYY-MM-DD.sql`,或任何以年、月、日或日期作为 `XXX` 的 `updateXXX.sql` 文件。
+- 如果 `update.sql` 已存在,直接更新该文件并保留无关的原有内容,不得另行创建其他 SQL 更新文件。
+
+## 方法位置
+
+- 新增方法必须写在目标文件的最下方;类或对象中的方法应追加在其最后一个已有方法之后、闭合括号之前。
+- 不得将新增方法插入文件顶部,也不得放在已有方法之前。
+- 修改已有方法时,保留其原有位置。
+
+## Controller 代码风格
+
+- Controller 写法参考项目现有实现(如 `app/controller/tender_controller.js`),保持项目代码风格一致。
+- 页面渲染使用 `const renderData` 组织数据,再传给 `this.layout(...)`。常规数据可以直接在 `renderData` 对象初始化时集中设置;后续补充的数据,或 `if` 等条件分支中仅对部分用户可见的数据,再通过 `renderData.xxx` 赋值。不要求所有字段都逐项赋值。
+
+## Service 与查询代码风格
+
+- Service 写法必须参考系统现有实现,优先复用已有方法,保持项目代码风格一致。
+- 普通数据库操作优先使用系统已有的 Service、基类方法或数据库封装;只有遇到复杂 SQL 查询时才使用手写 SQL。
+- 手写 SQL 时,使用 `const sql` 和 `const sqlParam` 分别声明 SQL 语句与参数。
+- 参数必须通过占位符传递,不得直接拼接参数到 SQL 语句中。
+- 所有权限查询均不使用 `SqlBuilder`。
+
+## 弹窗组织
+
+- 弹窗模板独立放置,不得直接混写在主页面模板中。
+- 首页或列表页的弹窗可以使用对应模块下的 `modal.ejs`。
+- 其他页面的弹窗使用 `<页面名>_modal.ejs`,例如广元大屏使用 `guangyuan_modal.ejs`,避免不同页面统一使用 `modal.ejs` 而混淆归属。
+- 如果需要独立的弹窗 JavaScript 文件,同样按页面命名为 `<页面名>_modal.js`;首页或列表页可以使用 `modal.js`。不必仅为命名而新建独立脚本。
+
+## 测试文件
+
+- 不新增测试文件。
+
+## 页面样式组织
+
+- 页面自定义 CSS 统一写入 `app/public/css/main.css`,不另建页面专用 CSS 文件。
+- 使用页面专属类名限定样式作用范围,避免影响其他页面。
+
+## 前端 JavaScript 代码风格
+
+- 前端 JavaScript 的业务接口请求优先使用系统封装的 `postData`,不另行使用 `fetch` 或重复封装底层 Ajax;页面导航和静态资源加载不在此限制内。
+- `postData` 的成功回调接收的是解包后的 `data`,不要再次按完整响应对象读取;异步包装时必须保留失败回调,使加载失败和重试流程正常工作。

+ 1 - 1
app/base/base_bills_service.js

@@ -815,7 +815,7 @@ class BaseBillsSerivce extends TreeService {
                                     quantity: ag.quantity || 0, expr: ag.expr || '', calc_template: ag.calc_template || ''
                                 };
                                 pasteAncGclData.push(nig);
-                                if (!ag.calc_template || ag.calcDetail.length === 0) continue;
+                                if (!ag.calc_template || !ag.calcDetail || ag.calcDetail.length === 0) continue;
 
                                 let sumQty = 0;
                                 for (const cd of ag.calcDetail) {

+ 5 - 0
app/base/base_controller.js

@@ -90,6 +90,9 @@ class BaseController extends Controller {
                         case 'financial':
                             im.url = `/sp/${ctx.subProject.id}/${im.controller}/${ctx.subProject.financialToUrl}`;
                             break;
+                        case 'file':
+                            im.url = `/sp/${ctx.subProject.id}/filesjs`;
+                            break;
                         case 'budget':
                             for (const child of im.children) {
                                 if (child.msg === 'budget') {
@@ -107,6 +110,7 @@ class BaseController extends Controller {
             }
         } else {
             menuList.management.display = ctx.session && ctx.session.sessionProject ? ctx.session.sessionProject.page_show.openManagement : false;
+            menuList.projectscreen.display = !!ctx.showProjectScreen;
         }
         ctx.menuList = menuList;
     }
@@ -170,6 +174,7 @@ class BaseController extends Controller {
                 breadCrumb: data.breadCrumb === undefined ? '' : data.breadCrumb,
                 tenderList: data.tenderList === undefined ? [] : data.tenderList,
                 jsFiles: data.jsFiles ? data.jsFiles : this.app.jsFiles.common,
+                pageTitle: data.pageTitle || '',
                 maintainData,
                 maintainConst,
             };

+ 18 - 1
app/const/account_permission.js

@@ -73,6 +73,14 @@ const permission = {
             { title: '查阅所有施工标段', value: 1 },
         ],
     },
+    projectscreen: {
+        class: 'fa fa-television',
+        title: '项目大屏',
+        type: 'checkbox',
+        children: [
+            { title: '查看', value: 1 },
+        ],
+    },
     // cooperation: {
     //     class: '',
     //     title: '协作办公',
@@ -100,6 +108,12 @@ const PermissionCheck = {
         switch (key) {
             case 'viewPmData': return permission && permission.tender ? permission.tender.indexOf('6') >= 0 : false;
             case 'delFile': return permission && permission.tender ? permission.tender.indexOf('7') >= 0 : false;
+            case 'projectScreen': {
+                if (!permission || !permission.projectscreen) return false;
+                const projectScreenPermission = permission.projectscreen instanceof Array
+                    ? permission.projectscreen : [permission.projectscreen];
+                return projectScreenPermission.map(String).indexOf('1') >= 0;
+            }
             default: return false;
         }
     },
@@ -108,7 +122,10 @@ const PermissionCheck = {
     },
     delFile: function(permission) {
         return this.check(permission, 'delFile');
-    }
+    },
+    projectScreen: function(permission) {
+        return this.check(permission, 'projectScreen');
+    },
 };
 // 用户重新发送权限默认值
 const noticeAgain = {

+ 1 - 0
app/const/fun_set.js

@@ -73,6 +73,7 @@ const defaultInfo = {
         end_month: 0,
         end_day: 0,
     },
+    change_plan_name: '',
     change_class: changeClass,
     change_state: changeState,
 };

+ 1 - 0
app/const/page_show.js

@@ -56,6 +56,7 @@ const defaultSetting = {
     maxMultiStageCount: 5,
     openStageStart: 0,
     openDataCollect: 1,
+    openProjectScreen: 0,
     openFile: 1,
     openBudget: 1,
     openPayment: 1,

+ 33 - 0
app/const/project_screen.js

@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * 项目大屏模板注册表。
+ * 数据库只保存 code,页面及数据提供器必须在代码中注册,避免动态执行数据库内容。
+ */
+const templates = [
+    {
+        code: 'general1',
+        name: '通用大屏一',
+        description: '通用项目综合展示模板',
+        previewClass: 'project-screen-preview-general1',
+    },
+    {
+        code: 'general3',
+        name: '通用大屏三',
+        description: '通用项目图表展示模板',
+        previewClass: 'project-screen-preview-general3',
+    },
+    {
+        code: 'guangyuan',
+        name: '广元定制',
+        description: '广元项目定制展示模板',
+        previewClass: 'project-screen-preview-guangyuan',
+    },
+];
+
+const getTemplate = code => templates.find(item => item.code === code) || null;
+
+module.exports = {
+    templates,
+    getTemplate,
+};

+ 9 - 0
app/const/safe_inspection.js

@@ -0,0 +1,9 @@
+'use strict';
+
+// 安全巡检独立扩展,避免改变质量巡检的状态与流程。
+const inspection = JSON.parse(JSON.stringify(require('./audit').inspection));
+inspection.stage = { legacy: 0, report: 1, rectification: 2, reply: 3 };
+inspection.stageString = ['旧流程', '上报', '整改', '批复'];
+inspection.filter.status.reply = 13;
+inspection.filter.statusString[13] = '批复中';
+module.exports = inspection;

+ 114 - 0
app/controller/assistant_controller.js

@@ -0,0 +1,114 @@
+'use strict';
+
+const AiDify = require('../lib/ai_dify');
+
+/**
+ * 全局业务查询助手
+ *
+ * @param {Object} app Egg应用
+ * @return {Object} 助手Controller
+ * @author Codex
+ */
+module.exports = app => {
+    class AssistantController extends app.BaseController {
+        /**
+         * 查询业务数据
+         *
+         * @param {Object} ctx - egg全局页面
+         * @return {void}
+         */
+        async query(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data || '{}');
+                const question = data.question ? data.question.trim() : '';
+                if (!question) throw '请输入要查询的内容';
+                if (question.length > 200) throw '问题不能超过200个字符';
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: await ctx.service.assistant.query(question, data.context || {}),
+                };
+            } catch (err) {
+                console.log(err);
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '助手查询失败');
+            }
+        }
+
+        /** v2:Chatflow 分类和解析,共用 assistant Service 的业务查询。 */
+        async queryV2(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data || '{}');
+                const question = typeof data.question === 'string' ? data.question.trim() : '';
+                if (!question) throw '请输入要查询的内容';
+                if (question.length > 200) throw '问题不能超过200个字符';
+                const sessionUser = ctx.session.sessionUser;
+                if (sessionUser.loginType === 4) throw '游客账号暂不支持使用查询助手';
+                const context = data.context && typeof data.context === 'object' ? data.context : {};
+                const path = typeof context.path === 'string' ? context.path.split(/[?#]/)[0] : '';
+                if (path.length > 1024 || (path && !/^\/(?!\/)/.test(path))) throw '页面路径不正确';
+                const conversationId = data.conversationId || '';
+                if (typeof conversationId !== 'string' || (conversationId && !/^[a-f0-9-]{36}$/i.test(conversationId))) {
+                    throw '助手会话参数不正确,请清空对话后重试';
+                }
+                const config = app.config.difyInfo;
+                if (!config || !config.apiKey || !config.apiKey.assistant || !config.baseUrl) throw '尚未配置查询助手 Chatflow';
+                const dify = new AiDify({ baseUrl: config.baseUrl, apiKey: config.apiKey.assistant });
+                // 会话归属由 Dify 按服务端生成的 user 校验,不能使用客户端传入的用户 ID。
+                const user = `assistant-${app.config.env}-${ctx.session.sessionProject.id}-${sessionUser.accountId}`;
+                let response;
+                try {
+                    response = await dify.askAssistant({ question, path, user, conversationId });
+                } catch (err) {
+                    // ai_dify 已将网络错误转换为不含密钥、请求正文的安全提示。
+                    throw err.message;
+                }
+                const parsed = response.result;
+                let result = {
+                    answer: parsed.answer,
+                    summary: [],
+                    items: parsed.sources.filter(source => source && typeof source.title === 'string' &&
+                        typeof source.url === 'string' && /^https?:\/\//i.test(source.url))
+                        .slice(0, 20).map(source => ({ title: source.title, url: source.url })),
+                    suggestions: [],
+                };
+                if (parsed.type === 'business' && parsed.status === 'query') {
+                    try {
+                        result = await ctx.service.assistant.queryParameters(parsed.query, Object.assign({}, context, { path }));
+                    } catch (err) {
+                        this.log(err);
+                        // 保留已完成解析的会话,让用户能补充标段、期号等条件。
+                        result = { answer: typeof err === 'string' ? err : '业务查询失败,请稍后重试。', summary: [], items: [], suggestions: [] };
+                    }
+                }
+                result.conversationId = response.conversationId;
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                console.log(err);
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '助手查询失败');
+            }
+        }
+
+        /** 下载查询助手返回的统一附件。 */
+        async attachment(ctx) {
+            try {
+                const file = await ctx.service.assistant.getAttachmentFile(ctx.params.source, ctx.params.id);
+                const disposition = `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`;
+                const headers = {
+                    'Content-Type': 'application/octet-stream',
+                    'Content-Disposition': disposition,
+                };
+                if (/^[1-9]\d*$/.test(String(file.filesize))) headers['Content-Length'] = file.filesize;
+                ctx.response.set(headers);
+                ctx.body = await ctx.helper.ossFileGet(file.filepath);
+            } catch (err) {
+                this.log(err);
+                ctx.status = 404;
+                ctx.body = this.ajaxErrorBody(err, '附件下载失败');
+            }
+        }
+    }
+
+    return AssistantController;
+};

+ 9 - 9
app/controller/change_controller.js

@@ -3651,7 +3651,7 @@ module.exports = app => {
                 // 判断是否被变更申请调用了,是则无法发起修订
                 const projectInfo = await ctx.service.changePlan.getDataByCondition({ tid: ctx.tender.id, apply_code: ctx.change.code });
                 if (projectInfo) {
-                    throw '该变更申请已被变更方案调用,无法发起重新审批';
+                    throw '该变更申请已被' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '调用,无法发起重新审批';
                 }
                 if (ctx.session.sessionUser.loginStatus === 0) {
                     const code = ctx.request.body.code;
@@ -3705,7 +3705,7 @@ module.exports = app => {
                 // 判断是否被变更申请调用了,是则无法发起修订
                 const projectInfo = await ctx.service.changePlan.getDataByCondition({ tid: ctx.tender.id, apply_code: ctx.change.code });
                 if (projectInfo) {
-                    throw '该变更申请已被变更方案调用,无法发起修订';
+                    throw '该变更申请已被' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '调用,无法发起修订';
                 }
                 if (ctx.session.sessionUser.loginStatus === 0) {
                     const code = ctx.request.body.code;
@@ -3951,7 +3951,7 @@ module.exports = app => {
                 }
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.code || data.code === '') {
-                    throw '变更方案编号不能为空';
+                    throw (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '编号不能为空';
                 }
 
                 // 在生成新变更方案后,需要copy前一个变更方案报表的签名信息
@@ -3975,7 +3975,7 @@ module.exports = app => {
             try {
                 const result = await ctx.service.changePlan.delete(ctx.request.body.cpid);
                 if (!result) {
-                    throw '删除变更方案失败';
+                    throw '删除' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '失败';
                 }
                 ctx.redirect(ctx.request.header.referer);
             } catch (err) {
@@ -4337,10 +4337,10 @@ module.exports = app => {
                     throw '数据错误';
                 }
                 if (ctx.change.uid !== ctx.session.sessionUser.accountId) {
-                    throw '您无权上报该变更方案数据';
+                    throw '您无权上报该' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '数据';
                 }
                 if (ctx.change.status === auditConst.status.checking || ctx.change.status === auditConst.status.checked) {
-                    throw '该变更方案数据当前无法上报';
+                    throw '该' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '数据当前无法上报';
                 }
 
                 await ctx.service.changePlanAudit.start(ctx.change.id, ctx.change.times);
@@ -4362,7 +4362,7 @@ module.exports = app => {
             try {
                 const auditConst = audit.changePlan;
                 if (!ctx.change || ctx.change.status !== auditConst.status.checking) {
-                    throw '当前变更方案数据有误';
+                    throw '当前' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '数据有误';
                 }
                 if (ctx.change.curAuditorIds.length === 0 || ctx.change.curAuditorIds.indexOf(ctx.session.sessionUser.accountId) === -1) {
                     throw '您无权进行该操作';
@@ -4502,7 +4502,7 @@ module.exports = app => {
                 // 判断是否被变更申请调用了,是则无法发起修订
                 const projectInfo = await ctx.service.change.getDataByCondition({ tid: ctx.tender.id, plan_code: ctx.change.code });
                 if (projectInfo) {
-                    throw '该变更方案已被变更令调用,无法发起重新审批';
+                    throw '该' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '已被变更令调用,无法发起重新审批';
                 }
                 if (ctx.session.sessionUser.loginStatus === 0) {
                     const code = ctx.request.body.code;
@@ -4556,7 +4556,7 @@ module.exports = app => {
                 // 判断是否被变更申请调用了,是则无法发起修订
                 const projectInfo = await ctx.service.change.getDataByCondition({ tid: ctx.tender.id, plan_code: ctx.change.code });
                 if (projectInfo) {
-                    throw '该变更方案已被变更令调用,无法发起修订';
+                    throw '该' + (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') + '已被变更令调用,无法发起修订';
                 }
                 if (ctx.session.sessionUser.loginStatus === 0) {
                     const code = ctx.request.body.code;

+ 3 - 11
app/controller/contract_controller.js

@@ -369,18 +369,10 @@ module.exports = app => {
                     attribute: attributeColumnSet,
                 };
                 if (ctx.contractOptions.tid) {
-                    const tenders = await ctx.service.tender.getAllDataByCondition({
-                        where: { spid: ctx.subProject.id },
-                        columns: ['id', 'name'],
-                    });
                     renderData.contractColCopyScope = 'tender';
-                    renderData.contractColCopyTargets = [{
-                        id: 'sub-project-root', name: ctx.subProject.name, tree_level: 1, is_folder: true,
-                    }].concat(tenders.filter(item => {
-                        return Number(item.id) !== Number(ctx.contractOptions.tid);
-                    }).map(item => {
-                        return { id: item.id, name: item.name, tree_level: 2, is_folder: false };
-                    }));
+                    renderData.contractColCopyTargets = await ctx.service.category.getContractCopyTenderTreeNodes(
+                        ctx.subProject, ctx.contractOptions.tid
+                    );
                 } else {
                     const subProjects = await ctx.service.subProject.getSubProjectTreeNodes(
                         ctx.session.sessionProject.id,

+ 3 - 1
app/controller/dashboard_controller.js

@@ -126,6 +126,7 @@ module.exports = app => {
                 const spChangeApply = subProject.page_show.openChangeApply ? ctx.helper._.filter(auditShenpiChangeApply, { spid: subProject.id }) : [];
                 noticeList.push(...ctx.helper.addKeyValue4ObjArray(spChangeApply, 'shenpi_type', 'changeApply'));
                 const spChangePlan = subProject.page_show.openChangePlan ? ctx.helper._.filter(auditShenpiChangePlan, { spid: subProject.id }) : [];
+                ctx.helper.addKeyValue4ObjArray(spChangePlan, 'change_plan_name', (ctx.service.subProject.getFunSet(subProject.fun_set).change_plan_name || '变更方案'));
                 noticeList.push(...ctx.helper.addKeyValue4ObjArray(spChangePlan, 'shenpi_type', 'changePlan'));
                 const spPayment = subProject.page_show.openPayment ? ctx.helper._.filter(auditShenpiPayment, { spid: subProject.id }) : [];
                 noticeList.push(...ctx.helper.addKeyValue4ObjArray(spPayment, 'shenpi_type', 'payment'));
@@ -312,6 +313,7 @@ module.exports = app => {
                 }
                 if (sp) {
                     t.sp_name = sp.name;
+                    if (type === 'changePlan') t.change_plan_name = (sp && ctx.service.subProject.getFunSet(sp.fun_set).change_plan_name || '变更方案');
                     t.start_audit = 0;
                     let calcTime;
                     let closeType = false;
@@ -474,7 +476,7 @@ module.exports = app => {
             ];
             if (ctx.subProject.page_show.openChangeProject) shenpi_count.push({ count: await ctx.service.changeProjectAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '变更立项' });
             if (ctx.subProject.page_show.openChangeApply) shenpi_count.push({ count: await ctx.service.changeApplyAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '变更申请' });
-            if (ctx.subProject.page_show.openChangePlan) shenpi_count.push({ count: await ctx.service.changePlanAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '变更方案' });
+            if (ctx.subProject.page_show.openChangePlan) shenpi_count.push({ count: await ctx.service.changePlanAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案') });
             if (ctx.subProject.page_show.openMaterial) shenpi_count.push({ count: await ctx.service.materialAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '材料调差' });
             if (ctx.subProject.page_show.openFinancial) shenpi_count.push({ count: await ctx.service.financialPayAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '资金支付' });
             if (ctx.subProject.page_show.qualityInspection) shenpi_count.push({ count: await ctx.service.qualityInspectionAudit.getCountByChecked(ctx.session.sessionUser.accountId, ctx.subProject.id), name: '质量巡检' });

+ 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({

+ 682 - 37
app/controller/file_controller.js

@@ -12,6 +12,10 @@ const sendToWormhole = require('stream-wormhole');
 const path = require('path');
 const advanceConst = require('../const/advance');
 
+const FILE_MANAGEMENT_EXTRA_EXTENSIONS = [
+    '.sbp', '.jygs', '.gkgs', '.sjgs', '.sgys', '.qdys', '.gcjs', '.sjys',
+];
+
 module.exports = app => {
     class FileController extends app.BaseController {
 
@@ -23,6 +27,153 @@ module.exports = app => {
             if (!ctx.subProject.lock_file) throw '请先锁定,再管理分类数据';
         }
 
+        isAdmin(ctx) {
+            return Number(ctx.session.sessionUser.is_admin) === 1;
+        }
+
+        getFileUploadWhitelist(ctx) {
+            const defaultWhitelist = ctx.app.config.multipart.whitelist || [];
+            return Array.from(new Set(defaultWhitelist.concat(FILE_MANAGEMENT_EXTRA_EXTENSIONS).map(ext => ext.toLowerCase())));
+        }
+
+        isFileUploadExtensionAllowed(ctx, filename) {
+            const ext = path.extname(filename || '').toLowerCase();
+            return this.getFileUploadWhitelist(ctx).indexOf(ext) >= 0;
+        }
+
+        getFileUploadCheck(ctx) {
+            const whitelist = this.getFileUploadWhitelist(ctx);
+            return (fieldname, fileStream, filename) => {
+                if (!fileStream || !filename) return null;
+                const ext = path.extname(filename).toLowerCase();
+                if (whitelist.indexOf(ext) >= 0) return null;
+                const error = new Error(`资料管理不支持${ext || '无扩展名'}格式文件`);
+                error.status = 400;
+                return error;
+            };
+        }
+
+        hasFilePermission(ctx, permissionKey) {
+            if (this.isAdmin(ctx)) return true;
+            const permission = ctx.service.subProjPermission.PermissionConst.file[permissionKey];
+            const filePermission = ctx.subProject.permission.file_permission || [];
+            return permission && filePermission.indexOf(permission.value) >= 0;
+        }
+
+        hasFileConfigPermission(ctx) {
+            return this.hasFilePermission(ctx, 'manage_dir') || this.hasFilePermission(ctx, 'auth_user');
+        }
+
+        applyFileConfigViewPermission(ctx, permissionMap) {
+            if (!this.hasFileConfigPermission(ctx)) return permissionMap;
+            Object.keys(permissionMap || {}).forEach(filingId => {
+                permissionMap[filingId].can_view = 1;
+            });
+            return permissionMap;
+        }
+
+        checkFilePermission(ctx, permissionKey) {
+            if (!this.hasFilePermission(ctx, permissionKey)) throw '您无权进行该操作';
+        }
+
+        hasLegacyFilePermission(ctx, value) {
+            if (this.isAdmin(ctx)) return true;
+            return (ctx.subProject.permission.file_permission || []).indexOf(value) >= 0;
+        }
+
+        async getProjectFiling(ctx, filingId) {
+            const filing = await ctx.service.filing.getDataById(filingId);
+            if (!filing || filing.is_deleted || filing.spid !== ctx.subProject.id) throw '分类不存在';
+            return filing;
+        }
+
+        async fillFilingAddUserNames(ctx, filingList) {
+            const rows = filingList || [];
+            const userIds = this.app._.uniq(rows.map(x => Number(x.create_uid)).filter(x => x > 0));
+            const users = userIds.length > 0 ? await ctx.service.projectAccount.getAllDataByCondition({
+                columns: ['id', 'name'],
+                where: { id: userIds },
+            }) : [];
+            const userNameMap = {};
+            users.forEach(user => { userNameMap[Number(user.id)] = user.name || ''; });
+            rows.forEach(filing => {
+                filing.add_user = userNameMap[Number(filing.create_uid)] || '';
+            });
+            return rows;
+        }
+
+        async fillFilingPermissionCreatorNames(ctx, filingList, permissionRows) {
+            const rows = filingList || [];
+            const exactPermissionRows = (permissionRows || []).filter(permission => {
+                return permission.filing_id && Number(permission.create_uid) > 0;
+            });
+            const creatorIds = this.app._.uniq(exactPermissionRows
+                .map(permission => Number(permission.create_uid)).filter(uid => uid > 0));
+            const creators = creatorIds.length > 0 ? await ctx.service.projectAccount.getAllDataByCondition({
+                columns: ['id', 'name'],
+                where: { id: creatorIds },
+            }) : [];
+            const creatorNameMap = {};
+            creators.forEach(creator => { creatorNameMap[Number(creator.id)] = creator.name || ''; });
+            rows.forEach(filing => {
+                const creatorNames = this.app._.uniq(exactPermissionRows.filter(permission => {
+                    return String(permission.filing_id) === String(filing.id);
+                }).map(permission => creatorNameMap[Number(permission.create_uid)]).filter(Boolean));
+                if (creatorNames.length > 0) filing.add_user = creatorNames.join('、');
+            });
+            return rows;
+        }
+
+        async checkFilingView(ctx, filing) {
+            const permission = await ctx.service.subProjectFilingPermission.getResolvedPermission(
+                ctx.subProject.id,
+                ctx.session.sessionUser.accountId,
+                filing.id,
+                filing.filing_type,
+                ctx.subProject.permission.file_permission,
+                ctx.subProject.permission.filing_type,
+                this.isAdmin(ctx)
+            );
+            if (this.hasFileConfigPermission(ctx)) permission.can_view = 1;
+            if (!permission.can_view) throw '您无权查看该资料目录';
+            return permission;
+        }
+
+        async getFilingOperationPermission(ctx, filing) {
+            return await this.checkFilingView(ctx, filing);
+        }
+
+        async checkFilingOperation(ctx, filing, permissionField) {
+            const permission = await this.getFilingOperationPermission(ctx, filing);
+            if (!permission[permissionField]) throw '您无权进行该操作';
+            return permission;
+        }
+
+        async getFilingFromDirectoryData(ctx, data) {
+            const filingId = data.id || data.tree_pre_id || (data.tree_pid && data.tree_pid !== '-1' ? data.tree_pid : '');
+            if (!filingId) throw '请先选择资料类别';
+            return await this.getProjectFiling(ctx, filingId);
+        }
+
+        filterVisibleFiling(filingList, permissionMap) {
+            const filingMap = {};
+            (filingList || []).forEach(filing => { filingMap[String(filing.id)] = filing; });
+            const visibleIds = new Set();
+            (filingList || []).forEach(filing => {
+                const permission = permissionMap[filing.id];
+                if (!permission || !permission.can_view) return;
+                let current = filing;
+                while (current) {
+                    const currentId = String(current.id);
+                    if (visibleIds.has(currentId)) break;
+                    visibleIds.add(currentId);
+                    if (current.tree_pid === '-1' || current.tree_pid === -1) break;
+                    current = filingMap[String(current.tree_pid)];
+                }
+            });
+            return (filingList || []).filter(filing => visibleIds.has(String(filing.id)));
+        }
+
         /**
          * 概算投资
          *
@@ -54,14 +205,15 @@ module.exports = app => {
 
         async file(ctx) {
             try {
+                if (!this.hasLegacyFilePermission(ctx, 1)) throw '您无权查看旧版资料管理';
                 const renderData = {
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.file),
                 };
                 renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, ctx.subProject.permission.filing_type);
                 renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
-                renderData.canFiling = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.filing.value) >= 0;
-                renderData.canUpload = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.upload.value) >= 0;
-                renderData.canEdit = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.editfile.value) >= 0;
+                renderData.canFiling = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 3);
+                renderData.canUpload = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 2);
+                renderData.canEdit = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 4);
                 renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
                 await this.layout('file/file.ejs', renderData, 'file/file_modal.ejs');
             } catch (err) {
@@ -69,6 +221,403 @@ module.exports = app => {
             }
         }
 
+        /**
+         * SpreadJS 替代 zTree 展示资料分类树 Demo
+         *
+         * @param {Object} ctx - egg context
+         */
+        async fileSjsDemo(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.sjs_demo),
+                };
+                renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, ctx.subProject.permission.filing_type);
+                renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
+                // 根据 lock_file 判断是否可编辑
+                const canEdit = !ctx.subProject.lock_file;
+                renderData.canFiling = canEdit;
+                renderData.canUpload = canEdit;
+                renderData.canEdit = canEdit;
+                renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
+                await this.layout('file/file_sjs_demo.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        /**
+         * 资料管理 SpreadJS 版本
+         * 使用 SpreadJS 替代 zTree 展示文件分类树
+         *
+         * @param {Object} ctx - egg context
+         */
+        async filesjs(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.filesjs),
+                    fileUploadWhitelist: this.getFileUploadWhitelist(ctx),
+                    useSpreadMoveFile: true,
+                    pageTitle: '资料归集',
+                };
+                const allFiling = await ctx.service.filing.getValidFiling(ctx.params.id, 'all');
+                const canManageDir = this.hasFilePermission(ctx, 'manage_dir');
+                renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
+                renderData.filingPermissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                    ctx.subProject.id,
+                    ctx.session.sessionUser.accountId,
+                    allFiling,
+                    ctx.subProject.permission.filing_type,
+                    ctx.subProject.permission.file_permission,
+                    this.isAdmin(ctx)
+                );
+                this.applyFileConfigViewPermission(ctx, renderData.filingPermissionMap);
+                renderData.filing = this.filterVisibleFiling(allFiling, renderData.filingPermissionMap);
+                // 共用 file_modal.ejs 需要渲染全部弹窗,实际显示与操作由 filingPermissionMap 控制。
+                renderData.canFiling = true;
+                renderData.canUpload = true;
+                renderData.canEdit = false;
+                renderData.canManageDir = this.hasFileConfigPermission(ctx);
+                renderData.needFilingInitialization = canManageDir && allFiling.length === 0;
+                renderData.filingInitializationTemplates = [];
+                renderData.filingInitializationProjects = [];
+                if (renderData.needFilingInitialization) {
+                    renderData.filingInitializationTemplates = await ctx.service.filingTemplateList.getAllDataByCondition({
+                        columns: ['id', 'name'],
+                        where: { ft_type: ctx.service.filingTemplateList.FtType.org },
+                        orders: [['create_time', 'asc']],
+                    });
+                    renderData.filingInitializationProjects = await ctx.service.subProject.getManageDirProjects(
+                        ctx.session.sessionProject.id,
+                        ctx.session.sessionUser.accountId,
+                        this.isAdmin(ctx),
+                        ctx.subProject.id
+                    );
+                }
+                renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
+                await this.layout('file/filesjs.ejs', renderData, 'file/filesjs_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        async initializeFiling(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.init_type) throw '请选择初始化方式';
+
+                let sourceData = [];
+                let templateData = null;
+                if (data.init_type === 'template') {
+                    if (!data.template_id) throw '请选择系统模板库';
+                    templateData = await ctx.service.filingTemplateList.getDataByCondition({
+                        id: data.template_id,
+                        ft_type: ctx.service.filingTemplateList.FtType.org,
+                    });
+                    if (!templateData) throw '选择的系统模板不存在';
+                    sourceData = await ctx.service.filingTemplate.getAllDataByCondition({
+                        where: { temp_id: templateData.id },
+                        orders: [['tree_level', 'asc'], ['tree_order', 'asc']],
+                    });
+                    if (sourceData.length === 0) throw '选择的系统模板没有目录数据';
+                } else if (data.init_type === 'project') {
+                    if (!data.source_spid) throw '请选择来源项目';
+                    const sourceProjects = await ctx.service.subProject.getManageDirProjects(
+                        ctx.session.sessionProject.id,
+                        ctx.session.sessionUser.accountId,
+                        this.isAdmin(ctx),
+                        ctx.subProject.id
+                    );
+                    const sourceProject = sourceProjects.find(project => {
+                        return String(project.id) === String(data.source_spid);
+                    });
+                    if (!sourceProject) throw '来源项目不存在或您没有管理目录权限';
+                    sourceData = await ctx.service.filing.getValidFiling(sourceProject.id, 'all');
+                }
+                const result = await ctx.service.filing.initializeDirectory(
+                    ctx.subProject.id,
+                    data.init_type,
+                    sourceData,
+                    ctx.session.sessionUser.accountId,
+                    templateData
+                );
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '初始化资料目录失败');
+            }
+        }
+
+        async configDir(ctx) {
+            try {
+                const canManageDir = this.hasFilePermission(ctx, 'manage_dir');
+                const canAuthUser = this.hasFilePermission(ctx, 'auth_user');
+                if (!canManageDir && !canAuthUser) throw '您无权查看配置目录';
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.config_dir),
+                    pageTitle: '资料管理-目录配置',
+                };
+                renderData.canManageDir = canManageDir;
+                renderData.canAuthUser = canAuthUser;
+                renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, 'all');
+                await this.fillFilingAddUserNames(ctx, renderData.filing);
+                renderData.permissionData = renderData.canAuthUser ? await ctx.service.subProjPermission.getPermission(ctx.params.id) : [];
+                renderData.filingPermissionData = renderData.canAuthUser
+                    ? await ctx.service.subProjectFilingPermission.getConfigPermissionRows(ctx.params.id, renderData.filing) : [];
+                if (renderData.canAuthUser) {
+                    await this.fillFilingPermissionCreatorNames(
+                        ctx, renderData.filing, renderData.filingPermissionData
+                    );
+                }
+                renderData.accountList = renderData.canAuthUser
+                    ? await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file') : [];
+                await this.layout('file/config_dir.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+                ctx.session.postError = err.toString();
+                ctx.redirect(this.menu.menu.dashboard.url);
+            }
+        }
+
+        async configDirLock(ctx) {
+            const redirectUrl = `/sp/${ctx.subProject.id}/config-dir`;
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                const lock = Number(ctx.request.body.lock);
+                if (lock !== 0 && lock !== 1) throw '锁定状态参数错误';
+                await ctx.service.subProject.save({ id: ctx.subProject.id, lock_file: lock });
+                ctx.redirect(redirectUrl);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '资料管理锁定状态修改失败');
+                ctx.redirect(redirectUrl);
+            }
+        }
+
+        async configDirUpdate(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const result = await this.updateFiling(ctx, data);
+                if (result && result.create) await this.fillFilingAddUserNames(ctx, result.create);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '修改失败');
+            }
+        }
+
+        async configDirMoveFiles(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.source_filing_id || !data.target_filing_id) throw '缺少参数';
+                const sourceFiling = await this.getProjectFiling(ctx, data.source_filing_id);
+                const targetFiling = await this.getProjectFiling(ctx, data.target_filing_id);
+                const result = await ctx.service.file.moveFilingFiles(sourceFiling, targetFiling);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '批量移动文件失败');
+            }
+        }
+
+        async getFilingNodePermission(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                const filingId = ctx.request.body.filing_id;
+                const filing = await this.getProjectFiling(ctx, filingId);
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const projectAccounts = await ctx.service.subProjPermission.getPermission(ctx.subProject.id);
+                const permissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                const authorizedIds = permissionRows.filter(row => {
+                    return String(row.filing_id || '') === String(filing.id);
+                }).map(row => Number(row.uid));
+                const accountMap = {};
+                projectAccounts.forEach(permission => {
+                    const userId = Number(permission.uid);
+                    accountMap[userId] = {
+                        id: userId,
+                        name: permission.name,
+                        company: permission.company,
+                        role: permission.role,
+                    };
+                });
+                selectableAccounts.forEach(account => {
+                    accountMap[Number(account.id)] = account;
+                });
+                const authorizedUsers = authorizedIds.map(uid => accountMap[uid]).filter(Boolean).map(account => {
+                    return {
+                        id: Number(account.id),
+                        name: account.name,
+                        company: account.company,
+                        role: account.role,
+                    };
+                }).filter((account, index, list) => {
+                    return list.findIndex(item => Number(item.id) === Number(account.id)) === index;
+                });
+                /*
+                 * 下拉选择仍只使用数据范围内账号;这里额外合并项目账号,
+                 * 是为了让已经存在的旧授权不会因数据范围调整而从弹窗消失。
+                 */
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                authorizedUsers.forEach(account => {
+                    account.selectable = selectableIds.indexOf(Number(account.id)) >= 0;
+                });
+                ctx.body = { err: 0, msg: '', data: authorizedUsers };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '获取授权用户失败');
+            }
+        }
+
+        async saveFilingNodePermission(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.filing_id) throw '请选择需要授权的资料目录';
+                if (!(data.user_permissions instanceof Array)) throw '授权用户权限格式错误';
+                if (data.user_permissions.find(x => !x || typeof x !== 'object')) throw '授权用户权限格式错误';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                const userPermissions = data.user_permissions;
+                const requestedUserIds = userPermissions.map(x => Number(x.uid !== undefined ? x.uid : x.id));
+                if (requestedUserIds.find(uid => !Number.isInteger(uid) || uid <= 0)) throw '授权用户数据错误';
+                if (new Set(requestedUserIds).size !== requestedUserIds.length) throw '授权用户数据重复';
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                const currentPermissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                const currentUserIds = currentPermissionRows.filter(row => {
+                    return String(row.filing_id || '') === String(filing.id);
+                }).map(row => Number(row.uid));
+                const invalidUserId = requestedUserIds.find(uid => {
+                    return selectableIds.indexOf(uid) < 0 && currentUserIds.indexOf(uid) < 0;
+                });
+                if (invalidUserId) throw '选择的用户超出资料管理数据范围';
+                await ctx.service.subProjectFilingPermission.savePermissions(
+                    ctx.subProject,
+                    filing,
+                    userPermissions,
+                    ctx.session.sessionUser.accountId,
+                    { replaceExisting: true }
+                );
+                const savedPermissionRows = await ctx.service.subProjectFilingPermission.getRows(ctx.subProject.id);
+                const savedPermissions = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                await this.fillFilingAddUserNames(ctx, [filing]);
+                await this.fillFilingPermissionCreatorNames(ctx, [filing], savedPermissionRows);
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: {
+                        permissions: savedPermissions,
+                        filings: [{ id: filing.id, add_user: filing.add_user || '' }],
+                    },
+                };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存授权用户失败');
+            }
+        }
+
+        async addFilingNodePermissions(ctx) {
+            await this._saveBatchFilingNodePermissions(ctx, false);
+        }
+
+        async coverFilingNodePermissions(ctx) {
+            await this._saveBatchFilingNodePermissions(ctx, true);
+        }
+
+        async _saveBatchFilingNodePermissions(ctx, replaceExisting) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data) throw '批量授权数据错误';
+                if (!(data.target_filing_ids instanceof Array) || data.target_filing_ids.length === 0) {
+                    throw '请选择目标资料目录';
+                }
+                if (data.target_filing_ids.length > 500) throw '一次最多选择500个目标资料目录';
+                if (!(data.user_permissions instanceof Array) || data.user_permissions.length === 0) {
+                    throw '请选择需要配置的授权用户';
+                }
+                if (data.user_permissions.find(x => !x || typeof x !== 'object')) {
+                    throw '授权用户权限格式错误';
+                }
+
+                const targetFilingIds = data.target_filing_ids.map(id => String(id || '').trim());
+                if (targetFilingIds.find(id => !id)) throw '目标资料目录数据错误';
+                if (new Set(targetFilingIds).size !== targetFilingIds.length) throw '目标资料目录重复';
+
+                const targetFilingRows = await ctx.service.filing.getAllDataByCondition({
+                    where: {
+                        id: targetFilingIds,
+                        spid: ctx.subProject.id,
+                        is_deleted: 0,
+                    },
+                });
+                const targetFilingMap = {};
+                targetFilingRows.forEach(filing => { targetFilingMap[String(filing.id)] = filing; });
+                if (targetFilingIds.find(filingId => !targetFilingMap[filingId])) throw '目标资料目录不存在';
+                const targetFilings = targetFilingIds.map(filingId => targetFilingMap[filingId]);
+
+                const userPermissions = data.user_permissions;
+                const requestedUserIds = userPermissions.map(x => Number(x.uid !== undefined ? x.uid : x.id));
+                if (requestedUserIds.find(uid => !Number.isInteger(uid) || uid <= 0)) throw '授权用户数据错误';
+                if (new Set(requestedUserIds).size !== requestedUserIds.length) throw '授权用户数据重复';
+
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                const invalidUserId = requestedUserIds.find(uid => selectableIds.indexOf(uid) < 0);
+                if (invalidUserId) throw '选择的用户超出资料管理数据范围';
+
+                const permissionService = ctx.service.subProjectFilingPermission;
+                if (replaceExisting) {
+                    await permissionService.coverPermissionsToFilings(
+                        ctx.subProject,
+                        targetFilings,
+                        userPermissions,
+                        ctx.session.sessionUser.accountId
+                    );
+                } else {
+                    await permissionService.addPermissionsToFilings(
+                        ctx.subProject,
+                        targetFilings,
+                        userPermissions,
+                        ctx.session.sessionUser.accountId
+                    );
+                }
+
+                const savedPermissionRows = await ctx.service.subProjectFilingPermission.getRows(ctx.subProject.id);
+                const targetPermissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, targetFilings
+                );
+                await this.fillFilingAddUserNames(ctx, targetFilings);
+                await this.fillFilingPermissionCreatorNames(ctx, targetFilings, savedPermissionRows);
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: {
+                        permissions: targetPermissionRows,
+                        filings: targetFilings.map(filing => ({
+                            id: filing.id,
+                            add_user: filing.add_user || '',
+                        })),
+                    },
+                };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, replaceExisting ? '批量覆盖授权失败' : '批量新增授权失败');
+            }
+        }
+
         async getFilingTypePermission(ctx) {
             try {
                 if (ctx.subProject.project_id !== this.ctx.session.sessionProject.id) throw '您无权操作该数据';
@@ -95,6 +644,8 @@ module.exports = app => {
             try {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
+                const filing = await this.getFilingFromDirectoryData(ctx, data);
+                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
                 const result = await ctx.service.filing.add(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -106,6 +657,8 @@ module.exports = app => {
             try {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
+                const filing = await this.getFilingFromDirectoryData(ctx, data);
+                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
                 const result = await ctx.service.filing.del(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -117,6 +670,8 @@ module.exports = app => {
             try {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
+                const filing = await this.getFilingFromDirectoryData(ctx, data);
+                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
                 const result = await ctx.service.filing.save(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -130,6 +685,8 @@ module.exports = app => {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
+                const filing = await this.getFilingFromDirectoryData(ctx, data);
+                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
                 const result = await ctx.service.filing.move(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -141,6 +698,8 @@ module.exports = app => {
         async loadFile(ctx) {
             try {
                 const data = JSON.parse(ctx.request.body.data);
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkFilingView(ctx, filing);
                 const order = data.order.split('|');
                 if (order.length !== 2) throw '加载文件错误';
                 if (order[0] !== 'filename' && order[0] !== 'create_time') throw '加载文件错误';
@@ -159,10 +718,9 @@ module.exports = app => {
             }
         }
 
-        async checkCanUpload(ctx) {
-            if (ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.upload.value) < 0) {
-                throw '您无权上传、导入、删除文件';
-            }
+        async checkCanUpload(ctx, filing) {
+            this.checkUnlock(ctx);
+            await this.checkFilingOperation(ctx, filing, 'can_upload');
         }
 
         async checkFiling(filing) {
@@ -174,6 +732,8 @@ module.exports = app => {
             try{
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.filing_id || !data.files) throw '缺少参数';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 const result = await ctx.service.file.checkFiles(data.filing_id, data.files);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch(error) {
@@ -185,16 +745,17 @@ module.exports = app => {
         async uploadFile(ctx){
             let stream;
             try {
-                await this.checkCanUpload(ctx);
-
-                const parts = ctx.multipart({ autoFields: true });
+                const parts = ctx.multipart({
+                    autoFields: true,
+                    checkFile: this.getFileUploadCheck(ctx),
+                });
 
                 let index = 0;
                 const create_time = Date.parse(new Date()) / 1000;
-                let stream = await parts();
+                stream = await parts();
                 const user = await ctx. service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
-                const filing = await ctx.service.filing.getDataById(parts.field.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, parts.field.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 await this.checkFiling(filing);
 
                 const uploadfiles = [];
@@ -234,6 +795,7 @@ module.exports = app => {
         }
         async delFile(ctx) {
             try{
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.del) throw '缺少参数';
                 const result = await ctx.service.file.delFiles(data.del);
@@ -245,6 +807,7 @@ module.exports = app => {
         }
         async saveFile(ctx) {
             try {
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id) throw '缺少参数';
                 const result = await ctx.service.file.saveFile(data.id, data.filename);
@@ -254,27 +817,44 @@ module.exports = app => {
                 ctx.ajaxErrorBody(error, '编辑附件失败');
             }
         }
+        async lockFile(ctx) {
+            try {
+                this.checkUnlock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.id) throw '缺少参数';
+                const result = await ctx.service.file.setLocked(data.id, data.is_locked);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (error) {
+                this.log(error);
+                ctx.ajaxErrorBody(error, '修改文件锁定状态失败');
+            }
+        }
         async moveFile(ctx) {
             try {
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id || !data.filingId) throw '缺少参数';
+                const targetFiling = await this.getProjectFiling(ctx, data.filingId);
+                await this.checkFilingOperation(ctx, targetFiling, 'can_upload');
                 const result = await ctx.service.file.moveFile(data.id, data.filingId);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (error) {
                 this.log(error);
-                ctx.ajaxErrorBody(error, '编辑附件失败');
+                ctx.ajaxErrorBody(error, '移动文件失败');
             }
         }
         async uploadBigFile(ctx) {
             try {
-                await this.checkCanUpload(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.type || !data.filing_id || !data.fileInfo) throw '缺少参数';
-                const filing = await ctx.service.filing.getDataById(data.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
 
                 let result;
                 const fileInfo = path.parse(data.fileInfo.filename);
+                if (!this.isFileUploadExtensionAllowed(ctx, data.fileInfo.filename)) {
+                    throw `资料管理不支持${fileInfo.ext || '无扩展名'}格式文件`;
+                }
                 switch(data.type) {
                     case 'begin':
                         const create_time = Date.parse(new Date()) / 1000;
@@ -443,8 +1023,8 @@ module.exports = app => {
                 if (!data.filing_id || !data.files) throw '缺少参数';
 
                 const user = await ctx. service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
-                const filing = await ctx.service.filing.getDataById(data.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 await this.checkFiling(filing);
 
                 const result = await ctx.service.file.relaFiles(filing, data.files, user);
@@ -549,10 +1129,36 @@ module.exports = app => {
             try {
                 const limit = 1000;
                 const data = JSON.parse(ctx.request.body.data);
-                if (!data.filing_type || !data.keyword) throw '数据错误';
+                if (!data.keyword) throw '数据错误';
+                if (data.filing_id instanceof Array) {
+                    const filingIds = this.app._.uniq(data.filing_id.map(id => String(id || '')).filter(Boolean));
+                    if (filingIds.length === 0) throw '数据错误';
+                    const filings = await ctx.service.filing.getAllDataByCondition({
+                        where: { id: filingIds, spid: ctx.subProject.id, is_deleted: 0 },
+                    });
+                    const permissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                        ctx.subProject.id,
+                        ctx.session.sessionUser.accountId,
+                        filings,
+                        ctx.subProject.permission.filing_type,
+                        ctx.subProject.permission.file_permission,
+                        this.isAdmin(ctx)
+                    );
+                    this.applyFileConfigViewPermission(ctx, permissionMap);
+                    const validFilingIds = filings.filter(filing => {
+                        return permissionMap[filing.id] && permissionMap[filing.id].can_view;
+                    }).map(filing => filing.id);
+                    const result = await ctx.service.file.searchByFilingIds(validFilingIds, data.keyword, limit);
+                    ctx.body = { err: 0, msg: '', data: { list: result, limit } };
+                    return;
+                }
+                if (!data.filing_type) throw '数据错误';
                 const validFilingType = [];
                 for (const f of data.filing_type) {
-                    if (ctx.subProject.permission.filing_type === 'all' || ctx.subProject.permission.filing_type.indexOf(f) >= 0) validFilingType.push(f);
+                    const filingType = Number(f);
+                    if (!Number.isInteger(filingType) || filingType <= 0) continue;
+                    if (ctx.subProject.permission.filing_type === 'all' ||
+                        ctx.subProject.permission.filing_type.indexOf(filingType) >= 0) validFilingType.push(filingType);
                 }
                 const result = await ctx.service.file.search(validFilingType, data.keyword, limit);
                 ctx.body = { err: 0, msg: '', data: { list: result, limit } };
@@ -599,28 +1205,67 @@ module.exports = app => {
             try {
                 this.checkLock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
-                if (!data.updateType) throw '数据错误';
-                let result;
-                const updateData = JSON.parse(JSON.stringify(data));
-                delete updateData.updateType;
-                if (data.updateType === 'add') {
-                    result = await ctx.service.filing.add(updateData);
-                } else if (data.updateType === 'del') {
-                    result = await ctx.service.filing.del(updateData);
-                } else if (data.updateType === 'save') {
-                    result = await ctx.service.filing.save(updateData);
-                } else if (data.updateType === 'move') {
-                    if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
-                    result = await ctx.service.filing.move(updateData);
-                } else if (data.updateType === 'multi' ) {
-                    result = await ctx.service.filing.multiUpdate(ctx.subProject.id, data.data);
-                }
+                const result = await this.updateFiling(ctx, data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
                 ctx.log(err);
                 ctx.ajaxErrorBody(err, '修改失败');
             }
         }
+
+        async updateFiling(ctx, data) {
+            if (!data.updateType) throw '数据错误';
+            const updateData = JSON.parse(JSON.stringify(data));
+            delete updateData.updateType;
+            if (data.updateType === 'add') {
+                return await ctx.service.filing.add(updateData);
+            } else if (data.updateType === 'del') {
+                return await ctx.service.filing.del(updateData);
+            } else if (data.updateType === 'save') {
+                return await ctx.service.filing.save(updateData);
+            } else if (data.updateType === 'edit') {
+                return await ctx.service.filing.editDirectory(updateData);
+            } else if (data.updateType === 'move') {
+                if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
+                return await ctx.service.filing.move(updateData);
+            } else if (data.updateType === 'multi') {
+                return await ctx.service.filing.multiUpdate(ctx.subProject.id, data.data);
+            }
+            throw '未知的修改类型';
+        }
+
+        async saveFilingNames(ctx) {
+            try {
+                this.checkUnlock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !Array.isArray(data.items) || data.items.length === 0) throw '请选择需要替换名称的分类';
+                const ids = data.items.map(item => item && item.id);
+                if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length) throw '分类数据格式错误';
+                const filings = await ctx.service.filing.getAllDataByCondition({
+                    where: { id: ids, spid: ctx.subProject.id, is_deleted: 0 },
+                });
+                if (filings.length !== ids.length) throw '部分分类不存在,请刷新页面后重新粘贴';
+                const permissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                    ctx.subProject.id,
+                    ctx.session.sessionUser.accountId,
+                    filings,
+                    ctx.subProject.permission.filing_type,
+                    ctx.subProject.permission.file_permission,
+                    this.isAdmin(ctx)
+                );
+                this.applyFileConfigViewPermission(ctx, permissionMap);
+                for (const filing of filings) {
+                    const permission = permissionMap[filing.id];
+                    if (!permission || !permission.can_view || !permission.can_edit_dir) throw '部分分类没有编辑目录权限';
+                    if (Number(filing.is_fixed)) throw '固定分类不可修改名称';
+                }
+                const result = await ctx.service.filing.saveNames(ctx.subProject.id, data.items);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.body = this.ajaxErrorBody(err, '批量替换分类名称失败');
+            }
+        }
     }
 
     return FileController;

+ 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 '不存在该文件';

+ 17 - 15
app/controller/material_controller.js

@@ -844,6 +844,8 @@ module.exports = app => {
                 }
                 // renderData.materialBillsData = await this._getMaterialBillsData(ctx);
                 renderData.materialExponentData = await this._getMaterialExponentData(ctx);
+                renderData.canAdminEditLatestRisk = ctx.session.sessionUser.is_admin && ctx.material.order === ctx.material.highOrder &&
+                    (ctx.material.status === auditConst.status.uncheck || ctx.material.status === auditConst.status.checkNo);
                 // 取对应期的截取上期的调差金额和应耗数量
                 if (ctx.material.highOrder !== ctx.material.order) {
                     for (const [mindex, me] of renderData.materialExponentData.entries()) {
@@ -1238,22 +1240,22 @@ module.exports = app => {
                 // 转换为整型,不然会无法获取
                 if (data.ms_id) data.ms_id = parseInt(data.ms_id);
                 if (data.mn_id) data.mn_id = parseInt(data.mn_id);
-                const isPastAdminRiskEdit = ctx.material.readOnly && ctx.session.sessionUser.is_admin && ctx.material.order < ctx.material.highOrder && (data.type === 'update' || data.type === 'paste');
-                if (isPastAdminRiskEdit) {
+                const canAdminEditLatestRisk = ctx.material.readOnly && ctx.session.sessionUser.is_admin && ctx.material.order === ctx.material.highOrder &&
+                    (ctx.material.status === auditConst.status.uncheck || ctx.material.status === auditConst.status.checkNo) &&
+                    (data.type === 'update' || data.type === 'paste');
+                if (canAdminEditLatestRisk) {
                     const updateList = data.type === 'paste' ? data.updateData : [data.updateData];
-                    const riskUpdates = updateList.map(item => ({
-                        id: ctx.material.showStageExponent || ctx.material.exponent_node ? item.me_id : item.id,
-                        m_up_risk: item.m_up_risk,
-                        m_down_risk: item.m_down_risk,
-                    }));
-                    if (riskUpdates.some(item => !item.id)) throw '往期指数工料参数有误';
-                    if (ctx.material.showStageExponent || ctx.material.exponent_node) {
-                        responseData.data = data.type === 'paste' ?
-                            await ctx.service.materialExponent.saveDatas(riskUpdates, data.ms_id, data.mn_id) :
-                            await ctx.service.materialExponent.save(riskUpdates[0], data.ms_id, data.mn_id);
-                    } else {
-                        responseData.data = await ctx.service.materialExponent.saveHistoryRisks(riskUpdates);
-                    }
+                    const riskUpdates = updateList.map(item => {
+                        const riskUpdate = { id: item.id };
+                        if (item.m_up_risk !== undefined) riskUpdate.m_up_risk = item.m_up_risk;
+                        if (item.m_down_risk !== undefined) riskUpdate.m_down_risk = item.m_down_risk;
+                        return riskUpdate;
+                    });
+                    if (riskUpdates.some(item => !item.id)) throw '指数工料参数有误';
+                    if (riskUpdates.some(item => Object.keys(item).length === 1)) throw '管理员只允许修改上涨、下跌幅度';
+                    responseData.data = data.type === 'paste' ?
+                        await ctx.service.materialExponent.saveDatas(riskUpdates, data.ms_id, data.mn_id) :
+                        await ctx.service.materialExponent.save(riskUpdates[0], data.ms_id, data.mn_id);
                     ctx.body = responseData;
                     return;
                 }

+ 67 - 0
app/controller/project_screen_controller.js

@@ -0,0 +1,67 @@
+'use strict';
+
+const projectScreenConst = require('../const/project_screen');
+
+module.exports = app => {
+    class ProjectScreenController extends app.BaseController {
+        constructor(ctx) {
+            super(ctx);
+            ctx.showProject = true;
+            ctx.showTitle = true;
+        }
+
+        async index(ctx) {
+            try {
+                if (!ctx.showProjectScreen) throw '项目大屏未开启或没有访问权限';
+                const pid = ctx.session.sessionProject.id;
+                const screens = await ctx.service.projectScreen.getList(pid);
+                const screen = await ctx.service.projectScreen.getViewScreen(pid, ctx.params.sid || '');
+                if (!screen) throw '项目尚未添加大屏';
+                const template = projectScreenConst.getTemplate(screen.template_code);
+                if (!template) throw '大屏模板不存在';
+                const screenList = screens.map(item => {
+                    return Object.assign(item, {
+                        template: projectScreenConst.getTemplate(item.template_code),
+                    });
+                });
+                const summaries = template.code === 'guangyuan' ? await ctx.service.projectScreen.getStatusProjects(null, { summary: true }) : [];
+                const finishedProjects = summaries.filter(project => project.status === '已建');
+                const plannedProjects = summaries.filter(project => project.status === '拟建');
+                const buildingProjects = summaries.filter(project => project.status === '在建');
+                const listOnly = projects => projects.map(project => ({ id: project.id, name: project.name, group: project.group }));
+                const renderData = {
+                    screens: screenList,
+                    screen,
+                    template,
+                };
+                renderData.finishedProjects = listOnly(finishedProjects);
+                renderData.plannedProjects = listOnly(plannedProjects);
+                renderData.buildingProjects = listOnly(buildingProjects);
+                renderData.overview = template.code === 'guangyuan' ? await ctx.service.projectScreen.getOverview(plannedProjects, buildingProjects, finishedProjects) : null;
+                await this.layout('project_screen/index.ejs', renderData, template.code === 'guangyuan' ? 'project_screen/guangyuan_modal.ejs' : '');
+            } catch (error) {
+                this.log(error);
+                ctx.session.postError = error.toString();
+                ctx.redirect('/dashboard');
+            }
+        }
+        async detail(ctx) {
+            try {
+                if (!ctx.showProjectScreen) throw '项目大屏未开启或没有访问权限';
+                const screens = await ctx.service.projectScreen.getList(ctx.session.sessionProject.id);
+                if (!screens.some(screen => screen.template_code === 'guangyuan')) throw '项目尚未添加广元大屏';
+                const data = JSON.parse(ctx.request.body.data);
+                const status = { planned: '拟建', building: '在建', finished: '已建' }[data.module];
+                if (!status || typeof data.id !== 'string' || !data.id) throw '项目参数错误';
+                const projects = await ctx.service.projectScreen.getStatusProjects(status, { id: data.id });
+                if (!projects.length) throw '项目不存在或没有访问权限';
+                ctx.body = { err: 0, msg: '', data: projects[0] };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { err: 1, msg: error.toString(), data: null };
+            }
+        }
+    }
+
+    return ProjectScreenController;
+};

+ 1 - 1
app/controller/report_controller.js

@@ -1100,7 +1100,7 @@ module.exports = app => {
                 switch (stage_id) {
                     case rptBusiness.BusinessStageId.change_plan:
                         otherChangeList = await ctx.service.changePlan.getListByStatus(tenderId, 0, 0, 0, 0, state);
-                        otherHintName = '变更方案';
+                        otherHintName = (ctx.subProject && ctx.subProject.fun_set && ctx.subProject.fun_set.change_plan_name || '变更方案');
                         break;
                     case rptBusiness.BusinessStageId.change_project:
                         otherChangeList = await ctx.service.changeProject.getListByStatus(tenderId, 0, 0, 0, 0, state);

+ 69 - 6
app/controller/safe_controller.js

@@ -9,6 +9,7 @@
  */
 
 const auditConst = require('../const/audit');
+const safeInspectionConst = require('../const/safe_inspection');
 const auditType = require('../const/audit').auditType;
 const shenpiConst = require('../const/shenpi');
 const codeRuleConst = require('../const/code_rule');
@@ -603,17 +604,65 @@ module.exports = app => {
             }
         }
 
+        // 巡检概况
+        async inspectionOverview(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const tender = await this.service.tender.getDataById(ctx.tender.id);
+                const year = moment().year();
+                const renderData = {
+                    moment,
+                    tender,
+                    permission: ctx.permission.safe_inspection,
+                    overview: await ctx.service.safeInspection.getOverviewData(ctx.tender.id, year),
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.safe.inspection_overview),
+                };
+                await this.layout('safe/inspection_overview.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检概况数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        // 巡检整改
+        async inspectionRectification(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const status = safeInspectionConst.filter.status.rectification; // 正在整改
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检整改数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        // 巡检批复
+        async inspectionApproval(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const status = safeInspectionConst.filter.status.reply; // 正在批复
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检批复数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
         // 质量巡检单功能
         async _filterInspection(ctx, status = 0) {
             try {
                 ctx.session.sessionUser.tenderId = ctx.tender.id;
                 const sorts = ctx.query.sort ? ctx.query.sort : 0;
                 const orders = ctx.query.order ? ctx.query.order : 0;
-                const filter = JSON.parse(JSON.stringify(auditConst.inspection.filter));
+                const filter = JSON.parse(JSON.stringify(safeInspectionConst.filter));
                 filter.count = [];
                 filter.count[filter.status.pending] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.pending);
                 filter.count[filter.status.uncheck] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.uncheck);
                 filter.count[filter.status.checking] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.checking);
+                filter.count[filter.status.reply] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.reply);
                 filter.count[filter.status.rectification] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.rectification);
                 filter.count[filter.status.checked] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.checked);
                 filter.count[filter.status.checkStop] = await ctx.service.safeInspection.getCountByStatus(ctx.tender.id, filter.status.checkStop);// await ctx.service.change.pendingDatas(tender.id, ctx.session.sessionUser.accountId);
@@ -684,7 +733,7 @@ module.exports = app => {
                     filter,
                     inspectionList,
                     auditType,
-                    auditConst: auditConst.inspection,
+                    auditConst: safeInspectionConst,
                     status,
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.safe.inspection),
                     pageInfo,
@@ -726,7 +775,7 @@ module.exports = app => {
                         if (!data.check_item || !data.check_date) {
                             throw '请填写检查项';
                         }
-                        reponseData.data = await ctx.service.safeInspection.addByWap(ctx.tender.id, ctx.session.sessionUser.accountId, data.code, data.check_item, data.check_date);
+                        reponseData.data = await ctx.service.safeInspection.addByWap(ctx.tender.id, data);
                         break;
                     default:throw '参数有误';
                 }
@@ -759,7 +808,7 @@ module.exports = app => {
                     moment,
                     tender,
                     inspection: ctx.inspection,
-                    auditConst: auditConst.inspection,
+                    auditConst: safeInspectionConst,
                     fileList,
                     whiteList,
                     auditType,
@@ -777,6 +826,11 @@ module.exports = app => {
                     const groupList = accountList.filter(item1 => item1.company === item.name);
                     return { groupName: item.name, groupList };
                 }).filter(x => { return x.groupList.length > 0; });
+                if (ctx.inspection.flow_version === 1) {
+                    const groupedIds = new Set(renderData.accountGroup.reduce((ids, group) => ids.concat(group.groupList.map(user => user.id)), []));
+                    const others = accountList.filter(user => !groupedIds.has(user.id));
+                    if (others.length) renderData.accountGroup.push({ groupName: '其他用户', groupList: others });
+                }
                 await this.layout('safe/inspection_information.ejs', renderData, 'safe/inspection_information_modal.ejs');
             } catch (err) {
                 this.log(err);
@@ -790,6 +844,11 @@ module.exports = app => {
                 const reponseData = {
                     err: 0, msg: '', data: {},
                 };
+                if (ctx.inspection.flow_version === 1) {
+                    reponseData.data = await ctx.service.safeInspectionAudit.processFlow(ctx.inspection, data);
+                    ctx.body = reponseData;
+                    return;
+                }
                 switch (data.type) {
                     case 'update-field':
                         if (!(!ctx.inspection.readOnly || ctx.inspection.rectificationPower)) {
@@ -911,7 +970,7 @@ module.exports = app => {
         async uploadInspectionFile(ctx) {
             let stream;
             try {
-                // this._checkAdvanceFileCanModify(ctx);
+                if (ctx.inspection.flow_version === 1 && !ctx.inspection.canUpload) throw '当前不可上传附件';
                 const parts = this.ctx.multipart({
                     autoFields: true,
                 });
@@ -973,7 +1032,11 @@ module.exports = app => {
             try {
                 const { id } = JSON.parse(ctx.request.body.data);
                 const fileInfo = await ctx.service.safeInspectionAtt.getDataById(id);
-                if (fileInfo || Object.keys(fileInfo).length) {
+                if (ctx.inspection.flow_version === 1) {
+                    if (!fileInfo || fileInfo.qiid !== ctx.inspection.id || !ctx.inspection.canUpload || fileInfo.uid !== ctx.session.sessionUser.accountId) throw '您无权删除该附件';
+                    if (ctx.inspection.status === safeInspectionConst.status.checked && !fileInfo.extra_upload && !PermissionCheck.delFile(ctx.session.sessionUser.permission)) throw '该附件不可删除';
+                }
+                if (fileInfo && Object.keys(fileInfo).length) {
                     // 先删除文件
                     // await fs.unlinkSync(path.resolve(this.app.baseDir, './app', fileInfo.filepath));
                     await ctx.app.fujianOss.delete(ctx.app.config.fujianOssFolder + fileInfo.filepath);

+ 79 - 0
app/controller/setting_controller.js

@@ -26,6 +26,7 @@ const funSet = require('../const/fun_set');
 const projectSettingConst = require('../const/project_setting');
 const SpreadConst = require('../const/spread');
 const shenpiConst = require('../const/shenpi');
+const projectScreenConst = require('../const/project_screen');
 
 module.exports = app => {
 
@@ -48,6 +49,84 @@ module.exports = app => {
             ctx.subMenu.s2b.display = !!ctx.session.sessionProject.gxby || !!ctx.session.sessionProject.dagl;
         }
 
+        _checkProjectScreenAdmin(ctx) {
+            if (!ctx.session.sessionUser.is_admin) throw '没有访问权限';
+            if (!ctx.session.sessionProject || !ctx.session.sessionProject.id) throw '项目不存在';
+        }
+
+        async module(ctx) {
+            try {
+                this._checkProjectScreenAdmin(ctx);
+                const projectId = ctx.session.sessionProject.id;
+                await this._checkMenu(projectId);
+                const screens = await ctx.service.projectScreen.getList(projectId);
+                await this.layout('setting/module.ejs', {
+                    screens,
+                    templates: projectScreenConst.templates,
+                    projectScreenEnabled: ctx.session.sessionProject.page_show.openProjectScreen === 1,
+                    jsFiles: this.app.jsFiles.common.concat(['/public/js/project_screen_setting.js']),
+                }, 'setting/module_modal.ejs');
+            } catch (error) {
+                ctx.log(error);
+                ctx.redirect('/dashboard');
+            }
+        }
+
+        async projectScreenSave(ctx) {
+            let errorMessage = '项目大屏操作失败';
+            try {
+                this._checkProjectScreenAdmin(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const projectId = ctx.session.sessionProject.id;
+                const responseData = { err: 0, msg: '', data: null };
+                switch (data.type) {
+                    case 'status': {
+                        errorMessage = '保存项目大屏开关失败';
+                        const enabled = parseInt(data.enabled) === 1 ? 1 : 0;
+                        ctx.session.sessionProject.page_show.openProjectScreen = enabled;
+                        await ctx.service.project.updatePageshow(projectId);
+                        responseData.data = { enabled };
+                        break;
+                    }
+                    case 'add': {
+                        errorMessage = '添加大屏失败';
+                        const screens = await ctx.service.projectScreen.addScreens(projectId, data.codes);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'delete': {
+                        errorMessage = '删除大屏失败';
+                        const screens = await ctx.service.projectScreen.deleteScreen(projectId, data.id);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'default': {
+                        errorMessage = '设置默认大屏失败';
+                        const screens = await ctx.service.projectScreen.setDefault(projectId, data.id);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'permissionload':
+                        errorMessage = '加载项目成员失败';
+                        responseData.data = await ctx.service.projectAccount.getProjectScreenPermissionAccounts(
+                            projectId,
+                            data
+                        );
+                        break;
+                    case 'permissionsave':
+                        errorMessage = '保存项目大屏权限失败';
+                        await ctx.service.projectAccount.saveProjectScreenPermissions(projectId, data.changes);
+                        break;
+                    default:
+                        throw '提交数据错误';
+                }
+                ctx.body = responseData;
+            } catch (error) {
+                ctx.log(error);
+                ctx.ajaxErrorBody(error, errorMessage);
+            }
+        }
+
         /**
          * 项目信息页面(Get)
          *

+ 62 - 4
app/controller/sub_proj_setting_controller.js

@@ -273,6 +273,13 @@ module.exports = app => {
             }
         }
 
+
+        /*
+        view	查看	1	file_permission
+        upload	上传/引用	2	file_permission
+        editfile	编辑文件	4	file_permission
+        filing	文件类别编辑	3	file_permission
+        */
         async permission(ctx) {
             try {
                 this.defaultCheck(ctx);
@@ -335,6 +342,50 @@ module.exports = app => {
             }
         }
 
+        async dataRange(ctx) {
+            try {
+                this.defaultCheck(ctx);
+                const moduleKey = ctx.query.module || 'file';
+                const moduleInfo = ctx.service.subProjDataRange.getModule(moduleKey);
+                const result = await Promise.all([
+                    ctx.service.subProjDataRange.getRangeTree(ctx.subProject),
+                    ctx.service.subProjDataRange.getRangeConfig(ctx.subProject.id, moduleKey),
+                ]);
+                const renderData = {
+                    moduleKey,
+                    moduleInfo,
+                    moduleList: ctx.service.subProjDataRange.getModuleList(),
+                    rangeTree: result[0].tree,
+                    unassignedUsers: result[0].unassignedUsers,
+                    rangeConfig: result[1],
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.setting.sp_data_range),
+                };
+                await this.layout('sp_setting/data_range.ejs', renderData, 'sp_setting/data_range_modal.ejs');
+            } catch (error) {
+                ctx.log(error);
+                ctx.session.postError = error.toString();
+                ctx.redirect(`/sp/${ctx.subProject.id}/setting/user`);
+            }
+        }
+
+        async dataRangeUpdate(ctx) {
+            try {
+                this.defaultCheck(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const moduleKey = data.module_key || 'file';
+                const result = await ctx.service.subProjDataRange.saveRange(
+                    ctx.subProject,
+                    moduleKey,
+                    data.ranges,
+                    ctx.session.sessionUser.accountId
+                );
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存数据范围失败');
+            }
+        }
+
         async logs(ctx) {
             try {
                 this.defaultCheck(ctx);
@@ -356,15 +407,16 @@ module.exports = app => {
 
         async fun(ctx) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
                 const funRela = this.ctx.subProject.fun_rela;
                 const fun_set = this.ctx.subProject.fun_set;
-                await this.layout('sp_setting/fun.ejs', {
+                const renderData = {
                     funRela,
                     imType,
                     endMonth: funSet.endMonth,
                     funSet: fun_set,
-                }, 'sp_setting/fun_modal.ejs');
+                };
+                await this.layout('sp_setting/fun.ejs', renderData, 'sp_setting/fun_modal.ejs');
             } catch (error) {
                 ctx.log(error);
                 ctx.redirect(`/sp/${ctx.subProject.id}/dashboard`);
@@ -378,13 +430,19 @@ module.exports = app => {
          */
         async updateFun(ctx) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
 
                 const data = JSON.parse(ctx.request.body.data);
                 if (data) ctx.request.body = data;
                 const rule = ctx.service.subProject.rule('fun');
                 ctx.validate(rule);
 
+                if (data.addFunSet && Object.prototype.hasOwnProperty.call(data.addFunSet, 'change_plan_name')) {
+                    if (typeof data.addFunSet.change_plan_name !== 'string') throw '变更方案名称格式错误';
+                    data.addFunSet.change_plan_name = data.addFunSet.change_plan_name.trim();
+                    if (data.addFunSet.change_plan_name.length > 5) throw '变更方案名称字符不能超过5个';
+                }
+
                 const result = await ctx.service.subProject.updateFunRela(ctx.subProject.id, ctx.request.body);
                 if (!result) throw '保存数据失败';
                 this.ctx.subProject.page_show.openChangeProject = data.openChangeProject ? 1 : 0;

+ 4 - 0
app/controller/tender_controller.js

@@ -56,6 +56,7 @@ module.exports = app => {
                 for (const t of tenderList) {
                     await this.ctx.service.tenderCache.loadTenderCache(t, this.ctx.session.sessionUser.accountId);
                     t.canFinish = await this.ctx.service.tender.checkTenderCanFinish(t);
+                    [t.change_tp, t.change_p_tp, t.change_n_tp, t.change_valuation_tp, t.change_unvaluation_tp] = await this.ctx.service.change.getChangeTp(t.id);
                 }
                 const categoryData = await this.ctx.service.category.getAllCategory(this.ctx.subProject);
                 const valuations = await this.ctx.service.valuation.getProjectValidValuation(this.ctx.session.sessionProject.id);
@@ -95,6 +96,7 @@ module.exports = app => {
                 for (const t of tenderList) {
                     await this.ctx.service.tenderCache.loadTenderCache(t, this.ctx.session.sessionUser.accountId);
                     t.canFinish = await this.ctx.service.tender.checkTenderCanFinish(t);
+                    [t.change_tp, t.change_p_tp, t.change_n_tp, t.change_valuation_tp, t.change_unvaluation_tp] = await this.ctx.service.change.getChangeTp(t.id);
                 }
                 const categoryData = await this.ctx.service.category.getAllCategory(this.ctx.subProject);
                 const valuations = await this.ctx.service.valuation.getProjectValidValuation(this.ctx.session.sessionProject.id);
@@ -132,6 +134,7 @@ module.exports = app => {
                     t.visitor = (await this.ctx.service.tenderTourist.getTourists(t.id)).map(x => { return x.user_name; });
                     await this.ctx.service.tenderCache.loadTenderCache(t, this.ctx.session.sessionUser.accountId);
                     t.canFinish = await this.ctx.service.tender.checkTenderCanFinish(t);
+                    [t.change_tp, t.change_p_tp, t.change_n_tp, t.change_valuation_tp, t.change_unvaluation_tp] = await this.ctx.service.change.getChangeTp(t.id);
                 }
                 renderData.categoryData = await this.ctx.service.category.getAllCategory(this.ctx.subProject);
                 renderData.valuations = await this.ctx.service.valuation.getProjectValidValuation(this.ctx.session.sessionProject.id);
@@ -162,6 +165,7 @@ module.exports = app => {
                     t.visitor = (await this.ctx.service.tenderTourist.getTourists(t.id)).map(x => { return x.user_name; });
                     await this.ctx.service.tenderCache.loadTenderCache(t, this.ctx.session.sessionUser.accountId);
                     t.canFinish = await this.ctx.service.tender.checkTenderCanFinish(t);
+                    [t.change_tp, t.change_p_tp, t.change_n_tp, t.change_valuation_tp, t.change_unvaluation_tp] = await this.ctx.service.change.getChangeTp(t.id);
                 }
                 renderData.categoryData = await this.ctx.service.category.getAllCategory(this.ctx.subProject);
                 renderData.valuations = await this.ctx.service.valuation.getProjectValidValuation(this.ctx.session.sessionProject.id);

+ 1 - 0
app/controller/wap_controller.js

@@ -213,6 +213,7 @@ module.exports = app => {
                         subProjects.push(sp);
                     }
                 }
+                cp.change_plan_name = (sp && ctx.service.subProject.getFunSet(sp.fun_set).change_plan_name || '变更方案');
                 if (sp) {
                     const pageShow = JSON.parse(sp.page_show);
                     if (pageShow.openChangePlan) {

+ 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);

+ 52 - 0
app/lib/ai_dify.js

@@ -139,6 +139,58 @@ class AiDify {
         });
         return await this.workflows(body);
     }
+
+    /** 查询助手专用 Chatflow;只返回解析结果,不将业务数据发送给模型。 */
+    async askAssistant({ question, path = '', user, conversationId = '' }) {
+        let response;
+        try {
+            response = await axios.post(`${this.baseUrl.replace(/\/$/, '')}/chat-messages`, {
+                inputs: { path },
+                query: question,
+                user,
+                conversation_id: conversationId,
+                response_mode: 'blocking',
+                auto_generate_name: false,
+            }, {
+                headers: { Authorization: `Bearer ${this.apiKey}` },
+                timeout: 110000,
+                maxRedirects: 0,
+                maxContentLength: 1024 * 1024,
+            });
+        } catch (err) {
+            // 不透传 Axios 错误对象,其中包含 Authorization 和用户问题。
+            const status = err.response && err.response.status;
+            if (status === 401 || status === 403) throw new Error('查询助手 Dify 授权失败,请检查服务端配置');
+            if (status === 404 && conversationId) throw new Error('助手会话已失效,请清空对话后重试');
+            if (err.code === 'ECONNABORTED') throw new Error('查询助手响应超时,请稍后重试');
+            console.log(err);
+            throw new Error('查询助手 Dify 调用失败,请检查已发布的 Chatflow 和服务地址');
+        }
+        const data = response.data || {};
+        let result;
+        try {
+            // 只兼容包裹整个 JSON 的代码围栏,不猜测或修复损坏的参数。
+            const text = typeof data.answer === 'string' ? data.answer.trim() : '';
+            result = JSON.parse(text.replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i, '$1'));
+        } catch (err) {
+            throw new Error('查询助手返回的 JSON 无法解析,请检查 Chatflow 的 Answer 配置');
+        }
+        if (!result || result.version !== '1.1.0' ||
+            !['manual', 'business', 'capability'].includes(result.type) ||
+            !['answer', 'query', 'clarify', 'error'].includes(result.status) ||
+            typeof result.answer !== 'string' || !Array.isArray(result.missing_fields) ||
+            !Array.isArray(result.sources) ||
+            (result.status === 'query' && (result.type !== 'business' || !result.query ||
+                result.answer !== '' || result.missing_fields.length !== 0)) ||
+            (result.type !== 'business' && (result.query !== null || !['answer', 'error'].includes(result.status))) ||
+            (result.status !== 'query' && !result.answer.trim())) {
+            throw new Error('查询助手返回格式不符合 1.1.0 协议,请检查 Chatflow 配置');
+        }
+        if (typeof data.conversation_id !== 'string' || !/^[a-f0-9-]{36}$/i.test(data.conversation_id)) {
+            throw new Error('查询助手未返回有效会话 ID');
+        }
+        return { result, conversationId: data.conversation_id };
+    }
 }
 
 module.exports = AiDify;

+ 1 - 2
app/lib/analysis_excel.js

@@ -77,7 +77,7 @@ const aeUtils = {
     isMatch18(tempData) {
         const checkCode = this.check18MainCode;
         return _.every(tempData, x => {
-            return !x.code ||  checkCode(x.code);
+            return !x.code || checkCode(x.code);
             // return !x.code || !!x.code.match(mainReg);
         });
     }
@@ -397,7 +397,6 @@ class ImportStd18Tree extends ImportBaseTree {
         if (!parent.code) return false;
         const numberPart = parent.code.replace(gdXmjPartReg, '');
         if (!numberPart || !codeNumberPart || numberPart.length >= codeNumberPart.length) return false;
-        if (parent === '1060501' && code == '1060501-102') console.log(numberPart);
         return code.indexOf(numberPart) === 0 ||
             code.indexOf('G' + numberPart) === 0 ||
             code.indexOf('GD' + numberPart) === 0;

+ 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;

+ 4 - 1
app/middleware/advance_check.js

@@ -60,7 +60,7 @@ module.exports = () => {
                 const auditList = yield this.service.advanceAudit.getAllDataByCondition({ where: { vid: advance.id, times: advance.times }, orders: [['order', 'asc']] });
                 const auditIdList = _.map(auditList, 'audit_id');
                 if (shenpi_status === shenpiConst.sp_status.gdspl) {
-                    const shenpiList = yield this.service.shenpiAudit.getAllDataByCondition({ where: { tid: advance.tid, sp_type: shenpiConst.sp_type.advance, sp_status: shenpi_status } });
+                    const shenpiList = yield this.service.shenpiAudit.getAllDataByCondition({ where: { tid: advance.tid, sp_type: shenpiConst.sp_type.advance, sp_status: shenpi_status }, orders: [['audit_order', 'asc'], ['id', 'asc']] });
                     const shenpiIdList = _.map(shenpiList, 'audit_id');
                     // 判断2个id数组是否相同,不同则删除原审批流,切换成固定的审批流
                     if (!_.isEqual(auditIdList, shenpiIdList)) {
@@ -77,6 +77,9 @@ module.exports = () => {
                     }
                 }
             }
+            // 固定流程/终审同步后,供控制器使用的审批人信息也应重新读取。
+            advance.auditors = yield this.service.advanceAudit.getAuditors(advance.id, advance.times);
+            advance.curAuditor = yield this.service.advanceAudit.getCurAuditor(advance.id, advance.times);
             yield next;
         } catch (err) {
             this.helper.log(err);

+ 2 - 2
app/middleware/change_plan_check.js

@@ -28,10 +28,10 @@ module.exports = options => {
             }
             const cpid = this.params.cpid || this.request.body.cpid;
             if (!cpid) {
-                throw '您访问的变更方案不存在';
+                throw '您访问的' + (this.subProject && this.subProject.fun_set && this.subProject.fun_set.change_plan_name || '变更方案') + '不存在';
             }
             const change = yield this.service.changePlan.getDataById(cpid);
-            if (!change) throw '变更方案数据有误';
+            if (!change) throw (this.subProject && this.subProject.fun_set && this.subProject.fun_set.change_plan_name || '变更方案') + '数据有误';
             // 读取原报、审核人数据
             yield this.service.changePlan.loadChangeUser(change);
             // decimal小数位设置

+ 8 - 2
app/middleware/safe_inspection_check.js

@@ -33,6 +33,7 @@ module.exports = options => {
             }
             const inspection = yield this.service.safeInspection.getDataById(qiid);
             if (!inspection) throw '质量巡检数据有误';
+            yield this.service.safeInspectionAudit.prepareDraftFlow(inspection);
             // 读取原报、审核人数据
             yield this.service.safeInspection.loadUser(inspection);
             // 权限相关
@@ -76,7 +77,9 @@ module.exports = options => {
             if ((inspection.status === status.uncheck || inspection.status === status.checkNo) && this.tender.info.shenpi.safe_inspection !== shenpiConst.sp_status.sqspr) {
                 const shenpi_status = this.tender.info.shenpi.safe_inspection;
                 // 进一步比较审批流是否与审批流程设置的相同,不同则替换为固定审批流或固定的终审
-                const auditList = yield this.service.safeInspectionAudit.getAllDataByCondition({ where: { qiid: inspection.id, times: inspection.times, is_rectification: 0 }, orders: [['order', 'asc']] });
+                const where = { qiid: inspection.id, times: inspection.times, is_rectification: 0, is_old: 0 };
+                if (inspection.flow_version === 1) where.flow_stage = 1;
+                const auditList = yield this.service.safeInspectionAudit.getAllDataByCondition({ where, orders: [['order', 'asc']] });
                 if (shenpi_status === shenpiConst.sp_status.gdspl) {
                     const shenpiList = yield this.service.shenpiAudit.getAllDataByCondition({ where: { tid: inspection.tid, sp_type: shenpiConst.sp_type.safe_inspection, sp_status: shenpi_status } });
                     // 判断2个id数组是否相同,不同则删除原审批流,切换成固定的审批流
@@ -97,7 +100,7 @@ module.exports = options => {
                 } else if (shenpi_status === shenpiConst.sp_status.gdzs) {
                     const shenpiInfo = yield this.service.shenpiAudit.getDataByCondition({ tid: inspection.tid, sp_type: shenpiConst.sp_type.safe_inspection, sp_status: shenpi_status });
                     // 判断最后一个id是否与固定终审id相同,不同则删除原审批流中如果存在的id和添加终审
-                    const lastAuditors = auditList.filter(x => { x.order === auditList[auditList.length - 1].order; });
+                    const lastAuditors = auditList.filter(x => { return x.order === auditList[auditList.length - 1].order; });
                     if (shenpiInfo && (lastAuditors.length === 0 || (lastAuditors.length > 1 || shenpiInfo.audit_id !== lastAuditors[0].aid))) {
                         yield this.service.safeInspectionAudit.updateLastAudit(inspection, auditList, shenpiInfo.audit_id);
                         yield this.service.safeInspection.loadUser(inspection);
@@ -107,6 +110,9 @@ module.exports = options => {
                     }
                 }
             }
+            if (inspection.flow_version === 1) {
+                inspection.canUpload = !inspection.readOnly || inspection.rectificationPower || inspection.shenpiPower || (inspection.status === status.checked && inspection.uid === accountId);
+            }
             yield next;
         } catch (err) {
             console.log(err);

+ 8 - 0
app/middleware/session_auth.js

@@ -3,6 +3,7 @@
 // 加密类
 const crypto = require('crypto');
 const messageType = require('../const/message_type');
+const accountPermission = require('../const/account_permission');
 
 module.exports = options => {
     /**
@@ -71,6 +72,13 @@ module.exports = options => {
             this.session.sessionProject.custom = projectData.custom;
             this.session.sessionProject.customType = projectData.customType;
             this.session.sessionProject.funSet = projectData.fun_set ? JSON.parse(projectData.fun_set) : null;
+            this.showProjectScreen = false;
+            if (this.session.sessionProject.page_show.openProjectScreen) {
+                const hasProjectScreen = yield this.service.projectScreen.hasScreen(projectData.id);
+                const canViewProjectScreen = this.session.sessionUser.is_admin ||
+                    accountPermission.PermissionCheck.projectScreen(this.session.sessionUser.permission);
+                this.showProjectScreen = hasProjectScreen && canViewProjectScreen;
+            }
 
             // 同步消息
             yield this.service.notify.syncNotifyData();

Разница между файлами не показана из-за своего большого размера
+ 895 - 0
app/public/css/main.css


BIN
app/public/images/filesjs-folder.png


BIN
app/public/images/filesjs-leaf.png


BIN
app/public/images/filesjs-tree-closed.png


BIN
app/public/images/filesjs-tree-leaf.png


BIN
app/public/images/filesjs-tree-open.png


BIN
app/public/images/juecedaping08.png


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

@@ -59,7 +59,7 @@ $(document).ready(function () {
                 if (i === 0) return
                 group.forEach((auditor, j) => {
                     html.push('<tr>')
-                    html.push(`<td class="text-left">${j === 0 ? i + '审' : ''}</td>`)
+                    html.push(`<td class="text-left">${j === 0 ? auditor.audit_order + '审' : ''}</td>`)
                     html.push(`<td>${auditor.name} <small class="text-muted">${auditor.role || ''}</small></td>`)
                     const statusText = auditor.status === auditConst.status.uncheck ? '待审批' : (auditConst.auditString[auditor.status] || '')
                     html.push(`<td class="text-center"><span class="${auditConst.auditStringClass[auditor.status] || ''}">${statusText}</span></td>`)
@@ -223,7 +223,7 @@ $(document).ready(function () {
                             html.push('<a href="javascript: void(0)" class="text-danger pull-right">移除</a>');
                         }
                         html.push('<span>');
-                        html.push(data.order + ' ');
+                        html.push(data.audit_order + ' ');
                         html.push(data.name + ' ');
                         html.push('</span>');
                         html.push('<small class="text-muted">');
@@ -270,7 +270,7 @@ $(document).ready(function () {
             li.remove();
             for (const rst of result) {
                 const aLi = $('li[auditorId=' + rst.audit_id + ']');
-                $('span', aLi).text(rst.order + ' ' + rst.name + ' ')
+                $('span', aLi).text(rst.audit_order + ' ' + rst.name + ' ')
             }
             // 删除左边审核人
             $(`#auditors2 li[data-auditorid='${data.auditorId}']`).remove();

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

@@ -0,0 +1,316 @@
+'use strict';
+
+(function() {
+    $(function() {
+        const root = $('#global-assistant');
+        if (root.length === 0) return;
+
+        const panel = root.find('.global-assistant-panel');
+        const messages = root.find('.global-assistant-messages');
+        const suggestions = root.find('.global-assistant-suggestions');
+        const input = root.find('.global-assistant-input');
+        const sendButton = root.find('.global-assistant-send');
+        const entry = root.find('.global-assistant-entry');
+        const projectId = root.data('project-id');
+        const storageKey = 'global-assistant-history-v2-' + projectId + '-' + userID;
+        const entryPositionKey = 'global-assistant-entry-position-' + userID;
+        let history = loadHistory();
+        let conversationId = getConversationId();
+        let sending = false;
+        let entryPosition = loadEntryPosition();
+        let entryDrag = null;
+        let suppressEntryClick = false;
+
+        applyEntryPosition();
+
+        entry.on('click', function(event) {
+            if (suppressEntryClick) {
+                event.preventDefault();
+                suppressEntryClick = false;
+                return;
+            }
+            panel.addClass('show').attr('aria-hidden', 'false');
+            $(this).hide();
+            input.trigger('focus');
+            scrollToBottom();
+        });
+
+        entry.on('pointerdown', function(event) {
+            const pointer = event.originalEvent;
+            if (pointer.pointerType === 'mouse' && pointer.button !== 0) return;
+            entryDrag = {
+                pointerId: pointer.pointerId,
+                startX: pointer.clientX,
+                startY: pointer.clientY,
+                positionX: entryPosition.x,
+                positionY: entryPosition.y,
+                moved: false,
+            };
+            suppressEntryClick = false;
+            this.setPointerCapture(pointer.pointerId);
+            entry.addClass('dragging');
+            event.preventDefault();
+        });
+
+        entry.on('pointermove', function(event) {
+            const pointer = event.originalEvent;
+            if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
+            const offsetX = entryDrag.startX - pointer.clientX;
+            const offsetY = entryDrag.startY - pointer.clientY;
+            if (Math.abs(offsetX) > 3 || Math.abs(offsetY) > 3) entryDrag.moved = true;
+            entryPosition = clampEntryPosition({
+                x: entryDrag.positionX + offsetX,
+                y: entryDrag.positionY + offsetY,
+            });
+            applyEntryPosition();
+            event.preventDefault();
+        });
+
+        entry.on('pointerup pointercancel', function(event) {
+            const pointer = event.originalEvent;
+            if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
+            suppressEntryClick = event.type === 'pointerup' && entryDrag.moved;
+            if (this.hasPointerCapture(pointer.pointerId)) this.releasePointerCapture(pointer.pointerId);
+            entryDrag = null;
+            entry.removeClass('dragging');
+            saveEntryPosition();
+        });
+
+        $(window).on('resize.globalAssistant', function() {
+            entryPosition = clampEntryPosition(entryPosition);
+            applyEntryPosition();
+            saveEntryPosition();
+        });
+
+        root.find('.global-assistant-close').on('click', function() {
+            panel.removeClass('show').attr('aria-hidden', 'true');
+            root.find('.global-assistant-entry').show();
+        });
+
+        root.find('.global-assistant-clear').on('click', function() {
+            if (sending) return;
+            history = [];
+            conversationId = '';
+            saveHistory();
+            renderHistory();
+        });
+
+        root.find('.global-assistant-form').on('submit', function(event) {
+            event.preventDefault();
+            ask(input.val());
+        });
+
+        input.on('keydown', function(event) {
+            if (event.key === 'Enter' && !event.shiftKey) {
+                event.preventDefault();
+                root.find('.global-assistant-form').trigger('submit');
+            }
+        });
+
+        suggestions.on('click', 'button', function() {
+            ask($(this).text());
+        });
+
+        renderHistory();
+
+        function ask(question) {
+            question = $.trim(question || '');
+            if (!question || sending) return;
+            input.val('');
+            addHistory({ role: 'user', text: question });
+            setSending(true);
+
+            postData('/assistant/query/v2', {
+                question,
+                conversationId,
+                context: {
+                    path: window.location.pathname,
+                    title: document.title,
+                    subProjectId: root.data('sub-project-id') || '',
+                    tenderId: getTenderId(),
+                },
+            }, function(data) {
+                conversationId = data.conversationId || '';
+                addHistory({ role: 'assistant', text: data.answer, data });
+                setSending(false);
+            }, function(message) {
+                addHistory({ role: 'assistant', text: message || '查询失败,请稍后重试。' });
+                setSending(false);
+            }, false, '助手查询失败');
+        }
+
+        function setSending(value) {
+            sending = value;
+            input.prop('disabled', value);
+            sendButton.prop('disabled', value);
+            root.find('.global-assistant-clear').prop('disabled', value);
+            messages.find('.global-assistant-loading').remove();
+            if (value) {
+                const loading = $('<div class="global-assistant-message assistant global-assistant-loading"></div>');
+                loading.append($('<div class="global-assistant-bubble"></div>').text('正在查找相关内容…'));
+                messages.append(loading);
+            }
+            scrollToBottom();
+            if (!value) input.trigger('focus');
+        }
+
+        function addHistory(message) {
+            history.push(message);
+            if (history.length > 20) history = history.slice(history.length - 20);
+            saveHistory();
+            renderHistory();
+        }
+
+        function renderHistory() {
+            messages.empty();
+            if (history.length === 0) {
+                appendMessage({
+                    role: 'assistant',
+                    text: '你好,我可以查询当前账号有权查看的业务数据,也可以从用户手册中查找操作说明。',
+                });
+                renderSuggestions(['查询平台概况', '查询项目', '如何创建标段', '怎么设置审批流程']);
+            } else {
+                history.forEach(appendMessage);
+                const lastAssistant = history.slice().reverse().find(item => item.role === 'assistant' && item.data);
+                renderSuggestions(lastAssistant && lastAssistant.data.suggestions ? lastAssistant.data.suggestions : []);
+            }
+            scrollToBottom();
+        }
+
+        function appendMessage(message) {
+            const wrapper = $('<div class="global-assistant-message"></div>').addClass(message.role);
+            const bubble = $('<div class="global-assistant-bubble"></div>').text(message.text || '');
+            wrapper.append(bubble);
+            if (message.role === 'assistant' && message.data) {
+                appendSummary(bubble, message.data.summary || []);
+                appendItems(bubble, message.data.items || []);
+                if (message.data.truncated) {
+                    bubble.append($('<div class="global-assistant-more"></div>').text('结果较多,仅展示前20条。请增加名称或编号缩小范围。'));
+                }
+                if (message.data.tenderMatchNotice) {
+                    bubble.append($('<div class="global-assistant-tender-notice"></div>').text(message.data.tenderMatchNotice));
+                }
+            }
+            messages.append(wrapper);
+        }
+
+        function appendSummary(bubble, summary) {
+            if (!summary.length) return;
+            const summaryBox = $('<div class="global-assistant-summary"></div>');
+            summary.forEach(function(item) {
+                const block = $('<div></div>');
+                block.append($('<span></span>').text(item.label));
+                block.append($('<strong></strong>').text(item.value));
+                summaryBox.append(block);
+            });
+            bubble.append(summaryBox);
+        }
+
+        function appendItems(bubble, items) {
+            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)')
+                    .attr('target', isExternal ? '_blank' : '_self');
+                if (isExternal) row.attr('rel', 'noopener noreferrer');
+                const info = $('<div></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></span>').text(item.value));
+                list.append(row);
+            });
+            bubble.append(list);
+        }
+
+        function renderSuggestions(list) {
+            suggestions.empty();
+            list.slice(0, 4).forEach(function(text) {
+                suggestions.append($('<button type="button"></button>').text(text));
+            });
+        }
+
+        function getTenderId() {
+            const match = window.location.pathname.match(/^\/tender\/(\d+)/);
+            return match ? match[1] : '';
+        }
+
+        function scrollToBottom() {
+            messages.scrollTop(messages.prop('scrollHeight'));
+        }
+
+        function loadHistory() {
+            try {
+                const value = JSON.parse(localStorage.getItem(storageKey) || '[]');
+                return value instanceof Array ? value : [];
+            } catch (err) {
+                return [];
+            }
+        }
+
+        function saveHistory() {
+            try {
+                localStorage.setItem(storageKey, JSON.stringify(history));
+            } catch (err) {
+                // 浏览器禁用本地存储时仅保留当前页面内的对话。
+            }
+        }
+
+        function getConversationId() {
+            const last = history.slice().reverse().find(item => item.role === 'assistant' && item.data);
+            return last && last.data.conversationId ? last.data.conversationId : '';
+        }
+
+        function loadEntryPosition() {
+            try {
+                const value = JSON.parse(localStorage.getItem(entryPositionKey) || '{}');
+                return clampEntryPosition({ x: Number(value.x) || 0, y: Number(value.y) || 0 });
+            } catch (err) {
+                return { x: 0, y: 0 };
+            }
+        }
+
+        function saveEntryPosition() {
+            try {
+                localStorage.setItem(entryPositionKey, JSON.stringify(entryPosition));
+            } catch (err) {
+                // 浏览器禁用本地存储时仅在当前页面保留按钮位置。
+            }
+        }
+
+        function applyEntryPosition() {
+            entry.css('transform', `translate(${-entryPosition.x}px, ${-entryPosition.y}px)`);
+        }
+
+        function clampEntryPosition(position) {
+            const panelWidth = parseFloat(panel.css('width')) || 420;
+            const panelHeight = Math.min(650, Math.max(0, window.innerHeight - 48));
+            const maxX = Math.max(0, panelWidth - entry.outerWidth());
+            const maxY = Math.max(0, panelHeight - entry.outerHeight());
+            return {
+                x: Math.min(maxX, Math.max(0, Number(position.x) || 0)),
+                y: Math.min(maxY, Math.max(0, Number(position.y) || 0)),
+            };
+        }
+    });
+})();

+ 21 - 8
app/public/js/change_information.js

@@ -308,7 +308,7 @@ $(document).ready(() => {
             {title: '名称', colSpan: '1', rowSpan: '2', field: 'name', hAlign: 0, width: 120},
             {title: '单位', colSpan: '1', rowSpan: '2', field: 'unit', hAlign: 1, width: 60},
             {title: '单价', colSpan: '1', rowSpan: '2', field: 'unit_price', hAlign: 2, width: 60, type: 'Number', getValue: 'getValue.unit_price'},
-            {title: '变更方案|数量', colSpan: '2|1', rowSpan: '1|1', field: 'pamount', hAlign: 2, width: 60, type: 'Number', getValue: 'getValue.pamount'},
+            {title: planName + '|数量', colSpan: '2|1', rowSpan: '1|1', field: 'pamount', hAlign: 2, width: 60, type: 'Number', getValue: 'getValue.pamount'},
             {title: '|金额', colSpan: '|1', rowSpan: '|1', field: 'pa_tp', hAlign: 2, width: 80, type: 'Number', getValue: 'getValue.pa_tp'},
             {title: '变更令|数量', colSpan: '2|1', rowSpan: '1|1', field: 'camount', hAlign: 2, width: 60, type: 'Number', getValue: 'getValue.camount'},
             {title: '|金额', colSpan: '|1', rowSpan: '|1', field: 'ca_tp', hAlign: 2, width: 80, type: 'Number', getValue: 'getValue.ca_tp'},
@@ -6395,27 +6395,40 @@ function calcChangePrice() {
         if (g) {
             const list = _.filter(changeList, { gcl_id: g });
             let spamount = 0;
+            let positive_amount = 0;
+            let negative_amount = 0;
             let valuation_amount = 0;
             let unvaluation_amount = 0;
             let unitPrice = 0;
             for (const c of list) {
                 if (c.spamount) {
-                    spamount = ZhCalc.add(spamount, ZhCalc.round(c.spamount, findDecimal(c.unit)));
+                    const amount = ZhCalc.round(c.spamount, findDecimal(c.unit));
+                    spamount = ZhCalc.add(spamount, amount);
+                    if (amount >= 0) {
+                        positive_amount = ZhCalc.add(positive_amount, amount);
+                    } else {
+                        negative_amount = ZhCalc.add(negative_amount, amount);
+                    }
                     unitPrice = c.unit_price;
                     if (c.is_valuation) {
                         valuation_amount = ZhCalc.add(valuation_amount, ZhCalc.round(c.spamount, findDecimal(c.unit)));
                     } else {
                         unvaluation_amount = ZhCalc.add(unvaluation_amount, ZhCalc.round(c.spamount, findDecimal(c.unit)));
                     }
-                    const posOrNePrice = ZhCalc.round(ZhCalc.mul(ZhCalc.round(c.spamount, findDecimal(c.unit)), ZhCalc.round(c.unit_price, unitPriceUnit)), totalPriceUnit) || 0;
-                    if (posOrNePrice >= 0) {
-                        positive_tp = ZhCalc.add(positive_tp, posOrNePrice);
-                    } else {
-                        negative_tp = ZhCalc.add(negative_tp, posOrNePrice);
-                    }
+                    // const posOrNePrice = ZhCalc.round(ZhCalc.mul(ZhCalc.round(c.spamount, findDecimal(c.unit)), ZhCalc.round(c.unit_price, unitPriceUnit)), totalPriceUnit) || 0;
+                    // if (posOrNePrice >= 0) {
+                    //     positive_tp = ZhCalc.add(positive_tp, posOrNePrice);
+                    // } else {
+                    //     negative_tp = ZhCalc.add(negative_tp, posOrNePrice);
+                    // }
                 }
             }
             const price = ZhCalc.round(ZhCalc.mul(spamount, ZhCalc.round(unitPrice, unitPriceUnit)), totalPriceUnit);
+            // 正负数量分别汇总后计算金额,避免相互抵消。
+            const positive_price = ZhCalc.round(ZhCalc.mul(positive_amount, ZhCalc.round(unitPrice, unitPriceUnit), totalPriceUnit), totalPriceUnit) || 0;
+            const negative_price = ZhCalc.round(ZhCalc.mul(negative_amount, ZhCalc.round(unitPrice, unitPriceUnit), totalPriceUnit), totalPriceUnit) || 0;
+            positive_tp = ZhCalc.add(positive_tp, positive_price);
+            negative_tp = ZhCalc.add(negative_tp, negative_price);
             const valuation_price = ZhCalc.round(ZhCalc.mul(valuation_amount, ZhCalc.round(unitPrice, unitPriceUnit)), totalPriceUnit) || 0;
             const unvaluation_price = ZhCalc.round(ZhCalc.mul(unvaluation_amount, ZhCalc.round(unitPrice, unitPriceUnit)), totalPriceUnit) || 0;
             valuation_tp = ZhCalc.add(valuation_tp, valuation_price);

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

@@ -448,7 +448,7 @@ $(document).ready(() => {
     if (orders[0] === 'time') {
         $('#bpaixu').text('排序:创建时间');
     } else {
-        $('#bpaixu').text('排序:变更方案编号');
+        $('#bpaixu').text('排序:' + planName + '编号');
     }
     // let sortSetting = getLocalCache('change-'+ $('#tenderId').val() +'-list-sort');
     // if (sortSetting && parseInt(sortSetting) === 1) {

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

@@ -199,7 +199,7 @@ function checkAuditorFrom () {
     }
     let flag = false;
     if (change.code === '') {
-        toastr.error('变更方案编号不能为空');
+        toastr.error(planName + '编号不能为空');
         flag = true;
     }
     if (!change.name) {

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

@@ -105,7 +105,7 @@ $(document).ready(() => {
         switch(val_name) {
             case 'code':
                 if(!val) {
-                    toastr.error('方案编号不能为空');
+                    toastr.error(planName + '编号不能为空');
                     $(this).val(change[val_name]);
                     return false;
                 }

Разница между файлами не показана из-за своего большого размера
+ 1569 - 0
app/public/js/config_dir.js


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

@@ -1118,7 +1118,7 @@ $(document).ready(function() {
         refreshTenderFileType() {
             const html= [];
             for (const tft of this.tenderFileType) {
-                html.push(`<option value="${tft.value}">${tft.text}</option>`);
+                html.push(`<option value="${tft.value}">${_.escape(tft.text)}</option>`);
             }
             $('#tf-type').html(html.join(''));
         }
@@ -1162,7 +1162,7 @@ $(document).ready(function() {
                 tender.change_plan.forEach(x => {
                     selects.push({ value: x.id, text: x.code })
                 });
-                this.tenderFileType.push({ value: 'change_plan', text: '变更方案', select: selects });
+                this.tenderFileType.push({ value: 'change_plan', text: planName, select: selects });
             }
             if (tender.change_apply && tender.change_apply.length > 0) {
                 const selects = [];
@@ -1220,7 +1220,7 @@ $(document).ready(function() {
                 const checked = f.checked ? "checked" : '';
                 html.push(`<td><input type="checkbox" name="rf-check" rfid="${f.rf_id}" ${checked}></td>`);
                 html.push(`<td>${f.filename}${f.fileext}</td>`);
-                html.push(`<td>${typeStr.join(' - ')}</td>`);
+                html.push(`<td>${_.escape(typeStr.join(' - '))}</td>`);
                 html.push('</tr>');
             }
             $('#rf-files').html(html.join(''));

Разница между файлами не показана из-за своего большого размера
+ 2560 - 0
app/public/js/filesjs.js


+ 41 - 19
app/public/js/global.js

@@ -101,24 +101,46 @@ $(function(){
         }
     });
 
-    $('.bg-nav > li > .nav-up-down').on('click', function() {
-        const nav = $(this).attr('id');
-        if ($(this).children('.child-up-down').hasClass('fa-angle-down')) {
-            $(this).children('.child-up-down').removeClass('fa-angle-down').addClass('fa-angle-right');
-            $('.bg-nav li[data-index="'+ nav + '"]').slideUp('fast');
-        } else {
-            $(this).children('.child-up-down').removeClass('fa-angle-right').addClass('fa-angle-down');
-            $('.bg-nav li[data-index="'+ nav + '"]').slideDown('fast');
-        }
-        setLocalCache('nav-' + nav, $(this).children('.child-up-down').hasClass('fa-angle-down') ? 1 : 0);
-    });
+    // 只保存一个展开项;没有记录时全部收起。
+    const navGroups = $('.bg-nav > li > .nav-up-down');
+    const expandedNavKey = 'nav-expanded-item';
+    function updateNavGroups(expandedNav, animate) {
+        navGroups.each(function() {
+            const nav = $(this).attr('id');
+            const expanded = nav === expandedNav;
+            $(this).attr('aria-expanded', expanded ? 'true' : 'false');
+            $(this).children('.child-up-down')
+                .toggleClass('fa-angle-down', expanded)
+                .toggleClass('fa-angle-right', !expanded);
+            const children = $('.bg-nav li[data-index="' + nav + '"]').stop(true, true);
+            if (animate) {
+                if (expanded) {
+                    children.slideDown('fast');
+                } else {
+                    children.slideUp('fast');
+                }
+            } else {
+                children.toggle(expanded);
+            }
+        });
+    }
 
-    // 侧栏菜单状态判断展开收起
-    $('.bg-nav > li > .nav-up-down').each(function() {
+    let expandedNav = null;
+    try {
+        expandedNav = getLocalCache(expandedNavKey);
+    } catch (err) {
+        // 浏览器禁用本地存储时仍可正常展开、收起。
+    }
+    updateNavGroups(expandedNav);
+
+    navGroups.on('click', function() {
         const nav = $(this).attr('id');
-        if (getLocalCache('nav-' + nav) == 0) {
-            $(this).children('.child-up-down').removeClass('fa-angle-down').addClass('fa-angle-right');
-            $('.bg-nav li[data-index="'+ nav + '"]').slideUp(0);
+        expandedNav = $(this).attr('aria-expanded') === 'true' ? '' : nav;
+        updateNavGroups(expandedNav, true);
+        try {
+            setLocalCache(expandedNavKey, expandedNav);
+        } catch (err) {
+            // 本地存储不可用时仅保留本页状态。
         }
     });
 
@@ -294,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",
@@ -323,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);
                 }
@@ -331,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();
             }

+ 162 - 6
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});
@@ -3355,17 +3377,17 @@ $(document).ready(function() {
                 });
             }
         },
-        insertPos: function (sheet) {
+        insertPos: function (sheet, count = 1) {
             const selection = sheet.getSelections();
             const sortData = sheet.zh_data;
             const row = selection[0].row;
             const node = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
             const select = SpreadJsObj.getSelectObject(sheet);
             const porder = select ? select.porder : sortData.length + 1;
-            const data = {
-                updateType: 'insert',
-                updateData: [ { lid: node.id, tid: tender.id, porder } ],
-            };
+            const data = { updateType: 'insert', updateData: [], };
+            for (let i = 0; i < count; i++) {
+                data.updateData.push({ lid: node.id, tid: tender.id, porder: porder + i });
+            }
             postData('/tender/' + getTenderId() + '/pos/update', data, function (result) {
                 const updateRst = pos.updateDatas(result.pos);
                 posOperationObj.loadCurPosData();
@@ -3642,6 +3664,28 @@ $(document).ready(function() {
                     return !readOnly;
                 }
             },
+            'batchInsert': {
+                name: '批量插入',
+                type: 'batchInsert',
+                value: '2',
+                icon: 'fa-plus',
+                disabled: function (key, opt) {
+                    const node = SpreadJsObj.getSelectObject(ledgerSpread.getActiveSheet());
+                    return node && node.children && node.children.length > 0;
+                },
+                batchInsert: function (obj, root) {
+                    if (_.toNumber(obj.value) > _.toNumber(obj.max)) {
+                        obj.value = obj.max;
+                        toastr.warning('批量插入不可多于' + obj.max);
+                    } else if(_.toNumber(obj.value) < _.toNumber(obj.min)) {
+                        obj.value = obj.min;
+                        toastr.warning('批量插入不可少于' + obj.min);
+                    } else {
+                        posOperationObj.insertPos(posSheet,  parseInt(obj.value));
+                        root.$menu.trigger('contextmenu:hide');
+                    }
+                },
+            },
             'delete': {
                 name: '删除',
                 icon: 'fa-remove',
@@ -6207,7 +6251,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 +6266,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 +6328,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 +6353,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 +6395,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 +6622,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 = '';

+ 5 - 6
app/public/js/material_exponent.js

@@ -14,7 +14,6 @@ function getPasteHint (str, row = '') {
 }
 $(document).ready(() => {
     autoFlashHeight();
-    const canAdminEditHistoryRisk = isAdmin && materialOrder < materialHighOrder;
     const materialExponentSpread = SpreadJsObj.createNewSpread($('#material-exponent-spread')[0]);
     const materialExponentSpreadSetting = {
         cols: [
@@ -39,7 +38,7 @@ $(document).ready(() => {
         defaultRowHeight: 21,
         headerFont: '12px 微软雅黑',
         font: '12px 微软雅黑',
-        readOnly: readOnly && !canAdminEditHistoryRisk,
+        readOnly: readOnly && !canAdminEditLatestRisk,
     };
     const materialExponentBase = {
         isUsed: function (data) {
@@ -94,7 +93,7 @@ $(document).ready(() => {
                 return !(!readOnly && data.type === 2);
             },
             risk: function (data) {
-                return !(data.type === 2 && (!readOnly || canAdminEditHistoryRisk));
+                return !(data.type === 2 && ((!readOnly && data.mid === materialID) || canAdminEditLatestRisk));
             },
             isConstant: function (data) {
                 // return !(!readOnly && materialExponentBase.isConstant(data));
@@ -125,7 +124,7 @@ $(document).ready(() => {
     };
     SpreadJsObj.initSheet(stageTjSpread.getActiveSheet(), stageTjSpreadSetting);
 
-    const needUpdateArray = ['weight_num', 'm_up_risk', 'm_down_risk', 'm_price', 'effective_price', 'remark'];
+    const needUpdateArray = ['weight_num', 'm_price', 'effective_price', 'remark'];
 
     const materialExponentSpreadObj = {
         getMaterialExponentData: function (msid = null, mnid = null) {
@@ -443,7 +442,7 @@ $(document).ready(() => {
             }
         },
         deletePress: function (sheet) {
-            if (!sheet.zh_setting || (readOnly && !canAdminEditHistoryRisk) || sheet.zh_setting.readOnly) return;
+            if (!sheet.zh_setting || (readOnly && !canAdminEditLatestRisk) || sheet.zh_setting.readOnly) return;
             // materialExponentBase.isUsed(select)
             if (sheet.zh_setting && sheet.zh_data) {
                 const sel = sheet.getSelections()[0];
@@ -836,7 +835,7 @@ $(document).ready(() => {
         $('#calc_basic_select').html(html);
     }
 
-    if (!readOnly || canAdminEditHistoryRisk) {
+    if (!readOnly || canAdminEditLatestRisk) {
         materialExponentSpread.bind(spreadNS.Events.EditEnded, materialExponentSpreadObj.editEnded);
         materialExponentSpread.bind(spreadNS.Events.ClipboardPasted, materialExponentSpreadObj.clipboardPasted);
         if (!editForAudit) SpreadJsObj.addDeleteBind(materialExponentSpread, materialExponentSpreadObj.deletePress);

+ 2 - 2
app/public/js/phase_pay_detail.js

@@ -952,7 +952,7 @@ $(document).ready(() => {
                 });
             },
             importMaterial: function(node, tp, memo) {
-                const data = { postType: 'update', postData: { id: node.id, tp, postil: memo } };
+                const data = { postType: 'update', postData: { id: node.id, tp, postil: memo, expr: '' } };
                 postData('update', data, function (result) {
                     if (result.reload) {
                         payEvent.reloadPays(result.reload);
@@ -1189,7 +1189,7 @@ $(document).ready(() => {
                         name: '导入材差数据',
                         visible: function(key, opt) {
                             const select = SpreadJsObj.getSelectObject(sheet);
-                            return (!select.children || select.children.length ===0) && !readOnly;
+                            return (!select.children || select.children.length ===0) && !readOnly && !payUtils.check.isFixed(select);
                         },
                         callback: function(key, opt) {
                             const select = SpreadJsObj.getSelectObject(sheet);

Разница между файлами не показана из-за своего большого размера
+ 139 - 0
app/public/js/project_screen_guangyuan.js


Разница между файлами не показана из-за своего большого размера
+ 106 - 0
app/public/js/project_screen_guangyuan_building.js


Разница между файлами не показана из-за своего большого размера
+ 124 - 0
app/public/js/project_screen_guangyuan_finished.js


Разница между файлами не показана из-за своего большого размера
+ 169 - 0
app/public/js/project_screen_guangyuan_planned.js


+ 275 - 0
app/public/js/project_screen_setting.js

@@ -0,0 +1,275 @@
+/* global $, postData, toastr, window */
+'use strict';
+
+$(function() {
+    const pageData = window.projectScreenPageData || { screens: [], templates: [] };
+    let screens = pageData.screens || [];
+    const templates = pageData.templates || [];
+    let selectedId = screens.length ? String((screens.find(x => parseInt(x.is_default) === 1) || screens[0]).id) : '';
+    let selectedCodes = [];
+    let permissionPage = 1;
+    const permissionPageSize = 50;
+    let permissionChanges = {};
+    let permissionOriginal = {};
+
+    const escapeHtml = function(value) {
+        return $('<div>').text(value === undefined || value === null ? '' : value).html();
+    };
+
+    const selectedScreen = function() {
+        return screens.find(x => String(x.id) === selectedId);
+    };
+
+    const postProjectScreen = function(type, data, successCallback, errorCallback) {
+        postData(
+            '/setting/module/projectscreen',
+            $.extend({ type }, data),
+            successCallback,
+            errorCallback
+        );
+    };
+
+    const renderScreens = function() {
+        if (selectedId && !screens.some(x => String(x.id) === selectedId)) {
+            selectedId = screens.length ? String(screens[0].id) : '';
+        }
+        if (!selectedId && screens.length) {
+            selectedId = String((screens.find(x => parseInt(x.is_default) === 1) || screens[0]).id);
+        }
+        const html = [];
+        screens.forEach(screen => {
+            const active = String(screen.id) === selectedId ? ' active' : '';
+            html.push(`<div class="list-group-item list-group-item-action d-flex align-items-center project-screen-list-item${active}" data-id="${screen.id}">`);
+            html.push(`<span>${escapeHtml(screen.name)}</span>`);
+            if (parseInt(screen.is_default) === 1) html.push('<span class="ml-auto text-primary">默认</span>');
+            html.push('</div>');
+        });
+        $('#project-screen-list').html(html.join(''));
+
+        const screen = selectedScreen();
+        if (!screen) {
+            $('#project-screen-selected-name').text('大屏预览');
+            $('#project-screen-default, #project-screen-delete, #project-screen-preview').hide();
+            $('#project-screen-empty').show();
+            return;
+        }
+        $('#project-screen-selected-name').text(screen.name);
+        $('#project-screen-delete, #project-screen-preview').show();
+        $('#project-screen-empty').hide();
+        $('#project-screen-default').toggle(parseInt(screen.is_default) !== 1);
+        const previewClass = screen.template && screen.template.previewClass ? screen.template.previewClass : '';
+        const preview = $('#project-screen-preview');
+        preview.removeClass(function(index, className) {
+            return (className.match(/project-screen-preview-\S+/g) || []).join(' ');
+        });
+        if (previewClass) preview.addClass(previewClass);
+    };
+
+    const renderTemplates = function() {
+        const html = [];
+        templates.forEach(template => {
+            const added = screens.some(screen => screen.template_code === template.code);
+            const selected = selectedCodes.indexOf(template.code) >= 0;
+            const cardClass = selected ? ' border-primary' : '';
+            const statusClass = selected ? 'badge-primary' : 'badge-secondary';
+            const statusText = added ? '已添加' : (selected ? '已选择' : '可选择');
+            html.push('<div class="col-6 mb-3">');
+            html.push(`<div class="card h-100 project-screen-template-item${added ? ' added' : ''}${cardClass}" data-code="${escapeHtml(template.code)}">`);
+            html.push(`<div class="project-screen-template-preview ${escapeHtml(template.previewClass)}"><div></div></div>`);
+            html.push('<div class="card-body py-2 d-flex align-items-center">');
+            html.push(`<strong>${escapeHtml(template.name)}</strong><span class="badge ${statusClass} ml-auto">${statusText}</span>`);
+            html.push('</div></div></div>');
+        });
+        $('#project-screen-template-grid').html(html.join(''));
+        $('#project-screen-selected-count').text(`已选择 ${selectedCodes.length} 个`);
+        $('#project-screen-add').text(selectedCodes.length ? `添加(${selectedCodes.length})` : '添加');
+    };
+
+    const openDeleteModal = function(id) {
+        selectedId = id || selectedId;
+        renderScreens();
+        const screen = selectedScreen();
+        if (!screen) return;
+        const remaining = screens.filter(x => x.id !== screen.id);
+        let hint = '删除后该大屏配置会被移除,已有业务数据不会受到影响。';
+        if (parseInt(screen.is_default) === 1 && remaining.length) {
+            hint += ` “${remaining[0].name}”将自动成为默认大屏。`;
+        } else if (parseInt(screen.is_default) === 1) {
+            hint += ' 这是最后一个大屏,删除后左侧项目大屏入口将隐藏。';
+        }
+        $('#project-screen-delete-name').text(screen.name);
+        $('#project-screen-delete-hint').text(hint);
+        $('#project-screen-delete-modal').modal('show');
+    };
+
+    $('#project-screen-status').change(function() {
+        const input = this;
+        const enabled = input.checked ? 1 : 0;
+        input.disabled = true;
+        postProjectScreen('status', { enabled }, function() {
+            $('#project-screen-module-status')
+                .toggleClass('text-success', enabled === 1)
+                .text(enabled ? '已开启' : '');
+            input.disabled = false;
+        }, function() {
+            input.checked = !input.checked;
+            input.disabled = false;
+        });
+    });
+
+    $('.project-screen-tab').click(function(event) {
+        event.preventDefault();
+        const tab = $(this).attr('data-tab');
+        $('.project-screen-tab').removeClass('active');
+        $(this).addClass('active');
+        if (tab === 'permission') {
+            $('#project-screen-config').hide();
+            $('#project-screen-permission').show();
+            loadPermissions(1);
+        } else {
+            $('#project-screen-permission').hide();
+            $('#project-screen-config').show();
+        }
+    });
+
+    $('#project-screen-select').click(function() {
+        selectedCodes = [];
+        renderTemplates();
+        $('#project-screen-select-modal').modal('show');
+    });
+
+    $('#project-screen-template-grid').on('click', '.project-screen-template-item:not(.added)', function() {
+        const code = $(this).attr('data-code');
+        const index = selectedCodes.indexOf(code);
+        if (index >= 0) selectedCodes.splice(index, 1);
+        else selectedCodes.push(code);
+        renderTemplates();
+    });
+
+    $('#project-screen-add').click(function() {
+        if (!selectedCodes.length) {
+            toastr.warning('请至少选择一个大屏');
+            return;
+        }
+        postProjectScreen('add', { codes: selectedCodes }, function(data) {
+            screens = data.screens;
+            const firstAdded = screens.find(x => selectedCodes[0] === x.template_code);
+            selectedId = firstAdded ? String(firstAdded.id) : (screens.length ? String(screens[0].id) : '');
+            renderScreens();
+            $('#project-screen-select-modal').modal('hide');
+            toastr.success(`已添加 ${selectedCodes.length} 个大屏`);
+        });
+    });
+
+    $('#project-screen-list').on('click', '.project-screen-list-item', function() {
+        selectedId = $(this).attr('data-id');
+        renderScreens();
+    });
+
+    $('#project-screen-delete').click(function() { openDeleteModal(selectedId); });
+
+    $('#project-screen-delete-confirm').click(function() {
+        const screen = selectedScreen();
+        if (!screen) return;
+        postProjectScreen('delete', { id: screen.id }, function(data) {
+            screens = data.screens;
+            selectedId = screens.length ? String(screens[0].id) : '';
+            renderScreens();
+            $('#project-screen-delete-modal').modal('hide');
+            toastr.success('大屏删除成功');
+        });
+    });
+
+    $('#project-screen-default').click(function() {
+        const screen = selectedScreen();
+        if (!screen) return;
+        postProjectScreen('default', { id: screen.id }, function(data) {
+            screens = data.screens;
+            renderScreens();
+            toastr.success('默认大屏设置成功');
+        });
+    });
+
+    const renderPermissionRows = function(result) {
+        const html = [];
+        permissionOriginal = {};
+        result.list.forEach(account => {
+            const disabled = account.is_admin ? 'disabled' : '';
+            const authorized = !!(account.is_admin || account.authorized);
+            const checked = authorized ? 'checked' : '';
+            const adminIcon = account.is_admin ? ' <span class="ml-1" title="管理员"><i class="fa fa-user-circle-o"></i></span>' : '';
+            permissionOriginal[account.id] = authorized;
+            html.push(`<tr data-id="${account.id}"><td>${escapeHtml(account.account)}${adminIcon}</td><td>${escapeHtml(account.name)}</td><td>${escapeHtml(account.company)}</td><td>${escapeHtml(account.role)}</td>`);
+            html.push(`<td><input type="checkbox" class="project-screen-permission-check" ${checked} ${disabled}></td></tr>`);
+        });
+        $('#project-screen-permission-body').html(html.join(''));
+        $('#project-screen-permission-empty').toggle(result.list.length === 0);
+        $('#project-screen-permission-count').text(`已授权 ${result.authorizedTotal} / 共 ${result.allTotal} 人`);
+        const totalPage = Math.max(Math.ceil(result.total / result.pageSize), 1);
+        $('#project-screen-page-info').text(`${result.page} / ${totalPage}`);
+        $('#project-screen-prev').prop('disabled', result.page <= 1);
+        $('#project-screen-next').prop('disabled', result.page >= totalPage);
+        $('#project-screen-permission-all').prop('checked', false);
+    };
+
+    const updatePermissionSaveButton = function() {
+        $('#project-screen-permission-save').toggle(Object.keys(permissionChanges).length > 0);
+    };
+
+    const setPermissionChange = function(id, authorized) {
+        if (permissionOriginal[id] === authorized) delete permissionChanges[id];
+        else permissionChanges[id] = { id, authorized };
+    };
+
+    function loadPermissions(page) {
+        permissionPage = Math.max(page || 1, 1);
+        postProjectScreen('permissionload', {
+            page: permissionPage,
+            pageSize: permissionPageSize,
+            keyword: $('#project-screen-keyword').val(),
+            authorized: $('#project-screen-authorized').prop('checked') ? 1 : 0,
+        }, function(result) {
+            permissionPage = result.page;
+            permissionChanges = {};
+            updatePermissionSaveButton();
+            renderPermissionRows(result);
+        });
+    }
+
+    $('#project-screen-permission-body').on('change', '.project-screen-permission-check', function() {
+        const id = parseInt($(this).closest('tr').attr('data-id'));
+        setPermissionChange(id, this.checked);
+        updatePermissionSaveButton();
+    });
+
+    $('#project-screen-permission-all').change(function() {
+        const checked = this.checked;
+        $('#project-screen-permission-body .project-screen-permission-check:not(:disabled)').each(function() {
+            this.checked = checked;
+            const id = parseInt($(this).closest('tr').attr('data-id'));
+            setPermissionChange(id, checked);
+        });
+        updatePermissionSaveButton();
+    });
+
+    $('#project-screen-permission-save').click(function() {
+        const changes = Object.values(permissionChanges);
+        if (!changes.length) return;
+        postProjectScreen('permissionsave', { changes }, function() {
+            permissionChanges = {};
+            updatePermissionSaveButton();
+            toastr.success('项目大屏权限保存成功');
+            loadPermissions(permissionPage);
+        });
+    });
+
+    $('#project-screen-search').click(function() { loadPermissions(1); });
+    $('#project-screen-keyword').keydown(function(event) {
+        if (event.keyCode === 13) loadPermissions(1);
+    });
+    $('#project-screen-authorized').change(function() { loadPermissions(1); });
+    $('#project-screen-prev').click(function() { loadPermissions(permissionPage - 1); });
+    $('#project-screen-next').click(function() { loadPermissions(permissionPage + 1); });
+
+    renderScreens();
+});

+ 27 - 2
app/public/js/revise.js

@@ -1909,7 +1909,7 @@ $(document).ready(() => {
                 });
             }
         },
-        insertPos: function (sheet) {
+        insertPos: function (sheet, count = 1) {
             const selection = sheet.getSelections();
             const sortData = sheet.zh_data;
             const row = selection[0].row;
@@ -1923,8 +1923,11 @@ $(document).ready(() => {
             const data = {
                 postType: 'pos',
                 posPostType: 'insert',
-                postData: [ { lid: node.id, porder } ],
+                postData: [],
             };
+            for (let i = 0; i < count; i++) {
+                data.postData.push({ lid: node.id, porder: porder + i });
+            }
             postData(window.location.pathname + '/update', data, function (result) {
                 const updateRst = pos.updateDatas(result.pos);
                 posSpreadObj.loadCurPosData();
@@ -2168,6 +2171,28 @@ $(document).ready(() => {
                         posSpreadObj.insertPos(posSheet);
                     }
                 },
+                'batchInsert': {
+                    name: '批量插入',
+                    type: 'batchInsert',
+                    value: '2',
+                    icon: 'fa-plus',
+                    disabled: function (key, opt) {
+                        const node = SpreadJsObj.getSelectObject(billsSheet);
+                        return node && node.children && node.children.length > 0 && node.settle_status !== settleStatus.finish;
+                    },
+                    batchInsert: function (obj, root) {
+                        if (_.toNumber(obj.value) > _.toNumber(obj.max)) {
+                            obj.value = obj.max;
+                            toastr.warning('批量插入不可多于' + obj.max);
+                        } else if(_.toNumber(obj.value) < _.toNumber(obj.min)) {
+                            obj.value = obj.min;
+                            toastr.warning('批量插入不可少于' + obj.min);
+                        } else {
+                            posSpreadObj.insertPos(posSheet, parseInt(obj.value));
+                            root.$menu.trigger('contextmenu:hide');
+                        }
+                    },
+                },
                 'delete': {
                     name: '删除',
                     icon: 'fa-remove',

+ 2 - 2
app/public/js/revise_price.js

@@ -251,7 +251,7 @@ $(document).ready(() => {
     const priceOprObj = {
         addRevisePrice(data) {
             const op = revisePrice.data.find(x => {
-                return x.b_code === data.b_code && x.name === x.name && x.unit === x.unit && checkZero(ZhCalc.sub(x.org_price, data.unit_price)) && (!x.rela_lid || !x.rela_cid);
+                return x.b_code === data.b_code && x.name === data.name && x.unit === data.unit && checkZero(ZhCalc.sub(x.org_price, data.unit_price)) && (!x.rela_lid || !x.rela_cid);
             });
             if (op) {
                 toastr.warning('已存在该单价调整');
@@ -273,7 +273,7 @@ $(document).ready(() => {
             const add = [];
             for (const data of datas) {
                 const op = revisePrice.data.find(x => {
-                    return x.b_code === data.b_code && x.name === x.name && x.unit === x.unit && checkZero(ZhCalc.sub(x.org_price, data.unit_price)) && (!x.rela_lid || !x.rela_cid);
+                    return x.b_code === data.b_code && x.name === data.name && x.unit === data.unit && checkZero(ZhCalc.sub(x.org_price, data.unit_price)) && (!x.rela_lid || !x.rela_cid);
                 });
                 if (op) continue;
                 add.push({ b_code: data.b_code, name: data.name, unit: data.unit, unit_price: data.unit_price });

+ 210 - 25
app/public/js/safe_inspection_information.js

@@ -10,6 +10,7 @@
 
 $(document).ready(function () {
     autoFlashHeight();
+    if (inspection.flow_version === 1) bindSafeInspectionFlow();
 
     $.subMenu({
         menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
@@ -37,7 +38,8 @@ $(document).ready(function () {
         };
     }
 
-    $('textarea[id]').each(function () {
+    // 弹窗隐藏时高度为 0,处理意见框使用默认高度,不参与高度缓存。
+    $('textarea[id]').not('#safe-flow-opinion').each(function () {
         var $el = $(this);
         var el = $el[0];
         var id = $el.attr('id');
@@ -100,13 +102,13 @@ $(document).ready(function () {
         if (type === 'hide') {
             $(this).parent().find(`div[data-toggleid="${idx}"]`).show(() => {
                 $(this).children().find('i').removeClass('fa-plus-square').addClass('fa-minus-square-o')
-                $(this).find('.acc-btn').attr('data-type', 'show')
+                $(this).find('.acc-btn').attr('data-type', 'show').attr('aria-expanded', 'true')
 
             })
         } else {
             $(this).parent().find(`div[data-toggleid="${idx}"]`).hide(() => {
                 $(this).children().find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square')
-                $(this).find('.acc-btn').attr('data-type', 'hide')
+                $(this).find('.acc-btn').attr('data-type', 'hide').attr('aria-expanded', 'false')
             })
         }
         return false
@@ -118,7 +120,9 @@ $(document).ready(function () {
         oldSearchVal = e.target.value
         timer && clearTimeout(timer)
         timer = setTimeout(() => {
-            const newVal = $('.gr-search').val()
+            const $search = $(e.target);
+            const $list = $search.closest('.dropdown-menu').find('.book-list');
+            const newVal = $search.val()
             let html = ''
             if (newVal && newVal === oldSearchVal) {
                 accountList.filter(item => item && inspection.uid !== item.id && (item.name.indexOf(newVal) !== -1 || (item.mobile && item.mobile.indexOf(newVal) !== -1))).forEach(item => {
@@ -128,10 +132,10 @@ $(document).ready(function () {
                         <span class="text-muted">${item.role || ''}</span>
                     </dd>`
                 })
-                $('.book-list').empty()
-                $('.book-list').append(html)
+                $list.empty()
+                $list.append(html)
             } else {
-                if (!$('.acc-btn').length) {
+                if (!$list.find('.acc-btn').length) {
                     accountGroup.forEach((group, idx) => {
                         if (!group) return
                         html += `<dt><a href="javascript: void(0);" class="acc-btn" data-groupid="${idx}" data-type="hide"><i class="fa fa-plus-square"></i>
@@ -148,8 +152,8 @@ $(document).ready(function () {
                         });
                         html += '</div>'
                     })
-                    $('.book-list').empty()
-                    $('.book-list').append(html)
+                    $list.empty()
+                    $list.append(html)
                 }
             }
         }, 400);
@@ -186,7 +190,7 @@ $(document).ready(function () {
             }
         });
 
-        $("#check_table input").on('change', function (e) {
+        $("#check_table input[data-key]").on('change', function (e) {
             const value = $(this).val().trim();
             const key = $(this).data('key');
             if (key === 'check_date') {
@@ -242,7 +246,7 @@ $(document).ready(function () {
                 id: inspection.id,
             };
             data[field] = value;
-            postData(`${preUrl}/save`, {type: 'update-field', update: data}, function (result) {
+            postSafeInspection(`${preUrl}/save`, {type: 'update-field', update: data}, function (result) {
                 inspection[field] = value;
                 if (field === 'check_date') {
                     checkDate.selectDate(inspection.check_date ? new Date(inspection.check_date) : new Date());
@@ -261,7 +265,7 @@ $(document).ready(function () {
         $('#shenpi_select dl').on('click', 'dd', function () {
             const id = parseInt($(this).data('id'))
             if (id !== 0) {
-                postData(preUrl + '/save', {type: 'add-audit', auditorId: id}, (datas) => {
+                postSafeInspection(preUrl + '/save', {type: 'add-audit', auditorId: id}, (datas) => {
                     // <p class="m-0 ml-2"><small class="text-muted">中交第一公路工程局有限公司国道311线满别公路施工一分部</small></p>
                     const html = [];
                     // 如果是重新上报,添加到重新上报列表中
@@ -318,7 +322,7 @@ $(document).ready(function () {
                 type: 'del-audit',
                 auditorId: parseInt(li.attr('auditorId')),
             };
-            postData(preUrl + '/save', data, (result) => {
+            postSafeInspection(preUrl + '/save', data, (result) => {
                 li.remove();
                 for (const rst of result) {
                     const aLi = $('li[auditorid=' + rst.aid + ']');
@@ -343,7 +347,7 @@ $(document).ready(function () {
                 toastr.error('请正确输入“确认删除本次巡检”');
                 return;
             }
-            postData(preUrl + '/save', {type: 'del-inspection' }, function (result) {
+            postSafeInspection(preUrl + '/save', {type: 'del-inspection' }, function (result) {
                 const is_wap = window.location.pathname.indexOf('/wap/') !== -1;
                 let link = `/sp/${spid}/safe/tender/${tender_id}/inspection`;
                 if (is_wap) {
@@ -386,11 +390,11 @@ $(document).ready(function () {
 
         $('#start-btn').click(function () {
             $('#start-btn').prop('disabled', true);
-            postData(preUrl + '/save', { type: 'start-inspection' }, function (result) {
+            postSafeInspection(preUrl + '/save', { type: 'start-inspection' }, function (result) {
                 window.location.reload();
             });
         });
-    } else if (inspection.shenpiPower) {
+    } else if (inspection.shenpiPower && inspection.flow_version !== 1) {
         // 添加到审批流程中
         $('dl').on('click', 'dd', function () {
             const id = parseInt($(this).data('id'))
@@ -438,7 +442,7 @@ $(document).ready(function () {
                 opinion,
                 rectification_uid: $('#rectification-uid').val(),
             }
-            postData(preUrl + '/save', data, function (result) {
+            postSafeInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -464,7 +468,7 @@ $(document).ready(function () {
                 checkType: parseInt(inlineRadio1 ? inlineRadio1 : inlineRadio2),
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postSafeInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -480,11 +484,11 @@ $(document).ready(function () {
                 checkType: auditConst.status.checkStop,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postSafeInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
-    } else if (inspection.rectificationPower) {
+    } else if (inspection.rectificationPower && inspection.flow_version !== 1) {
         $('#judge-success-btn').click(function () {
             const flag = !(inspection.rectification_item && inspection.rectification_date);
             if (flag) {
@@ -505,7 +509,7 @@ $(document).ready(function () {
                 checkType: auditConst.status.checked,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postSafeInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -521,7 +525,7 @@ $(document).ready(function () {
                 checkType: auditConst.status.checkNoPre,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postSafeInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -585,7 +589,7 @@ $(document).ready(function () {
             };
             data[field] = value;
             console.log(data);
-            postData(`${preUrl}/save`, {type: 'update-field', update: data}, function (result) {
+            postSafeInspection(`${preUrl}/save`, {type: 'update-field', update: data}, function (result) {
                 inspection[field] = value;
                 if (field === 'rectification_date') {
                     rectificationDate.selectDate(inspection.rectification_date ? new Date(inspection.rectification_date) : '');
@@ -638,13 +642,14 @@ $(document).ready(function () {
                     showDel = true
                 }
             }
+            if (inspection.flow_version === 1 && !inspection.canUpload) showDel = false;
             return {...file, showDel}
         })
         const is_wap = window.location.pathname.indexOf('/wap/') !== -1;
         if (is_wap) {
             let html = `<div style="display: flex;">
             <b>附件</b>`;
-                if (inspection.filePermission) {
+                if (inspection.flow_version === 1 ? inspection.canUpload : inspection.filePermission) {
                     html += `<span style="margin-left: auto;"><a href="#addfujian" data-toggle="modal" class="btn btn-sm btn-light text-primary" data-placement="bottom" title="" data-original-title="上传附件"><i class="fa fa-cloud-upload" aria-hidden="true"></i> 上传附件</a></span>`;
                 }
             html += `</div><div>`;
@@ -671,7 +676,7 @@ $(document).ready(function () {
             html += '</div>';
             $('#file-content').append(html);
         } else {
-            let html = inspection.filePermission ? `<tr><td colspan="5"><a href="#addfujian" data-toggle="modal" class="btn btn-sm btn-light text-primary" data-placement="bottom" title="" data-original-title="上传附件"><i class="fa fa-cloud-upload" aria-hidden="true"></i> 上传附件</a></td></tr>` : '';
+            let html = (inspection.flow_version === 1 ? inspection.canUpload : inspection.filePermission) ? `<tr><td colspan="5"><a href="#addfujian" data-toggle="modal" class="btn btn-sm btn-light text-primary" data-placement="bottom" title="" data-original-title="上传附件"><i class="fa fa-cloud-upload" aria-hidden="true"></i> 上传附件</a></td></tr>` : '';
             newFiles.forEach((file, idx) => {
                 if (file.showDel) {
                     html += `<tr><td>${idx + 1}</td><td><a href="${file.filepath}" target="_blank">${file.filename}</a></td><td>${file.username}</td><td>${moment(file.upload_time).format('YYYY-MM-DD HH:mm:ss')}</td><td><a href="${preUrl}/file/${file.id}/download" class="mr-2"><i class="fa fa-download"></i></a><a href="javascript: void(0);" class="text-danger file-del" data-id="${file.id}"><i class="fa fa-remove"></i></a></td></tr>`
@@ -716,3 +721,183 @@ function validateFiles(files) {
     })
 }
 
+// 表单自动保存和流程提交按顺序执行,避免提交先于最后一次字段保存。
+let safeInspectionSaveQueue = Promise.resolve(true);
+let safeInspectionSaving = 0;
+function postSafeInspection(url, data, success, failure) {
+    if (inspection.flow_version !== 1) return postData(url, data, success, failure);
+    if (!safeInspectionSaving) safeInspectionSaveQueue = Promise.resolve(true);
+    safeInspectionSaving++;
+    const payload = Object.assign({}, data, { times: inspection.times, flow_stage: inspection.flow_stage, stage_round: inspection.stage_round });
+    const previous = safeInspectionSaveQueue;
+    safeInspectionSaveQueue = previous.then(function (saved) {
+        if (!saved && data.type !== 'update-field') {
+            toastr.error('上一次保存失败,请检查内容并重试');
+            if (failure) failure();
+            return false;
+        }
+        return new Promise(function (resolve) {
+            postData(url, payload, function (result) {
+                resolve(true);
+                if (success) success(result);
+            }, function () {
+                resolve(false);
+                if (failure) failure.apply(null, arguments);
+            });
+        });
+    }).then(function (saved) {
+        safeInspectionSaving--;
+        return saved;
+    });
+}
+
+function bindSafeInspectionFlow() {
+    const $flow = $('.safe-inspection-flow');
+    const $radios = $('[name="safe-need-rectification"]');
+    let needRectification = safeInspectionNeedDraft();
+    showSafeInspectionNeed(needRectification);
+    let pending = null;
+    let busy = false;
+    function request(data, $button, reload) {
+        if (busy) return;
+        busy = true;
+        $button.prop('disabled', true);
+        postSafeInspection(preUrl + '/save', data, function () {
+            busy = false;
+            $button.prop('disabled', false);
+            if (reload) {
+                if (!inspection.readOnly && ['flow-add-audit', 'flow-del-audit'].includes(data.type)) safeInspectionNeedDraft(needRectification);
+                window.location.reload();
+            } else toastr.success('保存成功');
+        }, function () {
+            busy = false;
+            $button.prop('disabled', false);
+        });
+    }
+    function rectificationData(auditId) {
+        const $form = $('.flow-rectification[data-id="' + Number(auditId) + '"]');
+        if ($form.length !== 1) {
+            toastr.error('未找到当前整改任务,请刷新页面');
+            return null;
+        }
+        return {
+            auditId: Number($form.data('id')),
+            rectification_item: $form.find('.flow-rectification-item').val().trim(),
+            rectification_date: $form.find('.flow-rectification-date').val(),
+            opinion: $form.find('.flow-rectification-opinion').val().trim(),
+        };
+    }
+    $flow.on('click', '.flow-picker .dropdown-menu', function (event) { event.stopPropagation(); });
+    $flow.on('input propertychange', '.flow-user-search', function () {
+        const $picker = $(this).closest('.flow-picker');
+        const keyword = $(this).val().trim().toLowerCase();
+        let matches = 0;
+        $picker.find('.flow-user-group').each(function () {
+            let groupMatches = 0;
+            $(this).find('.flow-add-user').each(function () {
+                const visible = !keyword || ($(this).attr('data-name') || '').toLowerCase().indexOf(keyword) >= 0 || ($(this).attr('data-mobile') || '').indexOf(keyword) >= 0;
+                $(this).toggleClass('d-none', !visible);
+                if (visible) groupMatches++;
+            });
+            $(this).toggleClass('d-none', !!keyword && !groupMatches);
+            $(this).find('dt').toggle(!keyword);
+            $(this).find('.dd-content').toggle(!!keyword);
+            $(this).find('.acc-btn').attr('data-type', 'hide').attr('aria-expanded', 'false');
+            $(this).find('.acc-btn i').removeClass('fa-minus-square-o').addClass('fa-plus-square');
+            matches += groupMatches;
+        });
+        $picker.find('.flow-no-user').toggleClass('d-none', !keyword || matches > 0);
+    });
+    $flow.on('click', '.flow-add-user', function () {
+        if ($(this).attr('aria-disabled') === 'true') return;
+        request({ type: 'flow-add-audit', target_stage: Number($(this).closest('.flow-picker').data('stage')), auditorId: Number($(this).data('id')) }, $(this), true);
+    });
+    $flow.on('keydown', '.flow-add-user', function (event) {
+        if (event.key === 'Enter' || event.key === ' ') {
+            event.preventDefault();
+            $(this).trigger('click');
+        }
+    });
+    $flow.on('click', '.flow-remove-user', function () {
+        request({ type: 'flow-del-audit', target_stage: Number($(this).data('stage')), auditorId: Number($(this).data('id')) }, $(this), true);
+    });
+    $radios.on('change', function () {
+        const value = Number($(this).val());
+        if (busy || inspection.readOnly || ![0, 1].includes(value)) {
+            showSafeInspectionNeed(needRectification);
+            return;
+        }
+        needRectification = value;
+        showSafeInspectionNeed(needRectification);
+    });
+    $('.flow-save-rectification').on('click', function () {
+        const data = rectificationData($(this).data('id'));
+        if (data) request(Object.assign({ type: 'flow-save-rectification' }, data), $(this), false);
+    });
+    $('.flow-action').on('click', function () {
+        if (busy) return;
+        const type = $(this).data('action');
+        pending = { type, auditId: Number($(this).data('id')), checkType: Number($(this).data('result')) };
+        let message = '确认提交上报,并按设置的顺序开始审批?';
+        if (type === 'start-inspection') {
+            pending = { type, need_rectification: needRectification };
+            message = needRectification ? '确认提交上报?上报通过后进入整改、批复流程。' : '确认提交上报?本次无整改、批复流程。';
+        } else if (type === 'flow-complete-rectification') {
+            const data = rectificationData($(this).data('id'));
+            if (!data) {
+                pending = null;
+                return;
+            }
+            Object.assign(pending, data);
+            if (!pending.rectification_item || !pending.rectification_date) {
+                pending = null;
+                toastr.error('请填写整改内容和整改日期');
+                return;
+            }
+            message = '确认本人整改完成?所有整改人完成后才会进入批复。';
+        } else if (type === 'flow-check') {
+            message = pending.checkType === 3 ? '确认本次审批通过?' : pending.checkType === 4 ? '确认关闭本次巡检?' : inspection.flow_stage === 3 ? '确认退回重新整改?所有整改人将同时开始新一轮整改。' : pending.checkType === 5 ? '确认退回上报人重新上报?' : '确认退回上一个上报审批节点?';
+        }
+        $('#safe-flow-title').text($(this).text());
+        $('#safe-flow-message').text(message);
+        $('#safe-flow-opinion').val('').css('height', '');
+        $('#safe-flow-opinion-group').toggle(type === 'flow-check');
+        $('#safe-flow-confirm').modal('show');
+    });
+    $('#safe-flow-submit').on('click', function () {
+        if (!pending || busy) return;
+        if (pending.type === 'flow-check') {
+            pending.opinion = $('#safe-flow-opinion').val().trim();
+            if (pending.checkType !== 3 && !pending.opinion) {
+                toastr.error('请填写退回或关闭原因');
+                return;
+            }
+        }
+        request(pending, $(this), true);
+    });
+}
+
+// 仅在添加、移除审批人引起的刷新之间暂存选择,读取后即清除。
+function safeInspectionNeedDraft(value) {
+    const current = inspection.need_rectification === 0 ? 0 : 1;
+    const key = 'safe-inspection-need:' + cur_uid + ':' + inspection.id + ':' + inspection.times + ':' + inspection.stage_round;
+    try {
+        if (value !== undefined) {
+            sessionStorage.setItem(key, JSON.stringify({ value, savedValue: current }));
+        } else {
+            const saved = sessionStorage.getItem(key);
+            sessionStorage.removeItem(key);
+            const draft = saved ? JSON.parse(saved) : null;
+            if (!inspection.readOnly && draft && draft.savedValue === current && [0, 1].includes(draft.value)) return draft.value;
+        }
+    } catch (error) {
+        console.warn('无法暂存是否整改选项', error);
+    }
+    return current;
+}
+
+function showSafeInspectionNeed(value) {
+    $('[name="safe-need-rectification"]').filter('[value="' + value + '"]').prop('checked', true);
+    $('.safe-inspection-flow [data-flow-stage="2"], .safe-inspection-flow [data-flow-stage="3"]').toggleClass('d-none', value === 0);
+    $('.safe-inspection-flow .flow-no-rectification').toggleClass('d-none', value !== 0);
+}

+ 80 - 0
app/public/js/safe_inspection_overview.js

@@ -0,0 +1,80 @@
+'use strict';
+
+$(document).ready(function () {
+    const $page = $('.safe-inspection-overview');
+    if (!$page.length) return;
+    const inspectionUrl = $page.attr('data-inspection-url');
+    const charts = [];
+    const resize = function () { charts.forEach(function (chart) { chart.resize(); }); };
+    $.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) {
+            $page.find('.panel-title').toggleClass('fluid', info.mini);
+            $('#sub-menu').toggleClass('panel-sidebar', !info.mini);
+            autoFlashHeight();
+            resize();
+        },
+    });
+    const pieElement = document.getElementById('safe-overview-pie');
+    if (pieElement && safeInspectionOverview.total > 0) {
+        const pie = echarts.init(pieElement);
+        pie.setOption({
+            color: ['#dc3545', '#f0ad00', '#28a745'],
+            tooltip: { trigger: 'item', formatter: '{b}:{c}({d}%)' },
+            legend: { top: 0, data: ['整改中', '审批中', '已完成'] },
+            graphic: [{
+                type: 'text', left: 'center', top: '48%', silent: true,
+                style: { text: safeInspectionOverview.total + '\n三类合计', textAlign: 'center', fontSize: 14, lineHeight: 22, fill: '#495057' },
+            }],
+            series: [{
+                type: 'pie', radius: ['42%', '66%'], center: ['50%', '55%'],
+                avoidLabelOverlap: true, minShowLabelAngle: 1,
+                label: { formatter: '{b}\n{c}({d}%)', fontSize: 12 },
+                data: safeInspectionOverview.statuses.map(function (item) {
+                    return { name: item.name, value: item.value, key: item.key, label: { show: item.value > 0 }, labelLine: { show: item.value > 0 } };
+                }),
+            }],
+        });
+        pie.on('click', function (event) {
+            if (!event.data) return;
+            if (event.data.key === 'checking') {
+                // 上报、批复分别使用现有列表入口,避免合并统计后跳转到单一阶段。
+                setTimeout(function () { $('#safe-overview-approval').dropdown('toggle').trigger('focus'); }, 0);
+            } else if (event.data.key === 'rectification') {
+                window.location.href = inspectionUrl + '/rectification';
+            } else if (event.data.key === 'completed') {
+                window.location.href = inspectionUrl + '?status=3';
+            }
+        });
+        charts.push(pie);
+    }
+    const trend = safeInspectionOverview.trend;
+    const trendChart = echarts.init(document.getElementById('safe-overview-trend'));
+    trendChart.setOption({
+        tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
+        legend: { data: ['首次上报数', '完成数', '需整改占比'] },
+        grid: { left: 15, right: 25, top: 65, bottom: 20, containLabel: true },
+        xAxis: { type: 'category', data: trend.months },
+        yAxis: [
+            { type: 'value', name: '数量(单)', min: 0, minInterval: 1 },
+            { type: 'value', name: '比例(%)', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
+        ],
+        series: [
+            { name: '首次上报数', type: 'bar', data: trend.submitted, itemStyle: { color: '#007bff' }, barMaxWidth: 24 },
+            { name: '完成数', type: 'bar', data: trend.completed, itemStyle: { color: '#28a745' }, barMaxWidth: 24 },
+            { name: '需整改占比', type: 'line', yAxisIndex: 1, data: trend.ratios, itemStyle: { color: '#f0ad00' }, symbolSize: 6 },
+        ],
+    });
+    charts.push(trendChart);
+    window.addEventListener('resize', resize);
+    const observer = window.ResizeObserver ? new window.ResizeObserver(resize) : null;
+    if (observer) observer.observe($page[0]);
+    window.addEventListener('pagehide', function (event) {
+        if (event.persisted) return;
+        window.removeEventListener('resize', resize);
+        if (observer) observer.disconnect();
+        charts.forEach(function (chart) { chart.dispose(); });
+    });
+});

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

@@ -13,7 +13,7 @@ const tenderListSpec = (function(){
             html.push(arr.indexOf(node) === arr.length - 1 ? '└' : '├');
             html.push('</span>');
             //html.push('<a href="/tender/' + node.id + '">', node[c.field], '</a>');
-            html.push(`<a href="/sp/${spid}/safe/tender/${node.id}/${isInspection ? 'inspection' : 'stage'}" name="name" style="min-width: 200px;word-break:break-all;" id="${node.id}">${node.name}</a>`);
+            html.push(`<a href="/sp/${spid}/safe/tender/${node.id}/${isInspection ? 'inspection/overview' : 'stage'}" name="name" style="min-width: 200px;word-break:break-all;" id="${node.id}">${node.name}</a>`);
         }
         html.push('</td>');
 

+ 1 - 1
app/public/js/shares/drag_tree.js

@@ -453,4 +453,4 @@ const createDragTree = function (setting) {
         }
     }
     return new DragTree(setting);
-};
+};

+ 1 - 1
app/public/js/shares/tender_select.js

@@ -125,7 +125,7 @@ const TenderSelect = function (setting) {
             SpreadJsObj.initSheet(this.tenderSheet, {
                 cols: [
                     {title: '选择', field: 'selected', hAlign: 1, width: 40, formatter: '@', cellType: 'checkbox'},
-                    {title: '名称', field: 'name', hAlign: 0, width: 180, formatter: '@', cellType: 'tree'},
+                    {title: '名称', field: 'name', hAlign: 0, width: 180, formatter: '@', cellType: 'tree', showTips: true},
                     {title: '期数', field: 'phase', hAlign: 1, width: 60, formatter: '@'},
                     {title: '审批状态', field: 'status', hAlign: 1, width: 60, formatter: '@'}
                 ],

+ 4 - 3
app/public/js/shares/tools_att.js

@@ -39,7 +39,7 @@
             '        <div style="overflow:auto; overflow-x:hidden; height: 100%">\n' +
             '            <div class="mt-1 mb-1" id="att-cur-hint"></div>\n' +
             '            <table class="table table-sm table-bordered table-hover" style="word-break:break-all; table-layout: fixed">\n' +
-            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"><th>文件名</th><th width="80">上传</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
+            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"><th>文件名</th><th width="80">上传</th><th width="85">上传时间</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
             '                <tbody id="cur-att-list" class="list-table"></tbody>\n' +
             '            </table>\n' +
             '        </div>\n' +
@@ -47,7 +47,7 @@
             '    <div class="tab-pane" id="att-all" style="height: 100%">\n' +
             '        <div style="overflow:auto; overflow-x:hidden; height: 100%">\n' +
             '            <table class="table table-sm table-bordered table-hover" style="word-break:break-all; table-layout: fixed">\n' +
-            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"></th><th>文件名</th><th width="80">上传</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
+            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"></th><th>文件名</th><th width="80">上传</th><th width="85">上传时间</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
             '                <tbody id="all-att-list" class="list-table"></tbody>\n' +
             '           </table>\n' +
             '        </div>\n' +
@@ -59,7 +59,7 @@
             '                <div class="input-group-append"><button id="att-search-btn" class="btn btn-outline-secondary" type="button">搜索</button></div>\n' +
             '            </div>\n' +
             '            <table class="table table-sm table-bordered table-hover" style="word-break:break-all; table-layout: fixed">\n' +
-            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"></th><th>文件名</th><th width="80">上传</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
+            '                <thead><tr><th width="25"><input type="checkbox" class="check-all-file"></th><th>文件名</th><th width="80">上传</th><th width="85">上传时间</th><th width="' + ctrlLength + '">操作</th></tr></thead>\n' +
             '                <tbody id="search-att-list" class="list-table"></tbody>\n' +
             '           </table>\n' +
             '        </div>\n' +
@@ -84,6 +84,7 @@
             const tipType = 'title='; //'data-toggle="tooltip" data-html="true" data-placement="left" data-original-title=';
             html.push(`<td file-id="${att.id}"><div class="d-flex"><a href="javascript:void(0)" ${tipType}"${tipHtml}" class="pl-0 col-11" file-id=${att.id}>${att.filename}${att.fileext}</a></div></td>`);
             html.push(`<td>${att[fileInfo.user_name]}</td>`);
+            html.push(`<td>${moment(att.create_time).format('YYYY-MM-DD')}</td>`);
             const canDel = setting.readOnly ? false : (att[fileInfo.user_id] === userID || setting.editfile) && (!setting.checked || att.extra_upload);
             const moveHtml = canDel && setting.moveUrl ? `<a href="javascript: void(0);" class="ml-1 text-primary" name="att-move" file-id="${att.id}" ${tipType}"移动"><i class="fa fa-exchange fa-fw"></i></a>` : '';
             const editHtml = canDel && setting.saveUrl ? `<a href="javascript: void(0);" class="ml-1 text-primary" name="att-edit" file-id="${att.id}" ${tipType}"重命名"><i class="fa fa-pencil fa-fw"></i></a>` : '';

+ 2 - 4
app/public/js/sp_data.js

@@ -44,9 +44,7 @@ $(document).ready(() => {
                 }
                 if (col.type === 'Number') {
                     const num = _.toNumber(newValue);
-                    if (num) {
-                        data[col.field] = num;
-                    }
+                    data[col.field] = num || 0;
                 } else {
                     data[col.field] = newValue;
                 }
@@ -118,7 +116,7 @@ $(document).ready(() => {
 
                 for (let iRow = sels[0].row; iRow < sels[0].row + sels[0].rowCount; iRow++) {
                     let bDel = false;
-                    const node = SpreadJsObj.getRowObject(info.sheet, iRow);
+                    const node = SpreadJsObj.getRowObject(sheet, iRow);
                     if (node) {
                         const data = { id: node.id };
                         for (let iCol = sels[0].col; iCol < sels[0].col + sels[0].colCount; iCol++) {

+ 353 - 0
app/public/js/sp_setting_data_range.js

@@ -0,0 +1,353 @@
+$(document).ready(function() {
+    autoFlashHeight();
+
+    const RangeType = {
+        unitType: 1,
+        unit: 2,
+        user: 3,
+    };
+    const typeIndex = {};
+    const unitIndex = {};
+    const userIndex = {};
+
+    dataRangeTree.forEach(type => {
+        typeIndex[Number(type.id)] = type;
+        type.units.forEach(unit => {
+            unit.parentTypeId = Number(type.id);
+            unitIndex[Number(unit.id)] = unit;
+            unit.users.forEach(user => {
+                user.parentTypeId = Number(type.id);
+                user.parentUnitId = Number(unit.id);
+                userIndex[Number(user.id)] = user;
+            });
+        });
+    });
+    dataRangeUnassignedUsers.forEach(user => {
+        user.parentTypeId = Number(user.account_group) || 0;
+        userIndex[Number(user.id)] = user;
+    });
+
+    const makeSelection = function(config) {
+        return {
+            unitTypeIds: new Set((config.unit_type_ids || []).map(Number)),
+            unitIds: new Set((config.unit_ids || []).map(Number)),
+            userIds: new Set((config.user_ids || []).map(Number)),
+        };
+    };
+    const cloneSelection = function(source) {
+        return {
+            unitTypeIds: new Set(Array.from(source.unitTypeIds)),
+            unitIds: new Set(Array.from(source.unitIds)),
+            userIds: new Set(Array.from(source.userIds)),
+        };
+    };
+    let savedSelection = makeSelection(dataRangeConfig);
+    let savedConfigured = Boolean(dataRangeConfig.configured);
+    let currentSelection = cloneSelection(savedSelection);
+
+    const escapeHtml = function(value) {
+        return $('<div>').text(value === undefined || value === null ? '' : value).html();
+    };
+    const containsKeyword = function(value, keyword) {
+        return !keyword || String(value || '').toLowerCase().indexOf(keyword) >= 0;
+    };
+    const getSelectionSet = function(rangeType) {
+        if (rangeType === RangeType.unitType) return currentSelection.unitTypeIds;
+        if (rangeType === RangeType.unit) return currentSelection.unitIds;
+        return currentSelection.userIds;
+    };
+    const getSelectionSetFrom = function(selection, rangeType) {
+        if (rangeType === RangeType.unitType) return selection.unitTypeIds;
+        if (rangeType === RangeType.unit) return selection.unitIds;
+        return selection.userIds;
+    };
+    const getRangeName = function(rangeType, targetId) {
+        let node;
+        if (rangeType === RangeType.unitType) node = typeIndex[targetId];
+        if (rangeType === RangeType.unit) node = unitIndex[targetId];
+        if (rangeType === RangeType.user) node = userIndex[targetId];
+        return node ? node.name : '';
+    };
+    const getRangeTitle = function(rangeType) {
+        if (rangeType === RangeType.unitType) return '单位类型';
+        if (rangeType === RangeType.unit) return '单位';
+        return '用户';
+    };
+
+    const removeTypeDescendants = function(typeId) {
+        const type = typeIndex[typeId];
+        if (!type) return;
+        type.units.forEach(unit => {
+            currentSelection.unitIds.delete(Number(unit.id));
+            unit.users.forEach(user => currentSelection.userIds.delete(Number(user.id)));
+        });
+        dataRangeUnassignedUsers.forEach(user => {
+            if (Number(user.parentTypeId) === Number(typeId)) currentSelection.userIds.delete(Number(user.id));
+        });
+    };
+    const removeUnitDescendants = function(unitId) {
+        const unit = unitIndex[unitId];
+        if (!unit) return;
+        unit.users.forEach(user => currentSelection.userIds.delete(Number(user.id)));
+    };
+
+    const renderCheckbox = function(rangeType, targetId, disabled) {
+        const checked = getSelectionSet(rangeType).has(Number(targetId)) ? ' checked' : '';
+        const disabledAttr = disabled ? ' disabled' : '';
+        return `<input type="checkbox" class="mr-1 data-range-check" data-range-type="${rangeType}" data-target-id="${targetId}"${checked}${disabledAttr}>`;
+    };
+    const renderNode = function(options) {
+        const indent = options.level * 20;
+        const toggle = options.hasChildren
+            ? '<span class="data-range-toggle"><i class="fa fa-caret-down"></i></span>'
+            : '<span class="data-range-toggle empty"></span>';
+        const checkbox = options.selectable === false ? '' : renderCheckbox(options.rangeType, options.targetId, options.disabled);
+        const icon = options.rangeType === RangeType.user
+            ? '<i class="fa fa-user text-warning mr-1"></i>'
+            : '<i class="fa fa-folder text-warning mr-1"></i>';
+        const extra = options.extra ? ` <small class="text-muted">${escapeHtml(options.extra)}</small>` : '';
+        return `<div class="data-range-node" style="padding-left:${indent}px">${toggle}${checkbox}${icon}<span>${escapeHtml(options.name)}</span>${extra}</div>`;
+    };
+
+    const renderTree = function() {
+        const keyword = $('#data-range-keyword').val().trim().toLowerCase();
+        const html = [];
+        dataRangeTree.forEach(type => {
+            const typeMatch = containsKeyword(type.name, keyword);
+            const visibleUnits = [];
+            type.units.forEach(unit => {
+                const unitMatch = containsKeyword(unit.name, keyword);
+                const users = typeMatch || unitMatch ? unit.users : unit.users.filter(user => {
+                    return containsKeyword(user.name, keyword) || containsKeyword(user.role, keyword);
+                });
+                if (!keyword || typeMatch || unitMatch || users.length > 0) visibleUnits.push({ unit, users });
+            });
+            if (keyword && !typeMatch && visibleUnits.length === 0) return;
+
+            html.push('<div class="data-range-tree-group">');
+            html.push(renderNode({
+                level: 0,
+                hasChildren: visibleUnits.length > 0,
+                rangeType: RangeType.unitType,
+                targetId: Number(type.id),
+                name: type.name,
+            }));
+            html.push('<div class="data-range-children">');
+            visibleUnits.forEach(item => {
+                const typeSelected = currentSelection.unitTypeIds.has(Number(type.id));
+                html.push('<div class="data-range-tree-group">');
+                html.push(renderNode({
+                    level: 1,
+                    hasChildren: item.users.length > 0,
+                    rangeType: RangeType.unit,
+                    targetId: Number(item.unit.id),
+                    name: item.unit.name,
+                    disabled: typeSelected,
+                }));
+                html.push('<div class="data-range-children">');
+                item.users.forEach(user => {
+                    const unitSelected = currentSelection.unitIds.has(Number(item.unit.id));
+                    html.push(renderNode({
+                        level: 2,
+                        hasChildren: false,
+                        rangeType: RangeType.user,
+                        targetId: Number(user.id),
+                        name: user.name,
+                        extra: user.role,
+                        disabled: typeSelected || unitSelected,
+                    }));
+                });
+                html.push('</div></div>');
+            });
+            html.push('</div></div>');
+        });
+
+        const otherUsers = keyword ? dataRangeUnassignedUsers.filter(user => {
+            return containsKeyword(user.name, keyword) || containsKeyword(user.company, keyword) || containsKeyword(user.role, keyword);
+        }) : dataRangeUnassignedUsers;
+        if (otherUsers.length > 0) {
+            html.push('<div class="data-range-tree-group">');
+            html.push(renderNode({ level: 0, hasChildren: true, selectable: false, name: '未关联单位用户' }));
+            html.push('<div class="data-range-children">');
+            otherUsers.forEach(user => {
+                const typeSelected = currentSelection.unitTypeIds.has(Number(user.parentTypeId));
+                html.push(renderNode({
+                    level: 1,
+                    hasChildren: false,
+                    rangeType: RangeType.user,
+                    targetId: Number(user.id),
+                    name: user.name,
+                    extra: user.company || user.role,
+                    disabled: typeSelected,
+                }));
+            });
+            html.push('</div></div>');
+        }
+        if (html.length === 0) html.push('<div class="text-muted text-center py-4">没有匹配的数据</div>');
+        $('#data-range-tree').html(html.join(''));
+    };
+
+    const renderSelected = function() {
+        const html = [];
+        const appendItems = function(rangeType, ids) {
+            Array.from(ids).sort((a, b) => a - b).forEach(targetId => {
+                html.push('<div class="data-range-selected-item d-flex align-items-center">');
+                html.push(`<span class="badge badge-light mr-2">${getRangeTitle(rangeType)}</span>`);
+                html.push(`<span>${escapeHtml(getRangeName(rangeType, targetId))}</span>`);
+                html.push(`<a href="javascript:void(0);" class="ml-auto text-danger remove-data-range" data-range-type="${rangeType}" data-target-id="${targetId}" title="移除"><i class="fa fa-times"></i></a>`);
+                html.push('</div>');
+            });
+        };
+        appendItems(RangeType.unitType, currentSelection.unitTypeIds);
+        appendItems(RangeType.unit, currentSelection.unitIds);
+        appendItems(RangeType.user, currentSelection.userIds);
+        const total = currentSelection.unitTypeIds.size + currentSelection.unitIds.size + currentSelection.userIds.size;
+        if (total === 0) html.push('<div class="text-muted text-center py-4">暂未选择范围</div>');
+        $('#data-range-selected-count').text(total);
+        $('#selected-unit-type-count').text(currentSelection.unitTypeIds.size);
+        $('#selected-unit-count').text(currentSelection.unitIds.size);
+        $('#selected-user-count').text(currentSelection.userIds.size);
+        $('#data-range-selected-list').html(html.join(''));
+    };
+
+    const renderSummary = function() {
+        if (!savedConfigured) {
+            $('#data-range-summary').text('当前未配置数据范围,默认可选择当前子项目全部启用成员。');
+            return;
+        }
+        $('#data-range-summary').text(
+            `已设置:单位类型 ${savedSelection.unitTypeIds.size} 项,单位 ${savedSelection.unitIds.size} 项,具体用户 ${savedSelection.userIds.size} 项。`
+        );
+    };
+
+    const renderMainSelection = function() {
+        const html = [];
+        const appendItems = function(rangeType, ids) {
+            Array.from(ids).sort((a, b) => a - b).forEach(targetId => {
+                const rangeName = getRangeName(rangeType, targetId);
+                if (!rangeName) return;
+                html.push(`<span class="data-range-value-item" title="${escapeHtml(getRangeTitle(rangeType))}">`);
+                html.push(`<span class="data-range-value-name">${escapeHtml(rangeName)}</span>`);
+                html.push(`<a href="javascript:void(0);" class="remove-main-data-range" data-range-type="${rangeType}" data-target-id="${targetId}" title="移除${escapeHtml(rangeName)}" aria-label="移除${escapeHtml(rangeName)}"><i class="fa fa-times"></i></a>`);
+                html.push('</span>');
+            });
+        };
+        appendItems(RangeType.unitType, savedSelection.unitTypeIds);
+        appendItems(RangeType.unit, savedSelection.unitIds);
+        appendItems(RangeType.user, savedSelection.userIds);
+        $('#data-range-value-list').html(html.join(''));
+    };
+
+    const selectionToRanges = function(selection) {
+        const ranges = [];
+        const appendRanges = function(rangeType, ids) {
+            ids.forEach(targetId => ranges.push({ range_type: rangeType, target_id: targetId }));
+        };
+        appendRanges(RangeType.unitType, selection.unitTypeIds);
+        appendRanges(RangeType.unit, selection.unitIds);
+        appendRanges(RangeType.user, selection.userIds);
+        return ranges;
+    };
+
+    let saving = false;
+    const saveSelection = function(selection, successCallback) {
+        if (saving) return;
+        saving = true;
+        $('#save-data-range').prop('disabled', true);
+        postData(`/sp/${dataRangeSpid}/setting/user/data-range/update`, {
+            module_key: dataRangeModuleKey,
+            ranges: selectionToRanges(selection),
+        }, function(result) {
+            const config = result || {
+                configured: selectionToRanges(selection).length > 0,
+                unit_type_ids: Array.from(selection.unitTypeIds),
+                unit_ids: Array.from(selection.unitIds),
+                user_ids: Array.from(selection.userIds),
+            };
+            savedSelection = makeSelection(config);
+            savedConfigured = Boolean(config.configured);
+            saving = false;
+            $('#save-data-range').prop('disabled', false);
+            renderMainSelection();
+            renderSummary();
+            if (successCallback) successCallback();
+        }, function() {
+            saving = false;
+            $('#save-data-range').prop('disabled', false);
+        });
+    };
+
+    const refreshModal = function() {
+        renderTree();
+        renderSelected();
+    };
+
+    $('#open-data-range-modal').on('click keydown', function(event) {
+        if ($(event.target).closest('.remove-main-data-range').length > 0) return;
+        if (event.type === 'keydown' && event.keyCode !== 13 && event.keyCode !== 32) return;
+        event.preventDefault();
+        currentSelection = cloneSelection(savedSelection);
+        $('#data-range-keyword').val('');
+        refreshModal();
+        $('#data-range-modal').modal('show');
+    });
+
+    $('body').on('click', '.remove-main-data-range', function(event) {
+        event.preventDefault();
+        event.stopPropagation();
+        if (saving) return;
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        const nextSelection = cloneSelection(savedSelection);
+        getSelectionSetFrom(nextSelection, rangeType).delete(targetId);
+        saveSelection(nextSelection, function() {
+            toastr.success('授权用户范围已移除');
+        });
+    });
+
+    $('#data-range-search').click(renderTree);
+    $('#data-range-keyword').on('keydown', function(event) {
+        if (event.keyCode === 13) {
+            event.preventDefault();
+            renderTree();
+        }
+    });
+    $('#data-range-keyword').on('input', renderTree);
+
+    $('body').on('click', '.data-range-toggle:not(.empty)', function() {
+        const children = $(this).parent().next('.data-range-children');
+        children.toggleClass('collapsed');
+        $('i', this).toggleClass('fa-caret-down fa-caret-right');
+    });
+
+    $('body').on('change', '.data-range-check', function() {
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        const selection = getSelectionSet(rangeType);
+        if (this.checked) {
+            selection.add(targetId);
+            if (rangeType === RangeType.unitType) removeTypeDescendants(targetId);
+            if (rangeType === RangeType.unit) removeUnitDescendants(targetId);
+        } else {
+            selection.delete(targetId);
+        }
+        refreshModal();
+    });
+
+    $('body').on('click', '.remove-data-range', function() {
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        getSelectionSet(rangeType).delete(targetId);
+        refreshModal();
+    });
+
+    $('#save-data-range').click(function() {
+        saveSelection(currentSelection, function() {
+            $('#data-range-modal').modal('hide');
+            toastr.success('数据范围保存成功');
+        });
+    });
+
+    renderMainSelection();
+    renderSummary();
+});

+ 341 - 151
app/public/js/spreadjs_rela/spreadjs_zh.js

@@ -1472,6 +1472,217 @@ const SpreadJsObj = {
 
     CellType: {
         /**
+         * 获取 带悬浮提示的CellType
+         * @returns {TipCellType}
+         */
+        getTipCellType: function (scrollHeightClass = false) {
+            const maxHintWidth = 200, indent = 15, borderIndent = 10;
+            const TipCellType = function () {};
+            // 继承 SpreadJs定义的 普通的TextCellType
+            TipCellType.prototype = new spreadNS.CellTypes.Text();
+            const proto = TipCellType.prototype;
+            proto.getTextDisplayWidth = function(hitinfo, str, font) {
+                const xs = hitinfo.sheet.getParent().xs;
+                const ctx = xs.childNodes[0].getContext("2d");
+                if (font && font !== '') {
+                    ctx.font = font;
+                } else {
+                    ctx.font = hitinfo.cellStyle.font;
+                }
+                return ctx.measureText(str).width;
+            };
+            proto.showTip = function (hitinfo, text) {
+                return text && text !== '';
+            };
+            /**
+             * 获取点击信息
+             * @param {Number} x
+             * @param {Number} y
+             * @param {Object} cellStyle
+             * @param {Object} cellRect
+             * @param {Object} context
+             * @returns {{x: *, y: *, row: *, col: *|boolean|*[]|number|{}|UE.dom.dtd.col, cellStyle: *, cellRect: *, sheet: *|StyleSheet, sheetArea: *}}
+             */
+            proto.getHitInfo = function (x, y, cellStyle, cellRect, context) {
+                return {
+                    x: x,
+                    y: y,
+                    row: context.row,
+                    col: context.col,
+                    cellStyle: cellStyle,
+                    cellRect: cellRect,
+                    sheet: context.sheet,
+                    sheetArea: context.sheetArea,
+                    ctx: context.sheet.getParent().xs,
+                };
+            };
+            /**
+             * 鼠标进入单元格事件 - 显示悬浮提示
+             * @param {Object} hitinfo - 见getHitInfo返回值
+             */
+            proto.processMouseEnter = function (hitinfo) {
+                let text = hitinfo.sheet.getText(hitinfo.row, hitinfo.col);
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                if (col.getTip && Object.prototype.toString.apply(col.getTip) === "[object Function]") {
+                    const sortData = SpreadJsObj.getSortData(hitinfo.sheet);
+                    text = col.getTip(sortData[hitinfo.row]);
+                }
+                const pos = SpreadJsObj.getObjPos(hitinfo.sheet.getParent().qo);
+                if (pos && this.showTip(hitinfo, text)) {
+                    if (!this._toolTipElement) {
+                        let div = $('#autoTip')[0];
+                        if (!div) {
+                            div = document.createElement("div");
+                            $(div).css("position", "absolute")
+                                .css("border", "1px #C0C0C0 solid")
+                                .css("box-shadow", "1px 2px 5px rgba(0,0,0,0.4)")
+                                .css("font", "9pt Arial")
+                                .css("background", "white")
+                                .css("padding", 5)
+                                .css("z-index", 9999)
+                                .css("max-width", maxHintWidth)
+                                .css("word-wrap", "break-word")
+                                .attr("id", 'autoTip');
+                            document.body.insertBefore(div, null);
+                        }
+                        const validWidth = $(window).width() - (pos.x + hitinfo.x + indent) - borderIndent;
+                        const textWidth = this.getTextDisplayWidth(hitinfo, text, "9pt Arial");
+                        const scrollTop = scrollHeightClass ? $(scrollHeightClass).scrollTop() : 0;
+                        if (validWidth >= maxHintWidth || textWidth <= validWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x + indent);
+                        } else if (textWidth > maxHintWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x - indent - maxHintWidth);
+                        } else {
+                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x - indent - textWidth);
+                        }
+                        this._toolTipElement = div;
+                        $(div).show("fast");
+                    }
+                }
+            };
+            /**
+             * 鼠标移出单元格事件 - 隐藏悬浮提示
+             * @param {Object} hitinfo - 见getHitInfo返回值
+             */
+            proto.processMouseLeave = function (hitinfo) {
+                if (this._toolTipElement) {
+                    $(this._toolTipElement).hide();
+                    this._toolTipElement = null;
+                }
+            };
+
+            return new TipCellType();
+        },
+        getAutoTipCellType: function (scrollHeightClass) {
+            const AutoTipCellType = function () {};
+            // 继承 TipCellType
+            AutoTipCellType.prototype = SpreadJsObj.CellType.getTipCellType(scrollHeightClass);
+            const proto = AutoTipCellType.prototype;
+            proto.showTip = function (hitinfo, text) {
+                return text && text !== '' && this.getTextDisplayWidth(hitinfo, text) > hitinfo.cellRect.width;
+            };
+
+            return new AutoTipCellType();
+        },
+        getDelayTipCellType: function () {
+            const maxHintWidth = 200, indent = 15, borderIndent = 10;
+            const TipCellType = function () {};
+            // 继承 SpreadJs定义的 普通的TextCellType
+            TipCellType.prototype = new spreadNS.CellTypes.Text();
+            const proto = TipCellType.prototype;
+            proto.getTextDisplayWidth = function(hitinfo, str, font) {
+                const xs = hitinfo.sheet.getParent().xs;
+                const ctx = xs.childNodes[0].getContext("2d");
+                if (font && font !== '') {
+                    ctx.font = font;
+                } else {
+                    ctx.font = hitinfo.cellStyle.font;
+                }
+                return ctx.measureText(str).width;
+            };
+            proto.showTip = function (hitinfo, text) {
+                return text && text !== '';
+            };
+            /**
+             * 获取点击信息
+             * @param {Number} x
+             * @param {Number} y
+             * @param {Object} cellStyle
+             * @param {Object} cellRect
+             * @param {Object} context
+             * @returns {{x: *, y: *, row: *, col: *|boolean|*[]|number|{}|UE.dom.dtd.col, cellStyle: *, cellRect: *, sheet: *|StyleSheet, sheetArea: *}}
+             */
+            proto.getHitInfo = function (x, y, cellStyle, cellRect, context) {
+                return {
+                    x: x,
+                    y: y,
+                    row: context.row,
+                    col: context.col,
+                    cellStyle: cellStyle,
+                    cellRect: cellRect,
+                    sheet: context.sheet,
+                    sheetArea: context.sheetArea,
+                    ctx: context.sheet.getParent().xs,
+                };
+            };
+            /**
+             * 鼠标进入单元格事件 - 显示悬浮提示
+             * @param {Object} hitinfo - 见getHitInfo返回值
+             */
+            proto.processMouseEnter = function (hitinfo) {
+                let text = hitinfo.sheet.getText(hitinfo.row, hitinfo.col);
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                if (col.getTip && Object.prototype.toString.apply(col.getTip) === "[object Function]") {
+                    const sortData = SpreadJsObj.getSortData(hitinfo.sheet);
+                    text = col.getTip(sortData[hitinfo.row]);
+                }
+                const pos = SpreadJsObj.getObjPos(hitinfo.sheet.getParent().qo);
+                if (pos && this.showTip(hitinfo, text)) {
+                    if (!this._toolTipElement) {
+                        let div = $('#autoTip')[0];
+                        if (!div) {
+                            div = document.createElement("div");
+                            $(div).css("position", "absolute")
+                                .css("border", "1px #C0C0C0 solid")
+                                .css("box-shadow", "1px 2px 5px rgba(0,0,0,0.4)")
+                                .css("font", "9pt Arial")
+                                .css("background", "white")
+                                .css("padding", 5)
+                                .css("z-index", 999)
+                                .css("max-width", maxHintWidth)
+                                .css("word-wrap", "break-word")
+                                .attr("id", 'autoTip');
+                            document.body.insertBefore(div, null);
+                        }
+                        const validWidth = $(window).width() - (pos.x + hitinfo.x + indent) - borderIndent;
+                        const textWidth = this.getTextDisplayWidth(hitinfo, text, "9pt Arial");
+                        if (validWidth >= maxHintWidth || textWidth <= validWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x + indent);
+                        } else if (textWidth > maxHintWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x - indent - maxHintWidth);
+                        } else {
+                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x - indent - textWidth);
+                        }
+                        this._toolTipElement = div;
+                        const self = this;
+                        setTimeout(()=>{ if (self._toolTipElement) $(self._toolTipElement).show("fast");}, 300);
+                    }
+                }
+            };
+            /**
+             * 鼠标移出单元格事件 - 隐藏悬浮提示
+             * @param {Object} hitinfo - 见getHitInfo返回值
+             */
+            proto.processMouseLeave = function (hitinfo) {
+                if (this._toolTipElement) {
+                    $(this._toolTipElement).hide();
+                    this._toolTipElement = null;
+                }
+            };
+
+            return new TipCellType();
+        },
+        /**
          * 获取树结构CellType
          * 通过SpreadJsObj.loadSheetData(sheet, 'tree', tree)加载树结构数据
          * 要求tree类型为PathTree, 节点必须含有{id, pid, level, order, is_leaf}数据
@@ -1486,6 +1697,7 @@ const SpreadJsObj = {
             const lineColor = '#515151';//'#b8b8b8';
             const expandBoxColor = '#434343';//'#808080';
             const dotLine = true;
+            const hintMaxHintWidth = 200, hintIndent = 15, hintBorderIndent = 10;
 
             /**
              * 画一条点线段
@@ -1588,6 +1800,84 @@ const SpreadJsObj = {
                 }
             };
 
+            /**
+             * 绘制文件夹图标(使用arcTo兼容旧浏览器)
+             * @param {Object} canvas - 画布
+             * @param {Number} x - 左上角x坐标
+             * @param {Number} y - 左上角y坐标
+             * @param {Number} s - 图标尺寸
+             */
+            const drawFolderIcon = function (canvas, x, y, s) {
+                canvas.save();
+                // 文件夹整体(含顶部标签页)
+                canvas.fillStyle = '#FFC107';
+                canvas.strokeStyle = '#F57F17';
+                canvas.lineWidth = 0.8;
+                canvas.beginPath();
+                canvas.moveTo(x, y + 3);
+                canvas.lineTo(x, y);
+                canvas.lineTo(x + s * 0.4, y);
+                canvas.lineTo(x + s * 0.5, y + 3);
+                canvas.lineTo(x + s, y + 3);
+                canvas.arcTo(x + s, y + s, x, y + s, 1.5);
+                canvas.arcTo(x, y + s, x, y, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 前面板(浅色,营造层次感)
+                canvas.fillStyle = '#FFD54F';
+                canvas.beginPath();
+                canvas.moveTo(x, y + 5);
+                canvas.arcTo(x + s, y + 5, x + s, y + s, 1.5);
+                canvas.arcTo(x + s, y + s, x, y + s, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.restore();
+            };
+            /**
+             * 绘制文件图标(带折角和文字线条)
+             * @param {Object} canvas - 画布
+             * @param {Number} x - 左上角x坐标
+             * @param {Number} y - 左上角y坐标
+             * @param {Number} s - 图标尺寸
+             */
+            const drawFileIcon = function (canvas, x, y, s) {
+                const fold = s * 0.3;
+                canvas.save();
+                // 文件主体(带折角轮廓)
+                canvas.fillStyle = '#E3F2FD';
+                canvas.strokeStyle = '#42A5F5';
+                canvas.lineWidth = 0.8;
+                canvas.beginPath();
+                canvas.moveTo(x, y);
+                canvas.lineTo(x + s - fold, y);
+                canvas.lineTo(x + s, y + fold);
+                canvas.lineTo(x + s, y + s);
+                canvas.arcTo(x, y + s, x, y, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 折角三角形
+                canvas.fillStyle = '#90CAF9';
+                canvas.beginPath();
+                canvas.moveTo(x + s - fold, y);
+                canvas.lineTo(x + s - fold, y + fold);
+                canvas.lineTo(x + s, y + fold);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 文字线条
+                canvas.strokeStyle = '#90CAF9';
+                canvas.lineWidth = 0.6;
+                canvas.beginPath();
+                canvas.moveTo(x + 2, y + fold + 3);
+                canvas.lineTo(x + s - 3, y + fold + 3);
+                canvas.moveTo(x + 2, y + fold + 5.5);
+                canvas.lineTo(x + s - 5, y + fold + 5.5);
+                canvas.stroke();
+                canvas.restore();
+            };
+
             let TreeNodeCellType = function (){};
             TreeNodeCellType.prototype = new spreadNS.CellTypes.Text();
             const proto = TreeNodeCellType.prototype;
@@ -1693,6 +1983,29 @@ const SpreadJsObj = {
                         const move = (node[setting.level]) * indent + (node[setting.level]) * levelIndent + xOffset;
                         x = x + move;
                         w = w - move;
+                        // 绘制节点图标(文件夹/文件等)
+                        const col = options.sheet.zh_setting.cols[options.col];
+                        if (col && col.getIcon) {
+                            const iconType = col.getIcon(node);
+                            if (iconType) {
+                                const customImage = typeof iconType === 'object' ? iconType.image : null;
+                                const customSize = typeof iconType === 'object' ? Number(iconType.size) : 0;
+                                const fallbackType = typeof iconType === 'object' ? iconType.fallback : iconType;
+                                const iconSize = customSize > 0 ? customSize : 14;
+                                const iconY = y + Math.floor((h - iconSize) / 2);
+                                const imageReady = customImage && customImage.complete &&
+                                    (customImage.naturalWidth || customImage.width);
+                                if (imageReady) {
+                                    canvas.drawImage(customImage, x, iconY, iconSize, iconSize);
+                                } else if (fallbackType === 'folder') {
+                                    drawFolderIcon(canvas, x, iconY, iconSize);
+                                } else if (fallbackType === 'file') {
+                                    drawFileIcon(canvas, x, iconY, iconSize);
+                                }
+                                x += iconSize + 4;
+                                w -= iconSize + 4;
+                            }
+                        }
                     }
                 }
                 // Drawing Text
@@ -1716,7 +2029,8 @@ const SpreadJsObj = {
                     cellStyle: cellStyle,
                     cellRect: cellRect,
                     sheet: context.sheet,
-                    sheetArea: context.sheetArea
+                    sheetArea: context.sheetArea,
+                    ctx: context.sheet.getParent().xs,
                 };
             };
             /**
@@ -1766,18 +2080,6 @@ const SpreadJsObj = {
                 }
             };
 
-            return new TreeNodeCellType();
-        },
-        /**
-         * 获取 带悬浮提示的CellType
-         * @returns {TipCellType}
-         */
-        getTipCellType: function (scrollHeightClass = false) {
-            const maxHintWidth = 200, indent = 15, borderIndent = 10;
-            const TipCellType = function () {};
-            // 继承 SpreadJs定义的 普通的TextCellType
-            TipCellType.prototype = new spreadNS.CellTypes.Text();
-            const proto = TipCellType.prototype;
             proto.getTextDisplayWidth = function(hitinfo, str, font) {
                 const xs = hitinfo.sheet.getParent().xs;
                 const ctx = xs.childNodes[0].getContext("2d");
@@ -1789,138 +2091,26 @@ const SpreadJsObj = {
                 return ctx.measureText(str).width;
             };
             proto.showTip = function (hitinfo, text) {
-                return text && text !== '';
-            };
-            /**
-             * 获取点击信息
-             * @param {Number} x
-             * @param {Number} y
-             * @param {Object} cellStyle
-             * @param {Object} cellRect
-             * @param {Object} context
-             * @returns {{x: *, y: *, row: *, col: *|boolean|*[]|number|{}|UE.dom.dtd.col, cellStyle: *, cellRect: *, sheet: *|StyleSheet, sheetArea: *}}
-             */
-            proto.getHitInfo = function (x, y, cellStyle, cellRect, context) {
-                return {
-                    x: x,
-                    y: y,
-                    row: context.row,
-                    col: context.col,
-                    cellStyle: cellStyle,
-                    cellRect: cellRect,
-                    sheet: context.sheet,
-                    sheetArea: context.sheetArea,
-                    ctx: context.sheet.getParent().xs,
-                };
-            };
-            /**
-             * 鼠标进入单元格事件 - 显示悬浮提示
-             * @param {Object} hitinfo - 见getHitInfo返回值
-             */
-            proto.processMouseEnter = function (hitinfo) {
-                let text = hitinfo.sheet.getText(hitinfo.row, hitinfo.col);
-                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
-                if (col.getTip && Object.prototype.toString.apply(col.getTip) === "[object Function]") {
-                    const sortData = SpreadJsObj.getSortData(hitinfo.sheet);
-                    text = col.getTip(sortData[hitinfo.row]);
-                }
-                const pos = SpreadJsObj.getObjPos(hitinfo.sheet.getParent().qo);
-                if (pos && this.showTip(hitinfo, text)) {
-                    if (!this._toolTipElement) {
-                        let div = $('#autoTip')[0];
-                        if (!div) {
-                            div = document.createElement("div");
-                            $(div).css("position", "absolute")
-                                .css("border", "1px #C0C0C0 solid")
-                                .css("box-shadow", "1px 2px 5px rgba(0,0,0,0.4)")
-                                .css("font", "9pt Arial")
-                                .css("background", "white")
-                                .css("padding", 5)
-                                .css("z-index", 9999)
-                                .css("max-width", maxHintWidth)
-                                .css("word-wrap", "break-word")
-                                .attr("id", 'autoTip');
-                            document.body.insertBefore(div, null);
-                        }
-                        const validWidth = $(window).width() - (pos.x + hitinfo.x + indent) - borderIndent;
-                        const textWidth = this.getTextDisplayWidth(hitinfo, text, "9pt Arial");
-                        const scrollTop = scrollHeightClass ? $(scrollHeightClass).scrollTop() : 0;
-                        if (validWidth >= maxHintWidth || textWidth <= validWidth) {
-                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x + indent);
-                        } else if (textWidth > maxHintWidth) {
-                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x - indent - maxHintWidth);
-                        } else {
-                            $(div).html(text).css("top", pos.y + hitinfo.y - scrollTop + indent).css("left", pos.x + hitinfo.x - indent - textWidth);
-                        }
-                        this._toolTipElement = div;
-                        $(div).show("fast");
-                    }
-                }
-            };
-            /**
-             * 鼠标移出单元格事件 - 隐藏悬浮提示
-             * @param {Object} hitinfo - 见getHitInfo返回值
-             */
-            proto.processMouseLeave = function (hitinfo) {
-                if (this._toolTipElement) {
-                    $(this._toolTipElement).hide();
-                    this._toolTipElement = null;
-                }
-            };
+                if (!text) return false;
 
-            return new TipCellType();
-        },
-        getAutoTipCellType: function (scrollHeightClass) {
-            const AutoTipCellType = function () {};
-            // 继承 TipCellType
-            AutoTipCellType.prototype = SpreadJsObj.CellType.getTipCellType(scrollHeightClass);
-            const proto = AutoTipCellType.prototype;
-            proto.showTip = function (hitinfo, text) {
-                return text && text !== '' && this.getTextDisplayWidth(hitinfo, text) > hitinfo.cellRect.width;
-            };
+                const col = hitinfo.sheet.zh_setting.cols[hitinfo.col];
+                if (!col.showTips) return false;
 
-            return new AutoTipCellType();
-        },
-        getDelayTipCellType: function () {
-            const maxHintWidth = 200, indent = 15, borderIndent = 10;
-            const TipCellType = function () {};
-            // 继承 SpreadJs定义的 普通的TextCellType
-            TipCellType.prototype = new spreadNS.CellTypes.Text();
-            const proto = TipCellType.prototype;
-            proto.getTextDisplayWidth = function(hitinfo, str, font) {
-                const xs = hitinfo.sheet.getParent().xs;
-                const ctx = xs.childNodes[0].getContext("2d");
-                if (font && font !== '') {
-                    ctx.font = font;
-                } else {
-                    ctx.font = hitinfo.cellStyle.font;
+                const tree = hitinfo.sheet.zh_tree;
+                const node = hitinfo.row < tree.nodes.length ? tree.nodes[hitinfo.row] : null;
+                if (!node) return false;
+
+                const move = (node[tree.setting.level]) * indent + (node[tree.setting.level]) * levelIndent + xOffset;
+                let iconWidth = 0;
+                if (col.getIcon) {
+                    const iconType = col.getIcon(node);
+                    if (iconType) {
+                        const customSize = typeof iconType === 'object' ? Number(iconType.size) : 0;
+                        iconWidth = (customSize > 0 ? customSize : 14) + 4;
+                    }
                 }
-                return ctx.measureText(str).width;
-            };
-            proto.showTip = function (hitinfo, text) {
-                return text && text !== '';
-            };
-            /**
-             * 获取点击信息
-             * @param {Number} x
-             * @param {Number} y
-             * @param {Object} cellStyle
-             * @param {Object} cellRect
-             * @param {Object} context
-             * @returns {{x: *, y: *, row: *, col: *|boolean|*[]|number|{}|UE.dom.dtd.col, cellStyle: *, cellRect: *, sheet: *|StyleSheet, sheetArea: *}}
-             */
-            proto.getHitInfo = function (x, y, cellStyle, cellRect, context) {
-                return {
-                    x: x,
-                    y: y,
-                    row: context.row,
-                    col: context.col,
-                    cellStyle: cellStyle,
-                    cellRect: cellRect,
-                    sheet: context.sheet,
-                    sheetArea: context.sheetArea,
-                    ctx: context.sheet.getParent().xs,
-                };
+                const validWidth = hitinfo.cellRect.width - move - iconWidth;
+                return this.getTextDisplayWidth(hitinfo, text) > validWidth;
             };
             /**
              * 鼠标进入单元格事件 - 显示悬浮提示
@@ -1945,20 +2135,20 @@ const SpreadJsObj = {
                                 .css("font", "9pt Arial")
                                 .css("background", "white")
                                 .css("padding", 5)
-                                .css("z-index", 999)
-                                .css("max-width", maxHintWidth)
+                                .css("z-index", 1999)
+                                .css("max-width", hintMaxHintWidth)
                                 .css("word-wrap", "break-word")
                                 .attr("id", 'autoTip');
                             document.body.insertBefore(div, null);
                         }
-                        const validWidth = $(window).width() - (pos.x + hitinfo.x + indent) - borderIndent;
+                        const validWidth = $(window).width() - (pos.x + hitinfo.x + hintIndent) - hintBorderIndent;
                         const textWidth = this.getTextDisplayWidth(hitinfo, text, "9pt Arial");
-                        if (validWidth >= maxHintWidth || textWidth <= validWidth) {
-                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x + indent);
-                        } else if (textWidth > maxHintWidth) {
-                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x - indent - maxHintWidth);
+                        if (validWidth >= hintMaxHintWidth || textWidth <= validWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y + hintIndent).css("left", pos.x + hitinfo.x + hintIndent);
+                        } else if (textWidth > hintMaxHintWidth) {
+                            $(div).html(text).css("top", pos.y + hitinfo.y + hintIndent).css("left", pos.x + hitinfo.x - hintIndent - hintMaxHintWidth);
                         } else {
-                            $(div).html(text).css("top", pos.y + hitinfo.y + indent).css("left", pos.x + hitinfo.x - indent - textWidth);
+                            $(div).html(text).css("top", pos.y + hitinfo.y + hintIndent).css("left", pos.x + hitinfo.x - hintIndent - textWidth);
                         }
                         this._toolTipElement = div;
                         const self = this;
@@ -1977,7 +2167,7 @@ const SpreadJsObj = {
                 }
             };
 
-            return new TipCellType();
+            return new TreeNodeCellType();
         },
         /**
          * 获取 带图片的cellType(图片需在document中定义好img,并写入col的img属性)

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

@@ -4839,8 +4839,9 @@ $(document).ready(() => {
         getCurHint: function(node) { return ''; },
         locate: function (att) {
             if (!att) return;
-            SpreadJsObj.locateDataBy(slSheet, function(x) { return x.id === att.node.id; });
+            SpreadJsObj.locateTreeNode(slSheet, att.node.ledger_id, true);
             stageFile.getCurAttHtml(att.node);
+            stageTreeSpreadObj.loadRelaData();
         }
     });
     $('a', '.right-nav').bind('click', function () {

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

@@ -964,7 +964,7 @@ $(document).ready(() => {
         },
         importMaterial: function(node, tp, memo) {
             const select = SpreadJsObj.getSelectObject(paySheet);
-            const data = { type: 'stage', updateData: { pid: select.pid, tp, postil: memo } };
+            const data = { type: 'stage', updateData: { pid: select.pid, tp, postil: memo, expr: '' } };
             postData(window.location.pathname + '/save', data, function (result) {
                 loadUpdateDealPays(result);
                 SpreadJsObj.reLoadSheetData(paySpread.getActiveSheet());

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

@@ -459,4 +459,4 @@ $(document).ready(function() {
             $('#sm-management').attr('tree_id', '');
         });
     });
-});
+});

+ 5 - 2
app/public/js/tender_list_progress.js

@@ -98,7 +98,7 @@ const tenderListSpec = (function(){
         html.push('</td>');
         // 总价
         html.push('<td width="120px" class="text-right">');
-        html.push(node.sum_tp ? node.sum_tp : '');
+        html.push(node.sum_tp1 ? node.sum_tp1 : '');
         html.push('</td>');
         // 截止本期累计完成/本期完成/未完成
         html.push('<td width="30%">');
@@ -122,7 +122,7 @@ const tenderListSpec = (function(){
         html.push('<th class="text-center" style="width: 230px">', '当前流程', '</th>');
         html.push('<th class="text-center" style="width: 150px">', '上一流程审批时间', '</th>');
         html.push('<th class="text-center" style="width: 120px">', '签约合同价', '</th>');
-        html.push('<th style="width: 120px" class="text-center">', '总价 <i class="fa fa-question-circle text-primary"  data-placement="bottom" data-toggle="tooltip" data-original-title="0号台账+截止本期数量变更"></i>', '</th>');
+        html.push('<th style="width: 120px" class="text-center">', '总价 <i class="fa fa-question-circle text-primary"  data-placement="bottom" data-toggle="tooltip" data-original-title="0号台账+批复变更总金额"></i>', '</th>');
         html.push('<th style="width: 30%" class="text-center">', '截止上期完成/本期完成/未完成', '</th>');
         html.push('</tr>', '</thead>');
         return html.join('');
@@ -141,6 +141,7 @@ const tenderListSpec = (function(){
         } else {
             tender.sum_tp = tender.total_price;
         }
+        tender.sum_tp1 = ZhCalc.add(tender.total_price, tender.change_tp);
     }
     function calculateParent(node) {
         if (node.children && node.cid) {
@@ -148,6 +149,7 @@ const tenderListSpec = (function(){
             node.pre_gather_tp = 0;
             node.gather_tp = 0;
             node.sum_tp = 0;
+            node.sum_tp1 = 0;
             node.lastStage = 0;
             node.contract_price = 0;
             node.stage_count = 0;
@@ -157,6 +159,7 @@ const tenderListSpec = (function(){
                 node.pre_gather_tp = ZhCalc.add(node.pre_gather_tp, c.pre_gather_tp);
                 node.gather_tp = ZhCalc.add(node.gather_tp, c.gather_tp);
                 node.sum_tp = ZhCalc.add(node.sum_tp, c.sum_tp);
+                node.sum_tp1 = ZhCalc.add(node.sum_tp1, c.sum_tp1);
                 node.lastStage = c.cid
                     ? Math.max(node.lastStage, c.lastStage)
                     : (c.lastStage ? Math.max(node.lastStage, c.lastStage.order) : node.lastStage);

+ 6 - 2
app/public/report/js/rpt_change_rela.js

@@ -9,7 +9,7 @@ const rptChangeRela = (function (){
     let curType = '';
     const initList = function () {
         const header = info[curType].colHeader;
-        $('#scr-header').html(`<tr class="text-center"><th>${header[0]}</th><th style="vertical-align: middle">${header[1]}</th><th style="vertical-align: middle">${header[2]}</th></tr>`);
+        $('#scr-header').html(`<tr class="text-center"><th>${header[0]}</th><th style="vertical-align: middle">${_.escape(header[1])}</th><th style="vertical-align: middle">${header[2]}</th></tr>`);
         const html = [], arr = data[curType];
         for (const a of arr) {
             const checked = a.selected ? 'checked' : '';
@@ -19,6 +19,10 @@ const rptChangeRela = (function (){
     };
     const showChangeRela = async function (type) {
         curType = type;
+        if (type === 'change_plan') {
+            info.change_plan.title = '请选择' + reportChangePlanName;
+            info.change_plan.colHeader[1] = reportChangePlanName + '编号';
+        }
         document.getElementById('scr-title').innerText = info[type].title;
         if (!data[type]) {
             const result = await postDataAsync(`/tender/${window.location.pathname.split('/')[2]}/load`, { filter: curType });
@@ -72,4 +76,4 @@ const rptChangeRela = (function (){
         $('#select-change-rela').modal('hide');
     };
     return { showChangeRela, updateChangeRela }
-})();
+})();

+ 35 - 0
app/router.js

@@ -80,6 +80,12 @@ module.exports = app => {
     app.post('/sign/save', 'signController.save');
     app.post('/reset/password', 'loginController.resetPassword');
 
+    // 全局业务查询助手
+    app.post('/assistant/query', sessionAuth, 'assistantController.query');
+    app.post('/assistant/query/v1', sessionAuth, 'assistantController.query');
+    app.post('/assistant/query/v2', sessionAuth, 'assistantController.queryV2');
+    app.get('/assistant/attachment/:source/:id', sessionAuth, 'assistantController.attachment');
+
     // 项目管理对计量接口相关
     app.get('/management/account', api3managementCheck, 'loginController.account');
     app.get('/management/account/sync', api3managementCheck, 'loginController.syncProjectAccount');
@@ -205,8 +211,15 @@ module.exports = app => {
     app.post('/setting/spread/save', sessionAuth, projectManagerCheck, 'settingController.saveSpread');
     app.post('/setting/spread/preview', sessionAuth, projectManagerCheck, 'settingController.previewSpread');
     app.post('/setting/spread/reset', sessionAuth, projectManagerCheck, 'settingController.resetSpread');
+    // 模块管理-项目大屏
+    app.get('/setting/module', sessionAuth, 'settingController.module');
+    app.post('/setting/module/projectscreen', sessionAuth, 'settingController.projectScreenSave');
     // ------------------------- 项目相关 -----------------------------
 
+    app.get('/projectscreen', sessionAuth, 'projectScreenController.index');
+    app.post('/projectscreen/data/detail', sessionAuth, 'projectScreenController.detail');
+    app.get('/projectscreen/:sid', sessionAuth, 'projectScreenController.index');
+
 
     // ------------------------- 项目内部相关 -----------------------------
     // **项目设置
@@ -223,6 +236,8 @@ module.exports = app => {
     app.get('/sp/:id/setting/user', sessionAuth, subProjectCheck, 'subProjSettingController.user');
     app.get('/sp/:id/setting/user/permission', sessionAuth, subProjectCheck, 'subProjSettingController.permission');
     app.post('/sp/:id/setting/user/permission/update', sessionAuth, subProjectCheck, 'subProjSettingController.permissionUpdate');
+    app.get('/sp/:id/setting/user/data-range', sessionAuth, subProjectCheck, 'subProjSettingController.dataRange');
+    app.post('/sp/:id/setting/user/data-range/update', sessionAuth, subProjectCheck, 'subProjSettingController.dataRangeUpdate');
     // 操作日志
     app.get('/sp/:id/setting/logs', sessionAuth, subProjectCheck, 'subProjSettingController.logs');
     app.get('/sp/:id/setting/logs/type/:type', sessionAuth, subProjectCheck, 'subProjSettingController.logs');
@@ -392,10 +407,23 @@ module.exports = app => {
     app.post('/file/template/:id/update', sessionAuth, projectManagerCheck, 'fileController.updateTemplate');
     // 资料归集-文件
     app.get('/sp/:id/file', sessionAuth, subProjectCheck, 'fileController.file');
+    // 资料归集-SpreadJS 版本(第二版)
+    app.get('/sp/:id/filesjs', sessionAuth, subProjectCheck, 'fileController.filesjs');
+    // 配置目录(SpreadJS树管理+授权用户)
+    app.get('/sp/:id/config-dir', sessionAuth, subProjectCheck, 'fileController.configDir');
+    app.post('/sp/:id/config-dir/lock', sessionAuth, subProjectCheck, 'fileController.configDirLock');
+    app.post('/sp/:id/config-dir/update', sessionAuth, subProjectCheck, 'fileController.configDirUpdate');
+    app.post('/sp/:id/config-dir/file/move', sessionAuth, subProjectCheck, 'fileController.configDirMoveFiles');
+    app.post('/sp/:id/config-dir/permission', sessionAuth, subProjectCheck, 'fileController.getFilingNodePermission');
+    app.post('/sp/:id/config-dir/permission/save', sessionAuth, subProjectCheck, 'fileController.saveFilingNodePermission');
+    app.post('/sp/:id/config-dir/permission/add-other', sessionAuth, subProjectCheck, 'fileController.addFilingNodePermissions');
+    app.post('/sp/:id/config-dir/permission/cover-other', sessionAuth, subProjectCheck, 'fileController.coverFilingNodePermissions');
     app.post('/sp/:id/permission', sessionAuth, projectManagerCheck, subProjectCheck, 'fileController.getFilingTypePermission');
     app.post('/sp/:id/permission/save', sessionAuth, projectManagerCheck, subProjectCheck, 'fileController.saveFilingTypePermission');
+    app.post('/sp/:id/filing/init', sessionAuth, subProjectCheck, 'fileController.initializeFiling');
     app.post('/sp/:id/filing/add', sessionAuth, subProjectCheck, 'fileController.addFiling');
     app.post('/sp/:id/filing/save', sessionAuth, subProjectCheck, 'fileController.saveFiling');
+    app.post('/sp/:id/filing/names/save', sessionAuth, subProjectCheck, 'fileController.saveFilingNames');
     app.post('/sp/:id/filing/del', sessionAuth, subProjectCheck, 'fileController.delFiling');
     app.post('/sp/:id/filing/move', sessionAuth, subProjectCheck, 'fileController.moveFiling');
     app.post('/sp/:id/file/load', sessionAuth, subProjectCheck, 'fileController.loadFile');
@@ -404,6 +432,7 @@ module.exports = app => {
     app.post('/sp/:id/file/upload/big', sessionAuth, subProjectCheck, 'fileController.uploadBigFile');
     app.post('/sp/:id/file/del', sessionAuth, subProjectCheck, 'fileController.delFile');
     app.post('/sp/:id/file/save', sessionAuth, subProjectCheck, 'fileController.saveFile');
+    app.post('/sp/:id/file/lock', sessionAuth, subProjectCheck, 'fileController.lockFile');
     app.post('/sp/:id/file/move', sessionAuth, subProjectCheck, 'fileController.moveFile');
     app.post('/sp/:id/file/rela', sessionAuth, subProjectCheck, 'fileController.relaFile');
     app.post('/sp/:id/file/rela/tender', sessionAuth, subProjectCheck, 'fileController.loadValidRelaTender');
@@ -618,6 +647,9 @@ module.exports = app => {
     // 安全巡检
     app.get('/sp/:id/safe/inspection', sessionAuth, subProjectCheck, 'safeController.inspectionTender');
     app.get('/sp/:id/safe/tender/:tid/inspection', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspection');
+    app.get('/sp/:id/safe/tender/:tid/inspection/overview', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionOverview');
+    app.get('/sp/:id/safe/tender/:tid/inspection/rectification', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionRectification');
+    app.get('/sp/:id/safe/tender/:tid/inspection/approval', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionApproval');
     app.post('/sp/:id/safe/tender/:tid/inspection/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionSave');
     app.get('/sp/:id/safe/tender/:tid/inspection/:qiid/information', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, safeInspectionCheck, 'safeController.inspectionInformation');
     app.post('/sp/:id/safe/tender/:tid/inspection/:qiid/information/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, safeInspectionCheck, 'safeController.inspectionInformationSave');
@@ -727,6 +759,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');

+ 1 - 1
app/service/advance.js

@@ -156,7 +156,7 @@ module.exports = app => {
             for (let idx = 0; idx < auditors.length; idx++) {
                 const { audit_id } = auditors[idx];
                 await ctx.service.advanceAudit.db.insert(ctx.service.advanceAudit.tableName, {
-                    tid: latestOrder.tid, audit_id, type: latestOrder.type, vid: record.insertId, times: 1, order: idx + 1, status: 1, create_time: new Date(),
+                    tid, audit_id, type, vid: record.insertId, times: 1, order: idx + 1, audit_order: idx + 1, audit_type: 1, status: 1, create_time: new Date(),
                 });
             }
             // auditors.forEach(async (auditor, idx) => {

+ 71 - 39
app/service/advance_audit.js

@@ -15,6 +15,13 @@ module.exports = app => {
             this.tableName = 'advance_audit';
         }
 
+        async checkAuditNodes(vid, times) {
+            const rows = await this.getAllDataByCondition({ where: { vid, times } });
+            if (rows.some(x => !(x.audit_order > 0) || x.audit_type !== auditType.key.common)) {
+                throw '请先回填当前预付款的审批节点序号和普通审批类型';
+            }
+        }
+
         /**
          * 获取审核人流程列表
          * @param {Number} vid 预付款记录id
@@ -22,12 +29,9 @@ module.exports = app => {
          * @return {Promise<Array>} 查询结果集
          */
         async getAuditGroupByList(vid, times = 1) {
-            const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, la.`times`, la.`vid`, la.`order` ' +
-                '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id`' +
-                '  WHERE la.`vid` = ? and la.`times` = ? GROUP BY la.`audit_id` ORDER BY la.`order`';
-            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, times];
-            return await this.db.query(sql, sqlParam);
+            const auditors = await this.getAuditors(vid, times);
+            // 一个节点可因退回、重审产生多条过程记录,流程展示取该节点最新记录。
+            return this._.sortBy(this._.uniqBy(auditors.slice().reverse(), 'audit_order'), 'audit_order');
         }
 
         /**
@@ -39,7 +43,7 @@ module.exports = app => {
         async getAuditorsWithOwner(vid, times = 1) {
             const result = await this.getAuditGroupByList(vid, times);
             const sql =
-                'SELECT pa.`id` As audit_id, pa.`name`, pa.`company`, pa.`role`, ? As times, ? As vid, 0 As `order`' +
+                'SELECT pa.`id` As audit_id, pa.`name`, pa.`company`, pa.`role`, ? As times, ? As vid, 0 As `order`, 0 As audit_order, 1 As audit_type' +
                 '  FROM ' + this.ctx.service.advance.tableName + ' As s' +
                 '  LEFT JOIN ' + this.ctx.service.projectAccount.tableName + ' As pa' +
                 '  ON s.uid = pa.id' +
@@ -72,12 +76,18 @@ module.exports = app => {
          * @return {Boolean} 是否插入成功
          */
         async addAuditor(tid, vid, audit_id, times = 1, type, is_gdzs = 0) {
+            await this.checkAuditNodes(vid, times);
             const transaction = await this.db.beginTransaction();
             try {
-                let newOrder = await this.getNewOrder(vid, times);
+                const audits = await transaction.select(this.tableName, { where: { vid, times }, orders: [['order', 'asc']] });
+                let newOrder = audits.length ? audits[audits.length - 1].order + 1 : 1;
+                let newAuditOrder = audits.length ? Math.max(...audits.map(x => x.audit_order)) + 1 : 1;
                 // 判断是否存在固定终审,存在则newOrder - 1并使终审order+1
-                newOrder = is_gdzs === 1 ? newOrder - 1 : newOrder;
-                if (is_gdzs) await this._syncOrderByDelete(transaction, vid, newOrder, times, '+');
+                if (is_gdzs && audits.length) {
+                    newOrder--;
+                    newAuditOrder--;
+                    await this._syncOrderByDelete(transaction, vid, newOrder, times, '+', newAuditOrder);
+                }
                 const record = {
                     tid,
                     vid,
@@ -85,6 +95,8 @@ module.exports = app => {
                     audit_id,
                     times,
                     order: newOrder,
+                    audit_order: newAuditOrder,
+                    audit_type: auditType.key.common,
                     status: auditConst.status.uncheck,
                 };
                 const result = await transaction.insert(this.tableName, record);
@@ -105,6 +117,7 @@ module.exports = app => {
          * @return {Promise<boolean>}
          */
         async deleteAuditor(vid, audit_id, times = 1) {
+            await this.checkAuditNodes(vid, times);
             const transaction = await this.db.beginTransaction();
             try {
                 const condition = { vid, audit_id, times };
@@ -112,7 +125,7 @@ module.exports = app => {
                 if (!auditor) {
                     throw '该审核人不存在';
                 }
-                await this._syncOrderByDelete(transaction, vid, auditor.order, times);
+                await this._syncOrderByDelete(transaction, vid, auditor.order, times, '-', auditor.audit_order);
                 await transaction.delete(this.tableName, condition);
                 await transaction.commit();
             } catch (err) {
@@ -131,7 +144,7 @@ module.exports = app => {
          * @return {Promise<*>} 查询结果集
          * @private
          */
-        async _syncOrderByDelete(transaction, vid, order, times, selfOperate = '-') {
+        async _syncOrderByDelete(transaction, vid, order, times, selfOperate = '-', auditOrder = order) {
             this.initSqlBuilder();
             this.sqlBuilder.setAndWhere('vid', {
                 value: this.db.escape(vid),
@@ -151,6 +164,8 @@ module.exports = app => {
             });
             const [sql, sqlParam] = this.sqlBuilder.build(this.tableName, 'update');
             const data = await transaction.query(sql, sqlParam);
+            // 节点序号与过程序号分开移动,退回上一审后两者不再相等。
+            await transaction.query('UPDATE ?? SET audit_order = audit_order ' + (selfOperate === '+' ? '+' : '-') + ' 1 WHERE vid = ? AND times = ? AND audit_order >= ?', [this.tableName, vid, times, auditOrder]);
             return data;
         }
 
@@ -162,7 +177,7 @@ module.exports = app => {
          */
         async getCurAuditor(vid, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
+                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`audit_order`, la.`audit_type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
                 '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id` ' +
                 '  WHERE la.`vid` = ? and la.`status` = ? and la.`times` = ?';
             const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, auditConst.status.checking, times];
@@ -177,12 +192,12 @@ module.exports = app => {
          */
         async getAuditors(vid, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time`, g.`sort` ' +
-                'FROM ?? AS la, ?? AS pa, (SELECT t1.`audit_id`,(@i:=@i+1) as `sort` FROM (SELECT t.`audit_id`, t.`order` FROM (select `audit_id`, `order` from ?? WHERE `vid` = ? AND `times` = ? ORDER BY `order` LIMIT 200) t GROUP BY t.`audit_id` ORDER BY t.`order`) t1, (select @i:=0) as it) as g ' +
-                'WHERE la.`vid` = ? and la.`times` = ? and la.`audit_id` = pa.`id` and g.`audit_id` = la.`audit_id` order by la.`order`';
-            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, this.tableName, vid, times, vid, times];
+                'SELECT la.*, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`audit_order` AS `sort` ' +
+                'FROM ?? AS la LEFT JOIN ?? AS pa ON la.audit_id = pa.id ' +
+                'WHERE la.vid = ? AND la.times = ? ORDER BY la.`order`, la.id';
+            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, times];
             const result = await this.db.query(sql, sqlParam);
-            const sql2 = 'SELECT COUNT(a.`audit_id`) as num FROM (SELECT `audit_id` FROM ?? WHERE `vid` = ? AND `times` = ? GROUP BY `audit_id`) as a';
+            const sql2 = 'SELECT MAX(audit_order) AS num FROM ?? WHERE vid = ? AND times = ?';
             const sqlParam2 = [this.tableName, vid, times];
             const count = await this.db.queryOne(sql2, sqlParam2);
             for (const i in result) {
@@ -200,7 +215,7 @@ module.exports = app => {
          */
         async getAuditor(vid, audit_id, times = 1) {
             const sql =
-                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
+                'SELECT la.`audit_id`, pa.`name`, pa.`company`, pa.`role`, pa.`mobile`, pa.`telephone`, la.`times`, la.`audit_order`, la.`audit_type`, la.`order`, la.`status`, la.`opinion`, la.`create_time`, la.`end_time` ' +
                 '  FROM ?? AS la Left Join ?? AS pa On la.`audit_id` = pa.`id` ' +
                 '  WHERE la.`vid` = ? and la.`audit_id` = ? and la.`times` = ?';
             const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, vid, audit_id, times];
@@ -214,6 +229,7 @@ module.exports = app => {
          * @param {Object} data - 载荷
          */
         async start(vid, times = 1, data) {
+            await this.checkAuditNodes(vid, times);
             const audit = await this.getDataByCondition({ vid, times, order: 1 });
             if (!audit) {
                 if(this.ctx.tender.info.shenpi.advance === shenpiConst.sp_status.gdspl) {
@@ -367,15 +383,15 @@ module.exports = app => {
             if (!audit) {
                 throw '审核数据错误';
             }
-            const sql = 'SELECT `tid`, `vid`, `audit_id`, `order` FROM ?? WHERE `vid` = ? and `times` = ? GROUP BY `audit_id` ORDER BY `id` ASC';
+            // 按流程节点去重,保留节点序号和审批类型;退回、重审产生的过程记录不重复复制。
+            const sql = 'SELECT `tid`, `vid`, `type`, `audit_id`, `audit_order`, `audit_type` FROM ?? WHERE `vid` = ? and `times` = ? ' +
+                'GROUP BY `tid`, `vid`, `type`, `audit_id`, `audit_order`, `audit_type` ORDER BY `audit_order` ASC';
             const sqlParam = [this.tableName, advanceId, times];
             const auditors = await this.db.query(sql, sqlParam);
-            let order = 1;
             for (const a of auditors) {
                 a.times = times + 1;
-                a.order = order;
+                a.order = a.audit_order;
                 a.status = auditConst.status.uncheck;
-                order++;
             }
             const transaction = await this.db.beginTransaction();
             try {
@@ -425,7 +441,7 @@ module.exports = app => {
             const time = new Date();
             // 整理当前流程审核人状态更新
             const audit = await this.getDataByCondition({ vid: advanceId, times, status: auditConst.status.checking });
-            if (!audit || audit.order <= 1) {
+            if (!audit || audit.audit_order <= 1) {
                 throw '审核数据错误';
             }
             // 添加重新审批后,不能用order-1,取groupby值里的上一个才对
@@ -448,6 +464,7 @@ module.exports = app => {
                 // 顺移气候审核人流程顺序
                 this.initSqlBuilder();
                 this.sqlBuilder.setAndWhere('vid', { value: advanceId, operate: '=' });
+                this.sqlBuilder.setAndWhere('times', { value: times, operate: '=' });
                 this.sqlBuilder.setAndWhere('order', { value: audit.order, operate: '>' });
                 this.sqlBuilder.setUpdateData('order', { value: 2, selfOperate: '+' });
                 const [sql, sqlParam] = this.sqlBuilder.build(this.tableName, 'update');
@@ -459,6 +476,8 @@ module.exports = app => {
                     audit_id: preAuditor.audit_id,
                     times: audit.times,
                     order: audit.order + 1,
+                    audit_order: preAuditor.audit_order,
+                    audit_type: preAuditor.audit_type,
                     status: auditConst.status.checking,
                     create_time: time,
                 };
@@ -470,6 +489,8 @@ module.exports = app => {
                     audit_id: audit.audit_id,
                     times: audit.times,
                     order: audit.order + 2,
+                    audit_order: audit.audit_order,
+                    audit_type: audit.audit_type,
                     status: auditConst.status.uncheck,
                 };
                 await transaction.insert(this.tableName, uncheckNewAuditors);
@@ -514,6 +535,7 @@ module.exports = app => {
          * @return {Promise<void>}
          */
         async check(advanceId, checkData, times = 1, type) {
+            await this.checkAuditNodes(advanceId, times);
             if (checkData.checkType !== auditConst.status.checked && checkData.checkType !== auditConst.status.checkNo && checkData.checkType !== auditConst.status.checkNoPre) {
                 throw '提交数据错误';
             }
@@ -539,6 +561,7 @@ module.exports = app => {
          * @return {Promise<*>} - 可用的变更令列表
          */
         async checkAgain(advance) {
+            await this.checkAuditNodes(advance.id, advance.times);
             const accountId = this.ctx.session.sessionUser.accountId;
             // 初始化事务
             const time = new Date();
@@ -556,6 +579,7 @@ module.exports = app => {
                     checkAgainAuditors.push({
                         tid: advance.tid, vid: advance.id, audit_id: x.audit_id, type: advance.type,
                         times: x.times, order: maxOrder + 1,
+                        audit_order: x.audit_order, audit_type: x.audit_type,
                         status: auditConst.status.checkAgain,
                         create_time: time, end_time: time, opinion: '',
                     });
@@ -565,6 +589,7 @@ module.exports = app => {
                     checkingAuditors.push({
                         tid: advance.tid, vid: advance.id, audit_id: x.audit_id, type: advance.type,
                         times: x.times, order: maxOrder + 2,
+                        audit_order: x.audit_order, audit_type: x.audit_type,
                         status: auditConst.status.checking,
                         create_time: time,
                     });
@@ -748,6 +773,7 @@ module.exports = app => {
                     newAuditors.push({
                         tid: advance.tid, vid: advance.id, type: advance.type, audit_id: aid,
                         times: advance.times, order, status: auditConst.status.uncheck,
+                        audit_order: order, audit_type: auditType.key.common,
                     });
                     order++;
                 }
@@ -769,7 +795,7 @@ module.exports = app => {
                     await transaction.delete(this.tableName, { vid: advance.id, times: advance.times, audit_id: lastId });
                     const audit = this._.find(auditList, { 'audit_id': lastId });
                     // 顺移之后审核人流程顺序
-                    await this._syncOrderByDelete(transaction, advance.id, audit.order, advance.times);
+                    await this._syncOrderByDelete(transaction, advance.id, audit.order, advance.times, '-', audit.audit_order);
                     order = order - 1;
                 }
 
@@ -777,6 +803,7 @@ module.exports = app => {
                 const newAuditor = {
                     tid: advance.tid, vid: advance.id, type: advance.type, audit_id: lastId,
                     times: advance.times, order, status: auditConst.status.uncheck,
+                    audit_order: order, audit_type: auditType.key.common,
                 };
                 await transaction.insert(this.tableName, newAuditor);
                 await transaction.commit();
@@ -791,13 +818,7 @@ module.exports = app => {
          */
         async getAdminAuditGroups(vid, times) {
             const ownerAndAuditors = await this.getAuditorsWithOwner(vid, times);
-            const owner = ownerAndAuditors[0];
-            const auditors = [owner].concat(await this.getAuditors(vid, times));
-            return auditors.map((item, index) => [{
-                ...item,
-                audit_type: auditType.key.common,
-                audit_order: index,
-            }]);
+            return ownerAndAuditors.map(item => [item]);
         }
 
         /**
@@ -810,12 +831,20 @@ module.exports = app => {
                     where: { vid: advance.id, times: advance.times },
                     orders: [['order', 'asc'], ['id', 'asc']],
                 });
-                const current = audits.find(item => item.audit_id === Number(data.old_aid));
+                const current = audits.slice().reverse().find(item => item.audit_id === Number(data.old_aid));
                 if (!current) throw '审批人不存在';
+                if (audits.some(x => !(x.audit_order > 0) || x.audit_type !== auditType.key.common)) {
+                    throw '请先回填当前预付款的审批节点序号和普通审批类型';
+                }
+                if (data.operate !== 'add' && audits.some(x => x.id !== current.id && x.audit_order === current.audit_order)) {
+                    throw '该节点已有历史审批记录,无法移除或更换';
+                }
 
                 if (data.operate !== 'del') {
                     const newId = Number(data.new_aid);
                     if (!newId) throw '请选择审批人';
+                    if (newId === Number(advance.uid)) throw '原报人不能添加为审批人';
+                    if (data.operate === 'add' && newId === current.audit_id) throw '该审核人已存在,请勿重复添加';
                     if (audits.some(item => item.audit_id === newId && item.id !== current.id)) {
                         throw '该审核人已存在,请勿重复添加';
                     }
@@ -826,7 +855,7 @@ module.exports = app => {
 
                     if (data.operate === 'add') {
                         if ([auditConst.status.uncheck, auditConst.status.checking].indexOf(current.status) < 0) throw '当前节点后无法新增';
-                        await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '+');
+                        await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '+', current.audit_order + 1);
                         await transaction.insert(this.tableName, {
                             tid: advance.tid,
                             vid: advance.id,
@@ -834,6 +863,8 @@ module.exports = app => {
                             audit_id: newId,
                             times: advance.times,
                             order: current.order + 1,
+                            audit_order: current.audit_order + 1,
+                            audit_type: auditType.key.common,
                             status: auditConst.status.uncheck,
                         });
                     } else if (data.operate === 'change') {
@@ -845,20 +876,21 @@ module.exports = app => {
                 } else {
                     if (current.status !== auditConst.status.uncheck) throw '当前审批人无法移除';
                     await transaction.delete(this.tableName, { id: current.id });
-                    await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times);
+                    await this._syncOrderByDelete(transaction, advance.id, current.order + 1, advance.times, '-', current.audit_order + 1);
                 }
 
                 // 同步固定审批配置,避免中间件再次用旧配置覆盖本次调整。
                 const shenpiStatus = this.ctx.tender.info.shenpi.advance;
-                const newAudits = await transaction.select(this.tableName, {
+                const allNewAudits = await transaction.select(this.tableName, {
                     where: { vid: advance.id, times: advance.times },
-                    orders: [['order', 'asc'], ['id', 'asc']],
+                    orders: [['audit_order', 'asc'], ['order', 'desc']],
                 });
+                const newAudits = this._.uniqBy(allNewAudits, 'audit_order');
                 if (shenpiStatus === shenpiConst.sp_status.gdspl) {
-                    const groups = newAudits.map((item, index) => [{
+                    const groups = newAudits.map(item => [{
                         ...item,
                         audit_type: auditType.key.common,
-                        audit_order: index + 1,
+                        audit_order: item.audit_order,
                     }]);
                     await this.ctx.service.shenpiAudit.updateAuditListWithAuditType(
                         transaction, this.ctx.tender.id, shenpiStatus, shenpiConst.sp_type.advance, groups, advance.sp_group || 0

Разница между файлами не показана из-за своего большого размера
+ 2228 - 0
app/service/assistant.js


+ 77 - 0
app/service/category.js

@@ -221,6 +221,83 @@ module.exports = app => {
             return data;
         }
 
+        /**
+         * 按标段自定义分类生成合同列设置的复制目标树
+         * @param {Object} subProject - 当前子项目
+         * @param {Number} currentTenderId - 当前标段id
+         * @returns {Promise<Array>} - 按树顺序排列的分类和标段
+         */
+        async getContractCopyTenderTreeNodes(subProject, currentTenderId) {
+            const category = await this.getAllCategory(subProject);
+            const levelCategory = category.filter(item => item.level && item.level > 0)
+                .sort((a, b) => a.level - b.level);
+            const tenders = await this.ctx.service.tender.getContractList('', null, 1);
+            const root = {
+                id: 'sub-project-root', name: subProject.name, tree_level: 1, is_folder: true, children: [],
+            };
+
+            for (const tender of tenders) {
+                const tenderCategory = Array.isArray(tender.category) ? tender.category : [];
+                let parent = root;
+                let categoryPath = [];
+                for (const [index, item] of levelCategory.entries()) {
+                    const selected = tenderCategory.find(value => String(value.cid) === String(item.id));
+                    if (selected) {
+                        categoryPath.push({ item, valueId: selected.value });
+                    } else {
+                        if (index === 0) {
+                            categoryPath = tenderCategory.map(value => ({
+                                item: category.find(cate => String(cate.id) === String(value.cid)), valueId: value.value,
+                            })).filter(value => value.item);
+                        }
+                        break;
+                    }
+                }
+                for (const pathItem of categoryPath) {
+                    const categoryValue = pathItem.item.value.find(value => String(value.id) === String(pathItem.valueId));
+                    if (!categoryValue) break;
+                    let folder = parent.children.find(child => child.is_folder &&
+                        String(child.cid) === String(pathItem.item.id) && String(child.vid) === String(pathItem.valueId));
+                    if (!folder) {
+                        folder = {
+                            id: `category-${pathItem.item.id}-${pathItem.valueId}`,
+                            cid: pathItem.item.id,
+                            vid: pathItem.valueId,
+                            name: categoryValue.value,
+                            sort: categoryValue.sort,
+                            tree_level: parent.tree_level + 1,
+                            is_folder: true,
+                            children: [],
+                        };
+                        parent.children.push(folder);
+                    }
+                    parent = folder;
+                }
+                parent.children.push({
+                    id: tender.id,
+                    name: tender.name,
+                    tree_level: parent.tree_level + 1,
+                    is_folder: false,
+                    current: String(tender.id) === String(currentTenderId),
+                });
+            }
+
+            const result = [];
+            const stack = [root];
+            while (stack.length) {
+                const node = stack.pop();
+                result.push(node);
+                if (!node.children) continue;
+                node.children.sort((a, b) => {
+                    if (a.is_folder !== b.is_folder) return a.is_folder ? -1 : 1;
+                    if (a.is_folder) return Number(a.sort || 0) - Number(b.sort || 0);
+                    return a.name.localeCompare(b.name, 'zh-Hans-CN');
+                });
+                for (let index = node.children.length - 1; index >= 0; index--) stack.push(node.children[index]);
+            }
+            return result;
+        }
+
     }
 
     return Category;

+ 12 - 0
app/service/change_audit_list.js

@@ -504,12 +504,19 @@ module.exports = app => {
                 if (g) {
                     const list = this._.filter(changeList, { gcl_id: g });
                     let spamount = 0;
+                    let positive_amount = 0;
+                    let negative_amount = 0;
                     let valuation_amount = 0;
                     let unvaluation_amount = 0;
                     let unitPrice = 0;
                     for (const cl of list) {
                         if (cl.spamount) {
                             spamount = this.ctx.helper.add(spamount, cl.spamount);
+                            if (cl.spamount >= 0) {
+                                positive_amount = this.ctx.helper.add(positive_amount, cl.spamount);
+                            } else {
+                                negative_amount = this.ctx.helper.add(negative_amount, cl.spamount);
+                            }
                             unitPrice = cl.unit_price;
                             if (cl.is_valuation) {
                                 valuation_amount = this.ctx.helper.add(valuation_amount, cl.spamount);
@@ -525,6 +532,11 @@ module.exports = app => {
                         }
                     }
                     const price = this.ctx.helper.mul(spamount, this.ctx.helper.round(unitPrice, up_decimal), tp_decimal);
+                    // 正负数量分别汇总后计算金额,避免相互抵消。
+                    const positive_price = this.ctx.helper.mul(positive_amount, this.ctx.helper.round(unitPrice, up_decimal), tp_decimal) || 0;
+                    const negative_price = this.ctx.helper.mul(negative_amount, this.ctx.helper.round(unitPrice, up_decimal), tp_decimal) || 0;
+                    positive_tp = this.ctx.helper.add(positive_tp, positive_price);
+                    negative_tp = this.ctx.helper.add(negative_tp, negative_price);
                     const valuation_price = this.ctx.helper.mul(valuation_amount, this.ctx.helper.round(unitPrice, up_decimal), tp_decimal) || 0;
                     const unvaluation_price = this.ctx.helper.mul(unvaluation_amount, this.ctx.helper.round(unitPrice, up_decimal), tp_decimal) || 0;
                     valuation_tp = this.ctx.helper.add(valuation_tp, valuation_price);

+ 1 - 1
app/service/change_plan.js

@@ -82,7 +82,7 @@ module.exports = app => {
             const codeCount = await this.db.queryOne(sql, sqlParam);
             const count = codeCount.count;
             if (count > 0) {
-                throw '变更方案编号重复';
+                throw (this.ctx.subProject && this.ctx.subProject.fun_set && this.ctx.subProject.fun_set.change_plan_name || '变更方案') + '编号重复';
             }
 
             // 初始化事务

+ 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;

+ 143 - 13
app/service/file.js

@@ -10,6 +10,11 @@
 
 const path = require('path');
 
+const COST_FILE_EXTENSIONS = [
+    '.cpd', '.ctd', '.yup', '.ybpx', '.sbp', '.jygs', '.gkgs', '.sjgs',
+    '.sgys', '.qdys', '.gcjs', '.sjys',
+];
+
 module.exports = app => {
     class File extends app.BaseService {
 
@@ -32,7 +37,8 @@ module.exports = app => {
             files.forEach(x => {
                 x.filepath = ossPath + x.filepath;
                 x.viewpath = helper.getPreviewPath(x.fileext, x.filepath);
-                x.fileext_str = helper.fileExtStr(x.fileext);
+                const fileExt = String(x.fileext || '').toLowerCase();
+                x.fileext_str = COST_FILE_EXTENSIONS.indexOf(fileExt) >= 0 ? '造价文件' : helper.fileExtStr(fileExt);
                 x.canEdit = x.user_id === userId;
             });
         }
@@ -50,6 +56,44 @@ module.exports = app => {
             return files.filter(x => { return existFilesName.indexOf(x) >= 0; });
         }
 
+        async _checkCanEditFile(file) {
+            if (!file || file.spid !== this.ctx.subProject.id || file.is_deleted) throw '文件不存在';
+            if (Number(file.is_locked) === 1) throw '文件已锁定,无法编辑、移动或删除';
+            const isAdmin = Number(this.ctx.session.sessionUser.is_admin) === 1;
+            if (isAdmin) return;
+            const permission = await this.ctx.service.subProjectFilingPermission.getResolvedPermission(
+                this.ctx.subProject.id,
+                this.ctx.session.sessionUser.accountId,
+                file.filing_id,
+                file.filing_type,
+                this.ctx.subProject.permission.file_permission,
+                this.ctx.subProject.permission.filing_type,
+                isAdmin
+            );
+            if (!permission.can_edit_file) throw '您没有该目录的编辑文件权限';
+        }
+
+        async setLocked(id, isLocked) {
+            const locked = Number(isLocked);
+            if (locked !== 0 && locked !== 1) throw '文件锁定状态错误';
+            const file = await this.getDataById(id);
+            if (!file || file.spid !== this.ctx.subProject.id || file.is_deleted) throw '文件不存在';
+            const isAdmin = Number(this.ctx.session.sessionUser.is_admin) === 1;
+            const permission = await this.ctx.service.subProjectFilingPermission.getResolvedPermission(
+                this.ctx.subProject.id,
+                this.ctx.session.sessionUser.accountId,
+                file.filing_id,
+                file.filing_type,
+                this.ctx.subProject.permission.file_permission,
+                this.ctx.subProject.permission.filing_type,
+                isAdmin
+            );
+            if (!permission.can_lock_file) throw '您没有该目录的锁定文件权限';
+            const updateData = { id: file.id, is_locked: locked };
+            await this.defaultUpdate(updateData);
+            return updateData;
+        }
+
         async addFiles(filing, fileInfo, user) {
             const conn = await this.db.beginTransaction();
             const result = {};
@@ -79,11 +123,11 @@ module.exports = app => {
             if (files.length === 0) return;
 
             const fileDatas = await this.getAllDataByCondition({ where: { id: files } });
+            if (fileDatas.length !== files.length) throw '文件不存在';
             const filing = await this.ctx.service.filing.getDataById(fileDatas[0].filing_id);
-            if (this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) {
-                for (const file of fileDatas) {
-                    if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '无权删除文件';
-                }
+            for (const file of fileDatas) {
+                if (file.filing_id !== filing.id) throw '请选择同一资料类别下的文件';
+                await this._checkCanEditFile(file);
             }
             const result = {};
 
@@ -129,10 +173,10 @@ module.exports = app => {
             return result;
         }
 
-        async saveFile(id, filename){
+        async saveFile(id, filename) {
             const file = await this.getDataById(id);
             if (!file) throw '文件不存在';
-            if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '您无权编辑该文件';
+            await this._checkCanEditFile(file);
 
             const info = path.parse(filename);
             const updateData = { id, filename: info.name, fileext: info.ext};
@@ -143,10 +187,17 @@ module.exports = app => {
         async moveFile(id, filing_id) {
             const file = await this.getDataById(id);
             if (!file) throw '文件不存在';
-            if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '您无权编辑该文件';
+            await this._checkCanEditFile(file);
             const orgFiling = await this.ctx.service.filing.getDataById(file.filing_id);
             const filing = await this.ctx.service.filing.getDataById(filing_id);
-            if (!filing) throw '目标分类不存在';
+            if (!filing || filing.spid !== this.ctx.subProject.id || filing.is_deleted) throw '目标分类不存在';
+            if (String(file.filing_id) === String(filing.id)) throw '文件已在目标目录';
+            const childCount = await this.ctx.service.filing.count({
+                spid: this.ctx.subProject.id,
+                tree_pid: filing.id,
+                is_deleted: 0,
+            });
+            if (childCount > 0) throw '目标目录必须是最底层目录';
 
             const conn = await this.db.beginTransaction();
             try {
@@ -162,16 +213,95 @@ module.exports = app => {
             }
         }
 
+        async moveFilingFiles(sourceFiling, targetFiling) {
+            const spid = this.ctx.subProject.id;
+            if (!sourceFiling || sourceFiling.spid !== spid || sourceFiling.is_deleted) {
+                throw '源目录不存在,请刷新页面后重试';
+            }
+            if (!targetFiling || targetFiling.spid !== spid || targetFiling.is_deleted) {
+                throw '目标目录不存在,请刷新页面后重试';
+            }
+
+            const filingService = this.ctx.service.filing;
+            const filingList = await filingService.getValidFiling(spid, 'all');
+            const childrenMap = {};
+            filingList.forEach(filing => {
+                const parentId = String(filing.tree_pid);
+                if (!childrenMap[parentId]) childrenMap[parentId] = [];
+                childrenMap[parentId].push(filing);
+            });
+
+            const sourceId = String(sourceFiling.id);
+            const targetId = String(targetFiling.id);
+            const sourceFilingIds = new Set([sourceId]);
+            const pending = [sourceId];
+            while (pending.length > 0) {
+                const parentId = pending.shift();
+                const children = childrenMap[parentId] || [];
+                children.forEach(child => {
+                    const childId = String(child.id);
+                    if (sourceFilingIds.has(childId)) return;
+                    sourceFilingIds.add(childId);
+                    pending.push(childId);
+                });
+            }
+
+            if (sourceFilingIds.has(targetId)) throw '目标目录不能位于当前目录及其子目录中';
+            if ((childrenMap[targetId] || []).length > 0) throw '目标目录必须是最底层目录';
+
+            const sourceIds = Array.from(sourceFilingIds);
+            const conn = await this.db.beginTransaction();
+            try {
+                const movedCount = await conn.count(this.tableName, {
+                    spid,
+                    filing_id: sourceIds,
+                    is_deleted: 0,
+                });
+                if (movedCount <= 0) throw '当前目录及子目录暂无可移动文件';
+
+                await conn.query(
+                    `UPDATE ${this.tableName} SET filing_id = ?, filing_type = ? ` +
+                    'WHERE spid = ? AND is_deleted = 0 AND filing_id IN (?)',
+                    [targetFiling.id, targetFiling.filing_type, spid, sourceIds]
+                );
+                const targetCount = await conn.count(this.tableName, {
+                    spid,
+                    filing_id: targetFiling.id,
+                    is_deleted: 0,
+                });
+                const filingUpdates = sourceIds.map(id => { return { id, file_count: 0 }; });
+                filingUpdates.push({ id: targetFiling.id, file_count: targetCount });
+                await conn.updateRows(filingService.tableName, filingUpdates);
+                await conn.commit();
+                return { moved_count: movedCount, filings: filingUpdates };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+
         async search(filing_type, keyword, limit = 1000) {
             if (!filing_type || filing_type.length === 0 || !keyword) return [];
             const sql = `SELECT * FROM ${this.tableName}` +
-                `  WHERE spid = ? and is_deleted = 0 and filing_type in (${filing_type.join(',')}) and filename like '%${keyword}%'`+
-                `  ORDER BY update_time DESC LIMIT 0, ${limit}`;
-            const result = await this.db.query(sql, [this.ctx.subProject.id]);
+                '  WHERE spid = ? and is_deleted = 0 and filing_type in (?) and filename like ?' +
+                '  ORDER BY update_time DESC LIMIT 0, ?';
+            const result = await this.db.query(sql, [this.ctx.subProject.id, filing_type, `%${keyword}%`, Number(limit)]);
+            this.analysisFiles(result);
+            return result;
+        }
+
+        async searchByFilingIds(filingIds, keyword, limit = 1000) {
+            if (!filingIds || filingIds.length === 0 || !keyword) return [];
+            const sql = `SELECT * FROM ${this.tableName}` +
+                '  WHERE spid = ? and is_deleted = 0 and filing_id in (?) and filename like ?' +
+                '  ORDER BY update_time DESC LIMIT 0, ?';
+            const result = await this.db.query(sql, [
+                this.ctx.subProject.id, filingIds, `%${keyword}%`, Number(limit),
+            ]);
             this.analysisFiles(result);
             return result;
         }
     }
 
     return File;
-};
+};

+ 219 - 21
app/service/filing.js

@@ -92,9 +92,10 @@ module.exports = app => {
                 f.newId = this.uuid.v4();
                 const parent = f.tree_pid !== rootId ? templateFiling.find(x => { return x.id === f.tree_pid; }) : null;
                 const newData = {
-                    id: f.newId, tree_pid : parent ? parent.newId : rootId, tree_level: f.tree_level, tree_order: f.tree_order,
-                    spid, add_user_id: this.ctx.session.sessionUser.accountId, is_fixed: f.is_fixed,
-                    filing_type: f.filing_type, name: f.name, tips: f.tips, file_company: f.file_company,
+                    id: f.newId, tree_pid: parent ? parent.newId : rootId, tree_level: f.tree_level, tree_order: f.tree_order,
+                    spid, add_user_id: this.ctx.session.sessionUser.accountId,
+                    create_uid: this.ctx.session.sessionUser.accountId, is_fixed: f.is_fixed,
+                    filing_type: f.filing_type, name: f.name, tips: f.tips, upload_tips: f.upload_tips, file_company: f.file_company,
                 };
                 insertData.push(newData);
             }
@@ -105,6 +106,100 @@ module.exports = app => {
             }
         }
 
+        _buildInitializedFiling(spid, sourceData, operatorUid) {
+            const source = (sourceData || []).slice().sort((x, y) => {
+                const levelDiff = Number(x.tree_level) - Number(y.tree_level);
+                return levelDiff || Number(x.tree_order) - Number(y.tree_order);
+            });
+            const sourceNodeMap = {};
+            const insertData = [];
+            for (const node of source) {
+                const sourceId = String(node.id || '');
+                if (!sourceId || sourceNodeMap[sourceId]) throw '来源项目目录数据错误';
+                const sourceParentId = String(node.tree_pid);
+                const isRoot = sourceParentId === rootId;
+                const parent = isRoot ? null : sourceNodeMap[sourceParentId];
+                if (!isRoot && !parent) throw '来源项目目录结构不完整';
+
+                const newNode = {
+                    id: this.uuid.v4(),
+                    spid,
+                    tree_pid: parent ? parent.id : rootId,
+                    tree_level: parent ? Number(parent.tree_level) + 1 : 1,
+                    tree_order: Number(node.tree_order),
+                    name: String(node.name || ''),
+                    filing_type: Number(node.filing_type),
+                    add_user_id: Number(operatorUid),
+                    create_uid: Number(operatorUid),
+                    is_fixed: Number(node.is_fixed) === 1 ? 1 : 0,
+                    is_deleted: 0,
+                    file_count: 0,
+                    tips: node.tips || '',
+                    upload_tips: node.upload_tips || '',
+                    file_company: node.file_company || '',
+                    is_rela: Number(node.is_rela) === 1 ? 1 : 0,
+                };
+                if (!newNode.name || !Number.isInteger(newNode.filing_type) || newNode.filing_type <= 0 ||
+                    !Number.isInteger(newNode.tree_order) || newNode.tree_order <= 0) {
+                    throw '来源项目目录数据错误';
+                }
+                sourceNodeMap[sourceId] = newNode;
+                insertData.push(newNode);
+            }
+            return insertData;
+        }
+
+        /**
+         * 首次初始化项目资料目录。
+         *
+         * 空白模式创建一个可继续编辑的根目录;模板和项目模式只复制目录字段,
+         * 不复制文件与目录授权用户配置。
+         *
+         * @param {String} spid 目标子项目ID
+         * @param {String} initType 初始化方式(blank/template/project)
+         * @param {Array} sourceData 来源模板或项目的有效目录
+         * @param {Number} operatorUid 初始化用户ID
+         * @param {Object|null} templateData 选用的系统模板
+         * @return {Object} 初始化结果
+         */
+        async initializeDirectory(spid, initType, sourceData, operatorUid, templateData = null) {
+            let directorySource = sourceData || [];
+            if (initType === 'blank') {
+                directorySource = [{
+                    id: 'blank-root', tree_pid: rootId, tree_level: 1, tree_order: 1,
+                    name: '新建文件夹', filing_type: maxFilingType + 1, is_fixed: 1,
+                    tips: '', upload_tips: '', file_company: '', is_rela: 0,
+                }];
+            } else if (initType !== 'project' && initType !== 'template') {
+                throw '初始化方式错误';
+            }
+            if (initType === 'template' && (!templateData || !templateData.id)) throw '系统模板数据错误';
+            if (directorySource.length === 0) throw '来源数据没有可复制的资料目录';
+            const insertData = this._buildInitializedFiling(spid, directorySource, operatorUid);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.query('SELECT id FROM ?? WHERE id = ? FOR UPDATE', [
+                    this.ctx.service.subProject.tableName, spid,
+                ]);
+                const activeCount = await conn.count(this.tableName, { spid, is_deleted: 0 });
+                if (activeCount > 0) throw '当前项目已经存在资料目录,无需重复初始化';
+                await conn.insert(this.tableName, insertData);
+                if (initType === 'template') {
+                    await conn.update(this.ctx.service.subProject.tableName, {
+                        id: spid,
+                        filing_template_id: templateData.id,
+                        filing_template_name: templateData.name,
+                    });
+                }
+                await conn.commit();
+                return { count: insertData.length };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+
         _filterValidFiling(filing, filingType) {
             const validFiling = filing.filter(x => { return filingType.indexOf(x.filing_type) > -1;});
             const checkParent = function(child) {
@@ -180,6 +275,7 @@ module.exports = app => {
                 const name = await this.getNewName(this.ctx.subProject.id);
                 const insertData = {
                     id: this.uuid.v4(), spid: this.ctx.subProject.id, add_user_id: sessionUser.accountId,
+                    create_uid: sessionUser.accountId,
                     tree_pid: parent ? parent.id : rootId, tree_level: parent ? parent.tree_level + 1 : 1, tree_order,
                     name, filing_type: filing_type, is_fixed: parent ? 0 : 1
                 };
@@ -230,6 +326,9 @@ module.exports = app => {
                 await conn.updateRows(this.tableName, delData);
                 if (updateData.length > 0) conn.updateRows(this.tableName, updateData);
                 await conn.update(this.ctx.service.file.tableName, { is_deleted: 1}, { where: {filing_id: delData.map(x => { return x.id; })} });
+                await this.ctx.service.subProjectFilingPermission.removeFilingNodes(
+                    filing.spid, delData.map(x => x.id), conn
+                );
 
                 await conn.commit();
                 return { delete: delData.map(x => { return x.id }), update: updateData };
@@ -240,30 +339,57 @@ module.exports = app => {
         }
         async move(data) {
             const filing = await this.getDataById(data.id);
-            if (!filing) throw '移动的分类不存在,请刷新页面后重试';
-            const parent = await this.getDataById(data.tree_pid);
-            if (!parent && filing.tree_pid !== data.tree_pid) throw '移动后的分类不存在,请刷新页面后重试';
-            const sibling = await this.getAllDataByCondition({ where: { spid: filing.spid, tree_pid: data.tree_pid, is_deleted: 0 } });
+            if (!filing || filing.is_deleted) throw '移动的分类不存在,请刷新页面后重试';
+            if (this.ctx.subProject && filing.spid !== this.ctx.subProject.id) throw '移动的分类不属于当前项目';
+            if (Number(filing.is_fixed)) throw '固定分类不可移动';
+            if (data.tree_pid === undefined || data.tree_pid === null || data.tree_pid === '') throw '请选择移动后的目录';
+            const treePid = String(data.tree_pid);
+            const treeOrder = Number(data.tree_order);
+            if (!Number.isInteger(treeOrder) || treeOrder < 0) throw '移动后的目录顺序错误';
             const posterity = await this.getPosterityData(filing.id);
-            const updateData = { id: filing.id, tree_order: data.tree_order, tree_pid: data.tree_pid, tree_level: (parent ? parent.tree_level : 0) + 1 };
+            const filingWithFiles = [filing, ...posterity].find(x => !x.is_deleted && Number(x.file_count) > 0);
+            if (filingWithFiles) throw `分类【${filingWithFiles.name}】下存在文件,不可移动目录`;
+            if (treePid === String(filing.id) || posterity.find(x => String(x.id) === treePid)) {
+                throw '不能将目录移动到自身或其子目录下';
+            }
+            const parent = treePid === rootId ? null : await this.getDataById(treePid);
+            if (treePid !== rootId && (!parent || parent.is_deleted || parent.spid !== filing.spid)) {
+                throw '移动后的分类不存在,请刷新页面后重试';
+            }
+            if (parent && String(filing.tree_pid) !== treePid && Number(parent.file_count) > 0) {
+                throw `分类【${parent.name}】下存在文件,不可添加子分类`;
+            }
+            const sibling = await this.getAllDataByCondition({ where: { spid: filing.spid, tree_pid: treePid, is_deleted: 0 } });
+            const updateData = { id: filing.id, tree_order: treeOrder, tree_pid: treePid, tree_level: (parent ? parent.tree_level : 0) + 1 };
+            if (data.name !== undefined) {
+                const name = String(data.name).trim();
+                if (!name) throw '目录名称不能为空';
+                if (name.length > 100) throw '目录名称不能超过100个字符';
+                updateData.name = name;
+            }
+            if (data.is_fixed !== undefined) {
+                const isFixed = Number(data.is_fixed);
+                if (isFixed !== 0 && isFixed !== 1) throw '固定目录状态错误';
+                updateData.is_fixed = isFixed;
+            }
             const posterityUpdateData = posterity.map(x => {
-               return { id: x.id,  tree_level: (parent ? parent.tree_level : 0) + 1 - filing.tree_level + x.tree_level };
+                return { id: x.id, tree_level: (parent ? parent.tree_level : 0) + 1 - filing.tree_level + x.tree_level };
             });
             const siblingUpdateData = [];
-            if (data.tree_pid === filing.tree_pid) {
-                if (data.tree_order < filing.tree_order) {
+            if (treePid === String(filing.tree_pid)) {
+                if (treeOrder < filing.tree_order) {
                     sibling.forEach(x => {
                         if (x.id === filing.id) return;
-                        if (x.tree_order < data.tree_order) return;
+                        if (x.tree_order < treeOrder) return;
                         if (x.tree_order > filing.tree_order) return;
-                        siblingUpdateData.push({id: x.id, tree_order: x.tree_order + 1});
+                        siblingUpdateData.push({ id: x.id, tree_order: x.tree_order + 1 });
                     });
                 } else {
                     sibling.forEach(x => {
                         if (x.id === filing.id) return;
                         if (x.tree_order < filing.tree_order) return;
-                        if (x.tree_order > data.tree_order) return;
-                        siblingUpdateData.push({id: x.id, tree_order: x.tree_order - 1});
+                        if (x.tree_order > treeOrder) return;
+                        siblingUpdateData.push({ id: x.id, tree_order: x.tree_order - 1 });
                     });
                 }
             } else {
@@ -271,13 +397,13 @@ module.exports = app => {
                 orgSibling.forEach(x => {
                     if (x.id === filing.id) return;
                     if (x.tree_order < filing.tree_order) return;
-                    siblingUpdateData.push({id: x.id, tree_order: x.tree_order - 1});
+                    siblingUpdateData.push({ id: x.id, tree_order: x.tree_order - 1 });
                 });
                 sibling.forEach(x => {
                     if (x.id === filing.id) return;
-                    if (x.tree_order < data.tree_order) return;
-                    siblingUpdateData.push({id: x.id, tree_order: x.tree_order + 1});
-                })
+                    if (x.tree_order < treeOrder) return;
+                    siblingUpdateData.push({ id: x.id, tree_order: x.tree_order + 1 });
+                });
             }
 
             const conn = await this.db.beginTransaction();
@@ -293,12 +419,47 @@ module.exports = app => {
             return { update: [updateData, ...posterityUpdateData, ...siblingUpdateData] };
         }
 
+        async editDirectory(data) {
+            if (!data || !data.id) throw '请选择需要编辑的目录';
+            const filing = await this.getDataById(data.id);
+            if (!filing || filing.is_deleted) throw '编辑的分类不存在,请刷新页面后重试';
+            if (this.ctx.subProject && filing.spid !== this.ctx.subProject.id) throw '编辑的分类不属于当前项目';
+            const name = String(data.name || '').trim();
+            if (!name) throw '目录名称不能为空';
+            if (name.length > 100) throw '目录名称不能超过100个字符';
+            const isFixed = Number(data.is_fixed);
+            if (isFixed !== 0 && isFixed !== 1) throw '固定目录状态错误';
+            if (data.tree_pid === undefined || data.tree_pid === null || data.tree_pid === '') throw '请选择目录';
+            const treePid = String(data.tree_pid);
+            if (treePid === String(filing.tree_pid)) {
+                const updateData = { id: filing.id, name, is_fixed: isFixed };
+                const result = await this.db.update(this.tableName, updateData);
+                if (result.affectedRows === 0) throw '更新目录失败';
+                return { update: [updateData] };
+            }
+
+            const parent = treePid === rootId ? null : await this.getDataById(treePid);
+            if (treePid !== rootId && (!parent || parent.is_deleted || parent.spid !== filing.spid)) {
+                throw '移动后的分类不存在,请刷新页面后重试';
+            }
+            const targetChildren = await this.getAllDataByCondition({
+                where: { spid: filing.spid, tree_pid: treePid, is_deleted: 0 },
+                orders: [['tree_order', 'asc']],
+            });
+            const lastChild = targetChildren.length > 0 ? targetChildren[targetChildren.length - 1] : null;
+            const treeOrder = lastChild ? Number(lastChild.tree_order) + 1 : 1;
+            return await this.move({
+                id: filing.id, tree_pid: treePid, tree_order: treeOrder,
+                name, is_fixed: isFixed,
+            });
+        }
+
         async multiUpdate(spid, data) {
             if (!data || data.length === 0) throw '提交数据格式错误';
 
             const sourceData = await this.getAllDataByCondition({ where: { spid } });
 
-            const validFields = ['id', 'is_fixed', 'name', 'filing_type', 'tree_order', 'tips', 'file_company'];
+            const validFields = ['id', 'is_fixed', 'name', 'filing_type', 'tree_order', 'tips', 'upload_tips', 'file_company'];
             const updateData = [];
             for (const d of data) {
                 if (!d.id) throw '提交数据格式错误';
@@ -320,7 +481,44 @@ module.exports = app => {
             const result = await this.db.queryOne(`SELECT SUM(file_count) AS file_count FROM ${this.tableName} WHERE spid = '${spid}' and is_deleted = 0`);
             return result.file_count;
         }
+
+        async saveNames(spid, items) {
+            if (!Array.isArray(items) || items.length === 0) throw '请选择需要替换名称的分类';
+            const ids = new Set();
+            const updateData = items.map(item => {
+                if (!item || typeof item.id !== 'string' || !item.id || ids.has(item.id)) throw '分类数据格式错误';
+                ids.add(item.id);
+                if (typeof item.name !== 'string' || typeof item.old_name !== 'string') throw '分类名称格式错误';
+                const name = item.name.trim();
+                if (!name) throw '目录名称不能为空';
+                if (name.length > 100) throw '目录名称不能超过100个字符';
+                return { id: item.id, name, old_name: item.old_name };
+            });
+            const conn = await this.db.beginTransaction();
+            try {
+                const result = [];
+                // 按 ID 更新,保持多批次重叠操作的加锁顺序一致。
+                const sortedData = [...updateData].sort((a, b) => a.id.localeCompare(b.id));
+                for (const item of sortedData) {
+                    const filing = await conn.get(this.tableName, { id: item.id, spid, is_deleted: 0 });
+                    if (!filing) throw '部分分类不存在,请刷新页面后重新粘贴';
+                    if (Number(filing.is_fixed)) throw '固定分类不可修改名称';
+                    if (filing.name !== item.old_name) throw '分类名称已发生变化,请刷新页面后重新粘贴';
+                    if (item.name === item.old_name) continue;
+                    const operation = await conn.update(this.tableName, { name: item.name }, {
+                        where: { id: item.id, spid, is_deleted: 0, is_fixed: 0, name: item.old_name },
+                    });
+                    if (operation.affectedRows !== 1) throw '分类信息已发生变化,请刷新页面后重新粘贴';
+                    result.push({ id: item.id, name: item.name });
+                }
+                await conn.commit();
+                return { update: result };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
     }
 
     return Filing;
-};
+};

+ 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';

+ 41 - 118
app/service/material_exponent.js

@@ -232,6 +232,7 @@ module.exports = app => {
                     case materialConst.exponent_status.shared_noNode: {
                         const exponentInfo = await transaction.get(this.tableName, { id: data.id, tid: this.ctx.tender.id });
                         if (!exponentInfo) throw '指数工料数据不存在';
+                        this._checkRiskEditPermission(data, exponentInfo);
                         this._setEffectivePrice(exponentInfo, data);
                         await transaction.update(this.tableName, data);
                         [result.ex_tp, result.ex_tax_tp, result.ex_expr] = await this.calcMaterialExTp(transaction);
@@ -248,7 +249,9 @@ module.exports = app => {
                         if (info.needUp) {
                             result.exponentData = await transaction.select(this.tableName, { where: { id: data.id } });
                         }
-                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { mid: this.ctx.material.id, mn_id, me_id: data.id } });
+                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, {
+                            where: info.needCalc ? { mid: this.ctx.material.id } : { mid: this.ctx.material.id, mn_id, me_id: data.id },
+                        });
                         result.nodeData = info.needCalc ? await transaction.select(this.ctx.service.materialExponentNode.tableName, { where: { mid: this.ctx.material.id } }) :
                             await transaction.select(this.ctx.service.materialExponentNode.tableName, { where: { mid: this.ctx.material.id, id: mn_id } });
                         break;
@@ -262,7 +265,9 @@ module.exports = app => {
                         if (info.needUp) {
                             result.exponentData = await transaction.select(this.tableName, { where: { id: data.id } });
                         }
-                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { mid: this.ctx.material.id, ms_id, me_id: data.id } });
+                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, {
+                            where: info.needCalc ? { mid: this.ctx.material.id } : { mid: this.ctx.material.id, ms_id, me_id: data.id },
+                        });
                         result.stageData = info.needCalc ? await transaction.select(this.ctx.service.materialStage.tableName, { where: { mid: this.ctx.material.id } }) :
                             await transaction.select(this.ctx.service.materialStage.tableName, { where: { mid: this.ctx.material.id, id: ms_id } });
                         break;
@@ -276,7 +281,9 @@ module.exports = app => {
                         if (info.needUp) {
                             result.exponentData = await transaction.select(this.tableName, { where: { id: data.id } });
                         }
-                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { mid: this.ctx.material.id, ms_id, mn_id, me_id: data.id } });
+                        result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, {
+                            where: info.needCalc ? { mid: this.ctx.material.id } : { mid: this.ctx.material.id, ms_id, mn_id, me_id: data.id },
+                        });
                         result.stageData = info.needCalc ? await transaction.select(this.ctx.service.materialStage.tableName, { where: { mid: this.ctx.material.id } }) :
                             await transaction.select(this.ctx.service.materialStage.tableName, { where: { mid: this.ctx.material.id, id: ms_id } });
                         result.nodeData = info.needCalc ? await transaction.select(this.ctx.service.materialExponentNode.tableName, { where: { mid: this.ctx.material.id } }) :
@@ -284,9 +291,6 @@ module.exports = app => {
                         break;
                     default: break;
                 }
-                if (this.ctx.material.order < this.ctx.material.highOrder && this.ctx.session.sessionUser.is_admin) {
-                    await this._syncHistoricalTotals(transaction);
-                }
                 await transaction.commit();
                 return result;
             } catch (err) {
@@ -297,14 +301,15 @@ module.exports = app => {
 
         async _updateOneExponentData(transaction, data, ms_id = null, mn_id = null) {
             // 当以下值和exponent值不相同时,需要同步更新最新的计算表达式和金额到stage表里,无需更新到exponentShard表
-            const updateColsArray = ['symbol', 'symbol_desc', 'code', 'basic_price', 'is_summary', 'basic_times'];
-            const updateCalcArray = ['weight_num', 'basic_price', 'is_summary'];
+            const updateColsArray = ['symbol', 'symbol_desc', 'code', 'basic_price', 'm_up_risk', 'm_down_risk', 'is_summary', 'basic_times'];
+            const updateCalcArray = ['weight_num', 'basic_price', 'm_up_risk', 'm_down_risk', 'is_summary'];
             let needCalc = false;
             let needUp = false;
             const meInfo = await this.getDataById(data.id);
             if (!meInfo) {
                 throw '指数工料数据不存在';
             }
+            this._checkRiskEditPermission(data, meInfo);
             const updateData = {};
             for (const uc of updateColsArray) {
                 if (data[uc] !== undefined && data[uc] !== meInfo[uc]) {
@@ -320,7 +325,7 @@ module.exports = app => {
                 updateData.id = data.id;
                 await transaction.update(this.tableName, updateData);
             }
-            if (updateData.basic_price !== undefined) {
+            if (updateData.basic_price !== undefined || updateData.m_up_risk !== undefined || updateData.m_down_risk !== undefined) {
                 await this.ctx.service.materialExponentShard.resetEffectivePrice(transaction, [data.id]);
             }
             if (needCalc) {
@@ -358,6 +363,7 @@ module.exports = app => {
                         for (const data of datas) {
                             const exponentInfo = this._.find(exponentList, { id: data.id });
                             if (!exponentInfo) throw '指数工料数据不存在';
+                            this._checkRiskEditPermission(data, exponentInfo);
                             this._setEffectivePrice(exponentInfo, data);
                         }
                         await transaction.updateRows(this.tableName, datas);
@@ -399,9 +405,6 @@ module.exports = app => {
                         break;
                     default: break;
                 }
-                if (this.ctx.material.order < this.ctx.material.highOrder && this.ctx.session.sessionUser.is_admin) {
-                    await this._syncHistoricalTotals(transaction);
-                }
                 await transaction.commit();
                 return result;
             } catch (err) {
@@ -410,113 +413,14 @@ module.exports = app => {
             }
         }
 
-        async saveHistoryRisks(datas) {
-            if (!this.ctx.session.sessionUser.is_admin || this.ctx.material.order >= this.ctx.material.highOrder) {
-                throw '无权修改往期指数幅度';
-            }
-            const updateList = datas instanceof Array ? datas : [datas];
-            const transaction = await this.db.beginTransaction();
-            try {
-                const historyIds = this._.map(updateList, 'id');
-                const historyList = await transaction.select(this.ctx.service.materialExponentHistory.tableName, {
-                    where: { id: historyIds, mid: this.ctx.material.id, order: this.ctx.material.order },
-                });
-                const updates = [];
-                for (const data of updateList) {
-                    const historyInfo = this._.find(historyList, { id: data.id });
-                    if (!historyInfo || historyInfo.type !== materialConst.ex_type[1].value) throw '往期指数工料数据不存在';
-                    const updateData = { id: historyInfo.id };
-                    if (data.m_up_risk !== undefined) updateData.m_up_risk = this._normalizeRisk(data.m_up_risk);
-                    if (data.m_down_risk !== undefined) updateData.m_down_risk = this._normalizeRisk(data.m_down_risk);
-                    if (Object.keys(updateData).length === 1) continue;
-                    updateData.effective_price = this.getEffectivePrice(Object.assign({}, historyInfo, updateData));
-                    const calcNum = historyInfo.basic_price > 0 ? this.ctx.helper.mul(historyInfo.weight_num, this.ctx.helper.div(updateData.effective_price, historyInfo.basic_price)) : 0;
-                    updateData.calc_num = calcNum ? this.ctx.helper.round(calcNum, this.ctx.material.exponent_decimal.qty) : calcNum;
-                    updates.push(updateData);
-                }
-                if (updates.length === 0) throw '上涨、下跌幅度未发生变化';
-                await transaction.updateRows(this.ctx.service.materialExponentHistory.tableName, updates);
-                const [ex_tp, ex_tax_tp, ex_expr] = await this._calcHistoryExTp(transaction);
-                await this._syncHistoricalTotals(transaction);
-                const historyData = await transaction.select(this.ctx.service.materialExponentHistory.tableName, { where: { id: this._.map(updates, 'id') } });
-                await transaction.commit();
-                return { ex_tp, ex_tax_tp, ex_expr, historyData };
-            } catch (err) {
-                await transaction.rollback();
-                throw err;
-            }
-        }
-
-        async _calcHistoryExTp(transaction) {
-            let basicCalc = 0;
-            const exCalc = this.ctx.material.ex_calc ? JSON.parse(this.ctx.material.ex_calc) : null;
-            if (exCalc) {
-                for (const calc of exCalc) {
-                    if (!calc.select) continue;
-                    basicCalc = this.ctx.helper.add(basicCalc, calc.code === 'zdy' && calc.result ? calc.result : calc.value);
-                }
-            }
-            const exponentList = await transaction.select(this.ctx.service.materialExponentHistory.tableName, {
-                where: { mid: this.ctx.material.id, order: this.ctx.material.order, is_summary: materialConst.is_summary.yes },
-            });
-            let expr = basicCalc + '*[';
-            let sumByChange = 0;
-            let constant = 0;
-            for (const ex of exponentList) {
-                if (ex.type === materialConst.ex_type[0].value) {
-                    constant = ex.weight_num || 0;
-                    expr += constant.toString();
-                } else if (ex.type === materialConst.ex_type[1].value) {
-                    const effectivePrice = ex.effective_price !== null && ex.effective_price !== undefined ? ex.effective_price : ex.m_price;
-                    const calcNum = ex.basic_price > 0 ? this.ctx.helper.mul(ex.weight_num, this.ctx.helper.div(effectivePrice, ex.basic_price)) : 0;
-                    const change = calcNum ? this.ctx.helper.round(calcNum, this.ctx.material.exponent_decimal.qty) : 0;
-                    expr += change !== 0 ? '+' + ex.weight_num.toString() + '*(' + effectivePrice.toString() + '/' + ex.basic_price.toString() + ')' : '';
-                    sumByChange = this.ctx.helper.add(sumByChange, change);
-                }
-            }
-            expr += '-1]';
-            expr = constant !== 0 ? expr : null;
-            const exTp = constant !== 0 ? this.ctx.helper.round(this.ctx.helper.mul(basicCalc, this.ctx.helper.sub(this.ctx.helper.add(constant, sumByChange), 1)), this.ctx.material.exponent_decimal.tp) : null;
-            const exTaxTp = exTp !== null ? this.ctx.helper.round(this.ctx.helper.mul(exTp, 1 + this.ctx.material.exponent_rate / 100), this.ctx.material.exponent_decimal.tp) : null;
-            await transaction.update(this.ctx.service.material.tableName, {
-                id: this.ctx.material.id,
-                ex_tp: exTp,
-                ex_tax_tp: exTaxTp,
-                ex_expr: expr,
-            });
-            return [exTp, exTaxTp, expr];
-        }
-
-        async _syncHistoricalTotals(transaction) {
-            const materialList = await transaction.select(this.ctx.service.material.tableName, {
-                where: { tid: this.ctx.tender.id },
-                orders: [['order', 'asc']],
-            });
-            let exponentPreTp = 0;
-            let exponentTaxPreTp = 0;
-            const updates = [];
-            for (const material of materialList) {
-                updates.push({
-                    id: material.id,
-                    ex_pre_tp: exponentPreTp,
-                    ex_tax_pre_tp: exponentTaxPreTp,
-                });
-                exponentPreTp = this.ctx.helper.add(exponentPreTp, material.ex_tp || 0);
-                exponentTaxPreTp = this.ctx.helper.add(exponentTaxPreTp, material.ex_tax_tp || 0);
-            }
-            if (updates.length > 0) await transaction.updateRows(this.ctx.service.material.tableName, updates);
-            const tpData = await this.ctx.service.materialAudit.getTpData(transaction, this.ctx.material.id, this.ctx.material.decimal, this.ctx.material.exponent_decimal);
-            await transaction.update(this.ctx.service.material.tableName, { id: this.ctx.material.id, tp_data: JSON.stringify(tpData) });
-        }
-
         async _updateMoreExponentData(transaction, datas, result, meList, ms_id = null, mn_id = null) {
             // 判断data值是否需要更新materialExponent和materialExponentShard
             const mesCondition = { me_id: this._.map(datas, 'id') };
             if (ms_id !== null) mesCondition.ms_id = ms_id;
             if (mn_id !== null) mesCondition.mn_id = mn_id;
             const mesList = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: mesCondition });
-            const materialExponentColArray = ['symbol', 'symbol_desc', 'code', 'basic_price', 'is_summary', 'basic_times'];
-            const materialExponentShardColArray = ['weight_num', 'm_up_risk', 'm_down_risk', 'm_price', 'remark'];
+            const materialExponentColArray = ['symbol', 'symbol_desc', 'code', 'basic_price', 'm_up_risk', 'm_down_risk', 'is_summary', 'basic_times'];
+            const materialExponentShardColArray = ['weight_num', 'm_price', 'remark'];
             const updateCalcArray = ['weight_num', 'basic_price', 'm_up_risk', 'm_down_risk', 'm_price', 'is_summary'];
             const updateMeArray = [];
             const udpateMseArray = [];
@@ -524,6 +428,8 @@ module.exports = app => {
             for (const data of datas) {
                 const meInfo = this._.find(meList, { id: data.id });
                 const mseInfo = this._.find(mesList, { me_id: data.id });
+                if (!meInfo || !mseInfo) throw '指数工料数据不存在';
+                this._checkRiskEditPermission(data, meInfo);
                 for (const uc of materialExponentColArray) {
                     if (data[uc] !== undefined && data[uc] !== meInfo[uc]) {
                         const updateMe = this._.find(updateMeArray, { id: meInfo.id });
@@ -566,10 +472,10 @@ module.exports = app => {
                 // const condition = mn_id ? { mid: this.ctx.material.id, mn_id } : { mid: this.ctx.material.id, ms_id };
                 result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { id: this._.map(udpateMseArray, 'id') } });
             }
-            const basicPriceMeIds = this._.map(this._.filter(updateMeArray, data => data.basic_price !== undefined), 'id');
-            if (basicPriceMeIds.length > 0) {
-                await this.ctx.service.materialExponentShard.resetEffectivePrice(transaction, basicPriceMeIds);
-                result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { mid: this.ctx.material.id, me_id: basicPriceMeIds } });
+            const sharedPriceMeIds = this._.map(this._.filter(updateMeArray, data => data.basic_price !== undefined || data.m_up_risk !== undefined || data.m_down_risk !== undefined), 'id');
+            if (sharedPriceMeIds.length > 0) {
+                await this.ctx.service.materialExponentShard.resetEffectivePrice(transaction, sharedPriceMeIds);
+                result.exponentShardData = await transaction.select(this.ctx.service.materialExponentShard.tableName, { where: { mid: this.ctx.material.id } });
             }
             return needCalc;
         }
@@ -817,12 +723,29 @@ module.exports = app => {
                 if (!materialExponent) continue;
                 const updateEx = { id: ex.id };
                 if (ex.m_price !== null && ex.m_price !== undefined) updateEx.m_price = this.ctx.helper.round(ex.m_price, decimal.up);
-                updateEx.effective_price = this.getEffectivePrice(Object.assign({}, materialExponent, ex, updateEx), decimal.up);
+                updateEx.effective_price = this.getEffectivePrice(Object.assign({}, materialExponent, ex, updateEx, {
+                    m_up_risk: materialExponent.m_up_risk,
+                    m_down_risk: materialExponent.m_down_risk,
+                }), decimal.up);
                 if (updateEx.m_price !== ex.m_price || updateEx.effective_price !== ex.effective_price) updateStageArray.push(updateEx);
             }
             if (updateArray.length > 0) await transaction.updateRows(this.tableName, updateArray);
             if (updateStageArray.length > 0) await transaction.updateRows(this.ctx.service.materialExponentShard.tableName, updateStageArray);
         }
+
+        _checkRiskEditPermission(data, exponentInfo) {
+            const riskChanged = ['m_up_risk', 'm_down_risk'].some(col => data[col] !== undefined && data[col] !== exponentInfo[col]);
+            if (!riskChanged) return;
+            if (this.ctx.material.order < this.ctx.material.highOrder) {
+                throw '历史期上涨、下跌幅度不能修改';
+            }
+            if (exponentInfo.type !== materialConst.ex_type[1].value) {
+                throw '定值不能设置上涨、下跌幅度';
+            }
+            if (exponentInfo.mid !== this.ctx.material.id && !this.ctx.session.sessionUser.is_admin) {
+                throw '往期添加的指数工料仅管理员可修改上涨、下跌幅度';
+            }
+        }
     }
 
     return MaterialExponent;

+ 0 - 6
app/service/material_exponent_node.js

@@ -95,8 +95,6 @@ module.exports = app => {
                                         mn_id: node.id,
                                         me_id: ex.id,
                                         type: ex.type,
-                                        m_up_risk: sourceData.m_up_risk || 0,
-                                        m_down_risk: sourceData.m_down_risk || 0,
                                         m_price: sourceData.m_price || 0,
                                         effective_price: sourceData.effective_price !== null && sourceData.effective_price !== undefined ? sourceData.effective_price : (sourceData.m_price || 0),
                                         remark: ex.remark,
@@ -114,8 +112,6 @@ module.exports = app => {
                                         mn_id: node.id,
                                         me_id: ex.id,
                                         type: ex.type,
-                                        m_up_risk: sourceData.m_up_risk || 0,
-                                        m_down_risk: sourceData.m_down_risk || 0,
                                         m_price: sourceData.m_price || 0,
                                         effective_price: sourceData.effective_price !== null && sourceData.effective_price !== undefined ? sourceData.effective_price : (sourceData.m_price || 0),
                                         remark: ex.remark,
@@ -157,8 +153,6 @@ module.exports = app => {
                                     mn_id: null,
                                     me_id: ex.id,
                                     type: ex.type,
-                                    m_up_risk: 0,
-                                    m_down_risk: 0,
                                     m_price: 0,
                                     effective_price: 0,
                                     remark: ex.remark,

+ 11 - 9
app/service/material_exponent_shard.js

@@ -12,7 +12,7 @@ const auditConst = require('../const/audit').material;
 const materialConst = require('../const/material');
 const MaterialCalculator = require('../lib/material_calc');
 
-const needUpdateArray = ['weight_num', 'm_up_risk', 'm_down_risk', 'm_price', 'remark'];
+const needUpdateArray = ['weight_num', 'm_price', 'remark'];
 
 module.exports = app => {
     class MaterialExponentShard extends app.BaseService {
@@ -77,14 +77,17 @@ module.exports = app => {
             for (const nu of needUpdateArray) {
                 if (data[nu] !== undefined && mesInfo[nu] !== data[nu]) updateData[nu] = data[nu];
             }
-            if (updateData.m_price !== undefined || updateData.m_up_risk !== undefined || updateData.m_down_risk !== undefined || needCalc) {
+            if (updateData.m_price !== undefined || needCalc) {
                 const exponentInfo = await this._getExponentInfo(transaction, data.id);
                 if (!exponentInfo) throw '指数工料数据不存在';
-                updateData.effective_price = this.ctx.service.materialExponent.getEffectivePrice(Object.assign({}, exponentInfo, mesInfo, updateData));
+                updateData.effective_price = this.ctx.service.materialExponent.getEffectivePrice(Object.assign({}, exponentInfo, mesInfo, updateData, {
+                    m_up_risk: exponentInfo.m_up_risk,
+                    m_down_risk: exponentInfo.m_down_risk,
+                }));
             }
             // 如果只有id,则不更新
             if (Object.keys(updateData).length > 1) await transaction.update(this.tableName, updateData);
-            if (updateData.m_price !== undefined || updateData.m_up_risk !== undefined || updateData.m_down_risk !== undefined || updateData.weight_num !== undefined || needCalc) {
+            if (updateData.m_price !== undefined || updateData.weight_num !== undefined || needCalc) {
                 const mnInfo = mn_id ? await transaction.get(this.ctx.service.materialExponentNode.tableName, { mid: this.ctx.material.id, id: mn_id }) : null;
                 const msInfo = ms_id && !mn_id ? await transaction.get(this.ctx.service.materialStage.tableName, { mid: this.ctx.material.id, id: ms_id }) : null;
                 const ex_calc = mnInfo ? JSON.parse(mnInfo.ex_calc) : msInfo ? JSON.parse(msInfo.ex_calc) : materialConst.ex_calc;
@@ -127,7 +130,10 @@ module.exports = app => {
             for (const shard of shardList) {
                 const exponentInfo = await this._getExponentInfo(transaction, shard.me_id);
                 if (!exponentInfo) continue;
-                const effectivePrice = this.ctx.service.materialExponent.getEffectivePrice(Object.assign({}, exponentInfo, shard));
+                const effectivePrice = this.ctx.service.materialExponent.getEffectivePrice(Object.assign({}, exponentInfo, shard, {
+                    m_up_risk: exponentInfo.m_up_risk,
+                    m_down_risk: exponentInfo.m_down_risk,
+                }));
                 if (effectivePrice !== shard.effective_price) updates.push({ id: shard.id, effective_price: effectivePrice });
             }
             if (updates.length > 0) await transaction.updateRows(this.tableName, updates);
@@ -322,8 +328,6 @@ module.exports = app => {
                         me_id: me.id,
                         type: me.type,
                         weight_num: me.weight_num,
-                        m_up_risk: me.m_up_risk,
-                        m_down_risk: me.m_down_risk,
                         m_price: me.m_price,
                         effective_price: me.effective_price !== null ? me.effective_price : me.m_price,
                         remark: me.remark,
@@ -354,8 +358,6 @@ module.exports = app => {
                         ms_id: ms.id,
                         type: e.type,
                         weight_num: e.weight_num,
-                        m_up_risk: e.m_up_risk,
-                        m_down_risk: e.m_down_risk,
                         m_price: e.m_price,
                         effective_price: e.effective_price !== null ? e.effective_price : e.m_price,
                     };

+ 113 - 0
app/service/project_account.js

@@ -1303,6 +1303,119 @@ module.exports = app => {
             }
             return info.sign_path ? info.sign_path.split('&%&') : []; // 多个签章用&%&分隔
         }
+
+        /**
+         * 解析项目大屏权限
+         *
+         * @param {String|Object} permission - 账号权限
+         * @return {Object} 权限对象
+         */
+        parseProjectScreenPermission(permission) {
+            if (!permission) return {};
+            try {
+                const result = typeof permission === 'string' ? JSON.parse(permission) : permission;
+                return result && typeof result === 'object' && !(result instanceof Array) ? result : {};
+            } catch (error) {
+                return {};
+            }
+        }
+
+        /**
+         * 获取项目大屏权限账号
+         *
+         * @param {Number} pid - 项目id
+         * @param {Object} options - 查询条件
+         * @return {Object} 账号列表及分页信息
+         */
+        async getProjectScreenPermissionAccounts(pid, options = {}) {
+            const keyword = (options.keyword || '').trim();
+            const authorized = parseInt(options.authorized) === 1;
+            const page = Math.max(parseInt(options.page) || 1, 1);
+            const pageSize = Math.min(Math.max(parseInt(options.pageSize) || 50, 10), 100);
+            const whereSql = ['project_id = ?', 'enable = 1'];
+            const whereSqlParam = [pid];
+            if (keyword) {
+                const keywordValue = '%' + keyword + '%';
+                whereSql.push('(account LIKE ? OR name LIKE ? OR company LIKE ? OR role LIKE ? OR mobile LIKE ?)');
+                whereSqlParam.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue);
+            }
+            const permissionWhereSql = `(is_admin = 1
+                OR permission LIKE '%"projectscreen":["1"]%'
+                OR permission LIKE '%"projectscreen":[1]%'
+                OR permission LIKE '%"projectscreen":"1"%'
+                OR permission LIKE '%"projectscreen":1%')`;
+            const baseWhereSql = whereSql.join(' AND ');
+            const listWhereSql = authorized ? baseWhereSql + ' AND ' + permissionWhereSql : baseWhereSql;
+
+            const totalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${listWhereSql}`;
+            const totalSqlParam = [this.tableName].concat(whereSqlParam);
+            const totalResult = await this.db.queryOne(totalSql, totalSqlParam);
+
+            const allTotalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${baseWhereSql}`;
+            const allTotalSqlParam = [this.tableName].concat(whereSqlParam);
+            const allTotalResult = await this.db.queryOne(allTotalSql, allTotalSqlParam);
+
+            const authorizedTotalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${baseWhereSql} AND ${permissionWhereSql}`;
+            const authorizedTotalSqlParam = [this.tableName].concat(whereSqlParam);
+            const authorizedTotalResult = await this.db.queryOne(authorizedTotalSql, authorizedTotalSqlParam);
+
+            const sql = `SELECT id, account, name, company, role, mobile, is_admin, permission
+                FROM ?? WHERE ${listWhereSql}
+                ORDER BY is_admin DESC, id DESC LIMIT ?, ?`;
+            const sqlParam = [this.tableName].concat(whereSqlParam, [(page - 1) * pageSize, pageSize]);
+            const list = await this.db.query(sql, sqlParam);
+            list.forEach(account => {
+                const accountPermission = this.parseProjectScreenPermission(account.permission);
+                const screenPermission = accountPermission.projectscreen instanceof Array
+                    ? accountPermission.projectscreen : [accountPermission.projectscreen];
+                account.authorized = account.is_admin === 1 || screenPermission.map(String).indexOf('1') >= 0;
+                delete account.permission;
+            });
+            return {
+                list,
+                page,
+                pageSize,
+                total: totalResult ? totalResult.count : 0,
+                allTotal: allTotalResult ? allTotalResult.count : 0,
+                authorizedTotal: authorizedTotalResult ? authorizedTotalResult.count : 0,
+            };
+        }
+
+        async saveProjectScreenPermissions(pid, changes) {
+            if (!(changes instanceof Array) || changes.length === 0) return true;
+            const permissionMap = {};
+            changes.forEach(item => {
+                const id = parseInt(item.id);
+                if (id > 0) permissionMap[id] = !!item.authorized;
+            });
+            const ids = Object.keys(permissionMap).map(id => this._.toInteger(id));
+            if (ids.length === 0) return true;
+
+            const transaction = await this.db.beginTransaction();
+            try {
+                const accounts = await transaction.select(this.tableName, {
+                    where: { id: ids, project_id: pid, enable: 1 },
+                });
+                const updateData = accounts.filter(account => account.is_admin !== 1).map(account => {
+                    const accountPermission = this.parseProjectScreenPermission(account.permission);
+                    if (permissionMap[account.id]) {
+                        accountPermission.projectscreen = ['1'];
+                    } else {
+                        delete accountPermission.projectscreen;
+                    }
+                    return {
+                        id: account.id,
+                        permission: JSON.stringify(accountPermission),
+                    };
+                });
+                if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+                await transaction.commit();
+                return true;
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
     }
 
     return ProjectAccount;

+ 317 - 0
app/service/project_screen.js

@@ -0,0 +1,317 @@
+'use strict';
+
+const projectScreenConst = require('../const/project_screen');
+
+module.exports = app => {
+    class ProjectScreen extends app.BaseService {
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'project_screen';
+        }
+
+        async getList(pid, transaction = null) {
+            const db = transaction || this.db;
+            const result = await db.select(this.tableName, {
+                where: { pid },
+                orders: [['id', 'asc']],
+            });
+            return result.map(item => {
+                const template = projectScreenConst.getTemplate(item.template_code);
+                return Object.assign(item, {
+                    template: template || {
+                        code: item.template_code,
+                        name: item.name,
+                        description: '',
+                        previewClass: '',
+                    },
+                });
+            });
+        }
+
+        async hasScreen(pid) {
+            return (await this.count({ pid })) > 0;
+        }
+
+        async getViewScreen(pid, id = '') {
+            const screens = await this.getList(pid);
+            if (screens.length === 0) return null;
+            if (id) {
+                const selected = screens.find(item => item.id === parseInt(id));
+                if (selected) return selected;
+            }
+            return screens.find(item => item.is_default === 1) || screens[0];
+        }
+
+        async addScreens(pid, codes) {
+            const selectedCodes = [];
+            for (const code of codes || []) {
+                if (!projectScreenConst.getTemplate(code)) throw '大屏模板不存在';
+                if (selectedCodes.indexOf(code) < 0) selectedCodes.push(code);
+            }
+            if (selectedCodes.length === 0) throw '请至少选择一个大屏';
+
+            const transaction = await this.db.beginTransaction();
+            try {
+                const activeList = await transaction.select(this.tableName, {
+                    where: { pid },
+                    orders: [['id', 'asc']],
+                });
+                const needDefault = activeList.length === 0;
+                const existingCodes = activeList.map(item => item.template_code);
+                const insertCodes = selectedCodes.filter(code => existingCodes.indexOf(code) < 0);
+                const insertData = insertCodes.map((code, index) => {
+                    const template = projectScreenConst.getTemplate(code);
+                    return {
+                        pid,
+                        template_code: code,
+                        name: template.name,
+                        is_default: needDefault && index === 0 ? 1 : 0,
+                    };
+                });
+                if (insertData.length > 0) await transaction.insert(this.tableName, insertData);
+
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async setDefault(pid, id) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const target = await transaction.get(this.tableName, {
+                    id, pid,
+                });
+                if (!target) throw '大屏不存在';
+                await transaction.update(this.tableName, { is_default: 0 }, {
+                    where: { pid },
+                });
+                await transaction.update(this.tableName, {
+                    is_default: 1,
+                }, {
+                    where: { id, pid },
+                });
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async deleteScreen(pid, id) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const target = await transaction.get(this.tableName, { id, pid });
+                if (!target) throw '大屏不存在或已删除';
+                await transaction.delete(this.tableName, { id, pid });
+                if (target.is_default === 1) {
+                    const remaining = await transaction.select(this.tableName, {
+                        where: { pid },
+                        orders: [['id', 'asc']],
+                    });
+                    if (remaining.length > 0) {
+                        await transaction.update(this.tableName, { is_default: 0 }, {
+                            where: { pid },
+                        });
+                        await transaction.update(this.tableName, { is_default: 1 }, {
+                            where: { id: remaining[0].id, pid },
+                        });
+                    }
+                }
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async getFinishedProjects() {
+            return this.getStatusProjects('已建');
+        }
+
+        async getStatusProjects(status, options = {}) {
+            const ctx = this.ctx;
+            const user = ctx.session.sessionUser;
+            const pid = ctx.session.sessionProject.id;
+            const nodes = await ctx.service.subProject.getSubProject(pid, user.accountId, user.is_admin);
+            const infos = await ctx.service.subProjInfo.getAllDataByCondition({ where: { project_id: pid, proj_status: status || ['拟建', '在建', '已建'] } });
+            const account = await ctx.service.projectAccount.getDataById(user.accountId);
+            const permission = account ? ctx.service.projectAccount.parseProjectScreenPermission(account.permission) : null;
+            const calc = ctx.helper;
+            // Preserve decimal precision during aggregation and unit conversion.
+            // 元 fields have eight decimal places; conversion to 万元 needs twelve.
+            const toWan = (value, unit) => {
+                if (value === null || value === undefined || value === '') return null;
+                if (unit === '万元') return calc.add(0, value);
+                if (unit === '亿元') return calc.mul(value, 10000, 12);
+                return calc.div(value, 10000, 12);
+            };
+            const result = [];
+            const selectedNodes = nodes.filter(n => !n.is_folder && (!options.id || n.id === options.id) && infos.some(info => info.id === n.id)).sort((a, b) => a.tree_order - b.tree_order);
+            const allTenders = [];
+            if (selectedNodes.length && (user.is_admin === 1 || (permission && permission.tender && permission.tender.indexOf('2') !== -1))) {
+                allTenders.push(...await ctx.service.tender.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) } }));
+                allTenders.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
+            } else {
+                // Batch projects with identical permission-related page settings.
+                const permissionGroups = new Map();
+                for (const node of selectedNodes) {
+                    const sub = Object.assign({}, node, { page_show: ctx.service.subProject.getPageShow(node.page_show) });
+                    const key = JSON.stringify(['openChangeProject', 'openChangeApply', 'openChangePlan'].map(flag => !!sub.page_show[flag]));
+                    if (!permissionGroups.has(key)) permissionGroups.set(key, { id: [], page_show: sub.page_show });
+                    permissionGroups.get(key).id.push(node.id);
+                }
+                for (const sub of permissionGroups.values()) {
+                    allTenders.push(...await ctx.service.tender.getList('', permission, user.is_admin, '', sub));
+                }
+            }
+            await ctx.service.tenderCache.loadTenderCaches(allTenders, user.accountId);
+            const allChanges = !options.summary && status === '在建' && allTenders.length ? await ctx.service.change.getAllCheckedChanges(allTenders.map(t => t.id)) : [];
+            const allProgress = !options.summary && selectedNodes.length && status !== '已建' ? await ctx.service.subProjProgress.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) }, orders: [['update_time', 'desc'], ['id', 'asc']] }) : [];
+            for (const node of selectedNodes) {
+                const info = infos.find(i => i.id === node.id);
+                if (!info) continue;
+                const tenders = allTenders.filter(t => t.spid === node.id);
+                let contract = 0;
+                let measured = 0;
+                let hasMeasured = false;
+                const tenderInvestments = [];
+                const changeRows = [];
+                for (const tender of tenders) {
+                    contract = calc.add(contract, tender.contract_price);
+                    const stage = tender.stage_tp || {};
+                    let tenderMeasured = 0;
+                    let tenderHasMeasured = false;
+                    for (const key of ['pre_contract_tp', 'contract_tp', 'contract_pc_tp', 'pre_qc_tp', 'qc_tp', 'qc_pc_tp']) {
+                        if (stage[key] !== null && stage[key] !== undefined && stage[key] !== '') {
+                            tenderMeasured = calc.add(tenderMeasured, stage[key]);
+                            tenderHasMeasured = true;
+                        }
+                    }
+                    measured = calc.add(measured, tenderMeasured);
+                    hasMeasured = hasMeasured || tenderHasMeasured;
+                    tenderInvestments.push({ id: tender.id, name: tender.name, measured: tenderHasMeasured ? toWan(tenderMeasured, '元') : null });
+                    if (!options.summary && status === '在建') {
+                        const changes = allChanges.filter(change => change.tid === tender.id);
+                        const totals = [1, 2, 3].map(quality => changes.filter(c => Number(c.quality) === quality).reduce((sum, c) => calc.add(sum, c.total_price), 0));
+                        const total = changes.reduce((sum, c) => calc.add(sum, c.total_price), 0);
+                        const ledger = tender.ledger_tp && tender.ledger_tp.total_price;
+                        changeRows.push({ id: tender.id, name: tender.name, contract: toWan(ledger, '元'),
+                            changes: totals.map(value => toWan(value, '元')), total: toWan(total, '元'),
+                            ratio: Number(ledger) ? calc.div(calc.mul(total, 100, 12), ledger, 2) : null });
+                    }
+                }
+                let finalAmount = null;
+                if (!options.summary && status === '已建' && node.budget_id) {
+                    const budget = await ctx.service.budget.getDataById(node.budget_id);
+                    if (budget && budget.final_id) {
+                        const final = await ctx.service.budgetFinal.getAllDataByCondition({ where: { final_id: budget.final_id } });
+                        const top = final.filter(row => Number(row.tree_pid) === -1);
+                        if (top.length) finalAmount = toWan(top.reduce((sum, row) => calc.add(sum, row.final_tp), 0), '元');
+                    }
+                }
+                const parent = nodes.find(n => n.id === node.tree_pid);
+                const progressRows = allProgress.filter(row => row.spid === node.id);
+                const latest = progressRows[0];
+                const keys = ['edit', 'submit', 'reply'];
+                const latestEvent = latest && keys.map((key, index) => ({ date: latest[key + '_date'], department: latest[key + '_department'], step: ['编制', '报审', '批复'][index] }))
+                    .filter(event => event.date).sort((a, b) => String(b.date).localeCompare(String(a.date)))[0];
+                const stages = progressRows.filter(row => row.tree_level > 1 && row.tree_is_leaf === 1).map(row => ({
+                    id: row.id, name: row.name,
+                    path: String(row.tree_full_path).split('-').map(id => progressRows.find(p => String(p.tree_id) === id)).filter(Boolean).map(p => p.name).join(' / ') || row.name,
+                    states: keys.map(key => row[key + '_progress'] === '已完成' ? 0 : row[key + '_progress'] === '进行中' ? 1 : 2),
+                    dates: keys.map(key => row[key + '_date'] || ''), departments: keys.map(key => row[key + '_department'] || '')
+                }));
+                result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '未分组',
+                    intro: info.proj_intro || '', date: info.jg_date || '', department: info.jg_department || '',
+                    measured: hasMeasured ? toWan(measured, '元') : null,
+                    tenders: tenderInvestments,
+                    changeRows, stages,
+                    progressText: latestEvent ? `${latestEvent.date},${latestEvent.department || ''}已完成「${latest.name}」${latestEvent.step}。` : '暂无阶段动态',
+                    amounts: ['lx', 'cb', 'sgt', 'pf'].map(prefix => toWan(info[prefix + '_tp'], info[prefix + '_tp_unit']))
+                        .concat([tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
+            }
+            return result;
+        }
+
+        async getOverview(planned, building, finished) {
+            const ctx = this.ctx;
+            const calc = ctx.helper;
+            const lists = [planned, building, finished];
+            const projects = [].concat(...lists);
+            const present = value => value !== null && value !== undefined && value !== '';
+            const rank = (list, amount) => list.map(p => ({ id: p.id, name: p.name, value: amount(p) }))
+                .filter(p => present(p.value)).sort((a, b) => b.value - a.value).slice(0, 10);
+            const rows = projects.length ? await ctx.service.subProjProgress.getAllDataByCondition({
+                where: { spid: projects.map(p => p.id) }, orders: [['update_time', 'desc'], ['id', 'asc']]
+            }) : [];
+            const dateText = value => value instanceof Date ? [value.getFullYear(), String(value.getMonth() + 1).padStart(2, '0'), String(value.getDate()).padStart(2, '0')].join('-') : String(value || '').slice(0, 10);
+            const trees = new Map(projects.map(p => [p.id, rows.filter(row => row.spid === p.id)]));
+            const leafColumns = tree => tree.filter(row => Number(row.tree_level) > 1 && Number(row.tree_is_leaf) === 1)
+                .sort((a, b) => a.tree_order - b.tree_order).map(row => {
+                    const path = String(row.tree_full_path).split('-').map(id => tree.find(n => String(n.tree_id) === id)).filter(Boolean);
+                    if (!path.length || path[path.length - 1].id !== row.id) path.push(row);
+                    return { key: JSON.stringify([path[0].name, row.name]), root: path[0].name,
+                        rootOrder: Number(path[0].tree_order) || 0,
+                        orderPath: path.slice(1).map(n => Number(n.tree_order) || 0),
+                        name: row.name, date: dateText(row.reply_date) };
+                }).sort((a, b) => {
+                    if (a.root !== b.root) return a.rootOrder - b.rootOrder || a.root.localeCompare(b.root, 'zh-CN');
+                    for (let i = 0; i < Math.min(a.orderPath.length, b.orderPath.length); i++) {
+                        if (a.orderPath[i] !== b.orderPath[i]) return a.orderPath[i] - b.orderPath[i];
+                    }
+                    return a.orderPath.length - b.orderPath.length;
+                });
+            const columnsByProject = new Map(projects.map(p => [p.id, leafColumns(trees.get(p.id))]));
+            const approvedKeys = new Set([].concat(...Array.from(columnsByProject.values())).filter(column => column.date).map(column => column.key));
+            // Merge same leaf names under the same root, regardless of intermediate paths.
+            const rootGroups = new Map();
+            for (const project of projects) {
+                for (const column of columnsByProject.get(project.id)) {
+                    // Only expose a column when at least one project has an approval date.
+                    if (!approvedKeys.has(column.key)) continue;
+                    if (!rootGroups.has(column.root)) rootGroups.set(column.root, new Map());
+                    const group = rootGroups.get(column.root);
+                    if (!group.has(column.key)) group.set(column.key, column);
+                }
+            }
+            // Sort roots only; keep leaf insertion order so different projects do not interleave.
+            const columns = [].concat(...Array.from(rootGroups.values()).sort((a, b) => {
+                const firstA = a.values().next().value;
+                const firstB = b.values().next().value;
+                return firstA.rootOrder - firstB.rootOrder || firstA.root.localeCompare(firstB.root, 'zh-CN');
+            }).map(group => Array.from(group.values())));
+            return {
+                counts: lists.map(list => list.length),
+                investment: lists.map(list => [0, 1, 2, 3, 4].map(index => {
+                    const values = list.map(p => p.amounts[index]).filter(present);
+                    return values.length ? values.reduce((sum, value) => calc.add(sum, value), 0) : null;
+                })),
+                planned: rank(planned, p => p.amounts[1]), finished: rank(finished, p => p.measured),
+                building: building.map(p => ({ id: p.id, name: p.name, contract: p.amounts[4], measured: p.measured,
+                    rate: present(p.measured) && Number(p.amounts[4]) !== 0 && present(p.amounts[4]) ? calc.div(calc.mul(p.measured, 100, 12), p.amounts[4], 2) : null
+                })).sort((a, b) => a.rate === null ? (b.rate === null ? 0 : 1) : b.rate === null ? -1 : b.rate - a.rate),
+                columns,
+                preview: projects.map(p => ({ id: p.id, name: p.name, group: p.group,
+                    dates: columns.map(column => {
+                        const dates = columnsByProject.get(p.id).filter(c => c.key === column.key && c.date).map(c => c.date);
+                        return dates.sort().pop() || '';
+                    }) })),
+                events: rows.slice(0, 20).map(row => {
+                    const project = projects.find(p => p.id === row.spid);
+                    const latest = ['edit', 'submit', 'reply'].map((key, index) => ({ date: dateText(row[key + '_date']),
+                        department: row[key + '_department'] || '', step: ['编制', '报审', '批复'][index] }))
+                        .filter(event => event.date).sort((a, b) => b.date.localeCompare(a.date))[0];
+                    return { name: project.name, date: latest ? latest.date : dateText(row.update_time),
+                        text: latest ? `${latest.department}已完成「${row.name}」${latest.step}` : `更新「${row.name}」` };
+                })
+            };
+        }
+    }
+
+    return ProjectScreen;
+};

+ 1 - 1
app/service/quality_inspection_audit.js

@@ -258,7 +258,7 @@ module.exports = app => {
                 '  FROM ?? AS ma' +
                 '  LEFT JOIN ?? AS m On ma.qiid = m.id' +
                 '  LEFT JOIN ?? As t On m.tid = t.id' +
-                '  LEFT JOIN ?? As ti On m.tid = ti.id' +
+                '  LEFT JOIN ?? As ti On m.tid = ti.tid' +
                 '  WHERE ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?)' +
                 '  ORDER BY ma.`begin_time` DESC';
             const sqlParam = [this.tableName, this.ctx.service.qualityInspection.tableName, this.ctx.service.tender.tableName, this.ctx.service.tenderInfo.tableName, auditorId, auditConst.status.checking, auditConst.status.rectification];

+ 223 - 137
app/service/safe_inspection.js

@@ -7,8 +7,9 @@
  * @date 2024/7/22
  * @version
  */
-const auditConst = require('../const/audit').inspection;
+const auditConst = require('../const/safe_inspection');
 const auditType = require('../const/audit').auditType;
+const moment = require('moment');
 module.exports = app => {
 
     class SafeInspection extends app.BaseService {
@@ -25,6 +26,7 @@ module.exports = app => {
         }
 
         async loadUser(inspection) {
+            if (inspection.flow_version === 1) return this.loadFlowUser(inspection);
             const status = auditConst.status;
             const accountId = this.ctx.session.sessionUser.accountId;
 
@@ -51,6 +53,7 @@ module.exports = app => {
         }
 
         async loadAuditViewData(inspection) {
+            if (inspection.flow_version === 1) return this.loadFlowViewData(inspection);
             const times = inspection.status === auditConst.status.checkNo ? inspection.times - 1 : inspection.times;
 
             if (!inspection.user) inspection.user = await this.ctx.service.projectAccount.getAccountInfoById(inspection.uid);
@@ -89,6 +92,7 @@ module.exports = app => {
                 const inspection = {
                     tid: tenderId,
                     uid: userId,
+                    flow_version: 1, need_rectification: 1, flow_stage: auditConst.stage.report, stage_round: 1,
                     status: auditConst.status.uncheck,
                     times: 1,
                     code,
@@ -138,6 +142,7 @@ module.exports = app => {
                 const inspection = {
                     tid,
                     uid: this.ctx.session.sessionUser.accountId,
+                    flow_version: 1, need_rectification: 1, flow_stage: auditConst.stage.report, stage_round: 1,
                     status: auditConst.status.uncheck,
                     times: 1,
                     code: data.code,
@@ -207,83 +212,18 @@ module.exports = app => {
         }
 
         async getListByStatus(tid, status, hadlimit = 0, sortBy = '', orderBy = '') {
-            let sql = '';
-            let sqlParam = '';
-            if ((this.ctx.tender.isTourist || this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all) && status === 0) {
-                sql = 'SELECT a.* FROM ?? As a WHERE a.tid = ?';
-                sqlParam = [this.tableName, tid];
-            } else {
-                switch (status) {
-                    case 0: // 所有
-                        sql =
-                            'SELECT a.* FROM ?? AS a WHERE a.tid = ? AND' +
-                            ' (a.uid = ? OR (a.status != ? AND a.id IN (SELECT b.qiid FROM ?? AS b WHERE b.aid = ? GROUP BY b.qiid))' +
-                            ' OR a.status = ?)';
-                        sqlParam = [
-                            this.tableName,
-                            tid,
-                            this.ctx.session.sessionUser.accountId,
-                            auditConst.status.uncheck,
-                            this.ctx.service.safeInspectionAudit.tableName,
-                            this.ctx.session.sessionUser.accountId,
-                            auditConst.status.checked,
-                        ];
-                        break;
-                    case auditConst.filter.status.pending: // 待处理(你的)
-                        sql = 'SELECT a.* FROM ?? as a WHERE a.tid = ? AND (a.id in(SELECT b.qiid FROM ?? as b WHERE b.tid = ? AND b.aid = ? AND (b.status = ? OR b.status = ?)) OR (a.uid = ? AND (a.status = ? OR a.status = ?)))';
-                        sqlParam = [this.tableName, tid, this.ctx.service.safeInspectionAudit.tableName, tid, this.ctx.session.sessionUser.accountId, auditConst.status.checking, auditConst.status.rectification, this.ctx.session.sessionUser.accountId, auditConst.status.uncheck, auditConst.status.checkNo];
-                        break;
-                    case auditConst.filter.status.uncheck: // 待上报(所有的)PS:取未上报,退回,修订的变更令
-                        sql =
-                            'SELECT a.* FROM ?? AS a WHERE ' +
-                            'a.tid = ? AND (a.status = ? OR a.status = ?)' +
-                            (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND a.uid = ' + this.ctx.session.sessionUser.accountId);
-                        sqlParam = [
-                            this.tableName,
-                            tid,
-                            auditConst.status.uncheck,
-                            auditConst.status.checkNo,
-                        ];
-                        break;
-                    case auditConst.filter.status.checking: // 进行中(所有的)
-                        sql =
-                            'SELECT a.* FROM ?? AS a WHERE ' +
-                            '(a.status = ? OR a.status = ?) AND a.tid = ?' +
-                            (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.safeInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                        sqlParam = [this.tableName, status, auditConst.status.checkNoPre, tid, this.ctx.service.safeInspectionAudit.tableName, this.ctx.session.sessionUser.accountId];
-                        break;
-                    case auditConst.filter.status.rectification: // 整改中(所有的)
-                    case auditConst.filter.status.checkStop: // 终止(所有的)
-                        sql =
-                            'SELECT a.* FROM ?? AS a WHERE ' +
-                            'a.status = ? AND a.tid = ?' +
-                            (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.safeInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                        sqlParam = [this.tableName, status, tid, this.ctx.service.safeInspectionAudit.tableName, this.ctx.session.sessionUser.accountId];
-                        break;
-                    case auditConst.filter.status.checked: // 已完成(所有的)
-                        sql = 'SELECT a.* FROM ?? as a WHERE a.status = ? AND a.tid = ?';
-                        sqlParam = [this.tableName, status, tid];
-                        break;
-                    default:
-                        break;
-                }
-            }
-            if (sortBy && orderBy) {
-                if (sortBy === 'code') {
-                    sql += ' ORDER BY CHAR_LENGTH(a.code) ' + orderBy + ',convert(a.code using gbk) ' + orderBy;
-                } else {
-                    sql += ' ORDER BY a.create_time ' + orderBy;
-                }
-            } else {
-                sql += ' ORDER BY a.create_time DESC';
-            }
+            const condition = this.getFlowListCondition(tid, status);
+            const direction = String(orderBy).toLowerCase() === 'asc' ? 'ASC' : 'DESC';
+            const ordering = sortBy === 'code' ? 'CHAR_LENGTH(a.code) ' + direction + ', CONVERT(a.code USING gbk) ' + direction : 'a.create_time ' + direction;
+            const sqlParam = condition.sqlParam;
+            const limit = hadlimit ? ' LIMIT ? OFFSET ?' : '';
             if (hadlimit) {
-                const limit = this.ctx.pageSize ? this.ctx.pageSize : this.app.config.pageSize;
-                const offset = limit * (this.ctx.page - 1);
-                const limitString = offset >= 0 ? offset + ',' + limit : limit;
-                sql += ' LIMIT ' + limitString;
+                const pageSize = Number(this.ctx.pageSize || this.app.config.pageSize);
+                sqlParam.push(pageSize, Math.max(0, (Number(this.ctx.page) || 1) - 1) * pageSize);
             }
+            const sql = 'SELECT a.* FROM ?? a WHERE ' + condition.where + ' ORDER BY ' + ordering + limit;
             const list = await this.db.query(sql, sqlParam);
+            for (const inspection of list) this.setFlowLabel(inspection);
             return list;
         }
 
@@ -294,71 +234,217 @@ module.exports = app => {
          * @return {void}
          */
         async getCountByStatus(tid, status = 0) {
-            if ((this.ctx.tender.isTourist || this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all) && status === 0) {
-                const sql5 = 'SELECT count(*) AS count FROM ?? AS a WHERE a.tid = ?';
-                const sqlParam5 = [this.tableName, tid];
-                const result5 = await this.db.query(sql5, sqlParam5);
-                return result5[0].count;
+            const condition = this.getFlowListCondition(tid, status);
+            const sql = 'SELECT COUNT(*) AS count FROM ?? a WHERE ' + condition.where;
+            const sqlParam = condition.sqlParam;
+            const result = await this.db.queryOne(sql, sqlParam);
+            return result.count;
+        }
+
+        getFlowListCondition(tid, filter) {
+            const uid = this.ctx.session.sessionUser.accountId;
+            const all = this.ctx.tender.isTourist || this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all;
+            const sqlParam = [this.tableName, tid];
+            const clauses = ['a.tid = ?'];
+            if (filter === auditConst.filter.status.pending) {
+                clauses.push('(a.uid = ? AND a.status IN (?, ?) OR (a.status IN (?, ?, ?) AND EXISTS (SELECT 1 FROM ?? b WHERE b.qiid = a.id AND b.aid = ? AND b.times = a.times AND b.is_old = ? AND b.status IN (?, ?) AND (a.flow_version = ? OR (b.flow_stage = a.flow_stage AND b.stage_round = a.stage_round)))))');
+                sqlParam.push(uid, 1, 5, 2, 6, 12, this.ctx.service.safeInspectionAudit.tableName, uid, 0, 2, 12, 0);
+            } else {
+                if (!all && filter !== auditConst.filter.status.checked) {
+                    clauses.push('(a.uid = ? OR (a.status <> ? AND EXISTS (SELECT 1 FROM ?? b WHERE b.qiid = a.id AND b.aid = ?))' + (filter === 0 ? ' OR a.status = ?' : '') + ')');
+                    sqlParam.push(uid, 1, this.ctx.service.safeInspectionAudit.tableName, uid);
+                    if (filter === 0) sqlParam.push(3);
+                }
+                switch (filter) {
+                    case 0: break;
+                    case auditConst.filter.status.uncheck:
+                        clauses.push('a.status IN (?, ?)'); sqlParam.push(1, 5); break;
+                    case auditConst.filter.status.checking:
+                        clauses.push('a.status IN (?, ?) AND (a.flow_version = ? OR a.flow_stage = ?)'); sqlParam.push(2, 6, 0, 1); break;
+                    case auditConst.filter.status.rectification:
+                        clauses.push('a.status = ? AND (a.flow_version = ? OR a.flow_stage = ?)'); sqlParam.push(12, 0, 2); break;
+                    case auditConst.filter.status.reply:
+                        clauses.push('a.flow_version = ? AND a.flow_stage = ? AND a.status IN (?, ?)'); sqlParam.push(1, 3, 2, 6); break;
+                    case auditConst.filter.status.checked:
+                    case auditConst.filter.status.checkStop:
+                        clauses.push('a.status = ?'); sqlParam.push(filter); break;
+                    default: throw '无效的巡检筛选条件';
+                }
             }
-            switch (status) {
-                case 0: // 所有
-                    const sql =
-                        'SELECT count(*) AS count FROM ?? AS a WHERE a.tid = ? AND ' +
-                        '(a.uid = ? OR (a.status != ? AND a.id IN (SELECT b.qiid FROM ?? AS b WHERE b.aid = ? GROUP BY b.qiid)) OR a.status = ?)';
-                    const sqlParam = [
-                        this.tableName,
-                        tid,
-                        this.ctx.session.sessionUser.accountId,
-                        auditConst.status.uncheck,
-                        this.ctx.service.safeInspectionAudit.tableName,
-                        this.ctx.session.sessionUser.accountId,
-                        auditConst.status.checked,
-                    ];
-                    const result = await this.db.query(sql, sqlParam);
-                    return result[0].count;
-                case auditConst.filter.status.pending: // 待处理(你的)
-                    const sql6 = 'SELECT count(*) AS count FROM ?? as a WHERE a.tid = ? AND (a.id in(SELECT b.qiid FROM ?? as b WHERE b.tid = ? AND b.aid = ? AND (b.status = ? OR b.status = ?)) OR (a.uid = ? AND (a.status = ? OR a.status = ?)))';
-                    const sqlParam6 = [this.tableName, tid, this.ctx.service.safeInspectionAudit.tableName, tid, this.ctx.session.sessionUser.accountId, auditConst.status.checking, auditConst.status.rectification, this.ctx.session.sessionUser.accountId, auditConst.status.uncheck, auditConst.status.checkNo];
-                    const result6 = await this.db.query(sql6, sqlParam6);
-                    return result6[0].count;
-                case auditConst.filter.status.uncheck: // 待上报(所有的)PS:取未上报,退回的变更立项
-                    const sql2 =
-                        'SELECT count(*) AS count FROM ?? AS a WHERE a.tid = ? AND (a.status = ? OR a.status = ?)' +
-                        (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND a.uid = ' + this.ctx.session.sessionUser.accountId);
-                    const sqlParam2 = [
-                        this.tableName,
-                        tid,
-                        auditConst.status.uncheck,
-                        auditConst.status.checkNo,
-                    ];
-                    const result2 = await this.db.query(sql2, sqlParam2);
-                    return result2[0].count;
-                case auditConst.filter.status.checking: // 进行中(所有的)
-                    const sql7 =
-                        'SELECT count(*) AS count FROM ?? as a WHERE ' +
-                        '(a.status = ? OR a.status = ?) AND a.tid = ?' +
-                        (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.safeInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                    const sqlParam7 = [this.tableName, status, auditConst.status.checkNoPre, tid, this.ctx.service.safeInspectionAudit.tableName, this.ctx.session.sessionUser.accountId];
-                    const result7 = await this.db.query(sql7, sqlParam7);
-                    return result7[0].count;
-                case auditConst.filter.status.rectification: // 整改中(所有的)
-                case auditConst.filter.status.checkStop: // 终止(所有的)
-                    const sql3 =
-                        'SELECT count(*) AS count FROM ?? as a WHERE ' +
-                        'a.status = ? AND a.tid = ?' +
-                        (this.ctx.session.sessionUser.is_admin || this.ctx.permission.safe_inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.safeInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                    const sqlParam3 = [this.tableName, status, tid, this.ctx.service.safeInspectionAudit.tableName, this.ctx.session.sessionUser.accountId];
-                    const result3 = await this.db.query(sql3, sqlParam3);
-                    return result3[0].count;
-                case auditConst.filter.status.checked: // 已完成(所有的)
-                    const sql4 = 'SELECT count(*) AS count FROM ?? as a WHERE a.status = ? AND a.tid = ?';
-                    const sqlParam4 = [this.tableName, status, tid];
-                    const result4 = await this.db.query(sql4, sqlParam4);
-                    return result4[0].count;
-                default:
-                    break;
+            return { where: clauses.join(' AND '), sqlParam };
+        }
+
+        setFlowLabel(inspection) {
+            const status = inspection.status === undefined ? inspection.mstatus : inspection.status;
+            inspection.flowLabel = inspection.flow_version === 1 && [2, 6, 12].includes(status) ? auditConst.stageString[inspection.flow_stage] + '中' : auditConst.statusString[status];
+            inspection.actionLabel = status === 1 || status === 5 ? '上报' : status === 12 ? '整改' : inspection.flow_version === 1 && inspection.flow_stage === 3 ? '批复' : '审批';
+        }
+
+        async loadFlowUser(inspection) {
+            inspection.user = await this.ctx.service.projectAccount.getAccountInfoById(inspection.uid);
+            inspection.auditors = await this.ctx.service.safeInspectionAudit.getAuditors(inspection.id, inspection.times);
+            inspection.auditorIds = this._.uniq(inspection.auditors.map(x => x.aid));
+            const current = inspection.auditors.filter(x => !x.is_old && x.flow_stage === inspection.flow_stage && x.stage_round === inspection.stage_round);
+            inspection.curAuditors = [2, 6, 12].includes(inspection.status) ? current.filter(x => [2, 12].includes(x.status)) : [];
+            inspection.curAuditorIds = inspection.curAuditors.map(x => x.aid);
+            inspection.flowAuditors = inspection.curAuditors.length ? current.filter(x => x.order === inspection.curAuditors[0].order) : [];
+            inspection.flowAuditorIds = inspection.flowAuditors.map(x => x.aid);
+            const order = inspection.curAuditors.length ? inspection.curAuditors[0].order : 0;
+            const next = current.filter(x => x.order > order && x.status === 1);
+            const nextOrder = next.length ? Math.min(...next.map(x => x.order)) : 0;
+            inspection.nextAuditors = next.filter(x => x.order === nextOrder);
+            inspection.nextAuditorIds = inspection.nextAuditors.map(x => x.aid);
+            this.setFlowLabel(inspection);
+        }
+
+        async loadFlowViewData(inspection) {
+            await this.loadFlowUser(inspection);
+            inspection.flowStages = [1, 2, 3].map(stage => ({
+                stage, name: auditConst.stageString[stage],
+                auditors: inspection.auditors.filter(x => !x.is_old && x.flow_stage === stage && (stage === 1 || x.stage_round === inspection.stage_round)),
+            }));
+            const sql = 'SELECT a.*, p.name, p.company, p.role FROM ?? a LEFT JOIN ?? p ON p.id = a.aid WHERE a.qiid = ? AND a.begin_time IS NOT NULL ORDER BY a.times, a.stage_round, a.flow_stage, a.order, a.id';
+            const sqlParam = [this.ctx.service.safeInspectionAudit.tableName, this.ctx.service.projectAccount.tableName, inspection.id];
+            inspection.flowHistory = await this.db.query(sql, sqlParam);
+            inspection.canAssignRectifiers = inspection.flow_stage === 1 && inspection.shenpiPower && inspection.need_rectification === 1;
+            const rectifiers = inspection.flowStages[1].auditors;
+            inspection.rectificationTotal = rectifiers.length;
+            inspection.rectificationDone = rectifiers.filter(x => x.status === 3).length;
+            inspection.flowDraft = [1, 5].includes(inspection.status);
+            inspection.flowCurrentAudit = inspection.curAuditors.find(x => x.aid === this.ctx.session.sessionUser.accountId) || null;
+            inspection.flowCanReturnPrevious = !!(inspection.flowCurrentAudit && inspection.flow_stage === 1 && inspection.flowStages[0].auditors.some(x => x.audit_order < inspection.flowCurrentAudit.audit_order));
+            for (const phase of inspection.flowStages) {
+                const maxOrder = phase.auditors.reduce((last, audit) => Math.max(last, audit.audit_order), 0);
+                const groups = [];
+                for (const audit of phase.auditors) {
+                    const group = phase.stage === 2 ? null : groups.find(x => x[0].order === audit.order);
+                    if (group) group.push(audit);
+                    else groups.push([audit]);
+                }
+                phase.nodes = groups.map(group => {
+                    const current = group.find(x => [4, 5, 6].includes(x.status)) || group.find(x => [2, 12].includes(x.status)) || group.find(x => x.status === 3) || group[0];
+                    const begins = group.filter(x => x.begin_time).map(x => new Date(x.begin_time).getTime()).filter(Number.isFinite);
+                    const ends = group.filter(x => x.end_time).map(x => new Date(x.end_time).getTime()).filter(Number.isFinite);
+                    const time = [3, 4, 5, 6, 8].includes(current.status) && ends.length ? Math.max(...ends) : begins.length ? Math.min(...begins) : null;
+                    return {
+                        label: phase.stage === 2 ? '整改人' : current.audit_order === maxOrder ? '终审' : current.audit_order + '审',
+                        auditors: group, status: current.status, time: time === null ? null : new Date(time), origin: false,
+                    };
+                });
+                if (phase.stage === 1 && !inspection.flowDraft) {
+                    const starts = inspection.flowHistory.filter(x => x.times === inspection.times && x.flow_stage === 1 && x.begin_time).map(x => new Date(x.begin_time).getTime()).filter(Number.isFinite);
+                    phase.nodes.unshift({ label: '原报', auditors: [inspection.user], status: 3, time: starts.length ? new Date(Math.min(...starts)) : null, origin: true });
+                }
             }
         }
+
+        async getOverviewData(tid, year) {
+            const [counts, rectifications, trend] = await Promise.all([
+                this.getOverviewCounts(tid),
+                this.getOverviewRectifications(tid),
+                this.getOverviewTrend(tid, year),
+            ]);
+            const statuses = [
+                { key: 'rectification', name: '整改中', value: counts.rectification },
+                { key: 'checking', name: '审批中', value: counts.report + counts.reply },
+                { key: 'completed', name: '已完成', value: counts.completed },
+            ];
+            return { counts, statuses, total: statuses.reduce((total, item) => total + item.value, 0), rectifications, trend };
+        }
+
+        async getOverviewRectifications(tid) {
+            const sql = 'SELECT a.id, a.code, a.check_item, MIN(b.begin_time) AS started_at,' +
+                ' COUNT(b.id) AS total, COALESCE(SUM(CASE WHEN b.status = 3 THEN 1 ELSE 0 END), 0) AS done' +
+                ' FROM ?? a LEFT JOIN ?? b ON b.qiid = a.id AND b.tid = a.tid AND b.times = a.times AND b.is_old = 0 AND b.status IN (3, 12)' +
+                ' AND ((a.flow_version = 1 AND b.flow_stage = 2 AND b.stage_round = a.stage_round) OR (a.flow_version = 0 AND b.is_rectification = 1))' +
+                ' WHERE a.tid = ? AND a.status = ? AND (a.flow_version = ? OR a.flow_stage = ?) GROUP BY a.id, a.code, a.check_item' +
+                ' ORDER BY MIN(b.begin_time) IS NULL, MIN(b.begin_time) ASC, a.id ASC LIMIT ?';
+            const sqlParam = [this.tableName, this.ctx.service.safeInspectionAudit.tableName, tid, auditConst.status.rectification, 0, auditConst.stage.rectification, 10];
+            const list = await this.db.query(sql, sqlParam);
+            if (!list.length) return [];
+            const users = await this.getOverviewRectifiers(tid, list.map(item => item.id));
+            const today = moment().startOf('day');
+            return list.map(item => ({
+                id: item.id, code: item.code, check_item: item.check_item || '',
+                total: Number(item.total), done: Number(item.done),
+                pendingUsers: this._.uniqBy(users.filter(user => user.qiid === item.id), 'aid').map(user => user.name || '未知人员'),
+                elapsedDays: item.started_at && moment(item.started_at).isValid() ? Math.max(0, today.diff(moment(item.started_at).startOf('day'), 'days')) : null,
+            }));
+        }
+
+        async getOverviewRectifiers(tid, ids) {
+            if (!ids.length) return [];
+            // ids 来自当前标段的整改清单,只批量读取这十条记录的待处理人员。
+            const sql = 'SELECT b.qiid, b.aid, p.name FROM ?? b JOIN ?? a ON a.id = b.qiid AND a.tid = b.tid' +
+                ' LEFT JOIN ?? p ON p.id = b.aid WHERE a.tid = ? AND a.id IN (?)' +
+                ' AND b.times = a.times AND b.is_old = 0 AND b.status = 12' +
+                ' AND ((a.flow_version = 1 AND b.flow_stage = 2 AND b.stage_round = a.stage_round) OR (a.flow_version = 0 AND b.is_rectification = 1))' +
+                ' ORDER BY b.`order`, b.id';
+            const sqlParam = [this.ctx.service.safeInspectionAudit.tableName, this.tableName, this.ctx.service.projectAccount.tableName, tid, ids];
+            return await this.db.query(sql, sqlParam);
+        }
+
+        async getOverviewTrend(tid, year) {
+            // 首次上报保留历史轮次和被替换节点;完成时间只取当前已完成流程的最终阶段。
+            const sql = 'SELECT YEAR(v.first_report_time) AS report_year, MONTH(v.first_report_time) AS report_month,' +
+                ' YEAR(v.completed_time) AS completed_year, MONTH(v.completed_time) AS completed_month,' +
+                ' COUNT(*) AS inspection_count, SUM(v.needs_rectification) AS rectification_count FROM (' +
+                ' SELECT a.id, CASE WHEN a.flow_version = 1 THEN CASE WHEN a.need_rectification = 1 THEN 1 ELSE 0 END' +
+                ' ELSE CASE WHEN a.rectification_uid > 0 THEN 1 ELSE 0 END END AS needs_rectification,' +
+                ' MIN(CASE WHEN (a.flow_version = 1 AND b.flow_stage = 1) OR (a.flow_version = 0 AND b.is_rectification = 0)' +
+                ' THEN b.begin_time END) AS first_report_time,' +
+                ' MAX(CASE WHEN a.status = 3 AND b.times = a.times AND b.is_old = 0 AND b.status = 3' +
+                ' AND ((a.flow_version = 1 AND b.flow_stage = a.flow_stage AND b.stage_round = a.stage_round)' +
+                ' OR (a.flow_version = 0 AND b.is_rectification = 1)) THEN b.end_time END) AS completed_time' +
+                ' FROM ?? a LEFT JOIN ?? b ON b.qiid = a.id AND b.tid = a.tid WHERE a.tid = ?' +
+                ' GROUP BY a.id, a.flow_version, a.need_rectification, a.rectification_uid' +
+                ' ) v WHERE v.first_report_time IS NOT NULL OR v.completed_time IS NOT NULL' +
+                ' GROUP BY YEAR(v.first_report_time), MONTH(v.first_report_time), YEAR(v.completed_time), MONTH(v.completed_time)';
+            const sqlParam = [this.tableName, this.ctx.service.safeInspectionAudit.tableName, tid];
+            const rows = await this.db.query(sql, sqlParam);
+            const submitted = Array(12).fill(0);
+            const completed = Array(12).fill(0);
+            const needsRectification = Array(12).fill(0);
+            for (const row of rows) {
+                const reportYear = Number(row.report_year);
+                const completedYear = Number(row.completed_year);
+                const reportMonth = Number(row.report_month);
+                const completedMonth = Number(row.completed_month);
+                if (reportYear === year && reportMonth >= 1 && reportMonth <= 12) {
+                    submitted[reportMonth - 1] += Number(row.inspection_count);
+                    needsRectification[reportMonth - 1] += Number(row.rectification_count);
+                }
+                if (completedYear === year && completedMonth >= 1 && completedMonth <= 12) {
+                    completed[completedMonth - 1] += Number(row.inspection_count);
+                }
+            }
+            return {
+                year, months: submitted.map((count, index) => year + '年' + (index + 1) + '月'), submitted, completed,
+                ratios: submitted.map((count, index) => (count ? Number((needsRectification[index] * 100 / count).toFixed(1)) : 0)),
+                hasData: submitted.some(count => count > 0) || completed.some(count => count > 0),
+            };
+        }
+
+        async getOverviewCounts(tid) {
+            const { status, stage } = auditConst;
+            // 概况统一统计当前标段,不复用列表按人员权限筛选的查询。
+            const sql = 'SELECT COUNT(CASE WHEN a.status = ? AND (a.flow_version = ? OR a.flow_stage = ?) THEN 1 END) AS rectification,' +
+                ' COUNT(CASE WHEN a.status IN (?, ?) AND (a.flow_version = ? OR a.flow_stage = ?) THEN 1 END) AS report,' +
+                ' COUNT(CASE WHEN a.flow_version = ? AND a.flow_stage = ? AND a.status IN (?, ?) THEN 1 END) AS reply,' +
+                ' COUNT(CASE WHEN a.status = ? THEN 1 END) AS completed FROM ?? a WHERE a.tid = ?';
+            const sqlParam = [
+                status.rectification, 0, stage.rectification,
+                status.checking, status.checkNoPre, 0, stage.report,
+                1, stage.reply, status.checking, status.checkNoPre,
+                status.checked, this.tableName, tid,
+            ];
+            const result = await this.db.queryOne(sql, sqlParam);
+            return {
+                rectification: Number(result.rectification), report: Number(result.report),
+                reply: Number(result.reply), completed: Number(result.completed),
+            };
+        }
+
     }
 
     return SafeInspection;

+ 315 - 26
app/service/safe_inspection_audit.js

@@ -7,10 +7,11 @@
  * @date 2024/7/22
  * @version
  */
-const auditConst = require('../const/audit').inspection;
+const auditConst = require('../const/safe_inspection');
 const pushType = require('../const/audit').pushType;
 const auditType = require('../const/audit').auditType;
 const shenpiConst = require('../const/shenpi');
+const moment = require('moment');
 module.exports = app => {
 
     class SafeInspectionAudit extends app.BaseService {
@@ -34,12 +35,13 @@ module.exports = app => {
          * @return {Promise<*>}
          */
         async getAuditors(inspectionId, times = 1, order_sort = 'asc') {
-            const sql = 'SELECT la.id, la.tid, la.aid, la.times, la.order, la.status, la.opinion, la.is_rectification, la.is_old, la.begin_time, la.end_time, la.audit_type, la.audit_order,' +
+            const direction = order_sort === 'desc' ? 'DESC' : 'ASC';
+            const sql = 'SELECT la.id, la.tid, la.aid, la.times, la.order, la.status, la.opinion, la.is_rectification, la.is_old, la.begin_time, la.end_time, la.audit_type, la.audit_order, la.flow_stage, la.stage_round, la.rectification_item, la.rectification_date,' +
                 '    pa.name, pa.company, pa.role, pa.mobile, pa.telephone, pa.sign_path' +
-                `  FROM ${this.tableName} la LEFT JOIN ${this.ctx.service.projectAccount.tableName} pa ON la.aid = pa.id` +
+                '  FROM ?? la LEFT JOIN ?? pa ON la.aid = pa.id' +
                 '  WHERE la.qiid = ? AND la.times = ?' +
-                '  ORDER BY la.order ' + order_sort;
-            const sqlParam = [inspectionId, times];
+                '  ORDER BY la.order ' + direction;
+            const sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, inspectionId, times];
             const result = await this.db.query(sql, sqlParam);
             const max_sort = this._.max(result.map(x => { return x.audit_order; }));
             for (const i in result) {
@@ -58,6 +60,11 @@ module.exports = app => {
         }
 
         async getAuditorsByStatus(qiId, status, times = 1) {
+            const inspection = await this.ctx.service.safeInspection.getDataById(qiId);
+            if (inspection && inspection.flow_version === 1) {
+                const auditors = await this.getAuditors(qiId, inspection.times);
+                return auditors.filter(x => !x.is_old && x.flow_stage === inspection.flow_stage && x.stage_round === inspection.stage_round && [2, 12].includes(x.status) && [2, 6, 12].includes(inspection.status));
+            }
             let auditor = [];
             let sql = '';
             let sqlParam = '';
@@ -221,21 +228,23 @@ module.exports = app => {
          * @return {Promise<*>}
          */
         async getAuditInspection(auditorId, spid = '') {
-            const spSql = spid ? ' and t.`spid` = "' + spid + '"' : '';
+            const spSql = spid ? ' AND t.spid = ?' : '';
             const sql = 'SELECT ma.`aid`, ma.`times`, ma.`order`, ma.`begin_time`, ma.`end_time`, ma.`tid`, ma.`qiid`,' +
-                '    m.`code` As `mcode`, m.`status` As `mstatus`,' +
+                '    m.`code` As `mcode`, m.`status` As `mstatus`, m.flow_version, m.flow_stage,' +
                 '    t.`name`, t.`project_id`, t.`type`, t.`user_id`, t.`spid` ' +
                 '  FROM ?? AS ma' +
                 '  LEFT JOIN ?? AS m On ma.qiid = m.id' +
                 '  LEFT JOIN ?? As t On m.tid = t.id' +
-                '  WHERE ((ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?)) OR (m.`uid` = ? and ma.`status` = ? and m.`status` = ? and ma.`times` = (m.`times`-1)))' + spSql +
+                '  WHERE ((ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?) AND ma.times = m.times AND ma.is_old = 0 AND m.status IN (2, 6, 12) AND (m.flow_version = 0 OR (ma.flow_stage = m.flow_stage AND ma.stage_round = m.stage_round))) OR (m.`uid` = ? and ma.`status` = ? and m.`status` = ? and ma.`times` = (m.`times`-1)))' + spSql +
                 '  ORDER BY ma.`begin_time` DESC';
             const sqlParam = [this.tableName, this.ctx.service.safeInspection.tableName, this.ctx.service.tender.tableName, auditorId, auditConst.status.checking, auditConst.status.rectification, auditorId, auditConst.status.checkNo, auditConst.status.checkNo];
+            if (spid) sqlParam.push(spid);
             const result = await this.db.query(sql, sqlParam);
             // 过滤result中存在重复sid的值, 保留最新的一条
             const filterResult = [];
             const qiidArr = [];
             for (const r of result) {
+                this.ctx.service.safeInspection.setFlowLabel(r);
                 if (qiidArr.indexOf(r.qiid) === -1) {
                     filterResult.push(r);
                     qiidArr.push(r.qiid);
@@ -252,14 +261,14 @@ module.exports = app => {
          */
         async getAuditInspectionByWap(auditorId) {
             const sql = 'SELECT ma.`aid`, ma.`times`, ma.`order`, ma.`begin_time`, ma.`end_time`, ma.`tid`, ma.`qiid`,' +
-                '    m.`code` As `mcode`, m.`status` As `mstatus`, m.`check_item`, m.`check_date`,' +
+                '    m.`code` As `mcode`, m.`status` As `mstatus`, m.flow_version, m.flow_stage, m.`check_item`, m.`check_date`,' +
                 '    t.id As t_id, t.`name` As t_name, t.`project_id` As t_pid, t.`type` As t_type, t.`user_id` As t_uid, t.`status` As t_status, t.`spid`, ' +
                 '    ti.`deal_info`' +
                 '  FROM ?? AS ma' +
                 '  LEFT JOIN ?? AS m On ma.qiid = m.id' +
                 '  LEFT JOIN ?? As t On m.tid = t.id' +
-                '  LEFT JOIN ?? As ti On m.tid = ti.id' +
-                '  WHERE ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?)' +
+                '  LEFT JOIN ?? As ti On m.tid = ti.tid' +
+                '  WHERE ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?) AND ma.times = m.times AND ma.is_old = 0 AND m.status IN (2, 6, 12) AND (m.flow_version = 0 OR (ma.flow_stage = m.flow_stage AND ma.stage_round = m.stage_round))' +
                 '  ORDER BY ma.`begin_time` DESC';
             const sqlParam = [this.tableName, this.ctx.service.safeInspection.tableName, this.ctx.service.tender.tableName, this.ctx.service.tenderInfo.tableName, auditorId, auditConst.status.checking, auditConst.status.rectification];
             const result = await this.db.query(sql, sqlParam);
@@ -267,6 +276,7 @@ module.exports = app => {
             const filterResult = [];
             const qiidArr = [];
             for (const r of result) {
+                this.ctx.service.safeInspection.setFlowLabel(r);
                 if (qiidArr.indexOf(r.qiid) === -1) {
                     filterResult.push(r);
                     qiidArr.push(r.qiid);
@@ -357,6 +367,7 @@ module.exports = app => {
         }
 
         async updateNewAuditList(inspection, newList) {
+            if (inspection.flow_version === 1) return this.syncFlowReport(inspection, newList);
             const transaction = await this.db.beginTransaction();
             try {
                 // 先删除旧的审批流,再添加新的
@@ -395,6 +406,12 @@ module.exports = app => {
         }
 
         async updateLastAudit(inspection, auditList, lastId) {
+            if (inspection.flow_version === 1) {
+                const list = auditList.filter(x => x.aid !== lastId).map(x => ({ audit_id: x.aid, audit_type: x.audit_type, audit_order: x.audit_order }));
+                const order = list.length ? Math.max(...list.map(x => x.audit_order)) + 1 : 1;
+                list.push({ audit_id: lastId, audit_type: auditType.key.common, audit_order: order });
+                return this.syncFlowReport(inspection, list);
+            }
             const transaction = await this.db.beginTransaction();
             try {
                 // 先判断auditList里的aid是否与lastId相同,相同则删除并重新更新order
@@ -550,20 +567,12 @@ module.exports = app => {
          * @return {Promise<*>}
          */
         async getDonesByAudit(auditorId, spid = '') {
-            const spSql = spid ? ' and t.`spid` = "' + spid + '"' : '';
-            const status = [auditConst.status.checked, auditConst.status.checkNo, auditConst.status.checkNoPre, auditConst.status.checkStop];
-            const sql =
-                'SELECT la.`status`, la.`end_time` as `shenpi_time`, la.`qiid`, t.`id`, m.`code` As `code`, t.`name`, t.`spid` ' +
-                '  FROM ?? AS la Left Join ?? AS t ON la.`tid` = t.`id` LEFT JOIN ?? AS m ON la.`qiid` = m.`id`' +
-                '  WHERE la.`aid` = ? AND la.`status` in (' + this.ctx.helper.getInArrStrSqlFilter(status) + ')' + spSql +
-                '  ORDER BY la.`end_time` DESC';
-            const sqlParam = [
-                this.tableName,
-                this.ctx.service.tender.tableName,
-                this.ctx.service.safeInspection.tableName,
-                auditorId,
-            ];
-            return await this.db.query(sql, sqlParam);
+            const sql = 'SELECT la.status, la.flow_stage, la.end_time AS shenpi_time, la.qiid, t.id, m.code, t.name, t.spid FROM ?? la LEFT JOIN ?? t ON la.tid = t.id LEFT JOIN ?? m ON la.qiid = m.id WHERE la.aid = ? AND la.status IN (?, ?, ?, ?)' + (spid ? ' AND t.spid = ?' : '') + ' ORDER BY la.end_time DESC';
+            const sqlParam = [this.tableName, this.ctx.service.tender.tableName, this.ctx.service.safeInspection.tableName, auditorId, 3, 5, 6, 4];
+            if (spid) sqlParam.push(spid);
+            const records = await this.db.query(sql, sqlParam);
+            for (const record of records) record.stage_name = record.flow_stage ? auditConst.stageString[record.flow_stage] : '';
+            return records;
         }
 
         /**
@@ -575,7 +584,8 @@ module.exports = app => {
          * @return {Promise<*>}
          */
         async copyPreAuditors(transaction, preInspection, newInspection) {
-            const auditList = await this.getUniqAuditor(preInspection.id, preInspection.times); // 全部参与的审批人
+            const previous = await this.getAuditors(preInspection.id, preInspection.times);
+            const auditList = this._.uniqBy(previous.filter(x => !x.is_old && !x.is_rectification && (preInspection.flow_version !== 1 || x.flow_stage === 1)), x => x.aid + ':' + x.audit_order);
             const newAuditors = [];
             for (const x of auditList) {
                 if (x.audit_order !== 0 && x.is_rectification === 0) {
@@ -583,6 +593,7 @@ module.exports = app => {
                         tid: preInspection.tid, qiid: newInspection.id, aid: x.aid,
                         times: 1, order: x.audit_order, status: auditConst.status.uncheck,
                         audit_type: x.audit_type, audit_order: x.audit_order,
+                        flow_stage: newInspection.flow_version === 1 ? 1 : 0, stage_round: 1,
                     });
                 }
             }
@@ -1272,6 +1283,284 @@ module.exports = app => {
             }
             return content.length ? JSON.stringify(content[0]) : '';
         }
+
+        async lockFlow(transaction, id) {
+            const sql = 'SELECT * FROM ?? WHERE id = ? FOR UPDATE';
+            const sqlParam = [this.ctx.service.safeInspection.tableName, id];
+            const rows = await transaction.query(sql, sqlParam);
+            if (!rows.length || rows[0].tid !== this.ctx.tender.id) throw '巡检不存在';
+            return rows[0];
+        }
+
+        async prepareDraftFlow(inspection) {
+            // 已上报的历史单继续使用旧流程,仅转换尚未上报的草稿。
+            if (inspection.flow_version === 1 || inspection.status !== 1) return;
+            const transaction = await this.db.beginTransaction();
+            try {
+                const current = await this.lockFlow(transaction, inspection.id);
+                if (!current.flow_version && current.status === 1) {
+                    const changes = { flow_version: 1, flow_stage: 1, stage_round: 1, need_rectification: 1 };
+                    await transaction.update(this.ctx.service.safeInspection.tableName, changes, { where: { id: current.id } });
+                    await transaction.update(this.tableName, { flow_stage: 1, stage_round: 1 }, { where: { qiid: current.id, times: current.times, is_rectification: 0 } });
+                    Object.assign(current, changes);
+                }
+                Object.assign(inspection, current);
+                await transaction.commit();
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        async syncFlowReport(inspection, list) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const current = await this.lockFlow(transaction, inspection.id);
+                if (current.flow_version !== 1 || ![1, 5].includes(current.status)) throw '流程已上报,请刷新页面';
+                await transaction.delete(this.tableName, { qiid: current.id, times: current.times, flow_stage: 1, is_old: 0 });
+                const records = list.map(x => this.flowRecord(current, {
+                    aid: x.audit_id, flow_stage: 1, order: x.audit_order, audit_order: x.audit_order, audit_type: x.audit_type,
+                }));
+                if (records.length) {
+                    await transaction.insert(this.tableName, records);
+                    await this.grantFlowPermission(transaction, records.map(x => x.aid));
+                }
+                await transaction.commit();
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        flowRecord(inspection, record) {
+            return {
+                tid: inspection.tid, qiid: inspection.id, times: inspection.times,
+                aid: record.aid, flow_stage: record.flow_stage, stage_round: inspection.stage_round,
+                order: record.order, audit_order: record.audit_order, audit_type: record.audit_type || 1,
+                status: 1, is_old: 0, is_rectification: record.flow_stage === 2 ? 1 : 0,
+                begin_time: null, end_time: null, opinion: '', rectification_item: '', rectification_date: null,
+            };
+        }
+
+        async grantFlowPermission(transaction, ids) {
+            const existing = await this.ctx.service.tenderPermission.getPartsPermission(this.ctx.tender.id, ['safe_inspection']);
+            const missing = this._.uniq(ids).filter(id => !existing.some(x => x.uid === id));
+            if (missing.length) {
+                await this.ctx.service.tenderPermission.saveOnePermission(this.ctx.tender.id, missing, missing.map(uid => ({ uid, safe_inspection: ['1'] })), ['safe_inspection'], transaction);
+            }
+        }
+
+        async activateFlow(transaction, inspection, stage) {
+            const records = await transaction.select(this.tableName, { where: { qiid: inspection.id, times: inspection.times, flow_stage: stage, stage_round: inspection.stage_round, is_old: 0, status: 1 }, orders: [['order', 'asc']] });
+            if (!records.length) throw '请先设置' + auditConst.stageString[stage] + '人员';
+            const active = stage === 2 ? records : records.filter(x => x.order === records[0].order);
+            const status = stage === 2 ? 12 : 2;
+            const begin_time = new Date();
+            await transaction.updateRows(this.tableName, active.map(x => ({ id: x.id, status, begin_time })));
+            await transaction.update(this.ctx.service.safeInspection.tableName, { id: inspection.id, flow_stage: stage, stage_round: inspection.stage_round, status });
+            Object.assign(inspection, { flow_stage: stage, status });
+        }
+
+        async processFlow(inspection, data) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const current = await this.lockFlow(transaction, inspection.id);
+                if (current.flow_version !== 1) throw '流程已变更,请刷新页面';
+                if (Number(data.times) !== current.times || Number(data.stage_round) !== current.stage_round || Number(data.flow_stage) !== current.flow_stage) throw '流程已更新,请刷新后操作';
+                const uid = this.ctx.session.sessionUser.accountId;
+                const ownerEdit = current.uid === uid && [1, 5].includes(current.status);
+                const records = await transaction.select(this.tableName, { where: { qiid: current.id, times: current.times, is_old: 0 }, orders: [['order', 'asc'], ['id', 'asc']] });
+                const active = records.filter(x => x.flow_stage === current.flow_stage && x.stage_round === current.stage_round && [2, 12].includes(x.status));
+                const self = [2, 6, 12].includes(current.status) ? active.find(x => x.aid === uid) : null;
+                const report = records.filter(x => x.flow_stage === 1);
+                const rectifiers = records.filter(x => x.flow_stage === 2 && x.stage_round === current.stage_round);
+                const reply = records.filter(x => x.flow_stage === 3 && x.stage_round === current.stage_round);
+                switch (data.type) {
+                    case 'update-field': {
+                        if (!ownerEdit) throw '当前状态不可修改巡检内容';
+                        const update = data.update || {};
+                        const fields = ['id', 'code', 'check_item', 'check_situation', 'action', 'check_date', 'inspector'];
+                        if (Number(update.id) !== current.id || Object.keys(update).some(x => !fields.includes(x))) throw '参数有误';
+                        for (const key of ['code', 'check_item', 'check_date']) {
+                            if (update[key] !== undefined && !String(update[key]).trim()) throw '巡检编号、检查项和检查日期不能为空';
+                        }
+                        for (const key of ['code', 'check_item', 'check_situation', 'action', 'inspector']) {
+                            if (update[key] !== undefined && (typeof update[key] !== 'string' || update[key].length > (['code', 'inspector'].includes(key) ? 255 : 1000))) throw '字段内容过长或格式错误';
+                        }
+                        if (update.check_date !== undefined) {
+                            if (typeof update.check_date !== 'string' || !moment(update.check_date, 'YYYY-MM-DD', true).isValid()) throw '检查日期格式错误';
+                        }
+                        if (update.code !== undefined && update.code !== current.code) {
+                            const other = await transaction.get(this.ctx.service.safeInspection.tableName, { tid: current.tid, code: update.code });
+                            if (other && other.id !== current.id) throw '该巡检编号已存在';
+                        }
+                        await transaction.update(this.ctx.service.safeInspection.tableName, Object.assign({}, update, { id: current.id }));
+                        break;
+                    }
+                    case 'flow-need-rectification':
+                        throw '请刷新页面,是否整改需随上报一起提交';
+                    case 'flow-add-audit':
+                    case 'flow-del-audit': {
+                        const stage = Number(data.target_stage);
+                        const canAssign = current.flow_stage === 1 && self && current.need_rectification === 1;
+                        if (!([1, 3].includes(stage) && ownerEdit || stage === 2 && canAssign)) throw '您无权设置该阶段人员';
+                        // 草稿可先配置批复人,是否启用整改、批复以上报时的选择为准。
+                        const mode = this.ctx.tender.info.shenpi.safe_inspection;
+                        if (stage === 1 && mode === shenpiConst.sp_status.gdspl) throw '固定上报流程不可修改';
+                        const stageRecords = records.filter(x => x.flow_stage === stage && x.stage_round === current.stage_round);
+                        const id = Number(data.auditorId);
+                        if (!Number.isInteger(id) || id <= 0) throw '请选择有效用户';
+                        if (data.type === 'flow-add-audit') {
+                            if (stage === 1 && id === current.uid) throw '上报审批人不能是上报人本人';
+                            if (stageRecords.some(x => x.aid === id)) throw '该人员已存在';
+                            const users = await this.ctx.service.projectAccount.getAllSubProjectAccount(this.ctx.subProject);
+                            if (!users.some(x => x.id === id)) throw '该用户不在当前项目中';
+                            let order = stage === 2 ? 1 : stageRecords.reduce((n, x) => Math.max(n, x.order), 0) + 1;
+                            if (stage === 1 && mode === shenpiConst.sp_status.gdzs && stageRecords.length) {
+                                const last = stageRecords[stageRecords.length - 1];
+                                order = last.order;
+                                await transaction.update(this.tableName, { id: last.id, order: order + 1, audit_order: order + 1 });
+                            }
+                            await transaction.insert(this.tableName, this.flowRecord(current, { aid: id, flow_stage: stage, order, audit_order: order, audit_type: stage === 2 ? 2 : 1 }));
+                            await this.grantFlowPermission(transaction, [id]);
+                        } else {
+                            const target = stageRecords.find(x => x.aid === id);
+                            if (!target) throw '该人员不存在';
+                            if (stage === 1 && mode === shenpiConst.sp_status.gdzs && target.order === Math.max(...stageRecords.map(x => x.order))) throw '固定终审人不可移除';
+                            await transaction.delete(this.tableName, { id: target.id });
+                        }
+                        break;
+                    }
+                    case 'start-inspection':
+                        if (!ownerEdit) throw '当前不可上报';
+                        if (![0, 1].includes(data.need_rectification)) throw '请选择是否整改';
+                        if (!current.code || !current.check_item || !current.check_date) throw '请完善巡检信息';
+                        if (!report.length) throw '请设置上报审批人';
+                        if (data.need_rectification && !reply.length) throw '请设置批复审批人';
+                        await transaction.update(this.ctx.service.safeInspection.tableName, { id: current.id, need_rectification: data.need_rectification });
+                        current.need_rectification = data.need_rectification;
+                        await this.activateFlow(transaction, current, 1);
+                        break;
+                    case 'del-inspection': {
+                        if (!ownerEdit) throw '当前不可删除';
+                        const files = await transaction.select(this.ctx.service.safeInspectionAtt.tableName, { where: { qiid: current.id } });
+                        if (files.length) await this.ctx.helper.delFiles(files);
+                        await transaction.delete(this.tableName, { qiid: current.id });
+                        await transaction.delete(this.ctx.service.safeInspectionAtt.tableName, { qiid: current.id });
+                        await transaction.delete(this.ctx.service.safeInspection.tableName, { id: current.id });
+                        break;
+                    }
+                    case 'flow-save-rectification':
+                    case 'flow-complete-rectification': {
+                        if (!self || current.flow_stage !== 2 || self.status !== 12 || Number(data.auditId) !== self.id) throw '您没有待处理的整改任务';
+                        const item = typeof data.rectification_item === 'string' ? data.rectification_item.trim() : '';
+                        const date = data.rectification_date || null;
+                        const opinion = typeof data.opinion === 'string' ? data.opinion.trim() : '';
+                        if (item.length > 1000 || opinion.length > 1000) throw '整改内容和意见最多1000字';
+                        if (date && (typeof date !== 'string' || !moment(date, 'YYYY-MM-DD', true).isValid())) throw '整改日期格式错误';
+                        const done = data.type === 'flow-complete-rectification';
+                        if (done && (!item || !date)) throw '请填写整改内容和整改日期';
+                        const update = { id: self.id, rectification_item: item, rectification_date: date, opinion };
+                        if (done) Object.assign(update, { status: 3, end_time: new Date() });
+                        await transaction.update(this.tableName, update);
+                        // 所有提交都先锁巡检主记录,再检查本轮其他人的结果。
+                        if (done && rectifiers.every(x => x.id === self.id || x.status === 3)) await this.activateFlow(transaction, current, 3);
+                        if (done) await this.noticeFlow(transaction, current, records, 3, opinion, 2);
+                        break;
+                    }
+                    case 'flow-check': {
+                        if (!self || ![1, 3].includes(current.flow_stage) || self.status !== 2 || Number(data.auditId) !== self.id) throw '您没有待处理的审批任务';
+                        const result = Number(data.checkType);
+                        const opinion = typeof data.opinion === 'string' ? data.opinion.trim() : '';
+                        if (![3, 4, 5, 6].includes(result) || opinion.length > 1000) throw '审批参数有误';
+                        if (result !== 3 && !opinion) throw '请填写退回或关闭原因';
+                        const phase = current.flow_stage;
+                        if (result === 3) {
+                            const phaseRecords = records.filter(x => x.flow_stage === phase && x.stage_round === current.stage_round);
+                            const group = phaseRecords.filter(x => x.order === self.order);
+                            const advance = self.audit_type !== auditType.key.and || group.every(x => x.id === self.id || x.status === 3);
+                            const later = phaseRecords.filter(x => x.order > self.order && x.status === 1);
+                            if (advance && !later.length && phase === 1 && current.need_rectification && !rectifiers.length) throw '请先指派至少一名整改人';
+                            await transaction.update(this.tableName, { id: self.id, status: 3, opinion, end_time: new Date() });
+                            if (advance) {
+                                const skipped = group.filter(x => x.id !== self.id && x.status === 2);
+                                if (skipped.length) await transaction.updateRows(this.tableName, skipped.map(x => ({ id: x.id, status: 8, end_time: new Date() })));
+                                if (later.length) {
+                                    await this.activateFlow(transaction, current, phase);
+                                } else if (phase === 1 && current.need_rectification) {
+                                    await this.activateFlow(transaction, current, 2);
+                                } else {
+                                    await transaction.update(this.ctx.service.safeInspection.tableName, { id: current.id, status: 3 });
+                                    current.status = 3;
+                                }
+                            }
+                        } else {
+                            await this.returnFlow(transaction, current, records, self, result, opinion);
+                        }
+                        await this.noticeFlow(transaction, current, records, result, opinion, phase);
+                        break;
+                    }
+                    default: throw '无效的新流程操作';
+                }
+                await transaction.commit();
+                return true;
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        async returnFlow(transaction, inspection, records, self, result, opinion) {
+            const time = new Date();
+            await transaction.update(this.tableName, { id: self.id, status: result, opinion, end_time: time });
+            const active = records.filter(x => x.id !== self.id && [2, 12].includes(x.status));
+            if (active.length) await transaction.updateRows(this.tableName, active.map(x => ({ id: x.id, status: 8, end_time: time })));
+            if (result === 4) {
+                await transaction.update(this.ctx.service.safeInspection.tableName, { id: inspection.id, status: 4 });
+                inspection.status = 4;
+                return;
+            }
+            if (inspection.flow_stage === 3) {
+                // 批复退回:原轮次保留,所有整改人同时开始新一轮整改。
+                const previous = records.filter(x => [2, 3].includes(x.flow_stage) && x.stage_round === inspection.stage_round);
+                inspection.stage_round += 1;
+                const copies = previous.map(x => this.flowRecord(inspection, x));
+                if (copies.length) await transaction.insert(this.tableName, copies);
+                await this.activateFlow(transaction, inspection, 2);
+                return;
+            }
+            if (result === 5) {
+                const templates = records.filter(x => x.flow_stage === 1 || x.flow_stage === 3 && x.stage_round === inspection.stage_round);
+                inspection.times += 1;
+                inspection.stage_round = 1;
+                const copies = templates.map(x => this.flowRecord(inspection, x));
+                if (copies.length) await transaction.insert(this.tableName, copies);
+                await transaction.update(this.ctx.service.safeInspection.tableName, { id: inspection.id, times: inspection.times, stage_round: 1, flow_stage: 1, status: 5 });
+                inspection.status = 5;
+                return;
+            }
+            const report = records.filter(x => x.flow_stage === 1);
+            const earlier = report.filter(x => x.audit_order < self.audit_order);
+            if (!earlier.length) throw '当前为首个上报审批节点,请退回上报人';
+            const target = Math.max(...earlier.map(x => x.audit_order));
+            const replaced = report.filter(x => x.audit_order >= target);
+            await transaction.updateRows(this.tableName, replaced.map(x => ({ id: x.id, is_old: 1 })));
+            const maxOrder = Math.max(...report.map(x => x.order));
+            const orders = this._.uniq(replaced.map(x => x.audit_order)).sort((a, b) => a - b);
+            const copies = replaced.map(x => this.flowRecord(inspection, Object.assign({}, x, { order: maxOrder + orders.indexOf(x.audit_order) + 1 })));
+            await transaction.insert(this.tableName, copies);
+            await this.activateFlow(transaction, inspection, 1);
+            await transaction.update(this.ctx.service.safeInspection.tableName, { id: inspection.id, status: 6 });
+            inspection.status = 6;
+        }
+
+        async noticeFlow(transaction, inspection, records, result, opinion, stage) {
+            const content = JSON.parse(await this.getNoticeContent(this.ctx.session.sessionProject.id, inspection.tid, inspection.id, this.ctx.session.sessionUser.accountId, opinion) || '{}');
+            content.flow_stage = stage;
+            content.stage_name = auditConst.stageString[stage];
+            const users = this._.uniq([inspection.uid].concat(records.map(x => x.aid)));
+            await transaction.insert('zh_notice', users.map(uid => ({ pid: this.ctx.session.sessionProject.id, tid: inspection.tid, type: pushType.safeInspection, uid, status: result, content: JSON.stringify(content) })));
+        }
     }
 
     return SafeInspectionAudit;

+ 0 - 6
app/service/stage_att.js

@@ -64,12 +64,6 @@ module.exports = app => {
                 item.orginpath = this.ctx.app.config.fujianOssPath + item.filepath;
                 item.filepath = this.ctx.app.config.fujianOssPath + item.filepath;
                 item.viewpath = this.ctx.helper.getPreviewPath(item.fileext, item.filepath);
-                delete item.filepath;
-                // if (!this.ctx.helper.canPreview(item.fileext)) {
-                //     item.filepath = `/tender/${this.ctx.tender.id}/measure/stage/${this.ctx.params.order}/download/file/${item.id}`;
-                // } else {
-                //     item.filepath = this.ctx.app.config.fujianOssPath + item.filepath;
-                // }
                 item.in_time = this.ctx.moment(item.in_time * 1000).format('YYYY-MM-DD');
             });
         }

+ 7 - 4
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;
@@ -2479,7 +2482,7 @@ module.exports = app => {
             const skipAudit = [];
             for (const audit of stage.curAuditors) {
                 const auditNodes = audit.audit_ledger_id.split(',').map(x => { return billsTree.getItems(parseInt(x))});
-                if (billsTree.checkNodesUsed(auditNodes, checkCalcFields)) {
+                if (!billsTree.checkNodesUsed(auditNodes, checkCalcFields)) {
                     skipAudit.push({ id: audit.id, status: auditConst.status.checkSkip, opinion: '协同节点未计量', end_time: curTime });
                 }
             }

+ 24 - 6
app/service/stage_change.js

@@ -178,7 +178,7 @@ class autoUseChange {
                     end_contract_qty = this.helper.add(end_contract_qty, preSb.contract_qty);
                 }
                 const end_contract_tp = this.helper.mul(end_contract_qty, bills.unit_price, this.decimal.tp);
-                return activeQty === end_contract_qty ? this.helper.sub(end_contract_tp, preSb.contract_tp) : this.helper.mul(contract_qty, bills.unit_price, this.decimal.tp);
+                return activeQty === end_contract_qty ? this.helper.sub(end_contract_tp, preSb ? preSb.contract_tp : 0) : this.helper.mul(contract_qty, bills.unit_price, this.decimal.tp);
             } else {
                 return this.helper.mul(contract_qty, bills.unit_price, this.decimal.tp);
             }
@@ -785,6 +785,16 @@ module.exports = app => {
 
                 const sumBills = [];
                 for (const cb of changeBillsPart[cid]) {
+                    cb.tp = this.ctx.helper.mul(cb.spamount, cb.unit_price, c.tp_decimal || this.ctx.tender.info.decimal.tp);
+                    cb.used_tp = this.ctx.helper.mul(cb.used_qty, cb.unit_price, this.ctx.tender.info.decimal.tp);
+                    if (cb.spamount > 0) {
+                        cb.p_tp = cb.tp;
+                        cb.p_used_tp = cb.used_tp;
+                    } else {
+                        cb.n_tp = cb.n_tp;
+                        cb.n_used_tp = cb.used_tp;
+                    }
+
                     let sb;
                     if (cb.gcl_id) {
                         sb = sumBills.find(x => { return x.gcl_id === cb.gcl_id; });
@@ -793,30 +803,38 @@ module.exports = app => {
                         sb = { gcl_id: cb.gcl_id, unit_price: cb.unit_price, used_qty: 0, p_used_qty: 0, n_used_qty: 0 };
                         sumBills.push(sb);
                     }
+                    sb.quantity = this.ctx.helper.add(sb.quantity, cb.spamount);
                     sb.used_qty = this.ctx.helper.add(sb.used_qty, cb.used_qty);
+
                     if (cb.spamount > 0) {
+                        sb.p_qty = this.ctx.helper.add(sb.p_qty, cb.spamount);
                         sb.p_used_qty = this.ctx.helper.add(sb.p_used_qty, cb.used_qty);
                     } else {
+                        sb.n_qty = this.ctx.helper.add(sb.n_qty, cb.spamount);
                         sb.n_used_qty = this.ctx.helper.add(sb.n_used_qty, cb.used_qty);
                     }
 
-                    cb.tp = this.ctx.helper.mul(cb.spamount, cb.unit_price, c.tp_decimal || this.ctx.tender.info.decimal.tp);
-                    cb.used_tp = this.ctx.helper.mul(cb.used_qty, cb.unit_price, this.ctx.tender.info.decimal.tp);
                 }
                 for (const sb of sumBills) {
+                    sb.tp = this.ctx.helper.mul(sb.quantity, sb.unit_price, this.ctx.tender.info.decimal.tp);
+                    sb.p_tp = this.ctx.helper.mul(sb.p_qty, sb.unit_price, this.ctx.tender.info.decimal.tp);
+                    sb.n_tp = this.ctx.helper.mul(sb.n_qty, sb.unit_price, this.ctx.tender.info.decimal.tp);
                     sb.used_tp = this.ctx.helper.mul(sb.used_qty, sb.unit_price, this.ctx.tender.info.decimal.tp);
                     sb.p_used_tp = this.ctx.helper.mul(sb.p_used_qty, sb.unit_price, this.ctx.tender.info.decimal.tp);
                     sb.n_used_tp = this.ctx.helper.mul(sb.n_used_qty, sb.unit_price, this.ctx.tender.info.decimal.tp);
 
+                    c.tp = this.ctx.helper.add(c.tp, sb.tp);
+                    c.p_tp = this.ctx.helper.add(c.p_tp, sb.p_tp);
+                    c.n_tp = this.ctx.helper.add(c.n_tp, sb.n_tp);
                     c.used_tp = this.ctx.helper.add(c.used_tp, sb.used_tp);
                     c.p_used_tp = this.ctx.helper.add(c.p_used_tp, sb.p_used_tp);
                     c.n_used_tp = this.ctx.helper.add(c.n_used_tp, sb.n_used_tp);
                 }
                 c.p_tp = c.positive_tp;
                 c.n_tp = c.negative_tp;
-                c.used_pt = c.total_price ? this.ctx.helper.mul(this.ctx.helper.div(c.used_tp, c.total_price, 4), 100) : 0;
-                c.p_used_pt = c.positive_tp ? this.ctx.helper.mul(this.ctx.helper.div(c.p_used_tp, c.positive_tp, 4), 100) : 0;
-                c.n_used_pt = c.negative_tp ? this.ctx.helper.mul(this.ctx.helper.div(c.n_used_tp, c.negative_tp, 4), 100) : 0;
+                c.used_pt = c.total_price ? this.ctx.helper.mul(this.ctx.helper.div(c.used_tp, c.tp, 4), 100) : 0;
+                c.p_used_pt = c.positive_tp ? this.ctx.helper.mul(this.ctx.helper.div(c.p_used_tp, c.p_tp, 4), 100) : 0;
+                c.n_used_pt = c.negative_tp ? this.ctx.helper.mul(this.ctx.helper.div(c.n_used_tp, c.n_tp, 4), 100) : 0;
             }
             return change;
         }

+ 1 - 0
app/service/stage_stash.js

@@ -55,6 +55,7 @@ class loadStageExcelTree {
                     const relaPos = self.pos.getLedgerPos(x.id);
                     x.has_pos = !!(relaPos && relaPos.length > 0);
                 }
+                if (!x.unit_price) x.unit_price = 0;
                 return node.b_code === _.trimEnd(x.b_code) && node.name === _.trimEnd(x.name) && node.unit === _.trimEnd(x.unit)
                     && node.unit_price === x.unit_price && node.has_pos === x.has_pos;
             } else {

+ 223 - 0
app/service/sub_proj_data_range.js

@@ -0,0 +1,223 @@
+'use strict';
+
+const accountGroup = require('../const/account_group').group;
+
+/**
+ * 子项目功能栏目数据范围
+ *
+ * range_type: 1-单位类型,2-单位,3-具体用户
+ *
+ * @param {Object} app - Egg应用实例
+ * @return {Object} 数据范围Service
+ */
+module.exports = app => {
+    class SubProjDataRange extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'sub_project_data_range';
+            this.Module = {
+                file: { key: 'file', name: '资料管理' },
+            };
+            this.RangeType = {
+                unitType: 1,
+                unit: 2,
+                user: 3,
+            };
+        }
+
+        getModuleList() {
+            return Object.keys(this.Module).map(key => this.Module[key]);
+        }
+
+        getModule(moduleKey) {
+            const moduleInfo = this.Module[moduleKey];
+            if (!moduleInfo) throw '数据范围功能栏目不存在';
+            return moduleInfo;
+        }
+
+        _toId(value) {
+            const id = Number(value);
+            return Number.isInteger(id) && id > 0 ? id : 0;
+        }
+
+        _getRangeKey(rangeType) {
+            if (rangeType === this.RangeType.unitType) return 'unit_type_ids';
+            if (rangeType === this.RangeType.unit) return 'unit_ids';
+            if (rangeType === this.RangeType.user) return 'user_ids';
+            return '';
+        }
+
+        async getRangeRows(spid, moduleKey) {
+            this.getModule(moduleKey);
+            return await this.getAllDataByCondition({
+                where: { spid, module_key: moduleKey },
+                orders: [['range_type', 'asc'], ['target_id', 'asc']],
+            });
+        }
+
+        async getRangeConfig(spid, moduleKey) {
+            const rows = await this.getRangeRows(spid, moduleKey);
+            const result = {
+                configured: rows.length > 0,
+                unit_type_ids: [],
+                unit_ids: [],
+                user_ids: [],
+            };
+            rows.forEach(row => {
+                const key = this._getRangeKey(Number(row.range_type));
+                const targetId = this._toId(row.target_id);
+                if (key && targetId && result[key].indexOf(targetId) < 0) result[key].push(targetId);
+            });
+            return result;
+        }
+
+        async _getScopeSource(subProject) {
+            const result = await Promise.all([
+                this.ctx.service.constructionUnit.getAllDataByCondition({
+                    where: { pid: subProject.project_id },
+                    orders: [['type', 'asc'], ['id', 'asc']],
+                }),
+                this.ctx.service.projectAccount.getAllSubProjectAccount(subProject, [
+                    'id', 'name', 'company', 'company_id', 'role', 'account_group', 'mobile',
+                ]),
+            ]);
+            return { unitList: result[0], accountList: result[1] };
+        }
+
+        async getRangeTree(subProject) {
+            const source = await this._getScopeSource(subProject);
+            const unitList = source.unitList;
+            const accountList = source.accountList;
+            const assignedUserIds = [];
+            const typeIds = this._.uniq(unitList.map(unit => Number(unit.type))).sort((a, b) => a - b);
+            const tree = typeIds.map(typeId => {
+                const units = unitList.filter(unit => Number(unit.type) === typeId).map(unit => {
+                    const users = accountList.filter(account => {
+                        const sameId = Number(account.company_id) === Number(unit.id);
+                        const oldDataMatch = !account.company_id && account.company === unit.name;
+                        if (sameId || oldDataMatch) assignedUserIds.push(Number(account.id));
+                        return sameId || oldDataMatch;
+                    }).map(account => ({
+                        id: Number(account.id),
+                        name: account.name,
+                        role: account.role || '',
+                    }));
+                    return { id: Number(unit.id), name: unit.name, users };
+                });
+                return {
+                    id: typeId,
+                    name: accountGroup[typeId] || `单位类型${typeId}`,
+                    units,
+                };
+            });
+
+            const unassignedUsers = accountList.filter(account => assignedUserIds.indexOf(Number(account.id)) < 0).map(account => ({
+                id: Number(account.id),
+                name: account.name,
+                company: account.company || '',
+                role: account.role || '',
+                account_group: Number(account.account_group) || 0,
+            }));
+            return { tree, unassignedUsers };
+        }
+
+        _normalizeRanges(ranges) {
+            if (!(ranges instanceof Array)) throw '数据范围格式错误';
+            const result = [];
+            const exists = {};
+            ranges.forEach(range => {
+                const rangeType = Number(range.range_type);
+                const targetId = this._toId(range.target_id);
+                if (!this._getRangeKey(rangeType) || !targetId) throw '数据范围包含无效数据';
+                const key = `${rangeType}_${targetId}`;
+                if (!exists[key]) {
+                    exists[key] = true;
+                    result.push({ range_type: rangeType, target_id: targetId });
+                }
+            });
+            return result;
+        }
+
+        async _validateRanges(subProject, ranges) {
+            const normalized = this._normalizeRanges(ranges);
+            const source = await this._getScopeSource(subProject);
+            const valid = {};
+            valid[this.RangeType.unitType] = this._.uniq(source.unitList.map(unit => Number(unit.type)));
+            valid[this.RangeType.unit] = source.unitList.map(unit => Number(unit.id));
+            valid[this.RangeType.user] = source.accountList.map(account => Number(account.id));
+            normalized.forEach(range => {
+                if (valid[range.range_type].indexOf(range.target_id) < 0) throw '数据范围包含不属于当前项目的数据';
+            });
+            return normalized;
+        }
+
+        async saveRange(subProject, moduleKey, ranges, createUid) {
+            this.getModule(moduleKey);
+            const normalized = await this._validateRanges(subProject, ranges);
+            const insertData = normalized.map(range => ({
+                id: this.uuid.v4(),
+                pid: subProject.project_id,
+                spid: subProject.id,
+                module_key: moduleKey,
+                range_type: range.range_type,
+                target_id: range.target_id,
+                create_uid: createUid,
+            }));
+            const transaction = await this.db.beginTransaction();
+            try {
+                await transaction.delete(this.tableName, { spid: subProject.id, module_key: moduleKey });
+                if (insertData.length > 0) await transaction.insert(this.tableName, insertData);
+                await transaction.commit();
+                return await this.getRangeConfig(subProject.id, moduleKey);
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        async getSelectableAccounts(subProject, moduleKey) {
+            const source = await this._getScopeSource(subProject);
+            const rows = await this.getRangeRows(subProject.id, moduleKey);
+            // 历史项目未配置数据范围时,保持原有的“全部子项目成员”行为。
+            if (rows.length === 0) return source.accountList;
+
+            const unitTypeIds = [];
+            const unitIds = [];
+            const userIds = [];
+            rows.forEach(row => {
+                const targetId = Number(row.target_id);
+                if (Number(row.range_type) === this.RangeType.unitType) unitTypeIds.push(targetId);
+                if (Number(row.range_type) === this.RangeType.unit) unitIds.push(targetId);
+                if (Number(row.range_type) === this.RangeType.user) userIds.push(targetId);
+            });
+            const unitIndex = {};
+            const unitNameIndex = {};
+            source.unitList.forEach(unit => {
+                unitIndex[Number(unit.id)] = unit;
+                unitNameIndex[unit.name] = unit;
+            });
+            return source.accountList.filter(account => {
+                const accountId = Number(account.id);
+                const unit = unitIndex[Number(account.company_id)] || unitNameIndex[account.company];
+                const companyId = unit ? Number(unit.id) : Number(account.company_id);
+                const unitType = unit ? Number(unit.type) : Number(account.account_group);
+                return userIds.indexOf(accountId) >= 0 ||
+                    unitIds.indexOf(companyId) >= 0 ||
+                    unitTypeIds.indexOf(unitType) >= 0;
+            });
+        }
+
+        async checkSelectableUserIds(subProject, moduleKey, userIds) {
+            if (!(userIds instanceof Array)) throw '授权用户数据格式错误';
+            const normalized = this._.uniq(userIds.map(id => this._toId(id)));
+            if (normalized.indexOf(0) >= 0) throw '授权用户数据错误';
+            const selectableAccounts = await this.getSelectableAccounts(subProject, moduleKey);
+            const selectableIds = selectableAccounts.map(account => Number(account.id));
+            if (normalized.find(id => selectableIds.indexOf(id) < 0)) throw '选择的用户超出资料管理数据范围';
+            return normalized;
+        }
+    }
+
+    return SubProjDataRange;
+};

+ 35 - 7
app/service/sub_proj_permission.js

@@ -31,10 +31,12 @@ module.exports = app => {
                     edit: { title: '编辑', value: 2 },
                 },
                 file: {
-                    view: { title: '查看', value: 1 },
-                    upload: { title: '上传/引用', value: 2 },
-                    editfile: { title: '编辑文件', value: 4 },
-                    filing: { title: '文件类别编辑', value: 3 },
+                    // view: { title: '查看', value: 1 },
+                    // upload: { title: '上传/引用', value: 2 },
+                    // editfile: { title: '编辑文件', value: 4 },
+                    // filing: { title: '文件类别编辑', value: 3 },
+                    manage_dir: { title: '管理目录', value: 5 },
+                    auth_user: { title: '授权用户', value: 6 },
                 },
                 manage: {
                     rela: { title: '关联标段', value: 1 },
@@ -144,7 +146,8 @@ module.exports = app => {
         get touristPermission () {
             return {
                 budget_permission: [this.PermissionConst.budget.view.value],
-                file_permission: [this.PermissionConst.file.view.value],
+                // 旧数据兼容:游客仍保留只读的资料查看权限。
+                file_permission: [1],
                 manage_permission: [],
                 filing_type: 'all',
                 info_permission: [this.PermissionConst.info.view.value],
@@ -287,6 +290,15 @@ module.exports = app => {
         async _updateUserPermission(data) {
             const datas = data instanceof Array ? data : [data];
             const updateData = [];
+            const filePermissionIds = datas.filter(x => x.file_permission !== undefined).map(x => x.id);
+            const oldFilePermissionMap = {};
+            if (filePermissionIds.length > 0) {
+                const oldPermissions = await this.getAllDataByCondition({
+                    columns: ['id', 'file_permission'],
+                    where: { id: filePermissionIds },
+                });
+                oldPermissions.forEach(x => { oldFilePermissionMap[x.id] = x.file_permission || ''; });
+            }
             // const contractData = [];
             for (const x of datas) {
                 const ud = { id: x.id };
@@ -296,7 +308,18 @@ module.exports = app => {
                             if (x[c.field] !== undefined) ud[c.field] = x[c.field] || '';
                         }
                     } else {
-                        if (x[p.field] !== undefined) ud[p.field] = x[p.field] || '';
+                        if (x[p.field] !== undefined) {
+                            if (p.field === 'file_permission') {
+                                // 新权限页只配置 5/6,保留旧项目已有的 1-4,避免隐藏列被覆盖。
+                                const oldValues = String(oldFilePermissionMap[x.id] || '').split(',')
+                                    .map(value => Number(value)).filter(value => value > 0 && value <= 4);
+                                const newValues = String(x[p.field] || '').split(',')
+                                    .map(value => Number(value)).filter(value => value >= 5);
+                                ud[p.field] = this._.uniq(oldValues.concat(newValues)).join(',');
+                            } else {
+                                ud[p.field] = x[p.field] || '';
+                            }
+                        }
                         // if (p.field === 'contract_permission') {
                         //     const spAudit = await this.getDataById(x.id);
                         //     if (spAudit) {
@@ -396,7 +419,12 @@ module.exports = app => {
         }
 
         async getFilingType(subProjectId) {
-            const permissionConst = {}, prefix = 'f';
+            const permissionConst = {
+                f1: '查看',
+                f2: '上传/引用',
+                f3: '文件类别编辑',
+                f4: '编辑文件',
+            }, prefix = 'f';
             for (const p in this.PermissionConst.file) {
                 const fp = this.PermissionConst.file[p];
                 permissionConst[prefix + fp.value] = fp.title;

+ 37 - 0
app/service/sub_project.js

@@ -281,6 +281,43 @@ module.exports = app => {
             return this._filterEmptyFolder(result);
         }
 
+        /**
+         * 获取当前账号可用于复制资料目录的项目。
+         *
+         * 仅返回账号拥有“管理目录”权限、且已经存在有效资料目录的非文件夹项目。
+         * 管理员默认拥有管理目录权限。
+         *
+         * @param {Number} pid 主项目ID
+         * @param {Number} uid 当前账号ID
+         * @param {Boolean} admin 是否管理员
+         * @param {String} excludeSpid 需要排除的当前子项目ID
+         * @return {Array} 可复制目录的项目
+         */
+        async getManageDirProjects(pid, uid, admin, excludeSpid = '') {
+            let projects = await this.getAllDataByCondition({
+                columns: ['id', 'name', 'tree_order'],
+                where: { project_id: pid, is_folder: 0, is_delete: 0 },
+                orders: [['tree_order', 'asc']],
+            });
+            projects = projects.filter(project => String(project.id) !== String(excludeSpid));
+            if (!admin && projects.length > 0) {
+                const permissionRows = await this.ctx.service.subProjPermission.getUserPermission(pid, uid);
+                const manageDirValue = this.ctx.service.subProjPermission.PermissionConst.file.manage_dir.value;
+                projects = projects.filter(project => {
+                    const permission = permissionRows.find(row => String(row.spid) === String(project.id));
+                    return permission && permission.file_permission.indexOf(manageDirValue) >= 0;
+                });
+            }
+            if (projects.length === 0) return [];
+
+            const filingRows = await this.ctx.service.filing.getAllDataByCondition({
+                columns: ['spid'],
+                where: { spid: projects.map(project => project.id), is_deleted: 0 },
+            });
+            const filingSpids = new Set(filingRows.map(filing => String(filing.spid)));
+            return projects.filter(project => filingSpids.has(String(project.id)));
+        }
+
         async getLastChild(tree_pid) {
             const result = await this.getAllDataByCondition({ where: { tree_pid, project_id: this.ctx.session.sessionProject.id }, orders: [['tree_order', 'desc']], limit: 1, offset: 0 });
             return result[0];

+ 597 - 0
app/service/sub_project_filing_permission.js

@@ -0,0 +1,597 @@
+'use strict';
+
+/**
+ * 资料目录用户权限。
+ *
+ * 新数据按 filing_id 精确到目录节点;filing_id 为 NULL 的记录是旧版
+ * filing_type 权限。用户在某个 filing_type 下只要存在节点权限,就按
+ * 节点精确判断;授权节点的祖先只获得路径查看权限,兄弟与子节点不继承
+ * 操作权限。没有节点权限时才回退旧版 filing_type 与 file_permission。
+ *
+ * @param {Object} app Egg 应用
+ * @return {Object} Service
+ */
+module.exports = app => {
+    class SubProjectFilingPermission extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'sub_project_filing_permission';
+            this.PermissionKey = {
+                upload: 'can_upload',
+                editFile: 'can_edit_file',
+                editDir: 'can_edit_dir',
+                lockFile: 'can_lock_file',
+            };
+        }
+
+        _toPermissionList(value) {
+            if (value instanceof Array) return value.map(x => Number(x)).filter(x => x > 0);
+            if (!value) return [];
+            return String(value).split(',').map(x => Number(x)).filter(x => x > 0);
+        }
+
+        _toFlag(value) {
+            return Number(value) === 1 ? 1 : 0;
+        }
+
+        _legacyFlags(filePermission) {
+            const permissions = this._toPermissionList(filePermission);
+            return {
+                can_view: 1,
+                can_upload: permissions.indexOf(2) >= 0 ? 1 : 0,
+                can_edit_file: permissions.indexOf(4) >= 0 ? 1 : 0,
+                can_edit_dir: permissions.indexOf(3) >= 0 ? 1 : 0,
+                can_lock_file: 0,
+                explicit: false,
+                scope: 'legacy',
+            };
+        }
+
+        _rowFlags(row) {
+            return {
+                can_view: 1,
+                can_upload: this._toFlag(row.can_upload),
+                can_edit_file: this._toFlag(row.can_edit_file),
+                can_edit_dir: this._toFlag(row.can_edit_dir),
+                can_lock_file: this._toFlag(row.can_lock_file),
+                explicit: true,
+                scope: row.filing_id ? 'node' : 'legacy',
+            };
+        }
+
+        _emptyFlags() {
+            return {
+                can_view: 0,
+                can_upload: 0,
+                can_edit_file: 0,
+                can_edit_dir: 0,
+                can_lock_file: 0,
+                explicit: true,
+                scope: 'node',
+            };
+        }
+
+        _pathFlags() {
+            return {
+                can_view: 1,
+                can_upload: 0,
+                can_edit_file: 0,
+                can_edit_dir: 0,
+                can_lock_file: 0,
+                explicit: false,
+                scope: 'path',
+            };
+        }
+
+        _adminFlags() {
+            return {
+                can_view: 1,
+                can_upload: 1,
+                can_edit_file: 1,
+                can_edit_dir: 1,
+                can_lock_file: 1,
+                explicit: true,
+                scope: 'admin',
+            };
+        }
+
+        _hasLegacyView(filingType, legacyFilingTypes) {
+            if (legacyFilingTypes === 'all') return true;
+            return this._toPermissionList(legacyFilingTypes).indexOf(Number(filingType)) >= 0;
+        }
+
+        _resolvePermissionFromRows(rows, filingId, filingType, legacyFilePermission, legacyFilingTypes, pathViewIds) {
+            const type = Number(filingType);
+            const nodeId = String(filingId || '');
+            const exact = rows.find(row => row.filing_id && String(row.filing_id) === nodeId);
+            if (exact) return this._rowFlags(exact);
+
+            // 精确授权节点的祖先只获得查看权限,用于展示通往授权节点的完整树路径。
+            if (pathViewIds && pathViewIds.has(nodeId)) return this._pathFlags();
+
+            // 同类型已有任一节点权限时,未明确授权的兄弟或父子节点不可查看。
+            const hasExactType = rows.some(row => row.filing_id && Number(row.filing_type) === type);
+            if (hasExactType) return this._emptyFlags();
+
+            if (!this._hasLegacyView(type, legacyFilingTypes)) return this._emptyFlags();
+            const legacyRow = rows.find(row => !row.filing_id && Number(row.filing_type) === type);
+            return legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(legacyFilePermission);
+        }
+
+        _getPathViewIds(rows, filingList) {
+            const filingMap = {};
+            (filingList || []).forEach(filing => { filingMap[String(filing.id)] = filing; });
+            const pathViewIds = new Set();
+            (rows || []).filter(row => row.filing_id).forEach(row => {
+                let current = filingMap[String(row.filing_id)];
+                const visited = new Set();
+                while (current && String(current.tree_pid) !== '-1') {
+                    const parentId = String(current.tree_pid);
+                    if (visited.has(parentId)) break;
+                    visited.add(parentId);
+                    if (!filingMap[parentId]) break;
+                    pathViewIds.add(parentId);
+                    current = filingMap[parentId];
+                }
+            });
+            return pathViewIds;
+        }
+
+        async getRows(spid, uid) {
+            const where = { spid };
+            if (uid !== undefined && uid !== null) where.uid = Number(uid);
+            return await this.getAllDataByCondition({ where });
+        }
+
+        /**
+         * 生成配置目录页使用的节点授权数据。
+         *
+         * 新记录直接按 filing_id 返回;旧记录仅映射到固定目录,并使用每个
+         * 固定目录自身的 filing_type。某用户在相同 filing_type 下已有精确
+         * 节点记录时,不再展开该用户的旧类型权限,避免权限扩散到其他节点。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Array} filingList 项目资料目录
+         * @return {Array} 节点授权数据
+         */
+        async getConfigPermissionRows(spid, filingList) {
+            const rows = await this.getRows(spid);
+            const filingIds = new Set((filingList || []).map(filing => String(filing.id)));
+            const legacyPermissionRows = await this.ctx.service.subProjPermission.getAllDataByCondition({
+                where: { spid },
+            });
+            const result = rows.filter(row => row.filing_id && filingIds.has(String(row.filing_id))).map(row => {
+                return Object.assign({}, row, { is_legacy: 0 });
+            });
+
+            (filingList || []).forEach(filing => {
+                if (Number(filing.is_fixed) !== 1) return;
+                const type = Number(filing.filing_type);
+                if (!Number.isInteger(type) || type <= 0) return;
+                legacyPermissionRows.forEach(permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    if (!uid || !this._hasLegacyView(type, permissionRow.filing_type)) return;
+                    const userRows = rows.filter(row => Number(row.uid) === uid);
+                    const hasExactType = userRows.some(row => {
+                        return row.filing_id && Number(row.filing_type) === type;
+                    });
+                    if (hasExactType) return;
+                    const legacyRow = userRows.find(row => {
+                        return !row.filing_id && Number(row.filing_type) === type;
+                    });
+                    const flags = legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(permissionRow.file_permission);
+                    result.push({
+                        id: legacyRow ? legacyRow.id : null,
+                        pid: permissionRow.pid,
+                        spid,
+                        uid,
+                        filing_type: type,
+                        filing_id: String(filing.id),
+                        can_upload: flags.can_upload,
+                        can_edit_file: flags.can_edit_file,
+                        can_edit_dir: flags.can_edit_dir,
+                        can_lock_file: flags.can_lock_file,
+                        create_time: legacyRow ? legacyRow.create_time : permissionRow.create_time,
+                        is_legacy: 1,
+                    });
+                });
+            });
+            return result;
+        }
+
+        /**
+         * 取得某用户在指定资料节点下的最终权限。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Number} uid 项目用户ID
+         * @param {String} filingId 资料目录ID
+         * @param {Number} filingType 兼容用资料类型
+         * @param {Array|String} legacyFilePermission 旧模块权限
+         * @param {Array|String} legacyFilingTypes 旧查看范围
+         * @param {Boolean} isAdmin 是否管理员
+         * @return {Object} 权限结果
+         */
+        async getResolvedPermission(spid, uid, filingId, filingType, legacyFilePermission, legacyFilingTypes, isAdmin) {
+            if (isAdmin) return this._adminFlags();
+            const rows = await this.getRows(spid, uid);
+            const filingList = await this.ctx.service.filing.getValidFiling(spid, 'all');
+            const pathViewIds = this._getPathViewIds(rows, filingList);
+            return this._resolvePermissionFromRows(
+                rows, filingId, filingType, legacyFilePermission, legacyFilingTypes, pathViewIds
+            );
+        }
+
+        /**
+         * 一次生成新资料管理页需要的 filing_id -> 权限映射。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Number} uid 项目用户ID
+         * @param {Array} filingList 项目资料目录
+         * @param {Array|String} legacyFilingTypes 旧查看范围
+         * @param {Array|String} legacyFilePermission 旧模块权限
+         * @param {Boolean} isAdmin 是否管理员
+         * @return {Object} 权限映射
+         */
+        async getPermissionMap(spid, uid, filingList, legacyFilingTypes, legacyFilePermission, isAdmin) {
+            const result = {};
+            const rows = isAdmin ? [] : await this.getRows(spid, uid);
+            let permissionTree = filingList || [];
+            if (!isAdmin) {
+                const filingMap = {};
+                permissionTree.forEach(filing => { filingMap[String(filing.id)] = filing; });
+                const isIncompleteTree = permissionTree.some(filing => {
+                    return String(filing.tree_pid) !== '-1' && !filingMap[String(filing.tree_pid)];
+                }) || rows.some(row => row.filing_id && !filingMap[String(row.filing_id)]);
+                if (isIncompleteTree) {
+                    permissionTree = await this.ctx.service.filing.getValidFiling(spid, 'all');
+                }
+            }
+            const pathViewIds = isAdmin ? new Set() : this._getPathViewIds(rows, permissionTree);
+            for (const filing of filingList || []) {
+                result[filing.id] = isAdmin ? this._adminFlags() : this._resolvePermissionFromRows(
+                    rows, filing.id, filing.filing_type, legacyFilePermission, legacyFilingTypes, pathViewIds
+                );
+            }
+            return result;
+        }
+
+        _normalizeUsers(users) {
+            if (!(users instanceof Array)) throw '授权用户权限格式错误';
+            const result = [];
+            const exists = {};
+            users.forEach(user => {
+                const uid = Number(user.uid !== undefined ? user.uid : user.id);
+                if (!Number.isInteger(uid) || uid <= 0) throw '授权用户数据错误';
+                if (exists[uid]) return;
+                exists[uid] = true;
+                result.push({
+                    uid,
+                    can_upload: this._toFlag(user.can_upload),
+                    can_edit_file: this._toFlag(user.can_edit_file),
+                    can_edit_dir: this._toFlag(user.can_edit_dir),
+                    can_lock_file: this._toFlag(user.can_lock_file),
+                });
+            });
+            return result;
+        }
+
+        async _getAllProjectFilingTypes(spid, connection = this.db) {
+            const rows = await connection.select(this.ctx.service.filing.tableName, {
+                columns: ['filing_type'],
+                where: { spid, is_deleted: 0 },
+            });
+            return this._.uniq(rows.map(x => Number(x.filing_type)).filter(x => x > 0));
+        }
+
+        _getRawFilingTypes(value, allFilingTypes) {
+            if (value === 'all') return allFilingTypes.slice();
+            return this._toPermissionList(value);
+        }
+
+        _createLegacyRow(subProject, permissionRow, filingType, operatorUid) {
+            const flags = this._legacyFlags(permissionRow.file_permission);
+            return {
+                id: this.uuid.v4(),
+                pid: subProject.project_id,
+                spid: subProject.id,
+                uid: Number(permissionRow.uid),
+                filing_type: Number(filingType),
+                filing_id: null,
+                can_upload: flags.can_upload,
+                can_edit_file: flags.can_edit_file,
+                can_edit_dir: flags.can_edit_dir,
+                can_lock_file: flags.can_lock_file,
+                create_uid: Number(operatorUid) || 0,
+                update_uid: Number(operatorUid) || 0,
+            };
+        }
+
+        /**
+         * 事务内同步查看范围与四项操作权限。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Object} filing 资料目录
+         * @param {Array} users 已授权用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @param {Object} options 保存方式及外部事务
+         * @return {Array} 保存后的权限
+         */
+        async savePermissions(subProject, filing, users, operatorUid, options = {}) {
+            if (!filing || !filing.id) throw '资料目录错误';
+            const type = Number(filing.filing_type);
+            if (!Number.isInteger(type) || type <= 0) throw '资料类型错误';
+            const filingId = String(filing.id);
+            const normalized = this._normalizeUsers(users);
+            const permissionService = this.ctx.service.subProjPermission;
+            const ownsTransaction = !options.transaction;
+            const transaction = options.transaction || await this.db.beginTransaction();
+            try {
+                // 锁定项目权限行,避免同时保存不同目录时相互覆盖 filing_type。
+                const permissionRows = await transaction.query(
+                    'SELECT * FROM ?? WHERE spid = ? FOR UPDATE',
+                    [permissionService.tableName, subProject.id]
+                );
+                const permissionUidList = permissionRows.map(x => Number(x.uid));
+                if (normalized.find(x => permissionUidList.indexOf(x.uid) < 0)) throw '授权用户不属于当前子项目';
+
+                const selectedIds = normalized.map(x => x.uid);
+                const allFilingTypes = await this._getAllProjectFilingTypes(subProject.id, transaction);
+                // 锁定同类型全部权限,既防止节点重复,也用于正确维护旧 filing_type 查看范围。
+                const currentTypeRows = await transaction.query(
+                    'SELECT * FROM ?? WHERE spid = ? AND filing_type = ? FOR UPDATE',
+                    [this.tableName, subProject.id, type]
+                );
+                const currentNodeRows = currentTypeRows.filter(row => String(row.filing_id || '') === filingId);
+                const deleteIds = options.replaceExisting === false ? [] : currentNodeRows
+                    .filter(x => selectedIds.indexOf(Number(x.uid)) < 0)
+                    .map(x => x.id);
+                const updateRows = [];
+                const insertRows = [];
+                const sameTypeFixedFilings = await transaction.select(this.ctx.service.filing.tableName, {
+                    columns: ['id'],
+                    where: { spid: subProject.id, filing_type: type, is_fixed: 1, is_deleted: 0 },
+                });
+                const legacyAuthorizedPermissionRows = permissionRows.filter(permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    const hasExactType = currentTypeRows.some(row => row.filing_id && Number(row.uid) === uid);
+                    return !hasExactType && this._hasLegacyView(type, permissionRow.filing_type);
+                });
+
+                const ensureLegacyRow = permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    const hasLegacyRow = currentTypeRows.some(row => !row.filing_id && Number(row.uid) === uid) ||
+                        insertRows.some(row => !row.filing_id && Number(row.uid) === uid);
+                    if (!hasLegacyRow) {
+                        insertRows.push(this._createLegacyRow(subProject, permissionRow, type, operatorUid));
+                    }
+                };
+
+                /*
+                 * 旧权限按 filing_type 覆盖同类型固定节点。首次编辑其中一个节点时,
+                 * 先把其他固定节点的旧授权固化为精确记录,再应用当前节点的新名单,
+                 * 避免一次无关保存导致其他同类型目录的旧授权丢失。
+                 */
+                const migratedExactRows = [];
+                legacyAuthorizedPermissionRows.forEach(permissionRow => {
+                    ensureLegacyRow(permissionRow);
+                    const uid = Number(permissionRow.uid);
+                    const legacyRow = currentTypeRows.find(row => !row.filing_id && Number(row.uid) === uid);
+                    const flags = legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(permissionRow.file_permission);
+                    sameTypeFixedFilings.forEach(legacyFiling => {
+                        const legacyFilingId = String(legacyFiling.id);
+                        if (legacyFilingId === filingId &&
+                            (options.replaceExisting !== false || selectedIds.indexOf(uid) >= 0)) return;
+                        const hasExactNode = currentTypeRows.some(row => {
+                            return row.filing_id && Number(row.uid) === uid &&
+                                String(row.filing_id) === legacyFilingId;
+                        }) || migratedExactRows.some(row => {
+                            return Number(row.uid) === uid && String(row.filing_id) === legacyFilingId;
+                        });
+                        if (hasExactNode) return;
+                        const migratedRow = {
+                            id: this.uuid.v4(),
+                            pid: subProject.project_id,
+                            spid: subProject.id,
+                            uid,
+                            filing_type: type,
+                            filing_id: legacyFilingId,
+                            can_upload: flags.can_upload,
+                            can_edit_file: flags.can_edit_file,
+                            can_edit_dir: flags.can_edit_dir,
+                            can_lock_file: flags.can_lock_file,
+                            create_uid: Number(operatorUid) || 0,
+                            update_uid: Number(operatorUid) || 0,
+                        };
+                        migratedExactRows.push(migratedRow);
+                        insertRows.push(migratedRow);
+                    });
+                });
+
+                // 用户原本拥有旧类型查看范围时,首次切换到节点授权前保存一条 NULL 兼容记录。
+                // NULL 记录只保留旧操作权限,不再代表节点查看授权。
+                normalized.forEach(user => {
+                    const permissionRow = permissionRows.find(row => Number(row.uid) === user.uid);
+                    const hasExactType = currentTypeRows.some(row => row.filing_id && Number(row.uid) === user.uid);
+                    const oldTypes = permissionRow ? this._getRawFilingTypes(permissionRow.filing_type, allFilingTypes) : [];
+                    if (!hasExactType && oldTypes.indexOf(type) >= 0) {
+                        ensureLegacyRow(permissionRow);
+                    }
+                });
+
+                normalized.forEach(user => {
+                    const current = currentNodeRows.find(x => Number(x.uid) === user.uid);
+                    const data = {
+                        can_upload: user.can_upload,
+                        can_edit_file: user.can_edit_file,
+                        can_edit_dir: user.can_edit_dir,
+                        can_lock_file: user.can_lock_file,
+                        update_uid: Number(operatorUid) || 0,
+                    };
+                    if (current) {
+                        updateRows.push(Object.assign({ id: current.id }, data));
+                    } else {
+                        insertRows.push(Object.assign({
+                            id: this.uuid.v4(),
+                            pid: subProject.project_id,
+                            spid: subProject.id,
+                            uid: user.uid,
+                            filing_type: type,
+                            filing_id: filingId,
+                            create_uid: Number(operatorUid) || 0,
+                        }, data));
+                    }
+                });
+
+                const otherExactUserIds = currentTypeRows
+                    .filter(row => row.filing_id && String(row.filing_id) !== filingId)
+                    .map(row => Number(row.uid));
+                const retainedCurrentUserIds = options.replaceExisting === false
+                    ? currentNodeRows.map(row => Number(row.uid)) : [];
+                const finalExactUserIds = this._.uniq(otherExactUserIds
+                    .concat(migratedExactRows.map(row => Number(row.uid)))
+                    .concat(retainedCurrentUserIds)
+                    .concat(selectedIds));
+                const affectedUserIds = this._.uniq(selectedIds
+                    .concat(currentNodeRows.map(row => Number(row.uid)))
+                    .concat(legacyAuthorizedPermissionRows.map(row => Number(row.uid))));
+                const viewUpdates = [];
+                permissionRows.forEach(row => {
+                    const uid = Number(row.uid);
+                    if (affectedUserIds.indexOf(uid) < 0) return;
+                    let types = this._getRawFilingTypes(row.filing_type, allFilingTypes);
+                    const shouldHave = finalExactUserIds.indexOf(uid) >= 0;
+                    const hasType = types.indexOf(type) >= 0;
+                    if (shouldHave && !hasType) types.push(type);
+                    if (!shouldHave && hasType) types = types.filter(value => value !== type);
+                    types = this._.uniq(types);
+                    if (types.join(',') !== String(row.filing_type || '')) {
+                        viewUpdates.push({ id: row.id, filing_type: types.join(',') });
+                    }
+                });
+
+                if (viewUpdates.length > 0) await transaction.updateRows(permissionService.tableName, viewUpdates);
+                if (deleteIds.length > 0) await transaction.delete(this.tableName, { id: deleteIds });
+                if (updateRows.length > 0) await transaction.updateRows(this.tableName, updateRows);
+                if (insertRows.length > 0) await transaction.insert(this.tableName, insertRows);
+                if (ownsTransaction) await transaction.commit();
+                return normalized.map(user => Object.assign({ filing_id: filingId, filing_type: type }, user));
+            } catch (err) {
+                if (ownsTransaction) await transaction.rollback();
+                throw err;
+            }
+        }
+
+        /**
+         * 将用户权限合并添加到多个资料目录,保留目标目录已有的其他用户。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Array} filings 目标资料目录
+         * @param {Array} users 需要添加的用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @return {Array} 本次写入的节点权限
+         */
+        async addPermissionsToFilings(subProject, filings, users, operatorUid) {
+            return this._savePermissionsToFilings(subProject, filings, users, operatorUid, false);
+        }
+
+        /**
+         * 将用户权限覆盖到多个资料目录,目标目录原有授权名单会被替换。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Array} filings 目标资料目录
+         * @param {Array} users 覆盖后的用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @return {Array} 本次写入的节点权限
+         */
+        async coverPermissionsToFilings(subProject, filings, users, operatorUid) {
+            return this._savePermissionsToFilings(subProject, filings, users, operatorUid, true);
+        }
+
+        async _savePermissionsToFilings(subProject, filings, users, operatorUid, replaceExisting) {
+            if (!(filings instanceof Array) || filings.length === 0) throw '请选择目标资料目录';
+            const normalized = this._normalizeUsers(users);
+            if (normalized.length === 0) throw '请选择需要配置的授权用户';
+            const filingIds = filings.map(filing => String(filing && filing.id || ''));
+            if (filingIds.find(id => !id) || this._.uniq(filingIds).length !== filingIds.length) {
+                throw '目标资料目录数据错误';
+            }
+
+            // 固定处理顺序,减少并发批量授权时产生数据库死锁的概率。
+            const sortedFilings = filings.slice().sort((a, b) => {
+                const typeDiff = Number(a.filing_type) - Number(b.filing_type);
+                return typeDiff || String(a.id).localeCompare(String(b.id));
+            });
+            const transaction = await this.db.beginTransaction();
+            const result = [];
+            try {
+                for (const filing of sortedFilings) {
+                    const rows = await this.savePermissions(
+                        subProject,
+                        filing,
+                        normalized,
+                        operatorUid,
+                        { transaction, replaceExisting }
+                    );
+                    result.push.apply(result, rows);
+                }
+                await transaction.commit();
+                return result;
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        /**
+         * 删除目录时同步清理精确节点权限,并维护旧 filing_type 查看字段。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Array} filingIds 被删除目录ID
+         * @param {Object} transaction 当前事务
+         */
+        async removeFilingNodes(spid, filingIds, transaction) {
+            if (!filingIds || filingIds.length === 0) return;
+            const permissionService = this.ctx.service.subProjPermission;
+            const permissionRows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? FOR UPDATE',
+                [permissionService.tableName, spid]
+            );
+            const rows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? AND filing_id IN (?) FOR UPDATE',
+                [this.tableName, spid, filingIds]
+            );
+            if (rows.length === 0) return;
+
+            await transaction.delete(this.tableName, { id: rows.map(row => row.id) });
+            const affectedTypes = this._.uniq(rows.map(row => Number(row.filing_type)).filter(value => value > 0));
+            const remainingRows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? AND filing_type IN (?) FOR UPDATE',
+                [this.tableName, spid, affectedTypes]
+            );
+            const allFilingTypes = await this._getAllProjectFilingTypes(spid, transaction);
+            const affectedKeys = this._.uniq(rows.map(row => `${Number(row.uid)}:${Number(row.filing_type)}`));
+            const updates = [];
+            permissionRows.forEach(permissionRow => {
+                let types = this._getRawFilingTypes(permissionRow.filing_type, allFilingTypes);
+                let changed = false;
+                affectedTypes.forEach(type => {
+                    const key = `${Number(permissionRow.uid)}:${type}`;
+                    if (affectedKeys.indexOf(key) < 0) return;
+                    const stillHasPermission = remainingRows.some(row => {
+                        return row.filing_id && Number(row.uid) === Number(permissionRow.uid) &&
+                            Number(row.filing_type) === type;
+                    });
+                    if (!stillHasPermission && types.indexOf(type) >= 0) {
+                        types = types.filter(value => value !== type);
+                        changed = true;
+                    }
+                });
+                if (changed) updates.push({ id: permissionRow.id, filing_type: this._.uniq(types).join(',') });
+            });
+            if (updates.length > 0) await transaction.updateRows(permissionService.tableName, updates);
+        }
+    }
+
+    return SubProjectFilingPermission;
+};

+ 0 - 0
app/service/tender.js


Некоторые файлы не были показаны из-за большого количества измененных файлов