@things-factory/erp-systemever 10.1.117 → 10.1.118
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 +44 -0
- package/dist-server/index.d.ts +8 -0
- package/dist-server/index.js +9 -4
- package/dist-server/index.js.map +1 -1
- package/dist-server/systemever-adapter.js +12 -20
- package/dist-server/systemever-adapter.js.map +1 -1
- package/dist-server/systemever-connector.d.ts +1 -0
- package/dist-server/systemever-connector.js +88 -0
- package/dist-server/systemever-connector.js.map +1 -0
- package/dist-server/systemever-query-policy.d.ts +30 -0
- package/dist-server/systemever-query-policy.js +37 -0
- package/dist-server/systemever-query-policy.js.map +1 -0
- package/dist-server/systemever-query-response.d.ts +2 -0
- package/dist-server/systemever-query-response.js +26 -0
- package/dist-server/systemever-query-response.js.map +1 -0
- package/dist-server/systemever-query-runner.d.ts +16 -0
- package/dist-server/systemever-query-runner.js +90 -0
- package/dist-server/systemever-query-runner.js.map +1 -0
- package/dist-server/systemever-query-task.d.ts +7 -0
- package/dist-server/systemever-query-task.js +65 -0
- package/dist-server/systemever-query-task.js.map +1 -0
- package/dist-server/systemever-schedule-task.d.ts +1 -0
- package/dist-server/systemever-schedule-task.js +35 -0
- package/dist-server/systemever-schedule-task.js.map +1 -0
- package/dist-server/systemever-work-order-query.d.ts +14 -0
- package/dist-server/systemever-work-order-query.js +157 -0
- package/dist-server/systemever-work-order-query.js.map +1 -0
- package/dist-server/systemever-work-order.d.ts +33 -0
- package/dist-server/systemever-work-order.js +160 -0
- package/dist-server/systemever-work-order.js.map +1 -0
- package/logs/.5e5d741d8b7784a2fbad65eedc0fd46946aaf6f2-audit.json +15 -0
- package/logs/connections-2026-10-05-11.log +0 -0
- package/package.json +5 -3
- package/server/index.ts +8 -0
- package/server/systemever-connector.ts +88 -0
- package/server/systemever-query-policy.ts +47 -0
- package/server/systemever-query-response.ts +29 -0
- package/server/systemever-query-runner.ts +79 -0
- package/server/systemever-query-task.ts +62 -0
- package/server/systemever-schedule-task.ts +37 -0
- package/server/systemever-work-order-query.ts +157 -0
- package/server/systemever-work-order.ts +184 -0
- package/test/fixtures/work-order-query-request.json +153 -0
- package/test/fixtures/work-order-query.json +239 -0
- package/test/systemever-connector.test.ts +84 -0
- package/test/systemever-query-policy.test.ts +41 -0
- package/test/systemever-query-response.test.ts +37 -0
- package/test/systemever-query-runner.test.ts +73 -0
- package/test/systemever-query-task.test.ts +106 -0
- package/test/systemever-schedule-task.test.ts +46 -0
- package/test/systemever-work-order.test.ts +101 -0
- package/tsconfig.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SystemEver 조회 태스크
|
|
2
|
+
|
|
3
|
+
`systemever-query`는 작업지시조회 화면만 지원한다. 로그인된 브라우저에서 SystemEver의 `Home.OpenPageSeq`, `Const.Message`, `Util.Ajax`를 사용한다. 화면 번호, 서비스 번호, 조회 메서드는 패키지가 고정한다. 저장·삭제·등록 메서드는 없다.
|
|
4
|
+
|
|
5
|
+
## 연결과 입력
|
|
6
|
+
|
|
7
|
+
연결 종류는 `systemever-connector`, 주소는 `https://mfg.systemevererp.com/`이다. 로그인 폼의 `usernameSelector`, `passwordSelector`, `submitSelector`는 실제 폼에 맞게 연결에 설정해야 한다. 로그인은 실제 브라우저 입력과 클릭으로 수행하며 초기 실패 후 한 번만 재시도한다.
|
|
8
|
+
|
|
9
|
+
계정은 해당 도메인 또는 가장 가까운 상위 도메인의 활성 EnvVar에서 읽는다. 연결 params에 계정 값을 저장하지 않는다.
|
|
10
|
+
|
|
11
|
+
- `ErpConnection::systemever::username`
|
|
12
|
+
- `ErpConnection::systemever::password`
|
|
13
|
+
|
|
14
|
+
시나리오 단계 예:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"type": "systemever-query",
|
|
19
|
+
"connection": "systemever",
|
|
20
|
+
"params": {
|
|
21
|
+
"screen": "work-order",
|
|
22
|
+
"dateFrom": "2026-10-05",
|
|
23
|
+
"dateTo": "2026-10-12",
|
|
24
|
+
"bizUnit": "1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
날짜는 `YYYY-MM-DD`, 사업단위는 숫자로 된 문자열이다. 다른 입력 키와 미지원 화면은 브라우저를 확보하기 전에 거절한다. 반환 `data`는 응답 `DataBlock1`의 열 이름을 키로 하는 행 배열이다. 명시적인 0행과 누락된 응답 블록을 구분하고, 기존 작업지시 매핑이 사용하는 필수 열을 검사한다. 다음 단계에서 `toScheduleEntries(rows, { timeZone })`로 계약 레코드로 변환해야 한다.
|
|
30
|
+
|
|
31
|
+
쿠키는 도메인별 연결 인스턴스가 재사용한다. 연결의 세션 잠금 안에서 조회하고 페이지와 풀 자원은 반환한다. 세션 만료를 확인하면 재인증 후 조회를 한 번 더 시도한다. 브라우저 콘솔, 원문 예외, 로그인 실패 화면은 기록하지 않는다.
|
|
32
|
+
|
|
33
|
+
## 확인한 범위
|
|
34
|
+
|
|
35
|
+
2026-10-05의 실제 조회 HAR에서 요청 조건 41개와 `Query(1)` 응답(97개 열, 0행)을 확보했다. 요청 예제 [work-order-query-request.json](test/fixtures/work-order-query-request.json)은 세션 번호·토큰·쿠키를 제거한 것이다. 기존 [work-order-query.json](test/fixtures/work-order-query.json)은 응답 예제다.
|
|
36
|
+
|
|
37
|
+
인계 문서에 따른 `ConfirmQuery(6) → Query(1)` 순서를 구현했지만 이번 HAR에는 6번 호출이 없다. 자동 로그인과 이 순서의 실서버 실행은 아직 검증하지 않았다. 품목·BOM·공정·작업장과 실적 조회도 아직 허용 목록에 없다. 예약 실행 및 plant 적재는 별도 후속 작업이다.
|
|
38
|
+
|
|
39
|
+
검증 명령:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npm test
|
|
43
|
+
npm run build:server
|
|
44
|
+
```
|
package/dist-server/index.d.ts
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
1
|
export * from './systemever-adapter.js';
|
|
2
|
+
export * from './systemever-work-order.js';
|
|
3
|
+
export * from './systemever-query-policy.js';
|
|
4
|
+
export * from './systemever-query-response.js';
|
|
5
|
+
export * from './systemever-work-order-query.js';
|
|
6
|
+
export * from './systemever-query-runner.js';
|
|
7
|
+
import './systemever-connector.js';
|
|
8
|
+
import './systemever-query-task.js';
|
|
9
|
+
import './systemever-schedule-task.js';
|
package/dist-server/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export * from './systemever-adapter.js';
|
|
2
|
+
export * from './systemever-work-order.js';
|
|
3
|
+
export * from './systemever-query-policy.js';
|
|
4
|
+
export * from './systemever-query-response.js';
|
|
5
|
+
export * from './systemever-work-order-query.js';
|
|
6
|
+
export * from './systemever-query-runner.js';
|
|
7
|
+
import './systemever-connector.js';
|
|
8
|
+
import './systemever-query-task.js';
|
|
9
|
+
import './systemever-schedule-task.js';
|
|
5
10
|
//# sourceMappingURL=index.js.map
|
package/dist-server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAA;AACvC,cAAc,4BAA4B,CAAA;AAC1C,cAAc,8BAA8B,CAAA;AAC5C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,kCAAkC,CAAA;AAChD,cAAc,8BAA8B,CAAA;AAC5C,OAAO,2BAA2B,CAAA;AAClC,OAAO,4BAA4B,CAAA;AACnC,OAAO,+BAA+B,CAAA","sourcesContent":["export * from './systemever-adapter.js'\nexport * from './systemever-work-order.js'\nexport * from './systemever-query-policy.js'\nexport * from './systemever-query-response.js'\nexport * from './systemever-work-order-query.js'\nexport * from './systemever-query-runner.js'\nimport './systemever-connector.js'\nimport './systemever-query-task.js'\nimport './systemever-schedule-task.js'\n"]}
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.systemEverAdapter = exports.SYSTEMEVER_WORK_REPORT_LAYOUT = void 0;
|
|
4
|
-
exports.localWorkDate = localWorkDate;
|
|
5
|
-
exports.workReportRow = workReportRow;
|
|
6
|
-
exports.exportProblems = exportProblems;
|
|
7
|
-
exports.exportWorkReports = exportWorkReports;
|
|
8
|
-
exports.createSystemEverAdapter = createSystemEverAdapter;
|
|
9
1
|
/*
|
|
10
2
|
* **Younglimwon SystemEver adapter** — one implementation of the contract port (`ErpAdapter`).
|
|
11
3
|
*
|
|
@@ -30,7 +22,7 @@ exports.createSystemEverAdapter = createSystemEverAdapter;
|
|
|
30
22
|
* 2026-10-01) in their order, which is the closest thing we have seen. The marker lives here, in the
|
|
31
23
|
* grounding and on the export screen — never inside `body`, because a person pastes every line of it.
|
|
32
24
|
*/
|
|
33
|
-
|
|
25
|
+
import { performanceKey, validatePerformance } from '@operato/ops-contract';
|
|
34
26
|
/**
|
|
35
27
|
* The columns of one pasted work report row, in order.
|
|
36
28
|
*
|
|
@@ -42,7 +34,7 @@ const ops_contract_1 = require("@operato/ops-contract");
|
|
|
42
34
|
* `Remark` carries our `performanceKey`. Whether the ERP keeps it is confirmed at the trial registration;
|
|
43
35
|
* until then the read-back leaves `carriedKey` empty and pairing falls back to its named rule.
|
|
44
36
|
*/
|
|
45
|
-
|
|
37
|
+
export const SYSTEMEVER_WORK_REPORT_LAYOUT = [
|
|
46
38
|
'WorkDate',
|
|
47
39
|
'WorkCenterName',
|
|
48
40
|
'WorkOrderNo',
|
|
@@ -61,7 +53,7 @@ exports.SYSTEMEVER_WORK_REPORT_LAYOUT = [
|
|
|
61
53
|
'Remark'
|
|
62
54
|
];
|
|
63
55
|
/** A local calendar day `YYYY-MM-DD` in `timeZone`. Throws on an unknown zone. */
|
|
64
|
-
function localWorkDate(iso, timeZone) {
|
|
56
|
+
export function localWorkDate(iso, timeZone) {
|
|
65
57
|
const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso));
|
|
66
58
|
const part = (type) => parts.find(p => p.type === type)?.value;
|
|
67
59
|
return `${part('year')}-${part('month')}-${part('day')}`;
|
|
@@ -72,8 +64,8 @@ function localWorkDate(iso, timeZone) {
|
|
|
72
64
|
* The work date is the day the span *starts* in the plant's zone: a night shift from 22:00 to 06:00
|
|
73
65
|
* belongs to the day it began, which is how a shift is booked.
|
|
74
66
|
*/
|
|
75
|
-
function workReportRow(entry, key, timeZone) {
|
|
76
|
-
const row = Object.fromEntries(
|
|
67
|
+
export function workReportRow(entry, key, timeZone) {
|
|
68
|
+
const row = Object.fromEntries(SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => [c, '']));
|
|
77
69
|
row.WorkDate = localWorkDate(entry.from, timeZone);
|
|
78
70
|
row.WorkOrderNo = entry.orderId;
|
|
79
71
|
row.ProdQty = String(entry.goodQuantity + entry.scrapQuantity);
|
|
@@ -87,7 +79,7 @@ function workReportRow(entry, key, timeZone) {
|
|
|
87
79
|
*
|
|
88
80
|
* Any reason refuses the whole batch (`stop`): a half-exported file is a file someone pastes.
|
|
89
81
|
*/
|
|
90
|
-
function exportProblems(cfg, entries, nowMs) {
|
|
82
|
+
export function exportProblems(cfg, entries, nowMs) {
|
|
91
83
|
const problems = [];
|
|
92
84
|
if (!entries?.length)
|
|
93
85
|
problems.push('nothing to export');
|
|
@@ -107,11 +99,11 @@ function exportProblems(cfg, entries, nowMs) {
|
|
|
107
99
|
const operations = new Map();
|
|
108
100
|
for (const { entry, key } of entries ?? []) {
|
|
109
101
|
const label = entry?.orderId || '(no order)';
|
|
110
|
-
for (const error of
|
|
102
|
+
for (const error of validatePerformance(entry, nowMs))
|
|
111
103
|
problems.push(`${label}: ${error}`);
|
|
112
104
|
let expected;
|
|
113
105
|
try {
|
|
114
|
-
expected =
|
|
106
|
+
expected = performanceKey(entry);
|
|
115
107
|
}
|
|
116
108
|
catch (e) {
|
|
117
109
|
/* validatePerformance already named what is missing. */
|
|
@@ -159,7 +151,7 @@ function fileNameOf(dates) {
|
|
|
159
151
|
* Build the paste batch: tab-separated data lines, no header line, one line per entry in the order given.
|
|
160
152
|
* `keys` follow the same order.
|
|
161
153
|
*/
|
|
162
|
-
function exportWorkReports(cfg, entries, nowMs = Date.now()) {
|
|
154
|
+
export function exportWorkReports(cfg, entries, nowMs = Date.now()) {
|
|
163
155
|
const problems = exportProblems(cfg, entries, nowMs);
|
|
164
156
|
if (problems.length)
|
|
165
157
|
return { ok: false, action: 'stop', error: problems.join('; ') };
|
|
@@ -170,13 +162,13 @@ function exportWorkReports(cfg, entries, nowMs = Date.now()) {
|
|
|
170
162
|
value: {
|
|
171
163
|
fileName: fileNameOf(rows.map(r => r.WorkDate)),
|
|
172
164
|
mediaType: 'text/tab-separated-values; charset=utf-8',
|
|
173
|
-
body: rows.map(r =>
|
|
165
|
+
body: rows.map(r => SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => r[c]).join('\t')).join('\r\n') + '\r\n',
|
|
174
166
|
keys: entries.map(({ key }) => key)
|
|
175
167
|
}
|
|
176
168
|
};
|
|
177
169
|
}
|
|
178
170
|
/** The adapter. It holds no state and opens no connection. */
|
|
179
|
-
function createSystemEverAdapter() {
|
|
171
|
+
export function createSystemEverAdapter() {
|
|
180
172
|
return {
|
|
181
173
|
type: 'systemever',
|
|
182
174
|
label: 'Younglimwon SystemEver (manual export)',
|
|
@@ -201,5 +193,5 @@ function createSystemEverAdapter() {
|
|
|
201
193
|
}
|
|
202
194
|
};
|
|
203
195
|
}
|
|
204
|
-
|
|
196
|
+
export const systemEverAdapter = createSystemEverAdapter();
|
|
205
197
|
//# sourceMappingURL=systemever-adapter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"systemever-adapter.js","sourceRoot":"","sources":["../server/systemever-adapter.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * **Younglimwon SystemEver adapter** — one implementation of the contract port (`ErpAdapter`).\n *\n * ── What kind of ERP this is (ADR-0110 decision 1) ───────────────────────\n * SystemEver has no official API and its work report screen (작업실적입력) has no file upload. A person\n * pastes rows into the sheet and saves. So this adapter is `posting: 'manual-export'`: it builds the rows,\n * it never lands anything. Lines it exports are \"exported, awaiting confirmation\" until a read-back pairs\n * them (plant's pairing function).\n *\n * Saving a work report issues the materials by BOM (every item is set to \"issue on material input\"), so\n * `materialWithPerformance: true` and there is no material door — a second door would issue twice.\n *\n * ── What it does not do (ADR-0110 addition 1) ────────────────────────────\n * It does not read. Read-back, orders and stock come from an integration scenario (headless login →\n * `/api/WebApi` query → jsonata → a plant task) in the contract's record shapes. The adapter holds the\n * declaration and `exportPerformance`, nothing else.\n *\n * ── What is not confirmed yet ────────────────────────────────────────────\n * The paste layout. Nobody has pasted into the sheet; the column order, the date format and whether the\n * remark column survives are confirmed at Miratek's trial registration (proposal 7.4). Until then\n * `grounding` stays `mock` and the layout below is the work report *query* columns (작업실적조회, read\n * 2026-10-01) in their order, which is the closest thing we have seen. The marker lives here, in the\n * grounding and on the export screen — never inside `body`, because a person pastes every line of it.\n */\nimport {\n performanceKey,\n validatePerformance,\n type ErpAdapter,\n type ErpConfig,\n type ErpExport,\n type ErpOutcome,\n type ProductionPerformanceEntry\n} from '@operato/ops-contract'\n\n/**\n * The columns of one pasted work report row, in order.\n *\n * Every name except `Remark` was seen in the 작업실적조회 response (svc 501295 / pgm 501504). `WorkReportSeq`\n * is left out — the ERP numbers its own rows. Columns we have no value for stay empty: we do not invent\n * a work centre, an item, a process or a lot the entry does not carry (SystemEver may fill them from the\n * work order; the trial registration tells).\n *\n * `Remark` carries our `performanceKey`. Whether the ERP keeps it is confirmed at the trial registration;\n * until then the read-back leaves `carriedKey` empty and pairing falls back to its named rule.\n */\nexport const SYSTEMEVER_WORK_REPORT_LAYOUT = [\n 'WorkDate',\n 'WorkCenterName',\n 'WorkOrderNo',\n 'GoodItemNo',\n 'ProcName',\n 'ProdQty',\n 'OKQty',\n 'BadQty',\n 'WorkHour',\n 'WorkerQty',\n 'RealLotNo',\n 'SerialNoFrom',\n 'SerialNoTo',\n 'IsMatInput',\n 'IsGoodIn',\n 'Remark'\n] as const\n\nexport type SystemEverWorkReportColumn = (typeof SYSTEMEVER_WORK_REPORT_LAYOUT)[number]\n\n/** Settings this adapter reads. No secrets: it never connects. */\nexport interface SystemEverConfig extends ErpConfig {\n /**\n * The plant's time zone (IANA, e.g. `Asia/Seoul`). The work date is a local calendar day; taking it from\n * UTC would put a morning shift on the previous day. Required — we do not guess where the plant is.\n */\n timeZone?: string\n}\n\n/** A local calendar day `YYYY-MM-DD` in `timeZone`. Throws on an unknown zone. */\nexport function localWorkDate(iso: string, timeZone: string): string {\n const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(\n new Date(iso)\n )\n const part = (type: string) => parts.find(p => p.type === type)?.value\n return `${part('year')}-${part('month')}-${part('day')}`\n}\n\n/**\n * One row for one entry.\n *\n * The work date is the day the span *starts* in the plant's zone: a night shift from 22:00 to 06:00\n * belongs to the day it began, which is how a shift is booked.\n */\nexport function workReportRow(\n entry: ProductionPerformanceEntry,\n key: string,\n timeZone: string\n): Record<SystemEverWorkReportColumn, string> {\n const row = Object.fromEntries(SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => [c, ''])) as Record<SystemEverWorkReportColumn, string>\n row.WorkDate = localWorkDate(entry.from, timeZone)\n row.WorkOrderNo = entry.orderId\n row.ProdQty = String(entry.goodQuantity + entry.scrapQuantity)\n row.OKQty = String(entry.goodQuantity)\n row.BadQty = String(entry.scrapQuantity)\n row.Remark = key\n return row\n}\n\n/**\n * Why this batch cannot be exported — every reason at once.\n *\n * Any reason refuses the whole batch (`stop`): a half-exported file is a file someone pastes.\n */\nexport function exportProblems(\n cfg: SystemEverConfig,\n entries: { entry: ProductionPerformanceEntry; key: string }[],\n nowMs: number\n): string[] {\n const problems: string[] = []\n\n if (!entries?.length) problems.push('nothing to export')\n\n const timeZone = String(cfg?.timeZone ?? '').trim()\n if (!timeZone) problems.push('timeZone is not set — the work date is a local day and we do not guess the plant zone')\n else {\n try {\n new Intl.DateTimeFormat('en-CA', { timeZone })\n } catch {\n problems.push(`timeZone ${timeZone} is not a known zone`)\n }\n }\n\n const seen = new Set<string>()\n /* order → the operations its lines name; `-` stands for \"no operation\". */\n const operations = new Map<string, Set<string>>()\n\n for (const { entry, key } of entries ?? []) {\n const label = entry?.orderId || '(no order)'\n\n for (const error of validatePerformance(entry, nowMs)) problems.push(`${label}: ${error}`)\n\n let expected: string | undefined\n try {\n expected = performanceKey(entry)\n } catch (e) {\n /* validatePerformance already named what is missing. */\n }\n if (!String(key ?? '').trim()) problems.push(`${label}: no key — the line cannot be paired back`)\n else if (expected !== undefined && key !== expected) {\n /* The key travels in the remark and comes back to pairing; a different spelling never pairs. */\n problems.push(`${label}: key ${key} is not this entry's performanceKey ${expected}`)\n }\n if (key && seen.has(key)) problems.push(`${label}: key ${key} appears twice — the same performance would be pasted twice`)\n if (key) seen.add(key)\n\n if (entry?.orderId) {\n const ops = operations.get(entry.orderId) ?? new Set<string>()\n ops.add(entry.operationId ?? '-')\n operations.set(entry.orderId, ops)\n }\n\n for (const value of [entry?.orderId, key]) {\n if (typeof value === 'string' && /[\\t\\r\\n]/.test(value)) {\n problems.push(`${label}: a tab or line break in ${JSON.stringify(value)} would split the pasted row`)\n }\n }\n }\n\n /*\n * A SystemEver work order is one process, so performance is booked per order. Lines of one order from\n * one operation are fine — the operation is dropped. Lines of one order from two operations would each\n * book their output and each issue the BOM: quantity and material multiplied by the operation count.\n */\n for (const [orderId, ops] of operations) {\n if (ops.size > 1) {\n problems.push(\n `${orderId}: lines from ${ops.size} operations (${[...ops].join(', ')}) — SystemEver books per work order, so they would be counted ${ops.size} times`\n )\n }\n }\n\n return problems\n}\n\nfunction fileNameOf(dates: string[]): string {\n const sorted = [...dates].sort()\n const first = sorted[0]\n const last = sorted[sorted.length - 1]\n return `systemever-work-report-${first}${last !== first ? `_${last}` : ''}.tsv`\n}\n\n/**\n * Build the paste batch: tab-separated data lines, no header line, one line per entry in the order given.\n * `keys` follow the same order.\n */\nexport function exportWorkReports(\n cfg: SystemEverConfig,\n entries: { entry: ProductionPerformanceEntry; key: string }[],\n nowMs = Date.now()\n): ErpOutcome<ErpExport> {\n const problems = exportProblems(cfg, entries, nowMs)\n if (problems.length) return { ok: false, action: 'stop', error: problems.join('; ') }\n\n const timeZone = String(cfg.timeZone).trim()\n const rows = entries.map(({ entry, key }) => workReportRow(entry, key, timeZone))\n\n return {\n ok: true,\n value: {\n fileName: fileNameOf(rows.map(r => r.WorkDate)),\n mediaType: 'text/tab-separated-values; charset=utf-8',\n body: rows.map(r => SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => r[c]).join('\\t')).join('\\r\\n') + '\\r\\n',\n keys: entries.map(({ key }) => key)\n }\n }\n}\n\n/** The adapter. It holds no state and opens no connection. */\nexport function createSystemEverAdapter(): ErpAdapter {\n return {\n type: 'systemever',\n label: 'Younglimwon SystemEver (manual export)',\n /* The paste layout is not confirmed until the trial registration (see the file head). */\n grounding: 'mock',\n capabilities: ['performance'],\n traits: {\n /* A work order is one process (its ProcName); performance is booked per order. */\n performanceGranularity: 'order',\n /* Saving a work report issues the BOM. */\n materialWithPerformance: true,\n /* A wrong line is deleted by a person on the ERP screen. */\n reversal: 'manual',\n posting: 'manual-export'\n },\n\n async probe(_cfg: ErpConfig) {\n /* Nothing to reach: the adapter only builds files. Reading SystemEver is the integration scenario. */\n return { ok: true, value: { detail: 'manual export — this adapter does not connect to SystemEver' } }\n },\n\n async exportPerformance(cfg: ErpConfig, entries: { entry: ProductionPerformanceEntry; key: string }[]) {\n return exportWorkReports(cfg as SystemEverConfig, entries)\n }\n }\n}\n\nexport const systemEverAdapter: ErpAdapter = createSystemEverAdapter()\n"]}
|
|
1
|
+
{"version":3,"file":"systemever-adapter.js","sourceRoot":"","sources":["../server/systemever-adapter.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * **Younglimwon SystemEver adapter** — one implementation of the contract port (`ErpAdapter`).\n *\n * ── What kind of ERP this is (ADR-0110 decision 1) ───────────────────────\n * SystemEver has no official API and its work report screen (작업실적입력) has no file upload. A person\n * pastes rows into the sheet and saves. So this adapter is `posting: 'manual-export'`: it builds the rows,\n * it never lands anything. Lines it exports are \"exported, awaiting confirmation\" until a read-back pairs\n * them (plant's pairing function).\n *\n * Saving a work report issues the materials by BOM (every item is set to \"issue on material input\"), so\n * `materialWithPerformance: true` and there is no material door — a second door would issue twice.\n *\n * ── What it does not do (ADR-0110 addition 1) ────────────────────────────\n * It does not read. Read-back, orders and stock come from an integration scenario (headless login →\n * `/api/WebApi` query → jsonata → a plant task) in the contract's record shapes. The adapter holds the\n * declaration and `exportPerformance`, nothing else.\n *\n * ── What is not confirmed yet ────────────────────────────────────────────\n * The paste layout. Nobody has pasted into the sheet; the column order, the date format and whether the\n * remark column survives are confirmed at Miratek's trial registration (proposal 7.4). Until then\n * `grounding` stays `mock` and the layout below is the work report *query* columns (작업실적조회, read\n * 2026-10-01) in their order, which is the closest thing we have seen. The marker lives here, in the\n * grounding and on the export screen — never inside `body`, because a person pastes every line of it.\n */\nimport {\n performanceKey,\n validatePerformance,\n type ErpAdapter,\n type ErpConfig,\n type ErpExport,\n type ErpOutcome,\n type ProductionPerformanceEntry\n} from '@operato/ops-contract'\n\n/**\n * The columns of one pasted work report row, in order.\n *\n * Every name except `Remark` was seen in the 작업실적조회 response (svc 501295 / pgm 501504). `WorkReportSeq`\n * is left out — the ERP numbers its own rows. Columns we have no value for stay empty: we do not invent\n * a work centre, an item, a process or a lot the entry does not carry (SystemEver may fill them from the\n * work order; the trial registration tells).\n *\n * `Remark` carries our `performanceKey`. Whether the ERP keeps it is confirmed at the trial registration;\n * until then the read-back leaves `carriedKey` empty and pairing falls back to its named rule.\n */\nexport const SYSTEMEVER_WORK_REPORT_LAYOUT = [\n 'WorkDate',\n 'WorkCenterName',\n 'WorkOrderNo',\n 'GoodItemNo',\n 'ProcName',\n 'ProdQty',\n 'OKQty',\n 'BadQty',\n 'WorkHour',\n 'WorkerQty',\n 'RealLotNo',\n 'SerialNoFrom',\n 'SerialNoTo',\n 'IsMatInput',\n 'IsGoodIn',\n 'Remark'\n] as const\n\nexport type SystemEverWorkReportColumn = (typeof SYSTEMEVER_WORK_REPORT_LAYOUT)[number]\n\n/** Settings this adapter reads. No secrets: it never connects. */\nexport interface SystemEverConfig extends ErpConfig {\n /**\n * The plant's time zone (IANA, e.g. `Asia/Seoul`). The work date is a local calendar day; taking it from\n * UTC would put a morning shift on the previous day. Required — we do not guess where the plant is.\n */\n timeZone?: string\n}\n\n/** A local calendar day `YYYY-MM-DD` in `timeZone`. Throws on an unknown zone. */\nexport function localWorkDate(iso: string, timeZone: string): string {\n const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(\n new Date(iso)\n )\n const part = (type: string) => parts.find(p => p.type === type)?.value\n return `${part('year')}-${part('month')}-${part('day')}`\n}\n\n/**\n * One row for one entry.\n *\n * The work date is the day the span *starts* in the plant's zone: a night shift from 22:00 to 06:00\n * belongs to the day it began, which is how a shift is booked.\n */\nexport function workReportRow(\n entry: ProductionPerformanceEntry,\n key: string,\n timeZone: string\n): Record<SystemEverWorkReportColumn, string> {\n const row = Object.fromEntries(SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => [c, ''])) as Record<SystemEverWorkReportColumn, string>\n row.WorkDate = localWorkDate(entry.from, timeZone)\n row.WorkOrderNo = entry.orderId\n row.ProdQty = String(entry.goodQuantity + entry.scrapQuantity)\n row.OKQty = String(entry.goodQuantity)\n row.BadQty = String(entry.scrapQuantity)\n row.Remark = key\n return row\n}\n\n/**\n * Why this batch cannot be exported — every reason at once.\n *\n * Any reason refuses the whole batch (`stop`): a half-exported file is a file someone pastes.\n */\nexport function exportProblems(\n cfg: SystemEverConfig,\n entries: { entry: ProductionPerformanceEntry; key: string }[],\n nowMs: number\n): string[] {\n const problems: string[] = []\n\n if (!entries?.length) problems.push('nothing to export')\n\n const timeZone = String(cfg?.timeZone ?? '').trim()\n if (!timeZone) problems.push('timeZone is not set — the work date is a local day and we do not guess the plant zone')\n else {\n try {\n new Intl.DateTimeFormat('en-CA', { timeZone })\n } catch {\n problems.push(`timeZone ${timeZone} is not a known zone`)\n }\n }\n\n const seen = new Set<string>()\n /* order → the operations its lines name; `-` stands for \"no operation\". */\n const operations = new Map<string, Set<string>>()\n\n for (const { entry, key } of entries ?? []) {\n const label = entry?.orderId || '(no order)'\n\n for (const error of validatePerformance(entry, nowMs)) problems.push(`${label}: ${error}`)\n\n let expected: string | undefined\n try {\n expected = performanceKey(entry)\n } catch (e) {\n /* validatePerformance already named what is missing. */\n }\n if (!String(key ?? '').trim()) problems.push(`${label}: no key — the line cannot be paired back`)\n else if (expected !== undefined && key !== expected) {\n /* The key travels in the remark and comes back to pairing; a different spelling never pairs. */\n problems.push(`${label}: key ${key} is not this entry's performanceKey ${expected}`)\n }\n if (key && seen.has(key)) problems.push(`${label}: key ${key} appears twice — the same performance would be pasted twice`)\n if (key) seen.add(key)\n\n if (entry?.orderId) {\n const ops = operations.get(entry.orderId) ?? new Set<string>()\n ops.add(entry.operationId ?? '-')\n operations.set(entry.orderId, ops)\n }\n\n for (const value of [entry?.orderId, key]) {\n if (typeof value === 'string' && /[\\t\\r\\n]/.test(value)) {\n problems.push(`${label}: a tab or line break in ${JSON.stringify(value)} would split the pasted row`)\n }\n }\n }\n\n /*\n * A SystemEver work order is one process, so performance is booked per order. Lines of one order from\n * one operation are fine — the operation is dropped. Lines of one order from two operations would each\n * book their output and each issue the BOM: quantity and material multiplied by the operation count.\n */\n for (const [orderId, ops] of operations) {\n if (ops.size > 1) {\n problems.push(\n `${orderId}: lines from ${ops.size} operations (${[...ops].join(', ')}) — SystemEver books per work order, so they would be counted ${ops.size} times`\n )\n }\n }\n\n return problems\n}\n\nfunction fileNameOf(dates: string[]): string {\n const sorted = [...dates].sort()\n const first = sorted[0]\n const last = sorted[sorted.length - 1]\n return `systemever-work-report-${first}${last !== first ? `_${last}` : ''}.tsv`\n}\n\n/**\n * Build the paste batch: tab-separated data lines, no header line, one line per entry in the order given.\n * `keys` follow the same order.\n */\nexport function exportWorkReports(\n cfg: SystemEverConfig,\n entries: { entry: ProductionPerformanceEntry; key: string }[],\n nowMs = Date.now()\n): ErpOutcome<ErpExport> {\n const problems = exportProblems(cfg, entries, nowMs)\n if (problems.length) return { ok: false, action: 'stop', error: problems.join('; ') }\n\n const timeZone = String(cfg.timeZone).trim()\n const rows = entries.map(({ entry, key }) => workReportRow(entry, key, timeZone))\n\n return {\n ok: true,\n value: {\n fileName: fileNameOf(rows.map(r => r.WorkDate)),\n mediaType: 'text/tab-separated-values; charset=utf-8',\n body: rows.map(r => SYSTEMEVER_WORK_REPORT_LAYOUT.map(c => r[c]).join('\\t')).join('\\r\\n') + '\\r\\n',\n keys: entries.map(({ key }) => key)\n }\n }\n}\n\n/** The adapter. It holds no state and opens no connection. */\nexport function createSystemEverAdapter(): ErpAdapter {\n return {\n type: 'systemever',\n label: 'Younglimwon SystemEver (manual export)',\n /* The paste layout is not confirmed until the trial registration (see the file head). */\n grounding: 'mock',\n capabilities: ['performance'],\n traits: {\n /* A work order is one process (its ProcName); performance is booked per order. */\n performanceGranularity: 'order',\n /* Saving a work report issues the BOM. */\n materialWithPerformance: true,\n /* A wrong line is deleted by a person on the ERP screen. */\n reversal: 'manual',\n posting: 'manual-export'\n },\n\n async probe(_cfg: ErpConfig) {\n /* Nothing to reach: the adapter only builds files. Reading SystemEver is the integration scenario. */\n return { ok: true, value: { detail: 'manual export — this adapter does not connect to SystemEver' } }\n },\n\n async exportPerformance(cfg: ErpConfig, entries: { entry: ProductionPerformanceEntry; key: string }[]) {\n return exportWorkReports(cfg as SystemEverConfig, entries)\n }\n }\n}\n\nexport const systemEverAdapter: ErpAdapter = createSystemEverAdapter()\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import integration from '@things-factory/integration-base';
|
|
2
|
+
import { SystemEverQueryError } from './systemever-query-policy.js';
|
|
3
|
+
const { ConnectionManager, HeadlessConnector, lookupDomainEnvVar } = integration;
|
|
4
|
+
/** Domain-isolated cookie reuse; one initial login and at most one login retry. */
|
|
5
|
+
class SystemEverConnector extends HeadlessConnector {
|
|
6
|
+
get taskPrefixes() { return ['systemever-query']; }
|
|
7
|
+
get description() { return 'SystemEver read-only browser connection'; }
|
|
8
|
+
get parameterSpec() {
|
|
9
|
+
return super.parameterSpec.filter(parameter => ![
|
|
10
|
+
'username', 'password', 'loginPagePath', 'loginApiUrl', 'forceLogin', 'retries'
|
|
11
|
+
].includes(parameter.name));
|
|
12
|
+
}
|
|
13
|
+
async connect(connection) {
|
|
14
|
+
try {
|
|
15
|
+
const endpoint = new URL(connection.endpoint);
|
|
16
|
+
if (endpoint.origin !== 'https://mfg.systemevererp.com' || endpoint.username || endpoint.password ||
|
|
17
|
+
endpoint.pathname !== '/' || endpoint.search || endpoint.hash) {
|
|
18
|
+
throw new SystemEverQueryError('systemever-query-invalid-connection');
|
|
19
|
+
}
|
|
20
|
+
const [username, password] = await Promise.all([
|
|
21
|
+
lookupDomainEnvVar(connection.domain, 'ErpConnection::systemever::username'),
|
|
22
|
+
lookupDomainEnvVar(connection.domain, 'ErpConnection::systemever::password')
|
|
23
|
+
]);
|
|
24
|
+
if (!username || !password) {
|
|
25
|
+
throw new SystemEverQueryError('systemever-query-missing-credentials');
|
|
26
|
+
}
|
|
27
|
+
connection.params = { ...connection.params, username, password, retries: 2, forceLogin: false,
|
|
28
|
+
loginPagePath: '/Home/Oauth2OpenIdSignIn', loginApiUrl: null };
|
|
29
|
+
await super.connect(connection);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof SystemEverQueryError)
|
|
33
|
+
throw error;
|
|
34
|
+
throw new SystemEverQueryError('systemever-query-connection-failed');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
isCookieValid(cookies) {
|
|
38
|
+
const now = Date.now() / 1000;
|
|
39
|
+
return Array.isArray(cookies) && cookies.some(cookie => cookie.expires === -1 || cookie.expires > now);
|
|
40
|
+
}
|
|
41
|
+
async setupPage(page, uri, timeout) {
|
|
42
|
+
await super.setupPage(page, uri, timeout);
|
|
43
|
+
// Vendor console/request messages may contain session values. Do not forward them to logs.
|
|
44
|
+
page.removeAllListeners('console');
|
|
45
|
+
page.removeAllListeners('requestfailed');
|
|
46
|
+
}
|
|
47
|
+
async applyCookiesAndVerifySession(page, cookies, loginInfo) {
|
|
48
|
+
await page.setCookie(...cookies);
|
|
49
|
+
await page.reload({ waitUntil: 'networkidle2', timeout: loginInfo.timeout });
|
|
50
|
+
const loggedIn = await page.evaluate(() => !!window.Home?._base?.ConnInfo?.LoginSeq);
|
|
51
|
+
if (!loggedIn)
|
|
52
|
+
await this.performLogin(page, '', loginInfo);
|
|
53
|
+
}
|
|
54
|
+
async performLogin(page, _uri, loginInfo) {
|
|
55
|
+
if (!loginInfo.username || !loginInfo.password) {
|
|
56
|
+
throw new SystemEverQueryError('systemever-query-missing-credentials');
|
|
57
|
+
}
|
|
58
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
await page.goto('https://mfg.systemevererp.com/Home/Oauth2OpenIdSignIn', {
|
|
61
|
+
waitUntil: 'networkidle2', timeout: loginInfo.timeout
|
|
62
|
+
});
|
|
63
|
+
if (new URL(page.url()).origin !== 'https://mfg.systemevererp.com') {
|
|
64
|
+
throw new SystemEverQueryError('systemever-query-login-origin-mismatch');
|
|
65
|
+
}
|
|
66
|
+
const selectors = loginInfo.loginSelectors;
|
|
67
|
+
const username = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.usernameSelector);
|
|
68
|
+
const password = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.passwordSelector);
|
|
69
|
+
const submit = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.submitSelector);
|
|
70
|
+
if (!username || !password || !submit)
|
|
71
|
+
throw new Error('login-form-missing');
|
|
72
|
+
await username.type(loginInfo.username);
|
|
73
|
+
await password.type(loginInfo.password);
|
|
74
|
+
await submit.click();
|
|
75
|
+
await page.waitForFunction(() => !!window.Home?._base?.ConnInfo?.LoginSeq, {
|
|
76
|
+
timeout: loginInfo.timeout
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
if (attempt === 1)
|
|
82
|
+
throw new SystemEverQueryError('systemever-query-login-failed');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ConnectionManager.registerConnector('systemever-connector', new SystemEverConnector());
|
|
88
|
+
//# sourceMappingURL=systemever-connector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"systemever-connector.js","sourceRoot":"","sources":["../server/systemever-connector.ts"],"names":[],"mappings":"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","sourcesContent":["import integration from '@things-factory/integration-base'\nimport { SystemEverQueryError } from './systemever-query-policy.js'\nconst { ConnectionManager, HeadlessConnector, lookupDomainEnvVar } = integration\n\n/** Domain-isolated cookie reuse; one initial login and at most one login retry. */\nclass SystemEverConnector extends HeadlessConnector {\n get taskPrefixes() { return ['systemever-query'] }\n get description() { return 'SystemEver read-only browser connection' }\n get parameterSpec() {\n return super.parameterSpec.filter(parameter => ![\n 'username', 'password', 'loginPagePath', 'loginApiUrl', 'forceLogin', 'retries'\n ].includes(parameter.name))\n }\n\n async connect(connection: any) {\n try {\n const endpoint = new URL(connection.endpoint)\n if (endpoint.origin !== 'https://mfg.systemevererp.com' || endpoint.username || endpoint.password ||\n endpoint.pathname !== '/' || endpoint.search || endpoint.hash) {\n throw new SystemEverQueryError('systemever-query-invalid-connection')\n }\n const [username, password] = await Promise.all([\n lookupDomainEnvVar(connection.domain, 'ErpConnection::systemever::username'),\n lookupDomainEnvVar(connection.domain, 'ErpConnection::systemever::password')\n ])\n if (!username || !password) {\n throw new SystemEverQueryError('systemever-query-missing-credentials')\n }\n connection.params = { ...connection.params, username, password, retries: 2, forceLogin: false,\n loginPagePath: '/Home/Oauth2OpenIdSignIn', loginApiUrl: null }\n await super.connect(connection)\n } catch (error) {\n if (error instanceof SystemEverQueryError) throw error\n throw new SystemEverQueryError('systemever-query-connection-failed')\n }\n }\n\n protected isCookieValid(cookies: any[]) {\n const now = Date.now() / 1000\n return Array.isArray(cookies) && cookies.some(cookie => cookie.expires === -1 || cookie.expires > now)\n }\n\n async setupPage(page: any, uri: string, timeout: number) {\n await super.setupPage(page, uri, timeout)\n // Vendor console/request messages may contain session values. Do not forward them to logs.\n page.removeAllListeners('console')\n page.removeAllListeners('requestfailed')\n }\n\n async applyCookiesAndVerifySession(page: any, cookies: any[], loginInfo: any) {\n await page.setCookie(...cookies)\n await page.reload({ waitUntil: 'networkidle2', timeout: loginInfo.timeout })\n const loggedIn = await page.evaluate(() => !!(window as any).Home?._base?.ConnInfo?.LoginSeq)\n if (!loggedIn) await this.performLogin(page, '', loginInfo)\n }\n\n async performLogin(page: any, _uri: string, loginInfo: any) {\n if (!loginInfo.username || !loginInfo.password) {\n throw new SystemEverQueryError('systemever-query-missing-credentials')\n }\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n await page.goto('https://mfg.systemevererp.com/Home/Oauth2OpenIdSignIn', {\n waitUntil: 'networkidle2', timeout: loginInfo.timeout\n })\n if (new URL(page.url()).origin !== 'https://mfg.systemevererp.com') {\n throw new SystemEverQueryError('systemever-query-login-origin-mismatch')\n }\n const selectors = loginInfo.loginSelectors\n const username = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.usernameSelector)\n const password = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.passwordSelector)\n const submit = await this.resolveShadowDom(page, selectors.shadowDomSelectors, selectors.submitSelector)\n if (!username || !password || !submit) throw new Error('login-form-missing')\n await username.type(loginInfo.username)\n await password.type(loginInfo.password)\n await submit.click()\n await page.waitForFunction(() => !!(window as any).Home?._base?.ConnInfo?.LoginSeq, {\n timeout: loginInfo.timeout\n })\n return\n } catch {\n if (attempt === 1) throw new SystemEverQueryError('systemever-query-login-failed')\n }\n }\n }\n}\n\nConnectionManager.registerConnector('systemever-connector', new SystemEverConnector())\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** ADR-0110: only verified read methods belong in the headless query task. */
|
|
2
|
+
declare const screens: Readonly<{
|
|
3
|
+
'work-order': Readonly<{
|
|
4
|
+
pgmSeq: 500880;
|
|
5
|
+
serviceSeq: 501041;
|
|
6
|
+
methods: readonly (Readonly<{
|
|
7
|
+
name: "ConfirmQuery";
|
|
8
|
+
methodSeq: 6;
|
|
9
|
+
}> | Readonly<{
|
|
10
|
+
name: "Query";
|
|
11
|
+
methodSeq: 1;
|
|
12
|
+
}>)[];
|
|
13
|
+
}>;
|
|
14
|
+
}>;
|
|
15
|
+
export declare class SystemEverQueryError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
constructor(code: string, cause?: unknown);
|
|
18
|
+
}
|
|
19
|
+
export interface SystemEverQueryPlan {
|
|
20
|
+
screen: keyof typeof screens;
|
|
21
|
+
pgmSeq: number;
|
|
22
|
+
serviceSeq: number;
|
|
23
|
+
methods: readonly {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly methodSeq: number;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
/** No caller-provided service, method, URL or executable code is accepted. */
|
|
29
|
+
export declare function planSystemEverQuery(input: unknown): SystemEverQueryPlan;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** ADR-0110: only verified read methods belong in the headless query task. */
|
|
2
|
+
const screens = Object.freeze({
|
|
3
|
+
'work-order': Object.freeze({
|
|
4
|
+
pgmSeq: 500880,
|
|
5
|
+
serviceSeq: 501041,
|
|
6
|
+
methods: Object.freeze([
|
|
7
|
+
Object.freeze({ name: 'ConfirmQuery', methodSeq: 6 }),
|
|
8
|
+
Object.freeze({ name: 'Query', methodSeq: 1 })
|
|
9
|
+
])
|
|
10
|
+
})
|
|
11
|
+
});
|
|
12
|
+
export class SystemEverQueryError extends Error {
|
|
13
|
+
constructor(code, cause) {
|
|
14
|
+
// Do not include input values: they may contain credentials or script text. A cause is named, not quoted —
|
|
15
|
+
// its message may carry the same values — and kept whole on `cause` for whoever reads the log.
|
|
16
|
+
const named = cause && typeof cause === 'object' ? String(cause.code || cause.name || '') : '';
|
|
17
|
+
super(named ? `${code} (cause: ${named})` : code, cause === undefined ? undefined : { cause });
|
|
18
|
+
this.name = 'SystemEverQueryError';
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** No caller-provided service, method, URL or executable code is accepted. */
|
|
23
|
+
export function planSystemEverQuery(input) {
|
|
24
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
25
|
+
throw new SystemEverQueryError('systemever-query-invalid-params');
|
|
26
|
+
}
|
|
27
|
+
const params = input;
|
|
28
|
+
if (Object.keys(params).some(key => key !== 'screen')) {
|
|
29
|
+
throw new SystemEverQueryError('systemever-query-unsupported-param');
|
|
30
|
+
}
|
|
31
|
+
if (typeof params.screen !== 'string' || !Object.hasOwn(screens, params.screen)) {
|
|
32
|
+
throw new SystemEverQueryError('systemever-query-unknown-screen');
|
|
33
|
+
}
|
|
34
|
+
const screen = params.screen;
|
|
35
|
+
return Object.freeze({ screen, ...screens[screen] });
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=systemever-query-policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"systemever-query-policy.js","sourceRoot":"","sources":["../server/systemever-query-policy.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC;QAC1B,MAAM,EAAE,MAAM;QACd,UAAU,EAAE,MAAM;QAClB,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC;YACrB,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;YACrD,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;SAC/C,CAAC;KACH,CAAC;CACH,CAAC,CAAA;AAEF,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAG7C,YAAY,IAAY,EAAE,KAAe;QACvC,2GAA2G;QAC3G,+FAA+F;QAC/F,MAAM,KAAK,GAAG,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAE,KAAa,CAAC,IAAI,IAAK,KAAa,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;QAChH,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,YAAY,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAA;QAC9F,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAA;QAClC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;IAClB,CAAC;CACF;AASD,8EAA8E;AAC9E,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,oBAAoB,CAAC,iCAAiC,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,MAAM,GAAG,KAAgC,CAAA;IAC/C,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,QAAQ,CAAC,EAAE,CAAC;QACtD,MAAM,IAAI,oBAAoB,CAAC,oCAAoC,CAAC,CAAA;IACtE,CAAC;IACD,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;QAChF,MAAM,IAAI,oBAAoB,CAAC,iCAAiC,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAA8B,CAAA;IACpD,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AACtD,CAAC","sourcesContent":["/** ADR-0110: only verified read methods belong in the headless query task. */\nconst screens = Object.freeze({\n 'work-order': Object.freeze({\n pgmSeq: 500880,\n serviceSeq: 501041,\n methods: Object.freeze([\n Object.freeze({ name: 'ConfirmQuery', methodSeq: 6 }),\n Object.freeze({ name: 'Query', methodSeq: 1 })\n ])\n })\n})\n\nexport class SystemEverQueryError extends Error {\n readonly code: string\n\n constructor(code: string, cause?: unknown) {\n // Do not include input values: they may contain credentials or script text. A cause is named, not quoted —\n // its message may carry the same values — and kept whole on `cause` for whoever reads the log.\n const named = cause && typeof cause === 'object' ? String((cause as any).code || (cause as any).name || '') : ''\n super(named ? `${code} (cause: ${named})` : code, cause === undefined ? undefined : { cause })\n this.name = 'SystemEverQueryError'\n this.code = code\n }\n}\n\nexport interface SystemEverQueryPlan {\n screen: keyof typeof screens\n pgmSeq: number\n serviceSeq: number\n methods: readonly { readonly name: string; readonly methodSeq: number }[]\n}\n\n/** No caller-provided service, method, URL or executable code is accepted. */\nexport function planSystemEverQuery(input: unknown): SystemEverQueryPlan {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n throw new SystemEverQueryError('systemever-query-invalid-params')\n }\n const params = input as Record<string, unknown>\n if (Object.keys(params).some(key => key !== 'screen')) {\n throw new SystemEverQueryError('systemever-query-unsupported-param')\n }\n if (typeof params.screen !== 'string' || !Object.hasOwn(screens, params.screen)) {\n throw new SystemEverQueryError('systemever-query-unknown-screen')\n }\n const screen = params.screen as keyof typeof screens\n return Object.freeze({ screen, ...screens[screen] })\n}\n"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SystemEverQueryError } from './systemever-query-policy.js';
|
|
2
|
+
/** Decode a single named query block; never interpret a missing block as an empty result. */
|
|
3
|
+
export function decodeSystemEverQueryBlock(response, tableName, requiredColumns) {
|
|
4
|
+
const tables = response?.JSonData?.Tables;
|
|
5
|
+
if (!Array.isArray(tables))
|
|
6
|
+
throw new SystemEverQueryError('systemever-query-invalid-response');
|
|
7
|
+
const matches = tables.filter(table => table?.TableName === tableName);
|
|
8
|
+
if (matches.length !== 1)
|
|
9
|
+
throw new SystemEverQueryError('systemever-query-block-mismatch');
|
|
10
|
+
const { Columns: columns, Rows: rows } = matches[0];
|
|
11
|
+
if (!Array.isArray(columns) ||
|
|
12
|
+
columns.some(column => typeof column !== 'string' || !column) ||
|
|
13
|
+
new Set(columns).size !== columns.length ||
|
|
14
|
+
!Array.isArray(rows))
|
|
15
|
+
throw new SystemEverQueryError('systemever-query-invalid-table');
|
|
16
|
+
if (requiredColumns.some(column => !columns.includes(column))) {
|
|
17
|
+
throw new SystemEverQueryError('systemever-query-missing-columns');
|
|
18
|
+
}
|
|
19
|
+
return rows.map(row => {
|
|
20
|
+
if (!Array.isArray(row) || row.length !== columns.length) {
|
|
21
|
+
throw new SystemEverQueryError('systemever-query-invalid-row');
|
|
22
|
+
}
|
|
23
|
+
return Object.fromEntries(columns.map((column, index) => [column, row[index]]));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=systemever-query-response.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"systemever-query-response.js","sourceRoot":"","sources":["../server/systemever-query-response.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAA;AAEnE,6FAA6F;AAC7F,MAAM,UAAU,0BAA0B,CACxC,QAAiB,EACjB,SAAiB,EACjB,eAAkC;IAElC,MAAM,MAAM,GAAI,QAAgB,EAAE,QAAQ,EAAE,MAAM,CAAA;IAClD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,MAAM,IAAI,oBAAoB,CAAC,mCAAmC,CAAC,CAAA;IAC/F,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,SAAS,CAAC,CAAA;IACtE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,oBAAoB,CAAC,iCAAiC,CAAC,CAAA;IAC3F,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IACnD,IACE,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QACvB,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC;QAC7D,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,MAAM;QACxC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QACpB,MAAM,IAAI,oBAAoB,CAAC,gCAAgC,CAAC,CAAA;IAClE,IAAI,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,oBAAoB,CAAC,kCAAkC,CAAC,CAAA;IACpE,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE;QACpB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC;YACzD,MAAM,IAAI,oBAAoB,CAAC,8BAA8B,CAAC,CAAA;QAChE,CAAC;QACD,OAAO,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IACjF,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import { SystemEverQueryError } from './systemever-query-policy.js'\n\n/** Decode a single named query block; never interpret a missing block as an empty result. */\nexport function decodeSystemEverQueryBlock(\n response: unknown,\n tableName: string,\n requiredColumns: readonly string[]\n): Record<string, unknown>[] {\n const tables = (response as any)?.JSonData?.Tables\n if (!Array.isArray(tables)) throw new SystemEverQueryError('systemever-query-invalid-response')\n const matches = tables.filter(table => table?.TableName === tableName)\n if (matches.length !== 1) throw new SystemEverQueryError('systemever-query-block-mismatch')\n const { Columns: columns, Rows: rows } = matches[0]\n if (\n !Array.isArray(columns) ||\n columns.some(column => typeof column !== 'string' || !column) ||\n new Set(columns).size !== columns.length ||\n !Array.isArray(rows)\n ) throw new SystemEverQueryError('systemever-query-invalid-table')\n if (requiredColumns.some(column => !columns.includes(column))) {\n throw new SystemEverQueryError('systemever-query-missing-columns')\n }\n return rows.map(row => {\n if (!Array.isArray(row) || row.length !== columns.length) {\n throw new SystemEverQueryError('systemever-query-invalid-row')\n }\n return Object.fromEntries(columns.map((column, index) => [column, row[index]]))\n })\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Reject invalid task inputs before acquiring a browser or authenticating. */
|
|
2
|
+
export declare function prepareSystemEverQuery(input: unknown): {
|
|
3
|
+
plan: import("./systemever-query-policy.js").SystemEverQueryPlan;
|
|
4
|
+
data: {
|
|
5
|
+
Tables: {
|
|
6
|
+
TableName: string;
|
|
7
|
+
Columns: string[];
|
|
8
|
+
ColumnsType: number[];
|
|
9
|
+
Rows: (string | number)[][];
|
|
10
|
+
}[];
|
|
11
|
+
IsSendXml: boolean;
|
|
12
|
+
DataBlock1: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
/** Runs only the fixed vendor query methods inside an authenticated Puppeteer page. */
|
|
16
|
+
export declare function runSystemEverQuery(page: any, input: unknown): Promise<Record<string, unknown>[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { planSystemEverQuery, SystemEverQueryError } from './systemever-query-policy.js';
|
|
2
|
+
import { workOrderQueryData } from './systemever-work-order-query.js';
|
|
3
|
+
import { decodeSystemEverQueryBlock } from './systemever-query-response.js';
|
|
4
|
+
import { WORK_ORDER_COLUMNS } from './systemever-work-order.js';
|
|
5
|
+
/** Reject invalid task inputs before acquiring a browser or authenticating. */
|
|
6
|
+
export function prepareSystemEverQuery(input) {
|
|
7
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
8
|
+
throw new SystemEverQueryError('systemever-query-invalid-params');
|
|
9
|
+
}
|
|
10
|
+
const params = input;
|
|
11
|
+
if (Object.keys(params).some(key => !['screen', 'dateFrom', 'dateTo', 'bizUnit'].includes(key))) {
|
|
12
|
+
throw new SystemEverQueryError('systemever-query-unsupported-param');
|
|
13
|
+
}
|
|
14
|
+
const plan = planSystemEverQuery({ screen: params.screen });
|
|
15
|
+
const data = workOrderQueryData(params);
|
|
16
|
+
return { plan, data };
|
|
17
|
+
}
|
|
18
|
+
/** Runs only the fixed vendor query methods inside an authenticated Puppeteer page. */
|
|
19
|
+
export async function runSystemEverQuery(page, input) {
|
|
20
|
+
const { plan, data } = prepareSystemEverQuery(input);
|
|
21
|
+
try {
|
|
22
|
+
if (new URL(page.url()).origin !== 'https://mfg.systemevererp.com') {
|
|
23
|
+
throw new SystemEverQueryError('systemever-query-unexpected-origin');
|
|
24
|
+
}
|
|
25
|
+
const result = await page.evaluate(async ({ data, methods }) => {
|
|
26
|
+
const home = window.Home;
|
|
27
|
+
if (!home?._base?.ConnInfo?.LoginSeq)
|
|
28
|
+
return { code: 'systemever-query-session-expired' };
|
|
29
|
+
home.OpenPageSeq(500880);
|
|
30
|
+
let token;
|
|
31
|
+
const deadline = Date.now() + 30000;
|
|
32
|
+
while (!token && Date.now() < deadline) {
|
|
33
|
+
const frame = document.querySelector('iframe[id="500880_iframe"]');
|
|
34
|
+
token = frame?.contentDocument?.querySelector('#__PageVerificationToken')?.value;
|
|
35
|
+
if (!token)
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
37
|
+
}
|
|
38
|
+
if (!token)
|
|
39
|
+
return { code: 'systemever-query-frame-timeout' };
|
|
40
|
+
let response;
|
|
41
|
+
for (const method of methods) {
|
|
42
|
+
const message = new home.Const.Message();
|
|
43
|
+
message.Seq = home._base.ConnInfo.LoginSeq;
|
|
44
|
+
message.PageToken = token;
|
|
45
|
+
message.Action = 228002;
|
|
46
|
+
message.ActionType = 0;
|
|
47
|
+
message.PgmMethodName = 'Query';
|
|
48
|
+
message.ServiceSeq = 501041;
|
|
49
|
+
message.MethodSeq = method.methodSeq;
|
|
50
|
+
Object.assign(message.Option, { PgmSeq: 500880, Timeout: 180, PgmMethodSeq: 2, XmlFlags: '2' });
|
|
51
|
+
message.JSonData = structuredClone(data);
|
|
52
|
+
response = await new Promise(resolve => {
|
|
53
|
+
const timer = setTimeout(() => resolve(null), 190000);
|
|
54
|
+
const finish = (value) => { clearTimeout(timer); resolve(value); };
|
|
55
|
+
try {
|
|
56
|
+
home.Util.Ajax('', message, 'POST', finish, () => finish(null), 'json', true);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
finish(null);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
if (!response)
|
|
63
|
+
return { code: 'systemever-query-request-failed' };
|
|
64
|
+
if (Number(response.ExeMsg?.ErrorSeq || 0) !== 0 || response.ExeMsg?.IsSystemError === true ||
|
|
65
|
+
(response.ExeMsg?.ErrStatus != null && Number(response.ExeMsg.ErrStatus) !== 0)) {
|
|
66
|
+
return { code: 'systemever-query-vendor-error' };
|
|
67
|
+
}
|
|
68
|
+
if (method.methodSeq === 6 && response.JSonData?.Tables?.some((table) => {
|
|
69
|
+
const status = table.Columns?.indexOf('Status') ?? -1;
|
|
70
|
+
return status >= 0 && table.Rows?.some((row) => Number(row[status] || 0) !== 0);
|
|
71
|
+
}))
|
|
72
|
+
return { code: 'systemever-query-vendor-error' };
|
|
73
|
+
}
|
|
74
|
+
// Project only tabular data: no session values or vendor object methods leave the page.
|
|
75
|
+
return { response: { JSonData: { Tables: response.JSonData?.Tables?.map((table) => ({
|
|
76
|
+
TableName: table.TableName, Columns: table.Columns, Rows: table.Rows
|
|
77
|
+
})) } } };
|
|
78
|
+
}, { data, methods: plan.methods });
|
|
79
|
+
if (result.code)
|
|
80
|
+
throw new SystemEverQueryError(result.code);
|
|
81
|
+
return decodeSystemEverQueryBlock(result.response, 'DataBlock1', WORK_ORDER_COLUMNS);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof SystemEverQueryError)
|
|
85
|
+
throw error;
|
|
86
|
+
// Browser exceptions can contain URLs, scripts and authentication values.
|
|
87
|
+
throw new SystemEverQueryError('systemever-query-browser-failed');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=systemever-query-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"systemever-query-runner.js","sourceRoot":"","sources":["../server/systemever-query-runner.ts"],"names":[],"mappings":"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","sourcesContent":["import { planSystemEverQuery, SystemEverQueryError } from './systemever-query-policy.js'\nimport { workOrderQueryData } from './systemever-work-order-query.js'\nimport { decodeSystemEverQueryBlock } from './systemever-query-response.js'\nimport { WORK_ORDER_COLUMNS } from './systemever-work-order.js'\n\n/** Reject invalid task inputs before acquiring a browser or authenticating. */\nexport function prepareSystemEverQuery(input: unknown) {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n throw new SystemEverQueryError('systemever-query-invalid-params')\n }\n const params = input as Record<string, any>\n if (Object.keys(params).some(key => !['screen', 'dateFrom', 'dateTo', 'bizUnit'].includes(key))) {\n throw new SystemEverQueryError('systemever-query-unsupported-param')\n }\n const plan = planSystemEverQuery({ screen: params.screen })\n const data = workOrderQueryData(params as any)\n return { plan, data }\n}\n\n/** Runs only the fixed vendor query methods inside an authenticated Puppeteer page. */\nexport async function runSystemEverQuery(page: any, input: unknown) {\n const { plan, data } = prepareSystemEverQuery(input)\n try {\n if (new URL(page.url()).origin !== 'https://mfg.systemevererp.com') {\n throw new SystemEverQueryError('systemever-query-unexpected-origin')\n }\n const result = await page.evaluate(async ({ data, methods }) => {\n const home = (window as any).Home\n if (!home?._base?.ConnInfo?.LoginSeq) return { code: 'systemever-query-session-expired' }\n home.OpenPageSeq(500880)\n let token: string | undefined\n const deadline = Date.now() + 30000\n while (!token && Date.now() < deadline) {\n const frame = document.querySelector<HTMLIFrameElement>('iframe[id=\"500880_iframe\"]')\n token = frame?.contentDocument?.querySelector<HTMLInputElement>('#__PageVerificationToken')?.value\n if (!token) await new Promise(resolve => setTimeout(resolve, 100))\n }\n if (!token) return { code: 'systemever-query-frame-timeout' }\n let response: any\n for (const method of methods) {\n const message = new home.Const.Message()\n message.Seq = home._base.ConnInfo.LoginSeq\n message.PageToken = token\n message.Action = 228002\n message.ActionType = 0\n message.PgmMethodName = 'Query'\n message.ServiceSeq = 501041\n message.MethodSeq = method.methodSeq\n Object.assign(message.Option, { PgmSeq: 500880, Timeout: 180, PgmMethodSeq: 2, XmlFlags: '2' })\n message.JSonData = structuredClone(data)\n response = await new Promise<any>(resolve => {\n const timer = setTimeout(() => resolve(null), 190000)\n const finish = (value: any) => { clearTimeout(timer); resolve(value) }\n try { home.Util.Ajax('', message, 'POST', finish, () => finish(null), 'json', true) }\n catch { finish(null) }\n })\n if (!response) return { code: 'systemever-query-request-failed' }\n if (Number(response.ExeMsg?.ErrorSeq || 0) !== 0 || response.ExeMsg?.IsSystemError === true ||\n (response.ExeMsg?.ErrStatus != null && Number(response.ExeMsg.ErrStatus) !== 0)) {\n return { code: 'systemever-query-vendor-error' }\n }\n if (method.methodSeq === 6 && response.JSonData?.Tables?.some((table: any) => {\n const status = table.Columns?.indexOf('Status') ?? -1\n return status >= 0 && table.Rows?.some((row: any[]) => Number(row[status] || 0) !== 0)\n })) return { code: 'systemever-query-vendor-error' }\n }\n // Project only tabular data: no session values or vendor object methods leave the page.\n return { response: { JSonData: { Tables: response.JSonData?.Tables?.map((table: any) => ({\n TableName: table.TableName, Columns: table.Columns, Rows: table.Rows\n })) } } }\n }, { data, methods: plan.methods })\n if (result.code) throw new SystemEverQueryError(result.code)\n return decodeSystemEverQueryBlock(result.response, 'DataBlock1', WORK_ORDER_COLUMNS)\n } catch (error) {\n if (error instanceof SystemEverQueryError) throw error\n // Browser exceptions can contain URLs, scripts and authentication values.\n throw new SystemEverQueryError('systemever-query-browser-failed')\n }\n}\n"]}
|