@stalfh233/omc-cli 0.4.95 → 0.4.97

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.
@@ -71,7 +71,7 @@ const SURFACES = [
71
71
  description: "字段族全链:create→publish→表单绑定;CHILD_TABLE 子列;FORMULA 计算(子表求和回主表、子表列同行运算)。",
72
72
  operations: ["create", "update", "publish", "delete"],
73
73
  intent: { level: "partial", note: "create/update(含 CHILD_TABLE subFields、FORMULA 计算)、publish 与 delete(absent:true)已声明式。" },
74
- notes: "子表列是 schemaCode=父字段 code 上的普通字段;field.update 不接受 subFields。子列支持标量族 + WORK_SHEET 关联列(需 relativeCode + options)。**计算类已覆盖(默认走计算规则,非 FORMULA 字段)**:子表求和、子表列同行运算用「普通字段 + 计算规则」(`describe datarule.create --example field-compute-rule|field-compute-subtable-sum|field-compute-child-column`);计算规则由**服务端**在 data.submit/data.update 时计算并回填且覆盖传入值(真机 1.1.25 复核)。嵌套子表/MULT_WORK_SHEET/RELEVANCE_DATA 不覆盖。",
74
+ notes: "子表列是 schemaCode=父字段 code 上的普通字段;field.update 不接受 subFields。子列支持标量族 + WORK_SHEET 关联列(需 relativeCode + options)。**计算类已覆盖(默认走计算规则,非 FORMULA 字段)**:子表求和、子表列同行运算用「普通字段 + 计算规则」(`describe datarule.create --example field-compute-rule|field-compute-subtable-sum|field-compute-child-column`);计算规则**在 REST 路径上不会执行也不会回填**(r17 2026-10-25 复核:`data.submit`/`data.update` 后目标字段仍为传入值;门控 `CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 在 REST 路径从不置真)。嵌套子表/MULT_WORK_SHEET/RELEVANCE_DATA 不覆盖。",
75
75
  },
