@stalfh233/omc-cli 0.4.72 → 0.4.73

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.
@@ -672,6 +672,59 @@ function refuseFieldOptions(reason, message) {
672
672
  };
673
673
  throw new ToolRefusalError({ status: "refused", blocker: "capability-out-of-coverage", citation });
674
674
  }
675
+ /**
676
+ * Aggregation query conditions are deserialized by Jackson into Java enums that
677
+ * are FIXED-ORDINAL (`BizPropertyType`, `OperatorType`): both must be written as
678
+ * their INTEGER code. A name (“NUMERICAL”/“GTEQ”) makes bizproperty/publish
679
+ * answer 50000 with `InvalidFormatException: Cannot deserialize value of type
680
+ * int from String` (live 2026-10-22, 1.1.25 scratch model). The portal UI writes
681
+ * ints, so accept the readable names and normalize them to the wire code —
682
+ * exactly the kind of adapter this lane already performs for `name_i18n`.
683
+ */
684
+ const AGGREGATION_OPERATOR_CODES = Object.freeze({
685
+ EQ: 1, NEQ: 2, GT: 3, IT: 4, GTEQ: 5, ITEQ: 6,
686
+ CONTAINS: 7, NOT_CONTAINS: 8, IS_NULL: 9, IS_NOT_NULL: 10,
687
+ OF: 11, NOT_OF: 12, HAVE: 13, NOT_HAVE: 14,
688
+ IN: 15, NOT_IN: 16, BETWEEN: 17, NOT_BETWEEN: 18,
689
+ CONTAINS_ALL: 19, NOT_CONTAINS_ALL: 20, CONTAINS_ANY: 21,
690
+ NOT_CONTAINS_ANY: 22, EQ_ANY: 23, NOT_EQ_ANY: 24,
691
+ });
692
+ function normalizeAggregationFormula(agg) {
693
+ const groups = agg.queryConditions;
694
+ if (groups === undefined)
695
+ return agg;
696
+ if (!Array.isArray(groups) || groups.some((group) => !Array.isArray(group)))
697
+ refuseFieldOptions("AGGREGATION aggregation.queryConditions is not an array of arrays", "rejected: aggregation.queryConditions 形状必须是 [[{propertyCode,propertyType(int),operatorType(int),propertyValue,valueType}],…](条件组)");
698
+ const normalized = groups.map((group) => group.map((leaf) => {
699
+ if (!leaf || typeof leaf !== "object" || Array.isArray(leaf))
700
+ refuseFieldOptions("AGGREGATION queryConditions leaf is not an object", "rejected: aggregation.queryConditions 每个条件必须是对​象");
701
+ const record = { ...leaf };
702
+ for (const key of ["propertyType", "currentPropertyType"]) {
703
+ const value = record[key];
704
+ if (value === undefined || value === null)
705
+ continue;
706
+ const code = typeof value === "number" ? value : FIELD_PROPERTY_TYPES[String(value).toUpperCase()];
707
+ if (typeof code !== "number" || !Number.isInteger(code))
708
+ refuseFieldOptions(`AGGREGATION queryConditions.${key} ${JSON.stringify(value)} is not a numeric BizPropertyType code`, `rejected: 聚合条件的 ${key} 必须是**整数**属性类型编码(Jackson 按 int 反序列化,传 "NUMERICAL" 会让 publish 50000);可用名字:${Object.keys(FIELD_PROPERTY_TYPES).join("、")}`);
709
+ record[key] = code;
710
+ }
711
+ const operator = record.operatorType;
712
+ if (operator !== undefined && operator !== null) {
713
+ const code = typeof operator === "number" ? operator : AGGREGATION_OPERATOR_CODES[String(operator).toUpperCase()];
714
+ if (typeof code !== "number" || !Number.isInteger(code) || code < 1 || code > 24)
715
+ refuseFieldOptions(`AGGREGATION queryConditions.operatorType ${JSON.stringify(operator)} is not an OperatorType code`, `rejected: 聚合条件的 operatorType 必须是整数 1–24(或名字:${Object.keys(AGGREGATION_OPERATOR_CODES).join("、")});传 "GTEQ" 会让 publish 50000`);
716
+ record.operatorType = code;
717
+ }
718
+ if (record.valueType === undefined)
719
+ record.valueType = "FIXED";
720
+ if (!["FIXED", "DYNAMIC"].includes(String(record.valueType)))
721
+ refuseFieldOptions(`AGGREGATION queryConditions.valueType ${JSON.stringify(record.valueType)} is not FIXED/DYNAMIC`, 'rejected: 聚合条件的 valueType 只能是 "FIXED" 或 "DYNAMIC"');
722
+ if (record.propertyValue !== undefined && typeof record.propertyValue !== "string")
723
+ record.propertyValue = String(record.propertyValue);
724
+ return record;
725
+ }));
726
+ return { ...agg, queryConditions: normalized };
727
+ }
675
728
  /** FORMULA formulaConfig decode; policy: string-only face, must parse to a non-array object. */
