/* * 예측 지표 — **스냅샷에서 무엇을 세는가.** * * 여기서 틀리면 예측 그래프가 알리지 않고 다른 것을 그린다(오류 없이 곡선이 나온다). 특히 에너지 지표는 * 「없음」과 「0」이 같은 그림이 되기 쉬워서, 그 경계를 명시로 잡아 둔다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { FORECAST_METRICS, metricsForKind } from '../server/service/twin-forecast/forecast-metrics.ts' test('물류 지표 — 기존 여섯은 그대로 센다', () => { const s = { items: [1, 2, 3], locations: [{ occupancy: 2 }, { occupancy: 5 }], tasks: [{}, {}], orders: [{ status: 'shipped' }, { status: 'picking' }, { status: 'cancelled' }] } assert.equal(FORECAST_METRICS.items(s), 3) assert.equal(FORECAST_METRICS.occupancy(s), 7) assert.equal(FORECAST_METRICS.tasks(s), 2) assert.equal(FORECAST_METRICS.orders(s), 3) assert.equal(FORECAST_METRICS.shipped(s), 1) /* 취소는 백로그가 아니다 — 대기와 취소를 같이 세면 「안 빠지는 일감」이 부풀려진다. */ assert.equal(FORECAST_METRICS.backlog(s), 1) }) /* * ── 받는 오더는 흐름 지표에서 빠진다 (2026-09-19, ADR-0076 보탬 ⑥) ────────── * * 입고 · 반품 입고는 도착이 세우고 도착이 이행한다. 세면 「나간 것」이 받을 때마다 늘고 백로그는 * 영영 안 줄어든다 — 그래프가 있지도 않은 일을 그린다. */ test('★ 입고 · 반품 입고는 세지 않는다 — 그 오더는 나가지 않는다', () => { const s = { orders: [ { kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'picking' }, { kind: 'inbound', status: 'received', requested: 10, fulfilled: 10 }, { kind: 'return', status: 'received', requested: 3, fulfilled: 3 } ] } assert.equal(FORECAST_METRICS.orders(s), 2, '흐름 오더 둘만 센다') assert.equal(FORECAST_METRICS.shipped(s), 1) assert.equal(FORECAST_METRICS.backlog(s), 1, '받은 오더가 백로그에 남으면 곡선이 안 내려간다') }) /* * ── 백로그는 「종결인가」로 묻는다 ────────────────────────────────────────── * * 낱말 둘로 물을 때 원본이 `completed` 를 쓰는 미러에서 끝난 오더가 영영 백로그에 남았다. `status` 는 * 열린 축이고 실 시스템은 자기 낱말을 쓴다 — 이 셈은 낱말이 아니라 양을 1차 근거로 본다. */ test('★ 원본의 낱말로 끝난 오더도 백로그에서 빠진다', () => { const s = { orders: [ { kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 }, { kind: 'outbound', status: 'FINISHED', requested: 2, fulfilled: 2 }, { kind: 'outbound', status: 'picking', requested: 4, fulfilled: 1 } ] } assert.equal(FORECAST_METRICS.backlog(s), 1, '아직 안 끝난 하나만 백로그다') }) test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => { const s = { orders: [{ status: 'picking' }, { status: 'shipped' }] } assert.equal(FORECAST_METRICS.orders(s), 2) assert.equal(FORECAST_METRICS.shipped(s), 1) }) test('에너지 지표 — 커널이 낸 값을 그대로 읽는다(다시 계산하지 않는다)', () => { const s = { energy: { peakSince: { kW: 812 }, open: { meanKW: 300, projectedKW: 455, projectionBasis: 'mean-so-far' }, closed: [{ overContract: true }, { overContract: false }, { overContract: true }] } } assert.equal(FORECAST_METRICS.peakKW(s), 812) assert.equal(FORECAST_METRICS.projectedKW(s), 455) assert.equal(FORECAST_METRICS.overWindows(s), 2) }) test('투영이 아직 없으면 지금까지의 평균으로 — 0 으로 떨어뜨리지 않는다', () => { assert.equal(FORECAST_METRICS.projectedKW({ energy: { open: { meanKW: 120 } } }), 120) }) test('에너지가 없는 스냅샷 — 세 지표 모두 0(그리지 않을 값이지, 관측한 0 이 아니다)', () => { assert.equal(FORECAST_METRICS.peakKW({ items: [] }), 0) assert.equal(FORECAST_METRICS.projectedKW({ items: [] }), 0) assert.equal(FORECAST_METRICS.overWindows({ items: [] }), 0) }) test('계약을 모르면 초과 구간은 0 — overContract 가 없는 구간을 초과로 세지 않는다', () => { const s = { energy: { closed: [{ maxKW: 900 }, { maxKW: 1200 }] } } assert.equal(FORECAST_METRICS.overWindows(s), 0) }) test('종류별 지표 목록 — 에너지 트윈에는 물류 지표가 섞이지 않는다', () => { const ems = metricsForKind('ems') assert.deepEqual(ems, ['projectedKW', 'peakKW', 'overWindows']) assert.equal(ems.includes('items' as any), false) assert.equal(metricsForKind('wms').includes('items'), true) /* 종류를 모르면 물류로 — 기존 트윈(kind 미선언)이 지표를 잃지 않는다. */ assert.equal(metricsForKind(undefined).includes('items'), true) }) test('목록의 모든 키가 실제로 계산 가능하다 — 화면에 뜨는데 서버가 모르는 지표는 없다', () => { for (const kind of ['ems', 'wms', 'mes', 'yms']) { for (const key of metricsForKind(kind)) { assert.equal(typeof (FORECAST_METRICS as any)[key], 'function', `${kind}:${key}`) } } }) /* * ★ Folding does not move the count (ADR-0092 decision 3, ruling 2026-09-24). * A twin folds finished orders out of its hot list and keeps kind × status counts. Counting the hot list * alone dropped orders/shipped at every fold, and made the gap trend's actual increase negative. */ const hot = (orders: any[], foldedOrders?: any) => ({ orders, ...(foldedOrders ? { foldedOrders } : {}) }) test('folding the finished orders leaves orders and shipped where they were', () => { const before = hot([ { id: 'o1', kind: 'outbound', status: 'shipped' }, { id: 'o2', kind: 'outbound', status: 'cancelled' }, { id: 'o3', kind: 'outbound', status: 'picking' }, { id: 'r1', kind: 'inbound', status: 'completed' } ]) const after = hot([{ id: 'o3', kind: 'outbound', status: 'picking' }], { counts: { outbound: { shipped: 1, cancelled: 1 }, inbound: { completed: 1 } }, boundaryMs: 1 }) for (const m of ['orders', 'shipped', 'backlog'] as const) assert.equal(FORECAST_METRICS[m](after), FORECAST_METRICS[m](before), m) assert.equal(FORECAST_METRICS.orders(after), 3, 'receiving orders are not counted, folded or not') }) test('a fold between T and T+h keeps the actual increase from going negative', () => { const atT = hot([{ kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'picking' }]) /* by T+h: one more shipped, and the three shipped have been folded */ const atTh = hot([{ kind: 'outbound', status: 'picking' }], { counts: { outbound: { shipped: 3 } }, boundaryMs: 2 }) assert.equal(FORECAST_METRICS.shipped(atTh) - FORECAST_METRICS.shipped(atT), 1) assert.ok(FORECAST_METRICS.orders(atTh) - FORECAST_METRICS.orders(atT) >= 0) }) test('a twin with nothing folded counts as before', () => { const s = hot([{ kind: 'outbound', status: 'shipped' }, { status: 'picking' }]) assert.equal(FORECAST_METRICS.orders(s), 2) assert.equal(FORECAST_METRICS.shipped(s), 1) }) test('a folded order with no kind is a flow order, as it is in the hot list', () => { const folded = hot([], { counts: { '': { shipped: 2 } }, boundaryMs: 1 }) const unfolded = hot([{ status: 'shipped' }, { status: 'shipped' }]) assert.equal(FORECAST_METRICS.orders(folded), FORECAST_METRICS.orders(unfolded)) assert.equal(FORECAST_METRICS.shipped(folded), FORECAST_METRICS.shipped(unfolded)) })