@stalfh233/omc-cli 0.4.73 → 0.4.75

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.
@@ -665,6 +665,171 @@ export function refuseEmptyCallerLayout(args) {
665
665
  message: `显式提供的 ${emptyFaces.join("、")} 是空布局("[]"/"{}")——平台设计器 Save 会把草稿清成该形态(非无损),照写会丢控件。若确要清空布局请显式传 allowEmptyLayout:true;若是设计器 Save 后的修复,先用表单版本历史(/api/api/app/bizsheet/history/list)取回完好版本原文再写回`,
666
666
  });
667
667
  }
668
+ /**
669
+ * Form-toolbar buttons are a THREE-LAYER configuration (live 2026-10-23, 1.1.25
670
+ * + backend source):
671
+ * 1. `bizsheet.options.customButtonJson` (a STRINGIFIED JSON array) is the
672
+ * authoring face — `ModelManagementFacadeImpl.publishBizForm:1022` turns it
673
+ * into `h_biz_button` rows with `triggerType=FORM`, `triggerCode=<formCode>`
674
+ * at publish time.
675
+ * 2. Those rows are what `ActionPermissionHandler` reads
676
+ * (`getByTriggerTypeAndCode(FORM, bizCode)`).
677
+ * 3. Whether a button shows at a given node is filtered by the workflow
678
+ * activity's `buttonPermission.customBtns[].enabled` (the designer's
679
+ * 「表单操作」 checkboxes) plus `showPermCode`.
680
+ *
681
+ * `customButtonJson` MUST stay a string inside the options JSON — passing the
682
+ * array inline makes the platform answer 50000 (fastjson
683
+ * "syntax error, position at 0, name customButtonJson").
684
+ */
685
+ export const FORM_BUTTON_LOCATIONS = Object.freeze(["FORM", "ROW", "LIST"]);
686
+ const FORM_BUTTON_BIND_ACTIONS = new Set([
687
+ "LIST",
688
+ "FORM",
689
+ "ADD_DATA",
690
+ "FLOW",
691
+ "BUSINESS_RULE",
692
+ "SCRIPT",
693
+ "CUSTOM_PAGE",
694
+ "WORKFLOW_CENTER_PAGE",
695
+ "FRONT_EVENT",
696
+ "AGENT",
697
+ ]);
698
+ const FORM_BUTTON_OPERATE_TYPES = new Set(["WITH_VIEW", "WITH_OUT_VIEW"]);
699
+ /** Validate the caller's form-button list before any wire (same shape as listview buttons, FORM location). */
700
+ export function validateFormButtons(buttons, tool) {
701
+ if (!Array.isArray(buttons))
702
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons 必须是数组(BizButtonModel 数组)` });
703
+ const seen = new Set();
704
+ return buttons.map((raw, index) => {
705
+ const button = (raw ?? {});
706
+ const code = typeof button.code === "string" ? button.code : "";
707
+ const name = typeof button.name === "string" ? button.name : "";
708
+ if (code === "")
709
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].code 必填(表单按钮的唯一标识,流程节点按它勾选)` });
710
+ if (name === "")
711
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].name 必填(设计器/表单页显示名)` });
712
+ if (seen.has(code))
713
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-duplicate", message: `${tool}: buttons 里 code ${code} 重复` });
714
+ seen.add(code);
715
+ const useLocation = typeof button.useLocation === "string" ? button.useLocation : "FORM";
716
+ if (!FORM_BUTTON_LOCATIONS.includes(useLocation))
717
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].useLocation 只能是 ${FORM_BUTTON_LOCATIONS.join("|")}(表单工具栏按钮用 FORM)` });
718
+ const bindAction = typeof button.bindAction === "string" ? button.bindAction : "";
719
+ if (!FORM_BUTTON_BIND_ACTIONS.has(bindAction))
720
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].bindAction 必须是 ButtonBindAction 名(SCRIPT/BUSINESS_RULE/FLOW/FORM/ADD_DATA/CUSTOM_PAGE/…)` });
721
+ const operateType = typeof button.operateType === "string" ? button.operateType : "";
722
+ if (!FORM_BUTTON_OPERATE_TYPES.has(operateType))
723
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].operateType 必须是 WITH_VIEW 或 WITH_OUT_VIEW` });
724
+ if (bindAction === "SCRIPT" || bindAction === "BUSINESS_RULE" || bindAction === "FLOW" || bindAction === "ADD_DATA" || bindAction === "FORM") {
725
+ const actionConfig = button.actionConfig;
726
+ const record = typeof actionConfig === "string" ? (() => { try {
727
+ return JSON.parse(actionConfig);
728
+ }
729
+ catch {
730
+ return undefined;
731
+ } })() : actionConfig;
732
+ const config = record && typeof record === "object" && !Array.isArray(record) ? record : undefined;
733
+ if (bindAction === "SCRIPT" && typeof config?.scriptData !== "string")
734
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] SCRIPT 需要 actionConfig.scriptData(前端脚本源码字符串)` });
735
+ if ((bindAction === "BUSINESS_RULE" || bindAction === "FLOW") && (typeof button.targetCode !== "string" || button.targetCode === ""))
736
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] ${bindAction} 需要 targetCode` });
737
+ }
738
+ return {
739
+ ...button,
740
+ code,
741
+ name,
742
+ useLocation,
743
+ bindAction,
744
+ operateType,
745
+ // The platform stores actionConfig as a string; a caller's object is
746
+ // serialized once here (double-encoding breaks the renderer).
747
+ ...(button.actionConfig !== undefined && typeof button.actionConfig !== "string" ? { actionConfig: JSON.stringify(button.actionConfig) } : {}),
748
+ sortKey: typeof button.sortKey === "number" ? button.sortKey : index,
749
+ };
750
+ });
751
+ }
752
+ function decodeOptionsColumn(value) {
753
+ if (value === null || value === undefined || value === "")
754
+ return {};
755
+ if (typeof value === "object" && !Array.isArray(value))
756
+ return { ...value };
757
+ if (typeof value === "string") {
758
+ try {
759
+ const parsed = JSON.parse(value);
760
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
761
+ return { ...parsed };
762
+ }
763
+ catch {
764
+ // A corrupt options column must not silently drop other options.
765
+ }
766
+ }
767
+ return {};
768
+ }
769
+ /**
770
+ * Merge the caller's form buttons into the sheet `options` column. Returns the
771
+ * options JSON string to POST (undefined = leave the column untouched).
772
+ */
773
+ export function mergeFormOptionsWithButtons(existingOptions, input, tool) {
774
+ const hasButtons = input.buttons !== undefined;
775
+ const hasJson = input.customButtonJson !== undefined;
776
+ const removeCodes = input.removeButtonCodes ?? [];
777
+ if (hasButtons && hasJson)
778
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-ambiguous", message: `${tool}: buttons 与 customButtonJson 只能给一种(buttons 数组更好读;customButtonJson 用于原样透传读回值)` });
779
+ if (!hasButtons && !hasJson && removeCodes.length === 0)
780
+ return undefined;
781
+ let incoming = [];
782
+ if (hasButtons) {
783
+ incoming = validateFormButtons(input.buttons, tool);
784
+ }
785
+ else if (hasJson) {
786
+ const raw = String(input.customButtonJson);
787
+ let parsed;
788
+ try {
789
+ parsed = JSON.parse(raw);
790
+ }
791
+ catch {
792
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: customButtonJson 必须是 JSON 数组字符串` });
793
+ }
794
+ incoming = validateFormButtons(parsed, tool);
795
+ }
796
+ const options = decodeOptionsColumn(existingOptions);
797
+ // Default MERGE: an upsert by `code` so a caller can add or edit ONE button
798
+ // without re-sending the whole list (and without a read-modify-write race).
799
+ // `replace` restores the whole-list semantics.
800
+ const mode = input.buttonsMode ?? "merge";
801
+ const existing = readFormButtons(options) ?? [];
802
+ let final;
803
+ if (mode === "replace") {
804
+ final = incoming;
805
+ }
806
+ else {
807
+ const byCode = new Map(existing.map((button) => [String(button.code ?? ""), button]));
808
+ for (const button of incoming)
809
+ byCode.set(String(button.code), { ...(byCode.get(String(button.code)) ?? {}), ...button });
810
+ for (const code of removeCodes)
811
+ byCode.delete(String(code));
812
+ final = [...byCode.values()].map((button, index) => ({ ...button, sortKey: typeof button.sortKey === "number" ? button.sortKey : index }));
813
+ }
814
+ if (removeCodes.length > 0 && mode === "replace")
815
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: removeButtonCodes 只用于 buttonsMode:"merge"(replace 已用新列表整体替换)` });
816
+ options.customButtonJson = JSON.stringify(final);
817
+ return JSON.stringify(options);
818
+ }
819
+ /** The caller's buttons from a READ face (parsed array, or null when absent). */
820
+ export function readFormButtons(options) {
821
+ const record = decodeOptionsColumn(options);
822
+ const raw = record.customButtonJson;
823
+ if (typeof raw !== "string" || raw === "")
824
+ return null;
825
+ try {
826
+ const parsed = JSON.parse(raw);
827
+ return Array.isArray(parsed) ? parsed : null;
828
+ }
829
+ catch {
830
+ return null;
831
+ }
832
+ }
668
833
  export async function executeFormDraftWrite(session, args) {
669
834
  if (args.draftHtmlJson !== undefined) {
670
835
  assertHtmlRender(args.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
@@ -856,6 +1021,11 @@ export async function executeFormDraftWrite(session, args) {
856
1021
  if (composed !== undefined)
857
1022
  assertFormulasSurvivedRebuild(composed, previousAttributes, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
858
1023
  }
1024
+ // Form toolbar buttons live on `options.customButtonJson`; the publish step
1025
+ // turns them into h_biz_button rows (triggerType=FORM). Merge rather than
1026
+ // replace so unrelated options (presetButtonManage, sheetPresetButtonManage)
1027
+ // survive.
1028
+ const mergedOptions = mergeFormOptionsWithButtons(pre?.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.draft");
859
1029
  const payload = {
860
1030
  ...(pre ?? {}),
861
1031
  schemaCode: args.schemaCode,
@@ -863,6 +1033,7 @@ export async function executeFormDraftWrite(session, args) {
863
1033
  name: pre?.name ?? args.sheetCode,
864
1034
  sheetType: pre?.sheetType ?? 0,
865
1035
  published: false,
1036
+ ...(mergedOptions !== undefined ? { options: mergedOptions } : {}),
866
1037
  // Draft writes are full-row updates, but they must not destroy the
867
1038
  // currently published face. Publish promotion is a separate operation;
868
1039
  // preserving these columns keeps the published form usable if the draft
@@ -878,7 +1049,115 @@ export async function executeFormDraftWrite(session, args) {
878
1049
  const performed = await wireCall(session, { path: ENDPOINTS.sheetUpdate, method: "POST", body: payload });
879
1050
  const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
880
1051
  const sheet = (readback.data ?? {});
881
- return { performed: { errcode: performed.errcode }, readback: { hasDraft: sheet.existDraft === true || typeof sheet.draftViewJson === "string", ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}) } };
1052
+ const formButtons = readFormButtons(sheet.options);
1053
+ return {
1054
+ performed: { errcode: performed.errcode },
1055
+ readback: {
1056
+ hasDraft: sheet.existDraft === true || typeof sheet.draftViewJson === "string",
1057
+ ...(formButtons !== null ? { formButtons, formButtonCodes: formButtons.map((button) => String(button.code ?? "")) } : {}),
1058
+ ...(mergedOptions !== undefined ? { customButtonJsonWritten: true } : {}),
1059
+ ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}),
1060
+ },
1061
+ };
1062
+ }
1063
+ /** Schema-wide FORM buttons (code -> row) — the set a workflow node can tick. */
1064
+ export async function readSchemaFormButtons(session, schemaCode) {
1065
+ const inventory = (await wireCall(session, { path: ENDPOINTS.systemGetCustomButtons, query: { schemaCode } })).data;
1066
+ const triggers = Array.isArray(inventory?.formCustomButtons) ? inventory.formCustomButtons : [];
1067
+ const byCode = {};
1068
+ for (const trigger of triggers) {
1069
+ const buttons = Array.isArray(trigger.buttonModels) ? trigger.buttonModels : [];
1070
+ for (const button of buttons) {
1071
+ const code = String(button.code ?? "");
1072
+ if (code !== "" && byCode[code] === undefined)
1073
+ byCode[code] = button;
1074
+ }
1075
+ }
1076
+ return byCode;
1077
+ }
1078
+ /**
1079
+ * Execute ONE form-toolbar button on ONE record — the exact request the portal
1080
+ * posts when that button is clicked (live 2026-10-23, Playwright capture,
1081
+ * AI-Platform 1.1.25):
1082
+ * POST /api/api/runtime/form/executeCustomButton
1083
+ * {bizButtonModel:<the h_biz_button row>, bizObjectList:[<full bizObject>],
1084
+ * reacquireDataFromDb:true}
1085
+ * Distinct from the list-row button face (`launcher/domain_service_runtime`
1086
+ * with `ids[]`): form buttons carry the whole record.
1087
+ */
1088
+ export async function executeFormRunButtonWrite(session, args) {
1089
+ const inventory = (await wireCall(session, { path: ENDPOINTS.systemGetCustomButtons, query: { schemaCode: args.schemaCode } })).data;
1090
+ const triggers = Array.isArray(inventory?.formCustomButtons) ? inventory.formCustomButtons : [];
1091
+ const trigger = triggers.find((row) => String(row.triggerCode ?? "") === args.sheetCode) ?? triggers[0];
1092
+ const buttons = Array.isArray(trigger?.buttonModels) ? trigger.buttonModels : [];
1093
+ if (buttons.length === 0)
1094
+ throw new ToolRefusalError({
1095
+ status: "refused",
1096
+ blocker: "form-button-absent",
1097
+ schemaCode: args.schemaCode,
1098
+ sheetCode: args.sheetCode,
1099
+ message: `表单 ${args.sheetCode} 没有自定义按钮(getCustomButtons 的 buttonModels 为空)——先用 form.publish 传 buttons(写 options.customButtonJson)生成 h_biz_button(triggerType=FORM)`,
1100
+ });
1101
+ const button = buttons.find((row) => String(row.code ?? "") === args.buttonCode);
1102
+ if (!button)
1103
+ throw new ToolRefusalError({
1104
+ status: "refused",
1105
+ blocker: "form-button-absent",
1106
+ schemaCode: args.schemaCode,
1107
+ sheetCode: args.sheetCode,
1108
+ buttonCode: args.buttonCode,
1109
+ available: buttons.map((row) => String(row.code ?? "")),
1110
+ message: `表单 ${args.sheetCode} 没有按钮 ${args.buttonCode};可用:${buttons.map((row) => String(row.code ?? "")).join("、")}`,
1111
+ });
1112
+ const loaded = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode, objectId: args.bizObjectId, isMobile: "false" } })).data;
1113
+ const bizObject = (loaded?.bizObject ?? null);
1114
+ if (!bizObject)
1115
+ throw new ToolRefusalError({
1116
+ status: "refused",
1117
+ blocker: "form-object-absent",
1118
+ schemaCode: args.schemaCode,
1119
+ bizObjectId: args.bizObjectId,
1120
+ message: `读不到记录 ${args.bizObjectId} 的 bizObject(runtime/form/load 无 bizObject)——先 data.load 核对 bizObjectId`,
1121
+ });
1122
+ // The portal flattens the org/person fields to id strings before posting
1123
+ // (form-detail's customButtonRule: `c.owner=(c.owner?.id)||""` etc.). Sending
1124
+ // the loaded objects makes the platform answer 10009
1125
+ // "Cannot deserialize value of type java.lang.String from Object value".
1126
+ const idOf = (value) => {
1127
+ if (typeof value === "string")
1128
+ return value;
1129
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1130
+ const id = value.id;
1131
+ return typeof id === "string" ? id : "";
1132
+ }
1133
+ return "";
1134
+ };
1135
+ const submitted = { ...bizObject };
1136
+ for (const key of ["owner", "creater", "modifier", "ownerDeptId", "createdDeptId"]) {
1137
+ if (submitted[key] !== undefined)
1138
+ submitted[key] = idOf(submitted[key]);
1139
+ }
1140
+ submitted.formMode = loaded?.formMode ?? "isRead";
1141
+ submitted.activityCode = loaded?.activityCode ?? null;
1142
+ const performed = await wireCall(session, {
1143
+ path: ENDPOINTS.runtimeFormExecuteCustomButton,
1144
+ method: "POST",
1145
+ body: { bizButtonModel: button, bizObjectList: [submitted], reacquireDataFromDb: true },
1146
+ });
1147
+ return {
1148
+ performed: { errcode: performed.errcode, buttonCode: args.buttonCode, bizObjectId: args.bizObjectId },
1149
+ readback: {
1150
+ schemaCode: args.schemaCode,
1151
+ sheetCode: args.sheetCode,
1152
+ buttonCode: args.buttonCode,
1153
+ buttonName: String(button.name ?? ""),
1154
+ bindAction: String(button.bindAction ?? ""),
1155
+ targetCode: button.targetCode ?? null,
1156
+ errmsg: performed.data?.errmsg ?? null,
1157
+ ...(performed.errcode === 302045 ? { dataInUse: true, hint: "302045:数据被占用(有人正在编辑该行),稍后重试" } : {}),
1158
+ effectHint: "平台只回执行结果;按钮绑定的规则副作用请另读 data.load。表单按钮走 executeCustomButton(带整条记录),与列表按钮的 launcher 路径不同",
1159
+ },
1160
+ };
882
1161
  }