676
729
  function parseOptionsObject(value) {
677
730
  return decodeJsonObjectColumn(value, { invalid: "undefined", array: "reject", object: "reject" });
@@ -843,7 +896,12 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
843
896
  else {
844
897
  const agg = parseOptionsObject(config.aggregation);
845
898
  if (!agg || !isNonEmptyString(agg.schemaCode) || !isNonEmptyString(agg.dataItemCode) || !isNonEmptyString(agg.formula))
846
- refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<子表schemaCode>", dataItemCode:"<子表字段code>", formula:"SUM({<子表schemaCode>.<子表字段code>})", calculateType?:.., queryConditions?:[]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
899
+ refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<目标模型schemaCode>", dataItemCode:"<目标字段code>", formula:"SUM({<目标模型>.<字段>})", calculateType:"SUM", queryConditions:[[…]]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
900
+ const normalizedAgg = normalizeAggregationFormula(agg);
901
+ if (normalizedAgg !== agg) {
902
+ config.aggregation = JSON.stringify(normalizedAgg);
903
+ options.formulaConfig = JSON.stringify(config);
904
+ }
847
905
  }
848
906
  }
849
907
  else if (!isNonEmptyString(config.expression)) {
@@ -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 未提交的行不会被回写。`,
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.72",
3
+ "version": "0.4.73",
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.73",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.72",
55
+ "omc-core": "0.4.73",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -151,6 +151,12 @@ 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 规则或流程事件规则。
154
160
  - **系统持久化节点是平台自带的,不要手写**:预置 `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…)。
155
161
  - **取 artifact 先投影**:`artifact get <id> --summary` 看形状 → `--jq '<path>'` 取那一段;只有确实要整份时才裸取(r6:47 次整包拉取 650KB,0 次用投影)。
156
162
  - **读回产物会自动清理,也可手动清**:每次大结果落一个 artifact(机器级目录,跨项目累积;实测一轮沙盒 504 个/3.4MB)。`createArtifact` 每 24h 顺手删 7 天未动的;要立刻回收用 `omc artifact prune --older-than 7d --max-bytes 268435456`(默认预算 256MB,超出按最旧优先删),`--json` 回 removed/freedBytes/kept。
@@ -211,7 +217,9 @@ rules:
211
217
  规则支持**触发条件**,不要用「两条无条件规则」表达互斥结果:
212
218
 
213
219
  - **节点级条件有两大类,别混**(各节点类型都可能有,完整字段/样例见 `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)。
220
+ - **`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`)。
221
+ - **`ASSIGN` 的 `targetObjectCode` 是写入目标,不是取数来源**:默认 `_input` = 当前单;`dataActions.targetDataItem` 必须是**该目标模型已发布字段**(写成别的模型 + 本单字段 → 550018)。要"从关联单取值回填本单",就 `targetObjectCode:"_input"` + `currentDataItemValue:"<本单来源字段>"`(关联字段引擎取 .id),不要把它指到对面模型。漏 `nonMatchingAdded` 会让 quote 阶段 NPE(publish 50000),工具已按设计器键补齐(warning `rule-graph-assign-keys-defaulted`)。
222
+ - **定位要操作哪些数据**(`filterCondition`:叶 `targetSchemaDataItem`+`ruleConditionType`):UPDATE=改哪条(空=整表)、DELETE=删哪条、CREATE=存在符合则不新增+`findMainData`、QUERY=`targetConditionGroups`、**ASSIGN=在目标表里匹配要赋值的行(不是执行门!)**。⚠️ 非唯一字段(金额等)会命中**多条**;改「当前/关联那一条」用唯一键(`id` 或 `from`)。`actions.filter` 表达这类(同模型省略→默认 `id=id`;跨模型省略 fail-closed)。
215
223
  - **执行门 / 满足条件触发**(`dataCondition`,BIZ_ACTION 用 `execConditionGroups`):**与定位两码事**——决定「要不要执行」,不决定「改哪条」。compact `actions.when` 表达 UPDATE 执行门(叶 `currentSchemaDataItem`+`ruleDataConditionType`);其它节点用 `nodeGraph`。**条件改自身**:`when: [{field: amount, gt: 10000}]` + 省略 `filter`(默认 `id=id`)——别把条件写进 `filter`,那会改掉所有符合的行(bench66 多行互覆)。
216
224
  - 子表定位 `filterSubCondition`;子表触发范围 `childTriggerConditionType∈ADD/MODIFY/DELETE`。
217
225
  - **触发时机**:数据事件(`Create`/`Update`/`Delete`)**只分发到平台预置 code 行**;流程相关用预置 `Available`(流程生效)/`Cancel`(流程作废),或流程事件绑定 `workflow.bindRule` 的 `end`/`cancel`/`start`/`activate`。