check-height-work-flow-nodes.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. /**
  2. * 高处作业票审批流校验
  3. *
  4. * 用法:node scripts/check-height-work-flow-nodes.js
  5. *
  6. * 校验内容:
  7. * 1) 动火作业、受限空间作业的既有实现文件未被改动(与 HEAD 完全一致)
  8. * 2) 高处作业 9 个后续环节的 code / name / 字段与源系统一致
  9. * 3) 环节匹配:按 code / 别名 / 名称 / 双向包含都能命中
  10. * 4) 默认安全措施清单与源系统 formData 逐条一致
  11. * 5) 高处作业运行时辅助(回显 / payload / 流程变量)的取值口径正确
  12. * 6) 组件模板与脚本一致:模板里调用的方法必须都已在组件中定义
  13. * (漏定义会在渲染到该分支时抛 "is not a function",整个环节区域渲染不出来)
  14. * 7) detail.vue 的作业类型分发与「自动切到后续环节 tab」的时机正确
  15. *
  16. * 源系统依据:
  17. * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowDefineListByEnterprise.do
  18. * -> 流程定义 HO_PERMIT_HEIGHT(id 3764,名称「危险作业-高处作业」)
  19. * https://www.acwlkj.com/IntellSecurity-web/flow/getFlowNodeDefineByFlowid.do?flow_id=3764
  20. * -> 节点定义(共 10 个节点,1 级为作业申请)
  21. */
  22. const fs = require('fs');
  23. const os = require('os');
  24. const path = require('path');
  25. const { execFileSync } = require('child_process');
  26. const babel = require('@babel/core');
  27. const Module = require('module');
  28. const ROOT = path.resolve(__dirname, '..');
  29. const SRC = path.join(
  30. ROOT,
  31. 'src/views/bpm/handleTask/components/dangerousWorkTicket'
  32. );
  33. const OUT = path.join(os.tmpdir(), 'kd-aiot-check-height-work');
  34. // 动火作业、受限空间作业的既有实现文件:高处作业实现不得改动它们
  35. const EXISTING_FILES = [
  36. 'fireFlowNodes.js',
  37. 'fireFlowTask.js',
  38. 'confinedSpaceFlowNodes.js',
  39. 'confinedSpaceFlowTask.js',
  40. 'components/fireTicketDetail.vue',
  41. 'components/flowNodeForm.vue',
  42. 'components/confinedSpaceFlow.vue'
  43. ];
  44. let failed = 0;
  45. function check(name, cond, extra) {
  46. if (cond) {
  47. console.log(' PASS ' + name);
  48. } else {
  49. failed++;
  50. console.log(
  51. ' FAIL ' + name + (extra ? ' -> ' + JSON.stringify(extra) : '')
  52. );
  53. }
  54. }
  55. // 编译 ESM -> CJS
  56. // heightFlowTask.js 引用了 fireFlowTask.js(只读复用),
  57. // fireFlowTask.js 又引用了 fireFlowNodes.js,一并编译进来
  58. fs.mkdirSync(OUT, { recursive: true });
  59. [
  60. 'heightFlowNodes.js',
  61. 'heightFlowTask.js',
  62. 'fireFlowNodes.js',
  63. 'fireFlowTask.js'
  64. ].forEach((file) => {
  65. const code = fs.readFileSync(path.join(SRC, file), 'utf8');
  66. const res = babel.transformSync(code, {
  67. filename: file,
  68. babelrc: false,
  69. configFile: false,
  70. plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
  71. });
  72. fs.writeFileSync(path.join(OUT, file), res.code);
  73. });
  74. // 高处作业运行时辅助会引用 @/api/*,这里用桩模块代替,避免校验脚本依赖后端接口
  75. const STUB = path.join(OUT, 'stub.js');
  76. fs.writeFileSync(
  77. STUB,
  78. 'module.exports = {\n' +
  79. ' getTaskListByProcessInstanceId: function () { return Promise.resolve([]); },\n' +
  80. ' getProcessInstance: function () { return Promise.resolve({}); }\n' +
  81. '};\n'
  82. );
  83. const originalResolve = Module._resolveFilename;
  84. Module._resolveFilename = function (request, ...args) {
  85. if (request.startsWith('@/')) {
  86. return STUB;
  87. }
  88. return originalResolve.call(this, request, ...args);
  89. };
  90. const hn = require(path.join(OUT, 'heightFlowNodes.js'));
  91. const ht = require(path.join(OUT, 'heightFlowTask.js'));
  92. console.log('\n[1] 动火作业、受限空间作业既有实现未被改动');
  93. EXISTING_FILES.forEach((file) => {
  94. let changed = true;
  95. try {
  96. execFileSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
  97. cwd: path.join(SRC, '..'),
  98. stdio: 'ignore'
  99. });
  100. changed = false;
  101. } catch (e) {
  102. changed = true;
  103. }
  104. check(file + ' 与 HEAD 一致', !changed);
  105. });
  106. console.log(
  107. '\n[2] 高处作业环节(源系统 HO_PERMIT_HEIGHT 共 10 节点,1 级为作业申请模板)'
  108. );
  109. const expected = [
  110. ['HO_PERMIT_HEIGHT_TWOTH', '风险辨识'],
  111. ['HO_PERMIT_HEIGHT_THREETH', '现场安全措施确认'],
  112. ['HO_PERMIT_HEIGHT_FOURTH', '安全交底'],
  113. ['HO_PERMIT_HEIGHT_FIVETH', '作业负责人意见'],
  114. ['HO_PERMIT_HEIGHT_SIXTH', '所在单位意见'],
  115. ['HO_PERMIT_HEIGHT_SEVENTH', '审核部门意见'],
  116. ['HO_PERMIT_HEIGHT_EIGHTTH', '审批部门意见'],
  117. ['HO_PERMIT_HEIGHT_NINETH', '作业实施'],
  118. ['HO_PERMIT_HEIGHT_TENTH', '完工验收']
  119. ];
  120. check('环节数量为 9', hn.HEIGHT_FLOW_NODES.length === 9, {
  121. len: hn.HEIGHT_FLOW_NODES.length
  122. });
  123. expected.forEach(([code, name], i) => {
  124. const node = hn.HEIGHT_FLOW_NODES[i];
  125. check(
  126. '第' + (i + 2) + '级 ' + code + ' / ' + name,
  127. node.code === code && node.name === name,
  128. { code: node.code, name: node.name }
  129. );
  130. });
  131. console.log('\n[3] 与源系统 formJson 一致性');
  132. const nodeOf = (k) => hn.HEIGHT_FLOW_NODES.find((n) => n.key === k);
  133. const colsOf = (k, fk) => {
  134. const node = nodeOf(k);
  135. const field = node.fields.find((f) => f.key === fk);
  136. return (field.columns || []).map((c) => c.label);
  137. };
  138. check(
  139. '风险辨识:结果为单选(源系统 radio,受限空间为多选)',
  140. nodeOf('risk').fields[0].type === 'radio' &&
  141. nodeOf('risk').fields[0].options.length === 20 &&
  142. nodeOf('risk').fields[0].options[0].label === '物体打击' &&
  143. nodeOf('risk').fields[0].options[19].label === '其它伤害'
  144. );
  145. check(
  146. '风险辨识:说明字段为单行文本(源系统 input)',
  147. nodeOf('risk').fields[1].type === 'text'
  148. );
  149. check(
  150. '风险辨识:说明字段别名不与单选框 key 冲突',
  151. !nodeOf('risk').fields[1].alias.includes('risk_result') &&
  152. !nodeOf('risk').fields[1].alias.includes('riskResult')
  153. );
  154. check(
  155. '现场安全措施确认:表名「安全措施确认表」,3 列(安全措施/是否确认/确认人)',
  156. nodeOf('measure').fields[0].label === '安全措施确认表' &&
  157. JSON.stringify(colsOf('measure', 'measure_list')) ===
  158. JSON.stringify(['安全措施', '是否确认', '确认人'])
  159. );
  160. check(
  161. '现场安全措施确认:编制人签字 + 确认人签字',
  162. JSON.stringify(nodeOf('measure').fields.map((f) => f.label)) ===
  163. JSON.stringify(['安全措施确认表', '编制人签字', '确认人签字'])
  164. );
  165. check(
  166. '安全交底:源系统未配置表单项,按同族流程惯例补齐「安全交底人签字 + 接受交底人签字」',
  167. JSON.stringify(nodeOf('brief').fields.map((f) => f.label)) ===
  168. JSON.stringify(['安全交底人签字', '接受交底人签字']) &&
  169. nodeOf('brief').fields.every((f) => f.type === 'sign')
  170. );
  171. check(
  172. '作业负责人意见:意见 + 签名',
  173. nodeOf('leader_opinion').fields.length === 2 &&
  174. nodeOf('leader_opinion').fields[0].type === 'textarea' &&
  175. nodeOf('leader_opinion').fields[1].type === 'sign'
  176. );
  177. check(
  178. '所在单位意见:意见 + 签名',
  179. nodeOf('unit_opinion').fields.length === 2 &&
  180. nodeOf('unit_opinion').fields[0].type === 'textarea' &&
  181. nodeOf('unit_opinion').fields[1].type === 'sign'
  182. );
  183. check(
  184. '审核部门意见:意见 + 签名',
  185. nodeOf('audit_opinion').fields.length === 2 &&
  186. nodeOf('audit_opinion').fields[0].type === 'textarea' &&
  187. nodeOf('audit_opinion').fields[1].type === 'sign'
  188. );
  189. check(
  190. '审批部门意见:意见 + 签名',
  191. nodeOf('approver_opinion').fields.length === 2 &&
  192. nodeOf('approver_opinion').fields[0].type === 'textarea' &&
  193. nodeOf('approver_opinion').fields[1].type === 'sign'
  194. );
  195. check(
  196. '作业实施:实施开始时间 + 实施结束时间 + 事中记录表',
  197. JSON.stringify(nodeOf('execute').fields.map((f) => f.label)) ===
  198. JSON.stringify(['作业实施开始时间', '作业实施结束时间', '事中记录表'])
  199. );
  200. check(
  201. '事中记录表 3 列(日期时间/记录内容/记录人)',
  202. JSON.stringify(colsOf('execute', 'during_record_list')) ===
  203. JSON.stringify(['日期时间', '记录内容', '记录人'])
  204. );
  205. check(
  206. '完工验收:意见 + 签名',
  207. nodeOf('acceptance').fields.length === 2 &&
  208. nodeOf('acceptance').fields[0].label === '意见' &&
  209. nodeOf('acceptance').fields[1].type === 'sign'
  210. );
  211. check(
  212. '安全措施默认清单 14 条与源系统 formData 逐条一致',
  213. hn.DEFAULT_HEIGHT_MEASURES.length === 14 &&
  214. hn.DEFAULT_HEIGHT_MEASURES[0] === '作业人员身体条件符合要求' &&
  215. hn.DEFAULT_HEIGHT_MEASURES[2] ===
  216. '作业人员佩戴符合标准要求的安全帽、安全带,有可能散发有毒气体的场所携带正压式空气呼吸器或面罩备用' &&
  217. hn.DEFAULT_HEIGHT_MEASURES[10] ===
  218. '30 m 以上高处作业时,作业人员已配备通信、联络工具' &&
  219. hn.DEFAULT_HEIGHT_MEASURES[13] === '其他相关特殊作业已办理相应安全作业票'
  220. );
  221. check(
  222. '默认行挂在 measure_list 字段上(defaultRows)',
  223. Array.isArray(nodeOf('measure').fields[0].defaultRows) &&
  224. nodeOf('measure').fields[0].defaultRows.length === 14 &&
  225. nodeOf('measure').fields[0].defaultRows[0].measure_content ===
  226. hn.DEFAULT_HEIGHT_MEASURES[0]
  227. );
  228. console.log('\n[4] 环节匹配(多级匹配:code / 别名 / 名称 / 双向包含)');
  229. const m = hn.matchHeightFlowNode('HO_PERMIT_HEIGHT_THREETH');
  230. check('按任务定义 key 精确命中', m && m.key === 'measure', {
  231. key: m && m.key
  232. });
  233. check(
  234. '按节点名称命中',
  235. (hn.matchHeightFlowNode('现场安全措施确认') || {}).key === 'measure'
  236. );
  237. check('按别名命中', (hn.matchHeightFlowNode('危险辨识') || {}).key === 'risk');
  238. check(
  239. '按平台自定义 key 反向包含命中',
  240. (hn.matchHeightFlowNode('height_risk') || {}).key === 'risk'
  241. );
  242. check(
  243. '流程定义 key / 一级作业申请 key 不误命中后续环节',
  244. hn.matchHeightFlowNode('HO_PERMIT_HEIGHT') === null &&
  245. hn.matchHeightFlowNode('HO_PERMIT_HEIGHT_ONETH') === null
  246. );
  247. check('空标识返回 null', hn.matchHeightFlowNode('', null) === null);
  248. console.log('\n[5] 运行时辅助(回显 / payload / 流程变量)');
  249. const measureNode = nodeOf('measure');
  250. const source = {
  251. measure_list: [
  252. { measure_content: 'x', measure_confirm: '2', measure_confirmer: '张三' }
  253. ],
  254. measure_preparer_sign: '/tmp/a.png'
  255. };
  256. const loaded = ht.loadNodeValues(measureNode, [source]);
  257. check(
  258. '回显:按自身 key 取到已保存内容',
  259. loaded.measure_list.length === 1 &&
  260. loaded.measure_preparer_sign === '/tmp/a.png'
  261. );
  262. check(
  263. '回显:未保存的字段为 undefined',
  264. loaded.measure_confirmer_sign === undefined
  265. );
  266. const payload = ht.buildNodePayload(measureNode, {
  267. measure_list: [{ measure_content: 'x' }],
  268. measure_preparer_sign: '/tmp/a.png'
  269. });
  270. check(
  271. 'payload:保留原始类型(数组不转字符串)',
  272. Array.isArray(payload.measure_list) && payload.measure_list.length === 1
  273. );
  274. check(
  275. 'payload:空值不写入',
  276. !('measure_confirmer_sign' in payload) && !('measure_confirm' in payload)
  277. );
  278. const vars = ht.buildNodeVariables(measureNode, {
  279. measure_list: [{ measure_content: 'x' }],
  280. measure_preparer_sign: '/tmp/a.png'
  281. });
  282. check(
  283. '流程变量:子表转 JSON 字符串、其余转字符串',
  284. typeof vars.measure_list === 'string' &&
  285. vars.measure_preparer_sign === '/tmp/a.png'
  286. );
  287. check(
  288. '回显:单选框的值不会串到风险辨识说明',
  289. ht.loadNodeValues(nodeOf('risk'), [{ risk_result: '9' }]).risk_result_desc ===
  290. undefined
  291. );
  292. const briefPayload = ht.buildNodePayload(nodeOf('brief'), {
  293. brief_sign: '/tmp/briefer.png',
  294. brief_receiver_sign: '/tmp/receiver.png'
  295. });
  296. check(
  297. '安全交底:两个签字写入 payload 与流程变量',
  298. briefPayload.brief_sign === '/tmp/briefer.png' &&
  299. briefPayload.brief_receiver_sign === '/tmp/receiver.png' &&
  300. ht.buildNodeVariables(nodeOf('brief'), {
  301. brief_sign: '/tmp/briefer.png'
  302. }).brief_sign === '/tmp/briefer.png'
  303. );
  304. console.log('\n[6] 组件模板与脚本一致性(模板调用的方法必须已在组件中定义)');
  305. const compiler = require('vue-template-compiler');
  306. const parser = require('@babel/parser');
  307. /**
  308. * 收集组件脚本里已定义的成员名(props / data / computed / methods)
  309. * @param {string} script script 源码
  310. * @returns {Set<string>} 成员名集合
  311. */
  312. function collectComponentMembers(script) {
  313. const names = new Set();
  314. const ast = parser.parse(script, {
  315. sourceType: 'module',
  316. plugins: ['optionalChaining', 'objectRestSpread', 'nullishCoalescingOperator']
  317. });
  318. let component = null;
  319. for (const node of ast.program.body) {
  320. if (node.type === 'ExportDefaultDeclaration') {
  321. component = node.declaration;
  322. }
  323. }
  324. const collectObjectKeys = (obj) => {
  325. ((obj && obj.properties) || []).forEach((prop) => {
  326. if (!prop.key) {
  327. return;
  328. }
  329. names.add(prop.key.name || prop.key.value);
  330. });
  331. };
  332. ((component && component.properties) || []).forEach((prop) => {
  333. if (!prop.key) {
  334. return;
  335. }
  336. const key = prop.key.name;
  337. const value = prop.value;
  338. if (key === 'props' || key === 'methods' || key === 'computed') {
  339. collectObjectKeys(value);
  340. return;
  341. }
  342. if (key === 'data') {
  343. // data() { return { ... } }
  344. const body = value && value.body;
  345. if (!body) {
  346. return;
  347. }
  348. const ret = body.body.find(
  349. (stmt) => stmt.type === 'ReturnStatement' && stmt.argument
  350. );
  351. if (ret) {
  352. collectObjectKeys(ret.argument);
  353. }
  354. }
  355. });
  356. return names;
  357. }
  358. /**
  359. * 取模板里被调用的成员名
  360. *
  361. * 注意:Vue 2 编译后的 render 用 with(this) 解析标识符,
  362. * 方法调用编译成裸函数名(如 getRows(field)),不带 vm. 前缀,
  363. * 因此这里直接扫描模板表达式,而不是扫描编译产物。
  364. *
  365. * @param {string} template 模板源码
  366. * @returns {Set<string>} 被调用的成员名集合
  367. */
  368. function collectTemplateCalls(template) {
  369. const calls = new Set();
  370. // 1) 插值 {{ expr }}
  371. // 2) 指令 / 事件绑定属性值: :xxx="expr"、v-xxx="expr"、v-xxx:arg="expr"、@xxx="expr"
  372. const attrRe =
  373. /(?:[:@][\w.\-]+|v-[\w\-]+(?::[\w.\-]+)?)\s*=\s*"([^"]*)"/g;
  374. const interpRe = /\{\{([\s\S]*?)\}\}/g;
  375. const exprs = [];
  376. let m;
  377. while ((m = attrRe.exec(template)) !== null) {
  378. exprs.push(m[1]);
  379. }
  380. while ((m = interpRe.exec(template)) !== null) {
  381. exprs.push(m[1]);
  382. }
  383. // 全局函数与 Vue 内部函数,不属于组件成员
  384. const globals = new Set([
  385. 'String',
  386. 'Number',
  387. 'Boolean',
  388. 'Array',
  389. 'Object',
  390. 'JSON',
  391. 'Math',
  392. 'Date',
  393. 'parseInt',
  394. 'parseFloat',
  395. 'isNaN',
  396. 'require'
  397. ]);
  398. // 标识符后紧跟 "(" 即为函数调用;排除属性访问(xxx.method())与内部变量
  399. const callRe = /(^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
  400. exprs.forEach((expr) => {
  401. let hit;
  402. callRe.lastIndex = 0;
  403. while ((hit = callRe.exec(expr)) !== null) {
  404. const name = hit[2];
  405. if (name.startsWith('$') || name.startsWith('_') || globals.has(name)) {
  406. continue;
  407. }
  408. calls.add(name);
  409. }
  410. });
  411. return { calls, errors: compiler.compile(template).errors || [] };
  412. }
  413. const sfcPath = path.join(SRC, 'components/heightFlow.vue');
  414. const sfc = compiler.parseComponent(fs.readFileSync(sfcPath, 'utf8'));
  415. const templateCheck = collectTemplateCalls(sfc.template.content);
  416. const members = collectComponentMembers(sfc.script.content);
  417. check(
  418. 'heightFlow.vue 模板编译无错误',
  419. templateCheck.errors.length === 0,
  420. templateCheck.errors
  421. );
  422. const missing = [...templateCheck.calls].filter((name) => !members.has(name));
  423. check(
  424. `模板调用的 ${templateCheck.calls.size} 个方法均已定义`,
  425. missing.length === 0,
  426. missing
  427. );
  428. check(
  429. '模板确实调用了 getConfirmColumn / confirmAll(子表「全部确认」按钮)',
  430. templateCheck.calls.has('getConfirmColumn') &&
  431. templateCheck.calls.has('confirmAll')
  432. );
  433. check(
  434. '未识别到环节时展示当前任务名/ key(便于排查流程模型配置)',
  435. templateCheck.calls.has('taskLabel') ||
  436. /taskLabel/.test(fs.readFileSync(sfcPath, 'utf8'))
  437. );
  438. check(
  439. '子表日期列渲染为日期时间选择器(el-date-picker)',
  440. /<el-date-picker[^>]*column\.type === 'date'/.test(
  441. fs.readFileSync(sfcPath, 'utf8')
  442. )
  443. );
  444. check(
  445. '作业实施:事中记录表「日期时间」列类型为 date',
  446. nodeOf('execute').fields
  447. .find((f) => f.key === 'during_record_list')
  448. .columns.some((c) => c.key === 'record_time' && c.type === 'date')
  449. );
  450. // detail.vue:三类作业的分支互斥且都已接入
  451. const detailSrc = fs.readFileSync(path.join(SRC, 'detail.vue'), 'utf8');
  452. check(
  453. 'detail.vue:按作业类型分发到各自的后续环节组件',
  454. /isConfinedSpaceWork[\s\S]{0,200}getConfinedTableValue/.test(detailSrc) &&
  455. /isHeightWork[\s\S]{0,200}getHeightTableValue/.test(detailSrc) &&
  456. /isHeightWork[\s\S]{0,200}saveHeight/.test(detailSrc)
  457. );
  458. check(
  459. 'detail.vue:默认 tab 是「作业票信息」(不再自动切到「后续环节」)',
  460. /activeTab:\s*'info'/.test(detailSrc) &&
  461. !/switchToFlowTab/.test(detailSrc)
  462. );
  463. // 受限空间组件只做只读体检,不参与断言(既有实现不做任何改动)
  464. const confinedSfc = compiler.parseComponent(
  465. fs.readFileSync(path.join(SRC, 'components/confinedSpaceFlow.vue'), 'utf8')
  466. );
  467. const confinedCheck = collectTemplateCalls(confinedSfc.template.content);
  468. const confinedMembers = collectComponentMembers(confinedSfc.script.content);
  469. const confinedMissing = [...confinedCheck.calls].filter(
  470. (name) => !confinedMembers.has(name)
  471. );
  472. console.log(
  473. ' INFO 受限空间组件模板调用体检:缺失 ' + confinedMissing.length + ' 个' +
  474. (confinedMissing.length ? ' -> ' + JSON.stringify(confinedMissing) : '')
  475. );
  476. console.log('\n' + (failed ? 'FAILED: ' + failed + ' 项' : 'ALL PASSED'));
  477. process.exit(failed ? 1 : 0);