@stalfh233/omc-cli 0.4.73 → 0.4.75

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,662 @@
1
+ import { z } from "zod";
2
+ import { ToolInputError, ToolRefusalError } from "../tool-types.js";
3
+ import { defineTool } from "../tool-types.js";
4
+ import { governedRead } from "../read.js";
5
+ import { writeTool } from "../write.js";
6
+ import { wireCall, wireRows, WireError } from "../wire.js";
7
+ import { ENDPOINTS } from "../endpoints.js";
8
+ import { environmentArg, capabilityArg } from "../args.js";
9
+ import { LANES } from "./lane-ids.js";
10
+ /**
11
+ * Organization WRITE faces (8.6.41 customer transcript, 2026-10-21).
12
+ *
13
+ * The read faces already existed (org.role.list / org.department.list /
14
+ * org.user.search); the customer's manual work was entirely writes: create a
15
+ * role group ("分组" in the UI is a role), create roles under it, add members,
16
+ * and set each member's management scope. Those are now first-class tools.
17
+ *
18
+ * Three wire quirks are encoded here because every one of them cost the
19
+ * customer a failed round:
20
+ * - `corpId` must ride the QUERY (a self-maintained org uses the literal
21
+ * "other" in the body), not only the header;
22
+ * - `add_role_user` wants `unitType` as a STRING "3", `update_ouscope` wants
23
+ * the INT 3;
24
+ * - `update_ouscope` is REPLACE semantics with no rollback, so the tool reads
25
+ * the current scope and merges by default (`replace:true` is explicit).
26
+ */
27
+ const LANE = LANES.workflow;
28
+ const SCOPE = "organization role/member/scope lifecycle via /api/api/organization/role/*";
29
+ const SCOPES = {
30
+ roleUsers: "organization role member read (role/users)",
31
+ userScope: "organization role member management scope read (role/get_ouscope)",
32
+ groupCreate: "organization role group create (role/add_role_group)",
33
+ roleCreate: "organization role create (role/add_role)",
34
+ roleUpdate: "organization role update (role/modify_role)",
35
+ addUsers: "organization role member add (role/add_role_user)",
36
+ removeUsers: "organization role member remove (role/strike_out_role_user)",
37
+ setScope: "organization role member scope update (role/update_ouscope)",
38
+ roleDelete: "organization role delete (role/strike_out_role)",
39
+ roleGroupDelete: "organization role group delete (role/strike_out_role_group)",
40
+ userTransfer: "organization user department transfer (user/dept_transfer)",
41
+ deptCreate: "organization department create (department/add)",
42
+ deptUpdate: "organization department update (department/modefied)",
43
+ deptDelete: "organization department delete (department/strike_out)",
44
+ deptUsers: "organization department member read (department/list_user)",
45
+ userCreate: "organization user create (user/add)",
46
+ userUpdate: "organization user update (user/modify)",
47
+ userDelete: "organization user delete (user/strike_out)",
48
+ userInfo: "organization user read (user/info)",
49
+ userDepartments: "organization user department read (user/departments)",
50
+ };
51
+ /**
52
+ * `DeptType` is a Jackson enum over INT indices: writing the readable
53
+ * "dept"/"DEPT" answers 10009 「类型转换异常: not one of [0,1,2,3]」 (live
54
+ * 2026-10-22, 1.1.25). Accept the names and map them.
55
+ */
56
+ const DEPT_TYPE_CODES = Object.freeze({
57
+ DEPT: 0, DEPARTMENT: 0, 部门: 0,
58
+ EDU_DEPT: 1, 家校部门: 1,
59
+ YUNS_DEPT: 2, 云枢部门: 2,
60
+ VIRTUAL_EDU_DEPT: 3, 虚拟家校部门: 3,
61
+ });
62
+ function deptTypeCode(value) {
63
+ if (value === undefined)
64
+ return 0;
65
+ if (typeof value === "number") {
66
+ if (!Number.isInteger(value) || value < 0 || value > 3)
67
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.department.create", message: `deptType ${String(value)} 不在 0..3(0=部门 1=家校部门 2=云枢部门 3=虚拟家校部门)` });
68
+ return value;
69
+ }
70
+ const code = DEPT_TYPE_CODES[String(value).trim().toUpperCase()] ?? DEPT_TYPE_CODES[String(value).trim()];
71
+ if (code === undefined)
72
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.department.create", message: `deptType "${String(value)}" 不认识;可用 ${Object.keys(DEPT_TYPE_CODES).join("、")} 或整数 0..3` });
73
+ return code;
74
+ }
75
+ const orgQuery = (session) => ({ corpId: session.enterpriseId() });
76
+ const roleUserId = (row) => String(row.id ?? row.userId ?? row.recId ?? "");
77
+ async function memberRows(session, roleId) {
78
+ const data = (await wireCall(session, {
79
+ path: ENDPOINTS.organizationRoleUsers,
80
+ query: { ...orgQuery(session), roleId, page: "0", size: "500" },
81
+ })).data;
82
+ return Array.isArray(data?.content) ? data.content : wireRows(data);
83
+ }
84
+ async function removeMembers(session, roleId, userIds) {
85
+ if (userIds.length === 0)
86
+ return;
87
+ await wireCall(session, {
88
+ path: ENDPOINTS.organizationRoleRemoveUser, method: "POST", query: orgQuery(session),
89
+ body: { roleId, userIds },
90
+ });
91
+ }
92
+ async function rolesInGroup(session, roleGroupId) {
93
+ // `expandAll=true` is mandatory: without it /role/list only fills the FIRST
94
+ // group's children, so roles under any other group read back as absent.
95
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList, query: { expandAll: "true" } })).data;
96
+ const roles = [];
97
+ const walk = (rows, inside) => {
98
+ for (const row of rows) {
99
+ const isGroup = row.group === true || (row.group === undefined && Array.isArray(row.children) && row.children.length > 0);
100
+ const id = String(row.id ?? "");
101
+ if (inside || (isGroup && id === roleGroupId)) {
102
+ if (!isGroup && id !== "")
103
+ roles.push({ id, name: String(row.name ?? "") });
104
+ }
105
+ if (Array.isArray(row.children))
106
+ walk(row.children, inside || (isGroup && id === roleGroupId));
107
+ }
108
+ };
109
+ walk(wireRows(data), false);
110
+ return roles;
111
+ }
112
+ export function registerOrganizationLane(tools) {
113
+ tools.push(defineTool({
114
+ name: "org.role.users",
115
+ layer: "low",
116
+ kind: "read",
117
+ lane: LANE,
118
+ scope: SCOPES.roleUsers,
119
+ description: "列出角色成员(`GET organization/role/users`)。返回 members[{id(recId),name,...}];**id 是 recId,不是账号**(addUsers/setUserScope 都传它)。",
120
+ inputSchema: {
121
+ environment: environmentArg,
122
+ roleId: z.string().min(1).describe("角色 id(不是角色 code)"),
123
+ page: z.number().int().min(0).optional(),
124
+ size: z.number().int().min(1).max(500).optional(),
125
+ capability: capabilityArg,
126
+ },
127
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
128
+ const data = (await wireCall(session, {
129
+ path: ENDPOINTS.organizationRoleUsers,
130
+ query: { ...orgQuery(session), roleId: String(args.roleId), page: String(args.page ?? 0), size: String(args.size ?? 500) },
131
+ })).data;
132
+ const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
133
+ const members = rows.map((row) => ({
134
+ id: roleUserId(row),
135
+ name: row.name ?? null,
136
+ username: row.username ?? null,
137
+ departmentId: row.departmentId ?? null,
138
+ unitType: row.unitType ?? null,
139
+ }));
140
+ return { roleId: String(args.roleId), count: members.length, total: data?.totalElements ?? members.length, members };
141
+ }),
142
+ }));
143
+ tools.push(defineTool({
144
+ name: "org.role.userScope",
145
+ layer: "low",
146
+ kind: "read",
147
+ lane: LANE,
148
+ scope: SCOPES.userScope,
149
+ description: "读成员的管理范围(`GET organization/role/get_ouscope`)。`type:\"1\"`=部门节点;update_ouscope 是覆盖语义,改前先读。",
150
+ inputSchema: {
151
+ environment: environmentArg,
152
+ roleId: z.string().min(1),
153
+ userId: z.string().min(1).describe("成员的用户记录 id(recId)"),
154
+ capability: capabilityArg,
155
+ },
156
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
157
+ const data = (await wireCall(session, {
158
+ path: ENDPOINTS.organizationRoleGetOuScope,
159
+ query: { ...orgQuery(session), roleId: String(args.roleId), userId: String(args.userId) },
160
+ })).data;
161
+ const scopes = wireRows(data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
162
+ return { roleId: String(args.roleId), userId: String(args.userId), count: scopes.length, scopes };
163
+ }),
164
+ }));
165
+ writeTool(tools, "org.roleGroup.create", LANE, SCOPES.groupCreate, "创建角色组(`POST organization/role/add_role_group`)。返回 roleGroupId,角色挂在它下面。**corpId 默认写当前企业**(`session.enterpriseId()`):平台的 `corpId` 只从 body 取,body 缺省时才按 parentId 反查企业设置,查不到就 NPE(被吞成 errcode 0);`corpId=\"other\"` 是平台合法的「非组织分组」语义,只有明确要建非组织分组时才传。", {
166
+ roleGroupName: z.string().min(1).describe("角色组名称(界面显示的分组名)"),
167
+ parentId: z.string().min(1).describe("上级角色组 id。**必填**:1.1.25 的 add_role_group 会先 getByParentId(parentId),空值直接 Preconditions 异常(50000 parentIds cannot be empty);顶层组请传现有组 id(如「默认」组),平台允许嵌套"),
168
+ code: z.string().optional(),
169
+ corpId: z.string().min(1).optional().describe("组织 corpId,默认当前登录企业;传 \"other\" 表示非组织分组(该组在组织管理里不归属任何企业)"),
170
+ }, async (session, args) => {
171
+ const corpId = args.corpId ?? session.enterpriseId();
172
+ const data = (await wireCall(session, {
173
+ path: ENDPOINTS.organizationRoleAddGroup, method: "POST",
174
+ query: orgQuery(session),
175
+ body: { roleGroupName: args.roleGroupName, corpId, ...(args.parentId ? { parentId: args.parentId } : {}), ...(args.code ? { code: args.code } : {}) },
176
+ })).data;
177
+ const roleGroupId = String(data?.roleGroupId ?? data?.id ?? "");
178
+ if (!roleGroupId)
179
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回 roleGroupId;不要假设创建成功,先 org.role.list 回读" });
180
+ const readbackCorpId = data?.corpId ?? null;
181
+ // The platform takes corpId from the body verbatim (RoleGroupHelper
182
+ // .createRoleGroupModel, 1.1.25): a silent rewrite means the value we sent
183
+ // did not land, and participants built from this group would not resolve
184
+ // in the org tree.
185
+ const corpIdHonoured = readbackCorpId === null || String(readbackCorpId) === corpId;
186
+ return {
187
+ performed: { errcode: 0 },
188
+ readback: {
189
+ roleGroupId,
190
+ roleGroupName: data?.roleGroupName ?? args.roleGroupName,
191
+ code: data?.code ?? null,
192
+ corpId: readbackCorpId,
193
+ ...(corpIdHonoured ? {} : { warning: `平台回读 corpId=${String(readbackCorpId)},与请求的 ${corpId} 不一致;该组可能不归属当前企业,先 org.role.list 核对` }),
194
+ },
195
+ };
196
+ });
197
+ writeTool(tools, "org.role.create", LANE, SCOPES.roleCreate, "创建角色(界面「分组」;`POST organization/role/add_role`)。返回 roleId;code 创建时 null、随后补 SYS<时间戳>,要稳定编码就传 code。", {
198
+ roleName: z.string().min(1),
199
+ roleGroupId: z.string().min(1).describe("所属角色组 id(org.role.list 的 groups[].id)"),
200
+ roleGroupName: z.string().optional(),
201
+ roleType: z.enum(["SYS", "CUSTOMIZE", "OTHER"]).optional().describe("默认 SYS(平台会把角色统一落成 SYS,见 RoleHelper.createRoleModel)"),
202
+ code: z.string().optional(),
203
+ remarks: z.string().optional(),
204
+ corpId: z.string().min(1).optional().describe("组织 corpId,默认当前登录企业;传 \"other\" 表示非组织分组角色"),
205
+ }, async (session, args) => {
206
+ const corpId = args.corpId ?? session.enterpriseId();
207
+ const data = (await wireCall(session, {
208
+ path: ENDPOINTS.organizationRoleAdd, method: "POST",
209
+ query: orgQuery(session),
210
+ body: {
211
+ roleName: args.roleName,
212
+ roleGroupId: args.roleGroupId,
213
+ ...(args.roleGroupName ? { roleGroupName: args.roleGroupName } : {}),
214
+ roleType: args.roleType ?? "SYS",
215
+ corpId,
216
+ ...(args.code ? { code: args.code } : {}),
217
+ ...(args.remarks ? { remarks: args.remarks } : {}),
218
+ },
219
+ })).data;
220
+ const roleId = String(data?.roleId ?? data?.id ?? "");
221
+ if (!roleId)
222
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回 roleId;先 org.role.list 回读确认" });
223
+ const readbackCorpId = data?.corpId ?? null;
224
+ return {
225
+ performed: { errcode: 0 },
226
+ readback: {
227
+ roleId,
228
+ roleName: data?.roleName ?? args.roleName,
229
+ code: data?.code ?? null,
230
+ corpId: readbackCorpId,
231
+ ...(readbackCorpId === null || String(readbackCorpId) === corpId ? {} : { warning: `平台回读 corpId=${String(readbackCorpId)},与请求的 ${corpId} 不一致` }),
232
+ },
233
+ };
234
+ });
235
+ // ── department ────────────────────────────────────────────────────────────
236
+ writeTool(tools, "org.department.create", LANE, SCOPES.deptCreate, "新建部门(`POST organization/department/add`)。**1.1.25 平台强制 parentId**(省略会 200007「部门parentId不能为空!」)——省略时工具自动取部门树的第一个根部门作父级;deptType 传名字或 0..3(平台按 int 枚举反序列化,写 \"dept\" 会 10009)。返回 deptId。", {
237
+ name: z.string().min(1).describe("部门名称"),
238
+ parentId: z.string().min(1).optional().describe("上级部门 id;省略则自动挂到部门树的第一个根部门(1.1.25 不接受空 parentId)"),
239
+ deptType: z.union([z.string(), z.number().int()]).optional().describe("默认 0=部门;1=家校部门 2=云枢部门 3=虚拟家校部门"),
240
+ code: z.string().optional(),
241
+ managerId: z.string().optional().describe("部门主管的用户 id"),
242
+ sortKey: z.number().int().optional(),
243
+ remarks: z.string().optional(),
244
+ }, async (session, args) => {
245
+ // department/add answers 200007 「部门parentId不能为空!」 on an empty
246
+ // parentId (live 1.1.25, r10 F5). Resolve the tree's first root instead of
247
+ // forwarding an omission the platform rejects.
248
+ let parentId = args.parentId;
249
+ if (!parentId) {
250
+ const treeData = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree })).data;
251
+ // `/department/tree` answers `{departmentList:[…]}` (the same shape
252
+ // org.department.list handles); wireRows alone would throw
253
+ // "expected row array in envelope data".
254
+ const roots = Array.isArray(treeData?.departmentList) ? treeData.departmentList : wireRows(treeData);
255
+ const root = roots.find((row) => {
256
+ const pid = row.parentId;
257
+ return pid === undefined || pid === null || pid === "";
258
+ });
259
+ parentId = root ? String(root.id ?? "") : "";
260
+ if (!parentId)
261
+ throw new ToolRefusalError({ status: "refused", blocker: "org-department-root-absent", message: "部门树没有任何根部门,无法推断 parentId;平台 1.1.25 不接受空 parentId,请先建根部门或显式传 parentId" });
262
+ }
263
+ const data = (await wireCall(session, {
264
+ path: ENDPOINTS.organizationDepartmentAdd, method: "POST", query: orgQuery(session),
265
+ body: {
266
+ name: args.name, corpId: session.enterpriseId(), unitType: "1", enabled: true, deptType: deptTypeCode(args.deptType),
267
+ parentId,
268
+ ...(args.code ? { code: args.code } : {}),
269
+ ...(args.managerId ? { managerId: args.managerId } : {}),
270
+ ...(args.sortKey !== undefined ? { sortKey: args.sortKey } : {}),
271
+ ...(args.remarks ? { remarks: args.remarks } : {}),
272
+ },
273
+ })).data;
274
+ const deptId = String(data?.id ?? "");
275
+ if (!deptId)
276
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回部门 id;先 org.department.list 回读确认" });
277
+ return { performed: { errcode: 0 }, readback: { deptId, name: data?.name ?? args.name, parentId: data?.parentId ?? args.parentId ?? null, code: data?.code ?? null } };
278
+ });
279
+ writeTool(tools, "org.department.update", LANE, SCOPES.deptUpdate, "改部门(`POST organization/department/modefied`)。先 get_dept_info 读现状再合并提交,只传要改的字段(名称/上级/主管/排序/备注/启用)。", {
280
+ deptId: z.string().min(1),
281
+ name: z.string().optional(),
282
+ parentId: z.string().optional(),
283
+ managerId: z.string().optional(),
284
+ sortKey: z.number().int().optional(),
285
+ remarks: z.string().optional(),
286
+ enabled: z.boolean().optional(),
287
+ }, async (session, args) => {
288
+ const current = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
289
+ if (!current)
290
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `get_dept_info 未返回部门 ${args.deptId}` });
291
+ const body = {
292
+ ...current,
293
+ ...(args.name !== undefined ? { name: args.name } : {}),
294
+ ...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
295
+ ...(args.managerId !== undefined ? { managerId: args.managerId } : {}),
296
+ ...(args.sortKey !== undefined ? { sortKey: args.sortKey } : {}),
297
+ ...(args.remarks !== undefined ? { remarks: args.remarks } : {}),
298
+ ...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
299
+ };
300
+ await wireCall(session, { path: ENDPOINTS.organizationDepartmentModify, method: "POST", query: orgQuery(session), body });
301
+ const after = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
302
+ 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
+ });
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) => {
305
+ const users = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: args.deptId } })).data);
306
+ 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 } };
308
+ });
309
+ tools.push(defineTool({
310
+ name: "org.department.users",
311
+ layer: "low", kind: "read", lane: LANE, scope: SCOPES.deptUsers,
312
+ description: "列出部门下的员工(`GET organization/department/list_user?deptId=`)。返回 members[{id(recId),name,username,mobile}]。",
313
+ inputSchema: { environment: environmentArg, deptId: z.string().min(1), capability: capabilityArg },
314
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
315
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: String(args.deptId) } })).data;
316
+ const members = wireRows(data).map((row) => ({ id: row.id ?? row.userId ?? null, name: row.name ?? null, username: row.username ?? null, mobile: row.mobile ?? null }));
317
+ return { deptId: String(args.deptId), count: members.length, members };
318
+ }),
319
+ }));
320
+ // ── employee ──────────────────────────────────────────────────────────────
321
+ writeTool(tools, "org.user.create", LANE, SCOPES.userCreate, "新建员工账号(`POST organization/user/add`)。必填 name/username/departmentId;工具自动补 `userLabelIds:[]`、`userManagerConfigList:[]`、`roleIds:[]` —— 平台 dealUserLabel 对 null 直接 NPE(50000,真机 1.1.25)。返回 userId(recId);初始密码由平台默认策略生成。", {
322
+ name: z.string().min(1),
323
+ username: z.string().min(1).describe("登录账号"),
324
+ departmentId: z.string().min(1).describe("所属部门 id(必须先存在,否则 201011「用户的部门不存在!」)"),
325
+ mobile: z.string().optional(),
326
+ email: z.string().optional(),
327
+ employeeNo: z.string().optional(),
328
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
329
+ roleIds: z.array(z.string()).optional().describe("同时授予的角色 id"),
330
+ partTimeDeptIds: z.array(z.string()).optional().describe("兼职部门 id(主部门仍是 departmentId)"),
331
+ deptIds: z.array(z.string()).optional().describe("`partTimeDeptIds` 的别名"),
332
+ userLabelIds: z.array(z.string()).optional(),
333
+ active: z.boolean().optional(),
334
+ status: z.enum(["ENABLE", "DISABLE", "LOCK"]).optional(),
335
+ }, async (session, args) => {
336
+ const data = (await wireCall(session, {
337
+ path: ENDPOINTS.organizationUserAdd, method: "POST", query: orgQuery(session),
338
+ body: {
339
+ name: args.name, username: args.username, departmentId: args.departmentId, currentDeptId: args.departmentId,
340
+ corpId: session.enterpriseId(), unitType: "3", enabled: true, active: args.active ?? true,
341
+ status: args.status ?? "ENABLE",
342
+ userLabelIds: args.userLabelIds ?? [], userManagerConfigList: [], roleIds: args.roleIds ?? [], deptIds: args.partTimeDeptIds ?? args.deptIds ?? [],
343
+ ...(args.mobile ? { mobile: args.mobile } : {}),
344
+ ...(args.email ? { email: args.email } : {}),
345
+ ...(args.employeeNo ? { employeeNo: args.employeeNo } : {}),
346
+ ...(args.gender ? { gender: args.gender } : {}),
347
+ },
348
+ })).data;
349
+ const userId = String(data?.id ?? "");
350
+ if (!userId)
351
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回用户 id;先 org.user.search 回读确认" });
352
+ return { performed: { errcode: 0 }, readback: { userId, name: data?.name ?? args.name, username: data?.username ?? args.username, departmentId: data?.departmentId ?? args.departmentId } };
353
+ });
354
+ writeTool(tools, "org.user.update", LANE, SCOPES.userUpdate, "改员工(`POST organization/user/modify`)。先 user/info 读现状再合并;工具强制补 `deptIds:[]` 等四个数组——平台 modified 对 null 的 deptIds 直接 NPE(50000,真机 1.1.25)。可改姓名/部门/手机/邮箱/角色/启停。", {
355
+ userId: z.string().min(1),
356
+ name: z.string().optional(),
357
+ departmentId: z.string().optional(),
358
+ mobile: z.string().optional(),
359
+ email: z.string().optional(),
360
+ employeeNo: z.string().optional(),
361
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
362
+ roleIds: z.array(z.string()).optional(),
363
+ partTimeDeptIds: z.array(z.string()).optional().describe("兼职部门 id;**覆盖语义**(传 [] 清空兼职,主部门不变)"),
364
+ active: z.boolean().optional(),
365
+ status: z.enum(["ENABLE", "DISABLE", "LOCK"]).optional(),
366
+ }, async (session, args) => {
367
+ const current = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: args.userId } })).data;
368
+ if (!current)
369
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `user/info 未返回用户 ${args.userId}` });
370
+ const body = {
371
+ ...current,
372
+ id: args.userId,
373
+ ...(args.name !== undefined ? { name: args.name } : {}),
374
+ ...(args.departmentId !== undefined ? { departmentId: args.departmentId, currentDeptId: args.departmentId } : {}),
375
+ ...(args.mobile !== undefined ? { mobile: args.mobile } : {}),
376
+ ...(args.email !== undefined ? { email: args.email } : {}),
377
+ ...(args.employeeNo !== undefined ? { employeeNo: args.employeeNo } : {}),
378
+ ...(args.gender !== undefined ? { gender: args.gender } : {}),
379
+ ...(args.active !== undefined ? { active: args.active } : {}),
380
+ ...(args.status !== undefined ? { status: args.status } : {}),
381
+ deptIds: args.partTimeDeptIds ?? (Array.isArray(current.deptIds) ? current.deptIds : []),
382
+ userLabelIds: Array.isArray(current.userLabelIds) ? current.userLabelIds : [],
383
+ userManagerConfigList: Array.isArray(current.userManagerConfigList) ? current.userManagerConfigList : [],
384
+ roleIds: args.roleIds ?? (Array.isArray(current.roleIds) ? current.roleIds : []),
385
+ };
386
+ await wireCall(session, { path: ENDPOINTS.organizationUserModify, method: "POST", query: orgQuery(session), body });
387
+ const after = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: args.userId } })).data;
388
+ const departments = wireRows((await wireCall(session, {
389
+ path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) },
390
+ })).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 } };
392
+ });
393
+ 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
+ await wireCall(session, { path: ENDPOINTS.organizationUserDelete, query: { ...orgQuery(session), id: args.userId } });
395
+ return { performed: { errcode: 0 }, readback: { userId: args.userId, deleted: true } };
396
+ });
397
+ tools.push(defineTool({
398
+ name: "org.user.info",
399
+ layer: "low", kind: "read", lane: LANE, scope: SCOPES.userInfo,
400
+ description: "读员工详情(`GET organization/user/info?id=`)。返回姓名/账号/部门/手机/邮箱/状态/角色 id 等。",
401
+ inputSchema: { environment: environmentArg, userId: z.string().min(1), capability: capabilityArg },
402
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
403
+ let data = null;
404
+ try {
405
+ data = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: String(args.userId) } })).data;
406
+ }
407
+ catch (error) {
408
+ // A deleted/unknown user answers 10011 (业务模型不存在-style business
409
+ // error) rather than an empty body; "read a user" should not turn an
410
+ // absence into a refusal.
411
+ if (!(error instanceof WireError) || error.errcode !== 10011)
412
+ throw error;
413
+ }
414
+ if (!data)
415
+ return { userId: String(args.userId), exists: false };
416
+ return {
417
+ userId: String(args.userId), exists: true,
418
+ name: data.name ?? null, username: data.username ?? null, departmentId: data.departmentId ?? null,
419
+ mobile: data.mobile ?? null, email: data.email ?? null, active: data.active ?? null,
420
+ // `status` is reserved by the tool envelope (ok/refused/…): a user's
421
+ // ENABLE/DISABLE value under that key made the read look like a failed
422
+ // tool call (live 2026-10-22).
423
+ userStatus: data.status ?? null,
424
+ roleIds: Array.isArray(data.roleIds) ? data.roleIds : [],
425
+ };
426
+ }),
427
+ }));
428
+ tools.push(defineTool({
429
+ name: "org.user.departments",
430
+ layer: "low", kind: "read", lane: LANE, scope: SCOPES.userDepartments,
431
+ description: "读员工所属部门(含兼职,`GET organization/user/departments?id=`)。返回 departments[{deptId,deptName,isMain}]。",
432
+ inputSchema: { environment: environmentArg, userId: z.string().min(1), capability: capabilityArg },
433
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
434
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) } })).data;
435
+ const departments = wireRows(data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
436
+ return { userId: String(args.userId), count: departments.length, departments };
437
+ }),
438
+ }));
439
+ writeTool(tools, "org.role.delete", LANE, SCOPES.roleDelete, "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员数、非空即拒绝并给出人数;`force:true` 才会先逐人移出再删。", {
440
+ roleId: z.string().min(1),
441
+ force: z.boolean().optional().describe("true = 先移出全部成员再删角色"),
442
+ }, async (session, args) => {
443
+ const rows = await memberRows(session, String(args.roleId));
444
+ if (rows.length > 0 && args.force !== true)
445
+ throw new ToolRefusalError({
446
+ status: "refused", blocker: "role-has-members", roleId: String(args.roleId), members: rows.length,
447
+ message: `角色下还有 ${rows.length} 名成员,平台拒绝删除(errmsg:角色下已经包含用户数据)。先 org.role.removeUsers,或传 force:true 让工具移出后再删。`,
448
+ });
449
+ if (rows.length > 0)
450
+ await removeMembers(session, String(args.roleId), rows.map(roleUserId));
451
+ await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: String(args.roleId) } });
452
+ return { performed: { errcode: 0 }, readback: { roleId: String(args.roleId), deleted: true, removedMembers: rows.length } };
453
+ });
454
+ writeTool(tools, "org.roleGroup.delete", LANE, SCOPES.roleGroupDelete, "删除角色组(`GET organization/role/strike_out_role_group?ids=`)。平台**拒绝删除仍含角色的组**(`-1 角色组下已经包含角色数据!`):默认先数角色、非空即拒绝;`force:true` 会逐个删角色(含其成员)再删组。", {
455
+ roleGroupId: z.string().min(1),
456
+ force: z.boolean().optional(),
457
+ }, async (session, args) => {
458
+ const roles = await rolesInGroup(session, String(args.roleGroupId));
459
+ if (roles.length > 0 && args.force !== true)
460
+ throw new ToolRefusalError({
461
+ status: "refused", blocker: "role-group-has-roles", roleGroupId: String(args.roleGroupId), roles: roles.length,
462
+ message: `角色组下还有 ${roles.length} 个角色,平台拒绝删除(errmsg:角色组下已经包含角色数据)。先删角色,或传 force:true。`,
463
+ });
464
+ for (const role of roles) {
465
+ const members = await memberRows(session, role.id);
466
+ if (members.length > 0)
467
+ await removeMembers(session, role.id, members.map(roleUserId));
468
+ await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: role.id } });
469
+ }
470
+ await wireCall(session, { path: ENDPOINTS.organizationRoleGroupDelete, query: { ...orgQuery(session), ids: String(args.roleGroupId) } });
471
+ return { performed: { errcode: 0 }, readback: { roleGroupId: String(args.roleGroupId), deleted: true, removedRoles: roles.map((role) => role.id) } };
472
+ });
473
+ writeTool(tools, "org.user.transferDepartment", LANE, SCOPES.userTransfer, "把员工从一个部门移到另一个部门(`POST organization/user/dept_transfer`,body `{userIds,sourceDept,targetDept}`)。sourceDept 必填(缺它 50000);移动后原部门不再包含该员工,主部门保持。", {
474
+ userId: z.string().min(1),
475
+ fromDeptId: z.string().min(1).describe("原部门 id(必填)"),
476
+ toDeptId: z.string().min(1).describe("目标部门 id"),
477
+ }, async (session, args) => {
478
+ await wireCall(session, {
479
+ path: ENDPOINTS.organizationUserDeptTransfer, method: "POST", query: orgQuery(session),
480
+ body: { userIds: [String(args.userId)], sourceDept: String(args.fromDeptId), targetDept: String(args.toDeptId) },
481
+ });
482
+ const departments = wireRows((await wireCall(session, {
483
+ path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) },
484
+ })).data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
485
+ return { performed: { errcode: 0 }, readback: { userId: String(args.userId), fromDeptId: String(args.fromDeptId), toDeptId: String(args.toDeptId), departments } };
486
+ });
487
+ writeTool(tools, "org.role.update", LANE, SCOPES.roleUpdate, "改角色名称/编码/备注/所属角色组(`POST organization/role/modify_role`)。先读现状再提交,只传要改的字段。**平台要求 roleGroupId 必填**(缺则 202012「角色组主键Id不能为空」),工具默认回填当前组;**corpId 不可改**(RoleHelper.update 不读该字段),要换企业只能在目标企业下重建角色。", {
488
+ roleId: z.string().min(1),
489
+ roleName: z.string().optional(),
490
+ roleGroupId: z.string().min(1).optional().describe("移到别的角色组(默认保持当前组)。平台 update 必填该字段,且目标组必须存在、roleType=SYS"),
491
+ code: z.string().optional(),
492
+ remarks: z.string().optional(),
493
+ }, async (session, args) => {
494
+ const current = (await wireCall(session, { path: ENDPOINTS.organizationRoleGet, query: { ...orgQuery(session), roleId: String(args.roleId) } })).data;
495
+ if (!current)
496
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `role/get 未返回角色 ${String(args.roleId)};不要盲目 modify` });
497
+ // RoleHelper.update (1.1.25) validates roleName AND roleGroupId as
498
+ // REQUIRED before touching the row (validateRoleParam): a delta that omits
499
+ // either answers 202001 「角色名称不能为空」 / 202012 「角色组主键Id不能为空」.
500
+ // Both are backfilled from role/get, so a remarks-only edit works.
501
+ const roleName = String(args.roleName ?? current.roleName ?? current.name ?? "");
502
+ if (roleName === "")
503
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `角色 ${String(args.roleId)} 读不到 roleName;平台 modify_role 要求该字段,请显式传 roleName` });
504
+ const roleGroupId = String(args.roleGroupId ?? current.roleGroupId ?? current.groupId ?? "");
505
+ if (roleGroupId === "")
506
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `角色 ${String(args.roleId)} 读不到 roleGroupId;平台 modify_role 要求该字段,请显式传 roleGroupId` });
507
+ const body = {
508
+ ...current,
509
+ roleId: String(args.roleId),
510
+ roleName,
511
+ roleGroupId,
512
+ ...(args.code !== undefined ? { code: args.code } : {}),
513
+ ...(args.remarks !== undefined ? { remarks: args.remarks } : {}),
514
+ corpId: current.corpId ?? "other",
515
+ roleType: current.roleType ?? "SYS",
516
+ };
517
+ await wireCall(session, { path: ENDPOINTS.organizationRoleModify, method: "POST", query: orgQuery(session), body });
518
+ const after = (await wireCall(session, { path: ENDPOINTS.organizationRoleGet, query: { ...orgQuery(session), roleId: String(args.roleId) } })).data;
519
+ // role/get answers RoleModel (name/groupId), not RoleVO (roleName/
520
+ // roleGroupId) — read both spellings so the readback is never null.
521
+ return {
522
+ performed: { errcode: 0 },
523
+ readback: {
524
+ roleId: String(args.roleId),
525
+ roleName: after?.roleName ?? after?.name ?? null,
526
+ roleGroupId: after?.roleGroupId ?? after?.groupId ?? null,
527
+ code: after?.code ?? null,
528
+ remarks: after?.remarks ?? null,
529
+ },
530
+ };
531
+ });
532
+ writeTool(tools, "org.role.addUsers", LANE, SCOPES.addUsers, "加成员并(可选)一次设好管理范围(`add_role_user` + 逐人 `update_ouscope`,一个工具调用)。users[].id 必须是 recId(org.user.search 的 id);传 `scopes` 会应用到本次新增的每个人。**平台的 add_role_user 虽然收 `ouScope` 但会忽略它**(实测 1.1.25,回读仍为空),所以范围必须单独写——工具内部已按顺序完成。加完回读成员与范围。", {
533
+ roleId: z.string().min(1),
534
+ users: z.array(z.object({ id: z.string().min(1).describe("用户记录 id(recId)"), name: z.string().min(1) })).min(1),
535
+ scopes: z.array(z.object({
536
+ id: z.string().min(1).describe("部门 id"),
537
+ name: z.string().min(1),
538
+ deptPath: z.string().optional(),
539
+ type: z.string().optional().describe("默认 \"1\"(部门节点)"),
540
+ })).optional().describe("应用到本次新增的每个成员的管​理范围;省略则不设(成员 ouScope 为空)"),
541
+ sortKey: z.number().int().optional(),
542
+ userIds: z.array(z.object({ id: z.string().min(1), name: z.string().min(1) })).optional().describe("`users` 的别名"),
543
+ }, async (session, args) => {
544
+ const users = args.users ?? args.userIds ?? [];
545
+ if (users.length === 0)
546
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.role.addUsers", message: "需要 users(或 userIds)至少一人" });
547
+ await wireCall(session, {
548
+ path: ENDPOINTS.organizationRoleAddUser, method: "POST",
549
+ query: orgQuery(session),
550
+ body: { roleId: args.roleId, sortKey: args.sortKey ?? 999, userIds: users.map((user) => ({ id: user.id, name: user.name, type: 3, unitType: "3" })) },
551
+ });
552
+ const readback = async () => {
553
+ const data = (await wireCall(session, {
554
+ path: ENDPOINTS.organizationRoleUsers,
555
+ query: { ...orgQuery(session), roleId: String(args.roleId), page: "0", size: "500" },
556
+ })).data;
557
+ return Array.isArray(data?.content) ? data.content : wireRows(data);
558
+ };
559
+ const rows = await readback();
560
+ const present = new Set(rows.map(roleUserId));
561
+ const added = users.filter((user) => present.has(user.id)).map((user) => user.id);
562
+ const missing = users.filter((user) => !present.has(user.id)).map((user) => user.id);
563
+ const scopeResults = [];
564
+ if (args.scopes !== undefined && args.scopes.length > 0) {
565
+ const ouScopeList = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type ?? "1", ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
566
+ for (const id of added) {
567
+ await wireCall(session, {
568
+ path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST", query: orgQuery(session),
569
+ body: { roleId: String(args.roleId), userId: id, unitType: 3, sortKey: args.sortKey ?? 999, ouScopeList },
570
+ });
571
+ const scope = wireRows((await wireCall(session, {
572
+ path: ENDPOINTS.organizationRoleGetOuScope,
573
+ query: { ...orgQuery(session), roleId: String(args.roleId), userId: id },
574
+ })).data);
575
+ scopeResults.push({ userId: id, scopes: scope.length });
576
+ }
577
+ }
578
+ return {
579
+ performed: { errcode: 0 },
580
+ readback: {
581
+ roleId: String(args.roleId), added,
582
+ ...(missing.length > 0 ? { missing, hint: "这些 recId 未出现在成员列表:确认 id 是 recId、用户未离职/禁用" } : {}),
583
+ ...(scopeResults.length > 0 ? { scopeResults } : {}),
584
+ // ouScope 是流程角色参与人解析的过滤维度:平台
585
+ // ParticipantService.listManagerIdsByUserIdsAndRoleCode 用
586
+ // `h_org_role_user.ouScope ∩ 发起人部门链`,ouScope 为空 = 该成员
587
+ // 永远解析不到。没传 scopes 时把这条代价显式说出来(r9+r10 两次
588
+ // 真机坐实;不是 corpId 问题)。
589
+ ...(args.scopes === undefined || args.scopes.length === 0
590
+ ? {
591
+ warnings: [
592
+ {
593
+ code: "org-role-member-scope-empty",
594
+ severity: "warning",
595
+ message: "本次未传 scopes:新增成员的 ouScope 为空,**不能作为流程角色参与人被解析**(平台按 ouScope ∩ 发起人部门链过滤,r9/r10 真机坐实)。如果这个角色要用于审批/参与人,请补 scopes(部门节点)或随后调用 org.role.setUserScope;只用于权限分配可忽略。",
596
+ },
597
+ ],
598
+ }
599
+ : {}),
600
+ total: rows.length,
601
+ },
602
+ };
603
+ });
604
+ writeTool(tools, "org.role.removeUsers", LANE, SCOPES.removeUsers, "把成员移出角色(`POST organization/role/strike_out_role_user`)。**平台要求 JSON body `{roleId,userIds:[...]}`**——用 query 或 `ids` 会报 10009 `Required request body is missing`(实测 1.1.25)。只移出角色,不删用户。", {
605
+ roleId: z.string().min(1),
606
+ ids: z.array(z.string().min(1)).min(1).describe("要移出的成员 recId 列表"),
607
+ }, async (session, args) => {
608
+ await wireCall(session, {
609
+ path: ENDPOINTS.organizationRoleRemoveUser, method: "POST", query: orgQuery(session),
610
+ body: { roleId: String(args.roleId), userIds: args.ids },
611
+ });
612
+ const data = (await wireCall(session, {
613
+ path: ENDPOINTS.organizationRoleUsers,
614
+ query: { ...orgQuery(session), roleId: String(args.roleId), page: "0", size: "500" },
615
+ })).data;
616
+ const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
617
+ const present = new Set(rows.map(roleUserId));
618
+ return { performed: { errcode: 0 }, readback: { roleId: String(args.roleId), removed: args.ids.filter((id) => !present.has(id)), stillPresent: args.ids.filter((id) => present.has(id)), total: data?.totalElements ?? rows.length } };
619
+ });
620
+ writeTool(tools, "org.role.setUserScope", LANE, SCOPES.setScope, "设置成员管理范围(`POST organization/role/update_ouscope`)。平台覆盖语义、无回滚:默认读旧值合并(mode:\"merge\"),mode:\"replace\" 才整体替换。unitType 传数字 3(addUsers 里是字符串 \"3\")。节点 {id,name,type:\"1\",deptPath}。", {
621
+ roleId: z.string().min(1),
622
+ userId: z.string().min(1).describe("成员 recId"),
623
+ scopes: z.array(z.object({
624
+ id: z.string().min(1).describe("部门 id"),
625
+ name: z.string().min(1),
626
+ deptPath: z.string().optional().describe("完整路径,如 根组织/广州总所"),
627
+ type: z.string().optional().describe("默认 \"1\"(部门节点)"),
628
+ })).describe("要设置的管理范围节点"),
629
+ mode: z.enum(["merge", "replace"]).optional().describe("默认 merge(读旧值合并);replace = 覆盖整个范围"),
630
+ dryRun: z.boolean().optional(),
631
+ }, async (session, args) => {
632
+ const before = wireRows((await wireCall(session, {
633
+ path: ENDPOINTS.organizationRoleGetOuScope,
634
+ query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
635
+ })).data);
636
+ const wanted = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type ?? "1", ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
637
+ const merged = args.mode === "replace"
638
+ ? wanted
639
+ : [...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]
640
+ .filter((row, index, all) => all.findIndex((candidate) => candidate.id === row.id) === index);
641
+ const removed = before.map((row) => String(row.id ?? "")).filter((id) => id !== "" && !merged.some((row) => row.id === id));
642
+ if (args.mode === "replace" && removed.length > 0)
643
+ // A data-loss write without an explicit acknowledgement is exactly the
644
+ // failure mode the customer hit manually; refuse unless replace was
645
+ // chosen deliberately (mode): the caller already opted in.
646
+ void removed;
647
+ await wireCall(session, {
648
+ path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST",
649
+ query: orgQuery(session),
650
+ body: { roleId: args.roleId, userId: args.userId, unitType: 3, sortKey: 999, ouScopeList: merged },
651
+ });
652
+ const after = wireRows((await wireCall(session, {
653
+ path: ENDPOINTS.organizationRoleGetOuScope,
654
+ query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
655
+ })).data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
656
+ return {
657
+ performed: { errcode: 0 },
658
+ readback: { roleId: args.roleId, userId: args.userId, mode: args.mode ?? "merge", before: before.length, after: after.length, scopes: after, ...(removed.length > 0 ? { removedScopeIds: removed } : {}) },
659
+ };
660
+ });
661
+ }
662
+ //# sourceMappingURL=organization.js.map