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";
}