Newer
Older
client_dwatch / lib / db / clean-area-source.ts
/**
 * 清掃面積の集計材料の読み取り(指示書AN ★段階3★ / §6-1〜§6-3)。
 *
 * ------------------------------------------------------------------------
 * ★★この層がやること / やらないこと★★
 * ------------------------------------------------------------------------
 * - やること … ★`clean_task_reports` の `groupBy` + `_sum`(面積)の結果と、
 *   完了率用に取り寄せた行(本清掃に紐づくものと予定外。指示書BG §2-5)を型に直す★ /
 *   ★ロールアップに要る施設マスタを1回だけ読む★ / ★番人の件数を数える★
 * - やらないこと … ★ロールアップ(`aggregateCleanArea()`。段階2 の純粋関数)★ /
 *   ★ログ出力(`reportCleanAreaAnomalies()`。`route` / `method` を知っているルート層)★
 *
 * ------------------------------------------------------------------------
 * ★★2026-09-30: 集計元を `clean_task_reports` に差し替えた★★(指示書AQ 決定2 / 決定3)
 * ------------------------------------------------------------------------
 * ★`clean_status_histories` は使わない★(指示書AQ 決定3。ユーザー指示 2026-09-30)。
 *
 * ★★面積(`clean_area` / `task_area`)は引き続き使う★★(指示書AQ §1-3)—
 *   ★総清掃面積(F-08)は面積そのものを出す画面★なので、集計元が変わるだけです。
 *
 * ------------------------------------------------------------------------
 * ★★2026-10-02: 1回ごとの完了率に面積を使う。予定外を完了率・実施回数から外す★★(D-155。指示書AS 段階4)
 * ------------------------------------------------------------------------
 * - ★完了率 = 1回ごとの完了率(`runCompletionRate()`)の単純平均★(レポート画面と同じ `meanCleanRatio()`)。
 *   ★Σ面積 ÷ Σ面積(期間の合計どうし)は使わない★(D-151 で廃止したまま)。
 *   ★`clean_task_reports.clean_ratio` 列は完了率に使わない★(D-155。`select` には残す。指示書AS D6)。
 * - ★母集団が2つある★(指示書AS D9。TeamWorks のサマリと同じ。第10回 N-3):
 *   | 何を | どの行から |
 *   |---|---|
 *   | 面積の合計(`clean_area` / `task_area`) | ★全行(予定外=`clean_timetable_id` が 0 か NULL を含む)★。`groupBy` |
 *   | 実施回数・完了率 | ★予定外の行(0・NULL)と、予定が本清掃(`precheck = 0`)の行★。行を取り寄せて JS |
 *   ★0 も予定外★(TeamWorks 回答 2026-10-27。判定は `lib/db/timetable-link.ts` の1本)。
 *   ★★2026-10-29: 実施回数・完了率にも予定外を入れた★★(指示書BG §2-5。ユーザー指示「エラー一覧以外も、予定外タスクを
 *   計算して出すようにしてください」)。★旧: 予定外は面積だけに入れ、実施回数・完了率から外していた(D-155 項3)★。
 *   ★事前チェックの予定に紐づく行・予定が見つからない(dangling)行は今までどおり入れない★(指示書BG §5)。
 * - ★平均はここで作らない★ — 屋号・ホールディングスへのロールアップで ★Σ と件数を足してから
 *   1回だけ割る★ため(施設ごとの平均を平均すると壊れる)。割るのは `aggregateCleanArea()`。
 *
 * ★★サマリテーブル(`facility_summaries` / `customer_summaries`)の面積を読まないこと★★
 *   (指示書AN §1-2 / 落とし穴1)— ★実データで `task_area` が全行 1000 固定★であり、
 *   ★期間内の合計になっていない★(TeamWorks 第9回 Q2-1 で照会中)。
 *   ★この画面は常に `clean_task_reports` から集計する★。
 *
 * ------------------------------------------------------------------------
 * ★★施設マスタを `deleted_at` で絞らない理由(★罠7★ と同じ考え方。★消さないこと★)★★
 * ------------------------------------------------------------------------
 * `prismaSource.facilities()` は ★`where: { deleted_at: null }`★ を付けています
 * (施設マスタ画面は論理削除された施設を出さない、という別の要件)。
 * ★★こちらは絞りません★★。理由は2つ:
 *
 * 1. ★面積は履歴ベース★ — ★論理削除は「これから使わない」という印であって、
 *    ★★過去に清掃した実績が消えるわけではない★★。絞ると
 *    ★その施設の面積が内訳から静かに落ちます★。
 * 2. ★★落ちるのは内訳だけではない★★ — `aggregateCleanArea()` は
 *    ★マスタに無い `facility_id` を `total` からも外す★(`unmatched_facility_ids` に回す)
 *    設計なので、絞ると ★★全体合計そのものが減ります★★。
 *    ★例外もテスト失敗も出ず、「累計面積が今日から小さくなった」とだけ見えます★。
 *
 * ★`loadFacilityNameIndex()` / `loadFacilityGroupIndex()` と扱いが揃っていること★。
 *
 * ★★2026-10-19 ユーザー決定で、削除済みの施設・清掃エリアの★面積の行★は全画面で外すことになった★★
 *   (`CleanAreaWhere` の `facility` / `section` の条件=`FACILITY_SECTION_VISIBLE_WHERE`。指示書BB U1・D-182)。
 *   ★マスタは今までどおり絞らない★(絞ると、まだ行が残っている施設の `facility_id` が `unmatched` に回る誤報になる)。
 *
 * ------------------------------------------------------------------------
 * ★★`include` を使わないこと★★(指示書AN §6-2 / 落とし穴8)
 * ------------------------------------------------------------------------
 * レポート → 施設の `include` は ★親行の数ぶん `IN` が膨らむ★(D-136 で実測済み)。
 * ★施設マスタは別に1回読み、JSで突き合わせる★(施設は実測12件、本番想定でも200件程度)。
 * ★施設マスタ側の入れ子 `select`(屋号・ホールディングス)は別の話★ —
 *   ★親行が施設の数(=数百)しかない★ので膨らみません(`loadFacilityGroupIndex()` と同じ判断)。
 */
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { chunkForInClause } from "@/lib/db/chunked";
import type { DbDateRange } from "@/lib/db/date-range";
import { SCHEDULED_TIMETABLE_ID_WHERE, scheduledTimetableId } from "@/lib/db/timetable-link";
import { findTimetablesByChunks } from "@/lib/db/timetable-source";
import { groupIdFromDb } from "@/lib/format/group-id";
import { runCompletionRate } from "@/lib/judge";
import type { CleanAreaFacilityMaster, CleanAreaFacilityStats } from "@/lib/types";

