@stalfh233/omc-cli 0.4.101 → 0.5.0

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.
@@ -4,6 +4,7 @@ import { capabilityArg, environmentArg } from "../args.js";
4
4
  import { governedRead } from "../read.js";
5
5
  import { writeTool } from "../write.js";
6
6
  import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../json-column.js";
7
+ import { flattenTreeRows, resolveApplicationCode } from "../support.js";
7
8
  import { executeRuleSaveWrite, ruleNodeGraphArg } from "../rule-support.js";
8
9
  import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
9
10
  import { resolveRuleRowId } from "../identity.js";
@@ -135,8 +136,13 @@ export function registerBusinessRuleLane(tools) {
135
136
  // schema×ruleCode 组合(171 次 rule.get / 229KB)。没有引用检索面时,
136
137
  // 全库枚举是理性选择——所以给它一个。客户端聚合:模型清单 → 每模型
137
138
  // rule.list → 每规则 rule.get → 图内匹配,一次调用、只回命中行。
139
+ // Model enumeration mirrors `model.list`: the bare modelList endpoint
140
+ // returns EMPTY rows on a real platform (live 2026-10-29: a full scan
141
+ // "found" 0 models → false zero-hit), so walk the application tree.
138
142
  const scoped = args.schemaCodes === undefined
139
- ? wireRows((await wireCall(session, { path: ENDPOINTS.modelList })).data).map((row) => String(row.schemaCode ?? "")).filter((code) => code !== "")
143
+ ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: await resolveApplicationCode(session, undefined).catch(() => "") } })).data))
144
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
145
+ .map((row) => String(row.code))
140
146
  : (Array.isArray(args.schemaCodes) ? args.schemaCodes : [args.schemaCodes]);
141
147
  const wanted = args.code;
142
148
  const matches = [];
