@stalfh233/omc-cli 0.4.97 → 0.4.98

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.
@@ -231,7 +231,12 @@ export const CAPABILITY_CUES = Object.freeze({
231
231
  onlinejs: ["在线js", "脚本", "联动", "onload", "onvalidate"],
232
232
  bizservice: ["集成", "接口", "服务", "回调", "对接", "外部系统", "datasql", "restful"],
233
233
  report: ["报表", "图表", "看板", "bi", "dashboard"],
234
- data: ["运行数据", "提交数据", "保存记录", "查记录"],
234
+ // r18 (2026-10-26): "造数据 / 造测试单据 / 录数据" is the RUN-DATA capability, but
235
+ // the goal usually also says 子表/明细 (field cues) — without these compound
236
+ // cues the router sent the probe to `field` in read mode (write:false), so it
237
+ // never reached data.submit. Compound (not bare 数据) to avoid stealing
238
+ // ordinary model/field asks that merely mention data.
239
+ data: ["运行数据", "提交数据", "保存记录", "查记录", "造数据", "测试数据", "测试单据", "造单据", "造单", "录数据", "填数据", "模拟数据", "业务数据", "造几条", "造两张", "造几张", "验数据"],
235
240
  });
236
241
  /**
237
242
  * Boundary cues — authored alongside CAPABILITY_CUES (same runtime authority).
package/dist/compute.js CHANGED
@@ -212,8 +212,27 @@ export async function cmdCompute(args) {
212
212
  // data.save / data.submit / data.update never refresh the target field, no
213
213
  // matter how correct the formula is. Say so in the receipt instead of letting
214
214
  // the caller believe the write took effect (r12 believed it; r16 disproved it).
215
+ // r18: the dry-run receipt showed only an optionsSummary hash, so the probe
216
+ // could not tell whether `{items.qty} * {items.price}` means per-row or
217
+ // aggregate-after-multiply — a semantic difference that changes the answer.
218
+ // Echo the exact compiled formula (and its editor twin) on the dry-run face.
219
+ const compiled = !apply
220
+ ? (() => {
221
+ const stitch = createArgs.options?.shortTextStitch;
222
+ if (typeof stitch !== "string")
223
+ return undefined;
224
+ try {
225
+ const parsed = JSON.parse(stitch);
226
+ return { formula: parsed.formula ?? null, editorText: parsed.editorText ?? null, editorMark: parsed.editorMark ?? null };
227
+ }
228
+ catch {
229
+ return undefined;
230
+ }
231
+ })()
232
+ : undefined;
215
233
  const withWarning = apply && classified.status === "ok" ? { ...result, ...computeExecutionNotice() } : result;
216
- writeEnvelope(envelope(classified.status, withWarning, request, classified.error));
234
+ const withCompiled = compiled ? { ...withWarning, compiledFormula: compiled } : withWarning;
235
+ writeEnvelope(envelope(classified.status, withCompiled, request, classified.error));
217
236
  return exitCode;
218
237
  }
219
238
  //# sourceMappingURL=compute.js.map
@@ -954,6 +954,16 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
954
954
  const agg = parseOptionsObject(config.aggregation);
955
955
  if (!agg || !isNonEmptyString(agg.schemaCode) || !isNonEmptyString(agg.dataItemCode) || !isNonEmptyString(agg.formula))
956
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 (String(agg.dataItemCode).includes("."))
958
+ throw new ToolRefusalError({
959
+ status: "refused",
960
+ blocker: "aggregation-data-item-not-scalar",
961
+ schemaCode: schemaCode ?? null,
962
+ dataItemCode: String(agg.dataItemCode),
963
+ message: `聚合数据项 ${String(agg.dataItemCode)} 带点号:平台只接受**来源模型的主表标量列 code**(不带 schema 前缀)。` +
964
+ `写成 <子表>.<子列> 会在 field.publish 回 309004「使用的聚合数据项不存在」(r18 真机)。` +
965
+ `要汇总子表列:先在来源主模型上物化一个数值字段(门户计算规则或写入方维护),再聚合那个标量列。`,
966
+ });
957
967
  if (schemaCode !== undefined && aggregationTargetsOwnSchema(schemaCode, String(agg.schemaCode)))
958
968
  throw new ToolRefusalError({
959
969
  status: "refused",
@@ -13,7 +13,7 @@ import { listSkills, readSkillSection } from "./skills.js";
13
13
  const SCOUT_CUES = ["能不能", "能不能够", "能否", "是否支持", "支持吗", "支持不支持", "可不可以", "有没有办法", "有没有可能", "怎么实现", "如何实现", "怎么写", "怎么配置"];
14
14
  const MODIFY_CUES = ["修改", "调整", "更新", "重命名", "改名", "编辑", "删除", "去掉", "移除", "改为", "改成"];
15
15
  const READ_CUES = ["查看", "读回", "盘点", "现状", "列出", "有哪些", "有没有", "检查", "审计", "导出", "只读", "看看", "看下", "查一下", "查询一下", "查一查", "梳理", "清点", "给我一份", "哪些", "是什么", "是否", "是否正确", "现有", "已有"];
16
- const BUILD_CUES = ["新建", "创建", "搭建", "从零", "从无到有", "做", "建", "加", "实现", "生成", "新增", "保存", "启用", "录入", "添加"];
16
+ const BUILD_CUES = ["新建", "创建", "搭建", "从零", "从无到有", "做", "建", "加", "实现", "生成", "新增", "保存", "启用", "录入", "添加", "造", "填报", "打卡", "录"];
17
17
  /** Whole-application asks — a single-model atom is the wrong shape for these. */