/**
 * ★`clean_task_reports.groupBy({ by: ["facility_id"] })` が返す1行★(指示書AQ 決定2)。
 *
 * ★`_sum` は対象行0件で `null` を返す★(§5-3 A-2)。★`null` のまま外へ出さないこと★。
 * ★面積の合計だけ★(指示書AS 段階4-1。★`clean_ratio` の `_sum` と `_count` は外した★ —
 *   完了率と実施回数は `CleanAreaRateRow` から数える)。
 */
export interface CleanAreaFacilityGroupRow {
  facility_id: bigint;
  _sum: { clean_area: number | null; task_area: number | null };
}

/**
 * ★完了率・実施回数のために取り寄せる `clean_task_reports` の1行★(指示書AS 段階4-1)。
 *
 * ★`clean_timetable_id` が 0 か NULL の行(予定外)も取り寄せる★(指示書BG §2-5。2026-10-29 から完了率・実施回数に入れる)。
 *   判定は純粋関数側の `scheduledTimetableId()` の1本(★`=== null` を直書きしない★)。
 * ★`clean_ratio` は読まないが `select` に入れる★(指示書AS D6。`runCompletionRate()` を列に戻すとき用。
 *   ★行をそのまま `runCompletionRate()` に渡す★)。
 */
export interface CleanAreaRateRow {
  facility_id: bigint;
  clean_timetable_id: bigint | null;
  clean_area: number;
  task_area: number;
  clean_ratio: number;
}

