@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.
Files changed (53) hide show
  1. package/README.md +44 -0
  2. package/dist-server/index.d.ts +8 -0
  3. package/dist-server/index.js +9 -4
  4. package/dist-server/index.js.map +1 -1
  5. package/dist-server/systemever-adapter.js +12 -20
  6. package/dist-server/systemever-adapter.js.map +1 -1
  7. package/dist-server/systemever-connector.d.ts +1 -0
  8. package/dist-server/systemever-connector.js +88 -0
  9. package/dist-server/systemever-connector.js.map +1 -0
  10. package/dist-server/systemever-query-policy.d.ts +30 -0
  11. package/dist-server/systemever-query-policy.js +37 -0
  12. package/dist-server/systemever-query-policy.js.map +1 -0
  13. package/dist-server/systemever-query-response.d.ts +2 -0
  14. package/dist-server/systemever-query-response.js +26 -0
  15. package/dist-server/systemever-query-response.js.map +1 -0
  16. package/dist-server/systemever-query-runner.d.ts +16 -0
  17. package/dist-server/systemever-query-runner.js +90 -0
  18. package/dist-server/systemever-query-runner.js.map +1 -0
  19. package/dist-server/systemever-query-task.d.ts +7 -0
  20. package/dist-server/systemever-query-task.js +65 -0
  21. package/dist-server/systemever-query-task.js.map +1 -0
  22. package/dist-server/systemever-schedule-task.d.ts +1 -0
  23. package/dist-server/systemever-schedule-task.js +35 -0
  24. package/dist-server/systemever-schedule-task.js.map +1 -0
  25. package/dist-server/systemever-work-order-query.d.ts +14 -0
  26. package/dist-server/systemever-work-order-query.js +157 -0
  27. package/dist-server/systemever-work-order-query.js.map +1 -0
  28. package/dist-server/systemever-work-order.d.ts +33 -0
  29. package/dist-server/systemever-work-order.js +160 -0
  30. package/dist-server/systemever-work-order.js.map +1 -0
  31. package/logs/.5e5d741d8b7784a2fbad65eedc0fd46946aaf6f2-audit.json +15 -0
  32. package/logs/connections-2026-10-05-11.log +0 -0
  33. package/package.json +5 -3
  34. package/server/index.ts +8 -0
  35. package/server/systemever-connector.ts +88 -0
  36. package/server/systemever-query-policy.ts +47 -0
  37. package/server/systemever-query-response.ts +29 -0
  38. package/server/systemever-query-runner.ts +79 -0
  39. package/server/systemever-query-task.ts +62 -0
  40. package/server/systemever-schedule-task.ts +37 -0
  41. package/server/systemever-work-order-query.ts +157 -0
  42. package/server/systemever-work-order.ts +184 -0
  43. package/test/fixtures/work-order-query-request.json +153 -0
  44. package/test/fixtures/work-order-query.json +239 -0
  45. package/test/systemever-connector.test.ts +84 -0
  46. package/test/systemever-query-policy.test.ts +41 -0
  47. package/test/systemever-query-response.test.ts +37 -0
  48. package/test/systemever-query-runner.test.ts +73 -0
  49. package/test/systemever-query-task.test.ts +106 -0
  50. package/test/systemever-schedule-task.test.ts +46 -0
  51. package/test/systemever-work-order.test.ts +101 -0
  52. package/tsconfig.json +2 -0
  53. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,184 @@
