import { type ElectricityCost, type TariffDeclaration } from '@operato/twin-kernel'; import { type WorkCalendarEntry } 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; }; } 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; } /** * 이벤트를 계산해 시간창 KPI 를 만든다. * * **호출부 규약**: `events` 는 창보다 **넉넉히 앞까지** 담아 넘긴다(예: 창 시작 − 리드타임 상한). * 완료된 작업의 시작 이벤트가 창 밖에 있을 수 있기 때문이다. 못 찾은 것은 `unpaired` 로 알린다 — * 평균에서 오류 없이 빠지면 지표가 거짓이 된다. * * 시간축은 `eventTime`(ISO) 차분이다. sim 클록이 실제 시각과 오프셋이 있어도 **차분은 정확**하다 * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다. */ export declare function foldKpi(events: KpiEvent[], window: KpiWindow, options?: KpiFoldOptions): KpiResult; /** * 관점 × 비교 교차 — **"구역별로 어제 대비 어떤가"**. * * 축(무엇을)과 비교(언제 대비)를 따로 보면 사용자가 머릿속에서 두 표를 대조해야 한다. 운영에서 가장 * 값 있는 질문은 그 교차다: 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. * * 정직성 규율: * · 두 구간에 **모두 있는** 축만 delta 를 낸다. * · 지금만 있는 축은 `isNew`(이전에 없던 일) — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다. * · **사라진 축**(전에 있었고 지금 없는 것)은 따로 돌려준다. 표에서 그냥 빠지면 **멈춘 구역이 * 눈에 보이지 않는다** — 운영에서는 이것이 가장 중요한 신호일 수 있다. */ export interface CrossedGroup extends KpiGroup { /** 비교 구간의 건수(두 구간에 모두 있을 때만). */ prevTasks?: number; /** 현재 − 비교구간 건수(두 구간에 모두 있을 때만). */ deltaTasks?: number; /** 비교 구간 대비 작업시간 중앙값 차이(둘 다 측정된 때만). */ deltaWorkP50Ms?: number; /** 비교 구간에는 없던 축 — delta 대신 이 표식을 준다. */ isNew?: boolean; } export declare function crossGroups(now: KpiGroup[], prev: KpiGroup[]): { items: CrossedGroup[]; disappeared: { key: string; tasks: number; }[]; }; /** * 비교 구간의 창 — 같은 **길이**로 옮긴다. * * previous: 바로 앞 구간(경계가 맞물리게 — 겹치거나 벌어지면 구간의 합이 어긋난다). * yesterday: 정확히 24시간 전 같은 자리. 시뮬 트윈의 클록이 실제 시각과 달라도 **차분은 정확**하다. */ export declare function shiftWindow(fromMs: number, toMs: number, basis: 'previous' | 'yesterday'): KpiWindow; /** * 중앙값 차이(현재 − 비교구간) — **둘 다 측정된 것만** 낸다. * * 한쪽이 결측이면 차이는 **없는 것**이다(0 이 아니다). 0 으로 내면 "변화 없음" 이라는 거짓이 된다. * 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록, 소요는 작을수록 좋다는 해석은 소비처의 몫이다. */ export declare function deltaP50(now: DurationStats, prev: DurationStats): number | undefined; /** 구간 하나의 최소 요약 — 추세를 그릴 정보만(전체 KPI 를 구간마다 실어 보내지 않는다). */ export interface KpiBucket { fromMs: number; toMs: number; tasks: number; orders: number; /** 그 구간의 작업시간 중앙값 — 추세에서 "느려지고 있는가" 를 보는 값. */ workP50Ms: number; } /** * 창을 N 등분해 구간별로 처리한다 — **같은 이벤트를 창만 바꿔** 다시 계산한다(조회 추가 없음). * * 마지막 구간은 나머지를 흡수한다(부동소수 나눗셈으로 끝이 밀리면 마지막 완료가 빠진다). * `count <= 1` 이면 쪼갤 것이 없으므로 빈 배열 — 호출부가 "추세 없음" 으로 다룬다. */ export declare function foldKpiBuckets(events: KpiEvent[], window: KpiWindow, count: number): KpiBucket[];