@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.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -3
  3. package/coverage/m1-coverage-manifest-v1.json +1278 -0
  4. package/dist/approval-token.js +102 -0
  5. package/dist/args.js +25 -0
  6. package/dist/artifacts.js +119 -0
  7. package/dist/bench/call-face-eval.js +256 -0
  8. package/dist/bench/context-attribution.js +151 -0
  9. package/dist/bench/discovery-cost-eval.js +230 -0
  10. package/dist/bench/driver.js +81 -0
  11. package/dist/bench/evals.js +236 -0
  12. package/dist/bench/fake-http-server.js +65 -0
  13. package/dist/bench/instrument.js +87 -0
  14. package/dist/bench/intent-face-eval.js +343 -0
  15. package/dist/bench/run.js +166 -0
  16. package/dist/bench/scenario.js +343 -0
  17. package/dist/bench/types.js +76 -0
  18. package/dist/bi-wire.js +41 -0
  19. package/dist/bizservice-config.js +581 -0
  20. package/dist/call.js +153 -0
  21. package/dist/capability-absences.js +23 -0
  22. package/dist/capability-overview.js +492 -0
  23. package/dist/capability-shape.js +154 -0
  24. package/dist/cli-contract.js +70 -0
  25. package/dist/cli-output.js +73 -0
  26. package/dist/cli.js +1418 -0
  27. package/dist/code-rules.js +69 -0
  28. package/dist/command-transport.js +155 -0
  29. package/dist/config-store.js +195 -0
  30. package/dist/context.js +21 -0
  31. package/dist/contract-consistency.js +66 -0
  32. package/dist/contract-resources.js +62 -0
  33. package/dist/coverage-consistency.js +62 -0
  34. package/dist/coverage-registry.js +76 -0
  35. package/dist/coverage.js +130 -0
  36. package/dist/data-list-filter.js +114 -0
  37. package/dist/discovery.js +390 -0
  38. package/dist/endpoints.js +154 -0
  39. package/dist/environment-policy.js +26 -0
  40. package/dist/execution-metadata.js +1092 -0
  41. package/dist/fake/app.js +45 -0
  42. package/dist/fake/b2-registration.js +594 -0
  43. package/dist/fake/businessrule.js +242 -0
  44. package/dist/fake/datarule.js +72 -0
  45. package/dist/fake/dictionary.js +108 -0
  46. package/dist/fake/environment.js +30 -0
  47. package/dist/fake/field.js +167 -0
  48. package/dist/fake/form.js +101 -0
  49. package/dist/fake/index.js +121 -0
  50. package/dist/fake/list-view.js +289 -0
  51. package/dist/fake/model.js +136 -0
  52. package/dist/fake/online-js.js +18 -0
  53. package/dist/fake/report.js +253 -0
  54. package/dist/fake/routes.js +47 -0
  55. package/dist/fake/rule-lifecycle.js +37 -0
  56. package/dist/fake/runtime-data.js +367 -0
  57. package/dist/fake/state.js +67 -0
  58. package/dist/fake/workflow.js +364 -0
  59. package/dist/field-change.js +200 -0
  60. package/dist/field-families.js +896 -0
  61. package/dist/form-layout.js +111 -0
  62. package/dist/form-support.js +821 -0
  63. package/dist/goal-routes.js +468 -0
  64. package/dist/governed-execution.js +87 -0
  65. package/dist/human-summary.js +212 -0
  66. package/dist/identity.js +62 -0
  67. package/dist/intent/baseline.js +57 -0
  68. package/dist/intent/capabilities/bizservice.js +274 -0
  69. package/dist/intent/capabilities/businessrule.js +493 -0
  70. package/dist/intent/capabilities/datarule.js +187 -0
  71. package/dist/intent/capabilities/field.js +545 -0
  72. package/dist/intent/capabilities/form.js +136 -0
  73. package/dist/intent/capabilities/index.js +64 -0
  74. package/dist/intent/capabilities/listview.js +157 -0
  75. package/dist/intent/capabilities/model.js +100 -0
  76. package/dist/intent/capabilities/onlinejs.js +108 -0
  77. package/dist/intent/capabilities/report.js +355 -0
  78. package/dist/intent/capabilities/workflow.js +458 -0
  79. package/dist/intent/capability.js +6 -0
  80. package/dist/intent/cli.js +91 -0
  81. package/dist/intent/compare.js +56 -0
  82. package/dist/intent/compiler.js +79 -0
  83. package/dist/intent/dsl.js +129 -0
  84. package/dist/intent/plan-file.js +63 -0
  85. package/dist/intent/readback.js +65 -0
  86. package/dist/intent/schema.js +158 -0
  87. package/dist/intent/validation.js +30 -0
  88. package/dist/intent/yaml.js +315 -0
  89. package/dist/json-column.js +68 -0
  90. package/dist/lanes/app-contract.js +95 -0
  91. package/dist/lanes/app-coverage.js +16 -0
  92. package/dist/lanes/app.js +174 -0
  93. package/dist/lanes/apply-changes.js +231 -0
  94. package/dist/lanes/b2-registration-contract.js +292 -0
  95. package/dist/lanes/b2-registration-coverage.js +48 -0
  96. package/dist/lanes/b2-registration.js +1187 -0
  97. package/dist/lanes/businessrule-contract.js +232 -0
  98. package/dist/lanes/businessrule-coverage.js +16 -0
  99. package/dist/lanes/businessrule.js +221 -0
  100. package/dist/lanes/contract-support.js +65 -0
  101. package/dist/lanes/coverage-declaration.js +9 -0
  102. package/dist/lanes/datarule-contract.js +155 -0
  103. package/dist/lanes/datarule-coverage.js +19 -0
  104. package/dist/lanes/datarule-protocol.js +308 -0
  105. package/dist/lanes/datarule.js +818 -0
  106. package/dist/lanes/dictionary-contract.js +78 -0
  107. package/dist/lanes/dictionary-coverage.js +24 -0
  108. package/dist/lanes/dictionary.js +235 -0
  109. package/dist/lanes/environment-contract.js +70 -0
  110. package/dist/lanes/environment-coverage.js +14 -0
  111. package/dist/lanes/environment.js +163 -0
  112. package/dist/lanes/field-contract.js +223 -0
  113. package/dist/lanes/field-coverage.js +27 -0
  114. package/dist/lanes/field.js +374 -0
  115. package/dist/lanes/form-contract.js +97 -0
  116. package/dist/lanes/form-coverage.js +16 -0
  117. package/dist/lanes/form.js +185 -0
  118. package/dist/lanes/lane-ids.js +34 -0
  119. package/dist/lanes/list-view-contract.js +175 -0
  120. package/dist/lanes/list-view-coverage.js +20 -0
  121. package/dist/lanes/list-view-shapes.js +1207 -0
  122. package/dist/lanes/list-view.js +578 -0
  123. package/dist/lanes/meta-contract.js +85 -0
  124. package/dist/lanes/meta.js +255 -0
  125. package/dist/lanes/model-contract.js +168 -0
  126. package/dist/lanes/model-coverage.js +20 -0
  127. package/dist/lanes/model.js +986 -0
  128. package/dist/lanes/online-js-contract.js +99 -0
  129. package/dist/lanes/online-js-coverage.js +28 -0
  130. package/dist/lanes/online-js.js +127 -0
  131. package/dist/lanes/report-contract.js +144 -0
  132. package/dist/lanes/report-coverage.js +21 -0
  133. package/dist/lanes/report.js +476 -0
  134. package/dist/lanes/rule-graph.js +1846 -0
  135. package/dist/lanes/rule-lifecycle-contract.js +92 -0
  136. package/dist/lanes/rule-lifecycle-coverage.js +20 -0
  137. package/dist/lanes/rule-lifecycle.js +176 -0
  138. package/dist/lanes/runtime-data-contract.js +264 -0
  139. package/dist/lanes/runtime-data-coverage.js +25 -0
  140. package/dist/lanes/runtime-data.js +1054 -0
  141. package/dist/lanes/workflow-contract.js +223 -0
  142. package/dist/lanes/workflow-coverage.js +40 -0
  143. package/dist/lanes/workflow.js +1813 -0
  144. package/dist/online-js-layout.js +58 -0
  145. package/dist/online-js-source.js +276 -0
  146. package/dist/package-tool.js +51 -0
  147. package/dist/package.js +73 -0
  148. package/dist/plan.js +73 -0
  149. package/dist/read.js +144 -0
  150. package/dist/redact.js +28 -0
  151. package/dist/rule-support.js +587 -0
  152. package/dist/runtime-support.js +134 -0
  153. package/dist/server.js +92 -0
  154. package/dist/session-manager.js +30 -0
  155. package/dist/session.js +149 -0
  156. package/dist/skills.js +112 -0
  157. package/dist/support.js +98 -0
  158. package/dist/tool-types.js +127 -0
  159. package/dist/tools.js +59 -0
  160. package/dist/usage-log.js +197 -0
  161. package/dist/wire.js +287 -0
  162. package/dist/workflow-support.js +99 -0
  163. package/dist/write-lease.js +26 -0
  164. package/dist/write-lock.js +109 -0
  165. package/dist/write.js +176 -0
  166. package/dist/zip.js +156 -0
  167. package/docs/tool-surface-map.md +42 -0
  168. package/package.json +71 -6
  169. package/skills/omc-acceptance-criteria.md +47 -0
  170. package/skills/omc-business-configuration.md +257 -0
  171. package/skills/omc-capabilities.md +197 -0
  172. package/skills/omc-capability-scouting.md +55 -0
  173. package/skills/omc-config-draft-review.md +171 -0
  174. package/skills/omc-five-piece-flow.md +35 -0
  175. package/skills/omc-glossary.md +86 -0
  176. package/skills/omc-refusals.md +110 -0
  177. package/skills/omc-requirement-analysis.md +161 -0
  178. package/skills/omc-requirement-vocabulary.md +51 -0
  179. package/skills/omc-start-here.md +82 -0
  180. package/skills/omc-tool-selection.md +119 -0
  181. package/skills/omc-write-hazards.md +87 -0
