/** * 危险作业票「后续环节」统一校验 * * 用法:node scripts/check-dangerous-work-flow-nodes.js * * 背景: * 八类作业票(动火 / 受限空间 / 高处 / 吊装 / 临时用电 / 动土 / 断路 / 盲板抽堵) * 的「后续环节」此前是同一份实现复制了八遍(8 份 *FlowNodes.js + 8 份 *FlowTask.js * + 7 份 xxxFlow.vue),现已收敛为: * - flowRuntime.js 公共运行时(回显 / payload / 流程变量 / 作业票参数) * - flowMatcher.js 公共环节匹配器(code -> 别名 -> 名称 -> 双向包含) * - flowFields.js 字段取值 key 工具(纯函数,不依赖接口) * - 八份 *FlowNodes.js 只描述各自的节点 schema * - 七份 components/xxxFlow.vue 各作业类型自己的「后续环节」组件(对外接口一致) * - detail.vue 由模块级常量 FLOW_TYPES 配置表驱动, * 环节组件由 渲染 * 本脚本是原来 7 个 check-*-work-flow-nodes.js 的合并版, * 基线里的全部 314 条断言都保留了下来,并补充了针对新结构的断言。 * * 校验内容: * 1) 三个公共模块存在且导出齐全 * 2) 八份节点定义文件各自导出节点表与匹配包装,包装确实委托给 matchFlowNode; * 节点表的 key / code / name 唯一,code 以该作业类型的 HO_PERMIT_ 前缀开头 * 3) 各作业类型的环节数量与「第 N 级 code / name」与源系统一致 * 4) 各环节的表单设计与源系统 formJson 一致(字段类型 / 列 / 默认清单) * 5) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中 * 6) 运行时辅助(回显 / payload / 流程变量)的取值口径正确 * 7) detail.vue 由 FLOW_TYPES 配置表驱动,七类作业各自的环节组件在位 * 8) 七个环节组件的对外接口一致性与模板/脚本一致性 * 9) 时间类字段一律用日期时间选择器渲染,不能落回输入框 * 10) 作业票内容(基本信息)的手写签名能正常显示 * 11) 作业类型名称匹配:全等优先 + 包含兜底 * 9) 作业票内容(基本信息)的手写签名能正常显示 * * 源系统依据: * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=... * -> 流程定义 HO_PERMIT_FIRE / HO_PERMIT_CONFINED_SPACE / HO_PERMIT_HEIGHT / * HO_PERMIT_HOISTING / HO_PERMIT_ELECTRICITY / HO_PERMIT_BREAK_GROUND / * HO_PERMIT_BREAK_ROAD / HO_PERMIT_BLIND_PLATE */ const fs = require('fs'); const os = require('os'); const path = require('path'); const babel = require('@babel/core'); const Module = require('module'); const compiler = require('vue-template-compiler'); const parser = require('@babel/parser'); const ROOT = path.resolve(__dirname, '..'); const SRC = path.join( ROOT, 'src/views/bpm/handleTask/components/dangerousWorkTicket' ); const OUT = path.join(os.tmpdir(), 'kd-aiot-check-dangerous-work-flow-nodes'); let failed = 0; let total = 0; function check(name, cond, extra) { total++; if (cond) { console.log(' PASS ' + name); } else { failed++; console.log( ' FAIL ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '') ); } } // --------------------------------------------------------------------------- // 一、编译 ESM -> CJS,并准备 @/ 桩模块 // --------------------------------------------------------------------------- // 八个作业类型的节点定义文件 + 三个公共模块 const COMPILE_FILES = [ 'flowFields.js', 'flowMatcher.js', 'flowRuntime.js', 'workTypes.js', 'templates.js', 'fireFlowNodes.js', 'confinedSpaceFlowNodes.js', 'heightFlowNodes.js', 'hoistingFlowNodes.js', 'electricityFlowNodes.js', 'breakGroundFlowNodes.js', 'breakRoadFlowNodes.js', 'blindPlateFlowNodes.js' ]; fs.mkdirSync(OUT, { recursive: true }); COMPILE_FILES.forEach((file) => { const code = fs.readFileSync(path.join(SRC, file), 'utf8'); const res = babel.transformSync(code, { filename: file, babelrc: false, configFile: false, plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')] }); fs.writeFileSync(path.join(OUT, file), res.code); }); // 运行时辅助会引用 @/api/*,这里用桩模块代替,避免校验脚本依赖后端接口 const STUB = path.join(OUT, 'stub.js'); fs.writeFileSync( STUB, 'module.exports = {\n' + ' getTaskListByProcessInstanceId: function () { return Promise.resolve([]); },\n' + ' getProcessInstance: function () { return Promise.resolve({}); }\n' + '};\n' ); const originalResolve = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { if (request.startsWith('@/')) { return STUB; } return originalResolve.call(this, request, ...args); }; const runtime = require(path.join(OUT, 'flowRuntime.js')); const matcher = require(path.join(OUT, 'flowMatcher.js')); const fieldUtils = require(path.join(OUT, 'flowFields.js')); // --------------------------------------------------------------------------- // 二、八类作业的配置表(数据驱动) // --------------------------------------------------------------------------- const TYPES = [ { key: 'fire', label: '动火', module: 'fireFlowNodes', nodesConst: 'FIRE_FLOW_NODES', matchFn: 'matchFireFlowNode', measuresConst: 'DEFAULT_FIRE_MEASURES', codePrefix: 'HO_PERMIT_FIRE_', flowCode: 'HO_PERMIT_FIRE', // 动火沿用自己的实现,不在 detail.vue 的 FLOW_TYPES 配置表内 templateKey: null, count: 11, expected: [ ['HO_PERMIT_FIRE_TWO', '风险辨识'], ['HO_PERMIT_FIRE_THREETH', '气体分析'], ['HO_PERMIT_FIRE_FOURTH', '现场安全措施落实'], ['HO_PERMIT_FIRE_FIVETH', '安全交底'], ['HO_PERMIT_FIRE_SIXTH', '作业负责人意见'], ['HO_PERMIT_FIRE_SEVENTH', '所在单位意见'], ['HO_PERMIT_FIRE_EIGHTTH', '安全管理部门意见'], ['HO_PERMIT_FIRE_NINETH', '动火审批人意见'], ['HO_PERMIT_FIRE_TENTH', '动火前验票'], ['HO_PERMIT_FIRE_ELETH', '作业实施'], ['HO_PERMIT_FIRE_TWETEENTH', '完工验收'] ] }, { key: 'confined_space', label: '受限空间', module: 'confinedSpaceFlowNodes', nodesConst: 'CONFINED_SPACE_FLOW_NODES', matchFn: 'matchConfinedSpaceFlowNode', measuresConst: 'DEFAULT_CONFINED_SPACE_MEASURES', codePrefix: 'HO_PERMIT_CONFINED_SPACE_', flowCode: 'HO_PERMIT_CONFINED_SPACE', templateKey: 'confined_space', count: 7, expected: [ ['HO_PERMIT_CONFINED_SPACE_TWOTH', '风险辨识'], ['HO_PERMIT_CONFINED_SPACE_THREETH', '气体分析'], ['HO_PERMIT_CONFINED_SPACE_FOURTH', '现场安全措施确认'], ['HO_PERMIT_CONFINED_SPACE_FIVETH', '安全交底'], ['HO_PERMIT_CONFINED_SPACE_SIXTH', '所在单位意见'], ['HO_PERMIT_CONFINED_SPACE_SEVENTH', '作业实施'], ['HO_PERMIT_CONFINED_SPACE_EIGHTTH', '完工验收'] ] }, { key: 'height', label: '高处', module: 'heightFlowNodes', nodesConst: 'HEIGHT_FLOW_NODES', matchFn: 'matchHeightFlowNode', measuresConst: 'DEFAULT_HEIGHT_MEASURES', codePrefix: 'HO_PERMIT_HEIGHT_', flowCode: 'HO_PERMIT_HEIGHT', templateKey: 'height', count: 9, expected: [ ['HO_PERMIT_HEIGHT_TWOTH', '风险辨识'], ['HO_PERMIT_HEIGHT_THREETH', '现场安全措施确认'], ['HO_PERMIT_HEIGHT_FOURTH', '安全交底'], ['HO_PERMIT_HEIGHT_FIVETH', '作业负责人意见'], ['HO_PERMIT_HEIGHT_SIXTH', '所在单位意见'], ['HO_PERMIT_HEIGHT_SEVENTH', '审核部门意见'], ['HO_PERMIT_HEIGHT_EIGHTTH', '审批部门意见'], ['HO_PERMIT_HEIGHT_NINETH', '作业实施'], ['HO_PERMIT_HEIGHT_TENTH', '完工验收'] ] }, { key: 'hoisting', label: '吊装', module: 'hoistingFlowNodes', nodesConst: 'HOISTING_FLOW_NODES', matchFn: 'matchHoistingFlowNode', measuresConst: 'DEFAULT_HOISTING_MEASURES', codePrefix: 'HO_PERMIT_HOISTING_', flowCode: 'HO_PERMIT_HOISTING', templateKey: 'hoisting', count: 9, expected: [ ['HO_PERMIT_HOISTING_TWOTH', '风险辨识'], ['HO_PERMIT_HOISTING_THREETH', '现场安全措施确认'], ['HO_PERMIT_HOISTING_FOURTH', '安全交底'], ['HO_PERMIT_HOISTING_FIVETH', '作业负责人意见'], ['HO_PERMIT_HOISTING_SIXTH', '所在单位意见'], ['HO_PERMIT_HOISTING_SEVENTH', '审核部门意见'], ['HO_PERMIT_HOISTING_EIGHTTH', '审批部门意见'], ['HO_PERMIT_HOISTING_NINETH', '作业实施'], ['HO_PERMIT_HOISTING_TENTH', '完工验收'] ] }, { key: 'electricity', label: '临时用电', module: 'electricityFlowNodes', nodesConst: 'ELECTRICITY_FLOW_NODES', matchFn: 'matchElectricityFlowNode', measuresConst: 'DEFAULT_ELECTRICITY_MEASURES', codePrefix: 'HO_PERMIT_ELECTRICITY_', flowCode: 'HO_PERMIT_ELECTRICITY', templateKey: 'electricity', count: 9, expected: [ ['HO_PERMIT_ELECTRICITY_TWOTH', '风险辨识'], ['HO_PERMIT_ELECTRICITY_THREETH', '气体分析'], ['HO_PERMIT_ELECTRICITY_FOURTH', '危害辨识'], ['HO_PERMIT_ELECTRICITY_FIVETH', '现场安全措施确认'], ['HO_PERMIT_ELECTRICITY_SIXTH', '安全交底'], ['HO_PERMIT_ELECTRICITY_SEVENTH', '作业负责人意见'], ['HO_PERMIT_ELECTRICITY_EIGHTTH', '用电单位意见'], ['HO_PERMIT_ELECTRICITY_NINETH', '作业实施'], ['HO_PERMIT_ELECTRICITY_TENTH', '完工验收'] ] }, { key: 'break_ground', label: '动土', module: 'breakGroundFlowNodes', nodesConst: 'BREAK_GROUND_FLOW_NODES', matchFn: 'matchBreakGroundFlowNode', measuresConst: 'DEFAULT_BREAK_GROUND_MEASURES', codePrefix: 'HO_PERMIT_BREAK_GROUND_', flowCode: 'HO_PERMIT_BREAK_GROUND', templateKey: 'break_ground', count: 9, expected: [ ['HO_PERMIT_BREAK_GROUND_TWOTH', '风险辨识'], ['HO_PERMIT_BREAK_GROUND_THREETH', '现场安全措施确认'], ['HO_PERMIT_BREAK_GROUND_FOURTH', '安全交底'], ['HO_PERMIT_BREAK_GROUND_FIVETH', '作业负责人意见'], ['HO_PERMIT_BREAK_GROUND_SIXTH', '所在单位意见'], ['HO_PERMIT_BREAK_GROUND_SEVENTH', '有关部门会签意见'], ['HO_PERMIT_BREAK_GROUND_EIGHTTH', '审批部门意见'], ['HO_PERMIT_BREAK_GROUND_NINETH', '作业实施'], ['HO_PERMIT_BREAK_GROUND_TENTH', '完工验收'] ] }, { key: 'break_road', label: '断路', module: 'breakRoadFlowNodes', nodesConst: 'BREAK_ROAD_FLOW_NODES', matchFn: 'matchBreakRoadFlowNode', measuresConst: 'DEFAULT_BREAK_ROAD_MEASURES', codePrefix: 'HO_PERMIT_BREAK_ROAD_', flowCode: 'HO_PERMIT_BREAK_ROAD', templateKey: 'break_road', count: 9, expected: [ ['HO_PERMIT_BREAK_ROAD_TWOTH', '风险辨识'], ['HO_PERMIT_BREAK_ROAD_THREETH', '现场安全措施确认'], ['HO_PERMIT_BREAK_ROAD_FOURTH', '安全交底'], ['HO_PERMIT_BREAK_ROAD_FIVETH', '作业负责人意见'], ['HO_PERMIT_BREAK_ROAD_SIXTH', '所在单位意见'], ['HO_PERMIT_BREAK_ROAD_SEVENTH', '消防、安全部门意见'], ['HO_PERMIT_BREAK_ROAD_EIGHTTH', '审批部门意见'], ['HO_PERMIT_BREAK_ROAD_NINETH', '作业实施'], ['HO_PERMIT_BREAK_ROAD_TENTH', '完工验收'] ] }, { key: 'blind_plate', label: '盲板抽堵', module: 'blindPlateFlowNodes', nodesConst: 'BLIND_PLATE_FLOW_NODES', matchFn: 'matchBlindPlateFlowNode', measuresConst: 'DEFAULT_BLIND_PLATE_MEASURES', codePrefix: 'HO_PERMIT_BLIND_PLATE_', flowCode: 'HO_PERMIT_BLIND_PLATE', templateKey: 'blind_plate', count: 7, expected: [ ['HO_PERMIT_BLIND_PLATE_TWOTH', '风险辨识'], ['HO_PERMIT_BLIND_PLATE_THREETH', '现场安全措施确认'], ['HO_PERMIT_BLIND_PLATE_FOURTH', '安全交底'], ['HO_PERMIT_BLIND_PLATE_FIVETH', '作业负责人意见'], ['HO_PERMIT_BLIND_PLATE_SIXTH', '所在单位意见'], ['HO_PERMIT_BLIND_PLATE_SEVENTH', '作业实施'], ['HO_PERMIT_BLIND_PLATE_EIGHTTH', '完工验收'] ] } ]; // 载入八份节点定义文件,组装每个作业类型的上下文 TYPES.forEach((type) => { type.mod = require(path.join(OUT, type.module + '.js')); type.nodes = type.mod[type.nodesConst]; type.measures = type.mod[type.measuresConst]; type.match = type.mod[type.matchFn]; type.nodeOf = (k) => type.nodes.find((n) => n.key === k); type.colsOf = (k, fk) => { const node = type.nodeOf(k); const field = node.fields.find((f) => f.key === fk); return (field.columns || []).map((c) => c.label); }; type.colOf = (k, fk, ck) => { const node = type.nodeOf(k); const field = node.fields.find((f) => f.key === fk); return (field.columns || []).find((c) => c.key === ck); }; }); // --------------------------------------------------------------------------- // 三、各作业类型的断言清单(按类型数据驱动) // --------------------------------------------------------------------------- /** * 生成「意见 + 签名」类环节的断言(只校验字段数量与类型) * @param {Array} list [节点 key, 环节标题] 的列表 */ function opinionChecks(list) { return list.map((item) => ({ name: item[1] + ':意见 + 签名', test: (t) => { const node = t.nodeOf(item[0]); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[1].type === 'sign' ); }, extra: (t) => ({ labels: t.nodeOf(item[0]).fields.map((f) => f.label) }) })); } /** * 生成「意见 + 签名」类环节的断言(同时校验字段标题) * @param {Array} list [节点 key, 环节标题, 签字标题] 的列表 */ function titledOpinionChecks(list) { return list.map((item) => ({ name: item[1] + ':意见 + 签名', test: (t) => { const node = t.nodeOf(item[0]); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[0].label === item[1] && node.fields[1].type === 'sign' && node.fields[1].label === item[2] ); }, extra: (t) => ({ labels: t.nodeOf(item[0]).fields.map((f) => f.label) }) })); } /** * 「现场安全措施确认」表名 + 3 列的断言(八个作业类型里除动火外口径一致) */ const measureTableCheck = { name: '现场安全措施确认:表名「安全措施确认表」,3 列(安全措施/是否确认/确认人)', test: (t) => t.nodeOf('measure').fields[0].label === '安全措施确认表' && JSON.stringify(t.colsOf('measure', 'measure_list')) === JSON.stringify(['安全措施', '是否确认', '确认人']) }; /** * 「现场安全措施确认」编制人签字 + 确认人签字的断言 */ const measureSignCheck = { name: '现场安全措施确认:编制人签字 + 确认人签字', test: (t) => JSON.stringify(t.nodeOf('measure').fields.map((f) => f.label)) === JSON.stringify(['安全措施确认表', '编制人签字', '确认人签字']) }; /** * 「默认行挂在 measure_list 字段上(defaultRows)」的断言 * @param {number} count 默认清单条数 */ function defaultRowsCheck(count) { return { name: '默认行挂在 measure_list 字段上(defaultRows)', test: (t) => Array.isArray(t.nodeOf('measure').fields[0].defaultRows) && t.nodeOf('measure').fields[0].defaultRows.length === count && t.nodeOf('measure').fields[0].defaultRows[0].measure_content === t.measures[0] }; } /** * 「事中记录表 3 列」的断言 * @param {string[]} labels 列标题 */ function duringRecordCheck(labels) { return { name: '事中记录表 3 列(' + labels.join('/') + ')', test: (t) => JSON.stringify(t.colsOf('execute', 'during_record_list')) === JSON.stringify(labels) }; } // 与源系统 formJson 一致性的断言(逐类型) const FORM_CHECKS = { fire: [ { name: '风险辨识:结果为多选 20 项与源系统完全一致', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '安全交底:三个签字(安全交底人签字 / 接受交底人签字 / 监护人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字', '监护人签字']) }, { name: '安全措施默认清单 15 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 15 && t.measures[0] === '动火设备内部构件清洗干净,蒸汽吹扫或水洗、置换合格,达到动火条件' && t.measures[14] === '其他相关特殊作业已办理相应安全作业票,作业现场四周已设立警戒区' } ], confined_space: [ { name: '气体分析:表名「气体分析记录表」', test: (t) => t.nodeOf('gas_analysis').fields[0].label === '气体分析记录表' }, { name: '气体分析:6 列且顺序与源系统一致(分析项目/气体名称/合格标准/取样分析时间/分析部位/分析人)', test: (t) => JSON.stringify(t.colsOf('gas_analysis', 'gas_analysis_list')) === JSON.stringify([ '分析项目', '气体名称', '合格标准', '取样分析时间', '分析部位', '分析人' ]), extra: (t) => t.colsOf('gas_analysis', 'gas_analysis_list') }, { name: '气体分析:分析项目 3 个选项(有毒有害气体/可燃气体/氧气含量)', test: (t) => JSON.stringify( t.nodeOf('gas_analysis').fields[0].columns[0].options.map((o) => o.label) ) === JSON.stringify(['有毒有害气体', '可燃气体', '氧气含量']) }, measureTableCheck, measureSignCheck, { name: '安全交底:只有 2 个签字(源系统无监护人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) }, { name: '所在单位意见:意见 + 签字', test: (t) => { const node = t.nodeOf('unit_opinion'); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[1].type === 'sign' ); } }, { name: '作业实施:实施时间 + 结束时间 + 事中记录表 + 气体分析记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify([ '作业实施时间', '作业结束时间', '事中记录表', '气体分析记录表' ]) }, { name: '作业实施:气体分析记录表 6 列与源系统一致(时间标签为「气体取样分析时间」)', test: (t) => JSON.stringify(t.colsOf('execute', 'gas_record_list')) === JSON.stringify([ '分析项目', '气体名称', '合格标准', '气体取样分析时间', '分析部位', '分析人' ]), extra: (t) => t.colsOf('execute', 'gas_record_list') }, duringRecordCheck(['时间', '记录内容', '记录人']), { name: '完工验收:意见 + 签字', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '风险辨识:多选 20 项与源系统完全一致', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段别名不与多选框标签冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('风险辨识结果') }, { name: '安全措施默认清单 14 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 14 && t.measures[0] === '盛装过有毒、可燃物料的受限空间,所有与受限空间有联系的阀门、管线已加盲板隔离,并落实盲板责任人,未采用水封或关闭阀门代替盲板' && t.measures[3] === '转动设备已切断电源,电源开关处已加锁并悬挂“禁止合闸”标志牌' && t.measures[13] === '其他相关特殊作业已办理相应安全作业票' }, defaultRowsCheck(14) ], height: [ { name: '风险辨识:结果为单选(源系统 radio,受限空间为多选)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'radio' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段为单行文本(源系统 input)', test: (t) => t.nodeOf('risk').fields[1].type === 'text' }, { name: '风险辨识:说明字段别名不与单选框 key 冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('risk_result') && !t.nodeOf('risk').fields[1].alias.includes('riskResult') }, measureTableCheck, measureSignCheck, { name: '安全交底:源系统未配置表单项,按同族流程惯例补齐「安全交底人签字 + 接受交底人签字」', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) && t.nodeOf('brief').fields.every((f) => f.type === 'sign') }, ...opinionChecks([ ['leader_opinion', '作业负责人意见'], ['unit_opinion', '所在单位意见'], ['audit_opinion', '审核部门意见'], ['approver_opinion', '审批部门意见'] ]), { name: '作业实施:实施开始时间 + 实施结束时间 + 事中记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表']) }, duringRecordCheck(['日期时间', '记录内容', '记录人']), { name: '完工验收:意见 + 签名', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 14 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 14 && t.measures[0] === '作业人员身体条件符合要求' && t.measures[2] === '作业人员佩戴符合标准要求的安全帽、安全带,有可能散发有毒气体的场所携带正压式空气呼吸器或面罩备用' && t.measures[10] === '30 m 以上高处作业时,作业人员已配备通信、联络工具' && t.measures[13] === '其他相关特殊作业已办理相应安全作业票' }, defaultRowsCheck(14) ], hoisting: [ { name: '风险辨识:结果为单选(源系统 radio,受限空间为多选)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'radio' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段为单行文本(源系统 input)', test: (t) => t.nodeOf('risk').fields[1].type === 'text' }, { name: '风险辨识:说明字段别名不与单选框 key 冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('risk_result') && !t.nodeOf('risk').fields[1].alias.includes('riskResult') }, measureTableCheck, measureSignCheck, { name: '安全交底:安全交底人签字 + 接受交底人签字', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) && t.nodeOf('brief').fields.every((f) => f.type === 'sign') }, ...opinionChecks([ ['leader_opinion', '作业负责人意见'], ['unit_opinion', '所在单位意见'], ['audit_opinion', '审核部门意见'], ['approver_opinion', '审批部门意见'] ]), { name: '作业实施:实施开始时间 + 实施结束时间 + 事中记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表']) }, duringRecordCheck(['日期时间', '记录内容', '记录人']), { name: '完工验收:意见 + 签名', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 20 条与源系统 formData 逐条一致', test: (t) => JSON.stringify(t.measures) === JSON.stringify(HOISTING_SOURCE_MEASURES), extra: (t) => ({ len: t.measures.length, first: t.measures[0], last: t.measures[t.measures.length - 1] }) }, defaultRowsCheck(20) ], electricity: [ { name: '气体分析:表名「气体分析记录表」', test: (t) => t.nodeOf('gas_analysis').fields[0].label === '气体分析记录表' }, { name: '气体分析:4 列且顺序与源系统一致(分析时间/分析点/分析结果/分析人)', test: (t) => JSON.stringify(t.colsOf('gas_analysis', 'gas_analysis_list')) === JSON.stringify(['分析时间', '分析点', '分析结果', '分析人']), extra: (t) => t.colsOf('gas_analysis', 'gas_analysis_list') }, { name: '气体分析:分析时间为日期类型、其余为多行文本(与源系统一致)', test: (t) => { const c = t.nodeOf('gas_analysis').fields[0].columns; return ( c[0].type === 'date' && c[1].type === 'textarea' && c[2].type === 'textarea' && c[3].type === 'textarea' ); } }, { name: '危害辨识:多选 + 单行文本说明(源系统该节点表单复制自风险辨识)', test: (t) => { const f = t.nodeOf('hazard').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[1].type === 'text' ); } }, { name: '风险辨识:多选 20 项 + 单行文本说明(源系统 checkbox + input)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' && f[1].type === 'text' ); } }, { name: '风险辨识:说明字段别名不与多选框标签冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('风险辨识结果') }, measureTableCheck, measureSignCheck, { name: '是否确认选项为 不涉及 / 已确认', test: (t) => JSON.stringify( t.nodeOf('measure').fields[0].columns[1].options.map((o) => o.label) ) === JSON.stringify(['不涉及', '已确认']) }, { name: '安全交底:2 个签字(安全交底人签字 / 接受交底人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) }, { name: '作业负责人意见:意见 + 签字', test: (t) => { const node = t.nodeOf('leader_opinion'); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[1].type === 'sign' ); } }, { name: '用电单位意见:意见 + 签字(不是受限空间的「所在单位意见」)', test: (t) => { const node = t.nodeOf('electric_unit_opinion'); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[1].type === 'sign' && !t.nodes.some((n) => n.name === '所在单位意见') ); } }, { name: '作业实施:开始时间 + 结束时间 + 事中记录表 + 可燃气体分析记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify([ '作业实施开始时间', '作业实施结束时间', '事中记录表', '可燃气体分析记录表' ]) }, duringRecordCheck(['时间', '记录内容', '记录人']), { name: '可燃气体分析记录表 4 列(分析时间/分析点/分析结果/分析人)', test: (t) => JSON.stringify(t.colsOf('execute', 'gas_record_list')) === JSON.stringify(['分析时间', '分析点', '分析结果', '分析人']) }, { name: '完工验收:意见 + 签字', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 13 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 13 && t.measures[0] === '作业人员持有电工作业操作证' && t.measures[2] === '上级开关已断电、加锁,并挂安全警示标牌' && t.measures[9] === '临时用电设施已装配漏电保护器,移动工具、手持工具已采取防漏电的安全措施(一机一闸一保护)' && t.measures[12] === '作业场所已进行气体检测且符合作业安全要求' }, defaultRowsCheck(13), // 风险辨识 / 危害辨识 取值 key 不冲突 { name: '两个环节的字段 key 无交集', test: (t) => { const riskKeys = t.nodeOf('risk').fields.map((f) => f.key); const hazardKeys = t.nodeOf('hazard').fields.map((f) => f.key); return riskKeys.every((k) => !hazardKeys.includes(k)); }, extra: (t) => ({ riskKeys: t.nodeOf('risk').fields.map((f) => f.key), hazardKeys: t.nodeOf('hazard').fields.map((f) => f.key) }) }, { name: '危害辨识使用 hazard_ 前缀', test: (t) => t.nodeOf('hazard') .fields.map((f) => f.key) .every((k) => k.startsWith('hazard_')), extra: (t) => t.nodeOf('hazard').fields.map((f) => f.key) }, { name: '危害辨识的别名不会命中风险辨识的取值 key', test: (t) => { const hazardAlias = t .nodeOf('hazard') .fields.flatMap((f) => f.alias || []); return ( !hazardAlias.includes('risk_result') && !hazardAlias.includes('risk_result_desc') ); } } ], break_ground: [ { name: '风险辨识:结果为多选 20 项(源系统 checkbox)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段为单行文本(源系统 input)', test: (t) => t.nodeOf('risk').fields[1].type === 'text' }, { name: '风险辨识:说明字段别名不与多选框 key 冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('risk_result') && !t.nodeOf('risk').fields[1].alias.includes('riskResult') }, measureTableCheck, measureSignCheck, { name: '现场安全措施确认:确认列为单选(不涉及 / 已确认)', test: (t) => t .colOf('measure', 'measure_list', 'measure_confirm') .options.map((o) => o.label) .join(',') === '不涉及,已确认' }, { name: '安全交底:两个签字(安全交底人签字 + 接受交底人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) && t.nodeOf('brief').fields.every((f) => f.type === 'sign') }, ...opinionChecks([ ['leader_opinion', '作业负责人意见'], ['unit_opinion', '所在单位意见'], ['countersign_opinion', '有关部门会签意见'], ['approver_opinion', '审批部门意见'] ]), { name: '意见类环节的取值 key 互不相同(源系统四处复制同一 model,不能互相覆盖)', test: (t) => new Set( t.nodes .filter((n) => n.fields[0].type === 'textarea') .map((n) => n.fields[0].key) ).size === 5 }, { name: '作业实施:实施开始时间 + 实施结束时间 + 事中记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表']) }, duringRecordCheck(['时间', '记录内容', '记录人']), { name: '完工验收:意见 + 签名', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 10 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 10 && t.measures[0] === '地下电力电缆、通信电(光)缆、局域网络电(光)缆已确认,保护措施已落实' && t.measures[4] === '已进行放坡处理和固壁支撑' && t.measures[6] === '现场夜间有充足照明:A.36 V.24V.12 V 防水型灯:B.36V、24 V.12 V 防爆型灯' && t.measures[8] === '易燃易爆、有毒气体存在的场所动土深度超过1.2m,已按照受限空间作业要求采取了措施' && t.measures[9] === '其他相关特殊作业已办理相应安全作业票' }, defaultRowsCheck(10) ], break_road: [ { name: '风险辨识:结果为多选 20 项(源系统 checkbox)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段为多行文本(源系统 textarea)', test: (t) => t.nodeOf('risk').fields[1].type === 'textarea' }, { name: '风险辨识:说明字段别名不与多选框 key 冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('risk_result') && !t.nodeOf('risk').fields[1].alias.includes('riskResult') }, measureTableCheck, measureSignCheck, { name: '现场安全措施确认:确认列为单选(不涉及 / 已确认)', test: (t) => t .colOf('measure', 'measure_list', 'measure_confirm') .options.map((o) => o.label) .join(',') === '不涉及,已确认' }, { name: '现场安全措施确认:确认人列为单行文本(源系统 input)', test: (t) => t.colOf('measure', 'measure_list', 'measure_confirmer').type === 'text' }, { name: '安全交底:两个签字(安全交底人签字 + 接受交底人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) && t.nodeOf('brief').fields.every((f) => f.type === 'sign') }, ...opinionChecks([ ['leader_opinion', '作业负责人意见'], ['unit_opinion', '所在单位意见'] ]), { name: '消防、安全部门意见:两个意见框 + 两个签字(消防部门 / 安全部门)', test: (t) => { const node = t.nodeOf('fire_safety_opinion'); return ( JSON.stringify(node.fields.map((f) => f.label)) === JSON.stringify([ '消防部门意见', '安全部门意见', '消防部门签字', '安全部门签字' ]) && node.fields[0].type === 'textarea' && node.fields[1].type === 'textarea' && node.fields[2].type === 'sign' && node.fields[3].type === 'sign' ); } }, { name: '签字项标题与源系统一致(不再统一写「手写签名」)', test: (t) => JSON.stringify( t.nodes.flatMap((n) => n.fields.filter((f) => f.type === 'sign').map((f) => f.label) ) ) === JSON.stringify([ '编制人签字', '确认人签字', '安全交底人签字', '接受交底人签字', '作业负责人签字', '所在单位签字', '消防部门签字', '安全部门签字', '审批部门签字', '签字' ]) }, { name: '同一环节内不存在重名的签字项(不会出现两个「手写签名」)', test: (t) => t.nodes.every((n) => { const labels = n.fields .filter((f) => f.type === 'sign') .map((f) => f.label); return new Set(labels).size === labels.length; }) }, { name: '「消防、安全部门意见」的两个签字标题互不相同', test: (t) => t.nodeOf('fire_safety_opinion').fields[2].label !== t.nodeOf('fire_safety_opinion').fields[3].label }, { name: '意见类环节的取值 key 互不相同(源系统多处复制同一 model,不能互相覆盖)', test: (t) => new Set( t.nodes .filter((n) => n.fields[0].type === 'textarea') .map((n) => n.fields[0].key) ).size === 5 }, { name: '审批部门意见:意见 + 签名', test: (t) => { const node = t.nodeOf('approver_opinion'); return ( node.fields.length === 2 && node.fields[0].type === 'textarea' && node.fields[1].type === 'sign' ); } }, { name: '作业实施:实施开始时间 + 实施结束时间 + 事中记录表', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表']) }, duringRecordCheck(['时间', '记录内容', '记录人']), { name: '完工验收:意见 + 签名', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 3 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 3 && t.measures[0] === '作业前,指定交通组织方案,并已通知相关部门或单位' && t.measures[1] === '作业前,在断路的路口和相关道路上设置交通警示标志,在作业区域附近设置路栏、道路作业警示灯、寻向标灯交通警示设施' && t.measures[2] === '夜间作业设置警示灯' }, defaultRowsCheck(3) ], blind_plate: [ { name: '风险辨识:结果为多选 20 项(源系统 checkbox)', test: (t) => { const f = t.nodeOf('risk').fields; return ( f[0].type === 'checkbox' && f[0].options.length === 20 && f[0].options[0].label === '物体打击' && f[0].options[19].label === '其它伤害' ); } }, { name: '风险辨识:说明字段为单行文本(源系统 input)', test: (t) => t.nodeOf('risk').fields[1].type === 'text' }, { name: '风险辨识:说明字段别名不与多选框 key 冲突', test: (t) => !t.nodeOf('risk').fields[1].alias.includes('risk_result') && !t.nodeOf('risk').fields[1].alias.includes('riskResult') }, measureTableCheck, measureSignCheck, { name: '现场安全措施确认:确认列为单选(不涉及 / 已确认)', test: (t) => t .colOf('measure', 'measure_list', 'measure_confirm') .options.map((o) => o.label) .join(',') === '不涉及,已确认' }, { name: '现场安全措施确认:安全措施列为多行文本(源系统 textarea)', test: (t) => t.colOf('measure', 'measure_list', 'measure_content').type === 'textarea' }, { name: '现场安全措施确认:确认人列为单行文本(源系统 input)', test: (t) => t.colOf('measure', 'measure_list', 'measure_confirmer').type === 'text' }, { name: '安全交底:两个签字(安全交底人签字 + 接受交底人签字)', test: (t) => JSON.stringify(t.nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) && t.nodeOf('brief').fields.every((f) => f.type === 'sign') }, ...titledOpinionChecks([ ['leader_opinion', '作业负责人意见', '作业负责人签字'], ['unit_opinion', '所在单位意见', '所在单位签字'] ]), { name: '签字项标题能分辨出是谁签的(同一环节内不重名)', test: (t) => t.nodes.every((n) => { const labels = n.fields .filter((f) => f.type === 'sign') .map((f) => f.label); return new Set(labels).size === labels.length; }) }, { name: '意见类环节的取值 key 互不相同(源系统多处复制同一 model,不能互相覆盖)', test: (t) => new Set( t.nodes .filter((n) => n.fields[0].type === 'textarea') .map((n) => n.fields[0].key) ).size === 3 }, { name: '作业实施:实施开始时间 + 事中记录表(源系统只有开始时间,没有结束时间)', test: (t) => JSON.stringify(t.nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify(['作业实施开始时间', '事中记录表']) }, duringRecordCheck(['时间', '记录内容', '记录人']), { name: '事中记录表「时间」列为日期时间(源系统 datePicker)', test: (t) => t .nodeOf('execute') .fields.find((f) => f.key === 'during_record_list') .columns.some((c) => c.key === 'record_time' && c.type === 'date') }, { name: '完工验收:意见 + 签名', test: (t) => { const node = t.nodeOf('acceptance'); return ( node.fields.length === 2 && node.fields[0].label === '意见' && node.fields[1].type === 'sign' ); } }, { name: '安全措施默认清单 10 条与源系统 formData 逐条一致', test: (t) => t.measures.length === 10 && t.measures[0] === '在管道、设备上作业时,降低系统压力,作业点应为常压或微正压' && t.measures[1] === '在有毒介质的管道、设备上作业时,尽可能降低系统压力,作业点应为常压' && t.measures[2] === '在有毒介质的管道、设备上作业时,作业人员穿戴适合的防护用具' && t.measures[3] === '易燃易爆场所,作业人员穿防静电工作服、工作鞋;作业时使用防爆灯具和防爆工具' && t.measures[4] === '易燃易爆场所,距作业地点30m内无其他动火作业' && t.measures[5] === '在强腐蚀性介质的管道、设备上作业时,作业人员已采取防止酸碱灼伤的措施' && t.measures[6] === '介质温度较高、可能造成烫伤的情况下,作业人员已采取防烫措施' && t.measures[7] === '同一管道上不同时进行两处以上的盲板抽堵作业' && t.measures[8] === '其它相关特殊作业已办理相应安全作业票' && t.measures[9] === '作业现场四周已设警戒区' }, defaultRowsCheck(10) ] }; // 环节匹配的断言(逐类型) const MATCH_CHECKS = { fire: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_FIRE_THREETH', key: 'gas_analysis' }, { name: '按节点名称命中', code: '现场安全措施落实', key: 'measure' }, { name: '按别名命中', code: '气体检测', key: 'gas_analysis' }, { name: '按平台自定义 key 反向包含命中', code: 'fire_gas_analysis', key: 'gas_analysis' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_FIRE', 'HO_PERMIT_FIRE_ONETH'] }, { name: '空标识返回 null', empty: true } ], confined_space: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_CONFINED_SPACE_THREETH', key: 'gas_analysis' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '气体检测', key: 'gas_analysis' }, { name: '按平台自定义 key 反向包含命中', code: 'cs_gas_analysis', key: 'gas_analysis' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_CONFINED_SPACE', 'HO_PERMIT_CONFINED_SPACE_ONETH'] }, { name: '空标识返回 null', empty: true } ], height: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_HEIGHT_THREETH', key: 'measure' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '危险辨识', key: 'risk' }, { name: '按平台自定义 key 反向包含命中', code: 'height_risk', key: 'risk' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_HEIGHT', 'HO_PERMIT_HEIGHT_ONETH'] }, { name: '空标识返回 null', empty: true } ], hoisting: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_HOISTING_THREETH', key: 'measure' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '危险辨识', key: 'risk' }, { name: '按平台自定义 key 反向包含命中', code: 'hoisting_risk', key: 'risk' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_HOISTING', 'HO_PERMIT_HOISTING_ONETH'] }, { name: '空标识返回 null', empty: true } ], electricity: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_ELECTRICITY_THREETH', key: 'gas_analysis' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '气体检测', key: 'gas_analysis' }, { name: '按平台自定义 key 反向包含命中', code: 'el_gas_analysis', key: 'gas_analysis' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_ELECTRICITY', 'HO_PERMIT_ELECTRICITY_ONETH'] }, { name: '空标识返回 null', empty: true }, { name: '「危害辨识」不会被「风险辨识」抢走', code: '危害辨识', key: 'hazard' }, { name: '「危险辨识」归到风险辨识节点', code: '危险辨识', key: 'risk' } ], break_ground: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_BREAK_GROUND_THREETH', key: 'measure' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '危险辨识', key: 'risk' }, { name: '按平台自定义 key 反向包含命中', code: 'break_ground_risk', key: 'risk' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_BREAK_GROUND', 'HO_PERMIT_BREAK_GROUND_ONETH'] }, { name: '空标识返回 null', empty: true } ], break_road: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_BREAK_ROAD_THREETH', key: 'measure' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '危险辨识', key: 'risk' }, { name: '按平台自定义 key 反向包含命中', code: 'break_road_risk', key: 'risk' }, { name: '第七级按 code 命中消防、安全部门意见', code: 'HO_PERMIT_BREAK_ROAD_SEVENTH', key: 'fire_safety_opinion' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_BREAK_ROAD', 'HO_PERMIT_BREAK_ROAD_ONETH'] }, { name: '空标识返回 null', empty: true } ], blind_plate: [ { name: '按任务定义 key 精确命中', code: 'HO_PERMIT_BLIND_PLATE_THREETH', key: 'measure' }, { name: '按节点名称命中', code: '现场安全措施确认', key: 'measure' }, { name: '按别名命中', code: '危险辨识', key: 'risk' }, { name: '按平台自定义 key 反向包含命中', code: 'blind_plate_risk', key: 'risk' }, { name: '第五级按 code 命中作业负责人意见', code: 'HO_PERMIT_BLIND_PLATE_FIVETH', key: 'leader_opinion' }, { name: '流程定义 key / 一级作业申请 key 不误命中后续环节', notMatch: ['HO_PERMIT_BLIND_PLATE', 'HO_PERMIT_BLIND_PLATE_ONETH'] }, { name: '空标识返回 null', empty: true } ] }; // 运行时辅助(回显 / payload / 流程变量)的断言(逐类型) const RUNTIME_CHECKS = { // 八类作业通用的 5 条 common: [ { name: '回显:按自身 key 取到已保存内容', test: (t) => { const loaded = runtime.loadNodeValues(t.nodeOf('measure'), [ RUNTIME_SOURCE ]); return ( loaded.measure_list.length === 1 && loaded.measure_preparer_sign === '/tmp/a.png' ); } }, { name: '回显:未保存的字段为 undefined', test: (t) => runtime.loadNodeValues(t.nodeOf('measure'), [RUNTIME_SOURCE]) .measure_confirmer_sign === undefined }, { name: 'payload:保留原始类型(数组不转字符串)', test: (t) => { const payload = runtime.buildNodePayload(t.nodeOf('measure'), { measure_list: [{ measure_content: 'x' }], measure_preparer_sign: '/tmp/a.png' }); return Array.isArray(payload.measure_list) && payload.measure_list.length === 1; } }, { name: 'payload:空值不写入', test: (t) => { const payload = runtime.buildNodePayload(t.nodeOf('measure'), { measure_list: [{ measure_content: 'x' }], measure_preparer_sign: '/tmp/a.png' }); return ( !('measure_confirmer_sign' in payload) && !('measure_confirm' in payload) ); } }, { name: '流程变量:子表转 JSON 字符串、其余转字符串', test: (t) => { const vars = runtime.buildNodeVariables(t.nodeOf('measure'), { measure_list: [{ measure_content: 'x' }], measure_preparer_sign: '/tmp/a.png' }); return ( typeof vars.measure_list === 'string' && vars.measure_preparer_sign === '/tmp/a.png' ); } } ], fire: [ { name: '流程变量:多选值用逗号连接', test: (t) => { const vars = runtime.buildNodeVariables(t.nodeOf('risk'), { risk_result: ['5', '8'], risk_result_desc: '触电、火灾' }); return ( vars.risk_result === '5,8' && vars.risk_result_desc === '触电、火灾' ); } } ], confined_space: [], height: [ { name: '回显:单选框的值不会串到风险辨识说明', test: (t) => runtime.loadNodeValues(t.nodeOf('risk'), [{ risk_result: '9' }]) .risk_result_desc === undefined }, { name: '安全交底:两个签字写入 payload 与流程变量', test: (t) => { const briefPayload = runtime.buildNodePayload(t.nodeOf('brief'), { brief_sign: '/tmp/briefer.png', brief_receiver_sign: '/tmp/receiver.png' }); return ( briefPayload.brief_sign === '/tmp/briefer.png' && briefPayload.brief_receiver_sign === '/tmp/receiver.png' && runtime.buildNodeVariables(t.nodeOf('brief'), { brief_sign: '/tmp/briefer.png' }).brief_sign === '/tmp/briefer.png' ); } } ], hoisting: [ { name: '回显:单选框的值不会串到风险辨识说明', test: (t) => runtime.loadNodeValues(t.nodeOf('risk'), [{ risk_result: '9' }]) .risk_result_desc === undefined }, { name: '安全交底:两个签字写入 payload 与流程变量', test: (t) => { const briefPayload = runtime.buildNodePayload(t.nodeOf('brief'), { brief_sign: '/tmp/briefer.png', brief_receiver_sign: '/tmp/receiver.png' }); return ( briefPayload.brief_sign === '/tmp/briefer.png' && briefPayload.brief_receiver_sign === '/tmp/receiver.png' && runtime.buildNodeVariables(t.nodeOf('brief'), { brief_sign: '/tmp/briefer.png' }).brief_sign === '/tmp/briefer.png' ); } } ], electricity: [ { name: '流程变量:多选值用逗号连接', test: (t) => { const riskVars = runtime.buildNodeVariables(t.nodeOf('risk'), { risk_result: ['5', '8'], risk_result_desc: '触电、火灾' }); return ( riskVars.risk_result === '5,8' && riskVars.risk_result_desc === '触电、火灾' ); } }, { name: '流程变量:风险辨识与危害辨识各自独立', test: (t) => { const riskVars = runtime.buildNodeVariables(t.nodeOf('risk'), { risk_result: ['5', '8'], risk_result_desc: '触电、火灾' }); const hazardVars = runtime.buildNodeVariables(t.nodeOf('hazard'), { hazard_result: ['5'], hazard_result_desc: '触电' }); return ( riskVars.risk_result === '5,8' && hazardVars.hazard_result === '5' ); } } ], break_ground: [ { name: '回显:多选框的值不会串到风险辨识说明', test: (t) => runtime.loadNodeValues(t.nodeOf('risk'), [{ risk_result: '9' }]) .risk_result_desc === undefined }, { name: '作业负责人意见:意见 + 签名写入 payload 与流程变量', test: (t) => { const opinionPayload = runtime.buildNodePayload( t.nodeOf('leader_opinion'), { leader_opinion: '同意', leader_sign: '/tmp/leader.png' } ); return ( opinionPayload.leader_opinion === '同意' && opinionPayload.leader_sign === '/tmp/leader.png' && runtime.buildNodeVariables(t.nodeOf('leader_opinion'), { leader_opinion: '同意' }).leader_opinion === '同意' ); } }, { name: '四个意见类环节的 payload 互不覆盖', test: (t) => runtime.buildNodePayload(t.nodeOf('unit_opinion'), { unit_opinion: '单位意见' }).unit_opinion === '单位意见' && runtime.buildNodePayload(t.nodeOf('approver_opinion'), { approver_opinion: '审批意见' }).approver_opinion === '审批意见' } ], break_road: [ { name: '回显:多选框的值不会串到风险辨识说明', test: (t) => runtime.loadNodeValues(t.nodeOf('risk'), [{ risk_result: '9' }]) .risk_result_desc === undefined }, { name: '作业负责人意见:意见 + 签名写入 payload 与流程变量', test: (t) => { const opinionPayload = runtime.buildNodePayload( t.nodeOf('leader_opinion'), { leader_opinion: '同意', leader_sign: '/tmp/leader.png' } ); return ( opinionPayload.leader_opinion === '同意' && opinionPayload.leader_sign === '/tmp/leader.png' && runtime.buildNodeVariables(t.nodeOf('leader_opinion'), { leader_opinion: '同意' }).leader_opinion === '同意' ); } }, { name: '消防、安全部门意见:两处意见互不覆盖', test: (t) => { const payload = runtime.buildNodePayload( t.nodeOf('fire_safety_opinion'), { fire_dept_opinion: '消防意见', safety_dept_opinion: '安全意见' } ); return ( payload.fire_dept_opinion === '消防意见' && payload.safety_dept_opinion === '安全意见' ); } }, { name: '所在单位意见 / 审批部门意见的 payload 互不覆盖', test: (t) => runtime.buildNodePayload(t.nodeOf('unit_opinion'), { unit_opinion: '单位意见' }).unit_opinion === '单位意见' && runtime.buildNodePayload(t.nodeOf('approver_opinion'), { approver_opinion: '审批意见' }).approver_opinion === '审批意见' } ], blind_plate: [ { name: '回显:多选框的值不会串到风险辨识说明', test: (t) => runtime.loadNodeValues(t.nodeOf('risk'), [{ risk_result: '9' }]) .risk_result_desc === undefined }, { name: '作业负责人意见:意见 + 签名写入 payload 与流程变量', test: (t) => { const opinionPayload = runtime.buildNodePayload( t.nodeOf('leader_opinion'), { leader_opinion: '同意', leader_sign: '/tmp/leader.png' } ); return ( opinionPayload.leader_opinion === '同意' && opinionPayload.leader_sign === '/tmp/leader.png' && runtime.buildNodeVariables(t.nodeOf('leader_opinion'), { leader_opinion: '同意' }).leader_opinion === '同意' ); } }, { name: '所在单位意见 / 完工验收的 payload 互不覆盖', test: (t) => runtime.buildNodePayload(t.nodeOf('unit_opinion'), { unit_opinion: '单位意见' }).unit_opinion === '单位意见' && runtime.buildNodePayload(t.nodeOf('acceptance'), { acceptance_opinion: '验收意见' }).acceptance_opinion === '验收意见' } ] }; // 源系统「现场安全措施确认」节点 formData 的默认 20 条(吊装作业),逐条比对 const HOISTING_SOURCE_MEASURES = [ '一、二级吊装作业已编制吊装作业方案,已经审查批准;吊装物体形状复杂、刚度小,长径比大、精密贵重,作业条件特殊的三级吊装作业,已编制吊装作业方案,已经审查批准', '吊装场所如有含危险物料的设备、管道时,应制定详细吊装方案,并对设备、管道采取有效防护措施,必要时停车,放空物料,置换后再进行吊装作业', '作业人员已按规定佩戴个体防护装备', '已对起重吊装设备、钢丝绳、揽风绳、链条、吊钩等各种机具进行检查,安全可靠', '已明确各自分工', '坚守岗位,并统一规定联络信号', '将建筑物、构筑物作为锚点,应经所属单位工程管理部门审查校算并批准', '吊装绳索、揽风绳、拖拉绳等不应与带电线路接触,并保持安全距离', '不应利用管道、管架、电杆、机电设备等作吊装锚点', '吊物捆扎坚固,未见绳打结、绳不齐现象,棱角吊物已采取衬垫措施', '起重机安全装置灵活好用', '吊装作业人员持有有效的法定资格证书', '地下通信电(光)缆、局域网络电(光)缆,排水沟的益板,承重吊装机械的负重量已确认,保护措施已落实', '起吊物的质量(t)经确认,在吊装机械的承重范围内', '在吊装高度的管线、电缆桥架已做好防护措施', '作业现场围栏、警戒线、警告牌、夜同警示灯已按要求设置', '作业高度和转臂范围内无架空线路', '在爆炸危险场所内的作业,机动车排气管已装阻火器', '露天作业,环境风力满足作业安全要求', '其他相关特殊作业已办理相应安全作业票' ]; // 运行时辅助断言里用的固定数据源 const RUNTIME_SOURCE = { measure_list: [ { measure_content: 'x', measure_confirm: '2', measure_confirmer: '张三' } ], measure_preparer_sign: '/tmp/a.png' }; // --------------------------------------------------------------------------- // 四、组件模板 / 脚本体检工具 // --------------------------------------------------------------------------- /** * 收集组件脚本里已定义的成员名(props / data / computed / methods) * @param {string} script script 源码 * @returns {Set} 成员名集合 */ function collectComponentMembers(script) { const names = new Set(); const ast = parser.parse(script, { sourceType: 'module', plugins: [ 'optionalChaining', 'objectRestSpread', 'nullishCoalescingOperator' ] }); let component = null; for (const node of ast.program.body) { if (node.type === 'ExportDefaultDeclaration') { component = node.declaration; } } const collectObjectKeys = (obj) => { ((obj && obj.properties) || []).forEach((prop) => { if (!prop.key) { return; } names.add(prop.key.name || prop.key.value); }); }; ((component && component.properties) || []).forEach((prop) => { if (!prop.key) { return; } const key = prop.key.name; const value = prop.value; if (key === 'props' || key === 'methods' || key === 'computed') { collectObjectKeys(value); return; } if (key === 'data') { // data() { return { ... } } const body = value && value.body; if (!body) { return; } const ret = body.body.find( (stmt) => stmt.type === 'ReturnStatement' && stmt.argument ); if (ret) { collectObjectKeys(ret.argument); } } }); return names; } /** * 取模板里被调用的成员名 * * 注意:Vue 2 编译后的 render 用 with(this) 解析标识符, * 方法调用编译成裸函数名(如 getRows(field)),不带 vm. 前缀, * 因此这里直接扫描模板表达式,而不是扫描编译产物。 * * @param {string} template 模板源码 * @returns {object} { calls, errors } */ function collectTemplateCalls(template) { const calls = new Set(); // 1) 插值 {{ expr }} // 2) 指令 / 事件绑定属性值: :xxx="expr"、v-xxx="expr"、v-xxx:arg="expr"、@xxx="expr" const attrRe = /(?:[:@][\w.-]+|v-[\w-]+(?::[\w.-]+)?)\s*=\s*"([^"]*)"/g; const interpRe = /\{\{([\s\S]*?)\}\}/g; const exprs = []; let m; while ((m = attrRe.exec(template)) !== null) { exprs.push(m[1]); } while ((m = interpRe.exec(template)) !== null) { exprs.push(m[1]); } // 全局函数与 Vue 内部函数,不属于组件成员 const globals = new Set([ 'String', 'Number', 'Boolean', 'Array', 'Object', 'JSON', 'Math', 'Date', 'parseInt', 'parseFloat', 'isNaN', 'require' ]); // 标识符后紧跟 "(" 即为函数调用;排除属性访问(xxx.method())与内部变量 const callRe = /(^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g; exprs.forEach((expr) => { let hit; callRe.lastIndex = 0; while ((hit = callRe.exec(expr)) !== null) { const name = hit[2]; if (name.startsWith('$') || name.startsWith('_') || globals.has(name)) { continue; } calls.add(name); } }); return { calls, errors: compiler.compile(template).errors || [] }; } // --------------------------------------------------------------------------- // 五、逐节校验 // --------------------------------------------------------------------------- console.log('\n[1] 公共模块(flowRuntime / flowMatcher / flowFields)'); ['flowRuntime.js', 'flowMatcher.js', 'flowFields.js'].forEach((file) => { check(file + ' 存在', fs.existsSync(path.join(SRC, file))); }); const RUNTIME_EXPORTS = [ 'getCurrentTaskInfo', 'loadProcessFormVariables', 'getFieldKeys', 'loadNodeValues', 'buildNodeVariables', 'buildNodePayload', 'mergeExtension', 'buildTicketUpdateParams', 'parseJsonObject' ]; RUNTIME_EXPORTS.forEach((fn) => { check('flowRuntime.js 导出 ' + fn, typeof runtime[fn] === 'function'); }); check('flowMatcher.js 导出 matchFlowNode', typeof matcher.matchFlowNode === 'function'); check('flowFields.js 导出 getFieldKeys', typeof fieldUtils.getFieldKeys === 'function'); check( 'flowRuntime.js 再导出的 getFieldKeys 与 flowFields.js 是同一实现', runtime.getFieldKeys === fieldUtils.getFieldKeys ); console.log('\n[2] 八个作业类型的节点定义文件'); TYPES.forEach((type) => { console.log(' -- ' + type.label + '作业(' + type.module + '.js)'); check( type.module + '.js 存在', fs.existsSync(path.join(SRC, type.module + '.js')) ); check( type.module + '.js 导出 ' + type.nodesConst, Array.isArray(type.mod[type.nodesConst]) && type.mod[type.nodesConst].length > 0 ); check( type.module + '.js 导出 ' + type.matchFn, typeof type.mod[type.matchFn] === 'function' ); check( type.module + '.js 再导出了 getFieldKeys', typeof type.mod.getFieldKeys === 'function' ); // 包装函数确实委托给通用匹配器:同一组候选标识下两者结果一致 const candidates = [] .concat(type.nodes.map((n) => n.code)) .concat(type.nodes.map((n) => n.name)) .concat(type.nodes.map((n) => n.key)) .concat([type.flowCode, type.flowCode + '_ONETH', '', null, undefined]); const delegated = candidates.every((c) => { const a = matcher.matchFlowNode(type.nodes, c); const b = type.match(c); return (a && a.key) === (b && b.key); }); check( type.module + '.js 的 ' + type.matchFn + ' 与 matchFlowNode 对同一组候选标识结果一致', delegated ); // 作业类型之间互不污染:节点定义文件只依赖公共模块,不引用其它作业类型的文件 const ownSource = fs.readFileSync(path.join(SRC, type.module + '.js'), 'utf8'); const otherTypes = TYPES.filter((t) => t.module !== type.module); const foreignImports = otherTypes.filter( (t) => ownSource.indexOf("'./" + t.module + "'") >= 0 ); check( type.module + '.js 不引用其它作业类型的节点定义文件', foreignImports.length === 0, foreignImports.map((t) => t.module) ); const keys = type.nodes.map((n) => n.key); const codes = type.nodes.map((n) => n.code); const names = type.nodes.map((n) => n.name); check(type.nodesConst + ' 的 key 唯一', new Set(keys).size === keys.length, keys); check(type.nodesConst + ' 的 code 唯一', new Set(codes).size === codes.length, codes); check(type.nodesConst + ' 的 name 唯一', new Set(names).size === names.length, names); check( type.nodesConst + ' 的 code 均以 ' + type.codePrefix + ' 开头', codes.every((c) => c.indexOf(type.codePrefix) === 0), codes.filter((c) => c.indexOf(type.codePrefix) !== 0) ); }); // 动火作业沿用自己的实现(fireFlowTask.js 已由公共的 flowRuntime.js 取代) [ 'fireFlowNodes.js', 'components/fireTicketDetail.vue', 'components/flowNodeForm.vue' ].forEach((file) => { check('动火作业的既有实现文件 ' + file + ' 仍然存在', fs.existsSync(path.join(SRC, file))); }); TYPES.forEach((type) => { console.log( '\n[3] ' + type.label + '作业环节(源系统 ' + type.flowCode + ' 共 ' + (type.count + 1) + ' 节点,1 级为作业申请模板)' ); check('环节数量为 ' + type.count, type.nodes.length === type.count, { len: type.nodes.length }); type.expected.forEach(([code, name], i) => { const node = type.nodes[i]; check( '第' + (i + 2) + '级 ' + code + ' / ' + name, node.code === code && node.name === name, { code: node.code, name: node.name } ); }); console.log('\n[4] ' + type.label + '作业:与源系统 formJson 一致性'); FORM_CHECKS[type.key].forEach((spec) => { check( spec.name, spec.test(type), spec.extra ? spec.extra(type) : undefined ); }); console.log('\n[5] ' + type.label + '作业:环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)'); MATCH_CHECKS[type.key].forEach((spec) => { if (spec.notMatch) { const hit = spec.notMatch.map((c) => type.match(c)); check( spec.name, hit.every((m) => m === null), hit.map((m) => (m && m.key) || null) ); return; } if (spec.empty) { check(spec.name, type.match('', null) === null); return; } const m = type.match(spec.code); check(spec.name, m && m.key === spec.key, { key: m && m.key }); }); console.log('\n[6] ' + type.label + '作业:运行时辅助(回显 / payload / 流程变量)'); RUNTIME_CHECKS.common.concat(RUNTIME_CHECKS[type.key] || []).forEach((spec) => { check(spec.name, spec.test(type)); }); }); console.log('\n[7] detail.vue:由 FLOW_TYPES 配置表驱动(七类作业各自一个环节组件)'); const detailSrc = fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8'); const templatesSrc = fs.readFileSync(path.join(SRC, 'templates.js'), 'utf8'); // 作业类型 -> 环节组件(xxxFlowNodes.js 对应 xxxFlow.vue) function componentFileOf(t) { return t.module.replace(/FlowNodes$/, 'Flow'); } function componentIdentOf(t) { const file = componentFileOf(t); return file.charAt(0).toUpperCase() + file.slice(1); } const GENERIC_TYPES = TYPES.filter((t) => t.templateKey); const flowTypesStart = detailSrc.indexOf('const FLOW_TYPES = ['); const flowTypesEnd = detailSrc.indexOf('];', flowTypesStart); const flowTypesBlock = flowTypesStart >= 0 ? detailSrc.slice(flowTypesStart, flowTypesEnd) : ''; check('detail.vue 源码包含 FLOW_TYPES 配置表', flowTypesStart >= 0 && flowTypesEnd > flowTypesStart); GENERIC_TYPES.forEach((t) => { check( 'FLOW_TYPES 登记了' + t.label + '作业(key = ' + t.templateKey + ')', new RegExp("key:\\s*'" + t.templateKey + "'").test(flowTypesBlock) ); }); check( 'FLOW_TYPES 的每一项都绑定了自己的节点表、匹配函数与环节组件', GENERIC_TYPES.every( (t) => flowTypesBlock.indexOf(t.nodesConst) >= 0 && flowTypesBlock.indexOf(t.matchFn) >= 0 && flowTypesBlock.indexOf(componentIdentOf(t)) >= 0 ) ); // component 字段的值必须与组件名一致,否则 渲染不出来 GENERIC_TYPES.forEach((t) => { const expect = componentIdentOf(t); const m = flowTypesBlock.match( new RegExp("key:\\s*'" + t.templateKey + "'[\\s\\S]{0,400}?component:\\s*'([^']+)'") ); check( 'FLOW_TYPES[' + t.templateKey + '].component === ' + expect, !!m && m[1] === expect, m ? m[1] : null ); }); check( '动火作业(fire)不在 FLOW_TYPES 内(沿用自己的实现)', !/key:\s*'fire'/.test(flowTypesBlock) ); check( 'detail.vue 用 currentFlowType 计算属性定位作业类型', /currentFlowType\(\)\s*\{[\s\S]{0,400}FLOW_TYPES\.find/.test(detailSrc) ); check( 'detail.vue 用 currentFlowNode 计算属性定位当前处理环节', /currentFlowNode\(\)\s*\{[\s\S]{0,400}currentFlowType\.match/.test(detailSrc) ); // 只统计