883
1162
  /**
884
1163
  * Shared form.publish write body — promotes the saved draft to published,
@@ -913,7 +1192,14 @@ export async function executeFormPublishWrite(session, args) {
913
1192
  throw new WireError("business", `form ${args.sheetCode} not found`, 302024);
914
1193
  if (pre.draftHtmlJson !== "[]")
915
1194
  assertHtmlRender(pre.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
916
- const forcePromote = args.force === true || args.rebuild === true;
1195
+ // A button-only publish on an ALREADY published form must still promote: the
1196
+ // platform materialises h_biz_button rows from options.customButtonJson during
1197
+ // the publish call, so short-circuiting "already-published" would silently
1198
+ // drop the buttons (live 2026-10-23).
1199
+ const hasButtonDelta = args.buttons !== undefined ||
1200
+ args.customButtonJson !== undefined ||
1201
+ (Array.isArray(args.removeButtonCodes) && args.removeButtonCodes.length > 0);
1202
+ const forcePromote = args.force === true || args.rebuild === true || hasButtonDelta;
917
1203
  if (pre.published === true && !forcePromote)
918
1204
  return { performed: { skipped: "already-published" }, readback: { published: true } };
919
1205
  if (!hasDraft(pre))
@@ -939,8 +1225,14 @@ export async function executeFormPublishWrite(session, args) {
939
1225
  });
940
1226
  }
941
1227
  }
1228
+ // Buttons ride `options.customButtonJson`; the platform converts them into
1229
+ // h_biz_button(triggerType=FORM) rows during THIS publish call
1230
+ // (ModelManagementFacadeImpl.publishBizForm:1022). Passing them here is the
1231
+ // only way to create form buttons without the designer.
1232
+ const mergedOptions = mergeFormOptionsWithButtons(pre.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.publish");
942
1233
  const payload = {
943
1234
  ...pre,
1235
+ ...(mergedOptions !== undefined ? { options: mergedOptions } : {}),
944
1236
  schemaCode: args.schemaCode,
945
1237
  code: args.sheetCode,
946
1238
  name: pre.name,
@@ -961,6 +1253,20 @@ export async function executeFormPublishWrite(session, args) {
961
1253
  const performed = await wireCall(session, { path: ENDPOINTS.sheetPublish, method: "POST", body: payload });
962
1254
  const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
963
1255
  const sheet = (readback.data ?? {});
964
- return { performed: { errcode: performed.errcode }, readback: { published: sheet.published === true, publishedHtmlJsonPresent: typeof sheet.publishedHtmlJson === "string" } };
1256
+ const formButtons = readFormButtons(sheet.options);
1257
+ return {
1258
+ performed: { errcode: performed.errcode },
1259
+ readback: {
1260
+ published: sheet.published === true,
1261
+ publishedHtmlJsonPresent: typeof sheet.publishedHtmlJson === "string",
1262
+ ...(formButtons !== null
1263
+ ? {
1264
+ formButtons,
1265
+ formButtonCodes: formButtons.map((button) => String(button.code ?? "")),
1266
+ formButtonNote: "按钮已写入 options.customButtonJson,本次 publish 已生成 h_biz_button(triggerType=FORM);表单页是否显示还取决于流程节点 buttonPermission.customBtns[].enabled(设计器「表单操作」勾选)",
1267
+ }
1268
+ : {}),
1269
+ },
1270
+ };
965
1271
  }
966
1272
  //# sourceMappingURL=form-support.js.map
@@ -168,6 +168,10 @@ function phraseOf(tool, args, outcome) {
168
168
  return `发布流程 ${stringArg(args, "code") ?? "?"}(${schemaCode ?? "?"})`;
169
169
  case "workflow.bindRule": {
170
170
  const codes = Array.isArray(args.ruleCodes) ? args.ruleCodes.join("、") : stringArg(args, "ruleCode") ?? "?";
171
+ const activityCode = stringArg(args, "activityCode");
172
+ const nodeEvent = stringArg(args, "nodeEvent");
173
+ if (activityCode !== undefined && nodeEvent !== undefined)
174
+ return `绑定流程节点事件规则:${stringArg(args, "workflowCode") ?? "?"} 的节点 ${activityCode}.${nodeEvent} ← ${codes}`;
171
175
  return `绑定流程事件规则:${stringArg(args, "workflowCode") ?? "?"} 的 ${stringArg(args, "handler") ?? "end"} 处理器 ← ${codes}`;
172
176
  }
173
177
  case "workflow.delete":
@@ -220,7 +220,14 @@ export function registerBusinessRuleLane(tools) {
220
220
  schemaCode: record.schemaCode,
221
221
  bizRuleType: record.bizRuleType,
222
222
  enabled: record.enabled,
223
+ // The platform's `status` column lags behind the enable switch: r10
224
+ // saw `enabled:true` with `ruleStatus:"DISABLED"` while rule.list said
225
+ // ENABLED. `enabled` is the dispatch truth; flag the disagreement
226
+ // instead of letting two faces contradict each other silently.
223
227
  ruleStatus: record.status,
228
+ ...(record.enabled === true && String(record.status ?? "") !== "ENABLED"
229
+ ? { statusLag: true, statusNote: "平台 status 列滞后:enabled=true 才是分发真相(列表面/日志可复核);不要据 ruleStatus=DISABLED 判定未启用" }
230
+ : {}),
224
231
  defaultRule: record.defaultRule,
225
232
  developmentMode: record.developmentMode,
226
233
  ruleVersion: record.ruleVersion,
@@ -15,11 +15,13 @@ export const FORM_WIRE = Object.freeze({
15
15
  publishAlreadyPublished: "published:true with force!==true skips as already-published",
16
16
  forceRepublish: "onlinejs.compose passes force:true so it can re-promote after a prior publish",
17
17
  emptyLayoutGuard: "ticket 74: the platform designer's Save is NOT lossless — it clears draftViewJson to \"[]\" and draftAttributesJson to \"{}\"; form.draft refuses CALLER-PROVIDED empty layouts (blocker form-empty-layout, zero wire, escape allowEmptyLayout:true) and form.publish refuses to promote a draft with fewer controls than the published face (blocker form-publish-degrade, escape allowDegrade:true); the recovery path is the form version history (/api/api/app/bizsheet/history/list) — fetch the intact version's view/attributes originals and rewrite the draft before publishing",
18
+ formButtons: "form toolbar buttons are a three-layer configuration (live 2026-10-23, 1.1.25): (1) bizsheet.options.customButtonJson — a STRINGIFIED JSON array — is the authoring face; ModelManagementFacadeImpl.publishBizForm:1022 turns it into h_biz_button rows with triggerType=FORM, triggerCode=<formCode> at publish; (2) those rows are what ActionPermissionHandler.getByTriggerTypeAndCode(FORM, bizCode) reads; (3) whether a button shows at a node is filtered by the workflow activity's buttonPermission.customBtns[].enabled (the designer 表单操作 checkboxes) plus showPermCode; the execute face is POST /api/api/runtime/form/executeCustomButton {bizButtonModel, bizObjectList:[<full bizObject>], reacquireDataFromDb:true} — NOT the list-button launcher path with ids[]",
18
19
  actionsColumns: "ticket 100: draftActionsJson/publishedActionsJson are REAL bizsheet columns (BizForm.java:95-100 @SerializableField; BizFormModel.java:59-62 serialized by bizsheet/get) — the designer writes draftActionsJson on save and both faces on publish (frontend-dist/admin/js/4280.cfa75764.js offsets 193538/185347); form.get full:true surfaces them; publish promotes draftActionsJson → publishedActionsJson",
19
20
  });
20
- 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` 核对。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
21
- 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). **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
22
- 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.";
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
+ 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). **表单工具栏按钮**:`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
+ 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` = 数据被占用。";
23
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).";
24
26
  export const FORM_LANE_CONTRACT = Object.freeze({
25
27
  lane: LANES.form,
@@ -42,6 +44,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
42
44
  forceRepublish: FORM_WIRE.forceRepublish,
43
45
  emptyLayoutGuard: FORM_WIRE.emptyLayoutGuard,
44
46
  actionsColumns: FORM_WIRE.actionsColumns,
47
+ formButtons: FORM_WIRE.formButtons,
45
48
  }),
46
49
  wireBindings: Object.freeze({
47
50
  draftBody: Object.freeze([behaviorBinding("test/form-scaffold.test.ts#form.draft scaffolds an empty default form from published fields", assertFormContract)]),
@@ -54,6 +57,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
54
57
  forceRepublish: Object.freeze([behaviorBinding("test/issue-11-online-js.test.ts#onlinejs.compose merges customScript into the existing draftHtmlJson layout", assertFormContract)]),
55
58
  emptyLayoutGuard: Object.freeze([behaviorBinding("test/ticket-74-form-empty-layout.test.ts#ticket 74: form.draft refuses an explicitly empty draftViewJson", assertFormContract)]),
56
59
  actionsColumns: Object.freeze([behaviorBinding("test/ticket-100-form-actions-columns.test.ts#form.get full:true surfaces draftActionsJson/publishedActionsJson", assertFormContract)]),
60
+ formButtons: Object.freeze([behaviorBinding("test/form-buttons.test.ts#form.publish with buttons materialises FORM-trigger buttons and form.get surfaces them", assertFormContract)]),
57
61
  }),
58
62
  evidence: Object.freeze([
59
63
  ".scratch/omc-route-wayfinder/leg2-closeout.md",
@@ -65,6 +69,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
65
69
  "form.draft": Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: FORM_DRAFT_DESCRIPTION }),
66
70
  "form.publish": Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: FORM_PUBLISH_DESCRIPTION }),
67
71
  configure_form: Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: CONFIGURE_FORM_DESCRIPTION }),
72
+ "form.runButton": Object.freeze({ scope: "form toolbar button execution", description: FORM_RUN_BUTTON_DESCRIPTION }),
68
73
  }),
69
74
  });
70
75
  export function assertFormContract() {
@@ -93,5 +98,10 @@ export function assertFormContract() {
93
98
  throw new Error("form contract must pin the force-republish override");
94
99
  if (!FORM_WIRE.emptyLayoutGuard.includes("form-empty-layout") || !FORM_WIRE.emptyLayoutGuard.includes("form-publish-degrade"))
95
100
  throw new Error("form contract must pin the empty-layout/degrade guards");
101
+ if (!FORM_WIRE.formButtons.includes("customButtonJson") || !FORM_WIRE.formButtons.includes("executeCustomButton") || !FORM_WIRE.formButtons.includes("publishBizForm:1022"))
102
+ throw new Error("form contract must pin the form-button three-layer mechanism");
103
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.draft", ["buttons", "customButtonJson", "h_biz_button", "buttonPermission"]);
104
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.publish", ["buttons", "customButtonJson", "triggerType=FORM", "buttonPermission"]);
105
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.runButton", ["executeCustomButton", "bizObjectList", "getCustomButtons", "rule.run", "302045"]);
96
106
  }
97
107
  //# sourceMappingURL=form-contract.js.map
@@ -4,12 +4,13 @@ const T = FORM_LANE_CONTRACT.tools;
4
4
  export const FORM_COVERAGE = declareLaneCoverage(FORM_LANE_CONTRACT.lane, [
5
5
  {
6
6
  capability: "Form publish chain (draft-first → publish → publishedHtmlJson readback)",
7
- evidence: [".scratch/omc-route-wayfinder/leg2-closeout.md", "packages/omc-cli/src/lanes/form.ts"],
7
+ evidence: [".scratch/omc-route-wayfinder/leg2-closeout.md", "packages/omc-cli/src/lanes/form.ts", "r11 2026-10-23 (1.1.25): form buttons live on bizsheet.options.customButtonJson → h_biz_button(triggerType=FORM) at publish; execution is POST runtime/form/executeCustomButton with the full bizObject (Playwright capture)"],
8
8
  tools: [
9
9
  claim("form.get", T["form.get"].scope),
10
10
  claim("form.draft", T["form.draft"].scope),
11
11
  claim("form.publish", T["form.publish"].scope),
12
12
  claim("configure_form", T["configure_form"].scope),
13
+ claim("form.runButton", T["form.runButton"].scope),
13
14
  ],
14
15
  },
15
16
  ]);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
- import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull, refuseEmptyCallerLayout, validateHtmlRender } from "../form-support.js";
3
+ import { executeFormDraftWrite, executeFormPublishWrite, executeFormRunButtonWrite, readFormButtons, readFormOrNull, refuseEmptyCallerLayout, validateHtmlRender } from "../form-support.js";
4
4
  import { capabilityArg, environmentArg } from "../args.js";
5
5
  import { governedRead } from "../read.js";
6
6
  import { writeTool } from "../write.js";
@@ -80,6 +80,13 @@ export function registerFormLane(tools) {
80
80
  publishedAttributesJson: sheet.publishedAttributesJson ?? null,
81
81
  publishedHtmlJson: sheet.publishedHtmlJson ?? null,
82
82
  publishedActionsJson: sheet.publishedActionsJson ?? null,
83
+ // Form-toolbar buttons are authored on `options.customButtonJson`
84
+ // and materialised into h_biz_button(triggerType=FORM) at publish
85
+ // (ModelManagementFacadeImpl.publishBizForm:1022). The projection
86
+ // used to drop `options`, so a read→write round trip could never
87
+ // carry the buttons back.
88
+ options: sheet.options ?? null,
89
+ customButtonJson: readFormButtons(sheet.options),
83
90
  // Ticket 109 — render compatibility: the Online JS panel resolves
84
91
  // generateCodeType(e) whose first line is `if(!e||!e.tagName)
85
92
  // return ""` and then does o[type].push(...) (54286.fbbb415f.js)
@@ -110,8 +117,23 @@ export function registerFormLane(tools) {
110
117
  }
111
118
  return Object.fromEntries(wanted.filter((code) => code in parsed).map((code) => [code, parsed[code]]));
112
119
  };
120
+ // Form buttons are tiny; list them plainly in the COMPACT face so a
121
+ // readback never needs the artifact just to see which buttons exist.
122
+ const formButtons = readFormButtons(sheet.options) ?? [];
113
123
  return {
114
124
  ...base,
125
+ ...(formButtons.length > 0
126
+ ? {
127
+ formButtonCodes: formButtons.map((button) => String(button.code ?? "")),
128
+ formButtons: formButtons.map((button) => ({
129
+ code: button.code ?? null,
130
+ name: button.name ?? null,
131
+ bindAction: button.bindAction ?? null,
132
+ targetCode: button.targetCode ?? null,
133
+ useLocation: button.useLocation ?? null,
134
+ })),
135
+ }
136
+ : {}),
115
137
  draftControlCodes: draftControls,
116
138
  publishedControlCodes: publishedControls,
117
139
  ...(wanted ? { draftControls: pick(sheet.draftAttributesJson), publishedControls: pick(sheet.publishedAttributesJson) } : {}),
@@ -127,6 +149,13 @@ export function registerFormLane(tools) {
127
149
  fieldCodes: z.array(z.string().min(1)).optional().describe("脚手架绑定的已发布字段(按此顺序);由 configure_form/intent 显式传入"),
128
150
  rebuild: z.boolean().optional().describe("true 时丢弃现有草稿布局,按已发布字段重新生成(新增字段/子表要用它;默认保留现有草稿)"),
129
151
  allowEmptyLayout: z.boolean().optional().describe("显式放行空布局(\"[]\"/\"{}\")写入;默认拒绝(form-empty-layout),防设计器 Save 清空后照写丢控件"),
152
+ buttons: z
153
+ .array(z.record(z.unknown()))
154
+ .optional()
155
+ .describe("表单工具栏按钮(写进 biz_sheet.options.customButtonJson;publish 时才生成 h_biz_button(triggerType=FORM))。每条至少 code/name/bindAction/operateType,useLocation 默认 FORM"),
156
+ customButtonJson: z.string().optional().describe("buttons 的原样 JSON 数组字符串(与 buttons 互斥;用于把 form.get 读回值直接回写)"),
157
+ buttonsMode: z.enum(["merge", "replace"]).optional().describe("默认 merge:按 code 增量 upsert(改一个按钮不必重发全表);replace:整表替换"),
158
+ removeButtonCodes: z.array(z.string().min(1)).optional().describe("要删除的按钮 code(仅 merge 模式)"),
130
159
  }, async (session, args) => executeFormDraftWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }), (args) => {
131
160
  // Zero-wire refusal (ticket 74): fires in semanticGate BEFORE the
132
161
  // governed pipeline — including the rollback before-capture read.
@@ -155,6 +184,13 @@ export function registerFormLane(tools) {
155
184
  .boolean()
156
185
  .optional()
157
186
  .describe("true 时一步刷新:先丢弃现有草稿、按已发布字段重建脚手架(保留非结构性控件 options),再强制提拔——改字段形状后刷新表单用一个调用;默认只发布现有草稿(无草稿时自动补默认)"),
187
+ buttons: z
188
+ .array(z.record(z.unknown()))
189
+ .optional()
190
+ .describe("表单工具栏按钮:本次发布写进 options.customButtonJson 并生成 h_biz_button(triggerType=FORM)。之后再按流程节点 buttonPermission.customBtns[].enabled 决定哪个节点可见"),
191
+ customButtonJson: z.string().optional().describe("buttons 的原样 JSON 数组字符串(与 buttons 互斥)"),
192
+ buttonsMode: z.enum(["merge", "replace"]).optional().describe("默认 merge:按 code 增量 upsert;replace:整表替换"),
193
+ removeButtonCodes: z.array(z.string().min(1)).optional().describe("要删除的按钮 code(仅 merge 模式)"),
158
194
  }, async (session, args) => executeFormPublishWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }));
159
195
  registerPackageTool(tools, {
160
196
  name: "configure_form",
@@ -202,5 +238,11 @@ export function registerFormLane(tools) {
202
238
  },
203
239
  summarize: (args, _outcome, phase) => writeSummary("configure_form", args, undefined, phase),
204
240
  });
241
+ writeTool(tools, "form.runButton", FORM_LANE, FORM_TOOLS["form.runButton"].scope, FORM_TOOLS["form.runButton"].description, {
242
+ schemaCode: z.string().min(1),
243
+ sheetCode: z.string().min(1).optional().describe("表单槽位码;省略时默认 = schemaCode"),
244
+ buttonCode: z.string().min(1).describe("表单按钮 code(form.get --full 的 customButtonJson / publish 读回的 formButtonCodes)"),
245
+ bizObjectId: z.string().min(1).describe("要执行按钮的业务记录 id(data.load/data.list 的 bizObjectId)"),
246
+ }, async (session, args) => executeFormRunButtonWrite(session, { schemaCode: args.schemaCode, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode), buttonCode: args.buttonCode, bizObjectId: args.bizObjectId }));
205
247
  }
206
248
  //# sourceMappingURL=form.js.map
@@ -0,0 +1,27 @@
1
+ import { claim, declareLaneCoverage } from "./coverage-declaration.js";
2
+ import { LANES } from "./lane-ids.js";
3
+ /**
4
+ * Organization writes (role group / role / members / management scope) sit on
5
+ * the same live-proven template lane; the scopes below are claimed here so the
6
+ * runtime gate can resolve them without a second manifest item.
7
+ */
8
+ export const ORGANIZATION_COVERAGE = declareLaneCoverage(LANES.workflow, [
9
+ {
10
+ capability: "W7 S29 greenfield workflow authoring",
11
+ evidence: [
12
+ "packages/omc-cli/src/lanes/organization.ts",
13
+ "OpenAPI /api/v3/api-docs (1.1.25): organization/role/add_role, add_role_user, update_ouscope",
14
+ ],
15
+ tools: [
16
+ claim("org.role.users", "organization role member read (role/users)"),
17
+ claim("org.role.userScope", "organization role member management scope read (role/get_ouscope)"),
18
+ claim("org.roleGroup.create", "organization role group create (role/add_role_group)"),
19
+ claim("org.role.create", "organization role create (role/add_role)"),
20
+ claim("org.role.update", "organization role update (role/modify_role)"),
21
+ claim("org.role.addUsers", "organization role member add (role/add_role_user)"),
22
+ claim("org.role.removeUsers", "organization role member remove (role/strike_out_role_user)"),
23
+ claim("org.role.setUserScope", "organization role member scope update (role/update_ouscope)"),
24
+ ],
25
+ },
26
+ ]);
27
+ //# sourceMappingURL=organization-coverage.js.map