@@ -126,6 +126,106 @@ function conditionShapeError(condition) {
126
126
  * admits the literal `"true"` marker (unconditional REQUIRED, per the
127
127
  * designer's `requiredType` check; only reproduced for type 4).
128
128
  */
129
+ /**
130
+ * r26 快速装载(2026-10-28):把 agent 最自然的符号式/裸值条件就地编译成
131
+ * 平台中文规范形状,替代「校验拒绝→读信封→重拼」的 4-6 轮往返。
132
+ *
133
+ * 触发边界(无歧义):先按 {@link conditionProtocolError} 校验——**已合规
134
+ * 的条件原样放行、零改写**;不合规(裸 `=`/`<>`、符号操作符配裸标量值、
135
+ * 中文操作符配裸词值)才尝试编译。编译不了的返回原校验错误(保留既有教学)。
136
+ *
137
+ * 值归一规则:JSON 值对象原样;裸数字/布尔/带引号字符串 → `custom` 值对象;
138
+ * 非裸引号的裸词(如 `{ctype} 等于 建设工程分包合同`)→ `custom` + 该词。
139
+ * 单选字段按选项 code 比较仍需 `specifyOption`(编译器无法推断选项语义,
140
+ * 回执 note 会提示)。
141
+ */
142
+ const SYMBOL_TO_CHINESE_OPERATOR = Object.freeze({
143
+ "=": "等于", "==": "等于", "!=": "不等于", "<>": "不等于",
144
+ ">": "大于", "<": "小于", ">=": "大于等于", "<=": "小于等于",
145
+ Contains: "包含", NotContains: "不包含", IsEmpty: "为空", IsNotEmpty: "不为空",
146
+ });
147
+ function compileSingleCondition(condition) {
148
+ const match = /^(\{[A-Za-z0-9_.]+\})\s+(\S+)(?:\s+([\s\S]+))?$/.exec(condition.trim());
149
+ if (match === null)
150
+ return { error: conditionShapeError(condition) ?? `条件「${condition}」无法解析` };
151
+ const field = match[1];
152
+ const rawOperator = match[2];
153
+ const rawValue = match[3];
154
+ const operator = rawOperator in SYMBOL_TO_CHINESE_OPERATOR ? SYMBOL_TO_CHINESE_OPERATOR[rawOperator] : rawOperator;
155
+ if (!CONDITION_OPERATORS.has(operator))
156
+ return { error: conditionShapeError(condition) ?? `操作符「${rawOperator}」不在平台条件词表内` };
157
+ if (CONDITION_UNARY_OPERATORS.has(operator)) {
158
+ if (rawValue === undefined)
159
+ return { condition: `${field} ${operator}`, changed: operator !== rawOperator || rawValue !== undefined };
160
+ return { error: `「${operator}」是不带值的操作符,不应带值` };
161
+ }
162
+ if (rawValue === undefined)
163
+ return { error: `「${operator}」需要值(裸值会被编译为 custom 值对象)` };
164
+ let valuePart;
165
+ let changed = operator !== rawOperator;
166
+ try {
167
+ const parsed = JSON.parse(rawValue);
168
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
169
+ valuePart = rawValue;
170
+ }
171
+ else {
172
+ valuePart = JSON.stringify({ valueType: "custom", value: parsed });
173
+ changed = true;
174
+ }
175
+ }
176
+ catch {
177
+ const word = rawValue.trim();
178
+ if (word.length === 0)
179
+ return { error: `「${operator}」需要值` };
180
+ // 成对的单/双引号包裹(如 'A')剥引号取内部——JSON.parse 失败只是因为引号不是 JSON 的。
181
+ const unquoted = word.length >= 2 && ((word.startsWith("'") && word.endsWith("'")) || (word.startsWith('"') && word.endsWith('"'))) ? word.slice(1, -1) : word;
182
+ valuePart = JSON.stringify({ valueType: "custom", value: unquoted });
183
+ changed = true;
184
+ }
185
+ return { condition: `${field} ${operator} ${valuePart}`, changed };
186
+ }
187
+ /** Compile one condition formula string (`&&`/`||` groups preserved). */
188
+ export function compileConditionFormula(formula) {
189
+ const groups = [];
190
+ let changed = false;
191
+ for (const group of formula.split(/\s*\|\|\s*/)) {
192
+ const conditions = [];
193
+ for (const condition of group.split(/\s*&&\s*/)) {
194
+ const result = compileSingleCondition(condition);
195
+ if ("error" in result)
196
+ return { error: `条件「${condition.trim()}」${result.error}` };
197
+ conditions.push(result.condition);
198
+ changed = changed || result.changed;
199
+ }
200
+ groups.push(conditions);
201
+ }
202
+ return { formula: groups.map((group) => group.join(" && ")).join(" || "), changed };
203
+ }
204
+ /**
205
+ * Normalize the form-condition keys of a field-options object IN PLACE-free:
206
+ * returns a new options object when compilation changed anything, plus a note
207
+ * per compiled key (for the tool receipt). Compliant formulas pass through
208
+ * untouched.
209
+ */
210
+ export function normalizeFormConditionOptions(options) {
211
+ const notes = [];
212
+ let next = options;
213
+ for (const key of ["requiredFormula", "readonlyCondition", "displayFormula"]) {
214
+ const formula = options[key];
215
+ if (typeof formula !== "string" || formula.trim() === "" || formula.trim() === "true")
216
+ continue;
217
+ if (conditionProtocolError(formula, { allowTrue: true }) === null)
218
+ continue;
219
+ const compiled = compileConditionFormula(formula);
220
+ if ("error" in compiled)
221
+ continue;
222
+ if (next === options)
223
+ next = { ...options };
224
+ next[key] = compiled.formula;
225
+ notes.push(`${key}: ${formula} → ${compiled.formula}`);
226
+ }
227
+ return { options: next, compiledNotes: notes };
228
+ }
129
229
  export function conditionProtocolError(formula, options = {}) {
130
230
  if (typeof formula !== "string" || formula.trim().length === 0)
131
231
  return "必须是非空字符串";
@@ -396,12 +396,18 @@ export function registerFieldLane(tools) {
396
396
  relativeCode: relativeCodeArg,
397
397
  choices: choicesArg,
398
398
  subFields: subFieldArg,
399
+ skipExisting: z.boolean().optional().describe("幂等重入:code 已存在于本模型时跳过并返回 existed:true(批量脚本重跑不再被 schema-code-taken 打断);跨模型占用仍拒绝"),
399
400
  propertyEmpty: z.boolean().optional().describe("数据模型层是否声明该字段不可为空;默认 false(可为空)。必填/只读请在表单设计与流程节点权限配置,不要在模型层设置——平台会据此把流程各节点强制成可见+可编辑+必填"),
400
401
  }, async (session, args) => {
401
402
  const draft = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data);
402
403
  const published = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
403
- if ([...draft, ...published].some((row) => (row.code ?? row.propertyCode) === args.code))
404
- throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST`, nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: true, code: args.code })}'` });
404
+ if ([...draft, ...published].some((row) => (row.code ?? row.propertyCode) === args.code)) {
405
+ // r28b 账本(2026-10-28):批量脚本重入被重复拒绝打断 69 次——skipExisting
406
+ // 让幂等重跑返回 existed:true 而不是拒(跨模型占用仍走 wire 层教学拒绝)。
407
+ if (args.skipExisting === true)
408
+ return { performed: { errcode: 0, skipped: true, reason: "duplicate-field" }, readback: { draftFieldPresent: true, existed: true, note: "skipExisting:true 命中已存在字段,幂等跳过(无写入)" } };
409
+ throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST;批量重入加 skipExisting:true 幂等跳过`, nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: true, code: args.code })}'` });
410
+ }
405
411
  // One seam owns field-change normalization, every reproduced gate, and the
406
412
  // wire body (src/field-change.ts), so configure_model and intent share it.
407
413
  assertNoSelfSchemaAggregation(args, published);
