@stalfh233/omc-cli 0.4.73 → 0.4.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/coverage/m1-coverage-manifest-v1.json +42 -2
- package/dist/cli.js +8 -2
- package/dist/describe-cache.js +91 -0
- package/dist/endpoints.js +22 -0
- package/dist/lanes/organization-coverage.js +27 -0
- package/dist/lanes/organization.js +628 -0
- package/dist/lanes/rule-graph.js +37 -0
- package/dist/lanes/rule-lifecycle.js +20 -0
- package/dist/lanes/workflow-contract.js +1 -1
- package/dist/lanes/workflow-coverage.js +21 -0
- package/dist/lanes/workflow.js +55 -6
- package/dist/tools.js +2 -0
- package/dist/usage-log.js +62 -0
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +7 -0
- package/skills/omc-capabilities.md +1 -1
|
@@ -339,10 +339,50 @@
|
|
|
339
339
|
"workflow.bindRule",
|
|
340
340
|
"workflow.delete",
|
|
341
341
|
"configure_workflow",
|
|
342
|
-
"workflow.validate"
|
|
342
|
+
"workflow.validate",
|
|
343
|
+
"org.role.users",
|
|
344
|
+
"org.role.userScope",
|
|
345
|
+
"org.roleGroup.create",
|
|
346
|
+
"org.role.create",
|
|
347
|
+
"org.role.update",
|
|
348
|
+
"org.role.addUsers",
|
|
349
|
+
"org.role.removeUsers",
|
|
350
|
+
"org.role.setUserScope",
|
|
351
|
+
"org.role.delete",
|
|
352
|
+
"org.roleGroup.delete",
|
|
353
|
+
"org.user.transferDepartment",
|
|
354
|
+
"org.department.create",
|
|
355
|
+
"org.department.update",
|
|
356
|
+
"org.department.delete",
|
|
357
|
+
"org.department.users",
|
|
358
|
+
"org.user.create",
|
|
359
|
+
"org.user.update",
|
|
360
|
+
"org.user.delete",
|
|
361
|
+
"org.user.info",
|
|
362
|
+
"org.user.departments"
|
|
343
363
|
],
|
|
344
364
|
"scopes": [
|
|
345
|
-
"greenfield workflow authoring"
|
|
365
|
+
"greenfield workflow authoring",
|
|
366
|
+
"organization role member read (role/users)",
|
|
367
|
+
"organization role member management scope read (role/get_ouscope)",
|
|
368
|
+
"organization role group create (role/add_role_group)",
|
|
369
|
+
"organization role create (role/add_role)",
|
|
370
|
+
"organization role update (role/modify_role)",
|
|
371
|
+
"organization role member add (role/add_role_user)",
|
|
372
|
+
"organization role member remove (role/strike_out_role_user)",
|
|
373
|
+
"organization role member scope update (role/update_ouscope)",
|
|
374
|
+
"organization role delete (role/strike_out_role)",
|
|
375
|
+
"organization role group delete (role/strike_out_role_group)",
|
|
376
|
+
"organization user department transfer (user/dept_transfer)",
|
|
377
|
+
"organization department create (department/add)",
|
|
378
|
+
"organization department update (department/modefied)",
|
|
379
|
+
"organization department delete (department/strike_out)",
|
|
380
|
+
"organization department member read (department/list_user)",
|
|
381
|
+
"organization user create (user/add)",
|
|
382
|
+
"organization user update (user/modify)",
|
|
383
|
+
"organization user delete (user/strike_out)",
|
|
384
|
+
"organization user read (user/info)",
|
|
385
|
+
"organization user department read (user/departments)"
|
|
346
386
|
]
|
|
347
387
|
},
|
|
348
388
|
{
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { cmdFormDefault } from "./form-default.js";
|
|
|
28
28
|
if (process.argv.includes("--utf8"))
|
|
29
29
|
process.env.OMC_ASCII_STDOUT = "0";
|
|
30
30
|
import { commandIndex, commandInventory, describeContract, describeContractFull, describeContractTool, describeExample, describeParam, describeSchemaOnly, describeTool, describeToolFull } from "./discovery.js";
|
|
31
|
+
import { repeatFullDescribe } from "./describe-cache.js";
|
|
31
32
|
import { buildCapabilityOverview, capabilityDetail, compactCapabilities, executableCapabilityBundle, resolveCapabilityId, DISCOVERY_AUTHORITY } from "./capability-overview.js";
|
|
32
33
|
import { goalRoute } from "./goal-routes.js";
|
|
33
34
|
import { absencePointer } from "./capability-absences.js";
|
|
@@ -630,8 +631,13 @@ function cmdDiscovery(kind, transport, value, options = {}) {
|
|
|
630
631
|
result = describeParam(registry, value ?? "", options.param);
|
|
631
632
|
else if (options.example !== undefined)
|
|
632
633
|
result = describeExample(registry, value ?? "", typeof options.example === "string" ? options.example : undefined);
|
|
633
|
-
else if (options.full)
|
|
634
|
-
|
|
634
|
+
else if (options.full) {
|
|
635
|
+
const full = describeToolFull(registry, value ?? "");
|
|
636
|
+
// r9: the same `--full` contract was re-shipped 8 times (134 KB) while the
|
|
637
|
+
// caller only wanted to re-check a shape. Within a project session the
|
|
638
|
+
// second identical read returns an artifact reference instead of the body.
|
|
639
|
+
result = full ? (repeatFullDescribe(value ?? "", full) ?? full) : full;
|
|
640
|
+
}
|
|
635
641
|
else
|
|
636
642
|
result = describeTool(registry, value ?? "");
|
|
637
643
|
if (!result) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { artifactRoot, joinConfigRoot } from "./command-transport.js";
|
|
5
|
+
import { createArtifact, readArtifact } from "./artifacts.js";
|
|
6
|
+
import { projectSessionActive } from "./usage-log.js";
|
|
7
|
+
/**
|
|
8
|
+
* Repeat-read suppression for `describe <tool> --full`.
|
|
9
|
+
*
|
|
10
|
+
* r9 (2026-10-22) spent 134 KB on EIGHT `describe rule.save --full` calls: the
|
|
11
|
+
* same contract, unchanged, re-shipped every time the caller re-checked a shape.
|
|
12
|
+
* The hint at the bottom of the response ("先试 --example/--param") did not help
|
|
13
|
+
* because a caller editing a graph wants the authoritative schema again, and
|
|
14
|
+
* `--full` had no memory.
|
|
15
|
+
*
|
|
16
|
+
* The first `--full` in a project still returns the whole contract and spills a
|
|
17
|
+
* copy to an artifact. A later identical read returns `{artifactId, sha256,
|
|
18
|
+
* bytes, fetch}` instead: the caller can project the one key it needs with
|
|
19
|
+
* `omc artifact get <id> --jq <path>` instead of paying for the whole body
|
|
20
|
+
* again. Identity is the CONTENT hash, so a changed contract (new version, new
|
|
21
|
+
* field) always returns the full body again.
|
|
22
|
+
*
|
|
23
|
+
* Scoped to a project session by construction: the cache only exists where the
|
|
24
|
+
* usage log does (`.omc-env` in the working directory, or OMC_USAGE_LOG). Repo
|
|
25
|
+
* tests and the golden master run without it, so their bytes stay stable.
|
|
26
|
+
*/
|
|
27
|
+
const CACHE_FILE = "describe-full-cache.json";
|
|
28
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
29
|
+
function cachePath() {
|
|
30
|
+
return resolve(joinConfigRoot(), ".omc", CACHE_FILE);
|
|
31
|
+
}
|
|
32
|
+
function readCache() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
35
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeCache(cache) {
|
|
42
|
+
try {
|
|
43
|
+
const path = cachePath();
|
|
44
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
45
|
+
writeFileSync(path, `${JSON.stringify(cache)}\n`, { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Best-effort: a cache write must never fail the command.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Return the compact repeat-read face for `tool`, or `undefined` when this is
|
|
53
|
+
* the first read (or the contract changed). Records the new entry as a side
|
|
54
|
+
* effect so the NEXT read is compact.
|
|
55
|
+
*/
|
|
56
|
+
export function repeatFullDescribe(tool, result) {
|
|
57
|
+
// No project session (repo tests, golden master, one-off runs) ⇒ no cache:
|
|
58
|
+
// those callers need byte-stable output, and a cache write would be litter.
|
|
59
|
+
if (!projectSessionActive())
|
|
60
|
+
return undefined;
|
|
61
|
+
const body = `${JSON.stringify(result, null, 2)}\n`;
|
|
62
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
63
|
+
const digest = createHash("sha256").update(body).digest("hex");
|
|
64
|
+
const cache = readCache();
|
|
65
|
+
const previous = cache[tool];
|
|
66
|
+
if (previous && previous.sha256 === digest && Date.now() - previous.at < CACHE_TTL_MS) {
|
|
67
|
+
// A pruned artifact would turn the summary into a dead end; re-ship instead.
|
|
68
|
+
if (existsSync(join(artifactRoot(), `${previous.artifactId}.artifact`))) {
|
|
69
|
+
try {
|
|
70
|
+
readArtifact(artifactRoot(), previous.artifactId);
|
|
71
|
+
return {
|
|
72
|
+
name: tool,
|
|
73
|
+
repeated: true,
|
|
74
|
+
artifactId: previous.artifactId,
|
|
75
|
+
sha256: digest,
|
|
76
|
+
bytes,
|
|
77
|
+
fetch: `omc artifact get ${previous.artifactId} --summary(再 --keys/--jq 取需要的键)`,
|
|
78
|
+
note: `本项目的会话里已读过同一个契约(${bytes}B,内容 sha256 未变),本次不再重发全文;用 fetch 里的命令按需投影`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// fall through to a full re-ship
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const artifact = createArtifact(artifactRoot(), body);
|
|
87
|
+
cache[tool] = { tool, sha256: digest, bytes, artifactId: artifact.artifactId, at: Date.now() };
|
|
88
|
+
writeCache(cache);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=describe-cache.js.map
|
package/dist/endpoints.js
CHANGED
|
@@ -120,6 +120,28 @@ export const ENDPOINTS = {
|
|
|
120
120
|
organizationRoleList: "/api/api/organization/role/list",
|
|
121
121
|
organizationDepartmentTree: "/api/api/organization/department/tree",
|
|
122
122
|
organizationUserSearch: "/api/api/organization/user/search",
|
|
123
|
+
organizationRoleUsers: "/api/api/organization/role/users",
|
|
124
|
+
organizationRoleGetOuScope: "/api/api/organization/role/get_ouscope",
|
|
125
|
+
organizationRoleUpdateOuScope: "/api/api/organization/role/update_ouscope",
|
|
126
|
+
organizationRoleAddGroup: "/api/api/organization/role/add_role_group",
|
|
127
|
+
organizationRoleAdd: "/api/api/organization/role/add_role",
|
|
128
|
+
organizationRoleModify: "/api/api/organization/role/modify_role",
|
|
129
|
+
organizationRoleGet: "/api/api/organization/role/get",
|
|
130
|
+
organizationDepartmentAdd: "/api/api/organization/department/add",
|
|
131
|
+
organizationDepartmentModify: "/api/api/organization/department/modefied",
|
|
132
|
+
organizationDepartmentDelete: "/api/api/organization/department/strike_out",
|
|
133
|
+
organizationDepartmentGet: "/api/api/organization/department/get_dept_info",
|
|
134
|
+
organizationDepartmentUsers: "/api/api/organization/department/list_user",
|
|
135
|
+
organizationUserAdd: "/api/api/organization/user/add",
|
|
136
|
+
organizationUserModify: "/api/api/organization/user/modify",
|
|
137
|
+
organizationUserDelete: "/api/api/organization/user/strike_out",
|
|
138
|
+
organizationUserInfo: "/api/api/organization/user/info",
|
|
139
|
+
organizationUserDepartments: "/api/api/organization/user/departments",
|
|
140
|
+
organizationRoleAddUser: "/api/api/organization/role/add_role_user",
|
|
141
|
+
organizationRoleRemoveUser: "/api/api/organization/role/strike_out_role_user",
|
|
142
|
+
organizationRoleDelete: "/api/api/organization/role/strike_out_role",
|
|
143
|
+
organizationRoleGroupDelete: "/api/api/organization/role/strike_out_role_group",
|
|
144
|
+
organizationUserDeptTransfer: "/api/api/organization/user/dept_transfer",
|
|
123
145
|
listQueryCreate: "/api/api/app/query/create",
|
|
124
146
|
listQueryGet: "/api/api/app/query/get",
|
|
125
147
|
listQueryUpdate: "/api/api/app/query/update",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { claim, declareLaneCoverage } from "./coverage-declaration.js";
|
|
2
|
+
import { LANES } from "./lane-ids.js";
|
|
3
|
+
/**
|
|
4
|
+
* Organization writes (role group / role / members / management scope) sit on
|
|
5
|
+
* the same live-proven template lane; the scopes below are claimed here so the
|
|
6
|
+
* runtime gate can resolve them without a second manifest item.
|
|
7
|
+
*/
|
|
8
|
+
export const ORGANIZATION_COVERAGE = declareLaneCoverage(LANES.workflow, [
|
|
9
|
+
{
|
|
10
|
+
capability: "W7 S29 greenfield workflow authoring",
|
|
11
|
+
evidence: [
|
|
12
|
+
"packages/omc-cli/src/lanes/organization.ts",
|
|
13
|
+
"OpenAPI /api/v3/api-docs (1.1.25): organization/role/add_role, add_role_user, update_ouscope",
|
|
14
|
+
],
|
|
15
|
+
tools: [
|
|
16
|
+
claim("org.role.users", "organization role member read (role/users)"),
|
|
17
|
+
claim("org.role.userScope", "organization role member management scope read (role/get_ouscope)"),
|
|
18
|
+
claim("org.roleGroup.create", "organization role group create (role/add_role_group)"),
|
|
19
|
+
claim("org.role.create", "organization role create (role/add_role)"),
|
|
20
|
+
claim("org.role.update", "organization role update (role/modify_role)"),
|
|
21
|
+
claim("org.role.addUsers", "organization role member add (role/add_role_user)"),
|
|
22
|
+
claim("org.role.removeUsers", "organization role member remove (role/strike_out_role_user)"),
|
|
23
|
+
claim("org.role.setUserScope", "organization role member scope update (role/update_ouscope)"),
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
//# sourceMappingURL=organization-coverage.js.map
|
|
@@ -0,0 +1,628 @@
|
|
|
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`)。parentId 传上级部门 id(根部门可省);deptType 传名字或 0..3(平台按 int 枚举反序列化,写 \"dept\" 会 10009)。返回 deptId。", {
|
|
237
|
+
name: z.string().min(1).describe("部门名称"),
|
|
238
|
+
parentId: z.string().min(1).optional().describe("上级部门 id(省略=建在根下)"),
|
|
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
|
+
const data = (await wireCall(session, {
|
|
246
|
+
path: ENDPOINTS.organizationDepartmentAdd, method: "POST", query: orgQuery(session),
|
|
247
|
+
body: {
|
|
248
|
+
name: args.name, corpId: session.enterpriseId(), unitType: "1", enabled: true, deptType: deptTypeCode(args.deptType),
|
|
249
|
+
...(args.parentId ? { parentId: args.parentId } : {}),
|
|
250
|
+
...(args.code ? { code: args.code } : {}),
|
|
251
|
+
...(args.managerId ? { managerId: args.managerId } : {}),
|
|
252
|
+
...(args.sortKey !== undefined ? { sortKey: args.sortKey } : {}),
|
|
253
|
+
...(args.remarks ? { remarks: args.remarks } : {}),
|
|
254
|
+
},
|
|
255
|
+
})).data;
|
|
256
|
+
const deptId = String(data?.id ?? "");
|
|
257
|
+
if (!deptId)
|
|
258
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回部门 id;先 org.department.list 回读确认" });
|
|
259
|
+
return { performed: { errcode: 0 }, readback: { deptId, name: data?.name ?? args.name, parentId: data?.parentId ?? args.parentId ?? null, code: data?.code ?? null } };
|
|
260
|
+
});
|
|
261
|
+
writeTool(tools, "org.department.update", LANE, SCOPES.deptUpdate, "改部门(`POST organization/department/modefied`)。先 get_dept_info 读现状再合并提交,只传要改的字段(名称/上级/主管/排序/备注/启用)。", {
|
|
262
|
+
deptId: z.string().min(1),
|
|
263
|
+
name: z.string().optional(),
|
|
264
|
+
parentId: z.string().optional(),
|
|
265
|
+
managerId: z.string().optional(),
|
|
266
|
+
sortKey: z.number().int().optional(),
|
|
267
|
+
remarks: z.string().optional(),
|
|
268
|
+
enabled: z.boolean().optional(),
|
|
269
|
+
}, async (session, args) => {
|
|
270
|
+
const current = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
|
|
271
|
+
if (!current)
|
|
272
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `get_dept_info 未返回部门 ${args.deptId}` });
|
|
273
|
+
const body = {
|
|
274
|
+
...current,
|
|
275
|
+
...(args.name !== undefined ? { name: args.name } : {}),
|
|
276
|
+
...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
|
|
277
|
+
...(args.managerId !== undefined ? { managerId: args.managerId } : {}),
|
|
278
|
+
...(args.sortKey !== undefined ? { sortKey: args.sortKey } : {}),
|
|
279
|
+
...(args.remarks !== undefined ? { remarks: args.remarks } : {}),
|
|
280
|
+
...(args.enabled !== undefined ? { enabled: args.enabled } : {}),
|
|
281
|
+
};
|
|
282
|
+
await wireCall(session, { path: ENDPOINTS.organizationDepartmentModify, method: "POST", query: orgQuery(session), body });
|
|
283
|
+
const after = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
|
|
284
|
+
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, name: after?.name ?? null, parentId: after?.parentId ?? null, managerId: after?.managerId ?? null, enabled: after?.enabled ?? null } };
|
|
285
|
+
});
|
|
286
|
+
writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", { deptId: z.string().min(1) }, async (session, args) => {
|
|
287
|
+
const users = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: args.deptId } })).data);
|
|
288
|
+
await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: args.deptId } });
|
|
289
|
+
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, deleted: true, usersBeforeDelete: users.length } };
|
|
290
|
+
});
|
|
291
|
+
tools.push(defineTool({
|
|
292
|
+
name: "org.department.users",
|
|
293
|
+
layer: "low", kind: "read", lane: LANE, scope: SCOPES.deptUsers,
|
|
294
|
+
description: "列出部门下的员工(`GET organization/department/list_user?deptId=`)。返回 members[{id(recId),name,username,mobile}]。",
|
|
295
|
+
inputSchema: { environment: environmentArg, deptId: z.string().min(1), capability: capabilityArg },
|
|
296
|
+
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
297
|
+
const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: String(args.deptId) } })).data;
|
|
298
|
+
const members = wireRows(data).map((row) => ({ id: row.id ?? row.userId ?? null, name: row.name ?? null, username: row.username ?? null, mobile: row.mobile ?? null }));
|
|
299
|
+
return { deptId: String(args.deptId), count: members.length, members };
|
|
300
|
+
}),
|
|
301
|
+
}));
|
|
302
|
+
// ── employee ──────────────────────────────────────────────────────────────
|
|
303
|
+
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);初始密码由平台默认策略生成。", {
|
|
304
|
+
name: z.string().min(1),
|
|
305
|
+
username: z.string().min(1).describe("登录账号"),
|
|
306
|
+
departmentId: z.string().min(1).describe("所属部门 id(必须先存在,否则 201011「用户的部门不存在!」)"),
|
|
307
|
+
mobile: z.string().optional(),
|
|
308
|
+
email: z.string().optional(),
|
|
309
|
+
employeeNo: z.string().optional(),
|
|
310
|
+
gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
|
|
311
|
+
roleIds: z.array(z.string()).optional().describe("同时授予的角色 id"),
|
|
312
|
+
partTimeDeptIds: z.array(z.string()).optional().describe("兼职部门 id(主部门仍是 departmentId)"),
|
|
313
|
+
deptIds: z.array(z.string()).optional().describe("`partTimeDeptIds` 的别名"),
|
|
314
|
+
userLabelIds: z.array(z.string()).optional(),
|
|
315
|
+
active: z.boolean().optional(),
|
|
316
|
+
status: z.enum(["ENABLE", "DISABLE", "LOCK"]).optional(),
|
|
317
|
+
}, async (session, args) => {
|
|
318
|
+
const data = (await wireCall(session, {
|
|
319
|
+
path: ENDPOINTS.organizationUserAdd, method: "POST", query: orgQuery(session),
|
|
320
|
+
body: {
|
|
321
|
+
name: args.name, username: args.username, departmentId: args.departmentId, currentDeptId: args.departmentId,
|
|
322
|
+
corpId: session.enterpriseId(), unitType: "3", enabled: true, active: args.active ?? true,
|
|
323
|
+
status: args.status ?? "ENABLE",
|
|
324
|
+
userLabelIds: args.userLabelIds ?? [], userManagerConfigList: [], roleIds: args.roleIds ?? [], deptIds: args.partTimeDeptIds ?? args.deptIds ?? [],
|
|
325
|
+
...(args.mobile ? { mobile: args.mobile } : {}),
|
|
326
|
+
...(args.email ? { email: args.email } : {}),
|
|
327
|
+
...(args.employeeNo ? { employeeNo: args.employeeNo } : {}),
|
|
328
|
+
...(args.gender ? { gender: args.gender } : {}),
|
|
329
|
+
},
|
|
330
|
+
})).data;
|
|
331
|
+
const userId = String(data?.id ?? "");
|
|
332
|
+
if (!userId)
|
|
333
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回用户 id;先 org.user.search 回读确认" });
|
|
334
|
+
return { performed: { errcode: 0 }, readback: { userId, name: data?.name ?? args.name, username: data?.username ?? args.username, departmentId: data?.departmentId ?? args.departmentId } };
|
|
335
|
+
});
|
|
336
|
+
writeTool(tools, "org.user.update", LANE, SCOPES.userUpdate, "改员工(`POST organization/user/modify`)。先 user/info 读现状再合并;工具强制补 `deptIds:[]` 等四个数组——平台 modified 对 null 的 deptIds 直接 NPE(50000,真机 1.1.25)。可改姓名/部门/手机/邮箱/角色/启停。", {
|
|
337
|
+
userId: z.string().min(1),
|
|
338
|
+
name: z.string().optional(),
|
|
339
|
+
departmentId: z.string().optional(),
|
|
340
|
+
mobile: z.string().optional(),
|
|
341
|
+
email: z.string().optional(),
|
|
342
|
+
employeeNo: z.string().optional(),
|
|
343
|
+
gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
|
|
344
|
+
roleIds: z.array(z.string()).optional(),
|
|
345
|
+
partTimeDeptIds: z.array(z.string()).optional().describe("兼职部门 id;**覆盖语义**(传 [] 清空兼职,主部门不变)"),
|
|
346
|
+
active: z.boolean().optional(),
|
|
347
|
+
status: z.enum(["ENABLE", "DISABLE", "LOCK"]).optional(),
|
|
348
|
+
}, async (session, args) => {
|
|
349
|
+
const current = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: args.userId } })).data;
|
|
350
|
+
if (!current)
|
|
351
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `user/info 未返回用户 ${args.userId}` });
|
|
352
|
+
const body = {
|
|
353
|
+
...current,
|
|
354
|
+
id: args.userId,
|
|
355
|
+
...(args.name !== undefined ? { name: args.name } : {}),
|
|
356
|
+
...(args.departmentId !== undefined ? { departmentId: args.departmentId, currentDeptId: args.departmentId } : {}),
|
|
357
|
+
...(args.mobile !== undefined ? { mobile: args.mobile } : {}),
|
|
358
|
+
...(args.email !== undefined ? { email: args.email } : {}),
|
|
359
|
+
...(args.employeeNo !== undefined ? { employeeNo: args.employeeNo } : {}),
|
|
360
|
+
...(args.gender !== undefined ? { gender: args.gender } : {}),
|
|
361
|
+
...(args.active !== undefined ? { active: args.active } : {}),
|
|
362
|
+
...(args.status !== undefined ? { status: args.status } : {}),
|
|
363
|
+
deptIds: args.partTimeDeptIds ?? (Array.isArray(current.deptIds) ? current.deptIds : []),
|
|
364
|
+
userLabelIds: Array.isArray(current.userLabelIds) ? current.userLabelIds : [],
|
|
365
|
+
userManagerConfigList: Array.isArray(current.userManagerConfigList) ? current.userManagerConfigList : [],
|
|
366
|
+
roleIds: args.roleIds ?? (Array.isArray(current.roleIds) ? current.roleIds : []),
|
|
367
|
+
};
|
|
368
|
+
await wireCall(session, { path: ENDPOINTS.organizationUserModify, method: "POST", query: orgQuery(session), body });
|
|
369
|
+
const after = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: args.userId } })).data;
|
|
370
|
+
const departments = wireRows((await wireCall(session, {
|
|
371
|
+
path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) },
|
|
372
|
+
})).data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
|
|
373
|
+
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 } };
|
|
374
|
+
});
|
|
375
|
+
writeTool(tools, "org.user.delete", LANE, SCOPES.userDelete, "删除员工(`GET organization/user/strike_out?id=`)。只传用户 id;返回 deleted。", { userId: z.string().min(1) }, async (session, args) => {
|
|
376
|
+
await wireCall(session, { path: ENDPOINTS.organizationUserDelete, query: { ...orgQuery(session), id: args.userId } });
|
|
377
|
+
return { performed: { errcode: 0 }, readback: { userId: args.userId, deleted: true } };
|
|
378
|
+
});
|
|
379
|
+
tools.push(defineTool({
|
|
380
|
+
name: "org.user.info",
|
|
381
|
+
layer: "low", kind: "read", lane: LANE, scope: SCOPES.userInfo,
|
|
382
|
+
description: "读员工详情(`GET organization/user/info?id=`)。返回姓名/账号/部门/手机/邮箱/状态/角色 id 等。",
|
|
383
|
+
inputSchema: { environment: environmentArg, userId: z.string().min(1), capability: capabilityArg },
|
|
384
|
+
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
385
|
+
let data = null;
|
|
386
|
+
try {
|
|
387
|
+
data = (await wireCall(session, { path: ENDPOINTS.organizationUserInfo, query: { ...orgQuery(session), id: String(args.userId) } })).data;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// A deleted/unknown user answers 10011 (业务模型不存在-style business
|
|
391
|
+
// error) rather than an empty body; "read a user" should not turn an
|
|
392
|
+
// absence into a refusal.
|
|
393
|
+
if (!(error instanceof WireError) || error.errcode !== 10011)
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
if (!data)
|
|
397
|
+
return { userId: String(args.userId), exists: false };
|
|
398
|
+
return {
|
|
399
|
+
userId: String(args.userId), exists: true,
|
|
400
|
+
name: data.name ?? null, username: data.username ?? null, departmentId: data.departmentId ?? null,
|
|
401
|
+
mobile: data.mobile ?? null, email: data.email ?? null, active: data.active ?? null,
|
|
402
|
+
// `status` is reserved by the tool envelope (ok/refused/…): a user's
|
|
403
|
+
// ENABLE/DISABLE value under that key made the read look like a failed
|
|
404
|
+
// tool call (live 2026-10-22).
|
|
405
|
+
userStatus: data.status ?? null,
|
|
406
|
+
roleIds: Array.isArray(data.roleIds) ? data.roleIds : [],
|
|
407
|
+
};
|
|
408
|
+
}),
|
|
409
|
+
}));
|
|
410
|
+
tools.push(defineTool({
|
|
411
|
+
name: "org.user.departments",
|
|
412
|
+
layer: "low", kind: "read", lane: LANE, scope: SCOPES.userDepartments,
|
|
413
|
+
description: "读员工所属部门(含兼职,`GET organization/user/departments?id=`)。返回 departments[{deptId,deptName,isMain}]。",
|
|
414
|
+
inputSchema: { environment: environmentArg, userId: z.string().min(1), capability: capabilityArg },
|
|
415
|
+
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
416
|
+
const data = (await wireCall(session, { path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) } })).data;
|
|
417
|
+
const departments = wireRows(data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
|
|
418
|
+
return { userId: String(args.userId), count: departments.length, departments };
|
|
419
|
+
}),
|
|
420
|
+
}));
|
|
421
|
+
writeTool(tools, "org.role.delete", LANE, SCOPES.roleDelete, "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员数、非空即拒绝并给出人数;`force:true` 才会先逐人移出再删。", {
|
|
422
|
+
roleId: z.string().min(1),
|
|
423
|
+
force: z.boolean().optional().describe("true = 先移出全部成员再删角色"),
|
|
424
|
+
}, async (session, args) => {
|
|
425
|
+
const rows = await memberRows(session, String(args.roleId));
|
|
426
|
+
if (rows.length > 0 && args.force !== true)
|
|
427
|
+
throw new ToolRefusalError({
|
|
428
|
+
status: "refused", blocker: "role-has-members", roleId: String(args.roleId), members: rows.length,
|
|
429
|
+
message: `角色下还有 ${rows.length} 名成员,平台拒绝删除(errmsg:角色下已经包含用户数据)。先 org.role.removeUsers,或传 force:true 让工具移出后再删。`,
|
|
430
|
+
});
|
|
431
|
+
if (rows.length > 0)
|
|
432
|
+
await removeMembers(session, String(args.roleId), rows.map(roleUserId));
|
|
433
|
+
await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: String(args.roleId) } });
|
|
434
|
+
return { performed: { errcode: 0 }, readback: { roleId: String(args.roleId), deleted: true, removedMembers: rows.length } };
|
|
435
|
+
});
|
|
436
|
+
writeTool(tools, "org.roleGroup.delete", LANE, SCOPES.roleGroupDelete, "删除角色组(`GET organization/role/strike_out_role_group?ids=`)。平台**拒绝删除仍含角色的组**(`-1 角色组下已经包含角色数据!`):默认先数角色、非空即拒绝;`force:true` 会逐个删角色(含其成员)再删组。", {
|
|
437
|
+
roleGroupId: z.string().min(1),
|
|
438
|
+
force: z.boolean().optional(),
|
|
439
|
+
}, async (session, args) => {
|
|
440
|
+
const roles = await rolesInGroup(session, String(args.roleGroupId));
|
|
441
|
+
if (roles.length > 0 && args.force !== true)
|
|
442
|
+
throw new ToolRefusalError({
|
|
443
|
+
status: "refused", blocker: "role-group-has-roles", roleGroupId: String(args.roleGroupId), roles: roles.length,
|
|
444
|
+
message: `角色组下还有 ${roles.length} 个角色,平台拒绝删除(errmsg:角色组下已经包含角色数据)。先删角色,或传 force:true。`,
|
|
445
|
+
});
|
|
446
|
+
for (const role of roles) {
|
|
447
|
+
const members = await memberRows(session, role.id);
|
|
448
|
+
if (members.length > 0)
|
|
449
|
+
await removeMembers(session, role.id, members.map(roleUserId));
|
|
450
|
+
await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: role.id } });
|
|
451
|
+
}
|
|
452
|
+
await wireCall(session, { path: ENDPOINTS.organizationRoleGroupDelete, query: { ...orgQuery(session), ids: String(args.roleGroupId) } });
|
|
453
|
+
return { performed: { errcode: 0 }, readback: { roleGroupId: String(args.roleGroupId), deleted: true, removedRoles: roles.map((role) => role.id) } };
|
|
454
|
+
});
|
|
455
|
+
writeTool(tools, "org.user.transferDepartment", LANE, SCOPES.userTransfer, "把员工从一个部门移到另一个部门(`POST organization/user/dept_transfer`,body `{userIds,sourceDept,targetDept}`)。sourceDept 必填(缺它 50000);移动后原部门不再包含该员工,主部门保持。", {
|
|
456
|
+
userId: z.string().min(1),
|
|
457
|
+
fromDeptId: z.string().min(1).describe("原部门 id(必填)"),
|
|
458
|
+
toDeptId: z.string().min(1).describe("目标部门 id"),
|
|
459
|
+
}, async (session, args) => {
|
|
460
|
+
await wireCall(session, {
|
|
461
|
+
path: ENDPOINTS.organizationUserDeptTransfer, method: "POST", query: orgQuery(session),
|
|
462
|
+
body: { userIds: [String(args.userId)], sourceDept: String(args.fromDeptId), targetDept: String(args.toDeptId) },
|
|
463
|
+
});
|
|
464
|
+
const departments = wireRows((await wireCall(session, {
|
|
465
|
+
path: ENDPOINTS.organizationUserDepartments, query: { ...orgQuery(session), id: String(args.userId) },
|
|
466
|
+
})).data).map((row) => ({ deptId: row.deptId ?? null, deptName: row.deptName ?? null, isMain: row.isMain ?? null }));
|
|
467
|
+
return { performed: { errcode: 0 }, readback: { userId: String(args.userId), fromDeptId: String(args.fromDeptId), toDeptId: String(args.toDeptId), departments } };
|
|
468
|
+
});
|
|
469
|
+
writeTool(tools, "org.role.update", LANE, SCOPES.roleUpdate, "改角色名称/编码/备注/所属角色组(`POST organization/role/modify_role`)。先读现状再提交,只传要改的字段。**平台要求 roleGroupId 必填**(缺则 202012「角色组主键Id不能为空」),工具默认回填当前组;**corpId 不可改**(RoleHelper.update 不读该字段),要换企业只能在目标企业下重建角色。", {
|
|
470
|
+
roleId: z.string().min(1),
|
|
471
|
+
roleName: z.string().optional(),
|
|
472
|
+
roleGroupId: z.string().min(1).optional().describe("移到别的角色组(默认保持当前组)。平台 update 必填该字段,且目标组必须存在、roleType=SYS"),
|
|
473
|
+
code: z.string().optional(),
|
|
474
|
+
remarks: z.string().optional(),
|
|
475
|
+
}, async (session, args) => {
|
|
476
|
+
const current = (await wireCall(session, { path: ENDPOINTS.organizationRoleGet, query: { ...orgQuery(session), roleId: String(args.roleId) } })).data;
|
|
477
|
+
if (!current)
|
|
478
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `role/get 未返回角色 ${String(args.roleId)};不要盲目 modify` });
|
|
479
|
+
// RoleHelper.update (1.1.25) validates roleName AND roleGroupId as
|
|
480
|
+
// REQUIRED before touching the row (validateRoleParam): a delta that omits
|
|
481
|
+
// either answers 202001 「角色名称不能为空」 / 202012 「角色组主键Id不能为空」.
|
|
482
|
+
// Both are backfilled from role/get, so a remarks-only edit works.
|
|
483
|
+
const roleName = String(args.roleName ?? current.roleName ?? current.name ?? "");
|
|
484
|
+
if (roleName === "")
|
|
485
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `角色 ${String(args.roleId)} 读不到 roleName;平台 modify_role 要求该字段,请显式传 roleName` });
|
|
486
|
+
const roleGroupId = String(args.roleGroupId ?? current.roleGroupId ?? current.groupId ?? "");
|
|
487
|
+
if (roleGroupId === "")
|
|
488
|
+
throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: `角色 ${String(args.roleId)} 读不到 roleGroupId;平台 modify_role 要求该字段,请显式传 roleGroupId` });
|
|
489
|
+
const body = {
|
|
490
|
+
...current,
|
|
491
|
+
roleId: String(args.roleId),
|
|
492
|
+
roleName,
|
|
493
|
+
roleGroupId,
|
|
494
|
+
...(args.code !== undefined ? { code: args.code } : {}),
|
|
495
|
+
...(args.remarks !== undefined ? { remarks: args.remarks } : {}),
|
|
496
|
+
corpId: current.corpId ?? "other",
|
|
497
|
+
roleType: current.roleType ?? "SYS",
|
|
498
|
+
};
|
|
499
|
+
await wireCall(session, { path: ENDPOINTS.organizationRoleModify, method: "POST", query: orgQuery(session), body });
|
|
500
|
+
const after = (await wireCall(session, { path: ENDPOINTS.organizationRoleGet, query: { ...orgQuery(session), roleId: String(args.roleId) } })).data;
|
|
501
|
+
// role/get answers RoleModel (name/groupId), not RoleVO (roleName/
|
|
502
|
+
// roleGroupId) — read both spellings so the readback is never null.
|
|
503
|
+
return {
|
|
504
|
+
performed: { errcode: 0 },
|
|
505
|
+
readback: {
|
|
506
|
+
roleId: String(args.roleId),
|
|
507
|
+
roleName: after?.roleName ?? after?.name ?? null,
|
|
508
|
+
roleGroupId: after?.roleGroupId ?? after?.groupId ?? null,
|
|
509
|
+
code: after?.code ?? null,
|
|
510
|
+
remarks: after?.remarks ?? null,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
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,回读仍为空),所以范围必须单独写——工具内部已按顺序完成。加完回读成员与范围。", {
|
|
515
|
+
roleId: z.string().min(1),
|
|
516
|
+
users: z.array(z.object({ id: z.string().min(1).describe("用户记录 id(recId)"), name: z.string().min(1) })).min(1),
|
|
517
|
+
scopes: z.array(z.object({
|
|
518
|
+
id: z.string().min(1).describe("部门 id"),
|
|
519
|
+
name: z.string().min(1),
|
|
520
|
+
deptPath: z.string().optional(),
|
|
521
|
+
type: z.string().optional().describe("默认 \"1\"(部门节点)"),
|
|
522
|
+
})).optional().describe("应用到本次新增的每个成员的管理范围;省略则不设(成员 ouScope 为空)"),
|
|
523
|
+
sortKey: z.number().int().optional(),
|
|
524
|
+
userIds: z.array(z.object({ id: z.string().min(1), name: z.string().min(1) })).optional().describe("`users` 的别名"),
|
|
525
|
+
}, async (session, args) => {
|
|
526
|
+
const users = args.users ?? args.userIds ?? [];
|
|
527
|
+
if (users.length === 0)
|
|
528
|
+
throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.role.addUsers", message: "需要 users(或 userIds)至少一人" });
|
|
529
|
+
await wireCall(session, {
|
|
530
|
+
path: ENDPOINTS.organizationRoleAddUser, method: "POST",
|
|
531
|
+
query: orgQuery(session),
|
|
532
|
+
body: { roleId: args.roleId, sortKey: args.sortKey ?? 999, userIds: users.map((user) => ({ id: user.id, name: user.name, type: 3, unitType: "3" })) },
|
|
533
|
+
});
|
|
534
|
+
const readback = async () => {
|
|
535
|
+
const data = (await wireCall(session, {
|
|
536
|
+
path: ENDPOINTS.organizationRoleUsers,
|
|
537
|
+
query: { ...orgQuery(session), roleId: String(args.roleId), page: "0", size: "500" },
|
|
538
|
+
})).data;
|
|
539
|
+
return Array.isArray(data?.content) ? data.content : wireRows(data);
|
|
540
|
+
};
|
|
541
|
+
const rows = await readback();
|
|
542
|
+
const present = new Set(rows.map(roleUserId));
|
|
543
|
+
const added = users.filter((user) => present.has(user.id)).map((user) => user.id);
|
|
544
|
+
const missing = users.filter((user) => !present.has(user.id)).map((user) => user.id);
|
|
545
|
+
const scopeResults = [];
|
|
546
|
+
if (args.scopes !== undefined && args.scopes.length > 0) {
|
|
547
|
+
const ouScopeList = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type ?? "1", ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
|
|
548
|
+
for (const id of added) {
|
|
549
|
+
await wireCall(session, {
|
|
550
|
+
path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST", query: orgQuery(session),
|
|
551
|
+
body: { roleId: String(args.roleId), userId: id, unitType: 3, sortKey: args.sortKey ?? 999, ouScopeList },
|
|
552
|
+
});
|
|
553
|
+
const scope = wireRows((await wireCall(session, {
|
|
554
|
+
path: ENDPOINTS.organizationRoleGetOuScope,
|
|
555
|
+
query: { ...orgQuery(session), roleId: String(args.roleId), userId: id },
|
|
556
|
+
})).data);
|
|
557
|
+
scopeResults.push({ userId: id, scopes: scope.length });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
performed: { errcode: 0 },
|
|
562
|
+
readback: {
|
|
563
|
+
roleId: String(args.roleId), added,
|
|
564
|
+
...(missing.length > 0 ? { missing, hint: "这些 recId 未出现在成员列表:确认 id 是 recId、用户未离职/禁用" } : {}),
|
|
565
|
+
...(scopeResults.length > 0 ? { scopeResults } : {}),
|
|
566
|
+
total: rows.length,
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
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)。只移出角色,不删用户。", {
|
|
571
|
+
roleId: z.string().min(1),
|
|
572
|
+
ids: z.array(z.string().min(1)).min(1).describe("要移出的成员 recId 列表"),
|
|
573
|
+
}, async (session, args) => {
|
|
574
|
+
await wireCall(session, {
|
|
575
|
+
path: ENDPOINTS.organizationRoleRemoveUser, method: "POST", query: orgQuery(session),
|
|
576
|
+
body: { roleId: String(args.roleId), userIds: args.ids },
|
|
577
|
+
});
|
|
578
|
+
const data = (await wireCall(session, {
|
|
579
|
+
path: ENDPOINTS.organizationRoleUsers,
|
|
580
|
+
query: { ...orgQuery(session), roleId: String(args.roleId), page: "0", size: "500" },
|
|
581
|
+
})).data;
|
|
582
|
+
const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
|
|
583
|
+
const present = new Set(rows.map(roleUserId));
|
|
584
|
+
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 } };
|
|
585
|
+
});
|
|
586
|
+
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}。", {
|
|
587
|
+
roleId: z.string().min(1),
|
|
588
|
+
userId: z.string().min(1).describe("成员 recId"),
|
|
589
|
+
scopes: z.array(z.object({
|
|
590
|
+
id: z.string().min(1).describe("部门 id"),
|
|
591
|
+
name: z.string().min(1),
|
|
592
|
+
deptPath: z.string().optional().describe("完整路径,如 根组织/广州总所"),
|
|
593
|
+
type: z.string().optional().describe("默认 \"1\"(部门节点)"),
|
|
594
|
+
})).describe("要设置的管理范围节点"),
|
|
595
|
+
mode: z.enum(["merge", "replace"]).optional().describe("默认 merge(读旧值合并);replace = 覆盖整个范围"),
|
|
596
|
+
dryRun: z.boolean().optional(),
|
|
597
|
+
}, async (session, args) => {
|
|
598
|
+
const before = wireRows((await wireCall(session, {
|
|
599
|
+
path: ENDPOINTS.organizationRoleGetOuScope,
|
|
600
|
+
query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
|
|
601
|
+
})).data);
|
|
602
|
+
const wanted = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type ?? "1", ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
|
|
603
|
+
const merged = args.mode === "replace"
|
|
604
|
+
? wanted
|
|
605
|
+
: [...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]
|
|
606
|
+
.filter((row, index, all) => all.findIndex((candidate) => candidate.id === row.id) === index);
|
|
607
|
+
const removed = before.map((row) => String(row.id ?? "")).filter((id) => id !== "" && !merged.some((row) => row.id === id));
|
|
608
|
+
if (args.mode === "replace" && removed.length > 0)
|
|
609
|
+
// A data-loss write without an explicit acknowledgement is exactly the
|
|
610
|
+
// failure mode the customer hit manually; refuse unless replace was
|
|
611
|
+
// chosen deliberately (mode): the caller already opted in.
|
|
612
|
+
void removed;
|
|
613
|
+
await wireCall(session, {
|
|
614
|
+
path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST",
|
|
615
|
+
query: orgQuery(session),
|
|
616
|
+
body: { roleId: args.roleId, userId: args.userId, unitType: 3, sortKey: 999, ouScopeList: merged },
|
|
617
|
+
});
|
|
618
|
+
const after = wireRows((await wireCall(session, {
|
|
619
|
+
path: ENDPOINTS.organizationRoleGetOuScope,
|
|
620
|
+
query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
|
|
621
|
+
})).data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
|
|
622
|
+
return {
|
|
623
|
+
performed: { errcode: 0 },
|
|
624
|
+
readback: { roleId: args.roleId, userId: args.userId, mode: args.mode ?? "merge", before: before.length, after: after.length, scopes: after, ...(removed.length > 0 ? { removedScopeIds: removed } : {}) },
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
//# sourceMappingURL=organization.js.map
|
package/dist/lanes/rule-graph.js
CHANGED
|
@@ -1805,6 +1805,43 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
|
|
|
1805
1805
|
walkConditionLeaves(node.targetConditionGroups, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where, warnings, (relaxEnums || carried?.has(String(node.nodeCode ?? "")) === true))));
|
|
1806
1806
|
scanConditionKeyCollisions(node.targetConditionGroups, nodeCode, warnings);
|
|
1807
1807
|
}
|
|
1808
|
+
// QUERY has THREE silent-empty exits in the engine (1.1.25 source, r9
|
|
1809
|
+
// §14.1): `QueryActivityBehavior:47-50` returns null when the resolved
|
|
1810
|
+
// inputs are empty, `:78-86` returns emptyList when getMainConditions()
|
|
1811
|
+
// is null/empty, and `ActivityBizObjectManagerImpl:172-178` returns
|
|
1812
|
+
// emptyList on a zero-hit page. NONE of them logs anything, so "executed,
|
|
1813
|
+
// matched nothing" is indistinguishable from "executed, worked" — r9 lost
|
|
1814
|
+
// ~1/4 of its call budget to this. The two shapes that reliably land in
|
|
1815
|
+
// the first two exits are visible here before the wire:
|
|
1816
|
+
const groups = node.targetConditionGroups;
|
|
1817
|
+
const targetIsChild = typeof node.targetSchemaCode === "string" && node.targetSchemaCode.includes(".");
|
|
1818
|
+
const mainEmpty = groups === undefined || countConditionLeaves(groups.mainConditions ?? groups) === 0;
|
|
1819
|
+
const sheetEmpty = countConditionLeaves(groups?.sheetConditions ?? []) === 0;
|
|
1820
|
+
// Mirrors QueryActivityBehavior.getPage exactly: an empty MAIN group
|
|
1821
|
+
// returns emptyList unconditionally; the sheet group is only consulted
|
|
1822
|
+
// when the target is a child table.
|
|
1823
|
+
if (mainEmpty || (targetIsChild && sheetEmpty)) {
|
|
1824
|
+
// A QUERY with no conditions at all is the "return emptyList" path, not
|
|
1825
|
+
// "select everything" — the opposite of UPDATE's semantics. Fail closed:
|
|
1826
|
+
// a caller who really wants every row must say so with an always-true
|
|
1827
|
+
// leaf, which is visible in the graph.
|
|
1828
|
+
blockers.push({
|
|
1829
|
+
code: "rule-graph-query-conditions-empty",
|
|
1830
|
+
nodeCode,
|
|
1831
|
+
key: "targetConditionGroups",
|
|
1832
|
+
message: `QUERY 的 ${mainEmpty ? "mainConditions" : "sheetConditions"} 没有任何条件叶——引擎 getPage 会直接返回空列表且不写日志(QueryActivityBehavior:78-86,1.1.25),运行期表现为「节点执行开始」后静默无输出。要查全表请显式写一条恒真条件叶(如 targetSchemaDataItem:"id" + ruleConditionType:"NOT_EMPTY"),否则补上真正的筛选条件。`,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
if (node.dataCondition !== undefined && node.dataCondition !== null && countConditionLeaves(node.dataCondition) === 0) {
|
|
1836
|
+
blockers.push({
|
|
1837
|
+
code: "rule-graph-query-datacondition-empty",
|
|
1838
|
+
nodeCode,
|
|
1839
|
+
key: "dataCondition",
|
|
1840
|
+
severity: "warning",
|
|
1841
|
+
hazard: true,
|
|
1842
|
+
message: "QUERY 的 dataCondition 是空条件组——输入数据经 DefaultConditionGroupBizObjectCompare 过滤后为空,QueryActivityBehavior:47-50 直接 return null(静默无输出,1.1.25)。要么去掉 dataCondition,要么写至少一条条件叶。",
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1808
1845
|
}
|
|
1809
1846
|
if (nodeType === "BRANCH") {
|
|
1810
1847
|
// Source evidence: RuleBranchQuoteImpl.getCurrentBizSchema throws
|
|
@@ -7,6 +7,7 @@ import { executeRuleSaveWrite, ruleNodeGraphArg, assertRuleSchemaExists, explain
|
|
|
7
7
|
import { ToolRefusalError } from "../tool-types.js";
|
|
8
8
|
import { prepareRuleGraph, validateRuleNodeGraph, PLATFORM_TRIGGER_RULE_CODES } from "./rule-graph.js";
|
|
9
9
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
10
|
+
import { currentUsageArgsDigest, priorIdenticalSuccess } from "../usage-log.js";
|
|
10
11
|
import { RULE_ENABLE_CHAIN, RULE_LIFECYCLE_LANE_CONTRACT } from "./rule-lifecycle-contract.js";
|
|
11
12
|
const RULE_LIFECYCLE_LANE = RULE_LIFECYCLE_LANE_CONTRACT.lane;
|
|
12
13
|
const RULE_LIFECYCLE_TOOLS = RULE_LIFECYCLE_LANE_CONTRACT.tools;
|
|
@@ -52,6 +53,25 @@ export async function executeRuleEnableWrite(session, args) {
|
|
|
52
53
|
const full = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(row.id) } })).data;
|
|
53
54
|
if (!full)
|
|
54
55
|
throw new WireError("business", `rule ${args.ruleCode} 完整模型读回为空(businessrule/get id ${row.id}),拒绝用列表投影发布`, 302024);
|
|
56
|
+
// Idempotency (r9 2026-10-22): identical `rule.enable` calls answered
|
|
57
|
+
// `alreadyEnabled:true` 14 times and each one still minted a new rule version
|
|
58
|
+
// row. The session log is the only durable memory a CLI has, so the memo is
|
|
59
|
+
// there: same (schemaCode, ruleCode) already enabled successfully, with no
|
|
60
|
+
// `rule.save` in between ⇒ nothing to do. Any save invalidates it.
|
|
61
|
+
const digest = currentUsageArgsDigest();
|
|
62
|
+
if (digest) {
|
|
63
|
+
const prior = priorIdenticalSuccess("call rule.enable", digest, { invalidators: ["call rule.save", "call rule.node.update"], keyFields: ["schemaCode", "ruleCode"] });
|
|
64
|
+
if (prior && prior.count > 0) {
|
|
65
|
+
return {
|
|
66
|
+
performed: { errcode: 0, alreadyEnabled: true, noop: true },
|
|
67
|
+
readback: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
...(prior.requestIds.length > 0 ? { priorRequestIds: prior.requestIds } : {}),
|
|
70
|
+
nextAction: "本会话已用相同参数成功 enable 过该规则,且此后没有 rule.save;本次未发任何 wire 请求(重复 enable 只会多铸一个规则版本)。改了图就先 rule.save,再按需 rule.enable",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
55
75
|
let published;
|
|
56
76
|
try {
|
|
57
77
|
published = await wireCall(session, { path: ENDPOINTS.businessRulePublish, method: "POST", body: { ...full, enabled: true, status: "ENABLED" } });
|
|
@@ -148,7 +148,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
|
|
|
148
148
|
}),
|
|
149
149
|
"org.department.list": Object.freeze({
|
|
150
150
|
scope: "workflow participant resolution",
|
|
151
|
-
description: "列出组织结构(`GET /api/api/organization/department/tree`),扁平化为 {id,name,code,corpId,parentId}
|
|
151
|
+
description: "列出组织结构(`GET /api/api/organization/department/tree`),扁平化为 {id,name,code,corpId,parentId}。**平台语义:不带 deptIds 只返回根部门;带 deptIds=<id> 返回该部门整棵子树(不是一层)**——工具先取根再逐个取子树,支持多层;可选 parentId 只看子树。用于需要按部门解析审批人(配合 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")` 或部门引用)时取得真实 code/id。",
|
|
152
152
|
}),
|
|
153
153
|
"org.user.search": Object.freeze({
|
|
154
154
|
scope: "workflow participant resolution",
|
|
@@ -18,6 +18,27 @@ export const WORKFLOW_COVERAGE = declareLaneCoverage(WORKFLOW_LANE_CONTRACT.lane
|
|
|
18
18
|
claim("workflow.delete", T["workflow.delete"].scope),
|
|
19
19
|
claim("configure_workflow", T["configure_workflow"].scope),
|
|
20
20
|
claim("workflow.validate", T["workflow.validate"].scope),
|
|
21
|
+
// Organization writes (role group / role / members / management scope).
|
|
22
|
+
claim("org.role.users", "organization role member read (role/users)"),
|
|
23
|
+
claim("org.role.userScope", "organization role member management scope read (role/get_ouscope)"),
|
|
24
|
+
claim("org.roleGroup.create", "organization role group create (role/add_role_group)"),
|
|
25
|
+
claim("org.role.create", "organization role create (role/add_role)"),
|
|
26
|
+
claim("org.role.update", "organization role update (role/modify_role)"),
|
|
27
|
+
claim("org.role.addUsers", "organization role member add (role/add_role_user)"),
|
|
28
|
+
claim("org.role.removeUsers", "organization role member remove (role/strike_out_role_user)"),
|
|
29
|
+
claim("org.role.setUserScope", "organization role member scope update (role/update_ouscope)"),
|
|
30
|
+
claim("org.role.delete", "organization role delete (role/strike_out_role)"),
|
|
31
|
+
claim("org.roleGroup.delete", "organization role group delete (role/strike_out_role_group)"),
|
|
32
|
+
claim("org.user.transferDepartment", "organization user department transfer (user/dept_transfer)"),
|
|
33
|
+
claim("org.department.create", "organization department create (department/add)"),
|
|
34
|
+
claim("org.department.update", "organization department update (department/modefied)"),
|
|
35
|
+
claim("org.department.delete", "organization department delete (department/strike_out)"),
|
|
36
|
+
claim("org.department.users", "organization department member read (department/list_user)"),
|
|
37
|
+
claim("org.user.create", "organization user create (user/add)"),
|
|
38
|
+
claim("org.user.update", "organization user update (user/modify)"),
|
|
39
|
+
claim("org.user.delete", "organization user delete (user/strike_out)"),
|
|
40
|
+
claim("org.user.info", "organization user read (user/info)"),
|
|
41
|
+
claim("org.user.departments", "organization user department read (user/departments)"),
|
|
21
42
|
],
|
|
22
43
|
},
|
|
23
44
|
{
|
package/dist/lanes/workflow.js
CHANGED
|
@@ -2313,9 +2313,18 @@ export function registerWorkflowLane(tools) {
|
|
|
2313
2313
|
lane: WORKFLOW_LANE,
|
|
2314
2314
|
scope: WORKFLOW_TOOLS["org.role.list"].scope,
|
|
2315
2315
|
description: WORKFLOW_TOOLS["org.role.list"].description,
|
|
2316
|
-
inputSchema: { environment: environmentArg, capability: capabilityArg },
|
|
2316
|
+
inputSchema: { environment: environmentArg, expandAll: z.boolean().optional().describe("默认 true:展开所有角色组并带回角色。平台的 /role/list 只有 expandAll=true 才填 children(RoleController.list,1.1.25);传 false 只回角色组、roles 恒为空,别据此判断「角色未创建」"), capability: capabilityArg },
|
|
2317
2317
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
2318
|
-
|
|
2318
|
+
// `/role/list` without `expandAll=true` only expands the FIRST group's
|
|
2319
|
+
// children (RoleController.list, 1.1.25), so a role created under any
|
|
2320
|
+
// other group is invisible — r9 read that as "role not created" and
|
|
2321
|
+
// burned 8 org.role.list calls. Always expand; expose the flag only so
|
|
2322
|
+
// a caller can ask for the container-only face deliberately.
|
|
2323
|
+
const expandAll = args.expandAll ?? true;
|
|
2324
|
+
const data = (await wireCall(session, {
|
|
2325
|
+
path: ENDPOINTS.organizationRoleList,
|
|
2326
|
+
query: expandAll ? { expandAll: "true" } : {},
|
|
2327
|
+
})).data;
|
|
2319
2328
|
return flattenRoleTree(wireRows(data));
|
|
2320
2329
|
}),
|
|
2321
2330
|
}));
|
|
@@ -2326,11 +2335,51 @@ export function registerWorkflowLane(tools) {
|
|
|
2326
2335
|
lane: WORKFLOW_LANE,
|
|
2327
2336
|
scope: WORKFLOW_TOOLS["org.department.list"].scope,
|
|
2328
2337
|
description: WORKFLOW_TOOLS["org.department.list"].description,
|
|
2329
|
-
inputSchema: { environment: environmentArg, capability: capabilityArg },
|
|
2338
|
+
inputSchema: { environment: environmentArg, parentId: z.string().min(1).optional().describe("只列出该部门下的子树(默认从根递归展开整棵部门树)"), capability: capabilityArg },
|
|
2330
2339
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2340
|
+
// `/department/tree` semantics (live 2026-10-22, 1.1.25): no `deptIds`
|
|
2341
|
+
// → the ROOTS; with `deptIds=<id>` → that department's WHOLE SUBTREE
|
|
2342
|
+
// (not one level). So: roots once, then one subtree call per root.
|
|
2343
|
+
const requested = args.parentId;
|
|
2344
|
+
const fetchLevel = async (deptId) => {
|
|
2345
|
+
const query = deptId === undefined ? {} : { deptIds: deptId };
|
|
2346
|
+
const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree, query })).data;
|
|
2347
|
+
return Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
|
|
2348
|
+
};
|
|
2349
|
+
const levels = [];
|
|
2350
|
+
const seen = new Set();
|
|
2351
|
+
const push = (rows) => {
|
|
2352
|
+
for (const row of rows) {
|
|
2353
|
+
const id = String(row.id ?? "");
|
|
2354
|
+
if (id === "" || seen.has(id))
|
|
2355
|
+
continue;
|
|
2356
|
+
seen.add(id);
|
|
2357
|
+
levels.push(row);
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
if (requested === undefined) {
|
|
2361
|
+
push(await fetchLevel(undefined));
|
|
2362
|
+
}
|
|
2363
|
+
else {
|
|
2364
|
+
// `deptIds=<id>` returns the subtree BELOW that department, excluding
|
|
2365
|
+
// it (live 2026-10-22). A caller asking for `parentId` expects that
|
|
2366
|
+
// department itself too, so add it from the authoritative record.
|
|
2367
|
+
try {
|
|
2368
|
+
const self = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { deptId: requested } })).data;
|
|
2369
|
+
if (self)
|
|
2370
|
+
push([self]);
|
|
2371
|
+
}
|
|
2372
|
+
catch {
|
|
2373
|
+
// an unknown id simply yields an empty list
|
|
2374
|
+
}
|
|
2375
|
+
push(await fetchLevel(requested));
|
|
2376
|
+
}
|
|
2377
|
+
for (const root of levels.filter((row) => String(row.parentId ?? "") === "" || requested !== undefined).map((row) => String(row.id ?? ""))) {
|
|
2378
|
+
if (seen.size >= 500)
|
|
2379
|
+
break;
|
|
2380
|
+
push(await fetchLevel(root));
|
|
2381
|
+
}
|
|
2382
|
+
const departments = levels.map((dept) => ({
|
|
2334
2383
|
id: dept.id ?? null,
|
|
2335
2384
|
name: dept.name ?? null,
|
|
2336
2385
|
code: dept.code ?? null,
|
package/dist/tools.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerBusinessRuleLane } from "./lanes/businessrule.js";
|
|
|
12
12
|
import { registerRuleLifecycleLane } from "./lanes/rule-lifecycle.js";
|
|
13
13
|
import { registerRuntimeDataLane } from "./lanes/runtime-data.js";
|
|
14
14
|
import { registerWorkflowLane } from "./lanes/workflow.js";
|
|
15
|
+
import { registerOrganizationLane } from "./lanes/organization.js";
|
|
15
16
|
import { registerOnlineJsLane } from "./lanes/online-js.js";
|
|
16
17
|
import { registerB2Lane } from "./lanes/b2-registration.js";
|
|
17
18
|
import { registerListViewLane } from "./lanes/list-view.js";
|
|
@@ -41,6 +42,7 @@ export function buildToolRegistry() {
|
|
|
41
42
|
registerRuleLifecycleLane(tools);
|
|
42
43
|
registerRuntimeDataLane(tools);
|
|
43
44
|
registerWorkflowLane(tools);
|
|
45
|
+
registerOrganizationLane(tools);
|
|
44
46
|
registerOnlineJsLane(tools);
|
|
45
47
|
registerB2Lane(tools);
|
|
46
48
|
registerListViewLane(tools);
|
package/dist/usage-log.js
CHANGED
|
@@ -2,6 +2,14 @@ import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
let pending;
|
|
5
|
+
/**
|
|
6
|
+
* The project session marker: a working directory that opted into the usage log
|
|
7
|
+
* (`.omc-env` present) or an explicit OMC_USAGE_LOG. Session-scoped caches key
|
|
8
|
+
* off this so repo tests and the golden master (no `.omc-env`) stay stable.
|
|
9
|
+
*/
|
|
10
|
+
export function projectSessionActive() {
|
|
11
|
+
return logPath() !== undefined;
|
|
12
|
+
}
|
|
5
13
|
function logPath() {
|
|
6
14
|
const explicit = process.env.OMC_USAGE_LOG;
|
|
7
15
|
if (explicit)
|
|
@@ -9,6 +17,60 @@ function logPath() {
|
|
|
9
17
|
const projectEnv = resolve(process.cwd(), ".omc-env");
|
|
10
18
|
return existsSync(projectEnv) ? resolve(process.cwd(), ".omc", "usage.jsonl") : undefined;
|
|
11
19
|
}
|
|
20
|
+
/** The args digest of the in-flight invocation (session-scoped memo key). */
|
|
21
|
+
export function currentUsageArgsDigest() {
|
|
22
|
+
return pending?.argsDigest;
|
|
23
|
+
}
|
|
24
|
+
/** The summarized args of the in-flight invocation (safe keys only). */
|
|
25
|
+
export function currentUsageArgSummary() {
|
|
26
|
+
return pending?.argSummary;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Was this exact invocation (same command + same args) already SUCCESSFUL in the
|
|
30
|
+
* current project session, with no intervening call from `invalidators` for the
|
|
31
|
+
* same business key?
|
|
32
|
+
*
|
|
33
|
+
* r9 (2026-10-22) called `rule.enable` with identical args 14 times; each call
|
|
34
|
+
* answered `alreadyEnabled:true` and minted another rule version. The log is the
|
|
35
|
+
* only durable session memory a CLI has, so the idempotency memo lives there.
|
|
36
|
+
*/
|
|
37
|
+
export function priorIdenticalSuccess(command, argsDigest, options = {}) {
|
|
38
|
+
const path = logPath();
|
|
39
|
+
if (!path)
|
|
40
|
+
return undefined;
|
|
41
|
+
const limit = options.limit ?? 60;
|
|
42
|
+
const tail = readUsageTail(path, 131072).slice(-limit);
|
|
43
|
+
const current = pending?.argSummary ?? {};
|
|
44
|
+
const keyFields = options.keyFields ?? ["schemaCode", "ruleCode", "code"];
|
|
45
|
+
const sameKey = (record) => {
|
|
46
|
+
const summary = record.argSummary && typeof record.argSummary === "object" ? record.argSummary : {};
|
|
47
|
+
return keyFields.every((field) => {
|
|
48
|
+
const wanted = current[field];
|
|
49
|
+
if (wanted === undefined)
|
|
50
|
+
return true;
|
|
51
|
+
return String(summary[field] ?? "") === String(wanted);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
let count = 0;
|
|
55
|
+
const requestIds = [];
|
|
56
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
57
|
+
const record = tail[index];
|
|
58
|
+
const recordCommand = String(record.command ?? "");
|
|
59
|
+
if (options.invalidators?.includes(recordCommand) && sameKey(record))
|
|
60
|
+
return count > 0 ? { count, requestIds } : undefined;
|
|
61
|
+
if (recordCommand !== command)
|
|
62
|
+
continue;
|
|
63
|
+
if (String(record.argsDigest ?? "") !== argsDigest)
|
|
64
|
+
continue;
|
|
65
|
+
if (String(record.status ?? "") !== "ok")
|
|
66
|
+
continue;
|
|
67
|
+
count += 1;
|
|
68
|
+
const requestId = record.requestId;
|
|
69
|
+
if (typeof requestId === "string" && requestIds.length < 3)
|
|
70
|
+
requestIds.push(requestId);
|
|
71
|
+
}
|
|
72
|
+
return count > 0 ? { count, requestIds } : undefined;
|
|
73
|
+
}
|
|
12
74
|
/** The command string of the in-flight invocation (for diagnostics). */
|
|
13
75
|
export function currentUsageCommand() {
|
|
14
76
|
return pending?.command;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.74",
|
|
4
4
|
"description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@stalfh233/omc-fake-platform": "0.4.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.74",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
55
|
+
"omc-core": "0.4.74",
|
|
56
56
|
"proper-lockfile": "4.1.2",
|
|
57
57
|
"write-file-atomic": "6.0.0",
|
|
58
58
|
"zod": "^3.23.8",
|
|
@@ -157,7 +157,14 @@ rules:
|
|
|
157
157
|
- **DYNAMIC 来源不要写点号**:`dataActions` 的 `currentDataItemValue` 写 `items.sealNo` 会被引擎当成**子表列集合**(DatasetImpl),标量目标收到集合 → 运行期 `submit/update` 报 50000(`WorkSheetBizField.fillClientContent: Incorrect value type`)。单跳关联字段直接写字段 code(引擎取 `.id`);子表列只能用于公式/聚合。`rule.save` 已写前拒绝(`rule-graph-create-dynamic-source-is-child-collection`)。
|
|
158
158
|
- **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
|
|
159
159
|
- **预置 Update 规则里的跨模型回写只在 `data.update` 触发**:`rule-graph-cross-model-update-trigger-inert` 现在是 hazard(在 `readback.lintHazards`,不混进 `lintWarnings`);create/submit 不执行它。要提交时生效,把节点放进 Create 规则或流程事件规则。
|
|
160
|
+
- **部门/员工写面**:`org.department.create`(`deptType` 传名字或 0..3,平台按 **int 枚举**反序列化,写 `"dept"` 会 10009)→ `org.department.update`(先读再合并)/`org.department.delete`(`strike_out`);`org.user.create` 必填 `name/username/departmentId`,**平台对 `userLabelIds`/`userManagerConfigList`/`roleIds` 的 null 直接 NPE**(50000,`UserHelper.dealUserLabel:428`),工具已补空数组;`org.user.update` 同样必须先补 `deptIds` 等数组(`UserHelper.modified` 对 null 的 `deptIds` NPE)。`org.department.list` 递归展开整棵树(`/department/tree` 每次只返回一层);`org.department.users` / `org.user.info` / `org.user.departments` 用于回读。新建用户的 `active` 平台回读为 false(账号激活另有流程),不要把 active 当作创建成功标志。
|
|
161
|
+
- **组织写面(角色/成员/管理范围)**:界面上的「分组」= 角色(`group:false`),容器是角色组(`group:true`)。链路:`org.roleGroup.create`(**parentId 必填**,1.1.25 传空会 50000 `parentIds cannot be empty`)→ `org.role.create`(roleId 平台生成,code 随后补 `SYS<时间戳>`)→ `org.role.addUsers`(`id` 必须传 **recId**,不是账号;工具补 `type:3`/`unitType:"3"`)→ `org.role.setUserScope`(**平台覆盖语义、无回滚**:默认读旧值合并,`mode:"replace"` 才整体替换;`unitType` 传数字 3)。读回用 `org.role.users` / `org.role.userScope`。
|
|
162
|
+
- **`corpId` 默认写当前企业,不要写 `"other"`**(r9 2026-10-22 更正):`RoleGroupHelper.createRoleGroupModel` 只从 body 取 corpId,缺省才按 parentId 反查企业设置、查不到就 NPE(被吞成 errcode 0);`other` 是平台**合法的「非组织分组」语义**,不是兜底值。工具现在默认 `session.enterpriseId()`,并回读校验(不一致给 warning);只有明确要建非组织分组时才显式传 `corpId:"other"`。
|
|
163
|
+
- **`org.role.list` 必须带 `expandAll=true`**:`/role/list` 不带该参数只展开**第一个**角色组的 children(`RoleController.list`,1.1.25),所以建在别的组下的角色读回为「不存在」——r9 因此误判「角色未创建」并重复读了 8 次。工具已默认展开,`expandAll:false` 才拿容器面。
|
|
164
|
+
- **`org.role.update` 的平台前置条件**:`modify_role` 的 `validateRoleParam` 要求 `roleName` 与 `roleGroupId` **都非空**(否则 202001 / 202012),所以只改备注也要回填这两项——工具已从 `role/get` 自动补齐。`corpId` 在 update 里不生效,换企业只能重建角色。
|
|
165
|
+
- **角色参与人可用性**:r9 对照实验证明,`corpId=other` 的角色**仍能正常解析参与人**(把非发起人加进该角色后,待办正确落到该人)。所以「审批人变成发起人」要按**解析为空**排查(角色零成员 / 成员没挂部门 / 自由文本 / 角色码不存在),不要归因于 corpId。
|
|
160
166
|
- **系统持久化节点是平台自带的,不要手写**:预置 `Create/Update/Delete` 行本来就带 `SYSTEM_CREATE/SYSTEM_UPDATE/SYSTEM_DELETE`(实测基线 `Start → Create(SYSTEM_CREATE) → End`)。`rule.save` 会丢弃调用方传入的 `SYSTEM_*` 并**从平台基线原样携带**那一个节点(code/name/几何都不由我们猜)。手写的后果:预置规则里类型错配 → `data.submit` 50000;自定义规则/流程事件里带系统节点 → 结束事件无当前对象,`Object <id> is not serialized`,每步仍回 DELIVERED 但什么都没写。只写平台允许配置的业务节点(UPDATE/CREATE/DELETE/BRANCH/ASSIGN/MESSAGE/BIZ_ACTION…)。
|
|
167
|
+
- **QUERY 节点的三个「静默空结果」入口(1.1.25 源码,r9 §14.1)**:`QueryActivityBehavior:47-50`(输入数据为空,或 `dataCondition` 过滤后为空 → `return null`)、`:78-86`(`mainConditions` 为空 → `emptyList`;目标带 `.` 时 `sheetConditions` 为空也 `emptyList`)、`ActivityBizObjectManagerImpl:172-178`(真查询 0 命中 → `emptyList`)。**三条都不写日志**,所以「节点执行开始后什么都没有」既可能是配置错也可能是 0 命中。前两条工具已在写前拦:`rule-graph-query-conditions-empty`(无条件叶,fail closed——QUERY 的空条件**不是**「查全表」,与 UPDATE 语义相反;要全表请写恒真叶)与 `rule-graph-query-datacondition-empty`(hazard)。第三条只能在运行期验证:先 `data.list` 确认目标行存在且状态已提交。
|
|
161
168
|
- **取 artifact 先投影**:`artifact get <id> --summary` 看形状 → `--jq '<path>'` 取那一段;只有确实要整份时才裸取(r6:47 次整包拉取 650KB,0 次用投影)。
|
|
162
169
|
- **读回产物会自动清理,也可手动清**:每次大结果落一个 artifact(机器级目录,跨项目累积;实测一轮沙盒 504 个/3.4MB)。`createArtifact` 每 24h 顺手删 7 天未动的;要立刻回收用 `omc artifact prune --older-than 7d --max-bytes 268435456`(默认预算 256MB,超出按最旧优先删),`--json` 回 removed/freedBytes/kept。
|
|
163
170
|
- **核验状态用投影,别重复整条读**:`data.load --args '{"schemaCode":..,"bizObjectId":..,"fields":["sequenceStatus","<业务状态字段>"]}'` 只回指定字段(缺失项在 `missingFields`)——r4 实测同一对象被整条读 74 次;大结果先 `artifact get <id> --summary` → `--keys/--jq`,别整包内联进推理。
|
|
@@ -138,7 +138,7 @@ only the section for the capability you need —
|
|
|
138
138
|
## 流程模板 `workflow`
|
|
139
139
|
|
|
140
140
|
- **能做哪些事**:流程模板 get_draft/get_published、create、update、publish、bindRule(事件处理器规则绑定)、delete、workitem.list、workflow.instanceActivity(实例节点状态诊断);参与人用组织对象。
|
|
141
|
-
- **工具**:`workflow.get_draft`、`workflow.get_published`、`workflow.validate`、`workflow.create`、`workflow.update`、`workflow.publish`、`workflow.bindRule`、`workflow.delete`、`configure_workflow`、`workitem.list`、`workflow.instanceActivity`、`org.role.list`、`org.department.list`、`org.user.search`
|
|
141
|
+
- **工具**:`workflow.get_draft`、`workflow.get_published`、`workflow.validate`、`workflow.create`、`workflow.update`、`workflow.publish`、`workflow.bindRule`、`workflow.delete`、`configure_workflow`、`workitem.list`、`workflow.instanceActivity`、`org.role.list`、`org.department.list`、`org.user.search`、`org.role.users`、`org.role.userScope`、`org.roleGroup.create`、`org.role.create`、`org.role.update`、`org.role.addUsers`、`org.role.removeUsers`、`org.role.setUserScope`、`org.department.create`、`org.department.update`、`org.department.delete`、`org.department.users`、`org.user.create`、`org.user.update`、`org.user.delete`、`org.user.info`、`org.user.departments`、`org.role.delete`、`org.roleGroup.delete`、`org.user.transferDepartment`
|
|
142
142
|
- **recipe**:`omc describe workflow.bindRule --example workflow-handler-binding`
|
|
143
143
|
- **边界 / 陷阱**:greenfield 首版;`publish`/`bindRule` 不得在 `ValidateMessage[]` 拒绝时报告成功。运行时 workitem(提交/审批)走 `data.submit`;想知道实例停在哪/是否错派,用 `workflow.instanceActivity`(无 status=1=真停,参与人全是发起人=解析为空退回)。
|
|
144
144
|
|