/**
 * 番人の `groupBy` が `where` に足す条件(★予定のタスクの行だけ★)。
 * ★`{ not: null }` に戻さないこと★ — 0(予定外。TeamWorks 回答 2026-10-27)が予定として数えられる。
 * ★完了率用の `findMany` はもう足さない★(予定外も取り寄せる。指示書BG §2-5)。
 */
export type CleanAreaScheduledWhere = { clean_timetable_id: typeof SCHEDULED_TIMETABLE_ID_WHERE };

/**
 * 集計と番人で共有する `where`(★同じ条件で撃つこと★。指示書AN §6-3)。
 *
 * ★`started_at` は `clean_task_reports` にも同じ名前で在る★(`prisma/schema.prisma:540`。
 *   NOT NULL)ので、差し替えで ★呼び出し側の `where` の組み立ては変わりません★。
 */
export interface CleanAreaWhere {
  started_at?: DbDateRange;
  facility_id?: { in: bigint[] };
  /** ★削除済みの施設の行を外す★(`FACILITY_SECTION_VISIBLE_WHERE`。指示書BB U1・D-182) */
  facility?: { deleted_at: null };
  /** ★削除済みの清掃エリアの行を外す★(同上) */
  section?: { deleted_at: null };
}

/**
 * 番人(重複検知)が返す1行。★`clean_timetable_id` は nullable★(`prisma/schema.prisma:524`)。
 */
export interface CleanAreaDuplicateGroupRow {
  clean_timetable_id: bigint | null;
}

/**
 * この関数群が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
 * ★`PrismaClient` をそのまま要求しないこと★ — 生成済みクライアントが無い環境でも
 * 単体テストが走るようにしてある(`lib/db/facility-group-source.ts` と同じ作法)。
 */
export interface CleanAreaStatsSourcePrisma {
  cleanTaskReport: {
    groupBy(args: {
      by: ["facility_id"];
      where: CleanAreaWhere;
      _sum: { clean_area: true; task_area: true };
    }): Promise<CleanAreaFacilityGroupRow[]>;
    findMany(args: {
      // ★予定外も取り寄せるので「予定のタスクだけ」の条件は付けない★(指示書BG §2-5)
      where: CleanAreaWhere;
      select: {
        facility_id: true;
        clean_timetable_id: true;
        clean_area: true;
        task_area: true;
        clean_ratio: true;
      };
    }): Promise<CleanAreaRateRow[]>;
  };
  cleanTimetable: {
    findMany(args: {
      where: { id: { in: bigint[] }; precheck: number };
      select: { id: true };
    }): Promise<{ id: bigint }[]>;
  };
  /**
   * ★予定は `clean_timetable_histories` からも読む★(指示書AU 段階3b。第11回 P-7)。
   * ★`cleanTimetable` と同じ引数を受ける★(`findTimetablesByChunks()` が同じ `args` を2表に渡す)。
   * ★`cleanTimetable.findMany` は影チェック(`{ where: { id: { in } }, select }`)も受ける★
   *   (`lib/db/timetable-source.ts`。履歴だけに出た id があるときだけ)
   */
  cleanTimetableHistory: {
    findMany(args: {
      where: { id: { in: bigint[] }; precheck: number };
      select: { id: true };
    }): Promise<{ id: bigint }[]>;
  };
}

/** 番人が使う Prisma の最小限の形(★`groupBy` の引数の形が上と違うので別にしてある★) */
export interface CleanAreaDuplicateSourcePrisma {
  cleanTaskReport: {
    groupBy(args: {
      by: ["clean_timetable_id"];
      where: CleanAreaWhere & CleanAreaScheduledWhere;
      _count: { clean_timetable_id: true };
      having: { clean_timetable_id: { _count: { gt: number } } };
    }): Promise<CleanAreaDuplicateGroupRow[]>;
  };
}

/** 施設マスタから読む列(★これ以上増やさないこと★。ロールアップに要るのは所属と名前だけ) */
export interface CleanAreaFacilityMasterRow {
  id: bigint;
  facility_name: string;
  customer_id: bigint;
  customer: {
    customer_name: string;
    /** ★`customers.group_id` は NOT NULL で、0 = ホールディングスなしのセンチネル★(D-011) */
    group_id: bigint;
    /**
     * ★`group_id = 0` に対応する `groups` の行は存在しない★ので、実行時には欠ける。
     * (Prisma の型は必須リレーションだが、★FK が緩い環境では欠けうる★ので `null` を許す)
     */
    group: { group_name: string } | null;
  };
}

