@stalfh233/omc-cli 0.4.96 → 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.
- package/dist/capability-overview.js +6 -1
- package/dist/compute.js +20 -1
- package/dist/field-families.js +10 -0
- package/dist/goal-routes.js +12 -1
- package/dist/lanes/field.js +54 -0
- package/dist/lanes/organization.js +72 -6
- package/dist/lanes/runtime-data.js +271 -33
- package/dist/model-scaffold.js +41 -38
- package/dist/recipes/field.js +4 -3
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +2 -1
- package/skills/omc-refusals.md +8 -0
- package/skills/omc-write-hazards.md +15 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/field-families.js
CHANGED
|
@@ -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",
|
package/dist/goal-routes.js
CHANGED
|
@@ -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
|
package/dist/lanes/field.js
CHANGED
|
@@ -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,
|
|
@@ -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「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", {
|
|
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
|
-
|
|
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
|
-
`平台不允许删带人的部门,也不提供"随部门级联转出"
|
|
393
|
-
|
|
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 {
|
|
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",
|
|
@@ -410,20 +410,46 @@ function collectChildArrayPaths(value, childCodes, prefix = "") {
|
|
|
410
410
|
}
|
|
411
411
|
return found;
|
|
412
412
|
}
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
|
445
|
+
return null;
|
|
424
446
|
}
|
|
425
|
-
|
|
426
|
-
|
|
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,195 @@ 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
|
+
}
|
|
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
|
+
});
|
|
445
659
|
}
|
|
446
660
|
/** Runtime business-object data keyed by field code. */
|
|
447
661
|
export function registerRuntimeDataLane(tools) {
|
|
@@ -766,7 +980,9 @@ export function registerRuntimeDataLane(tools) {
|
|
|
766
980
|
message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
|
|
767
981
|
});
|
|
768
982
|
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
769
|
-
const
|
|
983
|
+
const rawSaveData = args.formData;
|
|
984
|
+
const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
|
|
985
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
|
|
770
986
|
if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
|
|
771
987
|
// Portal draft-save shape. The row id travels in data.id (the portal
|
|
772
988
|
// form SDK receives a pre-generated id from form/load and echoes it);
|
|
@@ -792,15 +1008,22 @@ export function registerRuntimeDataLane(tools) {
|
|
|
792
1008
|
// isStartWf branch runs verifyReplay; the instance is DRAFT only when
|
|
793
1009
|
// data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
|
|
794
1010
|
const replayToken = await getReplayToken(session);
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
+
}
|
|
804
1027
|
const vo = (performed.data ?? {});
|
|
805
1028
|
const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
|
|
806
1029
|
const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
|
|
@@ -835,11 +1058,18 @@ export function registerRuntimeDataLane(tools) {
|
|
|
835
1058
|
readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
|
|
836
1059
|
};
|
|
837
1060
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
+
}
|
|
843
1073
|
const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
|
|
844
1074
|
let readbackData = null;
|
|
845
1075
|
if (typeof bizObjectId === "string" && bizObjectId) {
|
|
@@ -932,15 +1162,22 @@ export function registerRuntimeDataLane(tools) {
|
|
|
932
1162
|
message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
|
|
933
1163
|
});
|
|
934
1164
|
}
|
|
935
|
-
const formData = (await
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1165
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
|
|
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
|
+
}
|
|
944
1181
|
const performedData = (performed.data ?? {});
|
|
945
1182
|
const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
|
|
946
1183
|
const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
|
|
@@ -1066,7 +1303,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1066
1303
|
});
|
|
1067
1304
|
}
|
|
1068
1305
|
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1069
|
-
const stampedSubmit = await
|
|
1306
|
+
const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
|
|
1070
1307
|
const formData = stampedSubmit.formData;
|
|
1071
1308
|
// r5-asset 2026-10-20: the platform APPENDS child rows carried in an
|
|
1072
1309
|
// approval submit's formData (no upsert by row id, no error). Live: an
|
|
@@ -1267,6 +1504,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1267
1504
|
}), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
|
|
1268
1505
|
}
|
|
1269
1506
|
catch (error) {
|
|
1507
|
+
await diagnoseDbException(session, args.schemaCode, error);
|
|
1270
1508
|
// r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
|
|
1271
1509
|
// the same numeric code as TEST_CONNECTION_FAIL, so only the text
|
|
1272
1510
|
// separates them). The platform rate-limits per WORK ITEM, so a different
|
package/dist/model-scaffold.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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", {
|
package/dist/recipes/field.js
CHANGED
|
@@ -253,21 +253,22 @@ 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`
|
|
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」。",
|
|
260
261
|
"`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
262
|
"`calculateType` 是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION),不是枚举 code。",
|
|
262
263
|
"发布后平台会**自动生成一条 t100 CALCULATE_RULE 镜像行**(h_biz_data_rule,propertyCode=字段 code),它是回算触发的前置条件,别手动删。",
|
|
263
|
-
"
|
|
264
|
+
"**回算时机(r17b 2026-10-26 真机完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠,不依赖事件);② 来源行 submit/update/delete 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算——FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件(propertyCode 带 `.`)都验证过,DELETE 也会回算;③ ⚠️ 事件回算**只在目标模型发布后的几分钟内可靠**,之后静默停止(源端数据变了目标字段不更新、无报错)——重新 `field.publish --force` 目标模型即可恢复。定时巡检/对账场景务必重发或依赖目标行写入路径。",
|
|
264
265
|
],
|
|
265
266
|
priorToolReferences: [
|
|
266
267
|
{ tool: "field.list", args: { environment, schemaCode: "r17_c", published: true }, purpose: "确认来源模型与其列已发布,取 dataItemCode 与 DYNAMIC 条件的 propertyCode" },
|
|
267
268
|
{ tool: "field.list", args: { environment, schemaCode: "r17_d", published: true }, purpose: "确认目标模型已有所承载匹配值的字段(currentPropertyCode)" },
|
|
268
269
|
],
|
|
269
270
|
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
|
|
271
|
+
expectedReadback: { readback: { fieldPresent: true, options: { formulaConfig: "<含 AGGREGATION + DYNAMIC 条件>" } }, next: ["field.publish", "data.submit 来源行(紧接发布后几分钟内)→ data.load 目标行读到合计", "form.publish"] },
|
|
271
272
|
refusalRecovery: [
|
|
272
273
|
{ code: "aggregation-self-schema-cycle", action: "把 schemaCode 换成另一个模型;同模型/自己的子表聚合平台不支持" },
|
|
273
274
|
{ code: "duplicate-field", action: "字段已存在改用 field.update 或换 code" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.98",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
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,9 +154,10 @@ 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。
|
|
160
161
|
- **普通数值字段 + 计算规则(type2)不能替代**:REST 路径永不执行(`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 从不置真),门户里才算。
|
|
161
162
|
- **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`)。
|
|
162
163
|
- **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
|
package/skills/omc-refusals.md
CHANGED
|
@@ -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`,
|
|
@@ -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
|
## 写调用纪律
|
|
@@ -103,12 +105,22 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
|
|
|
103
105
|
(早前「覆盖传入值、API 可判定」的结论来自 r12,已被推翻——不要再用它做验收。)
|
|
104
106
|
- 源码门控:`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 只在 `BizObjectOptions.executeCalRule()` 为真时求值,而该 builder 全库 0 调用方(REST 路径从不置真)。
|
|
105
107
|
- 所以「计算规则是否生效」**不能用 API 判定**;正确性只能在门户表单里验。API 调用方请自己算好再写。
|
|
106
|
-
- **跨模型汇总可以走 FORMULA(100) + AGGREGATION,而且 REST 能回算**(r17 真机 100→105
|
|
108
|
+
- **跨模型汇总可以走 FORMULA(100) + AGGREGATION,而且 REST 能回算**(r17 真机 100→105;r17b 2026-10-26 补齐行为图谱):
|
|
107
109
|
`aggregation.schemaCode` 指向**另一个模型**,`queryConditions` 必须给 DYNAMIC 条件
|
|
108
|
-
(`propertyCode`=来源模型字段、`currentPropertyCode
|
|
109
|
-
`
|
|
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` 目标模型恢复。验收:发布后立刻改来源行并读目标字段;长期对账别依赖事件回算。
|
|
110
113
|
- **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003 —— `publishFormula` 把相对码改写成子表父模型(= 本模型),
|
|
111
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` 并列出该模型的公式字段。
|
|
112
124
|
- **区别:`FORMULA`(计算公式,`propertyType:100`)字段的 EXPRESSION 模式仍只在前端算** —— API 写入不回填。
|
|
113
125
|
|
|
114
126
|
## 规则与业务集成
|