Newer
Older
client_dwatch / lib / clean-logs / areas.ts
/**
 * エリア別完了率(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)。
 *
 * ★★結果待ちは問題に数えない★★(D-173 と同じ。指示書BB A3)— `problem_count` と `filter=problem` は
 *   `isProblemArea()` の1本で判定する。`total_count` には数え、平均は元から null を分母に入れない。
 *
 * ★閾値は直書きしない★ — 判定は `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),
      result_pending: r.result_pending,
    }))
    .sort(compareByCompletion);

  const problemCount = items.filter(isProblemArea).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(isProblemArea) : items,
  };
}

/**
 * ★問題エリアか★(`problem_count` と `filter=problem` が共有する1本。指示書BB A3・D-182)—
 *   判定が △・× で、★結果待ち(D-173)でない★こと。結果待ちは `grade: "×"` のままなので、旗を見ないと問題に入ってしまう
 *   (ダッシュボードの `problem_logs`・`/clean-logs` の `result=problem` と同じ扱い)。
 */
export function isProblemArea(item: AreaCompletionItem): boolean {
  return !item.result_pending && isProblemGrade(item.grade);
}