@stalfh233/omc-cli 0.6.11 → 0.6.13

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 (58) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +34 -4
  8. package/dist/cli-output.js +6 -2
  9. package/dist/cli.js +303 -69
  10. package/dist/describe-cache.js +8 -4
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +19 -1
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +2 -1
  24. package/dist/lanes/field.js +707 -59
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +2 -2
  27. package/dist/lanes/list-view.js +98 -2
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +192 -32
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/field.js +16 -3
  40. package/dist/recipes/form.js +2 -1
  41. package/dist/recipes/runtime-data.js +58 -8
  42. package/dist/recipes/workflow.js +86 -1
  43. package/dist/refusal-guidance.js +18 -6
  44. package/dist/rollback-spec.js +41 -2
  45. package/dist/rollback.js +49 -11
  46. package/dist/script.js +72 -12
  47. package/dist/skills.js +91 -3
  48. package/dist/tools.js +113 -1
  49. package/dist/wire.js +13 -1
  50. package/dist/write-lock.js +92 -4
  51. package/dist/write.js +129 -16
  52. package/package.json +5 -3
  53. package/skills/omc-business-configuration.md +1 -1
  54. package/skills/omc-capabilities.md +5 -4
  55. package/skills/omc-playbook.md +41 -0
  56. package/skills/omc-refusals.md +58 -6
  57. package/skills/omc-tool-selection.md +2 -0
  58. package/skills/omc-write-hazards.md +3 -2
@@ -1,18 +1,19 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
4
- import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
4
+ import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { governedRead } from "../read.js";
7
7
  import { writeTool } from "../write.js";
8
- import { decodeJsonColumn, decodeJsonObjectColumn, JSON_COLUMN_PASSTHROUGH } from "../json-column.js";
8
+ import { decodeJsonColumn, decodeJsonObjectColumn, JSON_COLUMN_NULL_COALESCE, JSON_COLUMN_PASSTHROUGH } from "../json-column.js";
9
9
  import { syncFormConditionDataRules } from "./datarule.js";
10
10
  import { FORM_CONDITION_OPTION_KEYS } from "./datarule-protocol.js";
11
11
  import { defineTool, ToolRefusalError } from "../tool-types.js";
12
12
  import { wireCall, wireRows, WireError } from "../wire.js";
13
- import { executeFormDraftWrite, executeFormPublishWrite } from "../form-support.js";
13
+ import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull } from "../form-support.js";
14
14
  import { FIELD_LANE_CONTRACT } from "./field-contract.js";
15
15
  import { mapBounded } from "../support.js";
16
+ import { extractBizObjectPage } from "./runtime-data.js";
16
17
  const FIELD_LANE = FIELD_LANE_CONTRACT.lane;
17
18
  const FIELD_TOOLS = FIELD_LANE_CONTRACT.tools;
