biz-a-cli 2.3.80-15419 → 2.3.80
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/bin/app.js +20 -255
- package/bin/hub.js +0 -16
- package/bin/hubEvent.js +5 -98
- package/bin/tunnelTarget.js +0 -4
- package/engine/bpm/workflow.js +6 -7
- package/engine/cc/configValidate.js +0 -53
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
- package/engine/cc/layer3Behavior.js +1 -2
- package/engine/cc/layer3Schema.js +1 -3
- package/engine/cc/livePipelines.js +3 -9
- package/engine/cc/publish.js +105 -86
- package/engine/domain/appConfig.js +18 -108
- package/engine/domain/applicationConfig.js +12 -87
- package/engine/domain/dialect.js +0 -138
- package/engine/domain/domainNavigation.js +50 -86
- package/engine/domain/entityConfigLoader.js +4 -16
- package/engine/domain/numericScale.js +0 -2
- package/engine/domain/system.js +0 -2
- package/engine/ext/publicTheme.js +1 -8
- package/engine/orm/apiRoute.js +61 -187
- package/engine/orm/entityConfig.js +2 -32
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +0 -19
- package/engine/orm/imgQuery.js +0 -92
- package/engine/orm/listQuery.js +1 -6
- package/package.json +1 -1
- package/worker/cliScriptWorker.js +0 -5
- package/engine/attachments/store.js +0 -195
- package/engine/domain/domainRegistration.js +0 -65
- package/engine/fina/approval.js +0 -141
- package/engine/fina/approvalService.js +0 -59
- package/engine/fina/attachments.js +0 -165
- package/engine/fina/client.js +0 -84
- package/engine/fina/config.js +0 -26
- package/engine/fina/context.js +0 -58
- package/engine/fina/document.js +0 -67
- package/engine/fina/documentWrite.js +0 -173
- package/engine/fina/documents/index.js +0 -28
- package/engine/fina/documents/jv.js +0 -105
- package/engine/fina/documents/jvWrite.js +0 -260
- package/engine/fina/documents/si.js +0 -40
- package/engine/fina/errors.js +0 -17
- package/engine/fina/features.js +0 -86
- package/engine/fina/guard.js +0 -102
- package/engine/fina/identity.js +0 -87
- package/engine/fina/live.js +0 -19
- package/engine/fina/lock.js +0 -35
- package/engine/fina/master.js +0 -331
- package/engine/fina/masters/coa.js +0 -104
- package/engine/fina/masters/currency.js +0 -39
- package/engine/fina/masters/department.js +0 -32
- package/engine/fina/masters/index.js +0 -21
- package/engine/fina/masters/project.js +0 -53
- package/engine/fina/period.js +0 -56
- package/engine/fina/print.js +0 -136
- package/engine/fina/readPolicy.js +0 -67
- package/engine/fina/recurring.js +0 -200
- package/engine/fina/route.js +0 -61
- package/engine/fina/routes/attachment.js +0 -41
- package/engine/fina/routes/document.js +0 -66
- package/engine/fina/routes/master.js +0 -26
- package/engine/fina/routes/period.js +0 -24
- package/engine/fina/routes/print.js +0 -19
- package/engine/fina/routes/recurring.js +0 -27
- package/engine/fina/routes/session.js +0 -23
- package/engine/fina/routes/setup.js +0 -58
- package/engine/fina/routes/submission.js +0 -46
- package/engine/fina/routes/template.js +0 -31
- package/engine/fina/settings.js +0 -159
- package/engine/fina/sql.js +0 -34
- package/engine/fina/submission.js +0 -290
- package/engine/fina/submissionCompare.js +0 -171
- package/engine/fina/submissionRepo.js +0 -114
- package/engine/fina/template.js +0 -118
- package/engine/fina/userMap.js +0 -79
- package/engine/orm/companyLocale.js +0 -74
- package/engine/orm/userApiDataset.js +0 -153
- package/migrations/postgres/1790600000000__fina.sql +0 -33
- package/migrations/postgres/1790700000000__attachment.sql +0 -29
- package/migrations/postgres/1790800000000__fina_jv_submission.sql +0 -41
package/engine/fina/client.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 — the FINA API as engine/fina calls it (docs/DOC8-FINA-API-CONTRACT.md §1).
|
|
3
|
-
*
|
|
4
|
-
* `POST <api>/fina/rest/<Class>/%22<method>%22`, content-type text/plain, body `{"_parameters":[…]}`.
|
|
5
|
-
*
|
|
6
|
-
* ⚠️ FINA ANSWERS AN ERROR AS HTTP 200: TFinaMethod wraps it, `{"result":[{"error":"…"}]}`; TOrmMethod returns a
|
|
7
|
-
* bare `{"error":"…"}`. Both are thrown here as FINA_REFUSED with FINA's own message (it is what the user needs to
|
|
8
|
-
* read: "You do not have the right to edit", "Debits and Credits are out of balance by 500"…).
|
|
9
|
-
* `scheduler/datalib.js` reads only `res.data?.error` and misses the wrapped form, which is why this has its own.
|
|
10
|
-
*
|
|
11
|
-
* Every call goes to the ONE FINA database this hub's FINA Cloud works on (config.js); a caller cannot choose
|
|
12
|
-
* another, because `dbIndex` is set here, after the caller's request.
|
|
13
|
-
*/
|
|
14
|
-
import axios from "axios";
|
|
15
|
-
import { finaError, FinaError } from "./errors.js";
|
|
16
|
-
|
|
17
|
-
export { FinaError };
|
|
18
|
-
|
|
19
|
-
/* Mass actions run documents one after another (M-11), each well inside the FINA API's own 55 s. */
|
|
20
|
-
const DEFAULT_TIMEOUT_MS = 55000;
|
|
21
|
-
|
|
22
|
-
export const createFinaClient = ({ url, dbIndex, http = axios, timeout = DEFAULT_TIMEOUT_MS }) => {
|
|
23
|
-
const post = async (methodClass, method, parameters) => {
|
|
24
|
-
let res;
|
|
25
|
-
try {
|
|
26
|
-
res = await http.post(`${url}/fina/rest/${methodClass}/%22${method}%22`, JSON.stringify({ _parameters: parameters }), {
|
|
27
|
-
headers: { "content-type": "text/plain" },
|
|
28
|
-
timeout,
|
|
29
|
-
validateStatus: () => true,
|
|
30
|
-
maxContentLength: Infinity,
|
|
31
|
-
maxBodyLength: Infinity,
|
|
32
|
-
});
|
|
33
|
-
} catch (error) {
|
|
34
|
-
throw finaError("FINA_UNREACHABLE", `The FINA API at ${url} could not be reached: ${error?.message ?? error}`);
|
|
35
|
-
}
|
|
36
|
-
if (!res || res.status < 200 || res.status >= 300) {
|
|
37
|
-
throw finaError("FINA_ERROR", `The FINA API answered ${res?.status} to ${method}.`);
|
|
38
|
-
}
|
|
39
|
-
let body;
|
|
40
|
-
try {
|
|
41
|
-
body = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
|
|
42
|
-
} catch {
|
|
43
|
-
throw finaError("FINA_ERROR", `The FINA API answered ${method} with something that is not JSON.`);
|
|
44
|
-
}
|
|
45
|
-
const refusal = body?.error ?? body?.result?.[0]?.error;
|
|
46
|
-
if (refusal !== undefined && refusal !== null && refusal !== "") throw finaError("FINA_REFUSED", String(refusal));
|
|
47
|
-
return body;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
dbIndex,
|
|
52
|
-
|
|
53
|
-
/* One statement, rows back with lower-cased keys (as execPostgres returns them). ⚠️ Single-line: a raw
|
|
54
|
-
newline ends the command on this transport (memory: FINA block path limits). */
|
|
55
|
-
executeSql: async (sql) => {
|
|
56
|
-
if (/[\r\n]/.test(String(sql))) throw finaError("INVALID", "A FINA statement must be on one line.");
|
|
57
|
-
const body = await post("TFinaMethod", "ExecuteSQL", [String(sql), String(dbIndex)]);
|
|
58
|
-
const first = body?.result?.[0];
|
|
59
|
-
if (typeof first !== "string" || first === "") return [];
|
|
60
|
-
const rows = JSON.parse(first)?.data;
|
|
61
|
-
return (Array.isArray(rows) ? rows : []).map((row) =>
|
|
62
|
-
Object.fromEntries(Object.entries(row).map(([key, value]) => [key.toLowerCase(), value])),
|
|
63
|
-
);
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
/* `EXECUTE BLOCK AS BEGIN …; END` — one atomic statement (contract §5). */
|
|
67
|
-
block: async (sql) => post("TOrmMethod", "block", [JSON.stringify({ dbIndex, method: "block", object: String(sql) })]),
|
|
68
|
-
|
|
69
|
-
/* Documents and masters (contract §2). Answers the key FINA gave: JVID, GLACCOUNT, DEPTID… */
|
|
70
|
-
json2fina: async (request) => {
|
|
71
|
-
const body = await post("TFinaMethod", "json2fina", [JSON.stringify({ ...request, dbIndex })]);
|
|
72
|
-
return { id: body?.result?.[0]?.id ?? null };
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
/* Doc 8 M4 — a document's printed pages (contract §3): FINA's report as JPEGs, concatenated, sent as an array of
|
|
76
|
-
byte values. ⚠️ `table` / `keyfield` / `id` go into FINA's SQL as they are: engine/fina/print.js builds them. */
|
|
77
|
-
template: async (request) => {
|
|
78
|
-
const body = await post("TFinaMethod", "template", [JSON.stringify({ ...request, dbIndex })]);
|
|
79
|
-
const bytes = body?.result?.[0];
|
|
80
|
-
if (!Array.isArray(bytes)) throw finaError("FINA_ERROR", "The FINA API answered template without the rendered pages.");
|
|
81
|
-
return Uint8Array.from(bytes);
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
};
|
package/engine/fina/config.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 — is FINA Cloud on for this hub, and which FINA database is it?
|
|
3
|
-
*
|
|
4
|
-
* ⚠️ AN OPERATOR SETTING (`--finaCloudDbindex`), NOT A CONSOLE ONE. One FINA API can serve several companies'
|
|
5
|
-
* databases by index; a tenant administrator who could choose the index could open another company's books. The
|
|
6
|
-
* index is therefore fixed where the hub is started, as `--dbindex` is.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ NOT the registry's `finaDbIndex`: that one says where LOGIN users live (engine/orm/apiRoute.js), which on a
|
|
9
|
-
* PostgreSQL tenant is the tenant's own database.
|
|
10
|
-
*
|
|
11
|
-
* Anything but a positive whole number means off: every `fina/*` route then answers NOT_CONFIGURED and the browser
|
|
12
|
-
* write guard stands aside.
|
|
13
|
-
*/
|
|
14
|
-
export const finaCloudConfig = (argv = {}) => {
|
|
15
|
-
const raw = argv?.finaCloudDbindex;
|
|
16
|
-
const dbIndex = Number(raw);
|
|
17
|
-
const enabled = raw !== undefined && raw !== null && raw !== "" && Number.isInteger(dbIndex) && dbIndex > 0;
|
|
18
|
-
return {
|
|
19
|
-
enabled,
|
|
20
|
-
dbIndex: enabled ? dbIndex : null,
|
|
21
|
-
/* Where this hub's own data lives: FINA$SETTING, FINA$USER_MAP, SYS$USERS, SYS$ADMINISTRATOR. */
|
|
22
|
-
tenantDbIndex: Number(argv?.dbindex),
|
|
23
|
-
/* The FINA API this hub already forwards to (hub.js -h / -p / --secure). */
|
|
24
|
-
url: `${argv?.secure === true ? "https" : "http"}://${argv?.hostname ?? "127.0.0.1"}:${argv?.port ?? 212}`,
|
|
25
|
-
};
|
|
26
|
-
};
|
package/engine/fina/context.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 D2 — WHO is calling a `fina/*` engine route, decided once, before the route runs.
|
|
3
|
-
*
|
|
4
|
-
* Only the SIGNED principal counts (x-biza-principal, verified here; principalForCliScript), never an id in the
|
|
5
|
-
* payload. Answers `{ ok: true, principal, config }` or `{ ok: false, code, error }`:
|
|
6
|
-
*
|
|
7
|
-
* - NOT_CONFIGURED: FINA Cloud is off on this hub (no --finaCloudDbindex).
|
|
8
|
-
* - FINA_CLOUD_NEEDS_POSTGRES: engine/fina's tables (FINA$SETTING, FINA$USER_MAP) are PostgreSQL migrations.
|
|
9
|
-
* - UNAUTHENTICATED:
|
|
10
|
-
* - no principal, or one that does not verify — in EVERY principal mode: `observe` lets a data request through,
|
|
11
|
-
* but it never invents an identity, and a FINA write needs one;
|
|
12
|
-
* - ⚠️ a principal signed for another database. The user id means a row of SYS$USERS in the database it was
|
|
13
|
-
* minted for; FINA$USER_MAP and SYS$ADMINISTRATOR are read in THIS hub's database. Two hubs on one machine can
|
|
14
|
-
* share a secret, so a token from the other would otherwise name somebody else's user here;
|
|
15
|
-
* - ⚠️ a session row that is revoked, expired or cannot be read (Doc 4 §9.4: access ends at once — the token
|
|
16
|
-
* alone would keep working for the rest of its 12 hours).
|
|
17
|
-
*/
|
|
18
|
-
import { principalForCliScript } from "../../security/internalPrincipal.js";
|
|
19
|
-
import { finaCloudConfig } from "./config.js";
|
|
20
|
-
|
|
21
|
-
const refused = (code, error) => ({ ok: false, code, error });
|
|
22
|
-
|
|
23
|
-
export const finaContext = async (headers, { argv = {}, isPostgresIndex, readSession = null } = {}) => {
|
|
24
|
-
const config = finaCloudConfig(argv);
|
|
25
|
-
if (!config.enabled) {
|
|
26
|
-
return refused("NOT_CONFIGURED", "FINA Cloud is not switched on for this company (the hub runs without --finaCloudDbindex).");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let postgres = false;
|
|
30
|
-
try {
|
|
31
|
-
postgres = typeof isPostgresIndex === "function" && isPostgresIndex(config.tenantDbIndex) === true;
|
|
32
|
-
} catch {
|
|
33
|
-
postgres = false;
|
|
34
|
-
}
|
|
35
|
-
if (!postgres) {
|
|
36
|
-
return refused("FINA_CLOUD_NEEDS_POSTGRES", `FINA Cloud needs this company's Biz-A database (${config.tenantDbIndex}) on PostgreSQL.`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const principal = principalForCliScript(headers, { mode: argv?.principalMode || "observe" });
|
|
40
|
-
if (!principal) return refused("UNAUTHENTICATED", "No signed-in Biz-A user came with this request. Sign in again.");
|
|
41
|
-
if (Number(principal.dbIndex) !== config.tenantDbIndex) {
|
|
42
|
-
return refused("UNAUTHENTICATED", "This sign-in belongs to another Biz-A database. Sign in to this company again.");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (principal.sessionId !== null && principal.sessionId !== undefined && principal.sessionId !== "" && typeof readSession === "function") {
|
|
46
|
-
let session;
|
|
47
|
-
try {
|
|
48
|
-
session = await readSession(principal.sessionId);
|
|
49
|
-
} catch {
|
|
50
|
-
session = null;
|
|
51
|
-
}
|
|
52
|
-
if (!session?.ok) {
|
|
53
|
-
return refused("UNAUTHENTICATED", `This session can no longer be used (${session?.reason ?? "unreadable"}). Sign in again.`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { ok: true, principal, config };
|
|
58
|
-
};
|
package/engine/fina/document.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 M4 — one FINA document read for its form (view, T-01): the master, its lines and their extended values, read
|
|
3
|
-
* through the document's server-owned descriptor (documents/*.js). Any mapped FINA user may read (D4: view).
|
|
4
|
-
*
|
|
5
|
-
* Extended fields (T-12–T-16): only the MAPPED ones are read, from EXTENDED by the master's and the lines'
|
|
6
|
-
* EXTENDEDIDs, and answered as `ext_<field>` beside the other values.
|
|
7
|
-
*/
|
|
8
|
-
import { finaError } from "./errors.js";
|
|
9
|
-
import { requireRight } from "./identity.js";
|
|
10
|
-
import { documentOf, documentId } from "./documents/index.js";
|
|
11
|
-
import { readMappings } from "./template.js";
|
|
12
|
-
|
|
13
|
-
/* The master row, or null — also what print and the template choice check a document against. */
|
|
14
|
-
export const readMaster = async (client, document, id) => {
|
|
15
|
-
const { select, from, filter, key } = document.master;
|
|
16
|
-
const where = [`M.${key} = ${id}`, ...(filter ? [filter] : [])].join(" AND ");
|
|
17
|
-
return (await client.executeSql(`SELECT ${select} FROM ${from} WHERE ${where} `))?.[0] ?? null;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const extendedValues = async (client, ids, fields) => {
|
|
21
|
-
const wanted = [...new Set(ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))];
|
|
22
|
-
if (wanted.length === 0 || fields.length === 0) return new Map();
|
|
23
|
-
const rows = (await client.executeSql(`SELECT EXTENDEDID, ${fields.join(", ")} FROM EXTENDED WHERE EXTENDEDID IN (${wanted.join(", ")})`)) ?? [];
|
|
24
|
-
return new Map(rows.map((row) => [Number(row.extendedid), row]));
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const withExtended = (row, extended, mappings) => {
|
|
28
|
-
const values = extended.get(Number(row.extendedid)) ?? {};
|
|
29
|
-
const out = { ...row };
|
|
30
|
-
for (const mapping of mappings) out[mapping.key] = values[mapping.field.toLowerCase()] ?? null;
|
|
31
|
-
return out;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
/* Doc 8 M5a — a document as FINA has it now: what the form shows (get) and what an edit starts from (documentWrite). */
|
|
35
|
-
export const readDocument = async (client, document, id) => {
|
|
36
|
-
const master = await readMaster(client, document, id);
|
|
37
|
-
if (!master) throw finaError("DOCUMENT_NOT_FOUND", `${document.label} ${id} was not found.`);
|
|
38
|
-
const { select, from, key, order, line } = document.detail;
|
|
39
|
-
const lines = (await client.executeSql(`SELECT ${select} FROM ${from} WHERE ${key} = ${id} ORDER BY ${order}`)) ?? [];
|
|
40
|
-
|
|
41
|
-
const mappings = await readMappings(client, document.extended);
|
|
42
|
-
const masterMappings = mappings.filter((m) => m.part === "master");
|
|
43
|
-
const lineMappings = mappings.filter((m) => m.part === "detail");
|
|
44
|
-
const fields = [...new Set(mappings.map((m) => m.field))];
|
|
45
|
-
const extended = await extendedValues(client, [master.extendedid, ...lines.map((l) => l.extendedid)], fields);
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
id,
|
|
49
|
-
templateId: master.templateid ?? null,
|
|
50
|
-
master: withExtended(master, extended, masterMappings),
|
|
51
|
-
lines: lines.map((row) => withExtended(line ? line(row) : row, extended, lineMappings)),
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const createDocumentService = ({ client, identity, submissionOf = null }) => ({
|
|
56
|
-
get: async (principal, name, rawId) => {
|
|
57
|
-
const document = documentOf(name);
|
|
58
|
-
const id = documentId(rawId);
|
|
59
|
-
requireRight(await identity.resolve(principal), document.rights, null);
|
|
60
|
-
const doc = await readDocument(client, document, id);
|
|
61
|
-
/* What an edit must be sent against: changed by someone else meanwhile, the save is refused (DOCUMENT_CHANGED). */
|
|
62
|
-
const answer = { ...doc, version: document.write?.version ? document.write.version(doc) : null };
|
|
63
|
-
/* Doc 8 M7b (L-02): a document that has an approval flow says which submission it came from, or null. */
|
|
64
|
-
if (submissionOf && document.write?.needsApproval) answer.submission = (await submissionOf(id)) ?? null;
|
|
65
|
-
return answer;
|
|
66
|
-
},
|
|
67
|
-
});
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 M5a — saving a FINA document from Biz-A (F-25, F-27, F-30, F-32, F-33; contract §2.1, §2.1a, §6, §8).
|
|
3
|
-
*
|
|
4
|
-
* The document's server-owned `write` block (documents/<doc>.js) says WHAT is written; this runs it in a fixed order:
|
|
5
|
-
* 1. the FINA right: create for a new document, edit for an existing one (D4);
|
|
6
|
-
* 2. Need Approval on → refused here (the approval flow, M7, is the only way in then);
|
|
7
|
-
* 3. on edit, the document as FINA has it NOW — gone, changed since it was opened (`version`), a locked line;
|
|
8
|
-
* 4. the rules — every problem in ONE DOCUMENT_RULE, so the user fixes them all at once;
|
|
9
|
-
* 5. the Biz-A date lock on the new AND the old date (D2a): an edit can neither enter nor leave the locked range;
|
|
10
|
-
* 6. FINA's CHECK_PERIOD on both dates (the API checks only the old one, appendix V12);
|
|
11
|
-
* 7. json2fina as the caller's FINA user; a new number colliding (HEADLESS, contract §9) is retried up to 3 times;
|
|
12
|
-
* 8. the number read back by id (the response has none, T-27), FINA's totals compared with what was sent.
|
|
13
|
-
*
|
|
14
|
-
* ⚠️ ONE SIMPAN, ONE DOCUMENT. A save the hub gives up on (28 s) may still be committing in FINA; the browser resends
|
|
15
|
-
* it with the SAME requestId, which answers the first save's outcome instead of writing a second document.
|
|
16
|
-
*/
|
|
17
|
-
import { finaError } from "./errors.js";
|
|
18
|
-
import { requireRight } from "./identity.js";
|
|
19
|
-
import { documentOf, documentId } from "./documents/index.js";
|
|
20
|
-
import { readDocument } from "./document.js";
|
|
21
|
-
import { listTemplates, assertTemplateOfType, readMappings } from "./template.js";
|
|
22
|
-
import { isDateLocked, toDateOnly } from "./lock.js";
|
|
23
|
-
import { checkPeriods } from "./period.js";
|
|
24
|
-
import { ownerOf } from "./attachments.js";
|
|
25
|
-
|
|
26
|
-
const COLLISION_RETRIES = 3;
|
|
27
|
-
const REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
28
|
-
const requests = new Map();
|
|
29
|
-
|
|
30
|
-
/* Tests only: forget the request ids seen. */
|
|
31
|
-
export const resetDocumentRequests = () => requests.clear();
|
|
32
|
-
|
|
33
|
-
export const createDocumentWriter = ({ client, identity, settings, features, attachments = null, now = () => Date.now() }) => {
|
|
34
|
-
const run = async (principal, name, request, { approved = false } = {}) => {
|
|
35
|
-
const document = documentOf(name);
|
|
36
|
-
const write = document.write;
|
|
37
|
-
if (!write) throw finaError("INVALID", `${document.label} is read only in Biz-A.`);
|
|
38
|
-
const blank = request?.id === null || request?.id === undefined || request?.id === "";
|
|
39
|
-
const id = blank ? null : documentId(request.id);
|
|
40
|
-
const isNew = id === null;
|
|
41
|
-
const who = requireRight(await identity.resolve(principal), document.rights, isNew ? "create" : "edit");
|
|
42
|
-
|
|
43
|
-
const config = await settings.read();
|
|
44
|
-
/* Doc 8 M7: with Need Approval on, a NEW document is submitted (fina/submission) and reaches FINA only through its
|
|
45
|
-
approval, which posts it here as `approved` (submission.js; no route passes that). A document already in FINA is
|
|
46
|
-
edited directly (Agung 2026-10-14). */
|
|
47
|
-
if (isNew && !approved && write.needsApproval?.(config)) {
|
|
48
|
-
throw finaError("DOCUMENT_RULE", `Need Approval is on in the FINA Cloud console: a new ${document.label} is submitted for approval (Pengajuan).`);
|
|
49
|
-
}
|
|
50
|
-
const flags = await features.read();
|
|
51
|
-
|
|
52
|
-
let current = null;
|
|
53
|
-
if (!isNew) {
|
|
54
|
-
current = await readDocument(client, document, id);
|
|
55
|
-
if (request?.version && write.version(current) !== request.version) {
|
|
56
|
-
throw finaError("DOCUMENT_CHANGED", `${document.label} ${current.master?.jvnumber ?? id} was changed in FINA since it was opened. Open it again.`);
|
|
57
|
-
}
|
|
58
|
-
const locked = write.locked ? await write.locked(client, id) : [];
|
|
59
|
-
if (locked.length > 0) throw finaError("DOCUMENT_RULE", locked.join("\n"));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const normalized = write.normalize(request?.master, request?.lines);
|
|
63
|
-
const problems = [...normalized.problems];
|
|
64
|
-
if (problems.length === 0) problems.push(...(await write.validate({ client, lines: normalized.lines, settings: config, features: flags })));
|
|
65
|
-
if (problems.length > 0) throw finaError("DOCUMENT_RULE", problems.join("\n"));
|
|
66
|
-
|
|
67
|
-
const upTo = config?.lock?.upTo ?? null;
|
|
68
|
-
const newDate = normalized.master.transdate;
|
|
69
|
-
const oldDate = current ? toDateOnly(current.master?.transdate) : null;
|
|
70
|
-
for (const date of [newDate, oldDate]) {
|
|
71
|
-
if (date && isDateLocked(date, upTo)) {
|
|
72
|
-
throw finaError("DATE_LOCKED", `${date} is on or before the Biz-A lock date ${toDateOnly(upTo)}: it cannot be saved from Biz-A.`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
await checkPeriods(client, [newDate, oldDate]);
|
|
76
|
-
|
|
77
|
-
if (normalized.master.templateid) assertTemplateOfType(await listTemplates(client, document.templateType), normalized.master.templateid);
|
|
78
|
-
const mappings = await readMappings(client, document.extended);
|
|
79
|
-
const data = write.payload({ isNew, master: normalized.master, lines: normalized.lines, current, mappings });
|
|
80
|
-
|
|
81
|
-
let answer;
|
|
82
|
-
for (let attempt = 0; ; attempt++) {
|
|
83
|
-
try {
|
|
84
|
-
answer = await client.json2fina({ id: isNew ? -1 : String(id), dataModule: write.dataModule, transType: write.transType, userId: who.finaUser.id, data });
|
|
85
|
-
break;
|
|
86
|
-
} catch (error) {
|
|
87
|
-
const collision = isNew && error?.code === "FINA_REFUSED" && /already exist/i.test(String(error?.message ?? ""));
|
|
88
|
-
if (collision && attempt < COLLISION_RETRIES) continue;
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const savedId = isNew ? documentId(answer?.id) : id;
|
|
94
|
-
const back = write.readBack ? await write.readBack(client, savedId, normalized.lines) : { number: null, warnings: [] };
|
|
95
|
-
return { id: savedId, number: back.number, warnings: back.warnings };
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
/*
|
|
99
|
-
* Doc 8 M5b — delete (F-32, F-33). FINA's API checks neither the delete right (V3) nor, as far as recorded, the period
|
|
100
|
-
* on a delete, so this does: the edit right (D4: delete = E), the document as FINA has it now (gone, a reconciled
|
|
101
|
-
* line, V17), the Biz-A lock and FINA's period on its date. One json2fina call removes the master, its lines, its GL
|
|
102
|
-
* entries and its extended rows (contract §2, verified in M0).
|
|
103
|
-
*/
|
|
104
|
-
const removeRun = async (principal, name, request) => {
|
|
105
|
-
const document = documentOf(name);
|
|
106
|
-
const write = document.write;
|
|
107
|
-
if (!write) throw finaError("INVALID", `${document.label} is read only in Biz-A.`);
|
|
108
|
-
const id = documentId(request?.id);
|
|
109
|
-
const who = requireRight(await identity.resolve(principal), document.rights, "delete");
|
|
110
|
-
const current = await readDocument(client, document, id);
|
|
111
|
-
const locked = write.locked ? await write.locked(client, id, "deleted") : [];
|
|
112
|
-
if (locked.length > 0) throw finaError("DOCUMENT_RULE", locked.join("\n"));
|
|
113
|
-
|
|
114
|
-
const config = await settings.read();
|
|
115
|
-
const upTo = config?.lock?.upTo ?? null;
|
|
116
|
-
const date = toDateOnly(current.master?.transdate);
|
|
117
|
-
if (isDateLocked(date, upTo)) {
|
|
118
|
-
throw finaError("DATE_LOCKED", `${date} is on or before the Biz-A lock date ${toDateOnly(upTo)}: it cannot be deleted from Biz-A.`);
|
|
119
|
-
}
|
|
120
|
-
await checkPeriods(client, [date]);
|
|
121
|
-
|
|
122
|
-
await client.json2fina({ id: String(id), delete: "1", dataModule: write.dataModule, transType: write.transType, userId: who.finaUser.id, data: { master: {} } });
|
|
123
|
-
/* Doc 8 M6: its attachments (Biz-A's, SYS$ATTACHMENT) go with it, once FINA has deleted it. */
|
|
124
|
-
const warnings = [];
|
|
125
|
-
if (attachments && document.attachments) {
|
|
126
|
-
try {
|
|
127
|
-
await attachments.removeOwner({ owner: ownerOf(document.name, id) });
|
|
128
|
-
} catch (error) {
|
|
129
|
-
warnings.push(`Its attachments could not be removed: ${error?.message ?? error}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return { id, number: current.master?.jvnumber ?? null, ...(warnings.length > 0 && { warnings }) };
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
/*
|
|
136
|
-
* Doc 8 M5b — a mass edit (M-13–M-17): the HEADER only. The keys a patch may carry are the date, the description and
|
|
137
|
-
* the master's MAPPED extended fields; anything else (a line, the number) is refused. The patched document then goes
|
|
138
|
-
* the single edit's way (M-16) with its own lines, so FINA re-posts it to the GL (M-15) and both dates are checked
|
|
139
|
-
* (M-17).
|
|
140
|
-
*/
|
|
141
|
-
const patchRun = async (principal, name, request) => {
|
|
142
|
-
const document = documentOf(name);
|
|
143
|
-
if (!document.write) throw finaError("INVALID", `${document.label} is read only in Biz-A.`);
|
|
144
|
-
const id = documentId(request?.id);
|
|
145
|
-
const patch = request?.master;
|
|
146
|
-
if (!patch || typeof patch !== "object" || Array.isArray(patch) || Object.keys(patch).length === 0) {
|
|
147
|
-
throw finaError("INVALID", "Nothing to change: a mass edit names the date, the description or an extended field.");
|
|
148
|
-
}
|
|
149
|
-
requireRight(await identity.resolve(principal), document.rights, "edit");
|
|
150
|
-
const current = await readDocument(client, document, id);
|
|
151
|
-
const extended = Object.keys(current.master ?? {}).filter((k) => /^ext_/.test(k));
|
|
152
|
-
const allowed = new Set(["transdate", "transdescription", ...extended]);
|
|
153
|
-
const refused = Object.keys(patch).filter((k) => !allowed.has(k));
|
|
154
|
-
if (refused.length > 0) {
|
|
155
|
-
throw finaError("INVALID", `A mass edit changes only the date, the description and the mapped extended fields, not ${refused.join(", ")}.`);
|
|
156
|
-
}
|
|
157
|
-
const own = Object.fromEntries(["transdate", "transdescription", "templateid", ...extended].map((k) => [k, current.master[k] ?? null]));
|
|
158
|
-
return run(principal, name, { id, version: document.write.version(current), master: { ...own, ...patch }, lines: current.lines });
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/* `requestId` (from the browser, one per action) makes a resend answer the first outcome; `dbIndex` scopes it. */
|
|
162
|
-
const once = (action, fn) => (principal, name, request = {}, { dbIndex, ...options } = {}) => {
|
|
163
|
-
const requestId = String(request?.requestId ?? "").trim();
|
|
164
|
-
if (!requestId) return fn(principal, name, request, options);
|
|
165
|
-
const at = now();
|
|
166
|
-
for (const [key, entry] of requests) if (at - entry.at > REQUEST_TTL_MS) requests.delete(key);
|
|
167
|
-
const key = `${action}:${dbIndex ?? ""}:${principal?.userId ?? ""}:${requestId}`;
|
|
168
|
-
if (!requests.has(key)) requests.set(key, { at, promise: fn(principal, name, request, options) });
|
|
169
|
-
return requests.get(key).promise;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
return { save: once("save", run), remove: once("remove", removeRun), patch: once("patch", patchRun) };
|
|
173
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 M4 — the FINA documents engine/fina serves, by the name the browser uses.
|
|
3
|
-
*
|
|
4
|
-
* ⚠️ SERVER-OWNED, like the masters: a request names a document; its tables, key, filter, template type, print
|
|
5
|
-
* dictionary and recurring class come from here. Nothing a browser sends reaches FINA's SQL except validated integers.
|
|
6
|
-
*/
|
|
7
|
-
import { finaError } from "../errors.js";
|
|
8
|
-
import jv from "./jv.js";
|
|
9
|
-
import si from "./si.js";
|
|
10
|
-
|
|
11
|
-
const DOCUMENTS = { jv, si };
|
|
12
|
-
|
|
13
|
-
export const DOCUMENT_NAMES = Object.keys(DOCUMENTS);
|
|
14
|
-
|
|
15
|
-
export const documentOf = (name) => {
|
|
16
|
-
const document = DOCUMENTS[String(name ?? "").trim().toLowerCase()];
|
|
17
|
-
if (!document) throw finaError("INVALID", `Unknown document ${JSON.stringify(name)}: it is one of ${DOCUMENT_NAMES.join(", ")}.`);
|
|
18
|
-
return document;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/* A document id is a positive integer — it goes into FINA's SQL. */
|
|
22
|
-
export const documentId = (value) => {
|
|
23
|
-
const id = Number(value);
|
|
24
|
-
if (value === null || value === undefined || typeof value === "boolean" || !Number.isInteger(id) || id <= 0 || !/^\d+$/.test(String(value).trim())) {
|
|
25
|
-
throw finaError("INVALID", `A document id is a positive whole number, not ${JSON.stringify(value)}.`);
|
|
26
|
-
}
|
|
27
|
-
return id;
|
|
28
|
-
};
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 M4 — the Journal Voucher as engine/fina reads, prints and keeps its recurring lines (contract §2.1, §3, §7;
|
|
3
|
-
* appendix F-03 §1.3). Its screen binding is the app's (finaDocuments.jv); this is what the SERVER trusts.
|
|
4
|
-
*
|
|
5
|
-
* Plain JVs only: Other Payment / Other Receipt and the RO rows share the JV table under another TRANSTYPE.
|
|
6
|
-
* Dept / Project / Person travel as codes, as the desktop's recurring and json2fina carry them.
|
|
7
|
-
*/
|
|
8
|
-
import jvWrite from "./jvWrite.js";
|
|
9
|
-
|
|
10
|
-
const money = (v) => (v === null || v === undefined || v === "" ? null : Number(v));
|
|
11
|
-
|
|
12
|
-
export default {
|
|
13
|
-
name: "jv",
|
|
14
|
-
label: "Journal Voucher",
|
|
15
|
-
rights: "jv",
|
|
16
|
-
templateType: 14,
|
|
17
|
-
/* Doc 8 M6 (F-34): a JV has attachments, kept in Biz-A (engine/fina/attachments.js). */
|
|
18
|
-
attachments: true,
|
|
19
|
-
/*
|
|
20
|
-
* Doc 8 M5a — written from Biz-A through FINA's TdmJV (contract §2.1, §2.1a). The rules, the payload and the version
|
|
21
|
-
* are jvWrite's; documentWrite.js runs them in order (right → approval → current → rules → lock → period → write).
|
|
22
|
-
*/
|
|
23
|
-
write: {
|
|
24
|
-
dataModule: "TdmJV",
|
|
25
|
-
transType: "GLJV",
|
|
26
|
-
...jvWrite,
|
|
27
|
-
/* A-01: with Need Approval on, a JV reaches FINA only through its approval (M7). */
|
|
28
|
-
needsApproval: (settings) => !!settings?.jv?.needApproval,
|
|
29
|
-
/* V17: a reconciled line blocks an edit and a delete (M5b). */
|
|
30
|
-
locked: async (client, id, verb = "edited") =>
|
|
31
|
-
((await client.executeSql(`SELECT SEQ FROM JVDET WHERE JVID = ${id} AND RECONCILEID IS NOT NULL`)) ?? []).map(
|
|
32
|
-
(r) => `Line ${Number(r.seq) + 1} is reconciled in FINA: this JV cannot be ${verb}.`,
|
|
33
|
-
),
|
|
34
|
-
/* T-27: the response carries no number; FINA's own totals are compared with what was sent. */
|
|
35
|
-
readBack: async (client, id, lines) => {
|
|
36
|
-
const head = (await client.executeSql(`SELECT JVNUMBER, JVAMOUNT FROM JV WHERE JVID = ${id}`))?.[0] ?? {};
|
|
37
|
-
const det = (await client.executeSql(`SELECT COUNT(*) AS N, SUM(GLAMOUNT) AS S FROM JVDET WHERE JVID = ${id}`))?.[0] ?? {};
|
|
38
|
-
const warnings = [];
|
|
39
|
-
if (Number(det.n) !== lines.length) warnings.push(`FINA keeps ${Number(det.n)} lines for this JV; ${lines.length} were sent.`);
|
|
40
|
-
if (Math.abs(Number(det.s ?? 0)) > 0.0005) warnings.push(`FINA's lines of this JV are out of balance by ${Math.abs(Number(det.s))}.`);
|
|
41
|
-
return { number: head.jvnumber ?? null, warnings };
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
master: {
|
|
45
|
-
table: "JV",
|
|
46
|
-
key: "JVID",
|
|
47
|
-
select: "M.JVID, M.JVNUMBER, M.TRANSDATE, M.TRANSDESCRIPTION, M.JVAMOUNT, M.TEMPLATEID, M.EXTENDEDID",
|
|
48
|
-
from: "JV M",
|
|
49
|
-
filter: "M.TRANSTYPE = 'JV'",
|
|
50
|
-
},
|
|
51
|
-
detail: {
|
|
52
|
-
select:
|
|
53
|
-
"D.SEQ, D.GLACCOUNT, A.ACCOUNTNAME, D.GLAMOUNT, D.DESCRIPTION, D.RATE, D.PRIMEAMOUNT, DP.DEPTNO, PJ.PROJECTNO, P.PERSONNO, P.NAME AS PERSONNAME, D.EXTENDEDID",
|
|
54
|
-
from:
|
|
55
|
-
"JVDET D LEFT JOIN GLACCNT A ON A.GLACCOUNT = D.GLACCOUNT LEFT JOIN DEPARTMENT DP ON DP.DEPTID = D.DEPTID " +
|
|
56
|
-
"LEFT JOIN PROJECT PJ ON PJ.PROJECTID = D.PROJECTID LEFT JOIN PERSONDATA P ON P.ID = D.SUBSIDIARY",
|
|
57
|
-
key: "D.JVID",
|
|
58
|
-
order: "D.SEQ",
|
|
59
|
-
/* The desktop shows a JV line as Debit / Credit; FINA stores one signed GLAMOUNT. */
|
|
60
|
-
line: (row) => {
|
|
61
|
-
const amount = money(row.glamount);
|
|
62
|
-
return { ...row, debit: amount !== null && amount > 0 ? amount : null, credit: amount !== null && amount < 0 ? -amount : null };
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
/* EXTENDEDMAPPING.MAPTABLENAME of the master and of a line (T-14, T-15). */
|
|
66
|
-
extended: { master: "JV", detail: "JVDET" },
|
|
67
|
-
print: {
|
|
68
|
-
/* `JVID` drives the report's queries; the totals are template variables that call the desktop form's functions,
|
|
69
|
-
which the API does not have — so they are passed as values (contract §3). */
|
|
70
|
-
dictionary: async ({ client, id }) => {
|
|
71
|
-
const totals =
|
|
72
|
-
(await client.executeSql(
|
|
73
|
-
`SELECT SUM(CASE WHEN GLAMOUNT > 0 THEN GLAMOUNT ELSE 0 END) AS DEBIT, SUM(CASE WHEN GLAMOUNT < 0 THEN -GLAMOUNT ELSE 0 END) AS CREDIT FROM JVDET WHERE JVID = ${id}`,
|
|
74
|
-
))?.[0] ?? {};
|
|
75
|
-
return [
|
|
76
|
-
{ param: "JVID", value: String(id) },
|
|
77
|
-
{ param: "Total Debit", value: String(money(totals.debit) ?? 0) },
|
|
78
|
-
{ param: "Total Credit", value: String(money(totals.credit) ?? 0) },
|
|
79
|
-
];
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
/*
|
|
83
|
-
* FINA desktop's `TJVRecurring` (RecurringUtility.pas): TABLENAME JVDET, the primary field GLACCOUNT, and the
|
|
84
|
-
* exception list it stores. Biz-A writes GLAMOUNT, never the desktop's Debit / Credit (replaying those can zero a
|
|
85
|
-
* line, appendix F-03 §1.3); it reads either. Mapped extended fields follow as `jvdet<field>` (lower case).
|
|
86
|
-
*/
|
|
87
|
-
recurring: {
|
|
88
|
-
class: "TJVRecurring",
|
|
89
|
-
tableName: "JVDET",
|
|
90
|
-
exception: "EXTENDEDID|JVID|SEQ|ProjectID|DeptID|SUBSIDIARY|EXTENDEDID|ReconcileID|FixAssetID|POSTED",
|
|
91
|
-
extendedPrefix: "jvdet",
|
|
92
|
-
fields: [
|
|
93
|
-
{ name: "GLACCOUNT", key: "glaccount", type: "string" },
|
|
94
|
-
{ name: "GLAMOUNT", key: "glamount", type: "money" },
|
|
95
|
-
{ name: "DESCRIPTION", key: "description", type: "string" },
|
|
96
|
-
{ name: "RATE", key: "rate", type: "money" },
|
|
97
|
-
{ name: "PRIMEAMOUNT", key: "primeamount", type: "money" },
|
|
98
|
-
{ name: "DeptNo", key: "deptno", type: "string" },
|
|
99
|
-
{ name: "ProjectNo", key: "projectno", type: "string" },
|
|
100
|
-
{ name: "PersonNo", key: "personno", type: "string" },
|
|
101
|
-
],
|
|
102
|
-
/* A desktop row with Debit / Credit and no GLAMOUNT. */
|
|
103
|
-
amountFrom: { key: "glamount", debit: "DEBIT", credit: "CREDIT" },
|
|
104
|
-
},
|
|
105
|
-
};
|