/**
 * この関数が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
 * ★`PrismaClient` をそのまま要求しないこと★ — 生成済みクライアントが無い環境でも
 * 単体テストが走るようにしてある(`lib/db/facility-group-source.ts` と同じ作法)。
 */
export interface CleanAreaFacilitySourcePrisma {
  facility: {
    findMany(args: {
      select: {
        id: true;
        facility_name: true;
        customer_id: true;
        customer: {
          select: {
            customer_name: true;
            group_id: true;
            group: { select: { group_name: true } };
          };
        };
      };
    }): Promise<CleanAreaFacilityMasterRow[]>;
  };
}

/**
 * 清掃面積の画面が1回の取得で必要とするもの一式(★段階4 のルートがそのまま組み立てられる形★)。
 *
 * ★ロールアップ(`aggregateCleanArea()`)をここで済ませない★ — ★粒度(`group_by`)は
 *   リクエストの引数★で、★同じ材料から3粒度とも作れる★(材料を粒度ごとに読み直さない)。
 */
export interface CleanAreaSourceResult {
  /** 施設ごとの集計値(★`aggregateCleanArea()` の第1引数にそのまま渡せる形★) */
  stats: CleanAreaFacilityStats[];
  /** 施設マスタ(★`aggregateCleanArea()` の第2引数★。★`deleted_at` で絞っていない★) */
  facilities: CleanAreaFacilityMaster[];
  /**
   * ★★番人★★(指示書AN §2-5 / §6-3 / 落とし穴2)—
   * ★1つの `clean_timetable_id` にレポートが2行以上ある `clean_timetable_id` の件数★。
   * ★対象期間・対象施設に絞って数えた値★(全期間・全施設を数え直したものではない)。
   * ★★`clean_timetable_id` が 0 か NULL の行(予定外)は数えない★★(指示書AQ 罠1。
   *   数え方の正は `countDuplicatedTimetables()`。★そちらの docstring を読むこと★)。
   *
   * ★★0 以外なら `SUM()` が同じ清掃の面積を2回数えている可能性がある★★ —
   *   ★例外もテスト失敗も出ず、数字が大きくなるだけ★なので、
   *   ★★呼び出し側(ルート層)は必ず `reportCleanAreaAnomalies()` に渡すこと★★
   *   (`route` / `method` を知っているのはルート層だけなので、ログはそちらで出す。
   *   `lib/clean-area/anomaly.ts` の docstring)。
   *   ★原因の断定は書かない★(`docs/logging-design.md` §7)。
   */
  duplicated_timetable_count: number;
  /**
   * ★完了率・実施回数のために取り寄せた行数★(D-156 論点2。ルート層が `judge.clean_area` ログに出す)。
   * ★期間を指定しないと全期間(保持2年)を運ぶ★ので、実データで重いかどうかをログで見る。
   * fixtures 版は、完了率の材料にした fixtures の行数。
   */
  rate_rows: number;
}

