@stalfh233/omc-fake-platform 0.0.0-stage → 0.4.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/form.js ADDED
@@ -0,0 +1,100 @@
1
+ import { capabilityShapeError } from "omc-core/capability-shape";
2
+ import { CAPABILITY_CONTROL_TYPES } from "omc-core/capability-shape";
3
+ import { businessRefusal, NOT_HANDLED } from "./state.js";
4
+ /** form-publish-chain lane: default-form draft/publish. Twin of src/lanes/form.ts. */
5
+ export const formRoute = (ctx, input) => {
6
+ const q = input.query ?? {};
7
+ const body = (input.body ?? {});
8
+ switch (input.path) {
9
+ case "/api/api/app/bizsheet/get": {
10
+ const sheet = ctx.state.sheets.get(q.sheetCode);
11
+ if (!sheet)
12
+ return null;
13
+ const name = q.sheetCode;
14
+ return {
15
+ id: `sheet-${q.sheetCode}`,
16
+ sheetCode: sheet.sheetCode,
17
+ code: sheet.sheetCode,
18
+ name,
19
+ sheetType: 0,
20
+ existDraft: sheet.existDraft,
21
+ published: sheet.published,
22
+ draftViewJson: sheet.draftViewJson ?? null,
23
+ draftAttributesJson: sheet.draftAttributesJson ?? null,
24
+ draftHtmlJson: sheet.draftHtmlJson ?? null,
25
+ // Ticket 100: real bizsheet columns (BizForm.java:95-100); the
26
+ // designer writes draftActionsJson on save and both faces on publish
27
+ // (frontend-dist/admin/js/4280.cfa75764.js offsets 193538/185347).
28
+ draftActionsJson: sheet.draftActionsJson ?? null,
29
+ publishedViewJson: sheet.publishedViewJson ?? null,
30
+ publishedAttributesJson: sheet.publishedAttributesJson ?? null,
31
+ publishedActionsJson: sheet.publishedActionsJson ?? null,
32
+ publishedHtmlJson: sheet.publishedHtmlJson ?? null,
33
+ binding: { fieldCodes: sheet.fieldCodes },
34
+ };
35
+ }
36
+ case "/api/api/app/bizsheet/create": {
37
+ const code = String(body.code ?? body.sheetCode);
38
+ if (!ctx.state.sheets.has(code))
39
+ ctx.state.sheets.set(code, { sheetCode: code, existDraft: false, published: false, draftHtmlJson: "[]", fieldCodes: [] });
40
+ return null;
41
+ }
42
+ case "/api/api/app/bizsheet/update": {
43
+ let attributes = {};
44
+ if (typeof body.draftAttributesJson === "string") {
45
+ try {
46
+ attributes = JSON.parse(body.draftAttributesJson);
47
+ }
48
+ catch {
49
+ return businessRefusal(50000, "draftAttributesJson 不是合法 JSON");
50
+ }
51
+ }
52
+ for (const [fieldCode, control] of Object.entries(attributes)) {
53
+ const controlType = Number(control.type);
54
+ const propertyType = Object.entries(CAPABILITY_CONTROL_TYPES).find(([, value]) => value === controlType)?.[0];
55
+ if (propertyType === undefined)
56
+ continue;
57
+ const options = control.options && typeof control.options === "object" ? control.options : {};
58
+ const shapeError = capabilityShapeError(Number(propertyType), "control", options);
59
+ if (shapeError)
60
+ return businessRefusal(50000, `${fieldCode}: ${shapeError}`);
61
+ }
62
+ const code = String(body.code ?? body.sheetCode);
63
+ const existing = ctx.state.sheets.get(code) ?? {
64
+ sheetCode: code,
65
+ existDraft: false,
66
+ published: false,
67
+ draftHtmlJson: "[]",
68
+ fieldCodes: [],
69
+ };
70
+ ctx.state.sheets.set(code, {
71
+ ...existing,
72
+ existDraft: true,
73
+ draftViewJson: body.draftViewJson ?? existing.draftViewJson ?? null,
74
+ draftAttributesJson: body.draftAttributesJson ?? existing.draftAttributesJson ?? null,
75
+ fieldCodes: typeof body.draftAttributesJson === "string" ? Object.keys(attributes) : existing.fieldCodes,
76
+ draftActionsJson: body.draftActionsJson ?? existing.draftActionsJson ?? null,
77
+ // draftHtmlJson holds the COMPLETE layout JSON array (including the
78
+ // customScript element when present). Default to "[]" on first
79
+ // draft so onlinejs.compose can parse it as an array.
80
+ draftHtmlJson: body.draftHtmlJson ?? existing.draftHtmlJson ?? "[]",
81
+ });
82
+ return null;
83
+ }
84
+ case "/api/api/app/bizsheet/publish": {
85
+ const code = String(body.code ?? body.sheetCode);
86
+ const sheet = ctx.state.sheets.get(code);
87
+ if (sheet) {
88
+ sheet.published = true;
89
+ sheet.existDraft = false;
90
+ sheet.publishedViewJson = sheet.draftViewJson ?? null;
91
+ sheet.publishedAttributesJson = sheet.draftAttributesJson ?? null;
92
+ sheet.publishedActionsJson = body.publishedActionsJson ?? "[]";
93
+ sheet.publishedHtmlJson = sheet.draftHtmlJson ?? "[]";
94
+ }
95
+ return null;
96
+ }
97
+ default:
98
+ return NOT_HANDLED;
99
+ }
100
+ };
@@ -0,0 +1,8 @@
1
+ import { FakeCloudPivot } from "./index.js";
2
+ export interface FakeHttpServer {
3
+ readonly fake: FakeCloudPivot;
4
+ readonly url: string;
5
+ close(): Promise<void>;
6
+ }
7
+ export declare function startFakeHttpServer(): Promise<FakeHttpServer>;
8
+ export declare function canonicalBusinessCalls(fake: FakeCloudPivot): string;
@@ -0,0 +1,64 @@
1
+ import { createServer } from "node:http";
2
+ import { FakeCloudPivot } from "./index.js";
3
+ export async function startFakeHttpServer() {
4
+ const server = createServer();
5
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
6
+ const address = server.address();
7
+ if (address === null || typeof address === "string") {
8
+ server.closeAllConnections?.();
9
+ await new Promise((resolve) => server.close(() => resolve()));
10
+ throw new Error("fake http server has no listen address");
11
+ }
12
+ const url = `http://127.0.0.1:${address.port}`;
13
+ const fake = new FakeCloudPivot({ version: "8.6.26", baseUrl: url });
14
+ server.on("request", (request, response) => {
15
+ const chunks = [];
16
+ request.on("data", (chunk) => chunks.push(chunk));
17
+ request.on("end", () => {
18
+ const target = new URL(request.url ?? "/", url);
19
+ const query = {};
20
+ for (const [key, value] of target.searchParams)
21
+ query[key] = value;
22
+ const raw = Buffer.concat(chunks);
23
+ const contentType = request.headers["content-type"] ?? "";
24
+ const body = raw.length === 0 ? undefined : contentType.includes("json") ? JSON.parse(raw.toString("utf8")) : raw.toString("utf8");
25
+ const headers = {};
26
+ for (const [key, value] of Object.entries(request.headers)) {
27
+ if (typeof value === "string") {
28
+ const capitalized = key.replace(/\b\w/g, (c) => c.toUpperCase()).replace(/-(\w)/g, (_m, c) => `-${c.toUpperCase()}`);
29
+ headers[capitalized] = value;
30
+ }
31
+ }
32
+ fake
33
+ .request({
34
+ path: target.pathname,
35
+ method: request.method === "POST" ? "POST" : "GET",
36
+ ...(Object.keys(query).length > 0 ? { query } : {}),
37
+ ...(body === undefined ? {} : { body }),
38
+ headers,
39
+ })
40
+ .then((result) => {
41
+ const payload = Buffer.isBuffer(result.body) ? result.body : JSON.stringify(result.body);
42
+ response.writeHead(result.status, Buffer.isBuffer(result.body) ? undefined : { "Content-Type": "application/json" });
43
+ response.end(payload);
44
+ })
45
+ .catch(() => {
46
+ response.writeHead(500, { "Content-Type": "application/json" });
47
+ response.end(JSON.stringify({ errcode: 500, errmsg: "fake transport failure", data: null }));
48
+ });
49
+ });
50
+ });
51
+ return {
52
+ fake,
53
+ url,
54
+ close: () => new Promise((resolve, reject) => {
55
+ server.closeAllConnections?.();
56
+ server.close((error) => (error ? reject(error) : resolve()));
57
+ }),
58
+ };
59
+ }
60
+ export function canonicalBusinessCalls(fake) {
61
+ return JSON.stringify(fake.state.callLog
62
+ .filter((entry) => !entry.path.startsWith("/user/") && !entry.path.startsWith("/api/public/") && !entry.path.startsWith("/api/api/organization/"))
63
+ .map((entry) => ({ path: entry.path, method: entry.method, body: entry.body ?? null, query: entry.query && Object.keys(entry.query).length > 0 ? entry.query : null })));
64
+ }
@@ -0,0 +1,54 @@
1
+ import type { CloudPivotHttp } from "omc-core";
2
+ import { type FakeApp, type FakeRequestInput, type FakeRule, type FakeSheet, type FakeState, type FakeCloudPivotOptions } from "./state.js";
3
+ export { FAKE_RSA_PUBLIC_KEY } from "./state.js";
4
+ export type { FakeCloudPivotOptions } from "./state.js";
5
+ /**
6
+ * In-memory fake CloudPivot 8.6 for offline CLI tests: implements the auth
7
+ * flow and every live-proven endpoint the tool surface drives. Supports
8
+ * failure injection (transport outage, non-8.6 version, business errcode).
9
+ *
10
+ * The wire behavior is split into one route module per configuration lane
11
+ * under ./fake/, mirroring src/lanes/*; this class owns the shared store,
12
+ * the failure-injection flags, and the seed/readback test accessors.
13
+ */
14
+ export declare class FakeCloudPivot implements CloudPivotHttp {
15
+ private readonly options;
16
+ readonly state: FakeState;
17
+ transportDown: boolean;
18
+ businessFailure: {
19
+ pathFragment: string;
20
+ errcode: number;
21
+ } | null;
22
+ /** Round-2 T-06: inject a RAW (non-errcode) response body to exercise malformed-envelope diagnostics. */
23
+ malformedResponse: {
24
+ pathFragment: string;
25
+ body: unknown;
26
+ } | null;
27
+ /** When true, /api/api/runtime/form/submit returns no objectId for formType=2. */
28
+ forceSubmitMissingObjectId: boolean;
29
+ forceSubmitMismatchedObjectId: boolean;
30
+ omitDraftSaveWorkItem: boolean;
31
+ constructor(options?: FakeCloudPivotOptions);
32
+ /** Test accessor: the stored default-form draft for a schema (undefined if none). */
33
+ sheetDraft(schemaCode: string): FakeSheet | undefined;
34
+ /** Test accessor: the last request body for the given path (or undefined). */
35
+ lastBody(path: string): unknown;
36
+ /** Test accessor: every recorded request body for the given path (in order). */
37
+ bodiesFor(path: string): unknown[];
38
+ queriesFor(path: string): (Record<string, string> | undefined)[];
39
+ seedRule(rule: Partial<FakeRule> & Pick<FakeRule, "schemaCode" | "code">): void;
40
+ seedDataRow(schemaCode: string, data: Record<string, unknown>, id?: string): void;
41
+ /**
42
+ * Reproduce ticket 07: mark a model's default list view as having no
43
+ * configured columns. Subsequent /runtime/query/list responses drop business
44
+ * columns from `data` (they read back null) though seedDataRow stored them.
45
+ */
46
+ markListViewUnconfigured(schemaCode: string): void;
47
+ seedApp(app: Partial<FakeApp> & Pick<FakeApp, "code">): void;
48
+ seedField(schemaCode: string, code: string, propertyType: number, published?: boolean): void;
49
+ request(input: FakeRequestInput): Promise<{
50
+ status: number;
51
+ body: unknown;
52
+ }>;
53
+ private route;
54
+ }
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ import { dispatchFakeRoute } from "./routes.js";
2
+ import { createFakeState, isBusinessRefusal, isMalformedEnvelope, isRawResponse } from "./state.js";
3
+ export { FAKE_RSA_PUBLIC_KEY } from "./state.js";
4
+ /**
5
+ * In-memory fake CloudPivot 8.6 for offline CLI tests: implements the auth
6
+ * flow and every live-proven endpoint the tool surface drives. Supports
7
+ * failure injection (transport outage, non-8.6 version, business errcode).
8
+ *
9
+ * The wire behavior is split into one route module per configuration lane
10
+ * under ./fake/, mirroring src/lanes/*; this class owns the shared store,
11
+ * the failure-injection flags, and the seed/readback test accessors.
12
+ */
13
+ export class FakeCloudPivot {
14
+ options;
15
+ state;
16
+ transportDown = false;
17
+ businessFailure = null;
18
+ /** Round-2 T-06: inject a RAW (non-errcode) response body to exercise malformed-envelope diagnostics. */
19
+ malformedResponse = null;
20
+ /** When true, /api/api/runtime/form/submit returns no objectId for formType=2. */
21
+ forceSubmitMissingObjectId = false;
22
+ forceSubmitMismatchedObjectId = false;
23
+ omitDraftSaveWorkItem = false;
24
+ constructor(options = {}) {
25
+ this.options = options;
26
+ this.state = createFakeState(options);
27
+ }
28
+ /** Test accessor: the stored default-form draft for a schema (undefined if none). */
29
+ sheetDraft(schemaCode) {
30
+ return this.state.sheets.get(schemaCode);
31
+ }
32
+ /** Test accessor: the last request body for the given path (or undefined). */
33
+ lastBody(path) {
34
+ for (let i = this.state.callLog.length - 1; i >= 0; i -= 1) {
35
+ if (this.state.callLog[i].path === path)
36
+ return this.state.callLog[i].body;
37
+ }
38
+ return undefined;
39
+ }
40
+ /** Test accessor: every recorded request body for the given path (in order). */
41
+ bodiesFor(path) {
42
+ return this.state.callLog.filter((entry) => entry.path === path).map((entry) => entry.body);
43
+ }
44
+ queriesFor(path) {
45
+ return this.state.callLog.filter((entry) => entry.path === path).map((entry) => entry.query);
46
+ }
47
+ seedRule(rule) {
48
+ const id = rule.id ?? `rule-${this.state.rules.size + 1}`;
49
+ this.state.rules.set(`${rule.schemaCode}#${rule.code}`, {
50
+ ...rule,
51
+ id,
52
+ schemaCode: rule.schemaCode,
53
+ code: rule.code,
54
+ name: rule.name ?? rule.code,
55
+ enabled: rule.enabled ?? false,
56
+ });
57
+ }
58
+ seedDataRow(schemaCode, data, id = `row-${this.state.dataRows.size + 1}`) {
59
+ this.state.dataRows.set(id, { bizObjectId: id, schemaCode, ...data });
60
+ }
61
+ /**
62
+ * Reproduce ticket 07: mark a model's default list view as having no
63
+ * configured columns. Subsequent /runtime/query/list responses drop business
64
+ * columns from `data` (they read back null) though seedDataRow stored them.
65
+ */
66
+ markListViewUnconfigured(schemaCode) {
67
+ this.state.unconfiguredListViews.add(schemaCode);
68
+ }
69
+ seedApp(app) {
70
+ this.state.apps.set(app.code, {
71
+ id: app.id ?? `app-${this.state.apps.size + 1}`,
72
+ code: app.code,
73
+ name: app.name ?? app.code,
74
+ groupId: app.groupId ?? [...this.state.appGroups.keys()][0] ?? "group-1",
75
+ ...(app.published !== undefined ? { published: app.published } : {}),
76
+ });
77
+ }
78
+ seedField(schemaCode, code, propertyType, published = true) {
79
+ this.state.fields.set(`${schemaCode}#${code}`, { code, name: code, type: String(propertyType), published });
80
+ }
81
+ async request(input) {
82
+ this.state.callLog.push({
83
+ path: input.path,
84
+ method: input.method,
85
+ body: input.body,
86
+ query: input.query,
87
+ ...(input.multipart !== undefined ? { multipart: { filename: input.multipart.filename, contentType: input.multipart.contentType, bytes: input.multipart.content.length } } : {}),
88
+ });
89
+ if (this.transportDown)
90
+ throw new Error("simulated transport outage");
91
+ if (this.businessFailure && input.path.includes(this.businessFailure.pathFragment))
92
+ return { status: 200, body: { errcode: this.businessFailure.errcode, errmsg: "simulated business refusal", data: null } };
93
+ if (this.malformedResponse && input.path.includes(this.malformedResponse.pathFragment))
94
+ return { status: 200, body: this.malformedResponse.body };
95
+ const handler = this.route(input);
96
+ if (isBusinessRefusal(handler))
97
+ return { status: 200, body: { errcode: handler.errcode, errmsg: handler.errmsg, data: null } };
98
+ // Ticket 76: raw-envelope passthrough for platforms answers that carry no
99
+ // numeric errcode (workflow/create duplicate-schema live shape).
100
+ if (isMalformedEnvelope(handler))
101
+ return { status: 200, body: handler.payload };
102
+ // Ticket 106: non-envelope bodies (upload refId plain string, export ZIP
103
+ // bytes) pass through verbatim.
104
+ if (isRawResponse(handler))
105
+ return { status: 200, body: handler.payload };
106
+ if (handler === undefined)
107
+ return { status: 404, body: { errcode: 404, errmsg: "not found", data: null } };
108
+ return { status: 200, body: { errcode: 0, errmsg: "ok", data: handler } };
109
+ }
110
+ route(input) {
111
+ const ctx = {
112
+ state: this.state,
113
+ options: this.options,
114
+ forceSubmitMissingObjectId: this.forceSubmitMissingObjectId,
115
+ forceSubmitMismatchedObjectId: this.forceSubmitMismatchedObjectId,
116
+ omitDraftSaveWorkItem: this.omitDraftSaveWorkItem,
117
+ };
118
+ return dispatchFakeRoute(ctx, input);
119
+ }
120
+ }
@@ -0,0 +1,3 @@
1
+ import { type LaneRoute } from "./state.js";
2
+ /** W1-list-view lane: design-time query views. Twin of src/lanes/list-view.ts. */
3
+ export declare const listViewRoute: LaneRoute;
@@ -0,0 +1,288 @@
1
+ import { businessRefusal, NOT_HANDLED } from "./state.js";
2
+ import { QUERY_ACTION_RELATIVE, QUERY_ACTION_TYPE, validateButtonActionConfig } from "omc-core/list-view-wire";
3
+ /**
4
+ * BizQueryController.dealHistoryDataOfButton (8.6.26 :535-631) injects these
5
+ * default query actions on the query/get read face when the stored view lacks
6
+ * them, so readback from a view that was never configured with actions still
7
+ * carries the platform buttons. The tool filters/decodes them; the fake mirrors
8
+ * the injection so unit tests see the same shape as live. Enum numbers come
9
+ * from the shared list-view shape descriptors (ticket 53).
10
+ */
11
+ const PLATFORM_DEFAULT_ACTIONS = [
12
+ { actionCode: "qr_code", name: "打印二维码", queryActionType: QUERY_ACTION_TYPE.index.QR_CODE, sortKey: 7, icon: "plus", associationType: QUERY_ACTION_RELATIVE.index.BIZSHEET, associationCode: "__schema__", clientType: null },
13
+ { actionCode: "batch_print", name: "批量打印", queryActionType: QUERY_ACTION_TYPE.index.BATCH_PRINT, sortKey: 6, icon: "plus", associationType: QUERY_ACTION_RELATIVE.index.BIZSHEET, associationCode: "__schema__", clientType: null },
14
+ { actionCode: "batch_update", name: "批量修改", queryActionType: QUERY_ACTION_TYPE.index.BATCH_UPDATE, sortKey: 3, icon: "plus", associationType: QUERY_ACTION_RELATIVE.index.BIZSHEET },
15
+ { actionCode: "import_attachment", name: "导入附件", queryActionType: QUERY_ACTION_TYPE.index.IMPORT_ATTACHMENT, sortKey: 10, icon: "upload" },
16
+ { actionCode: "export_attachment", name: "导出附件", queryActionType: QUERY_ACTION_TYPE.index.EXPORT_ATTACHMENT, sortKey: 11, icon: "download" },
17
+ { actionCode: "batch_submit", name: "批量提交", queryActionType: QUERY_ACTION_TYPE.index.BATCH_SUBMIT, sortKey: 3, icon: "plus" },
18
+ ];
19
+ /**
20
+ * Live 2026-09-17: a freshly created PC LIST view is seeded with STORED
21
+ * actions (not merely injected on read): add/delete/import/export/editowner.
22
+ * Measured on two un-configured views (eq_repair_order, a fresh model's
23
+ * default view) whose read face carries the full 11 (these 5 stored + the 6
24
+ * injected below). Because `query/update` REPLACES actions, configuring one
25
+ * action used to delete these five — the list then rendered with NO Add
26
+ * button. The fake must seed them or a regression cannot be caught.
27
+ */
28
+ const DEFAULT_STORED_ACTIONS = [
29
+ { actionCode: "add", name: "新增", queryActionType: QUERY_ACTION_TYPE.index.ADD, icon: "plus" },
30
+ { actionCode: "delete", name: "删除", queryActionType: QUERY_ACTION_TYPE.index.DELETE, icon: "delete" },
31
+ { actionCode: "import", name: "导入", queryActionType: QUERY_ACTION_TYPE.index.IMPORT, icon: "upload" },
32
+ { actionCode: "export", name: "导出", queryActionType: QUERY_ACTION_TYPE.index.EXPORT, icon: "download" },
33
+ { actionCode: "editowner", name: "修改拥有者", queryActionType: QUERY_ACTION_TYPE.index.EDITOWNER, icon: "plus" },
34
+ ];
35
+ function seedStoredActions(view) {
36
+ if (view.queryActions !== undefined)
37
+ return;
38
+ view.queryActions = DEFAULT_STORED_ACTIONS.map((action) => ({ ...action, schemaCode: view.schemaCode, clientType: "PC", associationType: action.actionCode === "add" ? QUERY_ACTION_RELATIVE.index.BIZSHEET : undefined, associationCode: action.actionCode === "add" ? view.schemaCode : undefined }));
39
+ }
40
+ function injectDefaultActions(schemaCode, stored) {
41
+ const actions = stored.map((action) => ({ ...action }));
42
+ const hasType = (type) => actions.some((action) => action.queryActionType === type);
43
+ const hasImportExport = hasType(21) || hasType(22);
44
+ for (const template of PLATFORM_DEFAULT_ACTIONS) {
45
+ const type = Number(template.queryActionType);
46
+ if (type === 21 || type === 22) {
47
+ if (hasImportExport)
48
+ continue;
49
+ }
50
+ else if (hasType(type))
51
+ continue;
52
+ const row = { ...template, schemaCode };
53
+ if (row.associationCode === "__schema__")
54
+ row.associationCode = schemaCode;
55
+ if (row.clientType === undefined)
56
+ row.clientType = "PC";
57
+ actions.push(row);
58
+ }
59
+ return actions;
60
+ }
61
+ /**
62
+ * BizQueryController.executeReSetQueryActionsSortKey (:962-998) resets sortKey
63
+ * to i+1 and backfills queryId/schemaCode/clientType; ModelManagementFacadeImpl
64
+ * .updateBizQuery (:1977-1987) copies a PC `add` action with a relativeCode to
65
+ * an APP `add`. The fake mirrors both so readback matches live.
66
+ */
67
+ function normalizeStoredActions(bodyActions, clientType, schemaCode) {
68
+ const actions = bodyActions.map((action, index) => ({
69
+ ...action,
70
+ sortKey: index + 1,
71
+ clientType,
72
+ schemaCode,
73
+ // Persisted BizQueryAction boolean columns default to false (observed live);
74
+ // platform-injected read-face actions are not persisted, so they stay null.
75
+ systemAction: action.systemAction ?? false,
76
+ customService: action.customService ?? false,
77
+ }));
78
+ for (const action of actions) {
79
+ if (action.actionCode === "add" && (action.associationCode === undefined || action.associationCode === null || action.associationCode === "")) {
80
+ action.associationType = QUERY_ACTION_RELATIVE.index.BIZSHEET;
81
+ action.associationCode = schemaCode;
82
+ }
83
+ }
84
+ if (clientType === "PC") {
85
+ const pcAdd = actions.find((action) => action.actionCode === "add" && action.clientType === "PC" && action.associationCode != null);
86
+ if (pcAdd) {
87
+ const existing = actions.findIndex((action) => action.clientType === "APP" && action.actionCode === "add");
88
+ if (existing >= 0)
89
+ actions.splice(existing, 1);
90
+ actions.push({ ...pcAdd, clientType: "APP", sortKey: actions.length + 1 });
91
+ }
92
+ }
93
+ return actions;
94
+ }
95
+ /**
96
+ * BizButtonFacadeImpl.addBizButtonList(String,String,ButtonTriggerType,String)
97
+ * (8.6.26 :112-137) returns early on a blank `customButtonJson`, so an omitted
98
+ * key leaves the stored buttons untouched; a JSON array fully replaces them
99
+ * (batchDeleteByTrigger then re-add), forcing schemaCode/triggerType QUERY/
100
+ * triggerCode view code and sortKey = array index. The fake mirrors that so the
101
+ * query/update round trip matches live.
102
+ */
103
+ function replaceStoredButtons(customButtonJson, schemaCode, triggerCode) {
104
+ if (typeof customButtonJson !== "string" || customButtonJson.trim() === "")
105
+ return undefined;
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(customButtonJson);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ if (!Array.isArray(parsed))
114
+ return undefined;
115
+ for (const button of parsed) {
116
+ if (!button || typeof button !== "object" || Array.isArray(button))
117
+ return undefined;
118
+ try {
119
+ validateButtonActionConfig(button);
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ return parsed.map((button, index) => ({
126
+ ...button,
127
+ schemaCode: button.schemaCode ?? schemaCode,
128
+ triggerType: "QUERY",
129
+ triggerCode,
130
+ sortKey: index,
131
+ }));
132
+ }
133
+ /** W1-list-view lane: design-time query views. Twin of src/lanes/list-view.ts. */
134
+ export const listViewRoute = (ctx, input) => {
135
+ const q = input.query ?? {};
136
+ const body = (input.body ?? {});
137
+ switch (input.path) {
138
+ case "/api/api/app/query/create": {
139
+ const code = String(body.code ?? `view-${ctx.state.listViews.size + 1}`);
140
+ // Live fidelity (ticket 81): creating a view whose code already exists
141
+ // answers 303002 编码重复 — a post-timeout retry hits exactly this.
142
+ const existing = ctx.state.listViews.get(code);
143
+ if (existing && existing.schemaCode === String(body.schemaCode ?? ""))
144
+ return businessRefusal(303002, `编码重复:${code}`);
145
+ const created = { code, name: String(body.name), schemaCode: String(body.schemaCode ?? ""), queryColumns: [] };
146
+ seedStoredActions(created);
147
+ ctx.state.listViews.set(code, created);
148
+ return { code };
149
+ }
150
+ case "/api/api/app/query/get_header": {
151
+ const view = ctx.state.listViews.get(q.code);
152
+ return view
153
+ ? { id: `view-${view.code}`, code: view.code, name: view.name, schemaCode: view.schemaCode, type: "LIST", showOnPc: false, publish: view.publish === true, ...(view.queryPresentationType !== undefined ? { queryPresentationType: view.queryPresentationType } : {}) }
154
+ : null;
155
+ }
156
+ case "/api/api/app/query/update_header": {
157
+ // BizQueryController.updateHeader → updateBizQuery(BizQueryHeaderModel):
158
+ // a PLAIN header update with NO publish gate (unlike /query/update, whose
159
+ // body-driven queryPresentationType assignment is skipped once the view
160
+ // is published — the BUG-11 live root cause).
161
+ const code = String(body.code ?? "");
162
+ const view = ctx.state.listViews.get(code);
163
+ if (!view)
164
+ return null;
165
+ if (body.queryPresentationType !== undefined)
166
+ view.queryPresentationType = body.queryPresentationType;
167
+ if (typeof body.name === "string")
168
+ view.name = body.name;
169
+ if (typeof body.publish === "boolean")
170
+ view.publish = body.publish;
171
+ return { code, queryPresentationType: view.queryPresentationType ?? null };
172
+ }
173
+ case "/api/api/app/query/get": {
174
+ // Live shape: every model owns a default list view (code = schemaCode)
175
+ // that starts without business columns. Lazy-create on first fetch.
176
+ // BizQueryFacadeImpl.getBizQuery sets model.clientType from the request
177
+ // (query/get requires it; query/update refuses a null clientType), so the
178
+ // fake echoes it too.
179
+ const schemaCode = String(q.schemaCode ?? "");
180
+ const code = String(q.code ?? schemaCode);
181
+ let view = ctx.state.listViews.get(code);
182
+ if (!view && code === schemaCode) {
183
+ // Live: a model's default view is created PUBLISHED (h_biz_query.publish=1).
184
+ view = { code, name: code, schemaCode, queryColumns: [], publish: true };
185
+ seedStoredActions(view);
186
+ ctx.state.listViews.set(code, view);
187
+ }
188
+ if (!view)
189
+ return null;
190
+ // BizQueryFacadeImpl.getBizQuery overwrites a condition's stored options
191
+ // with the field's own options before readback, so the read face is
192
+ // field-derived rather than the persisted condition value.
193
+ const queryConditions = view.queryConditions?.map((condition) => {
194
+ const propertyCode = String(condition.propertyCode ?? "");
195
+ const field = propertyCode.includes(".") ? undefined : ctx.state.fields.get(`${view.schemaCode || schemaCode}#${propertyCode}`);
196
+ return { ...condition, options: field?.options ?? null };
197
+ });
198
+ const clientType = String(q.clientType ?? "PC");
199
+ const queryActions = clientType === "PC" ? injectDefaultActions(view.schemaCode || schemaCode, view.queryActions ?? []) : view.queryActions;
200
+ return {
201
+ id: `view-${code}`,
202
+ code: view.code,
203
+ name: view.name,
204
+ schemaCode: view.schemaCode || schemaCode,
205
+ clientType,
206
+ queryColumns: view.queryColumns,
207
+ ...(view.options !== undefined ? { options: view.options } : {}),
208
+ ...(view.queryPresentationType !== undefined ? { queryPresentationType: view.queryPresentationType } : {}),
209
+ ...(view.queryGanttModel !== undefined ? { queryGanttModel: view.queryGanttModel } : {}),
210
+ ...(view.queryLayer !== undefined ? { queryLayer: view.queryLayer } : {}),
211
+ ...(view.multiTableHeaderConfig !== undefined ? { multiTableHeaderConfig: view.multiTableHeaderConfig } : {}),
212
+ ...(view.querySorts ? { querySorts: view.querySorts } : {}),
213
+ ...(queryConditions ? { queryConditions } : {}),
214
+ ...(queryActions && queryActions.length > 0 ? { queryActions } : {}),
215
+ ...(view.queryPresentation ? { queryPresentation: view.queryPresentation } : {}),
216
+ ...(view.customButtons ? { customButtons: view.customButtons } : {}),
217
+ };
218
+ }
219
+ case "/api/api/app/query/update": {
220
+ const code = String(body.code ?? "");
221
+ const view = ctx.state.listViews.get(code);
222
+ if (!view)
223
+ return null;
224
+ // BizQueryController.update calls validateNotEmptyGantt (:1121-1136) before
225
+ // updateBizQuery: a GANTT view requires queryGanttModel with non-null
226
+ // startTime/endTime/title codes.
227
+ if (body.queryPresentationType === "GANTT") {
228
+ const gantt = body.queryGanttModel;
229
+ if (!gantt || typeof gantt !== "object")
230
+ return businessRefusal(10005, "甘特图参数不能为空");
231
+ if (gantt.startTimePropertyCode == null)
232
+ return businessRefusal(10005, "甘特图开始时间映射参数不能为空!");
233
+ if (gantt.endTimePropertyCode == null)
234
+ return businessRefusal(10005, "甘特图结束时间映射参数不能为空!");
235
+ if (gantt.titlePropertyCode == null)
236
+ return businessRefusal(10005, "甘特图标题映射参数不能为空!");
237
+ }
238
+ if (Array.isArray(body.queryColumns))
239
+ view.queryColumns = body.queryColumns;
240
+ // BizQueryModel.options (String) is replaced whenever the update body
241
+ // carries the key; an omitted key leaves it untouched (updateBizQuery's
242
+ // name/options diff branch, ModelManagementFacadeImpl:1966-1973).
243
+ if (body.options !== undefined)
244
+ view.options = body.options;
245
+ // BizQueryHeaderModel.queryPresentationType is assigned by updateBizQuery
246
+ // (:1955) but the entity update block is SKIPPED when the stored view is
247
+ // published (ModelManagementFacadeImpl:1998-2010) — a live-verified
248
+ // silent drop (BUG-11). The tool must therefore drive /update_header for
249
+ // an already-published view.
250
+ if (body.queryPresentationType !== undefined && view.publish !== true)
251
+ view.queryPresentationType = body.queryPresentationType;
252
+ // saveBizQueryDetail only swaps queryGantt when the submitted model is
253
+ // non-null (:178-183), so an omitted key leaves the stored gantt.
254
+ if (body.queryGanttModel && typeof body.queryGanttModel === "object")
255
+ view.queryGanttModel = body.queryGanttModel;
256
+ // saveBizQueryDetail assigns queryLayer/multiTableHeaderConfig
257
+ // unconditionally (:184-186); the tool only sends them when configured.
258
+ if (Array.isArray(body.queryLayer))
259
+ view.queryLayer = body.queryLayer;
260
+ if (body.multiTableHeaderConfig !== undefined)
261
+ view.multiTableHeaderConfig = body.multiTableHeaderConfig;
262
+ if (Array.isArray(body.querySorts))
263
+ view.querySorts = body.querySorts;
264
+ if (Array.isArray(body.queryConditions))
265
+ view.queryConditions = body.queryConditions;
266
+ if (Array.isArray(body.queryActions)) {
267
+ view.queryActions = normalizeStoredActions(body.queryActions, String(body.clientType ?? "PC"), String(body.schemaCode ?? view.schemaCode ?? ""));
268
+ }
269
+ // BizQueryDetailServiceImpl.saveBizQueryDetail removes every presentation
270
+ // whose clientType matches and re-adds the submitted one; an omitted
271
+ // presentation (absent body key) leaves the stored one untouched.
272
+ if (body.queryPresentation && typeof body.queryPresentation === "object") {
273
+ view.queryPresentation = body.queryPresentation;
274
+ }
275
+ const buttons = replaceStoredButtons(body.customButtonJson, String(body.schemaCode ?? view.schemaCode ?? ""), code);
276
+ if (buttons !== undefined)
277
+ view.customButtons = buttons;
278
+ return { code: view.code };
279
+ }
280
+ case "/api/api/app/query/list":
281
+ return [...ctx.state.listViews.values()].map((view) => ({ code: view.code, name: view.name }));
282
+ case "/api/api/app/query/delete":
283
+ ctx.state.listViews.delete(q.code);
284
+ return null;
285
+ default:
286
+ return NOT_HANDLED;
287
+ }
288
+ };
@@ -0,0 +1,3 @@
1
+ import { type LaneRoute } from "./state.js";
2
+ /** model-management lane: app tree, model CRUD, physical delete. Twin of src/lanes/model.ts. */
3
+ export declare const modelRoute: LaneRoute;