18
19
  async function readFieldRollback(session, args) {
@@ -45,54 +46,6 @@ function fieldRollbackSpec(toolName) {
45
46
  function parseFieldOptionsForReadback(options) {
46
47
  return decodeJsonColumn(options, JSON_COLUMN_PASSTHROUGH);
47
48
  }
48
- /**
49
- * CHILD_TABLE(8) columns as a compact list (issue 2026-10-25 §7).
50
- *
51
- * The stored `subSchema` is the full designer payload — system columns included,
52
- * ~37.6 KB for a 10-column child table when asked for one field by code. A caller
53
- * almost always wants the column codes (to build formData / verify the attach);
54
- * the raw payload stays available behind `full:true`. Accepts both the object and
55
- * the JSON-string wire form.
56
- */
57
- function childColumnsOf(subSchema) {
58
- let parsed = subSchema;
59
- if (typeof subSchema === "string") {
60
- try {
61
- parsed = JSON.parse(subSchema);
62
- }
63
- catch {
64
- return [];
65
- }
66
- }
67
- if (parsed === null || typeof parsed !== "object")
68
- return [];
69
- const properties = parsed.properties;
70
- if (!Array.isArray(properties))
71
- return [];
72
- return properties
73
- .filter((child) => child !== null && typeof child === "object")
74
- .map((child) => {
75
- const rawType = child.propertyType ?? child.type;
76
- const type = rawType === undefined || rawType === null || Number.isNaN(Number(rawType)) ? null : Number(rawType);
77
- const typeLabel = type === null ? undefined : fieldTypeLabel(type);
78
- const code = String(child.code ?? child.propertyCode ?? "");
79
- return {
80
- code,
81
- name: String(child.name ?? child.code ?? child.propertyCode ?? ""),
82
- type,
83
- ...(typeLabel !== undefined ? { typeName: typeLabel } : {}),
84
- ...(typeof child.relativeCode === "string" && child.relativeCode ? { relativeCode: child.relativeCode } : {}),
85
- // r15 feedback: the projection mixed the platform's system columns with
86
- // the authored ones, so "which child columns did I declare?" still needed
87
- // a filter. Keep every column but mark the system ones.
88
- // `parentId` / `sortKey` are injected by the platform into every child
89
- // schema (they are the child row's link + order), so they are system
90
- // columns here even though a MAIN model never carries them.
91
- ...(SYSTEM_FIELD_CODES.has(code) || code === "parentId" || code === "sortKey" ? { system: true } : {}),
92
- };
93
- })
94
- .filter((child) => child.code.length > 0);
95
- }
96
49
  /**
97
50
  * `name_i18n` is a JSON locale map. When every locale value equals the display
98
51
  * `name` there is no translation to convey, so echoing the map on every field of
@@ -474,6 +427,7 @@ export function registerFieldLane(tools) {
474
427
  return {
475
428
  performed: { errcode: performed.errcode },
476
429
  readback: {
430
+ face: "draft",
477
431
  draftFieldPresent: rows.some((row) => (row.code ?? row.propertyCode) === args.code),
478
432
  ...(textLike
479
433
  ? {
@@ -501,6 +455,10 @@ export function registerFieldLane(tools) {
501
455
  relativeCode: relativeCodeArg,
502
456
  subFields: subFieldArg,
503
457
  propertyEmpty: z.boolean().optional().describe("覆盖数据模型层的不可为空声明;传 false 可把历史误建的字段改回可为空(必填/只读应在表单与流程节点配置)"),
458
+ syncDictionary: z
459
+ .boolean()
460
+ .optional()
461
+ .describe("true 且字段是字典-backed:服务端重读 dictionary 启用记录并重填 options.optionsSet.dictionary.useDictionariesData(字典改动不会被动传播到字段快照)"),
504
462
  cascadeRepublish: z
505
463
  .boolean()
506
464
  .optional()
@@ -513,6 +471,7 @@ export function registerFieldLane(tools) {
513
471
  return runFieldUpdateCascade(session, args, outcome);
514
472
  },
515
473
  preflight: (args) => assertNameI18nArg(args.name_i18n),
474
+ dryRunPayload: fieldUpdateDryRun,
516
475
  rollback: fieldRollbackSpec("field.update"),
517
476
  });
518
477
  writeTool({
@@ -536,6 +495,9 @@ export function registerFieldLane(tools) {
536
495
  description: FIELD_TOOLS["field.delete"].description,
537
496
  inputSchema: { schemaCode: z.string().min(1), code: z.string().min(1) },
538
497
  execute: async (session, args) => {
498
+ // 真写回执只给摘要(省查询成本);全量引用/行数扫描留给 --dry-run。
499
+ // impact 放进 readback:governedWrite 只透传 performed/readback/steps。
500
+ const impact = await collectFieldDeleteImpact(session, args, { full: false });
539
501
  const performed = await wireCall(session, {
540
502
  path: ENDPOINTS.fieldForceDelete,
541
503
  method: "POST",
@@ -544,11 +506,452 @@ export function registerFieldLane(tools) {
544
506
  const draft = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
545
507
  const published = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
546
508
  const gone = (value) => !wireRows(value).some((row) => (row.code ?? row.propertyCode) === args.code);
547
- return { performed: { errcode: performed.errcode }, readback: { absentDraft: gone(draft.data), absentPublished: gone(published.data) } };
509
+ return {
510
+ performed: { errcode: performed.errcode },
511
+ readback: { absentDraft: gone(draft.data), absentPublished: gone(published.data), impact },
512
+ };
513
+ },
514
+ dryRunPayload: async (session, args) => {
515
+ const impact = await collectFieldDeleteImpact(session, args, { full: true });
516
+ return { wouldChange: true, impact, schemaCode: args.schemaCode, code: args.code };
548
517
  },
549
518
  rollback: fieldRollbackSpec("field.delete"),
550
519
  });
551
520
  }
521
+ /** Normalize options JSON (string or object) into a plain object for comparison/merge. */
522
+ function optionsObjectOf(raw) {
523
+ if (raw === null || raw === undefined)
524
+ return {};
525
+ if (typeof raw === "string") {
526
+ const trimmed = raw.trim();
527
+ if (!trimmed)
528
+ return {};
529
+ try {
530
+ const parsed = JSON.parse(trimmed);
531
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
532
+ }
533
+ catch {
534
+ return {};
535
+ }
536
+ }
537
+ if (typeof raw === "object" && !Array.isArray(raw))
538
+ return raw;
539
+ return {};
540
+ }
541
+ /** Stable fingerprint for draft↔published options equality (r29e F10/F11). */
542
+ function optionsFingerprint(raw) {
543
+ const options = optionsObjectOf(raw);
544
+ // 去掉平台自动垫的空值键(如 dictionaryData:""),避免 SHORT_TEXT 因
545
+ // `{dictionaryData:""}` vs `{}` 假差异而拒绝 already-published 短路(ticket 81)。
546
+ // 字典/关联真正有内容的键仍参与比对。
547
+ const meaningful = {};
548
+ for (const [key, value] of Object.entries(options)) {
549
+ if (value === "" || value === null || value === undefined)
550
+ continue;
551
+ if (Array.isArray(value) && value.length === 0)
552
+ continue;
553
+ if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0)
554
+ continue;
555
+ meaningful[key] = value;
556
+ }
557
+ if (Object.keys(meaningful).length === 0)
558
+ return "";
559
+ try {
560
+ return JSON.stringify(meaningful);
561
+ }
562
+ catch {
563
+ return String(raw ?? "");
564
+ }
565
+ }
566
+ function dictionaryBindingOf(options) {
567
+ const optionsSet = options.optionsSet;
568
+ if (!optionsSet || optionsSet.optionsType !== "dictionary")
569
+ return undefined;
570
+ const dictionary = (optionsSet.dictionary ?? {});
571
+ const use = Array.isArray(dictionary.useDictionariesData)
572
+ ? dictionary.useDictionariesData.map((row) => ({
573
+ code: String(row.code ?? ""),
574
+ ...(row.name !== undefined ? { name: String(row.name) } : {}),
575
+ ...(row.id !== undefined ? { id: String(row.id) } : {}),
576
+ }))
577
+ : [];
578
+ return {
579
+ ...(typeof dictionary.checkedDictionary === "string" ? { checkedDictionary: dictionary.checkedDictionary } : {}),
580
+ ...(typeof dictionary.checkedDictionaryCode === "string" ? { checkedDictionaryCode: dictionary.checkedDictionaryCode } : {}),
581
+ useDictionariesData: use,
582
+ };
583
+ }
584
+ async function loadEnabledDictionaryRecords(session, binding) {
585
+ const dicId = binding.checkedDictionary;
586
+ const code = binding.checkedDictionaryCode;
587
+ let id = dicId ?? "";
588
+ if (!id && code) {
589
+ const header = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code } })).data;
590
+ id = String(header?.id ?? "");
591
+ }
592
+ if (!id)
593
+ return [];
594
+ const data = await wireCall(session, { path: ENDPOINTS.dictionaryEnabledRecords, query: { dicId: id } });
595
+ const rows = Array.isArray(data.data) ? data.data : [];
596
+ return rows
597
+ .map((row) => ({ id: String(row.id ?? ""), code: String(row.code ?? ""), name: String(row.name ?? row.code ?? "") }))
598
+ .filter((row) => row.code.length > 0)
599
+ .sort((left, right) => left.code.localeCompare(right.code));
600
+ }
601
+ /** Keys that change the stored data shape (not just display labels). */
602
+ const DATA_SHAPE_OPTION_KEYS = new Set(["scale", "propertyType", "propertyEmpty", "choices"]);
603
+ function fieldValuesEqual(left, right) {
604
+ return JSON.stringify(left) === JSON.stringify(right);
605
+ }
606
+ function choiceCodesOf(options) {
607
+ const set = options.optionsSet;
608
+ if (!set || typeof set !== "object" || Array.isArray(set))
609
+ return undefined;
610
+ const custom = set.custom;
611
+ if (!Array.isArray(custom))
612
+ return undefined;
613
+ return custom
614
+ .map((entry) => {
615
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
616
+ return "";
617
+ return String(entry.code ?? entry.value ?? "");
618
+ })
619
+ .filter((code) => code.length > 0);
620
+ }
621
+ async function loadFieldRowForUpdate(session, schemaCode, code) {
622
+ const listed = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "false" } });
623
+ let rows = wireRows(listed.data);
624
+ if (!rows.some((row) => (row.code ?? row.propertyCode) === code)) {
625
+ const published = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } });
626
+ rows = wireRows(published.data);
627
+ }
628
+ const record = rows.find((row) => (row.code ?? row.propertyCode) === code);
629
+ if (!record)
630
+ throw new WireError("business", `field ${code} not found on ${schemaCode}`, 301001);
631
+ return record;
632
+ }
633
+ async function countModelDataRows(session, schemaCode) {
634
+ try {
635
+ const result = await wireCall(session, {
636
+ path: ENDPOINTS.runtimeQueryList,
637
+ method: "POST",
638
+ body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
639
+ });
640
+ return { dataRows: extractBizObjectPage(result.data).total };
641
+ }
642
+ catch (error) {
643
+ return {
644
+ dataRows: "unknown",
645
+ note: `拿不到行数:${error instanceof Error ? error.message : String(error)}`,
646
+ };
647
+ }
648
+ }
649
+ /**
650
+ * Session-aware dry-run for field.update: structured from→to changes (only real
651
+ * diffs), plus an honest dataImpact note when storage-shape keys move.
652
+ */
653
+ async function fieldUpdateDryRun(session, args) {
654
+ const record = await loadFieldRowForUpdate(session, args.schemaCode, args.code);
655
+ if (args.options !== undefined) {
656
+ const publishedRows = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })
657
+ .then((response) => wireRows(response.data))
658
+ .catch(() => []);
659
+ assertNoSelfSchemaAggregation({ schemaCode: args.schemaCode, propertyType: record.type ?? record.propertyType, options: args.options }, publishedRows);
660
+ await assertNoChildSchemaAggregation(session, args.schemaCode, record.type ?? record.propertyType, args.options);
661
+ }
662
+ const targetVersion = session.detectedVersion();
663
+ const update = {
664
+ schemaCode: args.schemaCode,
665
+ code: args.code,
666
+ base: record,
667
+ ...(targetVersion ? { targetVersion } : {}),
668
+ ...(args.name !== undefined ? { name: args.name } : {}),
669
+ ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}),
670
+ ...(args.options !== undefined ? { options: args.options } : {}),
671
+ ...(args.choices !== undefined ? { choices: args.choices } : {}),
672
+ ...(args.relativeCode !== undefined ? { relativeCode: args.relativeCode } : {}),
673
+ ...(args.subFields !== undefined ? { subFields: args.subFields } : {}),
674
+ ...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
675
+ };
676
+ const prepared = prepareFieldUpdate(update);
677
+ const currentOptions = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
678
+ const changes = [];
679
+ const pushChange = (field, from, to) => {
680
+ if (fieldValuesEqual(from, to))
681
+ return;
682
+ changes.push({ field, from, to });
683
+ };
684
+ if (args.name !== undefined)
685
+ pushChange("name", record.name ?? null, prepared.name ?? args.name);
686
+ if (args.name_i18n !== undefined) {
687
+ const fromI18n = typeof record.name_i18n === "string" ? record.name_i18n : record.name_i18n ?? null;
688
+ pushChange("name_i18n", fromI18n, prepared.nameI18n ?? args.name_i18n);
689
+ }
690
+ if (args.propertyEmpty !== undefined)
691
+ pushChange("propertyEmpty", record.propertyEmpty ?? null, args.propertyEmpty);
692
+ if (args.relativeCode !== undefined)
693
+ pushChange("relativeCode", record.relativeCode ?? null, args.relativeCode);
694
+ if (args.options !== undefined || args.choices !== undefined) {
695
+ const optionKeys = new Set([...Object.keys(currentOptions), ...Object.keys(prepared.options)]);
696
+ for (const key of [...optionKeys].sort()) {
697
+ if (key === "optionsSet" && args.choices !== undefined)
698
+ continue;
699
+ pushChange(`options.${key}`, currentOptions[key] ?? null, prepared.options[key] ?? null);
700
+ }
701
+ if (args.choices !== undefined) {
702
+ const fromChoices = choiceCodesOf(currentOptions) ?? [];
703
+ pushChange("choices", fromChoices, [...args.choices]);
704
+ }
705
+ }
706
+ const shapeTouched = changes.some((change) => {
707
+ if (change.field === "propertyEmpty" || change.field === "choices")
708
+ return true;
709
+ if (change.field === "options.scale" || change.field === "options.propertyType" || change.field === "options.propertyEmpty")
710
+ return true;
711
+ if (change.field.startsWith("options.") && DATA_SHAPE_OPTION_KEYS.has(change.field.slice("options.".length)))
712
+ return true;
713
+ return false;
714
+ });
715
+ let dataImpact;
716
+ if (shapeTouched) {
717
+ const counted = await countModelDataRows(session, args.schemaCode);
718
+ const shapeFields = changes
719
+ .filter((change) => change.field === "propertyEmpty" || change.field === "choices" || change.field === "options.scale" || change.field === "options.propertyType" || change.field === "options.propertyEmpty" || (change.field.startsWith("options.") && DATA_SHAPE_OPTION_KEYS.has(change.field.slice("options.".length))))
720
+ .map((change) => change.field);
721
+ dataImpact = {
722
+ ...(counted.dataRows === "unknown"
723
+ ? { dataRows: "unknown", note: counted.note ?? "拿不到行数:data.list total 不可用" }
724
+ : {
725
+ dataRows: counted.dataRows,
726
+ note: `触及数据形状键(${shapeFields.join("、")});现有 ${counted.dataRows} 条数据不会被本工具自动重写,请自行评估显示/校验影响`,
727
+ }),
728
+ };
729
+ }
730
+ return {
731
+ wouldChange: changes.length > 0,
732
+ changes,
733
+ ...(dataImpact !== undefined ? { dataImpact } : {}),
734
+ schemaCode: args.schemaCode,
735
+ code: args.code,
736
+ };
737
+ }
738
+ function formControlCodes(face) {
739
+ if (typeof face !== "string")
740
+ return [];
741
+ try {
742
+ const parsed = JSON.parse(face);
743
+ return Object.keys(parsed);
744
+ }
745
+ catch {
746
+ return [];
747
+ }
748
+ }
749
+ function scriptMentionsField(script, code) {
750
+ if (!script)
751
+ return false;
752
+ const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
753
+ return new RegExp(`(?:^|[^A-Za-z0-9_])${escaped}(?:[^A-Za-z0-9_]|$)`).test(script);
754
+ }
755
+ async function scanFieldRuleReferences(session, schemaCode, code) {
756
+ const references = [];
757
+ let rows = [];
758
+ try {
759
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } })).data);
760
+ }
761
+ catch (error) {
762
+ return { references, unknown: `业务规则列表不可用:${error instanceof Error ? error.message : String(error)}` };
763
+ }
764
+ for (const row of rows) {
765
+ const ruleCode = String(row.code ?? "");
766
+ const ruleId = row.id;
767
+ if (typeof ruleId !== "string" || ruleId === "")
768
+ continue;
769
+ let record = null;
770
+ try {
771
+ record = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: ruleId } })).data;
772
+ }
773
+ catch {
774
+ continue;
775
+ }
776
+ if (!record)
777
+ continue;
778
+ const nodes = decodeJsonColumn(record.node, JSON_COLUMN_NULL_COALESCE);
779
+ if (!Array.isArray(nodes))
780
+ continue;
781
+ for (const rawNode of nodes) {
782
+ const nodeCode = String(rawNode.nodeCode ?? "");
783
+ const nodeType = String(rawNode.nodeType ?? "");
784
+ const mapping = rawNode.methodMapping;
785
+ let via;
786
+ if (["CREATE", "UPDATE", "DELETE"].includes(nodeType) && Array.isArray(rawNode.dataActions)
787
+ && rawNode.dataActions.some((action) => String(action.targetDataItem ?? "") === code)) {
788
+ via = "dataActions.targetDataItem";
789
+ }
790
+ if (!via && mapping) {
791
+ for (const [key, label] of [["inputMappings", "inputMappings.bizCode"], ["outputMappings", "outputMappings.bizCode"]]) {
792
+ if (Array.isArray(mapping[key]) && mapping[key].some((entry) => String(entry.bizCode ?? "") === code && (entry.codeType === undefined || entry.codeType === 1))) {
793
+ via = label;
794
+ break;
795
+ }
796
+ }
797
+ }
798
+ if (!via)
799
+ continue;
800
+ references.push({
801
+ kind: "businessRule",
802
+ code: ruleCode || nodeCode,
803
+ name: String(record.name ?? (ruleCode || nodeCode)),
804
+ via,
805
+ schemaCode,
806
+ });
807
+ break;
808
+ }
809
+ }
810
+ return { references };
811
+ }
812
+ async function scanFieldFormListviewOnlineJs(session, schemaCode, code) {
813
+ const references = [];
814
+ const dependentUpdates = {};
815
+ // form.get 面:控件码清单
816
+ try {
817
+ const sheet = (await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode: schemaCode } })).data;
818
+ if (!sheet) {
819
+ dependentUpdates.form = { present: false, note: "默认表单不存在(sheetGet 空)" };
820
+ }
821
+ else {
822
+ const draftControls = formControlCodes(sheet.draftAttributesJson);
823
+ const publishedControls = formControlCodes(sheet.publishedAttributesJson);
824
+ const inDraft = draftControls.includes(code);
825
+ const inPublished = publishedControls.includes(code);
826
+ dependentUpdates.form = {
827
+ present: true,
828
+ inDraft,
829
+ inPublished,
830
+ note: inDraft || inPublished
831
+ ? "删除字段后表单控件会残留;需 form.draft(rebuild)/form.publish 清理"
832
+ : "默认表单控件码未引用该字段",
833
+ };
834
+ if (inDraft || inPublished) {
835
+ references.push({
836
+ kind: "form",
837
+ code: schemaCode,
838
+ name: String(sheet.name ?? schemaCode),
839
+ via: inDraft && inPublished ? "draft+published controls" : inDraft ? "draft controls" : "published controls",
840
+ schemaCode,
841
+ });
842
+ }
843
+ // online JS:扫 customScript 原文是否提及字段 code
844
+ const scripts = [];
845
+ for (const face of [sheet.draftHtmlJson, sheet.publishedHtmlJson]) {
846
+ if (typeof face !== "string" || !face.trim())
847
+ continue;
848
+ try {
849
+ const parsed = JSON.parse(face);
850
+ if (!Array.isArray(parsed))
851
+ continue;
852
+ const custom = parsed.find((element) => element?.id === "customScript");
853
+ if (typeof custom?.innerHTML === "string" && custom.innerHTML.trim())
854
+ scripts.push(custom.innerHTML);
855
+ }
856
+ catch {
857
+ // ignore malformed html face
858
+ }
859
+ }
860
+ const mentioned = scripts.some((script) => scriptMentionsField(script, code));
861
+ dependentUpdates.onlineJs = {
862
+ scanned: true,
863
+ mentioned,
864
+ note: mentioned
865
+ ? "在线 JS 原文仍包含该字段 code;删除后脚本引用会静默残留,需 onlinejs.compose 清理"
866
+ : "在线 JS 原文未匹配到该字段 code(按词边界扫描,不含间接引用)",
867
+ };
868
+ if (mentioned) {
869
+ references.push({
870
+ kind: "onlineJs",
871
+ code: schemaCode,
872
+ name: String(sheet.name ?? schemaCode),
873
+ via: "customScript.innerHTML",
874
+ schemaCode,
875
+ });
876
+ }
877
+ }
878
+ }
879
+ catch (error) {
880
+ dependentUpdates.form = { unknown: true, note: `form.get 不可用:${error instanceof Error ? error.message : String(error)}` };
881
+ dependentUpdates.onlineJs = { unknown: true, note: `onlinejs 扫描跳过:form 面读取失败` };
882
+ }
883
+ // listview.get 面:列/条件
884
+ try {
885
+ const got = await wireCall(session, { path: ENDPOINTS.listQueryGet, query: { schemaCode, code: schemaCode, clientType: "PC" } });
886
+ const model = (got.data ?? null);
887
+ if (!model || !model.id) {
888
+ dependentUpdates.listview = { present: false, note: "默认列表视图不存在" };
889
+ }
890
+ else {
891
+ const columns = Array.isArray(model.queryColumns) ? model.queryColumns : [];
892
+ const conditions = Array.isArray(model.queryConditions) ? model.queryConditions : [];
893
+ const inColumns = columns.some((column) => String(column.propertyCode ?? "") === code);
894
+ const inConditions = conditions.some((condition) => String(condition.propertyCode ?? "") === code);
895
+ dependentUpdates.listview = {
896
+ present: true,
897
+ code: String(model.code ?? schemaCode),
898
+ inColumns,
899
+ inConditions,
900
+ note: inColumns || inConditions
901
+ ? "删除字段后列表列/条件会残留;需 listview.configure 清理"
902
+ : "默认列表视图列/条件未引用该字段",
903
+ };
904
+ if (inColumns || inConditions) {
905
+ references.push({
906
+ kind: "listview",
907
+ code: String(model.code ?? schemaCode),
908
+ name: String(model.name ?? model.code ?? schemaCode),
909
+ via: inColumns && inConditions ? "columns+conditions" : inColumns ? "columns" : "conditions",
910
+ schemaCode,
911
+ });
912
+ }
913
+ }
914
+ }
915
+ catch (error) {
916
+ dependentUpdates.listview = { unknown: true, note: `listview.get 不可用:${error instanceof Error ? error.message : String(error)}` };
917
+ }
918
+ return { references, dependentUpdates };
919
+ }
920
+ /**
921
+ * field.delete 影响面:dry-run 全量(行数 + 规则 + 表单/视图/在线JS),
922
+ * 真写回执给摘要(不扫规则图,只扫表单/视图/在线JS + 行数)。
923
+ */
924
+ async function collectFieldDeleteImpact(session, args, options) {
925
+ const counted = await countModelDataRows(session, args.schemaCode);
926
+ const references = [];
927
+ const unknowns = [];
928
+ if (options.full) {
929
+ const rules = await scanFieldRuleReferences(session, args.schemaCode, args.code);
930
+ references.push(...rules.references);
931
+ if (rules.unknown)
932
+ unknowns.push(rules.unknown);
933
+ }
934
+ else {
935
+ unknowns.push("真写回执未扫业务规则引用(成本高);完整引用面请先 --dry-run");
936
+ }
937
+ const surfaces = await scanFieldFormListviewOnlineJs(session, args.schemaCode, args.code);
938
+ references.push(...surfaces.references);
939
+ for (const key of ["form", "listview", "onlineJs"]) {
940
+ const face = surfaces.dependentUpdates[key];
941
+ if (face?.unknown === true && typeof face.note === "string")
942
+ unknowns.push(face.note);
943
+ }
944
+ return {
945
+ dataRows: counted.dataRows,
946
+ ...(counted.note ? { dataRowsNote: counted.note } : counted.dataRows === "unknown" ? { dataRowsNote: "拿不到行数:data.list total 不可用" } : {}),
947
+ references,
948
+ dependentUpdates: surfaces.dependentUpdates,
949
+ ...(unknowns.length > 0 ? { unknown: unknowns } : {}),
950
+ note: options.full
951
+ ? "完整影响面(规则+表单+视图+在线JS+行数)。删除不会自动清理依赖面残留。"
952
+ : "摘要影响面(表单+视图+在线JS+行数;规则请看 dry-run)。删除不会自动清理依赖面残留。",
953
+ };
954
+ }
552
955
  /** The proven field.update write body — shared by the write tool and the cascade path. */