@@ -0,0 +1,821 @@
1
+ import { wireCall, wireRows, WireError } from "./wire.js";
2
+ import { ENDPOINTS } from "./endpoints.js";
3
+ import { decodeJsonObjectColumn } from "./json-column.js";
4
+ import { ToolRefusalError } from "./tool-types.js";
5
+ import { FIELD_CONTROL_TYPES, SYSTEM_FIELD_CODES, fieldControlOptions, relationDisplayField, relationMappings, } from "./field-families.js";
6
+ import { deriveControlOptions } from "./capability-shape.js";
7
+ import { validateFormLayoutShape } from "./form-layout.js";
8
+ /**
9
+ * Propagate each relation field's `options.mappings` into its form control so
10
+ * the form-fill carry is configured where the form renderer reads it. The
11
+ * field is the source of truth; only controls matching a field code with a
12
+ * relation mapping or display field are touched.
13
+ */
14
+ export function propagateRelationOptions(attributesJson, fields) {
15
+ let attributes;
16
+ try {
17
+ attributes = JSON.parse(attributesJson);
18
+ }
19
+ catch {
20
+ return attributesJson;
21
+ }
22
+ for (const field of fields) {
23
+ if (!field.mappings && !field.displayField)
24
+ continue;
25
+ const control = attributes[field.code];
26
+ if (control && typeof control === "object" && control.options && typeof control.options === "object") {
27
+ if (field.mappings)
28
+ control.options.mappings = field.mappings;
29
+ if (field.displayField) {
30
+ control.options.displayField = field.displayField;
31
+ control.options.relativePropertyMatchCode = field.displayField;
32
+ // Ticket 61 / A4: the runtime resolves the displayed value from the
33
+ // CONTROL's relativePropertyCode, not just displayField — without it
34
+ // a field-level display-field change never reaches the running form.
35
+ control.options.relativePropertyCode = field.displayField;
36
+ }
37
+ }
38
+ }
39
+ return JSON.stringify(attributes);
40
+ }
41
+ export const propagateRelationMappings = propagateRelationOptions;
42
+ /** Validate the JSON shape consumed by the Online JS HTML renderer. */
43
+ export function validateHtmlRender(value, requirePlatformSkeleton = false) {
44
+ if (value === undefined || value === null)
45
+ return { ok: true, skipped: true };
46
+ if (typeof value !== "string" || value.trim() === "") {
47
+ return { ok: false, violations: [{ index: -1, problem: "html must be a non-empty JSON element array" }] };
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(value);
52
+ }
53
+ catch {
54
+ return { ok: false, violations: [{ index: -1, problem: "html must be valid JSON" }] };
55
+ }
56
+ if (!Array.isArray(parsed) || parsed.length === 0) {
57
+ return { ok: false, violations: [{ index: -1, problem: "html must be a non-empty JSON element array" }] };
58
+ }
59
+ const violations = [];
60
+ const ids = new Set();
61
+ parsed.forEach((element, index) => {
62
+ if (!element || typeof element !== "object" || Array.isArray(element)) {
63
+ violations.push({ index, problem: "element must be an object" });
64
+ return;
65
+ }
66
+ const record = element;
67
+ const tagNameInvalid = typeof record.tagName !== "string" || record.tagName.trim() === "";
68
+ const attrsInvalid = !Array.isArray(record.attrs);
69
+ if (tagNameInvalid || attrsInvalid)
70
+ violations.push({ index, problem: "tagName missing or attrs not an array" });
71
+ if (typeof record.id === "string")
72
+ ids.add(record.id);
73
+ });
74
+ if (requirePlatformSkeleton) {
75
+ for (const id of ["toolbar", "template", "beforeCustomScript", "customScript", "afterCustomScript"]) {
76
+ if (!ids.has(id))
77
+ violations.push({ index: -1, problem: `platform skeleton missing ${id}` });
78
+ }
79
+ if (!parsed.some((element) => element && typeof element === "object" && !Array.isArray(element) && element.tagName === "style")) {
80
+ violations.push({ index: -1, problem: "platform skeleton missing style" });
81
+ }
82
+ }
83
+ return violations.length === 0 ? { ok: true } : { ok: false, violations };
84
+ }
85
+ export function assertHtmlRender(value, details, requirePlatformSkeleton = false) {
86
+ const compat = validateHtmlRender(value, requirePlatformSkeleton);
87
+ if (!compat.ok) {
88
+ throw new ToolRefusalError({
89
+ status: "refused",
90
+ blocker: "form-html-render-invalid",
91
+ ...details,
92
+ htmlRenderCompat: compat,
93
+ message: "draftHtmlJson 未通过 Online JS HTML 渲染形状校验;拒绝写入/提拔,零 wire",
94
+ });
95
+ }
96
+ }
97
+ /**
98
+ * Ticket 69 — the org-selection discriminator per field property type
99
+ * (人员单选 50 / 人员多选 51 / 部门单选 60 / 部门多选 61 / 混合选人 5).
100
+ * `options.deptVisible` + `options.multi` are what the platform reads
101
+ * (BizPropertyTypeUpdateUtils.disposeUpdateType,
102
+ * DataModelInitUtils.updateSelectionType); the control type alone is not
103
+ * enough — a 人员单选 without deptVisible opened a department picker.
104
+ */
105
+ export function orgSelectorDiscriminator(fieldType) {
106
+ switch (fieldType) {
107
+ case 50: return { deptVisible: "user", multi: "false" };
108
+ case 51: return { deptVisible: "user", multi: "true" };
109
+ case 60: return { deptVisible: "org", multi: "false" };
110
+ case 61: return { deptVisible: "org", multi: "true" };
111
+ case 5: return { deptVisible: "all", multi: "true" };
112
+ default: return undefined;
113
+ }
114
+ }
115
+ /**
116
+ * Ticket 69 — normalise every org-selection control in an existing draft to
117
+ * carry the discriminator, so a form drafted before the fix (or by the
118
+ * designer) is repaired on the next form.draft and works after re-publish.
119
+ */
120
+ export function normalizeOrgSelectorControls(attributesJson, fields) {
121
+ let attributes;
122
+ try {
123
+ attributes = JSON.parse(attributesJson);
124
+ }
125
+ catch {
126
+ return attributesJson;
127
+ }
128
+ for (const field of fields) {
129
+ const discriminator = orgSelectorDiscriminator(field.type);
130
+ if (!discriminator)
131
+ continue;
132
+ const control = attributes[field.code];
133
+ if (!control || typeof control !== "object" || typeof control.options !== "object" || control.options === null)
134
+ continue;
135
+ const defaults = deriveControlOptions(field.type);
136
+ for (const [key, value] of Object.entries(defaults))
137
+ if (control.options[key] === undefined)
138
+ control.options[key] = value;
139
+ control.options.deptVisible = discriminator.deptVisible;
140
+ control.options.multi = discriminator.multi;
141
+ if (control.options.defaultValue === undefined)
142
+ control.options.defaultValue = [];
143
+ if (control.options.defaultValueType === undefined)
144
+ control.options.defaultValueType = "";
145
+ if (control.options.orgRoot === undefined)
146
+ control.options.orgRoot = [];
147
+ if (control.options.roles === undefined)
148
+ control.options.roles = "";
149
+ if (control.options.mappings === undefined)
150
+ control.options.mappings = "";
151
+ if (control.options.displayType === undefined)
152
+ control.options.displayType = "tag";
153
+ if (control.options.selectMode === undefined)
154
+ control.options.selectMode = "popup";
155
+ }
156
+ return JSON.stringify(attributes);
157
+ }
158
+ /**
159
+ * The form-interaction condition keys are FIELD-owned: the designer stores the
160
+ * condition on the field (field options) and the runtime renderer reads the
161
+ * COPY on the control (`control.options.requiredFormula` / `readonlyCondition`,
162
+ * live bundle 19497: `if(t.options.requiredFormula) u.required=...`).
163
+ *
164
+ * The scaffold copies them when it first builds a control, but a
165
+ * `form.draft`/`configure_form` WITHOUT `rebuild` keeps every existing control
166
+ * verbatim — so a later `field.update` that adds/changes a condition never
167
+ * reached an already-built form. Live bench20: the agent set the conditions
168
+ * AFTER the first configure_form, re-ran configure_form without rebuild, read
169
+ * `ok`, and the portal showed no condition. Sync the field-owned keys into the
170
+ * existing controls on EVERY draft write; keys absent on the field are left
171
+ * untouched, so a designer-only condition survives (never clobber).
172
+ */
173
+ const FIELD_OWNED_CONTROL_KEYS = ["requiredFormula", "readonlyFormula", "readonlyCondition", "displayFormula", "optionsSet"];
174
+ /**
175
+ * Repair relation child columns on an EXISTING child-table (201) control. The
176
+ * 2026-09-23 scaffold dropped `relativeCode/mappings/displayField` from child
177
+ * properties, so a WORK_SHEET child column was written with `schemaCode:""` and
178
+ * bizsheet/publish then failed 301015 "[列名]未选择关联模型" (BizFormController
179
+ * checkRelationCode requires options.schemaCode for column type 80/81/90).
180
+ * A non-rebuild draft keeps controls verbatim, so fill the missing relation keys
181
+ * in place from the model's subSchema — a designer-set non-empty schemaCode is
182
+ * never clobbered.
183
+ */
184
+ export function repairChildTableRelationColumns(attributesJson, fields) {
185
+ let attributes;
186
+ try {
187
+ attributes = JSON.parse(attributesJson);
188
+ }
189
+ catch {
190
+ return attributesJson;
191
+ }
192
+ for (const field of fields) {
193
+ if (field.type !== 8 || !field.childProperties)
194
+ continue;
195
+ const control = attributes[field.code];
196
+ if (!control || !Array.isArray(control.columns))
197
+ continue;
198
+ for (const column of control.columns) {
199
+ const child = field.childProperties.find((entry) => entry.code === column.key);
200
+ if (!child || (child.type !== 9 && child.type !== 11) || !child.relativeCode)
201
+ continue;
202
+ const options = column.options && typeof column.options === "object" && !Array.isArray(column.options)
203
+ ? column.options
204
+ : (column.options = {});
205
+ if (typeof options.schemaCode === "string" && options.schemaCode.trim() !== "")
206
+ continue;
207
+ options.schemaCode = child.relativeCode;
208
+ options.queryCode = child.relativeCode;
209
+ if (options.displayField === undefined || options.displayField === "")
210
+ options.displayField = child.displayField ?? "name";
211
+ if (options.relativePropertyMatchCode === undefined || options.relativePropertyMatchCode === "")
212
+ options.relativePropertyMatchCode = child.displayField ?? "name";
213
+ if (options.mappings === undefined || options.mappings === "")
214
+ options.mappings = child.mappings ?? "";
215
+ }
216
+ }
217
+ return JSON.stringify(attributes);
218
+ }
219
+ export function syncFieldDerivedControlOptions(attributesJson, fields) {
220
+ let attributes;
221
+ try {
222
+ attributes = JSON.parse(attributesJson);
223
+ }
224
+ catch {
225
+ return attributesJson;
226
+ }
227
+ for (const field of fields) {
228
+ if (field.fieldOptions === undefined)
229
+ continue;
230
+ const control = attributes[field.code];
231
+ if (!control || typeof control !== "object" || typeof control.options !== "object" || control.options === null)
232
+ continue;
233
+ for (const key of FIELD_OWNED_CONTROL_KEYS) {
234
+ const value = field.fieldOptions[key];
235
+ if (value !== undefined)
236
+ control.options[key] = value;
237
+ }
238
+ }
239
+ return JSON.stringify(attributes);
240
+ }
241
+ /**
242
+ * Ticket 66 — a model's "redundant" fields are the carry-through targets of
243
+ * its relation mappings (`options.mappings[{source,target}]`): values auto-filled
244
+ * from the related model, not hand-entered. They must still be bound on the
245
+ * form; only their visibility is governed (form-design hidden when the model
246
+ * has no workflow, else per-workflow-node permissions).
247
+ */
248
+ export function redundantFieldCodes(fields) {
249
+ const targets = new Set();
250
+ for (const field of fields) {
251
+ if (field.type !== 9 && field.type !== 11)
252
+ continue;
253
+ if (!field.mappings)
254
+ continue;
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(field.mappings);
258
+ }
259
+ catch {
260
+ continue;
261
+ }
262
+ if (!Array.isArray(parsed))
263
+ continue;
264
+ for (const entry of parsed) {
265
+ const target = entry?.target;
266
+ if (typeof target === "string" && target.trim())
267
+ targets.add(target.trim());
268
+ }
269
+ }
270
+ return targets;
271
+ }
272
+ /**
273
+ * Read a model's default form. CloudPivot answers errcode 302024
274
+ * ("表单不存在!") when the model has no form row yet — that is absence, not a
275
+ * failure, so callers get null and may create it.
276
+ */
277
+ async function readFormOrNull(session, schemaCode, sheetCode) {
278
+ try {
279
+ const result = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode } });
280
+ return (result.data ?? null);
281
+ }
282
+ catch (error) {
283
+ if (error instanceof WireError && error.errcode === 302024)
284
+ return null;
285
+ throw error;
286
+ }
287
+ }
288
+ /**
289
+ * Create the default form row for a model that has none (bizsheet/create;
290
+ * sheetType 0 = BizFormType.DEFAULT_SHEET). A model created via bizmodels/create
291
+ * carries no form, so form.draft must materialise it before it can hold a
292
+ * draft layout. The subsequent scaffold+update writes the layout.
293
+ */
294
+ async function createDefaultForm(session, schemaCode, sheetCode) {
295
+ await wireCall(session, {
296
+ path: ENDPOINTS.sheetCreate,
297
+ method: "POST",
298
+ body: {
299
+ schemaCode,
300
+ code: sheetCode,
301
+ name: sheetCode,
302
+ name_i18n: JSON.stringify({ "zh-CN": sheetCode, en: sheetCode }),
303
+ sheetType: 0,
304
+ },
305
+ });
306
+ }
307
+ /**
308
+ * Ticket 102 — families that occupy a full row (span 24) in real forms:
309
+ * long text and attachments are inherently wide (fwb evidence §rows:
310
+ * business rows are 1×24 long-text/attachment, 2×12 pairs of narrow fields).
311
+ * Every other business family packs two per row at span:12.
312
+ */
313
+ function wideBusinessFamily(type) {
314
+ return type === 1 || type === 6 || type === 8;
315
+ }
316
+ /**
317
+ * Sheet201 form control for a CHILD_TABLE(8) field: the parent control carries
318
+ * the child columns as `columns`, each keyed by the parent field code. Control
319
+ * `options` is a plain OBJECT (like every other scaffold control — the runtime
320
+ * 8.6.34 renderer mutates `control.options.name_i18n` and throws
321
+ * "Cannot create property 'name_i18n' on string" if it is a JSON string).
322
+ * Child column keys are NEVER added to viewJson — only the parent key is a
323
+ * layout row.
324
+ */
325
+ function childTableColumns(parentCode, children) {
326
+ return children.map((child) => ({
327
+ type: FIELD_CONTROL_TYPES[child.type] ?? 1,
328
+ width: "180",
329
+ key: child.code,
330
+ parentKey: parentCode,
331
+ // A relation child column (WORK_SHEET 9 / MULT_WORK_SHEET 11) is a WORK_SHEET
332
+ // form control (80/81) and MUST carry its target `schemaCode` — dropping it
333
+ // makes bizsheet/publish fail 301015 "[列名]未选择关联模型" (BizFormController
334
+ // checkRelationCode: WORK_SHEET = {80,81,90} require options.schemaCode).
335
+ options: fieldControlOptions(child.type, child.name, child.relativeCode, child.mappings, child.displayField ?? "name", child.fieldOptions),
336
+ }));
337
+ }
338
+ /**
339
+ * Keep the designer's non-structural control edits across a rebuild. The
340
+ * freshly scaffolded control owns its label and relation target keys (they must
341
+ * track the field metadata), but everything else the designer changed on the
342
+ * draft — carry `mappings`, `visible`, `labelVisible`, `span`, conditions — is
343
+ * layered back from the previous draft. Child-table (201) controls are skipped:
344
+ * their columns are regenerated from the model's subSchema.
345
+ */
346
+ export function preserveDesignerControlOptions(next, previous) {
347
+ const STRUCTURAL = new Set(["name", "name_i18n", "schemaCode", "displayField", "queryCode", "relativePropertyMatchCode"]);
348
+ const preserved = [];
349
+ for (const [key, control] of Object.entries(next)) {
350
+ const fresh = control;
351
+ const prior = previous[key];
352
+ if (!prior || typeof prior !== "object")
353
+ continue;
354
+ if (fresh.type === 201 || prior.type === 201)
355
+ continue;
356
+ if (!fresh.options || typeof fresh.options !== "object" || Array.isArray(fresh.options))
357
+ continue;
358
+ if (!prior.options || typeof prior.options !== "object" || Array.isArray(prior.options))
359
+ continue;
360
+ const merged = { ...fresh.options };
361
+ for (const [optionKey, value] of Object.entries(prior.options)) {
362
+ if (STRUCTURAL.has(optionKey) || value === undefined)
363
+ continue;
364
+ merged[optionKey] = value;
365
+ }
366
+ fresh.options = merged;
367
+ preserved.push(key);
368
+ }
369
+ return preserved;
370
+ }
371
+ function childTableParentControl(field) {
372
+ return {
373
+ key: field.code,
374
+ type: 201,
375
+ options: {
376
+ name: field.name,
377
+ name_i18n: { "zh-CN": field.name, en: field.name },
378
+ visible: true,
379
+ labelVisible: true,
380
+ span: 24,
381
+ widgetType: "子表",
382
+ width: 150,
383
+ displayPosition: "right",
384
+ rows: 0,
385
+ editable: true,
386
+ importable: true,
387
+ exportable: true,
388
+ addable: true,
389
+ deletable: true,
390
+ copyAble: true,
391
+ clearRowAble: true,
392
+ showTotal: false,
393
+ presetButtonManage: "",
394
+ summary: "",
395
+ },
396
+ columns: childTableColumns(field.code, field.childProperties ?? []),
397
+ };
398
+ }
399
+ /**
400
+ * Build the designer-standard default-form scaffold (research §13): Title
401
+ * (203, named after the model, useDataTitle) + system row [creater(101),
402
+ * createdTime(104), sequenceNo(100)] + one group (200), followed by the
403
+ * business fields with their mapped controls, packed compactly (ticket 102:
404
+ * narrow fields two per row at span:12; wide families alone at span:24 —
405
+ * row totals never exceed 24). Relation controls carry the target schema
406
+ * from the field's relativeCode. Used only when the target default form's
407
+ * draft layout is empty.
408
+ */
409
+ export function buildFormScaffold(fields, formName, hiddenCodes) {
410
+ const ts = Date.now();
411
+ const titleKey = `Title${ts}`;
412
+ const groupKey = `Group${ts}`;
413
+ const business = fields.filter((field) => !SYSTEM_FIELD_CODES.has(field.code) && FIELD_CONTROL_TYPES[field.type] && scaffoldControlConfigPresent(field));
414
+ const businessRows = [];
415
+ let pair = [];
416
+ for (const field of business) {
417
+ if (wideBusinessFamily(field.type)) {
418
+ // Flush any pending pair FIRST so the emitted row order always matches
419
+ // the field order (review finding: a wide family mid-pair used to jump
420
+ // the queue and reorder the layout).
421
+ if (pair.length > 0) {
422
+ businessRows.push(pair);
423
+ pair = [];
424
+ }
425
+ businessRows.push([field.code]);
426
+ continue;
427
+ }
428
+ pair.push(field.code);
429
+ if (pair.length === 2) {
430
+ businessRows.push(pair);
431
+ pair = [];
432
+ }
433
+ }
434
+ if (pair.length > 0)
435
+ businessRows.push(pair);
436
+ const rows = [[titleKey], ["creater", "createdTime", "sequenceNo"], [groupKey], ...businessRows];
437
+ const attributes = {
438
+ [titleKey]: { key: titleKey, type: 203, options: { name: formName, name_i18n: { en: formName, zh: formName }, visible: true, labelVisible: true, span: 24, labelAlign: "", style: "", tips: "", dataItemName: "", useDataTitle: true, styleInfo: { height: 1, bgColor: "#ffffff", bgImg: "", textAlign: "center", fontSize: 28, textColor: "#111218", blod: true, italic: false, textCss: "", textCssSwitch: false } } },
439
+ creater: { key: "creater", type: 101, options: { name: "创建人", name_i18n: "", visible: true, labelVisible: true, span: 8, labelAlign: "", style: "", tips: "", dataItemName: "", widgetType: "", dataItemType: "", mappings: "", verifyFormula: "", defaultValue: "" } },
440
+ createdTime: { key: "createdTime", type: 104, options: { name: "创建时间", name_i18n: "", visible: true, labelVisible: true, span: 8, labelAlign: "", style: "", tips: "", dataItemName: "", widgetType: "", dataItemType: "", format1: "YYYY-MM-DD HH:mm:ss", format: "YYYY-MM-DD HH:mm:ss", syncFormate: true, verifyFormula: "", defaultValue: "" } },
441
+ sequenceNo: { key: "sequenceNo", type: 100, options: { name: "单据号", name_i18n: "", visible: true, labelVisible: true, span: 8, labelAlign: "", style: "", tips: "", dataItemName: "", widgetType: "", dataItemType: "", seqNoSetting: [{ type: "UID", value: "aa860e64ff2e4f86851a661d663ae358" }], formPropertyType: 100, verifyFormula: "", displayFormula: "", defaultValue: "" } },
442
+ [groupKey]: { key: groupKey, type: 200, options: { name: "业务信息", tips: "", expand: true, align: "", style: "", styleInfo: { fontSize: 18, textColor: "#111218", blod: true, italic: false, textCss: "", textCssSwitch: false }, span: 24 } },
443
+ };
444
+ for (const field of business) {
445
+ if (field.type === 8) {
446
+ attributes[field.code] = childTableParentControl(field);
447
+ continue;
448
+ }
449
+ const control = { key: field.code, type: FIELD_CONTROL_TYPES[field.type], options: fieldControlOptions(field.type, field.name, field.relativeCode, field.mappings, field.displayField, field.fieldOptions) };
450
+ // Ticket 102: compact packing — narrow families span 12 (two per row),
451
+ // wide families keep the full 24.
452
+ control.options.span = wideBusinessFamily(field.type) ? 24 : 12;
453
+ // Ticket 66: a redundant (carry-through) field with no workflow to govern
454
+ // it per node is hidden in the form design; it stays bound so the carried
455
+ // value is still present on the form.
456
+ if (hiddenCodes?.has(field.code) && control.options && typeof control.options === "object") {
457
+ control.options.visible = false;
458
+ }
459
+ attributes[field.code] = control;
460
+ }
461
+ return { rows, attributes };
462
+ }
463
+ /**
464
+ * A control is only scaffolded when the family's strict configuration is
465
+ * present on the field. A FORMULA control without formulaConfig or a
466
+ * RELEVANCE_DATA control without relevanceField/referenceField would render an
467
+ * invalid control (admin validation refuses both), so such fields are skipped
468
+ * rather than bound with fabricated options.
469
+ */
470
+ function scaffoldControlConfigPresent(field) {
471
+ if (field.type === 100)
472
+ return typeof field.fieldOptions?.formulaConfig === "string" && field.fieldOptions.formulaConfig.length > 0;
473
+ if (field.type === 16)
474
+ return typeof field.fieldOptions?.relevanceField === "string" && field.fieldOptions.relevanceField.length > 0 && typeof field.fieldOptions?.referenceField === "string" && field.fieldOptions.referenceField.length > 0;
475
+ if (field.type === 8)
476
+ return Array.isArray(field.childProperties) && field.childProperties.length > 0;
477
+ return true;
478
+ }
479
+ /**
480
+ * Ticket 74 — an explicitly provided layout string that parses to an empty
481
+ * array/object ("[]", "{}", whitespace variants). The platform designer's
482
+ * Save is NOT lossless: it clears draftViewJson to "[]" and
483
+ * draftAttributesJson to "{}", and promoting that draft loses every
484
+ * control. Writing such a value explicitly is refused unless the caller
485
+ * passes allowEmptyLayout:true.
486
+ */
487
+ export function isEmptyLayoutJson(value) {
488
+ const trimmed = value.trim();
489
+ if (trimmed.length === 0)
490
+ return true;
491
+ try {
492
+ const parsed = JSON.parse(trimmed);
493
+ if (Array.isArray(parsed))
494
+ return parsed.length === 0;
495
+ if (parsed && typeof parsed === "object")
496
+ return Object.keys(parsed).length === 0;
497
+ return parsed === null;
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ }
503
+ /**
504
+ * Distribution-grade dry-lint for a CALLER-PROVIDED form layout (args, not the
505
+ * stored draft). These are the shapes that render fine on readback but blank
506
+ * the 8.6.34 page — the CLI refuses them before any wire call so a caller who
507
+ * never read the docs still cannot ship a broken form. Runs on ANY provided
508
+ * face (one-sided included), covering configure_form/apply_changes paths that
509
+ * do not pass through the `form.draft` write permission gate.
510
+ */
511
+ export function assertCallerFormLayout(args) {
512
+ if (args.draftViewJson === undefined && args.draftAttributesJson === undefined)
513
+ return;
514
+ const parse = (value) => {
515
+ if (typeof value !== "string")
516
+ return undefined;
517
+ try {
518
+ return JSON.parse(value);
519
+ }
520
+ catch {
521
+ return undefined;
522
+ }
523
+ };
524
+ const violations = validateFormLayoutShape(parse(args.draftViewJson), parse(args.draftAttributesJson));
525
+ if (violations.length > 0) {
526
+ throw new ToolRefusalError({
527
+ status: "refused",
528
+ blocker: "form-layout-invalid",
529
+ schemaCode: args.schemaCode,
530
+ sheetCode: args.sheetCode,
531
+ validation: violations,
532
+ message: `调用方提供的表单布局形状非法(写前零 wire 拒绝):${violations.map((v) => v.field ?? v.classification).join("、")}`,
533
+ });
534
+ }
535
+ }
536
+ /** Ticket 74 — control count on an attributes JSON face (0 when unparsable/absent). */
537
+ function controlCount(face) {
538
+ if (typeof face !== "string")
539
+ return 0;
540
+ try {
541
+ const parsed = JSON.parse(face);
542
+ return parsed && typeof parsed === "object" ? Object.keys(parsed).length : 0;
543
+ }
544
+ catch {
545
+ return 0;
546
+ }
547
+ }
548
+ /**
549
+ * Shared form.draft write body — read the current sheet, compose a scaffold
550
+ * from published fields when the form is empty, and POST the proven payload
551
+ * to bizsheet/update. Used by the form.draft write tool AND by configure_form,
552
+ * so the high-level orchestrator never bypasses the scaffold.
553
+ */
554
+ export async function executeFormDraftWrite(session, args) {
555
+ if (args.draftHtmlJson !== undefined) {
556
+ assertHtmlRender(args.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
557
+ }
558
+ // Caller-provided layouts get the distribution-grade shape gate (options
559
+ // objects, string viewJson cells, no child keys as rows) before any wire.
560
+ if (args.draftViewJson !== undefined || args.draftAttributesJson !== undefined)
561
+ assertCallerFormLayout(args);
562
+ // Ticket 74 — refuse CALLER-PROVIDED empty layouts before any wire call.
563
+ // The platform designer's Save clears draft faces to "[]"/"{}" (non-lossless);
564
+ // re-writing that shape explicitly is how the 13-control loss happened.
565
+ if (args.allowEmptyLayout !== true) {
566
+ const emptyFaces = [];
567
+ if (typeof args.draftViewJson === "string" && isEmptyLayoutJson(args.draftViewJson))
568
+ emptyFaces.push("draftViewJson");
569
+ if (typeof args.draftAttributesJson === "string" && isEmptyLayoutJson(args.draftAttributesJson))
570
+ emptyFaces.push("draftAttributesJson");
571
+ if (emptyFaces.length > 0) {
572
+ throw new ToolRefusalError({
573
+ status: "refused",
574
+ blocker: "form-empty-layout",
575
+ schemaCode: args.schemaCode,
576
+ sheetCode: args.sheetCode,
577
+ faces: emptyFaces,
578
+ message: `显式提供的 ${emptyFaces.join("、")} 是空布局("[]"/"{}")——平台设计器 Save 会把草稿清成该形态(非无损),照写会丢控件。若确要清空布局请显式传 allowEmptyLayout:true;若是设计器 Save 后的修复,先用表单版本历史(/api/api/app/bizsheet/history/list)取回完好版本原文再写回`,
579
+ });
580
+ }
581
+ }
582
+ // Ticket 48: an explicit fieldCodes selection shapes the scaffold — exactly
583
+ // those fields, in the given order. Validate against the published face
584
+ // BEFORE creating anything so an unknown code never writes a form row.
585
+ const wanted = args.fieldCodes;
586
+ let selectionUnknown;
587
+ if (wanted && args.draftAttributesJson === undefined) {
588
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
589
+ const publishedCodes = new Set(rows.map((row) => String(row.code ?? row.propertyCode ?? "")));
590
+ selectionUnknown = wanted.filter((code) => !publishedCodes.has(code));
591
+ }
592
+ if (selectionUnknown && selectionUnknown.length > 0) {
593
+ throw new ToolRefusalError({
594
+ status: "refused",
595
+ blocker: "form-fieldcodes-unknown",
596
+ schemaCode: args.schemaCode,
597
+ fieldCodes: selectionUnknown,
598
+ message: `fieldCodes 引用了模型 ${args.schemaCode} 发布面上不存在的字段:${selectionUnknown.join("、")};先 field.create + field.publish 这些字段,或修正 fieldCodes`,
599
+ });
600
+ }
601
+ // The default form may not exist yet on a freshly created model
602
+ // (bizmodels/create seeds no form) — create it, then scaffold into it.
603
+ let pre = await readFormOrNull(session, args.schemaCode, args.sheetCode);
604
+ if (pre === null) {
605
+ await createDefaultForm(session, args.schemaCode, args.sheetCode);
606
+ // Creation must be immediately authoritative before composing an update
607
+ // body; do not continue against an assumed/asynchronously visible form.
608
+ pre = await readFormOrNull(session, args.schemaCode, args.sheetCode);
609
+ if (pre === null)
610
+ throw new WireError("business", `form ${args.sheetCode} was created but is not readable`, 302024);
611
+ }
612
+ // Field-level relation mappings are the source of truth; read the published
613
+ // fields so the scaffold (or an existing draft) can carry them into the
614
+ // matching form control, where the form renderer reads them at fill time.
615
+ const publishedFields = args.draftAttributesJson === undefined
616
+ ? await Promise.all(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data).map(async (row) => {
617
+ const type = Number(row.type ?? row.propertyType);
618
+ const relativeCode = row.relativeCode ? String(row.relativeCode) : undefined;
619
+ // fieldOptions face decode; policy: corrupt strings throw, objects accepted, missing → undefined.
620
+ const fieldOptions = decodeJsonObjectColumn(row.options, { invalid: "throw", array: "accept", object: "accept" });
621
+ let displayField;
622
+ if (relativeCode && (type === 9 || type === 11)) {
623
+ displayField = relationDisplayField(row.options);
624
+ if (!displayField) {
625
+ const targetRows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: relativeCode, isPublish: "true" } })).data);
626
+ const title = targetRows.find((target) => String(target.code ?? target.propertyCode) === "name" || target.name === "数据标题");
627
+ displayField = title ? String(title.code ?? title.propertyCode) : "name";
628
+ }
629
+ }
630
+ const mappings = relationMappings(row.options);
631
+ // CHILD_TABLE(8): the child columns live on the row's subSchema; carry
632
+ // them onto the form control as Sheet201 columns.
633
+ const subSchema = type === 8 ? decodeJsonObjectColumn(row.subSchema, { invalid: "undefined", array: "reject", object: "accept" }) : undefined;
634
+ const rawChildren = subSchema && Array.isArray(subSchema.properties) ? subSchema.properties : undefined;
635
+ const childProperties = rawChildren
636
+ ?.filter((child) => child.defaultProperty !== true && !SYSTEM_FIELD_CODES.has(String(child.code ?? "")))
637
+ .map((child) => {
638
+ const childOptions = decodeJsonObjectColumn(child.options, { invalid: "undefined", array: "accept", object: "accept" });
639
+ const childType = Number(child.type ?? child.propertyType);
640
+ const childRelativeCode = child.relativeCode ? String(child.relativeCode) : undefined;
641
+ // Relation child columns MUST keep their target schema + carried
642
+ // mappings on the WORK_SHEET control, or bizsheet/publish answers
643
+ // 301015 (see childTableColumns).
644
+ const childMappings = relationMappings(child.options);
645
+ const childDisplayField = childRelativeCode && (childType === 9 || childType === 11) ? relationDisplayField(child.options) : undefined;
646
+ return {
647
+ code: String(child.code ?? ""),
648
+ name: String(child.name ?? child.code ?? ""),
649
+ type: childType,
650
+ ...(childRelativeCode ? { relativeCode: childRelativeCode } : {}),
651
+ ...(childMappings ? { mappings: childMappings } : {}),
652
+ ...(childDisplayField ? { displayField: childDisplayField } : {}),
653
+ ...(childOptions ? { fieldOptions: childOptions } : {}),
654
+ };
655
+ })
656
+ .filter((child) => child.code.length > 0 && Number.isFinite(child.type));
657
+ return {
658
+ code: String(row.code ?? row.propertyCode),
659
+ name: String(row.name ?? row.code ?? ""),
660
+ type,
661
+ ...(relativeCode ? { relativeCode } : {}),
662
+ ...(mappings ? { mappings } : {}),
663
+ ...(displayField ? { displayField } : {}),
664
+ ...(fieldOptions ? { fieldOptions } : {}),
665
+ ...(childProperties && childProperties.length > 0 ? { childProperties } : {}),
666
+ };
667
+ }))
668
+ : [];
669
+ let view = args.draftViewJson ?? (pre ? pre.draftViewJson : undefined);
670
+ let attributes = args.draftAttributesJson ?? (pre ? pre.draftAttributesJson : undefined);
671
+ // Designer edits that live ONLY on the form draft (relation carry mappings,
672
+ // control visibility/width, label tweaks) are not derivable from field
673
+ // metadata. A rebuild re-scaffolds, so capture the previous draft first and
674
+ // layer those edits back on (see preserveDesignerControlOptions).
675
+ const previousAttributes = !args.rebuild || typeof pre?.draftAttributesJson !== "string"
676
+ ? undefined
677
+ : decodeJsonObjectColumn(pre.draftAttributesJson, { invalid: "undefined", array: "reject", object: "accept" });
678
+ // `rebuild: true` discards the existing draft layout and re-scaffolds from
679
+ // the published fields, so fields added after the form was first built (a
680
+ // child table, a new relation) land in the layout. The platform designer's
681
+ // draft is otherwise preserved verbatim.
682
+ if (args.rebuild === true && args.draftViewJson === undefined && args.draftAttributesJson === undefined) {
683
+ view = undefined;
684
+ attributes = undefined;
685
+ }
686
+ let rebuildPreservedControls = [];
687
+ if (typeof view !== "string" || typeof attributes !== "string") {
688
+ // Ticket 48: an explicit fieldCodes selection picks (and orders) the
689
+ // scaffolded business fields; omitted scaffolds every published field.
690
+ const scaffoldFields = wanted
691
+ ? wanted.map((code) => publishedFields.find((field) => field.code === code)).filter((field) => field !== undefined)
692
+ : publishedFields;
693
+ if (scaffoldFields.length === 0)
694
+ throw new WireError("business", "form draft requires published fields to scaffold (none found); create and publish fields first", 302020);
695
+ // Ticket 66: redundant (carry-through) fields stay bound. When the model
696
+ // has NO workflow, hide them in the form design (option 1); with a
697
+ // workflow, leave them visible so per-node propertyPermissions govern
698
+ // (option 2, preferred — configured on the workflow template's activities).
699
+ const redundant = redundantFieldCodes(scaffoldFields);
700
+ let hidden;
701
+ if (redundant.size > 0) {
702
+ const hasWorkflow = (await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode: args.schemaCode } })).data === true;
703
+ if (!hasWorkflow)
704
+ hidden = redundant;
705
+ }
706
+ const scaffold = buildFormScaffold(scaffoldFields, pre?.name ?? String(args.sheetCode), hidden);
707
+ if (previousAttributes)
708
+ rebuildPreservedControls = preserveDesignerControlOptions(scaffold.attributes, previousAttributes);
709
+ view = JSON.stringify(scaffold.rows);
710
+ attributes = JSON.stringify(scaffold.attributes);
711
+ }
712
+ if (args.draftAttributesJson === undefined && typeof attributes === "string") {
713
+ attributes = propagateRelationMappings(attributes, publishedFields);
714
+ // Ticket 69: repair/densify org-selection controls on the existing draft.
715
+ attributes = normalizeOrgSelectorControls(attributes, publishedFields);
716
+ // Repair relation child columns written before the childProperties fix
717
+ // (301015 "[列名]未选择关联模型").
718
+ attributes = repairChildTableRelationColumns(attributes, publishedFields);
719
+ // Field-owned form conditions (required/readonly/display) must reach the
720
+ // control even when the form already existed — a non-rebuild draft keeps
721
+ // controls verbatim, so a later field.update never propagated (bench20).
722
+ attributes = syncFieldDerivedControlOptions(attributes, publishedFields);
723
+ }
724
+ const payload = {
725
+ ...(pre ?? {}),
726
+ schemaCode: args.schemaCode,
727
+ code: args.sheetCode,
728
+ name: pre?.name ?? args.sheetCode,
729
+ sheetType: pre?.sheetType ?? 0,
730
+ published: false,
731
+ // Draft writes are full-row updates, but they must not destroy the
732
+ // currently published face. Publish promotion is a separate operation;
733
+ // preserving these columns keeps the published form usable if the draft
734
+ // write or a later publish fails.
735
+ publishedViewJson: pre?.publishedViewJson ?? null,
736
+ publishedAttributesJson: pre?.publishedAttributesJson ?? null,
737
+ publishedActionsJson: pre?.publishedActionsJson ?? null,
738
+ publishedHtmlJson: pre?.publishedHtmlJson ?? null,
739
+ draftViewJson: view,
740
+ draftAttributesJson: attributes,
741
+ ...(args.draftHtmlJson ? { draftHtmlJson: args.draftHtmlJson } : {}),
742
+ };
743
+ const performed = await wireCall(session, { path: ENDPOINTS.sheetUpdate, method: "POST", body: payload });
744
+ const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
745
+ const sheet = (readback.data ?? {});
746
+ return { performed: { errcode: performed.errcode }, readback: { hasDraft: sheet.existDraft === true || typeof sheet.draftViewJson === "string", ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}) } };
747
+ }
748
+ /**
749
+ * Shared form.publish write body — promotes the saved draft to published,
750
+ * promoting draft*Json → published*Json verbatim (including draftHtmlJson →
751
+ * publishedHtmlJson). Used by the form.publish write tool AND by configure_form
752
+ * AND by onlinejs.compose. Pass `force:true` to override the
753
+ * already-published skip (compose needs this because it may legitimately
754
+ * re-publish after the caller has already published once).
755
+ */
756
+ export async function executeFormPublishWrite(session, args) {
757
+ // A publish promotes a saved draft. When the model has no form yet, or the
758
+ // form exists but holds no draft, materialise the draft first (form.draft
759
+ // creates the default form and scaffolds the published fields) so a single
760
+ // form.publish call is self-sufficient. A draft that already exists is left
761
+ // untouched — this only fills the gap.
762
+ let pre = await readFormOrNull(session, args.schemaCode, args.sheetCode);
763
+ const hasDraft = (sheet) => sheet !== null && typeof sheet.draftViewJson === "string" && typeof sheet.draftAttributesJson === "string";
764
+ if (!hasDraft(pre)) {
765
+ await executeFormDraftWrite(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
766
+ pre = await readFormOrNull(session, args.schemaCode, args.sheetCode);
767
+ }
768
+ if (!pre)
769
+ throw new WireError("business", `form ${args.sheetCode} not found`, 302024);
770
+ if (pre.draftHtmlJson !== "[]")
771
+ assertHtmlRender(pre.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
772
+ if (pre.published === true && args.force !== true)
773
+ return { performed: { skipped: "already-published" }, readback: { published: true } };
774
+ if (!hasDraft(pre))
775
+ throw new WireError("business", "form publish requires a saved draft (draftViewJson/draftAttributesJson)", 302020);
776
+ if (pre.draftHtmlJson !== "[]")
777
+ assertHtmlRender(pre.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
778
+ // Ticket 74 — degrade guard: refuse to promote a draft that carries FEWER
779
+ // controls than the already-published face. The platform designer's Save
780
+ // clears the draft faces to "[]"/"{}" (non-lossless); promoting that empty
781
+ // draft is exactly how the 13-control loss happened. Escape: allowDegrade:true.
782
+ if (args.allowDegrade !== true) {
783
+ const draftControls = controlCount(pre.draftAttributesJson);
784
+ const publishedControls = controlCount(pre.publishedAttributesJson);
785
+ if (draftControls < publishedControls) {
786
+ throw new ToolRefusalError({
787
+ status: "refused",
788
+ blocker: "form-publish-degrade",
789
+ schemaCode: args.schemaCode,
790
+ sheetCode: args.sheetCode,
791
+ draftControlCount: draftControls,
792
+ publishedControlCount: publishedControls,
793
+ message: `草稿面控件数(${draftControls})少于已发布面(${publishedControls})——拒绝降级提拔。平台设计器 Save 会清空草稿(非无损);恢复路径是表单版本历史 /api/api/app/bizsheet/history/list 取回完好版本的 view/attributes 原文重写草稿后再发布。确要减少控件请显式传 allowDegrade:true`,
794
+ });
795
+ }
796
+ }
797
+ const payload = {
798
+ ...pre,
799
+ schemaCode: args.schemaCode,
800
+ code: args.sheetCode,
801
+ name: pre.name,
802
+ sheetType: pre.sheetType ?? 0,
803
+ published: false,
804
+ existDraft: false,
805
+ sendMessage: false,
806
+ publishRemarks: args.publishRemarks ?? "omc-mcp-form-publication",
807
+ publishedViewJson: pre.draftViewJson,
808
+ publishedAttributesJson: pre.draftAttributesJson,
809
+ publishedActionsJson: pre.draftActionsJson ?? "[]",
810
+ publishedHtmlJson: pre.draftHtmlJson ?? "{}",
811
+ draftViewJson: pre.draftViewJson,
812
+ draftAttributesJson: pre.draftAttributesJson,
813
+ ...(pre.draftActionsJson !== null && pre.draftActionsJson !== undefined ? { draftActionsJson: pre.draftActionsJson } : {}),
814
+ ...(pre.draftHtmlJson !== null && pre.draftHtmlJson !== undefined ? { draftHtmlJson: pre.draftHtmlJson } : {}),
815
+ };
816
+ const performed = await wireCall(session, { path: ENDPOINTS.sheetPublish, method: "POST", body: payload });
817
+ const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
818
+ const sheet = (readback.data ?? {});
819
+ return { performed: { errcode: performed.errcode }, readback: { published: sheet.published === true, publishedHtmlJsonPresent: typeof sheet.publishedHtmlJson === "string" } };
820
+ }
821
+ //# sourceMappingURL=form-support.js.map