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.
package/dist/wfstart.js CHANGED
@@ -1,22 +1,74 @@
1
1
  import { createApiFromProfile } from './client.js';
2
- import { describeFormFields, HTMLTYPE_LABEL, getBrowserOptions } from './wfform.js';
2
+ import { describeFormFields, HTMLTYPE_LABEL, VIEWATTR_LABEL, fieldNonEditableReason, getBrowserOptions, browserNameSeparator, normalizeSelectValue } from './wfform.js';
3
3
  import { AuthTokenSession } from './auth-token.js';
4
4
  import { findCreateWfByName } from './wfcreate.js';
5
- import { uploadFile } from './wfupload.js';
5
+ import { uploadFile, fieldListType, uploadFieldValue } from './wfupload.js';
6
6
  import { getCurrentUser, defaultForField } from './wfuser.js';
7
7
  import { todayStr } from './util.js';
8
8
  import { beginSubmission, extractCreatedRequestId, updateSubmission } from './submissionstate.js';
9
- const VIEWATTR_LABEL = { 1: '可编辑', 2: '只读', 3: '隐藏' };
9
+ import { findShortcut, findShortcutFuzzy, hitShortcut, saveFieldDefault, saveShortcut, cacheFormFields, getCachedFormFields, getCachedLinkageResult, cacheLinkageResult, getFieldDefaults } from './memory.js';
10
+ import { executeFormCodeBlocks } from './wfcodeblock.js';
11
+ import { applyFormulas } from './formula.js';
10
12
  /** 已知系统字段 id(负数),单独标注,不作为业务填值候选。 */
11
13
  const SYS_FIELD_IDS = new Set(['-1', '-2', '-4', '-9', '-10']);