18
18
  const WHOLE_APP_CUES = ["应用", "系统", "整套", "从零", "从无到有", "端到端", "一条龙", "一整套", "业务应用", "平台"];
19
19
  function looksWholePackage(goal, capabilityCount) {
@@ -122,6 +122,10 @@ function capabilityScores(registry, goal) {
122
122
  const DEFAULT_LADDER_RECIPES = {
123
123
  businessrule: { tool: "rule.save", recipe: "business-rule-branch-conditional" },
124
124
  datarule: { tool: "datarule.create", recipe: "field-compute-rule" },
125
+ // r18: a "造数据 / 录测试单据" ask reached the `data` capability but had no
126
+ // runnable shape — the probe had to fetch `describe data.submit --example`
127
+ // itself. These lane defaults give every ladder step a copyable command.
128
+ data: { tool: "data.submit", recipe: "plain-data-submit" },
125
129
  };
126
130
  function defaultRecipeFor(registry, capabilityId, lane) {
127
131
  const wanted = DEFAULT_LADDER_RECIPES[capabilityId];
@@ -609,6 +613,13 @@ export function goalRoute(registry, goal) {
609
613
  route: {
610
614
  ...(whole ? { primary: "intent", atomicSupplement } : { ladder }),
611
615
  ...routeReads(readSections),
616
+ // r18: when a step already carries a goal-matched recipe, the generic
617
+ // first section ("3. Draft template") is noise — the probe read it and
618
+ // got nothing. Say that the recipe command is the answer and the
619
+ // sections are only a fallback.
620
+ ...(ladder.some((step) => step.recipe)
621
+ ? { readHint: "已命中配方:先跑 firstCommands 里的 `omc describe … --example …` 取形状;下面的 readSections 只是补充,不必先读(确实需要时用 `omc help <topic> --section \"<节>\"`,按 readMore 逐节补)。" }
622
+ : {}),
612
623
  // Each ladder step already carried a runnable recipe command, but it sat
613
624
  // buried at `ladder[i].recipe.command` behind a readiness line — r14's
614
625
  // probe reported "ladder 本身没给可直接跑的命令" and hand-built the rule
@@ -141,6 +141,21 @@ function assertNameI18nArg(nameI18n) {
141
141
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
142
142
  refuse("JSON 解析结果不是对象");
143
143
  }
144
+ /**
145
+ * r18 (2026-10-26 live): aggregating a CHILD-TABLE schema (`schemaCode:"items"`)
146
+ * publishes fine but makes the TARGET model unwritable — every data.submit /
147
+ * data.update answers `40000 调用数据库发生异常` with no detail (the probe spent
148
+ * ~40 calls bisecting it). The child schema also has no parent-document column,
149
+ * so matching by the parent's document number can never work: even when the
150
+ * write goes through the value is always 0.
151
+ *
152
+ * Child schemas are recognizable from their field list: they alone carry the
153
+ * system columns `parentId`(子表业务对象父类ID)and `sortKey`(子表排序号).
154
+ */
155
+ function childSchemaCodes(rows) {
156
+ const codes = new Set(rows.map((row) => String(row.code ?? row.propertyCode ?? "")));
157
+ return codes.has("parentId") && codes.has("sortKey");
158
+ }
144
159
  /**
145
160
  * r17 (2026-10-25, live + decompiled A/B): a FORMULA field CANNOT aggregate its
146
161
  * own model's child table. `BizPropertyManagerImpl.publishFormula` rewrites
@@ -184,6 +199,43 @@ function assertNoSelfSchemaAggregation(args, published) {
184
199
  message: aggregationSelfSchemaMessage(target, args.schemaCode),
185
200
  });
186
201
  }
202
+ /**
203
+ * The async half of the aggregation gate: a FOREIGN child schema cannot be
204
+ * detected syntactically, so read the target's field list once (only for
205
+ * AGGREGATION) and refuse when it carries the child-schema system columns.
206
+ */
207
+ async function assertNoChildSchemaAggregation(session, schemaCode, propertyType, options) {
208
+ const resolved = typeof propertyType === "number" ? propertyType : FIELD_PROPERTY_TYPES[String(propertyType)];
209
+ if (resolved !== FIELD_PROPERTY_TYPES.FORMULA)
210
+ return;
211
+ const config = decodeJsonObjectColumn(options.formulaConfig, { invalid: "undefined", array: "reject", object: "reject" });
212
+ if (!config || config.formulaType !== "AGGREGATION" || typeof config.aggregation !== "string")
213
+ return;
214
+ const agg = decodeJsonObjectColumn(config.aggregation, { invalid: "undefined", array: "reject", object: "reject" });
215
+ const target = typeof agg?.schemaCode === "string" ? agg.schemaCode.trim() : "";
216
+ if (!target)
217
+ return;
218
+ let rows;
219
+ try {
220
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: target, isPublish: "true" } })).data);
221
+ }
222
+ catch {
223
+ return;
224
+ }
225
+ if (!childSchemaCodes(rows))
226
+ return;
227
+ throw new ToolRefusalError({
228
+ status: "refused",
229
+ blocker: "aggregation-target-is-child-schema",
230
+ schemaCode,
231
+ aggregationSchemaCode: target,
232
+ message: `聚合目标 ${target} 是**子表 schema**(字段清单里带 parentId/sortKey 系统列)。` +
233
+ `平台允许发布,但目标模型从此**整个不可写**:data.submit/data.update 一律回 40000「调用数据库发生异常」且无明细(r18 真机,二分 40 次调用才定位)。` +
234
+ `而且子表 schema 没有父单单号列,按父单匹配恒为 0——即使不报错也算不出值。` +
235
+ `正确做法:聚合**来源模型的主表标量列**。需要子表合计时,先在来源主模型物化一个数值字段` +
236
+ `(门户计算规则 / 写入方维护),再用 AGGREGATION 聚合该标量。配方:omc describe field.create --example formula-aggregation-cross-model。`,
237
+ });
238
+ }
187
239
  export function registerFieldLane(tools) {
188
240
  tools.push(defineTool({
189
241
  name: "field.list",
@@ -344,6 +396,7 @@ export function registerFieldLane(tools) {
344
396
  // One seam owns field-change normalization, every reproduced gate, and the
345
397
  // wire body (src/field-change.ts), so configure_model and intent share it.
346
398
  assertNoSelfSchemaAggregation(args, published);
399
+ await assertNoChildSchemaAggregation(session, args.schemaCode, args.propertyType, args.options ?? {});
347
400
  const prepared = prepareFieldChange(args);
348
401
  let performed;
349
402
  try {
@@ -437,6 +490,7 @@ async function runFieldUpdate(session, args) {
437
490
  .then((response) => wireRows(response.data))
438
491
  .catch(() => []);
439
492
  assertNoSelfSchemaAggregation({ schemaCode: args.schemaCode, propertyType: record.type ?? record.propertyType, options: args.options }, publishedRows);
493
+ await assertNoChildSchemaAggregation(session, args.schemaCode, record.type ?? record.propertyType, args.options);
440
494
  }
441
495
  const update = {
442
496
  schemaCode: args.schemaCode,
@@ -598,6 +598,65 @@ export function assertRuntimeFieldValueShapes(meta, formData, childCodes, contex
598
598
  }
599
599
  }
600
600
  }
601
+ /**
602
+ * r18 (2026-10-26 live): `errcode 40000 调用数据库发生异常` carries NO detail and
603
+ * `omc-refusals` had nothing for it. A probe burned ~40 calls bisecting it:
604
+ * the cause was an AGGREGATION field whose `schemaCode` pointed at a CHILD-TABLE
605
+ * schema — publish succeeds, then every write on that model 40000s. A FORMULA
606
+ * field whose config cannot serialize does the same.
607
+ *
608
+ * Metadata is read ONLY on failure (zero cost on the success path). The refusal
609
+ * names the model's FORMULA/AGGREGATION fields so the caller bisects the right
610
+ * thing instead of guessing at forms/permissions.
611
+ */
612
+ async function diagnoseDbException(session, schemaCode, error) {
613
+ // Non-40000 errors fall through untouched (callers keep their own branches).
614
+ if (!(error instanceof WireError && error.kind === "business" && error.errcode === 40000))
615
+ return false;
616
+ let rows = [];
617
+ try {
618
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
619
+ }
620
+ catch {
621
+ // metadata unavailable: still explain what 40000 means
622
+ }
623
+ const formulaFields = [];
624
+ for (const row of rows) {
625
+ if (Number(row.propertyType ?? row.type) !== 100)
626
+ continue;
627
+ let formulaType;
628
+ let aggregationSchemaCode;
629
+ if (typeof row.options === "string" && row.options) {
630
+ try {
631
+ const options = JSON.parse(row.options);
632
+ const config = typeof options.formulaConfig === "string" ? JSON.parse(options.formulaConfig) : undefined;
633
+ if (typeof config?.formulaType === "string")
634
+ formulaType = config.formulaType;
635
+ if (typeof config?.aggregation === "string") {
636
+ const agg = JSON.parse(config.aggregation);
637
+ if (typeof agg.schemaCode === "string")
638
+ aggregationSchemaCode = agg.schemaCode;
639
+ }
640
+ }
641
+ catch {
642
+ formulaType = "unparsable-formulaConfig";
643
+ }
644
+ }
645
+ formulaFields.push({ code: String(row.code ?? row.propertyCode ?? ""), ...(formulaType ? { formulaType } : {}), ...(aggregationSchemaCode ? { aggregationSchemaCode } : {}) });
646
+ }
647
+ throw new ToolRefusalError({
648
+ status: "refused",
649
+ blocker: "runtime-write-db-exception",
650
+ schemaCode,
651
+ errcode: 40000,
652
+ formulaFields,
653
+ message: `平台回 40000「调用数据库发生异常」——这是**无明细的掩码**,不是表单/权限问题(r18 真机)。` +
654
+ `本模型上的公式字段:${formulaFields.length > 0 ? formulaFields.map((field) => `${field.code}(${field.formulaType ?? "?"}${field.aggregationSchemaCode ? `→${field.aggregationSchemaCode}` : ""})`).join("、") : "(读不到 field.list,或没有 FORMULA 字段)"}。` +
655
+ `最常见原因:**AGGREGATION 字段的 schemaCode 指向子表 schema** —— 平台允许发布,但该模型从此整个不可写。` +
656
+ `其次是 FORMULA 的 formulaConfig 无法序列化。` +
657
+ `定位:依次禁用/删除可疑公式字段再写一次(可逆);形状见 omc describe field.create --example formula-aggregation-cross-model。`,
658
+ });
659
+ }
601
660
  /** Runtime business-object data keyed by field code. */
602
661
  export function registerRuntimeDataLane(tools) {
603
662
  tools.push(defineTool({
@@ -949,15 +1008,22 @@ export function registerRuntimeDataLane(tools) {
949
1008
  // isStartWf branch runs verifyReplay; the instance is DRAFT only when
950
1009
  // data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
951
1010
  const replayToken = await getReplayToken(session);
952
- const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
953
- path: ENDPOINTS.runtimeFormSave,
954
- method: "POST",
955
- body: {
956
- workflowCode: args.workflowCode,
957
- ...(replayToken ? { replayToken } : {}),
958
- bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
959
- },
960
- }), formData, args.workflowCode);
1011
+ let performed;
1012
+ try {
1013
+ performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
1014
+ path: ENDPOINTS.runtimeFormSave,
1015
+ method: "POST",
1016
+ body: {
1017
+ workflowCode: args.workflowCode,
1018
+ ...(replayToken ? { replayToken } : {}),
1019
+ bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
1020
+ },
1021
+ }), formData, args.workflowCode);
1022
+ }
1023
+ catch (error) {
1024
+ await diagnoseDbException(session, args.schemaCode, error);
1025
+ throw error;
1026
+ }
961
1027
  const vo = (performed.data ?? {});
962
1028
  const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
963
1029
  const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
@@ -992,11 +1058,18 @@ export function registerRuntimeDataLane(tools) {
992
1058
  readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
993
1059
  };
994
1060
  }
995
- const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
996
- path: ENDPOINTS.runtimeFormSave,
997
- method: "POST",
998
- body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
999
- }), formData);
1061
+ let performed;
1062
+ try {
1063
+ performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
1064
+ path: ENDPOINTS.runtimeFormSave,
1065
+ method: "POST",
1066
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
1067
+ }), formData);
1068
+ }
1069
+ catch (error) {
1070
+ await diagnoseDbException(session, args.schemaCode, error);
1071
+ throw error;
1072
+ }
1000
1073
  const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