553
956
  async function runFieldUpdate(session, args) {
554
957
  const listed = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
@@ -560,7 +963,33 @@ async function runFieldUpdate(session, args) {
560
963
  const record = rows.find((row) => (row.code ?? row.propertyCode) === args.code);
561
964
  if (!record)
562
965
  throw new WireError("business", `field ${args.code} not found on ${args.schemaCode}`, 301001);
563
- if (args.options !== undefined) {
966
+ let effectiveOptions = args.options;
967
+ let dictionarySynced;
968
+ if (args.syncDictionary === true) {
969
+ const baseOptions = optionsObjectOf(args.options ?? record.options);
970
+ const binding = dictionaryBindingOf(baseOptions);
971
+ if (!binding?.checkedDictionary && !binding?.checkedDictionaryCode) {
972
+ throw new ToolRefusalError({
973
+ status: "refused",
974
+ blocker: "field-not-dictionary-backed",
975
+ schemaCode: args.schemaCode,
976
+ code: args.code,
977
+ message: `field.update syncDictionary:true 要求字段 options.optionsSet.optionsType=dictionary(含 checkedDictionary/checkedDictionaryCode);${args.schemaCode}.${args.code} 不是字典-backed`,
978
+ });
979
+ }
980
+ const enabled = await loadEnabledDictionaryRecords(session, binding);
981
+ const optionsSet = { ...baseOptions.optionsSet };
982
+ const dictionary = { ...(optionsSet.dictionary ?? {}) };
983
+ dictionary.useDictionariesData = enabled;
984
+ optionsSet.dictionary = dictionary;
985
+ optionsSet.optionsType = "dictionary";
986
+ effectiveOptions = { ...baseOptions, optionsSet };
987
+ dictionarySynced = {
988
+ from: binding.useDictionariesData.map((row) => row.code).filter(Boolean),
989
+ to: enabled.map((row) => row.code),
990
+ };
991
+ }
992
+ if (effectiveOptions !== undefined) {
564
993
  // Same platform cycle as field.create (r17). On UPDATE the caller may also
565
994
  // be rewriting a previously-fine AGGREGATION, so resolve the child tables
566
995
  // from the published face the same way; a read failure must not silently
@@ -568,8 +997,8 @@ async function runFieldUpdate(session, args) {
568
997
  const publishedRows = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })
569
998
  .then((response) => wireRows(response.data))
570
999
  .catch(() => []);
571
- assertNoSelfSchemaAggregation({ schemaCode: args.schemaCode, propertyType: record.type ?? record.propertyType, options: args.options }, publishedRows);
572
- await assertNoChildSchemaAggregation(session, args.schemaCode, record.type ?? record.propertyType, args.options);
1000
+ assertNoSelfSchemaAggregation({ schemaCode: args.schemaCode, propertyType: record.type ?? record.propertyType, options: effectiveOptions }, publishedRows);
1001
+ await assertNoChildSchemaAggregation(session, args.schemaCode, record.type ?? record.propertyType, effectiveOptions);
573
1002
  }
574
1003
  const targetVersion = session.detectedVersion();
575
1004
  const update = {
@@ -579,7 +1008,7 @@ async function runFieldUpdate(session, args) {
579
1008
  ...(targetVersion ? { targetVersion } : {}),
580
1009
  ...(args.name !== undefined ? { name: args.name } : {}),
581
1010
  ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}),
582
- ...(args.options !== undefined ? { options: args.options } : {}),
1011
+ ...(effectiveOptions !== undefined ? { options: effectiveOptions } : {}),
583
1012
  ...(args.choices !== undefined ? { choices: args.choices } : {}),
584
1013
  ...(args.relativeCode !== undefined ? { relativeCode: args.relativeCode } : {}),
585
1014
  ...(args.subFields !== undefined ? { subFields: args.subFields } : {}),
@@ -597,7 +1026,7 @@ async function runFieldUpdate(session, args) {
597
1026
  // failure never hides the field write (it is surfaced as formConditionSync.error).
598
1027
  let formConditionSync;
599
1028
  let formConditionSyncError;
600
- if (args.options !== undefined && !prepared.isChildTable) {
1029
+ if (effectiveOptions !== undefined && !prepared.isChildTable) {
601
1030
  const previous = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
602
1031
  try {
603
1032
  const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, previous, prepared.options);
@@ -619,11 +1048,13 @@ async function runFieldUpdate(session, args) {
619
1048
  ? { note: { conditionCompiled: prepared.conditionCompiled, hint: "符号式/裸值条件已就地编译为平台中文规范形状;单选字段按选项 code 比较仍需 specifyOption;此配置是门户交互层,data.submit 不拦截" } }
620
1049
  : {}),
621
1050
  readback: {
1051
+ face: "draft",
622
1052
  fieldPresent: after !== undefined,
623
1053
  ...(after?.name !== undefined ? { name: after.name } : {}),
624
1054
  ...(typeof after?.name_i18n === "string" ? { name_i18n: after.name_i18n } : {}),
625
1055
  ...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
626
1056
  ...(storedOptions !== undefined && storedOptions !== null ? { options: storedOptions } : {}),
1057
+ ...(dictionarySynced !== undefined ? { dictionarySynced } : {}),
627
1058
  ...(formConditionSync !== undefined ? { formConditionSync } : {}),
628
1059
  ...(formConditionSyncError !== undefined ? { formConditionSync: { error: formConditionSyncError } } : {}),
629
1060
  },
@@ -638,6 +1069,182 @@ async function runFieldUpdate(session, args) {
638
1069
  * cascaded republish exists precisely because the PUBLISHED face carries the
639
1070
  * stale shape while the field is present there.
640
1071
  */
1072
+ /**
1073
+ * GitLab issue #24(2026-11-05,Windows 8.6.44 实测):`field.publish` 用**子表**
1074
+ * 的 schemaCode 调用时平台回 300004「数据模型不存在!」——子表不是顶层 biz schema,
1075
+ * 发布是父模型级的。旧回执只给 `omc help omc-refusals`,用户只能自己猜父模型。
1076
+ * 这里在失败路径上反查一次(读面,最多 60 个模型、并发 4),把父模型直接点名。
1077
+ */
1078
+ async function findChildTableParent(session, childCode) {
1079
+ const candidates = wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data)
1080
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && String(row.code).length > 0)
1081
+ .slice(0, 60);
1082
+ const queue = [...candidates];
1083
+ let found;
1084
+ const workers = Array.from({ length: 4 }, async () => {
1085
+ for (;;) {
1086
+ if (found !== undefined)
1087
+ return;
1088
+ const row = queue.shift();
1089
+ if (row === undefined)
1090
+ return;
1091
+ const parent = String(row.code);
1092
+ try {
1093
+ const fields = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: parent, isPublish: "true" } })).data);
1094
+ if (fields.some((field) => Number(field.type ?? field.propertyType) === FIELD_PROPERTY_TYPES.CHILD_TABLE && String(field.code ?? field.propertyCode ?? "") === childCode)) {
1095
+ found = { parent };
1096
+ return;
1097
+ }
1098
+ }
1099
+ catch {
1100
+ // A model whose field face is unreadable must not abort the search.
1101
+ }
1102
+ }
1103
+ });
1104
+ await Promise.all(workers);
1105
+ return found;
1106
+ }
1107
+ /**
1108
+ * GitLab issue #24 的**主诉**:`field.publish` 成功只证明「字段可用」,
1109
+ * 业务页面还要表单布局里有这个控件。顶层字段看 attributes 的顶层键;子表列看
1110
+ * 父 Sheet201 控件的 `columns`(child keys 永远不进 viewJson)。
1111
+ *
1112
+ * 只在**确实缺**的时候占字节;同步时回一个短字符串。
1113
+ */
1114
+ async function dictionaryStaleAdvisory(session, publishedRows) {
1115
+ const stale = [];
1116
+ const cache = new Map();
1117
+ for (const row of publishedRows) {
1118
+ const code = String(row.code ?? row.propertyCode ?? "");
1119
+ if (!code || SYSTEM_FIELD_CODES.has(code))
1120
+ continue;
1121
+ const binding = dictionaryBindingOf(optionsObjectOf(row.options));
1122
+ if (!binding)
1123
+ continue;
1124
+ const cacheKey = `${binding.checkedDictionary ?? ""}|${binding.checkedDictionaryCode ?? ""}`;
1125
+ let enabledCodes = cache.get(cacheKey);
1126
+ if (enabledCodes === undefined) {
1127
+ try {
1128
+ enabledCodes = (await loadEnabledDictionaryRecords(session, binding)).map((entry) => entry.code);
1129
+ }
1130
+ catch {
1131
+ continue;
1132
+ }
1133
+ cache.set(cacheKey, enabledCodes);
1134
+ }
1135
+ const fieldCodes = binding.useDictionariesData.map((entry) => entry.code).filter(Boolean);
1136
+ const enabledSet = new Set(enabledCodes);
1137
+ const fieldSet = new Set(fieldCodes);
1138
+ const missingInField = enabledCodes.filter((entry) => !fieldSet.has(entry));
1139
+ const extraInField = fieldCodes.filter((entry) => !enabledSet.has(entry));
1140
+ if (missingInField.length > 0 || extraInField.length > 0) {
1141
+ stale.push({ code, missingInField, extraInField });
1142
+ }
1143
+ }
1144
+ return stale.length > 0 ? stale : undefined;
1145
+ }
1146
+ function relationMappingsEmptyWarnings(publishedRows) {
1147
+ const empty = publishedRows
1148
+ .filter((row) => {
1149
+ const type = Number(row.type ?? row.propertyType);
1150
+ if (type !== FIELD_PROPERTY_TYPES.WORK_SHEET)
1151
+ return false;
1152
+ const options = optionsObjectOf(row.options);
1153
+ const mappings = options.mappings;
1154
+ if (mappings === undefined || mappings === null)
1155
+ return true;
1156
+ if (typeof mappings === "string")
1157
+ return mappings.trim() === "" || mappings.trim() === "[]";
1158
+ if (Array.isArray(mappings))
1159
+ return mappings.length === 0;
1160
+ return false;
1161
+ })
1162
+ .map((row) => String(row.code ?? row.propertyCode ?? ""))
1163
+ .filter((code) => code.length > 0);
1164
+ if (empty.length === 0)
1165
+ return undefined;
1166
+ return [{ code: "relation-mappings-empty", fields: empty, hint: "选中时不回填副本列;配 options.mappings 后再 field.publish" }];
1167
+ }
1168
+ async function formBindingAdvisory(session, schemaCode, publishedRows) {
1169
+ const dictionaryStale = await dictionaryStaleAdvisory(session, publishedRows);
1170
+ const warnings = relationMappingsEmptyWarnings(publishedRows);
1171
+ let form = null;
1172
+ try {
1173
+ form = await readFormOrNull(session, schemaCode, schemaCode);
1174
+ }
1175
+ catch {
1176
+ return undefined;
1177
+ }
1178
+ const extras = {
1179
+ ...(dictionaryStale !== undefined ? { dictionaryStale } : {}),
1180
+ ...(warnings !== undefined ? { warnings } : {}),
1181
+ };
1182
+ if (form === null) {
1183
+ return {
1184
+ formPresent: false,
1185
+ note: `模型没有表单,业务页面看不到任何字段;建表单:omc call configure_form --args '${JSON.stringify({ schemaCode })}'`,
1186
+ ...extras,
1187
+ };
1188
+ }
1189
+ const attributesRaw = typeof form.publishedAttributesJson === "string" && form.publishedAttributesJson.trim() !== "" && form.publishedAttributesJson.trim() !== "{}"
1190
+ ? form.publishedAttributesJson
1191
+ : typeof form.draftAttributesJson === "string" && form.draftAttributesJson.trim() !== "" && form.draftAttributesJson.trim() !== "{}"
1192
+ ? form.draftAttributesJson
1193
+ : undefined;
1194
+ if (attributesRaw === undefined) {
1195
+ return {
1196
+ formPresent: true,
1197
+ note: `表单存在但布局为空,业务页面看不到字段;重建:omc call configure_form --args '${JSON.stringify({ schemaCode, rebuild: true })}'`,
1198
+ ...extras,
1199
+ };
1200
+ }
1201
+ let attributes;
1202
+ try {
1203
+ attributes = JSON.parse(attributesRaw);
1204
+ }
1205
+ catch {
1206
+ return { formPresent: true, note: "表单布局不是合法 JSON,未做绑定核对(先 form.get 读原文修复)", ...extras };
1207
+ }
1208
+ const business = publishedRows.filter((row) => {
1209
+ const code = String(row.code ?? row.propertyCode ?? "");
1210
+ return code.length > 0 && !SYSTEM_FIELD_CODES.has(code) && FIELD_PROPERTY_TYPES[fieldTypeName(row)] !== undefined;
1211
+ });
1212
+ const missingTopLevel = business
1213
+ .filter((row) => fieldTypeName(row) !== "CHILD_TABLE" && attributes[String(row.code ?? row.propertyCode)] === undefined)
1214
+ .map((row) => String(row.code ?? row.propertyCode));
1215
+ const missingChildColumns = [];
1216
+ for (const row of business) {
1217
+ if (fieldTypeName(row) !== "CHILD_TABLE")
1218
+ continue;
1219
+ const parent = String(row.code ?? row.propertyCode);
1220
+ const columns = attributes[parent]?.columns;
1221
+ const bound = new Set(Array.isArray(columns) ? columns.map((column) => String(column.key ?? "")) : []);
1222
+ const missing = childColumnsOf(row.subSchema).filter((column) => column.system !== true && !bound.has(column.code)).map((column) => column.code);
1223
+ if (missing.length > 0)
1224
+ missingChildColumns.push({ parent, fieldCodes: missing });
1225
+ }
1226
+ if (missingTopLevel.length === 0 && missingChildColumns.length === 0) {
1227
+ if (dictionaryStale === undefined && warnings === undefined)
1228
+ return "in-sync";
1229
+ return { formPresent: true, layout: "in-sync", ...extras };
1230
+ }
1231
+ const fix = missingChildColumns.length > 0
1232
+ ? { schemaCode, ...(missingTopLevel.length > 0 ? { fieldCodes: missingTopLevel } : {}), childColumns: missingChildColumns }
1233
+ : { schemaCode, fieldCodes: missingTopLevel, rebuild: true };
1234
+ return {
1235
+ formPresent: true,
1236
+ ...(missingTopLevel.length > 0 ? { notInFormLayout: missingTopLevel } : {}),
1237
+ ...(missingChildColumns.length > 0 ? { childColumnsNotInForm: missingChildColumns } : {}),
1238
+ note: "字段已发布但**表单布局里没有**,业务页面看不到。子表列用 childColumns 增量绑进 Sheet201(不动其它控件);顶层缺字段用 rebuild:true(会重建布局)",
1239
+ nextCommand: `omc call configure_form --args '${JSON.stringify(fix)}'`,
1240
+ ...extras,
1241
+ };
1242
+ }
1243
+ /** PropertyType label for a raw field row (accepts {type} or {propertyType}). */
1244
+ function fieldTypeName(row) {
1245
+ const raw = row.typeName ?? fieldTypeLabel(row.type ?? row.propertyType);
1246
+ return String(raw ?? "");
1247
+ }
641
1248
  export async function executeFieldPublish(session, args) {
642
1249
  // bizproperty/publish is MODEL-SCOPED: one call publishes every draft
643
1250
  // field of the schema. `code`/`codes` only select which fields to verify
@@ -683,8 +1290,30 @@ export async function executeFieldPublish(session, args) {
683
1290
  return { mode: "detail", verified: true, checked: bounded.length, ok: mismatches.length === 0, ...(mismatches.length > 0 ? { mismatches } : {}) };
684
1291
  };
685
1292
  const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
686
- const prePublished = wireRows(preRead.data).map(codeOf).filter((code) => code.length > 0);
687
- if (args.force !== true && requestedCodes.length > 0 && requestedCodes.every((code) => prePublished.includes(code))) {
1293
+ const prePublishedRows = wireRows(preRead.data);
1294
+ const prePublished = prePublishedRows.map(codeOf).filter((code) => code.length > 0);
1295
+ // r29e F10/F11:旧短路只看「code 在发布面存在」,改了 options 仍报
1296
+ // already-published + verification.ok(假绿)。再比草稿/发布 options 指纹;
1297
+ // 不一致就真发布。name/type 仍由 verifyFieldDetails 核对。
1298
+ const draftOptionsByCode = new Map(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data)
1299
+ .map((row) => [codeOf(row), optionsFingerprint(row.options)])
1300
+ .filter(([code]) => code.length > 0));
1301
+ const publishedOptionsByCode = new Map(prePublishedRows
1302
+ .map((row) => [codeOf(row), optionsFingerprint(row.options)])
1303
+ .filter(([code]) => code.length > 0));
1304
+ const optionsMatchRequested = requestedCodes.length > 0 &&
1305
+ requestedCodes.every((code) => {
1306
+ if (!prePublished.includes(code))
1307
+ return false;
1308
+ // 模型级 publish 后草稿面可能被清空(ticket 81 幂等重试场景)——草稿行不在
1309
+ // 时没有「内容不一致」可证,按已发布存在即可短路。
1310
+ if (!draftOptionsByCode.has(code))
1311
+ return true;
1312
+ const draftFp = draftOptionsByCode.get(code) ?? "";
1313
+ const publishedFp = publishedOptionsByCode.get(code) ?? "";
1314
+ return draftFp === publishedFp;
1315
+ });
1316
+ if (args.force !== true && requestedCodes.length > 0 && requestedCodes.every((code) => prePublished.includes(code)) && optionsMatchRequested) {
688
1317
  return {
689
1318
  performed: { skipped: "already-published", scope: "model" },
690
1319
  readback: {
@@ -693,6 +1322,7 @@ export async function executeFieldPublish(session, args) {
693
1322
  ...(args.codes ? { publishedFieldsPresent: Object.fromEntries(args.codes.map((code) => [code, true])) } : {}),
694
1323
  allRequestedPresent: true,
695
1324
  verification: await verifyFieldDetails(requestedCodes),
1325
+ formBinding: await formBindingAdvisory(session, args.schemaCode, prePublishedRows),
696
1326
  },
697
1327
  };
698
1328
  }
@@ -718,6 +1348,22 @@ export async function executeFieldPublish(session, args) {
718
1348
  break;
719
1349
  }
720
1350
  catch (error) {
1351
+ // GitLab issue #24 附带坑 1:用**子表** schemaCode 发布 → 平台 300004
1352
+ // 「数据模型不存在!」。子表不是顶层 biz schema,发布是父模型级。旧回执只
1353
+ // 把裸 wire 错误冒泡 + 指向 omc help omc-refusals,用户只能自己猜父模型。
1354
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 300004) {
1355
+ const parent = await findChildTableParent(session, args.schemaCode).catch(() => undefined);
1356
+ throw new ToolRefusalError({
1357
+ status: "refused",
1358
+ blocker: parent ? "field-publish-child-schema" : "wire-business",
1359
+ errcode: 300004,
1360
+ schemaCode: args.schemaCode,
1361
+ ...(parent ? { childOf: parent.parent, nextCommand: `omc call field.publish --args '${JSON.stringify({ schemaCode: parent.parent })}'` } : {}),
1362
+ message: parent
1363
+ ? `${args.schemaCode} 是子表(属于父模型 ${parent.parent}),不是顶层数据模型——平台按顶层 schema 发布,所以回 300004「数据模型不存在!」。改发父模型:omc call field.publish --args '${JSON.stringify({ schemaCode: parent.parent })}'(模型级一次发布全部草稿字段,子表列随父模型一起发布)`
1364
+ : `${args.schemaCode} 不在顶层数据模型清单里(300004 数据模型不存在)。若它是子表,请用**父模型**的 schemaCode 发布;\`omc call model.list\` 可核对顶层模型 code`,
1365
+ });
1366
+ }
721
1367
  const transientColumnSync = error instanceof WireError && error.kind === "business" && error.errcode === 50000 && error.message.includes("不存在列");
722
1368
  if (!(transientColumnSync && attempt < 3))
723
1369
  throw error;
@@ -748,6 +1394,8 @@ export async function executeFieldPublish(session, args) {
748
1394
  ...(args.codes ? { publishedFieldsPresent: Object.fromEntries(args.codes.map((code) => [code, publishedCodes.includes(code)])) } : {}),
749
1395
  ...(requested.length > 0 ? { allRequestedPresent: present.length === requested.length } : {}),
750
1396
  verification,
1397
+ // issue #24 主诉:publishedCodes 只证明「字段可用」,不等于「页面可见」。
1398
+ formBinding: await formBindingAdvisory(session, args.schemaCode, wireRows(readback.data)),
751
1399
  },
752
1400
  };
753
1401
  }