1
+ /*
2
+ * **SystemEver work orders → contract schedule entries** — a pure mapping, no I/O.
3
+ *
4
+ * Both reading roads reach it (ADR-0110 addition 1, amended 2026-10-05): the adapter's own `readSchedules` when
5
+ * it reads `/api/WebApi` directly, and the plant task when an integration scenario brings the rows. The rows
6
+ * are the 작업지시조회 response as SystemEver sends them (svc 501041 / pgm 500880), column names untouched.
7
+ *
8
+ * ── What was seen (2026-10-01 response, two rows) ────────────────────────
9
+ * WorkDate "20251107" · WorkStartTime "0900" · WorkEndTime "1800" planned day and hours, plant-local
10
+ * OrderQty "200.00000" a decimal in a string
11
+ * ItemUnitName "EA" · GoodItemNo "F0102-H1003" unit and item
12
+ * ProgStatus 6036004 · ProgStatusName "완료" the only status seen
13
+ * WorkOrderNo "20251107-0001" · WorkOrderSerl 1 one line per order
14
+ * LastDateTime "2025-11-28 14:35:13" plant-local
15
+ *
16
+ * ── What it does not decide ──────────────────────────────────────────────
17
+ * · **A status it does not know is not mapped.** Only 완료 was seen. 작성 · 확정 · 진행중 are read by their
18
+ * names; 중단 · 미완료 and anything else skip the order with the reason — read as `cancelled`, an order the
19
+ * floor has not started would be cancelled without a word.
20
+ * · **The revision is the content, not the last update time** (user decision 2026-09-30): a remark edit must
21
+ * not look like a new revision, which holds a started order for a person.
22
+ * · **No zone is assumed.** The plant's zone is required; the ERP's times are local.
23
+ */
24
+ import { createHash } from 'node:crypto'
25
+
26
+ import type { ProductionScheduleEntry, ScheduleStatus } from '@operato/ops-contract'
27
+
28
+ /** The columns this mapping reads — all present in the 2026-10-01 response. */
29
+ export const WORK_ORDER_COLUMNS = [
30
+ 'WorkOrderNo',
31
+ 'WorkOrderSerl',
32
+ 'ProgStatusName',
33
+ 'IsStop',
34
+ 'GoodItemNo',
35
+ 'OrderQty',
36
+ 'ItemUnitName',
37
+ 'WorkDate',
38
+ 'WorkStartTime',
39
+ 'WorkEndTime',
40
+ 'WorkCenterName',
41
+ 'LastDateTime'
42
+ ] as const
43
+
44
+ /**
45
+ * SystemEver progress status → contract status. Read by name; only 완료 has been seen (code 6036004).
46
+ * 중단 · 미완료 are left out on purpose (see the file head).
47
+ */
48
+ export const WORK_ORDER_STATUS: Readonly<Record<string, ScheduleStatus>> = {
49
+ 작성: 'draft',
50
+ 확정: 'released',
51
+ 진행중: 'started',
52
+ 완료: 'completed'
53
+ }
54
+
55
+ export type WorkOrderRow = Record<string, unknown>
56
+
57
+ export interface WorkOrderSkip {
58
+ orderId?: string
59
+ reason: string
60
+ }
61
+
62
+ export interface WorkOrderMapping {
63
+ entries: ProductionScheduleEntry[]
64
+ skipped: WorkOrderSkip[]
65
+ }
66
+
67
+ const text = (v: unknown) => (v == null ? '' : String(v).trim())
68
+
69
+ /** Offset of `timeZone` from UTC at `utcMs`, in ms. */
70
+ function offsetMs(utcMs: number, timeZone: string): number {
71
+ const parts = new Intl.DateTimeFormat('en-US', {
72
+ timeZone,
73
+ hourCycle: 'h23',
74
+ year: 'numeric',
75
+ month: '2-digit',
76
+ day: '2-digit',
77
+ hour: '2-digit',
78
+ minute: '2-digit',
79
+ second: '2-digit'
80
+ }).formatToParts(new Date(utcMs))
81
+ const n = (type: string) => Number(parts.find(p => p.type === type)?.value)
82
+ return Date.UTC(n('year'), n('month') - 1, n('day'), n('hour'), n('minute'), n('second')) - utcMs
83
+ }
84
+
85
+ /** A plant-local wall time as an ISO instant. Undefined when the parts are not a time. */
86
+ export function localToIso(
87
+ date: string,
88
+ time: string,
89
+ timeZone: string
90
+ ): string | undefined {
91
+ const d = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(date)
92
+ const t = /^(\d{2}):?(\d{2})(?::?(\d{2}))?$/.exec(time)
93
+ if (!d || !t) return undefined
94
+ const wall = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2], +(t[3] ?? 0))
95
+ if (!Number.isFinite(wall)) return undefined
96
+ /* two passes: the offset at the guessed instant, then at the corrected one (around a DST change) */
97
+ let at = wall - offsetMs(wall, timeZone)
98
+ at = wall - offsetMs(at, timeZone)
99
+ return new Date(at).toISOString()
100
+ }
101
+
102
+ /** The content revision — what the floor would act on, in a fixed order. */
103
+ export function contentRevision(e: Omit<ProductionScheduleEntry, 'revision' | 'recordTime' | 'orderId'>): string {
104
+ const content = [e.materialDefinitionId, e.quantity, e.uom, e.startTime ?? '', e.endTime ?? '', e.status]
105
+ return createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 16)
106
+ }
107
+
108
+ function one(row: WorkOrderRow, timeZone: string): ProductionScheduleEntry | WorkOrderSkip {
109
+ const orderId = text(row.WorkOrderNo)
110
+ if (!orderId) return { reason: 'no WorkOrderNo' }
111
+
112
+ if (text(row.IsStop) === '1') return { orderId, reason: 'stopped in SystemEver (IsStop) — not mapped' }
113
+
114
+ const statusName = text(row.ProgStatusName)
115
+ const status = WORK_ORDER_STATUS[statusName]
116
+ if (!status) return { orderId, reason: `unknown progress status "${statusName}" — not mapped` }
117
+
118
+ const materialDefinitionId = text(row.GoodItemNo)
119
+ if (!materialDefinitionId) return { orderId, reason: 'no GoodItemNo' }
120
+
121
+ const quantity = Number(text(row.OrderQty))
122
+ if (!text(row.OrderQty) || !Number.isFinite(quantity)) return { orderId, reason: `OrderQty "${text(row.OrderQty)}" is not a number` }
123
+
124
+ const uom = text(row.ItemUnitName)
125
+ if (!uom) return { orderId, reason: 'no ItemUnitName — a unit is not invented' }
126
+
127
+ const day = text(row.WorkDate)
128
+ const startTime = day && text(row.WorkStartTime) ? localToIso(day, text(row.WorkStartTime), timeZone) : undefined
129
+ const endTime = day && text(row.WorkEndTime) ? localToIso(day, text(row.WorkEndTime), timeZone) : undefined
130
+
131
+ const [lastDay, lastTime] = text(row.LastDateTime).split(/\s+/)
132
+ const recordTime = lastDay && lastTime ? localToIso(lastDay, lastTime, timeZone) : undefined
133
+
134
+ const content = {
135
+ materialDefinitionId,
136
+ quantity,
137
+ uom,
138
+ ...(startTime ? { startTime } : {}),
139
+ ...(endTime ? { endTime } : {}),
140
+ status
141
+ }
142
+
143
+ return {
144
+ orderId,
145
+ ...content,
146
+ revision: contentRevision(content),
147
+ ...(text(row.WorkCenterName) ? { workCenter: text(row.WorkCenterName) } : {}),
148
+ ...(recordTime ? { recordTime } : {})
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Map a 작업지시조회 response.
154
+ *
155
+ * An order that comes as more than one line (several `WorkOrderSerl`, i.e. several processes) is skipped whole:
156
+ * the contract entry is one order, and picking one line would drop the others. Miratek's in-house orders are one
157
+ * process each; if that changes, this says so instead of guessing.
158
+ *
159
+ * @throws when `timeZone` is missing or unknown — no row can be read without it.
160
+ */
161
+ export function toScheduleEntries(rows: WorkOrderRow[], options: { timeZone: string }): WorkOrderMapping {
162
+ const timeZone = text(options?.timeZone)
163
+ if (!timeZone) throw new Error('timeZone is not set — SystemEver times are plant-local')
164
+ new Intl.DateTimeFormat('en-US', { timeZone })
165
+
166
+ const lines = new Map<string, number>()
167
+ for (const row of rows) {
168
+ const no = text(row.WorkOrderNo)
169
+ if (no) lines.set(no, (lines.get(no) ?? 0) + 1)
170
+ }
171
+
172
+ const result: WorkOrderMapping = { entries: [], skipped: [] }
173
+ for (const row of rows) {
174
+ const no = text(row.WorkOrderNo)
175
+ if (no && (lines.get(no) ?? 0) > 1) {
176
+ result.skipped.push({ orderId: no, reason: `${lines.get(no)} lines (WorkOrderSerl) for one order — not mapped` })
177
+ continue
178
+ }
179
+ const mapped = one(row, timeZone)
180
+ if ('reason' in mapped) result.skipped.push(mapped)
181
+ else result.entries.push(mapped)
182
+ }
183
+ return result
184
+ }
@@ -0,0 +1,153 @@
1
+ {
2
+ "Action": 228002,
3
+ "ActionType": 0,
4
+ "PgmMethodName": "Query",
5
+ "ServiceSeq": 501041,
6
+ "MethodSeq": 1,
7
+ "JSonData": {
8
+ "Tables": [
9
+ {
10
+ "TableName": "DataBlock1",
11
+ "Columns": [
12
+ "WorkingTag",
13
+ "IDX_NO",
14
+ "DataSeq",
15
+ "Status",
16
+ "Selected",
17
+ "IsChangedMst",
18
+ "TABLE_NAME",
19
+ "IsSum",
20
+ "ItemClassSSeq",
21
+ "ItemClassSName",
22
+ "ItemClassLSeq",
23
+ "ItemClassLName",
24
+ "ItemClassMSeq",
25
+ "BizUnit",
26
+ "BizUnitName",
27
+ "WorkDateFr",
28
+ "WorkDateTo",
29
+ "WorkOrderNo",
30
+ "ProgStatus",
31
+ "ProgStatusName",
32
+ "WorkCenterSeq",
33
+ "WorkCenterName",
34
+ "DeptSeq",
35
+ "DeptName",
36
+ "ProcSeq",
37
+ "ProcName",
38
+ "WorkType",
39
+ "WorkTypeName",
40
+ "GoodItemName",
41
+ "GoodItemNo",
42
+ "GoodItemSpec",
43
+ "WorkOrderDateFr",
44
+ "WorkOrderDateTo",
45
+ "FileSeq",
46
+ "ProdPlanNo",
47
+ "OrderNo",
48
+ "CustSeq",
49
+ "CustName",
50
+ "WHSeq",
51
+ "WHName",
52
+ "PoNo"
53
+ ],
54
+ "ColumnsType": [
55
+ 0,
56
+ 1,
57
+ 1,
58
+ 1,
59
+ 1,
60
+ 0,
61
+ 0,
62
+ 0,
63
+ 0,
64
+ 0,
65
+ 0,
66
+ 0,
67
+ 0,
68
+ 0,
69
+ 0,
70
+ 0,
71
+ 0,
72
+ 0,
73
+ 0,
74
+ 0,
75
+ 0,
76
+ 0,
77
+ 0,
78
+ 0,
79
+ 0,
80
+ 0,
81
+ 0,
82
+ 0,
83
+ 0,
84
+ 0,
85
+ 0,
86
+ 0,
87
+ 0,
88
+ 0,
89
+ 0,
90
+ 0,
91
+ 0,
92
+ 0,
93
+ 0,
94
+ 0,
95
+ 0
96
+ ],
97
+ "Rows": [
98
+ [
99
+ "A",
100
+ 1,
101
+ 1,
102
+ 0,
103
+ 1,
104
+ "0",
105
+ "DataBlock1",
106
+ "0",
107
+ "",
108
+ "",
109
+ "",
110
+ "",
111
+ "",
112
+ "1",
113
+ "본사",
114
+ "20261005",
115
+ "20261012",
116
+ "",
117
+ "",
118
+ "",
119
+ "",
120
+ "",
121
+ "",
122
+ "",
123
+ "",
124
+ "",
125
+ "",
126
+ "",
127
+ "",
128
+ "",
129
+ "",
130
+ "",
131
+ "",
132
+ 0,
133
+ "",
134
+ "",
135
+ "",
136
+ "",
137
+ "",
138
+ "",
139
+ ""
140
+ ]
141
+ ]
142
+ }
143
+ ],
144
+ "IsSendXml": true,
145
+ "DataBlock1": "DataBlock1"
146
+ },
147
+ "Option": {
148
+ "PgmSeq": 500880,
149
+ "Timeout": 180,
150
+ "PgmMethodSeq": 2,
151
+ "XmlFlags": "2"
152
+ }
153
+ }
@@ -0,0 +1,239 @@
1
+ {
2
+ "source": "SystemEver 작업지시조회 svc 501041 / pgm 500880, response of 2026-10-01 — every column and value shape kept, names, item numbers and customer fields replaced; columns is the response column list as SystemEver sent it (rows omit null fields)",
3
+ "columns": [
4
+ "WorkOrderSeq",
5
+ "WorkOrderSerl",
6
+ "BizUnit",
7
+ "WorkDate",
8
+ "WorkOrderNo",
9
+ "ProgStatusName",
10
+ "ProgStatus",
11
+ "WorkCenterSeq",
12
+ "WorkCenterName",
13
+ "DeptSeq",
14
+ "DeptName",
15
+ "WorkType",
16
+ "WorkTypeName",
17
+ "GoodItemSeq",
18
+ "GoodItemName",
19
+ "GoodItemNo",
20
+ "GoodItemSpec",
21
+ "ItemUnitSeq",
22
+ "ItemUnitName",
23
+ "ProcRev",
24
+ "ProcNo",
25
+ "ProcSeq",
26
+ "ProcName",
27
+ "SMCanisterProcKind",
28
+ "SMCanisterProcKindName",
29
+ "AssyItemSeq",
30
+ "AssyItemName",
31
+ "AssyItemNo",
32
+ "AssyItemSpec",
33
+ "ProdUnitSeq",
34
+ "ProdUnitName",
35
+ "OrderQty",
36
+ "WorkOrderDate",
37
+ "WorkStartTime",
38
+ "WorkEndTime",
39
+ "IsProcQC",
40
+ "IsLastProc",
41
+ "WorkCond1",
42
+ "WorkCond2",
43
+ "WorkCond3",
44
+ "WorkCond4",
45
+ "WorkCond5",
46
+ "WorkCond6",
47
+ "WorkCond7",
48
+ "WorkCond8",
49
+ "WorkCond9",
50
+ "WorkCond10",
51
+ "Remark",
52
+ "WHSeq",
53
+ "WHName",
54
+ "ProdPlanNo",
55
+ "ProdPlanQty",
56
+ "ProgressQty",
57
+ "NonProgressQty",
58
+ "ProdQty",
59
+ "OKQty",
60
+ "BadQty",
61
+ "OrderNo",
62
+ "PoNo",
63
+ "CustName",
64
+ "DVDate",
65
+ "MatProcSeq",
66
+ "MatProcName",
67
+ "WorkerQty",
68
+ "MatOutYn",
69
+ "IsStop",
70
+ "StopEmp",
71
+ "StopDate",
72
+ "StopReason",
73
+ "Confirm",
74
+ "ConfirmEmp",
75
+ "ConfirmDate",
76
+ "ConfirmReason",
77
+ "IsOrderChg",
78
+ "BatchSize",
79
+ "BatchQty",
80
+ "ProcMethod",
81
+ "AttentionSpec",
82
+ "ToolName",
83
+ "ToolNo",
84
+ "ToolSeq",
85
+ "OrderCustName",
86
+ "SLOrderQty",
87
+ "GoodItemClassSName",
88
+ "LastDateTime",
89
+ "LastUserName",
90
+ "FileSeq",
91
+ "FileCheck",
92
+ "ProdReqNo",
93
+ "ReqTypeName",
94
+ "ProdReqSeq",
95
+ "ReqType",
96
+ "ItemClassSSeq",
97
+ "ItemClassMSeq",
98
+ "ItemClassLSeq",
99
+ "ItemClassMName",
100
+ "ItemClassLName"
101
+ ],
102
+ "rows": [
103
+ {
104
+ "AssyItemName": "TEST-AssyItemName-1",
105
+ "AssyItemNo": "F9901-T0001",
106
+ "AssyItemSeq": 1465,
107
+ "AssyItemSpec": "TEST-AssyItemSpec-1",
108
+ "BadQty": "0.00000",
109
+ "BatchQty": "0.00000",
110
+ "BatchSize": "0.00000",
111
+ "BizUnit": 1,
112
+ "CustName": "TEST-CustName",
113
+ "DVDate": " ",
114
+ "DeptName": "TEST-DeptName-1",
115
+ "DeptSeq": 9,
116
+ "GoodItemClassSName": "TEST-GoodItemClassSName-1",
117
+ "GoodItemName": "TEST-GoodItemName-1",
118
+ "GoodItemNo": "F9901-T0001",
119
+ "GoodItemSeq": 1465,
120
+ "GoodItemSpec": "TEST-GoodItemSpec-1",
121
+ "IsLastProc": "1",
122
+ "ItemClassLName": "TEST-ItemClassLName-1",
123
+ "ItemClassLSeq": 2003001,
124
+ "ItemClassMName": "TEST-ItemClassMName-1",
125
+ "ItemClassMSeq": 2002001,
126
+ "ItemClassSSeq": 2001001,
127
+ "ItemUnitName": "EA",
128
+ "ItemUnitSeq": 2,
129
+ "LastDateTime": "2025-11-28 14:35:13",
130
+ "LastUserName": "TEST-LastUserName-1",
131
+ "NonProgressQty": "0.00000",
132
+ "OKQty": "200.00000",
133
+ "OrderCustName": "TEST-OrderCustName",
134
+ "OrderNo": "TEST-OrderNo",
135
+ "OrderQty": "200.00000",
136
+ "PoNo": "TEST-PoNo",
137
+ "ProcName": "사내생산",
138
+ "ProcNo": 1,
139
+ "ProcRev": "00",
140
+ "ProcSeq": 2,
141
+ "ProdPlanNo": "20251107-0001",
142
+ "ProdPlanQty": "200.00000",
143
+ "ProdQty": "200.00000",
144
+ "ProdReqNo": "TEST-ProdReqNo",
145
+ "ProdReqSeq": 1,
146
+ "ProdUnitName": "EA",
147
+ "ProdUnitSeq": 2,
148
+ "ProgStatus": 6036004,
149
+ "ProgStatusName": "완료",
150
+ "ProgressQty": "200.00000",
151
+ "SLOrderQty": "200.00000",
152
+ "StopDate": "20251128",
153
+ "WHName": "TEST-WHName-1",
154
+ "WHSeq": 17,
155
+ "WorkCenterName": "TEST-WorkCenterName-1",
156
+ "WorkCenterSeq": 3,
157
+ "WorkCond4": "0.00000",
158
+ "WorkCond5": "0.00000",
159
+ "WorkCond6": "0.00000",
160
+ "WorkCond7": "0.00000",
161
+ "WorkDate": "20251107",
162
+ "WorkEndTime": "1800",
163
+ "WorkOrderDate": "20251128",
164
+ "WorkOrderNo": "20251107-0001",
165
+ "WorkOrderSeq": 13,
166
+ "WorkOrderSerl": 1,
167
+ "WorkStartTime": "0900",
168
+ "WorkType": 6041001,
169
+ "WorkTypeName": "생산계획"
170
+ },
171
+ {
172
+ "AssyItemName": "TEST-AssyItemName-2",
173
+ "AssyItemNo": "F9901-T0002",
174
+ "AssyItemSeq": 1490,
175
+ "AssyItemSpec": "TEST-AssyItemSpec-2",
176
+ "BadQty": "0.00000",
177
+ "BatchQty": "0.00000",
178
+ "BatchSize": "0.00000",
179
+ "BizUnit": 1,
180
+ "CustName": "TEST-CustName",
181
+ "DVDate": "20251231",
182
+ "DeptName": "TEST-DeptName-2",
183
+ "DeptSeq": 9,
184
+ "GoodItemClassSName": "TEST-GoodItemClassSName-2",
185
+ "GoodItemName": "TEST-GoodItemName-2",
186
+ "GoodItemNo": "F9901-T0002",
187
+ "GoodItemSeq": 1490,
188
+ "GoodItemSpec": "TEST-GoodItemSpec-2",
189
+ "IsLastProc": "1",
190
+ "ItemClassLName": "TEST-ItemClassLName-2",
191
+ "ItemClassLSeq": 2003001,
192
+ "ItemClassMName": "TEST-ItemClassMName-2",
193
+ "ItemClassMSeq": 2002001,
194
+ "ItemClassSSeq": 2001001,
195
+ "ItemUnitName": "EA",
196
+ "ItemUnitSeq": 2,
197
+ "LastDateTime": "2025-12-02 10:15:05",
198
+ "LastUserName": "TEST-LastUserName-2",
199
+ "NonProgressQty": "0.00000",
200
+ "OKQty": "200.00000",
201
+ "OrderCustName": "TEST-OrderCustName",
202
+ "OrderNo": "TEST-OrderNo",
203
+ "OrderQty": "200.00000",
204
+ "PoNo": "TEST-PoNo",
205
+ "ProcName": "사내생산",
206
+ "ProcNo": 1,
207
+ "ProcRev": "00",
208
+ "ProcSeq": 2,
209
+ "ProdPlanNo": "20251231-0002",
210
+ "ProdPlanQty": "200.00000",
211
+ "ProdQty": "200.00000",
212
+ "ProdReqNo": "TEST-ProdReqNo",
213
+ "ProdReqSeq": 11,
214
+ "ProdUnitName": "EA",
215
+ "ProdUnitSeq": 2,
216
+ "ProgStatus": 6036004,
217
+ "ProgStatusName": "완료",
218
+ "ProgressQty": "200.00000",
219
+ "SLOrderQty": "200.00000",
220
+ "WHName": "TEST-WHName-2",
221
+ "WHSeq": 17,
222
+ "WorkCenterName": "TEST-WorkCenterName-2",
223
+ "WorkCenterSeq": 3,
224
+ "WorkCond4": "0.00000",
225
+ "WorkCond5": "0.00000",
226
+ "WorkCond6": "0.00000",
227
+ "WorkCond7": "0.00000",
228
+ "WorkDate": "20251231",
229
+ "WorkEndTime": "1800",
230
+ "WorkOrderDate": "20251202",
231
+ "WorkOrderNo": "20251231-0002",
232
+ "WorkOrderSeq": 23,
233
+ "WorkOrderSerl": 1,
234
+ "WorkStartTime": "0900",
235
+ "WorkType": 6041001,
236
+ "WorkTypeName": "생산계획"
237
+ }
238
+ ]
239
+ }
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { registerHooks } from 'node:module'
4
+
5
+ // Replace the integration boundary only: loading its real shell would start application hooks.
6
+ const integrationUrl = 'data:text/javascript,' + encodeURIComponent(`
7
+ export const connections = new Map();
8
+ export const requestedKeys = [];
9
+ export const ConnectionManager = { registerConnector: (name, value) => connections.set(name, value) };
10
+ export async function lookupDomainEnvVar(domain, key) {
11
+ requestedKeys.push([domain.id, key]);
12
+ return domain.id === 'configured' ? 'credential-value' : undefined;
13
+ }
14
+ export class HeadlessConnector {
15
+ get parameterSpec() { return [{ name: 'username' }, { name: 'password' }, { name: 'timeout' }]; }
16
+ async connect(connection) { this.connection = connection; }
17
+ async setupPage() {}
18
+ async resolveShadowDom(page, shadow, selector) { return page.$(selector); }
19
+ }
20
+ export default { ConnectionManager, HeadlessConnector, lookupDomainEnvVar };
21
+ `)
22
+ registerHooks({ resolve(specifier, context, next) {
23
+ if (specifier === '@things-factory/integration-base' && context.parentURL?.endsWith('/systemever-connector.ts')) {
24
+ return { url: integrationUrl, shortCircuit: true }
25
+ }
26
+ return next(specifier, context)
27
+ } })
28
+ await import('../server/systemever-connector.js')
29
+ const boundary = await import(integrationUrl)
30
+ const connector = boundary.connections.get('systemever-connector')
31
+
32
+ test('connection reads only specified domain EnvVars and enforces reuse and one login retry', async () => {
33
+ await connector.connect({ domain: { id: 'configured' }, endpoint: 'https://mfg.systemevererp.com/',
34
+ params: { retries: 99, forceLogin: true, username: 'unused-inline-value', password: 'unused-inline-value' } })
35
+ assert.deepEqual(boundary.requestedKeys, [
36
+ ['configured', 'ErpConnection::systemever::username'], ['configured', 'ErpConnection::systemever::password']
37
+ ])
38
+ assert.equal(connector.connection.params.retries, 2)
39
+ assert.equal(connector.connection.params.forceLogin, false)
40
+ assert.equal(connector.connection.params.username, 'credential-value')
41
+ assert.deepEqual(connector.taskPrefixes, ['systemever-query'])
42
+ assert.deepEqual(connector.parameterSpec, [{ name: 'timeout' }])
43
+ await assert.rejects(connector.connect({ domain: { id: 'missing' }, endpoint: 'https://mfg.systemevererp.com/' }),
44
+ { code: 'systemever-query-missing-credentials' })
45
+ await assert.rejects(connector.connect({ domain: { id: 'configured' }, endpoint: 'https://secret@mfg.systemevererp.com/' }),
46
+ { code: 'systemever-query-invalid-connection' })
47
+ })
48
+
49
+ test('session cookies are reused while expired cookies require login', () => {
50
+ assert.equal(connector.isCookieValid([{ expires: -1 }]), true)
51
+ assert.equal(connector.isCookieValid([{ expires: Date.now() / 1000 - 1 }]), false)
52
+ assert.equal(connector.isCookieValid([]), false)
53
+ })
54
+
55
+ test('login uses browser controls, retries once, and never copies raw failures', async () => {
56
+ let visits = 0
57
+ let clicks = 0
58
+ let fail = true
59
+ const typed: string[] = []
60
+ const page = {
61
+ url: () => 'https://mfg.systemevererp.com/Home/Oauth2OpenIdSignIn',
62
+ goto: async (url: string) => { visits++; assert.equal(url, 'https://mfg.systemevererp.com/Home/Oauth2OpenIdSignIn') },
63
+ $: async () => ({ type: async (value: string) => typed.push(value), click: async () => { clicks++ } }),
64
+ waitForFunction: async () => { if (fail) throw new Error('sensitive-vendor-error') }
65
+ }
66
+ const login = { username: 'test-user', password: 'test-password', timeout: 100,
67
+ loginSelectors: { usernameSelector: '#user', passwordSelector: '#password', submitSelector: '#submit' } }
68
+ await assert.rejects(connector.performLogin(page, '', login), error => {
69
+ assert.equal((error as Error).message, 'systemever-query-login-failed')
70
+ return true
71
+ })
72
+ assert.equal(visits, 2)
73
+ assert.equal(clicks, 2)
74
+ assert.deepEqual(typed, ['test-user', 'test-password', 'test-user', 'test-password'])
75
+ fail = false
76
+ await connector.performLogin(page, '', login)
77
+ assert.equal(visits, 3)
78
+ })
79
+
80
+ test('browser console and failed request details are removed from logging', async () => {
81
+ const removed: string[] = []
82
+ await connector.setupPage({ removeAllListeners: (name: string) => removed.push(name) }, '', 100)
83
+ assert.deepEqual(removed, ['console', 'requestfailed'])
84
+ })