/**
 * 面積の `groupBy` の行と、完了率用に取り寄せた行から、`aggregateCleanArea()` が受け取れる形を作る
 * (★純粋関数★。指示書AS 段階4-1。旧 `cleanAreaStatsFromGroupRows()` を置き換えた)。
 *
 * - ★面積の合計★は `groupRows` から(★予定外を含む全行★)。`_sum` の `null` は 0 にする(§5-3 A-2)
 * - ★実施回数・完了率★は `reports` のうち、次のどちらかで数える(指示書BG §2-5):
 *   - ★予定外(`scheduledTimetableId()` が `null`。0・NULL)の行★ — 予定外タスク1件=実施1回(指示書BG §1)
 *   - ★`mainTimetableIds`(本清掃=`precheck = 0` の予定の id。`toString()` 済み)に入っている行★
 *   ★予定のある行は「本清掃の集合に入っている」という肯定条件で数える★(`errorCountByMainTimetable()` と同じ考え方)—
 *   事前チェックの予定に紐づく行・予定が見つからない(dangling)行はこれで自然に落ちる(表4 F3 / F4)
 *   - `executed_count += 1`(★`task_area = 0` の清掃も実施1回に数える★。表4 F5)
 *   - `const rate = runCompletionRate(report)`。★常に★ `clean_ratio_sum += rate` /
 *     `clean_ratio_count += 1`(★`task_area = 0` は 0% として足す。P-5=B。指示書AU 表3 F5★。
 *     ★名前は変えていない。中身は1回ごとの完了率の合計・件数。指示書AS D7★)
 * - ★`groupRows` に無い施設の行が `reports` に来たら例外を投げる★ — 同じ `where` を使っていれば
 *   起きない。起きたら `where` の食い違いなので、★黙って足さない★
 *
 * ★★`facility_id` は `BigInt` で返るので `Number()` に通すこと★★ —
 *   ★通さないと `Map` のキーが `1n` と `1` で別物になり、
 *   ★★全施設が「マスタに無い施設」に化けて合計が 0 になります★★(`dsDevices()` の罠1 と同じ形)。
 *
 * ★★`executed_count` の意味(2026-10-29。指示書BG §2-5)★★ —
 *   ★本清掃に紐づくレポートの行数 + 予定外のレポートの行数★(事前チェック・dangling は含まない)。
 *   ★旧(2026-10-02。指示書AS 段階4): 本清掃に紐づく行数だけ(予定外を含まない)★ —
 *   ★「面積はあるが実施回数0」の施設が出ていた★(D-156 論点7)。予定外も数えるので、今は面積のある施設は実施1回以上になる
 *   (事前チェック・dangling の行だけの施設を除く)。
 *
 * ★`clean_ratio_sum` は常に数値を入れる★(型は `number | null` のまま。Prisma の `_sum` の名残)。
 *
 * @param groupRows `loadCleanAreaFacilityStats()` の `groupBy`(面積の合計)
 * @param reports   完了率用に取り寄せた行(★予定外(0・NULL)を含む★。指示書BG §2-5)
 * @param mainTimetableIds 本清掃の予定の id(`toString()` 済み)
 */
export function cleanAreaStatsFromRows(
  groupRows: readonly CleanAreaFacilityGroupRow[],
  reports: readonly CleanAreaRateRow[],
  mainTimetableIds: ReadonlySet<string>
): CleanAreaFacilityStats[] {
  const byFacility = new Map<number, CleanAreaFacilityStats>();
  const stats = groupRows.map((row): CleanAreaFacilityStats => {
    const stat: CleanAreaFacilityStats = {
      facility_id: Number(row.facility_id),
      clean_area: row._sum.clean_area ?? 0,
      task_area: row._sum.task_area ?? 0,
      executed_count: 0,
      clean_ratio_sum: 0,
      clean_ratio_count: 0,
    };
    byFacility.set(stat.facility_id, stat);
    return stat;
  });

  for (const report of reports) {
    // ★予定外(0・NULL)は数える★(指示書BG §2-5)。★事前チェック・dangling は数えない★(肯定条件。表4 F3 / F4)
    //   ★`=== null` を直書きしない★(0 がすり抜ける。`lib/db/timetable-link.ts`)
    const timetableId = scheduledTimetableId(report.clean_timetable_id);
    if (timetableId !== null && !mainTimetableIds.has(timetableId.toString())) continue;

    const facilityId = Number(report.facility_id);
    const stat = byFacility.get(facilityId);
    if (stat === undefined) {
      // ★面積の `groupBy` と同じ `where` で取っていれば起きない★。起きたら `where` の食い違い
      throw new Error(
        `[db] cleanAreaStatsFromRows: 面積の集計に無い施設の行が来ました(facility_id=${facilityId})。` +
          "面積の groupBy と完了率用の findMany の where が食い違っています"
      );
    }
    stat.executed_count += 1;
    // ★1回ごとの完了率は `runCompletionRate()` だけが決める★(行をそのまま渡す。指示書AS D1 / D6)
    const rate = runCompletionRate(report);
    stat.clean_ratio_sum = (stat.clean_ratio_sum ?? 0) + rate;
    stat.clean_ratio_count += 1;
  }
  return stats;
}

