/* * 성과 목표 — **숫자를 판단으로 바꾸는 층.** * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 화면과 AI 는 "처리량 345건 · 작업시간 29초" 를 정확히 말한다. 그런데 **그것이 좋은지 나쁜지는 말할 수 * 없다.** 판단 근거가 앞 구간 비교뿐이라 "어제보다 나아졌다" 까지가 한계이고, 어제도 나빴으면 그 말은 * 쓸모가 없다. 목표가 붙는 순간 같은 숫자가 **정보에서 판단으로** 바뀐다. * * ── 층을 섞지 않는다 ──────────────────────────────────────────────────────── * 폴드(`kpi-fold`)는 **사실만** 낸다 — 목표를 모른다. 목표를 아는 순간 "사실을 세는 규칙" 과 "잘했나 * 판정하는 규칙" 이 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 판정은 * 여기, **순수 함수**로 따로 둔다(폴드처럼 테스트가 값싸다). * * ── 목표의 집 (사용자 결정 2026-08-02) ────────────────────────────────────── * **트윈은 운영 목표를 갖고, `@things-factory/kpi` 는 경영 목표를 갖는다.** 경계: * * 트윈(TwinTarget) 이 구간·이 교대·이 공간의 **즉시 판단**. 현장 관리자가 트윈 화면에서 고친다. * 값은 저널 폴드에서 **그때그때 계산**된다(저장된 값을 읽지 않는다). * packages/kpi **기간 평가**(조직·분기·점수·가중치·알림). 값은 저장돼 있어야 한다. * * 이 경계를 지키는 규칙 하나: **트윈은 목표를 복제하지 않는다.** 경영 목표가 필요하면 트윈의 사실을 * 내보내 그쪽이 소비하고, 그쪽 목표를 여기로 베껴 오지 않는다 — 베끼는 순간 두 개의 진실이 된다. */ /* * ── 운영 임계(커널 attention)와의 경계 ───────────────────────────────────── * * **attention 은 개체 하나의 지금 상태**다 — 앵커(설비·자리·오더)와 할 수 있는 조치를 갖는다. * **target(여기)은 구간의 집계**다 — 앵커도 조치도 없고 추세를 판단한다. * * 그래서 창 단위 집계 판정을 attention 으로 넣지 않는다: "가동률 저하"(창 평균) attention 을 만들면 * `utilization.ratio` 목표와 **같은 사실을 두 층이 다른 임계로 판정**하게 된다. * (design/map/performance.md §5.0-4 — 현재 다섯 attention 과 이 어휘는 겹치지 않는다.) */ import type { DurationStats, KpiResult } from './kpi-fold.js' /** 측정된 기록이 있을 때만 값으로 인정한다 — 없으면 모름(0 이 아니다). */ const measured = (k: KpiResult, v: number): number | undefined => (k.eventsInWindow > 0 ? v : undefined) /** 분위수는 표본이 있을 때만 값이다 — `count === 0` 의 `p50Ms: 0` 은 "0 밀리초" 가 아니라 "없음" 이다. */ const statOf = (s: DurationStats, pick: (d: DurationStats) => number): number | undefined => s.count > 0 ? pick(s) : undefined /** * 지표 어휘 — **이름을 한 곳에서 정한다.** * * 목표와 사실이 서로 다른 이름을 쓰면 그 사이에 매핑 표가 생기고, 그 표가 곧 방언이 된다. 그래서 * 목표가 가리킬 수 있는 지표는 여기 열거된 것뿐이고, 값을 꺼내는 방법도 여기 함께 있다. * * **`direction` 은 지표의 성질이지 사용자의 선택이 아니다.** 처리량은 높을수록 좋고 대기시간은 낮을수록 * 좋다 — 이것을 입력으로 받으면 반대로 적은 목표가 만들어지고, 그 뒤 판정이 전부 뒤집힌다. */ export const TWIN_METRIC = { /* 기록이 없는 구간은 처리량 0 이 아니라 **모름**이다 — `eventsInWindow` 가 그것을 구분한다. 0 으로 판정하면 "측정 못 한 구간" 이 "일을 안 한 구간" 으로 보고된다. */ 'throughput.tasks': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.tasks) }, 'throughput.orders': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.orders) }, /* 소요시간 분위수는 `count === 0` 이면 **0 이 아니라 없다.** 이것을 0 으로 읽으면 시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 — 미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). 테스트가 이것을 잡았다. */ 'leadTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p50Ms) }, 'leadTime.p90Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p90Ms) }, 'workTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.workTime, d => d.p50Ms) }, 'waitTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.waitTime, d => d.p50Ms) }, /* * ── 에너지 (2026-08-17) ──────────────────────────────────────────────────── * * 값은 폴드가 이미 내고 있었는데 **목표를 걸 수 있는 지표 목록에 없었다.** 그래서 화면은 「이번 창에 * 1,240kWh」를 보여 주면서도 그것이 좋은지 나쁜지는 말하지 못했다 — 에너지야말로 계약이라는 딱딱한 * 기준선이 있는 영역인데도 그랬다. * * 셋 다 방향은 **낮을수록 좋다**로 못 박는다. 「많이 쓸수록 좋다」는 에너지 목표는 없다. 산출이 늘어 * 전력량이 느는 경우는 원단위(`energy.intensity`)가 답할 자리이지, 방향을 뒤집을 이유가 아니다. * * 값이 없는 창(에너지 트윈이 아니거나 계측이 끊긴 창)은 `undefined` 로 남긴다 — 0 으로 읽으면 * 시간 지표에서 이미 겪은 그 결함이 그대로 재현된다(안 쓴 것이 아니라 못 잰 것인데 만점이 된다). */ 'energy.kWh': { direction: 'lower', unit: 'kWh', of: (k: KpiResult) => k.energy?.kWh }, /** 최대수요 — 계약 상한을 목표로 적는 자리다(요금의 기본요금 부분이 여기서 정해진다). */ 'energy.peakKW': { direction: 'lower', unit: 'kW', of: (k: KpiResult) => k.energy?.peakKW }, /** 원단위 — 완료 오더당 kWh. 답할 수 없으면(`null`) 판정하지 않는다. */ 'energy.intensity': { direction: 'lower', unit: 'kWh/order', of: (k: KpiResult) => (typeof k.energy?.intensity?.value === 'number' ? k.energy.intensity.value : undefined) }, /** 자원 점유 — 개별 자원이 아니라 **창 전체의 평균 비율**(자원별은 관점 축의 일이다). */ 'utilization.ratio': { direction: 'higher', unit: 'ratio', of: (k: KpiResult) => k.utilization.length ? k.utilization.reduce((s, u) => s + u.ratio, 0) / k.utilization.length : undefined } } as const export type TwinMetricId = keyof typeof TWIN_METRIC export const isTwinMetric = (id: string): id is TwinMetricId => id in TWIN_METRIC /** * 저장된 목표 행에서 **이 범위에 걸린 목표**를 고른다 — 좁은 선언이 넓은 선언을 덮는다. * * **왜 순수 함수인가**: 이 규칙(공간 목표 위에 인스턴스 목표를 얹는다)은 지금까지 DB 접근 뒤에 숨어 * 있어서 **화면을 눌러 봐야만** 확인할 수 있었다. 그런데 이 프로젝트는 로그인 뒤 화면을 자동으로 * 검증할 방법이 없어, 실제로 여러 기능이 "실화면 미검증" 으로 쌓였다. 규칙을 밖으로 빼면 테스트가 * 값싸진다 — 조회는 행을 가져오기만 하고, **판단은 여기서** 한다. */ /** * 축 목표까지 담은 목록 — 전체 목표와 **축의 한 값**에 걸린 목표를 함께 든다. * * 축 목표는 전체 목표를 **덮지 않는다.** 다른 질문이기 때문이다: "현장 전체가 100건" 과 * "용접 구역이 30건" 은 동시에 참일 수 있다. 덮게 만들면 축 하나에 목표를 거는 순간 전체 판정이 * 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다. */ export interface ScopedTargets { /** 창 전체에 걸린 목표. */ overall: TwinTargetSpec[] /** 축 → (그 축의 값 → 목표들). 요청한 축만 채운다. */ byAxisKey: Record } /** * 축의 각 값에 대해 목표 대비 판정 — **그룹 항목에 그대로 붙일 형태.** * * 그룹의 지표는 전체보다 좁다(주문 수·자원 점유는 축 단위로 뜻이 다르거나 없다). 없는 지표에 목표가 * 걸려 있으면 **판정하지 않는다** — 여기서 0 으로 읽으면 시간 지표가 "완벽 달성" 으로 뒤집힌다 * (전체 판정에서 이미 겪은 결함이다). */ export function judgeGroupTargets( group: { key: string; tasks: number; leadTime: { count: number; p50Ms: number }; workTime: { count: number; p50Ms: number }; waitTime: { count: number; p50Ms: number } }, targets: readonly TwinTargetSpec[] | undefined ): TargetVerdict[] { const value = (metric: TwinMetricId): number | undefined => { switch (metric) { case 'throughput.tasks': return group.tasks case 'leadTime.p50Ms': return group.leadTime.count > 0 ? group.leadTime.p50Ms : undefined case 'workTime.p50Ms': return group.workTime.count > 0 ? group.workTime.p50Ms : undefined case 'waitTime.p50Ms': return group.waitTime.count > 0 ? group.waitTime.p50Ms : undefined default: return undefined // 축 단위로 뜻이 없는 지표(주문 수·자원 점유) — 지어내지 않는다 } } const out: TargetVerdict[] = [] for (const t of targets ?? []) { const spec = TWIN_METRIC[t.metric] const actual = value(t.metric) if (!spec || typeof actual !== 'number' || !Number.isFinite(actual) || !Number.isFinite(t.target)) continue out.push(verdictOf(t.metric, actual, t.target, spec.direction)) } return out } export function mergeTargets( rows: readonly TargetRow[], scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string } ): TwinTargetSpec[] { return scopedTargets(rows, scope).overall } /** 저장된 목표 행 — 판단에 필요한 것만(엔티티 전체를 요구하지 않는다: 테스트가 값싸진다). */ export interface TargetRow { metric: string target: number spaceId?: string | null instanceId?: string | null axis?: string | null axisKey?: string | null } /** 전체 목표와 축 목표를 함께 고른다 — 좁은 선언(인스턴스)이 넓은 선언(공간)을 덮는 규칙은 양쪽 같다. */ export function scopedTargets( rows: readonly TargetRow[], scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string } ): ScopedTargets { /* 같은 자리(지표 또는 축값+지표)에 공간·인스턴스 목표가 함께 있으면 인스턴스가 이긴다. */ const pick = new Map() for (const r of rows) { if (!isTwinMetric(r.metric) || typeof r.target !== 'number' || !Number.isFinite(r.target)) continue const inSpace = !!scope.spaceId && r.spaceId === scope.spaceId const inInstance = !!r.instanceId && scope.instanceIds.includes(r.instanceId) if (!inSpace && !inInstance) continue if (r.axis) { if (!scope.axis || r.axis !== scope.axis || !r.axisKey) continue // 지금 보는 축만 } const slot = `${r.axis ?? ''}\u0000${r.axisKey ?? ''}\u0000${r.metric}` const prev = pick.get(slot) if (prev?.fromInstance && !inInstance) continue // 인스턴스 선언을 공간 선언이 덮지 않는다 pick.set(slot, { target: r.target, fromInstance: inInstance, axisKey: r.axisKey ?? undefined, metric: r.metric }) } const overall: TwinTargetSpec[] = [] const byAxisKey: Record = {} for (const [slot, v] of pick) { const spec = { metric: v.metric, target: v.target } if (slot.startsWith('\u0000')) overall.push(spec) else (byAxisKey[v.axisKey as string] ??= []).push(spec) } return { overall, byAxisKey } } /** 목표 하나 — 어느 지표를, 얼마로. 방향은 지표가 안다(§TWIN_METRIC). */ export interface TwinTargetSpec { metric: TwinMetricId target: number } export interface TargetVerdict { metric: TwinMetricId /** 이 구간의 실제 값. */ actual: number target: number direction: 'higher' | 'lower' /** 목표를 만족했나. */ met: boolean /** * 목표 대비 비율 — **항상 "1 이상이면 좋다"** 로 정규화한다(방향이 다른 지표를 한 화면에서 비교할 수 * 있게). 처리량은 `actual/target`, 시간은 `target/actual`. */ ratio: number } /** * 목표 대비 판정 — **목표가 없는 지표는 판정하지 않는다.** * * 값이 없는 지표(그 구간에 기록이 없어 `undefined`)도 판정하지 않는다. 없는 것을 0 으로 놓으면 * "목표 크게 미달" 이라는 **없는 사실**이 만들어지고, 사용자는 멈춘 적 없는 라인을 고치러 간다. * 목표를 0 으로 준 경우도 비율을 내지 않는다(0 으로 나누지 않는다) — 만족 여부만 답한다. */ export function judgeTargets(kpi: KpiResult, targets: readonly TwinTargetSpec[] | undefined): TargetVerdict[] { const out: TargetVerdict[] = [] for (const t of targets ?? []) { const spec = TWIN_METRIC[t.metric] if (!spec) continue // 모르는 지표 이름 — 짐작해 매핑하지 않는다 const actual = spec.of(kpi) if (typeof actual !== 'number' || !Number.isFinite(actual)) continue // 값이 없다 ≠ 값이 0 if (!Number.isFinite(t.target)) continue out.push(verdictOf(t.metric, actual, t.target, spec.direction)) } return out } /** 판정 산수 한 곳 — 전체 판정과 축 판정이 **같은 규칙**을 쓴다(두 벌이면 어긋난다). */ function verdictOf(metric: TwinMetricId, actual: number, target: number, direction: 'higher' | 'lower'): TargetVerdict { const met = direction === 'higher' ? actual >= target : actual <= target const ratio = direction === 'higher' ? (target > 0 ? actual / target : Number.NaN) : actual > 0 ? target / actual : Number.NaN return { metric, actual, target, direction, met, ratio } }