@@ -420,6 +426,14 @@ export function registerFieldLane(tools) {
420
426
  // collides across models, not just inside this one. The registry lookup is
421
427
  // not exposed over REST, so the occupier cannot be listed here; say that
422
428
  // instead of leaving a bare platform code.
429
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 300005 && args.skipExisting === true) {
430
+ // r28b 账本(2026-10-28):批量重入 69 次 field.create 拒绝的大头是 code 已占——
431
+ // 幂等跳过让批量脚本可重跑,只有跨模型占用才走拒绝教学。
432
+ const existing = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
433
+ const present = wireRows(existing.data).some((row) => (row.code ?? row.propertyCode) === args.code);
434
+ if (present)
435
+ return { performed: { errcode: 0, skipped: true, reason: "schema-code-taken" }, readback: { draftFieldPresent: true, existed: true, note: "skipExisting:true 命中已存在字段,幂等跳过(无写入)" } };
436
+ }
423
437
  if (error instanceof WireError && error.kind === "business" && error.errcode === 300005)
424
438
  throw new ToolRefusalError({
425
439
  status: "refused",
@@ -543,6 +557,9 @@ async function runFieldUpdate(session, args) {
543
557
  const storedOptions = after !== undefined ? parseFieldOptionsForReadback(after.options) : undefined;
544
558
  return {
545
559
  performed: { errcode: performed.errcode },
560
+ ...(prepared.conditionCompiled !== undefined && prepared.conditionCompiled.length > 0
561
+ ? { note: { conditionCompiled: prepared.conditionCompiled, hint: "符号式/裸值条件已就地编译为平台中文规范形状;单选字段按选项 code 比较仍需 specifyOption;此配置是门户交互层,data.submit 不拦截" } }
562
+ : {}),
546
563
  readback: {
547
564
  fieldPresent: after !== undefined,
548
565
  ...(after?.name !== undefined ? { name: after.name } : {}),
@@ -21,7 +21,7 @@ export const FORM_WIRE = Object.freeze({
21
21
  const FORM_GET_DESCRIPTION = '读取默认表单(bizsheet/get)的发布状态与控件清单(紧凑默认)。**发布态以返回的 `published` / `existDraft` 布尔为准**(`published:true`=已发布;`published:false,existDraft:true`=只有草稿,**未发布**)——不要用 `publishedControlCodes` 是否非空来推断(草稿字段会让它非空,误判为已发布)。**每个模型交付前其默认表单必须 `published:true`**:用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布,并逐模型 `form.get` 核对。`full:true` 还返回 `options` 与解析后的 `customButtonJson`(表单工具栏按钮)。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
22
22
  const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list). **读回原样回写**:从 `form.get` 取回的 view/attributes 直接回传时加 `fromReadback:true`——工具会与已存草稿/发布面逐字比对,一致才放行(平台自身布局可能含子表列行/系统行默认宽度),被改动过的面照常按 validation 拒绝。**表单工具栏按钮**:`buttons`(数组)或 `customButtonJson`(原样字符串)写进 `biz_sheet.options.customButtonJson`;**要到 form.publish 才生成 h_biz_button(triggerType=FORM)**,且表单页是否显示还取决于流程节点 `buttonPermission.customBtns[].enabled`(设计器「表单操作」勾选)。`useLocation` 用 FORM/ROW/LIST,表单工具栏按钮用 FORM。 **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
23
23
  const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change. **表单工具栏按钮**:传 `buttons`(或 `customButtonJson`)→ 写进 `options.customButtonJson` 并在本次 publish 生成 `h_biz_button(triggerType=FORM, triggerCode=<formCode>)`(平台 `publishBizForm:1022`);`options.customButtonJson` 必须是**字符串化数组**(对象会 50000 syntax error);表单页是否显示再由流程节点 `buttonPermission.customBtns[].enabled` 过滤。";
24
- const FORM_RUN_BUTTON_DESCRIPTION = "执行表单工具栏按钮(平台同款面:`POST /api/api/runtime/form/executeCustomButton`,body `{bizButtonModel, bizObjectList:[<完整业务对象>], reacquireDataFromDb:true}`)。**与列表行按钮不同**:列表按钮发 `launcher/domain_service_runtime/<schema>/<rule>` + `ids[]`(用 `rule.run`),表单按钮带**整条记录**,不能互换。按钮来源 `GET /api/api/system/getCustomButtons`(只列 triggerType=FORM 的 h_biz_button)。前提:按钮已由 `form.publish` 的 `buttons`/`customButtonJson` 生成。返回是执行结果信封,规则副作用用 `data.load` 另读;被规则 UPDATE 的目标行须是 COMPLETED(引擎查不到 DRAFT)。`302045` = 数据被占用。";
24
+ const FORM_RUN_BUTTON_DESCRIPTION = "执行表单工具栏按钮(平台同款面:`POST /api/api/runtime/form/executeCustomButton`,body `{bizButtonModel, bizObjectList:[<完整业务对象>], reacquireDataFromDb:true}`)。**与列表行按钮不同**:列表按钮发 `launcher/domain_service_runtime/<schema>/<rule>` + `ids[]`(用 `rule.run`),表单按钮带**整条记录**,不能互换。按钮来源 `GET /api/api/system/getCustomButtons`(只列 triggerType=FORM 的 h_biz_button)。前提:按钮已由 `form.publish` 的 `buttons`/`customButtonJson` 生成。返回是执行结果信封,规则副作用用 `data.load` 另读;被规则 UPDATE 的目标行须是 COMPLETED(引擎查不到 DRAFT)。`302045` = 数据被占用。**`10005` 执行失败时优先换等价面 `rule.run`(launcher/domain_service_runtime/<schema>/<rule> + ids[])**——同一绑定规则在 rule.run 面已验证可用(r28b 实证),不要在 executeCustomButton 上反复重试。";
25
25
  const CONFIGURE_FORM_DESCRIPTION = "High-level: bind fields into the default form and publish it (form.draft → form.publish), subject to the write permission gate. Delegates to the proven form.draft scaffold + form.publish paths so the real empty-form layout (and any caller-supplied draftHtmlJson) is persisted and promoted — never bypasses with a shortcut body. `sheetCode` is optional: omitted means the model's default sheet (sheetCode = schemaCode).";
26
26
  export const FORM_LANE_CONTRACT = Object.freeze({
27
27
  lane: LANES.form,
@@ -216,7 +216,25 @@ export function registerOnlineJsLane(tools) {
216
216
  // Refuse a source that cannot run in the script slot (export/import, unknown
217
217
  // form.on event) BEFORE the write permission gate. This is the exact
218
218
  // class of mistake that blanked a live form page with no harness signal.
219
- (args) => lintOnlineJsSource(args.source), undefined, {
219
+ (args) => lintOnlineJsSource(args.source),
220
+ // r23 F4:dry-run 必须走与真写相同的 compose 校验路径(含 replace/merge 的
221
+ // wrapper 判定),否则 dry-run 过、真写拒——探针按 dry-run 绿灯写正式调用被拒。
222
+ async (session, args) => {
223
+ const current = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode ?? args.schemaCode } });
224
+ const sheet = (current.data ?? null);
225
+ const raw = typeof sheet?.draftHtmlJson === "string" ? sheet.draftHtmlJson : "";
226
+ const compat = validateHtmlRender(raw);
227
+ const elements = compat.ok ? parseLayoutElements(raw) : [];
228
+ const existingScript = String(elements.find((element) => element.id === "customScript")?.innerHTML ?? "");
229
+ const composed = composeCustomScript(existingScript, args.source, args.replace === true ? "replace" : "merge");
230
+ return {
231
+ mode: args.replace === true ? "replace" : "merge",
232
+ wouldChange: composed !== existingScript,
233
+ composedSha256: createHash("sha256").update(composed, "utf8").digest("hex"),
234
+ composedBytes: Buffer.byteLength(composed, "utf8"),
235
+ hint: "dry-run 已执行与真写一致的 compose 校验;该结果即正式调用将落库的脚本形状",
236
+ };
237
+ }, {
220
238
  target: (args) => `onlinejs:${String(args.schemaCode ?? "")}/${String(args.sheetCode ?? args.schemaCode ?? "")}`,
221
239
  read: async (session, args) => {
222
240
  const sheet = await readFormOrNull(session, String(args.schemaCode), String(args.sheetCode ?? args.schemaCode));
@@ -2201,7 +2201,7 @@ export function registerWorkflowLane(tools) {
2201
2201
  readback: { workflowCode: args.code, name: workflowName, nameResolvedFrom: resolvedFrom, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
2202
2202
  };
2203
2203
  }, undefined, undefined, workflowRollbackSpec("workflow.create"));
2204
- writeTool(tools, "workflow.update", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.update"].scope, WORKFLOW_TOOLS["workflow.update"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg, merge: z.boolean().optional().describe("增量:只发改动/新增的活动与路由,工具按 activityCode 合并到草稿(草稿为基;新增节点自动接链),不必先读回整模板再重发。默认自动:模板缺 START 或 END 时按增量 merge(部分模板),给了完整 START..END 才整模板替换;显式 true 强制增量、false 强制替换。merge 是并集、绝不删边:结果比草稿少边会被拒(workflow-merge-would-drop-routes);--dry-run 会返回 routesBefore/routesAfter/routesAdded/routesDropped") }, async (session, args) => {
2204
+ writeTool(tools, "workflow.update", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.update"].scope, WORKFLOW_TOOLS["workflow.update"].description, { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), template: workflowTemplateArg, merge: z.boolean().optional().describe("增量:只发改动/新增的活动与路由,工具按 activityCode 合并到草稿(草稿为基;新增节点自动接链),不必先读回整模板再重发。默认自动:模板缺 START 或 END 时按增量 merge(部分模板),给了完整 START..END 才整模板替换;显式 true 强制增量、false 强制替换。merge 是并集、绝不删边:结果比草稿少边会被拒(workflow-merge-would-drop-routes);--dry-run 会返回 routesBefore/routesAfter/routesAdded/routesDropped") }, async (session, args) => {
2205
2205
  const template = args.template ?? {};
2206
2206
  validateCallerActivities(template, "workflow.update");
2207
2207
  validateWorkflowRouteConditions(template, "workflow.update");
@@ -2314,7 +2314,7 @@ export function registerWorkflowLane(tools) {
2314
2314
  : "本次未给 routes/edges/rules:草稿连线原样沿用(新增节点才会自动接链)",
2315
2315
  };
2316
2316
  }, workflowRollbackSpec("workflow.update"));
2317
- writeTool(tools, "workflow.publish", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.publish"].scope, WORKFLOW_TOOLS["workflow.publish"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg.optional() }, async (session, args) => {
2317
+ writeTool(tools, "workflow.publish", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.publish"].scope, WORKFLOW_TOOLS["workflow.publish"].description, { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), template: workflowTemplateArg.optional() }, async (session, args) => {
2318
2318
  // An absent template publishes the stored draft as-is — the platform's own
2319
2319
  // path. The schema is optional so the contract no longer contradicts it.
2320
2320
  const template = args.template ?? {};
@@ -2653,7 +2653,7 @@ export function registerWorkflowLane(tools) {
2653
2653
  },
2654
2654
  };
2655
2655
  }, undefined, undefined, workflowRollbackSpec("workflow.update"));
2656
- writeTool(tools, "workflow.delete", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.delete"].scope, WORKFLOW_TOOLS["workflow.delete"].description, { schemaCode: z.string().min(1), code: z.string().min(1) }, async (session, args) => {
2656
+ writeTool(tools, "workflow.delete", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.delete"].scope, WORKFLOW_TOOLS["workflow.delete"].description, { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code") }, async (session, args) => {
2657
2657
  let performed;
2658
2658
  try {
2659
2659
  performed = await wireCall(session, { path: ENDPOINTS.workflowDelete, method: "POST", query: { workflowCode: args.code } });
@@ -58,9 +58,14 @@ export const ONLINE_JS_EVENTS = new Set([
58
58
  const WRAPPER_OPEN = /\(\s*function\s*\(\s*form\s*\)/;
59
59
  const WRAPPER_CLOSE = "})";
60
60
  const EVENT_AT = /form\.on\(\s*['"]([^'"]+)['"]/g;
61
- /** True when the script already carries the platform's `(function(form){ … })` scaffold. */
61
+ /** True when the script already carries the platform's `(function(form){ … })` scaffold.
62
+ * r23 F4/F5 实证:尾部必须容忍三种合法形式 —— 模板原生 `})`、IIFE 调用 `})(form);`/`})();`,
63
+ * 以及其后带注释/空白(中文注释曾被判成"裸代码"导致 replace 全拒)。 */
62
64
  export function hasLifecycleWrapper(script) {
63
- return WRAPPER_OPEN.test(script) && /\}\s*\)\s*;?\s*$/.test(script);
65
+ if (!WRAPPER_OPEN.test(script))
66
+ return false;
67
+ const tail = stripComments(script).trimEnd();
68
+ return /\}\s*\)\s*(?:\(\s*form\s*\))?\s*;?\s*$/.test(tail);
64
69
  }
65
70
  /**
66
71
  * Refuse a source that cannot run in the form's script slot BEFORE any wire
@@ -308,8 +308,9 @@ const ruleBizactionInvokeService = Object.freeze({
308
308
  "方法出参类型(bizPropertyType)与目标字段类型匹配",
309
309
  ],
310
310
  conditionalRequirements: [
311
+ "**出参回写三前提(r28b 8 组实验定案,缺一出参恒 null 且节点日志仍 successful)**:① 响应 JSON 根级平铺出参键(信封嵌套如 {data:{...}} 静默不回写)② 挂预置 Create/Update 规则上下文(自定义规则/rule.run 不回写业务字段)③ 用 data.submit/data.update 触发(data.save 存草稿不触发)。回写是否成功只认 data.load 读字段,不认调用日志。",
311
312
  "codeType:1 = 映射模型字段(bizCode=字段 code);codeType:0 = 常量(bizCode=常量值);serviceMethodParameterCode 必须与方法声明的参数 code 逐字一致",
312
- "**接口响应必须在 JSON 根级**返回与出参 code 同名的键——嵌套在信封里(如 {data:{level}})会静默映射不上(p7 实测)",
313
+ "**接口响应必须在 JSON 根级**返回与出参 code 同名的键——嵌套在信封里(如 {data:{level}})会静默映射不上(p7 实测;r28b 8 组实验全因信封端点失败,换平铺端点后 4 组一次全过)",
313
314
  "**平台自调自己(127.0.0.1)会被运行时鉴权拦**:errcode 500012「绑定的API需要提供正确的身份验证或凭据」——需要带出站凭据的真实外部服务,或服务端配置鉴权放行;这不是 BIZ_ACTION 不支持(p7 定案)",
314
315
  "BIZ_ACTION 放在 SYSTEM_CREATE **之前**(与自更新 UPDATE 相反:ASSIGN/BIZ_ACTION 在系统节点前,UPDATE 在系统节点后)",
315
316
  ],
@@ -277,8 +277,62 @@ const formulaAggregationCrossModel = Object.freeze({
277
277
  requiresEnvironmentDerivedIds: true,
278
278
  prerequisiteData: { source: "field.list(published:true) ×2(来源模型 + 目标模型)", fields: ["source.model", "source.dataItemCode", "source.matchField", "target.matchField"] },
279
279
  });
280
+ /**
281
+ * r29(2026-10-29 中冶终轮)+ 用户领域知识:外部数据(企查查/NC 等)在表单上
282
+ * 「填表时即带出」的标准模式——两段式组合。此前三轮探针(r24/r28b/r29)都发明
283
+ * 了「按钮触发规则回写」的重方案,因为没人知道标准模式;r29 实证 GetList 规则
284
+ * 实时调接口替换数据源在 1.1.25 不成立(CREATE 节点被校验器禁止指向本模型),
285
+ * 但「数据段落表 + 表单段关联下拉查表带出」完全成立且是平台正解。
286
+ *
287
+ * 2026-10-29 用户定案修正:GetList/Load 规则**实时调接口也是成立的**——触发点是
288
+ * 填表时**前端发起**的关联控件查询链(接口方法配 frontEndExtensionEnable:true,
289
+ * 前端直调);r29 判「不成立」是因为用 data.list(后端 REST 查询)验证,该链不
290
+ * 执行 BIZ_ACTION 出参替换。验证必须走门户填表(关键字回车→候选→选中带出)。
291
+ */
292
+ const externalLookupModel = Object.freeze({
293
+ id: "external-lookup-model",
294
+ title: "外部数据映射模型:接口数据落表 + 关联单选下拉带出(填表时即映射)",
295
+ aliases: ["外部数据源", "接口映射模型", "企查查下拉", "关联带出", "映射模型", "外部数据带出"],
296
+ status: "requires-prior-readback",
297
+ tool: "field.create",
298
+ args: {
299
+ environment,
300
+ schemaCode: model,
301
+ code: "ext_lookup_ref",
302
+ name: "外部数据引用",
303
+ propertyType: "WORK_SHEET",
304
+ options: {
305
+ schemaCode: "<映射模型code>",
306
+ queryCode: "<映射模型code>",
307
+ displayField: "<映射模型显示字段>",
308
+ mappings: [{ source: "<映射模型.业务字段>", target: "<本模型.落点字段>" }],
309
+ },
310
+ },
311
+ preconditions: [
312
+ "映射模型已建且字段与接口返回字段一一对应(如 qcc_company: company_name/credit_code)",
313
+ "数据段已通:接口数据已能落进映射模型表(见 conditionalRequirements 第 1 条的三个已验证组合)",
314
+ ],
315
+ conditionalRequirements: [
316
+ "**数据段首选(平台标准模式,2026-10-29 playwright 门户实证定案)**:映射模型的 GetList/Load 规则挂 BIZ_ACTION 实时调接口(接口方法配 frontEndExtensionEnable:true)——门户关联控件**打开即触发**前端查询链→GetList 规则→BIZ_ACTION 实时调外部接口(引擎日志 bizBusService.invoke + 外部网关日志双侧铁证)。**验证必须走门户填表;data.list 后端查询不反映此链,别用它判死**(r29 曾据此误判不成立)。配置侧三个收尾点:① WORK_SHEET 字段必须带 relativeCode(仅 options.schemaCode 发布表单会 301015 关联业务模型为空);② GetList 规则的 BIZ_ACTION 入参映射要接前端搜索词(实测未配词时 invoke 参数为空 {});③ 出参→列表行的映射配完候选才回显(未配时弹窗 Total 0 但接口已调)。「下拉框形态(输关键字回车查询)」是表单 attributes 层的控件形态设置,默认弹窗形态。备选(不需要实时时):预置 Create/Update 规则落映射表 / TIMING 定时同步。图内「BIZ_ACTION→CREATE 落表→再查」回环在 1.1.25 不可表达,不要走这条岔路。",
317
+ "**表单段是平台原生能力**:关联单选指向映射模型后,门户上输入关键字回车即查映射表、选中即 mappings 带出——前台填表时映射自然出现,无需按钮/脚本。",
318
+ "下拉框形态(输入关键字回车查询而非弹窗选择)在表单 attributes 层配置(form.draft 改控件形态),不在字段 options。",
319
+ "映射模型表要有数据下拉才有选项——空表=空下拉;数据段先通再配表单段。",
320
+ ],
321
+ priorToolReferences: [
322
+ { tool: "model.create", args: { environment }, purpose: "建映射模型(字段与接口返回一一对应)" },
323
+ { tool: "rule.save", args: { environment }, purpose: "数据段规则(三选一,见 conditionalRequirements)" },
324
+ ],
325
+ dryRun: { supported: true, command: `omc call field.create --env ${environment} --dry-run --args '<recipe.args>'`, notes: "校验 WORK_SHEET options 与 mappings 形状" },
326
+ expectedReadback: { readback: { field: { propertyType: "WORK_SHEET", options: { mappings: [{ source: "<映射模型.业务字段>" }] } } }, next: ["field.publish", "form.draft(控件形态配下拉框)", "form.publish", "门户填表验证:输关键字回车→选中→字段带出"] },
327
+ refusalRecovery: [
328
+ { code: "field-family-options-invalid", action: "WORK_SHEET options 必带 schemaCode/queryCode/displayField;mappings 用数组或 JSON 字符串均可(CLI 就地归一)" },
329
+ ...commonRecovery,
330
+ ],
331
+ notes: "r29 终轮定案:此模式是「外部数据填表带出」的平台正解;按钮触发回写是下策(多一次交互且体验差)。映射模型数据段的三个组合全部基于已验证的出参回写三前提(平铺端点/预置规则上下文/data 触发)。",
332
+ requiresEnvironmentDerivedIds: true,
333
+ });
280
334
  export const FIELD_METADATA = Object.freeze({
281
335
  "field.update": Object.freeze({ recipes: [fieldSerialNumber, formConditionalRequired, formAlwaysReadonly, formConditionalReadonly], relatedTools: ["field.list", "field.publish", "form.draft", "form.publish"] }),
282
- "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown, formulaAggregationCrossModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
336
+ "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown, formulaAggregationCrossModel, externalLookupModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
283
337
  });
284
338
  //# sourceMappingURL=field.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.101",
3
+ "version": "0.5.0",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "typecheck": "tsc --noEmit",
27
27
  "coverage:manifest": "tsx scripts/coverage-manifest.ts --write",
28
28
  "coverage:check": "tsx scripts/coverage-manifest.ts --check",
29
- "test": "npm run build && node --test --import tsx test/*.test.ts",
29
+ "test": "npm run build && OMC_ASCII_STDOUT=0 node --test --import tsx test/*.test.ts",
30
30
  "verify": "npm run typecheck && npm run coverage:check && npm test",
31
31
  "eval:call-face": "node --import tsx scripts/run-call-face-eval.ts",
32
32
  "eval:intent-face": "node --import tsx scripts/run-intent-face-eval.ts",
@@ -41,7 +41,9 @@
41
41
  "test:live-permission": "node conformance/live-permission-acceptance.mjs",
42
42
  "sandbox:dogfood": "node conformance/setup-dogfood-sandbox.mjs",
43
43
  "payload:report": "tsx scripts/payload-report.ts",
44
- "prepublishOnly": "npm run build"
44
+ "prepublishOnly": "npm run build",
45
+ "build:fast": "tsc --project tsconfig.build.json --incremental",
46
+ "typecheck:fast": "tsc --noEmit --incremental --tsBuildInfoFile .typecheck.tsbuildinfo"
45
47
  },
46
48
  "engines": {
47
49
  "node": ">=20"
@@ -50,9 +52,9 @@
50
52
  "access": "public"
51
53
  },
52
54
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.101",
55
+ "@stalfh233/omc-fake-platform": "0.5.0",
54
56
  "commander": "14.0.3",
55
- "omc-core": "0.4.101",
57
+ "omc-core": "0.5.0",
56
58
  "proper-lockfile": "4.1.2",
57
59
  "write-file-atomic": "6.0.0",
58
60
  "zod": "^3.23.8",
@@ -21,8 +21,12 @@ omc help omc-write-hazards
21
21
  - `form.draft rebuild:true` 会重建布局,但会保留已存在的非结构性控件 options;Sheet201 columns 以最新 subSchema 为准。
22
22
  - 空布局 `[]`/`{}` 默认拒绝;只有显式 `allowEmptyLayout:true` 才能清空。
23
23
  - **列数由 viewJson 行分组决定,不是 span**(2026-10-27 r24 真机实证):`viewJson` 是行的数组,一行内 N 个 code 即 N 列;`span` 只是 24 栅格下的控件宽度。只把字段 `span` 改 8 而不动行分组 → 控件变窄但每行仍两个,不是三列。改列数必须同时改行分组(三列=每行 3 个 code,字段 span=8)→ `form.publish` → `form.get` 回读 viewJson 核对。系统字段行与业务字段行分开调整。
24
+ - **下拉/单选/复选的选项在表单控件有两份独立副本**(2026-10-29 治豪 issue 同源事实,8.6/1.1.25 机制一致):数据项 `options.optionsSet.custom` 之外,表单控件的 `options.options.custom` 与 `options.optionsSet.custom` 是独立副本(× draft/published 双版本)——**只改字段不重存表单,门户/设计器不会变**。标准生效链:`field.update`(选项)→ `field.publish` → `form.draft`(rebuild 重建控件同步副本)→ `form.publish`;验收以表单面回读为准,数据项一致 ≠ 表单生效。
24
25
  - **模型不支持跨应用迁移**(1.1.25 真机实证:`folder-other-application` 拒绝):模型建错应用时走「新应用重建 + 关联引用改指 + 旧模型删除」,不存在物理迁移命令。
25
26
  - **流程编排器是线性的,无并行会签**(1.1.25 实证):多部门会签只能建成顺序节点链;需求里的「并行会签」按顺序节点落地并在交付说明里披露。
27
+ - **BIZ_ACTION 服务出参只认响应 JSON 根级平铺键**(p7/r26 双实证):嵌套在信封里(如 `{data:{level}}`)静默映射不上、目标字段全空——先把这类判断列进方案:接口形态要在「平铺 vs 信封」间做选择时,需要回写本平台的出参一律平铺。外部系统本身回信封的,扩展整形转发或放弃出参回写。
28
+ - **`{Originator.OU}` 参与人变量在 1.1.25 本环境不解析成部门**(r26 实证 4 轮误判):发起人部门参与人写法先小样本验证解析,失败改用显式部门/角色参与人,不要反复重配角色。
29
+ - **BRANCH 条件分支与运行时规则次序互斥**(r26 实证 3 轮):该版本 BRANCH 节点的工具校验与运行时执行规则冲突时,改用按钮触发或顺序节点路由条件,不硬调 BRANCH。
26
30
 
27
31
  ## 模型字段与关联
28
32
 
@@ -49,6 +53,12 @@ omc help omc-write-hazards
49
53
  - **`Create` 的触发判定是「旧状态→新状态」**:旧 `DRAFT` 且新 `PROCESSING` 或 `COMPLETED`(`DomainServiceImpl#getDefaultRuleCode`)。所以无流程模型一调 `data.submit`(→COMPLETED)**和流程表单提交(DRAFT→PROCESSING)都触发 Create**;`data.save` 存草稿不触发。不要因为"目标表没变化"就断言规则没触发。
50
54
  - **副作用缺失归因三步**:先查执行记录(`h_log_biz_service` 按服务/方法/时间,或容器日志)→ ①无调用=规则/触发问题 ②有调用但失败=参数/权限/业务异常 ③调用成功但不可见=视图投影/事务未提交/查询条件。禁止只看目标表。
51
55
  - **BIZ_ACTION 判成功只看传输**:REST 适配器解析响应后直接返回,**不校验 `errcode`**。扩展接口回 `{"errcode":50000}` + HTTP 200 会被当成节点成功(日志 `biz action node execute successful`)。要阻断必须走表单 `onValidate` 或数据规则,不能靠业务返回码。
56
+ - **`rule.run`/自定义规则不回写出参**(r26 实证 2 轮):出参合并只发生在预置 Update 规则的执行上下文;`rule.run` 手动触发与自定义规则不把服务出参写回本模型——出参落表要走「BIZ_ACTION 节点 outputMappings + 数据事件触发」,验收用 data.load 看字段而非 rule.run 回执。
57
+ - **出参回写三前提(r28b 8 组实验定案,单点总纲)**:① 响应 JSON 根级平铺出参键 ② 预置 Create/Update 规则上下文 ③ data.submit/data.update 触发(data.save 不触发)。三者缺一出参恒 null 且节点日志仍 successful——外部接口回信封的(`{code,message,data:[...]}`),先落平铺整形端点(平台扩展)再配 outputMappings,不要反复换规则形状试。
58
+ - **布尔出参映射到文本字段 → ClassCastException 50000**(r28b 实证):服务出参 `bizPropertyType:LOGICAL` 的值映射到 SHORT_TEXT 字段会让规则执行炸 50000——布尔出参只能映射 LOGICAL 字段;要落文本先在扩展侧转字符串。
59
+ - **审批提交的 formData 带子表数组 = 追加行**(r28b 实证):approval submit 的 formData 里带子表数组会被平台当「追加行」处理,审批回执只回传主表字段——审批链路里要改子表行,用 data.update 单独写,不要塞进 formData。
60
+ - **workItemId 不在 instanceActivity 里**(r28b 实证):流程实例活动查询不返回 workItemId;办理待办要的 workItemId 从 `search_workitems?state=PENDING` 的 `content[].id` 取。
61
+ - **模型创建/删除非原子,会有半态残留**(r28b 实证 ~20min):model.create 在表单层撞错(如「表单已存在」)时 schema 可能已半落库——refused 后换名重试前先 model.list 核对半态并清理;同理模型删除后平台读面不可见但库里可能留僵尸行(引擎报 `appFunction not exist` 就是它在作祟),迁移/复用编码前先核对。
52
62
  - **流程事件处理器吞异常**:`start`/`end`/`cancel`/`activate` 中的 BIZ_ACTION 抛异常时,`InstanceContext#processingBusiness` 只记 `processingBusiness error.` 并返回失败结果,**流程继续**。事件处理器不能做"提交前拦截"。
53
63
  - **禁写当前单据**:BIZ_ACTION 是 HTTP 自调用,外层事务已持行锁;同事务更新当前单据会等到 15s 超时并整条回滚。副作用写独立表;需要留痕用独立事务/异步重建 `RequestContext`。
54
64