14
+
15
+ /**
16
+ * loadForm.maindata 的主表业务字段 → { "<id>": value }(id>0)。
17
+ * 浏览器 generateFormData 会提交全部主表字段;发起提交只发改动字段会被服务端判定数据丢失(674 实证)。
18
+ */
19
+ function maindataToValues(maindata) {
20
+ const out = {};
21
+ for (const [key, cell] of Object.entries(maindata ?? {})) {
22
+ const m = String(key).match(/^field(-?\d+)$/);
23
+ if (!m)
24
+ continue;
25
+ const id = Number(m[1]);
26
+ if (id <= 0)
27
+ continue;
28
+ out[m[1]] = cell?.value === undefined || cell?.value === null ? '' : String(cell.value);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** 从 cookie 解析 userId(兜底)。 */
34
+ function parseUserIdFromCookie(cookie) {
35
+ if (!cookie) return '';
36
+ const m = cookie.match(/(?:^|;\s*)loginidweaver=([^;]+)/);
37
+ return m ? m[1] : '';
38
+ }
39
+
40
+ /** 获取当前 selfId(优先 getCurrentUser,cookie 兜底)。 */
41
+ async function resolveSelfId(cfg) {
42
+ try {
43
+ const u = await getCurrentUser(cfg);
44
+ const id = String(u?.userid ?? '');
45
+ if (id) return { selfId: id, selfName: String(u?.username ?? '') };
46
+ } catch { /* fall through */ }
47
+ const fromCookie = parseUserIdFromCookie(cfg?.cookie);
48
+ return { selfId: fromCookie, selfName: fromCookie };
49
+ }
50
+
12
51
  export async function resolveWorkflowId(cfg, id, name) {
13
- if (id)
14
- return id;
15
- if (!name)
16
- throw new Error('需要 --id 或 --name');
17
- const hits = await findCreateWfByName(cfg, name);
18
- if (hits.length === 0)
19
- throw new Error(`按名「${name}」未找到流程`);
52
+ if (id) return id;
53
+ if (!name) throw new Error('需要 --id 或 --name');
54
+ // ★ 记忆优先(模糊匹配):查用户是否记住过相同或相似关键词对应的流程
55
+ const { selfId } = await resolveSelfId(cfg);
56
+ if (selfId) {
57
+ // 1. 精确匹配
58
+ let shortcut = findShortcut(cfg, selfId, name);
59
+ if (shortcut) {
60
+ hitShortcut(cfg, selfId, name);
61
+ return shortcut.workflowId;
62
+ }
63
+ // 2. 模糊匹配("请个假"→"请假"、"休假"→"请假")
64
+ const fuzzy = findShortcutFuzzy(cfg, selfId, name);
65
+ if (fuzzy) {
66
+ hitShortcut(cfg, selfId, fuzzy.keyword);
67
+ return fuzzy.shortcut.workflowId;
68
+ }
69
+ }
70
+ const hits = await findCreateWfByName(cfg, name, selfId);
71
+ if (hits.length === 0) throw new Error(`按名「${name}」未找到流程`);
20
72
  if (hits.length > 1) {
21
73
  const list = hits.map((w) => `${w.name} [id=${w.id ?? ''}]`).join('\n ');
22
74
  throw new Error(`按名「${name}」找到 ${hits.length} 个候选,请指定 --id:\n ${list}`);
@@ -24,22 +76,78 @@ export async function resolveWorkflowId(cfg, id, name) {
24
76
  return hits[0].id ?? '';
25
77
  }
26
78
  /**
27
- * 取主表业务字段清单(isdetail==0,排除系统字段)。**不按 viewattr 过滤**(静态 viewattr
28
- * 不反映运行时可编辑性)。从 maindata 取当前值与选项。供 AI 向用户问答"填哪些"。
79
+ * 从缓存 tables 重建 raw.tableInfo({key: {fieldinfomap: {fieldId: rawField}}})。
80
+ * 字段元数据(detailtype/fielddbtype/selectattr 等)就在每个 fld.raw 里,
81
+ * 重建后 getBrowserOptions 可直接复用,不必为浏览框再拉一次 loadForm(~2.3s)。
82
+ */
83
+ function rebuildTableInfo(tables) {
84
+ const ti = {};
85
+ for (const t of tables ?? []) {
86
+ if (!t || !t.key) continue;
87
+ const fmap = {};
88
+ for (const fld of t.fields ?? []) {
89
+ if (fld?.id != null && fld.raw) fmap[String(fld.id)] = fld.raw;
90
+ }
91
+ ti[t.key] = { fieldinfomap: fmap };
92
+ }
93
+ return ti;
94
+ }
95
+
96
+ /**
97
+ * 取主表业务字段清单(isdetail==0,排除系统字段;**viewattr=0 隐藏字段不进入清单**)。
98
+ * 字段属性语义:0=隐藏 / 1=只读 / 2=可编辑 / 3=必填;可编辑性判据见 fieldNonEditableReason。
99
+ * 从 maindata 取当前值与选项。供 AI 向用户问答"填哪些"。
29
100
  */
30
101
  export async function getStartableFields(cfg, workflowId) {
31
- const f = await describeFormFields(cfg, workflowId);
102
+ // ★ 表单缓存:同流程字段结构极少变化,30 天 TTL,命中文档跳过一次 HTTP(~2.3s)
103
+ let f;
104
+ let fromCache = false;
105
+ let selfId = '';
106
+ try { ({ selfId } = await resolveSelfId(cfg)); } catch { /* 取不到不阻断 */ }
107
+ if (selfId) {
108
+ try {
109
+ const cached = getCachedFormFields(cfg, selfId, workflowId);
110
+ if (cached) {
111
+ f = {
112
+ workflowId,
113
+ workflowName: cached.workflowName,
114
+ tables: cached.tables,
115
+ totalFields: cached.totalFields,
116
+ verifyRight: cached.verifyRight,
117
+ submitParams: cached.submitParams,
118
+ params: cached.params,
119
+ // ★ 重建 tableInfo:getBrowserOptions 靠 raw.tableInfo 判断 formData 可复用,
120
+ // 缺它会导致浏览框路径每次重复 describeFormFields(~2.3s)。
121
+ raw: { linkageCfg: cached.linkageCfg, tableInfo: rebuildTableInfo(cached.tables) },
122
+ maindata: undefined, // 缓存不含 maindata(每次变化),浏览框走 getBrowserOptions 兜底
123
+ };
124
+ fromCache = true;
125
+ }
126
+ } catch { /* 缓存读失败不阻断,走正常流程 */ }
127
+ }
128
+ if (!f) {
129
+ f = await describeFormFields(cfg, workflowId);
130
+ if (f.verifyRight !== false) {
131
+ try {
132
+ if (selfId) cacheFormFields(cfg, selfId, workflowId, f);
133
+ } catch { /* 缓存写失败不阻断 */ }
134
+ }
135
+ }
32
136
  if (f.verifyRight === false)
33
137
  throw new Error(`无权限(verifyRight:false),跑 e9 auth token-login 后重试`);
34
138
  const main = f.tables.find((t) => t.key === 'main');
35
- const fields = (main?.fields ?? [])
36
- .filter((fld) => String(fld.isdetail ?? 0) === '0' && !SYS_FIELD_IDS.has(String(fld.id)))
139
+ // 发起节点隐藏字段(viewattr=0)不进入「待填清单」——它们在后续节点才填,展示出来会误导用户/AI。
140
+ const mainBusiness = (main?.fields ?? [])
141
+ .filter((fld) => String(fld.isdetail ?? 0) === '0' && !SYS_FIELD_IDS.has(String(fld.id)));
142
+ let hiddenCount = mainBusiness.filter((fld) => fld.viewattr === 0).length;
143
+ const fields = mainBusiness
144
+ .filter((fld) => fld.viewattr !== 0)
37
145
  .map((fld) => toStartable(fld, f.maindata));
38
146
  // 浏览框(htmltype=3)且无 maindata 选项的,调通用 browser data 接口取前 10
39
147
  for (const fld of fields) {
40
148
  if (fld.htmltype === 3 && (!fld.options || fld.options.length === 0)) {
41
149
  try {
42
- fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10);
150
+ fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10, selfId, {}, f);
43
151
  fld.inputHint = fld.options.length > 0 ? `浏览框(前10: 给 option id)` : '浏览选择(给 id)';
44
152
  }
45
153
  catch {
@@ -68,12 +176,14 @@ export async function getStartableFields(cfg, workflowId) {
68
176
  const idx = Number(t.key.substring('detail_'.length)) - 1;
69
177
  if (!Number.isFinite(idx) || idx < 0)
70
178
  continue;
71
- const dflds = t.fields.filter((fld) => !SYS_FIELD_IDS.has(String(fld.id)));
179
+ const dfldsAll = t.fields.filter((fld) => !SYS_FIELD_IDS.has(String(fld.id)));
180
+ hiddenCount += dfldsAll.filter((fld) => fld.viewattr === 0).length;
181
+ const dflds = dfldsAll.filter((fld) => fld.viewattr !== 0);
72
182
  const dStartable = dflds.map((fld) => toStartable(fld, f.maindata));
73
183
  for (const fld of dStartable) {
74
184
  if (fld.htmltype === 3 && (!fld.options || fld.options.length === 0)) {
75
185
  try {
76
- fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10);
186
+ fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10, selfId, {}, f);
77
187
  fld.inputHint = fld.options.length > 0 ? `明细浏览框(前10: 给 option id)` : '明细浏览选择(给 id)';
78
188
  }
79
189
  catch { /* 取选项失败不阻断 */ }
@@ -87,10 +197,14 @@ export async function getStartableFields(cfg, workflowId) {
87
197
  verifyRight: f.verifyRight,
88
198
  fields,
89
199
  detailTables,
200
+ hiddenCount, // 发起节点隐藏(viewattr=0)的字段数——不进入待填清单
201
+ tables: f.tables, // 原始表结构(供 prepareCreateSubmission/submitCreateRequest 复用)
202
+ raw: f.raw, // 原始 loadForm 响应(含 tableInfo.linkageCfg 等)
90
203
  maindata: f.maindata,
91
204
  submitParams: f.submitParams,
92
205
  params: f.params,
93
206
  linkageCfg: f.raw?.linkageCfg,
207
+ selfId, // 当前用户 ID(供下游缓存/联动用)
94
208
  };
95
209
  }
96
210
  function toStartable(fld, maindata) {
@@ -111,6 +225,10 @@ function toStartable(fld, maindata) {
111
225
  inputHint = '浏览选择(给 option id)';
112
226
  else if (ht === 5 && options.length > 0)
113
227
  inputHint = `选择框(给 option id)`;
228
+ else if (ht === 6)
229
+ inputHint = '附件上传(--file)';
230
+ else if (ht === 4)
231
+ inputHint = '复选框';
114
232
  else if (options.length > 1)
115
233
  inputHint = '选择框(给 option id)';
116
234
  else if (ht === 2)
@@ -118,6 +236,7 @@ function toStartable(fld, maindata) {
118
236
  else
119
237
  inputHint = '文本';
120
238
  const va = fld.viewattr;
239
+ const reason = fieldNonEditableReason(fld);
121
240
  return {
122
241
  id: String(fld.id),
123
242
  label: fld.label,
@@ -126,6 +245,9 @@ function toStartable(fld, maindata) {
126
245
  htmltypeLabel: ht != null ? HTMLTYPE_LABEL[ht] ?? `ht=${ht}` : undefined,
127
246
  viewattr: va,
128
247
  viewattrLabel: va != null ? VIEWATTR_LABEL[va] ?? `va=${va}` : undefined,
248
+ // 字段权限:viewattr 0隐藏/1只读/2可编辑/3必填 + isonlyshow/isedit/isview(判据见 fieldNonEditableReason)
249
+ editable: reason == null,
250
+ nonEditableReason: reason,
129
251
  currentValue: cell?.value != null ? String(cell.value) : undefined,
130
252
  options: options.length > 0 ? options : undefined,
131
253
  inputHint,
@@ -147,11 +269,14 @@ function toStartable(fld, maindata) {
147
269
  * - 明细行:field<id>_<rownum>=<值>(rownum 从 0,每明细表独立)
148
270
  * - existChangeRange=field<id>,field<id>...(field 前缀);verifyRequiredRange=field-9999,field-1,(标题必填)
149
271
  */
150
- export function buildCreateSubmitBody(workflowId, values, requestname, params, submitParams, browserNames = {}, details = []) {
272
+ export function buildCreateSubmitBody(workflowId, values, requestname, params, submitParams, browserNames = {}, details = [], options = {}) {
151
273
  const wf = String(workflowId);
152
274
  const p = params ?? {};
153
275
  const sp = submitParams ?? {};
154
- const filledIds = Object.keys(values).map((k) => (k.startsWith('field') ? k.replace(/^field/, '') : k));
276
+ const valueId = (k) => (String(k).startsWith('field') ? String(k).slice(5) : String(k));
277
+ const filledIds = [...new Set(Object.keys(values).map(valueId))];
278
+ // existChangeRange 只记「用户/默认/代码块/联动」实际给过的字段,不把全量重发的服务端默认值算进去
279
+ const changedIds = [...new Set((options.changedIds ?? Object.keys(values)).map(valueId))];
155
280
  // 当前流程创建人 userid(loadForm params 给定)。submit_token 等字段的 key 带此 userid 前缀,
156
281
  // 不能硬编码 "1"——非 sysadmin=1 的环境(如阿汤哥 222222600)会取不到 submit_token 致"创建人和系统登录人不一致"。
157
282
  const uid = String(p.f_weaver_belongto_userid ?? sp.f_weaver_belongto_userid ?? '1');
@@ -179,7 +304,8 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
179
304
  requestid: '-1',
180
305
  linkageUUID: String(sp.linkageUUID ?? ''),
181
306
  htmlfieldids: '',
182
- needwfback: '1',
307
+ // 正常提交:E9 bundle doSubmitNoBack→needwfback:"0"(doSubmitBack 的 "1" 是退回当前节点)
308
+ needwfback: '0',
183
309
  lastloginuserid: String(p.lastloginuserid ?? uid),
184
310
  nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
185
311
  workflowid: wf,
@@ -200,7 +326,7 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
200
326
  remarkquote: '',
201
327
  actiontype: 'requestOperation',
202
328
  isFirstSubmit: '',
203
- existChangeRange: filledIds.map((id) => `field${id}`).join(','),
329
+ existChangeRange: changedIds.filter((id) => !SYS_FIELD_IDS.has(id)).map((id) => `field${id}`).join(','),
204
330
  requestname,
205
331
  requestlevel: String(values['-2'] ?? '0'),
206
332
  'field-10': values['-10'] ?? values['field-10'] ?? '4',
@@ -216,7 +342,9 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
216
342
  body[`field${id}name`] = browserNames[id];
217
343
  }
218
344
  body.remark = String(values['-4'] ?? '');
219
- body.mainFieldUnEmptyCount = String(filledIds.filter((id) => !SYS_FIELD_IDS.has(id)).length);
345
+ const valueOf = (id) => (values[id] !== undefined ? values[id] : values[`field${id}`]);
346
+ body.mainFieldUnEmptyCount = String(filledIds.filter((id) => !SYS_FIELD_IDS.has(id)
347
+ && valueOf(id) !== undefined && valueOf(id) !== null && String(valueOf(id)) !== '').length);
220
348
  // 明细表行:每行每字段 field<id>_<rownum>=value(rownum 从 0,每明细表独立)+ 行控制字段
221
349
  const existDetail = [];
222
350
  let detailUnEmpty = 0;
@@ -226,7 +354,8 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
226
354
  continue; // 空明细表不设行控制(新建场景无删行)
227
355
  body[`nodesnum${dt.index}`] = String(rows.length);
228
356
  body[`indexnum${dt.index}`] = String(rows.length);
229
- body[`submitdtlid${dt.index}`] = rows.map(() => '0').join(',') + ',';
357
+ // E9 前端:submitdtlid = 存活行下标升序 + 尾逗号(不是 0,0,)
358
+ body[`submitdtlid${dt.index}`] = rows.map((_, rowIndex) => String(rowIndex)).join(',') + ',';
230
359
  body[`deldtlid${dt.index}`] = '';
231
360
  rows.forEach((row, rownum) => {
232
361
  for (const [k, v] of Object.entries(row)) {
@@ -234,8 +363,9 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
234
363
  if (SYS_FIELD_IDS.has(id))
235
364
  continue;
236
365
  body[`field${id}_${rownum}`] = v;
237
- if (browserNames[id])
238
- body[`field${id}_${rownum}name`] = browserNames[id];
366
+ const rowName = browserNames[`${id}_${rownum}`] ?? browserNames[id];
367
+ if (rowName)
368
+ body[`field${id}_${rownum}name`] = rowName;
239
369
  if (v !== '') {
240
370
  detailUnEmpty++;
241
371
  existDetail.push(`field${id}_${rownum}`);
@@ -256,36 +386,63 @@ export function buildCreateSubmitBody(workflowId, values, requestname, params, s
256
386
  }
257
387
  /**
258
388
  * 解析多选浏览框(htmltype=3)字段的显示名。
259
- * values 里某字段值=逗号分隔选项 id,这里查 browser data 接口拼成 "名1##~~@@##名2",
389
+ * values 里某字段值=逗号分隔选项 id,这里查 browser data 接口拼成名称串,
260
390
  * 供提交体设 field<id>name。取不到不阻断(提交体仍发 field<id>=ids)。
391
+ * 分隔符:普通浏览框 `##~~@@##`;自定义浏览框(161/162) `##~~@@~~##`(E9 前端实证)。
261
392
  *
262
393
  * 由 create start 的 dry-run 预览与真提交共享,避免两处重复 getBrowserOptions+拼名逻辑。
263
394
  */
264
- export async function resolveBrowserNames(cfg, workflowId, fields, values, details = []) {
395
+ export async function resolveBrowserNames(cfg, workflowId, fields, values, details = [], _selfId, formData) {
396
+ // 尝试取 selfId 用于浏览框选项缓存
397
+ let selfId = _selfId;
398
+ if (!selfId) {
399
+ try { ({ selfId } = await resolveSelfId(cfg)); } catch { /* 取不到不阻断 */ }
400
+ }
265
401
  const bn = {};
266
402
  for (const fld of fields) {
267
403
  if (fld.htmltype !== 3)
268
404
  continue;
269
405
  const id = String(fld.id);
270
- const fieldValues = [values[id]];
406
+ // 主表 + 明细行的每个值槽位:支持传 id 或显示名,解析后原地写回规范化 id
407
+ const slots = [];
408
+ if (values[id] !== undefined)
409
+ slots.push({ key: id, get: () => values[id], set: (v) => { values[id] = v; } });
271
410
  for (const detail of details) {
272
- for (const row of detail.rows ?? [])
273
- fieldValues.push(row[id] ?? row[`field${id}`]);
411
+ (detail.rows ?? []).forEach((row, rownum) => {
412
+ const key = row[id] !== undefined ? id : (row[`field${id}`] !== undefined ? `field${id}` : null);
413
+ if (key)
414
+ slots.push({ key: `${id}_${rownum}`, get: () => row[key], set: (v) => { row[key] = v; } });
415
+ });
274
416
  }
275
- const ids = [...new Set(fieldValues
276
- .filter((value) => value !== undefined && value !== null && value !== '')
277
- .flatMap((value) => String(value).split(','))
278
- .map((value) => value.trim())
279
- .filter(Boolean))];
280
- if (ids.length === 0)
417
+ if (slots.length === 0)
281
418
  continue;
282
419
  try {
283
- const opts = await getBrowserOptions(cfg, workflowId, String(fld.id), 50);
284
- const names = ids
285
- .map((id) => opts.find((o) => o.id === id)?.name ?? '')
286
- .filter(Boolean);
287
- if (names.length > 0)
288
- bn[id] = names.join('##~~@@##');
420
+ // 解析名称时取大页:自定义浏览框的选中项可能不在前 10/50 条
421
+ // 联动浏览框需带父值(values)才能拿到选项;formData 复用避免重复 describeFormFields
422
+ const opts = await getBrowserOptions(cfg, workflowId, id, 500, selfId, values, formData);
423
+ if (opts.length === 0)
424
+ continue;
425
+ const sep = browserNameSeparator(fld.raw ?? fld);
426
+ for (const slot of slots) {
427
+ const rawValues = String(slot.get() ?? '').split(',').map((s) => s.trim()).filter(Boolean);
428
+ if (rawValues.length === 0)
429
+ continue;
430
+ const idArr = [];
431
+ const names = [];
432
+ let resolvedAll = true;
433
+ for (const value of rawValues) {
434
+ const byId = opts.find((o) => String(o.id) === value);
435
+ const sameName = opts.filter((o) => String(o.name) === value);
436
+ const hit = byId ?? (sameName.length === 1 ? sameName[0] : null);
437
+ if (!hit) { resolvedAll = false; break; }
438
+ idArr.push(String(hit.id));
439
+ names.push(String(hit.name));
440
+ }
441
+ if (!resolvedAll)
442
+ continue;
443
+ slot.set(idArr.join(','));
444
+ bn[slot.key] = names.join(sep);
445
+ }
289
446
  }
290
447
  catch {
291
448
  /* 解析失败不阻断 */
@@ -299,7 +456,7 @@ export async function resolveBrowserNames(cfg, workflowId, fields, values, detai
299
456
  * 将用户传入的选项名(中文)匹配为选项 id。name→id 匹配完成后原地修改 merged。
300
457
  * 选项来源:先查 maindata 的 specialobj,再查 fieldinfomap 的 selectattr.selectitemlist。
301
458
  */
302
- function resolveOptionNames(tables, maindata, merged) {
459
+ function resolveOptionNames(tables, maindata, merged, optionsMap = {}) {
303
460
  for (const t of tables) {
304
461
  for (const fld of t.fields ?? []) {
305
462
  const ht = fld.htmltype;
@@ -309,7 +466,7 @@ function resolveOptionNames(tables, maindata, merged) {
309
466
  const v = merged[id];
310
467
  if (v === undefined || v === '')
311
468
  continue;
312
- // 收集选项:先 maindata.specialobj → 再 fieldinfomap.selectattr
469
+ // 收集选项:先 maindata.specialobj → 再 fieldinfomap.selectattr → 再外部 optionsMap(getBrowserOptions 主动拉取)
313
470
  const key = `field${id}`;
314
471
  const cell = maindata?.[key];
315
472
  const special = Array.isArray(cell?.specialobj) ? cell.specialobj : [];
@@ -319,15 +476,26 @@ function resolveOptionNames(tables, maindata, merged) {
319
476
  if (Array.isArray(rawItems))
320
477
  selectItems = rawItems.filter((it) => it && it.selectname != null);
321
478
  }
479
+ if (selectItems.length === 0 && Array.isArray(optionsMap[id])) {
480
+ selectItems = optionsMap[id].map((o) => ({ selectvalue: String(o.id ?? ''), selectname: String(o.name ?? '') }));
481
+ }
322
482
  if (selectItems.length === 0)
323
483
  continue;
324
484
  // 按逗号拆分(支持多选)
325
485
  const parts = String(v).split(',').map((s) => s.trim());
326
486
  const converted = parts.map((p) => {
327
- const match = selectItems.find((it) => it && String(it.selectname).trim() === p);
328
- if (match)
329
- return String(match.selectvalue ?? match.id ?? '');
330
- return p; // 不匹配则保持原值(可能是 id 本身)
487
+ // 精确匹配 → 前缀匹配(唯一→使用;多候选→保持原文避免盲选)
488
+ const exact = selectItems.find((it) => it && String(it.selectname).trim() === p);
489
+ if (exact)
490
+ return String(exact.selectvalue ?? exact.id ?? '');
491
+ const prefix = selectItems.filter((it) => it && String(it.selectname).trim().startsWith(p));
492
+ if (prefix.length === 1)
493
+ return String(prefix[0].selectvalue ?? prefix[0].id ?? '');
494
+ // 选项名是 p 的前缀(p 附带额外文字如选项名+说明),且唯一
495
+ const suffix = selectItems.filter((it) => p.startsWith(String(it.selectname).trim()));
496
+ if (suffix.length === 1)
497
+ return String(suffix[0].selectvalue ?? suffix[0].id ?? '');
498
+ return p; // 不匹配或不唯一则保持原值(可能是 id 本身)
331
499
  });
332
500
  const result = converted.join(',');
333
501
  if (result !== String(v)) {
@@ -432,9 +600,13 @@ async function resolveFieldLinkages(cfg, workflowId, f, merged) {
432
600
  * 返回 E9Response(成功通常含 requestid)。
433
601
  */
434
602
  /**
435
- * ★字段属性硬规则:只允许填写「可编辑」(viewattr=1) 的字段。
436
- * 只读(2)/隐藏(3) 字段**禁止**由用户/助手填值——不得绕过表单字段属性自行填值。
437
- * 明细表字段同样按 viewattr 判定。无属性信息(viewattr 缺失)不拦,交给后端判定,避免误伤。
603
+ * ★字段属性硬规则:只允许填写「可编辑」字段。
604
+ *
605
+ * 判据 = `fieldNonEditableReason`(与 E9 前端 `getFieldCurViewAttr` 对齐):
606
+ * - 权限位:`isonlyshow=1`(禁止手工编辑)/ `isedit=0`(节点不可编辑)/ `isview=0`(节点不可见)→ 拦截;
607
+ * - `viewattr`:**0=隐藏 / 1=只读 → 拦截;2=可编辑 / 3=必填 → 放行**;
608
+ * - 属性缺失 → 不拦,交给后端判定,避免误伤。
609
+ * 明细行键 `field<id>_<row>` 按字段 id 判定,防止只读明细字段绕过门禁。
438
610
  */
439
611
  export function findNonEditableFields(form, values, details = []) {
440
612
  const byId = new Map();
@@ -444,11 +616,16 @@ export function findNonEditableFields(form, values, details = []) {
444
616
  }
445
617
  const bad = [];
446
618
  const checkId = (rawId) => {
447
- const id = String(rawId ?? '').replace(/^field/, '');
619
+ const raw = String(rawId ?? '').replace(/^field/, '');
620
+ // 明细行键 field<id>_<rownum>:按字段 id 查权限(否则只读明细字段会绕过门禁)
621
+ const id = raw.replace(/_\d+$/, '');
448
622
  const fld = byId.get(id);
449
- if (!fld || fld.viewattr == null || fld.viewattr === 1)
623
+ if (!fld)
624
+ return; // 未知字段交给后端判定
625
+ const reason = fieldNonEditableReason(fld);
626
+ if (!reason)
450
627
  return;
451
- bad.push({ id, label: fld.label, viewattr: fld.viewattr });
628
+ bad.push({ id: raw, label: fld.label, reason, currentValue: fld.currentValue });
452
629
  };
453
630
  for (const k of Object.keys(values ?? {}))
454
631
  checkId(k);
@@ -459,30 +636,165 @@ export function findNonEditableFields(form, values, details = []) {
459
636
  return bad;
460
637
  }
461
638
 
462
- export async function prepareCreateSubmission(cfg, workflowId, values, requestname, details = []) {
463
- const f = await describeFormFields(cfg, workflowId);
639
+ export async function prepareCreateSubmission(cfg, workflowId, values, requestname, details = [], formData) {
640
+ const f = formData || await describeFormFields(cfg, workflowId);
464
641
  if (f.verifyRight === false)
465
642
  throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
466
- // ★字段属性硬规则:只读/隐藏字段禁止填值(不得绕过字段属性自行填值)
643
+ // ★字段属性硬规则:禁止手工编辑/不可见字段禁止填值(判据见 findNonEditableFields)
467
644
  const badFields = findNonEditableFields(f, values, details);
468
645
  if (badFields.length)
469
- throw new Error(`拒绝填写只读/隐藏字段:${badFields.map((b) => `${b.label ?? b.id}(${VIEWATTR_LABEL[b.viewattr] ?? 'va=' + b.viewattr})`).join('、')}。表单只允许填写「可编辑」字段,不得绕过字段属性自行填值。`);
646
+ throw new Error(`拒绝填写禁止编辑的字段:${badFields.map((b) => `${b.label ?? b.id}(${b.reason}${b.currentValue ? `,已有默认值 ${b.currentValue} 自动带出` : ''})`).join('、')}。表单只允许填写「可编辑」字段,不得绕过字段权限自行填值。`);
470
647
  const name = requestname || `${f.workflowName ?? '流程'}-系统管理员-${todayStr()}`;
471
648
  const merged = { ...values };
472
- // 用户未填的人员/部门/日期字段,用当前用户信息默认补上
473
649
  const main = f.tables.find((t) => t.key === 'main');
474
650
  let user = null;
475
651
  try {
476
652
  user = await getCurrentUser(cfg);
477
653
  }
478
654
  catch { /* 取不到不阻断 */ }
655
+ const selfId = user ? String(user.userid ?? '') : (formData?.selfId || '');
656
+ // 字段 id → label(冲突报告用)
657
+ const fieldLabelById = new Map();
658
+ for (const t of f.tables ?? [])
659
+ for (const fld of t.fields ?? [])
660
+ fieldLabelById.set(String(fld.id), fld.label);
661
+ // 代码块建议值 vs 用户显式值的冲突(用户意图优先,冲突只报告不覆盖)
662
+ const codeblockConflicts = [];
663
+ let codeblockDetailMutations = [];
664
+ let codeblockStatus = null;
665
+ // ★ 默认值优先级(2026-10-23 调整):用户手填 > 表单代码块 > 记忆默认值 > 内置标签默认值。
666
+ // 表单代码块代表流程自身的业务逻辑,必须先执行;否则日期/人员等字段会被 CLI 先填成
667
+ // “今天/当前用户”,代码块的值因“不覆盖已有值”被丢弃。
668
+ // ★ 代码块执行:模拟浏览器端 WfForm 逻辑(changeFieldValue 补值、registerCheckEvent 校验)
669
+ try {
670
+ const userId = user ? String(user.userid ?? '') : '';
671
+ // 联动包装函数(供代码块 triggerFieldAllLinkage 调用)
672
+ const linkageWrapper = async (triggerFieldId, triggerValue) => {
673
+ try {
674
+ return await resolveLinkage(cfg, workflowId, { [triggerFieldId]: triggerValue }, { formData: f, selfId });
675
+ } catch { return {}; }
676
+ };
677
+ const cb = await executeFormCodeBlocks(cfg, workflowId, merged, f, {
678
+ userId,
679
+ selfId,
680
+ resolveLinkageFn: linkageWrapper,
681
+ details,
682
+ });
683
+ const { mutations, blocked } = cb;
684
+ codeblockStatus = cb.codeblock ?? null;
685
+ codeblockDetailMutations = mutations.details ?? [];
686
+ if (mutations.values && Object.keys(mutations.values).length > 0) {
687
+ for (const [mKey, cv] of Object.entries(mutations.values)) {
688
+ // mKey 可能是 "123"(主表)或 "123_2"(明细行);merged 只用 fieldId 扁平存储
689
+ const fieldId = mKey.includes('_') ? mKey.split('_')[0] : mKey;
690
+ const currentVal = merged[fieldId];
691
+ // 只有“未出现/空串”才算用户没填;'0' 是合法业务值(如选择框“否”),必须保留并报告冲突
692
+ if (currentVal === undefined || currentVal === '') {
693
+ merged[fieldId] = cv.value;
694
+ if (cv.specialobj && Array.isArray(cv.specialobj) && cv.specialobj.length > 0) {
695
+ const so = cv.specialobj[0];
696
+ console.log(` 📝 代码块补值: ${fieldId}=${so.name ?? cv.value}`);
697
+ } else {
698
+ console.log(` 📝 代码块补值: ${fieldId}=${cv.value}`);
699
+ }
700
+ }
701
+ else if (String(currentVal) !== String(cv.value)) {
702
+ // 用户在 --values 里显式指定了不同的值 → 按用户意图保留,但把冲突报告出来
703
+ codeblockConflicts.push({
704
+ fieldId,
705
+ label: fieldLabelById.get(fieldId) ?? '',
706
+ codeblockValue: String(cv.value),
707
+ keptValue: String(currentVal),
708
+ });
709
+ const label = fieldLabelById.get(fieldId);
710
+ console.log(` 📝 代码块建议 ${label ? label + '(' + fieldId + ')' : fieldId}=${cv.value},但用户已指定 ${currentVal} → 保留用户值`);
711
+ }
712
+ }
713
+ }
714
+ if (mutations.attrs && Object.keys(mutations.attrs).length > 0) {
715
+ console.log(` 📝 代码块属性变更: ${Object.entries(mutations.attrs).map(([k,v]) => `${k}→${v}`).join(', ')}`);
716
+ }
717
+ if (blocked) {
718
+ console.warn(' ⚠️ 代码块校验阻断提交,请检查必填字段或业务规则');
719
+ }
720
+ }
721
+ catch (e) {
722
+ // 代码块执行失败不阻断提交,但状态行要明确告知
723
+ codeblockStatus = { fetched: false, jsBytes: 0, valueCount: 0, detailCount: 0, attrCount: 0, blocked: false, error: e.message };
724
+ }
725
+ // 代码块状态行:无 / 执行完成(计数)/ 执行失败(原因,走 stderr)
726
+ if (codeblockStatus) {
727
+ if (codeblockStatus.error) {
728
+ console.error(` ⚠️ 代码块执行失败:${codeblockStatus.error}(提交继续)`);
729
+ }
730
+ else if (!codeblockStatus.fetched) {
731
+ console.log(' 📝 代码块:无');
732
+ }
733
+ else {
734
+ console.log(` 📝 代码块:执行完成(${codeblockStatus.valueCount} 个字段补值 / ${codeblockStatus.detailCount} 条明细操作${codeblockStatus.attrCount ? ` / ${codeblockStatus.attrCount} 个属性变更` : ''})`);
735
+ }
736
+ }
737
+ // ★ 代码块明细变更:合并进 details(用户已填值优先,冲突报告)
738
+ if (codeblockDetailMutations.length > 0) {
739
+ const rowsByIndex = new Map();
740
+ for (const d of details)
741
+ rowsByIndex.set(Number(d.index), (d.rows ?? []).map((r) => ({ ...r })));
742
+ const idxOfTable = (table) => Number(String(table).replace(/^detail_/, '')) - 1;
743
+ for (const op of codeblockDetailMutations) {
744
+ const idx = idxOfTable(op.table);
745
+ if (!Number.isFinite(idx) || idx < 0)
746
+ continue;
747
+ if (!rowsByIndex.has(idx))
748
+ rowsByIndex.set(idx, []);
749
+ const rows = rowsByIndex.get(idx);
750
+ if (op.op === 'del') {
751
+ if (op.all)
752
+ rows.length = 0;
753
+ else if (rows[op.rowIndex] !== undefined)
754
+ rows.splice(op.rowIndex, 1);
755
+ continue;
756
+ }
757
+ if (op.op === 'add') {
758
+ rows.push({ ...op.row });
759
+ continue;
760
+ }
761
+ if (op.op === 'set') {
762
+ const row = rows[op.rowIndex] ?? (rows[op.rowIndex] = {});
763
+ const cur = row[op.fieldId];
764
+ if (cur === undefined || cur === '') {
765
+ row[op.fieldId] = op.value;
766
+ }
767
+ else if (String(cur) !== String(op.value)) {
768
+ const label = fieldLabelById.get(op.fieldId);
769
+ codeblockConflicts.push({ fieldId: op.fieldId, label: label ?? '', table: op.table, rowIndex: op.rowIndex, codeblockValue: String(op.value), keptValue: String(cur) });
770
+ console.log(` 📝 代码块建议 ${label ? label + '(' + op.fieldId + ')' : op.fieldId}[第${op.rowIndex + 1}行]=${op.value},但用户已指定 ${cur} → 保留用户值`);
771
+ }
772
+ }
773
+ }
774
+ // 回写 details(按 detail index 排序,空表不保留)
775
+ details.length = 0;
776
+ for (const [idx, rows] of [...rowsByIndex.entries()].sort((a, b) => a[0] - b[0])) {
777
+ if (rows.length > 0)
778
+ details.push({ index: idx, rows });
779
+ }
780
+ }
781
+ // ★ 记忆学习默认值:仅补仍为空的字段(用户手填/代码块已填的不覆盖)
782
+ try {
783
+ const defaults = getFieldDefaults(cfg, selfId, String(workflowId));
784
+ for (const [fid, entry] of Object.entries(defaults ?? {})) {
785
+ if (merged[fid] === undefined && entry && entry.default !== undefined && entry.default !== '')
786
+ merged[fid] = entry.default;
787
+ }
788
+ }
789
+ catch { /* 记忆读取失败不阻断 */ }
790
+ // ★ 内置标签默认值:仅补仍为空的字段(用户 > 代码块 > 记忆 > 内置)
479
791
  if (user) {
480
792
  for (const fld of main?.fields ?? []) {
481
793
  const id = String(fld.id);
482
794
  if (merged[id] !== undefined)
483
- continue; // 用户已填
484
- if (fld.viewattr === 2 || fld.viewattr === 3)
485
- continue; // 只读/隐藏字段由系统/联动控制,不补默认值(不得绕过字段属性自行填值)
795
+ continue;
796
+ if (fld.editable === false)
797
+ continue; // 禁止手工编辑/不可见字段由系统/联动控制,不补默认值
486
798
  const dv = defaultForField(fld.label, fld.htmltype, user);
487
799
  if (dv)
488
800
  merged[id] = dv;
@@ -490,7 +802,7 @@ export async function prepareCreateSubmission(cfg, workflowId, values, requestna
490
802
  }
491
803
  // 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值
492
804
  try {
493
- const linkageChanges = await resolveLinkage(cfg, workflowId, merged);
805
+ const linkageChanges = await resolveLinkage(cfg, workflowId, merged, { formData, selfId });
494
806
  for (const [fieldId, cv] of Object.entries(linkageChanges)) {
495
807
  if (merged[fieldId] === undefined || merged[fieldId] === '0') {
496
808
  merged[fieldId] = cv.value;
@@ -502,34 +814,115 @@ export async function prepareCreateSubmission(cfg, workflowId, values, requestna
502
814
  }
503
815
  }
504
816
  catch { /* 联动失败不阻断 */ }
817
+ // ★ 选项名→ID 自动转换:用户手填中文选项名(浏览框/选择框)时,主动拉取选项并转 id
818
+ try {
819
+ const optionMap = {};
820
+ // 收集需要转换的字段:htmltype=3(浏览框)/5(选择框) 且值不是纯数字 id 串/日期时间等直填值的
821
+ const needConvert = (fld) => {
822
+ const ht = fld.htmltype;
823
+ if (ht !== 3 && ht !== 5)
824
+ return;
825
+ const fid = String(fld.id);
826
+ const v = merged[fid];
827
+ if (v === undefined || v === '')
828
+ return;
829
+ const sv = String(v).trim();
830
+ if (/^[\d,]+$/.test(sv))
831
+ return; // 纯数字 id 串不需要转换
832
+ // 日期/时间浏览框直接填值,不走选项转换(避免无谓的 browser data 请求)
833
+ if (/^\d{4}-\d{2}-\d{2}/.test(sv) || /^\d{2}:\d{2}/.test(sv))
834
+ return;
835
+ optionMap[fid] = null;
836
+ };
837
+ for (const t of f.tables ?? [])
838
+ for (const fld of t.fields ?? [])
839
+ needConvert(fld);
840
+ const idsToFetch = Object.keys(optionMap);
841
+ if (idsToFetch.length > 0) {
842
+ const results = await Promise.allSettled(idsToFetch.map((fid) => getBrowserOptions(cfg, workflowId, fid, 50, selfId, merged, f).then((opts) => { optionMap[fid] = opts; }).catch(() => { optionMap[fid] = []; })));
843
+ resolveOptionNames(f.tables, f.maindata, merged, optionMap);
844
+ }
845
+ }
846
+ catch { /* 选项转换失败不阻断 */ }
505
847
  // 多选浏览框字段(htmltype=3)的值=逗号分隔id,解析对应 name 设 field<id>name
506
848
  const browserFields = f.tables.flatMap((table) => table.fields ?? []);
507
- const browserNames = await resolveBrowserNames(cfg, workflowId, browserFields, merged, details);
508
- const body = buildCreateSubmitBody(workflowId, merged, name, f.params, f.submitParams, browserNames, details);
509
- return { workflowId: String(workflowId), workflowName: f.workflowName, requestName: name, values: merged, details, browserNames, body };
849
+ const browserNames = await resolveBrowserNames(cfg, workflowId, browserFields, merged, details, undefined, f);
850
+ // 选择框(htmltype=5):显示名 → selectvalue(否则回显匹配不到选项,下拉框显示为空)
851
+ const selectFields = new Map();
852
+ for (const fld of browserFields)
853
+ if (Number(fld.htmltype) === 5)
854
+ selectFields.set(String(fld.id), fld);
855
+ const normalizeSelects = (obj) => {
856
+ for (const [k, v] of Object.entries(obj ?? {})) {
857
+ const id = k.startsWith('field') ? k.replace(/^field/, '') : k;
858
+ const fld = selectFields.get(String(id));
859
+ if (!fld)
860
+ continue;
861
+ const normalized = normalizeSelectValue(fld, v);
862
+ if (normalized !== undefined)
863
+ obj[k] = normalized;
864
+ }
865
+ };
866
+ normalizeSelects(merged);
867
+ for (const dt of details)
868
+ for (const row of dt.rows ?? [])
869
+ normalizeSelects(row);
870
+ // 公式字段(主表/明细):本地计算后写入 merged/details
871
+ const formulas = applyFormulas(f, merged, details);
872
+ // 提交体带全量主表业务字段(服务端默认值 + 本次改动);existChangeRange 只记 merged 里的改动
873
+ const bodyValues = { ...maindataToValues(f.maindata), ...merged };
874
+ const body = buildCreateSubmitBody(workflowId, bodyValues, name, f.params, f.submitParams, browserNames, details, { changedIds: Object.keys(merged) });
875
+ return { workflowId: String(workflowId), workflowName: f.workflowName, requestName: name, values: merged, details, browserNames, codeblockConflicts, codeblock: codeblockStatus, formulas, body };
510
876
  }
511
877
 
512
878
  /**
513
879
  * 真正提交(新建流程)。若传入 prepared,则只用确认阶段冻结的业务字段、明细行与浏览框名称,
514
880
  * 仅刷新本次表单必需的短期签名参数,绝不重新补默认值或执行联动。
515
881
  */
516
- export async function submitCreateRequest(cfg, workflowId, values, requestname, files = [], details = [], prepared = undefined) {
882
+ export async function submitCreateRequest(cfg, workflowId, values, requestname, files = [], details = [], prepared = undefined, opts = {}) {
517
883
  let plan = prepared;
518
884
  if (!plan) {
519
- plan = await prepareCreateSubmission(cfg, workflowId, values, requestname, details);
885
+ plan = await prepareCreateSubmission(cfg, workflowId, values, requestname, details, opts.formData);
520
886
  }
521
887
  else {
522
888
  plan = { ...prepared, values: { ...prepared.values } };
523
889
  }
524
890
  // 文件 ID 只能在真实提交时获得;其余已确认字段绝不重新解析或联动。
525
- for (const fl of (plan.files ?? files)) {
526
- const up = await uploadFile(cfg, { workflowId: String(workflowId), fieldId: fl.fieldId, filePath: fl.filePath, listType: fl.listType });
527
- plan.values[fl.fieldId] = up.fileid;
528
- }
529
- const f = await describeFormFields(cfg, workflowId);
891
+ // ★ 表单缓存:优先用调用方传入的 formData(避免重复 describeFormFields ~2.3s)
892
+ const f = opts.formData || await describeFormFields(cfg, workflowId);
530
893
  if (f.verifyRight === false)
531
894
  throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
532
- const body = buildCreateSubmitBody(workflowId, plan.values, plan.requestName, f.params, f.submitParams, plan.browserNames, plan.details);
895
+ // 附件字段的上传目录在 fieldinfomap.fileattr.docCategory(如 ",,2"),必须与表单字段一致
896
+ const allFields = f.tables.flatMap((t) => t.fields ?? []);
897
+ const categoryFor = (fieldId) => allFields.find((x) => String(x.id) === String(fieldId))?.raw?.fileattr?.docCategory || undefined;
898
+ for (const fl of (plan.files ?? files)) {
899
+ // 支持主表 fieldId 与明细行 fieldId_<row>(如 27799_0)
900
+ const m = String(fl.fieldId).match(/^(\d+)(?:_(\d+))?$/);
901
+ const baseId = m ? m[1] : String(fl.fieldId);
902
+ const rowIndex = m && m[2] !== undefined ? Number(m[2]) : null;
903
+ const fieldMeta = allFields.find((x) => String(x.id) === baseId);
904
+ // 附件字段走 list、图片字段走 img(E9 前端按 detailtype=2 判定)
905
+ const listType = fl.listType ?? fieldListType(fieldMeta);
906
+ const up = await uploadFile(cfg, {
907
+ workflowId: String(workflowId), fieldId: baseId, filePath: fl.filePath,
908
+ listType, category: categoryFor(baseId),
909
+ });
910
+ const fileValue = uploadFieldValue(up, listType);
911
+ if (rowIndex === null) {
912
+ plan.values[baseId] = fileValue;
913
+ continue;
914
+ }
915
+ const tableKey = fieldMeta?.table ?? '';
916
+ const detailIndex = tableKey.startsWith('detail_') ? Number(tableKey.slice('detail_'.length)) - 1 : NaN;
917
+ const detail = (plan.details ?? []).find((d) => Number(d.index) === detailIndex);
918
+ const row = detail?.rows?.[rowIndex];
919
+ if (!row)
920
+ throw new Error(`明细附件上传需要 --detail-file 中已有对应行:field${baseId}_${rowIndex}`);
921
+ row[baseId] = fileValue;
922
+ }
923
+ // 提交体带全量主表业务字段;existChangeRange 只记 plan.values 里的字段
924
+ const bodyValues = { ...maindataToValues(f.maindata), ...plan.values };
925
+ const body = buildCreateSubmitBody(workflowId, bodyValues, plan.requestName, f.params, f.submitParams, plan.browserNames, plan.details, { changedIds: Object.keys(plan.values) });
533
926
  const started = beginSubmission({ workflowId, workflowName: f.workflowName, requestName: plan.requestName, values: plan.values });
534
927
  if (started.duplicate) {
535
928
  throw new Error(`检测到相同内容刚刚已发起或结果待核验(submission_id=${started.record.id})。为避免重复创建,本次没有再次提交;请先运行 create verify ${started.record.id}。`);
@@ -544,6 +937,27 @@ export async function submitCreateRequest(cfg, workflowId, values, requestname,
544
937
  const result = extractCreatedRequestId(response);
545
938
  if (result.accepted) {
546
939
  const submission = updateSubmission(started.record.id, { status: 'created', requestId: result.requestId, responseType: result.type });
940
+ // ★ 自动记忆:成功提交后保存快捷方式 + 字段默认值
941
+ // ⚠️ 只跳过空串;'0' 是合法业务值(金额 0 / 数量 0 / 选择框"否"=0),必须记忆,
942
+ // 不能当"没填"处理(旧实现 userValues[fid] !== '0' 导致这些默认值永远学不到)。
943
+ if (opts.keyword) {
944
+ try {
945
+ const { selfId, selfName } = await resolveSelfId(cfg);
946
+ if (selfId) {
947
+ saveShortcut(cfg, selfId, selfName, opts.keyword, String(workflowId), f.workflowName);
948
+ const userValues = plan.values || {};
949
+ const mainFields = (f.tables.find(t => t.key === 'main')?.fields ?? []);
950
+ for (const fld of mainFields) {
951
+ const fid = String(fld.id);
952
+ if (userValues[fid] !== undefined && userValues[fid] !== ''
953
+ && !SYS_FIELD_IDS.has(fid)) {
954
+ saveFieldDefault(cfg, selfId, selfName, String(workflowId), fid,
955
+ fld.label || fid, String(userValues[fid]));
956
+ }
957
+ }
958
+ }
959
+ } catch { /* 记忆保存失败不阻断业务 */ }
960
+ }
547
961
  return { response, submission, outcome: 'created' };
548
962
  }
549
963
  const submission = updateSubmission(started.record.id, { status: 'uncertain', responseType: result.type, lastHttpStatus: response.status });
@@ -687,9 +1101,12 @@ export async function getWorkflowPreview(cfg, requestId) {
687
1101
  * 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值。
688
1102
  * 从 loadForm.linkageCfg.dataInputCfg 读取联动配置,对每个触发字段逐一调用。
689
1103
  * 返回 { "<fieldId>": { value, specialobj } } 的变更值映射。
1104
+ *
1105
+ * ★ formData 提供时可跳过 describeFormFields HTTP 调用(prepareCreateSubmission 已缓存)。
1106
+ * ★ selfId 提供时启用联动结果缓存(1 小时 TTL),同 workflow+config+字段+值组合免重复 HTTP。
690
1107
  */
691
- export async function resolveLinkage(cfg, workflowId, values) {
692
- const f = await describeFormFields(cfg, workflowId);
1108
+ export async function resolveLinkage(cfg, workflowId, values, opts = {}) {
1109
+ const f = opts.formData || await describeFormFields(cfg, workflowId);
693
1110
  const linkageCfg = f.raw?.linkageCfg;
694
1111
  if (!linkageCfg?.dataInputCfg)
695
1112
  return {};
@@ -697,21 +1114,37 @@ export async function resolveLinkage(cfg, workflowId, values) {
697
1114
  const sp = f.submitParams ?? {};
698
1115
  const base = cfg.baseUrl.replace(/\/+$/, '');
699
1116
  const sess = AuthTokenSession.fromProfile(cfg);
1117
+ const selfId = opts.selfId || '';
700
1118
  const changes = {};
701
1119
  for (const [triggerFieldId, configs] of Object.entries(linkageCfg.dataInputCfg)) {
702
1120
  if (!Array.isArray(configs))
703
1121
  continue;
704
1122
  const triggerValue = values[triggerFieldId];
705
- if (!triggerValue)
1123
+ // 只跳过"未填";'0'/0 是合法业务值(选择框"否"=0),不能当没填(旧实现 !triggerValue 会误跳)
1124
+ if (triggerValue === undefined || triggerValue === '')
706
1125
  continue;
707
1126
  for (const cfg of configs) {
708
1127
  const configId = cfg.config;
709
1128
  if (!configId)
710
1129
  continue;
1130
+
1131
+ const wfId = String(workflowId);
1132
+ // ★ 缓存优先
1133
+ if (selfId) {
1134
+ const cached = getCachedLinkageResult(cfg, selfId, wfId, String(configId), triggerFieldId, triggerValue);
1135
+ if (cached) {
1136
+ for (const [key, cv] of Object.entries(cached)) {
1137
+ const fieldId = key.replace(/^field/, '');
1138
+ changes[fieldId] = { value: cv.value, specialobj: cv.specialobj };
1139
+ }
1140
+ continue;
1141
+ }
1142
+ }
1143
+
711
1144
  try {
712
1145
  const body = new URLSearchParams({
713
1146
  requestid: '-1',
714
- workflowid: String(workflowId),
1147
+ workflowid: wfId,
715
1148
  nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
716
1149
  formid: String(sp.formid ?? p.formid ?? ''),
717
1150
  isbill: String(sp.isbill ?? p.isbill ?? '1'),
@@ -736,6 +1169,12 @@ export async function resolveLinkage(cfg, workflowId, values) {
736
1169
  const j = JSON.parse(await r.text());
737
1170
  const assignInfo = j[`assignInfo_${configId}`];
738
1171
  if (assignInfo?.changeValue) {
1172
+ // ★ 写入缓存
1173
+ if (selfId) {
1174
+ try {
1175
+ cacheLinkageResult(cfg, selfId, wfId, String(configId), triggerFieldId, triggerValue, assignInfo.changeValue);
1176
+ } catch { /* 缓存写失败不阻断 */ }
1177
+ }
739
1178
  for (const [key, cv] of Object.entries(assignInfo.changeValue)) {
740
1179
  const fieldId = key.replace(/^field/, '');
741
1180
  changes[fieldId] = { value: cv.value, specialobj: cv.specialobj };