Newer
Older
client_dwatch / lib / judge / kpi.ts
import { CLEAN_TASK_STATUS, FACILITY_STATUS_THRESHOLDS, RUN_COMPLETION_RATE } from "@/lib/config";
import type { FacilityStatus, KpiValue } from "@/lib/types";

/**
 * KPI4指標の計算(CLAUDE.md 4.2 / D-021)
 *
 *   稼働率       = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100   ← ★第11回 P-6=A★
 *   出発成功率   = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100   ← ★時刻の条件なし(P-6=A)★
 *   清掃完了率   = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数(1回ごとの完了率 = `runCompletionRate()`)
 *   エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100   ← ★分母は予定回数(P-6=B)★
 *
 * ★★3つの率はサマリ(`operation_ratio` / `launch_ratio` / `error_ratio`)と同じ式★★(第11回 P-6。D-158)。
 *   旧: 稼働率 = 実施回数 ÷ 予定・出発成功率 = 定刻に出発した回数 ÷ 予定・エラー発生率 = エラー ÷ 実施回数。
 *
 * ★★清掃完了率は D-155 で TeamWorks のサマリと同じ式にした(第11回で確定。D-158)★★(第10回 N-3。2026-10-02)—
 *   ★1回ごとの完了率を決めるのは `runCompletionRate()`★、★それを畳む(単純平均する)のは
 *   `meanCleanRatio()`★。どちらも1関数だけ。
 *   ★期間の合計どうしを割る面積比(Σ清掃面積 ÷ Σ清掃対象面積)は D-151 で廃止したまま★(使わない)。
 *   ★旧: D-151(2026-09-30)では `clean_task_reports.clean_ratio` 列の単純平均だった★が、
 *   TeamWorks から「列の計算式が間違っている」と回答があった(第10回 N-3)ため、列は完了率に使わない。
 *
 * ★戻り値は丸める前の生値★
 *   表示するときだけ `displayRate()` で整数に丸めます。
 *
 * 分母が0のときは null を返します(0%ではない)。
 * 「予定が1件もない期間」と「予定はあったが全部失敗した」は区別する必要があるためです。
 */

/** 分母0を null にする除算 */
function rate(numerator: number, denominator: number): number | null {
  if (denominator === 0) return null;
  return (numerator / denominator) * 100;
}

/** 稼働率 = 正常終了(`status = 4`)の回数 ÷ 清掃予定回数(P-6=A。分子は `isCompletedTask()` で数える) */
export function operationRate(completed: number, planned: number): number | null {
  return rate(completed, planned);
}

/**
 * 出発成功率 = 開始できた回数(レポート行がある回数)÷ 清掃予定回数。★時刻の条件なし(P-6=A)★。
 * 旧: 「定刻に出発した回数 ÷ 予定」と書いていたが、実際は時刻を見ていなかった。
 */
export function launchRate(started: number, planned: number): number | null {
  return rate(started, planned);
}

/**
 * ★その清掃が正常終了したか★(稼働率の分子。第11回 P-6=A。送付版 `:87`)。
 *
 * - ★status だけで決める。面積は見ない★(指示書AU 表2 K8: `clean_area = 0` でも status が正常終了なら数える)
 * - ★「最新の1行」で見る★ — 呼び出し側が `latestReportByTimetableId()` で選んだ行を渡す
 * - ★◎○△× の判定には使わない★(D-155 項2 は変わらない。判定は完了率で決める)
 * - ★正常終了かを決める述語はこれ1つだけ★(指示書AU D5。`lib/db/` の中で `report.status === …` と書かない)
 * - 構造的な型にしてある(`lib/judge/` は `lib/db/` を import しない。`runCompletionRate()` と同じ作法)
 *
 * @param report `clean_task_reports` の1行(無ければ undefined = 未実施)
 */
export function isCompletedTask(report: { status: number } | undefined): boolean {
  return report !== undefined && report.status === CLEAN_TASK_STATUS.completed;
}

