/* * 정규 인제스트 — **어휘가 둘이다**(물류 EPCIS · 에너지 계측). * * ── 왜 있나 (2026-08-14, EMS 5단계 준비) ──────────────────────────────────── * 이 seam 은 무조건 `type: 'ObjectEvent'` 를 만들었다. 그래서 커넥터가 계측을 이 길로 보내면 둘 중 * 하나가 됐다: `epc` 가 없어 거부되거나(그나마 나은 쪽), **엉뚱한 물품 관측**으로 읽혀 재고가 흔들린다. * 오류 없이 틀리는 쪽이 더 나쁘다. * * 이 시험이 지키는 것 둘: * ① 한 배치에 두 어휘가 섞여 와도 각자의 봉투가 된다(커넥터가 나눠 보낼 의무를 지지 않는다). * ② **거부분이 사라지지 않는다** — 예전에는 호출부가 `rejected` 를 받지도 않았다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts' /* * 이 파일의 **시계**. 아래 픽스처들이 8월 말을 쓰는데 시계가 8월 14일이었다 — 자기 시계보다 뒤의 * 사실을 표본으로 두고 있었다. 커널이 「아직 오지 않은 시각의 사실」을 거부하게 되면서 드러났다 * (2026-08-30). 시계를 픽스처 뒤로 옮긴다. */ const NOW = '2026-09-01T00:00:00.000Z' const epcisRecord = { epc: 'urn:epc:id:sgtin:0614141.107346.2017', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', disposition: 'urn:epcglobal:cbv:disp:in_progress', readPoint: 'rack-1' } const energyRecord = { meterId: 'meter-incoming', kW: 1200, at: '2026-08-14T09:00:30.000Z' } test('한 배치에 두 어휘가 섞여도 각자의 봉투가 된다', () => { const { accepted, rejected } = ingestCanonicalRecords([epcisRecord, energyRecord] as any, 't', NOW) assert.equal(rejected.length, 0, `거부 없음이어야 한다: ${JSON.stringify(rejected)}`) const types = accepted.map(e => e.eventType).sort() assert.deepEqual(types, ['energy.measured', 'epcis.ObjectEvent']) }) test('계측을 물품 관측으로 만들지 않는다 — 이것이 이 갈림의 이유다', () => { const { accepted } = ingestCanonicalRecords([energyRecord] as any, 't', NOW) assert.equal(accepted.length, 1) assert.equal(accepted[0].eventType, 'energy.measured') /* EPCIS 페이로드의 흔적이 없어야 한다 — 있으면 미러가 물품을 지어낸다. */ assert.equal((accepted[0].data as any).epcList, undefined) assert.equal((accepted[0].data as any).type, undefined) }) test('거부분이 사라지지 않는다 — 커넥터 매핑이 틀린 것을 알 수 있어야 한다', () => { const { accepted, rejected } = ingestCanonicalRecords( [ energyRecord, { meterId: 'm-2', kW: 10, at: '어제' }, // 시각 형식 오류 { epc: '', action: 'OBSERVE', bizStep: 'x' } // EPCIS 쪽 결손 ] as any, 't', NOW ) assert.equal(accepted.length, 1, '유효한 것은 통과한다') assert.equal(rejected.length, 2) for (const r of rejected) assert.ok(r.errors.length > 0, '이유 없는 거부가 없다') }) test('빈 입력에서 모든 갈래가 들어온 것이 없다 — 없는 것을 만들지 않는다', () => { /* * 어휘가 다섯이다 — 사건 넷과 사건이 아닌 것 하나(마스터데이터). 어느 갈래도 값을 지어내지 않는다. * * `undated` 는 `masterData` 와 같은 규칙으로 언제나 있다(빈 것이면 빈 객체). 없을 때 칸을 빼면 * 읽는 쪽이 「이 버전이 그것을 안 센다」와 「셌는데 0이다」를 구별하지 못한다. */ const none = { accepted: [], rejected: [], masterData: [], undated: {} } assert.deepEqual(ingestCanonicalRecords([], 't', NOW), none) assert.deepEqual(ingestCanonicalRecords(null, 't', NOW), none) }) test('시각이 없는 계측은 호출자의 기준 시각을 쓴다 — 도착 시각이 근거다', () => { const { accepted } = ingestCanonicalRecords([{ meterId: 'm-1', kW: 5 }] as any, 't', NOW) assert.equal(accepted[0].eventTime, NOW) }) /* * ── 세 번째 어휘: 설비 에너지 상태 (2026-08-15) ───────────────────────────── * 능력 계약은 태양광·축전지·감축 부하·차단기의 상태 필드를 선언해 두었는데 받는 길이 없었다. * 계량과 갈라 두는 이유는 커널에 적혀 있다 — 계량은 구간에 누적되고, 이것들은 그 설비의 지금이다. */ test('설비 에너지 상태는 EPCIS 도 계량도 아닌 자기 어휘로 들어온다', () => { const { accepted, rejected } = ingestCanonicalRecords( [ { equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z', generatedKW: 210 }, { meterId: 'm-1', kW: 800, at: '2026-08-15T09:00:00.000Z' }, { epc: 'urn:epc:id:sgtin:9521321.012345.3', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing' } ] as any, 'tenant-1', '2026-08-15T09:00:00.000Z' ) assert.equal(rejected.length, 0, JSON.stringify(rejected)) const types = accepted.map(a => a.eventType).sort() assert.deepEqual(types, ['energy.equipment', 'energy.measured', 'epcis.ObjectEvent'].sort(), '세 어휘가 각자 봉투로') }) test('바꿀 상태가 없는 보고는 거부한다 — 이유와 함께', () => { const { accepted, rejected } = ingestCanonicalRecords( [{ equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z' }] as any, 'tenant-1', '2026-08-15T09:00:00.000Z' ) assert.equal(accepted.length, 0) assert.match(rejected[0].errors.join(' '), /바꿀 상태/) }) test('충전율이 백분율 범위를 벗어나면 거부한다 — 단위를 잘못 맞춘 커넥터를 통과시키지 않는다', () => { const { rejected } = ingestCanonicalRecords( [{ equipmentId: 'ess-1', at: '2026-08-15T09:00:00.000Z', soc: 380 }] as any, 'tenant-1', '2026-08-15T09:00:00.000Z' ) assert.equal(rejected.length, 1) assert.match(rejected[0].errors.join(' '), /soc/) }) test('개폐 위치는 표준의 네 값만 받는다 — 과도·불량을 열림/닫힘으로 반올림하지 않는다', () => { const ok = ingestCanonicalRecords([{ equipmentId: 'cb-1', at: '2026-08-15T09:00:00.000Z', position: 'intermediate' }] as any, 't', '2026-08-15T09:00:00.000Z') assert.equal(ok.accepted.length, 1) const bad = ingestCanonicalRecords([{ equipmentId: 'cb-1', at: '2026-08-15T09:00:00.000Z', position: 'ON' }] as any, 't', '2026-08-15T09:00:00.000Z') assert.equal(bad.rejected.length, 1) assert.match(bad.rejected[0].errors.join(' '), /position/) }) /* * ── 레코드가 말한 시각이 봉투의 시각이 된다 (2026-08-23) ──────────────────── * * 이 경로는 `eventTimePath` 를 주지 않아 **언제나 폴링 시각**을 썼다. 그래서 커넥터가 이미 싣고 있던 * `eventTime` 이 알리지 않고 무시됐다 — 어디에도 오류가 나지 않고, 커넥터를 붙인 쪽은 그것이 먹는다고 * 믿고 있었다. * * 무엇이 걸렸나: **계보의 시각**이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다. 그리고 * 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(`kpi-fold`: `workMs = 완료 − 첫 착수`), 폴링 간격을 * 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다. */ test('레코드가 시각을 말하면 그것이 봉투의 시각이다 — 폴링 시각으로 덮지 않는다', () => { const POLLED = '2026-08-23T00:00:00.000Z' const SAID = '2026-04-15T07:35:53.000Z' const { accepted, rejected } = ingestCanonicalRecords( [{ ...epcisRecord, eventTime: SAID }] as any, 't', POLLED ) assert.deepEqual(rejected, []) assert.equal(accepted[0].eventTime, SAID, '원본이 말한 시각이어야 한다') assert.notEqual(accepted[0].eventTime, POLLED, '폴링 시각이 아니어야 한다') }) test('레코드가 시각을 말하지 않으면 폴링 시각으로 떨어진다 — 이 한 줄이 기존 경로를 막지 않는다', () => { /* * 커널의 경로 해석은 없는 값을 **오류로 만들지 않는다**(§`resolve`). 그래서 시각을 말하지 않는 * 레코드는 예전처럼 기본값을 쓴다. 이 단정이 없으면 위 한 줄이 알리지 않고 대량 거부를 낼 수 있다. */ const POLLED = '2026-08-23T00:00:00.000Z' const { accepted, rejected } = ingestCanonicalRecords([epcisRecord] as any, 't', POLLED) assert.deepEqual(rejected, []) assert.equal(accepted[0].eventTime, POLLED) }) /* * ── 다섯 번째 어휘 — 발전 적산 (2026-08-28, 커널 0.7.69) ────────────────────── * * 커널이 `energy.generated` 를 오래 선언만 하고 있었고, 그 문이 0.7.69 에 열렸다. 그런데 문이 열린 * 것과 길이 난 것은 다르다 — 이 seam 이 발전 적산을 **설비 상태 문으로 보내고 있었다.** * * 두 판정이 겹치기 때문이다. `isEnergyEquipmentRecord` 는 `equipmentId` 만 보므로 발전 적산에도 * 참이다. 그래서 순서를 정하지 않으면 적산이 설비 상태 문에 닿고, 그 문은 적산을 값으로 세지 않아 * 「바꿀 상태가 하나도 없다」로 돌려보낸다. * * 이 저장소에서 가장 자주 나는 결함 부류다 — 자리는 선언되어 있고 거기까지 가는 길이 없다. */ const generationRecord = { equipmentId: 'inv-001', kWh: 574.8, kWhSince: '2026-08-13T15:00:00.000Z', at: '2026-08-14T09:00:30.000Z' } test('발전 적산은 설비 상태가 아니라 자기 문으로 들어온다', () => { const { accepted, rejected } = ingestCanonicalRecords([generationRecord] as any, 't', NOW) assert.deepEqual(rejected, [], `거부 없음이어야 한다: ${JSON.stringify(rejected)}`) assert.equal(accepted.length, 1) assert.equal(accepted[0].eventType, 'energy.generated', '설비 상태 문으로 가면 여기서 거부됐을 것이다') }) test('적산이 쌓이기 시작한 시각이 봉투까지 간다 — 이것이 없으면 자정이 계기 교체로 남는다', () => { const { accepted } = ingestCanonicalRecords([generationRecord] as any, 't', NOW) assert.equal((accepted[0].data as any).since, '2026-08-13T15:00:00.000Z') assert.equal((accepted[0].data as any).kWh, 574.8) }) test('기준점을 말하지 않는 원본도 그대로 통과한다 — 없는 칸을 만들지 않는다', () => { const { accepted, rejected } = ingestCanonicalRecords( [{ equipmentId: 'inv-002', kWh: 120.5, at: '2026-08-14T09:00:30.000Z' }] as any, 't', NOW ) assert.deepEqual(rejected, []) assert.equal(accepted[0].eventType, 'energy.generated') assert.equal((accepted[0].data as any).since, undefined, '말하지 않은 것을 지어내지 않는다') }) test('한 배치에 설비 상태와 발전 적산이 섞여도 각자의 문으로 간다', () => { const { accepted, rejected } = ingestCanonicalRecords( [{ equipmentId: 'inv-001', generatedKW: 8.2, at: '2026-08-14T09:00:30.000Z' }, generationRecord] as any, 't', NOW ) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.deepEqual(accepted.map(e => e.eventType).sort(), ['energy.equipment', 'energy.generated']) }) test('★ 상태까지 걷는다 — 자정에 되돌아간 적산이 계기 교체로 남지 않는다', async () => { /* * 문이 열린 것과 길이 난 것은 다르다. 그래서 봉투에서 멈추지 않고 **상태까지** 걷는다. * * PPMS 인버터의 `acc` 는 하루 누적이다(수집 코드가 전날까지 총량을 빼고, 그 값을 하루 만에 * 만료되는 자리에 둔다). 그래서 자정마다 574.8 → 165.2 로 되돌아간다. 기준점을 말하지 않으면 * 커널이 그것을 계기 교체로 읽고, 「계기 교체가 하루에 한 번 일어난다」가 지난 기록에 남는다. */ const { EmsKernel } = await import('@operato/twin-kernel') const k = new EmsKernel('t') k.loadTwinModel({ locations: [{ id: 'incoming', type: 'incoming', capacity: 0 }], equipment: [{ id: 'inv-001', kind: 'pv-array', locationId: 'incoming' }] } as any) const send = (kWh: number, since: string | undefined, at: string) => { const { accepted, rejected } = ingestCanonicalRecords( [{ equipmentId: 'inv-001', kWh, ...(since ? { kWhSince: since } : {}), at }] as any, 't', at ) assert.deepEqual(rejected, [], JSON.stringify(rejected)) for (const e of accepted) k.apply(e as any) } const inverter = () => (k.getSnapshot() as any).equipment.find((e: any) => e.id === 'inv-001') /* 첫날 저녁 — 그날 자정부터 574.8kWh */ send(574.8, '2026-08-26T15:00:00.000Z', '2026-08-27T14:37:00.000Z') assert.equal(inverter().generatedKWh, 574.8, '적산이 상태에 선다') /* 다음 날 아침 — 값이 줄었지만 기준점이 나아갔다 */ send(165.2, '2026-08-27T15:00:00.000Z', '2026-08-28T01:12:00.000Z') const after = inverter() assert.equal(after.generatedKWh, 165.2) assert.equal(after.generatedKWhSince, '2026-08-27T15:00:00.000Z', '어느 구간의 적산인지 남는다') assert.equal(after.generatedKWhResetAt, undefined, '구간이 새로 시작한 것은 계기의 이상이 아니다') }) test('기준점 없이 값이 줄면 모른다고 표시한다 — 짐작을 사실로 적지 않는다', async () => { const { EmsKernel } = await import('@operato/twin-kernel') const k = new EmsKernel('t') k.loadTwinModel({ locations: [{ id: 'incoming', type: 'incoming', capacity: 0 }], equipment: [{ id: 'inv-002', kind: 'pv-array', locationId: 'incoming' }] } as any) const send = (kWh: number, at: string) => { const { accepted } = ingestCanonicalRecords([{ equipmentId: 'inv-002', kWh, at }] as any, 't', at) for (const e of accepted) k.apply(e as any) } send(574.8, '2026-08-27T14:37:00.000Z') send(165.2, '2026-08-28T01:12:00.000Z') const eq = (k.getSnapshot() as any).equipment.find((e: any) => e.id === 'inv-002') assert.ok(eq.generatedKWhResetAt, '되돌아간 사실은 남긴다') assert.equal(eq.generatedKWhBasis, 'inferred', '그 판정이 짐작이라는 것도 함께 남긴다') }) /* * ── 여섯째·일곱째 어휘 — 사용 구간과 청구서 (2026-08-29, 커널 0.7.72) ────────── * * 커널이 문을 열었고, 이 seam 은 또 그 어휘를 몰랐다. 발전 적산 때와 같은 자리다. * * 이번 겹침이 더 나쁘다. `isEnergyRecord` 는 `meterId` 가 있으면 참이므로 사용 구간에도 참이다. * 순서를 정하지 않으면 시간별 사용량이 계량 표본으로 들어가는데, 그 문은 `kWh` 를 적산 * 레지스터로 읽는다. 우리가 넣는 값은 이미 구간의 양이라 차분의 차분이 된다 — **거부되지 않고 * 값만 틀린다.** */ const usageRecord = { meterId: 'incoming', from: '2026-08-28T15:00:00.000Z', to: '2026-08-28T16:00:00.000Z', kWh: 474.48, maxKW: 478.08, unitPrice: 87.3, currency: 'KRW', basis: 'provider' } const billRecord = { from: '2026-06-30T15:00:00.000Z', to: '2026-07-31T15:00:00.000Z', demandCharge: 13728000, currency: 'KRW', billingDemandKW: 1650 } test('★ 사용 구간은 계량 표본이 아니라 자기 문으로 들어온다', () => { const { accepted, rejected } = ingestCanonicalRecords([usageRecord] as any, 't', NOW) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.equal(accepted.length, 1) assert.notEqual(accepted[0].eventType, 'energy.measured', '계량 문으로 가면 kWh 가 적산으로 읽힌다') }) test('청구서도 자기 문으로 들어온다', () => { const { accepted, rejected } = ingestCanonicalRecords([billRecord] as any, 't', NOW) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.equal(accepted.length, 1) assert.notEqual(accepted[0].eventType, 'epcis.ObjectEvent', '물품 관측으로 만들지 않는다') }) test('계량 표본은 그대로 계량 문으로 간다 — 이 갈림이 기존 경로를 막지 않는다', () => { const { accepted, rejected } = ingestCanonicalRecords( [{ meterId: 'incoming', kW: 1200, at: '2026-08-14T09:00:30.000Z' }] as any, 't', NOW ) assert.deepEqual(rejected, []) assert.equal(accepted[0].eventType, 'energy.measured') }) test('한 배치에 넷이 섞여도 각자의 문으로 간다', () => { const { accepted, rejected } = ingestCanonicalRecords( [energyRecord, generationRecord, usageRecord, billRecord] as any, 't', NOW ) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.equal(accepted.length, 4, '넷 다 받아들인다') assert.equal(new Set(accepted.map(e => e.eventType)).size, 4, '넷이 서로 다른 사건이다') }) /* * ── 여덟째 어휘 — 요금 기준 (2026-08-29, 커널 0.7.76) ───────────────────────── * * 문이 열렸는데 유입 갈림이 또 몰랐다. 오늘 세 번째다. */ const tariffBasisRecord = { from: '2026-07-31T15:00:00.000Z', to: '2026-08-31T15:00:00.000Z', billingDemandKW: 1650, demandChargePerKW: 8320, currency: 'KRW' } test('★ 요금 기준이 자기 문으로 들어온다', () => { const { accepted, rejected } = ingestCanonicalRecords([tariffBasisRecord] as any, 't', NOW) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.equal(accepted.length, 1) assert.equal(accepted[0].eventType, 'energy.tariff.basis') }) test('요금 기준의 사건 시각은 기간의 시작이다 — 진행 중인 주기도 미래에 적히지 않는다', () => { const { accepted } = ingestCanonicalRecords([tariffBasisRecord] as any, 't', NOW) assert.equal(accepted[0].eventTime, '2026-07-31T15:00:00.000Z', '청구서와 달리 to 가 아니다') }) test('요금 기준과 청구서를 섞어도 각자의 문으로 간다', () => { const { accepted, rejected } = ingestCanonicalRecords([tariffBasisRecord, billRecord] as any, 't', NOW) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.deepEqual(accepted.map(e => e.eventType).sort(), ['energy.bill', 'energy.tariff.basis']) }) test('설비의 전기 계측값은 설비 상태 문으로 간다 — 발전 출력이 없어도 받는다', () => { const { accepted, rejected } = ingestCanonicalRecords( [{ equipmentId: '002', dcVoltage: 469.13, dcCurrent: 68.7, acVoltage: [377.8, 379.9, 377.7], acCurrent: [37, 37.2, 37] }] as any, 't', NOW ) assert.deepEqual(rejected, [], JSON.stringify(rejected)) assert.equal(accepted[0].eventType, 'energy.equipment') }) /* ── 발전 단가가 지나갈 길 (2026-08-30) ──────────────────────────────────── * * ★ 오늘 다섯 번째다. 커널에 문을 열고 이 갈림에 길을 내지 않으면, 레코드가 아무 문에도 * 들어가지 못하고 조용히 사라진다 — 거부도 되지 않으므로 아무도 모른다. */ test('발전 단가가 그 문으로 간다', () => { const r = ingestCanonicalRecords( [{ from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' }] as any, 't', NOW ) assert.equal(r.rejected.length, 0, `거부되면 안 된다: ${JSON.stringify(r.rejected)}`) assert.equal(r.accepted.length, 1) assert.equal((r.accepted[0] as any).eventType, 'energy.generation.price') assert.equal((r.accepted[0] as any).eventTime, '2026-08-29T15:00:00.000Z', '사건 시각은 기간의 시작이다') }) test('★ 단가·사용 구간·요금 기준·청구서가 한 배치에 섞여 와도 각자의 문으로 간다', () => { const r = ingestCanonicalRecords( [ { from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' }, { meterId: 'incoming', from: '2026-08-29T15:00:00.000Z', to: '2026-08-29T16:00:00.000Z', kWh: 474.48 }, { from: '2026-07-31T15:00:00.000Z', to: '2026-08-31T15:00:00.000Z', billingDemandKW: 1650, demandChargePerKW: 8320, currency: 'KRW' }, { from: '2026-06-30T15:00:00.000Z', to: '2026-07-31T15:00:00.000Z', demandCharge: 13_728_000, currency: 'KRW' } ] as any, 't', NOW ) assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected)) const types = (r.accepted as any[]).map(e => e.eventType).sort() assert.deepEqual(types, ['energy.bill', 'energy.generation.price', 'energy.tariff.basis', 'energy.usage.period']) }) /* ── fact type 마다 시각 칸 이름이 다르다 ──────────────────────────────────── */ test('★ nonconformance 는 decidedAt 을 자기 시각으로 쓴다 — 세 번째로 밟은 자리다', () => { /* * [측정함 2026-09-06] backfill 뒤 mes-line-a 의 nonconformance 4건이 event_time == created_at * 이었다. payload 의 `data.decidedAt` 은 2026-09-01 인데 저장된 시각은 2026-09-05 였다 — 5일 차이. * * push 경로에서는 안 보인다. 사실이 나자마자 보내니 몇 초 차이다. backfill 이 그 간격을 벌려서 * 드러냈다. */ const got = ingestCanonicalRecords( [{ subjectId: 'LOT-A', decision: 'scrap', decidedAt: '2026-09-01T02:50:51.832Z' }] as any, 't', NOW ) assert.equal(got.accepted[0]?.eventTime, '2026-09-01T02:50:51.832Z', 'ingest 시각으로 떨어졌다') assert.deepEqual(got.undated, {}, '시각을 읽었으면 세지 않는다') }) test('★ 시각을 못 읽으면 그 사실을 센다 — 조용히 지금 시각으로 떨어지지 않는다', () => { /* * 오류가 아니다. 절대값 snapshot 은 polling 순간이 맞는 시각이라 여기 세어지는 것이 정상이다. * 다만 그 수가 어디에도 안 남으면 `event_time == created_at` 이 유일한 단서이고, 그것은 우연히 * 같은 것과 구별되지 않는다. */ const got = ingestCanonicalRecords( [{ subjectId: 'LOT-B', decision: 'scrap' }] as any, 't', NOW ) assert.deepEqual(got.undated, { disposition: 1 }) }) test('공통 이름이 있으면 그것이 이긴다 — 봉투 층의 판단이 가장 바깥이다', () => { const got = ingestCanonicalRecords( [{ subjectId: 'L', decision: 'scrap', at: '2026-09-03T00:00:00.000Z', decidedAt: '2026-09-01T00:00:00.000Z' }] as any, 't', NOW ) assert.equal(got.accepted[0]?.eventTime, '2026-09-03T00:00:00.000Z') })