/* * 업무 KPI 폴드 — **이벤트 저널을 계산해** 시간창 지표를 만든다(순수). * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 지금 트윈이 답할 수 있는 것은 "이 순간의 값"(점유 92%)과 "인프라 부하"(초당 이벤트 수)뿐이다. * 운영이 묻는 것은 다르다 — **지난 한 시간에 몇 건 처리했나, 한 건에 얼마나 걸리나, 자원은 얼마나 * 일했나.** 그 값이 없으면 AI 는 "점유 92%" 는 말해도 "목표보다 나쁜가" 는 답할 수 없다. * * ── 원료는 이미 있다 ──────────────────────────────────────────────────────── * 저널(`TwinEvent`)에 `task.status`·`order.status`·`equipment.status` 전이가 `eventTime`(ISO)과 함께 * append-only 로 쌓인다. 그래서 새 계측을 심을 필요가 없다 — **계산만 하면 된다.** * 같은 모양의 선례가 이 폴더에 있다(`oee-accumulator.ts` — equipment 전이를 계산해 OEE 카운터 생산). * * ── 이 파일이 하지 않는 것 ────────────────────────────────────────────────── * · 목표와 비교하지 않는다. "95% 목표 대비" 는 목표를 저장하는 별개 층(경영 KPI)의 몫이고, * 이 층은 **사실만** 생산한다. 두 층을 섞으면 목표를 정의하지 않은 현장은 사실조차 못 본다. * · 체류시간(dwell)은 아직 내지 않는다. 물건이 노드에 머문 시간은 item 단위 이벤트(epcis.*)를 * 짝지어야 하고, 그 짝맞춤 규칙이 도메인마다 다르다. **없는 것을 0 으로 꾸미지 않는다.** * · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다). */ /* * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것, * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니 * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다). * 이 파일은 이제 "계산한 기록 → 창 지표" 만 한다. */ import { attributeEnergy, electricityCost, energyIntensity, energyOfWindows, foldTaskRecords, type ElectricityCost, type TariffDeclaration, type TaskFacets } from '@operato/twin-kernel' import { type WorkCalendarEntry } from '@operato/ops-contract' import { ENERGY_EVENT, isFlowOrder, isOrderTerminal, activeShiftAt } from '@operato/ops-contract' /** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */ export interface KpiEvent { eventType?: string /** ISO 시각. 파싱 불가하면 그 줄은 버린다(알리지 않고 0 으로 세지 않는다). */ eventTime?: string payload?: any } export interface KpiWindow { /** 창의 시작·끝(epoch ms). 완료 시각이 이 안에 든 것만 성과로 센다. */ fromMs: number toMs: number } export interface DurationStats { /** 짝이 맞아 계산된 건수. */ count: number /** * 하위 꼬리 — **분포를 양쪽에서 계산하지하기 위해** 낸다. * p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다. */ p10Ms: number p50Ms: number p90Ms: number avgMs: number } /** * 관점 축 — **같은 사실을 다른 각으로** 본다. * * 고정된 한 렌즈(자원별)만 낼 수 있으면 사용자가 "구역별로는?" 이라고 물어도 답이 없다. 사실 계산이 * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다. * * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나) * location 도착 지점별(어디서) order 오더별(무엇을 위해) * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다) * shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다) */ export type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift' export interface KpiGroup { /** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */ key: string /** 이 축에서 완료된 건수. */ tasks: number leadTime: DurationStats workTime: DurationStats waitTime: DurationStats /** 이 축에 붙어 있던 시간의 합(자원 축에서 특히 의미 있다). */ busyMs: number /** * **품질 판정이 있었던 건수만** 센다 (2026-08-19). * * 판정이 없는 작업(이동·체류·중간 스테이션)은 어느 쪽에도 세지 않는다 — 모름을 양품으로 세면 양품률이 * 알리지 않고 100% 로 올라간다. 그래서 `good + scrap` 은 `tasks` 보다 작을 수 있고, 그것이 정상이다. * * 판정이 하나도 없으면 **필드를 만들지 않는다**(0/0 을 「양품률 0%」로 읽히게 두지 않는다). */ quality?: { good: number; scrap: number } } export interface KpiResult { window: { fromMs: number; toMs: number } /** 창 안에 **완료된** 건수 — 성과는 완료 시점으로 센다(시작 시점으로 세면 진행 중인 것이 섞인다). */ throughput: { tasks: number; orders: number } /** * 에너지 — **이 창의 전력량과 원단위.** 에너지 트윈이 없으면 `undefined`(0 이 아니다). * * ── 왜 KPI 폴드 안에 있나 (2026-08-14, §10 7단계) ───────────────────────── * 원단위는 분자(전력량)와 분모(산출)를 **같은 창**에서 나눈 값이다. 질의를 따로 두면 두 창을 맞추는 * 일이 소비처의 몫이 되고, 어긋난 두 사실을 나눈 숫자는 아무것도 뜻하지 않는다(커널의 규칙이 그것을 * 거절한다). 같은 폴드 안에 두면 창이 하나뿐이라 어긋날 수 없다. * * 분자는 **저널의 구간 마감 사건**에서 온다(`energy.demand.window`) — 커널 상태가 아니라 저널이므로 * 멈춘 트윈·과거 구간에도 답할 수 있다. */ energy?: { /** 이 창의 전력량. 셀 수 있는 구간이 없으면 `undefined`. */ kWh?: number /** * 이 창의 **최대수요**(kW)와 그것이 난 구간의 시작. * * 전력량과 다른 질문이다: 전력량은 요금의 사용량 부분이고, 최대수요는 **기본요금**과 계약 위반을 * 정한다. 평균으로 갈음할 수 없어서 커널이 구간의 최대들 중 가장 큰 값을 따로 낸다. */ peakKW?: number peakStartMs?: number /** * 그 피크 구간에서 **설비가 끌어당긴** 최대(발전·방전 차감 전)와 그 차이(깎인 양). * * 요금은 계통 수요(`peakKW`)로 매겨지지만, 「무엇이 그 수요를 만들었나」와 「무엇이 덜어냈나」는 * 이 두 수의 간격이 답한다. 발전·축전을 선언하지 않은 현장에서는 없다(차감할 것이 없다). */ peakGrossKW?: number shavedKW?: number /** * 이 창의 **발전량** — 커널이 마감한 기간 사실(`energy.generation.period`)에서 온다. * * 부하(`kWh`)와 더하지 않는다: 발전은 설비가 만든 양이고 부하는 계량 지점을 지난 양이다. 두 수를 * 더하면 어느 쪽도 아닌 숫자가 된다. * * 성과지표(ISO 50001 EnPI)와 태양광 성능비가 이 값을 분자로 쓴다. 발전 사실이 없으면 `undefined` * 다(0 이 아니다 — 「발전이 없다」와 「발전 설비가 없다」는 다르다). */ generated?: { /** 창에 온전히 든 기간들의 합(kWh). */ kWh: number /** 센 기간 수. */ counted: number /** * 그중 **관측이 기간을 다 덮지 못한** 기간 수. * * 커널이 기간마다 관측 구간을 함께 낸다. 마지막 표본이 기간 끝보다 이르면 그만큼 재지 않은 * 것이고, 그 수를 감추면 화면이 덜 잰 합을 온전한 합으로 말한다. */ partial: number /** 설비별 몫 — 이것은 **측정**이다(배분이 아니다). 각 설비의 적산에서 왔다. */ byEquipment: { id: string; kWh: number }[] /** * **이 창에 다 들어오지 않아 더하지 못한 기간** — 조용히 버리지 않는다. * * 더하는 것은 창에 온전히 든 기간뿐이다(걸친 기간을 더하면 다른 날 값이 이 창의 값인 척한다). * 그런데 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 드는 일이 거의 없다 — 창이 한낮에서 * 시작하기 때문이다. 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고, 사용자는 그것을 * 「발전량이 없다」로 읽었다(2026-08-30 실측). * * 이 값이 있으면 화면은 **「이 구간에 다 들어오지 않은 기간이 있다」**를 말해야 한다. */ notCounted?: { periods: number; kWh: number; earliestStart: string; latestEnd: string } /** * 이용률 — 만든 양 ÷ (정격 × 그 기간의 시간). * * **어느 정격으로 나눴는지를 함께 낸다.** 변환기를 낀 발전은 직류 정격이 교류보다 크고, * 직류로 나눠 보여 주는 시스템이 실제로 있다. 밝히지 않으면 두 화면의 수가 두 배 달라도 * 왜 다른지 말할 수 없다. * * 창 전체가 아니라 **센 기간들의 길이**로 나눈다 — 하루치 하나만 센 24시간 창에서 창 길이로 * 나누면 그 하루가 창 전체에 걸쳐 있었던 것처럼 작아진다. */ capacityFactor?: { value: number; ratedKW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location'; hours: number } /** * 낸 것의 값 — 기간마다 그때의 단가로 매긴다. * * **모든 기간에 단가가 있을 때만** 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인 * 척한다 — 못 매긴 기간 수를 함께 낸다. */ revenue?: { amount: number; currency: string; priced: number; unpriced: number } } /** * `kWh` 가 어느 회계에서 왔나. * * 'demand-window' 우리가 표본에서 마감한 구간 (계량이 오는 현장) * 'usage-period' 밖에서 마감되어 온 구간 (계량 표본이 없는 현장) * * 둘을 더하지 않는다 — 같은 전기를 두 번 센다. 둘 다 있으면 계량 쪽을 쓰고 그 사실을 낸다. */ kWhBasis?: 'demand-window' | 'usage-period' /** * 기본요금을 매긴 **요금적용전력과 그 근거**. * * 이 값은 매달 다시 정해진다. 선언에 박아 두면 사람이 갱신해야 하고, 안 하면 낡은 값으로 * 기본요금이 조용히 틀린다. 그래서 **청구서가 먼저다** — 원본이 매일 실어 보낸다. * * 'bill' 이 창을 덮는 청구서가 말한 값 * 'carried-forward' 가장 최근 청구서의 값을 이어 쓴다(요금 제도가 실제로 그렇게 군다) * 'declaration' 청구가 오지 않는 현장 — 모델의 선언 * * `declaredKW` 와 다르면 **선언이 낡았다**(`stale`). 그 사실을 감추면 아무도 다시 읽지 않는다. */ billingDemand?: { kW: number from: 'tariff-basis' | 'bill' | 'carried-forward' | 'declaration' declaredKW?: number stale?: boolean /** * 이 창에서 관측한 최고 수요가 그 기준을 넘었다 — **다음 주기의 기본요금이 오른다.** * * 계약 초과와 다른 값이다. 계약 초과는 지금 줄이면 되고, 이것은 이미 정해진 결과다. * * `observedTo` 는 이 최고값이 언제까지의 것인지다. 최고 수요는 구간이 마감되어야 오므로 * 넘은 것을 뒤늦게 안다 — 그 한계를 화면이 말할 수 있어야 한다. */ exceeded?: { peakKW: number; overKW: number; observedTo: string } } /** * 밖에서 마감되어 온 사용 구간 — 계량 표본이 없는 현장의 유일한 근거다. * * `basis` 별 건수를 함께 낸다: 같은 kWh 라도 계량기가 잰 것과 공급자 화면에서 긁어온 것은 무게가 * 다르고, 실제로 긁어온 값이 망가져 있는 것을 본 적이 있다. */ usage?: { kWh: number counted: number /** 구간마다 제 단가로 매긴 사용량 요금 — 모든 구간이 단가를 실었을 때만. */ charge?: number currency?: string /** 단가가 없어 요금을 매기지 못한 구간 수 — 조용히 빼지 않는다. */ unpriced: number byBasis: { basis: string; counted: number }[] } /** * 이 창에 온전히 든 **청구서** — 공급자가 확정한 금액. * * 우리 계산을 덮지 않는다. 창이 청구 주기와 정확히 같을 때만 견줄 수 있으므로 그 사실을 * `matchesWindow` 로 함께 낸다 — 다른 창의 두 수를 빼면 그 차이는 아무것도 뜻하지 않는다. */ bill?: { from: string to: string total?: number energyCharge?: number demandCharge?: number currency?: string billingDemandKW?: number matchesWindow: boolean /** * 창이 같을 때, **우리 계산에서 청구액을 뺀 값** — 0 이 아니면 우리 계산이 틀렸다는 뜻이다. * * 항목별로도 낸다(2026-08-29). 총액만 견주면 원본이 총액을 주지 않는 청구서에서는 비교가 * 영영 나오지 않는다 — 실제로 첫 청구서에 기본요금만 있었다. 양쪽에 있는 항목만 견준다. */ diff?: { total?: number; demandCharge?: number; energyCharge?: number } } /** 파생의 근거 — 구간 평균부하 × 길이(`mean-kw`). */ basis: string /** 센 구간 수 · 못 센 구간 수(계측이 끊긴 구간) — 알리지 않고 자르지 않는다. */ counted: number skipped: number /** 완료 오더당 전력량 — 답할 수 없으면 `null` 과 이유. */ intensity: { value: number | null; unit?: string; reason?: string } /** * 배전 계통의 **자리별 몫** — 「이 분기가 얼마 썼나」. * * ── 이것은 측정이 아니라 배분이다 ────────────────────────────────────── * 저널에 있는 에너지 사실은 **현장 총량 한 줄**이다(뿌리 계량기 합). 계량기별 구간 사실이 없으므로 * 자리별 몫은 **나눈 것**이고, 무엇으로 나눴는지(`weightKind`)를 값과 함께 낸다. 근거를 감추면 * 나눈 숫자가 잰 숫자와 같은 무게로 읽힌다 — CBAM·ESG 보고에서 첫 질문에 깨지는 지점이다. * * 가중치는 **작업 가동시간**이다. 정격(kW)으로 나누지 않는다: 시뮬 트윈의 총부하가 정격의 합으로 * 파생되므로, 그 총량을 다시 정격으로 나누면 넣은 값이 그대로 나오는 동어반복이 된다. * * 그래서 이 값에는 **가정이 하나 실려 있다**: 전기가 가동시간에 비례한다. 공조·조명처럼 공정에 * 붙지 않는 부하도 그 비율에 섞인다 — 화면이 그 문장을 함께 말해야 한다. */ byFeeder?: { /** 자리(분기·수전) id. 자리를 모르는 자원은 `unknown` 으로 모인다(알리지 않고 버리지 않는다). */ key: string kWh: number /** * 이 몫의 근거 — **커널이 정한다.** * * 선언된 부하가 여럿이면 배분(`apportioned`)이다. 선언된 부하가 **하나뿐인** 현장에서는 커널이 * 「그 계량기는 그것만 잰다」고 읽어 `measured` 를 낸다 — 모델이 그렇게 말했으므로 그것도 사실이다. * 우리가 어느 쪽으로도 낙인찍지 않는다. */ basis: 'apportioned' | 'measured' /** * 무엇으로 나눴나 — **배분일 때만** 있다. * * 분기마다 계량기가 있는 현장에서는 나누지 않는다(그 값이 곧 계량이다). 그때 이 칸을 * `runtimeMs` 로 채우면 「가동시간으로 나눴다」는 없는 사실을 말하게 된다. */ weightKind?: 'runtimeMs' /** 이 자리의 몫이 전체의 얼마였나 — 배분일 때만. */ weightShare?: number /** * **피크가 찍힌 순간** 이 분기가 얼마였나(kW) — 요금이 걸린 수다. * * 창의 평균이나 합이 아니다: 최대수요를 낸 구간의 그 순간 값이다(다른 순간을 섞으면 「어느 분기를 * 깎아야 피크가 내려가나」의 답이 흐려진다). 그 구간에 이 분기의 표본이 없었으면 **없다**. */ peakKW?: number }[] /** * 나누지 못한 양과 그 이유 — **알리지 않고 0 으로 만들지 않는다.** * `no-weights` = 그 창에 돌아간 자원이 없다(가동시간이 전부 0). 그러면 총량은 그대로 남는다. */ unattributedKWh?: number /** * 왜 나누지 못했나(커널 코드) — `no-weights`(가동시간이 전부 0) · `no-consumers`(덮는 대상 미선언) · * `not-submetered`(뿌리에서 재었으나 하위 계량이 덮지 않는 부분 — 결손이 아니라 계량 범위의 사실). */ unattributedReason?: string /** * 분기 계량의 합이 뿌리 계량보다 **큰** 양 — 두 계량기의 회계가 다르다는 사실이다. * * 값을 깎아 맞추지 않는다: 그것은 계량을 우리가 고쳐 쓰는 일이고, 어느 쪽이 맞는지는 현장이 안다. */ submeterExcessKWh?: number /** * 배분에서 **빠진 자원 수** — 그 창에 가동 기록이 없는 설비다. 0kWh 를 주지 않는 대신 몇 대가 * 빠졌는지 센다(대기전력이 있는 설비에서 0 은 곧 거짓이다). */ excludedResources?: number /** * 이 창의 전기요금 — **청구서가 아니다.** 계산과 그 한계(TOU·래칫·최소청구액 미모델)는 커널 * (`electricityCost`)이 갖고 여기서는 나른다. 단가가 없으면 `reason: 'no-tariff'` 로 온다. */ cost?: ElectricityCost } /** 리드타임 — 생성(created)에서 완료까지. 사용자가 체감하는 소요 시간. */ leadTime: DurationStats /** 작업시간 — 착수(in-progress)에서 완료까지. 대기를 뺀 실작업. */ workTime: DurationStats /** 대기시간 — 생성에서 착수까지. 자원이 부족하면 여기가 늘어난다. */ waitTime: DurationStats /** 자원별 점유 — 작업에 붙어 있던 시간의 합과 창 대비 비율(작업으로 설명되는 부분만). */ utilization: { resourceId: string; busyMs: number; ratio: number }[] /** * **셈에서 빠진 것** — 완료는 창 안이지만 시작 이벤트를 못 찾은 건수. * 조회 구간이 짧아 시작이 잘렸거나 저널이 유실된 경우다. 이 값을 숨기면 평균이 알리지 않고 거짓이 된다. */ unpaired: { leadTime: number; workTime: number } /** * **완료로 세지 않은 오더 상태 낱말의 인구조사** — 낱말 → 창 안에서 그 낱말로 본 건수. * * 「못 읽었다」가 **아니다.** `picking` 처럼 완료가 아닌 낱말이 여기 오는 것은 정상이다. 이 값은 * 판정이 아니라 **재료**다: 완료가 0 인데 여기 `{ finished: 2780 }` 이 있으면 원인이 곧 보인다. * * ── 왜 필요한가, 그리고 무엇의 대체가 아닌가 ──────────────────────────────── * 이 폴드는 완료를 **동의어 목록**으로 판정한다(`completed`·`fulfilled`·`done`·`finished`). 그 방식은 * 원본의 낱말에서 뜻을 **추론**하는 것이고, 이 저장소가 다른 자리에서 거절하는 그것이다 * (「선언이 있으면 변환이지만 없으면 발명이다」). * * 실제로 그 대가를 치렀다: `finished` 가 목록에 없어 첫 실 시스템의 완료 오더가 전부 0 이었다. * 그런데 **빈 자리는 이 목록이 아니라 커넥터였다** — 그 커넥터에 상태 표(`WORK_ORDER_STATE`)가 * 정의돼 있으면서 오더 경로에서 쓰이지 않았고(로트 경로는 썼다), 원본 낱말이 그대로 흘렀다. * 목록에 `finished` 를 더한 것은 그 사실을 가린 땜빵이었다. * * **정석은 커넥터가 자기 표로 옮기고, 옮길 수 없는 낱말은 세고 넣지 않는 것이다**(재고 처분이 이미 * 그 규율로 선다: 「표현할 길이 없는 것은 넣지 않는다」). 그러면 이 폴드는 추론하지 않아도 된다. * * 그러므로 이 값은 **동의어 목록을 늘리는 근거가 아니다.** 목록은 커넥터들이 옮기기 시작하면 * 닫힌 어휘로 좁혀야 하고, 그때까지 이 값이 어긋남을 말해 준다. 순서가 중요하다 — 목록을 먼저 * 좁히면 아직 옮기지 않는 원본의 완료가 그 순간 0 이 된다. * * 상태를 말하지 않은 사건은 여기 오지 않는다. 그것은 「모르는 낱말」이 아니라 다른 결함이고 * (유입 문이 상태를 필수로 받는다) 그 진단은 그 문의 몫이다. */ orderStatusNotCounted: Record /** * 소비한 이벤트 수 — **넘겨받은 배열 전체**(창 앞 lookback 포함). 진단용이다. * * "측정된 기록이 있는가" 의 판단에는 쓰지 말 것 — 호출부는 창보다 앞까지 읽어 넘기므로(완료된 작업의 * 시작이 창 밖일 수 있다) 창 안이 비어 있어도 이 값은 0 이 아니다. 그 판단은 `eventsInWindow` 로 한다. */ eventsSeen: number /** * **창 안에** 들어온 이벤트 수 — "이 구간에 측정할 기록이 있었는가" 의 정답. * * 이것이 0 이면 "처리량 0" 이 아니라 **"기록이 없다"** 다. 두 상황을 섞으면 사용자가 잘못된 결론을 * 낸다(앞 구간의 기록만 읽어 놓고 "0건 처리" 라고 말하는 일이 실제로 가능했다). */ eventsInWindow: number /** 요청한 관점으로 쪼갠 값(요청했을 때만). */ groups?: { by: KpiGroupBy; items: KpiGroup[]; truncated?: number; truncatedTasks?: number } } /** * 창의 전력량을 **배전 계통의 자리별로 나눈다** — 근거를 함께. * * ── 왜 커널 함수를 쓰나 ───────────────────────────────────────────────────── * 「나눌 근거가 없으면 나누지 않는다」·「합은 보존된다」·「몫이 0 이면 0kWh 를 주지 않는다」는 규칙이 * 커널의 `attributeEnergy` 에 이미 있다. 여기서 다시 쓰면 두 벌이 되고, 그때부터 두 화면이 다른 합을 * 말한다. 이 함수가 하는 일은 **재료를 모아 넘기고 답을 자리별로 묶는 것**뿐이다. * * ── 가중치는 가동시간이다 ─────────────────────────────────────────────────── * 정격(kW)으로 나누지 않는다: 시뮬 트윈의 총부하가 정격의 합에서 파생되므로 그 총량을 다시 정격으로 * 나누면 넣은 값이 그대로 나온다(새 정보 0의 동어반복). 가동시간은 **저널이 관측한 사실**이다. * * 자리를 모르는 자원은 `unknown` 으로 모인다 — 빼면 합이 줄고, 아무 자리에 넣으면 거짓이 된다. */ function feederShares( kWh: number | undefined, utilization: { resourceId: string; busyMs: number }[], resourceFeeder?: Record ): Partial> { /* 선언이 없으면 분해하지 않는다 — 자리를 모르는 채 나눈 숫자는 아무 것의 몫도 아니다. */ if (!resourceFeeder || !Object.keys(resourceFeeder).length) return {} if (kWh === undefined || !(kWh > 0)) return {} /* * ── 풀이 **무엇을 덮는지 사실대로** 말한다 (2026-08-17) ──────────────────── * * 처음에는 「그 창에 돌아간 자원」만 덮는다고 넘겼다. 그러자 돌아간 자원이 하나인 창에서 커널이 * 그것을 **`measured`(전용 계량)** 로 판정했다 — 커널의 규칙은 옳다(「덮는 소비처가 하나면 그 * 계량기는 그것만 잰다는 선언이다」). 틀린 것은 내 선언이었다: 현장 계량기는 돌아간 것만 재지 않는다. * * 그래서 **선언된 부하 전부**를 덮는다고 말한다(`resourceFeeder` 의 키가 곧 그 선언이다). * 그러면 그 창에 돌지 않은 설비는 몫 0 으로 **배분에서 빠지고 그 수가 보고된다** — 0kWh 를 주는 * 대신 「이 설비는 이 창에 가동 기록이 없다」를 말하는 쪽이다. */ /* * 덮는 대상은 **선언된 부하 ∪ 그 창에 돌아간 자원**이다. * * 선언만 쓰면, 돌았는데 전기 계통에 선언되지 않은 설비의 몫이 나머지 설비에 얹힌다 — 그 설비가 * 안 쓴 전기를 쓴 것으로 만든다. 돌아간 것만 쓰면, 선언됐지만 쉰 설비가 사라져 「이 창에 안 돌았다」를 * 말할 수 없다. 둘을 합해야 두 사실이 다 남는다(자리를 모르는 몫은 `unknown` 으로 모인다). */ const busyOf = new Map(utilization.map(u => [u.resourceId, u.busyMs])) const ids = new Set([...Object.keys(resourceFeeder), ...busyOf.keys()]) const consumers = [...ids].map(id => ({ id, weight: busyOf.get(id) ?? 0 })) if (!consumers.length) return {} const r = attributeEnergy({ pools: [{ meterId: 'site', kWh, consumerIds: consumers.map(c => c.id) }], consumers, weightKind: 'runtimeMs' }) const byKey = new Map() for (const sh of r.shares) { /* 자리를 모르면 `unknown` — 그 몫이 있다는 사실은 남기고, 어디의 것인지는 지어내지 않는다. */ const key = resourceFeeder[sh.consumerId] ?? 'unknown' const cur = byKey.get(key) ?? { kWh: 0, weightShare: 0, apportioned: false } cur.kWh += sh.kWh cur.weightShare += sh.weightShare ?? 0 /* **근거는 커널이 정한다.** 여기서 낙인찍으면 커널이 「쟀다」고 한 것을 「나눴다」고 바꿔 말하거나 그 반대가 된다. 섞이면 약한 쪽(배분)으로 적는다 — 강한 주장을 올려 잡지 않는다. */ if (sh.basis === 'apportioned') cur.apportioned = true byKey.set(key, cur) } const byFeeder = [...byKey.entries()] .map(([key, v]) => ({ key, kWh: v.kWh, basis: (v.apportioned ? 'apportioned' : 'measured') as 'apportioned' | 'measured', weightKind: 'runtimeMs' as const, weightShare: v.weightShare })) .sort((a, b) => b.kWh - a.kWh) const un = r.unattributed.reduce((sum, u) => sum + u.kWh, 0) return { ...(byFeeder.length ? { byFeeder } : {}), ...(un > 0 ? { unattributedKWh: un, unattributedReason: r.unattributed[0]?.reason } : {}), ...(r.excluded.length ? { excludedResources: r.excluded.length } : {}) } } /** * 분기별 전력량을 **계량으로** 읽는다 — 계량이 있으면 배분하지 않는다. * * ── 왜 필요했나 (2026-08-18) ──────────────────────────────────────────────── * 실 게이트웨이는 분기마다 계량기를 보낸다. 그런데 화면은 「나누지 못함」으로 답하고 있었다 — 여기에 * 가동시간 배분 경로만 있었기 때문이다. 계량이 있는데 배분으로 말하면 **근거를 실제보다 약하게** 보고하는 * 것이고, 그 위에 세운 SEU 판단이 무거운 값을 놓친다. 미러 트윈에는 설비 가동 기록조차 없어서 * (원천이 계량만 준다) 그 경로로는 분기별 값이 영원히 나오지 않았다. * * 값의 출처는 커널이 구간 마감 사실에 실어 주는 **지점별 몫**이다(적산의 뺄셈). 표본을 직접 읽지 않는 * 이유는 조회 상한이다 — 계측은 초당 여러 건이라 표본으로는 창을 채우기 전에 상한에 걸린다. * * 나머지(뿌리 − 분기 합)는 **버리지도 얹지도 않는다**: 수전 직결 부하·손실·계량되지 않은 회로가 그 * 차이이므로, 어느 분기에 얹으면 그 분기가 쓰지 않은 전기를 쓴 것으로 만든다. 커널의 `remainder` 풀로 * 넘겨 「하위 계량이 덮지 않는다」로 남긴다. * * 분기 계량 합이 뿌리보다 크면(회계가 다른 두 계량기) **그 사실을 낸다** — 값을 깎아 맞추면 계량을 * 우리가 고쳐 쓰는 것이다. */ /** * 창의 **발전량** — 커널이 마감한 기간 사실을 합한다. * * ── 왜 합이 전부인가 ─────────────────────────────────────────────────────── * 기간 발전량은 커널이 이미 확정한 사실이다(적산의 종류마다 다른 규칙으로 얻는다). 여기서 다시 * 계산하면 규칙이 두 벌이 되고, 그때부터 두 화면이 다른 발전량을 말한다. 이 함수가 하는 일은 * **창에 든 것을 고르고, 설비별로 묶고, 근거가 얼마나 덮였는지 세는 것**이다. * * ── 걸친 기간은 세지 않는다 ───────────────────────────────────────────────── * 구간 마감과 같은 규칙이다. 하루의 절반이 창에 들었다고 절반을 세면 그것은 관측이 아니라 우리가 나눈 * 값이고, 합이 창을 넘는다. * * ── 덜 덮인 기간을 센다 ───────────────────────────────────────────────────── * 기간의 끝이 자정인데 마지막 표본이 저녁이었으면 그 사이는 재지 않은 것이다. 그 수를 함께 내야 화면이 * 「이 합은 이만큼 덜 잰 것」이라고 말할 수 있다. 얼마나 어긋나면 문제인지는 현장이 정한다. */ function generatedOfPeriods( periods: readonly { equipmentId: string kWh: number startMs: number endMs: number observedFromMs: number observedToMs: number }[], range: { startMs: number; endMs: number }, /** 이용률의 분모 — 없으면 이용률을 내지 않는다(0 이나 짐작을 만들지 않는다). */ rated?: { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' }, /** 기간마다 적용할 단가 — 기간을 덮는 것을 쓴다. 없으면 금액을 만들지 않는다. */ prices?: readonly { startMs: number; endMs: number; unitPrice: number; currency: string }[] ): Partial> { const inside = periods.filter(g => g.startMs >= range.startMs && g.endMs <= range.endMs) /* * ── **걸친 기간을 조용히 버리지 않는다** (2026-08-30) ────────────────────── * * 더하는 것은 창에 **온전히 들어온 기간**뿐이다. 그것이 맞는 셈이다 — 걸친 기간을 더하면 다른 날의 * 값이 이 창의 값인 척한다. * * 그런데 버린 것을 말하지 않았다. 하루 단위 기간은 「지금부터 24시간 전」 창에 온전히 들어오는 일이 * 거의 없다(창이 한낮에서 시작하므로). 그래서 어제 발전량이 저널에 멀쩡히 있는데 화면이 비었고, * 사용자는 그것을 **「발전량이 없다」**로 읽었다. 실측으로 그 일이 났다. * * 이제 걸친 것을 세어 함께 낸다. 화면이 「이 창에 다 들어오지 않은 기간이 있다」를 말할 수 있다. */ const straddling = periods.filter( g => !(g.startMs >= range.startMs && g.endMs <= range.endMs) && g.endMs > range.startMs && g.startMs < range.endMs ) const notCounted = straddling.length ? { periods: straddling.length, kWh: straddling.reduce((a, g) => a + g.kWh, 0), earliestStart: new Date(Math.min(...straddling.map(g => g.startMs))).toISOString(), latestEnd: new Date(Math.max(...straddling.map(g => g.endMs))).toISOString() } : undefined if (!inside.length) { /* 온전히 들어온 것이 없다 — 그래도 걸친 것이 있으면 그 사실을 낸다(빈 값과 「없음」은 다르다). */ return notCounted ? { generated: { kWh: 0, counted: 0, partial: 0, byEquipment: [], notCounted } } : {} } const byEquipment = new Map() let partial = 0 for (const g of inside) { byEquipment.set(g.equipmentId, (byEquipment.get(g.equipmentId) ?? 0) + g.kWh) const coveredFrom = Number.isFinite(g.observedFromMs) ? g.observedFromMs <= g.startMs : false const coveredTo = Number.isFinite(g.observedToMs) ? g.observedToMs >= g.endMs : false if (!coveredFrom || !coveredTo) partial++ } const kWh = inside.reduce((a, g) => a + g.kWh, 0) /* * 이용률의 분모는 **센 기간들의 길이**다. 창 길이로 나누면, 하루치 하나만 센 24시간 창에서 그 하루가 * 창 전체에 걸쳐 있었던 것처럼 작아진다. */ const hours = inside.reduce((a, g) => a + (g.endMs - g.startMs) / 3_600_000, 0) const capacityFactor = rated && rated.kW > 0 && hours > 0 ? { value: kWh / (rated.kW * hours), ratedKW: rated.kW, basis: rated.basis, from: rated.from, hours } : undefined /* * 낸 것의 값 — 기간마다 **그 기간을 덮는 단가**로 매긴다. 걸친 단가는 쓰지 않는다: 하루 단가로 * 이틀치를 매기면 그 절반은 다른 날의 값이 된다. * * 모든 기간에 단가가 있을 때만 금액을 만든다. 일부만 매기고 더하면 그 합이 창 전체의 값인 척한다. */ let amount = 0 let priced = 0 let currency: string | undefined for (const g of inside) { const p = (prices ?? []).find(x => x.startMs <= g.startMs && x.endMs >= g.endMs) if (!p) continue amount += g.kWh * p.unitPrice priced++ currency = currency ?? p.currency } const unpriced = inside.length - priced const revenue = priced > 0 && unpriced === 0 && currency ? { amount, currency, priced, unpriced } : undefined return { generated: { kWh, counted: inside.length, partial, byEquipment: [...byEquipment.entries()] .map(([id, kWh]) => ({ id, kWh })) .sort((a, b) => b.kWh - a.kWh), ...(capacityFactor ? { capacityFactor } : {}), ...(revenue ? { revenue } : {}), /* 이 창에 다 들어오지 않아 더하지 못한 기간 — 있으면 화면이 그것을 말한다. */ ...(notCounted ? { notCounted } : {}) } } } /** * 밖에서 마감되어 온 사용 구간을 합한다 — **구간 마감과 더하지 않는다.** * * 두 회계가 같은 전기를 말한다. 더하면 두 번 세고, 둘 다 있는 현장에서는 계량 쪽이 강한 근거이므로 * 그쪽을 쓴다. 여기서는 이쪽 몫만 계산하고, 무엇을 쓸지는 부르는 쪽이 정한다. * * 단가는 **모든 구간이 실었을 때만** 요금을 만든다. 일부만 실었는데 있는 것만 더하면 그 합이 창의 * 요금인 척한다 — 못 매긴 구간 수를 함께 낸다. */ function usageOfPeriods( periods: readonly { meterId: string; kWh: number; startMs: number; endMs: number; unitPrice: number; currency?: string; basis: string }[], range: { startMs: number; endMs: number } ): NonNullable['usage'] | undefined { const inside = periods.filter(u => u.startMs >= range.startMs && u.endMs <= range.endMs) if (!inside.length) return undefined const byBasis = new Map() let kWh = 0 let charge = 0 let unpriced = 0 let currency: string | undefined for (const u of inside) { kWh += u.kWh byBasis.set(u.basis, (byBasis.get(u.basis) ?? 0) + 1) if (Number.isFinite(u.unitPrice)) { charge += u.kWh * u.unitPrice if (!currency) currency = u.currency } else unpriced++ } return { kWh, counted: inside.length, ...(unpriced === 0 && currency ? { charge, currency } : {}), unpriced, byBasis: [...byBasis.entries()].map(([basis, counted]) => ({ basis, counted })).sort((a, b) => b.counted - a.counted) } } /** * **요금적용전력을 고른다 — 청구서가 먼저다.** * * 순서에 뜻이 있다. 청구서는 그 기간에 실제로 매겨진 값이고, 선언은 사람이 옮겨 적은 값이다. * 그 값이 매달 바뀌므로 옮겨 적은 것은 곧 낡는다. */ function billingDemandOf( billsInWindow: readonly { endMs: number; billingDemandKW: number }[], tariffBasis: { billingDemandKW?: number } | undefined, lastBill: { billingDemandKW?: number } | undefined, declaredKW: number | undefined, /** 이 창에서 관측한 최고 수요와 그 값이 언제까지의 것인지 — 사용 구간의 `maxKW` 에서 온다. */ observedPeak?: { kW: number; toMs: number } ): NonNullable['billingDemand'] | undefined { const declared = Number.isFinite(Number(declaredKW)) ? Number(declaredKW) : undefined const withValue = billsInWindow.filter(b => Number.isFinite(b.billingDemandKW)) const fromBill = withValue.length ? withValue.reduce((a, x) => (x.endMs > a.endMs ? x : a)).billingDemandKW : undefined const carried = Number.isFinite(Number(lastBill?.billingDemandKW)) ? Number(lastBill?.billingDemandKW) : undefined /* 이 주기에 적용되는 기준이 가장 먼저다 — 청구서는 끝난 기간의 것이다. */ const fromBasis = Number.isFinite(Number(tariffBasis?.billingDemandKW)) ? Number(tariffBasis?.billingDemandKW) : undefined const picked = fromBasis !== undefined ? { kW: fromBasis, from: 'tariff-basis' as const } : fromBill !== undefined ? { kW: fromBill, from: 'bill' as const } : carried !== undefined ? { kW: carried, from: 'carried-forward' as const } : declared !== undefined ? { kW: declared, from: 'declaration' as const } : undefined if (!picked) return undefined /* 선언이 있는데 청구서와 다르면 그 사실을 낸다 — 낡은 선언은 스스로 말해야 한다. */ const stale = picked.from !== 'declaration' && declared !== undefined && declared !== picked.kW const exceeded = observedPeak && observedPeak.kW > picked.kW ? { peakKW: observedPeak.kW, overKW: observedPeak.kW - picked.kW, observedTo: new Date(observedPeak.toMs).toISOString() } : undefined return { ...picked, ...(declared !== undefined ? { declaredKW: declared } : {}), ...(stale ? { stale: true } : {}), ...(exceeded ? { exceeded } : {}) } } /** * 창에 온전히 든 청구서 하나 — 가장 늦게 끝난 것. * * **창이 청구 주기와 같을 때만 견준다.** 한 달 청구서를 하루 창의 값과 빼면 그 차이는 아무것도 뜻하지 * 않는다. 그래서 같은지를 값과 함께 낸다. */ function billOfWindow( bills: readonly { from: string to: string startMs: number endMs: number total: number energyCharge: number demandCharge: number currency?: string billingDemandKW: number }[], range: { startMs: number; endMs: number }, ours?: { total?: number; demandCharge?: number; energyCharge?: number } ): NonNullable['bill'] | undefined { const inside = bills.filter(b => b.startMs >= range.startMs && b.endMs <= range.endMs) if (!inside.length) return undefined const b = inside.reduce((a, x) => (x.endMs > a.endMs ? x : a)) const matchesWindow = b.startMs === range.startMs && b.endMs === range.endMs /* 양쪽에 있는 항목만 견준다 — 한쪽이 없는 것을 0 으로 두면 그 차이가 통째로 어긋남처럼 보인다. */ const gap = (mine: unknown, billed: number): number | undefined => Number.isFinite(Number(mine)) && Number.isFinite(billed) ? Number(mine) - billed : undefined const diff = matchesWindow ? { ...(gap(ours?.total, b.total) !== undefined ? { total: gap(ours?.total, b.total) as number } : {}), ...(gap(ours?.demandCharge, b.demandCharge) !== undefined ? { demandCharge: gap(ours?.demandCharge, b.demandCharge) as number } : {}), ...(gap(ours?.energyCharge, b.energyCharge) !== undefined ? { energyCharge: gap(ours?.energyCharge, b.energyCharge) as number } : {}) } : {} return { from: b.from, to: b.to, ...(Number.isFinite(b.total) ? { total: b.total } : {}), ...(Number.isFinite(b.energyCharge) ? { energyCharge: b.energyCharge } : {}), ...(Number.isFinite(b.demandCharge) ? { demandCharge: b.demandCharge } : {}), ...(b.currency ? { currency: b.currency } : {}), ...(Number.isFinite(b.billingDemandKW) ? { billingDemandKW: b.billingDemandKW } : {}), matchesWindow, ...(Object.keys(diff).length ? { diff } : {}) } } function measuredFeeders( windows: readonly { startMs: number endMs: number maxKW?: number points: { id: string; kWh: number; root: boolean }[] pointsAtPeak?: { id: string; kW: number }[] }[], range: { startMs: number; endMs: number }, totalKWh: number | undefined, meterFeeder?: Record ): Partial> | undefined { if (!meterFeeder || !Object.keys(meterFeeder).length) return undefined if (totalKWh === undefined || !(totalKWh > 0)) return undefined /* 창에 온전히 든 구간만 — 전체 전력량과 같은 규칙이어야 몫과 합이 같은 창을 말한다. */ const byFeederKWh = new Map() let sawPoints = false for (const w of windows) { if (!(w.startMs >= range.startMs && w.endMs <= range.endMs)) continue for (const p of w.points ?? []) { const feeder = meterFeeder[p.id] if (!feeder) continue // 뿌리 계량기·모르는 지점 — 분기의 몫이 아니다 sawPoints = true byFeederKWh.set(feeder, (byFeederKWh.get(feeder) ?? 0) + p.kWh) } } if (!sawPoints) return undefined // 계량이 없으면 이 경로를 쓰지 않는다(배분으로 돌아간다) const feeders = [...byFeederKWh.entries()].filter(([, kWh]) => kWh > 0) if (!feeders.length) return undefined const submetered = feeders.reduce((sum, [, kWh]) => sum + kWh, 0) const remainder = totalKWh - submetered const r = attributeEnergy({ pools: [ ...feeders.map(([feeder, kWh]) => ({ meterId: `${feeder}:meter`, kWh, consumerIds: [feeder] })), ...(remainder > 0 ? [{ meterId: 'root:remainder', kWh: remainder, consumerIds: [], remainder: true }] : []) ], consumers: feeders.map(([feeder]) => ({ id: feeder })) }) /* * ── 피크를 만든 분기 (2026-08-18) ───────────────────────────────────────── * * 요금은 kW 로 매겨지므로 「어느 분기를 깎아야 피크가 내려가나」가 실무의 질문이다. 그 답은 **피크가 * 찍힌 구간의 그 순간** 값이고, 창 안의 여러 구간을 더하거나 평균하면 뜻이 사라진다(다른 순간의 값을 * 섞는 것이다). 그래서 **최대수요를 낸 구간 하나**만 골라 그 순간의 지점 값을 쓴다. */ const peakWindow = windows .filter(w => w.startMs >= range.startMs && w.endMs <= range.endMs && Number.isFinite(Number(w.maxKW))) .reduce<(typeof windows)[number] | undefined>((best, w) => (!best || Number(w.maxKW) > Number(best.maxKW) ? w : best), undefined) const peakOf = new Map() for (const p of peakWindow?.pointsAtPeak ?? []) { const feeder = meterFeeder[p.id] if (!feeder) continue // 뿌리 계량기·모르는 지점 — 분기의 몫이 아니다 peakOf.set(feeder, (peakOf.get(feeder) ?? 0) + p.kW) } const byFeeder = r.shares .map(sh => ({ key: sh.consumerId, kWh: sh.kWh, basis: sh.basis, weightKind: undefined, weightShare: undefined, /* 피크 순간의 값이 없으면 만들지 않는다 — 그 구간에 이 분기의 표본이 없었다는 뜻이다. */ ...(peakOf.has(sh.consumerId) ? { peakKW: peakOf.get(sh.consumerId) } : {}) })) .sort((a, b) => b.kWh - a.kWh) as NonNullable['byFeeder'] const un = r.unattributed.reduce((sum, u) => sum + u.kWh, 0) return { ...(byFeeder?.length ? { byFeeder } : {}), ...(un > 0 ? { unattributedKWh: un, unattributedReason: r.unattributed[0]?.reason } : {}), /* 분기 합이 뿌리를 넘었다 — 두 계량기의 회계가 다르다는 사실이다(깎아 맞추지 않는다). */ ...(remainder < 0 ? { submeterExcessKWh: -remainder } : {}) } } /** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */ /* * ── 같은 사실이 두 벌 있어도 한 번만 센다 (2026-08-30) ────────────────────── * * 마감된 구간 사실은 정체성이 내용에 있다(커널 0.7.81). 그런데 저널에 이미 여러 벌 적힌 것이 있다 — * 쓰는 자리에서 막기 전에 들어간 것들이다(실측: 어느 날의 발전 단가가 세 벌). * * 지난 기록을 지우지 않는다 — 저널은 일어난 일의 기록이다. 대신 **읽을 때 한 번만 센다.** * 그러지 않으면 그 구간의 수익·사용량이 그 횟수만큼 세어진다. */ function onceById(rows: readonly T[]): T[] { const seen = new Set() return rows.filter(r => { const id = (r as any)?.payload?.eventId if (typeof id !== 'string' || !id) return true if (seen.has(id)) return false seen.add(id) return true }) } export interface KpiFoldOptions { groupBy?: KpiGroupBy /** * 이 트윈이 이어받고 있는 **마지막 청구서** — 요금적용전력의 우선 근거(§`billingDemandOf`). * * 창 밖의 청구서는 저널 조회에 걸리지 않는다. 그런데 그 값은 이 창에도 적용되므로 호출부가 * 커널 상태에서 읽어 넘긴다. */ lastBill?: { to: string; billingDemandKW?: number } /** * 이 트윈이 이어받고 있는 **요금 기준** — 이 주기에 적용되는 값(§`ENERGY_EVENT.tariffBasis`). * * 청구서보다 먼저다. 청구서는 끝난 기간의 정산이라 이번 주기의 기준을 말하지 않는다. */ tariffBasis?: { from: string; billingDemandKW?: number } /** * 이 트윈의 **발전 정격**과 그 근거 — 이용률의 분모(§ 커널 `generationRated`). * * 모델의 선언에서 오므로 호출부가 읽어 넘긴다. 폴드는 저널만 본다. */ generationRated?: { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' } /** * 이 트윈이 이어받고 있는 **발전 단가** — 창 밖의 단가도 이 창에 적용될 수 있다. * * 창 안의 단가 사실이 먼저다. 이것은 그 창에 단가가 없을 때 쓴다. */ generationPrice?: { from: string; to: string; unitPrice: number; currency: string } /** * 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라 * 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(알리지 않고 다른 축으로 바꾸지 않는다). */ locationArea?: Record /** * 자원 → **배전 계통의 자리**(분기·수전) 지도. * * ── 왜 밖에서 오나 ─────────────────────────────────────────────────────── * 이벤트에는 전기 계통이 없다. 「이 설비가 어느 분기에서 먹는가」는 에너지 트윈의 모델이 아는 * 선언이고(설비 하나에 사실 둘 — 공장에서는 자원, 전기에서는 부하), 그 선언을 읽는 것은 호출부의 일이다. * * 없으면 분기 분해를 **하지 않는다** — 자리를 모르는 채 나누면 그 숫자는 아무 것의 몫도 아니다. */ resourceFeeder?: Record /** * **계량 지점 → 그것이 재는 분기** 지도. * * 있으면 분기별 전력량을 계량으로 읽는다(배분보다 강한 근거). 이 지도는 모델의 선언에서 오고 * (계량기의 자리), 그것을 읽는 것은 호출부의 일이다 — 폴드는 저널만 본다. */ meterFeeder?: Record /** * 선언된 **전기요금 단가** — 수를 금액으로 바꾸는 유일한 입력. * * 이벤트에는 요금이 없다. 단가는 현장의 계약이고(에너지 트윈 모델의 수전 자리에 선언한다), 그것을 * 읽는 것은 호출부의 일이다. 없으면 금액을 만들지 않는다 — 짐작한 단가로 낸 금액은 숫자가 있다는 * 것만으로 사람을 결정으로 밀어붙인다. */ tariff?: TariffDeclaration /** * 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다. * 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 알리지 않고 바꾸지 않는다). */ shifts?: WorkCalendarEntry[] /** * 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다 * (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC. * * ⚠ 한 수다. 접기는 지난 구간을 훑으므로 **일광절약시간 전환을 넘을 수 없다** — 그때는 * `offsetAt` 을 넘긴다. */ utcOffsetMinutes?: number /** * 그 순간의 시각 기준을 푸는 함수 — **`utcOffsetMinutes` 보다 먼저 본다**. * * ── 왜 수가 아니라 함수인가 (2026-09-07) ───────────────────────────────── * 접기는 지난 구간을 훑는다. 1년치를 교대별로 접으면 그 1년 안에 일광절약시간 전환이 두 번 들어 * 있는데, 고정 오프셋 하나로 읽으면 전환 뒤의 사실이 **한 시간 밀려 다른 교대로 세어진다.** * 오류가 나지 않으므로 교대별 성과가 조용히 거짓이 된다. `America/Tijuana`(PST −480 / PDT −420)가 * 이미 자료에 있다. * * 이 파일은 시간대 데이터를 들지 않는다(커널과 같은 자세다) — 부르는 쪽이 푸는 법을 준다. 그래서 * 이 인자가 함수이고, 시험은 여전히 평범한 값으로 돈다. * * `undefined` 를 돌려주면 그 순간은 기준을 모르는 것이다 — 0(UTC)으로 채우지 않고 * `utcOffsetMinutes` 로 떨어진다. */ offsetAt?: (atMs: number) => number | undefined /** * 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**. * * 축의 수만 알리면 부족하다 — "그 외 89개" 로는 그것이 3건인지 300건인지 모른다. 보여준 축이 전부인 * 것처럼 읽히면 사용자가 꼬리를 놓친다. */ groupLimit?: number } /* ── 관점 축의 재료 ───────────────────────────────────────────────────────── * 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode· * resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다. */ /** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */ interface CompletionRecord { taskId: string at: number facets: TaskFacets leadMs?: number workMs?: number waitMs?: number } /** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(알리지 않고 버리지 않는다). */ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string { const f = rec.facets switch (options.groupBy) { case 'resource': return f.resource ?? 'unknown' case 'taskKind': return f.kind ?? 'unknown' case 'location': return f.location ?? 'unknown' case 'order': return f.order ?? 'unknown' case 'area': /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가 * 그것을 구역으로 오해한다). */ return (f.location && options.locationArea?.[f.location]) ?? 'unknown' case 'shift': /* * 교대는 **저널에 찍혀 있지 않다 — 계산한다.** * * 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고, * ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는 * `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 — * 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다. * * 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다. */ if (!options.shifts?.length) return 'unknown' /* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도 않은 교대에 집계돼, 교대별 성과가 알리지 않고 거짓이 된다. */ /* 그 순간의 기준을 먼저 묻는다 — 없으면 한 수로 떨어진다(§`offsetAt`). */ const atMs = typeof rec.at === 'number' ? rec.at : Date.parse(String(rec.at)) const offset = (Number.isFinite(atMs) ? options.offsetAt?.(atMs) : undefined) ?? options.utcOffsetMinutes return activeShiftAt(options.shifts, rec.at, offset) ?? 'unknown' default: return 'unknown' } } /** * 이 축의 품질 — **판정이 있었던 것만** 센다(없으면 필드를 만들지 않는다). * * 양품률의 이력이 여기서 나온다: 커널이 작업 완료에 결과를 적고(`TaskStatusDelta.outcome`), 폴드가 축으로 * 들고(`TaskFacets.outcome`), 여기서 종류별로 센다. 그 사슬이 없으면 「이 공정의 양품률」은 저널에 * 남지 않는다(설비 누적 카운터는 종류를 모른다). */ function qualityOf(rows: CompletionRecord[]): { quality?: { good: number; scrap: number } } { let good = 0 let scrap = 0 for (const r of rows) { const o = r.facets.outcome if (o === 'good') good++ else if (o === 'scrap') scrap++ } return good + scrap > 0 ? { quality: { good, scrap } } : {} } /** * 기록을 축으로 묶어 축별 통계를 낸다 — **전체와 같은 규칙**으로(같은 stats 함수, 같은 창). * * 축의 값이 아주 많을 수 있다(오더 축은 수천 개). 그래서 건수 큰 것부터 상한까지만 돌려주고 * **잘라낸 수를 함께 알린다** — 들어온 것이 없는 절단은 "이게 전부" 라는 거짓을 만든다. */ function groupRecords( records: CompletionRecord[], options: KpiFoldOptions ): { by: KpiGroupBy; items: KpiGroup[]; truncated?: number; truncatedTasks?: number } { const by = options.groupBy as KpiGroupBy const bins = new Map() for (const rec of records) { const key = facetKey(rec, options) const bin = bins.get(key) if (bin) bin.push(rec) else bins.set(key, [rec]) } const all = [...bins.entries()] .map(([key, rows]) => ({ key, tasks: rows.length, leadTime: stats(rows.map(r => r.leadMs).filter((v): v is number => v !== undefined)), workTime: stats(rows.map(r => r.workMs).filter((v): v is number => v !== undefined)), waitTime: stats(rows.map(r => r.waitMs).filter((v): v is number => v !== undefined)), busyMs: rows.reduce((a, r) => a + (r.workMs ?? 0), 0), ...qualityOf(rows) })) .sort((a, b) => b.tasks - a.tasks || (a.key < b.key ? -1 : 1)) const limit = Math.max(1, Math.min(100, options.groupLimit ?? 20)) const items = all.slice(0, limit) const dropped = all.slice(limit) /* 축별로 "창 대비 비율" 은 내지 않는다 — 자원 아닌 축(오더·작업종류)에서 그 비율은 의미가 없고, * 의미 없는 숫자를 내면 사용자가 그것으로 판단한다. 자원 점유는 `utilization` 이 이미 낸다. */ return { by, items, ...(dropped.length ? { truncated: dropped.length, truncatedTasks: dropped.reduce((a, g) => a + g.tasks, 0) } : {}) } } function ms(value: string | undefined): number | null { if (!value) return null const t = Date.parse(value) return Number.isFinite(t) ? t : null } /** 분위수 — 정렬된 값에서 선형 보간 없이 가장 가까운 아래 값(작은 표본에서 과장하지 않는다). */ function quantile(sorted: number[], q: number): number { if (sorted.length === 0) return 0 const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(q * sorted.length))) return sorted[idx] } function stats(values: number[]): DurationStats { if (values.length === 0) return { count: 0, p10Ms: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 } const sorted = [...values].sort((a, b) => a - b) const sum = sorted.reduce((a, b) => a + b, 0) return { count: sorted.length, p10Ms: quantile(sorted, 0.1), p50Ms: quantile(sorted, 0.5), p90Ms: quantile(sorted, 0.9), avgMs: Math.round(sum / sorted.length) } } /** * 이벤트를 계산해 시간창 KPI 를 만든다. * * **호출부 규약**: `events` 는 창보다 **넉넉히 앞까지** 담아 넘긴다(예: 창 시작 − 리드타임 상한). * 완료된 작업의 시작 이벤트가 창 밖에 있을 수 있기 때문이다. 못 찾은 것은 `unpaired` 로 알린다 — * 평균에서 오류 없이 빠지면 지표가 거짓이 된다. * * 시간축은 `eventTime`(ISO) 차분이다. sim 클록이 실제 시각과 오프셋이 있어도 **차분은 정확**하다 * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다. */ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult { /* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 — 두 곳에 적으면 같은 저널로 다른 답이 나온다). */ const { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status')) let completedOrders = 0 /* 완료로 읽지 못한 오더 상태 낱말 → 건수(§`orderStatusNotCounted`). */ const orderStatusNotCounted = new Map() /* 이미 완료로 센 오더 — 폴링 원본이 같은 종결을 다시 보내도 한 번만 센다. */ const completedOrderIds = new Set() let seen = 0 let inWindow = 0 for (const e of events) { const at = ms(e.eventTime) if (at === null) continue seen++ if (at >= window.fromMs && at <= window.toMs) inWindow++ const d = e.payload?.data ?? e.payload ?? {} if (e.eventType === 'order.status') { /* * **끝났나는 커널이 답한다** (2026-08-23) — `isOrderTerminal`. * * 예전에는 여기서 **동의어 목록**으로 추론했다(`completed`·`fulfilled`·`done`·`finished`). * 그 방식이 낸 대가가 둘이었고 둘 다 들어온 것이 없었다. * * ① 원본의 낱말이 목록에 없으면 완료가 0 이 된다. 실제로 그랬다 — 첫 실 시스템의 `FINISHED` * 가 빠져 있었고, 그때 목록에 낱말을 더한 것은 진짜 빈 자리(그 커넥터가 자기 상태 표를 * 안 쓰고 있었다)를 가린 땜빵이었다. * ② **시뮬 트윈에서는 언제나 0 이었다.** 시뮬은 오더를 이행하며 `fulfilled` 만 올리고 * `status` 는 바꾸지 않는다 — 낱말로 묻는 판정은 시뮬의 완료를 한 건도 세지 못했다. * * 표준이 그 축을 갈라 두었다: `orders.status` ↔ `RequestState`(표준도 열거하지 않는다)이고, * 「됐나」는 **실적**에서 읽는다(`JobResponse`, 요구는 `SegmentRequirement.Quantity`). 그래서 * 판정의 1차 근거는 **양**이고 낱말은 양으로 말할 수 없는 종결(취소·중도 종료)에만 쓴다. */ /* * ── 받는 오더는 처리량이 아니다 (2026-09-19, ADR-0076 보탬 ⑥) ──────────── * * 입고 · 반품 입고는 세우는 순간 `fulfilled >= requested` 다 — 받았다는 사실이 곧 그 오더의 * 완료이기 때문이다. 그래서 세면 **받을 때마다 처리량이 한 건씩 는다.** 판정은 계약의 * `isFlowOrder` 한 곳이 한다(여기서 `kind` 를 다시 적지 않는다). */ if (!isFlowOrder(d)) continue const status = String(d.status ?? '').toLowerCase() const done = isOrderTerminal({ status: d.status, requested: d.requested, fulfilled: d.fulfilled }) if (at >= window.fromMs && at <= window.toMs) { /* * **오더당 한 번만 센다.** 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 * 있고(그것은 결함이 아니라 폴링의 성질이다), 사건마다 세면 처리량이 알리지 않고 부풀려진다. * 작업 쪽은 커널의 짝맞춤이 이미 그 규율을 갖고 있다(「완료는 작업당 하나」) — 오더만 없었다. */ if (done) { const id = String(d.orderId ?? '') if (id && !completedOrderIds.has(id)) { completedOrderIds.add(id) completedOrders++ } else if (!id) completedOrders++ // 정체를 모르면 셈에서 빼지 않는다(알리지 않고 줄이지 않는다) } /* * **완료로 세지 않은 낱말의 인구조사.** 「못 읽었다」가 아니다 — `picking` 처럼 완료가 아닌 * 낱말이 여기 오는 것은 정상이다. 완료가 0 인데 여기 낱말이 쌓여 있으면 원인이 곧 보인다. * * 빈 낱말은 세지 않는다: 상태를 말하지 않은 사건은 다른 결함이고(유입 문이 상태를 필수로 * 받는다) 여기서 섞으면 이 값의 뜻이 흐려진다. */ else if (status) orderStatusNotCounted.set(status, (orderStatusNotCounted.get(status) ?? 0) + 1) } } } /* * 에너지 — 창에 **온전히 든** 구간 마감 사건만 센다(걸친 구간은 커널 규칙이 뺀다). * 사건이 하나도 없으면 이 트윈에는 에너지가 없는 것이므로 블록 자체를 내지 않는다. */ const demandWindows = events .filter(e => e.eventType === ENERGY_EVENT.demandWindow) .map(e => { const d = e.payload?.data ?? e.payload ?? {} /* 최대(`maxKW`)도 함께 넘긴다 — 요금이 걸리는 수는 평균이 아니라 최대다. 총부하(`grossMaxKW`)는 「무엇이 깎였나」를 되짚는 축이다(발전·방전 차감 전). */ return { startMs: Number(d.startMs), endMs: Number(d.endMs), meanKW: Number(d.meanKW), maxKW: Number(d.maxKW), grossMaxKW: Number(d.grossMaxKW), /* 지점별 몫 — 계량이 분기에 있으면 그 값은 **측정**이다(배분이 아니다). 커널이 계층 판정 (`root`)까지 실어 주므로 여기서 같은 규칙을 다시 쓰지 않는다. */ points: (Array.isArray(d.points) ? d.points : []) .map((p: any) => ({ id: String(p?.id ?? ''), kWh: Number(p?.kWh), root: p?.root === true })) .filter((p: any) => p.id && Number.isFinite(p.kWh)), /* 피크가 찍힌 순간의 지점별 kW — 요금이 걸린 수는 kW 다(전력량이 큰 분기와 피크를 만든 분기는 다르다). */ pointsAtPeak: (Array.isArray(d.pointsAtPeak) ? d.pointsAtPeak : []) .map((p: any) => ({ id: String(p?.id ?? ''), kW: Number(p?.kW) })) .filter((p: any) => p.id && Number.isFinite(p.kW)) } }) .filter(w => Number.isFinite(w.startMs) && Number.isFinite(w.endMs)) /* * 발전 기간 사실 — 커널이 기간을 마감할 때 낸 것이다. 구간 마감과 같은 규칙으로 **창에 온전히 든 * 것만** 센다(걸친 기간을 반만 세면 합이 창을 넘는다). * * 관측 구간(`observedFrom`·`observedTo`)을 함께 읽는다 — 값의 근거가 얼마나 덮였는지가 그 둘에 있다. */ const generationPeriods = onceById(events.filter(e => e.eventType === ENERGY_EVENT.generationPeriod)) .map(e => { const d = e.payload?.data ?? e.payload ?? {} return { equipmentId: String(d.equipmentId ?? ''), kWh: Number(d.kWh), startMs: Date.parse(String(d.periodStart ?? '')), endMs: Date.parse(String(d.periodEnd ?? '')), observedFromMs: Date.parse(String(d.observedFrom ?? '')), observedToMs: Date.parse(String(d.observedTo ?? '')) } }) .filter(g => g.equipmentId && Number.isFinite(g.kWh) && Number.isFinite(g.startMs) && Number.isFinite(g.endMs)) /* * 밖에서 마감되어 온 사용 구간 — 구간 마감과 같은 규칙으로 **창에 온전히 든 것만** 센다. */ const usagePeriods = onceById(events.filter(e => e.eventType === ENERGY_EVENT.usagePeriod)) .map(e => { const d = e.payload?.data ?? e.payload ?? {} return { meterId: String(d.meterId ?? ''), kWh: Number(d.kWh), startMs: Date.parse(String(d.from ?? '')), endMs: Date.parse(String(d.to ?? '')), unitPrice: Number(d.unitPrice), maxKW: Number(d.maxKW), currency: d.currency ? String(d.currency) : undefined, basis: d.basis ? String(d.basis) : 'unstated' } }) .filter(u => u.meterId && Number.isFinite(u.kWh) && Number.isFinite(u.startMs) && Number.isFinite(u.endMs)) /* 청구서 — 창에 온전히 든 것 중 가장 늦게 끝난 것 하나. */ const bills = onceById(events.filter(e => e.eventType === ENERGY_EVENT.bill)) .map(e => { const d = e.payload?.data ?? e.payload ?? {} return { from: String(d.from ?? ''), to: String(d.to ?? ''), startMs: Date.parse(String(d.from ?? '')), endMs: Date.parse(String(d.to ?? '')), total: Number(d.total), energyCharge: Number(d.energyCharge), demandCharge: Number(d.demandCharge), currency: d.currency ? String(d.currency) : undefined, billingDemandKW: Number(d.billingDemandKW) } }) .filter(b => Number.isFinite(b.startMs) && Number.isFinite(b.endMs)) /* 발전 단가 — 기간마다 그때의 값이다(날마다 바뀐다). */ const generationPrices = onceById(events.filter(e => e.eventType === ENERGY_EVENT.generationPrice)) .map(e => { const d = e.payload?.data ?? e.payload ?? {} return { startMs: Date.parse(String(d.from ?? '')), endMs: Date.parse(String(d.to ?? '')), unitPrice: Number(d.unitPrice), currency: String(d.currency ?? '') } }) .filter(p => Number.isFinite(p.startMs) && Number.isFinite(p.endMs) && Number.isFinite(p.unitPrice) && p.currency) const leads: number[] = [] const works: number[] = [] const waits: number[] = [] const busyByResource = new Map() /* 창 안에 완료된 건들의 **기록** — 통계를 낸 뒤에도 버리지 않는다. 관점 축은 같은 기록을 다시 * 묶는 것일 뿐이므로, 기록을 남기면 축을 하나 더 붙이는 값이 거의 0 이 된다. */ const records: CompletionRecord[] = [] let tasksInWindow = 0 let unpairedLead = 0 let unpairedWork = 0 for (const t of tasks) { const completed = t.completedMs if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다 if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다 tasksInWindow++ const c = t.createdMs const s = t.startedMs const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets } const resource = rec.facets.resource if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c)) else unpairedLead++ if (s !== undefined && completed >= s) { const busy = completed - s works.push((rec.workMs = busy)) if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy) } else { unpairedWork++ } if (c !== undefined && s !== undefined && s >= c) waits.push((rec.waitMs = s - c)) records.push(rec) } const span = Math.max(1, window.toMs - window.fromMs) const utilization = [...busyByResource.entries()] .map(([resourceId, busyMs]) => ({ resourceId, busyMs, ratio: Math.min(1, busyMs / span) })) .sort((a, b) => b.busyMs - a.busyMs) return { window: { fromMs: window.fromMs, toMs: window.toMs }, throughput: { tasks: tasksInWindow, orders: completedOrders }, /* 발전만 있는 현장(계량 없는 발전소)도 답이 나와야 한다 — 둘 중 하나만 있어도 블록을 낸다. */ ...(demandWindows.length || generationPeriods.length || usagePeriods.length || bills.length ? (() => { const e = energyOfWindows(demandWindows, { startMs: window.fromMs, endMs: window.toMs }) const usage = usageOfPeriods(usagePeriods, { startMs: window.fromMs, endMs: window.toMs }) /* * 어느 회계를 쓸지 정한다 — **더하지 않는다.** 계량 표본에서 우리가 마감한 값이 있으면 그것을 * 쓰고(강한 근거), 없으면 밖에서 마감되어 온 값을 쓴다. 무엇을 썼는지 함께 낸다. */ const usedKWh = e.kWh !== undefined ? e.kWh : usage?.kWh const kWhBasis = e.kWh !== undefined ? 'demand-window' : usage ? 'usage-period' : undefined /* 분모는 **완료 오더**다(대당 전력). 창에 완료가 없으면 커널 규칙이 「정의되지 않음」으로 답한다. */ const i = energyIntensity({ kWh: usedKWh, energyWindow: e.window, denominator: { kind: 'output', value: completedOrders, window: e.window } }) /* * 요금은 **구간마다 제 단가로** 매긴 값이 있으면 그것을 쓴다. 단일 단가로 접으면 시간대마다 * 다른 단가가 사라지고, 「피크를 깎아 얼마를 아끼나」가 그 차이 위에 선다. */ /* 요금적용전력은 청구서가 먼저다 — 선언은 청구가 오지 않는 현장의 몫이다. */ /* 이 창의 최고 수요 — 사용 구간이 실어 온 값 중 가장 큰 것(원본 계량기가 잰 값이다). */ const peaks = usagePeriods .filter(u => u.startMs >= window.fromMs && u.endMs <= window.toMs && Number.isFinite(u.maxKW)) .map(u => ({ kW: u.maxKW as number, toMs: u.endMs })) const observedPeak = peaks.length ? peaks.reduce((a, x) => (x.kW > a.kW ? x : a)) : undefined const billingDemand = billingDemandOf( bills.filter(b => Number.isFinite(b.billingDemandKW)).map(b => ({ endMs: b.endMs, billingDemandKW: b.billingDemandKW })), options.tariffBasis, options.lastBill, options.tariff?.billingDemandKW, observedPeak ) const cost = options.tariff ? electricityCost({ kWh: usedKWh, peakKW: e.peakKW, tariff: { ...options.tariff, ...(billingDemand ? { billingDemandKW: billingDemand.kW } : {}) }, ...(usage?.charge !== undefined ? { energyChargeFromPeriods: usage.charge } : {}) }) : undefined return { energy: { ...(usedKWh !== undefined ? { kWh: usedKWh } : {}), ...(kWhBasis ? { kWhBasis } : {}), ...(billingDemand ? { billingDemand } : {}), ...(usage ? { usage } : {}), /* 이 창의 최대수요 — 계약 대비 판정이 보는 수다(전력량과 같은 구간 집합에서 왔다). */ ...(e.peakKW !== undefined ? { peakKW: e.peakKW, peakStartMs: e.peakStartMs } : {}), /* 깎인 양은 **같은 구간의 두 수**의 차이다 — 여기서 한 번만 계산해 두 곳이 갈라지지 않게. */ ...(e.peakGrossKW !== undefined && e.peakKW !== undefined ? { peakGrossKW: e.peakGrossKW, ...(e.peakGrossKW > e.peakKW ? { shavedKW: e.peakGrossKW - e.peakKW } : {}) } : {}), basis: e.basis, counted: e.counted, skipped: e.skipped, intensity: i.value === null ? { value: null, reason: (i as any).reason } : { value: i.value, unit: (i as any).unit }, ...(measuredFeeders(demandWindows, { startMs: window.fromMs, endMs: window.toMs }, e.kWh, options.meterFeeder) ?? feederShares(e.kWh, utilization, options.resourceFeeder)), /* 금액은 같은 창의 전력량·최대수요에서 나온다 — 창이 하나뿐이라 어긋날 수 없다. */ ...(cost ? { cost } : {}), ...generatedOfPeriods( generationPeriods, { startMs: window.fromMs, endMs: window.toMs }, options.generationRated, /* 창 안의 단가 사실이 먼저이고, 없으면 이어받은 마지막 단가를 쓴다. */ generationPrices.length ? generationPrices : options.generationPrice ? [ { startMs: Date.parse(options.generationPrice.from), endMs: Date.parse(options.generationPrice.to), unitPrice: options.generationPrice.unitPrice, currency: options.generationPrice.currency } ] : [] ), /* 청구서는 사실이다 — 우리 계산을 덮지 않고 나란히 둔다(§`billOfWindow`). */ ...(() => { const b = billOfWindow(bills, { startMs: window.fromMs, endMs: window.toMs }, cost as any) return b ? { bill: b } : {} })() } } })() : {}), leadTime: stats(leads), workTime: stats(works), waitTime: stats(waits), utilization, unpaired: { leadTime: unpairedLead, workTime: unpairedWork }, /* 못 읽은 오더 상태 낱말 — 비면 빈 객체다(§`orderStatusNotCounted`). */ orderStatusNotCounted: Object.fromEntries(orderStatusNotCounted), eventsSeen: seen, eventsInWindow: inWindow, ...(options.groupBy ? { groups: groupRecords(records, options) } : {}) } } /** * 관점 × 비교 교차 — **"구역별로 어제 대비 어떤가"**. * * 축(무엇을)과 비교(언제 대비)를 따로 보면 사용자가 머릿속에서 두 표를 대조해야 한다. 운영에서 가장 * 값 있는 질문은 그 교차다: 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. * * 정직성 규율: * · 두 구간에 **모두 있는** 축만 delta 를 낸다. * · 지금만 있는 축은 `isNew`(이전에 없던 일) — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다. * · **사라진 축**(전에 있었고 지금 없는 것)은 따로 돌려준다. 표에서 그냥 빠지면 **멈춘 구역이 * 눈에 보이지 않는다** — 운영에서는 이것이 가장 중요한 신호일 수 있다. */ export interface CrossedGroup extends KpiGroup { /** 비교 구간의 건수(두 구간에 모두 있을 때만). */ prevTasks?: number /** 현재 − 비교구간 건수(두 구간에 모두 있을 때만). */ deltaTasks?: number /** 비교 구간 대비 작업시간 중앙값 차이(둘 다 측정된 때만). */ deltaWorkP50Ms?: number /** 비교 구간에는 없던 축 — delta 대신 이 표식을 준다. */ isNew?: boolean } export function crossGroups( now: KpiGroup[], prev: KpiGroup[] ): { items: CrossedGroup[]; disappeared: { key: string; tasks: number }[] } { const before = new Map(prev.map(g => [g.key, g])) const items: CrossedGroup[] = now.map(g => { const p = before.get(g.key) if (!p) return { ...g, isNew: true } return { ...g, prevTasks: p.tasks, deltaTasks: g.tasks - p.tasks, ...(deltaP50(g.workTime, p.workTime) !== undefined ? { deltaWorkP50Ms: deltaP50(g.workTime, p.workTime) } : {}) } }) const present = new Set(now.map(g => g.key)) const disappeared = prev.filter(g => !present.has(g.key)).map(g => ({ key: g.key, tasks: g.tasks })) return { items, disappeared } } /* ── 시간 비교 ────────────────────────────────────────────────────────────── * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 같은 길이의 창을 옮겨 * 한 번 더 계산하면 되므로(폴드는 순수) 값싸다. 창 옮기기와 차이 계산은 계산 규칙이라 여기 있다 — * 조회 층에 두면 테스트가 DB 를 끌고 와야 한다. */ /** * 비교 구간의 창 — 같은 **길이**로 옮긴다. * * previous: 바로 앞 구간(경계가 맞물리게 — 겹치거나 벌어지면 구간의 합이 어긋난다). * yesterday: 정확히 24시간 전 같은 자리. 시뮬 트윈의 클록이 실제 시각과 달라도 **차분은 정확**하다. */ export function shiftWindow(fromMs: number, toMs: number, basis: 'previous' | 'yesterday'): KpiWindow { const span = toMs - fromMs const back = basis === 'yesterday' ? 24 * 60 * 60_000 : span return { fromMs: fromMs - back, toMs: toMs - back } } /** * 중앙값 차이(현재 − 비교구간) — **둘 다 측정된 것만** 낸다. * * 한쪽이 결측이면 차이는 **없는 것**이다(0 이 아니다). 0 으로 내면 "변화 없음" 이라는 거짓이 된다. * 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록, 소요는 작을수록 좋다는 해석은 소비처의 몫이다. */ export function deltaP50(now: DurationStats, prev: DurationStats): number | undefined { return now.count > 0 && prev.count > 0 ? now.p50Ms - prev.p50Ms : undefined } /** 구간 하나의 최소 요약 — 추세를 그릴 정보만(전체 KPI 를 구간마다 실어 보내지 않는다). */ export interface KpiBucket { fromMs: number toMs: number tasks: number orders: number /** 그 구간의 작업시간 중앙값 — 추세에서 "느려지고 있는가" 를 보는 값. */ workP50Ms: number } /** * 창을 N 등분해 구간별로 처리한다 — **같은 이벤트를 창만 바꿔** 다시 계산한다(조회 추가 없음). * * 마지막 구간은 나머지를 흡수한다(부동소수 나눗셈으로 끝이 밀리면 마지막 완료가 빠진다). * `count <= 1` 이면 쪼갤 것이 없으므로 빈 배열 — 호출부가 "추세 없음" 으로 다룬다. */ export function foldKpiBuckets(events: KpiEvent[], window: KpiWindow, count: number): KpiBucket[] { const n = Math.max(0, Math.min(48, Math.floor(count))) if (n <= 1) return [] const step = (window.toMs - window.fromMs) / n const out: KpiBucket[] = [] for (let i = 0; i < n; i++) { const fromMs = window.fromMs + step * i const toMs = i === n - 1 ? window.toMs : window.fromMs + step * (i + 1) const b = foldKpi(events, { fromMs, toMs }) out.push({ fromMs, toMs, tasks: b.throughput.tasks, orders: b.throughput.orders, workP50Ms: b.workTime.p50Ms }) } return out }