76
76
  {
77
77
  id: "form",
@@ -214,8 +214,11 @@ export const CAPABILITY_CUES = Object.freeze({
214
214
  // (it matched 子表+字段) and answered with the "create a child table" recipe,
215
215
  // so the agent hand-built an AGGREGATION rule node — a shape the platform
216
216
  // accepts and then fails at runtime. Row aggregation is a FIELD COMPUTE RULE
217
- // (`omc compute` / datarule type2). These compound cues outscore field's
218
- // 子表+字段 so the aggregate intent reaches the right ladder.
217
+ // (`omc compute` / datarule type2) — the ONLY configurable path, and it runs
218
+ // in the portal form only (r17 2026-10-25: the REST submit/update path never
219
+ // computes it, and the FORMULA-field AGGREGATION alternative is refused at
220
+ // publish with 305003). These compound cues outscore field's 子表+字段 so the
221
+ // aggregate intent at least reaches the ladder that states the real limits.
219
222
  datarule: ["字段级规则", "控件规则", "校验规则", "计算拼接", "显示规则", "规则", "type1", "type2", "type3",
220
223
  "统计子表", "子表行数", "子表计数", "子表求和", "子表汇总", "行数统计", "计数写回", "汇总回主表", "求和回主表", "聚合计算",
221
224
  "不早于", "不能早于", "不晚于", "不能晚于", "早于今天", "晚于今天", "日期校验", "提交校验", "校验不通过", "必填校验", "正则校验", "重复校验", "长度校验"],
package/dist/compute.js CHANGED
@@ -132,10 +132,13 @@ export function computeExecutionNotice() {
132
132
  "BizObjectOpConf.java:95 — setForceExecuteCalculateRule(options.isExecuteCalRule())",
133
133
  "engine/bizobject/BizObjectOptions.java:58 — 唯一的 executeCalRule() builder,全库无调用方",
134
134
  ],
135
- aggregationPathNote: "子表聚合的正解是 AGGREGATION 形状(options.formulaType=\"AGGREGATION\" + aggregation),它走 " +
136
- "FormulaPropertySupport.onTriggerObjectChanged 的事件回算,不依赖 executeCalRule。" +
137
- "0.4.95 取证时该形状仍未跑通(平台把 JSON 绑定失败掩码成 305003「死循环」),因此 OMC 暂不提供;" +
138
- "在补齐前不要用字段计算规则做服务端聚合,改用 business rule 或二开接口。",
135
+ aggregationPathNote: "AGGREGATION 形状(options.formulaType=\"AGGREGATION\" + aggregation)已对齐(0.4.96):" +
136
+ "propertyType/currentPropertyType(BizPropertyType) 与 operatorType(OperatorType) 必须写整数码," +
137
+ "写名字会让 ObjectMapperUtils 抛 json processing exception 并被掩码成 305003「死循环」;OMC 会把名字自动转成码。" +
138
+ "但**同模型子表聚合依然不可用**:平台 publishFormula 把 aggregation.schemaCode 改写成子表的父模型," +
139
+ "checkCycleOfAggregation:1032 随后判成环 → 无条件 305003(8.6.26 与 1.1.25 代码相同,r17 真机 A/B 已证)。" +
140
+ "跨模型聚合可以发布。服务端没有可用的子表聚合路径:计算规则不执行、AGGREGATION 同模型不发布、" +
141
+ "触发器回算只覆盖跨模型。替代:business rule(子表触发 → UPDATE 主表)或二开。",
139
142
  };
140
143
  }
141
144
  export async function cmdCompute(args) {
@@ -153,7 +156,8 @@ export async function cmdCompute(args) {
153
156
  const usage = [
154
157
  "用法:omc compute --schema <模型code> --target <目标字段> --formula \"<公式,{字段} 引用>\" [--name 规则名] [--env 环境] [--apply]",
155
158
  "条件聚合:--formula \"{<子表code>.<子列code>}\" --where \"<子列code> = <值>\" [--aggregate count|sum] → 编译为 COUNTIF / SUMIF",
156
- "⚠️ 但 1.1.25 上这条规则**不会**被 data.save/submit/update 触发(源码取证:type2 的 shortTextStitch 载体只在 executeCalRule=true 时执行,而 REST 路径从不置真)。子表聚合的正解是 AGGREGATION 形状,OMC 尚未做对;见 --apply 回执的 executionWarning。",
159
+ "⚠️ 这条规则**只在门户前端执行**:data.save/submit/update 不会触发它(源码取证:type2 的 shortTextStitch 载体只在 executeCalRule=true 时执行,REST 路径从不置真;r17 2026-10-25 真机复核:提交后目标字段仍为传入值)。API 调用方请自行算好再写。",
160
+ "⚠️ 也不能改用 FORMULA 字段的 AGGREGATION 兜底:同模型子表聚合在 field.publish 必抛 305003(aggregation-self-schema-cycle,平台 publishFormula/checkCycleOfAggregation 逻辑成环)。跨模型聚合能发布,但回算依赖门户/事件。汇总请走业务规则或二开。",
157
161
  "公式语法:同类字段 {字段code};子表列 {子表code.子列code};无条件聚合 SUM/COUNT/MAX/MIN/AVG({子表.子列}) 需加 --allow-unfiltered 显式确认。",
158
162
  "默认 dry-run,--apply 才写入。",
159
163
  ].join("\n");
@@ -689,13 +689,59 @@ function refuseFieldOptions(reason, message) {
689
689
  */
690
690
  const AGGREGATION_OPERATOR_CODES = Object.freeze({
691
691
  EQ: 1, NEQ: 2, GT: 3, IT: 4, GTEQ: 5, ITEQ: 6,
692
+ // Readable aliases for the two comparison names the enum spells oddly
693
+ // (`IT` = 小于, `ITEQ` = 小于等于). Writers reach for LT/LTEQ/GTE far more
694
+ // often than for the Java constant names, so accept both.
695
+ LT: 4, LTE: 6, LTEQ: 6, LE: 6, GTE: 5,
692
696
  CONTAINS: 7, NOT_CONTAINS: 8, IS_NULL: 9, IS_NOT_NULL: 10,
693
697
  OF: 11, NOT_OF: 12, HAVE: 13, NOT_HAVE: 14,
694
698
  IN: 15, NOT_IN: 16, BETWEEN: 17, NOT_BETWEEN: 18,
695
699
  CONTAINS_ALL: 19, NOT_CONTAINS_ALL: 20, CONTAINS_ANY: 21,
696
700
  NOT_CONTAINS_ANY: 22, EQ_ANY: 23, NOT_EQ_ANY: 24,
697
701
  });
698
- function normalizeAggregationFormula(agg) {
702
+ /**
703
+ * Platform `StripPrefixUtil.stripPrefix`: everything up to and INCLUDING the
704
+ * first dot is dropped (`r17_agg.items` → `items`; `items` → `items`).
705
+ */
706
+ export function stripAggregationPrefix(code) {
707
+ const dot = code.indexOf(".");
708
+ return dot >= 0 ? code.slice(dot + 1) : code;
709
+ }
710
+ /**
711
+ * Does an aggregation target resolve to the schema the field itself sits on?
712
+ * Both spellings count: the bare schema code (`r17_agg`), the child path
713
+ * (`r17_agg.items` → parent `r17_agg`), and a bare child code that equals the
714
+ * model code (impossible, guarded for completeness).
715
+ */
716
+ export function aggregationTargetsOwnSchema(schemaCode, aggregationSchemaCode) {
717
+ const raw = aggregationSchemaCode.trim();
718
+ if (raw === schemaCode)
719
+ return true;
720
+ if (raw.startsWith(`${schemaCode}.`))
721
+ return true;
722
+ return stripAggregationPrefix(raw) === schemaCode;
723
+ }
724
+ /**
725
+ * The refusal a same-model aggregation must carry. It names the platform
726
+ * mechanism so the reader stops looking for a payload fix, and gives the three
727
+ * paths that actually work.
728
+ */
729
+ export function aggregationSelfSchemaMessage(aggregationSchemaCode, schemaCode) {
730
+ return (`rejected: 聚合字段不能聚合**自己所在模型**的数据(schemaCode=${aggregationSchemaCode} 解析到 ${schemaCode})。` +
731
+ `这不是 payload 写错:平台 AggregationFormula.schemaCode 指向「被聚合的模型」,` +
732
+ `指向自己的子表时 BizPropertyManagerImpl.publishFormula 会把它改写成子表的**父模型**(= 本模型),` +
733
+ `随后 checkCycleOfAggregation:1032 拿这个相对 code 和字段自己的 schemaCode 比较,两者相等即判定成环 → ` +
734
+ `**无条件抛 305003「规则配置出现死循环」**。8.6.26 与 AI-Platform 1.1.25 的这段代码完全相同(r17 2026-10-25 反编译比对),` +
735
+ `真机 A/B 也印证:聚合**别的模型**的子表发布 errcode:0,只差这一处相对 code。` +
736
+ `可行替代(按推荐序):` +
737
+ `① **跨模型汇总**——omc describe field.create --example formula-aggregation-cross-model,` +
738
+ `r17 已真机跑通(来源行 submit → 目标字段 100→105):schemaCode 指向**另一个模型**,` +
739
+ `queryConditions 用 DYNAMIC 条件把两侧对上(propertyCode=来源字段、currentPropertyCode=目标字段),` +
740
+ `空条件会静默得 0;` +
741
+ `② 业务规则(子表触发 → UPDATE 主表);③ 二开。` +
742
+ `另:普通数值字段 + 计算规则(datarule type2)在 REST 路径上也不会执行(executeCalRule 从不置真),不要用它兜底。`);
743
+ }
744
+ export function normalizeAggregationFormula(agg) {
699
745
  const groups = agg.queryConditions;
700
746
  if (groups === undefined)
701
747
  return agg;
@@ -711,14 +757,14 @@ function normalizeAggregationFormula(agg) {
711
757
  continue;
712
758
  const code = typeof value === "number" ? value : FIELD_PROPERTY_TYPES[String(value).toUpperCase()];
713
759
  if (typeof code !== "number" || !Number.isInteger(code))
714
- 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("、")}`);
760
+ refuseFieldOptions(`AGGREGATION queryConditions.${key} ${JSON.stringify(value)} is not a numeric BizPropertyType code`, `rejected: 聚合条件的 ${key} 必须是**整数**属性类型编码(BizPropertyType 的 @JsonCreator 只接受 int;传 "NUMERICAL" 会让 ObjectMapperUtils 抛 json processing exception,被上层掩码成 305003 或 publish 50000);可用名字:${Object.keys(FIELD_PROPERTY_TYPES).join("、")}`);
715
761
  record[key] = code;
716
762
  }
717
763
  const operator = record.operatorType;
718
764
  if (operator !== undefined && operator !== null) {
719
765
  const code = typeof operator === "number" ? operator : AGGREGATION_OPERATOR_CODES[String(operator).toUpperCase()];
720
766
  if (typeof code !== "number" || !Number.isInteger(code) || code < 1 || code > 24)
721
- 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`);
767
+ refuseFieldOptions(`AGGREGATION queryConditions.operatorType ${JSON.stringify(operator)} is not an OperatorType code`, `rejected: 聚合条件的 operatorType 必须是整数 1–24(或名字:${Object.keys(AGGREGATION_OPERATOR_CODES).join("、")});OperatorType 的 @JsonCreator 只接受 int,传 "GTEQ" 会抛 json processing exception 并被掩码成 305003/publish 50000`);
722
768
  record.operatorType = code;
723
769
  }
724
770
  if (record.valueType === undefined)
@@ -889,12 +935,17 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
889
935
  // empty-queryConditions probe evaluate to 0) — a FALSE NEGATIVE (the same
890
936
  // 二开-vs-配置 call an agent wrongly made: 表单字段「计算」公式可直接做聚合).
891
937
  // Validate the evidenced shape and allow it.
892
- // NOTE (open): the exact aggregation for a SAME-model child table (子表求和回
893
- // 主表) still needs a live sample — a guessed child-schemaCode shape hit
894
- // 305003 at publish (checkCycleOfAggregation in BizPropertyManagerImpl treats
895
- // the child's parent == the field's own schema as a cycle). The UI save
896
- // payload is the authoritative shape; do not hand-fabricate it.
897
- // The EXPRESSION-as-aggregate trap below still applies only to EXPRESSION.
938
+ //
939
+ // SAME-model aggregation is NOT fabricable (r17 2026-10-25, source + live):
940
+ // AggregationFormula.schemaCode is the AGGREGATED model, so for the field's
941
+ // own child table `publishFormula` rewrites it to the child's PARENT
942
+ // (BizPropertyManagerImpl.publishFormula) and `checkCycleOfAggregation:1032`
943
+ // then compares that relative code with the field's own schemaCode — they
944
+ // are the same model, so publish throws 305003 unconditionally. The check is
945
+ // identical in 8.6.26 and AI-Platform 1.1.25, so this is a platform
946
+ // limitation, not a payload mistake. Live A/B: aggregating ANOTHER model's
947
+ // child table published errcode:0 (the only difference is the relative code).
948
+ // Refuse instead of letting publish answer the masked 305003.
898
949
  const isAggregation = config.formulaType === "AGGREGATION";
899
950
  if (isAggregation) {
900
951
  if (!isNonEmptyString(config.aggregation))
@@ -903,6 +954,14 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
903
954
  const agg = parseOptionsObject(config.aggregation);
904
955
  if (!agg || !isNonEmptyString(agg.schemaCode) || !isNonEmptyString(agg.dataItemCode) || !isNonEmptyString(agg.formula))
905
956
  refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<目标模型schemaCode>", dataItemCode:"<目标字段code>", formula:"SUM({<目标模型>.<字段>})", calculateType:"SUM", queryConditions:[[…]]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
957
+ if (schemaCode !== undefined && aggregationTargetsOwnSchema(schemaCode, String(agg.schemaCode)))
958
+ throw new ToolRefusalError({
959
+ status: "refused",
960
+ blocker: "aggregation-self-schema-cycle",
961
+ schemaCode,
962
+ aggregationSchemaCode: String(agg.schemaCode),
963
+ message: aggregationSelfSchemaMessage(String(agg.schemaCode), schemaCode),
964
+ });
906
965
  const normalizedAgg = normalizeAggregationFormula(agg);
907
966
  if (normalizedAgg !== agg) {
908
967
  config.aggregation = JSON.stringify(normalizedAgg);
@@ -392,9 +392,16 @@ const COMPOSER_ROUTES = Object.freeze([
392
392
  id: "compute",
393
393
  command: "omc compute --schema <模型code> --target <目标字段> --formula '<公式>' [--where '<子列> = <值>'] --apply",
394
394
  usage: "omc compute --help",
395
- summary: "字段计算规则一条命令(datarule type2):普通表达式、子表求和、条件计数 COUNTIF/SUMIF;服务端 submit/update 时回填",
396
- groups: [["计算", "统计", "汇总", "求和", "聚合", "计数", "合计"], ["字段", "子表", "列", "回主表", "写入"]],
397
- avoid: [],
395
+ // r17: this composer writes a datarule type2 row, which the platform only
396
+ // evaluates in the PORTAL form — never on the REST save/submit/update path
397
+ // (CalculateHandler is gated on executeCalRule, which no REST route sets).
398
+ // Claiming "服务端回填" here sent agents to build a rule and then read back 0.
399
+ summary: "字段计算规则一条命令(datarule type2):普通表达式、子表求和、条件计数 COUNTIF/SUMIF。⚠️ 只在门户表单里算,REST 提交不回填;需要 API 可读的跨模型汇总用 field.create --example formula-aggregation-cross-model",
400
+ groups: [["计算", "统计", "汇总", "求和", "聚合", "计数", "合计"], ["字段", "子表", "列", "回主表", "写入", "模型"]],
401
+ // r17: a CROSS-MODEL roll-up is not this composer's shape — `compute` writes
402
+ // a datarule type2 row, which cannot read another model at all. Those asks
403
+ // belong to field.create's formula-aggregation-cross-model recipe.
404
+ avoid: ["另一个模型", "其它模型", "其他模型", "跨模型", "另一张表", "另一个表"],
398
405
  topics: ["omc-write-hazards"],
399
406
  },
400
407
  {
@@ -11,6 +11,7 @@ import { resolveDataRuleRow } from "../identity.js";
11
11
  import { wireCall, wireRows, WireError } from "../wire.js";
12
12
  import { runtimeWriteWithAppMapping } from "../runtime-support.js";
13
13
  import { DATARULE_LANE_CONTRACT, DATARULE_TYPE_OPTION_KEYS } from "./datarule-contract.js";
14
+ import { normalizeAggregationFormula } from "../field-families.js";
14
15
  import { CONDITION_PROTOCOL_EXAMPLES, dataRuleOptionsShapeError } from "./datarule-protocol.js";
15
16
  const DATARULE_LANE = DATARULE_LANE_CONTRACT.lane;
16
17
  const DATARULE_TOOLS = DATARULE_LANE_CONTRACT.tools;
@@ -391,6 +392,20 @@ export function assertDataRuleOptions(dataRuleType, options, checkType) {
391
392
  '正确形状:options:{shortTextStitch:"{\\"formula\\":\\"{字段code}\\",\\"editorText\\":\\"{字段名}\\",\\"editorMark\\":\\"{字段名}\\"}"}(editorText/editorMark 仅展示用;formula 是表达式)',
392
393
  });
393
394
  }
395
+ if (formulaType === "AGGREGATION" && typeof options.aggregation === "string") {
396
+ // r17 (2026-10-25): the SAME Jackson binding applies on this path.
397
+ // `propertyType` / `currentPropertyType` are BizPropertyType
398
+ // (@JsonCreator int) and `operatorType` is OperatorType (@JsonCreator int);
399
+ // the readable names are only a convenience the CLI adapts. Writing
400
+ // "EQ"/"SHORT_TEXT" through makes ObjectMapperUtils throw
401
+ // "json processing exception", which the dead-cycle check masks as
402
+ // 305003 死循环 AT CREATE TIME (live: this cost r16 45% of its refusals).
403
+ // Normalize to the integer codes the platform actually stores.
404
+ const parsed = JSON.parse(options.aggregation);
405
+ const normalized = normalizeAggregationFormula(parsed);
406
+ if (normalized !== parsed)
407
+ options.aggregation = JSON.stringify(normalized);
408
+ }
394
409
  // editorText ↔ formula round-trip (display correctness). Skipped for the
395
410
  // AGGREGATION carrier (its display object differs).
396
411
  if (formulaType !== "AGGREGATION")
@@ -90,9 +90,9 @@ export const FIELD_FORMULA_WIRE = Object.freeze({
90
90
  outerOptions: '{"formulaConfig":"<JSON string>","persistent":true[, "format":""|"percentile" — readback-only mirror, fwb §100]}',
91
91
  innerConfig: '{"resultType":0|2,"resultFormat":""|"percentile","formulaType":"EXPRESSION","expression":"<JSON string {formula,editorText,editorMark}>"}',
92
92
  expressionFormula: '<fieldCode> placeholders, e.g. {omc_a} + {omc_b}',
93
- aggregationConfig: 'AGGREGATION nested {schemaCode,dataItemCode,calculateType,formula,queryConditions} = 表单「计算」控件的「聚合计算」(UI 实测 2026-10-07:跨模型聚合,求和/最大/最小/…). FormulaProcessor.executeMainAggregation 用 queryConditions 查该 schema、对匹配行的 dataItemCode 求值 formula. shape-validated & allowed. **计算类需求默认不用 FORMULA 字段**:子表求和回主表、子表列同行运算一律「普通字段 + 计算规则」(`datarule.create` dataRuleType:2,recipes `field-compute-subtable-sum` / `field-compute-child-column`,前台真机算得 7.00/350);FORMULA 字段仅作已存量读取。计算规则(`datarule.create` dataRuleType:2)**由服务端在 data.submit/data.update 时计算并回填,且覆盖传入值**(真机 1.1.25 复核 2026-10-24);本 FORMULA 类型字段才是仅前端计算、API 不回填。',
93
+ aggregationConfig: 'AGGREGATION nested {schemaCode,dataItemCode,calculateType,formula,queryConditions} = 表单「计算」控件的「聚合计算」(UI 实测 2026-10-07:跨模型聚合,求和/最大/最小/…). FormulaProcessor.executeMainAggregation 用 queryConditions 查该 schema、对匹配行的 dataItemCode 求值 formula. shape-validated & allowed. **计算类需求默认不用 FORMULA 字段**:子表求和回主表、子表列同行运算一律「普通字段 + 计算规则」(`datarule.create` dataRuleType:2,recipes `field-compute-subtable-sum` / `field-compute-child-column`,前台真机算得 7.00/350);FORMULA 字段仅作已存量读取。计算规则(`datarule.create` dataRuleType:2)**在 REST 路径上不会执行,也不会回填**(r17 2026-10-25 复核:`data.submit` 写入后目标字段仍为传入值 0,`docker logs` 无任何 CalculateHandler/formula-recalc 记录;源码门控见 0.4.95:`CalculateHandler.java:50` 由 `forceExecuteCalculateRule` 门控,唯一开启入口 `BizObjectOptions.executeCalRule()` 全库 0 调用方)。本 FORMULA 类型字段同样只在门户前端算。**跨模型汇总在 REST 路径上可用(r17 2026-10-25 真机验证)**:FORMULA(100) + `formulaType:"AGGREGATION"`,`aggregation.schemaCode` 指向**另一个模型**,`queryConditions` 必须给 DYNAMIC 条件把两侧对上(`propertyCode` = 来源模型字段,`currentPropertyCode` = 目标模型字段;写反或留空则反向过滤器为空、目标字段恒 0),来源行 submit/update/delete 触发 `FormulaPropertySupport.onTriggerObjectChanged` 回算;发布时会自动生成 t100 CALCULATE_RULE 镜像行(回算前置)。实测 100→105。配方 `formula-aggregation-cross-model`。**同模型(含自己的子表)聚合仍然不可用**:`field.publish` 必抛 305003(`aggregation-self-schema-cycle`),这是平台 `publishFormula` 把相对码改写成子表父模型后 `checkCycleOfAggregation:1032` 判成环的必然结果。',
94
94
  legacyRejected: "resultType/expression top-level keys are not read by the platform and are refused",
95
- runtimeVerification: "本 FORMULA(100) 字段类型:计算由门户**前端**执行,API save/update/submit 不回填/重算(真机 2026-09-25/2026-10-07,仅覆盖该字段类型)。**不要**把这条套到字段计算规则(datarule dataRuleType:2)上——后者由服务端在 data.submit/data.update 时计算并回填,且覆盖传入值(真机 1.1.25 复核 2026-10-24,含删除规则后传入值生效的对照组)。带子表计算字段的模型 API data.save/update 可能 50000(服务端重算 NPE),前台正常。",
95
+ runtimeVerification: "本 FORMULA(100) 字段类型:计算由门户**前端**执行,API save/update/submit 不回填/重算(真机 2026-09-25/2026-10-07,仅覆盖该字段类型)。字段计算规则(datarule dataRuleType:2)**同样不回填**:r17(2026-10-25)在 AI-Platform 1.1.25 上重建 `SUM({rows.amt})` 的 shortTextStitch 规则,`data.submit` 与 `data.update` 后 `total` 都仍等于传入值 0,`docker logs` 无 CalculateHandler/formula-recalc 记录;源码门控见 `CalculateHandler.java:50`(`forceExecuteCalculateRule`,唯一开启入口 `BizObjectOptions.executeCalRule()` 全库 0 调用方)。带子表计算字段的模型 API data.save/update 可能 50000(服务端重算 NPE),前台正常。",
96
96
  });
97
97
  /** RELEVANCE_DATA(16) options wire (8.6.26 RelevanceDataOptions + admin field serializer). */
98
98
  export const FIELD_RELEVANCE_WIRE = Object.freeze({
@@ -152,7 +152,7 @@ export const FIELD_LANE_CONTRACT = Object.freeze({
152
152
  "8.6.26 FormulaPropertyOptions (cloudpivot-shared-8.6.26): options={formulaConfig:string,persistent}; FormulaConfig={resultType,resultFormat,formulaType,expression,aggregation}; FormulaProcessor reads config.expression as a JSON string {formula,editorText,editorMark} and blanks {fieldCode} placeholders",
153
153
  "omc-core formula-field-contract.ts v1 (live-verified): {formulaConfig,persistent:true}, resultType 0|2, resultFormat \"\"|\"percentile\"",
154
154
  "field-families-fwb-20260914.md:170-180 (fwb readback 2026-09-14): FORMULA(100) field row options = {formulaConfig, persistent, format} with formulaConfig.resultFormat \"percentile\" and outer format \"percentile\" — the evidenced non-empty resultFormat and the readback-only outer format key are accepted; unknown values stay refused fail-closed",
155
- "ticket 24 live (2026-09-12): FORMULA options read back verbatim via field.list; EXPRESSION field evaluated 3+4=7 on save. An empty-queryConditions AGGREGATION probe evaluated to 0 — REVERSED 2026-10-07: that was a false negative; AGGREGATION (表单「计算」控件「聚合计算」, 跨模型聚合) is supported; subtable→main roll-up and child-column arithmetic are delivered as 普通字段+计算规则 (datarule type2, recipes field-compute-subtable-sum / field-compute-child-column; UI+runtime verified 2026-10-08) — the earlier FORMULA-field recipes were removed as the wrong vehicle",
155
+ "ticket 24 live (2026-09-12): FORMULA options read back verbatim via field.list; EXPRESSION field evaluated 3+4=7 on save. An empty-queryConditions AGGREGATION probe evaluated to 0 — REVERSED 2026-10-07: that was a false negative; AGGREGATION (表单「计算」控件「聚合计算」, 跨模型聚合) is supported; subtable→main roll-up and child-column arithmetic are delivered as 普通字段+计算规则 (datarule type2, recipes field-compute-subtable-sum / field-compute-child-column; PORTAL-form verified 2026-10-08 — the API path never computes them, see the r17 2026-10-25 note below) — the earlier FORMULA-field recipes were removed as the wrong vehicle",
156
156
  "8.6.26 RelevanceDataOptions (bizmodel): {relevanceField,referenceField,referenceFieldPropertyType,referenceFieldControlType}; validateRelevanceDataPropertyConfig requires referenceField+relevanceField; admin field serializer adds originalOptions/format1",
157
157
  "ticket 24 live (2026-09-12): FORMULA options readback + RELEVANCE_DATA create/publish/form-binding readback",
158
158
  "ticket 61 source (8.6.26): the runtime list face resolves a relation's displayed value from the field ROW's top-level relativePropertyCode, not options.displayField — QueryRuntimeController.java:1214-1226 (getRelativePropertyCode; empty → \"name\" data title); BizPropertyController create/update :851/:987 never auto-fill it, so every relation create/update must carry it",
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
4
- import { choicesArg, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
4
+ import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { governedRead } from "../read.js";
7
7
  import { writeTool } from "../write.js";
@@ -141,6 +141,49 @@ function assertNameI18nArg(nameI18n) {
141
141
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
142
142
  refuse("JSON 解析结果不是对象");
143
143
  }
144
+ /**
145
+ * r17 (2026-10-25, live + decompiled A/B): a FORMULA field CANNOT aggregate its
146
+ * own model's child table. `BizPropertyManagerImpl.publishFormula` rewrites
147
+ * AggregationFormula.schemaCode → the child's PARENT, then
148
+ * `checkCycleOfAggregation:1032` compares that relative code with the field's
149
+ * own schemaCode; for a same-model child they are equal, so publish throws
150
+ * 305003「规则配置出现死循环」unconditionally (identical code in 8.6.26 and
151
+ * AI-Platform 1.1.25). Aggregating ANOTHER model's child table published
152
+ * errcode:0 in the same session — the relative code is the only difference.
153
+ *
154
+ * The static gate in field-families can only see the spelled-out target
155
+ * (`r17_agg` / `r17_agg.items`); a bare child code (`items`) is indistinguishable
156
+ * from an external model without a read. The create path already reads the
157
+ * published field list, so resolve it here for free and refuse before the write.
158
+ */
159
+ function assertNoSelfSchemaAggregation(args, published) {
160
+ const raw = args.propertyType;
161
+ const propertyType = typeof raw === "number" ? raw : FIELD_PROPERTY_TYPES[String(raw)];
162
+ if (propertyType !== FIELD_PROPERTY_TYPES.FORMULA)
163
+ return;
164
+ const formulaConfig = args.options?.formulaConfig;
165
+ const config = decodeJsonObjectColumn(formulaConfig, { invalid: "undefined", array: "reject", object: "reject" });
166
+ if (!config || config.formulaType !== "AGGREGATION" || typeof config.aggregation !== "string")
167
+ return;
168
+ const agg = decodeJsonObjectColumn(config.aggregation, { invalid: "undefined", array: "reject", object: "reject" });
169
+ const target = typeof agg?.schemaCode === "string" ? agg.schemaCode.trim() : "";
170
+ if (!target)
171
+ return;
172
+ const childCodes = published
173
+ .filter((row) => Number(row.type ?? row.propertyType) === FIELD_PROPERTY_TYPES.CHILD_TABLE)
174
+ .map((row) => String(row.code ?? row.propertyCode ?? ""));
175
+ const resolvesToSelf = aggregationTargetsOwnSchema(args.schemaCode, target) || childCodes.includes(stripAggregationPrefix(target));
176
+ if (!resolvesToSelf)
177
+ return;
178
+ throw new ToolRefusalError({
179
+ status: "refused",
180
+ blocker: "aggregation-self-schema-cycle",
181
+ schemaCode: args.schemaCode,
182
+ aggregationSchemaCode: target,
183
+ childTableCodes: childCodes,
184
+ message: aggregationSelfSchemaMessage(target, args.schemaCode),
185
+ });
186
+ }
144
187
  export function registerFieldLane(tools) {
145
188
  tools.push(defineTool({
146
189
  name: "field.list",
@@ -300,6 +343,7 @@ export function registerFieldLane(tools) {
300
343
  throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST`, nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: true, code: args.code })}'` });
301
344
  // One seam owns field-change normalization, every reproduced gate, and the
302
345
  // wire body (src/field-change.ts), so configure_model and intent share it.
346
+ assertNoSelfSchemaAggregation(args, published);
303
347
  const prepared = prepareFieldChange(args);
304
348
  let performed;
305
349
  try {
@@ -384,6 +428,16 @@ async function runFieldUpdate(session, args) {
384
428
  const record = rows.find((row) => (row.code ?? row.propertyCode) === args.code);
385
429
  if (!record)
386
430
  throw new WireError("business", `field ${args.code} not found on ${args.schemaCode}`, 301001);
431
+ if (args.options !== undefined) {
432
+ // Same platform cycle as field.create (r17). On UPDATE the caller may also
433
+ // be rewriting a previously-fine AGGREGATION, so resolve the child tables
434
+ // from the published face the same way; a read failure must not silently
435
+ // skip the gate, so fall back to the spelled-out target only.
436
+ const publishedRows = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })
437
+ .then((response) => wireRows(response.data))
438
+ .catch(() => []);
439
+ assertNoSelfSchemaAggregation({ schemaCode: args.schemaCode, propertyType: record.type ?? record.propertyType, options: args.options }, publishedRows);
440
+ }
387
441
  const update = {
388
442
  schemaCode: args.schemaCode,
389
443
  code: args.code,
@@ -324,12 +324,61 @@ export function registerOrganizationLane(tools) {
324
324
  const after = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
325
325
  return { performed: { errcode: 0 }, readback: { deptId: args.deptId, name: after?.name ?? null, parentId: after?.parentId ?? null, managerId: after?.managerId ?? null, enabled: after?.enabled ?? null } };
326
326
  });
327
- writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", { deptId: z.string().min(1), force: z.boolean().optional().describe("true = 先逐个删除子部门(仅当子部门无用户)再删本部门;有用户仍由平台裁决") }, async (session, args) => {
327
+ writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", {
328
+ deptId: z.string().min(1),
329
+ force: z.boolean().optional().describe("true = 先逐个删除子部门(仅当子部门无用户)再删本部门;有用户仍由平台裁决"),
330
+ transferUsersTo: z
331
+ .string()
332
+ .min(1)
333
+ .optional()
334
+ .describe("有员工时的转出目标部门 id:给了它就先把本部门(及 force 时的子树各部门)的员工逐人 transferDepartment 到该部门再删;不给则带人即拒绝。目标必须是现存部门,且不能是被删子树的一部分"),
335
+ }, async (session, args) => {
328
336
  const users = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: args.deptId } })).data);
329
337
  // Pre-check children: the platform's 200009 ("部门下面存在子部门") is
330
338
  // accurate but sends the caller hunting for ids. Name them and hand over
331
339
  // the child-delete command (found during r15 org cleanup).
332
340
  const walked = await collectOrgDepartments(session);
341
+ // transferUsersTo (opt-in member evacuation): the target must be a real
342
+ // department and must NOT be inside the subtree being deleted — moving
343
+ // people into a dept that is about to be deleted would lose them.
344
+ const transferTarget = typeof args.transferUsersTo === "string" ? args.transferUsersTo.trim() : "";
345
+ let transferUsers;
346
+ if (transferTarget) {
347
+ if (!walked.rows.some((row) => row.id === transferTarget))
348
+ throw new ToolRefusalError({
349
+ status: "refused", blocker: "department-transfer-target-absent", deptId: String(args.deptId), transferUsersTo: transferTarget,
350
+ message: `transferUsersTo 的目标部门 ${transferTarget} 不存在(org.department.list 里没有)。先确认部门 id,或先创建目标部门。`,
351
+ });
352
+ const subtreeIds = new Set([String(args.deptId)]);
353
+ let grew = true;
354
+ while (grew) {
355
+ grew = false;
356
+ for (const row of walked.rows)
357
+ if (subtreeIds.has(row.parentId) && !subtreeIds.has(row.id)) {
358
+ subtreeIds.add(row.id);
359
+ grew = true;
360
+ }
361
+ }
362
+ if (subtreeIds.has(transferTarget))
363
+ throw new ToolRefusalError({
364
+ status: "refused", blocker: "department-transfer-into-subtree", deptId: String(args.deptId), transferUsersTo: transferTarget,
365
+ message: `transferUsersTo=${transferTarget} 在被删除的子树里——把人转进一个即将被删的部门会丢成员。换一个子树外的部门。`,
366
+ });
367
+ transferUsers = async (fromDeptId, deptUsers) => {
368
+ let moved = 0;
369
+ for (const member of deptUsers) {
370
+ const userId = String(member.id ?? member.userId ?? "");
371
+ if (!userId)
372
+ continue;
373
+ await wireCall(session, {
374
+ path: ENDPOINTS.organizationUserDeptTransfer, method: "POST", query: orgQuery(session),
375
+ body: { userIds: [userId], sourceDept: fromDeptId, targetDept: transferTarget },
376
+ });
377
+ moved += 1;
378
+ }
379
+ return moved;
380
+ };
381
+ }
333
382
  const children = walked.rows
334
383
  .filter((row) => row.parentId === String(args.deptId))
335
384
  .map((row) => ({ id: row.id, name: row.name }))
@@ -363,6 +412,11 @@ export function registerOrganizationLane(tools) {
363
412
  deletedChildren = ordered.length;
364
413
  for (const child of ordered) {
365
414
  try {
415
+ if (transferUsers) {
416
+ const childUsers = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: child.id } })).data);
417
+ if (childUsers.length > 0)
418
+ await transferUsers(child.id, childUsers);
419
+ }
366
420
  await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: child.id } });
367
421
  }
368
422
  catch (error) {
@@ -372,12 +426,16 @@ export function registerOrganizationLane(tools) {
372
426
  status: "refused", blocker: usersPending ? "department-has-users" : "department-delete-failed",
373
427
  deptId: String(args.deptId), blockedChild: child,
374
428
  nextCommand: `omc call org.department.users --args '${JSON.stringify({ deptId: child.id })}'`,
375
- message: `force 清理到子部门 ${child.name}(${child.id}) 时失败:${message}。${usersPending ? "该部门下还有员工——先转出再删。" : "平台拒绝删除该子部门。"}`,
429
+ message: `force 清理到子部门 ${child.name}(${child.id}) 时失败:${message}。${usersPending ? "该部门下还有员工——先转出再删(或传 transferUsersTo 让工具搬)。" : "平台拒绝删除该子部门。"}`,
376
430
  });
377
431
  }
378
432
  }
379
433
  }
