weaver-e9-assistant-cli 1.1.4 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/auth-token.js +4 -4
- package/dist/cache-store.js +73 -0
- package/dist/cli.js +1508 -69
- package/dist/client.js +1 -1
- package/dist/cube.js +16 -38
- package/dist/formula.js +246 -0
- package/dist/hrm.js +33 -19
- package/dist/kq.js +65 -0
- package/dist/memory.js +968 -0
- package/dist/nodemap.js +232 -0
- package/dist/odoc.js +7 -2
- package/dist/parse.js +4 -0
- package/dist/subordinate.js +111 -35
- package/dist/supervise.js +152 -0
- package/dist/teamreport.js +77 -0
- package/dist/todobatch.js +20 -9
- package/dist/tododetail.js +516 -0
- package/dist/todosubmit.js +657 -17
- package/dist/util.js +21 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfcodeblock.js +761 -0
- package/dist/wfcreate.js +29 -1
- package/dist/wfform.js +286 -67
- package/dist/wfstart.js +518 -79
- package/dist/wfsummary.js +1 -1
- package/dist/wfupload.js +48 -4
- package/dist/workflow.js +4 -2
- package/dist/workplan.js +21 -6
- package/package.json +2 -2
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 下属日报批量查询(按人并发)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:CLI 原先没有「一次查多个下属日报」的入口,AI 只能逐个下属调用,
|
|
5
|
+
* 每次都是新进程 + 一次请求,N 个下属就 N 次排队。
|
|
6
|
+
*
|
|
7
|
+
* 本模块按人并发调用微博主页接口(blogUserId 不同、彼此独立,可安全并发):
|
|
8
|
+
* 1) subordinateinfo/getSearchResult → dataKey(一次)
|
|
9
|
+
* 2) table/datas 大页拉全下属(串行翻页,同一 dataKey 不可并发)
|
|
10
|
+
* 3) 每个下属 GET /api/blog/base/getBlogHomepageList?blogUserId=<id>&startDate=endDate=date
|
|
11
|
+
* 并发上限 TEAM_REPORT_CONCURRENCY
|
|
12
|
+
*
|
|
13
|
+
* 判定:当天存在 isReCalled=0(非撤回)的微博 → 已写日报;仅撤回/无记录 → 未写。
|
|
14
|
+
*/
|
|
15
|
+
import { getBlogHomepageList, isBlogRecalled } from './blog.js';
|
|
16
|
+
import { listAllSubordinates } from './subordinate.js';
|
|
17
|
+
import { mapLimit, todayStr } from './util.js';
|
|
18
|
+
|
|
19
|
+
/** 按人并发上限:下属数量可能很多,限流避免打满服务端。 */
|
|
20
|
+
export const TEAM_REPORT_CONCURRENCY = 5;
|
|
21
|
+
|
|
22
|
+
/** 条目是否属于目标日期(workdate 优先,回退 createdate;无日期字段时按接口已按日期过滤处理)。 */
|
|
23
|
+
export function isReportOnDate(item, date) {
|
|
24
|
+
const workdate = String(item?.workdate ?? '').trim();
|
|
25
|
+
if (workdate)
|
|
26
|
+
return workdate.startsWith(date);
|
|
27
|
+
const createdate = String(item?.createdate ?? '').trim();
|
|
28
|
+
if (createdate)
|
|
29
|
+
return createdate.startsWith(date);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 批量查询下属日报。
|
|
35
|
+
* 返回 { date, allLevel, total, writtenCount, items, unmatched, complete }。
|
|
36
|
+
* items[i] = { id, name, written, count, recalled, posts, error }。
|
|
37
|
+
*/
|
|
38
|
+
export async function collectSubordinateReports(cfg, { date, allLevel = false, names = [], concurrency = TEAM_REPORT_CONCURRENCY } = {}) {
|
|
39
|
+
const targetDate = date ?? todayStr();
|
|
40
|
+
const subs = await listAllSubordinates(cfg, { allLevel, names });
|
|
41
|
+
const items = await mapLimit(subs.items, concurrency, async (person) => {
|
|
42
|
+
try {
|
|
43
|
+
const r = await getBlogHomepageList(cfg, {
|
|
44
|
+
scope: '全部',
|
|
45
|
+
blogUserId: person.id,
|
|
46
|
+
startDate: targetDate,
|
|
47
|
+
endDate: targetDate,
|
|
48
|
+
});
|
|
49
|
+
const dayItems = r.items.filter((it) => isReportOnDate(it, targetDate));
|
|
50
|
+
const normal = dayItems.filter((it) => !isBlogRecalled(it));
|
|
51
|
+
return {
|
|
52
|
+
id: person.id,
|
|
53
|
+
name: person.name,
|
|
54
|
+
written: normal.length > 0,
|
|
55
|
+
count: normal.length,
|
|
56
|
+
recalled: dayItems.length - normal.length,
|
|
57
|
+
posts: normal,
|
|
58
|
+
error: null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// 单人失败不拖垮整批:保留错误,由调用方展示
|
|
63
|
+
return {
|
|
64
|
+
id: person.id, name: person.name, written: false, count: 0, recalled: 0, posts: [], error: e?.message ?? String(e),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
date: targetDate,
|
|
70
|
+
allLevel: !!allLevel,
|
|
71
|
+
total: items.length,
|
|
72
|
+
writtenCount: items.filter((x) => x.written).length,
|
|
73
|
+
items,
|
|
74
|
+
unmatched: subs.unmatched,
|
|
75
|
+
complete: subs.complete,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/todobatch.js
CHANGED
|
@@ -39,16 +39,25 @@ function pickRandomFields(row) {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/** 分类 → 人类可读列表名(用于进度与报错文案)。 */
|
|
43
|
+
const CATEGORY_LABEL = { todo: '待办(待处理)', done: '已办', mine: '我发起的' };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 在指定分类列表中取出与 requestIds 匹配的原始行(含 randomField)+ dataKey。跨页匹配直到全部命中或到末尾。
|
|
47
|
+
* opts.category:'todo'(默认,待处理)/ 'done'(已办)/ 'mine'(我发起的)——关注在三个分类里都可用;
|
|
48
|
+
* 提交/转发只对待办有意义,调用方自行传 'todo'。
|
|
49
|
+
*/
|
|
50
|
+
async function loadRowsFor(cfg, requestIds, opts = {}) {
|
|
51
|
+
const category = opts.category ?? 'todo';
|
|
52
|
+
const label = CATEGORY_LABEL[category] ?? category;
|
|
53
|
+
// 修复 #6:逐页定位是最耗时环节,实时打印进度,避免长时间无输出被误判卡死。
|
|
45
54
|
const wanted = new Set(requestIds.map(String));
|
|
46
55
|
const found = new Map();
|
|
47
56
|
let dataKey = '';
|
|
48
|
-
console.error(` [批量]
|
|
57
|
+
console.error(` [批量] 正在「${label}」列表定位 ${requestIds.length} 条流程(按请求顺序逐页搜索)...`);
|
|
49
58
|
for (let current = 1; current <= 10; current++) {
|
|
50
|
-
console.error(` [批量]
|
|
51
|
-
const list = await getWfList(cfg, { category
|
|
59
|
+
console.error(` [批量] 读取${label}第 ${current} 页...`);
|
|
60
|
+
const list = await getWfList(cfg, { category, page: current, pageSize: '100' });
|
|
52
61
|
dataKey = list.dataKey || dataKey;
|
|
53
62
|
const rows = extractRows(list.raw?.datas);
|
|
54
63
|
for (const row of rows) {
|
|
@@ -64,7 +73,7 @@ async function loadRowsFor(cfg, requestIds) {
|
|
|
64
73
|
}
|
|
65
74
|
const missing = requestIds.map(String).filter((id) => !found.has(id));
|
|
66
75
|
if (missing.length)
|
|
67
|
-
throw new Error(
|
|
76
|
+
throw new Error(`批量操作需要在「${label}」列表中定位这 ${missing.length} 条,未找到:${missing.join(', ')}。请确认 request-id 属于该列表(可用 --category 切换 待办/已办/我发起),或该流程已不在列表中。`);
|
|
68
77
|
// 保持调用方传入顺序,便于回执与 multiSubIds 对应
|
|
69
78
|
const rows = requestIds.map((id) => found.get(String(id)));
|
|
70
79
|
return { dataKey, rows };
|
|
@@ -150,12 +159,14 @@ export async function batchAttention(cfg, opts) {
|
|
|
150
159
|
const attentionId = String(opts.attentionId ?? types[0]?.id ?? '');
|
|
151
160
|
if (!attentionId)
|
|
152
161
|
throw new Error('未能获取关注类型(getAttentionTypeSet 返回空),请确认该环境的“关注类型”已配置,或手动传 --attention-id。');
|
|
153
|
-
|
|
162
|
+
// 关注在 待办/已办/我发起 三个分类下都有「我的关注」页签,故定位列表可指定(默认待办)
|
|
163
|
+
const category = opts.category ?? 'todo';
|
|
164
|
+
const { dataKey, rows } = await loadRowsFor(cfg, requestIds, { category });
|
|
154
165
|
const gate = await runGate(api, 'batchAttention', rows, dataKey);
|
|
155
166
|
const result = await api.post('/api/workflow/requestAttention/saveAttention', {
|
|
156
167
|
src: 'batchadd',
|
|
157
168
|
requestids: requestIds.join(','),
|
|
158
169
|
ids: attentionId + ',',
|
|
159
170
|
});
|
|
160
|
-
return { requestIds, attentionTypes: types, attentionId, gate, result };
|
|
171
|
+
return { requestIds, category, attentionTypes: types, attentionId, gate, result };
|
|
161
172
|
}
|
|
@@ -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
|
+
}
|