@stalfh233/omc-cli 0.4.88 → 0.4.91
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/cli-contract.js +97 -7
- package/dist/cli.js +82 -14
- package/dist/goal-routes.js +57 -11
- package/dist/lanes/field.js +74 -2
- package/dist/lanes/list-view.js +36 -1
- package/dist/lanes/model.js +4 -1
- package/dist/lanes/org-tree.js +52 -0
- package/dist/lanes/organization.js +110 -6
- package/dist/lanes/rule-lifecycle.js +26 -5
- package/dist/lanes/runtime-data.js +59 -3
- package/dist/lanes/workflow.js +95 -70
- package/dist/model-purge.js +123 -45
- package/dist/path-projection.js +40 -0
- package/dist/rule-support.js +8 -0
- package/dist/runtime-support.js +1 -0
- package/dist/write.js +1 -1
- package/package.json +3 -3
- package/skills/omc-start-here.md +2 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ENDPOINTS } from "../endpoints.js";
|
|
2
|
+
import { wireCall, wireRows } from "../wire.js";
|
|
3
|
+
export async function collectOrgDepartments(session, options = {}) {
|
|
4
|
+
const limit = options.limit ?? 500;
|
|
5
|
+
const fetchLevel = async (deptId) => {
|
|
6
|
+
const query = deptId === undefined ? {} : { deptIds: deptId };
|
|
7
|
+
const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree, query })).data;
|
|
8
|
+
return Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
|
|
9
|
+
};
|
|
10
|
+
const rows = [];
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const push = (level) => {
|
|
13
|
+
const added = [];
|
|
14
|
+
for (const row of level) {
|
|
15
|
+
const id = String(row.id ?? "");
|
|
16
|
+
if (id === "" || seen.has(id))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(id);
|
|
19
|
+
rows.push({ ...row, id, name: String(row.name ?? ""), parentId: String(row.parentId ?? "") });
|
|
20
|
+
added.push(id);
|
|
21
|
+
}
|
|
22
|
+
return added;
|
|
23
|
+
};
|
|
24
|
+
const queue = [];
|
|
25
|
+
if (options.rootId !== undefined) {
|
|
26
|
+
try {
|
|
27
|
+
const self = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { deptId: options.rootId } })).data;
|
|
28
|
+
if (self)
|
|
29
|
+
push([self]);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// unknown id → empty result
|
|
33
|
+
}
|
|
34
|
+
queue.push(...push(await fetchLevel(options.rootId)));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const roots = await fetchLevel(undefined);
|
|
38
|
+
push(roots);
|
|
39
|
+
queue.push(...roots.map((row) => String(row.id ?? "")).filter((id) => id.length > 0));
|
|
40
|
+
}
|
|
41
|
+
let truncated = false;
|
|
42
|
+
while (queue.length > 0) {
|
|
43
|
+
if (rows.length >= limit) {
|
|
44
|
+
truncated = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
const parentId = queue.shift();
|
|
48
|
+
queue.push(...push(await fetchLevel(parentId)));
|
|
49
|
+
}
|
|
50
|
+
return { rows, truncated };
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=org-tree.js.map
|
|
@@ -7,6 +7,7 @@ import { wireCall, wireRows, WireError } from "../wire.js";
|
|
|
7
7
|
import { ENDPOINTS } from "../endpoints.js";
|
|
8
8
|
import { environmentArg, capabilityArg } from "../args.js";
|
|
9
9
|
import { LANES } from "./lane-ids.js";
|
|
10
|
+
import { collectOrgDepartments } from "./org-tree.js";
|
|
10
11
|
/**
|
|
11
12
|
* Organization WRITE faces (8.6.41 customer transcript, 2026-10-21).
|
|
12
13
|
*
|
|
@@ -130,11 +131,33 @@ export function registerOrganizationLane(tools) {
|
|
|
130
131
|
query: { ...orgQuery(session), roleId: String(args.roleId), page: String(args.page ?? 0), size: String(args.size ?? 500) },
|
|
131
132
|
})).data;
|
|
132
133
|
const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
|
|
134
|
+
// The wire row carries `deptId: null` and `departmentId` absent; the
|
|
135
|
+
// member's department rides `parentDepartmentName[0].deptId` (and
|
|
136
|
+
// `ouScope[0].id`). Projecting only `departmentId` made every member look
|
|
137
|
+
// department-less (r15 misdiagnosis; P4).
|
|
138
|
+
const memberDepartment = (row) => {
|
|
139
|
+
const direct = row.departmentId ?? row.deptId;
|
|
140
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
141
|
+
return direct;
|
|
142
|
+
const parents = Array.isArray(row.parentDepartmentName) ? row.parentDepartmentName : [];
|
|
143
|
+
for (const parent of parents)
|
|
144
|
+
if (typeof parent.deptId === "string" && parent.deptId)
|
|
145
|
+
return parent.deptId;
|
|
146
|
+
const scopes = Array.isArray(row.ouScope) ? row.ouScope : [];
|
|
147
|
+
for (const scope of scopes)
|
|
148
|
+
if (typeof scope.id === "string" && scope.id)
|
|
149
|
+
return scope.id;
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
133
152
|
const members = rows.map((row) => ({
|
|
134
153
|
id: roleUserId(row),
|
|
135
154
|
name: row.name ?? null,
|
|
136
155
|
username: row.username ?? null,
|
|
137
|
-
departmentId: row
|
|
156
|
+
departmentId: memberDepartment(row),
|
|
157
|
+
departmentName: row.departmentName ?? null,
|
|
158
|
+
// `ouScope` decides whether a role member can be resolved as a workflow
|
|
159
|
+
// participant. Surface the count so an empty scope is visible here.
|
|
160
|
+
scopeCount: Array.isArray(row.ouScope) ? row.ouScope.length : null,
|
|
138
161
|
unitType: row.unitType ?? null,
|
|
139
162
|
}));
|
|
140
163
|
return { roleId: String(args.roleId), count: members.length, total: data?.totalElements ?? members.length, members };
|
|
@@ -301,10 +324,67 @@ export function registerOrganizationLane(tools) {
|
|
|
301
324
|
const after = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
|
|
302
325
|
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, name: after?.name ?? null, parentId: after?.parentId ?? null, managerId: after?.managerId ?? null, enabled: after?.enabled ?? null } };
|
|
303
326
|
});
|
|
304
|
-
writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", { deptId: z.string().min(1) }, async (session, args) => {
|
|
327
|
+
writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", { deptId: z.string().min(1), force: z.boolean().optional().describe("true = 先逐个删除子部门(仅当子部门无用户)再删本部门;有用户仍由平台裁决") }, async (session, args) => {
|
|
305
328
|
const users = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: args.deptId } })).data);
|
|
329
|
+
// Pre-check children: the platform's 200009 ("部门下面存在子部门") is
|
|
330
|
+
// accurate but sends the caller hunting for ids. Name them and hand over
|
|
331
|
+
// the child-delete command (found during r15 org cleanup).
|
|
332
|
+
const walked = await collectOrgDepartments(session);
|
|
333
|
+
const children = walked.rows
|
|
334
|
+
.filter((row) => row.parentId === String(args.deptId))
|
|
335
|
+
.map((row) => ({ id: row.id, name: row.name }))
|
|
336
|
+
.filter((row) => row.id.length > 0);
|
|
337
|
+
let deletedChildren = 0;
|
|
338
|
+
if (children.length > 0) {
|
|
339
|
+
if (args.force !== true)
|
|
340
|
+
throw new ToolRefusalError({
|
|
341
|
+
status: "refused", blocker: "department-has-children", deptId: String(args.deptId), children,
|
|
342
|
+
nextCommand: `omc call org.department.delete --args '${JSON.stringify({ deptId: children[0].id })}'`,
|
|
343
|
+
message: `部门 ${args.deptId} 下还有 ${children.length} 个子部门,平台拒绝删除(200009)。先删子部门,或传 force:true 让工具按顺序删除:${children.map((child) => `${child.name}(${child.id})`).join("、")}。`,
|
|
344
|
+
});
|
|
345
|
+
// force = deepest-first over the WHOLE subtree (a direct child with its
|
|
346
|
+
// own children would fail 200009 otherwise).
|
|
347
|
+
const byParent = new Map();
|
|
348
|
+
for (const row of walked.rows) {
|
|
349
|
+
if (row.id === String(args.deptId))
|
|
350
|
+
continue;
|
|
351
|
+
const list = byParent.get(row.parentId) ?? [];
|
|
352
|
+
list.push({ id: row.id, name: row.name });
|
|
353
|
+
byParent.set(row.parentId, list);
|
|
354
|
+
}
|
|
355
|
+
const ordered = [];
|
|
356
|
+
const walk = (parentId) => {
|
|
357
|
+
for (const child of byParent.get(parentId) ?? []) {
|
|
358
|
+
walk(child.id);
|
|
359
|
+
ordered.push(child);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
walk(String(args.deptId));
|
|
363
|
+
deletedChildren = ordered.length;
|
|
364
|
+
for (const child of ordered) {
|
|
365
|
+
try {
|
|
366
|
+
await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: child.id } });
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
370
|
+
const usersPending = /200002|用户/.test(message);
|
|
371
|
+
throw new ToolRefusalError({
|
|
372
|
+
status: "refused", blocker: usersPending ? "department-has-users" : "department-delete-failed",
|
|
373
|
+
deptId: String(args.deptId), blockedChild: child,
|
|
374
|
+
nextCommand: `omc call org.department.users --args '${JSON.stringify({ deptId: child.id })}'`,
|
|
375
|
+
message: `force 清理到子部门 ${child.name}(${child.id}) 时失败:${message}。${usersPending ? "该部门下还有员工——先转出再删。" : "平台拒绝删除该子部门。"}`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (users.length > 0)
|
|
381
|
+
throw new ToolRefusalError({
|
|
382
|
+
status: "refused", blocker: "department-has-users", deptId: String(args.deptId), users: users.length,
|
|
383
|
+
nextCommand: `omc call org.department.users --args '${JSON.stringify({ deptId: String(args.deptId) })}'`,
|
|
384
|
+
message: `部门 ${args.deptId} 下还有 ${users.length} 名员工,平台拒绝删除(200002)。先用 org.department.users 看人,再用 org.user.transferDepartment 转出(平台不允许删带人的部门)。`,
|
|
385
|
+
});
|
|
306
386
|
await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: args.deptId } });
|
|
307
|
-
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, deleted: true, usersBeforeDelete: users.length } };
|
|
387
|
+
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, deleted: true, usersBeforeDelete: users.length, deletedChildren } };
|
|
308
388
|
});
|
|
309
389
|
tools.push(defineTool({
|
|
310
390
|
name: "org.department.users",
|
|
@@ -388,7 +468,26 @@ export function registerOrganizationLane(tools) {
|
|
|
388
468
|
const departments = wireRows((await wireCall(session, {
|
|
389
469
|
path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) },
|
|
390
470
|
})).data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
|
|
391
|
-
return {
|
|
471
|
+
return {
|
|
472
|
+
performed: { errcode: 0 },
|
|
473
|
+
readback: {
|
|
474
|
+
userId: args.userId,
|
|
475
|
+
name: after?.name ?? null,
|
|
476
|
+
departmentId: after?.departmentId ?? null,
|
|
477
|
+
mobile: after?.mobile ?? null,
|
|
478
|
+
email: after?.email ?? null,
|
|
479
|
+
active: after?.active ?? null,
|
|
480
|
+
userStatus: after?.status ?? null,
|
|
481
|
+
departments,
|
|
482
|
+
// r15 P7: `active` did not persist on 1.1.25 (passed true, read false).
|
|
483
|
+
// It looks derived from account activation/binding rather than a
|
|
484
|
+
// writable switch — say so instead of letting the caller re-create the
|
|
485
|
+
// user to "fix" it.
|
|
486
|
+
...(args.active !== undefined && after?.active !== args.active
|
|
487
|
+
? { activeWarning: `传了 active=${String(args.active)},平台读回 ${String(after?.active)}:active 在 1.1.25 上不是可直接写的开关(疑似由账号激活/绑定派生)。启停账号用 status=ENABLE/DISABLE/LOCK;不要为改 active 新建用户。` }
|
|
488
|
+
: {}),
|
|
489
|
+
},
|
|
490
|
+
};
|
|
392
491
|
});
|
|
393
492
|
writeTool(tools, "org.user.delete", LANE, SCOPES.userDelete, "删除员工(`GET organization/user/strike_out?id=`)。只传用户 id;返回 deleted。", { userId: z.string().min(1) }, async (session, args) => {
|
|
394
493
|
await wireCall(session, { path: ENDPOINTS.organizationUserDelete, query: { ...orgQuery(session), id: args.userId } });
|
|
@@ -444,6 +543,7 @@ export function registerOrganizationLane(tools) {
|
|
|
444
543
|
if (rows.length > 0 && args.force !== true)
|
|
445
544
|
throw new ToolRefusalError({
|
|
446
545
|
status: "refused", blocker: "role-has-members", roleId: String(args.roleId), members: rows.length,
|
|
546
|
+
nextCommand: `omc call org.role.delete --args '${JSON.stringify({ roleId: String(args.roleId), force: true })}'`,
|
|
447
547
|
message: `角色下还有 ${rows.length} 名成员,平台拒绝删除(errmsg:角色下已经包含用户数据)。先 org.role.removeUsers,或传 force:true 让工具移出后再删。`,
|
|
448
548
|
});
|
|
449
549
|
if (rows.length > 0)
|
|
@@ -459,6 +559,7 @@ export function registerOrganizationLane(tools) {
|
|
|
459
559
|
if (roles.length > 0 && args.force !== true)
|
|
460
560
|
throw new ToolRefusalError({
|
|
461
561
|
status: "refused", blocker: "role-group-has-roles", roleGroupId: String(args.roleGroupId), roles: roles.length,
|
|
562
|
+
nextCommand: `omc call org.roleGroup.delete --args '${JSON.stringify({ roleGroupId: String(args.roleGroupId), force: true })}'`,
|
|
462
563
|
message: `角色组下还有 ${roles.length} 个角色,平台拒绝删除(errmsg:角色组下已经包含角色数据)。先删角色,或传 force:true。`,
|
|
463
564
|
});
|
|
464
565
|
for (const role of roles) {
|
|
@@ -624,7 +725,10 @@ export function registerOrganizationLane(tools) {
|
|
|
624
725
|
id: z.string().min(1).describe("部门 id"),
|
|
625
726
|
name: z.string().min(1),
|
|
626
727
|
deptPath: z.string().optional().describe("完整路径,如 根组织/广州总所"),
|
|
627
|
-
|
|
728
|
+
// The wire wants the enum as a STRING ("1"); a number used to die in
|
|
729
|
+
// input validation with a generic tool-input-invalid (r10). Accept both
|
|
730
|
+
// and normalize, so the mistake cannot cost a round.
|
|
731
|
+
type: z.union([z.string(), z.number()]).optional().describe("部门节点固定 \"1\"(字符串;也接受数字 1,工具会归一化)"),
|
|
628
732
|
})).describe("要设置的管理范围节点"),
|
|
629
733
|
mode: z.enum(["merge", "replace"]).optional().describe("默认 merge(读旧值合并);replace = 覆盖整个范围"),
|
|
630
734
|
dryRun: z.boolean().optional(),
|
|
@@ -633,7 +737,7 @@ export function registerOrganizationLane(tools) {
|
|
|
633
737
|
path: ENDPOINTS.organizationRoleGetOuScope,
|
|
634
738
|
query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
|
|
635
739
|
})).data);
|
|
636
|
-
const wanted = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type
|
|
740
|
+
const wanted = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type === undefined ? "1" : String(scope.type), ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
|
|
637
741
|
const merged = args.mode === "replace"
|
|
638
742
|
? wanted
|
|
639
743
|
: [...before.map((row) => ({ id: String(row.id ?? ""), name: String(row.name ?? ""), type: String(row.type ?? "1"), ...(row.deptPath ? { deptPath: String(row.deptPath) } : {}) })).filter((row) => row.id !== ""), ...wanted]
|
|
@@ -204,11 +204,32 @@ export function registerRuleLifecycleLane(tools) {
|
|
|
204
204
|
ruleCode: args.ruleCode,
|
|
205
205
|
message: `规则 ${args.ruleCode} 当前未启用(enabled=${String(row.enabled)})——先 rule.enable(publish+enable 两步),否则设计器显示「失效·未启用」且按钮不会执行它`,
|
|
206
206
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
let performed;
|
|
208
|
+
try {
|
|
209
|
+
performed = await wireCall(session, {
|
|
210
|
+
path: ruleRuntimeExecutePath(args.schemaCode, args.ruleCode),
|
|
211
|
+
method: "POST",
|
|
212
|
+
body: { schemaCode: args.schemaCode, ids, ruleCode: args.ruleCode, consolidation: args.consolidation === true },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// 550001 业务规则不存在 on the launcher runtime path means the rule was
|
|
217
|
+
// not REGISTERED as a custom trigger (r15: a data-event graph saved with
|
|
218
|
+
// customTrigger:true still 550001s). Name the semantic instead of
|
|
219
|
+
// forwarding a bare platform code.
|
|
220
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === 550001)
|
|
221
|
+
throw new ToolRefusalError({
|
|
222
|
+
status: "refused",
|
|
223
|
+
blocker: "rule-not-custom-trigger",
|
|
224
|
+
schemaCode: args.schemaCode,
|
|
225
|
+
ruleCode: args.ruleCode,
|
|
226
|
+
nextCommand: `omc call rule.get --args '${JSON.stringify({ schemaCode: args.schemaCode, ruleCode: args.ruleCode })}'`,
|
|
227
|
+
message: `平台 launcher 运行时没有注册规则 ${args.ruleCode}(550001 业务规则不存在)。\`rule.run\` 走的是「按钮/自定义触发」端点,` +
|
|
228
|
+
`只认 CUSTOM_NODE 触发的规则;数据事件(Create/Update/…)的规则不会注册在这里。` +
|
|
229
|
+
`要看规则图里有没有 CUSTOM_NODE,用 rule.get 看 nodes;要验证数据事件规则请走 data.submit/data.update,要验证按钮请绑到列表/表单按钮后在门户点击。`,
|
|
230
|
+
});
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
212
233
|
const payload = (performed.data ?? null);
|
|
213
234
|
const objects = Array.isArray(payload) ? payload : [];
|
|
214
235
|
return {
|
|
@@ -216,6 +216,47 @@ function projectReadbackRecord(record, args) {
|
|
|
216
216
|
return record;
|
|
217
217
|
return compactRuntimeRecord(source);
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Approval-submit readback (issue 2026-10-25 §2).
|
|
221
|
+
*
|
|
222
|
+
* An approval submit changed almost nothing: the caller needs "did the flow
|
|
223
|
+
* advance" (sequenceStatus) plus "did the fields I typed persist". Returning the
|
|
224
|
+
* whole compact record made every approval level pay for the full row again
|
|
225
|
+
* (r12: 25 data.submit calls / 55.6 KB — the single largest read face).
|
|
226
|
+
* `summary`/`fields`/`full` still win when supplied; this is only the default.
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* Which submitted values did the immediate readback NOT show? (r10 F8)
|
|
230
|
+
*
|
|
231
|
+
* The platform's form/load face can answer with only system columns right after
|
|
232
|
+
* a write transaction (r14: `readback.authoritative` held just `modifiedTime`
|
|
233
|
+
* while `data.load` a moment later had every business field). Without a note the
|
|
234
|
+
* caller reads that as "the write dropped my data" and writes again.
|
|
235
|
+
*/
|
|
236
|
+
function readbackEmptyFields(formData, record) {
|
|
237
|
+
const source = record !== null && typeof record === "object" && !Array.isArray(record) ? record : {};
|
|
238
|
+
return Object.entries(formData)
|
|
239
|
+
.filter(([key, value]) => value !== null && value !== undefined && (source[key] === null || source[key] === undefined))
|
|
240
|
+
.map(([key]) => key);
|
|
241
|
+
}
|
|
242
|
+
function readbackWarningFor(emptyFields, toolName) {
|
|
243
|
+
if (emptyFields.length === 0)
|
|
244
|
+
return undefined;
|
|
245
|
+
return `${toolName} 已 DELIVERED,但即时读回里这些本次提交的字段为空/缺省:${emptyFields.join("、")}。平台 form/load 在写事务后可能先回系统字段、业务字段滞后(真机 1.1.25,r14 见过只回 modifiedTime 的一笔)。**先 omc call data.load 核对,不要重写**;若 data.load 仍为空,才按投影/字段 code 排查。`;
|
|
246
|
+
}
|
|
247
|
+
function projectApprovalReadback(record, submittedKeys) {
|
|
248
|
+
if (record === null || typeof record !== "object" || Array.isArray(record))
|
|
249
|
+
return {};
|
|
250
|
+
const source = record;
|
|
251
|
+
const projected = {};
|
|
252
|
+
for (const key of ["id", "bizObjectId", "schemaCode", "sequenceStatus"])
|
|
253
|
+
if (key in source)
|
|
254
|
+
projected[key] = source[key];
|
|
255
|
+
for (const key of submittedKeys)
|
|
256
|
+
if (key in source && !(key in projected))
|
|
257
|
+
projected[key] = compactRuntimeValue(source[key]);
|
|
258
|
+
return projected;
|
|
259
|
+
}
|
|
219
260
|
/** Shared optional schema fields for the write readback projection. */
|
|
220
261
|
export const readbackProjectionArgs = {
|
|
221
262
|
summary: z.boolean().optional().describe("只回身份/状态字段,裁剪 readback.authoritative(默认关闭,读回为业务字段+身份的精简记录)"),
|
|
@@ -750,8 +791,11 @@ export function registerRuntimeDataLane(tools) {
|
|
|
750
791
|
// branch) — business rules only run once data.submit advances the
|
|
751
792
|
// row. Saying so on every plain-save readback prevents the
|
|
752
793
|
// "rule didn't fire" misdiagnosis.
|
|
794
|
+
const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
|
|
795
|
+
const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
|
|
753
796
|
const plainReadback = {
|
|
754
|
-
authoritative: projectReadbackRecord(
|
|
797
|
+
authoritative: projectReadbackRecord(savedRecord, args),
|
|
798
|
+
...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
|
|
755
799
|
draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
|
|
756
800
|
};
|
|
757
801
|
// r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
|
|
@@ -824,6 +868,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
824
868
|
schemaCode: args.schemaCode,
|
|
825
869
|
bizObjectId: args.bizObjectId,
|
|
826
870
|
sheetCode,
|
|
871
|
+
nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
|
|
827
872
|
message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
|
|
828
873
|
});
|
|
829
874
|
}
|
|
@@ -840,12 +885,14 @@ export function registerRuntimeDataLane(tools) {
|
|
|
840
885
|
const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
|
|
841
886
|
const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
|
|
842
887
|
const sequenceStatus = extractSequenceStatus(readbackData);
|
|
888
|
+
const updatedRecord = normalizeLoadedRecord(readbackData);
|
|
889
|
+
const updatedEmptyFields = readbackEmptyFields(formData, updatedRecord);
|
|
843
890
|
return {
|
|
844
891
|
performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
|
|
845
892
|
// Flat record contract (ticket 17): live FormLoadVO normalizes to the
|
|
846
893
|
// fake's flat field shape so `authoritative.<fieldCode>` reads work on
|
|
847
894
|
// both faces.
|
|
848
|
-
readback: { authoritative: projectReadbackRecord(
|
|
895
|
+
readback: { authoritative: projectReadbackRecord(updatedRecord, args), sequenceStatus, ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}) },
|
|
849
896
|
};
|
|
850
897
|
});
|
|
851
898
|
/**
|
|
@@ -1006,6 +1053,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1006
1053
|
schemaCode: args.schemaCode,
|
|
1007
1054
|
bizObjectId: targetObjectId,
|
|
1008
1055
|
workflowCode: explicitWorkflowCode,
|
|
1056
|
+
nextCommand: "omc describe data.submit --example workflow-backed-data-submit",
|
|
1009
1057
|
message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
|
|
1010
1058
|
});
|
|
1011
1059
|
}
|
|
@@ -1021,6 +1069,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1021
1069
|
schemaCode: args.schemaCode,
|
|
1022
1070
|
bizObjectId: targetObjectId,
|
|
1023
1071
|
sheetCode,
|
|
1072
|
+
nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
|
|
1024
1073
|
message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
|
|
1025
1074
|
});
|
|
1026
1075
|
}
|
|
@@ -1163,13 +1212,20 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1163
1212
|
// start path returns only {result, objectId}); the authoritative loaded
|
|
1164
1213
|
// row carries it. Prefer the response, then the row.
|
|
1165
1214
|
const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
|
|
1215
|
+
// An approval submit returns the status + the fields the caller submitted,
|
|
1216
|
+
// not the whole row: r12's 25 approvals were the largest read face at
|
|
1217
|
+
// 55.6 KB. Explicit summary/fields/full keep their contract.
|
|
1218
|
+
const approvalDelta = hasWorkItem && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
|
|
1166
1219
|
readback = {
|
|
1167
1220
|
...readback,
|
|
1168
1221
|
workflowInstanceId: effectiveInstanceId,
|
|
1169
1222
|
objectId,
|
|
1170
1223
|
workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
|
|
1171
1224
|
sequenceStatus,
|
|
1172
|
-
submittedData: projectReadbackRecord(loadedRecord, args),
|
|
1225
|
+
submittedData: approvalDelta ? projectApprovalReadback(loadedRecord, Object.keys(formData ?? {})) : projectReadbackRecord(loadedRecord, args),
|
|
1226
|
+
...(approvalDelta
|
|
1227
|
+
? { readbackShape: "approval-delta", readbackHint: "审批提交默认只回 sequenceStatus + 本次提交的字段;要整条记录(含子表/系统字段)加 full:true,要指定字段加 fields:[...]" }
|
|
1228
|
+
: {}),
|
|
1173
1229
|
};
|
|
1174
1230
|
// Multi-level approval: after a submit advances the flow, the caller
|
|
1175
1231
|
// almost always needs the NEXT pending todo — bench52/53 paid an extra
|
package/dist/lanes/workflow.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ENDPOINTS } from "../endpoints.js";
|
|
3
|
+
import { collectOrgDepartments } from "./org-tree.js";
|
|
3
4
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
4
5
|
import { governedRead } from "../read.js";
|
|
5
6
|
import { ToolRefusalError } from "../tool-types.js";
|
|
@@ -732,6 +733,86 @@ function participantReferenceWarnings(template) {
|
|
|
732
733
|
}
|
|
733
734
|
return warnings;
|
|
734
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Role participants are filtered by `ouScope ∩ originator's department chain`.
|
|
738
|
+
* A role whose members all have an empty scope therefore resolves to NOBODY —
|
|
739
|
+
* but the workflow still publishes, the submit still answers 0, and the todo
|
|
740
|
+
* silently falls back to the originator (r9/r10 live; r15 lost 12 calls to it).
|
|
741
|
+
* Refuse BEFORE the update wire (read-only check, fail-open when the member
|
|
742
|
+
* list is unavailable) so the trap is named where the participant is authored.
|
|
743
|
+
*/
|
|
744
|
+
async function assertRoleParticipantScopes(session, body) {
|
|
745
|
+
const rows = Array.isArray(body.activities)
|
|
746
|
+
? body.activities
|
|
747
|
+
: body.activities && typeof body.activities === "object"
|
|
748
|
+
? Object.values(body.activities)
|
|
749
|
+
: [];
|
|
750
|
+
const roles = [];
|
|
751
|
+
for (const activity of rows) {
|
|
752
|
+
if (String(activity.activityType ?? "") !== "PARTICIPANT")
|
|
753
|
+
continue;
|
|
754
|
+
const raw = activity.participant;
|
|
755
|
+
if (typeof raw !== "string" || !raw.trim().startsWith("["))
|
|
756
|
+
continue;
|
|
757
|
+
for (const segment of raw.split("+")) {
|
|
758
|
+
let parsed;
|
|
759
|
+
try {
|
|
760
|
+
parsed = JSON.parse(segment.trim());
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (!Array.isArray(parsed))
|
|
766
|
+
continue;
|
|
767
|
+
for (const entry of parsed) {
|
|
768
|
+
if (entry === null || typeof entry !== "object")
|
|
769
|
+
continue;
|
|
770
|
+
const record = entry;
|
|
771
|
+
if (String(record.type ?? record.participantType ?? "") !== "2")
|
|
772
|
+
continue;
|
|
773
|
+
const roleId = String(record.id ?? "");
|
|
774
|
+
if (roleId)
|
|
775
|
+
roles.push({ activityCode: String(activity.activityCode ?? ""), roleId, roleName: String(record.name ?? roleId) });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const problems = [];
|
|
780
|
+
for (const role of roles) {
|
|
781
|
+
let members;
|
|
782
|
+
try {
|
|
783
|
+
const data = (await wireCall(session, {
|
|
784
|
+
path: ENDPOINTS.organizationRoleUsers,
|
|
785
|
+
query: { corpId: session.enterpriseId(), roleId: role.roleId, page: "0", size: "500" },
|
|
786
|
+
})).data;
|
|
787
|
+
members = Array.isArray(data?.content) ? data.content : wireRows(data);
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// A read failure must not block the write (fail open) — the platform
|
|
791
|
+
// will answer, and the participant warnings still ride the readback.
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (members.length === 0)
|
|
795
|
+
continue; // nothing to prove
|
|
796
|
+
const withScope = members.filter((member) => Array.isArray(member.ouScope) && member.ouScope.length > 0);
|
|
797
|
+
if (withScope.length === 0)
|
|
798
|
+
problems.push({ ...role, members: members.length });
|
|
799
|
+
}
|
|
800
|
+
if (problems.length === 0)
|
|
801
|
+
return;
|
|
802
|
+
const detail = problems.map((problem) => `${problem.activityCode} → 角色 ${problem.roleName}(${problem.roleId}) 的 ${problem.members} 名成员 ouScope 全为空`).join(";");
|
|
803
|
+
const first = problems[0];
|
|
804
|
+
throw new ToolRefusalError({
|
|
805
|
+
status: "refused",
|
|
806
|
+
blocker: "workflow-participant-role-scope-empty",
|
|
807
|
+
roles: problems,
|
|
808
|
+
nextCommand: `omc call org.role.users --args '${JSON.stringify({ roleId: first.roleId })}'`,
|
|
809
|
+
message: `节点参与者用了角色,但该角色成员的 ouScope 为空:${detail}。` +
|
|
810
|
+
`平台按「ouScope ∩ 发起人部门链」解析角色成员,空范围 = 永远解析不到人;流程仍会发布、submit 仍回 errcode 0,` +
|
|
811
|
+
`待办会**静默回退给发起人**(r9/r10/r15 真机)。` +
|
|
812
|
+
`先给成员补管理范围(部门节点):omc call org.role.setUserScope --args '{"roleId":"${first.roleId}","userId":"<成员recId>","scopes":[{"id":"<部门id>","name":"<部门名>","type":"1"}],"mode":"replace"}',` +
|
|
813
|
+
`或在 addUsers 时直接带 scopes。补完再重试本次 workflow.update。`,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
735
816
|
function isPlatformParticipantReference(participant) {
|
|
736
817
|
if (/^\{[^}]+\}$/.test(participant))
|
|
737
818
|
return true; // {Originator} keyword
|
|
@@ -2102,6 +2183,7 @@ export function registerWorkflowLane(tools) {
|
|
|
2102
2183
|
await backfillDesignerDefaults(session, args.schemaCode, body, template);
|
|
2103
2184
|
assertFirstActivityIsOriginator(template, "workflow.update", body);
|
|
2104
2185
|
validateWorkflowRender(body, "workflow.update");
|
|
2186
|
+
await assertRoleParticipantScopes(session, body);
|
|
2105
2187
|
// Live: workflow/update does NOT run the template validator — it stores a
|
|
2106
2188
|
// refused participant silently and only /publish answers the
|
|
2107
2189
|
// ValidateMessage[]. So the guard belongs on publish/bindRule, not here.
|
|
@@ -2553,6 +2635,7 @@ export function registerWorkflowLane(tools) {
|
|
|
2553
2635
|
blocker: "workflow-has-instances",
|
|
2554
2636
|
workflowCode: args.code,
|
|
2555
2637
|
...(pending > 0 ? { pendingWorkItems: pending } : {}),
|
|
2638
|
+
nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowCode: args.code })}'`,
|
|
2556
2639
|
message: `流程 ${args.code} 还有流程实例,平台拒绝删除(402508)。**不要改用 model.delete 绕过**——那会把流程模板级联删掉,` +
|
|
2557
2640
|
`留下指向空模板的待办,而平台全局待办搜索会给每条待办解析模板,**一条缺失就整页 NPE(50000),所有用户的待办列表一起坏掉**(r14 实测 8 连击,需维护者清库)。` +
|
|
2558
2641
|
`正确顺序:① 先完结/清理实例的待办(workitem.list --workflowCode ${args.code} 看还有哪些在办);② 再 workflow.delete;③ 最后才 model.delete。`,
|
|
@@ -3028,50 +3111,15 @@ export function registerWorkflowLane(tools) {
|
|
|
3028
3111
|
lane: WORKFLOW_LANE,
|
|
3029
3112
|
scope: WORKFLOW_TOOLS["org.department.list"].scope,
|
|
3030
3113
|
description: WORKFLOW_TOOLS["org.department.list"].description,
|
|
3031
|
-
inputSchema: { environment: environmentArg, parentId: z.string().min(1).optional().describe("
|
|
3114
|
+
inputSchema: { environment: environmentArg, parentId: z.string().min(1).optional().describe("只列出该部门及其下属(BFS 展开;默认从根展开整棵部门树)"), capability: capabilityArg },
|
|
3032
3115
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
3033
|
-
// `/department/tree` semantics (
|
|
3034
|
-
// →
|
|
3035
|
-
//
|
|
3116
|
+
// `/department/tree` semantics (re-verified r15, 1.1.25): no `deptIds`
|
|
3117
|
+
// → ROOTS; `deptIds=<id>` → DIRECT CHILDREN. The old "whole subtree"
|
|
3118
|
+
// reading hid third-level departments (电气组) from this list. The shared
|
|
3119
|
+
// walker does a bounded BFS.
|
|
3036
3120
|
const requested = args.parentId;
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3039
|
-
const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree, query })).data;
|
|
3040
|
-
return Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
|
|
3041
|
-
};
|
|
3042
|
-
const levels = [];
|
|
3043
|
-
const seen = new Set();
|
|
3044
|
-
const push = (rows) => {
|
|
3045
|
-
for (const row of rows) {
|
|
3046
|
-
const id = String(row.id ?? "");
|
|
3047
|
-
if (id === "" || seen.has(id))
|
|
3048
|
-
continue;
|
|
3049
|
-
seen.add(id);
|
|
3050
|
-
levels.push(row);
|
|
3051
|
-
}
|
|
3052
|
-
};
|
|
3053
|
-
if (requested === undefined) {
|
|
3054
|
-
push(await fetchLevel(undefined));
|
|
3055
|
-
}
|
|
3056
|
-
else {
|
|
3057
|
-
// `deptIds=<id>` returns the subtree BELOW that department, excluding
|
|
3058
|
-
// it (live 2026-10-22). A caller asking for `parentId` expects that
|
|
3059
|
-
// department itself too, so add it from the authoritative record.
|
|
3060
|
-
try {
|
|
3061
|
-
const self = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { deptId: requested } })).data;
|
|
3062
|
-
if (self)
|
|
3063
|
-
push([self]);
|
|
3064
|
-
}
|
|
3065
|
-
catch {
|
|
3066
|
-
// an unknown id simply yields an empty list
|
|
3067
|
-
}
|
|
3068
|
-
push(await fetchLevel(requested));
|
|
3069
|
-
}
|
|
3070
|
-
for (const root of levels.filter((row) => String(row.parentId ?? "") === "" || requested !== undefined).map((row) => String(row.id ?? ""))) {
|
|
3071
|
-
if (seen.size >= 500)
|
|
3072
|
-
break;
|
|
3073
|
-
push(await fetchLevel(root));
|
|
3074
|
-
}
|
|
3121
|
+
const walked = await collectOrgDepartments(session, requested === undefined ? {} : { rootId: requested });
|
|
3122
|
+
const levels = walked.rows;
|
|
3075
3123
|
const departments = levels.map((dept) => ({
|
|
3076
3124
|
id: dept.id ?? null,
|
|
3077
3125
|
name: dept.name ?? null,
|
|
@@ -3112,33 +3160,10 @@ export function registerWorkflowLane(tools) {
|
|
|
3112
3160
|
if (!keyword) {
|
|
3113
3161
|
return governedRead(ctx, self, args, async (session) => {
|
|
3114
3162
|
const orgQuery = { corpId: session.enterpriseId() };
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3119
|
-
const id = String(row.id ?? "");
|
|
3120
|
-
if (id === "" || seenDepartments.has(id))
|
|
3121
|
-
continue;
|
|
3122
|
-
seenDepartments.add(id);
|
|
3123
|
-
order.push(id);
|
|
3124
|
-
}
|
|
3125
|
-
};
|
|
3126
|
-
// `/department/tree` answers `{departmentList:[...]}`, not a bare array;
|
|
3127
|
-
// `wireRows` only understands arrays/page envelopes.
|
|
3128
|
-
const treeRows = async (deptId) => {
|
|
3129
|
-
const data = (await wireCall(session, {
|
|
3130
|
-
path: ENDPOINTS.organizationDepartmentTree,
|
|
3131
|
-
query: deptId === undefined ? orgQuery : { ...orgQuery, deptIds: deptId },
|
|
3132
|
-
})).data;
|
|
3133
|
-
return Array.isArray(data?.departmentList) ? data.departmentList : [];
|
|
3134
|
-
};
|
|
3135
|
-
pushRows(await treeRows());
|
|
3136
|
-
// `deptIds` returns the subtree BELOW that department; walk the roots.
|
|
3137
|
-
for (const rootId of [...order]) {
|
|
3138
|
-
if (order.length >= 200)
|
|
3139
|
-
break;
|
|
3140
|
-
pushRows(await treeRows(rootId));
|
|
3141
|
-
}
|
|
3163
|
+
// r15: `deptIds` answers DIRECT CHILDREN only, so the earlier
|
|
3164
|
+
// roots + one-level walk missed users in third-level departments.
|
|
3165
|
+
const walked = await collectOrgDepartments(session, { limit: 200 });
|
|
3166
|
+
const order = walked.rows.map((row) => row.id);
|
|
3142
3167
|
const seenUsers = new Map();
|
|
3143
3168
|
let departmentsScanned = 0;
|
|
3144
3169
|
for (const deptId of order.slice(0, 200)) {
|
|
@@ -3163,7 +3188,7 @@ export function registerWorkflowLane(tools) {
|
|
|
3163
3188
|
count: users.length,
|
|
3164
3189
|
total: users.length,
|
|
3165
3190
|
departmentsScanned,
|
|
3166
|
-
...(order.length
|
|
3191
|
+
...(walked.truncated || order.length >= 200 ? { truncated: true, note: `部门树超过 200 个,只聚合了前 200 个;用 wd 关键字精确检索` } : {}),
|
|
3167
3192
|
users,
|
|
3168
3193
|
hint: "无关键字=列全员(按部门树聚合)。平台 user/search 的 wd 不支持空串(10005),所以这条路径走 department/list_user;只要某部门的人用 org.department.users。",
|
|
3169
3194
|
};
|