/**
* エリア別完了率(GET /clean-logs/{id}/areas)の純粋関数。
*
* ★仕様の正は `d-watch-api.yaml` の `/clean-logs/{id}/areas`★
* (出所: TeamWorks 第8回 A9-2。2026-09-11 受領)。
*
* ★★このファイルに DB アクセスを混ぜないこと★★(`CLAUDE.md` §9.4。
* `lib/clean-logs/query.ts` と同じ流儀)。行の取得は `DataSource.areaCompletions()`、
* 絞り込み・並べ替え・全体率の計算はここ、という分担にしてある。
*
* ★★1エリア = 1清掃ロボット★★(`CLAUDE.md` §8)。
* ★1回の清掃の中を売り場・通路・レジ前に区切った内訳ではない★ — その粒度は取得できない。
*/
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import {
isProblemGrade,
judgeGrade,
meanCleanRatio,
type CleanRatioTally,
} from "@/lib/judge";
import type {
AreaCompletionItem,
AreaCompletionRow,
AreaCompletionsResponse,
} from "@/lib/types";
export type AreaFilter = "problem" | "all";
const AREA_FILTERS: readonly AreaFilter[] = ["problem", "all"];
/**
* `filter` クエリパラメータの解釈。★既定は `"problem"`★(YAML の `default`)。
*
* ★指定なし(キーが無い)だけが既定にフォールバックする★ —
* ★`filter=`(空文字)は不正値として 422★。`parseCleanLogQuery()` の `result` が
* `searchParams.get(...) ?? "all"` で同じ扱いをしているので、それに揃えた
* (空文字を既定に寄せると、打ち間違いが黙って既定で動くことになる)。
*/
export function parseAreaFilter(
searchParams: URLSearchParams
): QueryParseResult<AreaFilter> {
const raw = searchParams.get("filter") ?? "problem";
if (!AREA_FILTERS.includes(raw as AreaFilter)) {
return fail(422, "validation_failed", "filterの値が不正です", {
filter: ["problem / all のいずれかを指定してください"],
});
}
return { ok: true, query: raw as AreaFilter };
}
/**
* その清掃ログと ★同じ施設・同じ日★ の行だけを取り出す(★その清掃ログ自身を含む★)。
*
* ★`{id}` はエリアを特定するキーではなく、施設と日付を特定するための入口★
* (指示書Z 決定1)。呼び出し側は `{id}` の行から `facility_id` と JST 日付を取り出して渡す。
*
* ★`date` は JST の `YYYY-MM-DD`★ — DB 由来の値は `jstIsoFromDb()` を通してから
* 先頭10文字を取ること(`start_at` の生の `Date` で突き合わせないこと。D-115)。
*/
export function selectAreaRowsForLog(
rows: readonly AreaCompletionRow[],
target: { facility_id: number; date: string }
): AreaCompletionRow[] {
return rows.filter(
(r) => r.facility_id === target.facility_id && r.date === target.date
);
}
/**
* 並び順の比較: ★完了率の昇順。未実施(null)は先頭。同値は `section_id` の昇順★
*
* ★悪いものから見る画面なので、いちばん悪い「未実施」を先頭に置く★(指示書Z 決定10)。
* ★同値の順を `section_id` で固定するのは、実行ごとに並びが変わらないようにするため★。
*/
function compareByCompletion(
a: AreaCompletionItem,
b: AreaCompletionItem
): number {
if (a.completion_rate === null || b.completion_rate === null) {
if (a.completion_rate === b.completion_rate) return a.section_id - b.section_id;
return a.completion_rate === null ? -1 : 1;
}
if (a.completion_rate !== b.completion_rate) {
return a.completion_rate - b.completion_rate;
}
return a.section_id - b.section_id;
}
/**
* ★全体率の材料(分子・分母)★を数える。★全体率の数え方の規則はこの関数だけが持つ★(指示書AR 段階4)。
*
* - 分子 `cleanRatioSum` = ★`completion_rate` が `null` でない行の `completion_rate` の合計★
* - 分母 `cleanRatioCount` = ★その行数★(★未実施=`completion_rate === null` の行は分子にも分母にも入れない★)
*
* ★`buildAreaCompletions()`(全体率を出す)と APIルートの `judge.areas` ログ(分子・分母を残す)の
* 両方がこれを使う★ — ★ログ側で別の数え方をすると、ログと画面の全体率が食い違う★。
* ★割り算はしない★(`meanCleanRatio()` の仕事。件数0で `null`)。★丸めない★(`CLAUDE.md` §4.3)。
*
* ★渡すのは `filter` で絞る**前**の行★(`total_count` / `overall_*` と同じ母集団。決定8 / 決定12)。
*/
export function overallCompletionTally(
rows: readonly AreaCompletionRow[]
): CleanRatioTally {
let cleanRatioSum = 0;
let cleanRatioCount = 0;
for (const r of rows) {
if (r.completion_rate === null) continue; // ★未実施は分母に入れない★
cleanRatioSum += r.completion_rate;
cleanRatioCount += 1;
}
return { cleanRatioSum, cleanRatioCount };
}
/**
* 判定・並べ替え・件数・全体率・絞り込みをまとめてレスポンスの形にする。
*
* ★★行も全体も1回ごとの完了率から出す★★(2026-09-30 指示書AQ 決定4 / §5-1・2026-10-02 D-155)
*
* | | 出し方 |
* |---|---|
* | 各行の `completion_rate` | ★1回ごとの完了率(`runCompletionRate()`。データ層の `completionRateFromReport()` 経由)★ |
* | `overall_completion_rate` | ★各行の完了率の単純平均(清掃1回=1票)★。`meanCleanRatio()` |
*
* ★★以前は全体だけ面積ベース(Σclean_area ÷ Σtask_area)だった★★(指示書Z 決定7 の「意図した非対称」)。
* ★面積比は `clean_ratio` と相関しない★(CSV実測: `clean_area > task_area` が 129/189 行)ため、
* ★レポート画面・総清掃面積 F-08・R-3 と同じ `meanCleanRatio()`(`lib/judge/kpi.ts`)で畳む★
* (★画面ごとに完了率の式が違う状態をやめる★。§5-1)。
* ★面積加重にしないこと★ — 理由は `meanCleanRatio()` の docstring。
*
* ★★未実施(`completion_rate === null`)の行は平均の分母に入れない★★ —
* 0% として混ぜると ★稼働率で既に数えている失敗を完了率でも二重に数える★
* (`aggregateCleanStatsByFacility()` と同じ扱い)。★全行が未実施なら `null`★。
*
* ★★`total_count` / `problem_count` / `overall_*` は `filter` で絞る**前**に計算する★★
* (「問題エリアのみ」を選んだら全体率まで変わる、という挙動にしないため。決定8 / 決定12)。
*
* ★閾値は直書きしない★ — 判定は `judgeGrade()`、問題判定は `isProblemGrade()`、
* 全体率は `meanCleanRatio()`(件数0で `null`)に任せる(`CLAUDE.md` §7)。
* ★★丸めないこと★★ — 表示の丸めは画面側(`CLAUDE.md` §4.3)。
*
* ★未実施は `completion_rate: null`(`0` と混ぜない。決定4)★。
* ★面積(`clean_area` / `task_area`)はこの関数では読まない★(完了率の計算に使わないため)。
* ★全体率の分子・分母の数え方は `overallCompletionTally()`★(ログと共用)。
*/
export function buildAreaCompletions(
rows: readonly AreaCompletionRow[],
filter: AreaFilter
): AreaCompletionsResponse {
const items: AreaCompletionItem[] = rows
.map((r) => ({
section_id: r.section_id,
section_name: r.section_name,
completion_rate: r.completion_rate,
grade: judgeGrade(r.completion_rate),
}))
.sort(compareByCompletion);
const problemCount = items.filter((i) => isProblemGrade(i.grade)).length;
// ★Σ と件数は `overallCompletionTally()` で数え、割るのは `meanCleanRatio()` に任せる★
// (自前で割らない。★`judge.areas` ログも同じ関数で数える★。指示書AR 段階4)
const overall = meanCleanRatio(overallCompletionTally(rows));
return {
overall_completion_rate: overall,
overall_grade: judgeGrade(overall),
total_count: items.length,
problem_count: problemCount,
items: filter === "problem" ? items.filter((i) => isProblemGrade(i.grade)) : items,
};
}