weaver-e9-assistant-cli 1.1.4 → 1.1.11

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.
@@ -1,6 +1,479 @@
1
1
  import { createApiFromProfile } from './client.js';
2
2
  import { getWfList } from './workflow.js';
3
3
  import { todayStr, nowTimeStr } from './util.js';
4
+ import { findNonEditableFields } from './wfstart.js';
5
+ import { getBrowserOptions, isCustomBrowserField, browserNameSeparator, normalizeSelectValue } from './wfform.js';
6
+ import { applyFormulas } from './formula.js';
7
+ import { uploadFile, fieldListType, uploadFieldValue } from './wfupload.js';
8
+ import {
9
+ parseDetailFileSpec, fetchTodoDetailRows, planTodoDetails, buildDetailBodyKeys,
10
+ countDetailUnEmpty, countMainUnEmpty, isDetailValueKey, isDetailTableKey,
11
+ detailIndexFromKey, normalizeFieldId,
12
+ } from './tododetail.js';
13
+
14
+ /**
15
+ * 构造待办当前节点的公式上下文(主表当前值 + 明细当前行 + 本次 --field-values 覆盖),
16
+ * 执行 formulaCfg 后返回需要写进提交体的公式字段值。
17
+ */
18
+ function buildTodoFormulaOverrides(j0, fieldValues) {
19
+ const maindata = j0?.maindata ?? {};
20
+ const values = {};
21
+ for (const [key, cell] of Object.entries(maindata)) {
22
+ const m = String(key).match(/^field(-?\d+)$/);
23
+ if (m)
24
+ values[m[1]] = cell?.value ?? '';
25
+ }
26
+ for (const [key, value] of Object.entries(fieldValues ?? {})) {
27
+ const raw = String(key).replace(/^field/, '');
28
+ if (/^\d+$/.test(raw))
29
+ values[raw] = value;
30
+ }
31
+ const details = [];
32
+ for (const [tableKey, table] of Object.entries(j0?.tableInfo ?? {})) {
33
+ if (!tableKey.startsWith('detail_'))
34
+ continue;
35
+ const index = Number(tableKey.slice('detail_'.length)) - 1;
36
+ const fieldIds = new Set(Object.keys(table?.fieldinfomap ?? {}));
37
+ const rowsMap = new Map();
38
+ for (const [key, cell] of Object.entries(maindata)) {
39
+ const m = String(key).match(/^field(\d+)_(\d+)$/);
40
+ if (!m || !fieldIds.has(m[1]))
41
+ continue;
42
+ const row = Number(m[2]);
43
+ if (!rowsMap.has(row))
44
+ rowsMap.set(row, {});
45
+ rowsMap.get(row)[m[1]] = cell?.value ?? '';
46
+ }
47
+ for (const [key, value] of Object.entries(fieldValues ?? {})) {
48
+ const m = String(key).replace(/^field/, '').match(/^(\d+)_(\d+)$/);
49
+ if (!m || !fieldIds.has(m[1]))
50
+ continue;
51
+ const row = Number(m[2]);
52
+ if (!rowsMap.has(row))
53
+ rowsMap.set(row, {});
54
+ rowsMap.get(row)[m[1]] = value;
55
+ }
56
+ const rows = [...rowsMap.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row);
57
+ if (rows.length > 0)
58
+ details.push({ index, rows });
59
+ }
60
+ const result = applyFormulas(j0, values, details);
61
+ const overrides = {};
62
+ for (const item of result.applied) {
63
+ const fieldId = item.dest.split('.').pop();
64
+ if (item.table === 'main')
65
+ overrides[`field${fieldId}`] = item.value;
66
+ else
67
+ overrides[`field${fieldId}_${item.row}`] = item.value;
68
+ }
69
+ return { overrides, result };
70
+ }
71
+
72
+ /**
73
+ * 只做「当前节点字段属性」校验,不提交——供 CLI dry-run 提前拦截,避免"预览通过、提交才报错"。
74
+ * 返回被违规填值的只读/隐藏字段清单(空数组 = 通过)。
75
+ */
76
+ export async function validateFieldValuesAtNode(cfg, requestId, fieldValues) {
77
+ if (!fieldValues || !Object.keys(fieldValues).length)
78
+ return [];
79
+ const api = await createApiFromProfile(cfg);
80
+ const r = await api.post('/api/workflow/reqform/loadForm', { iscreate: '0', requestid: requestId });
81
+ return findNonEditableFields({ tables: nodeFieldTables(r.json ?? {}) }, fieldValues);
82
+ }
83
+
84
+ /**
85
+ * 从 loadForm 响应的 tableInfo 抽出字段定义,供 findNonEditableFields 判定。
86
+ * ⚠️ 这里用的是 **iscreate=0(处理态)** 的属性——即**当前节点**的字段权限,
87
+ * 而非 create 路径的表单设计态(iscreate=1)。
88
+ */
89
+ function nodeFieldTables(json) {
90
+ const tables = [];
91
+ for (const key of Object.keys(json?.tableInfo ?? {})) {
92
+ const fmap = json.tableInfo[key]?.fieldinfomap;
93
+ if (!fmap || typeof fmap !== 'object')
94
+ continue;
95
+ tables.push({
96
+ key,
97
+ attr: json.tableInfo[key]?.detailtableattr ?? null,
98
+ fields: Object.values(fmap).map((f) => ({
99
+ id: String(f?.fieldid ?? ''),
100
+ label: String(f?.fieldlabel ?? ''),
101
+ // 服务端已带出的默认值/当前值:只读但有默认值时展示层会提示"自动带出"
102
+ currentValue: (() => {
103
+ const v = json?.maindata?.[`field${f?.fieldid}`]?.value;
104
+ return (v === undefined || v === null || v === '') ? undefined : String(v);
105
+ })(),
106
+ // 字段属性:viewattr 0隐藏/1只读/2可编辑/3必填 + isonlyshow/isedit/isview(判据见 fieldNonEditableReason)。
107
+ viewattr: (f?.viewattr === undefined || f?.viewattr === null || f?.viewattr === '') ? null : Number(f.viewattr),
108
+ isonlyshow: (f?.isonlyshow === undefined || f?.isonlyshow === null || f?.isonlyshow === '') ? null : Number(f.isonlyshow),
109
+ isedit: (f?.isedit === undefined || f?.isedit === null || f?.isedit === '') ? null : Number(f.isedit),
110
+ isview: (f?.isview === undefined || f?.isview === null || f?.isview === '') ? null : Number(f.isview),
111
+ // 明细规划/附件上传需要:字段类型、明细表归属、附件目录(fileattr.docCategory)
112
+ htmltype: (f?.htmltype === undefined || f?.htmltype === null || f?.htmltype === '') ? null : Number(f.htmltype),
113
+ detailtype: (f?.detailtype === undefined || f?.detailtype === null || f?.detailtype === '') ? null : String(f.detailtype),
114
+ table: key,
115
+ raw: f ?? {},
116
+ })),
117
+ });
118
+ }
119
+ return tables;
120
+ }
121
+
122
+ /** 字段元数据:fieldId → { id,label,table,htmltype,detailtype,raw } + 明细表列表。 */
123
+ function collectFormMeta(json) {
124
+ const fieldById = new Map();
125
+ const detailTables = [];
126
+ for (const [key, table] of Object.entries(json?.tableInfo ?? {})) {
127
+ const fmap = table?.fieldinfomap;
128
+ if (fmap && typeof fmap === 'object') {
129
+ for (const [fid, f] of Object.entries(fmap)) {
130
+ fieldById.set(String(fid), {
131
+ id: String(fid),
132
+ label: f?.fieldlabel,
133
+ table: key,
134
+ htmltype: (f?.htmltype === undefined || f?.htmltype === null || f?.htmltype === '') ? undefined : Number(f.htmltype),
135
+ detailtype: (f?.detailtype === undefined || f?.detailtype === null || f?.detailtype === '') ? undefined : String(f.detailtype),
136
+ raw: f ?? {},
137
+ });
138
+ }
139
+ }
140
+ if (isDetailTableKey(key)) {
141
+ detailTables.push({
142
+ key,
143
+ idx: detailIndexFromKey(key),
144
+ attr: table?.detailtableattr ?? {},
145
+ });
146
+ }
147
+ }
148
+ return { fieldById, detailTables };
149
+ }
150
+
151
+ /** 是否涉及明细行(明细键 / 明细表 --detail-file / 明细附件)。 */
152
+ function hasDetailInputs(fieldValues, fieldById, detailFile, files) {
153
+ if ((detailFile ?? []).length > 0)
154
+ return true;
155
+ if (Object.keys(fieldValues ?? {}).some((k) => isDetailValueKey(k, fieldById)))
156
+ return true;
157
+ return (files ?? []).some((fl) => {
158
+ const m = String(fl?.fieldId ?? '').match(/^(?:field)?(\d+)(?:_(\d+))?$/);
159
+ return Boolean(m && isDetailTableKey(fieldById.get(m[1])?.table));
160
+ });
161
+ }
162
+
163
+ /** 无明细规划时的主表值:排除真正的明细键,保留旧用法(主表字段带 _<row> 后缀)。 */
164
+ function buildLegacyMainValues(fieldValues, fieldById) {
165
+ const out = {};
166
+ for (const [k, v] of Object.entries(fieldValues ?? {})) {
167
+ if (isDetailValueKey(k, fieldById))
168
+ continue;
169
+ out[k.startsWith('field') ? k.slice('field'.length) : k] = v;
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * 当前主表业务字段值(id>0)。
176
+ * 浏览器 generateFormData 会提交**全部主表字段**;只发改动字段会被服务端判定
177
+ * 「主表数据信息异常/存在数据丢失风险」(674 真机实证),因此以当前值为底再覆盖改动。
178
+ */
179
+ function currentMainValues(j1) {
180
+ const out = {};
181
+ for (const [key, cell] of Object.entries(j1?.maindata ?? {})) {
182
+ const m = String(key).match(/^field(-?\d+)$/);
183
+ if (!m)
184
+ continue;
185
+ const id = Number(m[1]);
186
+ if (id <= 0)
187
+ continue; // 系统字段 -1/-2/-4/-9/-10 由 requestname/remark/field-10 等专门处理
188
+ out[m[1]] = cell?.value === undefined || cell?.value === null ? '' : String(cell.value);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** 主表选择框/自定义浏览框规范化;返回需要额外写入 body 的 name 键。 */
194
+ async function normalizeMainValues(cfg, workflowId, tableInfo, mainValues, browserParams) {
195
+ const overrides = {};
196
+ const custom = await resolveCustomBrowserValues(cfg, workflowId, tableInfo, mainValues, browserParams);
197
+ for (const [k, v] of Object.entries(custom)) {
198
+ if (k.replace(/^field/, '').endsWith('name'))
199
+ overrides[k] = v;
200
+ else
201
+ mainValues[normalizeFieldId(k)] = v;
202
+ }
203
+ const select = resolveSelectValues(tableInfo, mainValues);
204
+ for (const [k, v] of Object.entries(select)) {
205
+ overrides[k] = v;
206
+ mainValues[normalizeFieldId(k)] = v;
207
+ }
208
+ return overrides;
209
+ }
210
+
211
+ /** 明细改动行的选择框 / 浏览框规范化(显示名→ID,并补 name)。 */
212
+ async function normalizeDetailPlan(cfg, workflowId, fieldById, plan, browserParams) {
213
+ const optionCache = new Map();
214
+ for (const t of plan?.tables ?? []) {
215
+ // 全量存活行都要规范化:浏览器提交时每行都会带 field<id>_<row> 与 name
216
+ for (const rowIndex of t.rowOrder ?? [...t.rows.keys()].sort((a, b) => a - b)) {
217
+ const row = t.rows.get(rowIndex);
218
+ for (const [fid, raw] of Object.entries(row.send ?? {})) {
219
+ const field = fieldById.get(String(fid));
220
+ if (!field)
221
+ continue;
222
+ const value = String(raw ?? '').trim();
223
+ if (value === '')
224
+ continue;
225
+ if (field.htmltype === 5) {
226
+ const normalized = normalizeSelectValue(field, value);
227
+ if (normalized !== undefined) {
228
+ row.send[fid] = normalized;
229
+ row.values[fid] = normalized;
230
+ }
231
+ continue;
232
+ }
233
+ if (field.htmltype !== 3)
234
+ continue;
235
+ // 日期/时间浏览框直接填值,不做选项转换
236
+ if (/^\d{4}-\d{2}-\d{2}/.test(value) || /^\d{2}:\d{2}/.test(value))
237
+ continue;
238
+ try {
239
+ let opts = optionCache.get(String(fid));
240
+ if (!opts) {
241
+ opts = await getBrowserOptions(cfg, workflowId, String(fid), 500, undefined, {}, null, {
242
+ detailtype: field.raw?.detailtype,
243
+ fielddbtype: field.raw?.fielddbtype,
244
+ params: browserParams,
245
+ });
246
+ optionCache.set(String(fid), opts);
247
+ }
248
+ if (!Array.isArray(opts) || opts.length === 0)
249
+ continue;
250
+ const rawValues = value.split(',').map((x) => x.trim()).filter(Boolean);
251
+ const ids = [];
252
+ const names = [];
253
+ let resolvedAll = true;
254
+ for (const v of rawValues) {
255
+ const byId = opts.find((o) => String(o.id) === v);
256
+ const sameName = opts.filter((o) => String(o.name) === v);
257
+ const hit = byId ?? (sameName.length === 1 ? sameName[0] : null);
258
+ if (!hit) { resolvedAll = false; break; }
259
+ ids.push(String(hit.id));
260
+ names.push(String(hit.name));
261
+ }
262
+ if (!resolvedAll)
263
+ continue;
264
+ row.send[fid] = ids.join(',');
265
+ row.values[fid] = ids.join(',');
266
+ row.names = row.names ?? {};
267
+ row.names[fid] = names.join(browserNameSeparator(field));
268
+ }
269
+ catch { /* 选项解析失败不阻断提交 */ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 明细公式:用主表当前值 + 本次改动 + 明细规划行做四则运算,
277
+ * 结果写回 mainValues / plan 行(并标记为改动行,确保进入提交体)。
278
+ */
279
+ function applyPlanFormulas(j1, mainValues, plan) {
280
+ const values = {};
281
+ for (const [k, cell] of Object.entries(j1?.maindata ?? {})) {
282
+ const m = String(k).match(/^field(-?\d+)$/);
283
+ if (m)
284
+ values[m[1]] = cell?.value ?? '';
285
+ }
286
+ Object.assign(values, mainValues);
287
+ const details = plan.tables.map((t) => {
288
+ const rowIndexes = [...t.rows.keys()].sort((a, b) => a - b);
289
+ return { index: t.idx, rowIndexes, rows: rowIndexes.map((ri) => ({ ...t.rows.get(ri).values })) };
290
+ });
291
+ const result = applyFormulas(j1, values, details.map(({ index, rows }) => ({ index, rows })));
292
+ const applied = [];
293
+ for (const item of result.applied) {
294
+ const destId = String(item.dest).split('.').pop();
295
+ if (item.table === 'main') {
296
+ mainValues[destId] = item.value;
297
+ applied.push(item);
298
+ continue;
299
+ }
300
+ const idx = Number(String(item.table).replace(/^detail_/, '')) - 1;
301
+ const detail = details.find((x) => x.index === idx);
302
+ const table = plan.tables.find((x) => x.idx === idx);
303
+ const rowIndex = detail?.rowIndexes?.[item.row];
304
+ if (!table || rowIndex === undefined)
305
+ continue;
306
+ const row = table.rows.get(rowIndex);
307
+ row.values[destId] = item.value;
308
+ row.send[destId] = item.value;
309
+ row.changed = true;
310
+ table.changedRowIndexes.add(rowIndex);
311
+ table.changedKeys.add(`field${destId}_${rowIndex}`);
312
+ applied.push(item);
313
+ }
314
+ return { applied, skipped: result.skipped };
315
+ }
316
+
317
+ /** dry-run:附件字段写入占位值(不真正上传)。 */
318
+ function applyFilePlaceholders(fileTargets, mainValues, plan) {
319
+ const ph = (p) => `<待上传:${String(p ?? '').split(/[\\/]/).pop() || 'file'}>`;
320
+ for (const target of fileTargets ?? []) {
321
+ const value = ph(target.filePath);
322
+ if (target.rowIndex === null || target.rowIndex === undefined) {
323
+ mainValues[target.fieldId] = value;
324
+ continue;
325
+ }
326
+ const idx = detailIndexFromKey(target.meta?.table);
327
+ const table = plan?.tables.find((x) => x.idx === idx);
328
+ const row = table?.rows.get(target.rowIndex);
329
+ if (row) {
330
+ row.values[target.fieldId] = value;
331
+ row.send[target.fieldId] = value;
332
+ }
333
+ }
334
+ }
335
+
336
+ /** 无明细规划时,把 --file 转成主表附件目标(明细附件此时应已走规划分支)。 */
337
+ function mainFileTargets(meta, files) {
338
+ const out = [];
339
+ const seen = new Set();
340
+ for (const fl of files ?? []) {
341
+ const raw = String(fl?.fieldId ?? '').trim();
342
+ const m = raw.match(/^(?:field)?(\d+)$/);
343
+ if (!m)
344
+ throw new Error(`--file 字段目标非法:${raw}(主表附件应为 <字段id>=<路径>)`);
345
+ const fid = m[1];
346
+ if (seen.has(fid))
347
+ throw new Error(`同一附件字段重复指定文件:field${fid}(当前仅支持单文件)`);
348
+ seen.add(fid);
349
+ const field = meta.fieldById.get(fid);
350
+ if (!field)
351
+ throw new Error(`附件字段 ${fid} 不在当前表单`);
352
+ if (isDetailTableKey(field.table))
353
+ throw new Error(`明细附件必须写成 <字段id>_<行号>(如 ${fid}_0=${fl.filePath})`);
354
+ if (field.htmltype !== 6)
355
+ throw new Error(`字段 ${field.label ?? fid}(${fid})不是附件/图片字段(htmltype=${field.htmltype ?? '?'})`);
356
+ out.push({ key: `field${fid}`, fieldId: fid, rowIndex: null, filePath: fl.filePath, meta: field });
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /** 真正上传附件(全部校验通过后才调用)。 */
362
+ async function uploadTodoFiles(cfg, prep) {
363
+ const targets = prep.plan?.fileTargets ?? mainFileTargets(prep.meta, prep.files);
364
+ for (const target of targets) {
365
+ const field = target.meta ?? prep.meta.fieldById.get(target.fieldId);
366
+ const category = field?.raw?.fileattr?.docCategory || undefined;
367
+ // 附件字段走 list、图片字段走 img(E9 前端按 detailtype=2 判定),不能只看扩展名
368
+ const listType = target.listType ?? fieldListType(field);
369
+ const up = await uploadFile(cfg, {
370
+ workflowId: String(prep.wfId ?? ''),
371
+ fieldId: String(target.fieldId),
372
+ filePath: target.filePath,
373
+ listType,
374
+ category,
375
+ });
376
+ const fileValue = uploadFieldValue(up, listType);
377
+ if (target.rowIndex === null || target.rowIndex === undefined) {
378
+ prep.mainValues[target.fieldId] = fileValue;
379
+ continue;
380
+ }
381
+ const idx = detailIndexFromKey(field?.table);
382
+ const table = prep.plan?.tables.find((x) => x.idx === idx);
383
+ const row = table?.rows.get(target.rowIndex);
384
+ if (!row)
385
+ throw new Error(`明细表 ${table?.key ?? ''} 第 ${target.rowIndex} 行不存在,附件无法写入`);
386
+ row.values[target.fieldId] = fileValue;
387
+ row.send[target.fieldId] = fileValue;
388
+ row.changed = true;
389
+ table.changedRowIndexes.add(target.rowIndex);
390
+ table.changedKeys.add(`field${target.fieldId}_${target.rowIndex}`);
391
+ }
392
+ }
393
+ /**
394
+ * 解析 --field-values 里的自定义浏览框(htmltype=3, detailtype=161/162):
395
+ * 补 field<id>name(多选分隔符 `##~~@@~~##`),并把用户传的「显示名」规范化为选项 id。
396
+ * E9 前端 generateFormData 实证:该类字段提交后展示依赖 field<id>name;
397
+ * 只发 field<id> 会导致提交后看起来「数据为空」。
398
+ * 解析失败不阻断提交(保留用户原值),返回可覆盖 body 的键值。
399
+ */
400
+ async function resolveCustomBrowserValues(cfg, workflowId, tableInfo, fieldValues, browserParams) {
401
+ const meta = new Map();
402
+ for (const table of Object.values(tableInfo ?? {}))
403
+ for (const [fid, f] of Object.entries(table?.fieldinfomap ?? {}))
404
+ meta.set(String(fid), f);
405
+ const overrides = {};
406
+ for (const [k, v] of Object.entries(fieldValues ?? {})) {
407
+ const rawKey = String(k).replace(/^field/, '');
408
+ const m = rawKey.match(/^(\d+)(_\d+)?$/);
409
+ if (!m)
410
+ continue;
411
+ const fid = m[1];
412
+ const suffix = m[2] ?? '';
413
+ const f = meta.get(fid);
414
+ if (!f || !isCustomBrowserField(f))
415
+ continue;
416
+ const rawValues = String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
417
+ if (rawValues.length === 0)
418
+ continue;
419
+ try {
420
+ // 解析名称时取大页:自定义浏览框的选中项可能不在前 10/100 条
421
+ // metaOverride 走第 8 位(6=relateValues 7=formData 均为空)
422
+ const opts = await getBrowserOptions(cfg, workflowId, fid, Math.max(500, rawValues.length), undefined, {}, null, {
423
+ detailtype: f.detailtype,
424
+ fielddbtype: f.fielddbtype,
425
+ params: browserParams,
426
+ });
427
+ const ids = [];
428
+ const names = [];
429
+ let resolvedAll = true;
430
+ for (const value of rawValues) {
431
+ const byId = opts.find((o) => String(o.id) === value);
432
+ const sameName = opts.filter((o) => String(o.name) === value);
433
+ const hit = byId ?? (sameName.length === 1 ? sameName[0] : null);
434
+ if (!hit) { resolvedAll = false; break; }
435
+ ids.push(String(hit.id));
436
+ names.push(String(hit.name));
437
+ }
438
+ if (!resolvedAll)
439
+ continue;
440
+ overrides[`field${fid}${suffix}`] = ids.join(',');
441
+ overrides[`field${fid}${suffix}name`] = names.join(browserNameSeparator(f));
442
+ }
443
+ catch { /* 选项解析失败不阻断提交 */ }
444
+ }
445
+ return overrides;
446
+ }
447
+
448
+ /**
449
+ * 规范化 --field-values 里的选择框(htmltype=5):
450
+ * 允许传选项显示名,按 selectattr.selectitemlist 转成 selectvalue。
451
+ * 不转的话服务端存的是显示名,回显时匹配不到选项 → 下拉框显示为空。
452
+ * 全部值都能匹配才覆盖;否则保留用户原值。
453
+ */
454
+ function resolveSelectValues(tableInfo, fieldValues) {
455
+ const meta = new Map();
456
+ for (const table of Object.values(tableInfo ?? {}))
457
+ for (const [fid, f] of Object.entries(table?.fieldinfomap ?? {}))
458
+ meta.set(String(fid), f);
459
+ const overrides = {};
460
+ for (const [k, v] of Object.entries(fieldValues ?? {})) {
461
+ const rawKey = String(k).replace(/^field/, '');
462
+ const m = rawKey.match(/^(\d+)(_\d+)?$/);
463
+ if (!m)
464
+ continue;
465
+ const fid = m[1];
466
+ const suffix = m[2] ?? '';
467
+ const f = meta.get(fid);
468
+ if (!f || Number(f.htmltype) !== 5)
469
+ continue;
470
+ const normalized = normalizeSelectValue(f, v);
471
+ if (normalized !== undefined)
472
+ overrides[`field${fid}${suffix}`] = normalized;
473
+ }
474
+ return overrides;
475
+ }
476
+
4
477
  /**
5
478
  * 提交待办到下一节点。POST requestOperation(iscreate=0)。
6
479
  * 两步 loadForm 拿签名(先取 wf+node,再带 wf+node 取 per-request 签名)。
@@ -8,8 +481,15 @@ import { todayStr, nowTimeStr } from './util.js';
8
481
  * 修复(P0-5):submit_token key 旧硬编码 `1_<rid>_...`(前缀=1=sysadmin),非 sysadmin 取不到 token;
9
482
  * 改用 `p.f_weaver_belongto_userid` 动态拼 key,并兜底 `1_` 旧前缀,与 wfstart.js create 路径一致。
10
483
  */
11
- export async function submitTodo(cfg, opts) {
12
- const { requestId } = opts;
484
+ /**
485
+ * 准备待办提交(dry-run 与真提交共用):
486
+ * loadForm ×2 → 字段权限校验 → 明细 detailData/规划 → 选项规范化 → 公式。
487
+ * 不做任何上传,返回值可交给 buildTodoSubmitBody / uploadTodoFiles。
488
+ */
489
+ export async function prepareTodoSubmission(cfg, opts) {
490
+ const requestId = String(opts.requestId ?? '');
491
+ if (!requestId)
492
+ throw new Error('缺少 requestId');
13
493
  const remarkText = opts.remark ?? 'OK';
14
494
  const remarkHtml = remarkText.split(/\r?\n/).map((l) => `<p>${l}</p>`).join('');
15
495
  const api = await createApiFromProfile(cfg);
@@ -20,21 +500,161 @@ export async function submitTodo(cfg, opts) {
20
500
  const sp0 = j0.submitParams ?? {};
21
501
  const wfId = String(p0.workflowid ?? '');
22
502
  const nodeId = String(p0.nodeid ?? '');
503
+ // ★字段属性硬规则(当前节点):--field-values/--file/明细改动只允许动该节点可编辑的字段。
504
+ // 依据本次 iscreate=0 的 loadForm(处理态=当前节点字段权限)。
505
+ const assertEditable = (json, extra = {}) => {
506
+ const fv = { ...(opts.fieldValues ?? {}), ...extra };
507
+ if (!Object.keys(fv).length)
508
+ return;
509
+ const bad = findNonEditableFields({ tables: nodeFieldTables(json) }, fv);
510
+ if (bad.length)
511
+ throw new Error(`拒绝填写当前节点禁止编辑的字段:${bad.map((b) => `${b.label || b.id}(${b.reason}${b.currentValue ? `, 已有默认值 ${b.currentValue} 自动带出` : ''})`).join('、')}。本节点只允许修改「可编辑」字段,不得绕过节点字段权限自行填值。`);
512
+ };
513
+ assertEditable(j0);
23
514
  // 第二步 loadForm(带 wf+node)→ 拿 per-request 签名
24
515
  let p = p0;
25
516
  let sp = sp0;
517
+ let j1 = j0;
26
518
  if (wfId && nodeId) {
27
519
  const r1 = await api.post('/api/workflow/reqform/loadForm', { iscreate: '0', requestid: requestId, workflowid: wfId, nodeid: nodeId });
28
- const j1 = r1.json ?? {};
520
+ j1 = r1.json ?? {};
521
+ // 第二次 loadForm 才是带 wf+node 的最终节点权限;再校验一次,防止两次响应权限位不一致时漏拦
522
+ assertEditable(j1);
29
523
  p = j1.params ?? p0;
30
524
  sp = j1.submitParams ?? sp0;
31
525
  }
526
+ const tableInfo = j1.tableInfo ?? j0.tableInfo ?? {};
527
+ const meta = collectFormMeta(j1);
528
+ const detailFile = parseDetailFileSpec(opts.detailFile ?? []);
529
+ const files = opts.files ?? [];
530
+ const hasDetail = hasDetailInputs(opts.fieldValues, meta.fieldById, detailFile, files);
531
+ let plan = null;
532
+ let fileTargets = [];
533
+
534
+ if (hasDetail) {
535
+ const detailRows = await fetchTodoDetailRows(api, { ...p, requestid: requestId }, tableInfo);
536
+ plan = planTodoDetails({
537
+ tableInfo,
538
+ detailRows,
539
+ fieldValues: opts.fieldValues ?? {},
540
+ detailFile,
541
+ files,
542
+ });
543
+ // 明细改动字段 + 附件目标同样要过当前节点字段权限
544
+ const extra = {};
545
+ for (const t of plan.tables) {
546
+ for (const rowIndex of t.changedRowIndexes) {
547
+ const row = t.rows.get(rowIndex);
548
+ for (const fid of Object.keys(row.send ?? {}))
549
+ extra[`field${fid}_${rowIndex}`] = row.send[fid];
550
+ }
551
+ }
552
+ for (const target of plan.fileTargets)
553
+ extra[target.key] = '<file>';
554
+ assertEditable(j1, extra);
555
+ fileTargets = plan.fileTargets;
556
+ }
557
+ else if (files.length > 0) {
558
+ fileTargets = mainFileTargets(meta, files);
559
+ const extra = {};
560
+ for (const target of fileTargets)
561
+ extra[target.key] = '<file>';
562
+ assertEditable(j1, extra);
563
+ }
564
+
565
+ // 以当前主表全量值为底,再覆盖本次改动(与浏览器提交体一致,避免服务端判「数据丢失」)
566
+ const mainValues = currentMainValues(j1);
567
+ if (plan)
568
+ Object.assign(mainValues, plan.mainChanges);
569
+ else
570
+ Object.assign(mainValues, buildLegacyMainValues(opts.fieldValues, meta.fieldById));
571
+ const bodyOverrides = {};
572
+ const browserParams = {
573
+ ...p,
574
+ billid: String(sp.billid ?? p.billid ?? sp.formid ?? p.formid ?? ''),
575
+ formid: String(sp.formid ?? p.formid ?? ''),
576
+ isbill: String(sp.isbill ?? p.isbill ?? '1'),
577
+ };
578
+
579
+ // 选择框 / 浏览框规范化(主表 + 明细改动行)
580
+ if (plan) {
581
+ Object.assign(bodyOverrides, await normalizeMainValues(cfg, wfId, tableInfo, mainValues, browserParams));
582
+ await normalizeDetailPlan(cfg, wfId, meta.fieldById, plan, browserParams);
583
+ }
584
+ else {
585
+ const custom = await resolveCustomBrowserValues(cfg, wfId, tableInfo, opts.fieldValues ?? {}, browserParams);
586
+ const select = resolveSelectValues(tableInfo, opts.fieldValues ?? {});
587
+ Object.assign(bodyOverrides, custom, select);
588
+ for (const [k, v] of Object.entries(custom)) {
589
+ const id = normalizeFieldId(k);
590
+ if (!id.endsWith('name') && !id.includes('_'))
591
+ mainValues[id] = v;
592
+ }
593
+ for (const [k, v] of Object.entries(select)) {
594
+ const id = normalizeFieldId(k);
595
+ if (!id.includes('_'))
596
+ mainValues[id] = v;
597
+ }
598
+ }
599
+
600
+ // 公式字段
601
+ const formula = { applied: [], skipped: [] };
602
+ if (plan) {
603
+ const result = applyPlanFormulas(j1, mainValues, plan);
604
+ formula.applied.push(...result.applied);
605
+ formula.skipped.push(...result.skipped);
606
+ }
607
+ else {
608
+ const result = buildTodoFormulaOverrides(j0, opts.fieldValues ?? {});
609
+ formula.applied.push(...result.result.applied);
610
+ formula.skipped.push(...result.result.skipped);
611
+ for (const [k, v] of Object.entries(result.overrides ?? {})) {
612
+ bodyOverrides[k] = v;
613
+ const id = normalizeFieldId(k);
614
+ if (!id.includes('_'))
615
+ mainValues[id] = v;
616
+ }
617
+ }
618
+
619
+ if (opts.preview)
620
+ applyFilePlaceholders(fileTargets, mainValues, plan);
621
+
622
+ // existChangeRange 只记本次改动,不包含为通过完整性校验而全量重发的原值
623
+ const changedMain = new Set();
624
+ if (plan) {
625
+ for (const k of plan.changedMainKeys)
626
+ changedMain.add(k);
627
+ }
628
+ else {
629
+ for (const k of Object.keys(opts.fieldValues ?? {})) {
630
+ if (!isDetailValueKey(k, meta.fieldById))
631
+ changedMain.add(`field${normalizeFieldId(k)}`);
632
+ }
633
+ }
634
+ for (const target of fileTargets) {
635
+ if (target.rowIndex === null)
636
+ changedMain.add(target.key);
637
+ }
638
+ for (const item of formula.applied) {
639
+ if (item.table === 'main')
640
+ changedMain.add(`field${String(item.dest).split('.').pop()}`);
641
+ }
642
+
643
+ return {
644
+ api, requestId, remarkText, remarkHtml, j0, j1, p, sp, wfId, nodeId,
645
+ tableInfo, meta, plan, mainValues, bodyOverrides, files, fileTargets,
646
+ formula, changedMainKeys: [...changedMain], warnings: plan?.warnings ?? [],
647
+ };
648
+ }
649
+
650
+ /** 构造 requestOperation 提交体(不含上传;附件真实 id 由 uploadTodoFiles 写入)。 */
651
+ export function buildTodoSubmitBody(prep) {
652
+ const { requestId, remarkHtml, j0, p, sp, mainValues, bodyOverrides, plan, wfId, nodeId } = prep;
32
653
  const userId = String(p.f_weaver_belongto_userid ?? '1');
33
654
  // submit_token:loadForm 的 submitParams 对部分待办不返回 token(实跑发现 node 5967 待办没有),
34
655
  // E9 校验的是"客户端现生成的较新时间戳"防重复提交;故用 Date.now() 现生成,兜底 loadForm 值。
35
656
  const submitTokenKey = `${userId}_${requestId}_request_submit_token`;
36
657
  const submitToken = String(sp[submitTokenKey] ?? sp[`1_${requestId}_request_submit_token`] ?? Date.now());
37
- // 构造提交体
38
658
  // 从 signatureAttributesStr 解码提取 lastOperateDate 等运行时字段
39
659
  const sigDecoded = Buffer.from(String(p.signatureAttributesStr ?? ''), 'base64').toString('utf8');
40
660
  const lastOpDate = (sigDecoded.match(/lastOperateDate=([^|]*)/)?.[1] ?? '').trim();
@@ -102,9 +722,10 @@ export async function submitTodo(cfg, opts) {
102
722
  currentdate: todayStrVal,
103
723
  // ★ 0.5.1 修复:submit 必须带 needwfback。E9 bundle 实证:
104
724
  // doSubmitBack()→needwfback:"1"(提交后流程退回当前节点)、doSubmitNoBack()→needwfback:"0"(正常流转)。
105
- // 旧 body 缺此字段时服务端把"提交"当不流转处理(停在原节点)。
106
- // 取 loadForm 回传值兜底 '0'(默认不回退=正常流转到下一节点)。
107
- needwfback: String(sp.needwfback === undefined || sp.needwfback === null ? '0' : sp.needwfback),
725
+ // loadForm 可能回传空串(674 实测),空串按未返回处理,兜底 '0'(正常流转到下一节点)。
726
+ needwfback: String(sp.needwfback === undefined || sp.needwfback === null || sp.needwfback === ''
727
+ ? (p.needwfback === undefined || p.needwfback === null || p.needwfback === '' ? '0' : p.needwfback)
728
+ : sp.needwfback),
108
729
  remark: remarkHtml,
109
730
  'field-annexupload': '',
110
731
  signdocids: '',
@@ -134,17 +755,36 @@ export async function submitTodo(cfg, opts) {
134
755
  openDataVerify: '0',
135
756
  wfTestStr: '',
136
757
  };
137
- // 修改字段值(可选)
138
- if (opts.fieldValues) {
139
- let count = 0;
140
- for (const [k, v] of Object.entries(opts.fieldValues)) {
141
- const id = k.startsWith('field') ? k.replace(/^field/, '') : k;
142
- body[`field${id}`] = v;
143
- count++;
144
- }
145
- body.mainFieldUnEmptyCount = String(count);
758
+ // 主表字段值(不发送 null 占位;附件真实 id 已在上传后写入 mainValues)
759
+ for (const [key, value] of Object.entries(mainValues ?? {})) {
760
+ if (value === undefined || value === null)
761
+ continue;
762
+ const id = key.startsWith('field') ? key.slice('field'.length) : key;
763
+ body[`field${id}`] = value;
764
+ }
765
+ // 自定义浏览框 name / 选择框规范化 / 旧路径公式覆盖
766
+ Object.assign(body, bodyOverrides ?? {});
767
+ // 明细行控制 + 改动行字段
768
+ if (plan) {
769
+ Object.assign(body, buildDetailBodyKeys(plan));
770
+ body.detailFieldUnEmptyCount = String(countDetailUnEmpty(plan));
146
771
  }
147
- return api.post('/api/workflow/reqform/requestOperation', body);
772
+ body.mainFieldUnEmptyCount = String(countMainUnEmpty(j0?.maindata, mainValues));
773
+ const changed = [
774
+ ...(prep.changedMainKeys ?? []),
775
+ ...(plan?.changedDetailKeys ?? []),
776
+ ];
777
+ body.existChangeRange = [...new Set(changed)].join(',');
778
+ return body;
779
+ }
780
+
781
+ /** 提交待办:准备 → (全部校验通过后)上传附件 → 构造 body → requestOperation。 */
782
+ export async function submitTodo(cfg, opts) {
783
+ const prep = await prepareTodoSubmission(cfg, opts);
784
+ if ((prep.fileTargets ?? []).length > 0)
785
+ await uploadTodoFiles(cfg, prep);
786
+ const body = buildTodoSubmitBody(prep);
787
+ return prep.api.post('/api/workflow/reqform/requestOperation', body);
148
788
  }
149
789
  async function readMineListEvidence(cfg, requestId, before = {}) {
150
790
  const query = {