Procházet zdrojové kódy

feat(quality-inspection): 新增质量巡检三阶段流程及概况页面

- 新增上报、整改、批复三阶段流程及独立状态常量
- 新增巡检概况、整改列表、批复列表路由及控制器方法
- 实现前端流程操作绑定,支持审批人增删与阶段流转
- 添加表单自动保存与流程提交的顺序队列机制
- 新增概况页面饼图与趋势图,集成 ECharts 可视化
- 补充巡检流程、整改表单及概况页面的样式
- 中间件增加 flow_version 判断,支持草稿流程准备与上传权限计算
- 修复审批列表过滤中缺少 return 的闭包 bug
caipin před 3 týdny
rodič
revize
d45ad29638

+ 9 - 0
app/const/quality_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;

+ 65 - 5
app/controller/quality_controller.js

@@ -9,6 +9,7 @@
  */
 
 const auditConst = require('../const/audit');
+const qualityInspectionConst = require('../const/quality_inspection');
 const auditType = require('../const/audit').auditType;
 const shenpiConst = require('../const/shenpi');
 const codeRuleConst = require('../const/code_rule');
@@ -597,11 +598,12 @@ module.exports = app => {
                 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(qualityInspectionConst.filter));
                 filter.count = [];
                 filter.count[filter.status.pending] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.pending);
                 filter.count[filter.status.uncheck] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.uncheck);
                 filter.count[filter.status.checking] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.checking);
+                filter.count[filter.status.reply] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.reply);
                 filter.count[filter.status.rectification] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.rectification);
                 filter.count[filter.status.checked] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.checked);
                 filter.count[filter.status.checkStop] = await ctx.service.qualityInspection.getCountByStatus(ctx.tender.id, filter.status.checkStop);// await ctx.service.change.pendingDatas(tender.id, ctx.session.sessionUser.accountId);
@@ -672,7 +674,7 @@ module.exports = app => {
                     filter,
                     inspectionList,
                     auditType,
-                    auditConst: auditConst.inspection,
+                    auditConst: qualityInspectionConst,
                     status,
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.quality.inspection),
                     pageInfo,
