@stalfh233/omc-cli 0.4.63 → 0.4.65

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/dist/cli.js CHANGED
@@ -458,6 +458,15 @@ async function cmdArtifactGet(id, transport, options = {}) {
458
458
  if (options.summary) {
459
459
  const record = content && typeof content === "object" && !Array.isArray(content) ? content : undefined;
460
460
  const keys = record ? Object.entries(record).map(([key, value]) => ({ key, bytes: Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8"), kind: Array.isArray(value) ? `array[${value.length}]` : value === null ? "null" : typeof value })).sort((left, right) => right.bytes - left.bytes) : [];
461
+ // `--summary --jq <path>`: shape AND the one value, in a single call. Without
462
+ // this the caller had to spend one round on the shape and another on the
463
+ // path (or pull the whole body); r5 hit that on 67 KB workflow templates.
464
+ if (options.jq !== undefined) {
465
+ const projected = projectJsonPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
466
+ return emitTransportResult(projected.missed
467
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }, fetch: artifactFetchHint(id) }
468
+ : { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: projected.value ?? null } }, transport);
469
+ }
461
470
  return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, fetch: artifactFetchHint(id) }, transport);
462
471
  }
463
472
  if (options.keys !== undefined) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared lint for the platform's formula engine (EXPRESSION actions and
3
+ * dataRuleType-2 CALCULATE carriers).
4
+ *
5
+ * Evidence (AI-Platform v1.1.25): the engine parses `formula` with Aviator and
6
+ * registers exactly the names in `CalculateMethodType` (cloudpivot-shared),
7
+ * one function per code, via `CalculateRuleExecutor`. A name outside that set
8
+ * fails at submit time with `errcode 101005 表达式格式错误`; text concatenation
9
+ * with `+` instead of `STRING(...)` does NOT fail — `+` is numeric addition and
10
+ * the target silently stores null (live 2026-10-20, r4-training: `{a}+"-X"`
11
+ * wrote null while `STRING({a},"-X")` wrote `CNAME-X`).
12
+ *
13
+ * The platform validates nothing at save time, so this lint runs before the
14
+ * wire. Severity is advisory: an unknown name is loud at submit, but the caller
15
+ * should see it at write time rather than after a data probe.
16
+ */
17
+ export const FORMULA_FUNCTIONS = new Set([
18
+ "STRING", "SUBSTRING", "REPLACE", "UPPERMONEY", "IF", "CONTAINS", "GETADDRESS", "LEN",
19
+ "LOWER", "UPPER", "SEARCH", "STARTSWITH", "SUBSTITUTE", "TRIM", "GETUNITNAME", "LEFT",
20
+ "RIGHT", "TEXT", "UNION", "DATE", "COUNTIF", "SUM", "AVG", "MAX", "MIN", "COUNT", "ROUND",
21
+ "INTUP", "INTDOWN", "VALUE", "FIXED", "ABS", "CEILING", "FLOOR", "COS", "SIN", "LARGE",
22
+ "SMALL", "LOG", "MOD", "PI", "POWER", "RAND", "SQRT", "SUMIF", "DATEDIF", "YEAR", "MONTH",
23
+ "DAY", "WEEKNUM", "WEEKDAY", "QUARTER", "HOUR", "MINUTE", "SECOND", "ADDDAY", "ADDMONTH",
24
+ "ADDYEAR", "TODAY", "BETWEEN", "NOTBETWEEN", "ADDHOUR", "ADDMINUTE", "NOW", "CASE", "ISNULL",
25
+ ]);
26
+ /** Names agents reach for that the engine does NOT register, with the real one. */
27
+ export const FORMULA_FUNCTION_ALIASES = Object.freeze({
28
+ CONCAT: "STRING",
29
+ CONCATENATE: "STRING",
30
+ STRCAT: "STRING",
31
+ JOIN: "STRING",
32
+ });
33
+ const TEXT_PLUS_CONCAT = /(\{[^{}]+\}|[A-Za-z_][A-Za-z0-9_]*\s*\([^()]*\))\s*\+\s*["']|["']\s*\+\s*(\{[^{}]+\}|[A-Za-z_][A-Za-z0-9_]*\s*\()/;
34
+ /**
35
+ * Lint one formula string. `where` names the carrier in the caller's own terms
36
+ * (e.g. `dataActions[0].value.formula`), so the message points at the key the
37
+ * caller has to fix.
38
+ */
39
+ export function lintFormulaText(formula, where) {
40
+ if (typeof formula !== "string" || formula.trim().length === 0)
41
+ return [];
42
+ const entries = [];
43
+ for (const match of formula.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) {
44
+ const name = match[1].toUpperCase();
45
+ const alias = FORMULA_FUNCTION_ALIASES[name];
46
+ if (alias) {
47
+ entries.push({
48
+ code: "formula-function-alias",
49
+ severity: "warning",
50
+ functionName: match[1],
51
+ message: `${where}: 公式里的 ${match[1]}() 不是平台函数;文本合并用 ${alias}({a},"-",{b})。别名写法在提交时报 errcode 101005 表达式格式错误。`,
52
+ });
53
+ continue;
54
+ }
55
+ if (FORMULA_FUNCTIONS.has(name))
56
+ continue;
57
+ entries.push({
58
+ code: "formula-function-unknown",
59
+ severity: "warning",
60
+ functionName: match[1],
61
+ message: `${where}: 公式函数 ${match[1]}() 不在平台注册表(CalculateMethodType)——提交时会报 101005 表达式格式错误。文本合并用 STRING();函数表见 cpdocs BusinessRulesTrigger.md「表达式函数」。`,
62
+ });
63
+ }
64
+ if (TEXT_PLUS_CONCAT.test(formula)) {
65
+ entries.push({
66
+ code: "formula-text-plus-concat",
67
+ severity: "warning",
68
+ message: `${where}: 公式用 + 拼接了字符串字面量——+ 是数值加法,文本目标上会静默写 null(真机 2026-10-20)。文本合并用 STRING({a},"-",{b})。`,
69
+ });
70
+ }
71
+ return entries;
72
+ }
73
+ //# sourceMappingURL=formula-lint.js.map
@@ -1,3 +1,4 @@
1
+ import { lintFormulaText } from "../formula-lint.js";
1
2
  import { z } from "zod";
2
3
  import { ENDPOINTS } from "../endpoints.js";
3
4
  import { capabilityArg, environmentArg } from "../args.js";
@@ -396,6 +397,20 @@ export function assertDataRuleOptions(dataRuleType, options, checkType) {
396
397
  assertStitchEditorTextRoundTrips(options);
397
398
  }
398
399
  }
