@stalfh233/omc-cli 0.4.87 → 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.
@@ -45,6 +45,54 @@ function fieldRollbackSpec(toolName) {
45
45
  function parseFieldOptionsForReadback(options) {
46
46
  return decodeJsonColumn(options, JSON_COLUMN_PASSTHROUGH);
47
47
  }
48
+ /**
49
+ * CHILD_TABLE(8) columns as a compact list (issue 2026-10-25 §7).
50
+ *
51
+ * The stored `subSchema` is the full designer payload — system columns included,
52
+ * ~37.6 KB for a 10-column child table when asked for one field by code. A caller
53
+ * almost always wants the column codes (to build formData / verify the attach);
54
+ * the raw payload stays available behind `full:true`. Accepts both the object and
55
+ * the JSON-string wire form.
56
+ */
57
+ function childColumnsOf(subSchema) {
58
+ let parsed = subSchema;
59
+ if (typeof subSchema === "string") {
60
+ try {
61
+ parsed = JSON.parse(subSchema);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ if (parsed === null || typeof parsed !== "object")
68
+ return [];
69
+ const properties = parsed.properties;
70
+ if (!Array.isArray(properties))
71
+ return [];
72
+ return properties
73
+ .filter((child) => child !== null && typeof child === "object")
74
+ .map((child) => {
75
+ const rawType = child.propertyType ?? child.type;
76
+ const type = rawType === undefined || rawType === null || Number.isNaN(Number(rawType)) ? null : Number(rawType);
77
+ const typeLabel = type === null ? undefined : fieldTypeLabel(type);
78
+ const code = String(child.code ?? child.propertyCode ?? "");
79
+ return {
80
+ code,
81
+ name: String(child.name ?? child.code ?? child.propertyCode ?? ""),
82
+ type,
83
+ ...(typeLabel !== undefined ? { typeName: typeLabel } : {}),
84
+ ...(typeof child.relativeCode === "string" && child.relativeCode ? { relativeCode: child.relativeCode } : {}),
85
+ // r15 feedback: the projection mixed the platform's system columns with
86
+ // the authored ones, so "which child columns did I declare?" still needed
87
+ // a filter. Keep every column but mark the system ones.
88
+ // `parentId` / `sortKey` are injected by the platform into every child
89
+ // schema (they are the child row's link + order), so they are system
90
+ // columns here even though a MAIN model never carries them.
91
+ ...(SYSTEM_FIELD_CODES.has(code) || code === "parentId" || code === "sortKey" ? { system: true } : {}),
92
+ };
93
+ })
94
+ .filter((child) => child.code.length > 0);
95
+ }
48
96
  /**
49
97
  * `name_i18n` is a JSON locale map. When every locale value equals the display
50
98
  * `name` there is no translation to convey, so echoing the map on every field of
@@ -117,7 +165,21 @@ export function registerFieldLane(tools) {
117
165
  ...(redundantNameI18n(row.name_i18n, row.name) ? {} : typeof row.name_i18n === "string" ? { name_i18n: row.name_i18n } : {}),
118
166
  type: row.type ?? row.propertyType,
119
167
  ...(typeLabel !== undefined ? { typeName: typeLabel } : {}),
120
- ...(row.subSchema !== undefined && row.subSchema !== null ? { subSchema: row.subSchema } : {}),
168
+ // A CHILD_TABLE row's `subSchema` is the whole designer payload; the
169
+ // default face carries the column list + count and keeps the raw
170
+ // payload behind `full:true` (issue 2026-10-25 §7: one field read was
171
+ // 37.6 KB for a 5-code answer).
172
+ ...(row.subSchema !== undefined && row.subSchema !== null
173
+ ? args.full === true
174
+ ? { subSchema: row.subSchema }
175
+ : (() => {
176
+ const subColumns = childColumnsOf(row.subSchema);
177
+ const businessColumns = subColumns.filter((column) => column.system !== true).length;
178
+ return subColumns.length > 0
179
+ ? { subColumnCount: subColumns.length, businessSubColumnCount: businessColumns, subColumns, subSchemaHint: "子表列已投影为 code/name/type(system:true 是平台注入的系统列);要平台原始 subSchema 加 full:true" }
180
+ : { subSchema: row.subSchema };
181
+ })()
182
+ : {}),
121
183
  ...(row.relativeCode ? { relativeCode: row.relativeCode } : {}),
122
184
  ...(typeof row.relativePropertyCode === "string" && row.relativePropertyCode ? { relativePropertyCode: row.relativePropertyCode } : {}),
123
185
  ...(options !== undefined ? { options } : {}),
@@ -150,11 +212,20 @@ export function registerFieldLane(tools) {
150
212
  const value = options[key];
151
213
  return key === "readonlyFormula" ? value === true : typeof value === "string" && value.trim() !== "";
152
214
  });
215
+ // Inventory reads could not see child columns at all (r13 §7): the
216
+ // only face that carried them was the 37.6 KB subSchema. Codes alone
217
+ // are ~12 B each and answer "did the child columns land?".
218
+ const subColumns = field.subColumns;
219
+ const businessCodes = Array.isArray(subColumns) ? subColumns.filter((column) => column.system !== true).map((column) => column.code) : undefined;
153
220
  return {
154
221
  code: field.code,
155
222
  type: field.type,
156
223
  ...(field.typeName !== undefined ? { typeName: field.typeName } : {}),
157
224
  ...(field.name !== undefined ? { name: field.name } : {}),
225
+ // Inventory answers "which columns did I author?" — system
226
+ // columns are reported as a count, not mixed into the list.
227
+ ...(businessCodes !== undefined ? { subColumnCodes: businessCodes } : {}),
228
+ ...(Array.isArray(subColumns) && subColumns.length > businessCodes.length ? { systemSubColumnCount: subColumns.length - businessCodes.length } : {}),
158
229
  ...(conditions.length > 0 ? { conditions } : {}),
159
230
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
160
231
  };
@@ -202,6 +273,7 @@ export function registerFieldLane(tools) {
202
273
  type: field.type,
203
274
  ...(field.typeName !== undefined ? { typeName: field.typeName } : {}),
204
275
  ...(field.name !== undefined ? { name: field.name } : {}),
276
+ ...(Array.isArray(field.subColumns) ? { subColumns: field.subColumns } : {}),
205
277
  ...(conditions.length > 0 ? { conditions } : {}),
206
278
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
207
279
  };
@@ -225,7 +297,7 @@ export function registerFieldLane(tools) {
225
297
  const draft = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data);
226
298
  const published = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
227
299
  if ([...draft, ...published].some((row) => (row.code ?? row.propertyCode) === args.code))
228
- throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST` });
300
+ throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST`, nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: true, code: args.code })}'` });
229
301
  // One seam owns field-change normalization, every reproduced gate, and the
230
302
  // wire body (src/field-change.ts), so configure_model and intent share it.
231
303
  const prepared = prepareFieldChange(args);
@@ -776,7 +776,27 @@ export function registerListViewLane(tools) {
776
776
  const before = await wireCall(session, { path: ENDPOINTS.listQueryGetHeader, query: { schemaCode: args.schemaCode, code: args.code } });
777
777
  if (before.data == null)
778
778
  return { performed: { skipped: "already-absent" }, readback: { absent: true, header: null } };
779
- const performed = await wireCall(session, { path: ENDPOINTS.listQueryDelete, method: "POST", query: { schemaCode: args.schemaCode, code: args.code } });
779
+ let performed;
780
+ try {
781
+ performed = await wireCall(session, { path: ENDPOINTS.listQueryDelete, method: "POST", query: { schemaCode: args.schemaCode, code: args.code } });
782
+ }
783
+ catch (error) {
784
+ // 306103 删除后将无可用视图 — the platform requires at least one visible
785
+ // list view per model. r15's `nextCommand` fell back to the generic
786
+ // refusal router; name the constraint and the only real deletion path.
787
+ if (error instanceof WireError && error.kind === "business" && (error.errcode === 306103 || /无可用视图/.test(error.message)))
788
+ throw new ToolRefusalError({
789
+ status: "refused",
790
+ blocker: "listview-last-visible",
791
+ schemaCode: args.schemaCode,
792
+ code: args.code,
793
+ nextCommand: `omc model-purge --code ${args.schemaCode}`,
794
+ message: `不能删除视图 ${args.code}:平台要求每个模型至少保留一条可见列表视图(306103)。` +
795
+ `单独删最后一条视图没有意义——视图会随模型一起级联删除。要继续用这个模型就**就地改**(listview.configure 省略 code 即改默认视图),` +
796
+ `要整体清理就用 model-purge(默认 dry-run,先看计划)。`,
797
+ });
798
+ throw error;
799
+ }
780
800
  const readback = await wireCall(session, { path: ENDPOINTS.listQueryGetHeader, query: { schemaCode: args.schemaCode, code: args.code } });
781
801
  return { performed: { errcode: performed.errcode }, readback: { absent: readback.data == null, header: readback.data ?? null } };
782
802
  }, undefined, undefined, listviewRollbackSpec("listview.delete"));
@@ -199,7 +199,10 @@ export function registerModelLane(tools) {
199
199
  const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
200
200
  const all = tree
201
201
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
202
- .map((row) => ({ code: row.code, name: row.name ?? row.text ?? null }));
202
+ // `schemaCode` is the parameter name every other tool takes; `code` is
203
+ // the tree row's key. Echo both so `--jq 'models[].schemaCode'` works
204
+ // (r15 P6: the alias was missing and the path missed).
205
+ .map((row) => ({ code: row.code, schemaCode: row.code, name: row.name ?? row.text ?? null }));
203
206
  const needle = args.filter?.trim();
204
207
  const models = needle ? all.filter((model) => String(model.code).includes(needle) || String(model.name ?? "").includes(needle)) : all;
205
208
  // `filterApplied` disambiguates an empty result: it distinguishes "the
@@ -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