@stalfh233/omc-cli 0.4.62 → 0.4.64

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 把 "子表.字段" 解析成**集合**,目标主表仍只查询一次,不会按每条子表行各更新一次,运行结果是 0 行命中(r5-asset 2026-10-20 真机:保存/发布全绿、状态不变)。单行回写改用主表字段做 DYNAMIC 来源;一张单多行逐件回写需二开(BIZ_ACTION 自调接口一次处理多行)。`,
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 {
@@ -300,10 +319,10 @@ async function stampChildRowStatuses(session, schemaCode, formData) {
300
319
  }
301
320
  }
302
321
  catch {
303
- return formData;
322
+ return { formData, childCodes: [] };
304
323
  }
305
324
  if (childCodes.size === 0)
306
- return formData;
325
+ return { formData, childCodes: [] };
307
326
  let changed = false;
308
327
  const next = { ...formData };
309
328
  for (const code of childCodes) {
@@ -321,7 +340,7 @@ async function stampChildRowStatuses(session, schemaCode, formData) {
321
340
  return { ...row, rowStatus: hasId ? "Modified" : "Added" };
322
341
  });
323
342
  }
324
- return changed ? next : formData;
343
+ return { formData: changed ? next : formData, childCodes: [...childCodes] };
325
344
  }
326
345
  /** Runtime business-object data keyed by field code. */
327
346
  export function registerRuntimeDataLane(tools) {
@@ -646,7 +665,7 @@ export function registerRuntimeDataLane(tools) {
646
665
  message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
647
666
  });
648
667
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
649
- const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
668
+ const formData = (await stampChildRowStatuses(session, args.schemaCode, args.formData)).formData;
650
669
  if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
651
670
  // Portal draft-save shape. The row id travels in data.id (the portal
652
671
  // form SDK receives a pre-generated id from form/load and echoes it);
@@ -808,7 +827,7 @@ export function registerRuntimeDataLane(tools) {
808
827
  message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
809
828
  });
810
829
  }
811
- const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
830
+ const formData = (await stampChildRowStatuses(session, args.schemaCode, args.formData)).formData;
812
831
  const performed = await wireCall(session, {
813
832
  path: ENDPOINTS.runtimeFormSave,
814
833
  method: "POST",
@@ -907,6 +926,7 @@ export function registerRuntimeDataLane(tools) {
907
926
  workItemId: z.string().optional(),
908
927
  bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
909
928
  agree: z.boolean().optional(),
929
+ allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
910
930
  approval: z.record(z.string(), z.unknown()).optional(),
911
931
  runMode: z.string().optional(),
912
932
  isSimulative: z.boolean().optional(),
@@ -926,7 +946,28 @@ export function registerRuntimeDataLane(tools) {
926
946
  });
927
947
  }
928
948
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
929
- const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
949
+ const stampedSubmit = await stampChildRowStatuses(session, args.schemaCode, args.formData);
950
+ const formData = stampedSubmit.formData;
951
+ // r5-asset 2026-10-20: the platform APPENDS child rows carried in an
952
+ // approval submit's formData (no upsert by row id, no error). Live: an
953
+ // approval that echoed the whole form twice turned 2 detail rows into 6
954
+ // and 30000 into 90000 while every call answered errcode 0 — node-level
955
+ // `editable:false` does not stop it server-side. Refuse before the wire.
956
+ if ((hasWorkItem || hasInstance) && args.allowChildRowAppend !== true) {
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));
962
+ if (carried.length > 0)
963
+ throw new ToolRefusalError({
964
+ status: "refused",
965
+ blocker: "submit-approval-child-rows",
966
+ schemaCode: args.schemaCode,
967
+ childTables: carried,
968
+ message: `审批提交(workItemId/workflowInstanceId)的 formData 不要带子表数组:${carried.join("、")}。平台按「追加行」处理——每提交一次就再插一份(真机:2 行 → 6 行,30000 → 90000),且不报错、审批节点的 editable:false 也拦不住。只传主表字段;确实要在审批中追加子行时显式传 allowChildRowAppend:true。`,
969
+ });
970
+ }
930
971
  // Portal required-field semantics: the REST submit does NOT enforce them,
931
972
  // so a missing required value must be REPORTED, not silently accepted as a
932
973
  // portal-valid submit (dogfood R3). Best-effort read; a failure never
@@ -482,6 +482,84 @@ function mapCallerRules(callerRoutes, draftRules, activities) {
482
482
  * `org.role.list` / `org.department.list` first. Advisory (never blocks): the
483
483
  * platform accepts the save, so this is a readback lint, not a write gate.
484
484
  */
485
+ /**
486
+ * Participant FUNCTION names that the runtime does not register. Read-back and
487
+ * seeded templates carry them (the CRM seed uses FindUsersSpecByRoleFunction),
488
+ * and 1.1.25 only ships FindUserByRoleFunction — the others throw
489
+ * FunctionNotFoundException inside ParticipantActivityRuntime, so the instance
490
+ * stalls in PROCESSING with no work item while every API call answers errCode 0.
491
+ * A warning was not enough (r4 lost ~200 rounds to it), so publish refuses.
492
+ */
493
+ export function unregisteredParticipantFunctions(template) {
494
+ const activities = Array.isArray(template.activities) ? template.activities : [];
495
+ const found = [];
496
+ for (const activity of activities) {
497
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
498
+ continue;
499
+ const participant = String(activity.participant ?? "").trim();
500
+ const match = /^(FindUsersByRoleFunction|FindUsersSpecByRoleFunction)\s*\(/.exec(participant);
501
+ if (match)
502
+ found.push({ activityCode: String(activity.activityCode ?? ""), functionName: match[1] });
503
+ }
504
+ return found;
505
+ }
506
+ /**
507
+ * The designer default OMC backfills is `rejectToStart:true` on approval nodes
508
+ * (see backfillDesignerDefaults). With that flag, rejecting returns the instance
509
+ * to the fill node and the flow keeps running: endEventHandler and
510
+ * cancelEventHandler never fire (live 2026-10-20, r5-asset — instance stayed
511
+ * PROCESSING, todo back at A2, status still 审批中). A rule bound to `cancel`
512
+ * therefore silently never runs. Advisory: reject-to-start is a legitimate
513
+ * choice, only "I bound a cancel rule and expect it to run" is broken.
514
+ */
515
+ /**
516
+ * A delta that omits `routes` keeps the draft's edges (mergeCallerDelta /
517
+ * completeWorkflowChain). That is the right default, but it is invisible: after
518
+ * 0.4.63's fix a branched draft survives an activities-only update, and a caller
519
+ * has no way to tell "I preserved 5 edges including the else fallback" from
520
+ * "the platform rebuilt a linear chain". Report the inherited branch edges.
521
+ */
522
+ function inheritedBranchEdges(template) {
523
+ const rows = Array.isArray(template.activities) ? template.activities : [];
524
+ const rules = Array.isArray(template.rules) ? template.rules : [];
525
+ const outbound = new Map();
526
+ for (const rule of rules) {
527
+ const pre = String(rule.preActivityCode ?? "");
528
+ const post = String(rule.postActivityCode ?? "");
529
+ if (pre === "" || post === "")
530
+ continue;
531
+ outbound.set(pre, [...(outbound.get(pre) ?? []), post]);
532
+ }
533
+ const nameOf = new Map(rows.map((row) => [String(row.activityCode ?? ""), String(row.activityName ?? "")]));
534
+ return [...outbound.entries()]
535
+ .filter(([, targets]) => targets.length > 1)
536
+ .map(([pre, targets]) => `${pre}${nameOf.get(pre) ? `(${nameOf.get(pre)})` : ""} → ${targets.join(" | ")}`);
537
+ }
538
+ export function rejectToStartCancelWarnings(template) {
539
+ const activities = Array.isArray(template.activities) ? template.activities : [];
540
+ const blocking = activities
541
+ .filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT")
542
+ .filter((activity) => {
543
+ const action = activity.permittedAction;
544
+ if (action === undefined || action === null || typeof action !== "object")
545
+ return true; // designer default -> true
546
+ return action.rejectToStart !== false;
547
+ })
548
+ .map((activity) => String(activity.activityCode ?? ""));
549
+ if (blocking.length === 0)
550
+ return [];
551
+ const cancel = template.cancelEventHandler;
552
+ const cancelRules = cancel && typeof cancel === "object" ? cancel.bizActions : undefined;
553
+ const bound = Array.isArray(cancelRules) ? cancelRules.length : 0;
554
+ if (bound === 0)
555
+ return [];
556
+ return [{
557
+ code: "workflow-reject-to-start-suppresses-cancel-handler",
558
+ severity: "warning",
559
+ message: `cancelEventHandler 绑了 ${bound} 条规则,但审批节点 ${blocking.join("、")} 的 rejectToStart 为 true(设计器默认):驳回会把实例退回填写节点并继续运行,end/cancel 处理器都不触发,这些规则不会执行(r5-asset 真机:实例仍 PROCESSING、待办回 A2)。` +
560
+ `两种改法:① 在这个节点的 permittedAction 里显式传 rejectToStart:false(驳回即结束,cancel 生效);② 驳回后的状态改成由发起人手工处理或二开监听。`,
561
+ }];
562
+ }
485
563
  function participantReferenceWarnings(template) {
486
564
  const activities = Array.isArray(template.activities) ? template.activities : [];
487
565
  const warnings = [];
@@ -691,7 +769,7 @@ function validateWorkflowRender(template, tool) {
691
769
  * chain order" — reusing any edge whose endpoints survived. A draft whose
692
770
  * (possibly branching) graph is untouched passes the chain check verbatim.
693
771
  */
694
- function completeWorkflowChain(rules, activities) {
772
+ export function completeWorkflowChain(rules, activities) {
695
773
  const codes = activities.map((activity) => String(activity.activityCode ?? "")).filter((code) => code.length > 0);
696
774
  if (codes.length < 2)
697
775
  return rules;
@@ -712,13 +790,24 @@ function completeWorkflowChain(rules, activities) {
712
790
  }
713
791
  for (const code of codes) {
714
792
  const type = typeOf(code);
715
- if (type !== "START" && (inbound.get(code) ?? 0) !== 1)
793
+ if (type !== "START" && (inbound.get(code) ?? 0) < 1)
716
794
  valid = false;
717
- if (type !== "END" && (outbound.get(code) ?? 0) !== 1)
795
+ if (type !== "END" && (outbound.get(code) ?? 0) < 1)
718
796
  valid = false;
719
797
  }
720
798
  if (valid)
721
799
  return rules;
800
+ // r5-asset 2026-10-20: a node with TWO outgoing edges is a conditional
801
+ // branch (condition edge + utilizeElse fallback), NOT an invalid chain. The
802
+ // old rule demanded outbound === 1, so every branched graph fell through to
803
+ // the linear rebuild below — which maps consecutive activity codes and
804
+ // silently DROPS the else edge. Live symptom: a workflow.update that sent
805
+ // activities without routes erased A3→A5, every low-amount instance stalled
806
+ // in PROCESSING with errcode 0, and the only trace was a missing edge in
807
+ // get_published. Never linearize a graph that already branches.
808
+ const branched = [...outbound.values()].some((count) => count > 1);
809
+ if (branched)
810
+ return rules;
722
811
  const byEdge = new Map(rules.map((rule) => [`${rule.preActivityCode}->${rule.postActivityCode}`, rule]));
723
812
  return codes.slice(0, -1).map((code, index) => byEdge.get(`${code}->${codes[index + 1]}`) ?? { preActivityCode: code, postActivityCode: codes[index + 1] });
724
813
  }
@@ -1740,8 +1829,16 @@ export function registerWorkflowLane(tools) {
1740
1829
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
1741
1830
  validateWorkflowRender(data, "workflow.update readback");
1742
1831
  const participantWarnings = participantReferenceWarnings(data);
1832
+ // Evaluate on `body` (post-backfill): the readback may drop the handler
1833
+ // keys, and the backfilled permittedAction is exactly what will run.
1834
+ const rejectWarnings = rejectToStartCancelWarnings(body);
1743
1835
  const renamedActivities = renamedActivityPairs(template, data);
1744
1836
  const renamedInPlace = inPlaceRenames(data);
1837
+ // Caller sent no routes at all: the draft's edges (branches included) are
1838
+ // what rides the wire. Say so, and name the branch edges, because the only
1839
+ // other trace is a missing edge in a later getPublished readback.
1840
+ const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
1841
+ const branchEdges = callerSentRoutes ? [] : inheritedBranchEdges(data);
1745
1842
  // 改名验收的权威读数:每个节点的当前显示名(platform may keep
1746
1843
  // activityName or sync it; effectiveActivityName resolves both shapes).
1747
1844
  const rawActivityRows = Array.isArray(data.activities)
@@ -1752,7 +1849,7 @@ export function registerWorkflowLane(tools) {
1752
1849
  const activityNames = Object.fromEntries(rawActivityRows
1753
1850
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
1754
1851
  .filter(([code]) => code !== ""));
1755
- 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) } };
1852
+ 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) } };
1756
1853
  }, (args) => {
1757
1854
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
1758
1855
  // governed pipeline — including the rollback before-capture read.
@@ -1771,6 +1868,18 @@ export function registerWorkflowLane(tools) {
1771
1868
  // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
1772
1869
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
1773
1870
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
1871
+ const unregistered = unregisteredParticipantFunctions(body);
1872
+ if (unregistered.length > 0)
1873
+ throw new ToolRefusalError({
1874
+ status: "refused",
1875
+ blocker: "workflow-participant-function-unregistered",
1876
+ workflowCode: args.code,
1877
+ activities: unregistered,
1878
+ message: `流程发布被拒:参与人函数 ${[...new Set(unregistered.map((entry) => entry.functionName + "()"))].join("、")} 在 AI-Platform v1.1.25 未注册(节点 ${unregistered.map((entry) => entry.activityCode).join("、")})。` +
1879
+ `发布能过,但运行期 ParticipantActivityRuntime 抛 FunctionNotFoundException:实例卡 PROCESSING、不生成待办,而 data.submit 仍回 errCode 0(r4-training 真机)。` +
1880
+ `内置函数只有 FindUserByRole({Originator.OU},"<roleCode>","random");跨部门/全局角色改用参与者对象数组(角色 id)。` +
1881
+ `这两个名字常见于 workflow.get_published 读回与种子流程,不要回抄。`,
1882
+ });
1774
1883
  validateWorkflowRender(body, "workflow.publish");
1775
1884
  const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
1776
1885
  // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
@@ -1795,7 +1904,8 @@ export function registerWorkflowLane(tools) {
1795
1904
  });
1796
1905
  validateWorkflowRender(data, "workflow.publish readback");
1797
1906
  const participantWarnings = participantReferenceWarnings(data);
1798
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1907
+ const rejectWarnings = rejectToStartCancelWarnings(data);
1908
+ 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) } };
1799
1909
  }, (args) => {
1800
1910
  assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
1801
1911
  }, undefined, workflowRollbackSpec("workflow.publish"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.62",
3
+ "version": "0.4.64",
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.62",
53
+ "@stalfh233/omc-fake-platform": "0.4.64",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.62",
55
+ "omc-core": "0.4.64",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -125,3 +125,29 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
125
125
  ## 固定验收顺序
126
126
 
127
127
  执行阶段以 `omc-five-piece-flow` 为准;写面安全只额外强调两点:写前先 `commands/describe/contract` + dry-run 静态校验,写后权威 readback,必要时再用 CLI 触发运行验证。
128
+
129
+ ## 审批提交回传子表 = 追加行(真机 2026-10-20)
130
+
131
+ 带 `workItemId` / `workflowInstanceId` 的 `data.submit` 里放子表数组,平台按**追加**处理,不按行 `id` upsert、也不报错:
132
+ 2 行明细回传两次 → 6 行,`totalValue` 30000 → 90000,审批节点的 `editable:false` 拦不住服务端写入。
133
+ **只传主表字段**;确需追加行时显式 `allowChildRowAppend:true`(0.4.63 起默认拒绝,blocker `submit-approval-child-rows`)。
134
+ 收尾时前后各读一次子表行数确认未变。
135
+
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
+ 由此得到四条使用纪律:
148
+
149
+ 1. **主表目标上的 `sheetConditions` 是死配置**——不会生效(OMC 0.4.64 起给 warning)。
150
+ 2. **来源子表字段是集合,不是逐行扇出**:`currentSchemaDataItem:"items.asset"` 会被解析成该子表所有行的值集合,目标主表仍只查询一次。写这个形状能存能发布,但运行 0 行命中(r5-asset 真机)。OMC 0.4.64 起 `rule-graph-cross-model-child-source-fanout` **拒绝**。
151
+ 3. **一张单多行逐件回写**(每条明细各改一条另一模型的主表行)没有可用的纯配置形状——走 BIZ_ACTION 自调二开接口,一次处理多行;单行回写用主表字段做 DYNAMIC 来源。
152
+ 4. **BRANCH 后接两个跨表 UPDATE** 真机报 50000;改用单个 UPDATE + 条件动作,或二开。
153
+