/* * 업무 KPI 폴드 검증 — 저널을 계산해 시간창 지표를 만드는 규칙을 고정한다. * * 무엇을 지키는가: * ① **성과는 완료 시점으로 센다** — 시작 시점으로 세면 진행 중인 것이 섞여 처리량이 부풀려진다. * ② **셈에서 빠진 것을 밝힌다**(unpaired) — 시작 이벤트를 못 찾은 완료를 알리지 않고 버리면 평균이 거짓이 된다. * ③ **없는 것을 0 으로 꾸미지 않는다** — 저널이 비었으면 eventsSeen 0 으로 그 사실이 보인다. * ④ 시간축은 eventTime 차분 — sim 클록 오프셋이 있어도 차분은 정확하다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts' import { judgeTargets, judgeGroupTargets, mergeTargets, scopedTargets } from '../server/engine/kpi-target.ts' const T0 = Date.parse('2026-07-30T00:00:00Z') const at = (sec: number) => new Date(T0 + sec * 1000).toISOString() /** task.status 전이 한 줄. */ const task = (taskId: string, status: string, sec: number, resourceRef?: string): KpiEvent => ({ eventType: 'task.status', eventTime: at(sec), payload: { data: { taskId, status, resourceRef } } }) /** 축을 가진 전이 한 줄 — kind·orderId·toNode 까지(실제 TaskStatusDelta 모양). */ const taskAt = (taskId: string, status: string, sec: number, facets: Record): KpiEvent => ({ eventType: 'task.status', eventTime: at(sec), payload: { data: { taskId, status, ...facets } } }) const order = (orderId: string, status: string, sec: number): KpiEvent => ({ eventType: 'order.status', eventTime: at(sec), payload: { data: { orderId, status } } }) const WINDOW = { fromMs: T0, toMs: T0 + 3600 * 1000 } // 1시간 test('처리량: 창 안에 완료된 작업만 센다(진행 중은 세지 않는다)', () => { const r = foldKpi( [ task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 30), task('t2', 'created', 20), task('t2', 'in-progress', 25) // 완료 없음 — 진행 중 ], WINDOW ) assert.equal(r.throughput.tasks, 1) }) test('처리량: 창 밖에 완료된 것은 세지 않는다 — 창 경계는 완료 시각으로 판정', () => { const r = foldKpi( [task('t1', 'created', -100), task('t1', 'in-progress', -90), task('t1', 'completed', -10)], WINDOW ) assert.equal(r.throughput.tasks, 0) }) test('리드타임·작업시간·대기시간을 각각 낸다 — 셋은 다른 질문에 답한다', () => { /* 생성 0s → 착수 10s → 완료 40s : 리드 40s, 작업 30s, 대기 10s */ const r = foldKpi([task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 40)], WINDOW) assert.equal(r.leadTime.avgMs, 40000) assert.equal(r.workTime.avgMs, 30000) assert.equal(r.waitTime.avgMs, 10000) }) test('분위수: p50·p90 을 낸다(작은 표본에서 과장하지 않는다)', () => { const events: KpiEvent[] = [] /* 완료 소요를 1..10초로 열 건 */ for (let i = 1; i <= 10; i++) { events.push(task(`t${i}`, 'created', 100 + i * 20)) events.push(task(`t${i}`, 'in-progress', 100 + i * 20)) events.push(task(`t${i}`, 'completed', 100 + i * 20 + i)) } const r = foldKpi(events, WINDOW) assert.equal(r.workTime.count, 10) assert.equal(r.workTime.p50Ms, 6000) assert.equal(r.workTime.p90Ms, 10000) }) test('시작 이벤트를 못 찾은 완료는 unpaired 로 밝힌다 — 평균에서 오류 없이 빠지지 않게', () => { const r = foldKpi([task('t1', 'completed', 30)], WINDOW) assert.equal(r.throughput.tasks, 1, '완료는 센다') assert.equal(r.leadTime.count, 0, '리드타임은 계산할 수 없다') assert.equal(r.unpaired.leadTime, 1) assert.equal(r.unpaired.workTime, 1) }) test('창보다 앞선 시작도 짝지어진다 — 호출부가 앞 구간을 넉넉히 넘기면 리드타임이 살아난다', () => { const r = foldKpi([task('t1', 'created', -600), task('t1', 'in-progress', -300), task('t1', 'completed', 60)], WINDOW) assert.equal(r.leadTime.avgMs, 660000) assert.equal(r.unpaired.leadTime, 0) }) test('자원 점유: 작업에 붙어 있던 시간의 합과 창 대비 비율', () => { const r = foldKpi( [ task('t1', 'created', 0), task('t1', 'in-progress', 0, 'fk-1'), task('t1', 'completed', 1800, 'fk-1'), // 30분 task('t2', 'created', 0), task('t2', 'in-progress', 0, 'fk-2'), task('t2', 'completed', 900, 'fk-2') // 15분 ], WINDOW ) assert.deepEqual( r.utilization.map(u => [u.resourceId, u.busyMs]), [ ['fk-1', 1800000], ['fk-2', 900000] ], '점유가 큰 자원부터' ) assert.equal(Math.round(r.utilization[0].ratio * 100), 50, '1시간 창에서 30분 = 50%') }) /* * ── 오더 완료: **동의어를 받지 않는다**(2026-08-23 에 바뀐 규칙) ───────────── * * 이 시험은 예전에 「도메인 어휘를 넓게 받는다(completed/fulfilled/done)」였다 — 그것이 **동의어 목록** * 이었고, 그 방식이 낸 대가가 둘이었다. * * ① 원본의 낱말이 목록에 없으면 완료가 0 이 된다(첫 실 시스템의 `FINISHED` 가 그랬다). * ② **시뮬 트윈에서는 언제나 0 이었다** — 시뮬은 `fulfilled` 만 올리고 `status` 는 바꾸지 않는다. * * 판정은 이제 커널이 한다(`isOrderTerminal`): 1차 근거는 **양**, 낱말은 닫힌 둘(`completed`·`cancelled`). * 표준이 그 축을 갈라 두었다 — `RequestState` 는 표준도 열거하지 않고, 「됐나」는 실적에서 읽는다. */ test('오더 완료: 닫힌 종결 낱말만 받는다 — 동의어는 추론하지 않는다', () => { const r = foldKpi( [order('o1', 'completed', 10), order('o2', 'cancelled', 20), order('o3', 'in-progress', 30)], WINDOW ) assert.equal(r.throughput.orders, 2, 'completed · cancelled 둘') const r2 = foldKpi([order('o4', 'fulfilled', 10), order('o5', 'done', 20), order('o6', 'FINISHED', 30)], WINDOW) assert.equal(r2.throughput.orders, 0, '동의어로 보이는 낱말은 세지 않는다 — 커넥터가 옮길 일이다') assert.deepEqual(r2.orderStatusNotCounted, { fulfilled: 1, done: 1, finished: 1 }, '대신 그 사실을 말한다') }) test('오더 완료: 양이 답한다 — 낱말이 진행 중이라 말해도', () => { /* * 시뮬을 되살리는 자리다. 시뮬 오더는 이행돼도 상태가 `created` 로 남으므로, 낱말로 묻는 판정은 * 그 완료를 한 건도 세지 못했다. */ const qty = (orderId: string, status: string, sec: number, requested: number, fulfilled: number): KpiEvent => ({ eventType: 'order.status', eventTime: at(sec), payload: { data: { orderId, status, requested, fulfilled } } }) const r = foldKpi([qty('o1', 'created', 10, 10, 10), qty('o2', 'created', 20, 10, 9)], WINDOW) assert.equal(r.throughput.orders, 1, '10 중 10 은 끝났고 10 중 9 는 아니다') }) /* * ── 받는 오더는 처리량이 아니다 (2026-09-19, ADR-0076 보탬 ⑥) ──────────────── * * 입고 · 반품 입고는 세우는 순간 `fulfilled >= requested` 다 — 받았다는 사실이 곧 그 오더의 완료이기 * 때문이다. 그대로 세면 도착마다 처리량이 한 건씩 부풀고, 그 수는 화면에서 「많이 해냈다」로 읽힌다. */ test('★ 입고 · 반품 입고는 처리량에서 빠진다 — 받은 것은 해낸 것이 아니다', () => { const kinded = (orderId: string, kind: string, sec: number): KpiEvent => ({ eventType: 'order.status', eventTime: at(sec), payload: { data: { orderId, kind, status: 'received', requested: 10, fulfilled: 10 } } }) const out = (orderId: string, sec: number): KpiEvent => ({ eventType: 'order.status', eventTime: at(sec), payload: { data: { orderId, kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 } } }) const r = foldKpi([kinded('i1', 'inbound', 10), kinded('r1', 'return', 20), out('o1', 30)], WINDOW) assert.equal(r.throughput.orders, 1, '나간 오더 하나만 센다') }) test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => { const r = foldKpi([order('o1', 'completed', 10)], WINDOW) assert.equal(r.throughput.orders, 1) }) test('오더 완료: 같은 오더를 한 번만 센다 — 폴링 원본이 다시 보내도', () => { /* * 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 있다(결함이 아니라 폴링의 성질). * 사건마다 세면 처리량이 알리지 않고 부풀려진다 — 작업 쪽은 커널의 짝맞춤이 이미 그 규율을 갖고 있었고 * 오더만 없었다. */ const r = foldKpi([order('o1', 'completed', 10), order('o1', 'completed', 20), order('o2', 'completed', 30)], WINDOW) assert.equal(r.throughput.orders, 2, '오더 둘 — 사건은 셋이다') }) test('저널이 비면 0 이 아니라 "본 이벤트 없음" 으로 드러난다', () => { const r = foldKpi([], WINDOW) assert.equal(r.eventsSeen, 0) assert.equal(r.eventsInWindow, 0) assert.equal(r.throughput.tasks, 0) assert.equal(r.leadTime.count, 0) }) test('시각이 없거나 깨진 줄은 버린다 — 0 으로 세지 않는다', () => { const r = foldKpi( [ { eventType: 'task.status', payload: { data: { taskId: 'x', status: 'completed' } } }, { eventType: 'task.status', eventTime: 'not-a-date', payload: { data: { taskId: 'y', status: 'completed' } } } ], WINDOW ) assert.equal(r.eventsSeen, 0) assert.equal(r.throughput.tasks, 0) }) test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태 차이에 걸리지 않게', () => { const r = foldKpi( [ { eventType: 'task.status', eventTime: at(0), payload: { taskId: 't1', status: 'in-progress' } }, { eventType: 'task.status', eventTime: at(20), payload: { taskId: 't1', status: 'completed' } } ], WINDOW ) assert.equal(r.workTime.avgMs, 20000) }) test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => { /* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건). * 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */ const r = foldKpi( [ task('t1', 'created', 0), task('t1', 'in-progress', 10, 'fk-1'), task('t1', 'completed', 40, 'fk-1'), task('t1', 'completed', 45, 'fk-1') // 재전송 ], WINDOW ) assert.equal(r.throughput.tasks, 1, '한 건이다') assert.equal(r.workTime.count, 1) assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준') assert.deepEqual( r.utilization.map(u => [u.resourceId, u.busyMs]), [['fk-1', 35000]], '점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)' ) }) test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => { const r = foldKpi( [ task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'in-progress', 15), // 재전송 task('t1', 'completed', 40) ], WINDOW ) assert.equal(r.workTime.avgMs, 30000, '처음 착수(10s) 기준') }) /* ── 구간 쪼개기(추세) ───────────────────────────────────────────────────── * 화면의 추세선이 여기서 나온다. 조회는 한 번이고 창만 바꿔 다시 계산한다. */ test('구간 쪼개기: N 등분해 구간별 처리량을 낸다', () => { /* 1시간 창을 4등분(15분씩). 각 구간에 한 건씩 완료되게 놓는다. */ const events: KpiEvent[] = [] for (let i = 0; i < 4; i++) { const sec = i * 900 + 100 // 100s, 1000s, 1900s, 2800s events.push(task(`t${i}`, 'created', sec - 30), task(`t${i}`, 'in-progress', sec - 20), task(`t${i}`, 'completed', sec)) } const buckets = foldKpiBuckets(events, WINDOW, 4) assert.equal(buckets.length, 4) assert.deepEqual(buckets.map(b => b.tasks), [1, 1, 1, 1]) }) test('구간 쪼개기: 구간 합이 전체 창의 처리량과 같다 — 경계에서 새거나 겹치지 않는다', () => { const events: KpiEvent[] = [] for (let i = 0; i < 20; i++) { const sec = 50 + i * 170 events.push(task(`t${i}`, 'created', sec - 10), task(`t${i}`, 'in-progress', sec - 5), task(`t${i}`, 'completed', sec)) } const whole = foldKpi(events, WINDOW) const buckets = foldKpiBuckets(events, WINDOW, 6) const sum = buckets.reduce((a, b) => a + b.tasks, 0) assert.equal(sum, whole.throughput.tasks) }) test('구간 쪼개기: 마지막 구간이 창의 끝까지 덮는다 — 나눗셈 오차로 마지막 완료가 빠지지 않게', () => { /* 3등분(나누어떨어지지 않는 창) + 창의 마지막 순간에 완료 */ const win = { fromMs: WINDOW.fromMs, toMs: WINDOW.fromMs + 1000 * 1000 } // 1000초 const events = [task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 1000)] const buckets = foldKpiBuckets(events, win, 3) assert.equal(buckets[buckets.length - 1].toMs, win.toMs) assert.equal(buckets.reduce((a, b) => a + b.tasks, 0), 1) }) test('구간 쪼개기: 1 이하면 쪼개지 않는다(추세 없음)', () => { assert.deepEqual(foldKpiBuckets([], WINDOW, 1), []) assert.deepEqual(foldKpiBuckets([], WINDOW, 0), []) }) test('구간 쪼개기: 구간별 작업시간 중앙값을 낸다 — "느려지고 있는가" 를 본다', () => { const events: KpiEvent[] = [ /* 앞 구간: 10초 작업 */ task('a', 'created', 100), task('a', 'in-progress', 100), task('a', 'completed', 110), /* 뒤 구간: 40초 작업 */ task('b', 'created', 2000), task('b', 'in-progress', 2000), task('b', 'completed', 2040) ] const buckets = foldKpiBuckets(events, WINDOW, 2) assert.equal(buckets[0].workP50Ms, 10000) assert.equal(buckets[1].workP50Ms, 40000) }) /* ── 관점 축(groupBy) ─────────────────────────────────────────────────────── * 왜 계산 층에 있는가: 고정된 한 렌즈만 낼 수 있으면 "구역별로는?" 이라는 질문에 화면도 AI 도 답할 수 * 없다. **사실 계산이 다각도를 못 내면 AI 도 못 낸다** — 그래서 축은 폴드의 능력이어야 한다. */ const three = (id: string, sec: number, facets: Record): KpiEvent[] => [ taskAt(id, 'created', sec - 20, facets), taskAt(id, 'in-progress', sec - 10, facets), taskAt(id, 'completed', sec, facets) ] test('축 없이는 groups 를 내지 않는다 — 묻지 않은 것을 실어 보내지 않는다', () => { const r = foldKpi(three('t1', 100, { kind: 'move' }), WINDOW) assert.equal(r.groups, undefined) }) test('작업 종류별로 쪼갠다 — 전체 통계와 같은 규칙으로', () => { const events = [ ...three('a', 100, { kind: 'move' }), ...three('b', 200, { kind: 'move' }), ...three('c', 300, { kind: 'pick' }) ] const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' }) assert.equal(r.groups?.by, 'taskKind') assert.deepEqual( r.groups?.items.map(g => [g.key, g.tasks]), [ ['move', 2], ['pick', 1] ], '건수 큰 축부터' ) const move = r.groups!.items[0] assert.equal(move.workTime.avgMs, 10000, '착수→완료 10초') assert.equal(move.leadTime.avgMs, 20000, '생성→완료 20초') assert.equal(move.busyMs, 20000, '두 건 × 10초') }) test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이 새지 않는다', () => { const events = [ ...three('a', 100, { kind: 'move', orderId: 'o1', toNode: 'dock-1', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'pick', orderId: 'o1', toNode: 'rack-1', resourceRef: 'fk-2' }), ...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' }) ] const whole = foldKpi(events, WINDOW) for (const by of ['resource', 'taskKind', 'location', 'order'] as const) { const r = foldKpi(events, WINDOW, { groupBy: by }) const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0) assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`) } }) test('축의 값이 없던 건은 unknown 으로 모인다 — 알리지 않고 버리지 않는다', () => { const events = [...three('a', 100, { kind: 'move', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'move' })] const r = foldKpi(events, WINDOW, { groupBy: 'resource' }) assert.deepEqual(r.groups?.items.map(g => g.key).sort(), ['fk-1', 'unknown']) }) test('축의 값은 다른 전이에서 와도 읽는다 — 완료 이벤트에 kind 가 없어도 축이 비지 않는다', () => { /* 생성에만 kind 가 실리는 구현이 실제로 있다. 완료 줄만 보면 축이 통째로 unknown 이 된다. */ const events = [ taskAt('a', 'created', 80, { kind: 'putaway', orderId: 'o9' }), taskAt('a', 'in-progress', 90, {}), taskAt('a', 'completed', 100, {}) ] const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' }) assert.deepEqual(r.groups?.items.map(g => g.key), ['putaway']) const byOrder = foldKpi(events, WINDOW, { groupBy: 'order' }) assert.deepEqual(byOrder.groups?.items.map(g => g.key), ['o9']) }) test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값은 마지막 것이 사실', () => { const events = [ taskAt('a', 'created', 80, { toNode: 'dock-1' }), taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }), taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' }) ] const r = foldKpi(events, WINDOW, { groupBy: 'location' }) assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7']) }) test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없으면 노드 id 로 대체하지 않는다', () => { const events = [...three('a', 100, { toNode: 'rack-1' }), ...three('b', 200, { toNode: 'rack-2' })] const blind = foldKpi(events, WINDOW, { groupBy: 'area' }) assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나') const mapped = foldKpi(events, WINDOW, { groupBy: 'area', locationArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } }) assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다') }) test('축이 많으면 잘라내고 잘린 축의 수와 그 안의 건수를 함께 알린다 — 들어온 것이 없는 절단 금지', () => { /* 축마다 건수를 다르게 둔다: 축의 수만 알리면 "그 외 20개" 가 20건인지 60건인지 알 수 없다. */ const events: KpiEvent[] = [] let t = 0 for (let i = 0; i < 25; i++) { const n = i < 5 ? 3 : 1 // 앞 5개 축은 3건씩, 나머지 20개 축은 1건씩 for (let k = 0; k < n; k++) events.push(...three(`t${t++}`, 100 + t * 10, { orderId: `o${i}` })) } const r = foldKpi(events, WINDOW, { groupBy: 'order', groupLimit: 5 }) assert.equal(r.groups?.items.length, 5) assert.equal(r.groups?.truncated, 20, '잘린 축의 수') assert.equal(r.groups?.truncatedTasks, 20, '잘린 축들이 안고 있던 건수') const whole = foldKpi(events, WINDOW) assert.equal( r.groups!.items.reduce((a, g) => a + g.tasks, 0) + r.groups!.truncatedTasks!, whole.throughput.tasks, '보여준 건수 + 잘린 건수 = 전체(축의 수가 아니라 건수로 맞는다)' ) }) test('짝을 못 찾은 건도 축에서 센다 — 건수는 세고 소요만 비운다', () => { const r = foldKpi([taskAt('a', 'completed', 100, { kind: 'move' })], WINDOW, { groupBy: 'taskKind' }) const g = r.groups!.items[0] assert.equal(g.tasks, 1) assert.equal(g.workTime.count, 0) assert.equal(g.busyMs, 0) }) /* ── 시간 비교 ────────────────────────────────────────────────────────────── * "29초" 만으로는 좋아졌는지 나빠졌는지 알 수 없다. 창을 옮겨 한 번 더 계산 규칙을 고정한다. */ test('직전 구간: 같은 길이로 바로 앞 — 경계가 맞물린다(겹치거나 벌어지지 않게)', () => { const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous') assert.equal(w.toMs, WINDOW.fromMs, '앞 구간의 끝 = 현재 구간의 시작') assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs, '길이가 같다') }) test('어제 같은 시간: 정확히 24시간 전 같은 자리', () => { const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'yesterday') assert.equal(WINDOW.fromMs - w.fromMs, 24 * 60 * 60 * 1000) assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs) }) test('중앙값 차이: 현재 − 비교구간', () => { const now = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 } const prev = { count: 5, p50Ms: 30000, p90Ms: 0, avgMs: 0 } assert.equal(deltaP50(now, prev), 10000, '10초 느려졌다') assert.equal(deltaP50(prev, now), -10000, '부호로 방향을 말한다(좋고 나쁨은 판단하지 않는다)') }) test('중앙값 차이: 한쪽이 결측이면 차이는 없다 — 0 으로 내면 "변화 없음" 이라는 거짓이 된다', () => { const measured = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 } const missing = { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 } assert.equal(deltaP50(measured, missing), undefined) assert.equal(deltaP50(missing, measured), undefined) }) test('비교: 옮긴 창으로 계산하면 앞 구간의 성과가 나온다 — 같은 폴드, 창만 다르다', () => { /* 현재 구간에 2건, 직전 구간에 1건. */ const events = [ ...three('now-1', 100, { kind: 'move' }), ...three('now-2', 200, { kind: 'move' }), ...three('prev-1', -1800, { kind: 'move' }) ] const now = foldKpi(events, WINDOW) const prev = foldKpi(events, shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous')) assert.equal(now.throughput.tasks, 2) assert.equal(prev.throughput.tasks, 1) assert.equal(now.throughput.tasks - prev.throughput.tasks, 1, '처리량이 1건 늘었다') }) test('창 앞 구간만 읽었으면 "측정된 기록 있음" 이 아니다 — lookback 을 성과로 착각하지 않게', () => { /* 호출부는 창보다 앞까지 읽어 넘긴다(완료 작업의 시작이 창 밖일 수 있어서). 그 앞 구간 기록만 * 있는데 eventsSeen 으로 판단하면 "0건 처리" 라고 말하게 된다 — 실제로 가능했던 오류다. */ const before = [task('t1', 'created', -1200), task('t1', 'in-progress', -1100), task('t1', 'completed', -1000)] const r = foldKpi(before, WINDOW) assert.equal(r.eventsSeen, 3, '넘겨받은 것은 3줄') assert.equal(r.eventsInWindow, 0, '창 안에는 아무것도 없다 → "기록 없음"') assert.equal(r.throughput.tasks, 0) }) test('창 안 기록만 센다 — 앞 구간이 섞여도 창 안 개수는 정확하다', () => { const events = [ task('old', 'completed', -500), // 창 밖(앞) ...three('in-1', 100, { kind: 'move' }), // 창 안 3줄 task('late', 'completed', 4000) // 창 밖(뒤) ] const r = foldKpi(events, WINDOW) assert.equal(r.eventsSeen, 5) assert.equal(r.eventsInWindow, 3) }) /* ── 관점 × 비교 교차 ─────────────────────────────────────────────────────── * 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. 그 교차가 운영에서 가장 * 값 있는 질문이다. */ const g = (key: string, tasks: number, workMs = 10000) => { const events = [] for (let i = 0; i < tasks; i++) { const sec = 100 + i * 30 events.push( taskAt(`${key}-${i}`, 'created', sec - 20, { kind: key }), taskAt(`${key}-${i}`, 'in-progress', sec, { kind: key }), taskAt(`${key}-${i}`, 'completed', sec + workMs / 1000, { kind: key }) ) } return foldKpi(events, WINDOW, { groupBy: 'taskKind' }).groups!.items[0] } test('교차: 두 구간에 모두 있는 축은 delta 를 낸다', () => { const c = crossGroups([g('pick', 10, 20000)], [g('pick', 7, 10000)]) const it = c.items[0] assert.equal(it.prevTasks, 7) assert.equal(it.deltaTasks, 3) assert.equal(it.deltaWorkP50Ms, 10000, '작업시간이 10초 늘었다') assert.deepEqual(c.disappeared, []) }) test('교차: 지금만 있는 축은 isNew — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다', () => { const c = crossGroups([g('putaway', 5)], [g('pick', 5)]) const nw = c.items.find(x => x.key === 'putaway')! assert.equal(nw.isNew, true) assert.equal(nw.deltaTasks, undefined) assert.equal(nw.prevTasks, undefined) }) test('교차: 사라진 축을 따로 알린다 — 멈춘 구역이 표에서 오류 없이 빠지지 않게', () => { const c = crossGroups([g('pick', 5)], [g('pick', 5), g('putaway', 12)]) assert.deepEqual(c.disappeared, [{ key: 'putaway', tasks: 12 }]) }) test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어든 것을 섞지 않는다', () => { const c = crossGroups([g('pick', 3), g('putaway', 4)], []) assert.equal(c.items.every(x => x.isNew === true), true) assert.deepEqual(c.disappeared, []) }) /* ── 교대 축 ───────────────────────────────────────────────────────────────── * * 3교대처럼 하루를 끊김 없이 덮는 현장에서는 캘린더가 가용 시간을 줄이지 않는다. 그러면 교대 선언이 * 무엇을 주는가: **"언제" 를 묻는 축**이다. "어느 교대가 느린가" 는 3교대 공장에서 가장 먼저 나오는 * 질문인데, 그 축이 없으면 답이 없었다. * * **저널에는 교대가 없다 — 계산한다.** 이벤트에 교대 이름을 찍어 두면 나중에 교대 시간을 고쳤을 때 * **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 완료 시각 + 현장의 시각 기준 + 교대 선언에서 낸다. */ const SHIFTS_3 = [ { id: 'A', fromTime: '06:00', toTime: '14:00' }, { id: 'B', fromTime: '14:00', toTime: '22:00' }, { id: 'C', fromTime: '22:00', toTime: '06:00', toDayOffset: 1 as const } ] /** 하루치 창 — 세 교대가 모두 들어오게. */ const DAY = { fromMs: T0, toMs: T0 + 24 * 3600 * 1000 } /** `hour` 시(UTC)에 완료되는 작업 한 건. */ const doneAtHour = (id: string, hour: number): KpiEvent[] => [ task(id, 'created', hour * 3600 - 60), task(id, 'in-progress', hour * 3600 - 30), task(id, 'completed', hour * 3600) ] test('교대별로 쪼갠다 — 완료 시각에서 계산한다(저널에 없는 축)', () => { const r = foldKpi( [...doneAtHour('a1', 7), ...doneAtHour('a2', 9), ...doneAtHour('b1', 15), ...doneAtHour('c1', 23)], DAY, { groupBy: 'shift', shifts: SHIFTS_3 } ) const by = Object.fromEntries((r.groups?.items ?? []).map(g => [g.key, g.tasks])) assert.deepEqual(by, { A: 2, B: 1, C: 1 }) assert.equal(r.groups?.items.reduce((s, g) => s + g.tasks, 0), r.throughput.tasks, '축의 합이 전체와 같다') }) test('현장의 시각 기준으로 읽는다 — UTC 로 알리지 않고 가정하지 않는다', () => { const events = [...doneAtHour('t1', 20)] // 절대 20시 const utc = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }) const local = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3, utcOffsetMinutes: -420 }) assert.equal(utc.groups?.items[0].key, 'B', 'UTC 20시는 B 교대') assert.equal(local.groups?.items[0].key, 'A', 'Rosarito(UTC−7) 기준으로는 13시 — A 교대다') }) test('교대를 선언하지 않은 현장은 unknown — 없는 구분을 지어내지 않는다', () => { const r = foldKpi([...doneAtHour('t1', 7)], DAY, { groupBy: 'shift' }) assert.deepEqual((r.groups?.items ?? []).map(g => g.key), ['unknown']) }) test('교대 정의를 고치면 과거가 다시 세어진다 — 저널에 찍어 뒀다면 굳었을 것', () => { const events = [...doneAtHour('t1', 7)] const twoShift = [ { id: 'DAY', fromTime: '07:00', toTime: '19:00' }, { id: 'NIGHT', fromTime: '19:00', toTime: '07:00', toDayOffset: 1 as const } ] assert.equal(foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }).groups?.items[0].key, 'A') assert.equal( foldKpi(events, DAY, { groupBy: 'shift', shifts: twoShift }).groups?.items[0].key, 'DAY', '같은 저널, 다른 교대 정의 → 다시 센다' ) }) /* ── 목표 대비 판정 ─────────────────────────────────────────────────────────── * * 화면과 AI 는 "처리량 345건" 을 정확히 말하지만 **그것이 좋은지 나쁜지는 말할 수 없었다.** 근거가 앞 * 구간 비교뿐이라 "어제보다 나아졌다" 가 한계이고, 어제도 나빴으면 쓸모가 없다. * * 판정은 **사실 계산과 섞지 않는다** — 폴드는 목표를 모르고, 판정은 순수 함수로 따로 있다. */ const kpiOf = (events: KpiEvent[]) => foldKpi(events, DAY) test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없다', () => { const kpi = kpiOf([...doneAtHour('t1', 7), ...doneAtHour('t2', 8)]) const [more] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 2 }]) assert.equal(more.direction, 'higher') assert.equal(more.met, true, '처리량은 목표 이상이면 만족') const [faster] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 60_000 }]) assert.equal(faster.direction, 'lower', '시간은 낮을수록 좋다 — 입력이 아니라 지표가 안다') assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초') }) test('미달을 미달로 판정한다', () => { const kpi = kpiOf([...doneAtHour('t1', 7)]) const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }]) assert.equal(v.met, false) assert.equal(v.actual, 1) assert.equal(v.ratio, 0.2, '비율은 항상 "1 이상이면 좋다" 로 정규화된다') }) test('비율은 방향이 달라도 같은 뜻이다 — 한 화면에서 비교할 수 있게', () => { const kpi = kpiOf([...doneAtHour('t1', 7)]) const [slow] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 15_000 }]) // 실제 30초, 목표 15초 assert.equal(slow.met, false) assert.equal(slow.ratio, 0.5, '두 배 느리면 0.5 — 처리량이 절반일 때와 같은 숫자') }) test('목표가 없으면 판정하지 않는다', () => { assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), []), []) assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), []) }) test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 판정이 반대로 뒤집힌다', () => { const empty = foldKpi([], DAY) /* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면 시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 — 미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). */ assert.deepEqual(judgeTargets(empty, [{ metric: 'workTime.p50Ms', target: 15_000 }]), []) assert.deepEqual(judgeTargets(empty, [{ metric: 'throughput.tasks', target: 10 }]), [], '기록 없음 ≠ 처리량 0') }) test('모르는 지표 이름은 짐작해 매핑하지 않는다', () => { assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), [{ metric: 'cycleTime.p50' as never, target: 1 }]), []) }) /* ── 목표의 범위 (공간 vs 인스턴스) ────────────────────────────────────────── * * 이 규칙은 원래 DB 접근 뒤에 숨어 있어 **화면을 눌러 봐야만** 확인할 수 있었다. 로그인 뒤 화면을 * 자동으로 검증할 방법이 없는 프로젝트라 그런 규칙은 알리지 않고 미검증으로 쌓인다 — 그래서 밖으로 뺐다. */ const row = (metric: string, target: number, scope: { spaceId?: string; instanceId?: string }) => ({ metric, target, spaceId: scope.spaceId ?? null, instanceId: scope.instanceId ?? null }) test('공간 목표가 그 공간의 모든 트윈에 걸린다', () => { const got = mergeTargets([row('throughput.tasks', 100, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: ['t1', 't2'] }) assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 100 }]) }) test('인스턴스 목표가 공간 목표를 덮는다 — 예외를 표현할 수 있어야 한다', () => { const got = mergeTargets( [row('throughput.tasks', 100, { spaceId: 'site-a' }), row('throughput.tasks', 40, { instanceId: 't2' })], { spaceId: 'site-a', instanceIds: ['t2'] } ) assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 40 }], '"이 라인만 다르다" 를 말할 수 있어야 한다') }) test('다른 공간·다른 인스턴스의 목표는 새어 들어오지 않는다', () => { const got = mergeTargets( [row('throughput.tasks', 999, { spaceId: 'site-b' }), row('workTime.p50Ms', 999, { instanceId: 'other' })], { spaceId: 'site-a', instanceIds: ['t1'] } ) assert.deepEqual(got, []) }) test('모르는 지표 이름·숫자 아닌 값은 목표로 세지 않는다', () => { const got = mergeTargets( [row('cycleTime.p50', 10, { spaceId: 'site-a' }), row('throughput.tasks', Number.NaN, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: [] } ) assert.deepEqual(got, [], '평가할 수 없는 목표는 "목표 없음" 으로 읽혀 갭을 감춘다 — 아예 세지 않는다') }) test('공간을 안 고르고 트윈 하나만 볼 때도 그 인스턴스 목표는 걸린다', () => { const got = mergeTargets([row('waitTime.p50Ms', 5000, { instanceId: 't1' })], { instanceIds: ['t1'] }) assert.deepEqual(got, [{ metric: 'waitTime.p50Ms', target: 5000 }]) }) /* ── 축별 목표 ─────────────────────────────────────────────────────────────── * * 구역·교대마다 목표가 다를 수 있다("이 라인만 40건"). 그런데 축 목표는 **전체 목표를 덮지 않는다** — * 다른 질문이기 때문이다: "현장 전체 100건" 과 "용접 구역 30건" 은 동시에 참일 수 있다. 덮게 만들면 * 축 하나에 목표를 거는 순간 전체 판정이 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다. */ const arow = (metric: string, target: number, extra: Record = {}) => ({ metric, target, spaceId: 'site-a', instanceId: null, axis: null, axisKey: null, ...extra }) test('축 목표와 전체 목표가 함께 산다 — 덮지 않는다', () => { const got = scopedTargets( [arow('throughput.tasks', 100), arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], { spaceId: 'site-a', instanceIds: [], axis: 'area' } ) assert.deepEqual(got.overall, [{ metric: 'throughput.tasks', target: 100 }], '전체 목표는 그대로 있다') assert.deepEqual(got.byAxisKey.weld, [{ metric: 'throughput.tasks', target: 30 }]) }) test('지금 보는 축의 목표만 온다 — 다른 축의 목표가 섞이지 않는다', () => { const got = scopedTargets( [arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' }), arow('throughput.tasks', 50, { axis: 'shift', axisKey: 'A' })], { spaceId: 'site-a', instanceIds: [], axis: 'shift' } ) assert.deepEqual(Object.keys(got.byAxisKey), ['A']) }) test('축을 안 보고 있으면 축 목표는 오지 않는다 — 전체만', () => { const got = scopedTargets([arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], { spaceId: 'site-a', instanceIds: [] }) assert.deepEqual(got.byAxisKey, {}) assert.deepEqual(got.overall, []) }) test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않는다', () => { const group = { key: 'weld', tasks: 12, leadTime: { count: 12, p50Ms: 90_000 }, workTime: { count: 12, p50Ms: 30_000 }, waitTime: { count: 0, p50Ms: 0 } } const v = judgeGroupTargets(group, [ { metric: 'throughput.tasks', target: 10 }, { metric: 'workTime.p50Ms', target: 20_000 }, { metric: 'waitTime.p50Ms', target: 5_000 }, { metric: 'utilization.ratio', target: 0.8 } ]) assert.deepEqual( v.map(x => [x.metric, x.met]), [ ['throughput.tasks', true], ['workTime.p50Ms', false] ], '대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함' ) }) /* * ── 에너지 원단위 (§10 7단계) ─────────────────────────────────────────────── * * 원단위는 분자(전력량)와 분모(산출)를 **같은 창**에서 나눈 값이다. 그래서 별도 질의가 아니라 이 폴드 * 안에 있다 — 창이 하나뿐이면 어긋날 수 없다. * * 분자는 저널의 **구간 마감 사건**에서 온다(커널 상태가 아니라 저널이므로 멈춘 트윈·과거 구간에도 * 답한다). 그 값이 파생임을 `basis` 가 말하고, 못 센 구간 수를 함께 낸다. */ const T = Date.parse('2026-08-14T00:00:00.000Z') const q = 15 * 60_000 const win = (startMs: number, endMs: number, meanKW?: number) => ({ eventType: 'energy.demand.window', eventTime: new Date(endMs).toISOString(), payload: { data: { startMs, endMs, ...(meanKW !== undefined ? { meanKW } : {}) } } }) const orderDone = (atMs: number) => ({ eventType: 'order.status', eventTime: new Date(atMs).toISOString(), payload: { data: { status: 'completed' } } }) test('에너지 사건이 없으면 에너지 블록을 내지 않는다 — 없는 것을 만들지 않는다', () => { const r = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q }) assert.equal(r.energy, undefined) }) test('구간 마감에서 전력량과 대당 원단위를 낸다 — 같은 창이므로 어긋날 수 없다', () => { const r = foldKpi( [win(T, T + q, 400), win(T + q, T + 2 * q, 400), orderDone(T + 60_000), orderDone(T + q + 60_000)], { fromMs: T, toMs: T + 2 * q } ) assert.equal(r.energy?.kWh, 200, '400kW × 0.25h × 2구간') assert.equal(r.energy?.basis, 'mean-kw', '파생임을 밝힌다') assert.equal(r.energy?.counted, 2) assert.equal(r.energy?.intensity.value, 100, '200kWh ÷ 완료 2건') assert.equal(r.energy?.intensity.unit, 'kWh/unit') }) test('그 창에 완료가 없으면 원단위는 정의되지 않는다 — 전력량은 그대로 낸다', () => { const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }) assert.equal(r.energy?.kWh, 100, '전력량은 사실이다') assert.equal(r.energy?.intensity.value, null) assert.equal(r.energy?.intensity.reason, 'zero-denominator', '무한으로 내지 않는다') }) test('계측이 끊긴 구간은 세지 않고 몇 개를 못 셌는지 말한다', () => { const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q), orderDone(T + 60_000)], { fromMs: T, toMs: T + 2 * q }) assert.equal(r.energy?.counted, 1) assert.equal(r.energy?.skipped, 1, '알리지 않고 빼면 이 합이 그 창의 전부인 줄 읽는다') }) test('창에 걸친 구간은 세지 않는다 — 부분을 비례로 자르면 추정이 하나 더 생긴다', () => { const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q, 400)], { fromMs: T, toMs: T + q + 60_000 }) assert.equal(r.energy?.counted, 1) assert.equal(r.energy?.kWh, 100) }) /* * ── 창의 최대수요 (2026-08-17) ────────────────────────────────────────────── * * 전력량은 요금의 사용량 부분이고, 최대수요는 **기본요금과 계약 위반**을 정한다. 평균으로 갈음하면 * 계약을 넘긴 창이 「안 넘겼다」로 보고된다 — 15분 평균 400kW 인 창의 최대가 1,200kW 일 수 있다. */ const winMax = (startMs: number, endMs: number, meanKW: number, maxKW: number) => ({ eventType: 'energy.demand.window', eventTime: new Date(endMs).toISOString(), payload: { data: { startMs, endMs, meanKW, maxKW } } }) test('창의 최대수요는 구간 최대들 중 가장 큰 값이다 — 평균으로 갈음하지 않는다', () => { const r = foldKpi([winMax(T, T + q, 400, 620), winMax(T + q, T + 2 * q, 400, 1180)], { fromMs: T, toMs: T + 2 * q }) assert.equal(r.energy?.kWh, 200) assert.equal(r.energy?.peakKW, 1180) assert.equal(r.energy?.peakStartMs, T + q, '언제였는지도 답할 수 있어야 한다') }) test('구간이 최대를 싣지 않으면 최대수요는 없다 — 0 도 평균도 아니다', () => { const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }) assert.equal(r.energy?.kWh, 100) assert.equal(r.energy?.peakKW, undefined) }) /* * ── 에너지 목표 (2026-08-17) ──────────────────────────────────────────────── * * 값은 폴드가 내고 있었는데 목표를 걸 수 있는 어휘에 없었다 — 그래서 화면은 「1,240kWh」를 보이면서도 * 그것이 계약 안쪽인지 말하지 못했다. 방향은 셋 다 「낮을수록 좋다」로 못 박혀 있다. */ test('최대수요 목표 — 계약 상한을 넘겼는지 판정한다', () => { const kpi = foldKpi([winMax(T, T + q, 400, 1180)], { fromMs: T, toMs: T + q }) const [v] = judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }]) assert.equal(v.direction, 'lower', '많이 쓸수록 좋은 에너지 목표는 없다') assert.equal(v.actual, 1180) assert.equal(v.met, false, '계약 800kW 를 넘겼다') }) test('전력량·원단위에도 목표를 건다', () => { const kpi = foldKpi([win(T, T + q, 400), orderDone(T + 60_000)], { fromMs: T, toMs: T + q }) const [kwh] = judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }]) assert.equal(kwh.actual, 100) assert.equal(kwh.met, true) const [per] = judgeTargets(kpi, [{ metric: 'energy.intensity', target: 80 }]) assert.equal(per.actual, 100, '100kWh ÷ 완료 1건') assert.equal(per.met, false) }) test('에너지를 못 잰 창은 판정하지 않는다 — 0 으로 읽으면 안 쓴 것이 만점이 된다', () => { const kpi = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q }) assert.equal(judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }]).length, 0) assert.equal(judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }]).length, 0) /* 원단위는 분모가 없어 `null` 로 온다 — 그것도 판정 대상이 아니다. */ const noOrders = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }) assert.equal(noOrders.energy?.intensity.value, null) assert.equal(judgeTargets(noOrders, [{ metric: 'energy.intensity', target: 80 }]).length, 0) }) /* * ── 분기별 몫 (2026-08-17) ────────────────────────────────────────────────── * * 저널의 에너지 사실은 **현장 총량 한 줄**이다. 그래서 자리별 몫은 잰 것이 아니라 **나눈 것**이고, * 그 사실을 값과 함께 내야 한다. 이 검사가 지키는 것: 합 보존 · 근거 표시 · 모르는 자리를 지어내지 * 않기 · 선언이 없으면 분해하지 않기. */ const runs = (taskId: string, resource: string, fromSec: number, toSec: number) => [ { eventType: 'task.status', eventTime: new Date(T + fromSec * 1000).toISOString(), payload: { data: { taskId, status: 'in-progress', resourceRef: resource } } }, { eventType: 'task.status', eventTime: new Date(T + toSec * 1000).toISOString(), payload: { data: { taskId, status: 'completed', resourceRef: resource } } } ] test('분기별 몫 — 가동시간에 비례해 나누고, 합은 총량을 지킨다', () => { /* press 300초 · weld 100초 → 3:1. 총량 100kWh. */ const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)] const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } }) assert.equal(r.energy?.kWh, 100) const by = r.energy?.byFeeder ?? [] assert.equal(by.length, 2) const press = by.find(x => x.key === 'feeder-press')! const weld = by.find(x => x.key === 'feeder-weld')! assert.ok(Math.abs(press.kWh - 75) < 1e-6, `press ${press.kWh}`) assert.ok(Math.abs(weld.kWh - 25) < 1e-6, `weld ${weld.kWh}`) /* 합 보존 — 이것이 깨지면 어딘가에서 전기가 사라지거나 생겨난 것이고, 그 위 모든 원단위가 거짓이다. */ const sum = by.reduce((a, x) => a + x.kWh, 0) + (r.energy?.unattributedKWh ?? 0) assert.ok(Math.abs(sum - 100) < 1e-6, `합 ${sum}`) }) test('몫에는 근거가 붙는다 — 여럿을 나눈 것이면 배분이라고 말한다', () => { const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)] const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } }) const row = r.energy!.byFeeder![0] assert.equal(row.basis, 'apportioned') assert.equal(row.weightKind, 'runtimeMs') assert.ok(row.weightShare > 0) }) /* * 선언된 부하가 **하나뿐인** 현장 — 커널은 「그 계량기는 그것만 잰다」고 읽는다(모델이 그렇게 말했다). * 우리가 그것을 배분으로 낙인찍지 않는다: 처음에는 하드코딩으로 그렇게 했고, 그건 커널이 판정한 것을 * 화면에서 바꿔 말하는 짓이었다. 반대로 「돌아간 자원만 덮는다」고 거짓 선언을 넘기면 여럿인 현장에서도 * 이 경로로 빠져 배분이 측정으로 보고된다 — 그것이 실제로 처음의 결함이었다. */ test('선언된 부하가 하나면 커널의 판정을 그대로 옮긴다', () => { const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)] const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } }) assert.equal(r.energy!.byFeeder![0].basis, 'measured') }) test('돌지 않은 설비는 0kWh 를 받지 않고, 몇 대가 빠졌는지 센다', () => { const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)] const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld', 'paint-1': 'feeder-paint' } }) const keys = (r.energy!.byFeeder ?? []).map(x => x.key) assert.deepEqual(keys, ['feeder-press'], '가동 기록이 없는 자리는 행을 만들지 않는다(0 을 지어내지 않는다)') assert.equal(r.energy!.excludedResources, 2, '빠진 설비 수를 밝힌다') }) test('자리를 모르는 자원은 unknown 으로 모인다 — 빼지도, 아무 분기에 넣지도 않는다', () => { const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 200), ...runs('t2', 'ghost-1', 0, 200)] const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } }) const by = r.energy!.byFeeder! assert.ok(by.some(x => x.key === 'unknown'), '모르는 자리를 지어내지 않는다') const sum = by.reduce((a, x) => a + x.kWh, 0) assert.ok(Math.abs(sum - 100) < 1e-6, '모르는 몫까지 합해 총량을 지킨다') }) test('선언이 없으면 분해하지 않는다 — 자리를 모르는 채 나눈 숫자는 아무 것의 몫도 아니다', () => { const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)] const r = foldKpi(events, { fromMs: T, toMs: T + q }) assert.equal(r.energy?.byFeeder, undefined) assert.equal(r.energy?.kWh, 100, '총량은 그대로 낸다') }) test('그 창에 아무도 돌지 않았으면 나누지 않고 이유를 낸다', () => { /* 부하가 둘 이상 선언된 현장 — 나눌 대상은 있는데 몫이 없다. */ const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } }) assert.equal(r.energy?.byFeeder, undefined, '가동 기록이 없으면 나눌 근거가 없다') assert.equal(r.energy?.unattributedReason, 'no-weights') assert.equal(r.energy?.unattributedKWh, 100, '나누지 못한 양은 그대로 남는다(0 으로 만들지 않는다)') assert.equal(r.energy?.kWh, 100) }) /* * ── 금액 (2026-08-17) ─────────────────────────────────────────────────────── * * 트윈은 「최대수요 328kW」까지 말하고 멈춰 있었다. 피크를 깎는 일이 돈이 되는 이유가 요금의 구조에 * 있으므로(사용량 + 기본요금) 단가를 선언하면 금액이 나온다. 계산은 커널이 하고 폴드는 나른다 — * 이 검사는 **같은 창의 값에서 나오는지**와 **단가가 없을 때 지어내지 않는지**를 본다. */ test('단가를 선언하면 이 창의 금액이 나온다 — 전력량과 최대수요 각각에서', () => { const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q }, { tariff: { energyChargePerKWh: 0.1, demandChargePerKW: 20, currency: 'USD' } }) assert.equal(r.energy?.kWh, 100) assert.equal(r.energy?.peakKW, 1000) const c: any = r.energy?.cost assert.equal(c.energyCharge, 10, '100kWh × 0.1') assert.equal(c.demandCharge, 20_000, '1,000kW × 20') assert.equal(c.currency, 'USD') assert.equal(c.demandBasis, 'window-peak-as-period-peak', '기본요금의 조건이 값과 함께 온다') }) test('단가를 선언하지 않으면 금액을 만들지 않는다', () => { const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q }) assert.equal(r.energy?.cost, undefined) }) test('최대수요를 못 잰 창은 기본요금을 지어내지 않는다', () => { const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20, energyChargePerKWh: 0.1 } }) const c: any = r.energy?.cost assert.equal(c.energyCharge, 10) assert.equal(c.demandCharge, undefined) }) test('에너지 사실이 없는 창에는 금액도 없다 — 0 원이라고 말하지 않는다', () => { const r = foldKpi([orderDone(T + 1000)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20 } }) assert.equal(r.energy, undefined) }) /* * ── 깎인 양 (2026-08-17) ──────────────────────────────────────────────────── * * 요금은 계통 수요로 매겨지고, 그 수요를 만든 것은 설비 부하다. 두 수의 간격이 곧 태양광·배터리가 * 덜어낸 몫이다 — 그것이 피크 셰이빙의 값이다. **같은 구간의 두 수**여야 차이가 뜻을 갖는다. */ const winNet = (startMs: number, endMs: number, meanKW: number, maxKW: number, grossMaxKW?: number) => ({ eventType: 'energy.demand.window', eventTime: new Date(endMs).toISOString(), payload: { data: { startMs, endMs, meanKW, maxKW, ...(grossMaxKW !== undefined ? { grossMaxKW } : {}) } } }) test('피크 구간의 총부하와 깎인 양을 낸다', () => { const r = foldKpi([winNet(T, T + q, 300, 400, 600), winNet(T + q, T + 2 * q, 500, 900, 1200)], { fromMs: T, toMs: T + 2 * q }) assert.equal(r.energy?.peakKW, 900) assert.equal(r.energy?.peakGrossKW, 1200) assert.equal(r.energy?.shavedKW, 300, '1,200 − 900 — 그 순간 계통에서 덜어낸 몫') }) test('깎인 것이 없으면 그 값을 만들지 않는다 — 0 을 성과로 적지 않는다', () => { const r = foldKpi([winNet(T, T + q, 300, 400, 400)], { fromMs: T, toMs: T + q }) assert.equal(r.energy?.peakGrossKW, 400) assert.equal(r.energy?.shavedKW, undefined) }) test('총부하를 싣지 않은 옛 구간은 그대로 — 순수요와 같다고 단정하지 않는다', () => { const r = foldKpi([winMax(T, T + q, 400, 900)], { fromMs: T, toMs: T + q }) assert.equal(r.energy?.peakKW, 900) assert.equal(r.energy?.peakGrossKW, undefined) assert.equal(r.energy?.shavedKW, undefined) }) /* * ── 분기 계량이 있으면 배분하지 않는다 (2026-08-18) ─────────────────────────── * 실 게이트웨이는 분기마다 계량기를 보낸다. 그런데 화면은 「나누지 못함」으로 답했다 — 폴드에 가동시간 * 배분 경로만 있었기 때문이다. 미러 트윈에는 설비 가동 기록조차 없어(원천이 계량만 준다) 그 경로로는 * 분기별 값이 영원히 나오지 않았다. */ const windowFact = (startMs: number, points: { id: string; kWh: number; root?: boolean }[], meanKW = 400) => ({ eventType: 'energy.demand.window', eventTime: new Date(startMs + 900_000).toISOString(), instanceId: 'ems-1', payload: { data: { startMs, endMs: startMs + 900_000, meanKW, maxKW: meanKW, samples: 30, points: points.map(p => ({ id: p.id, kWh: p.kWh, ...(p.root ? { root: true } : {}) })) } } }) test('분기별 값이 계량에서 온다 — 근거는 measured, 가동시간 이야기를 하지 않는다', () => { const t0 = Date.parse('2026-08-18T00:00:00.000Z') const r = foldKpi([windowFact(t0, [ { id: 'meter-incoming', kWh: 100, root: true }, { id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 30 } ]) as any], { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } }) const e = r.energy! const byKey = new Map((e.byFeeder ?? []).map(f => [f.key, f])) assert.equal(byKey.get('feeder-a')?.kWh, 40) assert.equal(byKey.get('feeder-a')?.basis, 'measured') assert.equal(byKey.get('feeder-a')?.weightKind, undefined, '나누지 않았으니 무엇으로 나눴다고 말하지 않는다') assert.equal(byKey.get('feeder-b')?.kWh, 30) }) test('하위 계량이 덮지 않는 나머지를 남긴다 — 어느 분기에 얹지 않는다', () => { const t0 = Date.parse('2026-08-18T01:00:00.000Z') /* 구간 평균 400kW × 0.25h = 100kWh 가 총량이고, 분기 합은 70 이다. */ const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 30 }]) as any], { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } }) const e = r.energy! assert.equal(Math.round(e.kWh!), 100) assert.equal(Math.round(e.unattributedKWh!), 30) assert.equal(e.unattributedReason, 'not-submetered', '「선언이 없다」가 아니라 「계량이 덮지 않는다」') const sum = (e.byFeeder ?? []).reduce((s, f) => s + f.kWh, 0) + (e.unattributedKWh ?? 0) assert.ok(Math.abs(sum - e.kWh!) < 1e-6, `합이 보존된다(${sum} vs ${e.kWh})`) }) test('분기 합이 뿌리를 넘으면 그 사실을 낸다 — 깎아 맞추지 않는다', () => { const t0 = Date.parse('2026-08-18T02:00:00.000Z') const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 90 }, { id: 'meter-b', kWh: 30 }]) as any], { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } }) const e = r.energy! assert.equal(Math.round(e.submeterExcessKWh!), 20, '120 − 100') assert.equal(e.byFeeder?.find(f => f.key === 'feeder-a')?.kWh, 90, '계량값은 그대로 남는다') }) test('계량 지도가 없으면 예전 배분 경로 그대로 — 거동을 바꾸지 않는다', () => { const t0 = Date.parse('2026-08-18T03:00:00.000Z') const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }]) as any], { fromMs: t0, toMs: t0 + 900_000 }, { resourceFeeder: { 'press-1': 'feeder-a', 'press-2': 'feeder-b' } }) const e = r.energy! /* 선언된 부하가 둘인데 가동 기록이 없으니 나눌 몫이 없다 — 총량은 그대로 남고 이유가 붙는다(예전 거동). */ assert.equal(e.unattributedReason, 'no-weights') assert.equal(e.byFeeder, undefined, '계량 경로를 타지 않았다') }) test('피크를 만든 분기를 그 순간의 kW 로 답한다 — 전력량 큰 분기와 다를 수 있다', () => { const t0 = Date.parse('2026-08-18T06:00:00.000Z') const t1 = t0 + 900_000 /* 두 구간: 앞 구간이 전력량은 크지만(a 가 꾸준히), 피크는 뒤 구간에서 b 가 만든다. */ const w0 = { eventType: 'energy.demand.window', eventTime: new Date(t1).toISOString(), instanceId: 'ems-1', payload: { data: { startMs: t0, endMs: t1, meanKW: 400, maxKW: 500, samples: 30, points: [{ id: 'meter-a', kWh: 80 }, { id: 'meter-b', kWh: 20 }], pointsAtPeak: [{ id: 'meter-a', kW: 450 }, { id: 'meter-b', kW: 50 }] } } } const w1 = { eventType: 'energy.demand.window', eventTime: new Date(t1 + 900_000).toISOString(), instanceId: 'ems-1', payload: { data: { startMs: t1, endMs: t1 + 900_000, meanKW: 300, maxKW: 900, samples: 30, points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 35 }], pointsAtPeak: [{ id: 'meter-a', kW: 300 }, { id: 'meter-b', kW: 600 }] } } } const r = foldKpi([w0, w1] as any, { fromMs: t0, toMs: t1 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } }) const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f])) assert.equal(byKey.get('feeder-a')?.kWh, 120, '전력량은 a 가 크다') /* 피크 값은 **최대수요를 낸 구간 하나**에서 온다 — 두 구간을 섞으면 다른 순간의 값을 더하는 것이다. */ assert.equal(byKey.get('feeder-b')?.peakKW, 600, '피크를 만든 것은 b 다') assert.equal(byKey.get('feeder-a')?.peakKW, 300) }) test('피크 구간에 표본이 없던 분기는 피크 값을 만들지 않는다', () => { const t0 = Date.parse('2026-08-18T07:00:00.000Z') const w = { eventType: 'energy.demand.window', eventTime: new Date(t0 + 900_000).toISOString(), instanceId: 'ems-1', payload: { data: { startMs: t0, endMs: t0 + 900_000, meanKW: 200, maxKW: 300, samples: 10, points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 10 }], pointsAtPeak: [{ id: 'meter-a', kW: 300 }] } } } const r = foldKpi([w] as any, { fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } }) const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f])) assert.equal(byKey.get('feeder-a')?.peakKW, 300) assert.equal(byKey.get('feeder-b')?.peakKW, undefined, '그 순간 값을 모르면 만들지 않는다') }) /* * ── 완료로 읽지 못한 오더 상태 낱말을 **센다** (2026-08-23) ────────────────── * * 오더의 상태 어휘는 계약이 도메인에 넘긴 것이라(`picking`·`shipped`·`FINISHED`…) 이 폴드는 「완료로 * 읽히는 표현」을 동의어 목록으로 받는다. 그 대가는 **목록에 없는 낱말이 알리지 않고 0 으로 세어진다**는 것이다. * * 실제로 그랬다: `finished` 가 빠져 있어 첫 실 시스템의 완료 오더가 전부 0 이었고, 사람이 화면을 보고 * 의심해서야 드러났다. 목록을 넓히는 것으로는 다음 원본을 막지 못한다 — 그래서 **못 읽은 것을 센다.** */ test('완료로 센 낱말은 여기 오지 않는다 — 이 값은 세지 않은 것의 인구조사다', () => { const r = foldKpi( [order('o-1', 'completed', 10), order('o-2', 'CANCELLED', 20), order('o-3', 'cancelled', 30)], WINDOW ) assert.equal(r.throughput.orders, 3, '대소문자를 가리지 않는다') assert.deepEqual(r.orderStatusNotCounted, {}, '완료로 센 낱말은 여기 오지 않는다') }) test('세지 않은 낱말이 건수와 함께 남는다 — 완료 0 의 원인이 곧 보이게', () => { const r = foldKpi( [order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 20), order('o-3', 'picking', 30)], WINDOW ) assert.equal(r.throughput.orders, 0, '목록에 없는 낱말을 완료로 세지 않는다 — 추론하지 않는 것이 옳다') assert.deepEqual( r.orderStatusNotCounted, { closed: 2, picking: 1 }, '무엇을 몇 건 세지 않았는지 말한다 — picking 이 함께 오는 것은 정상이다(완료가 아니다)' ) }) test('창 밖의 낱말은 세지 않는다 — 이 값도 창의 사실이다', () => { const r = foldKpi([order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 7200)], WINDOW) assert.deepEqual(r.orderStatusNotCounted, { closed: 1 }, '창 안의 것만 센다') }) test('상태를 말하지 않은 사건은 여기 오지 않는다 — 그것은 다른 결함이다', () => { /* * 빈 낱말을 여기 섞으면 이 값의 뜻이 흐려진다 — 상태가 없는 사건은 **다른 결함**이고(유입 문이 상태를 * 필수로 받는다) 그 진단은 그 문의 몫이다. */ const r = foldKpi([{ eventType: 'order.status', eventTime: at(10), payload: { data: { orderId: 'o-1' } } }], WINDOW) assert.deepEqual(r.orderStatusNotCounted, {}) }) /* ── 밖에서 마감되어 온 사용 구간과 청구서 (2026-08-29) ──────────────────── * * ★ 계량 표본이 오지 않는 현장이 있다. 원본이 「이 구간에 X 썼다」를 이미 마감해 준다. * 그 값을 쓰지 않으면 그 현장의 화면은 영영 빈손이다. * * 두 회계를 **더하지 않는다** — 같은 전기를 두 번 센다. */ const usagePeriod = (kWh: number, fromSec: number, toSec: number, extra: Record = {}): KpiEvent => ({ eventType: 'energy.usage.period', eventTime: at(toSec), payload: { data: { meterId: 'incoming-1', from: at(fromSec), to: at(toSec), kWh, ...extra } } }) test('★ 계량 표본이 없어도 사용 구간으로 답한다 — 근거를 밝힌다', () => { const k = foldKpi([usagePeriod(474.48, 0, 1800, { basis: 'provider' }), usagePeriod(471.6, 1800, 3600, { basis: 'provider' })], WINDOW) assert.equal(k.energy?.kWh, 474.48 + 471.6) assert.equal(k.energy?.kWhBasis, 'usage-period') assert.equal(k.energy?.usage?.counted, 2) assert.deepEqual(k.energy?.usage?.byBasis, [{ basis: 'provider', counted: 2 }]) }) test('창에 걸친 구간은 세지 않는다 — 반만 세면 합이 창을 넘는다', () => { const k = foldKpi([usagePeriod(100, -1800, 1800)], WINDOW) assert.equal(k.energy?.usage, undefined) }) test('★ 구간마다 제 단가로 매긴다 — 단일 단가로 접지 않는다', () => { /* 새벽 87.3 · 낮 222.3 — 하나로 접으면 「피크를 깎아 얼마를 아끼나」가 사라진다. */ const k = foldKpi( [ usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW', basis: 'provider' }), usagePeriod(100, 1800, 3600, { unitPrice: 222.3, currency: 'KRW', basis: 'provider' }) ], WINDOW, { tariff: { energyChargePerKWh: 140.2, currency: 'KRW' } } as any ) assert.equal(k.energy?.usage?.charge, 100 * 87.3 + 100 * 222.3) assert.equal((k.energy?.cost as any)?.energyCharge, 100 * 87.3 + 100 * 222.3, '구간별 단가가 이긴다') assert.ok(!(k.energy?.cost as any)?.notModeled.includes('tou')) }) test('일부만 단가를 실었으면 요금을 만들지 않고 그 수를 낸다', () => { const k = foldKpi( [usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW' }), usagePeriod(100, 1800, 3600)], WINDOW ) assert.equal(k.energy?.usage?.charge, undefined, '있는 것만 더하면 그 합이 창의 요금인 척한다') assert.equal(k.energy?.usage?.unpriced, 1) }) test('★ 청구서는 사실로 나란히 둔다 — 창이 다르면 견주지 않는다', () => { const bill: KpiEvent = { eventType: 'energy.bill', eventTime: at(3600), payload: { data: { from: at(600), to: at(3000), total: 1_000_000, currency: 'KRW', billingDemandKW: 1650 } } } const k = foldKpi([bill], WINDOW) assert.equal(k.energy?.bill?.total, 1_000_000) assert.equal(k.energy?.bill?.billingDemandKW, 1650) assert.equal(k.energy?.bill?.matchesWindow, false, '한 달 청구서를 하루 창의 값과 빼면 아무것도 뜻하지 않는다') assert.equal(k.energy?.bill?.ourTotalMinusBilled, undefined) }) test('★ 창이 청구 주기와 같으면 우리 계산과의 차이를 낸다 — 0 이 아니면 우리가 틀렸다', () => { const bill: KpiEvent = { eventType: 'energy.bill', eventTime: at(3600), payload: { data: { from: at(0), to: at(3600), total: 100_000, currency: 'KRW' } } } const k = foldKpi([bill, usagePeriod(1_000, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, { tariff: { energyChargePerKWh: 87.3, currency: 'KRW' } } as any) assert.equal(k.energy?.bill?.matchesWindow, true) assert.equal(k.energy?.bill?.diff?.total, 1_000 * 87.3 - 100_000) }) test('★ 총액이 없는 청구서도 항목별로 견준다 — 실제 첫 청구서에 기본요금만 있었다', () => { const bill: KpiEvent = { eventType: 'energy.bill', eventTime: at(3600), payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW', billingDemandKW: 1650 } } } const k = foldKpi([bill], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1_650, currency: 'KRW' } } as any) assert.equal(k.energy?.bill?.diff?.demandCharge, 0, '요금적용전력으로 매기면 청구서와 같다') assert.equal(k.energy?.bill?.diff?.total, undefined, '한쪽에 없는 항목은 견주지 않는다') }) test('요금적용전력을 선언하지 않으면 기본요금이 어긋나는 것이 드러난다', () => { const bill: KpiEvent = { eventType: 'energy.bill', eventTime: at(3600), payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW' } } } /* 선언이 없으면 커널은 창의 피크로 조건부 값을 낸다 — 그 차이가 그대로 나온다. */ const peakWindow: KpiEvent = { eventType: 'energy.demand.window', eventTime: at(1800), payload: { data: { startMs: T0 + 900_000, endMs: T0 + 1_800_000, maxKW: 600, meanKW: 600, samples: 4 } } } const k = foldKpi([bill, peakWindow], WINDOW, { tariff: { demandChargePerKW: 8_320, currency: 'KRW' } } as any) const d = k.energy?.bill?.diff?.demandCharge assert.ok(d !== undefined && d !== 0, `어긋남이 드러나야 한다: ${d}`) }) /* ── 요금적용전력은 청구서가 먼저다 (2026-08-29) ─────────────────────────── * * ★ 그 값은 최근 몇 달의 최고 수요로 **매달 다시 정해진다.** 선언에 박아 두면 사람이 매달 * 갱신해야 하고, 안 하면 낡은 값으로 기본요금이 조용히 틀린다. */ const billEvent = (fromSec: number, toSec: number, data: Record): KpiEvent => ({ eventType: 'energy.bill', eventTime: at(toSec), payload: { data: { from: at(fromSec), to: at(toSec), currency: 'KRW', ...data } } }) test('★ 창을 덮는 청구서가 있으면 그 값으로 매긴다', () => { const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand?.kW, 1650) assert.equal(k.energy?.billingDemand?.from, 'bill') assert.equal((k.energy?.cost as any)?.demandCharge, 1650 * 8_320, '선언 1200 이 아니라 청구서 1650 으로 매긴다') }) test('★ 선언이 청구서와 다르면 낡았다고 말한다 — 감추면 아무도 다시 읽지 않는다', () => { const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand?.declaredKW, 1200) assert.equal(k.energy?.billingDemand?.stale, true) }) test('선언과 청구서가 같으면 낡았다고 하지 않는다', () => { const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand?.stale, undefined) }) test('★ 창에 청구서가 없으면 마지막 청구서의 값을 이어 쓴다 — 요금 제도가 그렇게 군다', () => { const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, { tariff: { demandChargePerKW: 8_320, currency: 'KRW' }, lastBill: { to: at(-86400), billingDemandKW: 1650 } } as any) assert.equal(k.energy?.billingDemand?.kW, 1650) assert.equal(k.energy?.billingDemand?.from, 'carried-forward') assert.equal((k.energy?.cost as any)?.demandBasis, 'billing-demand') }) test('청구가 오지 않는 현장은 선언을 쓴다', () => { const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 900, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand?.from, 'declaration') assert.equal(k.energy?.billingDemand?.kW, 900) assert.equal(k.energy?.billingDemand?.stale, undefined) }) test('셋 다 없으면 이 칸을 만들지 않는다 — 빈 값을 지어내지 않는다', () => { const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, { tariff: { demandChargePerKW: 8_320, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand, undefined) }) test('★ 관측한 최고가 청구 기준을 넘으면 얼마나 넘었는지와 언제까지의 값인지를 낸다', () => { /* 기본요금은 이 값으로 다시 정해진다 — 지금 줄여도 되돌아가지 않는다. */ const k = foldKpi([usagePeriod(400, 0, 1800, { maxKW: 1200 }), usagePeriod(400, 1800, 3600, { maxKW: 1700 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' } } as any) const e = k.energy?.billingDemand?.exceeded assert.equal(e?.peakKW, 1700) assert.equal(e?.overKW, 50) assert.equal(e?.observedTo, at(3600), '이 최고값이 언제까지의 것인지') }) test('넘지 않으면 그 칸을 만들지 않는다', () => { const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1200 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' } } as any) assert.equal(k.energy?.billingDemand?.exceeded, undefined) }) test('청구서가 말한 기준으로 넘었는지 본다 — 선언이 아니다', () => { const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1300 })], WINDOW, { tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }, lastBill: { to: at(-86400), billingDemandKW: 1200 } } as any) assert.equal(k.energy?.billingDemand?.kW, 1200) assert.equal(k.energy?.billingDemand?.exceeded?.overKW, 100) }) /* ── 이용률 (2026-08-30) ─────────────────────────────────────────────────── * * ★ 분모가 어느 정격인지가 값의 뜻을 바꾼다. 붙어 있는 원본은 직류로 나눠 2.2% 를 보여 주는데, * 교류로 나누면 4.08% 다. 같은 발전소를 두 화면에서 보는 사람이 두 배 다른 수를 본다. * * 그래서 커널은 교류를 먼저 쓰되(계통으로 나가는 양은 교류 정격을 넘을 수 없다) **어느 쪽으로 * 나눴는지를 값과 함께 낸다.** */ const generationPeriod = (kWh: number, fromSec: number, toSec: number): KpiEvent => ({ eventType: 'energy.generation.period', eventTime: at(toSec), payload: { data: { equipmentId: 'inv-1', kWh, periodStart: at(fromSec), periodEnd: at(toSec), observedFrom: at(fromSec), observedTo: at(toSec) } } }) test('★ 이용률은 만든 양 ÷ (정격 × 그 기간의 시간)', () => { /* 한 시간에 63.8kWh · 정격 127.6kW → 50% */ const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, { generationRated: { kW: 127.6, basis: 'ac', from: 'location' } } as any) const cf = k.energy?.generated?.capacityFactor assert.equal(cf?.value, 0.5) assert.equal(cf?.ratedKW, 127.6) assert.equal(cf?.hours, 1) }) test('★ 어느 정격으로 나눴는지 함께 낸다 — 두 화면의 수가 달라도 이유를 말할 수 있게', () => { const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, { generationRated: { kW: 234.35, basis: 'dc', from: 'location' } } as any) assert.equal(k.energy?.generated?.capacityFactor?.basis, 'dc') assert.equal(k.energy?.generated?.capacityFactor?.from, 'location') }) test('★ 창 길이가 아니라 센 기간들의 길이로 나눈다', () => { /* * 한 시간짜리 기간 하나만 센 창에서 창 길이(1시간)로 나누는 것과 같지만, 기간이 창보다 짧으면 * 달라진다. 창 길이로 나누면 그 기간이 창 전체에 걸쳐 있었던 것처럼 작아진다. */ const k = foldKpi([generationPeriod(63.8, 0, 1800)], WINDOW, { generationRated: { kW: 127.6, basis: 'ac', from: 'location' } } as any) assert.equal(k.energy?.generated?.capacityFactor?.hours, 0.5) assert.equal(k.energy?.generated?.capacityFactor?.value, 1, '30분에 정격만큼 만들었으면 100%다') }) test('정격이 없으면 이용률을 내지 않는다 — 0 이나 짐작을 만들지 않는다', () => { const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW) assert.equal(k.energy?.generated?.capacityFactor, undefined) assert.equal(k.energy?.generated?.kWh, 63.8, '발전량 자체는 그대로 낸다') })