@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,41 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { planSystemEverQuery, SystemEverQueryError } from '../server/systemever-query-policy.js'
4
+
5
+ test('verified work-order query fixes program, service and read method order', () => {
6
+ const plan = planSystemEverQuery({ screen: 'work-order' })
7
+ assert.equal(plan.pgmSeq, 500880)
8
+ assert.equal(plan.serviceSeq, 501041)
9
+ assert.deepEqual(plan.methods, [
10
+ { name: 'ConfirmQuery', methodSeq: 6 },
11
+ { name: 'Query', methodSeq: 1 }
12
+ ])
13
+ assert.ok(Object.isFrozen(plan))
14
+ assert.ok(Object.isFrozen(plan.methods))
15
+ assert.ok(plan.methods.every(Object.isFrozen))
16
+ })
17
+
18
+ test('unknown and prototype screen names fail before browser access', () => {
19
+ for (const screen of ['product', 'Save', 'Delete', '__proto__', 'constructor', '', 500880]) {
20
+ assert.throws(() => planSystemEverQuery({ screen }), {
21
+ code: 'systemever-query-unknown-screen'
22
+ })
23
+ }
24
+ })
25
+
26
+ test('request overrides and executable input are rejected without exposing values', () => {
27
+ for (const key of ['pgmSeq', 'serviceSeq', 'methodSeq', 'method', 'url', 'script', 'conditions']) {
28
+ assert.throws(() => planSystemEverQuery({ screen: 'work-order', [key]: 'secret-value' }), error => {
29
+ assert.ok(error instanceof SystemEverQueryError)
30
+ assert.equal(error.code, 'systemever-query-unsupported-param')
31
+ assert.ok(!error.message.includes('secret-value'))
32
+ return true
33
+ })
34
+ }
35
+ })
36
+
37
+ test('malformed task parameters fail with a named error', () => {
38
+ for (const params of [null, undefined, [], 'work-order', 1]) {
39
+ assert.throws(() => planSystemEverQuery(params), { code: 'systemever-query-invalid-params' })
40
+ }
41
+ })
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { decodeSystemEverQueryBlock } from '../server/systemever-query-response.js'
4
+
5
+ const envelope = (Columns: unknown, Rows: unknown) => ({
6
+ JSonData: { Tables: [{ TableName: 'DataBlock3', Columns, Rows }] }
7
+ })
8
+
9
+ test('query rows follow response column order and preserve identifiers and nulls', () => {
10
+ assert.deepEqual(decodeSystemEverQueryBlock(
11
+ envelope(['ItemName', 'ItemNo', 'Spec'], [['제품', '00012', null]]),
12
+ 'DataBlock3', ['ItemNo', 'ItemName']
13
+ ), [{ ItemName: '제품', ItemNo: '00012', Spec: null }])
14
+ })
15
+
16
+ test('an explicit empty block is accepted but missing or duplicate blocks are rejected', () => {
17
+ assert.deepEqual(decodeSystemEverQueryBlock(envelope(['ItemNo'], []), 'DataBlock3', ['ItemNo']), [])
18
+ for (const response of [null, {}, { JSonData: { Tables: [] } }, {
19
+ JSonData: { Tables: [envelope([], []).JSonData.Tables[0], envelope([], []).JSonData.Tables[0]] }
20
+ }]) assert.throws(() => decodeSystemEverQueryBlock(response, 'DataBlock3', []))
21
+ })
22
+
23
+ test('column drift and malformed rows reject the whole response', () => {
24
+ for (const response of [
25
+ envelope(['ItemName'], [['제품']]),
26
+ envelope(['ItemNo', 'ItemNo'], [['1', '2']]),
27
+ envelope([123], [['1']]),
28
+ envelope(['ItemNo'], [['1'], []]),
29
+ envelope(['ItemNo'], [{ ItemNo: '1' }])
30
+ ]) assert.throws(() => decodeSystemEverQueryBlock(response, 'DataBlock3', ['ItemNo']))
31
+ })
32
+
33
+ test('prototype-like column names remain ordinary own properties', () => {
34
+ const [row] = decodeSystemEverQueryBlock(envelope(['__proto__'], [['value']]), 'DataBlock3', [])
35
+ assert.equal(Object.getPrototypeOf(row), Object.prototype)
36
+ assert.equal(Object.getOwnPropertyDescriptor(row, '__proto__')?.value, 'value')
37
+ })
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { runSystemEverQuery } from '../server/systemever-query-runner.js'
4
+ import { workOrderQueryData } from '../server/systemever-work-order-query.js'
5
+ import { WORK_ORDER_COLUMNS } from '../server/systemever-work-order.js'
6
+
7
+ const params = { screen: 'work-order', dateFrom: '2026-10-05', dateTo: '2026-10-12', bizUnit: '1' }
8
+
9
+ test('41 captured conditions preserve defaults, isolate calls and validate date ranges', () => {
10
+ const first = workOrderQueryData(params)
11
+ const table = first.Tables[0]
12
+ assert.equal(table.Columns.length, 41)
13
+ assert.equal(table.Rows[0].length, 41)
14
+ assert.equal(table.ColumnsType.length, 41)
15
+ assert.equal(table.Rows[0][table.Columns.indexOf('WorkDateFr')], '20261005')
16
+ table.Rows[0][0] = 'tampered'
17
+ assert.equal(workOrderQueryData(params).Tables[0].Rows[0][0], 'A')
18
+ for (const change of [{ dateFrom: '2026-02-30' }, { dateFrom: '2026-10-13' }, { bizUnit: '../' }]) {
19
+ assert.throws(() => workOrderQueryData({ ...params, ...change }))
20
+ }
21
+ })
22
+
23
+ test('untrusted scripts, methods and endpoints never reach browser evaluation', async () => {
24
+ const page = { evaluate: () => { assert.fail('must not evaluate') }, url: () => 'https://example.com' }
25
+ for (const change of [{ script: 'secret' }, { MethodSeq: 2 }, { screen: '__proto__' }]) {
26
+ await assert.rejects(runSystemEverQuery(page, { ...params, ...change }))
27
+ }
28
+ await assert.rejects(runSystemEverQuery(page, params), { code: 'systemever-query-unexpected-origin' })
29
+ })
30
+
31
+ test('vendor calls use frame token, fixed query methods and stop on confirm errors', async () => {
32
+ const calls: any[] = []
33
+ let failConfirm = false
34
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
35
+ const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
36
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: { Home: {
37
+ _base: { ConnInfo: { LoginSeq: 'session' } }, OpenPageSeq: (seq: number) => assert.equal(seq, 500880),
38
+ Const: { Message: class { Option = {} } },
39
+ Util: { Ajax: (url: string, message: any, verb: string, success: any) => {
40
+ calls.push(message)
41
+ assert.equal(url, '')
42
+ assert.equal(verb, 'POST')
43
+ success({ ExeMsg: { ErrorSeq: failConfirm ? 1 : 0 }, JSonData: {
44
+ Tables: [{ TableName: 'DataBlock1', Columns: [...WORK_ORDER_COLUMNS], Rows: [] }]
45
+ } })
46
+ } }
47
+ } } })
48
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: {
49
+ querySelector: () => ({ contentDocument: { querySelector: () => ({ value: 'frame-token' }) } })
50
+ } })
51
+ const page = { url: () => 'https://mfg.systemevererp.com/',
52
+ evaluate: (fn: any, arg: any) => fn(arg), waitForFunction: async (fn: any) => assert.equal(fn(), true) }
53
+ try {
54
+ assert.deepEqual(await runSystemEverQuery(page, params), [])
55
+ assert.deepEqual(calls.map(call => call.MethodSeq), [6, 1])
56
+ for (const call of calls) {
57
+ assert.equal(call.Seq, 'session')
58
+ assert.equal(call.PageToken, 'frame-token')
59
+ assert.equal(call.ServiceSeq, 501041)
60
+ assert.equal(call.PgmMethodName, 'Query')
61
+ assert.equal(call.Option.PgmSeq, 500880)
62
+ }
63
+ calls.length = 0
64
+ failConfirm = true
65
+ await assert.rejects(runSystemEverQuery(page, params), { code: 'systemever-query-vendor-error' })
66
+ assert.deepEqual(calls.map(call => call.MethodSeq), [6])
67
+ } finally {
68
+ for (const [key, descriptor] of [['window', previousWindow], ['document', previousDocument]] as const) {
69
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor)
70
+ else delete (globalThis as any)[key]
71
+ }
72
+ }
73
+ })
@@ -0,0 +1,106 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { beforeEach } from 'node:test'
3
+ import { registerHooks } from 'node:module'
4
+ import { WORK_ORDER_COLUMNS } from '../server/systemever-work-order.js'
5
+
6
+ const integrationUrl = 'data:text/javascript,' + encodeURIComponent(`
7
+ export const state = { reads: 0, recovered: 0, released: 0, session: null, page: null, recoveryPage: null };
8
+ export const handlers = new Map();
9
+ export const TaskRegistry = { registerTaskHandler: (name, value) => handlers.set(name, value) };
10
+ export const ConnectionManager = {
11
+ getConnectionEntityByName: async () => { state.reads++; return { name: 'systemever', type: 'systemever-connector', endpoint: 'https://mfg.systemevererp.com/' }; },
12
+ getConnectionInstanceByName: async () => ({
13
+ reAuthenticateSession: async () => { state.recovered++; return { page: state.recoveryPage }; },
14
+ releasePage: async () => { state.released++; }
15
+ }),
16
+ withSession: async (connection, fn) => { state.session = connection; return fn(state.page); }
17
+ };
18
+ export default { ConnectionManager, TaskRegistry };
19
+ `)
20
+ registerHooks({ resolve(specifier, context, next) {
21
+ if (specifier === '@things-factory/integration-base' && context.parentURL?.endsWith('/systemever-query-task.ts')) {
22
+ return { url: integrationUrl, shortCircuit: true }
23
+ }
24
+ return next(specifier, context)
25
+ } })
26
+ const { lastQueryStartedAt, SYSTEMEVER_QUERY_FLOOR_MS } = await import('../server/systemever-query-task.js')
27
+ /* each test is its own moment; the floor is tested on its own below */
28
+ beforeEach(() => lastQueryStartedAt.clear())
29
+ const { handlers, state } = await import(integrationUrl)
30
+ const handler = handlers.get('systemever-query')
31
+ const params = { screen: 'work-order', dateFrom: '2026-10-05', dateTo: '2026-10-12', bizUnit: '1' }
32
+ const domain = { id: 'domain' }
33
+ const emptyPage = { url: () => 'https://mfg.systemevererp.com/', evaluate: async () => ({ response: {
34
+ JSonData: { Tables: [{ TableName: 'DataBlock1', Columns: [...WORK_ORDER_COLUMNS], Rows: [] }] }
35
+ } }) }
36
+
37
+ test('registered task rejects arbitrary instructions before acquiring credentials', async () => {
38
+ await assert.rejects(handler({ connection: 'systemever', params: { ...params, script: 'untrusted' } }, { domain }),
39
+ { code: 'systemever-query-unsupported-param' })
40
+ assert.equal(state.reads, 0)
41
+ })
42
+
43
+ test('task carries connector metadata into the session lock and returns decoded rows', async () => {
44
+ state.page = emptyPage
45
+ assert.deepEqual(await handler({ connection: 'systemever', params }, { domain }), { data: [] })
46
+ assert.equal(state.session.type, 'systemever-connector')
47
+ assert.equal(state.session.name, 'systemever')
48
+ assert.equal(state.session.domain, domain)
49
+ })
50
+
51
+ test('expired session recovers once and always releases the recovery page', async () => {
52
+ state.page = { ...emptyPage, evaluate: async () => ({ code: 'systemever-query-session-expired' }) }
53
+ state.recoveryPage = emptyPage
54
+ assert.deepEqual(await handler({ connection: 'systemever', params }, { domain }), { data: [] })
55
+ assert.equal(state.recovered, 1)
56
+ assert.equal(state.released, 1)
57
+ state.recoveryPage = state.page
58
+ lastQueryStartedAt.clear() // a second, separate run of the recovery case — not a test of the floor
59
+ await assert.rejects(handler({ connection: 'systemever', params }, { domain }), { code: 'systemever-query-session-expired' })
60
+ assert.equal(state.recovered, 2)
61
+ assert.equal(state.released, 2)
62
+ })
63
+
64
+ test('★ a second query of the same screen within a minute is refused by name; the browser is not asked', async () => {
65
+ state.page = emptyPage
66
+ await handler({ connection: 'systemever', params }, { domain })
67
+ const reads = state.reads
68
+ await assert.rejects(handler({ connection: 'systemever', params }, { domain }), { code: 'systemever-query-too-frequent' })
69
+ assert.equal(state.reads, reads)
70
+ assert.ok(SYSTEMEVER_QUERY_FLOOR_MS >= 60_000)
71
+ })
72
+
73
+ test('the floor is per domain — another plant is not held back', async () => {
74
+ state.page = emptyPage
75
+ await handler({ connection: 'systemever', params }, { domain })
76
+ assert.deepEqual(await handler({ connection: 'systemever', params }, { domain: { id: 'other' } }), { data: [] })
77
+ })
78
+
79
+ test('★ a platform refusal passes through by its own name — not renamed to a connection failure', async () => {
80
+ const original = (await import(integrationUrl)).ConnectionManager.getConnectionEntityByName
81
+ const manager = (await import(integrationUrl)).ConnectionManager
82
+ manager.getConnectionEntityByName = async () => {
83
+ throw Object.assign(new Error('integration-runtime-off: connecting "systemever" does not run in this process — MES_PROCESS_ROLE=worker'), { code: 'integration-runtime-off' })
84
+ }
85
+ try {
86
+ await assert.rejects(handler({ connection: 'systemever', params }, { domain }), { code: 'integration-runtime-off' })
87
+ } finally {
88
+ manager.getConnectionEntityByName = original
89
+ }
90
+ })
91
+
92
+ test('★ any other failure keeps its cause — named in the message, whole on cause, values not quoted', async () => {
93
+ const manager = (await import(integrationUrl)).ConnectionManager
94
+ const original = manager.getConnectionEntityByName
95
+ const boom = new TypeError('secret-looking value 1234')
96
+ manager.getConnectionEntityByName = async () => { throw boom }
97
+ try {
98
+ const error: any = await handler({ connection: 'systemever', params }, { domain }).catch(e => e)
99
+ assert.equal(error.code, 'systemever-query-connection-failed')
100
+ assert.match(error.message, /cause: TypeError/)
101
+ assert.doesNotMatch(error.message, /1234/)
102
+ assert.equal(error.cause, boom)
103
+ } finally {
104
+ manager.getConnectionEntityByName = original
105
+ }
106
+ })
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { readFileSync } from 'node:fs'
4
+ import { registerHooks } from 'node:module'
5
+
6
+ /*
7
+ * The scenario step that maps SystemEver work-order rows to contract schedule entries. The rows are the recorded
8
+ * 작업지시조회 response (anonymised) — the same fixture the mapping itself is tested on.
9
+ */
10
+ const integrationUrl = 'data:text/javascript,' + encodeURIComponent(`
11
+ export const handlers = new Map();
12
+ export const TaskRegistry = { registerTaskHandler: (name, value) => handlers.set(name, value) };
13
+ export default { TaskRegistry };
14
+ `)
15
+ registerHooks({ resolve(specifier, context, next) {
16
+ if (specifier === '@things-factory/integration-base' && context.parentURL?.endsWith('/systemever-schedule-task.ts')) {
17
+ return { url: integrationUrl, shortCircuit: true }
18
+ }
19
+ return next(specifier, context)
20
+ } })
21
+ await import('../server/systemever-schedule-task.js')
22
+ const { handlers } = await import(integrationUrl)
23
+ const handler = handlers.get('systemever-work-orders-to-schedule')
24
+
25
+ const ROWS = JSON.parse(readFileSync(new URL('./fixtures/work-order-query.json', import.meta.url), 'utf8')).rows
26
+
27
+ test('the previous step’s rows become contract schedule entries', async () => {
28
+ const { data } = await handler({ params: { accessor: 'query', timeZone: 'Asia/Seoul' } }, { data: { query: ROWS } })
29
+ assert.equal(data.entries.length, 2)
30
+ assert.equal(data.entries[0].orderId, '20251107-0001')
31
+ assert.equal(data.entries[0].status, 'completed')
32
+ assert.deepEqual(data.skipped, [])
33
+ })
34
+
35
+ test('an order that cannot be mapped is passed on as skipped with its reason, not dropped', async () => {
36
+ const rows = [{ ...ROWS[0], ProgStatusName: '중단' }, ROWS[1]]
37
+ const { data } = await handler({ params: { accessor: 'query', timeZone: 'Asia/Seoul' } }, { data: { query: rows } })
38
+ assert.equal(data.entries.length, 1)
39
+ assert.match(data.skipped[0].reason, /unknown progress status/)
40
+ })
41
+
42
+ test('no rows at the accessor, or no zone, is refused by name', async () => {
43
+ await assert.rejects(handler({ params: { accessor: 'nothing', timeZone: 'Asia/Seoul' } }, { data: {} }), { code: 'systemever-schedule-invalid-rows' })
44
+ await assert.rejects(handler({ params: { accessor: 'query', timeZone: '' } }, { data: { query: ROWS } }), { code: 'systemever-schedule-invalid-time-zone' })
45
+ await assert.rejects(handler({ params: { accessor: 'query', timeZone: 'Mars/Olympus' } }, { data: { query: ROWS } }), { code: 'systemever-schedule-invalid-time-zone' })
46
+ })
@@ -0,0 +1,101 @@
1
+ /*
2
+ * SystemEver work orders → contract schedule entries. The fixture is the 2026-10-01 작업지시조회 response with
3
+ * every column and value shape kept and the names replaced (test/fixtures/work-order-query.json).
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+
9
+ import { contentRevision, localToIso, toScheduleEntries, WORK_ORDER_COLUMNS } from '../server/systemever-work-order.ts'
10
+
11
+ const FIXTURE = JSON.parse(readFileSync(new URL('./fixtures/work-order-query.json', import.meta.url), 'utf8'))
12
+ const ROWS: Record<string, unknown>[] = FIXTURE.rows
13
+ const SEOUL = { timeZone: 'Asia/Seoul' }
14
+ const row = (over: Record<string, unknown> = {}) => ({ ...ROWS[0], ...over })
15
+
16
+ test('every column the mapping reads is in the recorded response', () => {
17
+ for (const column of WORK_ORDER_COLUMNS) assert.ok(FIXTURE.columns.includes(column), column)
18
+ })
19
+
20
+ test('the recorded rows map: order, item, quantity, unit, plant-local hours, completed', () => {
21
+ const { entries, skipped } = toScheduleEntries(ROWS, SEOUL)
22
+ assert.deepEqual(skipped, [])
23
+ assert.equal(entries.length, 2)
24
+ const [first] = entries
25
+ assert.equal(first.orderId, '20251107-0001')
26
+ assert.equal(first.materialDefinitionId, 'F9901-T0001')
27
+ assert.equal(first.quantity, 200)
28
+ assert.equal(first.uom, 'EA')
29
+ assert.equal(first.status, 'completed')
30
+ /* 2025-11-07 09:00 and 18:00 in Seoul */
31
+ assert.equal(first.startTime, '2025-11-07T00:00:00.000Z')
32
+ assert.equal(first.endTime, '2025-11-07T09:00:00.000Z')
33
+ assert.equal(first.recordTime, '2025-11-28T05:35:13.000Z')
34
+ assert.equal(first.workCenter, 'TEST-WorkCenterName-1')
35
+ })
36
+
37
+ test('the revision follows the content, not the last update time — a remark edit is not a new revision', () => {
38
+ const [a] = toScheduleEntries([row()], SEOUL).entries
39
+ const [remark] = toScheduleEntries([row({ Remark: 'edited', LastDateTime: '2025-12-01 09:00:00' })], SEOUL).entries
40
+ assert.equal(remark.revision, a.revision)
41
+
42
+ for (const change of [{ OrderQty: '150.00000' }, { WorkDate: '20251108' }, { ProgStatusName: '진행중' }, { GoodItemNo: 'F9901-T0009' }]) {
43
+ const [changed] = toScheduleEntries([row(change)], SEOUL).entries
44
+ assert.notEqual(changed.revision, a.revision, JSON.stringify(change))
45
+ }
46
+ })
47
+
48
+ test('statuses read by name; one not known is skipped with the reason, never read as cancelled', () => {
49
+ const pairs: [string, string][] = [['작성', 'draft'], ['확정', 'released'], ['진행중', 'started'], ['완료', 'completed']]
50
+ for (const [name, status] of pairs) assert.equal(toScheduleEntries([row({ ProgStatusName: name })], SEOUL).entries[0].status, status)
51
+
52
+ for (const name of ['중단', '미완료', '보류']) {
53
+ const { entries, skipped } = toScheduleEntries([row({ ProgStatusName: name })], SEOUL)
54
+ assert.equal(entries.length, 0, name)
55
+ assert.match(skipped[0].reason, /unknown progress status/)
56
+ }
57
+ })
58
+
59
+ test('a stopped order is skipped; a stop date alone (as on the recorded completed row) is not a stop', () => {
60
+ assert.equal(toScheduleEntries([row({ IsStop: '1' })], SEOUL).skipped.length, 1)
61
+ assert.equal(ROWS[0].StopDate, '20251128')
62
+ assert.equal(toScheduleEntries([row()], SEOUL).entries.length, 1)
63
+ })
64
+
65
+ test('an order that comes as two lines (two processes) is skipped whole, not cut to one line', () => {
66
+ const { entries, skipped } = toScheduleEntries([row({ WorkOrderSerl: 1 }), row({ WorkOrderSerl: 2 }), ROWS[1]], SEOUL)
67
+ assert.deepEqual(entries.map(e => e.orderId), ['20251231-0002'])
68
+ assert.equal(skipped.length, 2)
69
+ assert.match(skipped[0].reason, /2 lines/)
70
+ })
71
+
72
+ test('no unit, no item, or a quantity that is not a number skips the order — nothing is invented', () => {
73
+ assert.match(toScheduleEntries([row({ ItemUnitName: '' })], SEOUL).skipped[0].reason, /ItemUnitName/)
74
+ assert.match(toScheduleEntries([row({ GoodItemNo: null })], SEOUL).skipped[0].reason, /GoodItemNo/)
75
+ assert.match(toScheduleEntries([row({ OrderQty: 'abc' })], SEOUL).skipped[0].reason, /OrderQty/)
76
+ })
77
+
78
+ test('blank hours leave the times out rather than inventing midnight', () => {
79
+ const [e] = toScheduleEntries([row({ WorkStartTime: '', WorkEndTime: ' ' })], SEOUL).entries
80
+ assert.equal(e.startTime, undefined)
81
+ assert.equal(e.endTime, undefined)
82
+ })
83
+
84
+ test('the plant zone is required — the ERP times are local', () => {
85
+ assert.throws(() => toScheduleEntries(ROWS, { timeZone: '' }), /timeZone/)
86
+ assert.throws(() => toScheduleEntries(ROWS, { timeZone: 'Mars/Olympus' }))
87
+ /* the same wall time is another instant in another zone */
88
+ assert.equal(toScheduleEntries([row()], { timeZone: 'UTC' }).entries[0].startTime, '2025-11-07T09:00:00.000Z')
89
+ })
90
+
91
+ test('localToIso holds across a daylight-saving change', () => {
92
+ /* 2026-03-08 03:30 in New York is after the spring-forward gap: UTC-4 */
93
+ assert.equal(localToIso('20260308', '0330', 'America/New_York'), '2026-03-08T07:30:00.000Z')
94
+ assert.equal(localToIso('2026-01-15', '09:00', 'America/New_York'), '2026-01-15T14:00:00.000Z')
95
+ assert.equal(localToIso('2026-1-15', '9', 'UTC'), undefined)
96
+ })
97
+
98
+ test('contentRevision is stable for the same content', () => {
99
+ const content = { materialDefinitionId: 'X', quantity: 1, uom: 'EA', status: 'released' as const }
100
+ assert.equal(contentRevision(content), contentRevision({ ...content }))
101
+ })
package/tsconfig.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "extends": "../tsconfig-base.json",
3
3
  "compilerOptions": {
4
+ "module": "es2022",
5
+ "incremental": false,
4
6
  "outDir": "dist-server",
5
7
  "rootDir": "server"
6
8
  },