/** `loadCleanAreaFacilityStats()` の戻り(★面積・実施回数・完了率の材料と、取り寄せた行数★) */
export interface CleanAreaFacilityStatsLoad {
  stats: CleanAreaFacilityStats[];
  /** 完了率・実施回数のために取り寄せた `clean_task_reports` の行数(ログ用。D-156 論点2) */
  rate_rows: number;
}

/**
 * 施設ごとの面積・実施回数・完了率の材料を ★`clean_task_reports` から★集計する(指示書AQ 決定2・指示書AS 段階4)。
 *
 * ★★`lib/db/index.ts` は `groupBy` / `findMany` を直接書かず、この関数を呼ぶこと★★ —
 *   ★★「どのテーブルから面積を取るか」の正をこのファイル1箇所に置くため★★。
 *   ★直接書くと、次に参照元が変わったときにここだけ直して本体が古いまま残ります★
 *   (★例外もテスト失敗も出ず、画面の数字だけが古いテーブルのものになる★)。
 *
 * ★★関数名とシグネチャ `(prisma, where)` を変えないこと★★。取り寄せた行数(ログ用。D-156 論点2)も
 *   要る呼び出し側(`lib/db/index.ts` の `cleanArea()`)は `loadCleanAreaFacilityStatsWithRowCount()` を呼ぶ
 *   (中身は同じ1本。こちらはその `stats` だけを返す薄い包み)。
 *
 * ★★面積は `groupBy` + `_sum`(行を運ばない)★★ — ★返るのは最大でも施設数ぶん★で、
 *   ★期間の広さに関係しない★(指示書AN §2-5)。
 * ★★実施回数・完了率の材料は行を運ぶ★★(指示書AS 段階4)— 上限100・小数第1位の切り捨て
 *   (`runCompletionRate()`)は `groupBy` で書けず、★生SQL(`$queryRaw`)は禁止★(`CLAUDE.md` §3)のため。
 *   ★運ぶのは5列だけ★(★予定外(0・NULL)の行も運ぶ。指示書BG §2-5★)。★期間を指定しないと全期間(保持2年)を運ぶ★。
 *
 *   1. `groupBy`(面積)と `findMany`(完了率用の行)を `Promise.all` で
 *   2. 行の `clean_timetable_id` を重複なしで集め、`chunkForInClause()` で分けて
 *      `cleanTimetable.findMany`(`precheck = CLEAN_TIMETABLE_PRECHECK.main`)。★塊が0個ならクエリを撃たない★。
 *      ★`clean_timetable_histories` にも同じ引数で撃つ★(`findTimetablesByChunks()`。指示書AU 段階3b)
 *   3. 戻った id を `toString()` して `Set` にし、`cleanAreaStatsFromRows()` に渡す
 *
 * ★★`include` でレポート→予定を引かないこと★★(`clean_timetable_id` に FK が無い。`IN` が膨らむ。
 *   ファイル冒頭の「`include` を使わないこと」)。
 *
 * @param where ★番人(`countDuplicatedTimetables()`)と**同じもの**を渡すこと★(§6-3)。
 *   ★完了率用の `findMany` もこれをそのまま使う★(予定外も取り寄せる。指示書BG §2-5。番人だけが「予定のタスクだけ」を足す)
 */
export async function loadCleanAreaFacilityStats(
  prisma: CleanAreaStatsSourcePrisma,
  where: CleanAreaWhere
): Promise<CleanAreaFacilityStats[]> {
  return (await loadCleanAreaFacilityStatsWithRowCount(prisma, where)).stats;
}

/**
 * `loadCleanAreaFacilityStats()` と同じ集計に、★完了率用に取り寄せた行数(`rate_rows`)★を添えて返す。
 * ★集計の中身はこちらが正★(`loadCleanAreaFacilityStats()` はこれの `stats` を返すだけ)。
 * ★行数はルート層が `judge.clean_area` ログに出す★(データ層はルートを知らないのでログを出さない)。
 */
