/* * 예측 지표 — **스냅샷에서 수 하나를 뽑는 규칙.** * * ── 왜 순수 모듈인가 (2026-08-15) ─────────────────────────────────────────── * 이 표가 리졸버 안에 있어서 시험이 불러올 수 없었다(type-graphql·shell 을 물고 있다). 그런데 여기서 * 틀리면 예측 그래프 전체가 알리지 않고 다른 것을 그린다 — 오류 없이. 규칙은 시험이 닿는 자리에 둔다. * * ── 에너지 지표를 더한 이유 ───────────────────────────────────────────────── * 예측 화면의 지표가 물류 여섯(재고·점유·작업·오더·출고·백로그)으로 고정돼 있어서, 에너지 트윈을 * 고르면 **남의 질문에 답하는 그래프**가 떴다. 에너지 트윈에는 재고도 오더도 없으므로 그 값들은 * 전부 0 이고, 0 은 「없다」가 아니라 「이 트윈의 질문이 아니다」이다. * * 여기 더한 셋은 **커널이 이미 상태로 들고 있는 것**이다 — 새 계산을 만들지 않았다. 특히 부하 합은 * 커널이 뿌리 계량기로만 세는데(계층 계량의 이중 계상 방지), 그 규칙을 여기서 다시 쓰면 두 벌이 된다. * 그래서 지점을 직접 더하지 않고 **구간 값**을 읽는다. */ import { isFlowOrder, isOrderTerminal } from '@operato/ops-contract' /* * ── Folded orders are counted too (ADR-0092 decision 3, ruling 2026-09-24) ───────────── * * A twin folds finished orders out of its hot list at checkpoint time and keeps only their counts * (`foldedOrders.counts`, kind × terminal status, never trimmed). Counting the hot list alone would drop * `orders` and `shipped` the moment a fold happens — and "the increase since the fork" alone would still * break `twinForecastGapTrend`, whose actual at T+h is read from recovered state: a fold between T and * T+h makes the actual increase negative. So both metrics are cumulative including the folded side, and * an increase is the difference of the same number. Which folded kinds are flow orders is asked of the * contract's `isFlowOrder`, the same as the hot list; `shipped` uses the same word on both sides. */ function foldedFlowOrders(s: any, status?: string): number { let n = 0 for (const [kind, byStatus] of Object.entries((s?.foldedOrders?.counts ?? {}) as Record>)) { if (!isFlowOrder({ kind })) continue if (status) n += Number(byStatus?.[status]) || 0 else for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0 } return n } /** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이 * 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */ export type MetricFn = (s: any) => number export const FORECAST_METRICS: Record = { items: s => (s?.items || []).length, occupancy: s => (s?.locations || []).reduce((a: number, n: any) => a + (n.occupancy || 0), 0), tasks: s => (s?.tasks || []).length, /* * ── 세는 것은 **흐름 오더**다 (2026-09-19, ADR-0076 보탬 ⑥) ──────────────── * * 받는 오더(입고 · 반품 입고)는 도착이 세우고 도착이 이행한다. 그대로 세면 「나간 것」이 받을 때마다 * 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름 * 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다). */ orders: s => (s?.orders || []).filter(isFlowOrder).length + foldedFlowOrders(s), // 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나"). shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length + foldedFlowOrders(s, 'shipped'), /* * ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ───────────────────────── * * 낱말 둘(`shipped`·`cancelled`)로 물으면 **원본이 다른 낱말을 쓰는 미러에서 끝난 오더가 영영 * 백로그에 남는다.** `status` 는 열린 축이고(표준도 `RequestState` 를 열거하지 않는다) 실 시스템은 * `completed`·`FINISHED` 를 쓴다 — 그 트윈의 백로그 곡선은 내려가지 않는다. 종결 판정은 커널이 * 한 곳에서 한다(`isOrderTerminal` — 낱말이 아니라 **양**을 1차 근거로 본다). * * `shipped` 한 낱말은 남는다. 그것은 이 커널이 **자기가 쓰는 말**이고(WMS 의 출하 완료), 계약의 * 종결 목록(`completed` · `cancelled`)에는 없다. 양을 말하지 않는 스냅샷에서 그 오더가 백로그로 * 되돌아오지 않게 둘을 함께 본다 — 빼는 쪽이라 이 셈이 전보다 커지는 일은 없다. 커널의 낱말이 * 계약의 종결 목록에 들어오는 날 이 줄이 지워진다(아키텍트에게 올림). */ backlog: s => (s?.orders || []) .filter(isFlowOrder) .filter((o: any) => !isOrderTerminal(o) && o.status !== 'shipped').length, /* * ── 에너지 ──────────────────────────────────────────────────────────────── * `peakKW` 는 **마감된 구간의 최대**다 — 단조 증가라 지평선 위에서 「최대수요가 어디까지 오르나」로 * 읽힌다. 열린 구간은 넣지 않는다(아직 확정이 아니다 — 커널이 지키는 규율을 여기서도 지킨다). */ peakKW: s => Number(s?.energy?.peakSince?.kW) || 0, /* * 이번 구간이 이대로 가면 얼마로 마감되나 — 커널이 낸 투영(`mean-so-far`)을 그대로 읽는다. * 우리가 다시 계산하지 않는다: 투영 규칙이 두 벌이 되면 화면과 판정이 다른 말을 한다. */ projectedKW: s => Number(s?.energy?.open?.projectedKW ?? s?.energy?.open?.meanKW) || 0, /* * 계약을 넘긴 구간 수 — 「몇 번 넘나」는 요금이 걸린 질문이라 최대치와 따로 본다. * 계약을 모르는 트윈에서는 늘 0 이다(넘김을 판정할 근거가 없다 — 지어내지 않는다). */ overWindows: s => (s?.energy?.closed || []).filter((w: any) => w?.overContract === true).length } /** 이 지표가 그 종류의 트윈에서 뜻이 있나 — 화면이 목록을 고를 때 쓴다. */ export const ENERGY_METRICS = ['projectedKW', 'peakKW', 'overWindows'] as const export const FLOW_METRICS = ['items', 'occupancy', 'tasks', 'orders', 'shipped', 'backlog'] as const /** * 트윈 종류에 맞는 지표 목록 — **한 곳에서 정한다.** * * 화면이 각자 목록을 들고 있으면 종류가 늘 때 어느 화면이 뒤처졌는지 알 수 없다. */ export function metricsForKind(kind?: string): string[] { return kind === 'ems' ? [...ENERGY_METRICS] : [...FLOW_METRICS] }