weaver-e9-assistant-cli 1.0.0

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.
@@ -0,0 +1,717 @@
1
+ import { createApiFromProfile } from './client.js';
2
+ import { describeFormFields, HTMLTYPE_LABEL, getBrowserOptions } from './wfform.js';
3
+ import { AuthTokenSession } from './auth-token.js';
4
+ import { findCreateWfByName } from './wfcreate.js';
5
+ import { uploadFile } from './wfupload.js';
6
+ import { getCurrentUser, defaultForField } from './wfuser.js';
7
+ import { todayStr } from './util.js';
8
+ import { beginSubmission, extractCreatedRequestId, updateSubmission } from './submissionstate.js';
9
+ const VIEWATTR_LABEL = { 1: '可编辑', 2: '只读', 3: '隐藏' };
10
+ /** 已知系统字段 id(负数),单独标注,不作为业务填值候选。 */
11
+ const SYS_FIELD_IDS = new Set(['-1', '-2', '-4', '-9', '-10']);
12
+ 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}」未找到流程`);
20
+ if (hits.length > 1) {
21
+ const list = hits.map((w) => `${w.name} [id=${w.id ?? ''}]`).join('\n ');
22
+ throw new Error(`按名「${name}」找到 ${hits.length} 个候选,请指定 --id:\n ${list}`);
23
+ }
24
+ return hits[0].id ?? '';
25
+ }
26
+ /**
27
+ * 取主表业务字段清单(isdetail==0,排除系统字段)。**不按 viewattr 过滤**(静态 viewattr
28
+ * 不反映运行时可编辑性)。从 maindata 取当前值与选项。供 AI 向用户问答"填哪些"。
29
+ */
30
+ export async function getStartableFields(cfg, workflowId) {
31
+ const f = await describeFormFields(cfg, workflowId);
32
+ if (f.verifyRight === false)
33
+ throw new Error(`无权限(verifyRight:false),跑 e9 auth token-login 后重试`);
34
+ 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)))
37
+ .map((fld) => toStartable(fld, f.maindata));
38
+ // 浏览框(htmltype=3)且无 maindata 选项的,调通用 browser data 接口取前 10
39
+ for (const fld of fields) {
40
+ if (fld.htmltype === 3 && (!fld.options || fld.options.length === 0)) {
41
+ try {
42
+ fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10);
43
+ fld.inputHint = fld.options.length > 0 ? `浏览框(前10: 给 option id)` : '浏览选择(给 id)';
44
+ }
45
+ catch {
46
+ /* 取选项失败不影响主流程,用户仍可给 id */
47
+ }
48
+ }
49
+ }
50
+ // 当前用户相关字段(申请人/部门/日期等)填默认值
51
+ let user = null;
52
+ try {
53
+ user = await getCurrentUser(cfg);
54
+ }
55
+ catch { /* 取不到不影响 */ }
56
+ if (user) {
57
+ for (const fld of fields) {
58
+ const dv = defaultForField(fld.label, fld.htmltype, user);
59
+ if (dv)
60
+ fld.defaultValue = dv;
61
+ }
62
+ }
63
+ // 明细表(detail_1/detail_2/...):index=提交体后缀(detail_1→0,detail_2→1),取字段+浏览框选项
64
+ const detailTables = [];
65
+ for (const t of f.tables) {
66
+ if (!t.key.startsWith('detail_'))
67
+ continue;
68
+ const idx = Number(t.key.substring('detail_'.length)) - 1;
69
+ if (!Number.isFinite(idx) || idx < 0)
70
+ continue;
71
+ const dflds = t.fields.filter((fld) => !SYS_FIELD_IDS.has(String(fld.id)));
72
+ const dStartable = dflds.map((fld) => toStartable(fld, f.maindata));
73
+ for (const fld of dStartable) {
74
+ if (fld.htmltype === 3 && (!fld.options || fld.options.length === 0)) {
75
+ try {
76
+ fld.options = await getBrowserOptions(cfg, workflowId, fld.id, 10);
77
+ fld.inputHint = fld.options.length > 0 ? `明细浏览框(前10: 给 option id)` : '明细浏览选择(给 id)';
78
+ }
79
+ catch { /* 取选项失败不阻断 */ }
80
+ }
81
+ }
82
+ detailTables.push({ index: idx, key: t.key, fields: dStartable });
83
+ }
84
+ return {
85
+ workflowId: f.workflowId,
86
+ workflowName: f.workflowName,
87
+ verifyRight: f.verifyRight,
88
+ fields,
89
+ detailTables,
90
+ maindata: f.maindata,
91
+ submitParams: f.submitParams,
92
+ params: f.params,
93
+ linkageCfg: f.raw?.linkageCfg,
94
+ };
95
+ }
96
+ function toStartable(fld, maindata) {
97
+ const key = `field${fld.id}`;
98
+ const cell = maindata?.[key];
99
+ const special = Array.isArray(cell?.specialobj) ? cell.specialobj : [];
100
+ let options = special.filter((o) => o && (o.id != null || o.name != null)).map((o) => ({ id: String(o.id ?? ''), name: String(o.name ?? '') }));
101
+ const ht = fld.htmltype;
102
+ // 选择框(htmltype=5):选项在 fieldinfomap.selectattr.selectitemlist(不在 maindata)
103
+ if (ht === 5 && options.length === 0) {
104
+ const items = fld.raw?.selectattr?.selectitemlist;
105
+ if (Array.isArray(items)) {
106
+ options = items.filter((it) => it && it.selectname != null).map((it) => ({ id: String(it.selectvalue), name: String(it.selectname) }));
107
+ }
108
+ }
109
+ let inputHint;
110
+ if (ht === 3)
111
+ inputHint = '浏览选择(给 option id)';
112
+ else if (ht === 5 && options.length > 0)
113
+ inputHint = `选择框(给 option id)`;
114
+ else if (options.length > 1)
115
+ inputHint = '选择框(给 option id)';
116
+ else if (ht === 2)
117
+ inputHint = '多行文本';
118
+ else
119
+ inputHint = '文本';
120
+ const va = fld.viewattr;
121
+ return {
122
+ id: String(fld.id),
123
+ label: fld.label,
124
+ name: fld.name,
125
+ htmltype: ht,
126
+ htmltypeLabel: ht != null ? HTMLTYPE_LABEL[ht] ?? `ht=${ht}` : undefined,
127
+ viewattr: va,
128
+ viewattrLabel: va != null ? VIEWATTR_LABEL[va] ?? `va=${va}` : undefined,
129
+ currentValue: cell?.value != null ? String(cell.value) : undefined,
130
+ options: options.length > 0 ? options : undefined,
131
+ inputHint,
132
+ };
133
+ }
134
+ /**
135
+ * 构造新建提交体(扁平 form-urlencoded,匹配 create curl 字段集)。
136
+ * values: { "<id>": "值" }(业务字段值;图片/附件字段值=imagefileid;多选浏览框值=逗号分隔选项id)。
137
+ * requestname: 请求标题。browserNames: { "<fieldId>": "名称1##~~@@##名称2" }(多选浏览框的显示名,设 field<id>name)。
138
+ * details: 明细表行数据 [{index, rows:[{fieldId:value}, ...]}],index 对应 detailTables[].index(detail_1→0)。
139
+ * 每行每字段生成 field<id>_<rownum>=value(rownum 从 0 开始,每个明细表独立编号);
140
+ * + nodesnum<index>/indexnum<index>=行数 + submitdtlid<index>=0,0,...(每行一个0带尾逗号)+ deldtlid<index>=(空);
141
+ * + detailFieldUnEmptyCount=所有明细行非空字段总数;existChangeRange 追加 field<id>_<rownum>。
142
+ *
143
+ * 字段值结构(用户 create curl 确认):
144
+ * - 文本/数字字段:field<id>=<值>
145
+ * - 图片/附件字段:field<id>=<imagefileid>(annexdocids/annexdocinfos/field-annexupload 全空)
146
+ * - 多选浏览框:field<id>=<id1>,<id2> + field<id>name=<名1>##~~@@##<名2>
147
+ * - 明细行:field<id>_<rownum>=<值>(rownum 从 0,每明细表独立)
148
+ * - existChangeRange=field<id>,field<id>...(field 前缀);verifyRequiredRange=field-9999,field-1,(标题必填)
149
+ */
150
+ export function buildCreateSubmitBody(workflowId, values, requestname, params, submitParams, browserNames = {}, details = []) {
151
+ const wf = String(workflowId);
152
+ const p = params ?? {};
153
+ const sp = submitParams ?? {};
154
+ const filledIds = Object.keys(values).map((k) => (k.startsWith('field') ? k.replace(/^field/, '') : k));
155
+ // 当前流程创建人 userid(loadForm params 给定)。submit_token 等字段的 key 带此 userid 前缀,
156
+ // 不能硬编码 "1"——非 sysadmin=1 的环境(如阿汤哥 222222600)会取不到 submit_token 致"创建人和系统登录人不一致"。
157
+ const uid = String(p.f_weaver_belongto_userid ?? sp.f_weaver_belongto_userid ?? '1');
158
+ // 公文(odoc)流程标识:loadForm params.isWorkflowDoc=true 或 workflowtype=26。
159
+ // isOdocRequest/odocBtnFlag 浏览器提交体必带(否则 requestOperation 不进公文流转态,只保存不流转)。
160
+ const isWorkflowDoc = String(p.isWorkflowDoc ?? sp.isWorkflowDoc ?? 'false');
161
+ const isOdoc = isWorkflowDoc === 'true' || String(sp.workflowtype ?? p.workflowtype ?? '') === '26';
162
+ const body = {
163
+ formid: String(sp.formid ?? p.formid ?? ''),
164
+ 'f_weaver_belongto_userid': uid,
165
+ isWorkflowDoc,
166
+ 'f_weaver_belongto_usertype': String(p.f_weaver_belongto_usertype ?? sp.f_weaver_belongto_usertype ?? '0'),
167
+ nodetype: String(sp.nodetype ?? '0'),
168
+ method: '',
169
+ needoutprint: '',
170
+ src: 'submit',
171
+ isMultiDoc: '',
172
+ [`${uid}_${wf}_addrequest_submit_token`]: String(sp[`${uid}_${wf}_addrequest_submit_token`] ?? sp[`1_${wf}_addrequest_submit_token`] ?? ''),
173
+ topage: '',
174
+ workflowtype: String(sp.workflowtype ?? p.workflowtype ?? ''),
175
+ iscreate: '1',
176
+ comemessage: '',
177
+ remindTypes: '',
178
+ rand: '',
179
+ requestid: '-1',
180
+ linkageUUID: String(sp.linkageUUID ?? ''),
181
+ htmlfieldids: '',
182
+ needwfback: '1',
183
+ lastloginuserid: String(p.lastloginuserid ?? uid),
184
+ nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
185
+ workflowid: wf,
186
+ isbill: String(sp.isbill ?? '1'),
187
+ annexdocids: '',
188
+ signdocids: '',
189
+ annexdocinfos: '',
190
+ handWrittenSign: '',
191
+ remark: '',
192
+ 'field-annexupload': '',
193
+ signworkflowids: '',
194
+ remarkLocation: '',
195
+ isOdocRequest: isOdoc ? '1' : '0',
196
+ enableIntervenor: '',
197
+ odocBtnFlag: isOdoc ? 'odocTransfer1' : '',
198
+ verifyRequiredRange: 'field-9999,field-1,',
199
+ linkageUnFinishedKey: '',
200
+ remarkquote: '',
201
+ actiontype: 'requestOperation',
202
+ isFirstSubmit: '',
203
+ existChangeRange: filledIds.map((id) => `field${id}`).join(','),
204
+ requestname,
205
+ requestlevel: String(values['-2'] ?? '0'),
206
+ 'field-10': values['-10'] ?? values['field-10'] ?? '4',
207
+ 'field-9': '',
208
+ };
209
+ // 业务字段值(扁平 field<id>=<value>);浏览框字段额外设 field<id>name
210
+ for (const [k, v] of Object.entries(values)) {
211
+ const id = k.startsWith('field') ? k.replace(/^field/, '') : k;
212
+ if (SYS_FIELD_IDS.has(id))
213
+ continue; // 系统字段已单独处理
214
+ body[`field${id}`] = v;
215
+ if (browserNames[id])
216
+ body[`field${id}name`] = browserNames[id];
217
+ }
218
+ body.remark = String(values['-4'] ?? '');
219
+ body.mainFieldUnEmptyCount = String(filledIds.filter((id) => !SYS_FIELD_IDS.has(id)).length);
220
+ // 明细表行:每行每字段 field<id>_<rownum>=value(rownum 从 0,每明细表独立)+ 行控制字段
221
+ const existDetail = [];
222
+ let detailUnEmpty = 0;
223
+ for (const dt of details) {
224
+ const rows = dt.rows || [];
225
+ if (rows.length === 0)
226
+ continue; // 空明细表不设行控制(新建场景无删行)
227
+ body[`nodesnum${dt.index}`] = String(rows.length);
228
+ body[`indexnum${dt.index}`] = String(rows.length);
229
+ body[`submitdtlid${dt.index}`] = rows.map(() => '0').join(',') + ',';
230
+ body[`deldtlid${dt.index}`] = '';
231
+ rows.forEach((row, rownum) => {
232
+ for (const [k, v] of Object.entries(row)) {
233
+ const id = k.startsWith('field') ? k.replace(/^field/, '') : k;
234
+ if (SYS_FIELD_IDS.has(id))
235
+ continue;
236
+ body[`field${id}_${rownum}`] = v;
237
+ if (browserNames[id])
238
+ body[`field${id}_${rownum}name`] = browserNames[id];
239
+ if (v !== '') {
240
+ detailUnEmpty++;
241
+ existDetail.push(`field${id}_${rownum}`);
242
+ }
243
+ }
244
+ });
245
+ }
246
+ body.detailFieldUnEmptyCount = String(detailUnEmpty);
247
+ // existChangeRange 追加明细非空字段(带 _<rownum> 后缀)
248
+ if (existDetail.length > 0)
249
+ body.existChangeRange = (body.existChangeRange ? body.existChangeRange + ',' : '') + existDetail.join(',');
250
+ body.signatureAttributesStr = String(p.signatureAttributesStr ?? '');
251
+ body.signatureSecretKey = String(p.signatureSecretKey ?? '');
252
+ body.selectNextFlow = '0';
253
+ body.openDataVerify = '0';
254
+ body.wfTestStr = '';
255
+ return body;
256
+ }
257
+ /**
258
+ * 解析多选浏览框(htmltype=3)字段的显示名。
259
+ * values 里某字段值=逗号分隔选项 id,这里查 browser data 接口拼成 "名1##~~@@##名2",
260
+ * 供提交体设 field<id>name。取不到不阻断(提交体仍发 field<id>=ids)。
261
+ *
262
+ * 由 create start 的 dry-run 预览与真提交共享,避免两处重复 getBrowserOptions+拼名逻辑。
263
+ */
264
+ export async function resolveBrowserNames(cfg, workflowId, fields, values, details = []) {
265
+ const bn = {};
266
+ for (const fld of fields) {
267
+ if (fld.htmltype !== 3)
268
+ continue;
269
+ const id = String(fld.id);
270
+ const fieldValues = [values[id]];
271
+ for (const detail of details) {
272
+ for (const row of detail.rows ?? [])
273
+ fieldValues.push(row[id] ?? row[`field${id}`]);
274
+ }
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)
281
+ continue;
282
+ 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('##~~@@##');
289
+ }
290
+ catch {
291
+ /* 解析失败不阻断 */
292
+ }
293
+ }
294
+ return bn;
295
+ }
296
+ /**
297
+ * 选择框/浏览框字段:中文选项名 → 选项 ID 自动转换。
298
+ * 遍历所有表(主表+明细表)的字段,若字段 htmltype=3(浏览框) 或 5(选择框),
299
+ * 将用户传入的选项名(中文)匹配为选项 id。name→id 匹配完成后原地修改 merged。
300
+ * 选项来源:先查 maindata 的 specialobj,再查 fieldinfomap 的 selectattr.selectitemlist。
301
+ */
302
+ function resolveOptionNames(tables, maindata, merged) {
303
+ for (const t of tables) {
304
+ for (const fld of t.fields ?? []) {
305
+ const ht = fld.htmltype;
306
+ if (ht !== 3 && ht !== 5)
307
+ continue;
308
+ const id = String(fld.id);
309
+ const v = merged[id];
310
+ if (v === undefined || v === '')
311
+ continue;
312
+ // 收集选项:先 maindata.specialobj → 再 fieldinfomap.selectattr
313
+ const key = `field${id}`;
314
+ const cell = maindata?.[key];
315
+ const special = Array.isArray(cell?.specialobj) ? cell.specialobj : [];
316
+ let selectItems = special.filter((o) => o && (o.id != null || o.name != null)).map((o) => ({ selectvalue: String(o.id ?? ''), selectname: String(o.name ?? '') }));
317
+ if (selectItems.length === 0) {
318
+ const rawItems = fld.raw?.selectattr?.selectitemlist;
319
+ if (Array.isArray(rawItems))
320
+ selectItems = rawItems.filter((it) => it && it.selectname != null);
321
+ }
322
+ if (selectItems.length === 0)
323
+ continue;
324
+ // 按逗号拆分(支持多选)
325
+ const parts = String(v).split(',').map((s) => s.trim());
326
+ 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 本身)
331
+ });
332
+ const result = converted.join(',');
333
+ if (result !== String(v)) {
334
+ merged[id] = result;
335
+ }
336
+ }
337
+ }
338
+ }
339
+ /**
340
+ * 字段联动:已填字段触发联动,自动补上关联字段值。
341
+ * 遍历所有表的字段,若有 linkageId 且已填值,调 reqDataInputResult 获取联动值,
342
+ * 合并进 merged(不覆盖用户已填的值)。
343
+ *
344
+ * 接口:POST /api/workflow/linkage/reqDataInputResult
345
+ * 参数:linkageid(来自 fieldinfomap.linkageId)、linkageUUID(来自 loadForm)
346
+ * 响应:assignInfo_{linkageId}.changeValue = { fieldNNN: { value, specialobj } }
347
+ */
348
+ async function resolveFieldLinkages(cfg, workflowId, f, merged) {
349
+ const params = f.params ?? {};
350
+ const sp = f.submitParams ?? {};
351
+ const linkageUUID = String(sp.linkageUUID ?? '');
352
+ if (!linkageUUID)
353
+ return;
354
+ const uid = String(params.f_weaver_belongto_userid ?? sp.f_weaver_belongto_userid ?? '1');
355
+ const nodeid = String(sp.nodeid ?? params.nodeid ?? '');
356
+ const formid = String(sp.formid ?? params.formid ?? '');
357
+ // linkageCfg.dataInputCfg.{fieldId} = [{ config: linkageId, assignfields: [targetFieldId], relateFields: [triggerFieldId] }]
358
+ const dataInputCfg = f.raw?.linkageCfg?.dataInputCfg;
359
+ if (!dataInputCfg || typeof dataInputCfg !== 'object')
360
+ return;
361
+ const triggers = [];
362
+ for (const [fieldId, configs] of Object.entries(dataInputCfg)) {
363
+ if (!Array.isArray(configs))
364
+ continue;
365
+ const v = merged[String(fieldId)];
366
+ if (v === undefined || v === '')
367
+ continue;
368
+ for (const cfg of configs) {
369
+ const linkageId = cfg?.config;
370
+ if (!linkageId)
371
+ continue;
372
+ triggers.push({
373
+ linkageId: String(linkageId),
374
+ fieldId: String(fieldId),
375
+ value: String(v),
376
+ tableKey: 'main',
377
+ });
378
+ }
379
+ }
380
+ if (triggers.length === 0)
381
+ return;
382
+ const api = await createApiFromProfile(cfg);
383
+ for (const tr of triggers) {
384
+ const rowIndex = tr.tableKey === 'main' ? '-1' : '0'; // 明细表目前仅支持首行
385
+ const body = {
386
+ requestid: '-1', workflowid: String(workflowId), nodeid, formid,
387
+ isbill: '1', triSource: '1', showAI: '1',
388
+ [`triFieldid_${tr.linkageId}`]: tr.fieldId,
389
+ [`rowIndexStr_${tr.linkageId}`]: rowIndex,
390
+ [`triTableMark_${tr.linkageId}`]: tr.tableKey,
391
+ [`field${tr.fieldId}`]: tr.value,
392
+ linkageid: tr.linkageId,
393
+ linkageUUID,
394
+ wfTestStr: '',
395
+ f_weaver_belongto_userid: uid,
396
+ f_weaver_belongto_usertype: '0',
397
+ };
398
+ try {
399
+ const r = await api.post('/api/workflow/linkage/reqDataInputResult', body);
400
+ const j = r.json ?? {};
401
+ const assignInfo = j[`assignInfo_${tr.linkageId}`];
402
+ if (!assignInfo || assignInfo.resultOutOfLimit)
403
+ continue;
404
+ const changeValue = assignInfo.changeValue;
405
+ if (!changeValue || typeof changeValue !== 'object')
406
+ continue;
407
+ for (const [key, cv] of Object.entries(changeValue)) {
408
+ if (!cv || typeof cv !== 'object')
409
+ continue;
410
+ const targetId = key.replace(/^field/, '');
411
+ if (merged[targetId] !== undefined)
412
+ continue; // 用户已填则不覆盖
413
+ merged[targetId] = String(cv.value ?? '');
414
+ // 同时记录 specialobj 供后续使用(如 resolveBrowserNames)
415
+ if (Array.isArray(cv.specialobj) && cv.specialobj.length > 0) {
416
+ const so = cv.specialobj[0];
417
+ merged[`_so_${targetId}`] = JSON.stringify(so);
418
+ }
419
+ }
420
+ }
421
+ catch {
422
+ // 联动失败不阻断提交
423
+ }
424
+ }
425
+ }
426
+ /**
427
+ * 真正提交(新建流程)。POST /api/workflow/reqform/requestOperation(aiAgentToken)。
428
+ * files: {fieldId, filePath, listType?}[] —— 先上传拿 imagefileid,并入 field<id>(不设 annex 字段)。
429
+ * values 里多选浏览框字段的值=逗号分隔选项id,自动解析对应 name 设 field<id>name。
430
+ * details: 明细表行数据 [{index, rows:[{fieldId:value}]}],每明细表 index 对应 detailTables[].index(detail_1→0)。
431
+ * 明细浏览框字段解析并提交 id 与 name;确认后由提交计划冻结,避免二次解析漂移。
432
+ * 返回 E9Response(成功通常含 requestid)。
433
+ */
434
+ export async function prepareCreateSubmission(cfg, workflowId, values, requestname, details = []) {
435
+ const f = await describeFormFields(cfg, workflowId);
436
+ if (f.verifyRight === false)
437
+ throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
438
+ const name = requestname || `${f.workflowName ?? '流程'}-系统管理员-${todayStr()}`;
439
+ const merged = { ...values };
440
+ // 用户未填的人员/部门/日期字段,用当前用户信息默认补上
441
+ const main = f.tables.find((t) => t.key === 'main');
442
+ let user = null;
443
+ try {
444
+ user = await getCurrentUser(cfg);
445
+ }
446
+ catch { /* 取不到不阻断 */ }
447
+ if (user) {
448
+ for (const fld of main?.fields ?? []) {
449
+ const id = String(fld.id);
450
+ if (merged[id] !== undefined)
451
+ continue; // 用户已填
452
+ if (fld.viewattr === 2)
453
+ continue; // 只读字段由系统/联动控制,不补默认值(避免覆盖联动计算,如外出时间 WCSJ)
454
+ const dv = defaultForField(fld.label, fld.htmltype, user);
455
+ if (dv)
456
+ merged[id] = dv;
457
+ }
458
+ }
459
+ // 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值
460
+ try {
461
+ const linkageChanges = await resolveLinkage(cfg, workflowId, merged);
462
+ for (const [fieldId, cv] of Object.entries(linkageChanges)) {
463
+ if (merged[fieldId] === undefined || merged[fieldId] === '0') {
464
+ merged[fieldId] = cv.value;
465
+ if (cv.specialobj && cv.specialobj.length > 0) {
466
+ const so = cv.specialobj[0];
467
+ console.log(` 🔗 联动: 经办人 → ${fieldId}=${so.name} (id=${cv.value})`);
468
+ }
469
+ }
470
+ }
471
+ }
472
+ catch { /* 联动失败不阻断 */ }
473
+ // 多选浏览框字段(htmltype=3)的值=逗号分隔id,解析对应 name 设 field<id>name
474
+ const browserFields = f.tables.flatMap((table) => table.fields ?? []);
475
+ const browserNames = await resolveBrowserNames(cfg, workflowId, browserFields, merged, details);
476
+ const body = buildCreateSubmitBody(workflowId, merged, name, f.params, f.submitParams, browserNames, details);
477
+ return { workflowId: String(workflowId), workflowName: f.workflowName, requestName: name, values: merged, details, browserNames, body };
478
+ }
479
+
480
+ /**
481
+ * 真正提交(新建流程)。若传入 prepared,则只用确认阶段冻结的业务字段、明细行与浏览框名称,
482
+ * 仅刷新本次表单必需的短期签名参数,绝不重新补默认值或执行联动。
483
+ */
484
+ export async function submitCreateRequest(cfg, workflowId, values, requestname, files = [], details = [], prepared = undefined) {
485
+ let plan = prepared;
486
+ if (!plan) {
487
+ plan = await prepareCreateSubmission(cfg, workflowId, values, requestname, details);
488
+ }
489
+ else {
490
+ plan = { ...prepared, values: { ...prepared.values } };
491
+ }
492
+ // 文件 ID 只能在真实提交时获得;其余已确认字段绝不重新解析或联动。
493
+ for (const fl of (plan.files ?? files)) {
494
+ const up = await uploadFile(cfg, { workflowId: String(workflowId), fieldId: fl.fieldId, filePath: fl.filePath, listType: fl.listType });
495
+ plan.values[fl.fieldId] = up.fileid;
496
+ }
497
+ const f = await describeFormFields(cfg, workflowId);
498
+ if (f.verifyRight === false)
499
+ throw new Error('无权限(verifyRight:false),跑 e9 auth token-login');
500
+ const body = buildCreateSubmitBody(workflowId, plan.values, plan.requestName, f.params, f.submitParams, plan.browserNames, plan.details);
501
+ const started = beginSubmission({ workflowId, workflowName: f.workflowName, requestName: plan.requestName, values: plan.values });
502
+ if (started.duplicate) {
503
+ throw new Error(`检测到相同内容刚刚已发起或结果待核验(submission_id=${started.record.id})。为避免重复创建,本次没有再次提交;请先运行 create verify ${started.record.id}。`);
504
+ }
505
+ const api = await createApiFromProfile(cfg);
506
+ // 公文(odoc)流程:requestOperation 只保存主表+分配 requestid,返回 type=ASYNC_SUBMIT(进入
507
+ // 异步流转态但未推送)。**不在此自动流转**——需先调 getOdocNextNodes 取候选操作人,跟用户确认
508
+ // 流转给谁后,再调 odocFlowNext 带 nextDirections 推送。create start 仅保存+展示候选,
509
+ // create flow 子命令完成选定+流转(符合"确认后再流转")。
510
+ try {
511
+ const response = await api.post('/api/workflow/reqform/requestOperation', body);
512
+ const result = extractCreatedRequestId(response);
513
+ if (result.accepted) {
514
+ const submission = updateSubmission(started.record.id, { status: 'created', requestId: result.requestId, responseType: result.type });
515
+ return { response, submission, outcome: 'created' };
516
+ }
517
+ const submission = updateSubmission(started.record.id, { status: 'uncertain', responseType: result.type, lastHttpStatus: response.status });
518
+ return { response, submission, outcome: 'uncertain' };
519
+ }
520
+ catch (error) {
521
+ const submission = updateSubmission(started.record.id, { status: 'uncertain', error: String(error?.message ?? error) });
522
+ throw new Error(`发起请求结果未确认(submission_id=${submission.id}),系统已阻止自动重试以避免重复创建。请先运行 create verify ${submission.id} 核验。原始错误:${error.message}`);
523
+ }
524
+ }
525
+ export async function getOdocNextNodes(cfg, requestid, workflowId, params, submitParams) {
526
+ const p = params ?? {};
527
+ const sp = submitParams ?? {};
528
+ const uid = String(p.f_weaver_belongto_userid ?? sp.f_weaver_belongto_userid ?? '1');
529
+ const api = await createApiFromProfile(cfg);
530
+ const r = await api.post('/api/odoc/odocrequest/loadData', {
531
+ dataId: '', requestid, workflowid: String(workflowId),
532
+ nodeid: String(sp.nodeid ?? p.nodeid ?? ''), formid: String(sp.formid ?? p.formid ?? ''),
533
+ isbill: String(sp.isbill ?? p.isbill ?? '1'),
534
+ 'f_weaver_belongto_userid': uid, 'f_weaver_belongto_usertype': String(p.f_weaver_belongto_usertype ?? sp.f_weaver_belongto_usertype ?? '0'),
535
+ creater: uid, creatertype: '0', requestType: '2', remindTypes: '',
536
+ authSignatureStr: '', authStr: '', agentType: '', agentorByAgentId: '',
537
+ });
538
+ const j = r.json ?? {};
539
+ const list = Array.isArray(j.nextNodeList) ? j.nextNodeList : [];
540
+ return list.map((n) => ({
541
+ nodeid: Number(n.nodeid),
542
+ linkId: Number(n.linkId),
543
+ nodename: String(n.nodename ?? ''),
544
+ operators: Array.isArray(n.nodeOperators) ? n.nodeOperators.map((o) => ({ id: String(o.id ?? o.operatorid ?? ''), name: String(o.name ?? o.operatorname ?? '') })) : [],
545
+ defaultUserIds: String(n.nodeOperatorIds ?? ''),
546
+ isMustSelected: !!n.isMustSelected,
547
+ }));
548
+ }
549
+ /**
550
+ * 公文流转下一步。POST /api/odoc/odocrequest/flowNext(aiAgentToken 鉴权)。
551
+ *
552
+ * 公文流程 requestOperation(iscreate=1)只保存+进 ASYNC_SUBMIT 态,未推送。调本接口把请求推到
553
+ * 下一审批节点,公文才算"提交完成"。nextDirections 必须指定流转对象(userIds+nodeid+linkId),
554
+ * 由调用方从 getOdocNextNodes 选定后传入——空 nextDirections 是假成功(不真流转)。
555
+ *
556
+ * 链路(浏览器抓包对齐):getLastOperateInfo 取真实 lastOperate → POST flowNext(带选定 nextDirections)。
557
+ * 复用 requestOperation 提交体的 verifyRequiredRange(必填校验范围,flowNext 同样校验)。
558
+ *
559
+ * 返回 {success, msg}(success=1 即"提交成功",流程已流转到下一节点)。
560
+ */
561
+ export async function odocFlowNext(cfg, requestid, workflowId, params, submitParams, submitBody, nextDirections) {
562
+ const p = params ?? {};
563
+ const sp = submitParams ?? {};
564
+ const uid = String(p.f_weaver_belongto_userid ?? sp.f_weaver_belongto_userid ?? '1');
565
+ const wf = String(workflowId);
566
+ const api = await createApiFromProfile(cfg);
567
+ // 1) getLastOperateInfo — 取真实 lastOperate(flowNext body 必须带,否则报"处理前已流转"误报)
568
+ let lastOperateDate = todayStr();
569
+ let lastOperator = uid;
570
+ let lastOperateTime = '';
571
+ try {
572
+ const last = await api.post('/api/odoc/odocrequest/getLastOperateInfo', { requestid });
573
+ const lj = last.json ?? {};
574
+ if (lj.lastOperateDate)
575
+ lastOperateDate = String(lj.lastOperateDate);
576
+ if (lj.lastOperator)
577
+ lastOperator = String(lj.lastOperator);
578
+ if (lj.lastOperateTime)
579
+ lastOperateTime = String(lj.lastOperateTime);
580
+ }
581
+ catch { /* 取不到用默认,不阻断 */ }
582
+ // 2) flowNext — nextDirections 由调用方选定(来自 getOdocNextNodes),空则不流转
583
+ const body = {
584
+ formid: String(sp.formid ?? p.formid ?? ''),
585
+ 'f_weaver_belongto_userid': uid,
586
+ isWorkflowDoc: 'true',
587
+ 'f_weaver_belongto_usertype': String(p.f_weaver_belongto_usertype ?? sp.f_weaver_belongto_usertype ?? '0'),
588
+ nodetype: String(sp.nodetype ?? p.nodetype ?? '0'),
589
+ method: '', needoutprint: '', src: '',
590
+ isMultiDoc: '', topage: '',
591
+ workflowtype: String(sp.workflowtype ?? p.workflowtype ?? '26'),
592
+ iscreate: '0', comemessage: '', remindTypes: '', rand: '',
593
+ requestid, linkageUUID: '', htmlfieldids: '', needwfback: '',
594
+ lastloginuserid: String(p.lastloginuserid ?? uid),
595
+ nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
596
+ workflowid: wf,
597
+ isbill: String(sp.isbill ?? p.isbill ?? '1'),
598
+ [`${uid}_${wf}_addrequest_submit_token`]: '',
599
+ annexdocids: '', signdocids: '', annexdocinfos: '', handWrittenSign: '', remark: '',
600
+ 'field-annexupload': '', signworkflowids: '', remarkLocation: '',
601
+ isOdocRequest: '1', enableIntervenor: '0', odocBtnFlag: 'odocTransfer1',
602
+ verifyRequiredRange: submitBody.verifyRequiredRange ?? 'field-9999,field-1,',
603
+ linkageUnFinishedKey: '', isremark: '0',
604
+ [`${uid}_${requestid}_request_submit_token`]: '',
605
+ lastOperateDate, lastOperator, lastOperateTime,
606
+ creater: uid, creatertype: '0', requestType: '2',
607
+ authSignatureStr: '', authStr: '', agentType: '', agentorByAgentId: '',
608
+ isend: '1', assignRemark: '',
609
+ nextDirections: JSON.stringify(nextDirections.map((d) => ({
610
+ userIds: d.userIds, ccUserIds: '', userSignType: '', userTransferType: '', userCopyType: '',
611
+ nodeid: d.nodeid, linkId: d.linkId,
612
+ }))),
613
+ };
614
+ const r = await api.post('/api/odoc/odocrequest/flowNext', body);
615
+ const j = r.json ?? {};
616
+ return { success: j.success === 1 || j.success === true, msg: String(j.msg ?? '') };
617
+ }
618
+ /**
619
+ * 审批路径预览:获取流程的所有节点及操作者信息。
620
+ * POST /api/workflow/layout/getSimpleModeData
621
+ * 返回 nodeInfo 数组,每节点含 nodeName/operatorName/nodeType 等。
622
+ */
623
+ export async function getWorkflowPreview(cfg, requestId) {
624
+ const api = await createApiFromProfile(cfg);
625
+ // 先通过 loadForm 获取 workflowid
626
+ const r0 = await api.post('/api/workflow/reqform/loadForm', { iscreate: '0', requestid: requestId });
627
+ const j0 = r0.json ?? {};
628
+ const p0 = j0.params ?? {};
629
+ const wfId = String(p0.workflowid ?? '');
630
+ if (!wfId)
631
+ throw new Error('无法获取 workflowid');
632
+ const r = await api.post('/api/workflow/layout/getSimpleModeData', {
633
+ requestid: requestId,
634
+ requestType: '0',
635
+ workflowid: wfId,
636
+ });
637
+ const j = r.json ?? {};
638
+ const nodeInfo = Array.isArray(j.nodeInfo) ? j.nodeInfo : [];
639
+ const forecastInfo = j.forecastInfo ?? null;
640
+ return {
641
+ nodeInfo: nodeInfo.map((n) => ({
642
+ nodeName: String(n.nodeName ?? ''),
643
+ nodeOperatorName: String(n.nodeOperatorName ?? ''),
644
+ nodeType: n.nodeType,
645
+ isCurrent: n.isCurrent === true || n.isCurrent === 'true',
646
+ isPassed: n.isPassed === true || n.isPassed === 'true',
647
+ })),
648
+ forecastInfo: forecastInfo ? {
649
+ forecastNodeName: String(forecastInfo.forecastNodeName ?? ''),
650
+ forecastUserName: String(forecastInfo.forecastUserName ?? ''),
651
+ } : null,
652
+ };
653
+ }
654
+ /**
655
+ * 联动解析:根据已填字段值,调用 reqDataInputResult 获取联动字段值。
656
+ * 从 loadForm.linkageCfg.dataInputCfg 读取联动配置,对每个触发字段逐一调用。
657
+ * 返回 { "<fieldId>": { value, specialobj } } 的变更值映射。
658
+ */
659
+ export async function resolveLinkage(cfg, workflowId, values) {
660
+ const f = await describeFormFields(cfg, workflowId);
661
+ const linkageCfg = f.raw?.linkageCfg;
662
+ if (!linkageCfg?.dataInputCfg)
663
+ return {};
664
+ const p = f.params ?? {};
665
+ const sp = f.submitParams ?? {};
666
+ const base = cfg.baseUrl.replace(/\/+$/, '');
667
+ const sess = AuthTokenSession.fromProfile(cfg);
668
+ const changes = {};
669
+ for (const [triggerFieldId, configs] of Object.entries(linkageCfg.dataInputCfg)) {
670
+ if (!Array.isArray(configs))
671
+ continue;
672
+ const triggerValue = values[triggerFieldId];
673
+ if (!triggerValue)
674
+ continue;
675
+ for (const cfg of configs) {
676
+ const configId = cfg.config;
677
+ if (!configId)
678
+ continue;
679
+ try {
680
+ const body = new URLSearchParams({
681
+ requestid: '-1',
682
+ workflowid: String(workflowId),
683
+ nodeid: String(sp.nodeid ?? p.nodeid ?? ''),
684
+ formid: String(sp.formid ?? p.formid ?? ''),
685
+ isbill: String(sp.isbill ?? p.isbill ?? '1'),
686
+ triSource: '1',
687
+ showAI: '1',
688
+ [`triFieldid_${configId}`]: triggerFieldId,
689
+ [`rowIndexStr_${configId}`]: '-1',
690
+ [`triTableMark_${configId}`]: 'main',
691
+ [`field${triggerFieldId}`]: triggerValue,
692
+ linkageid: configId,
693
+ linkageUUID: String(sp.linkageUUID ?? ''),
694
+ wfTestStr: '',
695
+ 'f_weaver_belongto_userid': String(p.f_weaver_belongto_userid ?? '1'),
696
+ 'f_weaver_belongto_usertype': String(p.f_weaver_belongto_usertype ?? '0'),
697
+ });
698
+ const r = await fetch(`${base}/api/workflow/linkage/reqDataInputResult`, {
699
+ method: 'POST',
700
+ headers: { aiAgentToken: sess.token, 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
701
+ body: body.toString(),
702
+ signal: AbortSignal.timeout(15000),
703
+ });
704
+ const j = JSON.parse(await r.text());
705
+ const assignInfo = j[`assignInfo_${configId}`];
706
+ if (assignInfo?.changeValue) {
707
+ for (const [key, cv] of Object.entries(assignInfo.changeValue)) {
708
+ const fieldId = key.replace(/^field/, '');
709
+ changes[fieldId] = { value: cv.value, specialobj: cv.specialobj };
710
+ }
711
+ }
712
+ }
713
+ catch { /* 联动失败不阻断 */ }
714
+ }
715
+ }
716
+ return changes;
717
+ }