export async function loadCleanAreaFacilityStatsWithRowCount(
  prisma: CleanAreaStatsSourcePrisma,
  where: CleanAreaWhere
): Promise<CleanAreaFacilityStatsLoad> {
  const [groupRows, reports] = await Promise.all([
    prisma.cleanTaskReport.groupBy({
      by: ["facility_id"],
      where,
      // ★面積の合計は全行(予定外を含む)★(指示書AQ §1-3 / 指示書AS D9)
      _sum: { clean_area: true, task_area: true },
    }),
    prisma.cleanTaskReport.findMany({
      // ★★`where` は面積の集計と同じもの★★(指示書BG §2-5。予定外も実施回数・完了率に入れるので取り寄せる)。
      //   ★旧: 「予定のタスクだけ」(`clean_timetable_id: SCHEDULED_TIMETABLE_ID_WHERE`)を足していた★ —
      //   ★番人(`countDuplicatedTimetables()`)は今も足す★(重複の見張りは予定のある行だけ)
      where,
      // ★`clean_ratio` は完了率に使わないが入れる★(指示書AS D6)
      select: {
        facility_id: true,
        clean_timetable_id: true,
        clean_area: true,
        task_area: true,
        clean_ratio: true,
      },
    }),
  ]);

  // ★予定は id で引き直す★(`include` を使わない。`clean_timetable_id` に FK が無い)
  //   ★予定外(0・NULL)は `scheduledTimetableId()` で落とす★(0 の id で予定を引かない。予定外は予定を引かずに数える)
  const timetableIds = [
    ...new Map(
      reports
        .map((r) => scheduledTimetableId(r.clean_timetable_id))
        .filter((id): id is bigint => id !== null)
        .map((id) => [id.toString(), id] as const)
    ).values(),
  ];
  // ★塊が0個(行が0件)ならクエリを1本も撃たない★(`lib/db/chunked.ts`)
  // ★★予定は2表(`clean_timetables` / `clean_timetable_histories`)から読む★★(指示書AU 段階3b-3)—
  //   ★レポートの `clean_timetable_id` が履歴にしか無いと、本表だけでは「本清掃に紐づかない行」になり、
  //   完了率・実施回数から黙って落ちる(面積だけ残る)★。★`where` は両表に同じものを渡す★
  const timetableRows = await findTimetablesByChunks(prisma, chunkForInClause(timetableIds), (ids) => ({
    // ★`0` を直書きしない★(`CLEAN_TIMETABLE_PRECHECK.main`)
    where: { id: { in: ids }, precheck: CLEAN_TIMETABLE_PRECHECK.main },
    select: { id: true },
  }));
  const mainTimetableIds = new Set(timetableRows.map((row) => row.id.toString()));

  return {
    stats: cleanAreaStatsFromRows(groupRows, reports, mainTimetableIds),
    rate_rows: reports.length,
  };
}