/**
 * ★`meanCleanRatio()` の入力★ — ★1回ごとの完了率(`runCompletionRate()` の戻り)の**合計**と、その件数★。
 *
 * ★名前(`cleanRatioSum` / `cleanRatioCount`)は変えていない★(指示書AS D7)— 中身は
 *   ★`clean_task_reports.clean_ratio` 列ではない★。名前を変えると16ファイルの機械的な差分が
 *   中身の変更に混ざってレビューで読めなくなるため。暫定の式なので、戻すときにも名前を戻さずに済む。
 *
 * ★★平均そのものを持ち回らない理由★★ — ★率は加重平均できない★(`CLAUDE.md` §4.7 の
 *   「率は加重平均できないため合算できない」と同じ話)。★Σ と件数を別々に持てば、
 *   施設・屋号・ホールディングスをまたいで合算しても平均が壊れない★。
 *
 * ★構造的な型にしてある★ — `CleanStatsBucket`(`lib/db/report-mapping.ts`)はこの形を
 *   そのまま満たす。★`lib/judge/` から `lib/db/` を import しないため★(判定層はDBの都合を知らない)。
 */
export interface CleanRatioTally {
  /** ★1回ごとの完了率(`runCompletionRate()` の戻り)の合計★(百分率の和。★平均側では丸めない★) */
  cleanRatioSum: number;
  /**
   * 上の合計に足した件数(=平均の★分母★)。
   * ★未実施の清掃は数えない★(`task_area = 0` は 0% として数える。P-5=B)
   */
  cleanRatioCount: number;
}

/**
 * ★清掃完了率の畳み方=1回ごとの完了率の「清掃1回 = 1票」の単純平均★(指示書AQ 決定4 / §5-1・
 * D-155。★丸めない生値★。1回ごとの完了率は `runCompletionRate()`)。
 *
 * ★★完了率を複数行から出すときは、画面を問わず必ずこの関数を通すこと★★ —
 *   レポート画面(`lib/db/report-mapping.ts`)・総清掃面積 F-08(`lib/clean-area/aggregate.ts`)・
 *   エリア別完了率の全体率(`lib/clean-logs/areas.ts`)・要レスキューの急落判定 R-3
 *   (`lib/judge/rescue.ts`)が共用する。★2箇所で別々に畳むと、同じ施設が
 *   レポート画面で78%・F-08で120%と出る★(指示書AQ §5-1。出所: ユーザー提供CSV。
 *   期間の合計どうしの面積比90.2% ↔ `clean_ratio` 列の平均78.5%)。
 *
 * ★★なぜ単純平均で、面積加重にしないのか★★(出所を明記する。`CLAUDE.md` §6-6):
 *   - ★加重に使える面積が信用できない★(★1回ごとに上限100で切る `runCompletionRate()` とは別の話★)
 *     — ユーザー提供CSV `clean_task_reports.csv`(189行。
 *     2026-09-30 受領)で ★`clean_area > task_area` が 129/189(68.3%)★、
 *     個別行で ★面積比 13.8% ↔ `clean_ratio` 100.0%★ / ★面積比 162.8% ↔ `clean_ratio` 61.0%★
 *     という乖離がある(指示書AQ 決定4の根拠表)。★`task_area` で重みを付けると、
 *     廃止したはずの面積が別の経路で完了率に効いてしまう★。
 *   - ★施設をまたいだ合算と一致する★ — Σ と件数を持ち回るので、
 *     「施設ごとの平均を平均する」ことにはならない(`sumCleanStatsBuckets()` が正確)。
 *
 * ★分母0(`cleanRatioCount === 0`)は `null`★ — 「0%で清掃した」ではなく
 *   ★「率を出せる清掃が1回も無い」★(このファイルの他の率と同じ規則)。
 *
 * ★★`rate()` を通さないこと★★ — あちらは `× 100` する。
 *   1回ごとの完了率は ★既に百分率★ なので、通すと ★百分率をもう一度100倍してしまう★。
 *
 * ★★面積比の `completionRate(cleanedArea, targetArea)` は削除した★★(2026-09-30)—
 *   ★期間の合計どうし(Σ`clean_area` ÷ Σ`task_area`)で割らないこと★(D-151 で廃止したまま)。
 *   ★1回ごとに割るのは `runCompletionRate()` の仕事★(D-155)。
 *   ★面積の合計(`clean_area` / `task_area`)そのものは総清掃面積 F-08 の表示に残る★(指示書AQ §1-3)。
 *
 * ★2026-09-30 に `lib/db/report-mapping.ts` から移した★(`CLAUDE.md` §9.1: KPI計算は `lib/judge/`)。
 *   挙動は移す前と同じ。
 */
