/** * 临时用电作业票审批流校验 * * 用法:node scripts/check-electricity-work-flow-nodes.js * * 校验内容: * 1) 动火 / 受限空间 / 高处作业 / 吊装作业的既有实现文件未被改动 * 2) 临时用电 9 个后续环节的 code / name / 字段与源系统一致 * 3) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中,且不串台 * 4) 默认安全措施清单与源系统 formData 逐条一致 * 5) 「风险辨识」与「危害辨识」取值 key 不冲突(源系统两者表单相同) * 6) 临时用电运行时辅助(回显 / payload / 流程变量)的取值口径正确 * 7) 组件模板与脚本一致:模板里调用的方法必须都已在组件中定义 * (漏定义会在渲染到该分支时抛 "is not a function",整个环节区域渲染不出来) * 8) detail.vue 的作业类型分发与「自动切到后续环节 tab」的时机正确 * * 源系统依据: * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do * -> 流程定义 HO_PERMIT_ELECTRICITY(id 3766,名称「危险作业-临时用电作业」) * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=3766 * -> 节点定义(共 10 个节点,1 级为作业申请) */ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); const babel = require('@babel/core'); const Module = require('module'); 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-electricity'); // 其它作业类型的既有实现文件:临时用电实现不得改动它们 const EXISTING_FILES = [ 'fireFlowNodes.js', 'fireFlowTask.js', 'confinedSpaceFlowNodes.js', 'confinedSpaceFlowTask.js', 'heightFlowNodes.js', 'heightFlowTask.js', 'hoistingFlowNodes.js', 'hoistingFlowTask.js', 'components/fireTicketDetail.vue', 'components/flowNodeForm.vue', 'components/confinedSpaceFlow.vue', 'components/heightFlow.vue', 'components/hoistingFlow.vue' ]; let failed = 0; function check(name, cond, extra) { if (cond) { console.log(' PASS ' + name); } else { failed++; console.log( ' FAIL ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '') ); } } // 编译 ESM -> CJS // electricityFlowTask.js 引用了 fireFlowTask.js(只读复用), // fireFlowTask.js 又引用了 fireFlowNodes.js,一并编译进来 fs.mkdirSync(OUT, { recursive: true }); [ 'electricityFlowNodes.js', 'electricityFlowTask.js', 'fireFlowNodes.js', 'fireFlowTask.js' ].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 es = require(path.join(OUT, 'electricityFlowNodes.js')); const est = require(path.join(OUT, 'electricityFlowTask.js')); console.log('\n[1] 其它作业类型的既有实现未被改动'); EXISTING_FILES.forEach((file) => { let changed = true; try { execFileSync('git', ['diff', '--quiet', 'HEAD', '--', file], { cwd: path.join(SRC, '..'), stdio: 'ignore' }); changed = false; } catch (e) { changed = true; } check(file + ' 与 HEAD 一致', !changed); }); console.log( '\n[2] 临时用电环节(源系统 HO_PERMIT_ELECTRICITY 共 10 节点,1 级为作业申请模板)' ); const 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', '完工验收'] ]; check('环节数量为 9', es.ELECTRICITY_FLOW_NODES.length === 9, { len: es.ELECTRICITY_FLOW_NODES.length }); expected.forEach(([code, name], i) => { const node = es.ELECTRICITY_FLOW_NODES[i]; check( '第' + (i + 2) + '级 ' + code + ' / ' + name, node.code === code && node.name === name, { code: node.code, name: node.name } ); }); console.log('\n[3] 与源系统 formJson 一致性'); const nodeOf = (k) => es.ELECTRICITY_FLOW_NODES.find((n) => n.key === k); const colsOf = (k, fk) => { const node = nodeOf(k); const field = node.fields.find((f) => f.key === fk); return (field.columns || []).map((c) => c.label); }; check( '气体分析:表名「气体分析记录表」', nodeOf('gas_analysis').fields[0].label === '气体分析记录表' ); check( '气体分析:4 列且顺序与源系统一致(分析时间/分析点/分析结果/分析人)', JSON.stringify(colsOf('gas_analysis', 'gas_analysis_list')) === JSON.stringify(['分析时间', '分析点', '分析结果', '分析人']), colsOf('gas_analysis', 'gas_analysis_list') ); check( '气体分析:分析时间为日期类型、其余为多行文本(与源系统一致)', nodeOf('gas_analysis').fields[0].columns[0].type === 'date' && nodeOf('gas_analysis').fields[0].columns[1].type === 'textarea' && nodeOf('gas_analysis').fields[0].columns[2].type === 'textarea' && nodeOf('gas_analysis').fields[0].columns[3].type === 'textarea' ); check( '危害辨识:多选 + 单行文本说明(源系统该节点表单复制自风险辨识)', nodeOf('hazard').fields[0].type === 'checkbox' && nodeOf('hazard').fields[0].options.length === 20 && nodeOf('hazard').fields[1].type === 'text' ); check( '风险辨识:多选 20 项 + 单行文本说明(源系统 checkbox + input)', nodeOf('risk').fields[0].type === 'checkbox' && nodeOf('risk').fields[0].options.length === 20 && nodeOf('risk').fields[0].options[0].label === '物体打击' && nodeOf('risk').fields[0].options[19].label === '其它伤害' && nodeOf('risk').fields[1].type === 'text' ); check( '风险辨识:说明字段别名不与多选框标签冲突', !nodeOf('risk').fields[1].alias.includes('风险辨识结果') ); check( '现场安全措施确认:表名「安全措施确认表」,3 列(安全措施/是否确认/确认人)', nodeOf('measure').fields[0].label === '安全措施确认表' && JSON.stringify(colsOf('measure', 'measure_list')) === JSON.stringify(['安全措施', '是否确认', '确认人']) ); check( '现场安全措施确认:编制人签字 + 确认人签字', JSON.stringify(nodeOf('measure').fields.map((f) => f.label)) === JSON.stringify(['安全措施确认表', '编制人签字', '确认人签字']) ); check( '是否确认选项为 不涉及 / 已确认', JSON.stringify( nodeOf('measure').fields[0].columns[1].options.map((o) => o.label) ) === JSON.stringify(['不涉及', '已确认']) ); check( '安全交底:2 个签字(安全交底人签字 / 接受交底人签字)', JSON.stringify(nodeOf('brief').fields.map((f) => f.label)) === JSON.stringify(['安全交底人签字', '接受交底人签字']) ); check( '作业负责人意见:意见 + 签字', nodeOf('leader_opinion').fields.length === 2 && nodeOf('leader_opinion').fields[0].type === 'textarea' && nodeOf('leader_opinion').fields[1].type === 'sign' ); check( '用电单位意见:意见 + 签字(不是受限空间的「所在单位意见」)', nodeOf('electric_unit_opinion').fields.length === 2 && nodeOf('electric_unit_opinion').fields[0].type === 'textarea' && nodeOf('electric_unit_opinion').fields[1].type === 'sign' && !es.ELECTRICITY_FLOW_NODES.some((n) => n.name === '所在单位意见') ); check( '作业实施:开始时间 + 结束时间 + 事中记录表 + 可燃气体分析记录表', JSON.stringify(nodeOf('execute').fields.map((f) => f.label)) === JSON.stringify([ '作业实施开始时间', '作业实施结束时间', '事中记录表', '可燃气体分析记录表' ]) ); check( '事中记录表 3 列(时间/记录内容/记录人)', JSON.stringify(colsOf('execute', 'during_record_list')) === JSON.stringify(['时间', '记录内容', '记录人']) ); check( '可燃气体分析记录表 4 列(分析时间/分析点/分析结果/分析人)', JSON.stringify(colsOf('execute', 'gas_record_list')) === JSON.stringify(['分析时间', '分析点', '分析结果', '分析人']) ); check( '完工验收:意见 + 签字', nodeOf('acceptance').fields.length === 2 && nodeOf('acceptance').fields[0].label === '意见' && nodeOf('acceptance').fields[1].type === 'sign' ); check( '安全措施默认清单 13 条与源系统 formData 逐条一致', es.DEFAULT_ELECTRICITY_MEASURES.length === 13 && es.DEFAULT_ELECTRICITY_MEASURES[0] === '作业人员持有电工作业操作证' && es.DEFAULT_ELECTRICITY_MEASURES[2] === '上级开关已断电、加锁,并挂安全警示标牌' && es.DEFAULT_ELECTRICITY_MEASURES[9] === '临时用电设施已装配漏电保护器,移动工具、手持工具已采取防漏电的安全措施(一机一闸一保护)' && es.DEFAULT_ELECTRICITY_MEASURES[12] === '作业场所已进行气体检测且符合作业安全要求' ); check( '默认行挂在 measure_list 字段上(defaultRows)', Array.isArray(nodeOf('measure').fields[0].defaultRows) && nodeOf('measure').fields[0].defaultRows.length === 13 && nodeOf('measure').fields[0].defaultRows[0].measure_content === es.DEFAULT_ELECTRICITY_MEASURES[0] ); console.log('\n[4] 环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)'); const m = es.matchElectricityFlowNode('HO_PERMIT_ELECTRICITY_THREETH'); check('按任务定义 key 精确命中', m && m.key === 'gas_analysis', { key: m && m.key }); check( '按节点名称命中', (es.matchElectricityFlowNode('现场安全措施确认') || {}).key === 'measure' ); check( '按别名命中', (es.matchElectricityFlowNode('气体检测') || {}).key === 'gas_analysis' ); check( '按平台自定义 key 反向包含命中', (es.matchElectricityFlowNode('el_gas_analysis') || {}).key === 'gas_analysis' ); check( '流程定义 key / 一级作业申请 key 不误命中后续环节', es.matchElectricityFlowNode('HO_PERMIT_ELECTRICITY') === null && es.matchElectricityFlowNode('HO_PERMIT_ELECTRICITY_ONETH') === null ); check('空标识返回 null', es.matchElectricityFlowNode('', null) === null); check( '「危害辨识」不会被「风险辨识」抢走', (es.matchElectricityFlowNode('危害辨识') || {}).key === 'hazard' && (es.matchElectricityFlowNode('风险辨识') || {}).key === 'risk' ); check( '「危险辨识」归到风险辨识节点', (es.matchElectricityFlowNode('危险辨识') || {}).key === 'risk' ); console.log('\n[5] 风险辨识 / 危害辨识 取值 key 不冲突'); const riskNode = nodeOf('risk'); const hazardNode = nodeOf('hazard'); const riskKeys = riskNode.fields.map((f) => f.key); const hazardKeys = hazardNode.fields.map((f) => f.key); check( '两个环节的字段 key 无交集', riskKeys.every((k) => !hazardKeys.includes(k)), { riskKeys, hazardKeys } ); check( '危害辨识使用 hazard_ 前缀', hazardKeys.every((k) => k.startsWith('hazard_')), hazardKeys ); const hazardAlias = hazardNode.fields.flatMap((f) => f.alias || []); check( '危害辨识的别名不会命中风险辨识的取值 key', !hazardAlias.includes('risk_result') && !hazardAlias.includes('risk_result_desc') ); console.log('\n[6] 运行时辅助(回显 / payload / 流程变量)'); const measureNode = nodeOf('measure'); const source = { measure_list: [ { measure_content: 'x', measure_confirm: '2', measure_confirmer: '张三' } ], measure_preparer_sign: '/tmp/a.png' }; const loaded = est.loadNodeValues(measureNode, [source]); check( '回显:按自身 key 取到已保存内容', loaded.measure_list.length === 1 && loaded.measure_preparer_sign === '/tmp/a.png' ); check( '回显:未保存的字段为 undefined', loaded.measure_confirmer_sign === undefined ); const payload = est.buildNodePayload(measureNode, { measure_list: [{ measure_content: 'x' }], measure_preparer_sign: '/tmp/a.png' }); check( 'payload:保留原始类型(数组不转字符串)', Array.isArray(payload.measure_list) && payload.measure_list.length === 1 ); check( 'payload:空值不写入', !('measure_confirmer_sign' in payload) && !('measure_confirm' in payload) ); const vars = est.buildNodeVariables(measureNode, { measure_list: [{ measure_content: 'x' }], measure_preparer_sign: '/tmp/a.png' }); check( '流程变量:子表转 JSON 字符串、其余转字符串', typeof vars.measure_list === 'string' && vars.measure_preparer_sign === '/tmp/a.png' ); const riskVars = est.buildNodeVariables(riskNode, { risk_result: ['5', '8'], risk_result_desc: '触电、火灾' }); check( '流程变量:多选值用逗号连接', riskVars.risk_result === '5,8' && riskVars.risk_result_desc === '触电、火灾' ); const hazardVars = est.buildNodeVariables(hazardNode, { hazard_result: ['5'], hazard_result_desc: '触电' }); check( '流程变量:风险辨识与危害辨识各自独立', riskVars.risk_result === '5,8' && hazardVars.hazard_result === '5' ); console.log('\n[7] 组件模板与脚本一致性(模板调用的方法必须已在组件中定义)'); const compiler = require('vue-template-compiler'); const parser = require('@babel/parser'); /** * 收集组件脚本里已定义的成员名(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) 指令 / 事件绑定属性值: :xxx="expr"、v-xxx="expr"、v-xxx:arg="expr"、@xxx="expr" // 2) 插值 {{ 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 || [] }; } const sfcPath = path.join(SRC, 'components/electricityFlow.vue'); const sfcRaw = fs.readFileSync(sfcPath, 'utf8'); const sfc = compiler.parseComponent(sfcRaw); const templateCheck = collectTemplateCalls(sfc.template.content); const members = collectComponentMembers(sfc.script.content); check( 'electricityFlow.vue 模板编译无错误', templateCheck.errors.length === 0, templateCheck.errors ); const missing = [...templateCheck.calls].filter((name) => !members.has(name)); check( `模板调用的 ${templateCheck.calls.size} 个方法均已定义`, missing.length === 0, missing ); check( '模板确实调用了 getConfirmColumn / confirmAll(子表「全部确认」按钮)', templateCheck.calls.has('getConfirmColumn') && templateCheck.calls.has('confirmAll') ); check( '未识别到环节时展示当前任务名/ key(便于排查流程模型配置)', templateCheck.calls.has('taskLabel') || /taskLabel/.test(sfcRaw) ); check( '子表日期列渲染为日期时间选择器(el-date-picker)', /]*column\.type === 'date'/.test(sfcRaw) ); check( '组件引用的是临时用电作业自己的节点/任务文件', /from '\.\.\/electricityFlowNodes'/.test(sfc.script.content) && /from '\.\.\/electricityFlowTask'/.test(sfc.script.content) && !/from '\.\.\/heightFlow/.test(sfc.script.content) && !/from '\.\.\/hoistingFlow/.test(sfc.script.content) && !/from '\.\.\/confinedSpaceFlow/.test(sfc.script.content) ); check( '组件样式与受限空间实现保持同一套类名(scoped,不影响其它组件)', /current-node-box/.test(sfcRaw) && /all-nodes-box/.test(sfcRaw) && /node-form-header/.test(sfcRaw) ); console.log('\n[8] detail.vue:五类作业的分支互斥且都已接入'); const detailSrc = fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8'); check( 'detail.vue:按作业类型分发到各自的后续环节组件', /isConfinedSpaceWork[\s\S]{0,200}getConfinedTableValue/.test(detailSrc) && /isHeightWork[\s\S]{0,200}getHeightTableValue/.test(detailSrc) && /isHeightWork[\s\S]{0,200}saveHeight/.test(detailSrc) && /isHoistingWork[\s\S]{0,200}getHoistingTableValue/.test(detailSrc) && /isHoistingWork[\s\S]{0,200}saveHoisting/.test(detailSrc) && /isElectricityWork[\s\S]{0,200}getElectricityTableValue/.test(detailSrc) && /isElectricityWork[\s\S]{0,200}saveElectricity/.test(detailSrc) ); check( 'detail.vue:临时用电作业的 tab 与组件已注册', /name="flowElectricity"/.test(detailSrc) && /