weaver-e9-assistant-cli 1.1.5 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待办提交的明细行规划(添加 / 修改 / 删除)。
|
|
3
|
+
*
|
|
4
|
+
* 权威格式来自 E9 前端 `/spa/workflow/static4form/index.js` 的 `generateFormData`:
|
|
5
|
+
* - nodesnum<idx> = 存活行数
|
|
6
|
+
* - indexnum<idx> = 明细表行下标计数器(新增行 +1,删除不回退)
|
|
7
|
+
* - submitdtlid<idx> = 存活行下标升序 + 尾逗号(如 "0,1,3,")
|
|
8
|
+
* - dtl_id_<idx>_<row> = 已有行的 keyid(新增行不写)
|
|
9
|
+
* - deldtlid<idx> = 被删除已有行的 keyid,逗号分隔
|
|
10
|
+
* - field<id>_<row> = 本次提交的明细字段值(浏览器会全量提交;这里只提交改动行/新增行)
|
|
11
|
+
* - detailFieldUnEmptyCount = 所有存活行中非空明细字段总数
|
|
12
|
+
*
|
|
13
|
+
* 已有行的 keyid / indexnum 来自 POST /api/workflow/reqform/detailData:
|
|
14
|
+
* body { requestid, detailmark: "detail_1,detail_2", reqParams: JSON.stringify(commonApiParam) }
|
|
15
|
+
* 响应 { detail_1: { indexnum, rowDatas: { row_0: { keyid, field<id>: { value } } } } }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const COMMON_API_PARAM_RANGE = [
|
|
19
|
+
'requestid', 'workflowid', 'nodeid', 'formid', 'isbill',
|
|
20
|
+
'f_weaver_belongto_userid', 'f_weaver_belongto_usertype',
|
|
21
|
+
'authStr', 'authSignatureStr', 'signatureSecretKey', 'signatureAttributesStr',
|
|
22
|
+
'nodetype', 'iscreate', 'isfromtab', 'isviewonly', 'isaffirmance', 'isshared',
|
|
23
|
+
'iswfshare', 'ismode', 'modeid', 'isagent', 'beagenter', 'toexcel', 'creater',
|
|
24
|
+
'needconfirm', 'creatertype', 'requestType', 'isSelfAuth', 'isprint',
|
|
25
|
+
'wfmonitor', 'isurger', 'intervenorright', 'isSubmitDirect', 'selectNextFlow',
|
|
26
|
+
'agentType', 'agentorByAgentId', 'layouttype', 'freeNodeExtendNodeId',
|
|
27
|
+
'apiResultCacheKey',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const DETAIL_KEY_RE = /^(?:field)?(-?\d+)_(\d+)$/;
|
|
31
|
+
|
|
32
|
+
export function isDetailTableKey(key) {
|
|
33
|
+
return /^detail_\d+$/.test(String(key ?? ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detailIndexFromKey(key) {
|
|
37
|
+
const m = String(key ?? '').match(/^detail_(\d+)$/);
|
|
38
|
+
return m ? Number(m[1]) - 1 : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeFieldId(key) {
|
|
42
|
+
const s = String(key ?? '').trim();
|
|
43
|
+
return s.startsWith('field') ? s.slice('field'.length) : s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 从 loadForm 的 tableInfo 构造字段元数据表(fieldId → { table, htmltype, raw, ... })。 */
|
|
47
|
+
export function fieldMetaMap(tableInfo) {
|
|
48
|
+
const map = new Map();
|
|
49
|
+
for (const [tableKey, table] of Object.entries(tableInfo ?? {})) {
|
|
50
|
+
const fmap = table?.fieldinfomap;
|
|
51
|
+
if (!fmap || typeof fmap !== 'object')
|
|
52
|
+
continue;
|
|
53
|
+
for (const [fid, f] of Object.entries(fmap)) {
|
|
54
|
+
map.set(String(fid), {
|
|
55
|
+
id: String(fid),
|
|
56
|
+
label: f?.fieldlabel,
|
|
57
|
+
table: tableKey,
|
|
58
|
+
htmltype: f?.htmltype === undefined || f?.htmltype === null || f?.htmltype === '' ? undefined : Number(f.htmltype),
|
|
59
|
+
detailtype: f?.detailtype === undefined || f?.detailtype === null || f?.detailtype === '' ? undefined : String(f.detailtype),
|
|
60
|
+
raw: f ?? {},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 该键是否是「已知明细字段 + 行号」的明细键;主表字段带 _<row> 的旧用法不在此列。 */
|
|
68
|
+
export function isDetailValueKey(key, fieldById) {
|
|
69
|
+
const m = String(key ?? '').match(DETAIL_KEY_RE);
|
|
70
|
+
if (!m)
|
|
71
|
+
return false;
|
|
72
|
+
const meta = fieldById?.get(m[1]);
|
|
73
|
+
return Boolean(meta && isDetailTableKey(meta.table));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pickCommonApiParams(params = {}) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const k of COMMON_API_PARAM_RANGE) {
|
|
79
|
+
const v = params?.[k];
|
|
80
|
+
if (v !== undefined && v !== null && v !== '')
|
|
81
|
+
out[k] = String(v);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 读取当前请求的明细行(keyid + 字段值 + indexnum)。
|
|
88
|
+
* @returns Map<number, { key: string, indexnum: number, rows: Map<number,{keyid:number|null, values:Object}> }>
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchTodoDetailRows(api, params, tableInfo) {
|
|
91
|
+
const marks = Object.keys(tableInfo ?? {}).filter(isDetailTableKey);
|
|
92
|
+
const out = new Map();
|
|
93
|
+
if (marks.length === 0)
|
|
94
|
+
return out;
|
|
95
|
+
// 与前端 urlParams 一致:把 loadForm.params 的非空项全部带上(674 实测:只带子集时
|
|
96
|
+
// detailData 会 500;带全量后正常)。reqParams 仍按 commonApiParamRange 裁剪。
|
|
97
|
+
const top = {};
|
|
98
|
+
for (const [k, v] of Object.entries(params ?? {})) {
|
|
99
|
+
if (v === undefined || v === null || v === '')
|
|
100
|
+
continue;
|
|
101
|
+
top[k] = String(v);
|
|
102
|
+
}
|
|
103
|
+
const body = {
|
|
104
|
+
...top,
|
|
105
|
+
requestid: String(params?.requestid ?? ''),
|
|
106
|
+
detailmark: marks.join(','),
|
|
107
|
+
reqParams: JSON.stringify(pickCommonApiParams(params)),
|
|
108
|
+
};
|
|
109
|
+
const r = await api.post('/api/workflow/reqform/detailData', body);
|
|
110
|
+
const j = r.json ?? {};
|
|
111
|
+
const root = marks.some((k) => j[k] && typeof j[k] === 'object')
|
|
112
|
+
? j
|
|
113
|
+
: (j.datas && typeof j.datas === 'object' ? j.datas : j);
|
|
114
|
+
for (const key of marks) {
|
|
115
|
+
const idx = detailIndexFromKey(key);
|
|
116
|
+
const t = root?.[key];
|
|
117
|
+
if (!t || typeof t !== 'object')
|
|
118
|
+
continue;
|
|
119
|
+
if (!t.rowDatas || typeof t.rowDatas !== 'object') {
|
|
120
|
+
throw new Error(`明细数据接口未返回 ${key}.rowDatas,无法安全计算行控制字段(可能无权限或接口结构变化)`);
|
|
121
|
+
}
|
|
122
|
+
const indexnum = Number(t.indexnum);
|
|
123
|
+
if (!Number.isFinite(indexnum) || indexnum < 0) {
|
|
124
|
+
throw new Error(`明细数据接口未返回合法的 ${key}.indexnum,无法安全新增/删除行`);
|
|
125
|
+
}
|
|
126
|
+
const rows = new Map();
|
|
127
|
+
for (const [rowKey, rowData] of Object.entries(t.rowDatas)) {
|
|
128
|
+
const m = String(rowKey).match(/^row_(\d+)$/);
|
|
129
|
+
if (!m || !rowData || typeof rowData !== 'object')
|
|
130
|
+
continue;
|
|
131
|
+
const rowIndex = Number(m[1]);
|
|
132
|
+
let keyid = null;
|
|
133
|
+
if (rowData.keyid !== undefined && rowData.keyid !== null && rowData.keyid !== '') {
|
|
134
|
+
keyid = Number(rowData.keyid);
|
|
135
|
+
if (!Number.isFinite(keyid))
|
|
136
|
+
throw new Error(`明细表 ${key} 第 ${rowIndex} 行的 keyid 非法:${rowData.keyid}`);
|
|
137
|
+
}
|
|
138
|
+
const values = {};
|
|
139
|
+
for (const [k, v] of Object.entries(rowData)) {
|
|
140
|
+
const fm = k.match(/^field(-?\d+)$/);
|
|
141
|
+
if (!fm)
|
|
142
|
+
continue;
|
|
143
|
+
const value = v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'value')
|
|
144
|
+
? v.value
|
|
145
|
+
: v;
|
|
146
|
+
values[fm[1]] = value === undefined || value === null ? '' : String(value);
|
|
147
|
+
}
|
|
148
|
+
rows.set(rowIndex, { keyid, values });
|
|
149
|
+
}
|
|
150
|
+
out.set(idx, { key, indexnum, rows });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 规范化 --detail-file 的 JSON(CLI 已完成 JSON.parse;这里只做结构与类型校验)。 */
|
|
156
|
+
export function parseDetailFileSpec(spec) {
|
|
157
|
+
if (!Array.isArray(spec))
|
|
158
|
+
throw new Error('--detail-file 应为数组 [{index, rows:[...], deleteRows:[...]}]');
|
|
159
|
+
const out = [];
|
|
160
|
+
spec.forEach((entry, i) => {
|
|
161
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
162
|
+
throw new Error(`--detail-file 第 ${i + 1} 项不是对象`);
|
|
163
|
+
const index = Number(entry.index);
|
|
164
|
+
if (!Number.isInteger(index) || index < 0)
|
|
165
|
+
throw new Error(`--detail-file 第 ${i + 1} 项 index 非法:${entry.index}(应为 0 起的明细表序号)`);
|
|
166
|
+
const rows = [];
|
|
167
|
+
const rawRows = entry.rows ?? [];
|
|
168
|
+
if (!Array.isArray(rawRows))
|
|
169
|
+
throw new Error(`--detail-file 第 ${i + 1} 项 rows 应为数组`);
|
|
170
|
+
rawRows.forEach((raw, ri) => {
|
|
171
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
172
|
+
throw new Error(`--detail-file 第 ${i + 1} 项第 ${ri + 1} 行不是对象`);
|
|
173
|
+
const row = { values: {} };
|
|
174
|
+
if (raw.rowIndex !== undefined && raw.rowIndex !== null && raw.rowIndex !== '') {
|
|
175
|
+
const rowIndex = Number(raw.rowIndex);
|
|
176
|
+
if (!Number.isInteger(rowIndex) || rowIndex < 0)
|
|
177
|
+
throw new Error(`--detail-file 第 ${i + 1} 项第 ${ri + 1} 行 rowIndex 非法:${raw.rowIndex}`);
|
|
178
|
+
row.rowIndex = rowIndex;
|
|
179
|
+
}
|
|
180
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
181
|
+
if (k === 'rowIndex')
|
|
182
|
+
continue;
|
|
183
|
+
const id = normalizeFieldId(k);
|
|
184
|
+
if (!/^-?\d+$/.test(id))
|
|
185
|
+
continue;
|
|
186
|
+
row.values[id] = v === undefined || v === null ? '' : String(v);
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(row.values).length === 0)
|
|
189
|
+
throw new Error(`--detail-file 第 ${i + 1} 项第 ${ri + 1} 行没有任何字段值`);
|
|
190
|
+
rows.push(row);
|
|
191
|
+
});
|
|
192
|
+
const deleteRows = [];
|
|
193
|
+
const rawDeletes = entry.deleteRows ?? [];
|
|
194
|
+
if (!Array.isArray(rawDeletes))
|
|
195
|
+
throw new Error(`--detail-file 第 ${i + 1} 项 deleteRows 应为数组`);
|
|
196
|
+
rawDeletes.forEach((v) => {
|
|
197
|
+
const rowIndex = Number(v);
|
|
198
|
+
if (!Number.isInteger(rowIndex) || rowIndex < 0)
|
|
199
|
+
throw new Error(`--detail-file 第 ${i + 1} 项 deleteRows 含非法行号:${v}`);
|
|
200
|
+
deleteRows.push(rowIndex);
|
|
201
|
+
});
|
|
202
|
+
out.push({ index, rows, deleteRows });
|
|
203
|
+
});
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 规划待办明细变更(纯函数,不发送请求)。
|
|
209
|
+
*
|
|
210
|
+
* @param {object} args
|
|
211
|
+
* @param {object} args.tableInfo loadForm.tableInfo
|
|
212
|
+
* @param {Map} args.detailRows fetchTodoDetailRows 的结果
|
|
213
|
+
* @param {object} args.fieldValues --field-values 原始对象
|
|
214
|
+
* @param {Array} args.detailFile parseDetailFileSpec 的结果
|
|
215
|
+
* @param {Array} args.files [{fieldId, filePath}]
|
|
216
|
+
* @returns {{tables:Array, mainChanges:Object, changedMainKeys:string[], changedDetailKeys:string[], fileTargets:Array, warnings:string[]}}
|
|
217
|
+
*/
|
|
218
|
+
export function planTodoDetails({ tableInfo, detailRows, fieldValues = {}, detailFile = [], files = [] }) {
|
|
219
|
+
const fieldById = fieldMetaMap(tableInfo);
|
|
220
|
+
const touched = new Map();
|
|
221
|
+
const warnings = [];
|
|
222
|
+
|
|
223
|
+
const ensureTable = (idx) => {
|
|
224
|
+
if (touched.has(idx))
|
|
225
|
+
return touched.get(idx);
|
|
226
|
+
const key = `detail_${idx + 1}`;
|
|
227
|
+
const table = tableInfo?.[key];
|
|
228
|
+
if (!table)
|
|
229
|
+
throw new Error(`明细表 ${key} 不在当前表单(index=${idx})`);
|
|
230
|
+
const data = detailRows?.get(idx);
|
|
231
|
+
if (!data || !(data.rows instanceof Map))
|
|
232
|
+
throw new Error(`未取到 ${key} 的明细数据,无法安全修改明细行(detailData 未返回该表)`);
|
|
233
|
+
const rows = new Map();
|
|
234
|
+
for (const [rowIndex, row] of data.rows)
|
|
235
|
+
rows.set(rowIndex, { keyid: row.keyid ?? null, values: { ...row.values }, send: {}, isNew: false, changed: false });
|
|
236
|
+
const indexnum = Number(data.indexnum);
|
|
237
|
+
if (!Number.isFinite(indexnum) || indexnum < 0)
|
|
238
|
+
throw new Error(`明细表 ${key} 的 indexnum 非法:${data.indexnum}`);
|
|
239
|
+
const attr = table.detailtableattr ?? {};
|
|
240
|
+
if (Number(attr.isopenpaging) === 1)
|
|
241
|
+
warnings.push(`明细表 ${key} 开启了分页(isopenpaging=1);已按 detailData 全量行处理,请提交后核对行数。`);
|
|
242
|
+
const maxRow = rows.size > 0 ? Math.max(...rows.keys()) + 1 : 0;
|
|
243
|
+
const t = {
|
|
244
|
+
idx, key, attr, rows,
|
|
245
|
+
indexnum,
|
|
246
|
+
nextIndex: Math.max(indexnum, maxRow),
|
|
247
|
+
userSet: new Map(),
|
|
248
|
+
changedRows: new Set(),
|
|
249
|
+
changedKeys: new Set(),
|
|
250
|
+
deleted: new Set(),
|
|
251
|
+
deletedKeyids: [],
|
|
252
|
+
added: [],
|
|
253
|
+
};
|
|
254
|
+
touched.set(idx, t);
|
|
255
|
+
return t;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const requireFieldInTable = (meta, t, fid) => {
|
|
259
|
+
if (!meta)
|
|
260
|
+
throw new Error(`字段 ${fid} 不在当前表单`);
|
|
261
|
+
if (meta.table !== t.key)
|
|
262
|
+
throw new Error(`字段 ${fid} 属于 ${meta.table},不能写入明细表 ${t.key}(行号写错或明细表 index 不对)`);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const setUserValue = (t, rowIndex, fid, value, source) => {
|
|
266
|
+
const row = t.rows.get(rowIndex);
|
|
267
|
+
if (!row)
|
|
268
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行不存在;新增行请不要带 rowIndex`);
|
|
269
|
+
if (t.deleted.has(rowIndex))
|
|
270
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行已被 deleteRows 删除,不能再修改`);
|
|
271
|
+
const userKey = `${rowIndex}:${fid}`;
|
|
272
|
+
const text = String(value ?? '');
|
|
273
|
+
const prev = t.userSet.get(userKey);
|
|
274
|
+
if (prev && prev.value !== text) {
|
|
275
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行字段 ${fid} 同时被 ${prev.source} 和 ${source} 设置为不同值("${prev.value}" vs "${text}")`);
|
|
276
|
+
}
|
|
277
|
+
t.userSet.set(userKey, { value: text, source });
|
|
278
|
+
row.values[fid] = text;
|
|
279
|
+
row.send[fid] = text;
|
|
280
|
+
row.changed = true;
|
|
281
|
+
t.changedRows.add(rowIndex);
|
|
282
|
+
t.changedKeys.add(`field${fid}_${rowIndex}`);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// ① --detail-file:更新已有行 / 新增行 / 删除行
|
|
286
|
+
for (const entry of detailFile) {
|
|
287
|
+
const t = ensureTable(entry.index);
|
|
288
|
+
for (const rowSpec of entry.rows) {
|
|
289
|
+
if (rowSpec.rowIndex !== undefined) {
|
|
290
|
+
for (const [fid, value] of Object.entries(rowSpec.values)) {
|
|
291
|
+
requireFieldInTable(fieldById.get(fid), t, fid);
|
|
292
|
+
setUserValue(t, rowSpec.rowIndex, fid, value, '--detail-file');
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (Number(t.attr.isadd) !== 1)
|
|
297
|
+
throw new Error(`明细表 ${t.key} 当前节点不允许新增行(detailtableattr.isadd=${t.attr.isadd})`);
|
|
298
|
+
const rowIndex = t.nextIndex++;
|
|
299
|
+
const row = { keyid: null, values: {}, send: {}, isNew: true, changed: true };
|
|
300
|
+
t.rows.set(rowIndex, row);
|
|
301
|
+
t.added.push(rowIndex);
|
|
302
|
+
t.changedRows.add(rowIndex);
|
|
303
|
+
for (const [fid, value] of Object.entries(rowSpec.values)) {
|
|
304
|
+
requireFieldInTable(fieldById.get(fid), t, fid);
|
|
305
|
+
row.values[fid] = String(value ?? '');
|
|
306
|
+
row.send[fid] = String(value ?? '');
|
|
307
|
+
t.changedKeys.add(`field${fid}_${rowIndex}`);
|
|
308
|
+
t.userSet.set(`${rowIndex}:${fid}`, { value: String(value ?? ''), source: '--detail-file' });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const rowIndex of entry.deleteRows) {
|
|
312
|
+
if (t.deleted.has(rowIndex))
|
|
313
|
+
continue;
|
|
314
|
+
if (Number(t.attr.isdelete) !== 1)
|
|
315
|
+
throw new Error(`明细表 ${t.key} 当前节点不允许删除行(detailtableattr.isdelete=${t.attr.isdelete})`);
|
|
316
|
+
const row = t.rows.get(rowIndex);
|
|
317
|
+
if (!row)
|
|
318
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行不存在,无法删除`);
|
|
319
|
+
if (row.isNew)
|
|
320
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行是本次新增行,删除请直接从 --detail-file 移除该行`);
|
|
321
|
+
if (row.changed || [...t.userSet.keys()].some((k) => k.startsWith(`${rowIndex}:`)))
|
|
322
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行同时被修改和删除,请二选一`);
|
|
323
|
+
if (row.keyid === null || row.keyid === undefined)
|
|
324
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行缺少 keyid,无法安全删除`);
|
|
325
|
+
t.deleted.add(rowIndex);
|
|
326
|
+
t.deletedKeyids.push(row.keyid);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ② --field-values 里的明细键(field<id>_<row>)
|
|
331
|
+
for (const [key, value] of Object.entries(fieldValues ?? {})) {
|
|
332
|
+
if (!isDetailValueKey(key, fieldById))
|
|
333
|
+
continue;
|
|
334
|
+
const m = String(key).match(DETAIL_KEY_RE);
|
|
335
|
+
const fid = m[1];
|
|
336
|
+
const rowIndex = Number(m[2]);
|
|
337
|
+
const meta = fieldById.get(fid);
|
|
338
|
+
const idx = detailIndexFromKey(meta.table);
|
|
339
|
+
const t = ensureTable(idx);
|
|
340
|
+
if (t.deleted.has(rowIndex))
|
|
341
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行已被 deleteRows 删除,不能再修改`);
|
|
342
|
+
setUserValue(t, rowIndex, fid, value, '--field-values');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ③ --file:附件目标(主表 / 明细行)
|
|
346
|
+
const fileTargets = [];
|
|
347
|
+
const seenFileKeys = new Set();
|
|
348
|
+
for (const fl of files ?? []) {
|
|
349
|
+
const raw = String(fl?.fieldId ?? '').trim();
|
|
350
|
+
const m = raw.match(/^(?:field)?(\d+)(?:_(\d+))?$/);
|
|
351
|
+
if (!m)
|
|
352
|
+
throw new Error(`--file 字段目标非法:${raw}(应为 <字段id> 或 <字段id>_<行号>)`);
|
|
353
|
+
const fid = m[1];
|
|
354
|
+
const rowIndex = m[2] !== undefined ? Number(m[2]) : null;
|
|
355
|
+
const meta = fieldById.get(fid);
|
|
356
|
+
if (!meta)
|
|
357
|
+
throw new Error(`附件字段 ${fid} 不在当前表单`);
|
|
358
|
+
if (meta.htmltype !== 6)
|
|
359
|
+
throw new Error(`字段 ${meta.label ?? fid}(${fid})不是附件/图片字段(htmltype=${meta.htmltype ?? '?'})`);
|
|
360
|
+
if (isDetailTableKey(meta.table)) {
|
|
361
|
+
if (rowIndex === null)
|
|
362
|
+
throw new Error(`明细附件必须写成 <字段id>_<行号>(如 ${fid}_0=${fl.filePath})`);
|
|
363
|
+
const idx = detailIndexFromKey(meta.table);
|
|
364
|
+
const t = ensureTable(idx);
|
|
365
|
+
const row = t.rows.get(rowIndex);
|
|
366
|
+
if (!row)
|
|
367
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行不存在,无法上传附件;请先在 --detail-file 中新增该行`);
|
|
368
|
+
if (t.deleted.has(rowIndex))
|
|
369
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行已被删除,不能上传附件`);
|
|
370
|
+
const targetKey = `field${fid}_${rowIndex}`;
|
|
371
|
+
if (seenFileKeys.has(targetKey))
|
|
372
|
+
throw new Error(`同一附件字段/行重复指定文件:${targetKey}(当前仅支持单文件)`);
|
|
373
|
+
if (t.userSet.has(`${rowIndex}:${fid}`))
|
|
374
|
+
throw new Error(`明细字段 ${fid} 第 ${rowIndex} 行已有手填值,不能再上传附件`);
|
|
375
|
+
seenFileKeys.add(targetKey);
|
|
376
|
+
fileTargets.push({ key: targetKey, fieldId: fid, rowIndex, filePath: fl.filePath, meta });
|
|
377
|
+
row.changed = true;
|
|
378
|
+
t.changedRows.add(rowIndex);
|
|
379
|
+
t.changedKeys.add(targetKey);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (rowIndex !== null)
|
|
383
|
+
throw new Error(`主表附件字段 ${fid} 不能带行号`);
|
|
384
|
+
const targetKey = `field${fid}`;
|
|
385
|
+
if (seenFileKeys.has(targetKey))
|
|
386
|
+
throw new Error(`同一附件字段重复指定文件:${targetKey}(当前仅支持单文件)`);
|
|
387
|
+
seenFileKeys.add(targetKey);
|
|
388
|
+
fileTargets.push({ key: targetKey, fieldId: fid, rowIndex: null, filePath: fl.filePath, meta });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ④ 汇总
|
|
392
|
+
const tables = [];
|
|
393
|
+
for (const t of touched.values()) {
|
|
394
|
+
for (const rowIndex of t.deleted)
|
|
395
|
+
t.rows.delete(rowIndex);
|
|
396
|
+
const rowOrder = [...t.rows.keys()].sort((a, b) => a - b);
|
|
397
|
+
for (const rowIndex of rowOrder) {
|
|
398
|
+
const row = t.rows.get(rowIndex);
|
|
399
|
+
if (!row.isNew && (row.keyid === null || row.keyid === undefined))
|
|
400
|
+
throw new Error(`明细表 ${t.key} 第 ${rowIndex} 行缺少 keyid,无法安全提交(detailData 结构可能变化)`);
|
|
401
|
+
// 与浏览器 generateFormData 一致:存活行全量字段都进提交体(用户改动值覆盖原值),
|
|
402
|
+
// 只发改动行会被服务端判定为「数据丢失」。
|
|
403
|
+
row.send = { ...row.values, ...row.send };
|
|
404
|
+
}
|
|
405
|
+
const detailUnEmpty = rowOrder.reduce((n, rowIndex) =>
|
|
406
|
+
n + Object.values(t.rows.get(rowIndex).values).filter((v) => String(v ?? '') !== '').length, 0);
|
|
407
|
+
tables.push({
|
|
408
|
+
idx: t.idx,
|
|
409
|
+
key: t.key,
|
|
410
|
+
attr: t.attr,
|
|
411
|
+
rows: t.rows,
|
|
412
|
+
rowOrder,
|
|
413
|
+
indexnum: t.nextIndex,
|
|
414
|
+
nodesnum: rowOrder.length,
|
|
415
|
+
submitdtlid: rowOrder.length > 0 ? rowOrder.map(String).join(',') + ',' : '',
|
|
416
|
+
deletedKeyids: t.deletedKeyids,
|
|
417
|
+
addedRowIndexes: t.added,
|
|
418
|
+
changedRowIndexes: t.changedRows,
|
|
419
|
+
changedKeys: t.changedKeys,
|
|
420
|
+
detailUnEmpty,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const mainChanges = {};
|
|
425
|
+
const changedMainKeys = [];
|
|
426
|
+
for (const [key, value] of Object.entries(fieldValues ?? {})) {
|
|
427
|
+
if (isDetailValueKey(key, fieldById))
|
|
428
|
+
continue;
|
|
429
|
+
const id = normalizeFieldId(key);
|
|
430
|
+
mainChanges[id] = value;
|
|
431
|
+
changedMainKeys.push(`field${id}`);
|
|
432
|
+
}
|
|
433
|
+
for (const target of fileTargets) {
|
|
434
|
+
if (target.rowIndex === null) {
|
|
435
|
+
mainChanges[target.fieldId] = null; // 真实值在上传后写入
|
|
436
|
+
changedMainKeys.push(target.key);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const changedDetailKeys = tables.flatMap((t) => [...t.changedKeys]);
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
tables,
|
|
443
|
+
mainChanges,
|
|
444
|
+
changedMainKeys: [...new Set(changedMainKeys)],
|
|
445
|
+
changedDetailKeys: [...new Set(changedDetailKeys)],
|
|
446
|
+
fileTargets,
|
|
447
|
+
warnings,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 明细行控制字段 + 已改动行的字段值(未改动行只带 keyid/行下标,不重发字段值)。 */
|
|
452
|
+
export function buildDetailBodyKeys(plan) {
|
|
453
|
+
const out = {};
|
|
454
|
+
for (const t of plan?.tables ?? []) {
|
|
455
|
+
out[`nodesnum${t.idx}`] = String(t.nodesnum);
|
|
456
|
+
out[`indexnum${t.idx}`] = String(t.indexnum);
|
|
457
|
+
out[`submitdtlid${t.idx}`] = t.submitdtlid;
|
|
458
|
+
out[`deldtlid${t.idx}`] = t.deletedKeyids.join(',');
|
|
459
|
+
for (const rowIndex of t.rowOrder) {
|
|
460
|
+
const row = t.rows.get(rowIndex);
|
|
461
|
+
if (row.keyid !== null && row.keyid !== undefined)
|
|
462
|
+
out[`dtl_id_${t.idx}_${rowIndex}`] = String(row.keyid);
|
|
463
|
+
for (const [fid, value] of Object.entries(row.send ?? {})) {
|
|
464
|
+
out[`field${fid}_${rowIndex}`] = String(value ?? '');
|
|
465
|
+
const name = row.names?.[fid];
|
|
466
|
+
if (name)
|
|
467
|
+
out[`field${fid}_${rowIndex}name`] = String(name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 上传附件后重算明细非空计数(行值可能被文件 id 填充)。 */
|
|
475
|
+
export function countDetailUnEmpty(plan) {
|
|
476
|
+
let n = 0;
|
|
477
|
+
for (const t of plan?.tables ?? [])
|
|
478
|
+
for (const row of t.rows.values())
|
|
479
|
+
n += Object.values(row.values).filter((v) => String(v ?? '') !== '').length;
|
|
480
|
+
return n;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 主表非空字段计数:maindata 现有值 + 本次改动,只算 id>0 的主表字段。 */
|
|
484
|
+
export function countMainUnEmpty(maindata, changedMain = {}) {
|
|
485
|
+
const vals = {};
|
|
486
|
+
for (const [key, cell] of Object.entries(maindata ?? {})) {
|
|
487
|
+
const m = String(key).match(/^field(-?\d+)$/);
|
|
488
|
+
if (!m)
|
|
489
|
+
continue;
|
|
490
|
+
const id = Number(m[1]);
|
|
491
|
+
if (id <= 0)
|
|
492
|
+
continue;
|
|
493
|
+
vals[id] = cell?.value;
|
|
494
|
+
}
|
|
495
|
+
for (const [key, value] of Object.entries(changedMain ?? {})) {
|
|
496
|
+
const id = Number(normalizeFieldId(key));
|
|
497
|
+
if (!Number.isFinite(id) || id <= 0)
|
|
498
|
+
continue;
|
|
499
|
+
vals[id] = value;
|
|
500
|
+
}
|
|
501
|
+
return Object.values(vals).filter((v) => v !== undefined && v !== null && String(v) !== '').length;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** 明细规划摘要(dry-run 展示用)。 */
|
|
505
|
+
export function describeDetailPlan(plan) {
|
|
506
|
+
const lines = [];
|
|
507
|
+
for (const t of plan?.tables ?? []) {
|
|
508
|
+
const added = t.addedRowIndexes.length ? `新增行 ${t.addedRowIndexes.join(',')}` : '';
|
|
509
|
+
const deleted = t.deletedKeyids.length ? `删除行 keyid ${t.deletedKeyids.join(',')}` : '';
|
|
510
|
+
const changed = [...t.changedRowIndexes].filter((ri) => !t.addedRowIndexes.includes(ri));
|
|
511
|
+
const changedText = changed.length ? `修改行 ${changed.join(',')}` : '';
|
|
512
|
+
lines.push(`${t.key}:存活 ${t.nodesnum} 行(indexnum=${t.indexnum})` +
|
|
513
|
+
[added, changedText, deleted].filter(Boolean).map((x) => ` · ${x}`).join(''));
|
|
514
|
+
}
|
|
515
|
+
return lines;
|
|
516
|
+
}
|