weaver-e9-assistant-cli 1.1.5 → 1.1.12
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/auth-token.js +4 -4
- package/dist/cache-store.js +73 -0
- package/dist/cli.js +714 -57
- 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/memory.js +968 -0
- package/dist/nodemap.js +232 -242
- package/dist/subordinate.js +86 -35
- package/dist/teamreport.js +77 -0
- package/dist/tododetail.js +516 -0
- package/dist/todosubmit.js +619 -26
- package/dist/util.js +21 -0
- package/dist/wfcodeblock.js +761 -0
- package/dist/wfcreate.js +29 -1
- package/dist/wfform.js +286 -67
- package/dist/wfstart.js +516 -81
- 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,15 +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
623
|
if (!fld)
|
|
450
624
|
return; // 未知字段交给后端判定
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (fld.viewattr !== 2 && fld.viewattr !== 3)
|
|
625
|
+
const reason = fieldNonEditableReason(fld);
|
|
626
|
+
if (!reason)
|
|
454
627
|
return;
|
|
455
|
-
bad.push({ id, label: fld.label,
|
|
628
|
+
bad.push({ id: raw, label: fld.label, reason, currentValue: fld.currentValue });
|
|
456
629
|
};
|
|
457
630
|
for (const k of Object.keys(values ?? {}))
|
|
458
631
|
checkId(k);
|
|
@@ -463,30 +636,165 @@ export function findNonEditableFields(form, values, details = []) {
|
|
|
463
636
|
return bad;
|
|
464
637
|
}
|
|
465
638
|
|
|
466
|
-
export async function prepareCreateSubmission(cfg, workflowId, values, requestname, details = []) {
|
|
467
|
-
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);
|
|
468
641
|
if (f.verifyRight === false)
|
|
469
642
|
throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
|
|
470
|
-
//
|
|
643
|
+
// ★字段属性硬规则:禁止手工编辑/不可见字段禁止填值(判据见 findNonEditableFields)
|
|
471
644
|
const badFields = findNonEditableFields(f, values, details);
|
|
472
645
|
if (badFields.length)
|
|
473
|
-
throw new Error(
|
|
646
|
+
throw new Error(`拒绝填写禁止编辑的字段:${badFields.map((b) => `${b.label ?? b.id}(${b.reason}${b.currentValue ? `,已有默认值 ${b.currentValue} 自动带出` : ''})`).join('、')}。表单只允许填写「可编辑」字段,不得绕过字段权限自行填值。`);
|
|
474
647
|
const name = requestname || `${f.workflowName ?? '流程'}-系统管理员-${todayStr()}`;
|
|
475
648
|
const merged = { ...values };
|
|
476
|
-
// 用户未填的人员/部门/日期字段,用当前用户信息默认补上
|
|
477
649
|
const main = f.tables.find((t) => t.key === 'main');
|
|
478
650
|
let user = null;
|
|
479
651
|
try {
|
|
480
652
|
user = await getCurrentUser(cfg);
|
|
481
653
|
}
|
|
482
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
|
+
// ★ 内置标签默认值:仅补仍为空的字段(用户 > 代码块 > 记忆 > 内置)
|
|
483
791
|
if (user) {
|
|
484
792
|
for (const fld of main?.fields ?? []) {
|
|
485
793
|
const id = String(fld.id);
|
|
486
794
|
if (merged[id] !== undefined)
|
|
487
|
-
continue;
|
|
488
|
-
if (fld.
|
|
489
|
-
continue; //
|
|
795
|
+
continue;
|
|
796
|
+
if (fld.editable === false)
|
|
797
|
+
continue; // 禁止手工编辑/不可见字段由系统/联动控制,不补默认值
|
|
490
798
|
const dv = defaultForField(fld.label, fld.htmltype, user);
|
|
491
799
|
if (dv)
|
|
492
800
|
merged[id] = dv;
|
|
@@ -494,7 +802,7 @@ export async function prepareCreateSubmission(cfg, workflowId, values, requestna
|
|
|
494
802
|
}
|
|
495
803
|
// 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值
|
|
496
804
|
try {
|
|
497
|
-
const linkageChanges = await resolveLinkage(cfg, workflowId, merged);
|
|
805
|
+
const linkageChanges = await resolveLinkage(cfg, workflowId, merged, { formData, selfId });
|
|
498
806
|
for (const [fieldId, cv] of Object.entries(linkageChanges)) {
|
|
499
807
|
if (merged[fieldId] === undefined || merged[fieldId] === '0') {
|
|
500
808
|
merged[fieldId] = cv.value;
|
|
@@ -506,34 +814,115 @@ export async function prepareCreateSubmission(cfg, workflowId, values, requestna
|
|
|
506
814
|
}
|
|
507
815
|
}
|
|
508
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 { /* 选项转换失败不阻断 */ }
|
|
509
847
|
// 多选浏览框字段(htmltype=3)的值=逗号分隔id,解析对应 name 设 field<id>name
|
|
510
848
|
const browserFields = f.tables.flatMap((table) => table.fields ?? []);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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 };
|
|
514
876
|
}
|
|
515
877
|
|
|
516
878
|
/**
|
|
517
879
|
* 真正提交(新建流程)。若传入 prepared,则只用确认阶段冻结的业务字段、明细行与浏览框名称,
|
|
518
880
|
* 仅刷新本次表单必需的短期签名参数,绝不重新补默认值或执行联动。
|
|
519
881
|
*/
|
|
520
|
-
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 = {}) {
|
|
521
883
|
let plan = prepared;
|
|
522
884
|
if (!plan) {
|
|
523
|
-
plan = await prepareCreateSubmission(cfg, workflowId, values, requestname, details);
|
|
885
|
+
plan = await prepareCreateSubmission(cfg, workflowId, values, requestname, details, opts.formData);
|
|
524
886
|
}
|
|
525
887
|
else {
|
|
526
888
|
plan = { ...prepared, values: { ...prepared.values } };
|
|
527
889
|
}
|
|
528
890
|
// 文件 ID 只能在真实提交时获得;其余已确认字段绝不重新解析或联动。
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
plan.values[fl.fieldId] = up.fileid;
|
|
532
|
-
}
|
|
533
|
-
const f = await describeFormFields(cfg, workflowId);
|
|
891
|
+
// ★ 表单缓存:优先用调用方传入的 formData(避免重复 describeFormFields ~2.3s)
|
|
892
|
+
const f = opts.formData || await describeFormFields(cfg, workflowId);
|
|
534
893
|
if (f.verifyRight === false)
|
|
535
894
|
throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
|
|
536
|
-
|
|
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) });
|
|
537
926
|
const started = beginSubmission({ workflowId, workflowName: f.workflowName, requestName: plan.requestName, values: plan.values });
|
|
538
927
|
if (started.duplicate) {
|
|
539
928
|
throw new Error(`检测到相同内容刚刚已发起或结果待核验(submission_id=${started.record.id})。为避免重复创建,本次没有再次提交;请先运行 create verify ${started.record.id}。`);
|
|
@@ -548,6 +937,27 @@ export async function submitCreateRequest(cfg, workflowId, values, requestname,
|
|
|
548
937
|
const result = extractCreatedRequestId(response);
|
|
549
938
|
if (result.accepted) {
|
|
550
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
|
+
}
|
|
551
961
|
return { response, submission, outcome: 'created' };
|
|
552
962
|
}
|
|
553
963
|
const submission = updateSubmission(started.record.id, { status: 'uncertain', responseType: result.type, lastHttpStatus: response.status });
|
|
@@ -691,9 +1101,12 @@ export async function getWorkflowPreview(cfg, requestId) {
|
|
|
691
1101
|
* 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值。
|
|
692
1102
|
* 从 loadForm.linkageCfg.dataInputCfg 读取联动配置,对每个触发字段逐一调用。
|
|
693
1103
|
* 返回 { "<fieldId>": { value, specialobj } } 的变更值映射。
|
|
1104
|
+
*
|
|
1105
|
+
* ★ formData 提供时可跳过 describeFormFields HTTP 调用(prepareCreateSubmission 已缓存)。
|
|
1106
|
+
* ★ selfId 提供时启用联动结果缓存(1 小时 TTL),同 workflow+config+字段+值组合免重复 HTTP。
|
|
694
1107
|
*/
|
|
695
|
-
export async function resolveLinkage(cfg, workflowId, values) {
|
|
696
|
-
const f = await describeFormFields(cfg, workflowId);
|
|
1108
|
+
export async function resolveLinkage(cfg, workflowId, values, opts = {}) {
|
|
1109
|
+
const f = opts.formData || await describeFormFields(cfg, workflowId);
|
|
697
1110
|
const linkageCfg = f.raw?.linkageCfg;
|
|
698
1111
|
if (!linkageCfg?.dataInputCfg)
|
|
699
1112
|
return {};
|
|
@@ -701,21 +1114,37 @@ export async function resolveLinkage(cfg, workflowId, values) {
|
|
|
701
1114
|
const sp = f.submitParams ?? {};
|
|
702
1115
|
const base = cfg.baseUrl.replace(/\/+$/, '');
|
|
703
1116
|
const sess = AuthTokenSession.fromProfile(cfg);
|
|
1117
|
+
const selfId = opts.selfId || '';
|
|
704
1118
|
const changes = {};
|
|
705
1119
|
for (const [triggerFieldId, configs] of Object.entries(linkageCfg.dataInputCfg)) {
|
|
706
1120
|
if (!Array.isArray(configs))
|
|
707
1121
|
continue;
|
|
708
1122
|
const triggerValue = values[triggerFieldId];
|
|
709
|
-
|
|
1123
|
+
// 只跳过"未填";'0'/0 是合法业务值(选择框"否"=0),不能当没填(旧实现 !triggerValue 会误跳)
|
|
1124
|
+
if (triggerValue === undefined || triggerValue === '')
|
|
710
1125
|
continue;
|
|
711
1126
|
for (const cfg of configs) {
|
|
712
1127
|
const configId = cfg.config;
|
|
713
1128
|
if (!configId)
|
|
714
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
|
+
|
|
715
1144
|
try {
|
|
716
1145
|
const body = new URLSearchParams({
|
|
717
1146
|
requestid: '-1',
|
|
718
|
-
workflowid:
|
|
1147
|
+
workflowid: wfId,
|
|
719
1148
|
nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
|
|
720
1149
|
formid: String(sp.formid ?? p.formid ?? ''),
|
|
721
1150
|
isbill: String(sp.isbill ?? p.isbill ?? '1'),
|
|
@@ -740,6 +1169,12 @@ export async function resolveLinkage(cfg, workflowId, values) {
|
|
|
740
1169
|
const j = JSON.parse(await r.text());
|
|
741
1170
|
const assignInfo = j[`assignInfo_${configId}`];
|
|
742
1171
|
if (assignInfo?.changeValue) {
|
|
1172
|
+
// ★ 写入缓存
|
|
1173
|
+
if (selfId) {
|
|
1174
|
+
try {
|
|
1175
|
+
cacheLinkageResult(cfg, selfId, wfId, String(configId), triggerFieldId, triggerValue, assignInfo.changeValue);
|
|
1176
|
+
} catch { /* 缓存写失败不阻断 */ }
|
|
1177
|
+
}
|
|
743
1178
|
for (const [key, cv] of Object.entries(assignInfo.changeValue)) {
|
|
744
1179
|
const fieldId = key.replace(/^field/, '');
|
|
745
1180
|
changes[fieldId] = { value: cv.value, specialobj: cv.specialobj };
|