380
- if (users.length > 0) {
434
+ let transferredUsers = 0;
435
+ if (users.length > 0 && transferUsers) {
436
+ transferredUsers = await transferUsers(String(args.deptId), users);
437
+ }
438
+ if (users.length > 0 && !transferUsers) {
381
439
  // Name every member and hand over a runnable transfer for the first one:
382
440
  // "先看人再转出" left the caller to assemble the transfer args (item 9).
383
441
  const members = users.map((row) => ({ id: row.id ?? row.userId ?? null, name: row.name ?? null, username: row.username ?? null }));
@@ -389,12 +447,20 @@ export function registerOrganizationLane(tools) {
389
447
  ? `omc call org.user.transferDepartment --args '${JSON.stringify({ userId: first.id, departmentId: parentId })}'`
390
448
  : `omc call org.department.users --args '${JSON.stringify({ deptId: String(args.deptId) })}'`,
391
449
  message: `部门 ${args.deptId} 下还有 ${users.length} 名员工,平台拒绝删除(200002)。成员:${members.map((member) => `${member.name ?? "?"}(${member.id ?? "?"})`).join("、")}。` +
392
- `平台不允许删带人的部门,也不提供"随部门级联转出";逐个 org.user.transferDepartment 转到 ${parentId ? `父部门 ${parentId}` : "目标部门"} 后再删。` +
393
- `(force 只处理子部门,不搬人——搬人是组织语义决策,工具不替你做。)`,
450
+ `平台不允许删带人的部门,也不提供"随部门级联转出"。两个选择:` +
451
+ `① 逐个 org.user.transferDepartment 转到 ${parentId ? `父部门 ${parentId}` : "目标部门"} 后再删;` +
452
+ `② 本命令直接带 transferUsersTo:"<目标部门id>",工具会先把人逐人转出(含 force 时的子树各部门)再删。` +
453
+ `(默认不搬人——搬人是组织语义决策,不替你选目标部门。)`,
394
454
  });
395
455
  }