/**
 * ★★番人★★(指示書AN §2-5 / §6-3 / 落とし穴2)—
 * ★1つの `clean_timetable_id` に2行以上ある `clean_timetable_id` の件数★を数える。
 *
 * ★`having` で「2行以上のものだけ」をDB側に絞らせる★ — 絞らないと
 *   ★`clean_timetable_id` ごとに1行★返ってきます。★異常が無ければ0行しか運びません★。
 *
 * ------------------------------------------------------------------------
 * ★★`clean_timetable_id` が NULL と 0 の行を必ず除くこと★★(2026-09-30。指示書AQ 罠1。0 は 2026-10-27 追加)
 * ------------------------------------------------------------------------
 * `clean_task_reports.clean_timetable_id` は nullable(`prisma/schema.prisma:524`)。★NULL は予定外のタスク★
 * (予定を持たない実在の清掃。ユーザー回答 2026-10-01。`CLAUDE.md` §4.7)で、★埋まる予定は無い(NULL の行は常にある)★。
 * 旧: 「TeamWorks が今後埋める前提」(指示書AQ 決定5)— 誤り。
 * ★★SQL の `GROUP BY` は NULL 同士を同じグループに入れる★★ので、除かないと
 * ★★NULL 行が全部まとまって1つの巨大グループになり、`having > 1` に必ず当たります★★。
 * = ★実際には重複していないのに番人が毎回1件を報告する(偽陽性)★。
 * ★毎回出る警告は読まれなくなる★ので、★本物の重複を見逃すようになります★。
 *
 * ★★0 も予定外★★(TeamWorks 回答 2026-10-27)。★0 の行も同じ理由で1つのグループにまとまる★ので、
 * 除かないと ★予定外のタスクが2回以上あるだけで番人が誤報する★。
 *
 * ★`SCHEDULED_TIMETABLE_ID_WHERE`(`{ gt: 0 }`)で除く★ — ★`WHERE clean_timetable_id > 0` になり、
 *   SQL の比較で NULL は偽なので NULL も同時に落ちる★(列は unsigned)。
 *   ★旧: `{ not: null }`(`IS NOT NULL`)★ — これでは 0 が残る。★戻さないこと★。
 *
 * ★★除いた 0・NULL 行の面積・実施回数・完了率は `loadCleanAreaFacilityStats()` 側では**足されている**★★
 *   (実施回数・完了率は 2026-10-29 から。指示書BG §2-5)—
 *   こちらは「重複の見張り」であって「集計の母集団」ではないため、意図どおりです
 *   (★番人の `where` を集計側にも持ち込まないこと★。★重複の見張りは予定のある行だけのまま★。指示書BG §2-5)。
 *
 * @param where ★集計(`loadCleanAreaFacilityStats()`)と**同じもの**を渡すこと★(§6-3)—
 *   ★全期間・全施設を毎回数え直さない★
 */
export async function countDuplicatedTimetables(
  prisma: CleanAreaDuplicateSourcePrisma,
  where: CleanAreaWhere
): Promise<number> {
  const rows = await prisma.cleanTaskReport.groupBy({
    by: ["clean_timetable_id"],
    // ★★0・NULL 除外を落とさないこと★★(上の docstring。落とすと番人が毎回1件を誤報する)
    where: { ...where, clean_timetable_id: SCHEDULED_TIMETABLE_ID_WHERE },
    _count: { clean_timetable_id: true },
    having: { clean_timetable_id: { _count: { gt: 1 } } },
  });
  // ★返るのは「2行以上あった `clean_timetable_id`」の行数そのもの★
  return rows.length;
}

/**
 * ロールアップに使う施設マスタを、★列を絞って★全件読む。
 *
 * ★★`where` を足さないこと★★ — 上の「`deleted_at` で絞らない理由」。
 *   ★テスト(`lib/db/__tests__/clean-area-source.test.ts`)が
 *   「渡した引数のキーが `select` だけであること」を固定しています★。
 *
 * ★★`group_id` の 0 → `null` は `groupIdFromDb()` に任せる★★(D-011 / 落とし穴5)—
 *   ★`=== 0` をここに書かないこと★。`BigInt` のままだと `0n === 0` が `false` で
 *   静かに外れるので、先に `Number()` に落としてから渡す(`prismaSource.customers()` と同じ)。
 */
export async function loadCleanAreaFacilityMasters(
  prisma: CleanAreaFacilitySourcePrisma
): Promise<CleanAreaFacilityMaster[]> {
  const rows = await prisma.facility.findMany({
    select: {
      id: true,
      facility_name: true,
      customer_id: true,
      customer: {
        select: {
          customer_name: true,
          group_id: true,
          group: { select: { group_name: true } },
        },
      },
    },
  });
  return rows.map((row): CleanAreaFacilityMaster => {
    const group_id = groupIdFromDb(Number(row.customer.group_id));
    return {
      // ★BigInt を残すと `NextResponse.json` が例外を投げる(`lib/db/index.ts` と同じ作法)★
      id: Number(row.id),
      facility_name: row.facility_name,
      customer_id: Number(row.customer_id),
      customer_name: row.customer.customer_name,
      group_id,
      // ★「所属なし」のときは名前も `null`★ — センチネル(`group_id = 0`)に対応する
      //   `groups` の行は存在しないので、名前を出す相手がいない(`prismaSource.facilities()` と同じ)。
      group_name: group_id === null ? null : (row.customer.group?.group_name ?? null),
    };
  });
}