400
+ /**
401
+ * Advisory formula lint for the type-2 CALCULATE carrier. Same registry as the
402
+ * rule-graph EXPRESSION lint (src/formula-lint.ts): an unregistered function
403
+ * name fails at submit with 101005 and `+` on text silently writes null, and
404
+ * the platform validates neither at create time.
405
+ */
406
+ export function dataRuleFormulaWarnings(dataRuleType, options) {
407
+ if (dataRuleType !== 2)
408
+ return [];
409
+ const formula = type2FormulaText(options);
410
+ if (formula === null)
411
+ return [];
412
+ return lintFormulaText(formula, "options.shortTextStitch.formula");
413
+ }
399
414
  /** Extract the expression of a type-2 CALCULATE rule (mirrors calcExpressionShapeError's carrier choice). */
400
415
  function type2FormulaText(options) {
401
416
  const raw = typeof options.shortTextStitch === "string" ? options.shortTextStitch : typeof options.value === "string" ? options.value : undefined;
@@ -751,6 +766,7 @@ export function registerDataRuleLane(tools) {
751
766
  matchedCount: rules.length,
752
767
  rules: rules.map(decodeDataRule),
753
768
  ...(args.dataRuleType >= 3 ? { advisory: DATARULE_FORM_INTERACTION_ADVISORY } : {}),
769
+ ...(() => { const w = dataRuleFormulaWarnings(args.dataRuleType, args.options); return w.length ? { lintWarnings: w } : {}; })(),
754
770
  },
755
771
  };
756
772
  },