396
456
  await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: args.deptId } });
397
- return { performed: { errcode: 0 }, readback: { deptId: args.deptId, deleted: true, usersBeforeDelete: users.length, deletedChildren } };
457
+ return {
458
+ performed: { errcode: 0 },
459
+ readback: {
460
+ deptId: args.deptId, deleted: true, usersBeforeDelete: users.length, deletedChildren,
461
+ ...(transferredUsers > 0 ? { transferredUsers, transferredTo: transferTarget } : {}),
462
+ },
463
+ };
398
464
  });
399
465
  tools.push(defineTool({
400
466
  name: "org.department.users",
@@ -1237,7 +1237,7 @@ export function validateDataAction(action, nodeCode, index) {
1237
1237
  code: "rule-graph-aggregation-is-a-field-feature",
1238
1238
  nodeCode,
1239
1239
  key: `dataActions[${index}].ruleValueType`,
1240
- message: `${nodeCode}: 规则节点不支持 AGGREGATION 赋值——平台会存进去并启用,但运行期聚合结果是集合(ArrayList),标量目标收到集合即 50000「服务异常」(DecimalBizField.fillClientContent)。**子表计数/求和/取最值回主表用字段计算规则**:目标字段建成普通数值字段,然后 \`omc compute --schema <主表> --target <目标字段> --formula 'COUNTIF({<子表code>.<子列code>},"异常")'\`(默认 dry-run,--apply 写入;等价 datarule.create dataRuleType:2,配方 \`field-compute-subtable-sum\`)。规则图里只做 FIXED/DYNAMIC/EXPRESSION 的直接赋值。该计算规则由**服务端**在 data.submit/data.update 时计算并回填(真机 1.1.25 复核),写完可直接 data.load 断言结果;注意它会**覆盖**你传入的同名字段值。来源:r11 巡检沙盒 2026-10-24(1.1.25)。`,
1240
+ message: `${nodeCode}: 规则节点不支持 AGGREGATION 赋值——平台会存进去并启用,但运行期聚合结果是集合(ArrayList),标量目标收到集合即 50000「服务异常」(DecimalBizField.fillClientContent)。**子表计数/求和/取最值回主表用字段计算规则**:目标字段建成普通数值字段,然后 \`omc compute --schema <主表> --target <目标字段> --formula 'COUNTIF({<子表code>.<子列code>},"异常")'\`(默认 dry-run,--apply 写入;等价 datarule.create dataRuleType:2,配方 \`field-compute-subtable-sum\`)。规则图里只做 FIXED/DYNAMIC/EXPRESSION 的直接赋值。该计算规则**只在门户前端执行,REST 的 data.submit/data.update 不会回填**(r17 2026-10-25 复核:提交后目标字段仍为传入值),所以 API 里必须自己算好再写;正确性请用门户表单验证。来源:r11 巡检沙盒 2026-10-24(1.1.25)。`,
1241
1241
  }];
1242
1242
  if (!RULE_VALUE_TYPES.includes(valueType))
1243
1243
  blockers.push({ code: "rule-graph-action-value-type", nodeCode, key: "ruleValueType", message: `${where}: ruleValueType "${valueType}" 不在 ${RULE_VALUE_TYPES.join("/")}` });
@@ -410,20 +410,46 @@ function collectChildArrayPaths(value, childCodes, prefix = "") {
410
410
  }
411
411
  return found;
412
412
  }
413
- async function stampChildRowStatuses(session, schemaCode, formData) {
414
- const childCodes = new Set();
413
+ /**
414
+ * One published field.list read, reused by the child-row stamping AND the
415
+ * value-shape gates below, so the gates add zero extra wire calls.
416
+ */
417
+ async function loadPublishedFieldMeta(session, schemaCode) {
415
418
  try {
416
419
  const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
420
+ const childCodes = [];
421
+ const meta = new Map();
417
422
  for (const row of rows) {
418
- if (Number(row.propertyType ?? row.type) === CHILD_TABLE_PROPERTY_TYPE)
419
- childCodes.add(String(row.code ?? row.propertyCode ?? ""));
423
+ const code = String(row.code ?? row.propertyCode ?? "");
424
+ if (!code)
425
+ continue;
426
+ const type = Number(row.propertyType ?? row.type);
427
+ let format;
428
+ if (typeof row.options === "string" && row.options) {
429
+ try {
430
+ const parsed = JSON.parse(row.options);
431
+ if (typeof parsed.format === "string")
432
+ format = parsed.format;
433
+ }
434
+ catch {
435
+ // unparsable options: shape gates for this field are skipped
436
+ }
437
+ }
438
+ meta.set(code, { type, ...(format !== undefined ? { format } : {}) });
439
+ if (type === CHILD_TABLE_PROPERTY_TYPE)
440
+ childCodes.push(code);
420
441
  }
442
+ return { childCodes, meta };
421
443
  }
422
444
  catch {
423
- return { formData, childCodes: [] };
445
+ return null;
424
446
  }
425
- if (childCodes.size === 0)
426
- return { formData, childCodes: [] };
447
+ }
448
+ async function stampChildRowStatuses(session, schemaCode, formData, preloaded) {
449
+ const loaded = preloaded !== undefined ? preloaded : await loadPublishedFieldMeta(session, schemaCode);
450
+ if (!loaded || loaded.childCodes.length === 0)
451
+ return { formData, childCodes: loaded?.childCodes ?? [], meta: loaded?.meta ?? new Map() };
452
+ const childCodes = new Set(loaded.childCodes);
427
453
  let changed = false;
428
454
  const next = { ...formData };
429
455
  for (const code of childCodes) {
@@ -441,7 +467,136 @@ async function stampChildRowStatuses(session, schemaCode, formData) {
441
467
  return { ...row, rowStatus: hasId ? "Modified" : "Added" };
442
468
  });
443
469
  }
444
- return { formData: changed ? next : formData, childCodes: [...childCodes] };
470
+ return { formData: changed ? next : formData, childCodes: [...childCodes], meta: loaded.meta };
471
+ }
472
+ /**
473
+ * The one write-path preflight shared by data.save / data.update / data.submit:
474
+ * load the published field metadata ONCE, refuse the proven bad value shapes
475
+ * (date/relation/child-row-id) BEFORE any mutation, then stamp rowStatus.
476
+ */
477
+ async function prepareRuntimeFormData(session, schemaCode, formData, opts) {
478
+ const loaded = await loadPublishedFieldMeta(session, schemaCode);
479
+ if (loaded)
480
+ assertRuntimeFieldValueShapes(loaded.meta, formData, loaded.childCodes, { schemaCode, isUpdate: opts.isUpdate });
481
+ const stamped = await stampChildRowStatuses(session, schemaCode, formData, loaded);
482
+ return { formData: stamped.formData, childCodes: stamped.childCodes };
483
+ }
484
+ /**
485
+ * Translate a platform DATE options.format into a validator regex. The format
486
+ * enum is UPPERCASE (`YYYY-MM-DD HH:mm:ss`), tokenized longest-first; literals
487
+ * (`-`, `/`, `:`, space, 年/月/日) are escaped. Unknown formats return null —
488
+ * the gate then stays silent rather than guessing.
489
+ */
490
+ function dateFormatRegex(format) {
491
+ const KNOWN = new Set([
492
+ "YYYY", "YYYY-MM", "YYYY/MM", "YYYY-MM-DD", "YYYY/MM/DD",
493
+ "YYYY-MM-DD HH:mm", "YYYY/MM/DD HH:mm", "YYYY-MM-DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss",
494
+ "YYYY-MM-DD CN-APM", "YYYY/MM/DD CN-APM", "YYYY-MM-DD CN-APM HH:mm", "YYYY/MM/DD CN-APM HH:mm",
495
+ "YYYY-MM-DD EN-APM", "YYYY/MM/DD EN-APM", "YYYY-MM-DD EN-APM HH:mm", "YYYY/MM/DD EN-APM HH:mm",
496
+ "YYYY年MM月DD日", "YYYY年MM月",
497
+ ]);
498
+ if (!KNOWN.has(format))
499
+ return null;
500
+ const pattern = format
501
+ .split("CN-APM").join("\u0001")
502
+ .split("EN-APM").join("\u0002")
503
+ .split("YYYY").join("\\d{4}")
504
+ .split("MM").join("\\d{2}")
505
+ .split("DD").join("\\d{2}")
506
+ .split("HH").join("\\d{2}")
507
+ .split("mm").join("\\d{2}")
508
+ .split("ss").join("\\d{2}")
509
+ .split("\u0001").join("(?:上午|下午)")
510
+ .split("\u0002").join("(?:AM|PM)")
511
+ .replace(/[-/]/g, (ch) => `\\${ch}`);
512
+ return new RegExp(`^${pattern}$`);
513
+ }
514
+ /**
515
+ * r17 (2026-10-26) live write-shape evidence on 1.1.25 — every case below was
516
+ * observed on the wire, none is speculative:
517
+ * - DATE(3): a plain string in the field's declared format lands correctly;
518
+ * a millisecond timestamp is ACCEPTED and stored NULL (silent data loss);
519
+ * a date-only or ISO-8601-with-timezone string against a datetime format
520
+ * answers 50000 服务异常.
521
+ * - WORK_SHEET(9): a single relation field takes the bare object id string;
522
+ * an array answers 50000 服务异常.
523
+ * - CHILD_TABLE rows on an UPDATE of an existing record: a row without `id`
524
+ * is INSERTED as a NEW child row (no upsert) — r17: four updates each
525
+ * resending two id-less rows turned 2 child rows into 8, and a roll-up
526
+ * SUM over them silently doubled. The portal always sends the id (→
527
+ * rowStatus Modified) for existing rows; a hand-built payload must too.
528
+ */
529
+ export function assertRuntimeFieldValueShapes(meta, formData, childCodes, context) {
530
+ for (const [code, value] of Object.entries(formData)) {
531
+ if (value === null || value === undefined)
532
+ continue;
533
+ const field = meta.get(code);
534
+ if (!field)
535
+ continue;
536
+ if (field.type === 3 && typeof value === "number") {
537
+ throw new ToolRefusalError({
538
+ status: "refused",
539
+ blocker: "submit-date-shape",
540
+ schemaCode: context.schemaCode,
541
+ field: code,
542
+ message: `日期字段 ${code} 收到数字 ${value}(毫秒时间戳):平台接受但**静默落库 NULL**(r17 真机)。` +
543
+ `日期必须写成字段 format 对应的字符串(本字段 format=${field.format ?? "未知"}),如 "2026-10-26 11:00:00"。`,
544
+ });
545
+ }
546
+ if (field.type === 3 && typeof value === "string" && field.format) {
547
+ const re = dateFormatRegex(field.format);
548
+ if (re && !re.test(value.trim())) {
549
+ throw new ToolRefusalError({
550
+ status: "refused",
551
+ blocker: "submit-date-shape",
552
+ schemaCode: context.schemaCode,
553
+ field: code,
554
+ format: field.format,
555
+ message: `日期字段 ${code} 的值 "${value}" 不匹配字段声明的 format "${field.format}"——平台直接回 50000 服务异常(r17 真机:仅日期串/ISO8601 带时区都打回)。` +
556
+ `按 format 逐位对齐;改格式走 field.update。`,
557
+ });
558
+ }
559
+ }
560
+ if (field.type === 9 && Array.isArray(value)) {
561
+ // Same refusal as the post-hoc diagnoseRelationShapeFailure (which stays
562
+ // as the backstop for nested child-row relations); pre-write is cheaper.
563
+ throw new ToolRefusalError({
564
+ status: "refused",
565
+ blocker: "relation-single-array-shape",
566
+ schemaCode: context.schemaCode,
567
+ fields: [code],
568
+ message: `单选关联字段 ${code}(WORK_SHEET/propertyType 9)传了数组:平台稳定回 50000(r17b 2026-10-26 真机)。` +
569
+ `单值关联写 **id 字符串或 {id}**;多值关联(MULT_WORK_SHEET 11)与人员/组织(50/51)才用数组。`,
570
+ });
571
+ }
572
+ }
573
+ if (context.isUpdate) {
574
+ const problems = [];
575
+ for (const code of childCodes) {
576
+ const rows = formData[code];
577
+ if (!Array.isArray(rows))
578
+ continue;
579
+ const missing = rows.filter((row) => row !== null &&
580
+ typeof row === "object" &&
581
+ !Array.isArray(row) &&
582
+ !(typeof row.id === "string" && row.id.trim().length > 0) &&
583
+ !(typeof row.rowStatus === "string" && row.rowStatus.trim().length > 0));
584
+ if (missing.length > 0)
585
+ problems.push(`${code}(${missing.length}/${rows.length} 行无 id/rowStatus)`);
586
+ }
587
+ if (problems.length > 0) {
588
+ throw new ToolRefusalError({
589
+ status: "refused",
590
+ blocker: "submit-child-rows-missing-id",
591
+ schemaCode: context.schemaCode,
592
+ childTables: problems,
593
+ message: `更新既有记录时子表行必须带 id:${problems.join("、")}。平台对无 id 的子行按**新增插入**(不判重)——` +
594
+ `r17 真机:同一条记录连发 4 次带 2 行无 id 子表的 update,子行从 2 变 8,聚合 SUM 翻倍。` +
595
+ `改既有行:先 data.load 拿子行 id 原样带回(平台走 rowStatus=Modified);` +
596
+ `有意追加新行:显式给该行 rowStatus:"Added"。`,
597
+ });
598
+ }
599
+ }
445
600
  }
446
601
  /** Runtime business-object data keyed by field code. */
447
602
  export function registerRuntimeDataLane(tools) {
@@ -766,7 +921,9 @@ export function registerRuntimeDataLane(tools) {
766
921
  message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
767
922
  });
768
923
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
769
- const formData = (await stampChildRowStatuses(session, args.schemaCode, args.formData)).formData;
924
+ const rawSaveData = args.formData;
925
+ const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
926
+ const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
770
927
  if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
771
928
  // Portal draft-save shape. The row id travels in data.id (the portal
772
929
  // form SDK receives a pre-generated id from form/load and echoes it);
@@ -932,7 +1089,7 @@ export function registerRuntimeDataLane(tools) {
932
1089
  message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
933
1090
  });
