@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
|
@@ -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
|
+
})
|