Newer
Older
client_dwatch / lib / db / fixture-clean-area.ts
/**
 * fixtures 版 `cleanArea()` の集計材料づくり(指示書AN ★段階3★ / §6-4)。
 *
 * ★★`USE_FIXTURES` の true/false で数字が変わってはいけない★★(指示書AC 禁止事項6)—
 *   Prisma 版が SQL に降ろした条件(`started_at` の範囲・施設ID)と ★同じ意味★ を再現し、
 *   ★★その先のロールアップは同じ `aggregateCleanArea()` を通します★★
 *   (`CLAUDE.md` §9.5。★fixtures 用に別の集計を書かないこと★)。
 *
 * ------------------------------------------------------------------------
 * ★★なぜ `areaCompletions`(`lib/fixtures/areas.ts`)を材料にするのか★★
 * ------------------------------------------------------------------------
 * ★fixtures で面積(`clean_area` / `task_area`)を持っているのはこの1本だけ★です。
 * `AreaCompletionRow` は ★1行 = 1清掃エリア × 1日★(= P1 では 1ロボット。`CLAUDE.md` §8)で、
 * ★実DBの `clean_task_reports` の1行に対応します★(旧: `clean_status_histories`。指示書AQ 決定3)。
 *
 * ------------------------------------------------------------------------
 * ★★`completion_rate === null` の行を数えないこと★★(★静かにズレる箇所★)
 * ------------------------------------------------------------------------
 * `lib/fixtures/areas.ts` の組み立て(`build()`)は
 * ★「`completion_rate` が `null` の行は `task_area` を 0 にする」ことを検査で強制★しており、
 * これは ★★「その清掃にはレポートの行が無い(出発失敗・未実施)」★★ という意味です(指示書AQ §1-1)。
 *
 * ★実DBではレポートの行そのものが存在しない★ので、実施回数にも入りません。
 * ★ここで数えると `executed_count`(清掃実施回数)が fixtures だけ多くなります★
 * (★面積は 0 なので合計は変わらず、件数だけが静かに食い違う★)。
 *
 * ------------------------------------------------------------------------
 * ★★完了率は `runCompletionRate()` を通す★★(2026-10-02。D-155 / 指示書AS 段階4-3)
 * ------------------------------------------------------------------------
 * Prisma 版と同じく ★1回ごとの完了率を面積から `runCompletionRate()` で作り★、★常に★ Σ と件数に足す
 * (`task_area = 0` も 0% として足す。P-5=B。D-158)。★fixtures の行の `completion_rate` をそのまま足さないこと★(規則が2箇所に散る)。
 *
 * ★★fixtures(`lib/fixtures/areas.ts`)には予定外(`clean_timetable_id` が NULL)の行が無い★★ —
 *   どの行も本清掃の清掃ログ(`clean_log_id`)に紐づいている。★予定外も完了率・実施回数に入れる規則
 *   (指示書BG §2-5。2026-10-29)は Prisma 版の純粋関数 `cleanAreaStatsFromRows()` のテスト(表4)で確かめる★。
 */
import { runCompletionRate } from "@/lib/judge";
import type {
  AreaCompletionRow,
  CleanAreaDbQuery,
  CleanAreaFacilityStats,
} from "@/lib/types";

/**
 * fixtures の面積行から、施設ごとの集計材料を作る(★純粋関数★。DBアクセスを混ぜない)。
 *
 * ★日付の比較は文字列のまま行う★ — `AreaCompletionRow.date` は既に JST の `YYYY-MM-DD`
 *   なので、実行環境のタイムゾーンに依存しません(`filterFixtureAreaCompletions()` と同じ作法)。
 *   ★`new Date()` を挟むと、Prisma 版が壁時計値で絞っているのと基準がずれます★。
 *   ★`date_to` はその日を**含む**★(`dbDateRange()` と同じ契約)。
 *
 * ★★`facility_ids` は `!== null` で分岐すること★★(★罠1 / 落とし穴3★)—
 *   ★`null` = 絞らない / `[]` = 0件ヒット★。`?.length` で書くと
 *   ★「0件ヒット」が「全件」に化けます★(例外もテスト失敗も出ない)。
 *
 * ★戻りは `facility_id` の昇順★ — Prisma 側の `groupBy` は順序を約束しないが、
 *   ★fixtures が入力順に依存して揺れると、テストで固定できるものが減る★ので決め切っておく
 *   (★並びの正は `aggregateCleanArea()` の `compareRows()`★。ここは単なる決定性のため)。
 */
export function cleanAreaStatsFromFixtureAreas(
  rows: readonly AreaCompletionRow[],
  query: CleanAreaDbQuery
): CleanAreaFacilityStats[] {
  const byFacility = new Map<number, CleanAreaFacilityStats>();
  const facilityIds =
    query.facility_ids !== null ? new Set(query.facility_ids) : null;

  for (const row of rows) {
    // ★「レポートの行が無い」行は実DBに存在しない★(上の docstring)
    if (row.completion_rate === null) continue;
    if (facilityIds !== null && !facilityIds.has(row.facility_id)) continue;
    if (query.date_from !== null && row.date < query.date_from) continue;
    if (query.date_to !== null && row.date > query.date_to) continue;

    // ★1回ごとの完了率は `runCompletionRate()` だけが決める★(Prisma 版と同じ。指示書AS D1)
    const rate = runCompletionRate(row);
    const current = byFacility.get(row.facility_id);
    if (current === undefined) {
      byFacility.set(row.facility_id, {
        facility_id: row.facility_id,
        clean_area: row.clean_area,
        task_area: row.task_area,
        executed_count: 1,
        // ★完了率の材料(1回ごとの完了率の単純平均。D-155)★ — ★`runCompletionRate()` を通す★
        clean_ratio_sum: rate,
        clean_ratio_count: 1,
      });
      continue;
    }
    // ★`_sum` の `null` は fixtures には無い★(面積は常に数値)ので `?? 0` は要らないが、
    //   ★型は Prisma 版と同じ `number | null`★なので足し込みでは `?? 0` を通す。
    current.clean_area = (current.clean_area ?? 0) + row.clean_area;
    current.task_area = (current.task_area ?? 0) + row.task_area;
    current.executed_count += 1;
    current.clean_ratio_sum = (current.clean_ratio_sum ?? 0) + rate;
    current.clean_ratio_count += 1;
  }

  return [...byFacility.values()].sort((a, b) => a.facility_id - b.facility_id);
}