@@ -759,7 +775,13 @@ export function registerDataRuleLane(tools) {
759
775
  assertDataRuleTarget(args);
760
776
  assertDataRuleOptions(args.dataRuleType, args.options, args.checkType);
761
777
  assertDataRuleCheckType(args.dataRuleType, args.checkType);
762
- }, undefined, dataruleRollbackSpec("datarule.create"));
778
+ },
779
+ // Dry-run is the cheapest place to see the formula lint (the platform
780
+ // validates nothing at create time; the failure surfaces at submit).
781
+ async (_session, args) => {
782
+ const lintWarnings = dataRuleFormulaWarnings(args.dataRuleType, args.options);
783
+ return lintWarnings.length ? { lintWarnings } : {};
784
+ }, dataruleRollbackSpec("datarule.create"));
763
785
  writeTool(tools, "datarule.update", DATARULE_LANE, DATARULE_TOOLS["datarule.update"].scope, DATARULE_TOOLS["datarule.update"].description, {
764
786
  id: z.string().min(1).optional().describe("数据规则记录 id;省略时可改用业务键 schemaCode + propertyCode(+dataRuleType) 定位"),
765
787
  schemaCode: z.string().min(1),
@@ -10,6 +10,7 @@
10
10
  * call (dry lint) so a mis-shaped graph cannot reach the platform.
11
11
  */
12
12
  export { DYNAMIC_CONDITION_SOURCE_KEY, DYNAMIC_ACTION_SOURCE_KEY } from "omc-core/wire-facts";
13
+ import { lintFormulaText } from "../formula-lint.js";
13
14
  import { DYNAMIC_CONDITION_SOURCE_KEY, DYNAMIC_ACTION_SOURCE_KEY } from "omc-core/wire-facts";
14
15
  import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../json-column.js";
15
16
  export const RULE_VALUE_TYPES = ["DYNAMIC", "FIXED", "EMPTY", "VARIABLE", "EXPRESSION", "SYSTEM", "AGGREGATION"];
@@ -1092,32 +1093,8 @@ export function validateConditionLeaf(leaf, nodeCode, where, warnings, legacyTol
1092
1093
  return blockers;
1093
1094
  }
1094
1095
  /**
1095
- * The formula engine's registered function names — the `CalculateMethodType`
1096
- * enum in cloudpivot-shared (`CalculateRuleExecutor` registers one Aviator
1097
- * function per code). A name outside this set makes the platform answer
1098
- * `errcode 101005 表达式格式错误` at submit time (1.1.25 真机 2026-10-20).
1099
- */
1100
- const FORMULA_FUNCTIONS = new Set([
1101
- "STRING", "SUBSTRING", "REPLACE", "UPPERMONEY", "IF", "CONTAINS", "GETADDRESS", "LEN",
1102
- "LOWER", "UPPER", "SEARCH", "STARTSWITH", "SUBSTITUTE", "TRIM", "GETUNITNAME", "LEFT",
1103
- "RIGHT", "TEXT", "UNION", "DATE", "COUNTIF", "SUM", "AVG", "MAX", "MIN", "COUNT", "ROUND",
1104
- "INTUP", "INTDOWN", "VALUE", "FIXED", "ABS", "CEILING", "FLOOR", "COS", "SIN", "LARGE",
1105
- "SMALL", "LOG", "MOD", "PI", "POWER", "RAND", "SQRT", "SUMIF", "DATEDIF", "YEAR", "MONTH",
1106
- "DAY", "WEEKNUM", "WEEKDAY", "QUARTER", "HOUR", "MINUTE", "SECOND", "ADDDAY", "ADDMONTH",
1107
- "ADDYEAR", "TODAY", "BETWEEN", "NOTBETWEEN", "ADDHOUR", "ADDMINUTE", "NOW", "CASE", "ISNULL",
1108
- ]);
1109
- /** Names agents reach for that the engine does NOT register, with the real one. */
1110
- const FORMULA_FUNCTION_ALIASES = Object.freeze({
1111
- CONCAT: "STRING",
1112
- CONCATENATE: "STRING",
1113
- STRCAT: "STRING",
1114
- JOIN: "STRING",
1115
- });
1116
- /**
1117
- * Advisory lint for EXPRESSION formulas. The platform validates formulas only at
1118
- * submit time and the failure is loud for unknown functions (101005) but SILENT
1119
- * for text arithmetic: `{a}+"-X"` on a text target stores null, because `+` is
1120
- * numeric. Text concatenation must use `STRING(a,b,...)` (真机 2026-10-20).
1096
+ * Advisory lint for EXPRESSION formulas (shared with datarule so both carriers
1097
+ * use one function registry — see src/formula-lint.ts for the 1.1.25 evidence).
1121
1098
  */
1122
1099
  export function formulaWarnings(action, nodeCode, index) {
1123
1100
  if (String(action.ruleValueType ?? "") !== "EXPRESSION")
@@ -1128,20 +1105,13 @@ export function formulaWarnings(action, nodeCode, index) {
1128
1105
  const formula = value.formula;
1129
1106
  if (typeof formula !== "string" || !formula.trim())
1130
1107
  return [];
1131
- const warnings = [];
1132
- const where = `dataActions[${index}]`;
1133
- for (const match of formula.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) {
1134
- const name = match[1].toUpperCase();
1135
- const alias = FORMULA_FUNCTION_ALIASES[name];
1136
- if (alias) {
1137
- warnings.push({ code: "rule-graph-formula-function-alias", nodeCode, key: "value", severity: "warning", message: `${where}: 公式里的 ${match[1]}() 不是平台函数;文本合并用 ${alias}({a},"-",{b})。别名写法在提交时报 errcode 101005 表达式格式错误。` });
1138
- continue;
1139
- }
1140
- if (FORMULA_FUNCTIONS.has(name))
1141
- continue;
1142
- warnings.push({ code: "rule-graph-formula-function-unknown", nodeCode, key: "value", severity: "warning", message: `${where}: 公式函数 ${match[1]}() 不在平台注册表(CalculateMethodType)——提交时会报 101005 表达式格式错误。可用函数见 omc help omc-business-configuration 或 cpdocs BusinessRulesTrigger.md「表达式函数」。` });
1143
- }
1144
- return warnings;
1108
+ return lintFormulaText(formula, `dataActions[${index}].value.formula`).map((entry) => ({
1109
+ code: entry.code,
1110
+ nodeCode,
1111
+ key: "value",
1112
+ severity: "warning",
1113
+ message: entry.message,
1114
+ }));
1145
1115
  }
1146
1116
  /** Validate one dataAction; returns blockers (empty = clean). */
1147
1117
  export function validateDataAction(action, nodeCode, index) {
@@ -1414,6 +1384,56 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1414
1384
  // The graph shape (DYNAMIC filter `currentSchemaDataItem` → target `id`;
1415
1385
  // DYNAMIC action `currentDataItemValue`) is correct as-is. Surface the two
1416
1386
  // prerequisites as a non-gating note rather than the false "unverified" claim.
1387
+ // A BRANCH that fans out to TWO cross-model UPDATE nodes answers 50000 at
1388
+ // write/run time (r5-asset 2026-10-20: the "完好/损坏 → 各自回写资产台账"
1389
+ // shape failed with 50000). Loud, but only after the whole rule is written, so
1390
+ // warn before the wire and name the supported alternative.
1391
+ if (schemaCode !== undefined && schemaCode.length > 0 && Array.isArray(routes)) {
1392
+ const updates = new Map();
1393
+ for (const node of nodeRecords) {
1394
+ if (String(node.nodeType ?? "") !== "UPDATE")
1395
+ continue;
1396
+ const target = String(node.targetObjectCode ?? "").trim();
1397
+ if (target === "" || target === schemaCode)
1398
+ continue;
1399
+ updates.set(String(node.nodeCode ?? ""), { nodeCode: String(node.nodeCode ?? ""), target });
1400
+ }
1401
+ const branches = nodeRecords.filter((node) => String(node.nodeType ?? "") === "BRANCH");
1402
+ const fanOut = [];
1403
+ for (const branch of branches) {
1404
+ const start = String(branch.nodeCode ?? "");
1405
+ const queue = [start];
1406
+ const seen = new Set();
1407
+ const hit = [];
1408
+ while (queue.length > 0) {
1409
+ const current = queue.shift();
1410
+ if (seen.has(current))
1411
+ continue;
1412
+ seen.add(current);
1413
+ if (current !== start && updates.has(current))
1414
+ hit.push(current);
1415
+ for (const route of routes) {
1416
+ if (!route || typeof route !== "object")
1417
+ continue;
1418
+ if (String(route.preNode ?? "") === current) {
1419
+ const post = String(route.postNode ?? "");
1420
+ if (post !== "")
1421
+ queue.push(post);
1422
+ }
1423
+ }
1424
+ }
1425
+ if (new Set(hit).size > 1)
1426
+ fanOut.push(`${start} → ${[...new Set(hit)].join("、")}`);
1427
+ }
1428
+ if (fanOut.length > 0)
1429
+ blockers.push({
1430
+ code: "rule-graph-branch-multi-cross-model-update",
1431
+ severity: "warning",
1432
+ nodeCode: String(branches[0]?.nodeCode ?? ""),
1433
+ key: "routes",
1434
+ message: `分支节点后接多个跨模型 UPDATE(${fanOut.join(";")})——真机 2026-10-20 该组合在写/运行时报 50000。改成:① 单个跨模型 UPDATE + 条件动作(dataActions 内按条件区分),或 ② 通过 BIZ_ACTION 自调二开接口一次处理多行。`,
1435
+ });
1436
+ }
1417
1437
  if (schemaCode !== undefined && schemaCode.length > 0) {
1418
1438
  const writeTypes = new Set(["UPDATE", "CREATE", "DELETE"]);
1419
1439
  const crossModel = nodeRecords.filter((node) => {
@@ -1538,13 +1558,18 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1538
1558
  // logged 「查找范围不存在数据」 and the field kept its old value; moving the
1539
1559
  // same node after SYSTEM_CREATE wrote it. This is the one action type that
1540
1560
  // must sit AFTER persistence (ASSIGN/BIZ_ACTION must sit before).
1541
- if (String(systemNode.nodeType ?? "") === "SYSTEM_CREATE" && schemaCode) {
1561
+ if ((String(systemNode.nodeType ?? "") === "SYSTEM_CREATE" || String(systemNode.nodeType ?? "") === "SYSTEM_UPDATE") && schemaCode) {
1542
1562
  const selfUpdateIndex = nodeRecords.findIndex((node) => {
1543
1563
  const target = String(node.targetObjectCode ?? "");
1544
1564
  return String(node.nodeType) === "UPDATE" && (target === "" || target === schemaCode);
1545
1565
  });
1546
- if (selfUpdateIndex >= 0 && selfUpdateIndex < systemIndex)
1547
- blockers.push({ code: "rule-graph-update-before-persistence-order", nodeCode: String(nodeRecords[selfUpdateIndex].nodeCode ?? ""), key: "nodes", message: `Create 规则里的自更新 UPDATE 必须排在 SYSTEM_CREATE 之后(Start→SYSTEM_CREATE→UPDATE→End)——排在前面时规则能存能启用,但运行时查不到触发行(日志「查找范围不存在数据」),字段静默不写回(真机 2026-10-20 前后对照)。把它挪到系统节点之后。` });
1566
+ if (selfUpdateIndex >= 0 && selfUpdateIndex < systemIndex) {
1567
+ const systemType = String(systemNode.nodeType ?? "");
1568
+ const detail = systemType === "SYSTEM_CREATE"
1569
+ ? "Create 触发时触发行尚未落库,按 id 查不到(日志「查找范围不存在数据」),字段静默不写回"
1570
+ : "Update 触发时随后的 SYSTEM_UPDATE 会用本次提交值覆盖刚写的值(真机 2026-10-20:UPDATE 在前 note 落回提交值,在后才保留 UPDATE 值)";
1571
+ blockers.push({ code: "rule-graph-update-before-persistence-order", nodeCode: String(nodeRecords[selfUpdateIndex].nodeCode ?? ""), key: "nodes", message: `自更新 UPDATE 必须排在 ${systemType} 之后(Start→${systemType}→UPDATE→End)——排在前面时规则能存能启用,但${detail}。把它挪到系统节点之后。` });
1572
+ }
1548
1573
  }
1549
1574
  }
1550
1575
  // inputParam chaining: only "_input" or "_<existing nodeCode>_output".
@@ -1601,6 +1626,54 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1601
1626
  const actions = Array.isArray(node.dataActions) ? node.dataActions : [];
1602
1627
  if (actions.length === 0)
1603
1628
  blockers.push({ code: "rule-graph-update-without-actions", nodeCode, key: "dataActions", message: "UPDATE 节点至少需要 1 个 dataAction" });
1629
+ // ---- cross-model UPDATE shapes (source: AI-Platform v1.1.25
1630
+ // UpdateServiceExecutor + ActionMapping, re-decompiled 2026-10-20) ----
1631
+ // targetObjectCode WITHOUT a dot = 目标主表; WITH a dot =
1632
+ // "<目标主表>.<目标子表>" (ActionMapping.targetIsMainSchema splits on '.').
1633
+ // Execution: getUpdatedMainBizObject() queries the TARGET main table once
1634
+ // with mainConditions ONLY; sheetConditions is read only by
1635
+ // doUpdateChildData() for the target child. So:
1636
+ // (a) a source child field (currentSchemaDataItem "child.field") is read
1637
+ // as a COLLECTION (DatasetImpl) and still produces ONE target query —
1638
+ // there is no per-source-child-row fan-out. Writing that shape looks
1639
+ // right, publishes, and silently matches nothing (r5-asset 2026-10-20:
1640
+ // id = asset_apply_items.asset updated zero rows).
1641
+ // (b) sheetConditions on a MAIN target is dead config.
1642
+ const targetCode = String(node.targetObjectCode ?? "").trim();
1643
+ const targetIsChild = targetCode.includes(".");
1644
+ const targetMain = targetIsChild ? targetCode.split(".")[0] : targetCode;
1645
+ const filterRecord = (node.filterCondition && typeof node.filterCondition === "object" ? node.filterCondition : {});
1646
+ const mainGroups = Array.isArray(filterRecord.mainConditions) ? filterRecord.mainConditions : [];
1647
+ const childSourcedLeaves = [];
1648
+ for (const group of mainGroups) {
1649
+ if (!Array.isArray(group))
1650
+ continue;
1651
+ for (const leaf of group) {
1652
+ if (!leaf || typeof leaf !== "object")
1653
+ continue;
1654
+ const record = leaf;
1655
+ if (String(record.ruleValueType ?? "").toUpperCase() !== "DYNAMIC")
1656
+ continue;
1657
+ const source = String(record.currentSchemaDataItem ?? "").trim();
1658
+ if (source.includes("."))
1659
+ childSourcedLeaves.push(`${String(record.targetSchemaDataItem ?? "?")} = ${source}`);
1660
+ }
1661
+ }
1662
+ if (!targetIsChild && targetMain !== schemaCode && childSourcedLeaves.length > 0)
1663
+ blockers.push({
1664
+ code: "rule-graph-cross-model-child-source-fanout",
1665
+ nodeCode,
1666
+ key: "filterCondition.mainConditions",
1667
+ message: `${nodeCode}: 跨模型 UPDATE 的定位条件用了点号来源(${childSourcedLeaves.join("、")})——1.1.25 的 DatasetImpl 把 "x.y" 当作**子表路径**解析(parts[0] 找子表、收集该列所有行的值),既不是"关联字段.id",也不会按每行各更新一次:目标主表仍只查询一次,结果 0 行命中(r5/r6 真机:保存发布全绿、字段不变)。单跳关联字段直接用字段 code(引擎会取关联对象的 id,真机 2026-10-21 已验证);多跳(a.b)没有可用形状——把目标 id 用表单映射落到本模型的一个字段上再引用它,或走二开。`,
1668
+ });
1669
+ if (!targetIsChild && Array.isArray(filterRecord.sheetConditions) && filterRecord.sheetConditions.length > 0)
1670
+ blockers.push({
1671
+ code: "rule-graph-sheet-conditions-ignored-for-main-target",
1672
+ nodeCode,
1673
+ key: "filterCondition.sheetConditions",
1674
+ severity: "warning",
1675
+ message: `${nodeCode}: targetObjectCode "${targetCode}" 是主表目标,sheetConditions 只在目标是 "<主表>.<子表>" 时被 doUpdateChildData 读取——这里不会生效(UpdateServiceExecutor 只把 mainConditions 传给目标主表查询)。要筛目标子表请把 targetObjectCode 写成点号路径。`,
1676
+ });
1604
1677
  actions.forEach((action, index) => {
1605
1678
  if (action && typeof action === "object") {
1606
1679
  blockers.push(...validateDataAction(action, nodeCode, index));
@@ -290,6 +290,25 @@ async function portalRequiredViolations(session, schemaCode, sheetCode, formData
290
290
  * carrying its own `id` is "Modified", an id-less row is "Added"; an explicit
291
291
  * rowStatus is preserved verbatim.
292
292
  */
293
+ /**
294
+ * Paths of non-empty child-table arrays anywhere inside a form payload. The
295
+ * platform appends every child row it finds on an approval submit, regardless
296
+ * of nesting, so detection must not stop at the top level.
297
+ */
298
+ function collectChildArrayPaths(value, childCodes, prefix = "") {
299
+ if (value === null || typeof value !== "object")
300
+ return [];
301
+ if (Array.isArray(value))
302
+ return [];
303
+ const found = [];
304
+ for (const [key, child] of Object.entries(value)) {
305
+ const path = prefix ? `${prefix}.${key}` : key;
306
+ if (childCodes.has(key) && Array.isArray(child) && child.length > 0)
307
+ found.push(path);
308
+ found.push(...collectChildArrayPaths(child, childCodes, path));
309
+ }
310
+ return found;
311
+ }
293
312
  async function stampChildRowStatuses(session, schemaCode, formData) {
294
313
  const childCodes = new Set();
295
314
  try {
@@ -935,10 +954,11 @@ export function registerRuntimeDataLane(tools) {
935
954
  // and 30000 into 90000 while every call answered errcode 0 — node-level
936
955
  // `editable:false` does not stop it server-side. Refuse before the wire.
937
956
  if ((hasWorkItem || hasInstance) && args.allowChildRowAppend !== true) {
938
- const carried = stampedSubmit.childCodes.filter((code) => {
939
- const value = args.formData[code];
940
- return Array.isArray(value) && value.length > 0;
941
- });
957
+ // Scan at ANY depth: callers wrap the form as {data:{...}} or
958
+ // {bizObject:{data:{...}}}, and a top-level-only check let those through
959
+ // (the platform appends whatever it finds). The child codes come from the
960
+ // model's own field list.
961
+ const carried = collectChildArrayPaths(args.formData, new Set(stampedSubmit.childCodes));
942
962
  if (carried.length > 0)
943
963
  throw new ToolRefusalError({
944
964
  status: "refused",
@@ -85,6 +85,57 @@ const WORKFLOW_ROUTE_CONDITION_KEYS = ["text", "formula", "utilizeElse", "fixedP
85
85
  * and the line silently never fires (the same class of silent no-op that bit
86
86
  * checkType=2). Only caller-supplied `routes`/`rules` are checked.
87
87
  */
88
+ /**
89
+ * AI-Platform 1.1.25 rejects a template whose first activity after START is not
90
+ * the originator fill node: WorkflowTemplateValidateUtility.validateStartActivity
91
+ * emits 401035 「发起节点的参与者只能是{Originator}」 and publish answers
92
+ * errcode 0 with a ValidateMessage[] (nothing is stored). Live 2026-10-21: a
93
+ * role-approval node placed directly after START failed publish with exactly
94
+ * that code, and the generic message did not name the cause.
95
+ */
96
+ function assertFirstActivityIsOriginator(template, tool, merged) {
97
+ // Only gate AUTHORING intent: the caller must have sent both a START node and
98
+ // routes. A partial/stored-draft publish is left to the platform validator
99
+ // (401035), which the tool already surfaces faithfully.
100
+ const callerRows = callerActivities(template);
101
+ const callerRoutes = template.routes ?? template.rules ?? template.edges;
102
+ if (!Array.isArray(callerRows) || !Array.isArray(callerRoutes))
103
+ return;
104
+ if (!callerRows.some((activity) => String(activity.activityType ?? activity.type ?? "") === "START"))
105
+ return;
106
+ // Assert on the MERGED body: participant shorthands ({type:Originator,name:…})
107
+ // are normalized to "{Originator}" there.
108
+ const activities = callerActivities(merged ?? template);
109
+ const rawRoutes = merged ? (merged.routes ?? merged.rules ?? merged.edges) : callerRoutes;
110
+ if (!Array.isArray(activities) || !Array.isArray(rawRoutes))
111
+ return;
112
+ const start = activities.find((activity) => String(activity.activityType ?? activity.type ?? "") === "START");
113
+ if (!start)
114
+ return;
115
+ const startCode = activityCodeOf(start);
116
+ const first = rawRoutes
117
+ .map((entry) => (entry ?? {}))
118
+ .find((route) => String(route.preNode ?? route.preActivityCode ?? route.pre ?? route.from ?? "") === startCode);
119
+ if (!first)
120
+ return;
121
+ const nextCode = String(first.postNode ?? first.postActivityCode ?? first.post ?? first.to ?? "");
122
+ const next = activities.find((activity) => activityCodeOf(activity) === nextCode);
123
+ if (!next)
124
+ return;
125
+ const type = String(next.activityType ?? next.type ?? "");
126
+ const participant = String(next.participant ?? "").trim();
127
+ if (type === "PARTICIPANT" && /^\{Originator\}$/i.test(participant))
128
+ return;
129
+ throw new ToolRefusalError({
130
+ status: "refused",
131
+ blocker: "workflow-first-activity-not-originator",
132
+ workflowCode: String(template.workflowCode ?? template.code ?? ""),
133
+ activityCode: nextCode,
134
+ message: `${tool}: START 之后的第一个节点必须是参与者为 {Originator} 的填写节点(当前是 ${type || "?"}「${String(next.activityName ?? next.name ?? nextCode)}」${participant ? `,参与者 ${participant}` : ""})。` +
135
+ `平台校验 WorkflowTemplateValidateUtility.validateStartActivity 对此报 401035「发起节点的参与者只能是{Originator}」,publish 回 errcode 0 但一个 ValidateMessage[] 就把模板挡在门外、不落库(真机 2026-10-21)。` +
136
+ `正确形状:Start → 填写({Originator}) → 审批/条件分支 … → End;审批节点不能直接接在 START 后。`,
137
+ });
138
+ }
88
139
  function validateWorkflowRouteConditions(template, tool) {
89
140
  const raw = template.routes ?? template.rules ?? template.edges;
90
141
  if (!Array.isArray(raw))
@@ -482,6 +533,84 @@ function mapCallerRules(callerRoutes, draftRules, activities) {
482
533
  * `org.role.list` / `org.department.list` first. Advisory (never blocks): the
483
534
  * platform accepts the save, so this is a readback lint, not a write gate.
484
535
  */
536
+ /**
537
+ * Participant FUNCTION names that the runtime does not register. Read-back and
538
+ * seeded templates carry them (the CRM seed uses FindUsersSpecByRoleFunction),
539
+ * and 1.1.25 only ships FindUserByRoleFunction — the others throw
540
+ * FunctionNotFoundException inside ParticipantActivityRuntime, so the instance
541
+ * stalls in PROCESSING with no work item while every API call answers errCode 0.
542
+ * A warning was not enough (r4 lost ~200 rounds to it), so publish refuses.
543
+ */
544
+ export function unregisteredParticipantFunctions(template) {
545
+ const activities = Array.isArray(template.activities) ? template.activities : [];
546
+ const found = [];
547
+ for (const activity of activities) {
548
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
549
+ continue;
550
+ const participant = String(activity.participant ?? "").trim();
551
+ const match = /^(FindUsersByRoleFunction|FindUsersSpecByRoleFunction)\s*\(/.exec(participant);
552
+ if (match)
553
+ found.push({ activityCode: String(activity.activityCode ?? ""), functionName: match[1] });
554
+ }
555
+ return found;
556
+ }
557
+ /**
558
+ * The designer default OMC backfills is `rejectToStart:true` on approval nodes
559
+ * (see backfillDesignerDefaults). With that flag, rejecting returns the instance
560
+ * to the fill node and the flow keeps running: endEventHandler and
561
+ * cancelEventHandler never fire (live 2026-10-20, r5-asset — instance stayed
562
+ * PROCESSING, todo back at A2, status still 审批中). A rule bound to `cancel`
563
+ * therefore silently never runs. Advisory: reject-to-start is a legitimate
564
+ * choice, only "I bound a cancel rule and expect it to run" is broken.
565
+ */
566
+ /**
567
+ * A delta that omits `routes` keeps the draft's edges (mergeCallerDelta /
568
+ * completeWorkflowChain). That is the right default, but it is invisible: after
569
+ * 0.4.63's fix a branched draft survives an activities-only update, and a caller
570
+ * has no way to tell "I preserved 5 edges including the else fallback" from
571
+ * "the platform rebuilt a linear chain". Report the inherited branch edges.
572
+ */
573
+ function inheritedBranchEdges(template) {
574
+ const rows = Array.isArray(template.activities) ? template.activities : [];
575
+ const rules = Array.isArray(template.rules) ? template.rules : [];
576
+ const outbound = new Map();
577
+ for (const rule of rules) {
578
+ const pre = String(rule.preActivityCode ?? "");
579
+ const post = String(rule.postActivityCode ?? "");
580
+ if (pre === "" || post === "")
581
+ continue;
582
+ outbound.set(pre, [...(outbound.get(pre) ?? []), post]);
583
+ }
584
+ const nameOf = new Map(rows.map((row) => [String(row.activityCode ?? ""), String(row.activityName ?? "")]));
585
+ return [...outbound.entries()]
586
+ .filter(([, targets]) => targets.length > 1)
587
+ .map(([pre, targets]) => `${pre}${nameOf.get(pre) ? `(${nameOf.get(pre)})` : ""} → ${targets.join(" | ")}`);
588
+ }
589
+ export function rejectToStartCancelWarnings(template) {
590
+ const activities = Array.isArray(template.activities) ? template.activities : [];
591
+ const blocking = activities
592
+ .filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT")
593
+ .filter((activity) => {
594
+ const action = activity.permittedAction;
595
+ if (action === undefined || action === null || typeof action !== "object")
596
+ return true; // designer default -> true
597
+ return action.rejectToStart !== false;
598
+ })
599
+ .map((activity) => String(activity.activityCode ?? ""));
600
+ if (blocking.length === 0)
601
+ return [];
602
+ const cancel = template.cancelEventHandler;
603
+ const cancelRules = cancel && typeof cancel === "object" ? cancel.bizActions : undefined;
604
+ const bound = Array.isArray(cancelRules) ? cancelRules.length : 0;
605
+ if (bound === 0)
606
+ return [];
607
+ return [{
608
+ code: "workflow-reject-to-start-suppresses-cancel-handler",
609
+ severity: "warning",
610
+ message: `cancelEventHandler 绑了 ${bound} 条规则,但审批节点 ${blocking.join("、")} 的 rejectToStart 为 true(设计器默认):驳回会把实例退回填写节点并继续运行,end/cancel 处理器都不触发,这些规则不会执行(r5-asset 真机:实例仍 PROCESSING、待办回 A2)。` +
611
+ `两种改法:① 在这个节点的 permittedAction 里显式传 rejectToStart:false(驳回即结束,cancel 生效);② 驳回后的状态改成由发起人手工处理或二开监听。`,
612
+ }];
613
+ }
485
614
  function participantReferenceWarnings(template) {
486
615
  const activities = Array.isArray(template.activities) ? template.activities : [];
487
616
  const warnings = [];
@@ -1740,6 +1869,7 @@ export function registerWorkflowLane(tools) {
1740
1869
  const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
1741
1870
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
1742
1871
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
1872
+ assertFirstActivityIsOriginator(template, "workflow.update", body);
1743
1873
  validateWorkflowRender(body, "workflow.update");
1744
1874
  // Live: workflow/update does NOT run the template validator — it stores a
1745
1875
  // refused participant silently and only /publish answers the
@@ -1751,8 +1881,16 @@ export function registerWorkflowLane(tools) {
1751
1881
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
1752
1882
  validateWorkflowRender(data, "workflow.update readback");
1753
1883
  const participantWarnings = participantReferenceWarnings(data);
1884
+ // Evaluate on `body` (post-backfill): the readback may drop the handler
1885
+ // keys, and the backfilled permittedAction is exactly what will run.
1886
+ const rejectWarnings = rejectToStartCancelWarnings(body);
1754
1887
  const renamedActivities = renamedActivityPairs(template, data);
1755
1888
  const renamedInPlace = inPlaceRenames(data);
1889
+ // Caller sent no routes at all: the draft's edges (branches included) are
1890
+ // what rides the wire. Say so, and name the branch edges, because the only
1891
+ // other trace is a missing edge in a later getPublished readback.
1892
+ const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
1893
+ const branchEdges = callerSentRoutes ? [] : inheritedBranchEdges(data);
1756
1894
  // 改名验收的权威读数:每个节点的当前显示名(platform may keep
1757
1895
  // activityName or sync it; effectiveActivityName resolves both shapes).
1758
1896
  const rawActivityRows = Array.isArray(data.activities)
@@ -1763,7 +1901,7 @@ export function registerWorkflowLane(tools) {
1763
1901
  const activityNames = Object.fromEntries(rawActivityRows
1764
1902
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
1765
1903
  .filter(([code]) => code !== ""));
1766
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, ...(participantWarnings.length ? { participantWarnings } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1904
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1767
1905
  }, (args) => {
1768
1906
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
1769
1907
  // governed pipeline — including the rollback before-capture read.
@@ -1782,6 +1920,19 @@ export function registerWorkflowLane(tools) {
1782
1920
  // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
1783
1921
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
1784
1922
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
1923
+ assertFirstActivityIsOriginator(template, "workflow.publish", body);
1924
+ const unregistered = unregisteredParticipantFunctions(body);
1925
+ if (unregistered.length > 0)
1926
+ throw new ToolRefusalError({
1927
+ status: "refused",
1928
+ blocker: "workflow-participant-function-unregistered",
1929
+ workflowCode: args.code,
1930
+ activities: unregistered,
1931
+ message: `流程发布被拒:参与人函数 ${[...new Set(unregistered.map((entry) => entry.functionName + "()"))].join("、")} 在 AI-Platform v1.1.25 未注册(节点 ${unregistered.map((entry) => entry.activityCode).join("、")})。` +
1932
+ `发布能过,但运行期 ParticipantActivityRuntime 抛 FunctionNotFoundException:实例卡 PROCESSING、不生成待办,而 data.submit 仍回 errCode 0(r4-training 真机)。` +
1933
+ `内置函数只有 FindUserByRole({Originator.OU},"<roleCode>","random");跨部门/全局角色改用参与者对象数组(角色 id)。` +
1934
+ `这两个名字常见于 workflow.get_published 读回与种子流程,不要回抄。`,
1935
+ });
1785
1936
  validateWorkflowRender(body, "workflow.publish");
1786
1937
  const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
1787
1938
  // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
@@ -1806,7 +1957,8 @@ export function registerWorkflowLane(tools) {
1806
1957
  });
1807
1958
  validateWorkflowRender(data, "workflow.publish readback");
1808
1959
  const participantWarnings = participantReferenceWarnings(data);
1809
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1960
+ const rejectWarnings = rejectToStartCancelWarnings(data);
1961
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1810
1962
  }, (args) => {
1811
1963
  assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
1812
1964
  }, undefined, workflowRollbackSpec("workflow.publish"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.63",
3
+ "version": "0.4.65",
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.63",
53
+ "@stalfh233/omc-fake-platform": "0.4.65",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.63",
55
+ "omc-core": "0.4.65",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -151,6 +151,8 @@ rules:
151
151
  - **两条路**:模型+表单+流程一揽子交付走**意图**(`intent` 的 `workflow` 能力,声明式一次 apply);只动流程或想逐步控制走**低层链** `workflow.create → workflow.update(权限/节点)→ workflow.publish`,标准链配方见 `omc describe workflow.update`(含多级审批/participant 三形态/自动回填说明)。流程权限细节见下节。
152
152
  - 一个模型只能有一个流程;已有流程的模型直接 `workflow.update`,不要另建。
153
153
  - **核验状态用投影,别重复整条读**:`data.load --args '{"schemaCode":..,"bizObjectId":..,"fields":["sequenceStatus","<业务状态字段>"]}'` 只回指定字段(缺失项在 `missingFields`)——r4 实测同一对象被整条读 74 次;大结果先 `artifact get <id> --summary` → `--keys/--jq`,别整包内联进推理。
154
+ - **流程第一个节点必须是 `{Originator}` 填写节点**:平台 `WorkflowTemplateValidateUtility.validateStartActivity` 对「START 后直接接审批」报 401035「发起节点的参与者只能是{Originator}」,publish 回 errcode 0 但一个 ValidateMessage[] 就把模板挡在门外、不落库(真机 2026-10-21)。OMC 0.4.65 起在 `workflow.update`/`publish` 出门前拒绝(`workflow-first-activity-not-originator`)。
155
+ - **跨模型回写的目标行必须已提交(COMPLETED)**:DRAFT 行不参与目标查询,运行 0 行命中且日志只有「查找范围不存在数据」。单跳关联字段可直接作 DYNAMIC 来源(引擎取关联对象 id);点号路径按子表解析,`关联字段.id` / 两跳路径都不支持。
154
156
  - **参与人解析失败分两种,别当成同一种(1.1.25 实测)**:① **解析为空**(角色零成员、成员未挂部门、自由文本、角色码不存在)→ 引擎把任务**退回发起人**,流程继续,症状是「审批人显示成我自己」,`data.submit` 与 `workitem.list` 都正常;② **参与人函数未注册**(`FindUsersByRoleFunction` / `FindUsersSpecByRoleFunction`,1.1.25 未内置)→ `FunctionNotFoundException`、不生成待办、实例卡 PROCESSING 而接口仍回 `errCode 0`。区分用 `omc call workflow.instanceActivity --args '{"workflowInstanceId":"<id>"}'`:无 `status:1` 节点=卡住;`status:1` 且参与人全是发起人=`diagnosis.warnings` 给 `participant-fallback-to-originator`。不要从 `workflow.get_published` 的摘要回抄函数式参与人;内置写法只有 `FindUserByRole(org, roleCode[, order])`,跨部门/全局角色优先用参与者对象数组(角色 id)。
155
157
 
156
158
  ## 流程字段权限
@@ -133,10 +133,23 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
133
133
  **只传主表字段**;确需追加行时显式 `allowChildRowAppend:true`(0.4.63 起默认拒绝,blocker `submit-approval-child-rows`)。
134
134
  收尾时前后各读一次子表行数确认未变。
135
135
 
136
- ## 跨表回写只能取主表字段(真机 2026-10-20)
136
+ ## 跨表回写的真实语义(1.1.25 源码 + 真机 2026-10-20)
137
+
138
+ `targetObjectCode` 的**点号是目标侧路径**,不是来源字段路径:
139
+
140
+ | 写法 | 语义 | 执行 |
141
+ |------|------|------|
142
+ | `"asset_ledger"` | 目标主表 | 先按 `mainConditions` 查目标主表(**只查一次**),再逐行改 |
143
+ | `"asset_apply.asset_apply_items"` | 目标主表下的子表 | 先查目标主表,再用 `sheetConditions` 在**已查出的主表 ID 范围内**筛目标子表行 |
144
+
145
+ 对应源码:`UpdateServiceExecutor.getUpdatedMainBizObject()` 只把 `mainConditions` 交给目标主表查询;`sheetConditions` 只在 `doUpdateChildData()` 里被读取。
146
+
147
+ 两个前提(真机 2026-10-21):① **目标行必须已提交**——DRAFT 行不参与目标查询,`data.save` 建的行永远改不到,日志只有「查找范围不存在数据」;② **单跳关联字段可直接作 DYNAMIC 来源**(引擎取关联对象 id),点号路径按子表解析,`关联字段.id` 与两跳都不支持——用表单映射把目标 id 落到本模型字段再引用。
148
+
149
+ 由此得到四条使用纪律:
150
+
151
+ 1. **主表目标上的 `sheetConditions` 是死配置**——不会生效(OMC 0.4.64 起给 warning)。
152
+ 2. **来源子表字段是集合,不是逐行扇出**:`currentSchemaDataItem:"items.asset"` 会被解析成该子表所有行的值集合,目标主表仍只查询一次。写这个形状能存能发布,但运行 0 行命中(r5-asset 真机)。OMC 0.4.64 起 `rule-graph-cross-model-child-source-fanout` **拒绝**。
153
+ 3. **一张单多行逐件回写**(每条明细各改一条另一模型的主表行)没有可用的纯配置形状——走 BIZ_ACTION 自调二开接口,一次处理多行;单行回写用主表字段做 DYNAMIC 来源。
154
+ 4. **BRANCH 后接两个跨表 UPDATE** 真机报 50000;改用单个 UPDATE + 条件动作,或二开。
137
155
 
138
- - `id = {子表code}.asset` / `...asset.id`:保存发布全绿,**运行不命中**,无日志。
139
- - 请求体直接引用子表关联(CREATE 的 dataActions 等):提交报 `550010 业务规则使用的数据项不存在`。
140
- - BRANCH 后接两个跨表 UPDATE:`50000`。
141
- - 固定 id / 主表字段条件:可运行。
142
- 逐件联动(一张单多件资产)纯配置做不到,走二开 BIZ_ACTION 或人工。