@stalfh233/omc-cli 0.4.72 → 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.
@@ -792,6 +792,37 @@ export function prepareRuleGraph(ruleCode, graph, customTrigger, schemaCode) {
792
792
  warnings.push({ code: "rule-graph-input-param-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "inputParam", severity: "warning", message: `${nodeType} "${String(node.nodeCode ?? "")}" 缺 inputParam,已默认补 "_input"(平台按 inputParam 解析节点输入 schema,缺则 publish 300010「数据模型编码不能为空」)` });
793
793
  }
794
794
  }
795
+ // ASSIGN's quote stage dereferences three keys without a null check:
796
+ // `RuleAssignmentQuoteImpl.quote:61` calls `getNonMatchingAdded().booleanValue()`
797
+ // and `:77` does the same per child mapping. A graph that omits it therefore
798
+ // publishes as 50000 "服务异常" with an NPE in the launcher log — live
799
+ // 2026-10-22 (r8 seal sandbox), reproduced on a scratch model with an
800
+ // otherwise valid graph. The designer always writes them, so default the
801
+ // whole designer set instead of leaving a landmine.
802
+ if (nodeType === "ASSIGN") {
803
+ const defaults = [
804
+ ["triggerType", "CURRENT"],
805
+ ["dataCondition", { conditionGroup: [] }],
806
+ ["ruleVariableDataActions", []],
807
+ ["orderBys", []],
808
+ ["filterCondition", { mainConditions: [], sheetConditions: [] }],
809
+ ["dataActions", []],
810
+ ["children", []],
811
+ ["nonMatchingAdded", false],
812
+ ["addChildDataActions", []],
813
+ ];
814
+ const missing = defaults.filter(([key]) => current[key] === undefined || current[key] === null);
815
+ if (missing.length > 0) {
816
+ current = { ...current, ...Object.fromEntries(missing) };
817
+ warnings.push({
818
+ code: "rule-graph-assign-keys-defaulted",
819
+ nodeCode: String(node.nodeCode ?? ""),
820
+ key: "nonMatchingAdded",
821
+ severity: "warning",
822
+ message: `ASSIGN "${String(node.nodeCode ?? "")}" 缺设计器固定键(${missing.map(([key]) => key).join("、")}),已按设计器默认补齐——尤其 nonMatchingAdded:平台 quote 阶段直接 .booleanValue(),缺它会 publish 50000(NPE RuleAssignmentQuoteImpl.quote:61)`,
823
+ });
824
+ }
825
+ }
795
826
  if (String(current.nodeName ?? "").trim())
796
827
  return current;
797
828
  const nodeCode = String(current.nodeCode ?? "");
@@ -923,7 +954,7 @@ export const RULE_NODE_TEMPLATES = Object.freeze({
923
954
  },
924
955
  },
925
956
  ASSIGN: {
926
- description: "数据赋值(真机 2026-09-28 复核:可发布、可运行)。**`dataCondition` = 执行门**(输入数据满足条件才执行该节点,\"输入数据满足条件时触发执行动作\");**`filterCondition` = 按条件在输入数据源中匹配要取值的行**(\"根据此条件在输入数据源中进行匹配。匹配到多条数据时,会按第一条数据执行赋值\")——**两者是两码事,别混**:要\"满足条件才赋值\"用 `dataCondition`,不是 `filterCondition`(后者不拦执行)。`triggerType` ∈ CURRENT(当前数据)/RULE_VARIABLE(规则变量);CURRENT 时 `targetObjectCode` 默认 `\"_input\"`(=当前/输入对象),dataActions 写目标字段;RULE_VARIABLE 时 targetObjectCode 隐藏、dataActions 写 `_var_*`、`orderBys` 有默认 `[{code:\"createdTime\",direction:\"ASC\"}]`。**CURRENT+dataActions 必须排在系统持久化节点(SYSTEM_CREATE/SYSTEM_UPDATE)之前**(Start→ASSIGN→SYSTEM_CREATE→End),否则赋值合并进 _input 晚于持久化、读回无变化(工具 fail closed `rule-graph-assign-persistence-order`;工具注入系统节点时也会自动排到 ASSIGN/BIZ_ACTION 之后)。平台设计器键(SHEET_ASSIGN)required:nodeCode/nodeName/targetObjectCode/ruleVariableDataActions/dataActions/orderBys/addChildDataActions;dataCondition 默认 `{conditionGroup:[]}`、filterCondition 默认 `{mainConditions:[],sheetConditions:[]}`、nonMatchingAdded 默认 false、children/addChildDataActions 默认 []。",
957
+ description: "数据赋值:把输入里的值写进 `targetObjectCode` 指向的**目标行**(`targetObjectCode` 是**写入目标**,不是取数来源;缺省 `_input` = 当前/输入对象)。实测 2026-10-22(1.1.25):`targetObjectCode:\"_input\"` + `dataActions[{targetDataItem:\"<本单字段>\", ruleValueType:\"DYNAMIC\", currentDataItemValue:\"<本单来源字段>\"}]` 可发布可启用,目标字段是关联单选的也能写。**写成 `targetObjectCode:<别的模型>` + 目标是本单字段 → 平台自检 550018**(它按 targetObjectCode 校验 targetDataItem);**漏 `nonMatchingAdded` → quote 阶段 NPE,publish 50000**(`RuleAssignmentQuoteImpl.quote:61`;工具已按设计器键补齐并给 warning `rule-graph-assign-keys-defaulted`)。**`dataCondition` = 执行门**(输入数据满足条件才执行该节点,\"输入数据满足条件时触发执行动作\");**`filterCondition` = 按条件在输入数据源中匹配要取值的行**(\"根据此条件在输入数据源中进行匹配。匹配到多条数据时,会按第一条数据执行赋值\")——**两者是两码事,别混**:要\"满足条件才赋值\"用 `dataCondition`,不是 `filterCondition`(后者不拦执行)。`triggerType` ∈ CURRENT(当前数据)/RULE_VARIABLE(规则变量);CURRENT 时 `targetObjectCode` 默认 `\"_input\"`(=当前/输入对象),dataActions 写目标字段;RULE_VARIABLE 时 targetObjectCode 隐藏、dataActions 写 `_var_*`、`orderBys` 有默认 `[{code:\"createdTime\",direction:\"ASC\"}]`。**CURRENT+dataActions 必须排在系统持久化节点(SYSTEM_CREATE/SYSTEM_UPDATE)之前**(Start→ASSIGN→SYSTEM_CREATE→End),否则赋值合并进 _input 晚于持久化、读回无变化(工具 fail closed `rule-graph-assign-persistence-order`;工具注入系统节点时也会自动排到 ASSIGN/BIZ_ACTION 之后)。平台设计器键(SHEET_ASSIGN)required:nodeCode/nodeName/targetObjectCode/ruleVariableDataActions/dataActions/orderBys/addChildDataActions;dataCondition 默认 `{conditionGroup:[]}`、filterCondition 默认 `{mainConditions:[],sheetConditions:[]}`、nonMatchingAdded 默认 false、children/addChildDataActions 默认 []。",
927
958
  template: {
928
959
  nodeCode: "Assign_1", nodeName: "数据赋值", nodeType: "ASSIGN", nodeCategory: "actionNode", triggerType: "CURRENT", inputParam: "_input",
929
960
  dataCondition: { conditionGroup: [] }, dataConditionVisible: false,
@@ -1174,6 +1205,23 @@ export function formulaWarnings(action, nodeCode, index) {
1174
1205
  }
1175
1206
  /** Validate one dataAction; returns blockers (empty = clean). */
1176
1207
  export function validateDataAction(action, nodeCode, index) {
1208
+ // 1.1 (r8 2026-10-22): a DYNAMIC source written as a dotted path is parsed by
1209
+ // DatasetImpl as a CHILD-TABLE column — it collects that column across all
1210
+ // child rows, so a scalar target receives a COLLECTION. The graph saves and
1211
+ // publishes, then the runtime write answers 50000
1212
+ // (WorkSheetBizField.fillClientContent: "Incorrect value type, should be
1213
+ // string or map"). The engine reads a single-hop relation by its bare field
1214
+ // code (takes .id); there is no supported dotted scalar shape.
1215
+ {
1216
+ const source = String(action.currentDataItemValue ?? action.currentSchemaDataItem ?? "").trim();
1217
+ if (String(action.ruleValueType ?? "").toUpperCase() === "DYNAMIC" && source.includes("."))
1218
+ return [{
1219
+ code: "rule-graph-create-dynamic-source-is-child-collection",
1220
+ nodeCode,
1221
+ key: `dataActions[${index}].currentDataItemValue`,
1222
+ message: `${nodeCode}: DYNAMIC 来源 "${source}" 用了点号——引擎把 "x.y" 当**子表列集合**解析(DatasetImpl),不是"关联字段.id",标量目标会收到集合,运行期 submit/update 报 50000(WorkSheetBizField.fillClientContent: Incorrect value type, should be string or map)。单跳关联字段直接用字段 code(引擎取 .id);子表列只用于公式/聚合(FORMULA AGGREGATION),不能作为规则赋值来源。来源:r8 印章沙盒 2026-10-22。`,
1223
+ }];
1224
+ }
1177
1225
  const blockers = [];
1178
1226
  const where = `dataActions[${index}]`;
1179
1227
  const valueType = String(action.ruleValueType ?? "");
@@ -1510,6 +1558,7 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1510
1558
  nodeCode: String(crossModel[0].nodeCode ?? ""),
1511
1559
  key: "targetObjectCode",
1512
1560
  severity: "warning",
1561
+ ...(updateOnlyTrigger ? { hazard: true } : {}),
1513
1562
  message: updateOnlyTrigger
1514
1563
  ? `跨模型写回节点位于预置 Update 规则中:Update 规则只在 data.update 时触发,create/submit 不会执行它(真机 2026-09-28 复核)。要让回写在订单提交时生效,请把该 UPDATE 节点放进本模型的 Create 规则,或放进绑定到流程事件(如流程 end)的规则。`
1515
1564
  : `跨模型写回(目标 "${String(crossModel[0].targetObjectCode)}"):平台支持,运行时生效的两个前提——① 承载它的规则必须真的触发(Create 在提交时触发,Update 只在 data.update 触发;也可绑定流程事件);② 目标行必须已提交(COMMITTED),目标查询不匹配 DRAFT 行,data.save 未提交的行不会被回写。`,
@@ -1756,6 +1805,43 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1756
1805
  walkConditionLeaves(node.targetConditionGroups, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where, warnings, (relaxEnums || carried?.has(String(node.nodeCode ?? "")) === true))));
1757
1806
  scanConditionKeyCollisions(node.targetConditionGroups, nodeCode, warnings);
1758
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
+ }
1759
1845
  }
