@stalfh233/omc-cli 0.6.7 → 0.6.10
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/README.md +2 -1
- package/dist/approval-flow.js +5 -3
- package/dist/bizservice-config.js +26 -10
- package/dist/call.js +47 -0
- package/dist/capability-overview.js +47 -24
- package/dist/cli-contract.js +73 -26
- package/dist/cli-output.js +41 -2
- package/dist/cli-stdout.js +74 -0
- package/dist/cli.js +135 -52
- package/dist/command-transport.js +8 -1
- package/dist/config-store.js +17 -2
- package/dist/discovery.js +62 -3
- package/dist/field-families.js +35 -6
- package/dist/goal-routes.js +118 -11
- package/dist/human-summary.js +11 -0
- package/dist/lanes/app-contract.js +12 -5
- package/dist/lanes/app.js +221 -134
- package/dist/lanes/apply-changes.js +2 -1
- package/dist/lanes/b2-registration-contract.js +3 -3
- package/dist/lanes/b2-registration.js +890 -693
- package/dist/lanes/businessrule.js +75 -51
- package/dist/lanes/custom-page.js +166 -115
- package/dist/lanes/datarule-contract.js +2 -2
- package/dist/lanes/datarule.js +238 -154
- package/dist/lanes/dictionary.js +253 -110
- package/dist/lanes/environment.js +57 -9
- package/dist/lanes/field.js +160 -107
- package/dist/lanes/folder-contract.js +7 -3
- package/dist/lanes/folder.js +249 -118
- package/dist/lanes/form-contract.js +1 -1
- package/dist/lanes/form.js +188 -64
- package/dist/lanes/list-view.js +405 -369
- package/dist/lanes/meta.js +31 -6
- package/dist/lanes/model-contract.js +1 -1
- package/dist/lanes/model.js +610 -512
- package/dist/lanes/online-js-contract.js +1 -1
- package/dist/lanes/online-js.js +160 -146
- package/dist/lanes/organization.js +905 -640
- package/dist/lanes/programming.js +274 -136
- package/dist/lanes/report-contract.js +2 -2
- package/dist/lanes/report.js +354 -198
- package/dist/lanes/rule-lifecycle.js +294 -140
- package/dist/lanes/runtime-data-contract.js +4 -4
- package/dist/lanes/runtime-data.js +869 -745
- package/dist/lanes/workflow-contract.js +3 -3
- package/dist/lanes/workflow.js +732 -605
- package/dist/model-scaffold.js +26 -5
- package/dist/path-projection.js +196 -14
- package/dist/plan.js +25 -0
- package/dist/programming-workflow.js +55 -3
- package/dist/read-shapes.js +146 -0
- package/dist/read.js +40 -9
- package/dist/recipes/businessrule.js +74 -24
- package/dist/recipes/dictionary.js +7 -4
- package/dist/recipes/form.js +46 -0
- package/dist/recipes/runtime-data.js +3 -2
- package/dist/refusal-guidance.js +29 -5
- package/dist/rule-support.js +14 -1
- package/dist/rule-when.js +2 -2
- package/dist/tool-types.js +9 -3
- package/dist/version-family-gaps.js +101 -0
- package/dist/write.js +2 -1
- package/package.json +6 -4
- package/skills/omc-glossary.md +13 -0
- package/skills/omc-refusals.md +13 -5
- package/skills/omc-tool-selection.md +2 -1
- package/skills/omc-write-hazards.md +5 -2
|
@@ -9,6 +9,7 @@ import { writeTool } from "../write.js";
|
|
|
9
9
|
import { defineTool } from "../tool-types.js";
|
|
10
10
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
11
11
|
import { CHILD_TABLE_PROPERTY_TYPE, diagnoseApprovalSubmitIdentity, runtimeWriteWithAppMapping } from "../runtime-support.js";
|
|
12
|
+
import { closestMatch } from "../closest-match.js";
|
|
12
13
|
import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
|
|
13
14
|
import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
|
|
14
15
|
import { SYSTEM_FIELD_CODES } from "../field-families.js";
|
|
@@ -771,7 +772,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
771
772
|
// `Expected object, received array`). An array is the caller's AND-list,
|
|
772
773
|
// so accept it and treat it as `filters` rather than costing a retry
|
|
773
774
|
// (dogfood R1 round17).
|
|
774
|
-
filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op 取值见过滤语义:eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)。**like/contains 是子串包含(String.includes),不是前缀匹配,`%` / `_` 是字面量而非通配符**;按前缀分片请用 gte/lt 区间或更长的前缀+本地去重,别假设 like:"0%" 只命中 0 开头(GitLab #18 P1-3 实测 4 个"前缀分片"互相重叠 2258
|
|
775
|
+
filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op 取值见过滤语义:eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)。**like/contains 是子串包含(String.includes),不是前缀匹配,`%` / `_` 是字面量而非通配符**;按前缀分片请用 gte/lt 区间或更长的前缀+本地去重,别假设 like:"0%" 只命中 0 开头(GitLab #18 P1-3 实测 4 个"前缀分片"互相重叠 2258 条)。field 须是**已发布**字段 code——写错时拒绝信封直接带 publishedFields/didYouMean,无需再查 field.list'),
|
|
775
776
|
filters: z.array(z.record(z.string(), z.unknown())).optional().describe("clauses combined with AND; a malformed clause is refused with blocker invalid-filter-DSL"),
|
|
776
777
|
filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
|
|
777
778
|
rawCondition: z.unknown().optional(),
|
|
@@ -834,11 +835,29 @@ export function registerRuntimeDataLane(tools) {
|
|
|
834
835
|
// an explicit projection came back all-null, so a normal read that
|
|
835
836
|
// yields values performs zero metadata requests.
|
|
836
837
|
let publishedTypes = null;
|
|
838
|
+
// 运行面 filter 的「未知字段」此前只报字段名,调用方还得再发一跳 field.list
|
|
839
|
+
// (HANDOFF §三.1-2)。把已发布字段目录随拒绝一起带出:前 20 个 code(+名称)、
|
|
840
|
+
// 全量总数、拼写 did-you-mean;目录复用上面这次元数据读取,零新增 wire。
|
|
841
|
+
const FILTER_FIELD_SAMPLE_LIMIT = 20;
|
|
842
|
+
let publishedCatalog = null;
|
|
843
|
+
const publishedFields = async () => {
|
|
844
|
+
if (publishedCatalog)
|
|
845
|
+
return publishedCatalog;
|
|
846
|
+
const meta = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
|
|
847
|
+
publishedCatalog = meta
|
|
848
|
+
.map((row) => {
|
|
849
|
+
const code = String(row.code ?? row.propertyCode ?? "");
|
|
850
|
+
const rawName = row.name ?? row.propertyName;
|
|
851
|
+
const name = typeof rawName === "string" && rawName.length > 0 && rawName !== code ? rawName : undefined;
|
|
852
|
+
return { code, ...(name !== undefined ? { name } : {}), type: Number(row.propertyType ?? row.type) };
|
|
853
|
+
})
|
|
854
|
+
.filter((row) => row.code.length > 0);
|
|
855
|
+
return publishedCatalog;
|
|
856
|
+
};
|
|
837
857
|
const publishedTypeByCode = async () => {
|
|
838
858
|
if (publishedTypes)
|
|
839
859
|
return publishedTypes;
|
|
840
|
-
|
|
841
|
-
publishedTypes = new Map(meta.map((row) => [String(row.code ?? row.propertyCode), Number(row.propertyType ?? row.type)]));
|
|
860
|
+
publishedTypes = new Map((await publishedFields()).map((row) => [row.code, row.type]));
|
|
842
861
|
return publishedTypes;
|
|
843
862
|
};
|
|
844
863
|
if (fromDsl) {
|
|
@@ -847,13 +866,36 @@ export function registerRuntimeDataLane(tools) {
|
|
|
847
866
|
const type = typeByCode.get(code);
|
|
848
867
|
return Number.isInteger(type) ? type : undefined;
|
|
849
868
|
});
|
|
850
|
-
if (!injected.ok)
|
|
869
|
+
if (!injected.ok) {
|
|
870
|
+
const catalog = await publishedFields();
|
|
871
|
+
const codes = catalog.map((row) => row.code);
|
|
872
|
+
const sample = catalog.slice(0, FILTER_FIELD_SAMPLE_LIMIT);
|
|
873
|
+
const didYouMean = Object.fromEntries(injected.missing
|
|
874
|
+
.map((code) => [code, closestMatch(code, codes)])
|
|
875
|
+
.filter((entry) => entry[1] !== undefined));
|
|
876
|
+
const didYouMeanHint = Object.entries(didYouMean).map(([from, to]) => `;${from} 是否想写 ${to}`).join("");
|
|
877
|
+
// r26b 读回体积审计(2026-11-05):目录此前在**同一个信封里被写三遍**
|
|
878
|
+
// ——`publishedFields`(结构化,~930 B)+ `message` 里逐条再列一遍
|
|
879
|
+
// (~810 B)+ `error.message` 是 result.message 的镜像(又 ~810 B)。
|
|
880
|
+
// 一条「字段名拼错」回执因此 3.2 KB,而回答只需要 missing + didYouMean。
|
|
881
|
+
// message 改为只给判据与指针,目录只留结构化那一份。
|
|
882
|
+
const catalogHint = catalog.length === 0
|
|
883
|
+
? ";该模型当前没有已发布字段(先 field.create + field.publish 再过滤)"
|
|
884
|
+
: `;已发布字段共 ${catalog.length} 个,code/名称见 publishedFields${catalog.length > sample.length ? `(仅列前 ${sample.length} 个,其余用 field.list 查)` : ""}`;
|
|
851
885
|
return {
|
|
852
886
|
status: "refused",
|
|
853
887
|
blocker: "unknown-filter-field",
|
|
854
888
|
missing: injected.missing,
|
|
855
|
-
|
|
889
|
+
publishedFields: sample.map((row) => ({ code: row.code, ...(row.name !== undefined ? { name: row.name } : {}) })),
|
|
890
|
+
publishedFieldsTotal: catalog.length,
|
|
891
|
+
...(Object.keys(didYouMean).length > 0 ? { didYouMean } : {}),
|
|
892
|
+
// acceptedValues 是「合法取值全集」:截断时故意不给,避免把第 21 个
|
|
893
|
+
// 之后真实存在的字段误判成非法。
|
|
894
|
+
...(catalog.length > 0 && catalog.length <= FILTER_FIELD_SAMPLE_LIMIT ? { acceptedValues: { "filter.field": codes } } : {}),
|
|
895
|
+
message: `filter 字段不在已发布元数据里:${injected.missing.join("、")}(运行面过滤要用每个叶子的 propertyType,不做猜测;系统/外部字段改用 rawCondition)` + choiceKeySuffixHint(injected.missing, typeByCode) + didYouMeanHint + catalogHint,
|
|
896
|
+
...(catalog.length > 0 ? { nextCommand: `omc call field.list --env <env> --args '{"schemaCode":"${args.schemaCode}","published":true}'` } : {}),
|
|
856
897
|
};
|
|
898
|
+
}
|
|
857
899
|
condition = injected.condition;
|
|
858
900
|
// A2(GitLab #15):单选/字典字段按显示值过滤会静默 0 条(平台查询按
|
|
859
901
|
// 字典 key 匹配)。带出这些字段,total:0 时给写法提示,防"查不到数据"
|
|
@@ -956,6 +998,14 @@ export function registerRuntimeDataLane(tools) {
|
|
|
956
998
|
page: "all",
|
|
957
999
|
size: collected.length,
|
|
958
1000
|
pagesFetched: pageIndex,
|
|
1001
|
+
// 与普通读同一个口径(r28b 真机):草稿策略必须**从结果集实际统计**,
|
|
1002
|
+
// 不能按"有没有查询条件"静态声明——静态声明会与 items 自相矛盾。
|
|
1003
|
+
filter: (() => {
|
|
1004
|
+
const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
|
|
1005
|
+
return draftRows > 0
|
|
1006
|
+
? { draftPolicy: "included", draftRows, hint: `本次结果含 ${draftRows} 条 DRAFT 行——total 含草稿,不要当"已提交数据量";判定状态读 data.load 的 sequenceStatus` }
|
|
1007
|
+
: { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行;判定状态以 data.load 的 sequenceStatus 为准" };
|
|
1008
|
+
})(),
|
|
959
1009
|
// 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
|
|
960
1010
|
// total 的差就是被截断的条数——不再只靠 size 反推。
|
|
961
1011
|
itemsCount: collected.length,
|
|
@@ -1033,11 +1083,29 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1033
1083
|
...(projectionEmpty ? { projectionWarning: { reason: "business-columns-null", hint: "模型可能未配置列表视图:data.list 只投影列表列,业务列会全为 null;核对写入结果请用 data.load(或先建列表视图)" } } : {}),
|
|
1034
1084
|
...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
|
|
1035
1085
|
total: args.showTotal === false ? null : page.total,
|
|
1036
|
-
// This branch is the non-`all` read
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1086
|
+
// This branch is the non-`all` read. `excludedDraft` used to be
|
|
1087
|
+
// unconditional, but r26 真机实证(2026-11-05):**带查询条件时**草稿行
|
|
1088
|
+
// 被排除(total 也只计非草稿),**不带条件时**同一个 data.save 的 DRAFT
|
|
1089
|
+
// r28b 真机(2026-11-05):上一版按「有无查询条件」**静态声明**草稿策略,
|
|
1090
|
+
// 探针抓到自相矛盾——回执写 `excludedDraft:true`,返回的 items 里却是
|
|
1091
|
+
// DRAFT 行。静态 hint 永远可能和真实结果打架,`total:0` 被读成「写失败」
|
|
1092
|
+
// 就是这么来的。
|
|
1093
|
+
// 现在**从结果集实际统计**得出:items 里出现 DRAFT 就如实说 included。
|
|
1094
|
+
// 统一一个枚举键 `draftPolicy`(included | excluded | unspecified),
|
|
1095
|
+
// 不再用两个不同键名表达同一件事(那要调用方写两套分支)。
|
|
1096
|
+
...(() => {
|
|
1097
|
+
const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
|
|
1098
|
+
const policy = draftRows > 0
|
|
1099
|
+
? {
|
|
1100
|
+
draftPolicy: "included",
|
|
1101
|
+
draftRows,
|
|
1102
|
+
hint: `本次结果含 ${draftRows} 条 DRAFT 行(total 也计入)——**不要**把 total 当"已提交数据量",判定状态读 data.load 的 sequenceStatus`,
|
|
1103
|
+
}
|
|
1104
|
+
: condition !== undefined
|
|
1105
|
+
? { draftPolicy: "excluded", hint: "本次结果无 DRAFT 行;判定状态仍以 data.load 的 sequenceStatus 为准" }
|
|
1106
|
+
: { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行,但本面是否过滤草稿不能据此断定;判定状态读 data.load 的 sequenceStatus" };
|
|
1107
|
+
return { filter: policy };
|
|
1108
|
+
})(),
|
|
1041
1109
|
...(choiceFilterFields.length > 0 && Number(page.total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
|
|
1042
1110
|
totalPages: page.totalPages,
|
|
1043
1111
|
page: page.page ?? args.page ?? 0,
|
|
@@ -1171,266 +1239,282 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1171
1239
|
}, { full: args.full === true, target: `data.load:${args.schemaCode}:${args.bizObjectId}` });
|
|
1172
1240
|
},
|
|
1173
1241
|
}));
|
|
1174
|
-
writeTool(
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
const rawSaveData = args.formData;
|
|
1194
|
-
const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
|
|
1195
|
-
const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
|
|
1196
|
-
// issue#9 C1 + issue#10 勘误:提交面(submit:true / data.submit)的人员字段
|
|
1197
|
-
// 两种已知形状都不安全——对象数组有历史 50000 证据(UserBizField.fillClientContent,
|
|
1198
|
-
// 本闸拦截),字符串 id 数组在 8.6.41 实测同样 50000(#10 D)。可靠链路只有一条:
|
|
1199
|
-
// data.save 人员字段用对象数组 [{id,type,name}] 落库 → 提交时**省略人员字段**(值已持久化)。
|
|
1200
|
-
if (args.submit === true && args.allowStaffOnSubmit !== true) {
|
|
1201
|
-
const staffOnSubmit = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
|
|
1202
|
-
if (staffOnSubmit.length > 0)
|
|
1203
|
-
throw new ToolRefusalError({
|
|
1204
|
-
status: "refused",
|
|
1205
|
-
blocker: "submit-person-field-shape",
|
|
1206
|
-
schemaCode: args.schemaCode,
|
|
1207
|
-
fields: staffOnSubmit,
|
|
1208
|
-
message: `提交携带了人员字段:${staffOnSubmit.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都不安全(对象数组触发 UserBizField.fillClientContent 50000;字符串 id 在 8.6.41 实测同样 50000——issue#10 复现矩阵)。` +
|
|
1209
|
-
`可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交(submit:true / data.submit)时**省略人员字段**——值已持久化,提交只推进流程。` +
|
|
1210
|
-
`确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
// issue#10 A:save 面的字符串 id 才是错误形状(对象数组才是正解 B)——
|
|
1214
|
-
// 裸字符串/字符串数组在 save 面直落平台换一个无诊断的 50000,fail-closed。
|
|
1215
|
-
if (args.submit !== true) {
|
|
1216
|
-
const staffStringSave = await staffStringShapeViolations(session, args.schemaCode, formData).catch(() => []);
|
|
1217
|
-
if (staffStringSave.length > 0)
|
|
1242
|
+
writeTool({
|
|
1243
|
+
tools,
|
|
1244
|
+
name: "data.save",
|
|
1245
|
+
lane: RUNTIME_LANE,
|
|
1246
|
+
scope: RUNTIME_TOOLS["data.save"].scope,
|
|
1247
|
+
description: RUNTIME_TOOLS["data.save"].description,
|
|
1248
|
+
inputSchema: {
|
|
1249
|
+
schemaCode: z.string().min(1),
|
|
1250
|
+
sheetCode: z.string().optional(),
|
|
1251
|
+
formData: runtimeFormDataArg,
|
|
1252
|
+
workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
|
|
1253
|
+
submit: z.boolean().optional().describe("true=保存后立即直提(无流程模型 DRAFT→COMPLETED,触发 Create 规则);与 workflowCode 互斥。**按业务约定正式数据优先直接 data.submit(无 id=新建并提交一调完成)——本参数是便捷别名,save 的定位是草稿/暂存**"),
|
|
1254
|
+
allowStaffOnSubmit: z.boolean().optional().describe("submit:true 时显式允许携带人员字段(issue#10:人员字段提交面两种形状都可能 50000,可靠链路是 save 落库后提交时省略;个别平台构建接受提交面携带,自担风险)"),
|
|
1255
|
+
...readbackProjectionArgs,
|
|
1256
|
+
},
|
|
1257
|
+
execute: async (session, args) => {
|
|
1258
|
+
// F-A (self-audit): the combo used to be silently ignored (the workflow
|
|
1259
|
+
// draft branch returns without honoring submit). Refuse before any wire.
|
|
1260
|
+
if (args.submit === true && typeof args.workflowCode === "string" && args.workflowCode.trim())
|
|
1218
1261
|
throw new ToolRefusalError({
|
|
1219
1262
|
status: "refused",
|
|
1220
|
-
blocker: "
|
|
1263
|
+
blocker: "submit-workflow-code-conflict",
|
|
1221
1264
|
schemaCode: args.schemaCode,
|
|
1222
|
-
|
|
1223
|
-
message: `data.save 的人员字段用了字符串 id:${staffStringSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}——save 面的正确形状是对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门;issue#10 实测矩阵:字符串 id 50000、对象数组成功)。读回值 [{id,type,name,…}] 可直接回写。`,
|
|
1265
|
+
message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
|
|
1224
1266
|
});
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
if (existing) {
|
|
1267
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1268
|
+
const rawSaveData = args.formData;
|
|
1269
|
+
const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
|
|
1270
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
|
|
1271
|
+
// issue#9 C1 + issue#10 勘误:提交面(submit:true / data.submit)的人员字段
|
|
1272
|
+
// 两种已知形状都不安全——对象数组有历史 50000 证据(UserBizField.fillClientContent,
|
|
1273
|
+
// 本闸拦截),字符串 id 数组在 8.6.41 实测同样 50000(#10 D)。可靠链路只有一条:
|
|
1274
|
+
// data.save 人员字段用对象数组 [{id,type,name}] 落库 → 提交时**省略人员字段**(值已持久化)。
|
|
1275
|
+
if (args.submit === true && args.allowStaffOnSubmit !== true) {
|
|
1276
|
+
const staffOnSubmit = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
|
|
1277
|
+
if (staffOnSubmit.length > 0)
|
|
1237
1278
|
throw new ToolRefusalError({
|
|
1238
1279
|
status: "refused",
|
|
1239
|
-
blocker: "
|
|
1280
|
+
blocker: "submit-person-field-shape",
|
|
1240
1281
|
schemaCode: args.schemaCode,
|
|
1241
|
-
|
|
1282
|
+
fields: staffOnSubmit,
|
|
1283
|
+
message: `提交携带了人员字段:${staffOnSubmit.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都不安全(对象数组触发 UserBizField.fillClientContent 50000;字符串 id 在 8.6.41 实测同样 50000——issue#10 复现矩阵)。` +
|
|
1284
|
+
`可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交(submit:true / data.submit)时**省略人员字段**——值已持久化,提交只推进流程。` +
|
|
1285
|
+
`确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
// issue#10 A:save 面的字符串 id 才是错误形状(对象数组才是正解 B)——
|
|
1289
|
+
// 裸字符串/字符串数组在 save 面直落平台换一个无诊断的 50000,fail-closed。
|
|
1290
|
+
if (args.submit !== true) {
|
|
1291
|
+
const staffStringSave = await staffStringShapeViolations(session, args.schemaCode, formData).catch(() => []);
|
|
1292
|
+
if (staffStringSave.length > 0)
|
|
1293
|
+
throw new ToolRefusalError({
|
|
1294
|
+
status: "refused",
|
|
1295
|
+
blocker: "save-person-field-shape",
|
|
1296
|
+
schemaCode: args.schemaCode,
|
|
1297
|
+
fields: staffStringSave,
|
|
1298
|
+
message: `data.save 的人员字段用了字符串 id:${staffStringSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}——save 面的正确形状是对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门;issue#10 实测矩阵:字符串 id 50000、对象数组成功)。读回值 [{id,type,name,…}] 可直接回写。`,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
|
|
1302
|
+
// Portal draft-save shape. The row id travels in data.id (the portal
|
|
1303
|
+
// form SDK receives a pre-generated id from form/load and echoes it);
|
|
1304
|
+
// BizObjectModel.getId() reads data["id"], so a client-chosen id is
|
|
1305
|
+
// echoed back as FormSaveVO.objectId. An id that already exists in
|
|
1306
|
+
// the store would hit the create-path INSERT duplicate (40000), so an
|
|
1307
|
+
// existing id is refused up front.
|
|
1308
|
+
const suppliedId = typeof formData.id === "string" ? formData.id.trim() : "";
|
|
1309
|
+
if (suppliedId) {
|
|
1310
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, suppliedId);
|
|
1311
|
+
if (existing) {
|
|
1312
|
+
throw new ToolRefusalError({
|
|
1313
|
+
status: "refused",
|
|
1314
|
+
blocker: "save-workflow-draft-with-id-unsupported",
|
|
1315
|
+
schemaCode: args.schemaCode,
|
|
1316
|
+
bizObjectId: suppliedId,
|
|
1317
|
+
workflowCode: args.workflowCode,
|
|
1318
|
+
message: "workflow draft-save always INSERTs in 8.6.26 (no REST in-place start for an existing row); use a fresh id or no id, or submit an existing workflow draft via data.submit with its workItemId",
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const objectId = suppliedId || randomUUID().replaceAll("-", "");
|
|
1323
|
+
// isStartWf branch runs verifyReplay; the instance is DRAFT only when
|
|
1324
|
+
// data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
|
|
1325
|
+
const replayToken = await getReplayToken(session);
|
|
1326
|
+
let performed;
|
|
1327
|
+
try {
|
|
1328
|
+
performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
|
|
1329
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
1330
|
+
method: "POST",
|
|
1331
|
+
body: {
|
|
1332
|
+
workflowCode: args.workflowCode,
|
|
1333
|
+
...(replayToken ? { replayToken } : {}),
|
|
1334
|
+
bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
|
|
1335
|
+
},
|
|
1336
|
+
}), formData, args.workflowCode);
|
|
1337
|
+
}
|
|
1338
|
+
catch (error) {
|
|
1339
|
+
await diagnoseDbException(session, args.schemaCode, error);
|
|
1340
|
+
throw error;
|
|
1341
|
+
}
|
|
1342
|
+
const vo = (performed.data ?? {});
|
|
1343
|
+
const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
|
|
1344
|
+
const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
|
|
1345
|
+
// saveWorkflow resolves the start workItem via getWorkItem INSIDE the
|
|
1346
|
+
// still-uncommitted request transaction (autoCommit:false), so the live
|
|
1347
|
+
// FormSaveVO usually omits workItem. The committed row is visible to a
|
|
1348
|
+
// follow-up search_workitems read; resolve the pending start todo by
|
|
1349
|
+
// exact instanceId (the server filter is a NOT-equal exclusion).
|
|
1350
|
+
let workItem = vo.workItem;
|
|
1351
|
+
let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
|
|
1352
|
+
let workItemSource = workItemId ? "form-save" : null;
|
|
1353
|
+
if (!workItemId && workflowInstanceId) {
|
|
1354
|
+
// search_workitems via the shared workflow seam (ticket 21): exact
|
|
1355
|
+
// instanceId match is client-side (the server filter is a NOT-equal
|
|
1356
|
+
// exclusion); the seam's default size 200 is the unified scan width.
|
|
1357
|
+
const found = await searchWorkItems(session, {
|
|
1242
1358
|
workflowCode: args.workflowCode,
|
|
1243
|
-
|
|
1359
|
+
instanceId: workflowInstanceId,
|
|
1244
1360
|
});
|
|
1361
|
+
const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
|
|
1362
|
+
if (matchId) {
|
|
1363
|
+
workItemId = matchId;
|
|
1364
|
+
workItemSource = "workitem-search";
|
|
1365
|
+
}
|
|
1245
1366
|
}
|
|
1367
|
+
let readbackData = null;
|
|
1368
|
+
let sequenceStatus = null;
|
|
1369
|
+
readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
|
|
1370
|
+
sequenceStatus = extractSequenceStatus((readbackData ?? {}));
|
|
1371
|
+
return {
|
|
1372
|
+
performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
|
|
1373
|
+
readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
|
|
1374
|
+
};
|
|
1246
1375
|
}
|
|
1247
|
-
const objectId = suppliedId || randomUUID().replaceAll("-", "");
|
|
1248
|
-
// isStartWf branch runs verifyReplay; the instance is DRAFT only when
|
|
1249
|
-
// data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
|
|
1250
|
-
const replayToken = await getReplayToken(session);
|
|
1251
1376
|
let performed;
|
|
1252
1377
|
try {
|
|
1253
1378
|
performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
|
|
1254
1379
|
path: ENDPOINTS.runtimeFormSave,
|
|
1255
1380
|
method: "POST",
|
|
1256
|
-
body: {
|
|
1257
|
-
|
|
1258
|
-
...(replayToken ? { replayToken } : {}),
|
|
1259
|
-
bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
|
|
1260
|
-
},
|
|
1261
|
-
}), formData, args.workflowCode);
|
|
1381
|
+
body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
|
|
1382
|
+
}), formData);
|
|
1262
1383
|
}
|
|
1263
1384
|
catch (error) {
|
|
1264
1385
|
await diagnoseDbException(session, args.schemaCode, error);
|
|
1265
1386
|
throw error;
|
|
1266
1387
|
}
|
|
1267
|
-
const
|
|
1268
|
-
const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
|
|
1269
|
-
const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
|
|
1270
|
-
// saveWorkflow resolves the start workItem via getWorkItem INSIDE the
|
|
1271
|
-
// still-uncommitted request transaction (autoCommit:false), so the live
|
|
1272
|
-
// FormSaveVO usually omits workItem. The committed row is visible to a
|
|
1273
|
-
// follow-up search_workitems read; resolve the pending start todo by
|
|
1274
|
-
// exact instanceId (the server filter is a NOT-equal exclusion).
|
|
1275
|
-
let workItem = vo.workItem;
|
|
1276
|
-
let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
|
|
1277
|
-
let workItemSource = workItemId ? "form-save" : null;
|
|
1278
|
-
if (!workItemId && workflowInstanceId) {
|
|
1279
|
-
// search_workitems via the shared workflow seam (ticket 21): exact
|
|
1280
|
-
// instanceId match is client-side (the server filter is a NOT-equal
|
|
1281
|
-
// exclusion); the seam's default size 200 is the unified scan width.
|
|
1282
|
-
const found = await searchWorkItems(session, {
|
|
1283
|
-
workflowCode: args.workflowCode,
|
|
1284
|
-
instanceId: workflowInstanceId,
|
|
1285
|
-
});
|
|
1286
|
-
const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
|
|
1287
|
-
if (matchId) {
|
|
1288
|
-
workItemId = matchId;
|
|
1289
|
-
workItemSource = "workitem-search";
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1388
|
+
const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
|
|
1292
1389
|
let readbackData = null;
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1390
|
+
if (typeof bizObjectId === "string" && bizObjectId) {
|
|
1391
|
+
readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } })).data;
|
|
1392
|
+
}
|
|
1393
|
+
// Ticket 111: a plain save lands as sequenceStatus DRAFT, and DRAFT
|
|
1394
|
+
// rows SKIP the Create/Update rule dispatch entirely
|
|
1395
|
+
// (DomainServiceImpl.java:240-248 buckets drafts before the rule
|
|
1396
|
+
// branch) — business rules only run once data.submit advances the
|
|
1397
|
+
// row. Saying so on every plain-save readback prevents the
|
|
1398
|
+
// "rule didn't fire" misdiagnosis.
|
|
1399
|
+
const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
|
|
1400
|
+
const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
|
|
1401
|
+
const plainReadback = {
|
|
1402
|
+
authoritative: projectReadbackRecord(savedRecord, args),
|
|
1403
|
+
...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
|
|
1404
|
+
draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
|
|
1299
1405
|
};
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
// (resolvePublishedWorkflowCode → formType 1 → PROCESSING, not
|
|
1337
|
-
// COMPLETED) — the one-call chain's ruleHint would lie. Workflow-
|
|
1338
|
-
// backed models keep the two-phase chain.
|
|
1339
|
-
const publishedWorkflow = await resolvePublishedWorkflowCode(session, args.schemaCode);
|
|
1340
|
-
if (publishedWorkflow) {
|
|
1406
|
+
// r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
|
|
1407
|
+
// "record AND rule effect", not "a draft". Chain the no-flow submit in
|
|
1408
|
+
// the same governed write (the same sibling execute apply_changes uses).
|
|
1409
|
+
if (args.submit === true) {
|
|
1410
|
+
// F-B (self-audit): data.submit AUTO-STARTS a published workflow
|
|
1411
|
+
// (resolvePublishedWorkflowCode → formType 1 → PROCESSING, not
|
|
1412
|
+
// COMPLETED) — the one-call chain's ruleHint would lie. Workflow-
|
|
1413
|
+
// backed models keep the two-phase chain.
|
|
1414
|
+
const publishedWorkflow = await resolvePublishedWorkflowCode(session, args.schemaCode);
|
|
1415
|
+
if (publishedWorkflow) {
|
|
1416
|
+
return {
|
|
1417
|
+
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
|
|
1418
|
+
readback: {
|
|
1419
|
+
...plainReadback,
|
|
1420
|
+
submitRefused: {
|
|
1421
|
+
blocker: "submit-model-has-workflow",
|
|
1422
|
+
workflowCode: publishedWorkflow,
|
|
1423
|
+
message: `本模型有已发布流程 ${publishedWorkflow}:submit:true 的直提只适用无流程模型(流程模型会自动起流程得到 PROCESSING 而非完成态,Create 规则语义也不同)。草稿已保存;正确链路 = data.save {workflowCode} 建流程草稿(返回 workItemId)→ data.submit {bizObjectId, workflowInstanceId, workItemId}(见 describe data.submit --example workflow-backed-data-submit)`,
|
|
1424
|
+
},
|
|
1425
|
+
},
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
const submitTool = tools.find((entry) => entry.name === "data.submit");
|
|
1429
|
+
const submitOutcome = submitTool?.writeExecute
|
|
1430
|
+
? await submitTool.writeExecute(session, { schemaCode: args.schemaCode, bizObjectId, formData: args.formData })
|
|
1431
|
+
: { status: "error", message: "data.submit 工具不可用" };
|
|
1432
|
+
if ("status" in submitOutcome && typeof submitOutcome.status === "string" && submitOutcome.status !== "ok") {
|
|
1433
|
+
// The save itself DELIVERED — report it honestly with the submit
|
|
1434
|
+
// refusal attached, never as a plain refusal.
|
|
1435
|
+
const envelope = "envelope" in submitOutcome ? submitOutcome.envelope : submitOutcome;
|
|
1436
|
+
return {
|
|
1437
|
+
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
|
|
1438
|
+
readback: { ...plainReadback, submitRefused: envelope },
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
const okOutcome = submitOutcome;
|
|
1341
1442
|
return {
|
|
1342
|
-
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted:
|
|
1443
|
+
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: true },
|
|
1343
1444
|
readback: {
|
|
1344
|
-
...
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
workflowCode: publishedWorkflow,
|
|
1348
|
-
message: `本模型有已发布流程 ${publishedWorkflow}:submit:true 的直提只适用无流程模型(流程模型会自动起流程得到 PROCESSING 而非完成态,Create 规则语义也不同)。草稿已保存;正确链路 = data.save {workflowCode} 建流程草稿(返回 workItemId)→ data.submit {bizObjectId, workflowInstanceId, workItemId}(见 describe data.submit --example workflow-backed-data-submit)`,
|
|
1349
|
-
},
|
|
1445
|
+
...okOutcome.readback,
|
|
1446
|
+
submitted: true,
|
|
1447
|
+
ruleHint: "已直提 COMPLETED——Create 业务规则本次已分发;规则效果以本读回字段值为准",
|
|
1350
1448
|
},
|
|
1351
1449
|
};
|
|
1352
1450
|
}
|
|
1353
|
-
const submitTool = tools.find((entry) => entry.name === "data.submit");
|
|
1354
|
-
const submitOutcome = submitTool?.writeExecute
|
|
1355
|
-
? await submitTool.writeExecute(session, { schemaCode: args.schemaCode, bizObjectId, formData: args.formData })
|
|
1356
|
-
: { status: "error", message: "data.submit 工具不可用" };
|
|
1357
|
-
if ("status" in submitOutcome && typeof submitOutcome.status === "string" && submitOutcome.status !== "ok") {
|
|
1358
|
-
// The save itself DELIVERED — report it honestly with the submit
|
|
1359
|
-
// refusal attached, never as a plain refusal.
|
|
1360
|
-
const envelope = "envelope" in submitOutcome ? submitOutcome.envelope : submitOutcome;
|
|
1361
|
-
return {
|
|
1362
|
-
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
|
|
1363
|
-
readback: { ...plainReadback, submitRefused: envelope },
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
const okOutcome = submitOutcome;
|
|
1367
1451
|
return {
|
|
1368
|
-
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom
|
|
1369
|
-
readback:
|
|
1370
|
-
...okOutcome.readback,
|
|
1371
|
-
submitted: true,
|
|
1372
|
-
ruleHint: "已直提 COMPLETED——Create 业务规则本次已分发;规则效果以本读回字段值为准",
|
|
1373
|
-
},
|
|
1452
|
+
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
|
|
1453
|
+
readback: plainReadback,
|
|
1374
1454
|
};
|
|
1375
|
-
}
|
|
1376
|
-
return {
|
|
1377
|
-
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
|
|
1378
|
-
readback: plainReadback,
|
|
1379
|
-
};
|
|
1455
|
+
},
|
|
1380
1456
|
});
|
|
1381
|
-
writeTool(
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1457
|
+
writeTool({
|
|
1458
|
+
tools,
|
|
1459
|
+
name: "data.update",
|
|
1460
|
+
lane: RUNTIME_LANE,
|
|
1461
|
+
scope: RUNTIME_TOOLS["data.update"].scope,
|
|
1462
|
+
description: RUNTIME_TOOLS["data.update"].description,
|
|
1463
|
+
inputSchema: {
|
|
1464
|
+
schemaCode: z.string().min(1),
|
|
1465
|
+
sheetCode: z.string().optional(),
|
|
1466
|
+
bizObjectId: z.string().min(1).describe("id of the EXISTING record to edit; the tool refuses when it is absent (never silently inserts)"),
|
|
1467
|
+
formData: runtimeFormDataArg.describe("fields to change, keyed by field code; the platform merges them onto the stored record, so omitted fields keep their value (patch semantics)"),
|
|
1468
|
+
...readbackProjectionArgs,
|
|
1469
|
+
},
|
|
1470
|
+
execute: async (session, args) => {
|
|
1471
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1472
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, args.bizObjectId);
|
|
1473
|
+
if (!existing) {
|
|
1474
|
+
// The platform's save-with-id path silently inserts when the id is
|
|
1475
|
+
// unknown (BizObjectFacadeImpl.createBizObject). Refuse instead of
|
|
1476
|
+
// turning an edit into an accidental create.
|
|
1477
|
+
throw new ToolRefusalError({
|
|
1478
|
+
status: "refused",
|
|
1479
|
+
blocker: "update-target-absent",
|
|
1480
|
+
errcode: RUNTIME_DATA_WIRE.absenceErrcode,
|
|
1481
|
+
schemaCode: args.schemaCode,
|
|
1482
|
+
bizObjectId: args.bizObjectId,
|
|
1483
|
+
sheetCode,
|
|
1484
|
+
nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
|
|
1485
|
+
message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
|
|
1489
|
+
let performed;
|
|
1490
|
+
try {
|
|
1491
|
+
performed = await wireCall(session, {
|
|
1492
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
1493
|
+
method: "POST",
|
|
1494
|
+
// Live-proven shape: BizObjectModel.getId() reads data["id"], so the
|
|
1495
|
+
// target id MUST travel inside `data`; a top-level bizObject.id is
|
|
1496
|
+
// ignored and the platform silently creates a new row.
|
|
1497
|
+
body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
catch (error) {
|
|
1501
|
+
await diagnoseDbException(session, args.schemaCode, error);
|
|
1502
|
+
throw error;
|
|
1503
|
+
}
|
|
1504
|
+
const performedData = (performed.data ?? {});
|
|
1505
|
+
const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
|
|
1506
|
+
const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
|
|
1507
|
+
const sequenceStatus = extractSequenceStatus(readbackData);
|
|
1508
|
+
const updatedRecord = normalizeLoadedRecord(readbackData);
|
|
1509
|
+
const updatedEmptyFields = readbackEmptyFields(formData, updatedRecord);
|
|
1510
|
+
return {
|
|
1511
|
+
performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
|
|
1512
|
+
// Flat record contract (ticket 17): live FormLoadVO normalizes to the
|
|
1513
|
+
// fake's flat field shape so `authoritative.<fieldCode>` reads work on
|
|
1514
|
+
// both faces.
|
|
1515
|
+
readback: { authoritative: projectReadbackRecord(updatedRecord, args), sequenceStatus, ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}) },
|
|
1516
|
+
};
|
|
1517
|
+
},
|
|
1434
1518
|
});
|
|
1435
1519
|
/**
|
|
1436
1520
|
* Round 16b (dogfood R1-R3): `data.delete`'s `absent:true` only proves THAT id is
|
|
@@ -1455,549 +1539,589 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1455
1539
|
return { modelResidue: { total: null, clean: null, sheetCode, modelResidueError: error instanceof Error ? error.message : String(error) } };
|
|
1456
1540
|
}
|
|
1457
1541
|
}
|
|
1458
|
-
writeTool(
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
if (!before)
|
|
1480
|
-
return { bizObjectId, skipped: "already-absent", absent: true };
|
|
1481
|
-
const performed = await wireCall(session, {
|
|
1482
|
-
path: ENDPOINTS.runtimeFormDelete,
|
|
1483
|
-
method: "POST",
|
|
1484
|
-
query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
|
|
1485
|
-
});
|
|
1486
|
-
// The absence readback: a missing object surfaces either as an empty
|
|
1487
|
-
// payload or as a business error (304001 业务对象不存在) from form/load.
|
|
1488
|
-
// Both are authoritative absence, not a delete failure.
|
|
1489
|
-
let absent = false;
|
|
1490
|
-
try {
|
|
1491
|
-
const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
|
|
1492
|
-
const value = readback.data;
|
|
1493
|
-
absent = value == null || value === false;
|
|
1494
|
-
}
|
|
1495
|
-
catch (error) {
|
|
1496
|
-
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
|
|
1497
|
-
absent = true;
|
|
1498
|
-
else
|
|
1499
|
-
throw error;
|
|
1500
|
-
}
|
|
1501
|
-
return { bizObjectId, errcode: performed.errcode, absent };
|
|
1502
|
-
};
|
|
1503
|
-
if (ids.length === 1) {
|
|
1504
|
-
const one = await deleteOne(ids[0]);
|
|
1505
|
-
if (one.skipped === "already-absent")
|
|
1506
|
-
return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1507
|
-
return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1508
|
-
}
|
|
1509
|
-
const deleted = [];
|
|
1510
|
-
for (const id of ids)
|
|
1511
|
-
deleted.push(await deleteOne(id));
|
|
1512
|
-
// `deleted`/`count` ride `readback` — the write envelope surfaces only
|
|
1513
|
-
// performed/readback inline (extra top-level keys are artifact-only).
|
|
1514
|
-
return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1515
|
-
});
|
|
1516
|
-
writeTool(tools, "data.submit", RUNTIME_LANE, RUNTIME_TOOLS["data.submit"].scope, RUNTIME_TOOLS["data.submit"].description, {
|
|
1517
|
-
schemaCode: z.string().min(1),
|
|
1518
|
-
sheetCode: z.string().optional(),
|
|
1519
|
-
formData: runtimeFormDataArg,
|
|
1520
|
-
workflowCode: z.string().optional(),
|
|
1521
|
-
workflowInstanceId: z.string().optional(),
|
|
1522
|
-
workItemId: z.string().optional(),
|
|
1523
|
-
bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
|
|
1524
|
-
agree: z.boolean().optional(),
|
|
1525
|
-
allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
|
|
1526
|
-
allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
|
|
1527
|
-
approval: z.record(z.string(), z.unknown()).optional(),
|
|
1528
|
-
runMode: z.string().optional(),
|
|
1529
|
-
isSimulative: z.boolean().optional(),
|
|
1530
|
-
...readbackProjectionArgs,
|
|
1531
|
-
}, async (session, args) => {
|
|
1532
|
-
// r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
|
|
1533
|
-
// 100002「参数为空」with no hint which parameter. Absent and blank are not
|
|
1534
|
-
// the same intent: refuse the blank one by name, zero wire.
|
|
1535
|
-
const blank = ["workItemId", "workflowInstanceId", "workflowCode"].filter((key) => typeof args[key] === "string" && args[key].trim().length === 0 && args[key].length > 0);
|
|
1536
|
-
if (blank.length > 0)
|
|
1537
|
-
throw new ToolRefusalError({
|
|
1538
|
-
status: "refused",
|
|
1539
|
-
blocker: "submit-blank-parameter",
|
|
1540
|
-
schemaCode: args.schemaCode,
|
|
1541
|
-
parameters: blank,
|
|
1542
|
-
message: `这些参数传了空白字符串:${blank.join("、")}。平台会把这个空白当"参数为空"回 100002(SYS_PARAMETER_EMPTY),` +
|
|
1543
|
-
`而错误里不会说是哪一个。要么给出真实 id,要么整个省略该键(省略 ≠ 传空串)。`,
|
|
1544
|
-
});
|
|
1545
|
-
const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
|
|
1546
|
-
// r18b: pure shape check — run before any read/wire so a wrong-result
|
|
1547
|
-
// payload never costs a round trip.
|
|
1548
|
-
if (args.approval && typeof args.approval === "object") {
|
|
1549
|
-
const misplaced = args.approval;
|
|
1550
|
-
if (misplaced.agree !== undefined)
|
|
1551
|
-
throw new ToolRefusalError({
|
|
1552
|
-
status: "refused",
|
|
1553
|
-
blocker: "submit-approval-agree-misplaced",
|
|
1554
|
-
schemaCode: args.schemaCode,
|
|
1555
|
-
message: `approval.agree 不生效:平台只读**顶层** agree(true=通过 / false=驳回)。approval 对象里放 agree 会被**静默忽略并按通过处理**` +
|
|
1556
|
-
`(r18b 真机:approval:{agree:false} 仍写入 result=APPROVE 并把流程走完)。改成顶层参数 {"agree":false,...}。`,
|
|
1542
|
+
writeTool({
|
|
1543
|
+
tools,
|
|
1544
|
+
name: "data.delete",
|
|
1545
|
+
lane: RUNTIME_LANE,
|
|
1546
|
+
scope: RUNTIME_TOOLS["data.delete"].scope,
|
|
1547
|
+
description: RUNTIME_TOOLS["data.delete"].description,
|
|
1548
|
+
inputSchema: { schemaCode: z.string().min(1), sheetCode: z.string().optional(), bizObjectId: z.string().min(1).optional(), bizObjectIds: z.array(z.string().min(1)).optional().describe("批量删除多个业务对象 id(等价于逐个 data.delete,但只花一次往返;返回 deleted 列表)") },
|
|
1549
|
+
execute: async (session, args) => {
|
|
1550
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1551
|
+
const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
|
|
1552
|
+
if (ids.length === 0)
|
|
1553
|
+
throw new ToolInputError({
|
|
1554
|
+
status: "error",
|
|
1555
|
+
blocker: "tool-input-invalid",
|
|
1556
|
+
tool: "data.delete",
|
|
1557
|
+
// r28b F10: describe is not the next action — the caller already
|
|
1558
|
+
// described. Point at list→id then a pasteable single-id delete.
|
|
1559
|
+
nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode, all: true })}'`,
|
|
1560
|
+
message: "需要 bizObjectId(单个)或 bizObjectIds(数组,批量)。先 data.list 取 items[].id,再:{\"schemaCode\":\"" +
|
|
1561
|
+
args.schemaCode +
|
|
1562
|
+
"\",\"bizObjectId\":\"<items[].id>\"}(1.1.25 上多个 id 的 bizObjectIds 会被拒——请逐个单删)。",
|
|
1557
1563
|
});
|
|
1558
|
-
|
|
1564
|
+
// r30 (2026-10-31, 1.1.25 real machine): the batch form fake-succeeds —
|
|
1565
|
+
// DELIVERED + absent:true + a DECREASING modelResidue.total while the
|
|
1566
|
+
// rows are still queryable (data.list unchanged, data.load exists:true).
|
|
1567
|
+
// Re-issuing the same ids one at a time deleted them for real. Refuse
|
|
1568
|
+
// the batch shape and teach the per-id loop until the platform path is
|
|
1569
|
+
// understood; single-id deletes go through the exact same deleteOne.
|
|
1570
|
+
if (ids.length > 1)
|
|
1559
1571
|
throw new ToolRefusalError({
|
|
1560
1572
|
status: "refused",
|
|
1561
|
-
blocker: "
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1573
|
+
blocker: "data-delete-batch-fake-success",
|
|
1574
|
+
// r28b F14: point at a concrete single-id call, not the methodology page.
|
|
1575
|
+
nextCommand: `omc call data.delete --args '${JSON.stringify({ schemaCode: args.schemaCode, bizObjectId: ids[0] })}'`,
|
|
1576
|
+
message: "批量删除(bizObjectIds 多个)在 1.1.25 真机实证为假成功:回执 DELIVERED + absent:true + modelResidue.total 递减,但行仍在(data.list 不变、data.load exists:true);同样的 id 改单条逐个删才真删掉。请用 bizObjectId(单个)逐个删,每条删完用 data.load 确认 exists:false(absent:true 不是删除成功的证明)。" +
|
|
1577
|
+
`下一步:把 ${ids.length} 个 id 拆成 ${ids.length} 次单删(示例已写入 nextCommand;其余 id 替换 bizObjectId 即可)。`,
|
|
1565
1578
|
});
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1579
|
+
const deleteOne = async (bizObjectId) => {
|
|
1580
|
+
// Delete is destructive: probe the authoritative object first so an
|
|
1581
|
+
// absent id is an idempotent no-op, never a blind runtime delete.
|
|
1582
|
+
const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
|
|
1583
|
+
if (!before)
|
|
1584
|
+
return { bizObjectId, skipped: "already-absent", absent: true };
|
|
1585
|
+
const performed = await wireCall(session, {
|
|
1586
|
+
path: ENDPOINTS.runtimeFormDelete,
|
|
1587
|
+
method: "POST",
|
|
1588
|
+
query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
|
|
1589
|
+
});
|
|
1590
|
+
// The absence readback: a missing object surfaces either as an empty
|
|
1591
|
+
// payload or as a business error (304001 业务对象不存在) from form/load.
|
|
1592
|
+
// Both are authoritative absence, not a delete failure.
|
|
1593
|
+
let absent = false;
|
|
1594
|
+
try {
|
|
1595
|
+
const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
|
|
1596
|
+
const value = readback.data;
|
|
1597
|
+
absent = value == null || value === false;
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
|
|
1601
|
+
absent = true;
|
|
1602
|
+
else
|
|
1603
|
+
throw error;
|
|
1604
|
+
}
|
|
1605
|
+
return { bizObjectId, errcode: performed.errcode, absent };
|
|
1606
|
+
};
|
|
1607
|
+
if (ids.length === 1) {
|
|
1608
|
+
const one = await deleteOne(ids[0]);
|
|
1609
|
+
if (one.skipped === "already-absent")
|
|
1610
|
+
return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1611
|
+
return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1612
|
+
}
|
|
1613
|
+
const deleted = [];
|
|
1614
|
+
for (const id of ids)
|
|
1615
|
+
deleted.push(await deleteOne(id));
|
|
1616
|
+
// `deleted`/`count` ride `readback` — the write envelope surfaces only
|
|
1617
|
+
// performed/readback inline (extra top-level keys are artifact-only).
|
|
1618
|
+
return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
1619
|
+
},
|
|
1620
|
+
});
|
|
1621
|
+
writeTool({
|
|
1622
|
+
tools,
|
|
1623
|
+
name: "data.submit",
|
|
1624
|
+
lane: RUNTIME_LANE,
|
|
1625
|
+
scope: RUNTIME_TOOLS["data.submit"].scope,
|
|
1626
|
+
description: RUNTIME_TOOLS["data.submit"].description,
|
|
1627
|
+
inputSchema: {
|
|
1628
|
+
schemaCode: z.string().min(1),
|
|
1629
|
+
sheetCode: z.string().optional(),
|
|
1630
|
+
formData: runtimeFormDataArg,
|
|
1631
|
+
workflowCode: z.string().optional(),
|
|
1632
|
+
workflowInstanceId: z.string().optional(),
|
|
1633
|
+
workItemId: z.string().optional(),
|
|
1634
|
+
bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
|
|
1635
|
+
agree: z.boolean().optional(),
|
|
1636
|
+
allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
|
|
1637
|
+
allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
|
|
1638
|
+
approval: z.record(z.string(), z.unknown()).optional(),
|
|
1639
|
+
runMode: z.string().optional(),
|
|
1640
|
+
isSimulative: z.boolean().optional(),
|
|
1641
|
+
...readbackProjectionArgs,
|
|
1642
|
+
},
|
|
1643
|
+
execute: async (session, args) => {
|
|
1644
|
+
// r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
|
|
1645
|
+
// 100002「参数为空」with no hint which parameter. Absent and blank are not
|
|
1646
|
+
// the same intent: refuse the blank one by name, zero wire.
|
|
1647
|
+
const blank = ["workItemId", "workflowInstanceId", "workflowCode"].filter((key) => typeof args[key] === "string" && args[key].trim().length === 0 && args[key].length > 0);
|
|
1648
|
+
if (blank.length > 0)
|
|
1594
1649
|
throw new ToolRefusalError({
|
|
1595
1650
|
status: "refused",
|
|
1596
|
-
blocker: "submit-
|
|
1651
|
+
blocker: "submit-blank-parameter",
|
|
1597
1652
|
schemaCode: args.schemaCode,
|
|
1598
|
-
|
|
1599
|
-
message:
|
|
1653
|
+
parameters: blank,
|
|
1654
|
+
message: `这些参数传了空白字符串:${blank.join("、")}。平台会把这个空白当"参数为空"回 100002(SYS_PARAMETER_EMPTY),` +
|
|
1655
|
+
`而错误里不会说是哪一个。要么给出真实 id,要么整个省略该键(省略 ≠ 传空串)。`,
|
|
1600
1656
|
});
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1657
|
+
const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
|
|
1658
|
+
// r18b: pure shape check — run before any read/wire so a wrong-result
|
|
1659
|
+
// payload never costs a round trip.
|
|
1660
|
+
if (args.approval && typeof args.approval === "object") {
|
|
1661
|
+
const misplaced = args.approval;
|
|
1662
|
+
if (misplaced.agree !== undefined)
|
|
1663
|
+
throw new ToolRefusalError({
|
|
1664
|
+
status: "refused",
|
|
1665
|
+
blocker: "submit-approval-agree-misplaced",
|
|
1666
|
+
schemaCode: args.schemaCode,
|
|
1667
|
+
message: `approval.agree 不生效:平台只读**顶层** agree(true=通过 / false=驳回)。approval 对象里放 agree 会被**静默忽略并按通过处理**` +
|
|
1668
|
+
`(r18b 真机:approval:{agree:false} 仍写入 result=APPROVE 并把流程走完)。改成顶层参数 {"agree":false,...}。`,
|
|
1669
|
+
});
|
|
1670
|
+
if (misplaced.opinion !== undefined)
|
|
1671
|
+
throw new ToolRefusalError({
|
|
1672
|
+
status: "refused",
|
|
1673
|
+
blocker: "submit-approval-opinion-misplaced",
|
|
1674
|
+
schemaCode: args.schemaCode,
|
|
1675
|
+
message: `approval.opinion 不是平台键,会被静默丢弃(r18b 真机:意见不进 h_biz_comment,content 为 NULL)。` +
|
|
1676
|
+
`审批意见(BizCommentModel)用 **approval.content**;附件 approval.resources;下一步审批人 approval.relUsers。`,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
|
|
1680
|
+
const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
|
|
1681
|
+
if ((hasWorkItem || hasInstance) && !targetObjectId) {
|
|
1617
1682
|
throw new ToolRefusalError({
|
|
1618
1683
|
status: "refused",
|
|
1619
|
-
blocker: "submit-
|
|
1684
|
+
blocker: "submit-biz-object-id-required",
|
|
1620
1685
|
schemaCode: args.schemaCode,
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
`确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
|
|
1686
|
+
workItemId: args.workItemId,
|
|
1687
|
+
workflowInstanceId: args.workflowInstanceId,
|
|
1688
|
+
message: "workItemId or workflowInstanceId submit requires bizObjectId; refusing before runtime/form/submit",
|
|
1625
1689
|
});
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const activity = (await wireCall(session, {
|
|
1643
|
-
path: ENDPOINTS.workflowListInstanceActivity,
|
|
1644
|
-
query: { workflowInstanceId: String(args.workflowInstanceId ?? "") },
|
|
1645
|
-
})).data;
|
|
1646
|
-
const rows = Array.isArray(activity?.activities) ? activity.activities : wireRows(activity);
|
|
1647
|
-
const mine = rows.filter((row) => String(row.workItemId ?? "") === args.workItemId);
|
|
1648
|
-
const finished = mine.length > 0 && mine.every((row) => /FINISHED|COMPLETED/i.test(String(row.state ?? row.status ?? "")));
|
|
1649
|
-
if (finished)
|
|
1690
|
+
}
|
|
1691
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1692
|
+
const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
|
|
1693
|
+
const formData = stampedSubmit.formData;
|
|
1694
|
+
// r5-asset 2026-10-20: the platform APPENDS child rows carried in an
|
|
1695
|
+
// approval submit's formData (no upsert by row id, no error). Live: an
|
|
1696
|
+
// approval that echoed the whole form twice turned 2 detail rows into 6
|
|
1697
|
+
// and 30000 into 90000 while every call answered errcode 0 — node-level
|
|
1698
|
+
// `editable:false` does not stop it server-side. Refuse before the wire.
|
|
1699
|
+
if ((hasWorkItem || hasInstance) && args.allowChildRowAppend !== true) {
|
|
1700
|
+
// Scan at ANY depth: callers wrap the form as {data:{...}} or
|
|
1701
|
+
// {bizObject:{data:{...}}}, and a top-level-only check let those through
|
|
1702
|
+
// (the platform appends whatever it finds). The child codes come from the
|
|
1703
|
+
// model's own field list.
|
|
1704
|
+
const carried = collectChildArrayPaths(args.formData, new Set(stampedSubmit.childCodes));
|
|
1705
|
+
if (carried.length > 0)
|
|
1650
1706
|
throw new ToolRefusalError({
|
|
1651
1707
|
status: "refused",
|
|
1652
|
-
blocker: "submit-
|
|
1708
|
+
blocker: "submit-approval-child-rows",
|
|
1653
1709
|
schemaCode: args.schemaCode,
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
message: `待办 ${String(args.workItemId)} 已经是完成状态——本次提交**没有发生**,平台会回 100005「频繁重复提交!」(SYS_REPLAY_ERROR,按 workItem 维度限流,换载荷/等待都无效)。` +
|
|
1657
|
-
`这不是失败,是"已经提交过了"。用 workitem.list 看下一级待办,或 data.load 看当前 sequenceStatus,不要重复提交。`,
|
|
1710
|
+
childTables: carried,
|
|
1711
|
+
message: `审批提交(workItemId/workflowInstanceId)的 formData 不要带子表数组:${carried.join("、")}。平台按「追加行」处理——每提交一次就再插一份(真机:2 行 → 6 行,30000 → 90000),且不报错、审批节点的 editable:false 也拦不住。只传主表字段;确实要在审批中追加子行时显式传 allowChildRowAppend:true。`,
|
|
1658
1712
|
});
|
|
1659
1713
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1714
|
+
// Portal required-field semantics: the REST submit does NOT enforce them,
|
|
1715
|
+
// so a missing required value must be REPORTED, not silently accepted as a
|
|
1716
|
+
// portal-valid submit (dogfood R3). Best-effort read; a failure never
|
|
1717
|
+
// blocks the submit.
|
|
1718
|
+
// r16 P5:人员字段读回来是对象数组 `[{id,type,name,…}]`,写回去必须是**字符串 id**
|
|
1719
|
+
// ——写成对象会让 UserBizField.fillClientContent 断言失败,平台只回 50000。
|
|
1720
|
+
// 读写不对称且 schema 无法表达(formData 是 additionalProperties),所以在
|
|
1721
|
+
// 写前按已发布字段类型做一次浅校验,把这类形状错误变成一条指名道姓的拒绝。
|
|
1722
|
+
// issue#10(8.6.41 复现矩阵 + 本仓 1.1.25 复验):旧提示方向反了——
|
|
1723
|
+
// "写入只接受字符串 id"会让调用者按提示改成字符串数组,然后平台 50000
|
|
1724
|
+
// (submit 面 D)。提交面两种已知形状都不安全,可靠链路只有:save 时用
|
|
1725
|
+
// 对象数组落库 → 提交时省略人员字段(值已持久化)。
|
|
1726
|
+
if (args.allowStaffOnSubmit !== true) {
|
|
1727
|
+
const staffShaped = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
|
|
1728
|
+
if (staffShaped.length > 0)
|
|
1729
|
+
throw new ToolRefusalError({
|
|
1730
|
+
status: "refused",
|
|
1731
|
+
blocker: "submit-person-field-shape",
|
|
1732
|
+
schemaCode: args.schemaCode,
|
|
1733
|
+
fields: staffShaped,
|
|
1734
|
+
message: `提交携带了人员字段:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都可能 50000(对象数组触发 UserBizField.fillClientContent 断言;字符串 id 数组在 8.6.41/1.1.25 均实测 50000——issue#10 复现矩阵)。` +
|
|
1735
|
+
`可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交时**省略人员字段**——值已持久化,提交只推进流程。` +
|
|
1736
|
+
`确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
|
|
1737
|
+
});
|
|
1664
1738
|
}
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1739
|
+
const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
|
|
1740
|
+
// Directive: a workflow-backed model's submit must START the workflow, not
|
|
1741
|
+
// merely save a sheet row. When the caller names no workflow params, detect
|
|
1742
|
+
// the model's workflow and start it (formType=1 + workflowCode); a model
|
|
1743
|
+
// with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
|
|
1744
|
+
let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
|
|
1745
|
+
let autoStarted = false;
|
|
1746
|
+
// r16 P4: a second submit on the same work item answers 100005
|
|
1747
|
+
// SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 — the SAME numeric code
|
|
1748
|
+
// as TEST_CONNECTION_FAIL, so the text is the only discriminator). The
|
|
1749
|
+
// platform rate-limits per work item, not per request body, so retrying
|
|
1750
|
+
// with a different payload changes nothing; the caller needs to know
|
|
1751
|
+
// whether the FIRST submit actually landed. Diagnose before the wire.
|
|
1752
|
+
if (hasWorkItem) {
|
|
1753
|
+
try {
|
|
1754
|
+
const activity = (await wireCall(session, {
|
|
1755
|
+
path: ENDPOINTS.workflowListInstanceActivity,
|
|
1756
|
+
query: { workflowInstanceId: String(args.workflowInstanceId ?? "") },
|
|
1757
|
+
})).data;
|
|
1758
|
+
const rows = Array.isArray(activity?.activities) ? activity.activities : wireRows(activity);
|
|
1759
|
+
const mine = rows.filter((row) => String(row.workItemId ?? "") === args.workItemId);
|
|
1760
|
+
const finished = mine.length > 0 && mine.every((row) => /FINISHED|COMPLETED/i.test(String(row.state ?? row.status ?? "")));
|
|
1761
|
+
if (finished)
|
|
1762
|
+
throw new ToolRefusalError({
|
|
1763
|
+
status: "refused",
|
|
1764
|
+
blocker: "submit-workitem-already-finished",
|
|
1765
|
+
schemaCode: args.schemaCode,
|
|
1766
|
+
workItemId: args.workItemId,
|
|
1767
|
+
nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
|
|
1768
|
+
message: `待办 ${String(args.workItemId)} 已经是完成状态——本次提交**没有发生**,平台会回 100005「频繁重复提交!」(SYS_REPLAY_ERROR,按 workItem 维度限流,换载荷/等待都无效)。` +
|
|
1769
|
+
`这不是失败,是"已经提交过了"。用 workitem.list 看下一级待办,或 data.load 看当前 sequenceStatus,不要重复提交。`,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
catch (error) {
|
|
1773
|
+
if (error instanceof ToolRefusalError)
|
|
1774
|
+
throw error;
|
|
1775
|
+
// A read failure must not block the submit (fail open).
|
|
1776
|
+
}
|
|
1671
1777
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
let draftObjectId = null;
|
|
1686
|
-
if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
1687
|
-
throw new ToolRefusalError({
|
|
1688
|
-
status: "refused",
|
|
1689
|
-
blocker: "submit-existing-workflow-unsupported",
|
|
1690
|
-
schemaCode: args.schemaCode,
|
|
1691
|
-
bizObjectId: targetObjectId,
|
|
1692
|
-
workflowCode: explicitWorkflowCode,
|
|
1693
|
-
nextCommand: "omc describe data.submit --example workflow-backed-data-submit",
|
|
1694
|
-
message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
1697
|
-
let preSubmitExisting = null;
|
|
1698
|
-
if (targetObjectId) {
|
|
1699
|
-
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
|
|
1700
|
-
preSubmitExisting = existing;
|
|
1701
|
-
if (!existing) {
|
|
1702
|
-
throw new ToolRefusalError({
|
|
1703
|
-
status: "refused",
|
|
1704
|
-
blocker: "submit-target-absent",
|
|
1705
|
-
errcode: RUNTIME_DATA_WIRE.absenceErrcode,
|
|
1778
|
+
if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
1779
|
+
const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
|
|
1780
|
+
if (resolved) {
|
|
1781
|
+
explicitWorkflowCode = resolved;
|
|
1782
|
+
autoStarted = true;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const hasWorkflowCode = explicitWorkflowCode.length > 0;
|
|
1786
|
+
const hasWorkflow = hasWorkflowCode || hasWorkItem || hasInstance;
|
|
1787
|
+
const formType = hasWorkflow ? "1" : "2";
|
|
1788
|
+
const payload = {
|
|
1789
|
+
formType,
|
|
1790
|
+
bizObject: {
|
|
1706
1791
|
schemaCode: args.schemaCode,
|
|
1707
|
-
bizObjectId: targetObjectId,
|
|
1708
1792
|
sheetCode,
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
}
|
|
1713
|
-
// Direct-submit chain (ticket 18): form/submit persists bizObject.data
|
|
1714
|
-
// itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
|
|
1715
|
-
// merges via updateBizObject.mergeObject); formType=1 workItem ->
|
|
1716
|
-
// SubmitDataChangeHandlerImpl.updateBizObject — so the historical
|
|
1717
|
-
// pre-submit form/save + form/load round-trip is redundant wire. The
|
|
1718
|
-
// submit bizObject is synthesized from the authoritative pre-check
|
|
1719
|
-
// load overlaid with the caller's formData.
|
|
1720
|
-
const flat = normalizeLoadedRecord(existing) ?? {};
|
|
1721
|
-
payload.bizObject = {
|
|
1722
|
-
id: targetObjectId,
|
|
1723
|
-
schemaCode: args.schemaCode,
|
|
1724
|
-
sheetCode,
|
|
1725
|
-
ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
|
|
1726
|
-
// Engine-owned keys must never be echoed back: the loaded row's
|
|
1727
|
-
// `sequenceStatus: DRAFT` would be persisted verbatim and pin the
|
|
1728
|
-
// row to DRAFT across submits (the portal client never resubmits
|
|
1729
|
-
// the status column; live TID 10e6bc65 evidence, ticket 17).
|
|
1730
|
-
data: stripEngineOwnedKeys({ ...flat, ...formData }),
|
|
1731
|
-
};
|
|
1732
|
-
if (formType === "2")
|
|
1733
|
-
payload.actionCode = "submit";
|
|
1734
|
-
draftObjectId = targetObjectId;
|
|
1735
|
-
}
|
|
1736
|
-
else if (formType === "2") {
|
|
1737
|
-
// Live 1.1.25 (r7-vehicle 2026-10-21): the previous chain did
|
|
1738
|
-
// form/save (creating a DRAFT row) and then submitted with that id —
|
|
1739
|
-
// the platform INSERTED A SECOND ROW instead of completing the first,
|
|
1740
|
-
// leaving one COMPLETED + one orphan DRAFT per submit (verified in
|
|
1741
|
-
// ia38g_veh_vehicle: same plateNo, same second, statuses COMPLETED and
|
|
1742
|
-
// DRAFT). form/submit persists bizObject.data itself (ticket 18), so
|
|
1743
|
-
// skip the pre-save and let the platform create+complete in one call.
|
|
1744
|
-
payload.bizObject = {
|
|
1745
|
-
schemaCode: args.schemaCode,
|
|
1746
|
-
sheetCode,
|
|
1747
|
-
data: stripEngineOwnedKeys(formData),
|
|
1793
|
+
data: args.formData,
|
|
1794
|
+
},
|
|
1795
|
+
agree: args.agree !== false,
|
|
1748
1796
|
};
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
let replayToken;
|
|
1752
|
-
if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
1753
|
-
// Starting a workflow (formType=1 + workflowCode, no workItem) requires
|
|
1754
|
-
// a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
|
|
1755
|
-
// (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
|
|
1756
|
-
replayToken = await getReplayToken(session);
|
|
1757
|
-
}
|
|
1758
|
-
if (hasWorkflowCode)
|
|
1759
|
-
payload.workflowCode = explicitWorkflowCode;
|
|
1760
|
-
if (replayToken)
|
|
1761
|
-
payload.replayToken = replayToken;
|
|
1762
|
-
if (typeof args.workflowInstanceId === "string")
|
|
1763
|
-
payload.workflowInstanceId = args.workflowInstanceId;
|
|
1764
|
-
if (typeof args.workItemId === "string")
|
|
1765
|
-
payload.workItemId = args.workItemId;
|
|
1766
|
-
if (args.approval && typeof args.approval === "object")
|
|
1767
|
-
payload.approval = args.approval;
|
|
1768
|
-
if (typeof args.runMode === "string")
|
|
1769
|
-
payload.runMode = args.runMode;
|
|
1770
|
-
if (typeof args.isSimulative === "boolean")
|
|
1771
|
-
payload.isSimulative = args.isSimulative;
|
|
1772
|
-
// Approval submits are resolved against the LOGGED-IN user (r13: six
|
|
1773
|
-
// blind 40005s). Name the boundary before the write instead of letting the
|
|
1774
|
-
// platform answer "权限不足", which reads like a misconfiguration.
|
|
1775
|
-
if (hasWorkItem)
|
|
1776
|
-
await diagnoseApprovalSubmitIdentity(session, { workflowInstanceId: args.workflowInstanceId, workItemId: args.workItemId });
|
|
1777
|
-
let performed;
|
|
1778
|
-
try {
|
|
1779
|
-
performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
|
|
1780
|
-
path: ENDPOINTS.runtimeFormSubmit,
|
|
1781
|
-
method: "POST",
|
|
1782
|
-
body: payload,
|
|
1783
|
-
}), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
|
|
1784
|
-
}
|
|
1785
|
-
catch (error) {
|
|
1786
|
-
await diagnoseDbException(session, args.schemaCode, error);
|
|
1787
|
-
// r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
|
|
1788
|
-
// the same numeric code as TEST_CONNECTION_FAIL, so only the text
|
|
1789
|
-
// separates them). The platform rate-limits per WORK ITEM, so a different
|
|
1790
|
-
// payload or a longer wait changes nothing. The actionable question is
|
|
1791
|
-
// "did the earlier submit land?", which the todo list answers.
|
|
1792
|
-
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.replayTokenErrcode) {
|
|
1793
|
-
const stillMine = await workItemStillOpen(session, args.workflowInstanceId, args.workItemId).catch(() => null);
|
|
1797
|
+
let draftObjectId = null;
|
|
1798
|
+
if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
1794
1799
|
throw new ToolRefusalError({
|
|
1795
1800
|
status: "refused",
|
|
1796
|
-
blocker:
|
|
1801
|
+
blocker: "submit-existing-workflow-unsupported",
|
|
1797
1802
|
schemaCode: args.schemaCode,
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
nextCommand:
|
|
1801
|
-
message:
|
|
1802
|
-
? `100005「频繁重复提交!」在这里的真实含义:待办 ${String(args.workItemId ?? "(未提供)")} **已经提交过了**。限流按 workItem 维度,换载荷、等待、换 requestId 都无效——这不是失败。用 workitem.list 看下一级待办,或 data.load 看 sequenceStatus,不要重试本次提交。`
|
|
1803
|
-
: `100005 = 平台 SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68,与"测试连接不通过"共用该数值码,只能靠文案区分)。` +
|
|
1804
|
-
`两种成因:① 该待办已经提交过;② startWorkflow 路径的 replayToken 未刷新。` +
|
|
1805
|
-
`${stillMine === null ? "(待办状态读取失败,无法判定)" : stillMine ? "该待办仍在——多半是限流窗口未过。" : ""}` +
|
|
1806
|
-
`先 workitem.list 核对当前待办,确认没提交成功再等一会儿重试;改 payload 不会绕过它。`,
|
|
1803
|
+
bizObjectId: targetObjectId,
|
|
1804
|
+
workflowCode: explicitWorkflowCode,
|
|
1805
|
+
nextCommand: "omc describe data.submit --example workflow-backed-data-submit",
|
|
1806
|
+
message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
|
|
1807
1807
|
});
|
|
1808
1808
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1809
|
+
let preSubmitExisting = null;
|
|
1810
|
+
if (targetObjectId) {
|
|
1811
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
|
|
1812
|
+
preSubmitExisting = existing;
|
|
1813
|
+
if (!existing) {
|
|
1814
|
+
throw new ToolRefusalError({
|
|
1815
|
+
status: "refused",
|
|
1816
|
+
blocker: "submit-target-absent",
|
|
1817
|
+
errcode: RUNTIME_DATA_WIRE.absenceErrcode,
|
|
1818
|
+
schemaCode: args.schemaCode,
|
|
1819
|
+
bizObjectId: targetObjectId,
|
|
1820
|
+
sheetCode,
|
|
1821
|
+
nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
|
|
1822
|
+
message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
// Direct-submit chain (ticket 18): form/submit persists bizObject.data
|
|
1826
|
+
// itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
|
|
1827
|
+
// merges via updateBizObject.mergeObject); formType=1 workItem ->
|
|
1828
|
+
// SubmitDataChangeHandlerImpl.updateBizObject — so the historical
|
|
1829
|
+
// pre-submit form/save + form/load round-trip is redundant wire. The
|
|
1830
|
+
// submit bizObject is synthesized from the authoritative pre-check
|
|
1831
|
+
// load overlaid with the caller's formData.
|
|
1832
|
+
const flat = normalizeLoadedRecord(existing) ?? {};
|
|
1833
|
+
payload.bizObject = {
|
|
1834
|
+
id: targetObjectId,
|
|
1835
|
+
schemaCode: args.schemaCode,
|
|
1836
|
+
sheetCode,
|
|
1837
|
+
ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
|
|
1838
|
+
// Engine-owned keys must never be echoed back: the loaded row's
|
|
1839
|
+
// `sequenceStatus: DRAFT` would be persisted verbatim and pin the
|
|
1840
|
+
// row to DRAFT across submits (the portal client never resubmits
|
|
1841
|
+
// the status column; live TID 10e6bc65 evidence, ticket 17).
|
|
1842
|
+
data: stripEngineOwnedKeys({ ...flat, ...formData }),
|
|
1822
1843
|
};
|
|
1844
|
+
if (formType === "2")
|
|
1845
|
+
payload.actionCode = "submit";
|
|
1846
|
+
draftObjectId = targetObjectId;
|
|
1823
1847
|
}
|
|
1824
|
-
if (
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1848
|
+
else if (formType === "2") {
|
|
1849
|
+
// Live 1.1.25 (r7-vehicle 2026-10-21): the previous chain did
|
|
1850
|
+
// form/save (creating a DRAFT row) and then submitted with that id —
|
|
1851
|
+
// the platform INSERTED A SECOND ROW instead of completing the first,
|
|
1852
|
+
// leaving one COMPLETED + one orphan DRAFT per submit (verified in
|
|
1853
|
+
// ia38g_veh_vehicle: same plateNo, same second, statuses COMPLETED and
|
|
1854
|
+
// DRAFT). form/submit persists bizObject.data itself (ticket 18), so
|
|
1855
|
+
// skip the pre-save and let the platform create+complete in one call.
|
|
1856
|
+
payload.bizObject = {
|
|
1857
|
+
schemaCode: args.schemaCode,
|
|
1858
|
+
sheetCode,
|
|
1859
|
+
data: stripEngineOwnedKeys(formData),
|
|
1860
|
+
};
|
|
1861
|
+
payload.actionCode = "submit";
|
|
1832
1862
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
const sequenceStatus = extractSequenceStatus(record);
|
|
1840
|
-
readback = {
|
|
1841
|
-
...readback,
|
|
1842
|
-
objectId,
|
|
1843
|
-
schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
|
|
1844
|
-
formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
|
|
1845
|
-
sequenceStatus,
|
|
1846
|
-
...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
|
|
1847
|
-
submittedData: projectReadbackRecord(record, args),
|
|
1848
|
-
};
|
|
1849
|
-
}
|
|
1850
|
-
else {
|
|
1851
|
-
const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
|
|
1852
|
-
if (targetObjectId && returnedObjectId !== targetObjectId) {
|
|
1853
|
-
throw new ToolRefusalError({
|
|
1854
|
-
status: "refused",
|
|
1855
|
-
blocker: "submit-target-id-changed",
|
|
1856
|
-
expectedBizObjectId: targetObjectId,
|
|
1857
|
-
actualBizObjectId: returnedObjectId,
|
|
1858
|
-
message: "runtime/form/submit returned a different object id; refusing to accept a new row",
|
|
1859
|
-
});
|
|
1863
|
+
let replayToken;
|
|
1864
|
+
if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
1865
|
+
// Starting a workflow (formType=1 + workflowCode, no workItem) requires
|
|
1866
|
+
// a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
|
|
1867
|
+
// (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
|
|
1868
|
+
replayToken = await getReplayToken(session);
|
|
1860
1869
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1870
|
+
if (hasWorkflowCode)
|
|
1871
|
+
payload.workflowCode = explicitWorkflowCode;
|
|
1872
|
+
if (replayToken)
|
|
1873
|
+
payload.replayToken = replayToken;
|
|
1874
|
+
if (typeof args.workflowInstanceId === "string")
|
|
1875
|
+
payload.workflowInstanceId = args.workflowInstanceId;
|
|
1876
|
+
if (typeof args.workItemId === "string")
|
|
1877
|
+
payload.workItemId = args.workItemId;
|
|
1878
|
+
if (args.approval && typeof args.approval === "object")
|
|
1879
|
+
payload.approval = args.approval;
|
|
1880
|
+
if (typeof args.runMode === "string")
|
|
1881
|
+
payload.runMode = args.runMode;
|
|
1882
|
+
if (typeof args.isSimulative === "boolean")
|
|
1883
|
+
payload.isSimulative = args.isSimulative;
|
|
1884
|
+
// Approval submits are resolved against the LOGGED-IN user (r13: six
|
|
1885
|
+
// blind 40005s). Name the boundary before the write instead of letting the
|
|
1886
|
+
// platform answer "权限不足", which reads like a misconfiguration.
|
|
1887
|
+
if (hasWorkItem)
|
|
1888
|
+
await diagnoseApprovalSubmitIdentity(session, { workflowInstanceId: args.workflowInstanceId, workItemId: args.workItemId });
|
|
1889
|
+
let performed;
|
|
1890
|
+
try {
|
|
1891
|
+
performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
|
|
1892
|
+
path: ENDPOINTS.runtimeFormSubmit,
|
|
1893
|
+
method: "POST",
|
|
1894
|
+
body: payload,
|
|
1895
|
+
}), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
|
|
1875
1896
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
// almost always needs the NEXT pending todo — bench52/53 paid an extra
|
|
1900
|
-
// `workitem.list` wire call at every approval level because the submit
|
|
1901
|
-
// response carried only the object id. Attach the pending work items for
|
|
1902
|
-
// this instance while the flow is still in flight (best-effort: a lookup
|
|
1903
|
-
// failure must never fail the submit).
|
|
1904
|
-
if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
|
|
1905
|
-
try {
|
|
1906
|
-
const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
|
|
1907
|
-
const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
|
|
1908
|
-
workItemId: wireString(item.id),
|
|
1909
|
-
activityCode: wireString(item.activityCode),
|
|
1910
|
-
activityName: wireString(item.activityName),
|
|
1911
|
-
state: wireString(item.state),
|
|
1912
|
-
}));
|
|
1913
|
-
if (nextWorkItems.length > 0) {
|
|
1914
|
-
readback.nextWorkItems = nextWorkItems;
|
|
1915
|
-
// The submit RESPONSE's own workItemId is null (it names the item
|
|
1916
|
-
// just completed, not the next one). Expose the fully-formed args
|
|
1917
|
-
// for the NEXT approval so a multi-level flow advances without an
|
|
1918
|
-
// extra workitem.list round or guessing which id to use.
|
|
1919
|
-
readback.nextSubmit = {
|
|
1920
|
-
schemaCode: args.schemaCode,
|
|
1921
|
-
workItemId: nextWorkItems[0].workItemId,
|
|
1922
|
-
workflowInstanceId: effectiveInstanceId,
|
|
1923
|
-
bizObjectId: objectId,
|
|
1924
|
-
note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
|
|
1925
|
-
};
|
|
1926
|
-
}
|
|
1927
|
-
else if (sequenceStatus === "PROCESSING") {
|
|
1928
|
-
// r4-training 2026-10-20: an unresolvable participant (role with no
|
|
1929
|
-
// members, member not attached to the originator's department, or a
|
|
1930
|
-
// participant function that is not registered) makes the engine
|
|
1931
|
-
// create NO next work item while the submit still answers errCode 0.
|
|
1932
|
-
// Without this line the caller cannot tell "flow advanced" from
|
|
1933
|
-
// "flow silently stalled" — surface it instead of staying quiet.
|
|
1934
|
-
readback.flowWarning = {
|
|
1935
|
-
code: "flow-no-next-workitem",
|
|
1936
|
-
message: "提交成功但该实例没有生成下一节点待办,流程停在 PROCESSING。常见原因:审批参与人解析为空(角色无成员 / 成员未挂发起人部门 / 参与人函数未注册)。",
|
|
1937
|
-
checks: [
|
|
1938
|
-
`omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'(看每个节点的 status 与 participants;无 status=1 即停住)`,
|
|
1939
|
-
"容器日志搜 GetParticipantInfo error / CustomParticipleMethod{...} -> []",
|
|
1940
|
-
"参与人优先用参与者对象数组(角色 id);内置函数只认 FindUserByRole(org, roleCode[, order])",
|
|
1941
|
-
],
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
await diagnoseDbException(session, args.schemaCode, error);
|
|
1899
|
+
// r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
|
|
1900
|
+
// the same numeric code as TEST_CONNECTION_FAIL, so only the text
|
|
1901
|
+
// separates them). The platform rate-limits per WORK ITEM, so a different
|
|
1902
|
+
// payload or a longer wait changes nothing. The actionable question is
|
|
1903
|
+
// "did the earlier submit land?", which the todo list answers.
|
|
1904
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.replayTokenErrcode) {
|
|
1905
|
+
const stillMine = await workItemStillOpen(session, args.workflowInstanceId, args.workItemId).catch(() => null);
|
|
1906
|
+
throw new ToolRefusalError({
|
|
1907
|
+
status: "refused",
|
|
1908
|
+
blocker: stillMine === false ? "submit-workitem-already-finished" : "submit-rate-limited",
|
|
1909
|
+
schemaCode: args.schemaCode,
|
|
1910
|
+
workItemId: args.workItemId ?? null,
|
|
1911
|
+
workflowInstanceId: args.workflowInstanceId ?? null,
|
|
1912
|
+
nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
|
|
1913
|
+
message: stillMine === false
|
|
1914
|
+
? `100005「频繁重复提交!」在这里的真实含义:待办 ${String(args.workItemId ?? "(未提供)")} **已经提交过了**。限流按 workItem 维度,换载荷、等待、换 requestId 都无效——这不是失败。用 workitem.list 看下一级待办,或 data.load 看 sequenceStatus,不要重试本次提交。`
|
|
1915
|
+
: `100005 = 平台 SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68,与"测试连接不通过"共用该数值码,只能靠文案区分)。` +
|
|
1916
|
+
`两种成因:① 该待办已经提交过;② startWorkflow 路径的 replayToken 未刷新。` +
|
|
1917
|
+
`${stillMine === null ? "(待办状态读取失败,无法判定)" : stillMine ? "该待办仍在——多半是限流窗口未过。" : ""}` +
|
|
1918
|
+
`先 workitem.list 核对当前待办,确认没提交成功再等一会儿重试;改 payload 不会绕过它。`,
|
|
1919
|
+
});
|
|
1944
1920
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1921
|
+
throw error;
|
|
1922
|
+
}
|
|
1923
|
+
const performedData = (performed.data ?? {});
|
|
1924
|
+
let readback = { formType };
|
|
1925
|
+
if (formType === "2") {
|
|
1926
|
+
const objectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
|
|
1927
|
+
if (!objectId) {
|
|
1928
|
+
// Source contract: submitBizSheet ALWAYS returns objectId.
|
|
1929
|
+
// Absence is a transport / protocol deviation — never paper over
|
|
1930
|
+
// it with a fabricated sequenceStatus:COMPLETED.
|
|
1931
|
+
return {
|
|
1932
|
+
performed: { errcode: performed.errcode, sheetCode, resolvedFrom, formType, draftObjectId },
|
|
1933
|
+
readback: { formType, sequenceStatus: null, missingObjectId: true },
|
|
1934
|
+
};
|
|
1947
1935
|
}
|
|
1936
|
+
if (targetObjectId && objectId !== targetObjectId) {
|
|
1937
|
+
throw new ToolRefusalError({
|
|
1938
|
+
status: "refused",
|
|
1939
|
+
blocker: "submit-target-id-changed",
|
|
1940
|
+
expectedBizObjectId: targetObjectId,
|
|
1941
|
+
actualBizObjectId: objectId,
|
|
1942
|
+
message: "runtime/form/submit returned a different object id; refusing to accept a new row",
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
const load = await wireCall(session, {
|
|
1946
|
+
path: ENDPOINTS.runtimeFormLoad,
|
|
1947
|
+
query: { schemaCode: args.schemaCode, sheetCode, objectId },
|
|
1948
|
+
});
|
|
1949
|
+
const loaded = (load.data ?? {});
|
|
1950
|
+
const record = normalizeLoadedRecord(loaded) ?? {};
|
|
1951
|
+
const sequenceStatus = extractSequenceStatus(record);
|
|
1952
|
+
readback = {
|
|
1953
|
+
...readback,
|
|
1954
|
+
objectId,
|
|
1955
|
+
schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
|
|
1956
|
+
formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
|
|
1957
|
+
sequenceStatus,
|
|
1958
|
+
...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
|
|
1959
|
+
// r28b F2(真机 2026-11-05 复现):模型没配业务流水号时,平台把
|
|
1960
|
+
// `sequenceNo` 直接回填成 objectId 本身——32 位 hex 长得像单号,
|
|
1961
|
+
// 读回却没法区分「真的生成了单号」和「回填了行 id」。
|
|
1962
|
+
// 相等即标注,不等时不加(不猜)。
|
|
1963
|
+
...(typeof record.sequenceNo === "string" && String(record.sequenceNo) === objectId
|
|
1964
|
+
? { sequenceNoSource: "object-id", sequenceNoNote: "本模型未配置业务流水号:sequenceNo 回填的是 objectId 本身(不是生成的单号)。要单号请在模型上配序列号字段。" }
|
|
1965
|
+
: {}),
|
|
1966
|
+
submittedData: projectReadbackRecord(record, args),
|
|
1967
|
+
};
|
|
1948
1968
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
//
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1969
|
+
else {
|
|
1970
|
+
const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
|
|
1971
|
+
if (targetObjectId && returnedObjectId !== targetObjectId) {
|
|
1972
|
+
throw new ToolRefusalError({
|
|
1973
|
+
status: "refused",
|
|
1974
|
+
blocker: "submit-target-id-changed",
|
|
1975
|
+
expectedBizObjectId: targetObjectId,
|
|
1976
|
+
actualBizObjectId: returnedObjectId,
|
|
1977
|
+
message: "runtime/form/submit returned a different object id; refusing to accept a new row",
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
const workflowInstanceId = typeof performedData.workflowInstanceId === "string" ? performedData.workflowInstanceId : null;
|
|
1981
|
+
const objectId = returnedObjectId ?? targetObjectId;
|
|
1982
|
+
// Workflow engine status writes can lag the submit response
|
|
1983
|
+
// (autoCommit): an immediate authoritative form/load may still show
|
|
1984
|
+
// the pre-submit status. Poll briefly when a workflow submit reads
|
|
1985
|
+
// back unchanged — a genuinely unchanged mid-flow status costs one
|
|
1986
|
+
// short bounded wait, a lagged boundary transition resolves.
|
|
1987
|
+
const preSubmitStatus = preSubmitExisting ? extractSequenceStatus(preSubmitExisting) : null;
|
|
1988
|
+
let loaded = objectId ? await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId) : null;
|
|
1989
|
+
let sequenceStatus = extractSequenceStatus(loaded);
|
|
1990
|
+
for (let attempt = 0; objectId && sequenceStatus !== null && sequenceStatus === preSubmitStatus && attempt < 3; attempt += 1) {
|
|
1991
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1992
|
+
loaded = await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId);
|
|
1993
|
+
sequenceStatus = extractSequenceStatus(loaded);
|
|
1994
|
+
}
|
|
1995
|
+
const loadedRecord = normalizeLoadedRecord(loaded);
|
|
1996
|
+
// The platform's submit response often omits workflowInstanceId (the fake
|
|
1997
|
+
// start path returns only {result, objectId}); the authoritative loaded
|
|
1998
|
+
// row carries it. Prefer the response, then the row.
|
|
1999
|
+
const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
|
|
2000
|
+
// An approval submit returns the status + the fields the caller submitted,
|
|
2001
|
+
// not the whole row: r12's 25 approvals were the largest read face at
|
|
2002
|
+
// 55.6 KB. Explicit summary/fields/full keep their contract.
|
|
2003
|
+
// Pure-instance submits (workflowInstanceId without workItemId) go
|
|
2004
|
+
// through the same engine path and deserve the same delta (item 11).
|
|
2005
|
+
const approvalDelta = (hasWorkItem || hasInstance) && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
|
|
2006
|
+
readback = {
|
|
2007
|
+
...readback,
|
|
2008
|
+
workflowInstanceId: effectiveInstanceId,
|
|
2009
|
+
objectId,
|
|
2010
|
+
workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
|
|
2011
|
+
sequenceStatus,
|
|
2012
|
+
submittedData: approvalDelta ? projectApprovalReadback(loadedRecord, Object.keys(formData ?? {})) : projectReadbackRecord(loadedRecord, args),
|
|
2013
|
+
...(approvalDelta
|
|
2014
|
+
? { readbackShape: "approval-delta", readbackHint: "审批提交默认只回 sequenceStatus + 本次提交的字段;要整条记录(含子表/系统字段)加 full:true,要指定字段加 fields:[...]" }
|
|
2015
|
+
: {}),
|
|
2016
|
+
};
|
|
2017
|
+
// Multi-level approval: after a submit advances the flow, the caller
|
|
2018
|
+
// almost always needs the NEXT pending todo — bench52/53 paid an extra
|
|
2019
|
+
// `workitem.list` wire call at every approval level because the submit
|
|
2020
|
+
// response carried only the object id. Attach the pending work items for
|
|
2021
|
+
// this instance while the flow is still in flight (best-effort: a lookup
|
|
2022
|
+
// failure must never fail the submit).
|
|
2023
|
+
if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
|
|
2024
|
+
try {
|
|
2025
|
+
const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
|
|
2026
|
+
const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
|
|
2027
|
+
workItemId: wireString(item.id),
|
|
2028
|
+
activityCode: wireString(item.activityCode),
|
|
2029
|
+
activityName: wireString(item.activityName),
|
|
2030
|
+
state: wireString(item.state),
|
|
2031
|
+
}));
|
|
2032
|
+
if (nextWorkItems.length > 0) {
|
|
2033
|
+
readback.nextWorkItems = nextWorkItems;
|
|
2034
|
+
// The submit RESPONSE's own workItemId is null (it names the item
|
|
2035
|
+
// just completed, not the next one). Expose the fully-formed args
|
|
2036
|
+
// for the NEXT approval so a multi-level flow advances without an
|
|
2037
|
+
// extra workitem.list round or guessing which id to use.
|
|
2038
|
+
readback.nextSubmit = {
|
|
2039
|
+
schemaCode: args.schemaCode,
|
|
2040
|
+
workItemId: nextWorkItems[0].workItemId,
|
|
2041
|
+
workflowInstanceId: effectiveInstanceId,
|
|
2042
|
+
bizObjectId: objectId,
|
|
2043
|
+
note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
else if (sequenceStatus === "PROCESSING") {
|
|
2047
|
+
// r4-training 2026-10-20: an unresolvable participant (role with no
|
|
2048
|
+
// members, member not attached to the originator's department, or a
|
|
2049
|
+
// participant function that is not registered) makes the engine
|
|
2050
|
+
// create NO next work item while the submit still answers errCode 0.
|
|
2051
|
+
// Without this line the caller cannot tell "flow advanced" from
|
|
2052
|
+
// "flow silently stalled" — surface it instead of staying quiet.
|
|
2053
|
+
readback.flowWarning = {
|
|
2054
|
+
code: "flow-no-next-workitem",
|
|
2055
|
+
message: "提交成功但该实例没有生成下一节点待办,流程停在 PROCESSING。常见原因:审批参与人解析为空(角色无成员 / 成员未挂发起人部门 / 参与人函数未注册)。",
|
|
2056
|
+
checks: [
|
|
2057
|
+
`omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'(看每个节点的 status 与 participants;无 status=1 即停住)`,
|
|
2058
|
+
"容器日志搜 GetParticipantInfo error / CustomParticipleMethod{...} -> []",
|
|
2059
|
+
"参与人优先用参与者对象数组(角色 id);内置函数只认 FindUserByRole(org, roleCode[, order])",
|
|
2060
|
+
],
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
1977
2063
|
}
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
|
|
1981
|
-
requiredFields: validation.requiredFields,
|
|
1982
|
-
missingRequired: validation.missingRequired,
|
|
1983
|
-
...(validation.missingRequired.length > 0
|
|
1984
|
-
? {
|
|
1985
|
-
portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
|
|
2064
|
+
catch {
|
|
2065
|
+
// best-effort
|
|
1986
2066
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return {
|
|
2070
|
+
performed: {
|
|
2071
|
+
errcode: performed.errcode,
|
|
2072
|
+
sheetCode,
|
|
2073
|
+
resolvedFrom,
|
|
2074
|
+
formType,
|
|
2075
|
+
// r28b F2 追问(真机 2026-11-05):这里只有 `draftObjectId`,而**参数名**、
|
|
2076
|
+
// data.save/data.delete/data.update 的读回、以及 data.load 全叫 `bizObjectId`。
|
|
2077
|
+
// 同一个 id 换三个名字,调用方要把 submit 的回执接到下一步(读回/删除)
|
|
2078
|
+
// 时得先知道这个别名。两个键都给(draftObjectId 保留不破既有脚本)。
|
|
2079
|
+
...(draftObjectId ? { bizObjectId: draftObjectId, draftObjectId } : {}),
|
|
2080
|
+
// The workflow was auto-detected and started (formType=1) rather than
|
|
2081
|
+
// saved as a bare sheet row — surfaced so the caller knows the row is
|
|
2082
|
+
// now a PROCESSING workflow instance, not an orphan sheet record.
|
|
2083
|
+
...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
|
|
2084
|
+
},
|
|
2085
|
+
readback: {
|
|
2086
|
+
...readback,
|
|
2087
|
+
// Portal-required semantics are NOT enforced by the REST submit path;
|
|
2088
|
+
// say exactly what the tool checked so `ok` is never mistaken for
|
|
2089
|
+
// "the portal would have accepted this" (dogfood R3). This is a
|
|
2090
|
+
// SEPARATE concern from server-side business rules (which DO run on
|
|
2091
|
+
// submit) — name that so `portalRulesExecuted:false` is not misread as
|
|
2092
|
+
// "no rule ran" (dogfood R2).
|
|
2093
|
+
// r18b: an approval submit that omits `agree` is treated as APPROVE by
|
|
2094
|
+
// the platform (`agree: args.agree !== false`). That default is right
|
|
2095
|
+
// for the portal button, but for an API caller "I forgot the flag" and
|
|
2096
|
+
// "I approve" must not look identical in the receipt.
|
|
2097
|
+
...(hasWorkItem && args.agree === undefined
|
|
2098
|
+
? {
|
|
2099
|
+
approvalNote: "本次是审批提交但未传 agree——平台按**通过**处理(顶层 agree 缺省 true)。要驳回必须显式 agree:false;approval.agree 不生效。",
|
|
2100
|
+
}
|
|
2101
|
+
: {}),
|
|
2102
|
+
portalRulesExecuted: false,
|
|
2103
|
+
portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
|
|
2104
|
+
requiredFields: validation.requiredFields,
|
|
2105
|
+
missingRequired: validation.missingRequired,
|
|
2106
|
+
...(validation.missingRequired.length > 0
|
|
2107
|
+
? {
|
|
2108
|
+
portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
|
|
2109
|
+
}
|
|
2110
|
+
: {}),
|
|
2111
|
+
// Round 16b (dogfood R1-R3): a submit can leave BOTH a draft row and a
|
|
2112
|
+
// committed row, and deleting only the committed id leaves the draft
|
|
2113
|
+
// behind (data.list still counts it). Name both ids so cleanup does not
|
|
2114
|
+
// silently miss one. Round 17 (dogfood R2): when there is only ONE row
|
|
2115
|
+
// (a workflow submit advances the same row), say so explicitly — the
|
|
2116
|
+
// absence of a hint was itself read as an evidence gap.
|
|
2117
|
+
...(typeof readback.objectId === "string"
|
|
2118
|
+
? draftObjectId && readback.objectId !== draftObjectId
|
|
2119
|
+
? { cleanupHint: `本次提交产生 draft(${draftObjectId}) 与 committed(${String(readback.objectId)}) 两条记录;验收后两条都要 data.delete,直到 data.list total=0。` }
|
|
2120
|
+
: { cleanupHint: `本次提交只有一条记录(${String(readback.objectId)});验收后 data.delete 该 id,直到 data.list total=0。` }
|
|
2121
|
+
: {}),
|
|
2122
|
+
},
|
|
2123
|
+
};
|
|
2124
|
+
},
|
|
2001
2125
|
});
|
|
2002
2126
|
}
|
|
2003
2127
|
//# sourceMappingURL=runtime-data.js.map
|