export function meanCleanRatio(tally: CleanRatioTally): number | null {
  if (tally.cleanRatioCount === 0) return null;
  return tally.cleanRatioSum / tally.cleanRatioCount;
}

/**
 * 浮動小数の誤差とみなす桁(指示書AS D3)。★1e-6 ポイント未満の差は誤差として吸収する★。
 * 仕様の閾値ではなく計算上の都合なので `lib/config` には置かない(`RUN_COMPLETION_RATE` が式の部品)。
 */
const FLOAT_ERROR_DIGITS = 5;

/**
 * ★★1回の清掃の完了率(%)★★ — ★1回ごとの完了率を決めるのはこの関数だけ★(指示書AS D1)。
 *
 *   `min(clean_area ÷ task_area × 100, 100)` を ★小数第1位まで切り捨て★(`RUN_COMPLETION_RATE`)
 *
 * ★出所★: TeamWorks 第10回 N-3(サマリの式)。★当期(アプリがその場で集計する分)も同じ式にする★
 *   — 式が違うと、前期比(サマリ)と当期(アプリ)の差が別の式どうしの差になる(D-155 項1)。
 *
 * ★★確定★★(第11回 P-2=A「今の式のまま」・P-1「訂正後の `clean_ratio` 列もこの式と同じ値」。D-158)。
 *   旧: 暫定(D-155。ユーザー判断「一旦B」)。★`clean_ratio` 列に戻すときは、この関数の中身だけを直す★。
 *   そのため ★呼び出し側は行(`clean_ratio` も持っている)をそのまま渡すこと★
 *   (呼び出し側で `clean_area` / `task_area` だけに分解しない。指示書AS D6)。
 *
 * - ★`task_area === 0` は `0`★(P-5=B。第11回。D-158)。★`clean_area > 0` でも `0`★(割り算をしない。
 *   `10 / 0 = Infinity` を上限で 100 にしないため、★割る前に★見る)。旧: `null`(分子にも分母にも入れない。D-155 の暫定)。
 *   `task_area` は NOT NULL なので null の扱いは要らない
 * - ★`clean_area = 0`(`task_area > 0`)は `0`★ —「0%で実施」であって未実施(行が無い)ではない
 * - ★切り捨ての前に浮動小数の誤差を吸収する★(D3)— `0.57 / 1 * 100` は `56.99999999999999` になり、
 *   そのまま切り捨てると 56.9 になる(表1 R6)。★`Math.floor(raw * 10) / 10` と書かないこと★。
 *   ★`toFixed()` は四捨五入なので使わない★
 * - ★切り捨ては1回ごとの値だけ★ — 平均(`meanCleanRatio()`)は丸めない(D5。`CLAUDE.md` §4.3)
 *
 * ★★Σ面積 ÷ Σ面積(D-151 で廃止)とは別物★★ — こちらは ★1回ずつ割って上限100で切り★、
 *   それを清掃1回=1票で平均する。面積で重み付けもしない。
 *
 * ★承知している弱点(変えないこと)★: `clean_area` は同じ場所を複数回清掃した面積の総和
 *   (第10回 N-4)。ユーザー提供CSV 189行では 68.3% の行が `clean_area > task_area` で、
 *   ★それらは全部 100 になる(◎が多めに出る)★。ユーザーはこれを承知で採用した(D-155 項1)。
 *   ★上限100を外したり、別の補正を入れたりしないこと★。
 *
 * @param report `clean_task_reports` の1行(構造的な型。`lib/judge/` は `lib/db/` を import しない)
 * @returns 1回ごとの完了率(小数第1位まで)
 */