1760
1846
  if (nodeType === "BRANCH") {
1761
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}。用于需要按部门解析审批人(配合 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")` 或部门引用)时取得真实 code/id。",
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
  {
@@ -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
- const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList })).data;
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
- const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree })).data;
2332
- const list = Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
2333
- const departments = list.map((dept) => ({
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,
@@ -7,6 +7,7 @@ import { RULE_BIZ_RULE_TYPE_DESCRIPTION, RULE_CREATE_BODY, RULE_ROUTES_DESCRIPTI
7
7
  import { validateRuleNodeGraph, prepareRuleGraph, PLATFORM_TRIGGER_RULE_CODES, DEFAULT_SYSTEM_NODE_BY_RULE_CODE } from "./lanes/rule-graph.js";
8
8
  import { compileMethodConfigPlain } from "./bizservice-config.js";
9
9
  import { RESERVED_CODES } from "./code-rules.js";
10
+ import { FIELD_PROPERTY_TYPES } from "./field-families.js";
10
11
  /** Business-rule node graph. Platform trigger rules are addressed by ruleCode: Create / Update / Delete / Available / Cancel / GetList / Load. */
11
12
  export const ruleNodeGraphArg = z
12
13
  .object({
@@ -399,6 +400,108 @@ async function assertBizActionMappingFieldsExist(session, schemaCode, nodes) {
399
400
  * the schema against the authoritative model list BEFORE the first rule wire
400
401
  * call and refuses on miss — zero pollution.
401
402
  */
403
+ /**
404
+ * ASSIGN(数据赋值)is an OLD node — it is not new in 8.7/1.x. What changed is
405
+ * its SHAPE.
406
+ *
407
+ * Evidence (8.6.26 metadata):
408
+ * - `BusinessRuleUpgradeServiceImpl` batch-upgrades stored rules through
409
+ * `UpgradeRuleHandler`;
410
+ * - `UpgradeRuleHandler.upgradeAssignNode()` reads the legacy
411
+ * `triggerObjectCode`, writes `inputParam`+`targetObjectCode`, rewrites
412
+ * `currentDataItemValue` for child paths, and deletes
413
+ * `currentDataModel`/`targetSchemaModel`/`tableConditionGroups`/
414
+ * `tableConditionGroupsVisible`.
415
+ *
416
+ * An upgrade path only exists because older installations already stored ASSIGN
417
+ * in that legacy shape. So: ASSIGN exists before 8.6, the MODERN shape starts at
418
+ * 8.6, and AI-Platform 1.x uses the same modern shape (DataAssignNode in
419
+ * 1.1.25). OMC only writes the modern shape, so on a pre-8.6 environment it must
420
+ * refuse rather than send keys the target will silently misread.
421
+ */
422
+ export function assignNodes(nodes) {
423
+ return nodes
424
+ .filter((node) => String(node.nodeType ?? "") === "ASSIGN")
425
+ .map((node) => String(node.nodeCode ?? "ASSIGN"));
426
+ }
427
+ export function isModernAssignVersion(version) {
428
+ if (version === undefined || version.trim() === "")
429
+ return true;
430
+ const match = /^(\d+)\.(\d+)/u.exec(version.trim());
431
+ if (!match)
432
+ return true;
433
+ const major = Number(match[1]);
434
+ const minor = Number(match[2]);
435
+ if (major >= 1 && major < 8)
436
+ return true;
437
+ return major > 8 || (major === 8 && minor >= 6);
438
+ }
439
+ /**
440
+ * 1.2 (r8 2026-10-22, 1.1.25): a `dataCondition` that compares a text-CHOICE field
441
+ * (RADIO/CHECKBOX/DROPDOWN) with a FIXED value never evaluates — the launcher
442
+ * logs 「解析选框类型数据项的比较值失败」 and the node runs UNCONDITIONALLY (the
443
+ * customer's "异常归还" was written back as if it were "已归还"). This is a
444
+ * platform-boundary behaviour we cannot fix, but shipping a silent always-true
445
+ * gate is worse than warning: emit a non-gating warning naming the field and the
446
+ * supported alternatives (BRANCH, or two rules).
447
+ */
448
+ const CHOICE_CONDITION_TYPES = new Set([12, 13, 14, 15]);
449
+ async function radioConditionWarnings(session, schemaCode, nodes) {
450
+ const sources = new Set();
451
+ const collect = (condition) => {
452
+ if (!condition || typeof condition !== "object")
453
+ return;
454
+ const record = condition;
455
+ const groups = record.dataConditionGroups ?? record.conditionGroup ?? record.execConditionGroups;
456
+ if (!Array.isArray(groups))
457
+ return;
458
+ for (const group of groups) {
459
+ if (!Array.isArray(group))
460
+ continue;
461
+ for (const leaf of group) {
462
+ if (!leaf || typeof leaf !== "object")
463
+ continue;
464
+ const item = leaf;
465
+ if (String(item.ruleValueType ?? "").toUpperCase() !== "FIXED")
466
+ continue;
467
+ const source = String(item.currentSchemaDataItem ?? item.currentDataItemValue ?? "").trim();
468
+ if (source !== "" && !source.includes("."))
469
+ sources.add(source);
470
+ }
471
+ }
472
+ };
473
+ for (const node of nodes)
474
+ collect(node.dataCondition);
475
+ if (sources.size === 0)
476
+ return [];
477
+ let rows;
478
+ try {
479
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
480
+ }
481
+ catch {
482
+ return [];
483
+ }
484
+ const choice = rows.filter((row) => {
485
+ const code = String(row.code ?? row.propertyCode ?? "");
486
+ // The real wire carries `propertyType`; the fake/older projections carry
487
+ // `type`. Accept both (numbers, numeric strings, or family names).
488
+ const rawType = row.propertyType ?? row.type ?? row.typeName;
489
+ const type = typeof rawType === "number" ? rawType : FIELD_PROPERTY_TYPES[String(rawType ?? "").toUpperCase()] ?? Number(rawType);
490
+ return sources.has(code) && CHOICE_CONDITION_TYPES.has(type);
491
+ });
492
+ if (choice.length === 0)
493
+ return [];
494
+ return [
495
+ {
496
+ code: "rule-graph-radio-condition-unparseable",
497
+ nodeCode: "",
498
+ key: "dataCondition",
499
+ severity: "warning",
500
+ hazard: true,
501
+ message: `dataCondition 用 FIXED 值比较文本选择类字段(${choice.map((row) => String(row.name ?? row.code ?? "")).join("、")}):1.1.25 运行时日志「解析选框类型数据项的比较值失败」,条件**不生效**,节点会无条件执行(r8 印章沙盒 2026-10-22:异常归还也被当成已归还回写)。改用 BRANCH 判断该字段,或拆成两条规则;不要把「规则已 ENABLED」当作条件已生效。`,
502
+ },
503
+ ];
504
+ }
402
505
  export async function assertRuleSchemaExists(session, schemaCode) {
403
506
  // Authoritative per-schema probe: GET /bizmodels/get_all (BizSchemaController
404
507
  // .java:615-633) resolves getBizSchemaBySchemaCode and answers
@@ -554,9 +657,16 @@ export async function executeRuleSaveWrite(session, args) {
554
657
  // legacy warning 原样回传);新增/改动节点维持严格。存量永远可编辑。
555
658
  const carriedNodeCodes = await computeCarriedNodeCodes(session, args.schemaCode, args.ruleCode, preparedGraph);
556
659
  const blockers = [...prepared.warnings, ...validateRuleNodeGraph(preparedGraph, args.ruleCode, typeof args.schemaCode === "string" ? args.schemaCode : undefined, carriedNodeCodes ? { carriedNodeCodes } : {})];
660
+ // 1.2: needs the published field types (pure lint cannot see them).
661
+ blockers.push(...(await radioConditionWarnings(session, args.schemaCode, Array.isArray(preparedGraph.nodes) ? preparedGraph.nodes : [])));
557
662
  // Ticket 113: warning-severity findings (e.g. unknown mapping keys) do not
558
663
  // gate the write; they surface in the readback as lintWarnings.
559
- const lintWarnings = blockers.filter((blocker) => blocker.severity === "warning");
664
+ const warningFindings = blockers.filter((blocker) => blocker.severity === "warning");
665
+ // `hazard` warnings stay non-gating but are surfaced separately: the readback
666
+ // must not let an always-true condition or an inert trigger look like a
667
+ // plain advisory.
668
+ const lintWarnings = warningFindings.filter((blocker) => blocker.hazard !== true);
669
+ const lintHazards = warningFindings.filter((blocker) => blocker.hazard === true);
560
670
  if (blockers.some((blocker) => blocker.severity !== "warning")) {
561
671
  throw new ToolRefusalError({
562
672
  status: "refused",
@@ -583,6 +693,25 @@ export async function executeRuleSaveWrite(session, args) {
583
693
  // Ticket 96: phantom-schema gate — BEFORE the first businessrule wire call
584
694
  // (the rule list itself auto-creates preset rows for unknown schemas).
585
695
  await assertRuleSchemaExists(session, args.schemaCode);
696
+ // ASSIGN shape is version-dependent (see isModernAssignVersion).
697
+ const assignCodes = assignNodes(nodes);
698
+ if (assignCodes.length > 0) {
699
+ const version = session.detectedVersion();
700
+ if (!isModernAssignVersion(version)) {
701
+ throw new ToolRefusalError({
702
+ status: "refused",
703
+ blocker: "rule-graph-assign-version-unsupported",
704
+ schemaCode: args.schemaCode,
705
+ ruleCode: args.ruleCode,
706
+ version: version ?? "unknown",
707
+ nodes: assignCodes,
708
+ message: `检测到 CloudPivot ${version ?? "未知版本"}:「数据赋值」在 8.6 之前是旧形状(triggerObjectCode/currentDataModel/targetSchemaModel/tableConditionGroups),` +
709
+ `8.6.26 自带 BusinessRuleUpgradeServiceImpl + UpgradeRuleHandler.upgradeAssignNode 正是用来把旧图升级成 inputParam+targetObjectCode+nonMatchingAdded 的。` +
710
+ `也就是说 ASSIGN 本身不是新节点,**现代形状从 8.6 起**(AI-Platform 1.x 同形)。OMC 只写现代形状,在旧形状环境会静默误读。` +
711
+ `请改用 UPDATE/CREATE 表达同一逻辑,或在该版本的设计器里配置 ASSIGN。`,
712
+ });
713
+ }
714
+ }
586
715
  // Ticket 35: wire existence gate for BizMethod bindings — runs after the
587
716
  // local lint, before the create/update wire call (fail-closed; the platform
588
717
  // itself only gates the SERVICE, never the METHOD, at design time).
@@ -720,6 +849,7 @@ export async function executeRuleSaveWrite(session, args) {
720
849
  enabled,
721
850
  status: effective?.status,
722
851
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
852
+ ...(lintHazards.length > 0 ? { lintHazards } : {}),
723
853
  id: effective?.id ?? undefined,
724
854
  ruleVersion: effective?.ruleVersion,
725
855
  latestPublished: effective?.latestPublished,
@@ -736,6 +866,7 @@ export async function executeRuleSaveWrite(session, args) {
736
866
  published: false,
737
867
  enabled,
738
868
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
869
+ ...(lintHazards.length > 0 ? { lintHazards } : {}),
739
870
  id: effective?.id ?? undefined,
740
871
  ruleVersion: effective?.ruleVersion,
741
872
  latestPublished: effective?.latestPublished,
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.72",
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.72",
53
+ "@stalfh233/omc-fake-platform": "0.4.74",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.72",
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",
@@ -151,7 +151,20 @@ rules:
151
151
  - **两条路**:模型+表单+流程一揽子交付走**意图**(`intent` 的 `workflow` 能力,声明式一次 apply);只动流程或想逐步控制走**低层链** `workflow.create → workflow.update(权限/节点)→ workflow.publish`,标准链配方见 `omc describe workflow.update`(含多级审批/participant 三形态/自动回填说明)。流程权限细节见下节。
152
152
  - 一个模型只能有一个流程;已有流程的模型直接 `workflow.update`,不要另建。
153
153
  - **清理测试数据用批量删除**:`data.delete --args '{"schemaCode":"<code>","bizObjectIds":["id1","id2"]}'` 一次往返删多条(真机 r6:207 次逐条 `data.delete` 占 13.5% 调用)。
154
+ - **「聚合计算」是字段能力,不是规则节点**:FORMULA(计算公式)字段的 `options.formulaConfig.formulaType="AGGREGATION"` 就是 UI 里的「配置方式 → 聚合计算」,跨模型做 SUM/MAX/MIN/COUNT(1.1.25 实测可用,8.6.26 同形)。形状:
155
+ `options = {persistent:true, formulaConfig: JSON字符串({resultType:2, resultFormat:"", formulaType:"AGGREGATION", aggregation: JSON字符串({schemaCode:"<目标模型>", dataItemCode:"<目标字段>", calculateType:"SUM", formula:"SUM({<目标模型>.<字段>})", queryConditions:[[{propertyCode, propertyType, operatorType, propertyValue, valueType:"FIXED"}]]})})}`。
156
+ **`propertyType`/`operatorType` 必须写整数**(Jackson 按 int 反序列化枚举):propertyType 2=NUMERICAL、3=DATE…;operatorType 1=EQ / 2=NEQ / 3=GT / 4=IT / 5=GTEQ / 6=ITEQ / 9=IS_NULL / 10=IS_NOT_NULL …(工具接受名字并自动转成整数)。`queryConditions` 为**空数组时聚合结果是 0**(不是全表求和);目标模型的**草稿行不参与**(实测:DRAFT 行不入计数,提交后 10+32.5→42.5)。已发布的公式字段**不能改配置方式**(field.update 报 -1),要改就新建字段或趁草稿改。
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
+ - **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
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。
154
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` 确认目标行存在且状态已提交。
155
168
  - **取 artifact 先投影**:`artifact get <id> --summary` 看形状 → `--jq '<path>'` 取那一段;只有确实要整份时才裸取(r6:47 次整包拉取 650KB,0 次用投影)。
156
169
  - **读回产物会自动清理,也可手动清**:每次大结果落一个 artifact(机器级目录,跨项目累积;实测一轮沙盒 504 个/3.4MB)。`createArtifact` 每 24h 顺手删 7 天未动的;要立刻回收用 `omc artifact prune --older-than 7d --max-bytes 268435456`(默认预算 256MB,超出按最旧优先删),`--json` 回 removed/freedBytes/kept。
157
170
  - **核验状态用投影,别重复整条读**:`data.load --args '{"schemaCode":..,"bizObjectId":..,"fields":["sequenceStatus","<业务状态字段>"]}'` 只回指定字段(缺失项在 `missingFields`)——r4 实测同一对象被整条读 74 次;大结果先 `artifact get <id> --summary` → `--keys/--jq`,别整包内联进推理。
@@ -211,7 +224,9 @@ rules:
211
224
  规则支持**触发条件**,不要用「两条无条件规则」表达互斥结果:
212
225
 
213
226
  - **节点级条件有两大类,别混**(各节点类型都可能有,完整字段/样例见 `describe rule.save --full` 的 RULE_NODE_TEMPLATES):
214
- - **定位要操作哪些数据**(`filterCondition`:叶 `targetSchemaDataItem`+`ruleConditionType`):UPDATE=改哪条(空=整表)、DELETE=删哪条、CREATE=存在符合则不新增+`findMainData`、QUERY=`targetConditionGroups`、**ASSIGN=在输入数据源匹配要取值的行(不是执行门!)**。⚠️ 非唯一字段(金额等)会命中**多条**;改「当前/关联那一条」用唯一键(`id` 或 `from`)。`actions.filter` 表达这类(同模型省略→默认 `id=id`;跨模型省略 fail-closed)。
227
+ - **`ASSIGN`(数据赋值)不是新节点,新的是它的形状**:8.6.26 自带 `BusinessRuleUpgradeServiceImpl` + `UpgradeRuleHandler.upgradeAssignNode`,职责就是把旧形状(`triggerObjectCode`/`currentDataModel`/`targetSchemaModel`/`tableConditionGroups`)升级为 `inputParam`+`targetObjectCode`+`nonMatchingAdded`——存在升级器就说明 8.6 之前已经有 ASSIGN。**现代形状从 8.6 起**,AI-Platform 1.x 同形;<8.6 存的是旧形状,写现代形状会被静默误读,OMC 直接拒绝(`rule-graph-assign-version-unsupported`)。
228
+ - **`ASSIGN` 的 `targetObjectCode` 是写入目标,不是取数来源**:默认 `_input` = 当前单;`dataActions.targetDataItem` 必须是**该目标模型已发布字段**(写成别的模型 + 本单字段 → 550018)。要"从关联单取值回填本单",就 `targetObjectCode:"_input"` + `currentDataItemValue:"<本单来源字段>"`(关联字段引擎取 .id),不要把它指到对面模型。漏 `nonMatchingAdded` 会让 quote 阶段 NPE(publish 50000),工具已按设计器键补齐(warning `rule-graph-assign-keys-defaulted`)。
229
+ - **定位要操作哪些数据**(`filterCondition`:叶 `targetSchemaDataItem`+`ruleConditionType`):UPDATE=改哪条(空=整表)、DELETE=删哪条、CREATE=存在符合则不新增+`findMainData`、QUERY=`targetConditionGroups`、**ASSIGN=在目标表里匹配要赋值的行(不是执行门!)**。⚠️ 非唯一字段(金额等)会命中**多条**;改「当前/关联那一条」用唯一键(`id` 或 `from`)。`actions.filter` 表达这类(同模型省略→默认 `id=id`;跨模型省略 fail-closed)。
215
230
  - **执行门 / 满足条件触发**(`dataCondition`,BIZ_ACTION 用 `execConditionGroups`):**与定位两码事**——决定「要不要执行」,不决定「改哪条」。compact `actions.when` 表达 UPDATE 执行门(叶 `currentSchemaDataItem`+`ruleDataConditionType`);其它节点用 `nodeGraph`。**条件改自身**:`when: [{field: amount, gt: 10000}]` + 省略 `filter`(默认 `id=id`)——别把条件写进 `filter`,那会改掉所有符合的行(bench66 多行互覆)。
216
231
  - 子表定位 `filterSubCondition`;子表触发范围 `childTriggerConditionType∈ADD/MODIFY/DELETE`。
217
232
  - **触发时机**:数据事件(`Create`/`Update`/`Delete`)**只分发到平台预置 code 行**;流程相关用预置 `Available`(流程生效)/`Cancel`(流程作废),或流程事件绑定 `workflow.bindRule` 的 `end`/`cancel`/`start`/`activate`。