1001
1074
  let readbackData = null;
1002
1075
  if (typeof bizObjectId === "string" && bizObjectId) {
@@ -1090,14 +1163,21 @@ export function registerRuntimeDataLane(tools) {
1090
1163
  });
1091
1164
  }
1092
1165
  const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
1093
- const performed = await wireCall(session, {
1094
- path: ENDPOINTS.runtimeFormSave,
1095
- method: "POST",
1096
- // Live-proven shape: BizObjectModel.getId() reads data["id"], so the
1097
- // target id MUST travel inside `data`; a top-level bizObject.id is
1098
- // ignored and the platform silently creates a new row.
1099
- body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
1100
- });
1166
+ let performed;
1167
+ try {
1168
+ performed = await wireCall(session, {
1169
+ path: ENDPOINTS.runtimeFormSave,
1170
+ method: "POST",
1171
+ // Live-proven shape: BizObjectModel.getId() reads data["id"], so the
1172
+ // target id MUST travel inside `data`; a top-level bizObject.id is
1173
+ // ignored and the platform silently creates a new row.
1174
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
1175
+ });
1176
+ }
1177
+ catch (error) {
1178
+ await diagnoseDbException(session, args.schemaCode, error);
1179
+ throw error;
1180
+ }
1101
1181
  const performedData = (performed.data ?? {});
