@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/README.md +2 -2
- package/dist/app.d.ts +3 -0
- package/dist/app.js +44 -0
- package/dist/b2-registration.d.ts +2 -0
- package/dist/b2-registration.js +619 -0
- package/dist/businessrule.d.ts +3 -0
- package/dist/businessrule.js +241 -0
- package/dist/datarule.d.ts +2 -0
- package/dist/datarule.js +71 -0
- package/dist/dictionary.d.ts +2 -0
- package/dist/dictionary.js +107 -0
- package/dist/environment.d.ts +6 -0
- package/dist/environment.js +29 -0
- package/dist/field.d.ts +3 -0
- package/dist/field.js +166 -0
- package/dist/form.d.ts +3 -0
- package/dist/form.js +100 -0
- package/dist/http-server.d.ts +8 -0
- package/dist/http-server.js +64 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +120 -0
- package/dist/list-view.d.ts +3 -0
- package/dist/list-view.js +288 -0
- package/dist/model.d.ts +3 -0
- package/dist/model.js +135 -0
- package/dist/online-js.d.ts +3 -0
- package/dist/online-js.js +17 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +252 -0
- package/dist/routes.d.ts +2 -0
- package/dist/routes.js +46 -0
- package/dist/rule-lifecycle.d.ts +7 -0
- package/dist/rule-lifecycle.js +36 -0
- package/dist/runtime-data.d.ts +2 -0
- package/dist/runtime-data.js +366 -0
- package/dist/state.d.ts +400 -0
- package/dist/state.js +66 -0
- package/dist/workflow.d.ts +39 -0
- package/dist/workflow.js +363 -0
- package/package.json +24 -4
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
};
|
package/dist/model.d.ts
ADDED