@@ -738,7 +740,7 @@ module.exports = app => {
                     moment,
                     tender,
                     inspection: ctx.inspection,
-                    auditConst: auditConst.inspection,
+                    auditConst: qualityInspectionConst,
                     fileList,
                     whiteList,
                     auditType,
@@ -756,6 +758,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('quality/inspection_information.ejs', renderData, 'quality/inspection_information_modal.ejs');
             } catch (err) {
                 this.log(err);
@@ -769,6 +776,11 @@ module.exports = app => {
                 const reponseData = {
                     err: 0, msg: '', data: {},
                 };
+                if (ctx.inspection.flow_version === 1) {
+                    reponseData.data = await ctx.service.qualityInspectionAudit.processFlow(ctx.inspection, data);
+                    ctx.body = reponseData;
+                    return;
+                }
                 switch (data.type) {
                     case 'update-field':
                         if (!(!ctx.inspection.readOnly || ctx.inspection.rectificationPower)) {
@@ -885,7 +897,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,
                 });
@@ -947,7 +959,11 @@ module.exports = app => {
             try {
                 const { id } = JSON.parse(ctx.request.body.data);
                 const fileInfo = await ctx.service.qualityInspectionAtt.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 === qualityInspectionConst.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);
@@ -1003,6 +1019,50 @@ module.exports = app => {
                 }
             }
         }
+
+        async inspectionOverview(ctx) {
+            try {
+                if (!ctx.subProject.page_show.qualityInspection) throw '该功能已关闭';
+                const tender = await this.service.tender.getDataById(ctx.tender.id);
+                const year = moment().year();
+                const renderData = {
+                    moment,
+                    tender,
+                    permission: ctx.permission.inspection,
+                    overview: await ctx.service.qualityInspection.getOverviewData(ctx.tender.id, year),
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.quality.inspection_overview),
+                };
+                await this.layout('quality/inspection_overview.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检概况数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/quality/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        async inspectionRectification(ctx) {
+            try {
+                if (!ctx.subProject.page_show.qualityInspection) throw '该功能已关闭';
+                const status = qualityInspectionConst.filter.status.rectification; // 正在整改
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检整改数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/quality/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        async inspectionApproval(ctx) {
+            try {
+                if (!ctx.subProject.page_show.qualityInspection) throw '该功能已关闭';
+                const status = qualityInspectionConst.filter.status.reply; // 正在批复
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检批复数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/quality/tender/${ctx.tender.id}/inspection`);
+            }
+        }
     }
 
     return QualityController;

+ 9 - 3
app/middleware/quality_inspection_check.js

@@ -32,7 +32,8 @@ module.exports = options => {
                 throw '您访问的质量巡检不存在';
             }
             const inspection = yield this.service.qualityInspection.getDataById(qiid);
-            if (!inspection) throw '质量巡检数据有误';
+            if (!inspection || inspection.tid !== this.tender.id) throw '质量巡检数据有误';
+            yield this.service.qualityInspectionAudit.prepareDraftFlow(inspection);
             // 读取原报、审核人数据
             yield this.service.qualityInspection.loadUser(inspection);
             // 权限相关
@@ -76,7 +77,9 @@ module.exports = options => {
             if ((inspection.status === status.uncheck || inspection.status === status.checkNo) && this.tender.info.shenpi.inspection !== shenpiConst.sp_status.sqspr) {
                 const shenpi_status = this.tender.info.shenpi.inspection;
                 // 进一步比较审批流是否与审批流程设置的相同,不同则替换为固定审批流或固定的终审
-                const auditList = yield this.service.qualityInspectionAudit.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.qualityInspectionAudit.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.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.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.qualityInspectionAudit.updateLastAudit(inspection, auditList, shenpiInfo.audit_id);
                         yield this.service.qualityInspection.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);

+ 156 - 0
app/public/css/main.css

@@ -3399,3 +3399,159 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
     gap: 8px;
     flex-wrap: wrap;
 }
+
+/* 质量巡检三阶段流程及概况 */
+.quality-inspection-flow .flow-picker > .dropdown-menu {
+    width: 220px;
+}
+
+.quality-inspection-flow .flow-title {
+    padding: 5px 10px;
+    border: 1px solid #d6d9dc;
+    background: #e4e7ea;
+    line-height: 18px;
+}
+.quality-inspection-flow .flow-layout {
+    margin-right: 0;
+    margin-left: 0;
+    align-items: flex-start;
+}
+.quality-inspection-flow .flow-summary,
+.quality-inspection-flow .flow-details {
+    min-width: 0;
+    padding-right: 5px;
+    padding-left: 5px;
+}
+.quality-inspection-flow .flow-summary-table {
+    table-layout: fixed;
+}
+.quality-inspection-flow .flow-phase-label {
+    width: 56px;
+}
+.quality-inspection-flow .flow-summary-table th,
+.quality-inspection-flow .flow-summary-table td {
+    padding: 5px;
+    border-color: #d6d9dc;
+    background: #fff;
+    line-height: 18px;
+    overflow-wrap: break-word;
+}
+.quality-inspection-flow .flow-summary-table tbody + tbody {
+    border-top: 1px solid #d6d9dc;
+}
+@media (min-width: 768px) {
+    .quality-inspection-flow .flow-summary {
+        flex: 0 0 30%;
+        max-width: 30%;
+    }
+    .quality-inspection-flow .flow-details {
+        flex: 0 0 70%;
+        max-width: 70%;
+    }
+}
+
+.quality-inspection-flow .flow-phase-section {
+    margin-top: 20px;
+    margin-bottom: 20px;
+}
+.quality-inspection-flow .flow-phase-title {
+    margin-bottom: 12px;
+    line-height: 20px;
+}
+.quality-inspection-flow .flow-phase-person {
+    padding: 12px 18px;
+}
+.quality-inspection-flow .flow-person-names {
+    min-width: 0;
+    overflow-wrap: break-word;
+}
+.quality-inspection-flow .flow-timeline {
+    margin: 0;
+}
+.quality-inspection-flow .flow-timeline .timeline-item-date {
+    font-size: 12px;
+    line-height: 18px;
+    white-space: nowrap;
+}
+.quality-inspection-flow .flow-timeline-person .h6 {
+    font-weight: 600;
+}
+.quality-inspection-flow .flow-opinion {
+    min-height: 24px;
+    padding: 3px 8px;
+    white-space: pre-wrap;
+    overflow-wrap: break-word;
+}
+@media (max-width: 767px) {
+    .quality-inspection-flow .flow-phase-person {
+        padding: 8px 12px;
+    }
+    .quality-inspection-flow .flow-timeline .card-body {
+        padding-right: 8px !important;
+        padding-left: 8px !important;
+    }
+}
+
+#quality-flow-confirm #quality-flow-opinion {
+    height: 130px;
+}
+
+.quality-inspection-rectification {
+    table-layout: fixed;
+}
+.quality-inspection-rectification .rectification-label {
+    width: 150px;
+}
+.quality-inspection-rectification .flow-rectification-item {
+    min-height: 48px;
+}
+.quality-inspection-rectification .flow-rectification-date {
+    width: 200px;
+    max-width: 100%;
+}
+
+.quality-inspection-overview .card {
+    border-radius: 4px;
+}
+.quality-inspection-overview .card-header,
+.quality-inspection-overview .card-footer {
+    padding: 10px 15px;
+    background: #f8f9fa;
+}
+.quality-inspection-overview .overview-rectifications {
+    flex: 1;
+    max-height: 360px;
+    overflow: auto;
+}
+.quality-inspection-overview .overview-rectifications table {
+    table-layout: fixed;
+}
+.quality-inspection-overview .overview-rectifications th {
+    position: sticky;
+    top: 0;
+    z-index: 1;
+    background: #e4e7ea;
+}
+.quality-inspection-overview .overview-rectifications td {
+    vertical-align: middle;
+    overflow-wrap: break-word;
+}
+.quality-inspection-overview .overview-inspection-column {
+    width: 35%;
+}
+.quality-inspection-overview .overview-progress-column {
+    width: 23%;
+}
+.quality-inspection-overview .overview-days-column {
+    width: 17%;
+}
+.quality-inspection-overview .overview-pie {
+    height: 300px;
+}
+.quality-inspection-overview .overview-trend {
+    height: 350px;
+}
+.quality-inspection-overview .overview-status-links {
+    gap: 8px;
+    flex-wrap: wrap;
+}

+ 213 - 29
app/public/js/quality_inspection_information.js

@@ -10,6 +10,7 @@
 
 $(document).ready(function () {
     autoFlashHeight();
+    if (inspection.flow_version === 1) bindQualityInspectionFlow();
 
     $.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('#quality-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) {
+            postQualityInspection(`${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) => {
+                postQualityInspection(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) => {
+            postQualityInspection(preUrl + '/save', data, (result) => {
                 li.remove();
                 for (const rst of result) {
                     const aLi = $('li[auditorid=' + rst.aid + ']');
@@ -343,8 +347,7 @@ $(document).ready(function () {
                 toastr.error('请正确输入“确认删除本次巡检”');
                 return;
             }
-            postData(preUrl + '/save', {type: 'del-inspection' }, function (result) {
-                // 判断url是否是wap,是则跳转到wap页面,否则跳转到pc页面
+            postQualityInspection(preUrl + '/save', {type: 'del-inspection' }, function (result) {
                 const is_wap = window.location.pathname.indexOf('/wap/') !== -1;
                 let link = `/sp/${spid}/quality/tender/${tender_id}/inspection`;
                 if (is_wap) {
@@ -387,11 +390,11 @@ $(document).ready(function () {
 
         $('#start-btn').click(function () {
             $('#start-btn').prop('disabled', true);
-            postData(preUrl + '/save', { type: 'start-inspection' }, function (result) {
+            postQualityInspection(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'))
@@ -439,7 +442,7 @@ $(document).ready(function () {
                 opinion,
                 rectification_uid: $('#rectification-uid').val(),
             }
-            postData(preUrl + '/save', data, function (result) {
+            postQualityInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -465,7 +468,7 @@ $(document).ready(function () {
                 checkType: parseInt(inlineRadio1 ? inlineRadio1 : inlineRadio2),
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postQualityInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -481,11 +484,11 @@ $(document).ready(function () {
                 checkType: auditConst.status.checkStop,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postQualityInspection(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) {
@@ -506,7 +509,7 @@ $(document).ready(function () {
                 checkType: auditConst.status.checked,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postQualityInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -522,7 +525,7 @@ $(document).ready(function () {
                 checkType: auditConst.status.checkNoPre,
                 opinion,
             }
-            postData(preUrl + '/save', data, function (result) {
+            postQualityInspection(preUrl + '/save', data, function (result) {
                 window.location.reload();
             });
         });
@@ -586,7 +589,7 @@ $(document).ready(function () {
             };
             data[field] = value;
             console.log(data);
-            postData(`${preUrl}/save`, {type: 'update-field', update: data}, function (result) {
+            postQualityInspection(`${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) : '');
@@ -639,15 +642,16 @@ $(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) {
-                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>`;
-            }
+                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>`;
             newFiles.forEach((file, idx) => {
                 html += `<a href="${file.filepath}" target="_blank">
@@ -672,14 +676,14 @@ $(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>`
                 } else {
                     html += `<tr><td width="70">${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></td></tr>`
                 }
-            })
+            });
             $('#file-content').append(html);
         }
     }
@@ -717,3 +721,183 @@ function validateFiles(files) {
     })
 }
 
+// 表单自动保存和流程提交按顺序执行,避免提交先于最后一次字段保存。
+let qualityInspectionSaveQueue = Promise.resolve(true);
+let qualityInspectionSaving = 0;
+function postQualityInspection(url, data, success, failure) {
+    if (inspection.flow_version !== 1) return postData(url, data, success, failure);
+    if (!qualityInspectionSaving) qualityInspectionSaveQueue = Promise.resolve(true);
+    qualityInspectionSaving++;
+    const payload = Object.assign({}, data, { times: inspection.times, flow_stage: inspection.flow_stage, stage_round: inspection.stage_round });
+    const previous = qualityInspectionSaveQueue;
+    qualityInspectionSaveQueue = 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) {
+        qualityInspectionSaving--;
+        return saved;
+    });
+}
+
+function bindQualityInspectionFlow() {
+    const $flow = $('.quality-inspection-flow');
+    const $radios = $('[name="quality-need-rectification"]');
+    let needRectification = qualityInspectionNeedDraft();
+    showQualityInspectionNeed(needRectification);
+    let pending = null;
+    let busy = false;
+    function request(data, $button, reload) {
+        if (busy) return;
+        busy = true;
+        $button.prop('disabled', true);
+        postQualityInspection(preUrl + '/save', data, function () {
+            busy = false;
+            $button.prop('disabled', false);
+            if (reload) {
+                if (!inspection.readOnly && ['flow-add-audit', 'flow-del-audit'].includes(data.type)) qualityInspectionNeedDraft(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)) {
+            showQualityInspectionNeed(needRectification);
+            return;
+        }
+        needRectification = value;
+        showQualityInspectionNeed(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 ? '确认退回上报人重新上报?' : '确认退回上一个上报审批节点?';
+        }
+        $('#quality-flow-title').text($(this).text());
+        $('#quality-flow-message').text(message);
+        $('#quality-flow-opinion').val('').css('height', '');
+        $('#quality-flow-opinion-group').toggle(type === 'flow-check');
+        $('#quality-flow-confirm').modal('show');
+    });
+    $('#quality-flow-submit').on('click', function () {
+        if (!pending || busy) return;
+        if (pending.type === 'flow-check') {
+            pending.opinion = $('#quality-flow-opinion').val().trim();
+            if (pending.checkType !== 3 && !pending.opinion) {
+                toastr.error('请填写退回或关闭原因');
+                return;
+            }
+        }
+        request(pending, $(this), true);
+    });
+}
+
+// 仅在添加、移除审批人引起的刷新之间暂存选择,读取后即清除。
+function qualityInspectionNeedDraft(value) {
+    const current = inspection.need_rectification === 0 ? 0 : 1;
+    const key = 'quality-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 showQualityInspectionNeed(value) {
+    $('[name="quality-need-rectification"]').filter('[value="' + value + '"]').prop('checked', true);
+    $('.quality-inspection-flow [data-flow-stage="2"], .quality-inspection-flow [data-flow-stage="3"]').toggleClass('d-none', value === 0);
+    $('.quality-inspection-flow .flow-no-rectification').toggleClass('d-none', value !== 0);
+}

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

@@ -0,0 +1,80 @@
+'use strict';
+
+$(document).ready(function () {
+    const $page = $('.quality-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('quality-overview-pie');
+    if (pieElement && qualityInspectionOverview.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: qualityInspectionOverview.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: qualityInspectionOverview.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 () { $('#quality-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 = qualityInspectionOverview.trend;
+    const trendChart = echarts.init(document.getElementById('quality-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/quality_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}/quality/tender/${node.id}/${isInspection ? 'inspection' : 'info'}" name="name" style="min-width: 200px;word-break:break-all;" id="${node.id}">${node.name}</a>`);
+            html.push(`<a href="/sp/${spid}/quality/tender/${node.id}/${isInspection ? 'inspection/overview' : 'info'}" name="name" style="min-width: 200px;word-break:break-all;" id="${node.id}">${node.name}</a>`);
         }
         html.push('</td>');
 

+ 3 - 0
app/router.js

@@ -579,6 +579,9 @@ module.exports = app => {
     app.post('/sp/:id/quality/rule/save', sessionAuth, subProjectCheck, projectManagerCheck, 'qualityController.ruleSave');
     app.post('/sp/:id/quality/tender/:tid/rule/save', sessionAuth, subProjectCheck, tenderCheck, projectManagerCheck, 'qualityController.ruleSave');
     app.get('/sp/:id/quality/tender/:tid/inspection', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'qualityController.inspection');
+    app.get('/sp/:id/quality/tender/:tid/inspection/overview', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'qualityController.inspectionOverview');
+    app.get('/sp/:id/quality/tender/:tid/inspection/rectification', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'qualityController.inspectionRectification');
+    app.get('/sp/:id/quality/tender/:tid/inspection/approval', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'qualityController.inspectionApproval');
     app.post('/sp/:id/quality/tender/:tid/inspection/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'qualityController.inspectionSave');
     app.get('/sp/:id/quality/tender/:tid/inspection/:qiid/information', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, qualityInspectionCheck, 'qualityController.inspectionInformation');
     app.post('/sp/:id/quality/tender/:tid/inspection/:qiid/information/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, qualityInspectionCheck, 'qualityController.inspectionInformationSave');

+ 222 - 137
app/service/quality_inspection.js

@@ -7,8 +7,9 @@
  * @date 2024/7/22
  * @version
  */
-const auditConst = require('../const/audit').inspection;
+const auditConst = require('../const/quality_inspection');
 const auditType = require('../const/audit').auditType;
+const moment = require('moment');
 module.exports = app => {
 
     class QualityInspection 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,
@@ -207,83 +211,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.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.qualityInspectionAudit.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.qualityInspectionAudit.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.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.inspection.view_all ? '' :  ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.qualityInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                        sqlParam = [this.tableName, status, auditConst.status.checkNoPre, tid];
-                        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.inspection.view_all ? '' :  ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.qualityInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                        sqlParam = [this.tableName, status, tid];
-                        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 +233,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.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.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.qualityInspectionAudit.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.qualityInspectionAudit.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.qualityInspectionAudit.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.qualityInspectionAudit.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.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.inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.qualityInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                    const sqlParam7 = [this.tableName, status, auditConst.status.checkNoPre, tid];
-                    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.inspection.view_all ? '' : ' AND (a.uid = ' + this.ctx.session.sessionUser.accountId + ' OR a.id IN (SELECT b.qiid FROM ' + this.ctx.service.qualityInspectionAudit.tableName + ' AS b WHERE b.aid = ' + this.ctx.session.sessionUser.accountId + ' GROUP BY b.qiid))');
-                    const sqlParam3 = [this.tableName, status, tid];
-                    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.qualityInspectionAudit.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.qualityInspectionAudit.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.qualityInspectionAudit.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.qualityInspectionAudit.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.qualityInspectionAudit.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 QualityInspection;

+ 314 - 25
app/service/quality_inspection_audit.js

@@ -7,10 +7,11 @@
  * @date 2024/7/22
  * @version
  */
-const auditConst = require('../const/audit').inspection;
+const auditConst = require('../const/quality_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 QualityInspectionAudit 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.qualityInspection.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.qualityInspection.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.qualityInspection.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.tid' +
-                '  WHERE ma.`aid` = ? and (ma.`status` = ? OR ma.`status` = ?)' +
+                '  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.qualityInspection.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.qualityInspection.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.qualityInspection.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.qualityInspection.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.qualityInspection.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 || inspection.uid !== this.ctx.session.sessionUser.accountId || this.ctx.url.indexOf('/wap/') === 0) return;
+            const transaction = await this.db.beginTransaction();
+            try {
+                const current = await this.lockFlow(transaction, inspection.id);
+                if (!current.flow_version && current.status === 1 && current.uid === this.ctx.session.sessionUser.accountId) {
+                    const changes = { flow_version: 1, flow_stage: 1, stage_round: 1, need_rectification: 1 };
+                    await transaction.update(this.ctx.service.qualityInspection.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, ['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, inspection: ['1'] })), ['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.qualityInspection.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.qualityInspection.tableName, { tid: current.tid, code: update.code });
+                            if (other && other.id !== current.id) throw '该巡检编号已存在';
+                        }
+                        await transaction.update(this.ctx.service.qualityInspection.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.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.qualityInspection.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.qualityInspectionAtt.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.qualityInspectionAtt.tableName, { qiid: current.id });
+                        await transaction.delete(this.ctx.service.qualityInspection.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.qualityInspection.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.qualityInspection.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.qualityInspection.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.qualityInspection.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.inspection, uid, status: result, content: JSON.stringify(content) })));
+        }
     }
 
     return QualityInspectionAudit;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 2
app/view/dashboard/index.ejs


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 2
app/view/dashboard/workspace.ejs


+ 9 - 8
app/view/quality/inspection.ejs

@@ -68,7 +68,7 @@
                     <tr>
                         <th width="13%" id="sort_change">编号</th>
 <!--                        <th width="5%">部位</th>-->
-                        <th width="10%">检查项目</th><th width="25%">检查情况</th><th width="13%">处理要求及措施</th>
+                        <th width="10%">检查项目</th><th width="18%">检查情况</th><th width="7%">是否整改</th><th width="13%">处理要求及措施</th>
                         <th width="10%">检查日期</th><th width="7%">附件</th><th width="13%">状态</th>
                     </tr>
                     </thead>
@@ -78,6 +78,7 @@
 <!--                            <td></td>-->
                             <td><%- c.check_item.replace(/\r\n/g, '<br/>').replace(/\n/g, '<br/>').replace(/\s/g, ' ') %></td>
                             <td><%- c.check_situation.replace(/\r\n/g, '<br/>').replace(/\n/g, '<br/>').replace(/\s/g, ' ') %></td>
+                            <td class="text-center"><%= (c.flow_version === 1 ? c.need_rectification === 1 : !!c.rectification_uid) ? '是' : '-' %></td>
                             <td><%- c.action.replace(/\r\n/g, '<br/>').replace(/\n/g, '<br/>').replace(/\s/g, ' ') %></td>
                             <td class="text-center"><%- c.check_date ? moment(c.check_date).format('YYYY-MM-DD') : '' %></td>
                             <td class="text-center">
@@ -85,25 +86,25 @@
                             </td>
                             <td class="text-center">
                                 <% if (c.status === auditConst.status.uncheck && c.uid === ctx.session.sessionUser.accountId) { %>
-                                    <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- auditConst.statusButton[c.status] %></a>
+                                    <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- c.actionLabel || auditConst.statusButton[c.status] %></a>
                                 <% } else if ((c.status === auditConst.status.checkNo) && c.curAuditors && c.uid === ctx.session.sessionUser.accountId) { %>
-                                    <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- auditConst.statusButton[c.status] %></a>
+                                    <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- c.actionLabel || auditConst.statusButton[c.status] %></a>
                                 <% } else if ((c.status === auditConst.status.checking || c.status === auditConst.status.rectification) && c.curAuditors && c.curAuditors.findIndex(x => { return x.aid === ctx.session.sessionUser.accountId; }) >= 0) { %>
                                     <% const curAudit = c.curAuditors.find(x => { return x.aid === ctx.session.sessionUser.accountId; }); %>
                                     <% if (curAudit.status === auditConst.status.checking || curAudit.status === auditConst.status.rectification) { %>
-                                        <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- auditConst.statusButton[c.status] %></a>
+                                        <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- c.actionLabel || auditConst.statusButton[c.status] %></a>
                                     <% } else { %>
-                                        <span class="<%- auditConst.auditStringClass[c.status] %>"><i class="fa fa-circle <%- auditConst.auditStringClass[c.status] %>"></i> <%- auditConst.auditString[c.status] %></span>
+                                        <span class="<%- auditConst.auditStringClass[c.status] %>"><i class="fa fa-circle <%- auditConst.auditStringClass[c.status] %>"></i> <%- c.flowLabel || auditConst.auditString[c.status] %></span>
                                     <% } %>
-                                <% } else if (c.status === auditConst.status.checkNoPre && c.curAuditor2 && c.curAuditor2.findIndex(x => { return x.aid === ctx.session.sessionUser.accountId; }) >= 0) { %>
+                                <% } else if (c.status === auditConst.status.checkNoPre && c.curAuditors2 && c.curAuditors2.findIndex(x => { return x.aid === ctx.session.sessionUser.accountId; }) >= 0) { %>
                                     <% const curAudit = c.curAuditors2.find(x => { return x.aid === ctx.session.sessionUser.accountId; }); %>
                                     <% if (curAudit.status === auditConst.status.checking) { %>
-                                        <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- auditConst.statusButton[c.status] %></a>
+                                        <a href="/sp/<%- ctx.subProject.id %>/quality/tender/<%- ctx.tender.id %>/inspection/<%- c.id %>/information" class="btn <%- auditConst.statusButtonClass[c.status] %> btn-sm"><%- c.actionLabel || auditConst.statusButton[c.status] %></a>
                                     <% } else { %>
                                         <span class="<%- auditConst.auditStringClass[curAudit.status] %>"><i class="fa fa-circle <%- auditConst.auditStringClass[c.status] %>"></i> <%- auditConst.auditString[curAudit.status] %></span>
                                     <% } %>
                                 <% } else { %>
-                                    <span class="<%- auditConst.auditStringClass[c.status] %>"><% if (c.status !== auditConst.status.uncheck) { %><i class="fa fa-circle <%- auditConst.auditStringClass[c.status] %>"></i> <% } %><%- auditConst.auditString[c.status] %></span>
+                                    <span class="<%- auditConst.auditStringClass[c.status] %>"><% if (c.status !== auditConst.status.uncheck) { %><i class="fa fa-circle <%- auditConst.auditStringClass[c.status] %>"></i> <% } %><%- c.flowLabel || auditConst.auditString[c.status] %></span>
                                 <% } %>
                             </td>
                         </tr>

+ 227 - 0
app/view/quality/inspection_flow.ejs

@@ -0,0 +1,227 @@
+<div class="quality-inspection-flow mt-3">
+    <% const mobileFlow = ctx.url.indexOf('/wap/') === 0;
+       const showTimeline = !mobileFlow && !inspection.flowDraft;
+       const flowStatusText = { 1: '待处理', 2: '审批中', 3: '已完成', 4: '已关闭', 5: '审批退回', 6: '审批退回', 8: '已跳过', 12: '整改中' }; %>
+    <% if (mobileFlow) { %>
+        <div class="card mb-3">
+            <div class="card-header d-flex justify-content-between">
+                <span>审批流程 · <%= inspection.flowLabel %></span>
+                <% if (!inspection.readOnly) { %>
+                    <button type="button" class="btn btn-sm btn-primary flow-action" data-action="start-inspection">提交上报</button>
+                <% } %>
+            </div>
+            <div class="card-body">
+                <span class="mr-3">是否整改</span>
+                <% [1, 0].forEach(value => { %>
+                    <label class="mr-3 mb-0"><input type="radio" name="quality-need-rectification" value="<%= value %>" <%= inspection.need_rectification === value ? 'checked' : '' %> <%= inspection.readOnly ? 'disabled' : '' %>> <%= value ? '是' : '否' %></label>
+                <% }); %>
+                <small class="text-warning d-block mt-2">选择“是”时,上报结束后进入整改,所有整改人完成后进入批复。</small>
+            </div>
+        </div>
+    <% } else { %>
+        <div class="flow-title text-center mb-1">审批流程</div>
+    <% } %>
+    <% if (!showTimeline) { %>
+    <div class="row flow-layout">
+        <div class="col-md-4 flow-summary mb-3">
+            <table class="table table-bordered table-sm flow-summary-table mb-0" aria-label="审批流程人员">
+                <colgroup><col class="flow-phase-label"><col></colgroup>
+                <% inspection.flowStages.forEach(phase => { %>
+                    <% if (phase.stage === 1 || inspection.need_rectification || !inspection.readOnly) { %>
+                        <% const people = phase.auditors.map((audit, index) => ({
+                            name: audit.name,
+                            icon: phase.stage === 3 && index === phase.auditors.length - 1 ? 'fa-stop-circle' : 'fa-chevron-circle-down',
+                            title: phase.stage === 2 ? '整改人(同时处理)' : audit.audit_order + '审',
+                            placeholder: false,
+                        }));
+                        if (phase.stage === 1) people.unshift({ name: inspection.user.name, icon: 'fa-play-circle fa-rotate-90', title: '上报人', placeholder: false });
+                        if (!phase.auditors.length) people.push({ name: phase.stage === 2 ? '整改人由上报审批人指派' : '尚未设置审批人', icon: 'fa-circle-o', title: '', placeholder: true }); %>
+                        <tbody class="<%= phase.stage !== 1 && !inspection.need_rectification ? 'd-none' : '' %>" data-flow-stage="<%= phase.stage %>">
+                            <% people.forEach((person, index) => { %>
+                                <tr>
+                                    <% if (index === 0) { %><th class="text-center align-middle" scope="rowgroup" rowspan="<%= people.length %>"><%= phase.name %></th><% } %>
+                                    <td title="<%= person.title %>"><i class="fa <%= person.icon %> mr-1" aria-hidden="true"></i><span class="<%= person.placeholder ? 'text-muted' : '' %>"><%= person.name %></span></td>
+                                </tr>
+                            <% }); %>
+                        </tbody>
+                    <% } %>
+                <% }); %>
+            </table>
+        </div>
+        <div class="col-md-8 flow-details">
+    <% } %>
+            <% inspection.flowStages.forEach(phase => { %>
+                <% if (phase.stage === 1 || inspection.need_rectification || !inspection.readOnly) { %>
+                    <% const reportMode = ctx.tender.info.shenpi.inspection;
+                       const editable = phase.stage === 2 ? inspection.canAssignRectifiers : !inspection.readOnly && (phase.stage !== 1 || reportMode !== shenpiConst.sp_status.gdspl);
+                       const maxOrder = phase.auditors.length ? Math.max(...phase.auditors.map(x => x.order)) : 0; %>
+                    <% if (showTimeline) { %>
+                    <section class="flow-phase-section" data-flow-stage="<%= phase.stage %>">
+                        <div class="flow-phase-title text-center text-muted">巡检<%= phase.name %></div>
+                        <div class="row flow-layout">
+                            <div class="col-md-4 flow-summary mb-3">
+                                <div class="card">
+                                    <ul class="list-group list-group-flush">
+                                        <% phase.nodes.forEach(node => { %>
+                                            <li class="list-group-item d-flex align-items-center flow-phase-person">
+                                                <i class="fa <%= node.origin ? 'fa-play-circle fa-rotate-90' : node.label === '终审' ? 'fa-stop-circle' : 'fa-chevron-circle-down' %> mr-2" aria-hidden="true"></i>
+                                                <div class="flow-person-names">
+                                                    <% node.auditors.forEach(audit => { %><div><%= audit.name %></div><% }); %>
+                                                </div>
+                                                <div class="ml-auto pl-2 text-right">
+                                                    <span class="badge badge-light badge-pill"><%= node.label %></span>
+                                                    <% if (phase.stage === 2) { %><small class="d-block text-muted"><%= flowStatusText[node.status] %></small><% } %>
+                                                </div>
+                                            </li>
+                                        <% }); %>
+                                        <% if (!phase.nodes.length) { %><li class="list-group-item text-muted"><%= phase.stage === 2 ? '整改人由上报审批人指派' : '尚未设置审批人' %></li><% } %>
+                                    </ul>
+                                </div>
+                            </div>
+                            <div class="col-md-8 flow-details">
+                    <% } else { %>
+                    <div class="mb-3<%= phase.stage !== 1 && !inspection.need_rectification ? ' d-none' : '' %>" data-flow-stage="<%= phase.stage %>">
+                    <% } %>
+                        <% if (editable) { %>
+                            <div class="dropdown flow-picker mb-2 text-right" data-stage="<%= phase.stage %>">
+                                <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-toggle="dropdown">添加<%= phase.name %><%= phase.stage === 2 ? '人' : '流程' %></button>
+                                <div class="dropdown-menu dropdown-menu-right">
+                                    <div class="mb-2 p-2"><input class="form-control form-control-sm flow-user-search" placeholder="姓名/手机 检索" aria-label="检索<%= phase.name %>用户" autocomplete="off"></div>
+                                    <dl class="list-unstyled book-list text-left">
+                                        <% accountGroup.forEach((group, idx) => { %>
+                                            <div class="flow-user-group">
+                                                <dt><a href="javascript:void(0);" class="acc-btn" data-groupid="<%= idx %>" data-type="hide" aria-expanded="false" aria-label="展开或收起<%= group.groupName %>"><i class="fa fa-plus-square"></i></a> <%= group.groupName %></dt>
+                                                <div class="dd-content" data-toggleid="<%= idx %>">
+                                                    <% group.groupList.forEach(user => { %>
+                                                        <% if (phase.stage !== 1 || user.id !== inspection.uid) { %>
+                                                            <% const selected = phase.auditors.some(x => x.aid === user.id); %>
+                                                            <dd class="flow-add-user border-bottom p-2 mb-0" role="button" tabindex="<%= selected ? '-1' : '0' %>" aria-disabled="<%= selected ? 'true' : 'false' %>" data-id="<%= user.id %>" data-name="<%= user.name || '' %>" data-mobile="<%= user.mobile || '' %>">
+                                                                <p class="mb-0 d-flex"><span class="<%= selected ? 'text-muted' : 'text-primary' %>"><%= user.name %></span><span class="ml-auto"><%= user.mobile || '' %></span></p>
+                                                                <span class="text-muted"><%= user.role || '' %><%= selected ? '(已添加)' : '' %></span>
+                                                            </dd>
+                                                        <% } %>
+                                                    <% }); %>
+                                                </div>
+                                            </div>
+                                        <% }); %>
+                                        <dd class="flow-no-user text-muted p-2 mb-0 d-none">未找到匹配的用户</dd>
+                                    </dl>
+                                </div>
+                            </div>
+                        <% } %>
+                        <% if (showTimeline && phase.stage !== 2) { %>
+                            <ul class="timeline-list list-unstyled flow-timeline">
+                                <% phase.nodes.forEach((node, index) => { %>
+                                    <% const tone = node.status === 3 ? 'success' : [2, 5, 6, 12].includes(node.status) ? 'warning' : node.status === 4 ? 'danger' : 'secondary';
+                                       const icon = node.origin ? 'caret-down' : node.status === 3 ? 'check' : [5, 6].includes(node.status) ? 'level-up' : [2, 12].includes(node.status) ? 'ellipsis-h' : node.status === 4 ? 'stop' : ''; %>
+                                    <li class="timeline-list-item pb-3">
+                                        <% if (node.time) { %>
+                                            <div class="timeline-item-date"><%= moment(node.time).format('YYYY') %><span><%= moment(node.time).format('MM-DD') %></span><span><%= moment(node.time).format('HH:mm:ss') %></span></div>
+                                        <% } %>
+                                        <% if (index < phase.nodes.length - 1) { %><div class="timeline-item-tail"></div><% } %>
+                                        <div class="timeline-item-icon bg-<%= tone %> text-light"><% if (icon) { %><i class="fa fa-<%= icon %>" aria-hidden="true"></i><% } %></div>
+                                        <div class="timeline-item-content">
+                                            <div class="d-flex justify-content-between py-1">
+                                                <span class="text-black-50"><%= node.label %></span>
+                                                <span class="text-<%= tone %>"><%= node.origin ? '上报审批' : node.status === 3 ? '审批通过' : flowStatusText[node.status] %></span>
+                                            </div>
+                                            <div class="card">
+                                                <div class="card-body px-3 py-2">
+                                                    <% node.auditors.forEach((audit, personIndex) => { %>
+                                                        <div class="flow-timeline-person py-2<%= personIndex ? ' border-top' : '' %>">
+                                                            <div class="d-flex align-items-start">
+                                                                <div class="flow-person-names"><span class="h6 mr-1"><%= audit.name %></span><small class="text-muted"><%= audit.company || '' %><%= audit.company && audit.role ? '-' : '' %><%= audit.role || '' %></small></div>
+                                                                <% const personTone = node.origin || audit.status === 3 ? 'success' : [2, 5, 6, 12].includes(audit.status) ? 'warning' : audit.status === 4 ? 'danger' : 'secondary'; %>
+                                                                <span class="ml-auto pl-2 text-<%= personTone %>"><% if (!node.origin && node.auditors.length > 1) { %><small class="mr-1"><%= flowStatusText[audit.status] %></small><% } %><i class="fa <%= node.origin || audit.status === 3 ? 'fa-check-circle' : [2, 12].includes(audit.status) ? 'fa-commenting' : [5, 6].includes(audit.status) ? 'fa-reply' : audit.status === 4 ? 'fa-stop-circle' : 'fa-circle-o' %>" aria-hidden="true"></i></span>
+                                                            </div>
+                                                            <div class="bg-light flow-opinion mt-1" aria-label="处理意见"><i class="fa fa-commenting-o mr-1" aria-hidden="true"></i><%= audit.opinion || '' %></div>
+                                                        </div>
+                                                    <% }); %>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </li>
+                                <% }); %>
+                                <% if (!phase.nodes.length) { %><li class="text-muted py-2">尚未设置审批人</li><% } %>
+                            </ul>
+                        <% } else { %>
+                        <div class="card">
+                            <div class="card-header">
+                                <%= phase.name %>流程
+                                <% if (phase.stage === 2 && phase.auditors.length) { %><span class="float-right">第 <%= inspection.stage_round %> 轮 · 已完成 <%= inspection.rectificationDone %>/<%= inspection.rectificationTotal %></span><% } %>
+                            </div>
+                            <ul class="list-group list-group-flush">
+                                <% if (!phase.auditors.length) { %><li class="list-group-item text-muted"><%= phase.stage === 2 ? '整改人由上报审批人指派,可设置多人同时处理。' : '请设置' + phase.name + '审批人' %></li><% } %>
+                                <% phase.auditors.forEach(audit => { %>
+                                    <li class="list-group-item">
+                                        <div class="d-flex justify-content-between">
+                                            <span><%= audit.name %> <small class="text-muted"><%= audit.role || '' %><%= phase.stage !== 2 && audit.audit_type !== 1 ? ' · ' + auditType.info[audit.audit_type].name : '' %></small></span>
+                                            <% if (editable && !(phase.stage === 1 && reportMode === shenpiConst.sp_status.gdzs && audit.order === maxOrder)) { %>
+                                                <button class="btn btn-link btn-sm text-danger flow-remove-user p-0" data-stage="<%= phase.stage %>" data-id="<%= audit.aid %>" type="button">移除</button>
+                                            <% } else { %>
+                                                <span class="<%= auditConst.statusClass[audit.status] || '' %>"><%= audit.status === 1 ? '待处理' : audit.status === 3 ? '已完成' : audit.status === 8 ? '已跳过' : auditConst.statusString[audit.status] %></span>
+                                            <% } %>
+                                        </div>
+                                        <small class="text-muted"><%= audit.company || '' %></small>
+                                        <% if (mobileFlow && phase.stage === 2 && inspection.rectificationPower && audit.aid === ctx.session.sessionUser.accountId && audit.status === 12) { %>
+                                            <div class="flow-rectification mt-2" data-id="<%= audit.id %>">
+                                                <label class="d-block">整改内容<b class="text-danger">*</b><textarea class="form-control form-control-sm flow-rectification-item" rows="3" maxlength="1000"><%= audit.rectification_item || '' %></textarea></label>
+                                                <label class="d-block">整改日期<b class="text-danger">*</b><input class="form-control form-control-sm flow-rectification-date" type="date" value="<%= audit.rectification_date ? moment(audit.rectification_date).format('YYYY-MM-DD') : '' %>"></label>
+                                                <label class="d-block">说明<textarea class="form-control form-control-sm flow-rectification-opinion" rows="2" maxlength="1000"><%= audit.opinion || '' %></textarea></label>
+                                                <% if (mobileFlow) { %>
+                                                    <button type="button" class="btn btn-outline-primary btn-sm flow-save-rectification" data-id="<%= audit.id %>">保存</button>
+                                                    <button type="button" class="btn btn-success btn-sm flow-action" data-action="flow-complete-rectification" data-id="<%= audit.id %>">提交整改完成</button>
+                                                <% } %>
+                                            </div>
+                                        <% } else if (phase.stage === 2 && (audit.rectification_item || audit.rectification_date)) { %>
+                                            <div class="mt-2"><%= audit.rectification_item %></div>
+                                            <small class="text-muted">整改日期:<%= audit.rectification_date ? moment(audit.rectification_date).format('YYYY-MM-DD') : '' %></small>
+                                        <% } %>
+                                        <% if (audit.opinion && !(phase.stage === 2 && inspection.rectificationPower && audit.aid === ctx.session.sessionUser.accountId && audit.status === 12)) { %><p class="mb-0 mt-2"><%= audit.opinion %></p><% } %>
+                                        <% if (mobileFlow && phase.stage !== 2 && inspection.shenpiPower && inspection.flow_stage === phase.stage && audit.aid === ctx.session.sessionUser.accountId && audit.status === 2) { %>
+                                            <div class="mt-2">
+                                                <button type="button" class="btn btn-success btn-sm flow-action" data-action="flow-check" data-result="3" data-id="<%= audit.id %>"><%= phase.stage === 1 ? '审批' : phase.name %>通过</button>
+                                                <% if (phase.stage === 1) { %>
+                                                    <button type="button" class="btn btn-warning btn-sm flow-action" data-action="flow-check" data-result="5" data-id="<%= audit.id %>">退回上报人</button>
+                                                    <% if (phase.auditors.some(x => x.audit_order < audit.audit_order)) { %><button type="button" class="btn btn-outline-warning btn-sm flow-action" data-action="flow-check" data-result="6" data-id="<%= audit.id %>">退回上一审批节点</button><% } %>
+                                                <% } else { %>
+                                                    <button type="button" class="btn btn-warning btn-sm flow-action" data-action="flow-check" data-result="5" data-id="<%= audit.id %>">退回重新整改</button>
+                                                <% } %>
+                                                <button type="button" class="btn btn-outline-danger btn-sm flow-action" data-action="flow-check" data-result="4" data-id="<%= audit.id %>">关闭巡检</button>
+                                            </div>
+                                        <% } %>
+                                    </li>
+                                <% }); %>
+                            </ul>
+                        </div>
+                        <% } %>
+                    <% if (showTimeline) { %>
+                            </div>
+                        </div>
+                    </section>
+                    <% } else { %>
+                    </div>
+                    <% } %>
+                <% } %>
+            <% }); %>
+            <div class="alert alert-warning flow-no-rectification<%= inspection.need_rectification ? ' d-none' : '' %>">本次巡检无整改/批复流程</div>
+    <% if (!showTimeline) { %>
+        </div>
+    </div>
+    <% } %>
+    <% if (inspection.flowHistory.length) { %>
+        <details class="card mb-3 d-none">
+            <summary class="card-header">查看处理记录</summary>
+            <div class="list-group list-group-flush">
+                <% inspection.flowHistory.forEach(audit => { %>
+                    <div class="list-group-item">
+                        <div>第 <%= audit.times %> 次上报 / 第 <%= audit.stage_round %> 轮 · <%= auditConst.stageString[audit.flow_stage] %> · <%= audit.name %><span class="float-right"><%= audit.status === 3 ? '已完成' : audit.status === 8 ? '已跳过' : auditConst.statusString[audit.status] %></span></div>
+                        <small class="text-muted"><%= moment(audit.begin_time).format('YYYY-MM-DD HH:mm:ss') %><%= audit.end_time ? ' 至 ' + moment(audit.end_time).format('YYYY-MM-DD HH:mm:ss') : '' %></small>
+                        <% if (audit.flow_stage === 2) { %><p class="mb-0"><%= audit.rectification_item || '' %> <%= audit.rectification_date ? moment(audit.rectification_date).format('YYYY-MM-DD') : '' %></p><% } %>
+                        <% if (audit.opinion) { %><p class="mb-0"><%= audit.opinion %></p><% } %>
+                    </div>
+                <% }); %>
+            </div>
+        </details>
+    <% } %>
+</div>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 103 - 50
app/view/quality/inspection_information.ejs


+ 21 - 0
app/view/quality/inspection_information_modal.ejs

@@ -42,6 +42,7 @@
         </div>
     </div>
 </div>
+<% if (inspection.flow_version !== 1) { %>
 <!--提交审批-->
 <div class="modal fade" id="sp-done" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -60,6 +61,8 @@
     </div>
 </div>
 <% } %>
+<% } %>
+<% if (inspection.flow_version !== 1) { %>
 <% if (inspection.shenpiPower) { %>
     <div class="modal fade" id="sp-back" data-backdrop="static">
         <div class="modal-dialog" role="document">
@@ -246,3 +249,21 @@
         </div>
     </div>
 <% } %>
+
+<% } else { %>
+<div class="modal fade" id="quality-flow-confirm" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header"><h5 class="modal-title" id="quality-flow-title">确认操作</h5></div>
+            <div class="modal-body">
+                <p id="quality-flow-message"></p>
+                <div id="quality-flow-opinion-group"><label for="quality-flow-opinion">处理意见</label><textarea id="quality-flow-opinion" class="form-control" rows="3" maxlength="1000"></textarea></div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-primary btn-sm" id="quality-flow-submit">确认</button>
+            </div>
+        </div>
+    </div>
+</div>
+<% } %>

+ 95 - 0
app/view/quality/inspection_overview.ejs

@@ -0,0 +1,95 @@
+<% include ./sub_menu.ejs %>
+<% const inspectionUrl = '/sp/' + ctx.subProject.id + '/quality/tender/' + ctx.tender.id + '/inspection'; %>
+<div class="panel-content quality-inspection-overview" data-inspection-url="<%= inspectionUrl %>">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./sub_mini_menu.ejs %>
+            <div>巡检概况</div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body p-3">
+            <div class="row mb-3">
+                <div class="col-md-5 mb-3 mb-md-0">
+                    <div class="card h-100">
+                        <div class="card-header d-flex align-items-center">
+                            <strong>整改中(<%= overview.counts.rectification %>)</strong>
+                            <a class="ml-auto" href="<%= inspectionUrl %>/rectification">查看全部</a>
+                        </div>
+                        <div class="overview-rectifications">
+                            <table class="table table-sm table-hover mb-0">
+                                <thead>
+                                    <tr>
+                                        <th class="overview-inspection-column">巡检</th>
+                                        <th>待完成整改人</th>
+                                        <th class="text-center overview-progress-column">进度</th>
+                                        <th class="text-center overview-days-column">已持续</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <% overview.rectifications.forEach(item => { %>
+                                        <tr>
+                                            <td>
+                                                <a href="<%= inspectionUrl %>/<%= item.id %>/information"><%= item.code || '查看巡检' %></a>
+                                                <div class="text-muted text-truncate" title="<%= item.check_item %>"><%= item.check_item || '-' %></div>
+                                            </td>
+                                            <td><%= item.pendingUsers.length ? item.pendingUsers.join('、') : '-' %></td>
+                                            <td class="text-center"><%= item.total ? '已完成 ' + item.done + '/' + item.total : '-' %></td>
+                                            <td class="text-center"><span class="badge badge-light"><%= item.elapsedDays === null ? '-' : item.elapsedDays === 0 ? '今天' : item.elapsedDays + '天' %></span></td>
+                                        </tr>
+                                    <% }); %>
+                                    <% if (!overview.rectifications.length) { %>
+                                        <tr><td colspan="4" class="text-center text-muted py-5">暂无正在整改的巡检</td></tr>
+                                    <% } %>
+                                </tbody>
+                            </table>
+                        </div>
+                        <div class="card-footer text-muted small">
+                            按本轮整改持续时间排序<%= overview.counts.rectification > 10 ? ',展示前10条' : '' %>。
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-7">
+                    <div class="card h-100">
+                        <div class="card-header"><strong>巡检状态分布</strong></div>
+                        <div class="card-body">
+                            <% if (overview.total) { %>
+                                <div id="quality-overview-pie" class="overview-pie" role="img" aria-label="巡检状态分布:整改中<%= overview.counts.rectification %>,审批中<%= overview.counts.report + overview.counts.reply %>,已完成<%= overview.counts.completed %>"></div>
+                            <% } else { %>
+                                <div class="overview-pie d-flex align-items-center justify-content-center text-muted">暂无巡检状态数据</div>
+                            <% } %>
+                            <div class="overview-status-links d-flex justify-content-around text-center">
+                                <a href="<%= inspectionUrl %>/rectification">整改中 <strong><%= overview.counts.rectification %></strong></a>
+                                <div class="dropdown">
+                                    <button id="quality-overview-approval" type="button" class="btn btn-link btn-sm dropdown-toggle p-0" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">审批中 <strong><%= overview.counts.report + overview.counts.reply %></strong></button>
+                                    <div class="dropdown-menu dropdown-menu-right" aria-labelledby="quality-overview-approval">
+                                        <a class="dropdown-item" href="<%= inspectionUrl %>?status=2">上报审批(<%= overview.counts.report %>)</a>
+                                        <a class="dropdown-item" href="<%= inspectionUrl %>/approval">批复审批(<%= overview.counts.reply %>)</a>
+                                    </div>
+                                </div>
+                                <a href="<%= inspectionUrl %>?status=3">已完成 <strong><%= overview.counts.completed %></strong></a>
+                            </div>
+                        </div>
+                        <div class="card-footer text-muted small">审批中包含上报审批和批复审批,占比以这三类巡检合计为基数。</div>
+                    </div>
+                </div>
+            </div>
+            <div class="card mb-3">
+                <div class="card-header d-flex align-items-center">
+                    <strong>整改趋势</strong>
+                </div>
+                <div class="card-body">
+                    <% if (!overview.trend.hasData) { %><p class="text-center text-muted mb-0"><%= overview.trend.year %>年暂无上报或完成记录</p><% } %>
+                    <div id="quality-overview-trend" class="overview-trend" role="img" aria-label="<%= overview.trend.year %>年巡检上报、完成及需整改占比趋势"></div>
+                </div>
+                <div class="card-footer text-muted small">
+                    <div>首次上报数按实际首次提交月份统计,退回重报不重复计数;完成数按整个流程最终完成月份统计。</div>
+                    <div>需整改占比为该月首次上报记录中,按当前保存的整改设置计算的需整改比例;趋势统计当年数据,上方展示当前状态。</div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const qualityInspectionOverview = JSON.parse(unescape('<%- escape(JSON.stringify({ statuses: overview.statuses, total: overview.total, trend: overview.trend })) %>'));
+</script>

+ 4 - 1
app/view/quality/sub_memu_list.ejs

@@ -4,5 +4,8 @@
 <!--<nav-menu title="缺陷管理" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/flaw%>" ml="3" active="<%= ctx.url.indexOf('/flaw') %>"></nav-menu>-->
 <!--<nav-menu title="试验报告" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/lab%>" ml="3" active="<%= ctx.url.indexOf('/lab') %>"></nav-menu>-->
 <% } else { %>
-<nav-menu title="质量巡检" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/inspection %>" ml="3" active="<%= ctx.url.indexOf('/inspection') %>"></nav-menu>
+<nav-menu title="巡检概况" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/inspection/overview" ml="1" active="<%= ctx.url.indexOf('/inspection/overview') >= 0 ? 1 : -1 %>" icon="fa-pie-chart"></nav-menu>
+<nav-menu title="质量巡检" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/inspection" ml="3" active="<%= ctx.url.includes('/inspection') && !ctx.url.includes('/overview') && !ctx.url.includes('/rectification') && !ctx.url.includes('/approval') && !ctx.url.includes('/information') ? 1 : -1 %>" ></nav-menu>
+<nav-menu title="巡检整改" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/inspection/rectification" ml="3" active="<%= ctx.url.indexOf('/inspection/rectification') >= 0 ? 1 : -1 %>" ></nav-menu>
+<nav-menu title="巡检批复" url="/sp/<%- ctx.subProject.id %>/quality/tender/<%= ctx.tender.id %>/inspection/approval" ml="3" active="<%= ctx.url.indexOf('/inspection/approval') >= 0 ? 1 : -1 %>" ></nav-menu>
 <% } %>

+ 11 - 0
config/web.js

@@ -2397,6 +2397,17 @@ const JsFiles = {
                 ],
                 mergeFile: 'quality_rule',
             },
+            inspection_overview: {
+                files: [
+                    '/public/js/component/menu.js',
+                    '/public/js/echarts/echarts.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/quality_inspection_overview.js',
+                ],
+                mergeFile: 'quality_inspection_overview',
+            },
         },
         safe: {
             tender: {

+ 32 - 1
sql/update.sql

@@ -157,4 +157,35 @@ ALTER TABLE `zh_safe_inspection_audit`
   ADD INDEX `idx_safe_audit_stage`
     (`qiid`, `times`, `flow_stage`, `stage_round`, `is_old`, `status`),
   ADD INDEX `idx_safe_audit_todo`
-    (`aid`, `status`, `qiid`, `times`);
+    (`aid`, `status`, `qiid`, `times`);
+
+-- 质量巡检:上报、多人整改、批复流程(以下两条 ALTER TABLE 各执行一次)
+-- 历史单据默认 flow_version = 0,保留旧流程;新流程由业务代码启用。
+
+-- 1. 质量巡检主表
+ALTER TABLE `zh_quality_inspection`
+  ADD COLUMN `flow_version` tinyint unsigned NOT NULL DEFAULT 0
+    COMMENT '流程版本:0旧流程,1上报整改批复流程',
+  ADD COLUMN `need_rectification` tinyint unsigned NOT NULL DEFAULT 1
+    COMMENT '是否整改:0否,跳过整改和批复;1是',
+  ADD COLUMN `flow_stage` tinyint unsigned NOT NULL DEFAULT 0
+    COMMENT '当前阶段:0旧流程,1上报,2整改,3批复;完成状态由status判断',
+  ADD COLUMN `stage_round` int unsigned NOT NULL DEFAULT 1
+    COMMENT '整改批复轮次:首次为1,重新整改时递增',
+  ADD INDEX `idx_quality_inspection_stage`
+    (`tid`, `flow_version`, `flow_stage`, `status`);
+
+-- 2. 质量巡检审批记录表(每名整改人独立保存本轮整改内容和日期)
+ALTER TABLE `zh_quality_inspection_audit`
+  ADD COLUMN `flow_stage` tinyint unsigned NOT NULL DEFAULT 0
+    COMMENT '记录所属阶段:0旧流程,1上报,2整改,3批复',
+  ADD COLUMN `stage_round` int unsigned NOT NULL DEFAULT 1
+    COMMENT '所属整改批复轮次,与主表stage_round对应',
+  ADD COLUMN `rectification_item` varchar(1000) NOT NULL DEFAULT ''
+    COMMENT '该整改人本轮填写的整改内容',
+  ADD COLUMN `rectification_date` datetime NULL DEFAULT NULL
+    COMMENT '该整改人本轮填写的整改日期',
+  ADD INDEX `idx_quality_audit_stage`
+    (`qiid`, `times`, `flow_stage`, `stage_round`, `is_old`, `status`),
+  ADD INDEX `idx_quality_audit_todo`
+    (`aid`, `status`, `qiid`, `times`);