export function runCompletionRate(report: {
  clean_area: number;
  task_area: number;
}): number {
  if (report.task_area === 0) return 0;
  const { cap, decimals } = RUN_COMPLETION_RATE;
  const raw = Math.min((report.clean_area / report.task_area) * 100, cap);
  const absorbed = Math.round(raw * 10 ** (decimals + FLOAT_ERROR_DIGITS)) / 10 ** FLOAT_ERROR_DIGITS;
  return Math.floor(absorbed) / 10 ** decimals;
}

/**
 * エラー発生率 = エラー発生回数 ÷ ★清掃予定回数★(P-6=B。サマリの `error_count ÷ task_count` と同じ)。
 * ★上限で切らない★(指示書AU D9。1回の清掃に複数のエラーが紐づきうるので 100 を超えうる)。
 * 旧: 分母は実施回数。
 */
export function errorRate(errors: number, planned: number): number | null {
  return rate(errors, planned);
}

/**
 * 前期比つきのKPI値を組み立てる。
 *
 * 前期データがない(新規施設など)場合、prev と diff は null になり、
 * 画面では▲▼を表示しません。
 *
 * `value` も nullable です。上の4関数は分母0で null を返すため
 * (全施設定休日の週など「予定が1件もない期間」)、その値をそのまま渡せる必要があります。
 * **`value` / `prev` のどちらかが null なら `diff` も null**(差を取れないので0にしない)。
 */
export function toKpiValue(value: number | null, prev: number | null): KpiValue {
  return {
    value,
    prev,
    diff: value === null || prev === null ? null : value - prev,
  };
}

/**
 * 施設別パフォーマンスの 良好 / 注意 / 要改善 / 判定なし
 *
 * 算出基準は**稼働率ベース**。これは DFA 側の UI 判断であって、TeamWorks 待ちではない
 * ★当期の稼働率は P-6 で『正常終了 ÷ 予定』になった(以前は出発成功率と同じ値)。当期の施設ステータスは以前より低めに出る★
 * (閾値は据え置き。指示書AU §11 Q8)
 * (モックの 80 / 90 という区切りを、既存の FACILITY_STATUS_THRESHOLDS に寄せたもの)。
 * ★TeamWorks への照会は行っていない★ — 第2回〜第8回の確認書のどこにも該当する質問は無い
 * (2026-09-11 に全文検索で確認。ヒット0)。以前ここに「回答待ち」と書かれていたのは誤りで、
 * 来ない回答を次の人が待つ状態になっていた。
 * 見直すきっかけは「TeamWorks の回答が来たとき」ではなく、
 * **別の算出基準を採るという判断が出たとき**。
 * 閾値は `lib/config` の FACILITY_STATUS_THRESHOLDS から読む。
 *
 * `null`(分母0=その期間に清掃予定が1件もない)は**評価できない**ので「判定なし」にする
 * (決定1。2026-09-11 ユーザー確定)。`clean_timetables` には1週間分の予定がまとめて
 * 登録される(D-106)ため、週の初め・月の初めは「予定はあるが実施0件」が正常な状態になる。
 * 以前はここを「要改善」に寄せていたが、それだと週の初めに全施設が赤い「要改善」バッジに
 * なってしまうため区別した。
 */
export function judgeFacilityStatus(
  operationRateValue: number | null
): FacilityStatus {
  if (operationRateValue === null) return "no_data";
  if (operationRateValue >= FACILITY_STATUS_THRESHOLDS.good) return "good";
  if (operationRateValue >= FACILITY_STATUS_THRESHOLDS.warning) return "warning";
  return "bad";
}