weaver-e9-assistant-cli 1.1.4 → 1.1.5
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/cli.js +807 -25
- package/dist/kq.js +65 -0
- package/dist/nodemap.js +242 -0
- package/dist/odoc.js +7 -2
- package/dist/parse.js +4 -0
- package/dist/subordinate.js +25 -0
- package/dist/supervise.js +152 -0
- package/dist/todobatch.js +20 -9
- package/dist/todosubmit.js +47 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfstart.js +5 -1
- package/package.json +2 -2
package/dist/kq.js
CHANGED
|
@@ -183,6 +183,71 @@ export async function punch(cfg, opts) {
|
|
|
183
183
|
raw: j,
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
|
+
// ─── 考勤统计 / 假期余额(getHrmKQReportInfo)──────────────
|
|
187
|
+
/**
|
|
188
|
+
* 考勤统计 + 假期余额。POST /api/hrm/kq/report/getHrmKQReportInfo(token 鉴权)。
|
|
189
|
+
* 参数(OA 前端 bundle 逆向):`loaddata=1` **必传**(缺了只回空壳);`type` 1=年 / 2=月;
|
|
190
|
+
* `typevalue` "2026" / "2026-09";`resourceId` = 目标 hrmresource id(**可查他人**,缺省=当前登录人)。
|
|
191
|
+
* 返回四组:出勤统计(应出勤/实际出勤/缺勤)、流程时长(请假/出差/公出/加班/异常抵扣)、
|
|
192
|
+
* 异常(迟到/早退/旷工/漏签)、请假信息(年假/带薪病假/调休 的上一年可请/今年剩余/当前可请天数)。
|
|
193
|
+
*/
|
|
194
|
+
export async function getKqReport(cfg, opts = {}) {
|
|
195
|
+
const api = await createApiFromProfile(cfg);
|
|
196
|
+
const type = String(opts.type ?? '2');
|
|
197
|
+
const body = { loaddata: '1', type, typevalue: String(opts.typevalue ?? defaultKqTypeValue(type)) };
|
|
198
|
+
if (opts.resourceId)
|
|
199
|
+
body.resourceId = String(opts.resourceId);
|
|
200
|
+
const r = await api.post('/api/hrm/kq/report/getHrmKQReportInfo', body);
|
|
201
|
+
const j = r.json ?? {};
|
|
202
|
+
const flat = (g) => (g?.items ?? []).map((it) => ({ title: s(it.title || it.name), value: s(it.value), type: s(it.type) }));
|
|
203
|
+
const g1 = j.groupitem1 ?? {};
|
|
204
|
+
return {
|
|
205
|
+
type,
|
|
206
|
+
typevalue: body.typevalue,
|
|
207
|
+
resourceId: body.resourceId ?? '',
|
|
208
|
+
attendance: { workDays: s(g1.value), items: flat(g1) },
|
|
209
|
+
durations: flat(j.groupitem2),
|
|
210
|
+
exceptions: flat(j.groupitem3),
|
|
211
|
+
leave: (j.groupitem4?.items ?? []).map((g) => ({
|
|
212
|
+
title: s(g?.title),
|
|
213
|
+
items: (g?.item ?? []).filter((x) => x && x.title).map((x) => ({ title: s(x.title), value: s(x.value) })),
|
|
214
|
+
})),
|
|
215
|
+
status: j.status,
|
|
216
|
+
raw: j,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 月度考勤明细(签到/签退时间与 IP)。POST /api/hrm/kq/report/getHrmKQMonthReportInfo(仅月视图)。 */
|
|
221
|
+
export async function getKqMonthReport(cfg, opts = {}) {
|
|
222
|
+
const api = await createApiFromProfile(cfg);
|
|
223
|
+
const body = { loaddata: '1', type: '2', typevalue: String(opts.typevalue ?? defaultKqTypeValue('2')) };
|
|
224
|
+
if (opts.resourceId)
|
|
225
|
+
body.resourceId = String(opts.resourceId);
|
|
226
|
+
const r = await api.post('/api/hrm/kq/report/getHrmKQMonthReportInfo', body);
|
|
227
|
+
const res = r.json?.result ?? {};
|
|
228
|
+
const days = Object.keys(res)
|
|
229
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
230
|
+
.map((k) => {
|
|
231
|
+
const d = res[k] ?? {};
|
|
232
|
+
const all = (d.signInfo ?? []).flatMap((seg) => seg?.item ?? []);
|
|
233
|
+
const pick = (t) => s(all.find((x) => x?.title === t)?.value);
|
|
234
|
+
return {
|
|
235
|
+
date: s(d.date),
|
|
236
|
+
types: d.types ?? [],
|
|
237
|
+
signIn: pick('签到时间'), signInIp: pick('签到IP'),
|
|
238
|
+
signOut: pick('签退时间'), signOutIp: pick('签退IP'),
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
return { typevalue: body.typevalue, resourceId: body.resourceId ?? '', days, status: r.json?.status, raw: r.json };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 默认 typevalue:type=1 → "YYYY",type=2 → "YYYY-MM"(本地时区)。 */
|
|
245
|
+
function defaultKqTypeValue(type) {
|
|
246
|
+
const d = new Date();
|
|
247
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
248
|
+
return String(type) === '1' ? `${d.getFullYear()}` : `${d.getFullYear()}-${p(d.getMonth() + 1)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
186
251
|
// ───────────────── 工具 ─────────────────
|
|
187
252
|
function s(v) {
|
|
188
253
|
if (v === undefined || v === null)
|
package/dist/nodemap.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流程节点名解析:nodeid → nodename。
|
|
3
|
+
*
|
|
4
|
+
* 背景:待办/已办/我发起列表服务端**只给当前节点的 id**——行字段 `currentnodeid` 的值形如 "2882",
|
|
5
|
+
* 且行里没有节点名(实测 `currentnodeidspan` 也只是 id 本身,整行不含 currentnodename/currentnodespan)。
|
|
6
|
+
* 要显示名字只能按流程另取节点表。
|
|
7
|
+
*
|
|
8
|
+
* 接口(2026-10-14 实测 e9pc.e-cology.com.cn):
|
|
9
|
+
* POST /api/workflow/test/getNodeInfo body: { workflowId }
|
|
10
|
+
* → { nodeInfo: [{id, name, type}], wfInfo: {workflowName, version}, authority, tabInfo, ... }
|
|
11
|
+
* - 只需 workflowId;文档里的 requestid/userId/type 都非必需(逐个试过)。
|
|
12
|
+
* - ⚠️ 路径必须带 `/api` 前缀:`/workflow/test/getNodeInfo`(无前缀)返回登录页 HTML。
|
|
13
|
+
* - ⚠️ 接口挂在「流程测试」模块下,非所有部署/版本都有 → **一律 fail-soft**:
|
|
14
|
+
* 拿不到就返回 null,调用方退回显示节点 id,绝不让列表整体报错。
|
|
15
|
+
* - `type`:0=发起节点 / 1=审批节点 / 3=归档节点。
|
|
16
|
+
*
|
|
17
|
+
* 缓存:`~/.e9/node_map_<envkey>.json`,按 workflowId 存,TTL 30 天
|
|
18
|
+
* (节点表几乎不变;响应带 version,流程改版后重新拉取自然覆盖)。
|
|
19
|
+
* - `E9_NODEMAP_CACHE_DIR` 覆盖缓存目录(测试隔离用,避免污染真实 ~/.e9)。
|
|
20
|
+
* - `E9_NO_NODE_NAME=1` 或 CLI `--no-node-name` 整体停用解析。
|
|
21
|
+
*/
|
|
22
|
+
import { createApiFromProfile } from './client.js';
|
|
23
|
+
import { endpointFor, profileNameFromBaseUrl } from './config.js';
|
|
24
|
+
import { stripHtml } from './parse.js';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
|
|
29
|
+
/** 节点表缓存 TTL(天)。 */
|
|
30
|
+
export const NODEMAP_TTL_MS = (Number(process.env.E9_NODEMAP_TTL_DAYS ?? '30') || 30) * 24 * 3600 * 1000;
|
|
31
|
+
/** 并发拉取上限(一次列表页通常 ≤10 个流程,全量拉取路径可能几十个,别一次打满)。 */
|
|
32
|
+
const FETCH_CHUNK = 6;
|
|
33
|
+
|
|
34
|
+
function str(v) {
|
|
35
|
+
if (v === undefined || v === null || v === '')
|
|
36
|
+
return undefined;
|
|
37
|
+
return String(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 缓存目录(测试可用 E9_NODEMAP_CACHE_DIR 覆盖)。 */
|
|
41
|
+
export function nodeMapCacheDir() {
|
|
42
|
+
return process.env.E9_NODEMAP_CACHE_DIR || path.join(os.homedir(), '.e9');
|
|
43
|
+
}
|
|
44
|
+
/** 缓存文件路径:~/.e9/node_map_<envkey>.json(按环境隔离,nodeid 不跨环境通用)。 */
|
|
45
|
+
export function nodeMapCachePath(cfg) {
|
|
46
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
47
|
+
return path.join(nodeMapCacheDir(), `node_map_${profileNameFromBaseUrl(baseUrl)}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 是否停用节点名解析(E9_NO_NODE_NAME=1)。 */
|
|
51
|
+
export function isNodeNameDisabled() {
|
|
52
|
+
return process.env.E9_NO_NODE_NAME === '1';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 纯函数:把 getNodeInfo 响应归一化为 { workflowName, version, nodes:{id:name} }。
|
|
57
|
+
* 结构不符(登录页/空表/非 JSON)返回 null——调用方据此 fail-soft。
|
|
58
|
+
*/
|
|
59
|
+
export function parseNodeInfo(json) {
|
|
60
|
+
const list = json?.nodeInfo;
|
|
61
|
+
if (!Array.isArray(list))
|
|
62
|
+
return null;
|
|
63
|
+
const nodes = {};
|
|
64
|
+
for (const n of list) {
|
|
65
|
+
const id = str(n?.id);
|
|
66
|
+
if (!id)
|
|
67
|
+
continue;
|
|
68
|
+
nodes[id] = stripHtml(n?.name) || id;
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(nodes).length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
workflowName: str(json?.wfInfo?.workflowName),
|
|
74
|
+
version: str(json?.wfInfo?.version),
|
|
75
|
+
nodes,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 读缓存文件;坏文件/不存在返回 {}。 */
|
|
80
|
+
function readCache(cfg) {
|
|
81
|
+
try {
|
|
82
|
+
const p = nodeMapCachePath(cfg);
|
|
83
|
+
if (!fs.existsSync(p))
|
|
84
|
+
return {};
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
86
|
+
return raw && typeof raw === 'object' && raw.workflows ? raw.workflows : {};
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 写回缓存(单流程);写入失败不阻断(节点名只是锦上添花)。 */
|
|
94
|
+
function writeCache(cfg, workflowId, entry) {
|
|
95
|
+
try {
|
|
96
|
+
const p = nodeMapCachePath(cfg);
|
|
97
|
+
const workflows = readCache(cfg);
|
|
98
|
+
workflows[workflowId] = entry;
|
|
99
|
+
const baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
|
|
100
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
101
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
102
|
+
envkey: profileNameFromBaseUrl(baseUrl),
|
|
103
|
+
baseUrl,
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
workflows,
|
|
106
|
+
}, null, 2) + '\n', 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch { /* 忽略:缓存写失败不影响本次结果 */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isStale(entry) {
|
|
112
|
+
if (!entry || !entry.fetchedAt)
|
|
113
|
+
return true;
|
|
114
|
+
return Date.now() - new Date(entry.fetchedAt).getTime() > NODEMAP_TTL_MS;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 取某流程的节点表(带缓存)。任何失败返回 null(fail-soft,不抛)。
|
|
119
|
+
* @returns {Promise<{workflowName?:string, version?:string, nodes:Record<string,string>}|null>}
|
|
120
|
+
*/
|
|
121
|
+
export async function getNodeMap(cfg, workflowId, opts = {}) {
|
|
122
|
+
const wfId = str(workflowId);
|
|
123
|
+
if (!wfId)
|
|
124
|
+
return null;
|
|
125
|
+
const cached = readCache(cfg)[wfId];
|
|
126
|
+
if (!opts.refresh && cached && !isStale(cached) && cached.nodes)
|
|
127
|
+
return { workflowName: cached.workflowName, version: cached.version, nodes: cached.nodes };
|
|
128
|
+
const api = opts.api ?? await createApiFromProfile(cfg);
|
|
129
|
+
let json = null;
|
|
130
|
+
try {
|
|
131
|
+
const r = await api.post('/api/workflow/test/getNodeInfo', { workflowId: wfId });
|
|
132
|
+
json = r?.json ?? null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
json = null; // 网络/鉴权异常同上:降级,不打断列表
|
|
136
|
+
}
|
|
137
|
+
const parsed = parseNodeInfo(json);
|
|
138
|
+
if (!parsed)
|
|
139
|
+
return null;
|
|
140
|
+
writeCache(cfg, wfId, { ...parsed, workflowId: wfId, fetchedAt: new Date().toISOString() });
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 列出**本地缓存**里所有流程的节点表(供「按节点名跨流程解析」用)。
|
|
146
|
+
* 缓存来自历次列表查询;从没查过的流程不在其中。
|
|
147
|
+
*/
|
|
148
|
+
export function listCachedNodeMaps(cfg) {
|
|
149
|
+
const workflows = readCache(cfg);
|
|
150
|
+
return Object.entries(workflows)
|
|
151
|
+
.filter(([, w]) => w && w.nodes && Object.keys(w.nodes).length)
|
|
152
|
+
.map(([workflowId, w]) => ({ workflowId: String(workflowId), workflowName: w.workflowName, nodes: w.nodes }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 按**节点名**解析节点 id(供 CLI `--node-name`)。
|
|
157
|
+
*
|
|
158
|
+
* 节点 id 是**流程内编号**(每个流程一套),故解析必须先限定流程范围:
|
|
159
|
+
* - 给了 `workflowId`:只在该流程的节点表内找(按需拉取,不依赖缓存);
|
|
160
|
+
* - 未给:在**本地缓存**的所有流程节点表内找(缓存来自历次列表查询)。
|
|
161
|
+
*
|
|
162
|
+
* 匹配规则:**先精确同名**;无精确才做模糊(互相包含)。多候选一律**全部返回**,
|
|
163
|
+
* 由调用方列给用户选——严禁自动取首条(本仓库硬规则)。
|
|
164
|
+
*
|
|
165
|
+
* @returns {Promise<{candidates:Array<{workflowId,workflowName,nodeId,nodeName,matchType}>,
|
|
166
|
+
* exact:Array, scoped:boolean, poolSize:number, unavailable:boolean}>}
|
|
167
|
+
* `unavailable`:限定了流程但节点表取不到(接口不存在/异常)——与"确实没有同名节点"要区分开。
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveNodeByName(cfg, name, opts = {}) {
|
|
170
|
+
const want = String(name ?? '').trim();
|
|
171
|
+
const empty = { candidates: [], exact: [], scoped: Boolean(opts.workflowId), poolSize: 0, unavailable: false };
|
|
172
|
+
if (!want)
|
|
173
|
+
return empty;
|
|
174
|
+
let pool = [];
|
|
175
|
+
if (opts.workflowId) {
|
|
176
|
+
const m = await getNodeMap(cfg, opts.workflowId, opts);
|
|
177
|
+
if (!m)
|
|
178
|
+
return { ...empty, unavailable: true };
|
|
179
|
+
pool = [{ workflowId: String(opts.workflowId), workflowName: m.workflowName, nodes: m.nodes }];
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
pool = listCachedNodeMaps(cfg);
|
|
183
|
+
}
|
|
184
|
+
const candidates = [];
|
|
185
|
+
for (const w of pool) {
|
|
186
|
+
for (const [nodeId, nodeName] of Object.entries(w.nodes)) {
|
|
187
|
+
if (!nodeName)
|
|
188
|
+
continue;
|
|
189
|
+
const isExact = nodeName === want;
|
|
190
|
+
const isFuzzy = !isExact && (nodeName.includes(want) || want.includes(nodeName));
|
|
191
|
+
if (isExact || isFuzzy)
|
|
192
|
+
candidates.push({ workflowId: w.workflowId, workflowName: w.workflowName, nodeId, nodeName, matchType: isExact ? 'exact' : 'fuzzy' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
candidates,
|
|
197
|
+
exact: candidates.filter((c) => c.matchType === 'exact'),
|
|
198
|
+
scoped: Boolean(opts.workflowId),
|
|
199
|
+
poolSize: pool.length,
|
|
200
|
+
unavailable: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 从列表行取流程 id(normalizeRow 归一字段优先,回退原始行)。 */
|
|
205
|
+
function workflowIdOf(item) {
|
|
206
|
+
return str(item?.workflowId) ?? str(item?.raw?.workflowid) ?? str(item?.raw?.workflowId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 给列表行补 `nodeName`(就地写回)。返回补上的行数。
|
|
211
|
+
*
|
|
212
|
+
* - 行需带 `node`(节点 id);流程 id 取 `workflowId` 或 `raw.workflowid`。
|
|
213
|
+
* - 按流程去重,每个流程只请求一次;同一流程的多行共用一张表。
|
|
214
|
+
* - 任何失败都不抛:拿不到节点表的行保持原样(渲染层回退显示 id)。
|
|
215
|
+
*/
|
|
216
|
+
export async function annotateNodeNames(cfg, items, opts = {}) {
|
|
217
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
218
|
+
return 0;
|
|
219
|
+
if (opts.disabled)
|
|
220
|
+
return 0;
|
|
221
|
+
// 只对「node 是纯数字 id」的行解析:服务端不同列表的 span 给的东西不一样——
|
|
222
|
+
// 待办/公文给的是**节点 id**(需要解析),流程代理办理记录给的是**节点名**(解析无从匹配,纯浪费请求)。
|
|
223
|
+
const rows = items.filter((it) => /^\d+$/.test(str(it?.node) ?? '') && workflowIdOf(it));
|
|
224
|
+
if (rows.length === 0)
|
|
225
|
+
return 0;
|
|
226
|
+
const wfIds = [...new Set(rows.map(workflowIdOf))];
|
|
227
|
+
const maps = new Map();
|
|
228
|
+
for (let i = 0; i < wfIds.length; i += FETCH_CHUNK) {
|
|
229
|
+
const chunk = wfIds.slice(i, i + FETCH_CHUNK);
|
|
230
|
+
const got = await Promise.all(chunk.map((w) => getNodeMap(cfg, w, opts)));
|
|
231
|
+
chunk.forEach((w, idx) => maps.set(w, got[idx]));
|
|
232
|
+
}
|
|
233
|
+
let filled = 0;
|
|
234
|
+
for (const it of rows) {
|
|
235
|
+
const name = maps.get(workflowIdOf(it))?.nodes?.[str(it.node)];
|
|
236
|
+
if (name) {
|
|
237
|
+
it.nodeName = name;
|
|
238
|
+
filled++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return filled;
|
|
242
|
+
}
|
package/dist/odoc.js
CHANGED
|
@@ -119,8 +119,13 @@ export function normalizeOdocRow(row) {
|
|
|
119
119
|
createDate: stringify(get(['createdate'])),
|
|
120
120
|
sendDepartment: stringify(get(['senddepartment'])),
|
|
121
121
|
lastOperateDate: stringify(get(['lastoperatedate'])),
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// ⚠️ E9 公文列表这两列的**字段名与内容是反的**(2026-10-14 实测 wf=552 发文):
|
|
123
|
+
// currentnodeidspan → "拟稿"(公文环节名,是标签不是 id)
|
|
124
|
+
// currentnodespan → "2868"(真正的**节点 id**,用 getNodeInfo 可解析出节点名「拟稿发起」)
|
|
125
|
+
// 故不再沿用误导性的原名,改为语义正确的 stageName / node;nodeName 由 nodemap 回填。
|
|
126
|
+
stageName: stripHtml(get(['currentnodeidspan'])) || stringify(get(['currentnodeid'])),
|
|
127
|
+
node: stripHtml(get(['currentnodespan'])) || stringify(get(['currentnode'])),
|
|
128
|
+
nodeName: undefined,
|
|
124
129
|
zbr: stringify(get(['zbr'])),
|
|
125
130
|
wbr: stringify(get(['wbr'])),
|
|
126
131
|
docAbstract: stripHtml(get(['docabstractspan'])) || stringify(get(['docabstract'])),
|
package/dist/parse.js
CHANGED
|
@@ -158,7 +158,11 @@ export function normalizeRow(row, columns) {
|
|
|
158
158
|
creatorId,
|
|
159
159
|
createdate: spanOr('createdate', ['createdatespan', 'createDate']),
|
|
160
160
|
receivedate: spanOr('receivedate', ['receivedate', 'recieveDate']),
|
|
161
|
+
// 注意:服务端流程列表**只有节点 id**(currentnodeidspan 实测也是 id 本身,整行无节点名),
|
|
162
|
+
// 节点名由 nodemap.annotateNodeNames 按流程节点表回填到 nodeName。
|
|
161
163
|
node: stripHtml(get(['currentnodeidspan', 'currentnodespan'])) || stringify(get(['currentnodeid', 'nodeName', 'nodename'])),
|
|
164
|
+
nodeName: undefined,
|
|
165
|
+
workflowId: stringify(get(['workflowid', 'workflowId'])),
|
|
162
166
|
status: stripHtml(get(['statusspan'])) || stringify(get(['status'])),
|
|
163
167
|
level,
|
|
164
168
|
requestmark: stringify(get(['requestmark'])),
|
package/dist/subordinate.js
CHANGED
|
@@ -342,6 +342,31 @@ export async function assertNamesInScope(cfg, names, opts = {}) {
|
|
|
342
342
|
return resolved.length === 1 ? resolved[0] : resolved;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* 仅把姓名解析成 uid,**不做任何范围过滤**。
|
|
347
|
+
*
|
|
348
|
+
* 与 assertNamesInScope 的区别:后者用于「查询他人」类筛选,越界要拦;
|
|
349
|
+
* 而本函数用于**给他人派活**的场景(如流程代理把待办委托给谁——委托对象不限于下属),
|
|
350
|
+
* 只需遵守「人员搜索必须走 blog at-list」这条硬规则,不施加授权集合限制。
|
|
351
|
+
* 恰 1 个候选才返回;0 个 / 多个一律抛错并**列全候选**(不自动取首条)。
|
|
352
|
+
*/
|
|
353
|
+
export async function resolveNameToUid(cfg, name) {
|
|
354
|
+
const n = String(name ?? '').trim();
|
|
355
|
+
if (!n)
|
|
356
|
+
throw new Error('人员姓名为空。');
|
|
357
|
+
if (/^\d+$/.test(n))
|
|
358
|
+
return n;
|
|
359
|
+
const r = await getAtAllList(cfg, { name: n });
|
|
360
|
+
const candidates = sortPersonCandidates(r.items, n);
|
|
361
|
+
if (candidates.length === 0)
|
|
362
|
+
throw new Error(`未找到「${n}」对应的人员(at-list 无候选)。`);
|
|
363
|
+
if (candidates.length > 1) {
|
|
364
|
+
const list = candidates.slice(0, 10).map((c) => `${stripHtml(String(c.name ?? ''))}(${c.uid})`).join('、');
|
|
365
|
+
throw new Error(`「${n}」匹配到 ${candidates.length} 个人员,请确认后用 --agent <用户id> 指定:${list}${candidates.length > 10 ? ' …' : ''}`);
|
|
366
|
+
}
|
|
367
|
+
return String(candidates[0].uid ?? '');
|
|
368
|
+
}
|
|
369
|
+
|
|
345
370
|
/** 候选过滤到授权集合(纯函数)。策略未启用(默认)时不过滤,全部视为在范围内。 */
|
|
346
371
|
export function filterCandidatesToScope(scope, candidates) {
|
|
347
372
|
if (!isScopeEnforced()) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E9 流程督办(supervise)模块。
|
|
3
|
+
*
|
|
4
|
+
* 链路(HAR 抓包实证 + 前端 bundle 逆向):
|
|
5
|
+
* 1) POST /api/workflow/supervise/getTree body{menuIds,menuPathIds} → topTab[](页签)+ treedata(流程类型树)
|
|
6
|
+
* 2) POST /api/workflow/supervise/getTreeCount body 同上 → {flowAllCounts, flowOverCounts, treecount{}} 各类型计数
|
|
7
|
+
* 3) POST /api/workflow/supervise/getConditionInfo body 空 → conditioninfo(搜索条件定义,结构与待办同构)
|
|
8
|
+
* 4) POST /api/workflow/supervise/getSessionkey body{同待办过滤项 + tabKey} → sessionkey(**恒顶层**)
|
|
9
|
+
* tabKey:0=全部 / 1=未督办 / 2=已督办 / 3=我的关注
|
|
10
|
+
* 5) POST /api/ec/dev/table/datas body{dataKey=sessionkey,current,sortParams} → columns + datas 行
|
|
11
|
+
* 6) POST /api/ec/dev/table/counts body{dataKey} → {count}
|
|
12
|
+
* 7) POST /api/workflow/supervise/doSupervise body{requestids,remark} → 发起督办(勾选多条用逗号分隔)
|
|
13
|
+
*
|
|
14
|
+
* 列表行 `ops` 含 4 个操作(前端 href 实证):督办(handle) / 短信催办(smsHandle) / 邮件催办(emailHandle) / 关注(doAttention)。
|
|
15
|
+
* ⚠️ 短信/邮件催办的**实际发送接口未抓到**(前端 getSendEmailInfo 取信息后要打开弹窗填写再发),故本模块只做「督办」。
|
|
16
|
+
*/
|
|
17
|
+
import { endpointFor } from './config.js';
|
|
18
|
+
import { createApiFromProfile } from './client.js';
|
|
19
|
+
import { extractRows, extractColumns, extractTotal, pickByPaths, stripHtml } from './parse.js';
|
|
20
|
+
|
|
21
|
+
/** 督办菜单 id(抓包实证:menuIds=1,235)。 */
|
|
22
|
+
const SUPERVISE_MENU = { menuIds: '1,235', menuPathIds: '1,235' };
|
|
23
|
+
|
|
24
|
+
/** 督办页签:tabKey ↔ 中文名(getTree.topTab 实证)。 */
|
|
25
|
+
export const SUPERVISE_TABS = {
|
|
26
|
+
'全部': '0', '未督办': '1', '已督办': '2', '我的关注': '3',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 页签名/别名 → tabKey(数字直传)。 */
|
|
30
|
+
export function resolveSuperviseTab(spec) {
|
|
31
|
+
const s = String(spec ?? '').trim();
|
|
32
|
+
if (!s)
|
|
33
|
+
return '0';
|
|
34
|
+
if (/^[0-3]$/.test(s))
|
|
35
|
+
return s;
|
|
36
|
+
const key = Object.keys(SUPERVISE_TABS).find((k) => k === s || k.includes(s));
|
|
37
|
+
return key ? SUPERVISE_TABS[key] : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 督办页签 + 流程类型树。POST /api/workflow/supervise/getTree */
|
|
41
|
+
export async function getSuperviseTree(cfg) {
|
|
42
|
+
const api = await createApiFromProfile(cfg);
|
|
43
|
+
const r = await api.post('/api/workflow/supervise/getTree', SUPERVISE_MENU);
|
|
44
|
+
const j = r.json ?? {};
|
|
45
|
+
return {
|
|
46
|
+
tabs: (j.topTab ?? []).map((t) => ({ key: String(t.key ?? ''), title: String(t.title ?? '') })),
|
|
47
|
+
tree: j.treedata ?? [],
|
|
48
|
+
raw: j,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 督办计数。POST /api/workflow/supervise/getTreeCount(flowAllCounts=总数、flowOverCounts=未督办)。 */
|
|
53
|
+
export async function getSuperviseCount(cfg) {
|
|
54
|
+
const api = await createApiFromProfile(cfg);
|
|
55
|
+
const r = await api.post('/api/workflow/supervise/getTreeCount', SUPERVISE_MENU);
|
|
56
|
+
const j = r.json ?? {};
|
|
57
|
+
return {
|
|
58
|
+
total: j.flowAllCounts ?? null,
|
|
59
|
+
pending: j.flowOverCounts ?? null,
|
|
60
|
+
treeCount: j.treecount ?? {},
|
|
61
|
+
raw: j,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 督办搜索条件(结构与待办同构)。POST /api/workflow/supervise/getConditionInfo */
|
|
66
|
+
export async function getSuperviseConditions(cfg) {
|
|
67
|
+
const api = await createApiFromProfile(cfg);
|
|
68
|
+
const r = await api.post('/api/workflow/supervise/getConditionInfo', {});
|
|
69
|
+
const j = r.json ?? {};
|
|
70
|
+
const groups = (j.conditioninfo ?? []).map((g) => ({
|
|
71
|
+
title: String(g.title ?? ''),
|
|
72
|
+
items: (g.items ?? []).map((it) => ({
|
|
73
|
+
label: String(it.label ?? ''),
|
|
74
|
+
domkey: Array.isArray(it.domkey) ? it.domkey.join(',') : String(it.domkey ?? ''),
|
|
75
|
+
type: String(it.conditionType ?? ''),
|
|
76
|
+
})),
|
|
77
|
+
}));
|
|
78
|
+
return { groups, raw: j };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 督办列表。getSessionkey → table/datas → table/counts(同待办 table 链路)。
|
|
83
|
+
* opts: { tabKey, keyword, page, pageSize, ...过滤项 }
|
|
84
|
+
*/
|
|
85
|
+
export async function listSupervise(cfg, opts = {}) {
|
|
86
|
+
const api = await createApiFromProfile(cfg);
|
|
87
|
+
const tabKey = String(opts.tabKey ?? '0');
|
|
88
|
+
const body = {
|
|
89
|
+
requestname: opts.keyword ?? '',
|
|
90
|
+
workcode: '', requestlevel: '', creatertype: '0',
|
|
91
|
+
ownerdepartmentid: opts.creatorDept ?? '', creatersubcompanyid: opts.creatorSubcom ?? '',
|
|
92
|
+
receivedateselect: '0', receivedatefrom: '', receivedateto: '',
|
|
93
|
+
createdateselect: '0', createdatefrom: '', createdateto: '',
|
|
94
|
+
wfstatu: '1', currentnodetype: '', unophrmid: '', docids: '',
|
|
95
|
+
hrmcreaterid: opts.creator ?? '', crmids: '', proids: '',
|
|
96
|
+
...SUPERVISE_MENU, tabKey,
|
|
97
|
+
};
|
|
98
|
+
const r1 = await api.post('/api/workflow/supervise/getSessionkey', body);
|
|
99
|
+
const sessionkey = pickByPaths(r1.json, ['sessionkey', 'data.sessionkey']);
|
|
100
|
+
if (!sessionkey)
|
|
101
|
+
throw new Error(`未拿到督办 sessionkey(getSessionkey 响应:${(r1.text ?? '').slice(0, 200)})`);
|
|
102
|
+
const page = Number(opts.page ?? '1') || 1;
|
|
103
|
+
const r2 = await api.post('/api/ec/dev/table/datas', { dataKey: sessionkey, current: String(page), sortParams: '[]', pageSize: String(opts.pageSize ?? '10') });
|
|
104
|
+
const columns = extractColumns(r2.json);
|
|
105
|
+
const rows = extractRows(r2.json);
|
|
106
|
+
const items = rows.map((row) => normalizeSuperviseRow(row, columns));
|
|
107
|
+
let total = null;
|
|
108
|
+
let countsRaw = null;
|
|
109
|
+
try {
|
|
110
|
+
const r3 = await api.post('/api/ec/dev/table/counts', { dataKey: sessionkey });
|
|
111
|
+
countsRaw = r3.json;
|
|
112
|
+
total = extractTotal(r3.json);
|
|
113
|
+
}
|
|
114
|
+
catch { /* counts 失败不阻断 */ }
|
|
115
|
+
let pageSize = pickByPaths(r2.json, ['pageSize', 'data.pageSize']);
|
|
116
|
+
if (typeof pageSize !== 'number' && /^\d+$/.test(String(pageSize ?? '')))
|
|
117
|
+
pageSize = parseInt(String(pageSize), 10);
|
|
118
|
+
return { tabKey, page, total, pageSize: pageSize ?? null, items, sessionkey, columns, raw: { getSessionkey: r1.json, datas: r2.json, counts: countsRaw } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 规范化一行督办(可读值优先取 `<field>span`,回退原值)。 */
|
|
122
|
+
export function normalizeSuperviseRow(row, columns) {
|
|
123
|
+
if (!row || typeof row !== 'object')
|
|
124
|
+
return {};
|
|
125
|
+
void columns;
|
|
126
|
+
const get = (keys) => pickByPaths(row, keys);
|
|
127
|
+
const spanOr = (k, extra = []) => stripHtml(get([`${k}span`, k, ...extra]));
|
|
128
|
+
return {
|
|
129
|
+
requestId: String(pickByPaths(row, ['requestid', 'randomField2', 'randomFieldId']) ?? ''),
|
|
130
|
+
title: stripHtml(get(['requestnamespan'])) || String(get(['requestname', 'requestName']) ?? ''),
|
|
131
|
+
creator: spanOr('creater', ['createrName']),
|
|
132
|
+
createTime: spanOr('createtime', ['createdate']),
|
|
133
|
+
node: stripHtml(get(['currentnodeidspan', 'currentnodespan'])) || String(get(['currentnodeid', 'currentnodename']) ?? ''),
|
|
134
|
+
unoperators: stripHtml(get(['unoperatorsspan', 'unoperators'])),
|
|
135
|
+
workflowId: String(get(['workflowid', 'randomField0']) ?? ''),
|
|
136
|
+
raw: row,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 发起督办。POST /api/workflow/supervise/doSupervise(body: requestids + remark)。 */
|
|
141
|
+
export async function doSupervise(cfg, opts = {}) {
|
|
142
|
+
const ep = endpointFor(cfg);
|
|
143
|
+
const requestIds = String(opts.requestIds ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
144
|
+
const remark = String(opts.remark ?? '');
|
|
145
|
+
if (!requestIds.length)
|
|
146
|
+
throw new Error('doSupervise 需要 requestIds(逗号分隔多个)。');
|
|
147
|
+
if (!opts.send)
|
|
148
|
+
return { sent: false, endpoint: { baseUrl: ep.baseUrl }, requestIds, remark };
|
|
149
|
+
const api = await createApiFromProfile(cfg);
|
|
150
|
+
const r = await api.post('/api/workflow/supervise/doSupervise', { requestids: requestIds.join(','), remark });
|
|
151
|
+
return { sent: true, endpoint: { baseUrl: ep.baseUrl }, requestIds, remark, response: r };
|
|
152
|
+
}
|
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
|
}
|
package/dist/todosubmit.js
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
import { createApiFromProfile } from './client.js';
|
|
2
2
|
import { getWfList } from './workflow.js';
|
|
3
3
|
import { todayStr, nowTimeStr } from './util.js';
|
|
4
|
+
import { findNonEditableFields } from './wfstart.js';
|
|
5
|
+
|
|
6
|
+
/** viewattr 中文标签(报错文案用)。 */
|
|
7
|
+
const VIEWATTR_LABEL_LOCAL = { 1: '可编辑', 2: '只读', 3: '隐藏' };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 只做「当前节点字段属性」校验,不提交——供 CLI dry-run 提前拦截,避免"预览通过、提交才报错"。
|
|
11
|
+
* 返回被违规填值的只读/隐藏字段清单(空数组 = 通过)。
|
|
12
|
+
*/
|
|
13
|
+
export async function validateFieldValuesAtNode(cfg, requestId, fieldValues) {
|
|
14
|
+
if (!fieldValues || !Object.keys(fieldValues).length)
|
|
15
|
+
return [];
|
|
16
|
+
const api = await createApiFromProfile(cfg);
|
|
17
|
+
const r = await api.post('/api/workflow/reqform/loadForm', { iscreate: '0', requestid: requestId });
|
|
18
|
+
return findNonEditableFields({ tables: nodeFieldTables(r.json ?? {}) }, fieldValues);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从 loadForm 响应的 tableInfo 抽出字段定义,供 findNonEditableFields 判定。
|
|
23
|
+
* ⚠️ 这里用的是 **iscreate=0(处理态)** 的属性——即**当前节点**的字段权限,
|
|
24
|
+
* 而非 create 路径的表单设计态(iscreate=1)。
|
|
25
|
+
*/
|
|
26
|
+
function nodeFieldTables(json) {
|
|
27
|
+
const tables = [];
|
|
28
|
+
for (const key of Object.keys(json?.tableInfo ?? {})) {
|
|
29
|
+
const fmap = json.tableInfo[key]?.fieldinfomap;
|
|
30
|
+
if (!fmap || typeof fmap !== 'object')
|
|
31
|
+
continue;
|
|
32
|
+
tables.push({
|
|
33
|
+
key,
|
|
34
|
+
fields: Object.values(fmap).map((f) => ({
|
|
35
|
+
id: String(f?.fieldid ?? ''),
|
|
36
|
+
label: String(f?.fieldlabel ?? ''),
|
|
37
|
+
viewattr: (f?.viewattr === undefined || f?.viewattr === null || f?.viewattr === '') ? null : Number(f.viewattr),
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return tables;
|
|
42
|
+
}
|
|
4
43
|
/**
|
|
5
44
|
* 提交待办到下一节点。POST requestOperation(iscreate=0)。
|
|
6
45
|
* 两步 loadForm 拿签名(先取 wf+node,再带 wf+node 取 per-request 签名)。
|
|
@@ -20,6 +59,14 @@ export async function submitTodo(cfg, opts) {
|
|
|
20
59
|
const sp0 = j0.submitParams ?? {};
|
|
21
60
|
const wfId = String(p0.workflowid ?? '');
|
|
22
61
|
const nodeId = String(p0.nodeid ?? '');
|
|
62
|
+
// ★字段属性硬规则(当前节点):--field-values 只允许改该节点「可编辑」(viewattr=1) 的字段。
|
|
63
|
+
// 依据本次 iscreate=0 的 loadForm(**处理态=当前节点字段权限**),与 create 路径的设计态判定不同。
|
|
64
|
+
// 只读(2)/隐藏(3) 一律拒绝——不得绕过节点字段权限自行填值。
|
|
65
|
+
if (opts.fieldValues && Object.keys(opts.fieldValues).length) {
|
|
66
|
+
const bad = findNonEditableFields({ tables: nodeFieldTables(j0) }, opts.fieldValues);
|
|
67
|
+
if (bad.length)
|
|
68
|
+
throw new Error(`拒绝填写当前节点的只读/隐藏字段:${bad.map((b) => `${b.label || b.id}(${VIEWATTR_LABEL_LOCAL[b.viewattr] ?? 'va=' + b.viewattr})`).join('、')}。本节点只允许修改「可编辑」字段,不得绕过节点字段权限自行填值。`);
|
|
69
|
+
}
|
|
23
70
|
// 第二步 loadForm(带 wf+node)→ 拿 per-request 签名
|
|
24
71
|
let p = p0;
|
|
25
72
|
let sp = sp0;
|