@stalfh233/omc-cli 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +293 -3
- package/coverage/m1-coverage-manifest-v1.json +1278 -0
- package/dist/approval-token.js +102 -0
- package/dist/args.js +25 -0
- package/dist/artifacts.js +119 -0
- package/dist/bench/call-face-eval.js +256 -0
- package/dist/bench/context-attribution.js +151 -0
- package/dist/bench/discovery-cost-eval.js +230 -0
- package/dist/bench/driver.js +81 -0
- package/dist/bench/evals.js +236 -0
- package/dist/bench/fake-http-server.js +65 -0
- package/dist/bench/instrument.js +87 -0
- package/dist/bench/intent-face-eval.js +343 -0
- package/dist/bench/run.js +166 -0
- package/dist/bench/scenario.js +343 -0
- package/dist/bench/types.js +76 -0
- package/dist/bi-wire.js +41 -0
- package/dist/bizservice-config.js +581 -0
- package/dist/call.js +153 -0
- package/dist/capability-absences.js +23 -0
- package/dist/capability-overview.js +492 -0
- package/dist/capability-shape.js +154 -0
- package/dist/cli-contract.js +70 -0
- package/dist/cli-output.js +73 -0
- package/dist/cli.js +1418 -0
- package/dist/code-rules.js +69 -0
- package/dist/command-transport.js +155 -0
- package/dist/config-store.js +195 -0
- package/dist/context.js +21 -0
- package/dist/contract-consistency.js +66 -0
- package/dist/contract-resources.js +62 -0
- package/dist/coverage-consistency.js +62 -0
- package/dist/coverage-registry.js +76 -0
- package/dist/coverage.js +130 -0
- package/dist/data-list-filter.js +114 -0
- package/dist/discovery.js +390 -0
- package/dist/endpoints.js +154 -0
- package/dist/environment-policy.js +26 -0
- package/dist/execution-metadata.js +1092 -0
- package/dist/fake/app.js +45 -0
- package/dist/fake/b2-registration.js +594 -0
- package/dist/fake/businessrule.js +242 -0
- package/dist/fake/datarule.js +72 -0
- package/dist/fake/dictionary.js +108 -0
- package/dist/fake/environment.js +30 -0
- package/dist/fake/field.js +167 -0
- package/dist/fake/form.js +101 -0
- package/dist/fake/index.js +121 -0
- package/dist/fake/list-view.js +289 -0
- package/dist/fake/model.js +136 -0
- package/dist/fake/online-js.js +18 -0
- package/dist/fake/report.js +253 -0
- package/dist/fake/routes.js +47 -0
- package/dist/fake/rule-lifecycle.js +37 -0
- package/dist/fake/runtime-data.js +367 -0
- package/dist/fake/state.js +67 -0
- package/dist/fake/workflow.js +364 -0
- package/dist/field-change.js +200 -0
- package/dist/field-families.js +896 -0
- package/dist/form-layout.js +111 -0
- package/dist/form-support.js +821 -0
- package/dist/goal-routes.js +468 -0
- package/dist/governed-execution.js +87 -0
- package/dist/human-summary.js +212 -0
- package/dist/identity.js +62 -0
- package/dist/intent/baseline.js +57 -0
- package/dist/intent/capabilities/bizservice.js +274 -0
- package/dist/intent/capabilities/businessrule.js +493 -0
- package/dist/intent/capabilities/datarule.js +187 -0
- package/dist/intent/capabilities/field.js +545 -0
- package/dist/intent/capabilities/form.js +136 -0
- package/dist/intent/capabilities/index.js +64 -0
- package/dist/intent/capabilities/listview.js +157 -0
- package/dist/intent/capabilities/model.js +100 -0
- package/dist/intent/capabilities/onlinejs.js +108 -0
- package/dist/intent/capabilities/report.js +355 -0
- package/dist/intent/capabilities/workflow.js +458 -0
- package/dist/intent/capability.js +6 -0
- package/dist/intent/cli.js +91 -0
- package/dist/intent/compare.js +56 -0
- package/dist/intent/compiler.js +79 -0
- package/dist/intent/dsl.js +129 -0
- package/dist/intent/plan-file.js +63 -0
- package/dist/intent/readback.js +65 -0
- package/dist/intent/schema.js +158 -0
- package/dist/intent/validation.js +30 -0
- package/dist/intent/yaml.js +315 -0
- package/dist/json-column.js +68 -0
- package/dist/lanes/app-contract.js +95 -0
- package/dist/lanes/app-coverage.js +16 -0
- package/dist/lanes/app.js +174 -0
- package/dist/lanes/apply-changes.js +231 -0
- package/dist/lanes/b2-registration-contract.js +292 -0
- package/dist/lanes/b2-registration-coverage.js +48 -0
- package/dist/lanes/b2-registration.js +1187 -0
- package/dist/lanes/businessrule-contract.js +232 -0
- package/dist/lanes/businessrule-coverage.js +16 -0
- package/dist/lanes/businessrule.js +221 -0
- package/dist/lanes/contract-support.js +65 -0
- package/dist/lanes/coverage-declaration.js +9 -0
- package/dist/lanes/datarule-contract.js +155 -0
- package/dist/lanes/datarule-coverage.js +19 -0
- package/dist/lanes/datarule-protocol.js +308 -0
- package/dist/lanes/datarule.js +818 -0
- package/dist/lanes/dictionary-contract.js +78 -0
- package/dist/lanes/dictionary-coverage.js +24 -0
- package/dist/lanes/dictionary.js +235 -0
- package/dist/lanes/environment-contract.js +70 -0
- package/dist/lanes/environment-coverage.js +14 -0
- package/dist/lanes/environment.js +163 -0
- package/dist/lanes/field-contract.js +223 -0
- package/dist/lanes/field-coverage.js +27 -0
- package/dist/lanes/field.js +374 -0
- package/dist/lanes/form-contract.js +97 -0
- package/dist/lanes/form-coverage.js +16 -0
- package/dist/lanes/form.js +185 -0
- package/dist/lanes/lane-ids.js +34 -0
- package/dist/lanes/list-view-contract.js +175 -0
- package/dist/lanes/list-view-coverage.js +20 -0
- package/dist/lanes/list-view-shapes.js +1207 -0
- package/dist/lanes/list-view.js +578 -0
- package/dist/lanes/meta-contract.js +85 -0
- package/dist/lanes/meta.js +255 -0
- package/dist/lanes/model-contract.js +168 -0
- package/dist/lanes/model-coverage.js +20 -0
- package/dist/lanes/model.js +986 -0
- package/dist/lanes/online-js-contract.js +99 -0
- package/dist/lanes/online-js-coverage.js +28 -0
- package/dist/lanes/online-js.js +127 -0
- package/dist/lanes/report-contract.js +144 -0
- package/dist/lanes/report-coverage.js +21 -0
- package/dist/lanes/report.js +476 -0
- package/dist/lanes/rule-graph.js +1846 -0
- package/dist/lanes/rule-lifecycle-contract.js +92 -0
- package/dist/lanes/rule-lifecycle-coverage.js +20 -0
- package/dist/lanes/rule-lifecycle.js +176 -0
- package/dist/lanes/runtime-data-contract.js +264 -0
- package/dist/lanes/runtime-data-coverage.js +25 -0
- package/dist/lanes/runtime-data.js +1054 -0
- package/dist/lanes/workflow-contract.js +223 -0
- package/dist/lanes/workflow-coverage.js +40 -0
- package/dist/lanes/workflow.js +1813 -0
- package/dist/online-js-layout.js +58 -0
- package/dist/online-js-source.js +276 -0
- package/dist/package-tool.js +51 -0
- package/dist/package.js +73 -0
- package/dist/plan.js +73 -0
- package/dist/read.js +144 -0
- package/dist/redact.js +28 -0
- package/dist/rule-support.js +587 -0
- package/dist/runtime-support.js +134 -0
- package/dist/server.js +92 -0
- package/dist/session-manager.js +30 -0
- package/dist/session.js +149 -0
- package/dist/skills.js +112 -0
- package/dist/support.js +98 -0
- package/dist/tool-types.js +127 -0
- package/dist/tools.js +59 -0
- package/dist/usage-log.js +197 -0
- package/dist/wire.js +287 -0
- package/dist/workflow-support.js +99 -0
- package/dist/write-lease.js +26 -0
- package/dist/write-lock.js +109 -0
- package/dist/write.js +176 -0
- package/dist/zip.js +156 -0
- package/docs/tool-surface-map.md +42 -0
- package/package.json +71 -6
- package/skills/omc-acceptance-criteria.md +47 -0
- package/skills/omc-business-configuration.md +257 -0
- package/skills/omc-capabilities.md +197 -0
- package/skills/omc-capability-scouting.md +55 -0
- package/skills/omc-config-draft-review.md +171 -0
- package/skills/omc-five-piece-flow.md +35 -0
- package/skills/omc-glossary.md +86 -0
- package/skills/omc-refusals.md +110 -0
- package/skills/omc-requirement-analysis.md +161 -0
- package/skills/omc-requirement-vocabulary.md +51 -0
- package/skills/omc-start-here.md +82 -0
- package/skills/omc-tool-selection.md +119 -0
- package/skills/omc-write-hazards.md +87 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { FilterDslError, injectPropertyTypes, toQueryCondition } from "../data-list-filter.js";
|
|
4
|
+
import { ENDPOINTS } from "../endpoints.js";
|
|
5
|
+
import { capabilityArg, environmentArg } from "../args.js";
|
|
6
|
+
import { governedRead } from "../read.js";
|
|
7
|
+
import { ToolInputError, ToolRefusalError } from "../tool-types.js";
|
|
8
|
+
import { writeTool } from "../write.js";
|
|
9
|
+
import { defineTool } from "../tool-types.js";
|
|
10
|
+
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
11
|
+
import { CHILD_TABLE_PROPERTY_TYPE, runtimeWriteWithAppMapping } from "../runtime-support.js";
|
|
12
|
+
import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
|
|
13
|
+
import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
|
|
14
|
+
const RUNTIME_LANE = RUNTIME_DATA_LANE_CONTRACT.lane;
|
|
15
|
+
const RUNTIME_TOOLS = RUNTIME_DATA_LANE_CONTRACT.tools;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a model's single published workflow code, or null when the model has
|
|
18
|
+
* no workflow. A workflow-backed data submit must START the workflow
|
|
19
|
+
* (runtime/form/submit formType=1 + workflowCode), not merely save a sheet row
|
|
20
|
+
* (formType=2): only the former creates the workflow instance and runs node
|
|
21
|
+
* routing / approval. Detection via workflow/isworkflow (WorkflowTemplateController
|
|
22
|
+
* :1338), code via workflow/getWorkflow (returns the published template).
|
|
23
|
+
*/
|
|
24
|
+
async function resolvePublishedWorkflowCode(session, schemaCode) {
|
|
25
|
+
const isWorkflow = (await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode } })).data === true;
|
|
26
|
+
if (!isWorkflow)
|
|
27
|
+
return null;
|
|
28
|
+
const template = (await wireCall(session, { path: ENDPOINTS.workflowGetBySchema, query: { schemaCode } })).data;
|
|
29
|
+
const code = template && typeof template.workflowCode === "string" && template.workflowCode.length > 0 ? template.workflowCode : null;
|
|
30
|
+
return code;
|
|
31
|
+
}
|
|
32
|
+
function extractBizObjectPage(value) {
|
|
33
|
+
const root = value && typeof value === "object" ? value : {};
|
|
34
|
+
const candidate = root.bizObjectPage && typeof root.bizObjectPage === "object" ? root.bizObjectPage : root;
|
|
35
|
+
const content = Array.isArray(candidate.content) ? candidate.content.filter((row) => !!row && typeof row === "object") : [];
|
|
36
|
+
return {
|
|
37
|
+
total: typeof candidate.totalElements === "number" ? candidate.totalElements : 0,
|
|
38
|
+
totalPages: typeof candidate.totalPages === "number" ? candidate.totalPages : 0,
|
|
39
|
+
page: typeof candidate.page === "number" ? candidate.page : undefined,
|
|
40
|
+
size: typeof candidate.size === "number" ? candidate.size : undefined,
|
|
41
|
+
content,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the active form's sheetCode. Priority order: explicit caller arg
|
|
46
|
+
* (verbatim) > default-form sheetCode from bizsheet/get > schemaCode
|
|
47
|
+
* fallback. Returns the chosen sheetCode plus a provenance tag so callers
|
|
48
|
+
* can report which tier won.
|
|
49
|
+
*/
|
|
50
|
+
async function resolveFormSheetCode(session, args) {
|
|
51
|
+
const explicit = typeof args.sheetCode === "string" && args.sheetCode.trim() ? args.sheetCode.trim() : "";
|
|
52
|
+
if (explicit)
|
|
53
|
+
return { sheetCode: explicit, resolvedFrom: "explicit" };
|
|
54
|
+
try {
|
|
55
|
+
const result = await wireCall(session, {
|
|
56
|
+
path: ENDPOINTS.sheetGet,
|
|
57
|
+
query: { schemaCode: args.schemaCode, sheetCode: args.schemaCode },
|
|
58
|
+
});
|
|
59
|
+
const data = result.data;
|
|
60
|
+
const candidate = data?.code ?? data?.sheetCode;
|
|
61
|
+
if (typeof candidate === "string" && candidate) {
|
|
62
|
+
return { sheetCode: candidate, resolvedFrom: "default-form" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// fall through to schemaCode fallback
|
|
67
|
+
}
|
|
68
|
+
return { sheetCode: args.schemaCode, resolvedFrom: "fallback" };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read the record status from a form/load payload. The live face nests it as
|
|
72
|
+
* `bizObject.data.sequenceStatus`; the flat fake form carries it top-level.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* Read the workflow instance id from a form/load payload. The live face may
|
|
76
|
+
* carry it top-level, under `bizObject`, or under `bizObject.data` — the submit
|
|
77
|
+
* RESPONSE often omits it entirely (bench53: start/approval submits answered
|
|
78
|
+
* workflowInstanceId:null even though the instance existed), so the loaded row
|
|
79
|
+
* is the fallback the next-todo lookup depends on.
|
|
80
|
+
*/
|
|
81
|
+
function extractWorkflowInstanceId(readback) {
|
|
82
|
+
if (!readback)
|
|
83
|
+
return null;
|
|
84
|
+
const candidates = [
|
|
85
|
+
readback.workflowInstanceId,
|
|
86
|
+
readback.bizObject?.workflowInstanceId,
|
|
87
|
+
readback.bizObject?.data?.workflowInstanceId,
|
|
88
|
+
];
|
|
89
|
+
for (const candidate of candidates)
|
|
90
|
+
if (typeof candidate === "string" && candidate.length > 0)
|
|
91
|
+
return candidate;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function extractSequenceStatus(readback) {
|
|
95
|
+
if (!readback)
|
|
96
|
+
return null;
|
|
97
|
+
if (typeof readback.sequenceStatus === "string")
|
|
98
|
+
return readback.sequenceStatus;
|
|
99
|
+
const nested = readback.bizObject;
|
|
100
|
+
if (nested && typeof nested.sequenceStatus === "string")
|
|
101
|
+
return nested.sequenceStatus;
|
|
102
|
+
const nestedData = nested?.data;
|
|
103
|
+
if (nestedData && typeof nestedData.sequenceStatus === "string")
|
|
104
|
+
return nestedData.sequenceStatus;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Engine-owned record keys that must never be echoed back in a submit
|
|
109
|
+
* payload: persisting the loaded `sequenceStatus` pins the row to its
|
|
110
|
+
* pre-submit status (live evidence ticket 17 — the portal client only
|
|
111
|
+
* submits form fields, never the status column). The key list is the
|
|
112
|
+
* contract constant the fake's merge also consumes (ticket 20).
|
|
113
|
+
*/
|
|
114
|
+
function stripEngineOwnedKeys(data) {
|
|
115
|
+
const stripped = {};
|
|
116
|
+
for (const [key, value] of Object.entries(data)) {
|
|
117
|
+
if (ENGINE_OWNED_KEYS.includes(key))
|
|
118
|
+
continue;
|
|
119
|
+
stripped[key] = value;
|
|
120
|
+
}
|
|
121
|
+
return stripped;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a form/load payload to the flat record contract the tool surface
|
|
125
|
+
* promises: business fields plus `id`/`sequenceStatus`. The live face returns
|
|
126
|
+
* a FormLoadVO nesting them under `bizObject.data`; the flat fake form
|
|
127
|
+
* carries them top-level. A null payload stays null (absence).
|
|
128
|
+
*/
|
|
129
|
+
function normalizeLoadedRecord(payload) {
|
|
130
|
+
if (!payload)
|
|
131
|
+
return null;
|
|
132
|
+
const bizObject = payload.bizObject;
|
|
133
|
+
if (!bizObject || typeof bizObject !== "object")
|
|
134
|
+
return payload;
|
|
135
|
+
const record = bizObject;
|
|
136
|
+
const fields = record.data && typeof record.data === "object"
|
|
137
|
+
? { ...record.data }
|
|
138
|
+
: {};
|
|
139
|
+
if (typeof record.id === "string")
|
|
140
|
+
fields.id = record.id;
|
|
141
|
+
if (typeof record.sequenceStatus === "string")
|
|
142
|
+
fields.sequenceStatus = record.sequenceStatus;
|
|
143
|
+
return fields;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Engine/system record keys that dominate a raw form/load payload but carry no
|
|
147
|
+
* business meaning for the caller: the `owner`/`creater`/`modifier` staff blobs
|
|
148
|
+
* and the owning-department objects. Each is a ~30-key object full of nulls
|
|
149
|
+
* (live 2026-09-25: a 23-key record was ~3 KB, of which these were ~2.5 KB).
|
|
150
|
+
* They stay reachable via `full:true` (raw record) and in the spilled artifact.
|
|
151
|
+
*/
|
|
152
|
+
const RUNTIME_ENGINE_RECORD_KEYS = new Set([
|
|
153
|
+
"owner", "creater", "modifier", "createdDeptId", "ownerDeptId", "ownerDeptQueryCode",
|
|
154
|
+
"createdBy", "modifiedBy",
|
|
155
|
+
]);
|
|
156
|
+
/**
|
|
157
|
+
* Default-compact a runtime record: drop only KNOWN engine noise — `*_` keys are
|
|
158
|
+
* flow-engine internals (`activityName_`, `participants_`, …) and the staff/dept
|
|
159
|
+
* blobs above. Every business value is kept verbatim, INCLUDING null/empty (a
|
|
160
|
+
* null business field is a real, readable state; dropping it would make it
|
|
161
|
+
* indistinguishable from an absent field). `full:true` returns the untouched
|
|
162
|
+
* record.
|
|
163
|
+
*
|
|
164
|
+
* Round 13: the filter now recurses. A child-table field comes back as an array
|
|
165
|
+
* of row objects, and each row carries its own owner/creater/dept blobs and
|
|
166
|
+
* `*_` internals — the first version only stripped the top level, so a record
|
|
167
|
+
* with a child table still shipped the noise one level down. The keys are
|
|
168
|
+
* platform-reserved (`SYSTEM_FIELD_CODES` / engine internals), so a business
|
|
169
|
+
* field cannot collide with them and recursion cannot eat authored data.
|
|
170
|
+
*/
|
|
171
|
+
function compactRuntimeValue(value) {
|
|
172
|
+
if (Array.isArray(value))
|
|
173
|
+
return value.map(compactRuntimeValue);
|
|
174
|
+
if (value !== null && typeof value === "object")
|
|
175
|
+
return compactRuntimeRecord(value);
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function compactRuntimeRecord(record) {
|
|
179
|
+
const compact = {};
|
|
180
|
+
for (const [key, value] of Object.entries(record)) {
|
|
181
|
+
if (key.endsWith("_"))
|
|
182
|
+
continue;
|
|
183
|
+
if (RUNTIME_ENGINE_RECORD_KEYS.has(key))
|
|
184
|
+
continue;
|
|
185
|
+
compact[key] = compactRuntimeValue(value);
|
|
186
|
+
}
|
|
187
|
+
return compact;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Round 3 (issue §D5): the write readback inlined the whole `authoritative`
|
|
191
|
+
* record (owner/creater objects included), ~4 KB a row. Default is now the
|
|
192
|
+
* compact projection (business fields + identity); `summary:true` keeps
|
|
193
|
+
* identity/status keys, `fields:[...]` keeps exactly the named keys, and
|
|
194
|
+
* `full:true` returns the untouched record.
|
|
195
|
+
*/
|
|
196
|
+
function projectReadbackRecord(record, args) {
|
|
197
|
+
if (record === null || typeof record !== "object" || Array.isArray(record))
|
|
198
|
+
return record;
|
|
199
|
+
const source = record;
|
|
200
|
+
if (Array.isArray(args.fields) && args.fields.length > 0) {
|
|
201
|
+
const projected = {};
|
|
202
|
+
for (const field of args.fields)
|
|
203
|
+
if (field in source)
|
|
204
|
+
projected[field] = source[field];
|
|
205
|
+
return projected;
|
|
206
|
+
}
|
|
207
|
+
if (args.summary === true) {
|
|
208
|
+
const projected = {};
|
|
209
|
+
for (const key of ["id", "bizObjectId", "schemaCode", "name", "sequenceStatus"])
|
|
210
|
+
if (key in source)
|
|
211
|
+
projected[key] = source[key];
|
|
212
|
+
return projected;
|
|
213
|
+
}
|
|
214
|
+
if (args.full === true)
|
|
215
|
+
return record;
|
|
216
|
+
return compactRuntimeRecord(source);
|
|
217
|
+
}
|
|
218
|
+
/** Shared optional schema fields for the write readback projection. */
|
|
219
|
+
export const readbackProjectionArgs = {
|
|
220
|
+
summary: z.boolean().optional().describe("只回身份/状态字段,裁剪 readback.authoritative(默认关闭,读回为业务字段+身份的精简记录)"),
|
|
221
|
+
fields: z.array(z.string().min(1)).optional().describe("只保留 readback.authoritative 中列出的字段(比 summary 更精确)"),
|
|
222
|
+
full: z.boolean().optional().describe("返回未裁剪的原始记录(含 owner/creater 等系统字段与 *_ 引擎内部字段;默认已裁剪)"),
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Authoritative existence probe for an existing business object. The platform
|
|
226
|
+
* form/load face returns either a null payload or a business error
|
|
227
|
+
* (304001 业务对象不存在) when the object is absent; both are absence, not a
|
|
228
|
+
* read failure. `data.update` uses this to refuse an absent target instead of
|
|
229
|
+
* letting runtime/form/save silently insert (BizObjectFacadeImpl falls back to
|
|
230
|
+
* createBizObject when the id is not found).
|
|
231
|
+
*/
|
|
232
|
+
async function loadExistingBizObject(session, schemaCode, sheetCode, objectId) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await wireCall(session, {
|
|
235
|
+
path: ENDPOINTS.runtimeFormLoad,
|
|
236
|
+
query: { schemaCode, sheetCode, objectId },
|
|
237
|
+
});
|
|
238
|
+
const value = result.data;
|
|
239
|
+
if (value == null || value === false)
|
|
240
|
+
return null;
|
|
241
|
+
return typeof value === "object" ? value : {};
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
|
|
245
|
+
return null;
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Portal-required fields the published form declares (`control.options.required`
|
|
251
|
+
* or `requiredFormula`). The portal enforces these client-side; the REST
|
|
252
|
+
* runtime/form/submit path does NOT, so a submit with a required field missing
|
|
253
|
+
* returns ok/PROCESSING anyway (dogfood R3: `amount:null` still advanced the
|
|
254
|
+
* workflow). Read the PUBLISHED form controls and cross-check the submitted
|
|
255
|
+
* formData, then report the discrepancy instead of letting the caller believe a
|
|
256
|
+
* portal-invalid submit succeeded.
|
|
257
|
+
*/
|
|
258
|
+
async function portalRequiredViolations(session, schemaCode, sheetCode, formData) {
|
|
259
|
+
const sheet = ((await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode } })).data ?? {});
|
|
260
|
+
const face = typeof sheet.publishedAttributesJson === "string" ? sheet.publishedAttributesJson : typeof sheet.draftAttributesJson === "string" ? sheet.draftAttributesJson : undefined;
|
|
261
|
+
if (!face)
|
|
262
|
+
return { requiredFields: [], missingRequired: [] };
|
|
263
|
+
let parsed;
|
|
264
|
+
try {
|
|
265
|
+
parsed = JSON.parse(face);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return { requiredFields: [], missingRequired: [] };
|
|
269
|
+
}
|
|
270
|
+
const requiredFields = Object.entries(parsed)
|
|
271
|
+
.filter(([, control]) => control?.options?.required === true || typeof control?.options?.requiredFormula === "string")
|
|
272
|
+
.map(([code]) => code);
|
|
273
|
+
const missingRequired = requiredFields.filter((code) => {
|
|
274
|
+
const value = formData[code];
|
|
275
|
+
return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
|
|
276
|
+
});
|
|
277
|
+
return { requiredFields, missingRequired };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The platform dispatches each CHILD_TABLE(8) row on the form-save path by its
|
|
281
|
+
* `rowStatus` (BizObjectUpdateModel.convertBizObjectOpItem): "Added" → insert,
|
|
282
|
+
* "Modified" → update, "Deleted" → delete, and **any other/missing value →
|
|
283
|
+
* immutable (silently ignored)**. The portal client stamps it (the compiled
|
|
284
|
+
* admin bundle sets `rowStatus:"Modified"` on edited rows and `"Added"` on new
|
|
285
|
+
* ones); a hand-built payload that omits it loses every child edit while the
|
|
286
|
+
* write still answers errcode 0 (live 2026-10-08: data.update of a child row
|
|
287
|
+
* without rowStatus left process_name/plan fields unchanged). Stamp the obvious
|
|
288
|
+
* intent so OMC never reports a successful child write that did not land: a row
|
|
289
|
+
* carrying its own `id` is "Modified", an id-less row is "Added"; an explicit
|
|
290
|
+
* rowStatus is preserved verbatim.
|
|
291
|
+
*/
|
|
292
|
+
async function stampChildRowStatuses(session, schemaCode, formData) {
|
|
293
|
+
const childCodes = new Set();
|
|
294
|
+
try {
|
|
295
|
+
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
if (Number(row.propertyType ?? row.type) === CHILD_TABLE_PROPERTY_TYPE)
|
|
298
|
+
childCodes.add(String(row.code ?? row.propertyCode ?? ""));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return formData;
|
|
303
|
+
}
|
|
304
|
+
if (childCodes.size === 0)
|
|
305
|
+
return formData;
|
|
306
|
+
let changed = false;
|
|
307
|
+
const next = { ...formData };
|
|
308
|
+
for (const code of childCodes) {
|
|
309
|
+
const value = formData[code];
|
|
310
|
+
if (!Array.isArray(value))
|
|
311
|
+
continue;
|
|
312
|
+
next[code] = value.map((entry) => {
|
|
313
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
314
|
+
return entry;
|
|
315
|
+
const row = entry;
|
|
316
|
+
if (typeof row.rowStatus === "string" && row.rowStatus.trim())
|
|
317
|
+
return row;
|
|
318
|
+
changed = true;
|
|
319
|
+
const hasId = typeof row.id === "string" && row.id.trim().length > 0;
|
|
320
|
+
return { ...row, rowStatus: hasId ? "Modified" : "Added" };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return changed ? next : formData;
|
|
324
|
+
}
|
|
325
|
+
/** Runtime business-object data keyed by field code. */
|
|
326
|
+
export function registerRuntimeDataLane(tools) {
|
|
327
|
+
tools.push(defineTool({
|
|
328
|
+
name: "data.list",
|
|
329
|
+
layer: "low",
|
|
330
|
+
kind: "read",
|
|
331
|
+
lane: RUNTIME_LANE,
|
|
332
|
+
scope: RUNTIME_TOOLS["data.list"].scope,
|
|
333
|
+
description: RUNTIME_TOOLS["data.list"].description,
|
|
334
|
+
inputSchema: {
|
|
335
|
+
environment: environmentArg,
|
|
336
|
+
schemaCode: z.string().min(1),
|
|
337
|
+
queryCode: z.string().min(1).optional().describe("query view code; defaults to schemaCode (the default list view)"),
|
|
338
|
+
// `filter` may be a SINGLE clause object `{op,field,value}` or an ARRAY
|
|
339
|
+
// of clauses (a natural mistake that the old schema rejected with a bare
|
|
340
|
+
// `Expected object, received array`). An array is the caller's AND-list,
|
|
341
|
+
// so accept it and treat it as `filters` rather than costing a retry
|
|
342
|
+
// (dogfood R1 round17).
|
|
343
|
+
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 is one of eq, neq, gt, lt, gte, lte, like, notlike, in, notin, between, notbetween, isnull, isnotnull'),
|
|
344
|
+
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"),
|
|
345
|
+
filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
|
|
346
|
+
rawCondition: z.unknown().optional(),
|
|
347
|
+
displayFields: z.array(z.string().min(1)).optional(),
|
|
348
|
+
columns: z.array(z.string().min(1)).optional(),
|
|
349
|
+
orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional(),
|
|
350
|
+
mobile: z.boolean().optional(),
|
|
351
|
+
showTotal: z.boolean().default(true),
|
|
352
|
+
page: z.number().int().min(0).default(0),
|
|
353
|
+
size: z.number().int().min(1).max(200).default(10),
|
|
354
|
+
full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
|
|
355
|
+
capability: capabilityArg,
|
|
356
|
+
},
|
|
357
|
+
handler: async (ctx, args, self) => {
|
|
358
|
+
let condition;
|
|
359
|
+
let fromDsl = false;
|
|
360
|
+
try {
|
|
361
|
+
// An array `filter` is the caller's AND-list; fold it into `filters`.
|
|
362
|
+
const asFilters = args.filters ?? (Array.isArray(args.filter) ? args.filter : undefined);
|
|
363
|
+
const asFilter = Array.isArray(args.filter) ? undefined : args.filter;
|
|
364
|
+
condition = toQueryCondition({
|
|
365
|
+
rawCondition: args.rawCondition,
|
|
366
|
+
...(asFilter !== undefined ? { filter: asFilter } : {}),
|
|
367
|
+
...(asFilters !== undefined ? { filters: asFilters } : {}),
|
|
368
|
+
...(args.filterGroup !== undefined ? { filterGroup: args.filterGroup } : {}),
|
|
369
|
+
});
|
|
370
|
+
fromDsl = condition !== undefined && args.rawCondition === undefined;
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (error instanceof FilterDslError)
|
|
374
|
+
return {
|
|
375
|
+
status: "refused",
|
|
376
|
+
blocker: "invalid-filter-DSL",
|
|
377
|
+
reason: error.code,
|
|
378
|
+
message: `${error.message};filter 的最小合法形状:{"filter":{"op":"eq","field":"<字段code>","value":"<值>"}}(op 取 eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull);多条件用 filters:[clause,…](AND)或 filterGroup:{op:"and"|"or",clauses:[…]}。可运行示例:describe data.list --example list-filter-equality`,
|
|
379
|
+
minimalValidFilter: { filter: { op: "eq", field: "<fieldCode>", value: "<value>" } },
|
|
380
|
+
acceptedOps: ["eq", "neq", "gt", "lt", "gte", "lte", "like", "notlike", "in", "notin", "between", "notbetween", "isnull", "isnotnull"],
|
|
381
|
+
};
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
return governedRead(ctx, self, args, async (session) => {
|
|
385
|
+
// Published field metadata is the sole source for filter propertyType
|
|
386
|
+
// injection AND for the projectionHint below. Fetch at most once per
|
|
387
|
+
// call: the DSL path needs it up front; the hint path asks only after
|
|
388
|
+
// an explicit projection came back all-null, so a normal read that
|
|
389
|
+
// yields values performs zero metadata requests.
|
|
390
|
+
let publishedTypes = null;
|
|
391
|
+
const publishedTypeByCode = async () => {
|
|
392
|
+
if (publishedTypes)
|
|
393
|
+
return publishedTypes;
|
|
394
|
+
const meta = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
|
|
395
|
+
publishedTypes = new Map(meta.map((row) => [String(row.code ?? row.propertyCode), Number(row.propertyType ?? row.type)]));
|
|
396
|
+
return publishedTypes;
|
|
397
|
+
};
|
|
398
|
+
if (fromDsl) {
|
|
399
|
+
const typeByCode = await publishedTypeByCode();
|
|
400
|
+
const injected = injectPropertyTypes(condition, (code) => {
|
|
401
|
+
const type = typeByCode.get(code);
|
|
402
|
+
return Number.isInteger(type) ? type : undefined;
|
|
403
|
+
});
|
|
404
|
+
if (!injected.ok)
|
|
405
|
+
return {
|
|
406
|
+
status: "refused",
|
|
407
|
+
blocker: "unknown-filter-field",
|
|
408
|
+
missing: injected.missing,
|
|
409
|
+
message: `filter fields not found in published field metadata: ${injected.missing.join(", ")}; the runtime query requires each leaf's propertyType from field metadata (no guessing); system/foreign fields may use rawCondition`,
|
|
410
|
+
};
|
|
411
|
+
condition = injected.condition;
|
|
412
|
+
}
|
|
413
|
+
const requestedFields = args.displayFields ?? args.columns;
|
|
414
|
+
const fields = requestedFields && requestedFields.length > 0 ? requestedFields : undefined;
|
|
415
|
+
const body = {
|
|
416
|
+
schemaCode: args.schemaCode,
|
|
417
|
+
queryCode: args.queryCode ?? args.schemaCode,
|
|
418
|
+
filters: [],
|
|
419
|
+
mobile: args.mobile === true,
|
|
420
|
+
page: args.page ?? 0,
|
|
421
|
+
size: args.size ?? 10,
|
|
422
|
+
queryVersion: 1,
|
|
423
|
+
showTotal: args.showTotal !== false,
|
|
424
|
+
...(condition !== undefined ? { queryCondition: condition } : {}),
|
|
425
|
+
};
|
|
426
|
+
// An empty model answers with a business error (301005) on this face;
|
|
427
|
+
// that is "no data yet", not a query failure.
|
|
428
|
+
let result;
|
|
429
|
+
try {
|
|
430
|
+
result = await wireCall(session, { path: ENDPOINTS.runtimeQueryList, method: "POST", body });
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode) {
|
|
434
|
+
return { schemaCode: args.schemaCode, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [] };
|
|
435
|
+
}
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
const page = extractBizObjectPage(result.data);
|
|
439
|
+
const items = page.content.map((row) => {
|
|
440
|
+
const rawData = row.data && typeof row.data === "object" ? row.data : {};
|
|
441
|
+
// Round 10: default-compact each row with the SAME known-noise filter
|
|
442
|
+
// data.load uses (`*_` flow internals + owner/creater/dept blobs), so a
|
|
443
|
+
// list read no longer ships engine fields the caller then filters out.
|
|
444
|
+
// Explicit `fields` still projects exactly what was asked, and
|
|
445
|
+
// `full:true` keeps the untouched row.
|
|
446
|
+
const data = args.full === true ? rawData : compactRuntimeRecord(rawData);
|
|
447
|
+
const selected = fields ?? Object.keys(data).filter((field) => !["id", "bizObjectId", "schemaCode", "sequenceStatus", "createdTime", "updatedTime", "creator", "updater"].includes(field));
|
|
448
|
+
return { id: row.bizObjectId ?? row.id ?? data.id, fields: Object.fromEntries(selected.map((field) => [field, data[field] ?? null])) };
|
|
449
|
+
});
|
|
450
|
+
const readback = {
|
|
451
|
+
schemaCode: args.schemaCode,
|
|
452
|
+
...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
|
|
453
|
+
total: args.showTotal === false ? null : page.total,
|
|
454
|
+
totalPages: page.totalPages,
|
|
455
|
+
page: page.page ?? args.page ?? 0,
|
|
456
|
+
size: page.size ?? args.size ?? 20,
|
|
457
|
+
items,
|
|
458
|
+
};
|
|
459
|
+
// A model's unconfigured default list view projects business columns as
|
|
460
|
+
// null even though the stored values exist (ticket 07). Only when the
|
|
461
|
+
// caller explicitly projected columns, EVERY returned row yielded null
|
|
462
|
+
// for EVERY selected column, and the published metadata confirms those
|
|
463
|
+
// are real model fields, surface the recovery hint. `items` is never
|
|
464
|
+
// reshaped — the hint is a sibling readback field.
|
|
465
|
+
if (fields &&
|
|
466
|
+
fields.length > 0 &&
|
|
467
|
+
items.length > 0 &&
|
|
468
|
+
items.every((item) => fields.every((field) => item.fields[field] === null))) {
|
|
469
|
+
const typeByCode = await publishedTypeByCode();
|
|
470
|
+
if (fields.every((field) => typeByCode.has(field)))
|
|
471
|
+
readback.projectionHint = RUNTIME_DATA_WIRE.projectionHint;
|
|
472
|
+
}
|
|
473
|
+
return readback;
|
|
474
|
+
}, { full: args.full === true, target: `data.list:${JSON.stringify({ schemaCode: args.schemaCode, queryCode: args.queryCode ?? args.schemaCode, filter: args.filter, filters: args.filters, filterGroup: args.filterGroup, rawCondition: args.rawCondition, displayFields: args.displayFields, columns: args.columns, page: args.page ?? 0, size: args.size ?? 10 })}` });
|
|
475
|
+
},
|
|
476
|
+
}));
|
|
477
|
+
tools.push(defineTool({
|
|
478
|
+
name: "data.load",
|
|
479
|
+
layer: "low",
|
|
480
|
+
kind: "read",
|
|
481
|
+
lane: RUNTIME_LANE,
|
|
482
|
+
scope: RUNTIME_TOOLS["data.load"].scope,
|
|
483
|
+
description: RUNTIME_TOOLS["data.load"].description,
|
|
484
|
+
inputSchema: {
|
|
485
|
+
environment: environmentArg,
|
|
486
|
+
schemaCode: z.string(),
|
|
487
|
+
bizObjectId: z.string({ required_error: "bizObjectId 必填(业务对象 id;参数名是 bizObjectId,不是 id。从 data.submit 读回的 objectId/sequenceNo 或 data.list 的 row.bizObjectId 取)" }),
|
|
488
|
+
full: z.boolean().optional().describe("返回未裁剪的完整业务对象(含 owner/creater 等系统字段;默认返回业务字段+身份的精简记录)"),
|
|
489
|
+
capability: capabilityArg,
|
|
490
|
+
},
|
|
491
|
+
handler: async (ctx, args, self) => {
|
|
492
|
+
const includeSchema = !ctx.schemaCache.has(args.schemaCode);
|
|
493
|
+
return governedRead(ctx, self, args, async (session) => {
|
|
494
|
+
let result;
|
|
495
|
+
try {
|
|
496
|
+
result = await wireCall(session, {
|
|
497
|
+
path: ENDPOINTS.runtimeFormLoad,
|
|
498
|
+
query: { schemaCode: args.schemaCode, sheetCode: args.schemaCode, objectId: args.bizObjectId },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
// An absent (e.g. deleted) object answers the business error 304001
|
|
503
|
+
// 业务对象不存在. That is AUTHORITATIVE ABSENCE, not a governance
|
|
504
|
+
// refusal — surfacing it as policy-refused misleads a cleanup check
|
|
505
|
+
// (bench18 verified a delete by data.load and got policy-refused).
|
|
506
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
|
|
507
|
+
return { schemaCode: args.schemaCode, bizObjectId: args.bizObjectId, exists: false, data: null, note: "业务对象不存在(304001 = 缺席信号,非失败)" };
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
// Flat record contract: live FormLoadVO business fields normalize
|
|
511
|
+
// out of `bizObject.data` (ticket 17).
|
|
512
|
+
const normalized = normalizeLoadedRecord(result.data && typeof result.data === "object" ? result.data : null);
|
|
513
|
+
let data = normalized ?? result.data;
|
|
514
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
515
|
+
if (!includeSchema)
|
|
516
|
+
delete data.bizSchema;
|
|
517
|
+
if (args.full !== true)
|
|
518
|
+
data = compactRuntimeRecord(data);
|
|
519
|
+
}
|
|
520
|
+
if (includeSchema)
|
|
521
|
+
ctx.schemaCache.add(args.schemaCode);
|
|
522
|
+
return { schemaCode: args.schemaCode, bizObjectId: args.bizObjectId, exists: data != null, ...(includeSchema ? {} : { schemaCached: true }), data };
|
|
523
|
+
}, { full: args.full === true, target: `data.load:${args.schemaCode}:${args.bizObjectId}` });
|
|
524
|
+
},
|
|
525
|
+
}));
|
|
526
|
+
writeTool(tools, "data.save", RUNTIME_LANE, RUNTIME_TOOLS["data.save"].scope, RUNTIME_TOOLS["data.save"].description, {
|
|
527
|
+
schemaCode: z.string().min(1),
|
|
528
|
+
sheetCode: z.string().optional(),
|
|
529
|
+
formData: runtimeFormDataArg,
|
|
530
|
+
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"),
|
|
531
|
+
...readbackProjectionArgs,
|
|
532
|
+
}, async (session, args) => {
|
|
533
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
534
|
+
const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
|
|
535
|
+
if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
|
|
536
|
+
// Portal draft-save shape. The row id travels in data.id (the portal
|
|
537
|
+
// form SDK receives a pre-generated id from form/load and echoes it);
|
|
538
|
+
// BizObjectModel.getId() reads data["id"], so a client-chosen id is
|
|
539
|
+
// echoed back as FormSaveVO.objectId. An id that already exists in
|
|
540
|
+
// the store would hit the create-path INSERT duplicate (40000), so an
|
|
541
|
+
// existing id is refused up front.
|
|
542
|
+
const suppliedId = typeof formData.id === "string" ? formData.id.trim() : "";
|
|
543
|
+
if (suppliedId) {
|
|
544
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, suppliedId);
|
|
545
|
+
if (existing) {
|
|
546
|
+
throw new ToolRefusalError({
|
|
547
|
+
status: "refused",
|
|
548
|
+
blocker: "save-workflow-draft-with-id-unsupported",
|
|
549
|
+
schemaCode: args.schemaCode,
|
|
550
|
+
bizObjectId: suppliedId,
|
|
551
|
+
workflowCode: args.workflowCode,
|
|
552
|
+
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",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const objectId = suppliedId || randomUUID().replaceAll("-", "");
|
|
557
|
+
// isStartWf branch runs verifyReplay; the instance is DRAFT only when
|
|
558
|
+
// data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
|
|
559
|
+
const replayToken = await getReplayToken(session);
|
|
560
|
+
const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
|
|
561
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
562
|
+
method: "POST",
|
|
563
|
+
body: {
|
|
564
|
+
workflowCode: args.workflowCode,
|
|
565
|
+
...(replayToken ? { replayToken } : {}),
|
|
566
|
+
bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
|
|
567
|
+
},
|
|
568
|
+
}), formData);
|
|
569
|
+
const vo = (performed.data ?? {});
|
|
570
|
+
const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
|
|
571
|
+
const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
|
|
572
|
+
// saveWorkflow resolves the start workItem via getWorkItem INSIDE the
|
|
573
|
+
// still-uncommitted request transaction (autoCommit:false), so the live
|
|
574
|
+
// FormSaveVO usually omits workItem. The committed row is visible to a
|
|
575
|
+
// follow-up search_workitems read; resolve the pending start todo by
|
|
576
|
+
// exact instanceId (the server filter is a NOT-equal exclusion).
|
|
577
|
+
let workItem = vo.workItem;
|
|
578
|
+
let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
|
|
579
|
+
let workItemSource = workItemId ? "form-save" : null;
|
|
580
|
+
if (!workItemId && workflowInstanceId) {
|
|
581
|
+
// search_workitems via the shared workflow seam (ticket 21): exact
|
|
582
|
+
// instanceId match is client-side (the server filter is a NOT-equal
|
|
583
|
+
// exclusion); the seam's default size 200 is the unified scan width.
|
|
584
|
+
const found = await searchWorkItems(session, {
|
|
585
|
+
workflowCode: args.workflowCode,
|
|
586
|
+
instanceId: workflowInstanceId,
|
|
587
|
+
});
|
|
588
|
+
const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
|
|
589
|
+
if (matchId) {
|
|
590
|
+
workItemId = matchId;
|
|
591
|
+
workItemSource = "workitem-search";
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
let readbackData = null;
|
|
595
|
+
let sequenceStatus = null;
|
|
596
|
+
readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
|
|
597
|
+
sequenceStatus = extractSequenceStatus((readbackData ?? {}));
|
|
598
|
+
return {
|
|
599
|
+
performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
|
|
600
|
+
readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
|
|
604
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
605
|
+
method: "POST",
|
|
606
|
+
body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
|
|
607
|
+
}), formData);
|
|
608
|
+
const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
|
|
609
|
+
let readbackData = null;
|
|
610
|
+
if (typeof bizObjectId === "string" && bizObjectId) {
|
|
611
|
+
readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } })).data;
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
|
|
615
|
+
readback: {
|
|
616
|
+
authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args),
|
|
617
|
+
// Ticket 111: a plain save lands as sequenceStatus DRAFT, and DRAFT
|
|
618
|
+
// rows SKIP the Create/Update rule dispatch entirely
|
|
619
|
+
// (DomainServiceImpl.java:240-248 buckets drafts before the rule
|
|
620
|
+
// branch) — business rules only run once data.submit advances the
|
|
621
|
+
// row. Saying so on every plain-save readback prevents the
|
|
622
|
+
// "rule didn't fire" misdiagnosis.
|
|
623
|
+
draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);data.submit 提交(DRAFT→COMPLETED)后才触发 Create 规则",
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
writeTool(tools, "data.update", RUNTIME_LANE, RUNTIME_TOOLS["data.update"].scope, RUNTIME_TOOLS["data.update"].description, {
|
|
628
|
+
schemaCode: z.string().min(1),
|
|
629
|
+
sheetCode: z.string().optional(),
|
|
630
|
+
bizObjectId: z.string().min(1).describe("id of the EXISTING record to edit; the tool refuses when it is absent (never silently inserts)"),
|
|
631
|
+
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)"),
|
|
632
|
+
...readbackProjectionArgs,
|
|
633
|
+
}, async (session, args) => {
|
|
634
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
635
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, args.bizObjectId);
|
|
636
|
+
if (!existing) {
|
|
637
|
+
// The platform's save-with-id path silently inserts when the id is
|
|
638
|
+
// unknown (BizObjectFacadeImpl.createBizObject). Refuse instead of
|
|
639
|
+
// turning an edit into an accidental create.
|
|
640
|
+
throw new ToolRefusalError({
|
|
641
|
+
status: "refused",
|
|
642
|
+
blocker: "update-target-absent",
|
|
643
|
+
errcode: RUNTIME_DATA_WIRE.absenceErrcode,
|
|
644
|
+
schemaCode: args.schemaCode,
|
|
645
|
+
bizObjectId: args.bizObjectId,
|
|
646
|
+
sheetCode,
|
|
647
|
+
message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
|
|
651
|
+
const performed = await wireCall(session, {
|
|
652
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
653
|
+
method: "POST",
|
|
654
|
+
// Live-proven shape: BizObjectModel.getId() reads data["id"], so the
|
|
655
|
+
// target id MUST travel inside `data`; a top-level bizObject.id is
|
|
656
|
+
// ignored and the platform silently creates a new row.
|
|
657
|
+
body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
|
|
658
|
+
});
|
|
659
|
+
const performedData = (performed.data ?? {});
|
|
660
|
+
const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
|
|
661
|
+
const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
|
|
662
|
+
const sequenceStatus = extractSequenceStatus(readbackData);
|
|
663
|
+
return {
|
|
664
|
+
performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
|
|
665
|
+
// Flat record contract (ticket 17): live FormLoadVO normalizes to the
|
|
666
|
+
// fake's flat field shape so `authoritative.<fieldCode>` reads work on
|
|
667
|
+
// both faces.
|
|
668
|
+
readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord(readbackData), args), sequenceStatus },
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
/**
|
|
672
|
+
* Round 16b (dogfood R1-R3): `data.delete`'s `absent:true` only proves THAT id is
|
|
673
|
+
* gone, not that the model is clean — a submit can leave a separate draft row
|
|
674
|
+
* behind. After a delete, read the model's residue so "cleaned up" is a
|
|
675
|
+
* model-level fact (`total:0`). Best-effort: a query failure never fails the
|
|
676
|
+
* delete, but it IS surfaced as `modelResidueError`.
|
|
677
|
+
*/
|
|
678
|
+
async function modelResidue(session, schemaCode, sheetCode) {
|
|
679
|
+
try {
|
|
680
|
+
const result = await wireCall(session, {
|
|
681
|
+
path: ENDPOINTS.runtimeQueryList,
|
|
682
|
+
method: "POST",
|
|
683
|
+
body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
|
|
684
|
+
});
|
|
685
|
+
const page = extractBizObjectPage(result.data);
|
|
686
|
+
return { modelResidue: { total: page.total, clean: page.total === 0, sheetCode } };
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode)
|
|
690
|
+
return { modelResidue: { total: 0, clean: true, sheetCode } };
|
|
691
|
+
return { modelResidue: { total: null, clean: null, sheetCode, modelResidueError: error instanceof Error ? error.message : String(error) } };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
writeTool(tools, "data.delete", RUNTIME_LANE, RUNTIME_TOOLS["data.delete"].scope, RUNTIME_TOOLS["data.delete"].description, { 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 列表)") }, async (session, args) => {
|
|
695
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
696
|
+
const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
|
|
697
|
+
if (ids.length === 0)
|
|
698
|
+
throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "data.delete", message: "需要 bizObjectId(单个)或 bizObjectIds(数组,批量)" });
|
|
699
|
+
const deleteOne = async (bizObjectId) => {
|
|
700
|
+
// Delete is destructive: probe the authoritative object first so an
|
|
701
|
+
// absent id is an idempotent no-op, never a blind runtime delete.
|
|
702
|
+
const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
|
|
703
|
+
if (!before)
|
|
704
|
+
return { bizObjectId, skipped: "already-absent", absent: true };
|
|
705
|
+
const performed = await wireCall(session, {
|
|
706
|
+
path: ENDPOINTS.runtimeFormDelete,
|
|
707
|
+
method: "POST",
|
|
708
|
+
query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
|
|
709
|
+
});
|
|
710
|
+
// The absence readback: a missing object surfaces either as an empty
|
|
711
|
+
// payload or as a business error (304001 业务对象不存在) from form/load.
|
|
712
|
+
// Both are authoritative absence, not a delete failure.
|
|
713
|
+
let absent = false;
|
|
714
|
+
try {
|
|
715
|
+
const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
|
|
716
|
+
const value = readback.data;
|
|
717
|
+
absent = value == null || value === false;
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
|
|
721
|
+
absent = true;
|
|
722
|
+
else
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
return { bizObjectId, errcode: performed.errcode, absent };
|
|
726
|
+
};
|
|
727
|
+
if (ids.length === 1) {
|
|
728
|
+
const one = await deleteOne(ids[0]);
|
|
729
|
+
if (one.skipped === "already-absent")
|
|
730
|
+
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)) } };
|
|
731
|
+
return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
732
|
+
}
|
|
733
|
+
const deleted = [];
|
|
734
|
+
for (const id of ids)
|
|
735
|
+
deleted.push(await deleteOne(id));
|
|
736
|
+
// `deleted`/`count` ride `readback` — the write envelope surfaces only
|
|
737
|
+
// performed/readback inline (extra top-level keys are artifact-only).
|
|
738
|
+
return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
|
|
739
|
+
});
|
|
740
|
+
writeTool(tools, "data.submit", RUNTIME_LANE, RUNTIME_TOOLS["data.submit"].scope, RUNTIME_TOOLS["data.submit"].description, {
|
|
741
|
+
schemaCode: z.string().min(1),
|
|
742
|
+
sheetCode: z.string().optional(),
|
|
743
|
+
formData: runtimeFormDataArg,
|
|
744
|
+
workflowCode: z.string().optional(),
|
|
745
|
+
workflowInstanceId: z.string().optional(),
|
|
746
|
+
workItemId: z.string().optional(),
|
|
747
|
+
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)"),
|
|
748
|
+
agree: z.boolean().optional(),
|
|
749
|
+
approval: z.record(z.string(), z.unknown()).optional(),
|
|
750
|
+
runMode: z.string().optional(),
|
|
751
|
+
isSimulative: z.boolean().optional(),
|
|
752
|
+
...readbackProjectionArgs,
|
|
753
|
+
}, async (session, args) => {
|
|
754
|
+
const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
|
|
755
|
+
const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
|
|
756
|
+
const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
|
|
757
|
+
if ((hasWorkItem || hasInstance) && !targetObjectId) {
|
|
758
|
+
throw new ToolRefusalError({
|
|
759
|
+
status: "refused",
|
|
760
|
+
blocker: "submit-biz-object-id-required",
|
|
761
|
+
schemaCode: args.schemaCode,
|
|
762
|
+
workItemId: args.workItemId,
|
|
763
|
+
workflowInstanceId: args.workflowInstanceId,
|
|
764
|
+
message: "workItemId or workflowInstanceId submit requires bizObjectId; refusing before runtime/form/submit",
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
768
|
+
const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
|
|
769
|
+
// Portal required-field semantics: the REST submit does NOT enforce them,
|
|
770
|
+
// so a missing required value must be REPORTED, not silently accepted as a
|
|
771
|
+
// portal-valid submit (dogfood R3). Best-effort read; a failure never
|
|
772
|
+
// blocks the submit.
|
|
773
|
+
const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
|
|
774
|
+
// Directive: a workflow-backed model's submit must START the workflow, not
|
|
775
|
+
// merely save a sheet row. When the caller names no workflow params, detect
|
|
776
|
+
// the model's workflow and start it (formType=1 + workflowCode); a model
|
|
777
|
+
// with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
|
|
778
|
+
let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
|
|
779
|
+
let autoStarted = false;
|
|
780
|
+
if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
781
|
+
const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
|
|
782
|
+
if (resolved) {
|
|
783
|
+
explicitWorkflowCode = resolved;
|
|
784
|
+
autoStarted = true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const hasWorkflowCode = explicitWorkflowCode.length > 0;
|
|
788
|
+
const hasWorkflow = hasWorkflowCode || hasWorkItem || hasInstance;
|
|
789
|
+
const formType = hasWorkflow ? "1" : "2";
|
|
790
|
+
const payload = {
|
|
791
|
+
formType,
|
|
792
|
+
bizObject: {
|
|
793
|
+
schemaCode: args.schemaCode,
|
|
794
|
+
sheetCode,
|
|
795
|
+
data: args.formData,
|
|
796
|
+
},
|
|
797
|
+
agree: args.agree !== false,
|
|
798
|
+
};
|
|
799
|
+
let draftObjectId = null;
|
|
800
|
+
if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
801
|
+
throw new ToolRefusalError({
|
|
802
|
+
status: "refused",
|
|
803
|
+
blocker: "submit-existing-workflow-unsupported",
|
|
804
|
+
schemaCode: args.schemaCode,
|
|
805
|
+
bizObjectId: targetObjectId,
|
|
806
|
+
workflowCode: explicitWorkflowCode,
|
|
807
|
+
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",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
let preSubmitExisting = null;
|
|
811
|
+
if (targetObjectId) {
|
|
812
|
+
const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
|
|
813
|
+
preSubmitExisting = existing;
|
|
814
|
+
if (!existing) {
|
|
815
|
+
throw new ToolRefusalError({
|
|
816
|
+
status: "refused",
|
|
817
|
+
blocker: "submit-target-absent",
|
|
818
|
+
errcode: RUNTIME_DATA_WIRE.absenceErrcode,
|
|
819
|
+
schemaCode: args.schemaCode,
|
|
820
|
+
bizObjectId: targetObjectId,
|
|
821
|
+
sheetCode,
|
|
822
|
+
message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
// Direct-submit chain (ticket 18): form/submit persists bizObject.data
|
|
826
|
+
// itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
|
|
827
|
+
// merges via updateBizObject.mergeObject); formType=1 workItem ->
|
|
828
|
+
// SubmitDataChangeHandlerImpl.updateBizObject — so the historical
|
|
829
|
+
// pre-submit form/save + form/load round-trip is redundant wire. The
|
|
830
|
+
// submit bizObject is synthesized from the authoritative pre-check
|
|
831
|
+
// load overlaid with the caller's formData.
|
|
832
|
+
const flat = normalizeLoadedRecord(existing) ?? {};
|
|
833
|
+
payload.bizObject = {
|
|
834
|
+
id: targetObjectId,
|
|
835
|
+
schemaCode: args.schemaCode,
|
|
836
|
+
sheetCode,
|
|
837
|
+
ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
|
|
838
|
+
// Engine-owned keys must never be echoed back: the loaded row's
|
|
839
|
+
// `sequenceStatus: DRAFT` would be persisted verbatim and pin the
|
|
840
|
+
// row to DRAFT across submits (the portal client never resubmits
|
|
841
|
+
// the status column; live TID 10e6bc65 evidence, ticket 17).
|
|
842
|
+
data: stripEngineOwnedKeys({ ...flat, ...formData }),
|
|
843
|
+
};
|
|
844
|
+
if (formType === "2")
|
|
845
|
+
payload.actionCode = "submit";
|
|
846
|
+
draftObjectId = targetObjectId;
|
|
847
|
+
}
|
|
848
|
+
else if (formType === "2") {
|
|
849
|
+
const saved = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
|
|
850
|
+
path: ENDPOINTS.runtimeFormSave,
|
|
851
|
+
method: "POST",
|
|
852
|
+
body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
|
|
853
|
+
}));
|
|
854
|
+
const savedData = (saved.data ?? {});
|
|
855
|
+
draftObjectId = typeof savedData.bizObjectId === "string" ? savedData.bizObjectId : typeof savedData.objectId === "string" ? savedData.objectId : null;
|
|
856
|
+
if (!draftObjectId)
|
|
857
|
+
throw new WireError("business", "submit chain: draft save returned no bizObjectId", 304001);
|
|
858
|
+
// No post-save form/load (ticket 18): the submit persists
|
|
859
|
+
// bizObject.data itself and updateBizObject merges patch-style onto
|
|
860
|
+
// the stored row, and initSubmitBizObject.setOwner re-reads the
|
|
861
|
+
// stored row's owner server-side — so formData + the saved id is the
|
|
862
|
+
// complete submit payload.
|
|
863
|
+
payload.bizObject = {
|
|
864
|
+
id: draftObjectId,
|
|
865
|
+
schemaCode: args.schemaCode,
|
|
866
|
+
sheetCode,
|
|
867
|
+
data: stripEngineOwnedKeys(formData),
|
|
868
|
+
};
|
|
869
|
+
payload.actionCode = "submit";
|
|
870
|
+
}
|
|
871
|
+
let replayToken;
|
|
872
|
+
if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
|
|
873
|
+
// Starting a workflow (formType=1 + workflowCode, no workItem) requires
|
|
874
|
+
// a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
|
|
875
|
+
// (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
|
|
876
|
+
replayToken = await getReplayToken(session);
|
|
877
|
+
}
|
|
878
|
+
if (hasWorkflowCode)
|
|
879
|
+
payload.workflowCode = explicitWorkflowCode;
|
|
880
|
+
if (replayToken)
|
|
881
|
+
payload.replayToken = replayToken;
|
|
882
|
+
if (typeof args.workflowInstanceId === "string")
|
|
883
|
+
payload.workflowInstanceId = args.workflowInstanceId;
|
|
884
|
+
if (typeof args.workItemId === "string")
|
|
885
|
+
payload.workItemId = args.workItemId;
|
|
886
|
+
if (args.approval && typeof args.approval === "object")
|
|
887
|
+
payload.approval = args.approval;
|
|
888
|
+
if (typeof args.runMode === "string")
|
|
889
|
+
payload.runMode = args.runMode;
|
|
890
|
+
if (typeof args.isSimulative === "boolean")
|
|
891
|
+
payload.isSimulative = args.isSimulative;
|
|
892
|
+
const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
|
|
893
|
+
path: ENDPOINTS.runtimeFormSubmit,
|
|
894
|
+
method: "POST",
|
|
895
|
+
body: payload,
|
|
896
|
+
}));
|
|
897
|
+
const performedData = (performed.data ?? {});
|
|
898
|
+
let readback = { formType };
|
|
899
|
+
if (formType === "2") {
|
|
900
|
+
const objectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
|
|
901
|
+
if (!objectId) {
|
|
902
|
+
// Source contract: submitBizSheet ALWAYS returns objectId.
|
|
903
|
+
// Absence is a transport / protocol deviation — never paper over
|
|
904
|
+
// it with a fabricated sequenceStatus:COMPLETED.
|
|
905
|
+
return {
|
|
906
|
+
performed: { errcode: performed.errcode, sheetCode, resolvedFrom, formType, draftObjectId },
|
|
907
|
+
readback: { formType, sequenceStatus: null, missingObjectId: true },
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
if (targetObjectId && objectId !== targetObjectId) {
|
|
911
|
+
throw new ToolRefusalError({
|
|
912
|
+
status: "refused",
|
|
913
|
+
blocker: "submit-target-id-changed",
|
|
914
|
+
expectedBizObjectId: targetObjectId,
|
|
915
|
+
actualBizObjectId: objectId,
|
|
916
|
+
message: "runtime/form/submit returned a different object id; refusing to accept a new row",
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const load = await wireCall(session, {
|
|
920
|
+
path: ENDPOINTS.runtimeFormLoad,
|
|
921
|
+
query: { schemaCode: args.schemaCode, sheetCode, objectId },
|
|
922
|
+
});
|
|
923
|
+
const loaded = (load.data ?? {});
|
|
924
|
+
const record = normalizeLoadedRecord(loaded) ?? {};
|
|
925
|
+
const sequenceStatus = extractSequenceStatus(record);
|
|
926
|
+
readback = {
|
|
927
|
+
...readback,
|
|
928
|
+
objectId,
|
|
929
|
+
schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
|
|
930
|
+
formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
|
|
931
|
+
sequenceStatus,
|
|
932
|
+
...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
|
|
933
|
+
submittedData: projectReadbackRecord(record, args),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
|
|
938
|
+
if (targetObjectId && returnedObjectId !== targetObjectId) {
|
|
939
|
+
throw new ToolRefusalError({
|
|
940
|
+
status: "refused",
|
|
941
|
+
blocker: "submit-target-id-changed",
|
|
942
|
+
expectedBizObjectId: targetObjectId,
|
|
943
|
+
actualBizObjectId: returnedObjectId,
|
|
944
|
+
message: "runtime/form/submit returned a different object id; refusing to accept a new row",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
const workflowInstanceId = typeof performedData.workflowInstanceId === "string" ? performedData.workflowInstanceId : null;
|
|
948
|
+
const objectId = returnedObjectId ?? targetObjectId;
|
|
949
|
+
// Workflow engine status writes can lag the submit response
|
|
950
|
+
// (autoCommit): an immediate authoritative form/load may still show
|
|
951
|
+
// the pre-submit status. Poll briefly when a workflow submit reads
|
|
952
|
+
// back unchanged — a genuinely unchanged mid-flow status costs one
|
|
953
|
+
// short bounded wait, a lagged boundary transition resolves.
|
|
954
|
+
const preSubmitStatus = preSubmitExisting ? extractSequenceStatus(preSubmitExisting) : null;
|
|
955
|
+
let loaded = objectId ? await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId) : null;
|
|
956
|
+
let sequenceStatus = extractSequenceStatus(loaded);
|
|
957
|
+
for (let attempt = 0; objectId && sequenceStatus !== null && sequenceStatus === preSubmitStatus && attempt < 3; attempt += 1) {
|
|
958
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
959
|
+
loaded = await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId);
|
|
960
|
+
sequenceStatus = extractSequenceStatus(loaded);
|
|
961
|
+
}
|
|
962
|
+
const loadedRecord = normalizeLoadedRecord(loaded);
|
|
963
|
+
// The platform's submit response often omits workflowInstanceId (the fake
|
|
964
|
+
// start path returns only {result, objectId}); the authoritative loaded
|
|
965
|
+
// row carries it. Prefer the response, then the row.
|
|
966
|
+
const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
|
|
967
|
+
readback = {
|
|
968
|
+
...readback,
|
|
969
|
+
workflowInstanceId: effectiveInstanceId,
|
|
970
|
+
objectId,
|
|
971
|
+
workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
|
|
972
|
+
sequenceStatus,
|
|
973
|
+
submittedData: projectReadbackRecord(loadedRecord, args),
|
|
974
|
+
};
|
|
975
|
+
// Multi-level approval: after a submit advances the flow, the caller
|
|
976
|
+
// almost always needs the NEXT pending todo — bench52/53 paid an extra
|
|
977
|
+
// `workitem.list` wire call at every approval level because the submit
|
|
978
|
+
// response carried only the object id. Attach the pending work items for
|
|
979
|
+
// this instance while the flow is still in flight (best-effort: a lookup
|
|
980
|
+
// failure must never fail the submit).
|
|
981
|
+
if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
|
|
982
|
+
try {
|
|
983
|
+
const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
|
|
984
|
+
const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
|
|
985
|
+
workItemId: wireString(item.id),
|
|
986
|
+
activityCode: wireString(item.activityCode),
|
|
987
|
+
activityName: wireString(item.activityName),
|
|
988
|
+
state: wireString(item.state),
|
|
989
|
+
}));
|
|
990
|
+
if (nextWorkItems.length > 0) {
|
|
991
|
+
readback.nextWorkItems = nextWorkItems;
|
|
992
|
+
// The submit RESPONSE's own workItemId is null (it names the item
|
|
993
|
+
// just completed, not the next one). Expose the fully-formed args
|
|
994
|
+
// for the NEXT approval so a multi-level flow advances without an
|
|
995
|
+
// extra workitem.list round or guessing which id to use.
|
|
996
|
+
readback.nextSubmit = {
|
|
997
|
+
schemaCode: args.schemaCode,
|
|
998
|
+
workItemId: nextWorkItems[0].workItemId,
|
|
999
|
+
workflowInstanceId: effectiveInstanceId,
|
|
1000
|
+
bizObjectId: objectId,
|
|
1001
|
+
note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
// best-effort
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return {
|
|
1011
|
+
performed: {
|
|
1012
|
+
errcode: performed.errcode,
|
|
1013
|
+
sheetCode,
|
|
1014
|
+
resolvedFrom,
|
|
1015
|
+
formType,
|
|
1016
|
+
...(draftObjectId ? { draftObjectId } : {}),
|
|
1017
|
+
// The workflow was auto-detected and started (formType=1) rather than
|
|
1018
|
+
// saved as a bare sheet row — surfaced so the caller knows the row is
|
|
1019
|
+
// now a PROCESSING workflow instance, not an orphan sheet record.
|
|
1020
|
+
...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
|
|
1021
|
+
},
|
|
1022
|
+
readback: {
|
|
1023
|
+
...readback,
|
|
1024
|
+
// Portal-required semantics are NOT enforced by the REST submit path;
|
|
1025
|
+
// say exactly what the tool checked so `ok` is never mistaken for
|
|
1026
|
+
// "the portal would have accepted this" (dogfood R3). This is a
|
|
1027
|
+
// SEPARATE concern from server-side business rules (which DO run on
|
|
1028
|
+
// submit) — name that so `portalRulesExecuted:false` is not misread as
|
|
1029
|
+
// "no rule ran" (dogfood R2).
|
|
1030
|
+
portalRulesExecuted: false,
|
|
1031
|
+
portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
|
|
1032
|
+
requiredFields: validation.requiredFields,
|
|
1033
|
+
missingRequired: validation.missingRequired,
|
|
1034
|
+
...(validation.missingRequired.length > 0
|
|
1035
|
+
? {
|
|
1036
|
+
portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
|
|
1037
|
+
}
|
|
1038
|
+
: {}),
|
|
1039
|
+
// Round 16b (dogfood R1-R3): a submit can leave BOTH a draft row and a
|
|
1040
|
+
// committed row, and deleting only the committed id leaves the draft
|
|
1041
|
+
// behind (data.list still counts it). Name both ids so cleanup does not
|
|
1042
|
+
// silently miss one. Round 17 (dogfood R2): when there is only ONE row
|
|
1043
|
+
// (a workflow submit advances the same row), say so explicitly — the
|
|
1044
|
+
// absence of a hint was itself read as an evidence gap.
|
|
1045
|
+
...(typeof readback.objectId === "string"
|
|
1046
|
+
? draftObjectId && readback.objectId !== draftObjectId
|
|
1047
|
+
? { cleanupHint: `本次提交产生 draft(${draftObjectId}) 与 committed(${String(readback.objectId)}) 两条记录;验收后两条都要 data.delete,直到 data.list total=0。` }
|
|
1048
|
+
: { cleanupHint: `本次提交只有一条记录(${String(readback.objectId)});验收后 data.delete 该 id,直到 data.list total=0。` }
|
|
1049
|
+
: {}),
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
//# sourceMappingURL=runtime-data.js.map
|