@stalfh233/omc-cli 0.4.88 → 0.4.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.departmentId ?? null,
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 { performed: { errcode: 0 }, readback: { userId: args.userId, name: after?.name ?? null, departmentId: after?.departmentId ?? null, mobile: after?.mobile ?? null, email: after?.email ?? null, active: after?.active ?? null, userStatus: after?.status ?? null, departments } };
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
- type: z.string().optional().describe("默认 \"1\"(部门节点)"),
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 ?? "1", ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
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
- const performed = await wireCall(session, {
208
- path: ruleRuntimeExecutePath(args.schemaCode, args.ruleCode),
209
- method: "POST",
210
- body: { schemaCode: args.schemaCode, ids, ruleCode: args.ruleCode, consolidation: args.consolidation === true },
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(normalizeLoadedRecord((readbackData ?? {})), args),
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(normalizeLoadedRecord(readbackData), args), sequenceStatus },
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
@@ -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("只列出该部门下的子树(默认从根递归展开整棵部门树)"), capability: capabilityArg },
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 (live 2026-10-22, 1.1.25): no `deptIds`
3034
- // → the ROOTS; with `deptIds=<id>` → that department's WHOLE SUBTREE
3035
- // (not one level). So: roots once, then one subtree call per root.
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 fetchLevel = async (deptId) => {
3038
- const query = deptId === undefined ? {} : { deptIds: deptId };
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
- const seenDepartments = new Set();
3116
- const order = [];
3117
- const pushRows = (rows) => {
3118
- for (const row of rows) {
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 > 200 ? { truncated: true, note: `部门树超过 200 个,只聚合了前 200 个;用 wd 关键字精确检索` } : {}),
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
  };