1102
1182
  const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
1103
1183
  const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
@@ -1424,6 +1504,7 @@ export function registerRuntimeDataLane(tools) {
1424
1504
  }), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
1425
1505
  }
1426
1506
  catch (error) {
1507
+ await diagnoseDbException(session, args.schemaCode, error);
1427
1508
  // r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
1428
1509
  // the same numeric code as TEST_CONNECTION_FAIL, so only the text
1429
1510
  // separates them). The platform rate-limits per WORK ITEM, so a different
@@ -108,11 +108,50 @@ export async function cmdModelScaffold(args) {
108
108
  ...(environment !== undefined ? { environment } : {}),
109
109
  };
110
110
  const plan = describeScaffoldPlan(options);
111
+ // r18: run the field-family preflight even in DRY-RUN. It is zero-wire
112
+ // (`field.create --dry-run` runs only the local capability gate), and the
113
+ // probe's first attempt — NUMERICAL without options.scale — passed the old
114
+ // plan-only dry-run and would have half-built a model on --apply.
115
+ const preflightProblems = [];
116
+ const preflightTargets = [
117
+ ...fields.filter((field) => scaffoldChildrenOf(field).length === 0),
118
+ ...fields.filter((field) => scaffoldChildrenOf(field).length > 0),
119
+ ];
120
+ {
121
+ for (const field of preflightTargets) {
122
+ const probe = await runToolCall("field.create", {
123
+ args: scaffoldFieldArgs(code, field),
124
+ requestId: request,
125
+ dryRun: true,
126
+ ...(environment !== undefined ? { environment } : {}),
127
+ print: () => undefined,
128
+ });
129
+ const status = String(probe.result.status ?? "ok");
130
+ if (probe.exitCode !== 0 || (status !== "dry-run" && status !== "ok")) {
131
+ preflightProblems.push({ code: field.code, message: refusalText(probe.result) });
132
+ }
133
+ }
134
+ if (preflightProblems.length > 0) {
135
+ writeEnvelope(envelope("error", {
136
+ status: "refused",
137
+ blocker: "scaffold-preflight-failed",
138
+ model: code,
139
+ written: false,
140
+ failed: preflightProblems.length,
141
+ preflightProblems,
142
+ message: `有 ${preflightProblems.length} 个字段的预检没过,**尚未写入任何东西**。` +
143
+ `按 message 改 --fields 后重跑(常见:NUMERICAL 缺 options.scale、文本/数值字段用了自定义键名)。` +
144
+ `--fields 的每一项就是 field.create 的参数(形状见 omc describe field.create),子表列用 subFields(childFields 是别名)。`,
145
+ }, request, { code: "policy-refused", message: "字段形状预检未通过(未写入)", retryable: false }));
146
+ return CLI_EXIT_CODES.policyRefused;
147
+ }
148
+ }
111
149
  if (!apply) {
112
150
  writeEnvelope(envelope("ok", {
113
151
  status: "dry-run",
114
152
  ...plan,
115
- note: "dry-run 只列计划;--apply 依次执行 建模型 → 建字段 → 建子列 → 发布字段 → 发布表单。",
153
+ preflight: { checked: fields.length, passed: true },
154
+ note: "dry-run 只列计划;已对每个字段跑过本地形状预检(零 wire)。--apply 依次执行 建模型 → 建字段 → 建子列 → 发布字段 → 发布表单。",
116
155
  }, request));
117
156
  return CLI_EXIT_CODES.ok;
118
157
  }
@@ -138,43 +177,7 @@ export async function cmdModelScaffold(args) {
138
177
  return { ok: outcome.exitCode === 0, result };
139
178
  };
140
179
  const lastSummaries = [];
141
- // 0) 前置校验:把每个字段先跑一次 dry-run(走完整字段族 preflight,不写库)。
142
- // r13 真机:`NUMERICAL` 少 options.scale 时,前两个字段已经建好了才在第 3 个
143
- // 报错,留下一个半成品模型要手工清。半成品比慢一点贵得多。
144
- // 子列不在这里校验:它们的目标 schema(子表)此刻还不存在,dry-run 会误报。
145
- const preflightTargets = [
146
- ...fields.filter((field) => scaffoldChildrenOf(field).length === 0),
147
- ...fields.filter((field) => scaffoldChildrenOf(field).length > 0),
148
- ];
149
- const preflightProblems = [];
150
- for (const field of preflightTargets) {
151
- const probe = await runToolCall("field.create", {
152
- args: scaffoldFieldArgs(code, field),
153
- requestId: request,
154
- dryRun: true,
155
- ...(environment !== undefined ? { environment } : {}),
156
- print: () => undefined,
157
- });
158
- const status = String(probe.result.status ?? "ok");
159
- if (probe.exitCode !== 0 || (status !== "dry-run" && status !== "ok")) {
160
- preflightProblems.push({ code: field.code, message: refusalText(probe.result) });
161
- }
162
- }
163
- if (preflightProblems.length > 0) {
164
- const result = {
165
- status: "refused",
166
- blocker: "scaffold-preflight-failed",
167
- model: code,
168
- // Nothing was written — that is the point.
169
- written: false,
170
- failed: preflightProblems.length,
171
- preflightProblems,
172
- message: `有 ${preflightProblems.length} 个字段的 dry-run 预检没过,**尚未写入任何东西**。` +
173
- `按下面的 message 改 --fields 后重跑(常见:NUMERICAL 缺 options.scale、SELECTION/RADIO 缺 choices、CHILD_TABLE 缺 subFields)。`,
174
- };
175
- writeEnvelope(envelope("refused", result, request, { code: "policy-refused", message: String(result.message), retryable: false }));
176
- return CLI_EXIT_CODES.policyRefused;
177
- }
180
+ // 0) 字段形状预检已在上面的 dry-run 分支前完成(零 wire);这里只记录。
178
181
  steps.push({ step: "preflight", status: "ok", detail: { checked: preflightTargets.length, wrote: false } });
179
182
  // 1) 模型:已存在是常态(补建场景),不是失败。
180
183
  const created = await call("model.create", {
@@ -253,7 +253,8 @@ const formulaAggregationCrossModel = Object.freeze({
253
253
  preconditions: [
254
254
  "**只能汇总「别的模型」**:同模型(含自己的子表)聚合在 field.publish 必抛 305003(平台 publishFormula 把相对码改写成子表父模型 = 本模型,checkCycleOfAggregation:1032 判成环)。工具已写前拒绝 `aggregation-self-schema-cycle`。",
255
255
  "目标字段用 FORMULA(100)(不是普通字段 + 计算规则——type2 计算规则在 REST 路径永不执行)。",
256
- "`schemaCode` 是被汇总的**来源模型**;`dataItemCode` 是来源模型的列;`queryConditions` **不能为空**,否则反向过滤器为空、找不到要回算的目标行(真机:空条件时 recalc 日志照常打印但目标字段恒为 0)。",
256
+ "`schemaCode` 是被汇总的**来源模型**;`dataItemCode` 是来源模型的**主表标量列**(不带 schema 前缀);`queryConditions` **不能为空**,否则反向过滤器为空、找不到要回算的目标行(真机:空条件时 recalc 日志照常打印但目标字段恒为 0)。",
257
+ "**不要指向子表 schema**(r18 2026-10-26 真机):`schemaCode:\"<子表>\"` 能发布,但目标模型从此**整个不可写** —— `data.submit`/`data.update` 一律回 40000「调用数据库发生异常」且无明细;而且子表 schema 没有父单单号列,按父单匹配恒为 0。`dataItemCode` 写成 `<子表>.<子列>` 则在 publish 直接 309004「使用的聚合数据项不存在」。要汇总子表:先在来源**主模型**上物化一个数值字段(门户计算规则或写入方维护),再聚合该标量。OMC 已写前拒绝 `aggregation-target-is-child-schema` / `aggregation-data-item-not-scalar`。",
257
258
  ],
258
259
  conditionalRequirements: [
259
260
  "**DYNAMIC 条件的两个 code 分属两侧,写反了就静默 0**:`propertyCode` = **来源模型**的字段(这里是 r17_c.nm),`currentPropertyCode` = **目标模型**的字段(这里是 r17_d.srcNo)。语义 = 「来源行的 nm 等于本目标行的 srcNo」。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.97",
3
+ "version": "0.4.98",
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.97",
53
+ "@stalfh233/omc-fake-platform": "0.4.98",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.97",
55
+ "omc-core": "0.4.98",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -154,6 +154,7 @@ rules:
154
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
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
156
  - **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
157
+ - **聚合目标必须是来源主表的标量列**(r18 2026-10-26 真机):`aggregation.schemaCode` 指向**子表 schema** 时能发布,但目标模型从此整个不可写(`data.submit`/`data.update` 全回 40000 无明细),且子表无父单单号列、匹配恒 0;`dataItemCode` 写 `<子表>.<子列>` 则 publish 回 309004。要子表合计:先在来源主模型物化一个数值字段,再聚合该标量。
157
158
  - **DYNAMIC 条件的两个 code 分属两侧,写反了静默 0**:`propertyCode` = 来源模型字段,`currentPropertyCode` = 目标模型字段;语义「来源行的 X 等于本目标行的 Y」。
158
159
  - **`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
160
  - **回算时机(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。
@@ -62,6 +62,14 @@ condition/expression/query shape is off. Compare against
62
62
  `omc describe <tool> --example …` and the node-graph prose, then re-save.
63
63
  Escalate to 二开 **only** for a real `capability-out-of-coverage` (§2).
64
64
 
65
+ `40000 调用数据库发生异常` is the **most opaque** shape code — no detail at all.
66
+ Line up the model's field-level config first: an `AGGREGATION` FORMULA field whose
67
+ `aggregation.schemaCode` points at a **child-table schema** publishes fine and then
68
+ makes every `data.submit`/`data.update` on that model 40000 (r18 2026-10-26 live;
69
+ bisect by deleting/disabling the suspect formula field, which is reversible).
70
+ `omc` now answers `runtime-write-db-exception` and lists the model's formula
71
+ fields; the fix is to aggregate a MAIN-table scalar column instead.
72
+
65
73
  ## 4. Readback mismatch — the write landed but does not match
66
74
 
67
75
  `*-readback-failed`, `*-readback-mismatch`, `cleanup-absence-readback`,
@@ -112,6 +112,15 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
112
112
  **回算可靠性(重要)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/delete 的事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错,源变了目标不更新)——重新 `field.publish --force` 目标模型恢复。验收:发布后立刻改来源行并读目标字段;长期对账别依赖事件回算。
113
113
  - **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003 —— `publishFormula` 把相对码改写成子表父模型(= 本模型),
114
114
  `checkCycleOfAggregation:1032` 判成环。与 payload 无关,8.6.26 与 1.1.25 代码相同。OMC 已写前拒绝 `aggregation-self-schema-cycle`。
115
+ - **聚合目标不能是任何子表 schema,数据项必须是来源主表的标量列**(r18 2026-10-26 真机,代价约 40 次调用):
116
+ `aggregation.schemaCode` 指向子表时**能发布**,但目标模型从此整个不可写 —— `data.submit`/`data.update` 全部回
117
+ `40000 调用数据库发生异常`(无明细);且子表 schema 无父单单号列,按父单匹配恒为 0。
118
+ `dataItemCode` 写成 `<子表>.<子列>` 则在 publish 回 309004「使用的聚合数据项不存在」。
119
+ 正解:要子表合计,先在来源**主模型**物化一个数值字段(门户计算规则 / 写入方维护),再聚合该标量。
120
+ OMC 已写前拒绝 `aggregation-target-is-child-schema`(读一次来源字段清单识别 parentId+sortKey)与
121
+ `aggregation-data-item-not-scalar`(点号)。
122
+ - **`40000` 的无明细掩码怎么查**:先怀疑字段级配置(上面的聚合/公式),用「删/禁一个公式字段再写一次」二分,
123
+ 不要从表单/权限开始查。OMC 现在会把 40000 转成 `runtime-write-db-exception` 并列出该模型的公式字段。
115
124
  - **区别:`FORMULA`(计算公式,`propertyType:100`)字段的 EXPRESSION 模式仍只在前端算** —— API 写入不回填。
116
125
 
117
126
  ## 规则与业务集成