@steedos-labs/plugin-workflow 3.0.46 → 3.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/designer/dist/amis-renderer/amis-renderer.css +1 -1
- package/designer/dist/amis-renderer/amis-renderer.js +1 -1
- package/designer/dist/assets/index-CH5Rzd1r.css +1 -0
- package/designer/dist/assets/index-Z3wTJs2h.js +952 -0
- package/designer/dist/index.html +2 -2
- package/main/default/manager/import.js +20 -2
- package/main/default/objects/instances/buttons/instance_delete.button.yml +1 -1
- package/main/default/objects/instances/buttons/instance_related.button.yml +4 -1
- package/main/default/routes/api_workflow_ai_form_design.router.js +5 -2
- package/main/default/routes/api_workflow_ai_form_design_stream.router.js +5 -2
- package/main/default/routes/flow_form_design.ejs +8 -0
- package/main/default/routes/flow_form_design.router.js +1 -1
- package/main/default/test/FORMULA_SCAN_GUIDE.md +258 -0
- package/main/default/test/prompt-formula-analyze.md +59 -0
- package/main/default/test/prompt-formula-fix.md +75 -0
- package/main/default/test/reports/formula-scan/.gitkeep +0 -0
- package/main/default/test/reports/formula-scan/SCAN_HISTORY.md +165 -0
- package/main/default/test/reports/formula-scan/scan-result-v6-20260324.json +31846 -0
- package/main/default/test/reports/formula-scan/scan-result-v7-20260324.json +31543 -0
- package/main/default/test/run_approval_comments_upgrade.js +135 -0
- package/main/default/test/scan_production_formulas.js +573 -0
- package/main/default/test/test_formula_compat.js +38 -0
- package/main/default/utils/designerManager.js +1 -0
- package/main/default/utils/formula-compat.js +8 -0
- package/package.json +1 -1
- package/public/amis-renderer/amis-renderer.css +1 -1
- package/public/amis-renderer/amis-renderer.js +1 -1
- package/public/workflow/index.css +4 -0
- package/designer/dist/assets/index-BNKlgpz1.js +0 -943
- package/designer/dist/assets/index-D7JdeS9f.css +0 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan_production_formulas.js
|
|
3
|
+
*
|
|
4
|
+
* 生产公式扫描工具 — 连接 MongoDB 读取 forms 集合,遍历所有公式字段,
|
|
5
|
+
* 调用 mapFormula 检测未兼容的公式语法,输出 JSON 报告。
|
|
6
|
+
*
|
|
7
|
+
* 用法:
|
|
8
|
+
* FORMULA_SCAN_MONGO_URL=mongodb://127.0.0.1:27017/mydb \
|
|
9
|
+
* SCAN_LABEL=prod \
|
|
10
|
+
* node main/default/test/scan_production_formulas.js
|
|
11
|
+
*
|
|
12
|
+
* 环境变量:
|
|
13
|
+
* FORMULA_SCAN_MONGO_URL 优先使用此连接串
|
|
14
|
+
* MONGO_URL 回退连接串
|
|
15
|
+
* SCAN_LABEL 可选,用于报告文件命名标识
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const { mapFormula, getSafeCode, getTableFieldMap } = require('../utils/formula-compat');
|
|
23
|
+
|
|
24
|
+
// ─── 已知问题清单(经人工审核确认不需要代码修正) ─────────────────────────────
|
|
25
|
+
// key = "formId::fieldCode",value = { category, resolution }
|
|
26
|
+
// category: KNOWN_LIMITATION — mapFormula 功能边界 | BAD_SOURCE_DATA — 源表单数据本身有误
|
|
27
|
+
const KNOWN_ISSUES = {
|
|
28
|
+
'451766252f8838253eab81f8::所属信息站': {
|
|
29
|
+
category: 'KNOWN_LIMITATION',
|
|
30
|
+
resolution: 'judgeIndex 为老版本工作流自定义函数,不在 mapFormula 支持范围内(仅支持 sum/average/count/max/min/numToRMB),amis 无等价函数。历史版本,无需修正。',
|
|
31
|
+
},
|
|
32
|
+
'9THeSusZyo9E3pe5p::表名': {
|
|
33
|
+
category: 'KNOWN_LIMITATION',
|
|
34
|
+
resolution: '文本模板公式(字段引用+文字混排无运算符),mapFormula 尚无此转换路径。{YYYY} 为日期模板 token。历史版本,无需修正。',
|
|
35
|
+
},
|
|
36
|
+
'6d9fe8a132c96a664ca23808::发放补贴金额(元)': {
|
|
37
|
+
category: 'BAD_SOURCE_DATA',
|
|
38
|
+
resolution: '原始公式 {发放补贴数量}*{} 中 {} 为空引用,表单作者漏填字段名(推测应为 {发放补贴单价(元)})。源数据错误,非代码问题。',
|
|
39
|
+
},
|
|
40
|
+
'b9e7465f159b6640bbe49caa::概算、估算金额(元、大写)': {
|
|
41
|
+
category: 'BAD_SOURCE_DATA',
|
|
42
|
+
resolution: '原始公式 numToRMB(概算、估算金额{}) 花括号位置错误,正确写法应为 numToRMB({概算、估算金额})。源数据错误,非代码问题。',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ─── 工具函数 ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 脱敏 MongoDB 连接串:隐藏密码和数据库名称中的敏感部分。
|
|
50
|
+
* 例:mongodb://user:pass@host:27017/db_prod → mongodb://user:***@host:27017/***_prod
|
|
51
|
+
*/
|
|
52
|
+
function redactMongoUrl(url) {
|
|
53
|
+
if (!url) return url;
|
|
54
|
+
// 隐藏密码
|
|
55
|
+
let redacted = url.replace(/:([^/@]+)@/, ':***@');
|
|
56
|
+
// 隐藏数据库名(保留最后一个下划线后缀,便于识别环境)
|
|
57
|
+
redacted = redacted.replace(/(\/[^/?]+)(\?|$)/, (m, dbPart, suffix) => {
|
|
58
|
+
const dbName = dbPart.slice(1);
|
|
59
|
+
const underscoreIdx = dbName.lastIndexOf('_');
|
|
60
|
+
const masked = underscoreIdx > -1
|
|
61
|
+
? '***' + dbName.slice(underscoreIdx)
|
|
62
|
+
: '***';
|
|
63
|
+
return '/' + masked + suffix;
|
|
64
|
+
});
|
|
65
|
+
return redacted;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 返回今日日期的各部分,供两种格式化函数共用。
|
|
70
|
+
* @returns {{ y: string, m: string, day: string }}
|
|
71
|
+
*/
|
|
72
|
+
function todayParts() {
|
|
73
|
+
const d = new Date();
|
|
74
|
+
return {
|
|
75
|
+
y: String(d.getFullYear()),
|
|
76
|
+
m: String(d.getMonth() + 1).padStart(2, '0'),
|
|
77
|
+
day: String(d.getDate()).padStart(2, '0'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 今日日期字符串 YYYYMMDD
|
|
83
|
+
*/
|
|
84
|
+
function todayStr() {
|
|
85
|
+
const { y, m, day } = todayParts();
|
|
86
|
+
return `${y}${m}${day}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 今日日期字符串 YYYY-MM-DD(用于 JSON)
|
|
91
|
+
*/
|
|
92
|
+
function todayISODate() {
|
|
93
|
+
const { y, m, day } = todayParts();
|
|
94
|
+
return `${y}-${m}-${day}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── 检测规则 ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 对单个公式字段执行所有检测规则,返回 issues 数组(可能为空)。
|
|
101
|
+
*
|
|
102
|
+
* @param {object} params
|
|
103
|
+
* @param {string} params.formula 原始公式文本
|
|
104
|
+
* @param {object} params.tableFieldMap 由 getTableFieldMap 生成的映射
|
|
105
|
+
* @param {string} params.formId
|
|
106
|
+
* @param {string} params.formName
|
|
107
|
+
* @param {string} params.formVersionType "current" | "history"
|
|
108
|
+
* @param {object} params.field 字段对象
|
|
109
|
+
* @param {string} params.fieldLocation "top" | "table:{tableCode}"
|
|
110
|
+
* @param {string} params.formulaSource "formula" | "default_value"
|
|
111
|
+
* @param {string[]} params.siblingCodes 同层级其他字段 code
|
|
112
|
+
* @returns {{ issues: object[], convertedResult: string|null }}
|
|
113
|
+
*/
|
|
114
|
+
function checkFormula({ formula, tableFieldMap, formId, formName, formVersionType,
|
|
115
|
+
field, fieldLocation, formulaSource, siblingCodes }) {
|
|
116
|
+
|
|
117
|
+
const fieldCode = field.code || '';
|
|
118
|
+
const fieldType = field.type || '';
|
|
119
|
+
const hasFieldRef = /\{[^{}]+\}/.test(formula);
|
|
120
|
+
|
|
121
|
+
const baseInfo = {
|
|
122
|
+
formId,
|
|
123
|
+
formName,
|
|
124
|
+
formVersionType,
|
|
125
|
+
fieldCode,
|
|
126
|
+
fieldType,
|
|
127
|
+
fieldLocation,
|
|
128
|
+
formulaSource,
|
|
129
|
+
originalFormula: formula,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const issues = [];
|
|
133
|
+
let convertedResult = null;
|
|
134
|
+
|
|
135
|
+
// E4: 转换异常
|
|
136
|
+
try {
|
|
137
|
+
convertedResult = mapFormula(formula, tableFieldMap);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
issues.push({
|
|
140
|
+
level: 'ERROR',
|
|
141
|
+
ruleId: 'E4',
|
|
142
|
+
...baseInfo,
|
|
143
|
+
convertedResult: null,
|
|
144
|
+
errorDetail: `mapFormula 抛出异常: ${err.message}`,
|
|
145
|
+
siblingFields: siblingCodes,
|
|
146
|
+
});
|
|
147
|
+
return { issues, convertedResult: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// E1: 转换失败(返回 null 但公式含 {字段名} 引用)
|
|
151
|
+
if (convertedResult === null && hasFieldRef) {
|
|
152
|
+
issues.push({
|
|
153
|
+
level: 'ERROR',
|
|
154
|
+
ruleId: 'E1',
|
|
155
|
+
...baseInfo,
|
|
156
|
+
convertedResult: null,
|
|
157
|
+
errorDetail: '公式含 {字段名} 引用但 mapFormula 返回 null,无法转换',
|
|
158
|
+
siblingFields: siblingCodes,
|
|
159
|
+
});
|
|
160
|
+
return { issues, convertedResult: null };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (convertedResult !== null) {
|
|
164
|
+
// E2: 方括号残留 — 排除 ARRAYMAP 产生的 item['...'] 属性访问语法
|
|
165
|
+
const resultWithoutArrayAccess = convertedResult.replace(/\bitem\s*\[\s*'[^']*'\s*\]/g, '');
|
|
166
|
+
if (/[\[\]]/.test(resultWithoutArrayAccess)) {
|
|
167
|
+
issues.push({
|
|
168
|
+
level: 'ERROR',
|
|
169
|
+
ruleId: 'E2',
|
|
170
|
+
...baseInfo,
|
|
171
|
+
convertedResult,
|
|
172
|
+
errorDetail: '转换结果中残留方括号 []',
|
|
173
|
+
siblingFields: siblingCodes,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// E3: 花括号残留(非 ${ 的裸 { 表示字段未转换)
|
|
178
|
+
// 合法结果形如 ${...},内部不应有独立的 {fieldname} 未转换片段
|
|
179
|
+
if (/(?<!\$)\{/.test(convertedResult)) {
|
|
180
|
+
issues.push({
|
|
181
|
+
level: 'ERROR',
|
|
182
|
+
ruleId: 'E3',
|
|
183
|
+
...baseInfo,
|
|
184
|
+
convertedResult,
|
|
185
|
+
errorDetail: '转换结果中残留花括号 {}(非合法 amis 表达式)',
|
|
186
|
+
siblingFields: siblingCodes,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// W1: 除零风险(含除法且除数是字段引用)
|
|
192
|
+
if (/\/\s*\{[^{}]+\}/.test(formula)) {
|
|
193
|
+
issues.push({
|
|
194
|
+
level: 'WARN',
|
|
195
|
+
ruleId: 'W1',
|
|
196
|
+
...baseInfo,
|
|
197
|
+
convertedResult,
|
|
198
|
+
errorDetail: '公式含除法且除数为字段引用,存在除零风险',
|
|
199
|
+
siblingFields: siblingCodes,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { issues, convertedResult };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 检测同表单内 W2 字段名冲突:不同字段 getSafeCode 后结果相同。
|
|
208
|
+
* @param {string[]} codes 字段 code 列表
|
|
209
|
+
* @returns {Map<string, string[]>} safeCode → 原始 code 列表(仅冲突项)
|
|
210
|
+
*/
|
|
211
|
+
function detectSafeCodeConflicts(codes) {
|
|
212
|
+
const safeMap = new Map();
|
|
213
|
+
for (const code of codes) {
|
|
214
|
+
const safe = getSafeCode(code);
|
|
215
|
+
if (!safeMap.has(safe)) safeMap.set(safe, []);
|
|
216
|
+
safeMap.get(safe).push(code);
|
|
217
|
+
}
|
|
218
|
+
const conflicts = new Map();
|
|
219
|
+
for (const [safe, originals] of safeMap) {
|
|
220
|
+
if (originals.length > 1) conflicts.set(safe, originals);
|
|
221
|
+
}
|
|
222
|
+
return conflicts;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── 字段遍历 ─────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 递归遍历字段列表,收集含公式的字段信息。
|
|
229
|
+
*
|
|
230
|
+
* @param {object[]} fields
|
|
231
|
+
* @param {object} tableFieldMap 顶层 getTableFieldMap 结果
|
|
232
|
+
* @param {string} locationPrefix "top" 或 "table:{tableCode}"
|
|
233
|
+
* @returns {object[]} 每项包含 field, formula, source, location, siblingCodes
|
|
234
|
+
*/
|
|
235
|
+
function collectFormulaFields(fields, tableFieldMap, locationPrefix) {
|
|
236
|
+
if (!Array.isArray(fields)) return [];
|
|
237
|
+
const results = [];
|
|
238
|
+
|
|
239
|
+
// 同层级字段 code(用于 siblingFields)
|
|
240
|
+
const siblingCodes = fields.map(f => f.code).filter(Boolean);
|
|
241
|
+
|
|
242
|
+
for (const field of fields) {
|
|
243
|
+
// 递归处理嵌套字段(section / table)
|
|
244
|
+
if (Array.isArray(field.fields)) {
|
|
245
|
+
const childLocation = field.type === 'table'
|
|
246
|
+
? `table:${field.code}`
|
|
247
|
+
: locationPrefix;
|
|
248
|
+
results.push(...collectFormulaFields(field.fields, tableFieldMap, childLocation));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// formula 属性
|
|
252
|
+
if (field.formula && typeof field.formula === 'string' && field.formula.trim()) {
|
|
253
|
+
results.push({
|
|
254
|
+
field,
|
|
255
|
+
formula: field.formula.trim(),
|
|
256
|
+
source: 'formula',
|
|
257
|
+
location: locationPrefix,
|
|
258
|
+
siblingCodes: siblingCodes.filter(c => c !== field.code),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// default_value 含公式(含 {字段名} 引用才视为公式)
|
|
263
|
+
if (field.default_value && typeof field.default_value === 'string'
|
|
264
|
+
&& field.default_value.trim()
|
|
265
|
+
&& /\{[^{}]+\}/.test(field.default_value)) {
|
|
266
|
+
results.push({
|
|
267
|
+
field,
|
|
268
|
+
formula: field.default_value.trim(),
|
|
269
|
+
source: 'default_value',
|
|
270
|
+
location: locationPrefix,
|
|
271
|
+
siblingCodes: siblingCodes.filter(c => c !== field.code),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── 表单扫描 ─────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 扫描单个表单文档,返回该表单所有公式字段的检测结果。
|
|
283
|
+
* @param {object} form MongoDB forms 文档
|
|
284
|
+
* @returns {{ allFormulas: object[], issues: object[] }}
|
|
285
|
+
*/
|
|
286
|
+
function scanForm(form) {
|
|
287
|
+
const formId = String(form._id);
|
|
288
|
+
const formName = form.name || '';
|
|
289
|
+
const allFormulas = [];
|
|
290
|
+
const issues = [];
|
|
291
|
+
|
|
292
|
+
// 已处理的公式去重 key:current 优先,history 中若已有 current 则跳过
|
|
293
|
+
// 使用 JSON.stringify 避免字段内容含分隔符导致 key 碰撞
|
|
294
|
+
const seenKeys = new Set();
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 处理一个版本的 fields 列表
|
|
298
|
+
* @param {object[]} fields
|
|
299
|
+
* @param {"current"|"history"} versionType
|
|
300
|
+
*/
|
|
301
|
+
function processVersion(fields, versionType) {
|
|
302
|
+
if (!Array.isArray(fields)) return;
|
|
303
|
+
|
|
304
|
+
const tableFieldMap = getTableFieldMap(fields);
|
|
305
|
+
const formulaFields = collectFormulaFields(fields, tableFieldMap, 'top');
|
|
306
|
+
|
|
307
|
+
for (const { field, formula, source, location, siblingCodes } of formulaFields) {
|
|
308
|
+
const dedupeKey = JSON.stringify({ code: field.code, location, source, formula });
|
|
309
|
+
|
|
310
|
+
// current 版本直接处理,history 版本若 current 已有则跳过
|
|
311
|
+
if (versionType === 'history' && seenKeys.has(dedupeKey)) continue;
|
|
312
|
+
seenKeys.add(dedupeKey);
|
|
313
|
+
|
|
314
|
+
const { issues: fieldIssues, convertedResult } = checkFormula({
|
|
315
|
+
formula,
|
|
316
|
+
tableFieldMap,
|
|
317
|
+
formId,
|
|
318
|
+
formName,
|
|
319
|
+
formVersionType: versionType,
|
|
320
|
+
field,
|
|
321
|
+
fieldLocation: location,
|
|
322
|
+
formulaSource: source,
|
|
323
|
+
siblingCodes,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const status = fieldIssues.length > 0
|
|
327
|
+
? fieldIssues[0].level // ERROR 或 WARN
|
|
328
|
+
: 'PASS';
|
|
329
|
+
|
|
330
|
+
allFormulas.push({
|
|
331
|
+
formId,
|
|
332
|
+
formName,
|
|
333
|
+
formVersionType: versionType,
|
|
334
|
+
fieldCode: field.code || '',
|
|
335
|
+
fieldLocation: location,
|
|
336
|
+
formulaSource: source,
|
|
337
|
+
originalFormula: formula,
|
|
338
|
+
convertedResult: convertedResult !== undefined ? convertedResult : null,
|
|
339
|
+
status,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
issues.push(...fieldIssues);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 处理 current 版本
|
|
347
|
+
if (form.current && Array.isArray(form.current.fields)) {
|
|
348
|
+
processVersion(form.current.fields, 'current');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 处理历史版本
|
|
352
|
+
if (Array.isArray(form.historys)) {
|
|
353
|
+
for (const history of form.historys) {
|
|
354
|
+
if (Array.isArray(history.fields)) {
|
|
355
|
+
processVersion(history.fields, 'history');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// W2: 字段名冲突检测(仅基于 current 版本顶层字段)
|
|
361
|
+
if (form.current && Array.isArray(form.current.fields)) {
|
|
362
|
+
const allCodes = form.current.fields.map(f => f.code).filter(Boolean);
|
|
363
|
+
const conflicts = detectSafeCodeConflicts(allCodes);
|
|
364
|
+
for (const [safeCode, originals] of conflicts) {
|
|
365
|
+
issues.push({
|
|
366
|
+
level: 'WARN',
|
|
367
|
+
ruleId: 'W2',
|
|
368
|
+
formId,
|
|
369
|
+
formName,
|
|
370
|
+
formVersionType: 'current',
|
|
371
|
+
fieldCode: originals.join(' / '),
|
|
372
|
+
fieldType: '',
|
|
373
|
+
fieldLocation: 'top',
|
|
374
|
+
formulaSource: '',
|
|
375
|
+
originalFormula: '',
|
|
376
|
+
convertedResult: null,
|
|
377
|
+
errorDetail: `字段名冲突:${originals.join('、')} 经 getSafeCode 后均为 "${safeCode}"`,
|
|
378
|
+
siblingFields: allCodes,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { allFormulas, issues };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── 主流程 ───────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
async function main() {
|
|
389
|
+
const mongoUrl = process.env.FORMULA_SCAN_MONGO_URL || process.env.MONGO_URL;
|
|
390
|
+
if (!mongoUrl) {
|
|
391
|
+
console.error('错误:请设置环境变量 FORMULA_SCAN_MONGO_URL 或 MONGO_URL');
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const scanLabel = process.env.SCAN_LABEL || '';
|
|
396
|
+
const dateStr = todayStr();
|
|
397
|
+
const isoDate = todayISODate();
|
|
398
|
+
|
|
399
|
+
const reportFileName = scanLabel
|
|
400
|
+
? `scan-result-${scanLabel}-${dateStr}.json`
|
|
401
|
+
: `scan-result-${dateStr}.json`;
|
|
402
|
+
const reportDir = path.resolve(__dirname, 'reports/formula-scan');
|
|
403
|
+
const reportPath = path.join(reportDir, reportFileName);
|
|
404
|
+
const reportRelPath = `main/default/test/reports/formula-scan/${reportFileName}`;
|
|
405
|
+
|
|
406
|
+
// 确保输出目录存在
|
|
407
|
+
if (!fs.existsSync(reportDir)) {
|
|
408
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 连接 MongoDB
|
|
412
|
+
let client;
|
|
413
|
+
let MongoClient;
|
|
414
|
+
try {
|
|
415
|
+
MongoClient = require('mongodb').MongoClient;
|
|
416
|
+
} catch (e) {
|
|
417
|
+
console.error('错误:未找到 mongodb 模块,请先运行 npm install mongodb');
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
console.log(`正在连接 MongoDB: ${redactMongoUrl(mongoUrl)}`);
|
|
422
|
+
try {
|
|
423
|
+
client = new MongoClient(mongoUrl);
|
|
424
|
+
await client.connect();
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.error(`MongoDB 连接失败: ${err.message}`);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const dbName = mongoUrl.split('/').pop().split('?')[0];
|
|
432
|
+
const db = client.db(dbName);
|
|
433
|
+
const formsCollection = db.collection('forms');
|
|
434
|
+
|
|
435
|
+
console.log('正在读取 forms 集合...');
|
|
436
|
+
const forms = await formsCollection.find({}, {
|
|
437
|
+
projection: { _id: 1, name: 1, 'current.fields': 1, historys: 1 }
|
|
438
|
+
}).toArray();
|
|
439
|
+
|
|
440
|
+
console.log(`读取到 ${forms.length} 个表单,开始扫描...`);
|
|
441
|
+
|
|
442
|
+
// 汇总统计
|
|
443
|
+
let totalForms = forms.length;
|
|
444
|
+
let formsWithFormula = 0;
|
|
445
|
+
let totalFormulaFields = 0;
|
|
446
|
+
const bySource = { formula: 0, default_value: 0 };
|
|
447
|
+
const byStatus = { ERROR: 0, WARN: 0, PASS: 0 };
|
|
448
|
+
const byVersion = { current: 0, history: 0 };
|
|
449
|
+
const errorFormIds = new Set();
|
|
450
|
+
const warnFormIds = new Set();
|
|
451
|
+
|
|
452
|
+
const allErrors = [];
|
|
453
|
+
const allWarnings = [];
|
|
454
|
+
const allFormulasDeduped = [];
|
|
455
|
+
|
|
456
|
+
for (const form of forms) {
|
|
457
|
+
const { allFormulas, issues } = scanForm(form);
|
|
458
|
+
|
|
459
|
+
if (allFormulas.length > 0) {
|
|
460
|
+
formsWithFormula++;
|
|
461
|
+
totalFormulaFields += allFormulas.length;
|
|
462
|
+
for (const f of allFormulas) {
|
|
463
|
+
bySource[f.formulaSource] = (bySource[f.formulaSource] || 0) + 1;
|
|
464
|
+
byStatus[f.status] = (byStatus[f.status] || 0) + 1;
|
|
465
|
+
byVersion[f.formVersionType] = (byVersion[f.formVersionType] || 0) + 1;
|
|
466
|
+
}
|
|
467
|
+
allFormulasDeduped.push(...allFormulas);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (issues.length > 0) {
|
|
471
|
+
for (const issue of issues) {
|
|
472
|
+
if (issue.level === 'ERROR') {
|
|
473
|
+
errorFormIds.add(issue.formId);
|
|
474
|
+
allErrors.push(issue);
|
|
475
|
+
} else if (issue.level === 'WARN') {
|
|
476
|
+
warnFormIds.add(issue.formId);
|
|
477
|
+
allWarnings.push(issue);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 补充 W2 到 byStatus(W2 不走 allFormulas 流程,需单独计入)
|
|
484
|
+
const w2Count = allWarnings.filter(i => i.ruleId === 'W2').length;
|
|
485
|
+
if (w2Count > 0) {
|
|
486
|
+
byStatus.WARN = (byStatus.WARN || 0) + w2Count;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 统计 ERROR 的版本分布
|
|
490
|
+
const errorsByVersion = { current: 0, history: 0 };
|
|
491
|
+
for (const e of allErrors) {
|
|
492
|
+
errorsByVersion[e.formVersionType] = (errorsByVersion[e.formVersionType] || 0) + 1;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 将已知问题从 allErrors 中分离出来
|
|
496
|
+
const realErrors = [];
|
|
497
|
+
const knownIssuesList = [];
|
|
498
|
+
for (const e of allErrors) {
|
|
499
|
+
const key = `${e.formId}::${e.fieldCode}`;
|
|
500
|
+
const known = KNOWN_ISSUES[key];
|
|
501
|
+
if (known) {
|
|
502
|
+
knownIssuesList.push({
|
|
503
|
+
...e,
|
|
504
|
+
level: 'KNOWN_ISSUE',
|
|
505
|
+
category: known.category,
|
|
506
|
+
resolution: known.resolution,
|
|
507
|
+
});
|
|
508
|
+
} else {
|
|
509
|
+
realErrors.push(e);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 重算 ERROR 相关统计(排除已知问题)
|
|
514
|
+
const realErrorFormIds = new Set(realErrors.map(e => e.formId));
|
|
515
|
+
const realErrorsByVersion = { current: 0, history: 0 };
|
|
516
|
+
for (const e of realErrors) {
|
|
517
|
+
realErrorsByVersion[e.formVersionType] = (realErrorsByVersion[e.formVersionType] || 0) + 1;
|
|
518
|
+
}
|
|
519
|
+
byStatus.ERROR = realErrors.length;
|
|
520
|
+
byStatus.KNOWN_ISSUE = knownIssuesList.length;
|
|
521
|
+
|
|
522
|
+
// 生成报告
|
|
523
|
+
const report = {
|
|
524
|
+
scanDate: isoDate,
|
|
525
|
+
mongoUrl: redactMongoUrl(mongoUrl),
|
|
526
|
+
summary: {
|
|
527
|
+
totalForms,
|
|
528
|
+
formsWithFormula,
|
|
529
|
+
totalFormulaFields,
|
|
530
|
+
bySource,
|
|
531
|
+
byVersion,
|
|
532
|
+
byStatus,
|
|
533
|
+
errorsByVersion: realErrorsByVersion,
|
|
534
|
+
errorFormCount: realErrorFormIds.size,
|
|
535
|
+
warnFormCount: warnFormIds.size,
|
|
536
|
+
knownIssueCount: knownIssuesList.length,
|
|
537
|
+
},
|
|
538
|
+
errors: realErrors,
|
|
539
|
+
knownIssues: knownIssuesList,
|
|
540
|
+
warnings: allWarnings,
|
|
541
|
+
allFormulas: allFormulasDeduped,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8');
|
|
545
|
+
|
|
546
|
+
// 控制台输出简要统计
|
|
547
|
+
console.log('');
|
|
548
|
+
console.log('扫描完成');
|
|
549
|
+
console.log(`总表单数: ${totalForms}`);
|
|
550
|
+
console.log(`含公式字段的表单: ${formsWithFormula}`);
|
|
551
|
+
console.log(`公式字段总数: ${totalFormulaFields}`);
|
|
552
|
+
console.log(` - formula 字段: ${bySource.formula || 0}`);
|
|
553
|
+
console.log(` - default_value 公式字段: ${bySource.default_value || 0}`);
|
|
554
|
+
console.log(` - 来自 current 版本: ${byVersion.current || 0}`);
|
|
555
|
+
console.log(` - 来自 history 版本: ${byVersion.history || 0}`);
|
|
556
|
+
console.log('');
|
|
557
|
+
console.log('检测结果:');
|
|
558
|
+
console.log(` ERROR: ${realErrors.length} 条(涉及 ${realErrorFormIds.size} 个表单,current: ${realErrorsByVersion.current || 0},history: ${realErrorsByVersion.history || 0})`);
|
|
559
|
+
console.log(` KNOWN_ISSUE: ${knownIssuesList.length} 条(经人工审核确认无需代码修正)`);
|
|
560
|
+
console.log(` WARN: ${byStatus.WARN || 0} 条(涉及 ${warnFormIds.size} 个表单)`);
|
|
561
|
+
console.log(` PASS: ${byStatus.PASS || 0} 条`);
|
|
562
|
+
console.log('');
|
|
563
|
+
console.log(`报告已输出到: ${reportRelPath}`);
|
|
564
|
+
|
|
565
|
+
} finally {
|
|
566
|
+
await client.close();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
main().catch((err) => {
|
|
571
|
+
console.error('扫描失败:', err.message);
|
|
572
|
+
process.exit(1);
|
|
573
|
+
});
|
|
@@ -89,6 +89,44 @@ const tableFieldMapCases = [
|
|
|
89
89
|
input: 'sum({出票金额})',
|
|
90
90
|
tableFieldMap: { '出票金额': '表格(1)' },
|
|
91
91
|
expected: "${SUM(ARRAYMAP(表格_1, item => item['出票金额']))}"
|
|
92
|
+
},
|
|
93
|
+
// Bug 8: 全角括号 sum({x})
|
|
94
|
+
{
|
|
95
|
+
name: '全角括号聚合: sum({金额(替换)})子表字段',
|
|
96
|
+
input: 'sum({金额(替换)})',
|
|
97
|
+
tableFieldMap: { '金额(替换)': '表格' },
|
|
98
|
+
expected: "${SUM(ARRAYMAP(表格, item => item['金额(替换)']))}"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: '全角括号聚合: sum({amount})非子表字段',
|
|
102
|
+
input: 'sum({amount})',
|
|
103
|
+
tableFieldMap: null,
|
|
104
|
+
expected: '${SUM(amount)}'
|
|
105
|
+
},
|
|
106
|
+
// Bug 8: 缺失括号 sum{x}
|
|
107
|
+
{
|
|
108
|
+
name: '缺失括号: sum{含税金额} 子表字段',
|
|
109
|
+
input: 'sum{含税金额}',
|
|
110
|
+
tableFieldMap: { '含税金额': '明细' },
|
|
111
|
+
expected: "${SUM(ARRAYMAP(明细, item => item['含税金额']))}"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: '缺失括号: sum{(金额)} 子表字段',
|
|
115
|
+
input: 'sum{(金额)}',
|
|
116
|
+
tableFieldMap: { '(金额)': '明细' },
|
|
117
|
+
expected: "${SUM(ARRAYMAP(明细, item => item['(金额)']))}"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: '缺失括号: sum{概算总价} 非子表字段',
|
|
121
|
+
input: 'sum{概算总价}',
|
|
122
|
+
tableFieldMap: null,
|
|
123
|
+
expected: '${SUM(概算总价)}'
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: '缺失括号: sum{项目预算金额} 子表字段',
|
|
127
|
+
input: 'sum{项目预算金额}',
|
|
128
|
+
tableFieldMap: { '项目预算金额': '费用明细' },
|
|
129
|
+
expected: "${SUM(ARRAYMAP(费用明细, item => item['项目预算金额']))}"
|
|
92
130
|
}
|
|
93
131
|
];
|
|
94
132
|
|
|
@@ -266,6 +266,7 @@ async function updateForm(formId, form, forms, flows, currentUserId) {
|
|
|
266
266
|
|
|
267
267
|
current.events = form["current"]["events"];
|
|
268
268
|
|
|
269
|
+
current.formTitle = form["current"]["formTitle"];
|
|
269
270
|
current.tableTitleColor = form["current"]["tableTitleColor"];
|
|
270
271
|
current.tableBorderColor = form["current"]["tableBorderColor"];
|
|
271
272
|
current.tableShowOuterBorder = form["current"]["tableShowOuterBorder"];
|
|
@@ -28,6 +28,14 @@ const mapFormula = (formula, tableFieldMap) => {
|
|
|
28
28
|
if (trimmedFormula === '{now}') return '${NOW()}';
|
|
29
29
|
|
|
30
30
|
let newFormula = formula;
|
|
31
|
+
|
|
32
|
+
// 预处理:修正生产数据中的非标准聚合函数语法
|
|
33
|
+
// B: 全角括号 sum({x}) → sum({x})
|
|
34
|
+
newFormula = newFormula.replace(/(sum|average|count|max|min|numToRMB)\s*(/ig, '$1(');
|
|
35
|
+
newFormula = newFormula.replace(/\})/g, '})');
|
|
36
|
+
// C: 缺失括号 sum{x} → sum({x})
|
|
37
|
+
newFormula = newFormula.replace(/(sum|average|count|max|min|numToRMB)\{([^{}]*)\}/ig, '$1({$2})');
|
|
38
|
+
|
|
31
39
|
const isFunction = newFormula.match(/(sum|average|count|max|min|numToRMB)\s*\(/i);
|
|
32
40
|
const hasFieldRef = newFormula.match(/\{[^{}]+\}/);
|
|
33
41
|
const isOperator = newFormula.match(/[\+\-\*\/]/) && hasFieldRef && newFormula.indexOf('}.') < 0;
|