934
1091
  }
935
- const formData = (await stampChildRowStatuses(session, args.schemaCode, args.formData)).formData;
1092
+ const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
936
1093
  const performed = await wireCall(session, {
937
1094
  path: ENDPOINTS.runtimeFormSave,
938
1095
  method: "POST",
@@ -1066,7 +1223,7 @@ export function registerRuntimeDataLane(tools) {
1066
1223
  });
1067
1224
  }
1068
1225
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1069
- const stampedSubmit = await stampChildRowStatuses(session, args.schemaCode, args.formData);
1226
+ const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
1070
1227
  const formData = stampedSubmit.formData;
1071
1228
  // r5-asset 2026-10-20: the platform APPENDS child rows carried in an
1072
1229
  // approval submit's formData (no upsert by row id, no error). Live: an
@@ -55,7 +55,7 @@ const dataruleCalcRule = Object.freeze({
55
55
  name: "c=a+b",
56
56
  },
57
57
  preconditions: ["**拼装器优先**:简单表达式用 `omc compute --formula '{a}*0.4+{b}*0.3'`;**条件聚合**用 `omc compute --formula '{<子表>.<子列>}' --where '<子列> = <值>'`(自动编译 COUNTIF/SUMIF)——两者都默认 dry-run、`--apply` 写入。本配方仅用于拼装器表达不了的条件分支等复杂场景。",
58
- "**计算类需求:建普通业务字段(数值优先,文本亦可)+ 本计算规则;不要用「计算公式」字段类型(FORMULA)**——平台约定是普通字段顺带配计算规则(真机 2026-10-08 复核:普通数值字段 + type2 计算规则,门户算出 7.00,设计器「计算规则」弹窗显示公式)。",
58
+ "**计算类需求:建普通业务字段(数值优先,文本亦可)+ 本计算规则;不要用「计算公式」字段类型(FORMULA)**——平台约定是普通字段顺带配计算规则(2026-10-08 门户复核:普通数值字段 + type2 计算规则,门户算出 7.00,设计器「计算规则」弹窗显示公式)。⚠️ r17 2026-10-25:这条规则**只在门户前端执行**,REST 的 `data.submit`/`data.update` 写完目标字段仍是传入值,API 调用方必须自己算好再写。",
59
59
  "参与计算的字段已发布",
60
60
  ],
61
61
  conditionalRequirements: [
@@ -98,6 +98,7 @@ const dataruleSubtableSum = Object.freeze({
98
98
  preconditions: ["**拼装器优先**:简单表达式用 `omc compute --formula '{a}*0.4+{b}*0.3'`;**条件聚合**用 `omc compute --formula '{<子表>.<子列>}' --where '<子列> = <值>'`(自动编译 COUNTIF/SUMIF)——两者都默认 dry-run、`--apply` 写入。本配方仅用于拼装器表达不了的条件分支等复杂场景。",
99
99
  "**目标 `total` 是普通数值字段(不是「计算公式」字段)**;`<childSchemaCode>` 是子表字段的 code(如 `prop_repair_detail` / `Sub_item_information_table`),`amount` 是其子列 code",
100
100
  "子表与其列已发布",
101
+ "⚠️ r17 2026-10-25 复核:`SUM({子表.子列})` 的 type2 规则 `data.submit`/`data.update` 后目标字段**仍是传入值**(实测传 0 停在 0),`docker logs` 无任何 CalculateHandler/formula-recalc 记录。本配方只在**门户填表**时生效;API 路径请自行汇总后写入。同模型子表聚合也**不能**改用 FORMULA 字段的 AGGREGATION——`field.publish` 必抛 305003(`aggregation-self-schema-cycle`)。",
101
102
  ],
102
103
  conditionalRequirements: [
103
104
  "子表列引用用 `{子表字段code.子列code}` 点号全限定;求和 `SUM({子表.子列})`(真机核验:`SUM({prop_repair_detail.amount})`)。",
@@ -223,8 +223,61 @@ const businessModelDropdown = Object.freeze({
223
223
  requiresEnvironmentDerivedIds: true,
224
224
  prerequisiteData: { source: "discover_environment → field.list(published:true)", fields: ["source.model", "source.businessField", "app.code"] },
225
225
  });
226
+ const formulaAggregationCrossModel = Object.freeze({
227
+ id: "formula-aggregation-cross-model",
228
+ title: "跨模型汇总:FORMULA 字段 + AGGREGATION + DYNAMIC 条件(API 可回算,r17 真机 100→105 实测)",
229
+ aliases: ["跨模型汇总", "跨模型聚合", "另一个模型", "其它模型", "其他模型", "另一张表", "统计合计写入", "汇总到本模型", "API 回填聚合"],
230
+ status: "offline-valid",
231
+ tool: "field.create",
232
+ args: {
233
+ environment,
234
+ schemaCode: "r17_d",
235
+ code: "total3",
236
+ name: "按名称合计",
237
+ propertyType: "FORMULA",
238
+ options: {
239
+ formulaConfig: JSON.stringify({
240
+ formulaType: "AGGREGATION",
241
+ resultType: 2,
242
+ aggregation: JSON.stringify({
243
+ schemaCode: "r17_c",
244
+ dataItemCode: "amt",
245
+ calculateType: "SUM",
246
+ formula: "SUM({r17_c.amt})",
247
+ queryConditions: [[{ propertyCode: "nm", currentPropertyCode: "srcNo", operatorType: 1, valueType: "DYNAMIC", currentPropertyType: 0 }]],
248
+ }),
249
+ }),
250
+ persistent: true,
251
+ },
252
+ },
253
+ preconditions: [
254
+ "**只能汇总「别的模型」**:同模型(含自己的子表)聚合在 field.publish 必抛 305003(平台 publishFormula 把相对码改写成子表父模型 = 本模型,checkCycleOfAggregation:1032 判成环)。工具已写前拒绝 `aggregation-self-schema-cycle`。",
255
+ "目标字段用 FORMULA(100)(不是普通字段 + 计算规则——type2 计算规则在 REST 路径永不执行)。",
256
+ "`schemaCode` 是被汇总的**来源模型**;`dataItemCode` 是来源模型的列;`queryConditions` **不能为空**,否则反向过滤器为空、找不到要回算的目标行(真机:空条件时 recalc 日志照常打印但目标字段恒为 0)。",
257
+ ],
258
+ conditionalRequirements: [
259
+ "**DYNAMIC 条件的两个 code 分属两侧,写反了就静默 0**:`propertyCode` = **来源模型**的字段(这里是 r17_c.nm),`currentPropertyCode` = **目标模型**的字段(这里是 r17_d.srcNo)。语义 = 「来源行的 nm 等于本目标行的 srcNo」。",
260
+ "`propertyType`/`currentPropertyType` 是 BizPropertyType 的**整数码**(0=SHORT_TEXT 2=NUMERICAL 9=WORK_SHEET…),`operatorType` 是 OperatorType 的**整数码**(1=EQ 6=ITEQ 9=IS_NULL 10=IS_NOT_NULL…)。写名字会被 ObjectMapperUtils 抛 json processing exception 并掩码成 305003;工具会自动转码。",
261
+ "`calculateType` 是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION),不是枚举 code。",
262
+ "发布后平台会**自动生成一条 t100 CALCULATE_RULE 镜像行**(h_biz_data_rule,propertyCode=字段 code),它是回算触发的前置条件,别手动删。",
263
+ "**回算时机(r17b 2026-10-26 真机完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠,不依赖事件);② 来源行 submit/update/delete 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算——FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件(propertyCode 带 `.`)都验证过,DELETE 也会回算;③ ⚠️ 事件回算**只在目标模型发布后的几分钟内可靠**,之后静默停止(源端数据变了目标字段不更新、无报错)——重新 `field.publish --force` 目标模型即可恢复。定时巡检/对账场景务必重发或依赖目标行写入路径。",
264
+ ],
265
+ priorToolReferences: [
266
+ { tool: "field.list", args: { environment, schemaCode: "r17_c", published: true }, purpose: "确认来源模型与其列已发布,取 dataItemCode 与 DYNAMIC 条件的 propertyCode" },
267
+ { tool: "field.list", args: { environment, schemaCode: "r17_d", published: true }, purpose: "确认目标模型已有所承载匹配值的字段(currentPropertyCode)" },
268
+ ],
269
+ dryRun: { supported: true, command: `omc call field.create --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 校验 options 形状与聚合目标;同模型聚合在此拒绝" },
270
+ expectedReadback: { readback: { fieldPresent: true, options: { formulaConfig: "<含 AGGREGATION + DYNAMIC 条件>" } }, next: ["field.publish", "data.submit 来源行(紧接发布后几分钟内)→ data.load 目标行读到合计", "form.publish"] },
271
+ refusalRecovery: [
272
+ { code: "aggregation-self-schema-cycle", action: "把 schemaCode 换成另一个模型;同模型/自己的子表聚合平台不支持" },
273
+ { code: "duplicate-field", action: "字段已存在改用 field.update 或换 code" },
274
+ ...commonRecovery,
275
+ ],
276
+ requiresEnvironmentDerivedIds: true,
277
+ prerequisiteData: { source: "field.list(published:true) ×2(来源模型 + 目标模型)", fields: ["source.model", "source.dataItemCode", "source.matchField", "target.matchField"] },
278
+ });
226
279
  export const FIELD_METADATA = Object.freeze({
227
280
  "field.update": Object.freeze({ recipes: [fieldSerialNumber, formConditionalRequired, formAlwaysReadonly, formConditionalReadonly], relatedTools: ["field.list", "field.publish", "form.draft", "form.publish"] }),
228
- "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
281
+ "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown, formulaAggregationCrossModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
229
282
  });
230
283
  //# sourceMappingURL=field.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.95",
3
+ "version": "0.4.97",
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.95",
53
+ "@stalfh233/omc-fake-platform": "0.4.97",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.95",
55
+ "omc-core": "0.4.97",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -151,9 +151,13 @@ 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),要改就新建字段或趁草稿改。
154
+ - **「聚合计算」是字段能力,不是规则节点**:FORMULA(计算公式)字段的 `options.formulaConfig.formulaType="AGGREGATION"` 就是 UI 里的「配置方式 → 聚合计算」。**只能汇总另一个模型**(跨模型),**同模型/自己的子表会 305003**(`field.publish` 时 `publishFormula` 把相对码改写成子表父模型 = 本模型,`checkCycleOfAggregation:1032` 判成环;工具已写前拒绝 `aggregation-self-schema-cycle`)。可直接抄 `omc describe field.create --example formula-aggregation-cross-model`。形状:
155
+ `options = {persistent:true, formulaConfig: JSON字符串({resultType:2, formulaType:"AGGREGATION", aggregation: JSON字符串({schemaCode:"<来源模型>", dataItemCode:"<来源列>", calculateType:"SUM", formula:"SUM({<来源模型>.<列>})", queryConditions:[[{propertyCode:"<来源模型字段>", currentPropertyCode:"<目标模型字段>", operatorType:1, valueType:"DYNAMIC"}]]})})}`。
156
+ - **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
157
+ - **DYNAMIC 条件的两个 code 分属两侧,写反了静默 0**:`propertyCode` = 来源模型字段,`currentPropertyCode` = 目标模型字段;语义「来源行的 X 等于本目标行的 Y」。
158
+ - **`propertyType`/`currentPropertyType`(BizPropertyType)与 `operatorType`(OperatorType)必须写整数**——两者都是 `@JsonCreator get(int)`:propertyType 0=SHORT_TEXT 2=NUMERICAL 3=DATE 9=WORK_SHEET…;operatorType 1=EQ 2=NEQ 3=GT 4=IT(小于)5=GTEQ 6=ITEQ(小于等于)9=IS_NULL 10=IS_NOT_NULL…。写名字会被 `ObjectMapperUtils` 抛 json processing exception,再被死循环校验掩码成 305003(工具接受名字并自动转码)。`calculateType` 相反,是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION)。
159
+ - **回算时机(r17b 2026-10-26 完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/**delete** 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算(FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件都验过);③ ⚠️ 事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错)——`field.publish --force` 目标模型恢复。发布后平台自动生成 t100 `CALCULATE_RULE` 镜像行,别删。r17 真机:目标行 srcNo=C1、来源 C1 行 amt=100 → 读 100;再提交一条 C1/amt=5 → 读 105。
160
+ - **普通数值字段 + 计算规则(type2)不能替代**:REST 路径永不执行(`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 从不置真),门户里才算。
157
161
  - **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
162
  - **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
159
163
  - **预置 Update 规则里的跨模型回写只在 `data.update` 触发**:`rule-graph-cross-model-update-trigger-inert` 现在是 hazard(在 `readback.lintHazards`,不混进 `lintWarnings`);create/submit 不执行它。要提交时生效,把节点放进 Create 规则或流程事件规则。
@@ -91,7 +91,9 @@ only the section for the capability you need —
91
91
  - **边界 / 陷阱**:
92
92
  - **计算类需求默认走「普通字段 + 计算规则」,不要建「计算公式」字段类型(FORMULA)**:建一个**数值/单行文本**业务字段,再配一条**字段级计算规则**(`datarule.create` `dataRuleType:2`,`options.shortTextStitch={"formula","editorText","editorMark"}`)。理由:平台约定用正常业务字段承载、顺带配计算规则(真机 2026-10-08 production 表单核验 + 设计器弹窗显示)。「计算公式」字段类型不是默认,仅当确需一列专用公式时才用。
93
93
  - **计算规则公式引用**:同类字段裸 `{字段code}`(`{a}+{b}`);**子表列用 `{子表字段code.子列code}`**(`{Sub_item_information_table.taxInclusiveAmount}`);子表求和 `SUM({子表.子列})`;支持 `IF(cond,a,b)`、`CASE(v,c1,r1,c2,r2)`、`VALUE()`、`AND`、比较 `=`、字符串字面量与运算符空格。recipe:`omc describe datarule.create --example field-compute-rule|field-compute-subtable-sum|field-compute-child-column`。
94
- - 计算规则(`dataRuleType:2`)**由服务端在 `data.submit`/`data.update` 时计算并回填,且覆盖传入值**(真机 1.1.25,2026-10-24 复核;对照组:删规则后传入值生效)。可直接 `data.load` 读回验证,无需前台人工确认。
94
+ - 计算规则(`dataRuleType:2`)**在 REST 路径上不会执行也不会回填**(r17 2026-10-25 复核:`data.submit`/`data.update` 后目标字段仍为传入值)。
95
+ - **跨模型汇总可以用 FORMULA(100) + AGGREGATION,REST 能回算**(r17 真机 100→105):`omc describe field.create --example formula-aggregation-cross-model`;`queryConditions` 必须给 DYNAMIC 条件(propertyCode=来源字段、currentPropertyCode=目标字段),空条件静默得 0。
96
+ - **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003(`aggregation-self-schema-cycle`,平台 `checkCycleOfAggregation:1032` 逻辑成环)。
95
97
  - 只有 `FORMULA`(计算公式,`propertyType:100`)字段是**门户前端**计算、API 不回填(旧证据 2026-09-25/10-07,仅覆盖该字段类型)。
96
98
  - 子表列支持标量族 + WORK_SHEET 关联列(需 `relativeCode`+options);**嵌套子表 / MULT_WORK_SHEET / RELEVANCE_DATA 不覆盖**。
97
99
 
@@ -44,13 +44,18 @@ capability's coverage/tools/boundaries from `omc-capabilities`.
44
44
  Coverage status and boundaries for every capability (including 权限/子表/聚合/
45
45
  上线/内联/OCR) live in **`omc-capabilities`** — its 能力索引 table and
46
46
  [边界] section are the authority; read the relevant section there rather than a
47
- copy here. In particular, **跨模型/子表聚合、汇总、求和是配置能力**,不要当二开。
48
- 落点是**字段计算规则**:条件计数/求和用拼装器的 `--where`——
49
- `omc compute --schema <主表> --target <目标字段> --formula '{<子表code>.<子列code>}' --where "<子列code> = <值>"`(编译为 COUNTIF/SUMIF;等价手写 `--formula 'COUNTIF({<子表code>.<子列code>},"异常")'`)
50
- (等价 `datarule.create` `dataRuleType:2`,配方 `field-compute-subtable-sum` / `field-compute-child-column`)。
47
+ copy here. In particular, **跨模型汇总/求和是配置能力**(FORMULA(100) + AGGREGATION,REST 可回算),不要当二开;
48
+ **同模型(含自己的子表)聚合平台不支持**——`field.publish` 必抛 305003(`aggregation-self-schema-cycle`),别用 FORMULA 兜底。
49
+
50
+ 跨模型汇总的正解(r17 2026-10-25 真机 100→105 验证):
51
+ `omc describe field.create --example formula-aggregation-cross-model`。要点:`aggregation.schemaCode` 指向**另一个模型**,
52
+ `queryConditions` 必须给 DYNAMIC 条件(`propertyCode`=来源字段、`currentPropertyCode`=目标字段),
53
+ `propertyType`/`operatorType` 写整数码(工具会自动转名字),`calculateType` 写字符串。
54
+ 空 `queryConditions` 会静默得 0——它同时是「哪些目标行要回算」的反向过滤器输入。
55
+
56
+ 字段计算规则(`dataRuleType:2`)在 REST 路径上**不会执行也不会回填**(r17 2026-10-25 复核:`data.submit`/`data.update` 后目标字段仍为传入值)。
51
57
  **不要**在规则图(`rule.save`)里用 `ruleValueType:"AGGREGATION"` —— 平台会存进去并启用,运行期聚合结果是集合,
52
58
  标量目标收到集合即 50000「服务异常」(r11 巡检沙盒 2026-10-24,1.1.25;OMC 现已写前 fail-closed)。
53
- 字段计算规则(`dataRuleType:2`)由**服务端**在 `data.submit`/`data.update` 时计算并回填(且覆盖传入值,真机 1.1.25 复核):写完直接 `data.load` 读目标字段即可验证。只有 `FORMULA`(100) 字段是前端计算、API 不回填。
54
59
 
55
60
  Cue → capability is a starting point, never a substitute for 定面. A cue that
56
61
  could name two faces is resolved by the 定面表 in the parent skill.
@@ -36,6 +36,7 @@ omc help omc-write-hazards
36
36
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
37
37
  - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
38
38
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。
39
+ 其中三类已有**写前拒绝**(r17b 2026-10-26 起):日期收到毫秒时间戳(平台接受但落库 NULL)、日期字符串不匹配字段声明 format(50000)、单值关联收到数组(50000)——blocker `submit-date-shape` / `submit-relation-shape`。
39
40
  - **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
40
41
 
41
42
  - **多选关联(11) 的写入源码依据**(1.1.25 实机 jar):`WorkSheetMultiBizField.contentFromClient` 只接受 `String` 或 `Map`,其它类型直接 `AppAsserts.isTrue(false, "Incorrect value type, should be string or map.")` → 表现为 50000;`contentToDbValue()` 返回 `map.get("id")`,而读回时按 `";"` 切分(`MULTI_WORK_SHEET_SPILT`)并组装 `items` 数组。所以**数组是读写不对称的读侧形状,不能拿来写**;分号串才是写侧形状。实测三种形状:`"<id>"`/`"<id>;<id>"` 成功、`["<id>"]` 50000。
@@ -89,6 +90,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
89
90
 
90
91
  父 `CHILD_TABLE` 行不接受 `options/choices/subFields` 更新;子列直接走上面的 CRUD 面。
91
92
 
93
+ - **更新既有记录时,子表行必须带 `id`**(r17b 2026-10-26 真机):`data.update`/`data.submit(bizObjectId)` 里无 `id` 也无显式 `rowStatus` 的子行,平台按**新增插入**(不判重)——同一条记录连发 4 次带 2 行无 id 子表的 update,子行从 2 变 8,聚合 SUM 翻倍。改既有行:先 `data.load` 拿子行 id 原样带回(→ rowStatus=Modified);有意追加新行显式写 `rowStatus:"Added"`。OMC 已写前拒绝 `submit-child-rows-missing-id`。
92
94
  - **提交/审批都不要再回传子表数组**(真机 2026-10-19/20):`data.save` 建草稿后 `data.submit` 若带子表行(无 `id`),平台按新增追加(3→9);**审批提交同样**——`data.submit` 带 `workItemId`+`agree` 推进一步时回传子表,每级再 +1 行(1→2→3)。金额若显式传了合计不会翻倍,但行数与 SUM 口径已被污染,且没有报错。审批只回传标量字段(`agree`/意见/被改标量);确需改子行时带 `id`+`rowStatus:"Modified"`。验收加一条"审批后子表行数不变"。
93
95
 
94
96
  ## 写调用纪律
@@ -97,13 +99,20 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
97
99
 
98
100
  ## 计算规则(datarule)的取值时机
99
101
 
100
- - **字段计算规则(`datarule.create` `dataRuleType:2`)由服务端在写入时计算并回填**(真机 1.1.25 复核 2026-10-24):
101
- `data.submit` 与 `data.update` 都会算,且**计算值覆盖调用方传入的值**。
102
- - 实测:`COUNTIF({子表.checkResult},"异常")` → submit 4 行(3 异常)回读 `3`;update 传 `9` 回读仍是重算的 `2`;删除该规则后同一 update 传 `7` 则回读 `7`(对照组)。
103
- - 所以「计算规则是否生效」**可以用 API 判定**:写一条带子表的数据后 `data.load` 读目标字段。
104
- - 服务端聚合(子表计数/求和/回主表)**不需要二开**,用普通字段 + type2 计算规则即可参与流程分流、报表、后续 update。
105
- - **区别:`FORMULA`(计算公式,`propertyType:100`)字段仍只在前端算** —— 旧证据(2026-09-25 / 2026-10-07)只覆盖该字段类型,API 写入不回填。计算类需求默认不要建 FORMULA 字段(见 `omc-capabilities` §字段)。
106
- - ⚠️ **计算字段不要靠 `data.update` 主动赋值**:规则在时会重算并丢弃你传的值。要让某字段取特定值,先确认它没有计算规则(或删规则),再写。
102
+ - **字段计算规则(`datarule.create` `dataRuleType:2`)在 REST 路径上不会执行、也不会回填**(r17 2026-10-25 复核,AI-Platform 1.1.25):
103
+ `data.submit` 与 `data.update` 写完目标字段**仍是调用方传入值**;`docker logs` 里没有任何 CalculateHandler/formula-recalc 记录。
104
+ - 实测:`SUM({rows.amt})` 的 type2 规则 + 两行明细(10+20),submit 传 `total:0` 回读仍是 `0`;`data.update` 传 `0` 也仍是 `0`。
105
+ (早前「覆盖传入值、API 可判定」的结论来自 r12,已被推翻——不要再用它做验收。)
106
+ - 源码门控:`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 只在 `BizObjectOptions.executeCalRule()` 为真时求值,而该 builder 全库 0 调用方(REST 路径从不置真)。
107
+ - 所以「计算规则是否生效」**不能用 API 判定**;正确性只能在门户表单里验。API 调用方请自己算好再写。
108
+ - **跨模型汇总可以走 FORMULA(100) + AGGREGATION,而且 REST 能回算**(r17 真机 100→105;r17b 2026-10-26 补齐行为图谱):
109
+ `aggregation.schemaCode` 指向**另一个模型**,`queryConditions` 必须给 DYNAMIC 条件
110
+ (`propertyCode`=来源模型字段、`currentPropertyCode`=目标模型字段)。配方:`omc describe field.create --example formula-aggregation-cross-model`。
111
+ 已验证的形状:FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件(`propertyCode` 带 `.`)、来源行 DELETE 也会回算。
112
+ **回算可靠性(重要)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/delete 的事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错,源变了目标不更新)——重新 `field.publish --force` 目标模型恢复。验收:发布后立刻改来源行并读目标字段;长期对账别依赖事件回算。
113
+ - **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003 —— `publishFormula` 把相对码改写成子表父模型(= 本模型),
114
+ `checkCycleOfAggregation:1032` 判成环。与 payload 无关,8.6.26 与 1.1.25 代码相同。OMC 已写前拒绝 `aggregation-self-schema-cycle`。
115
+ - **区别:`FORMULA`(计算公式,`propertyType:100`)字段的 EXPRESSION 模式仍只在前端算** —— API 写入不回填。
107
116
 
108
117
  ## 规则与业务集成
109
118