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/README.md +2 -0
- package/dist/auth-token.js +4 -4
- package/dist/cache-store.js +73 -0
- package/dist/cli.js +1508 -69
- package/dist/client.js +1 -1
- package/dist/cube.js +16 -38
- package/dist/formula.js +246 -0
- package/dist/hrm.js +33 -19
- package/dist/kq.js +65 -0
- package/dist/memory.js +968 -0
- package/dist/nodemap.js +232 -0
- package/dist/odoc.js +7 -2
- package/dist/parse.js +4 -0
- package/dist/subordinate.js +111 -35
- package/dist/supervise.js +152 -0
- package/dist/teamreport.js +77 -0
- package/dist/todobatch.js +20 -9
- package/dist/tododetail.js +516 -0
- package/dist/todosubmit.js +657 -17
- package/dist/util.js +21 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfcodeblock.js +761 -0
- package/dist/wfcreate.js +29 -1
- package/dist/wfform.js +286 -67
- package/dist/wfstart.js +518 -79
- package/dist/wfsummary.js +1 -1
- package/dist/wfupload.js +48 -4
- package/dist/workflow.js +4 -2
- package/dist/workplan.js +21 -6
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
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
|
|
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
|
|
623
|
+
if (!fld)
|
|
624
|
+
return; // 未知字段交给后端判定
|
|
625
|
+
const reason = fieldNonEditableReason(fld);
|
|
626
|
+
if (!reason)
|
|
450
627
|
return;
|
|
451
|
-
bad.push({ id, label: fld.label,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 };
|