/**
 * データソースの切替
 *
 * 環境変数 `USE_FIXTURES` で「サンプルデータ」と「実DB（Prisma）」を切り替えます。
 *
 *   USE_FIXTURES=true   → lib/fixtures/ のサンプルデータを返す（DB不要）
 *   USE_FIXTURES=false  → Prisma経由で実DBを読む
 *
 * ★画面側のコードは切替を意識しません。APIルートがこのモジュールを呼ぶだけです★
 *
 * ★判定ロジック（lib/judge/）はこの切替の外側に置くこと★
 *   fixture開発中も本番と同じ判定ロジックが走るようにするためです（D-044）。
 *
 * 【重要】メインDBは原則読み取り専用です。
 * 書き込んでよいのは robot_error_reports / control_error_histories の
 * status / comment / responded_at / resolved_at の4カラムのみ（D-042）。
 * ★書き込みは `lib/db/robot-error-write.ts` の `writeRobotErrorColumns()` だけ★（指示書AT）。
 */
import type {
  AreaCompletionDbQuery,
  AreaCompletionRow,
  CleanAreaDbQuery,
  CleanLogDbQuery,
  CleanLogItem,
  CleanLogRunMeta,
  ContinuousProblemRow,
  Customer,
  DsAlert,
  DsDevice,
  ErrorBreakdownRow,
  ErrorReportDbQuery,
  ErrorReportItem,
  Facility,
  FacilityGroupRef,
  FacilityNameRef,
  FacilityPerformance,
  FacilityPerformanceDbQuery,
  FacilityPerformanceRow,
  Group,
  InspectionDetail,
  InspectionListItem,
  PrecheckDbQuery,
  PrecheckItem,
  PrecheckStatusPatch,
  ReportDbQuery,
  ReportKpi,
  ReportKpiDbQuery,
  ReportKpiRow,
  ReportPeriodKey,
  RescueDbQuery,
  RescueRequiredItem,
  Schedule,
} from "@/lib/types";

import {
  areaCompletions,
  cleanLogs,
  continuousProblems,
  customers,
  dsAlerts,
  dsDevices,
  errorBreakdown,
  errorReportRows,
  errorReportTimetables,
  facilities,
  facilityPerformance,
  groups,
  inspectionDetails,
  inspections,
  prechecks,
  reportKpis,
  schedules,
} from "@/lib/fixtures";
import { CLEAN_TIMETABLE_PRECHECK, MAKER_NAMES } from "@/lib/config";
import { judgeEscalationLevel, judgeFacilityStatus, judgeGrade } from "@/lib/judge";
import {
  completionRateFromReport,
  computeConsecutiveProblemDays,
  formatModelName,
  isFutureSchedule,
  latestReportByTimetableId,
  notExecutedFromReport,
  robotStatusFromErrors,
  type ConsecutiveInput,
  type RobotErrorLike,
} from "@/lib/db/clean-log-mapping";
import { chunkForInClause } from "@/lib/db/chunked";
import { isResultPending } from "@/lib/clean-logs/result-pending";
// ★清掃面積（指示書AN 段階3）★ — ★ロールアップ（`aggregateCleanArea()`）はここでやらない★。
//   材料（施設ごとの合計 + 施設マスタ + 番人の件数）を返すだけにして、
//   ★粒度（`group_by`）ごとの組み立てはルート層に任せる★（同じ材料から3粒度とも作れる）。
//   ★★`groupBy` はここに直書きせず、同ファイルの2関数を必ず経由する★★（指示書AQ §5-3）—
//   ★番人の `clean_timetable_id IS NOT NULL` 除外があちらにしか無い★ため。
import {
  countDuplicatedTimetables,
  loadCleanAreaFacilityMasters,
  loadCleanAreaFacilityStatsWithRowCount,
  type CleanAreaSourceResult,
  type CleanAreaWhere,
} from "@/lib/db/clean-area-source";
import { cleanAreaStatsFromFixtureAreas } from "@/lib/db/fixture-clean-area";
import {
  loadConsecutiveInputs,
  type ConsecutiveDbRange,
} from "@/lib/db/consecutive-source";
import { dbDateRange } from "@/lib/db/date-range";
import {
  buildRescueRequired,
  rescueInputsFromCleanLogs,
} from "@/lib/db/rescue-mapping";
import { loadFacilityGroupIndex } from "@/lib/db/facility-group-source";
import { loadFacilityNameIndex } from "@/lib/db/facility-name-source";
import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions";
import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs";
import { filterFixturePrechecks } from "@/lib/db/fixture-prechecks";
import {
  applyFixturePrecheckPatch,
  withFixturePrecheckOverride,
} from "@/lib/db/fixture-precheck-overrides";
import {
  applyFixtureErrorReportPatch,
  withFixtureErrorReportOverride,
} from "@/lib/db/fixture-error-report-overrides";
import {
  errorReportItemFromRow,
  errorReportKeyFromRow,
  errorReportListWhere,
  type ErrorReportKey,
  type ErrorReportRowSource,
  type ErrorTimetableRef,
} from "@/lib/db/error-report-mapping";
import { filterSortErrorReportKeys } from "@/lib/error-reports/query";
import { writeRobotErrorColumns } from "@/lib/db/robot-error-write";
import {
  findTimetableById,
  findTimetables,
  findTimetablesByChunks,
  type TimetableFindManyArgs,
} from "@/lib/db/timetable-source";
import {
  ROBOT_ERROR_COUNT_SELECT,
  ROBOT_ERROR_LIST_KEY_SELECT,
  ROBOT_ERROR_LIST_ROW_SELECT,
  ROBOT_ERROR_PRECHECK_ROW_SELECT,
  ROBOT_ERROR_STATUS_SELECT,
  ROBOT_ERROR_TYPE_SELECT,
} from "@/lib/db/robot-error-select";
import {
  filterFixtureFacilityPerformance,
  filterFixtureReportKpis,
  filterFixtureReportRows,
  filterFixtureRowsByFacility,
} from "@/lib/db/fixture-reports";
import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
// ★fixtures の屋号一覧を `id` 昇順にするため★（指示書AL 段階3 §4-4）—
//   ★並べ替え規則の「正」は `sortCustomers()` の1本★。ここに比較関数を写さない。
import { sortCustomers } from "@/lib/masters/query";
import {
  mainRunSucceededFromReport,
  PRECHECK_COLUMN_COUNT,
  precheckItemFromErrorRow,
  precheckMinutesFromCode,
  statusColumnsFromPrecheckRows,
} from "@/lib/db/precheck-mapping";
import { logEvent } from "@/lib/logger";
import {
  connectionStatusFromControlStatus,
  resolutionStatusFromControlErrorStatus,
} from "@/lib/db/ds-status";
import {
  aggregateCleanStatsByFacility,
  consecutiveProblemDaysAtPeriodEnd,
  continuousProblemsInPeriod,
  countMainErrorTypesInRange,
  customerSummariesByPeriodKey,
  errorBreakdownItemsFromCounts,
  errorBreakdownRowsFromSummary,
  errorCountByMainTimetable,
  kpiFromCleanStats,
  kpiFromSummaryRatios,
  mainTimetableStartAtById,
  periodMapKey,
  periodOfSummaryRow,
  sumCleanStatsBuckets,
} from "@/lib/db/report-mapping";
import { formatAddress } from "@/lib/format/address";
import { parseContract } from "@/lib/format/contract";
import { fromDbJst, jstIsoFromDb } from "@/lib/format/date";
// ★`customers.group_id` の 0 → null 変換はこの1本だけが知っている★（D-011 / 指示書AK §4-4）—
//   ★`=== 0` を `prismaSource` の中に書かないこと★（落とし穴3）。
import { groupIdFromDb } from "@/lib/format/group-id";
// ★`todayJst()` / `weekOfMonth()` はもうここでは使わない★（指示書AC 段階5）—
//   ★当期かどうかの判定は `isCurrentPeriod()` に、期間の範囲は `periodRange()` に任せる★。
//   ★★「今日」から期間キーを自分で組み立てないこと★★（月跨ぎ週の帰属を知っているのは
//   `weekOfMonth()` だけ。`CLAUDE.md` §4.7 / D-071）。
import { isCurrentPeriod, periodRange, shiftPeriod } from "@/lib/format/period";
import {
  formatEndTime,
  formatStartTime,
  weekdaysLabel,
} from "@/lib/format/schedule";

export { USE_FIXTURES } from "@/lib/db/use-fixtures";
import { USE_FIXTURES } from "@/lib/db/use-fixtures";

/**
 * データソースが実装すべきインターフェース。
 * フィルタや集計はこの層では行わず、APIルート側で組み立てます
 * （実DB接続時にクエリへ落とすときの差分を小さくするため）。
 *
 * ★例外: レポート4メソッドの「当期（進行中の週・月）」だけは `prismaSource` の中で
 *   詳細テーブルからその場集計する（指示書T 段階2。D-090項14/項15の解決）★ —
 *   `cleanLogs()` の連続日数・judge 呼び出しと同じ扱い
 *   （D-090項16「judgeをprismaSourceの中で呼ぶこと自体は妥当」）。
 *   確定期間（サマリテーブル由来）はこれまでどおり再計算しない（D-102 / CLAUDE.md 禁止事項1）。
 *   ★★2026-09-13（段階5）以降は「どの期間を集計すべきか」を `ReportDbQuery` で
 *   受け取る★★ので、★当期かどうかの判定も期間の範囲もその引数から導く★
 *   （`todayJst()` は当期判定（`isCurrentPeriod()`）の中だけで使う）。
 *
 * ★★2026-09-13（指示書AC 段階2 / ★段階3★ / ★段階4★ / ★段階5★）:
 *   `inspectionDetail()` 以外の主要メソッドは引数を取るようになりました★★ —
 *   ★「全部返す」しかできない契約では本番のデータ量（保持2年で約26万行）に耐えない★
 *   ため、★日付範囲・施設ID・単一ID・期間 を SQL に降ろします★。
 *   ★絞り込み規則の「正」は API ルート側の純粋関数（`filterCleanLogs()` /
 *   `selectAreaRowsForLog()` 等）に置いたまま★ —
 *   ★SQL 側はその写し★ です（指示書AC C-5。★だから `filterXxx()` を外さないこと★）。
 */
export interface DataSource {
  /**
   * 清掃詳細ログの一覧。
   *
   * ★★引数は必須★★（指示書AC C-4 項4）— ★全件が要る呼び出し側は「範囲なし」
   *   （`{ date_from: null, date_to: null, facility_ids: null }`）を明示的に渡すこと★。
   *   ★任意引数にすると「渡し忘れても動く（が全件読む）」状態が残り、
   *   直したつもりで直っていないことに誰も気づけない★。
   *
   * ★★`facility_ids` の `null` と `[]` は別物★★（`CleanLogDbQuery` の型コメント）—
   *   `null` = 施設で絞らない / `[]` = ★0件★。
   */
  cleanLogs(query: CleanLogDbQuery): Promise<CleanLogItem[]>;
  /**
   * ★清掃ログの id → { 清掃エリア, 予定の終了 }★（ダッシュボードの直近モード専用。D-159 / 指示書AV）。
   * ★`cleanLogs()` と同じ引数を渡すこと★（同じ `where`。突き合わせは `pickLatestRunPerSection()`）。
   * ★`cleanLogs()` が返さない id（未来の予定）を含んでよい★。逆（`cleanLogs()` の行に対応が無い）は呼び出し側の例外
   *
   * ★★`CleanLogItem` にこの2列を足さずに別メソッドにした理由★★（指示書AV §2 D9）: `CleanLogItem` は
   *   多くの経路が共有しており、内部用の列を足すと剥がし忘れた瞬間に API に漏れるため。
   */
  cleanLogRunMeta(query: CleanLogDbQuery): Promise<CleanLogRunMeta[]>;
  /**
   * ★施設名 → 施設ID の解決★にだけ使う、施設マスタの最小限の一覧（指示書AC 段階2 D-6）。
   *
   * ★★`facilities()` と用途が違う★★ — あちらは施設マスタ画面（F-05）に返す一覧で
   *   ★`deleted_at` で絞っている★。こちらは ★絞らない★（★罠7★。理由は
   *   `loadFacilityNameIndex()`（`lib/db/facility-name-source.ts`）の docstring）。
   *   ★★`facilities()` で代用しないこと★★ — 論理削除された施設のログが
   *   検索から静かに消えます。
   */
  facilityNameIndex(): Promise<FacilityNameRef[]>;
  /**
   * ★分類（ホールディングス／屋号）→ 施設ID の解決★にだけ使う、施設マスタの最小限の一覧
   * （指示書AK 段階3 §6-3）。
   *
   * ★★`facilityNameIndex()` と同じ立ち位置★★ — ★`facilities()` で代用しないこと★
   *   （あちらは `deleted_at` で絞っている。★罠7★。理由は
   *   `loadFacilityGroupIndex()`（`lib/db/facility-group-source.ts`）の docstring）。
   *
   * ★★`facilityNameIndex()` に列を足す形にしないこと★★ — 用途が違う2つの読み取りを
   *   1本にまとめると、片方の都合で `where` や列を変えたときに
   *   ★もう片方の要件を黙って壊します★（`dsDeviceExists()` を `dsDevices()` に
   *   足さなかったのと同じ判断）。
   *
   * ★解決（当たり外れ）は `lib/facilities/resolve-by-master.ts` の純粋関数★。
   *   ★このメソッドは行を読むだけ★。
   */
  facilityGroupIndex(): Promise<FacilityGroupRef[]>;
  /**
   * エリア別完了率の行。
   *
   * ★★引数は必須★★（指示書AC 段階3 / C-4 項4。`cleanLogs()` と同じ作法）—
   *   ★全件が要る呼び出し側は「範囲なし」（`{ facility_id: null, date: null }`）を
   *   明示的に渡すこと★。★以前は引数を取らず**全期間・全施設**を読んでいた★
   *   （1行の表示のために実測 1,582行。指示書AC B-3）。
   *
   * ★★`filter` の適用と全体率の計算は APIルート側のまま★★
   *   （`lib/clean-logs/areas.ts` の `selectAreaRowsForLog()` / `buildAreaCompletions()`）—
   *   ★SQL 側は `selectAreaRowsForLog()` の写し★ です（C-5）。
   */
  areaCompletions(query: AreaCompletionDbQuery): Promise<AreaCompletionRow[]>;
  /**
   * アラート（事前チェック）一覧。
   *
   * ★★引数は必須★★（指示書AC 段階4 / C-4 項4。`cleanLogs()` と同じ作法）—
   *   ★全件が要る呼び出し側は「範囲なし」（`{ date_from: null, date_to: null,
   *   facility_ids: null }`）を明示的に渡すこと★。
   *
   * ★★`facility_ids` の `null` と `[]` は別物★★（`cleanLogs()` と同じ契約。★罠1★）—
   *   `null` = 施設で絞らない / `[]` = ★0件★（分類で絞って1件も当たらなかった）。
   *
   * ★★2種類の行（`precheck` / `launch_failed`）を**同じ期間**で絞る★★ —
   *   ★基準はどちらも「本清掃行の `start_at`」★（`PrecheckDbQuery` の型コメント）。
   *   ★★片方だけ絞れている状態を検出するため、`severity` の内訳をテストで固定してある★★
   *   （`app/api/prechecks/__tests__/route.test.ts` の #3）。
   *
   * ★★対応状況（`status`）の絞り込みは APIルート側のまま★★
   *   （`filterPrechecksByStatus()` / `sortPrechecks()`。指示書AC C-5）。
   */
  prechecks(query: PrecheckDbQuery): Promise<PrecheckItem[]>;
  /**
   * `PATCH /prechecks/{id}/status` の前後の読み取り（指示書AT 段階4-2 / §3-3）。★参照用の接続で読む★。
   *
   * `{id}` を `robot_error_reports.error_id` として引き、★次をすべて満たすときだけ★行を返す:
   *   1. `error_id = id` の行がある（★大小文字も含めて JS の `===` で一致★。DB の照合順序は ci。D-4）
   *   2. その行の `clean_timetable_id` が null でない
   *   3. その `clean_timetables` 行があり、`precheckMinutesFromCode(precheck)` が null でない（＝事前チェック行）
   *   4. その事前チェック行の `parent_id` が指す本清掃行がある（★無ければ例外＝500★。`prechecks()` と同じ）
   * ★1〜3 のどれかを満たさなければ `null`（＝404）★。★出発失敗の id（`clean_timetables.id` の文字列）は
   * `error_id` として引いても当たらないので `null`★（別の分岐を作らない）。
   * ★期間・施設では絞らない★（一覧の絞り込みであって行の性質ではない）。
   * ★行の組み立ては `precheckItemFromErrorRow()`★（`prechecks()` と同じ関数）。
   * ★`main_run_succeeded`（D-171）は 4. の本清掃行の `clean_task_reports` から決める★（無ければ false。404 にはしない）。
   */
  precheckItemById(id: string): Promise<PrecheckItem | null>;
  /**
   * `robot_error_reports` の4カラムを `updateMany` の1文で書く（指示書AT §3-2 / 段階4-2）。
   * ★書き込み用の接続（`getWritePrisma()`）で書く★。中身は `writeRobotErrorColumns()`
   * （`lib/db/robot-error-write.ts`）の1本。
   *
   * ★`matched` で存在を判定しないこと★ — 存在確認は書く前に `precheckItemById()` で行う。
   * 0 なら「確認と書き込みの間に行が消えた」（ルートが 404 にする）。
   */
  updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch): Promise<{ matched: number }>;
  /**
   * エラー一覧（`GET /errors`。指示書AW）。★`robot_error_reports` の全行★が対象（事前チェック・本清掃・予定なし・`robot_id` NULL を問わない）。
   * ★2段階で読む★（D8）: キーだけを SQL で `status IN`・`facility_id IN`・期間で絞って読み、
   *   `filterSortErrorReportKeys()` で絞り込み・並べ替え → ページの `error_id` だけ列を足して読む。
   * ★`total` は絞り込み後・ページ分け前の件数★（`meta.total`）。
   */
  errorReports(query: ErrorReportDbQuery): Promise<{ items: ErrorReportItem[]; total: number }>;
  /**
   * `PATCH /errors/{error_id}/status` の前後の読み取り。★`error_id === id` の行があれば返す（事前チェックかどうかを問わない）★。
   * ★大小文字は JS の `===` で決める★（D-4。`precheckItemById()` と同じ）。★期間・施設・状態では絞らない★
   */
  errorReportById(id: string): Promise<ErrorReportItem | null>;
  /**
   * 4カラムを書く（`writeRobotErrorColumns()` の1本。★書き込み用の接続★）。
   * ★`matched` で存在を判定しないこと★ — 存在確認は書く前に `errorReportById()` で行う
   */
  updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch): Promise<{ matched: number }>;
  dsDevices(): Promise<DsDevice[]>;
  /**
   * ★備考の保存（`PATCH /ds-devices/{serial_number}/note`）の実在確認★だけに使う
   * （指示書AH 段階2）。
   *
   * ★★`dsDevices()` に引数を足す形にしないこと★★ — ★一覧側はサマリ（`summarizeDsDevices()`）
   *   のために全件が要る★（D-054。`summary.total` は q / status フィルタの影響を受けない）。
   *   ★`facilityNameIndex()` が前例★（「`facilities()` と用途が違うから別メソッドにした」）。
   *
   * ★★`deleted_at` の絞りを `dsDevices()` と揃えること★★（★罠7★）—
   *   絞りをやめると ★一覧に出ないデバイスの備考が保存できてしまい、どこからも参照されない
   *   行がストアに残る★。★それはこのチェックが防いでいる当のもの★。
   *
   * ★★当たり外れは呼び出し先の実装が JS の `===` で決める★★（D-4）—
   *   ★DBの既定の照合順序は大小文字を区別しない（ci）★ので、SQL の一致をそのまま
   *   答えにすると `ds-001` が `DS-001` に当たって 404 が 200 になる。
   *   ★保存キーはリクエストの生文字列★（`lib/settings/ds-device-notes.ts` は
   *   `Record<string, string>`）で、★一覧は `notes["DS-001"]` で引く★ため、
   *   ★★保存されたのに一生表示されない孤児行が残る★★。
   */
  dsDeviceExists(serialNumber: string): Promise<boolean>;
  dsAlerts(): Promise<DsAlert[]>;
  /**
   * ホールディングス一覧（`GET /groups`。指示書AK 段階1）。
   *
   * ★`deleted_at IS NULL` でのみ絞る★（`facilities()` と揃える）。
   *   ★`inactive` では絞らない★ — 無効化は削除ではなく、画面が見せ分ける可能性がある
   *   （`facilities()` :1300-1302 と同じ判断）。
   *
   * ★★引数を取らない★★ — 分類マスタは数十行の規模で、絞り込む対象が無い
   *   （`facilities()` / `schedules()` と同じ扱い）。
   *   ★`id = 0` の行はそもそも存在しない★（0 は `customers.group_id` 側のセンチネル。D-011）。
   */
  groups(): Promise<Group[]>;
  /**
   * 屋号一覧（`GET /customers`。指示書AK 段階1）。
   *
   * ★`group_id` の絞り込みは API ルート側の純粋関数★（`filterCustomersByGroup()`）—
   *   ★分類マスタは数十行なので SQL に降ろす利得が無い★（清掃ログの26万行とは事情が違う。
   *   指示書AC C-4 の「引数必須」はデータ量が理由）。★絞り込み規則を1箇所に置く★ほうを採る。
   *
   * ★★`customers.contract` を読まないこと★★（D-010 / `CLAUDE.md` §7。落とし穴5）—
   *   削除依頼済みの列で、契約フラグは `facilities.contract` のみが正。
   *
   * ★`group_id` は 0 → `null` に変換して返す★（D-011）。
   *   変換を知っているのは `groupIdFromDb()`（`lib/format/group-id.ts`）だけ。
   *
   * ★★`id` の昇順で返すこと★★（指示書AL ★段階3★ / §4-4。`groups()` と違う点）—
   *   `GET /customers` はルート側の `sortCustomers()` で並べ替えるので順序に依存しないが、
   *   ★`GET /reports/kpi` の 422 は `db.customers()` の並びをそのまま
   *   `details.customer_ids` に流す★（`resolveTargetCustomer()` は並べ替えない）。
   *   順序をDB任せにすると ★画面に出る候補の並びが環境で変わる★。
   */
  customers(): Promise<Customer[]>;
  facilities(): Promise<Facility[]>;
  schedules(): Promise<Schedule[]>;
  inspections(): Promise<InspectionListItem[]>;
  inspectionDetail(id: number): Promise<InspectionDetail | null>;

  /**
   * 要レスキュー店舗（F-01 の `GET /dashboard` の `rescue_required`。指示書AI 段階4）。
   *
   * ★★引数は必須★★（指示書AC C-4 項4。`cleanLogs()` と同じ作法）。
   *
   * ★★`cleanLogs()` で代用しないこと★★ — 3条件のうち
   *   ★R-1（未実施3日連続）と R-3（前回比の急落）は当日の行だけでは判定できない★し、
   *   ★★当該日に予定が無い施設も対象に含める★★（ユーザー指示 2026-09-15 / 指示書AI §1-5）ので
   *   母集団も違う。★`CleanLogItem` には前回比も未実施連続日数も入っていない★。
   *
   * ★★数値カード（`summary`）には影響しない★★ — あちらは当日のログだけから作る
   *   （`lib/dashboard/aggregate.ts`。★変更しない★）。
   *   ★★ただし分類の絞り込み（`facility_ids`）は両方に同じ配列を渡すこと★★
   *   （指示書AK §7-2）— ★母集団がずれると、数値カードと要レスキューが
   *   別の集団の話になる（例外もテスト失敗も出ない）★。
   *
   * ★入力は `loadConsecutiveInputs()` の1本から取る★（★新しい全期間クエリを書かないこと★。
   *   D-109 の再発防止。要レスキューは4本目の呼び出し元になる）。
   */
  rescueRequired(query: RescueDbQuery): Promise<RescueRequiredItem[]>;

  /**
   * 清掃面積の集計材料（`GET /clean-area`。指示書AN ★段階3★ / §6-1）。
   *
   * ★★引数は必須★★（指示書AC C-4 項4。`cleanLogs()` と同じ作法）—
   *   ★全期間が要る呼び出し側は「範囲なし」を明示的に渡すこと★。
   *
   * ★★集計材料を返すだけ★★ — ★ロールアップ（`aggregateCleanArea()`。段階2 の純粋関数）は
   *   呼ばない★。理由は ★粒度（`group_by`）がリクエストの引数★で、
   *   ★同じ材料から group / customer / facility の3粒度とも作れる★ため
   *   （粒度をここで受け取ると、粒度ごとに同じ集計クエリを撃ち直す形に誘導してしまう）。
   *   ★判定・集計をデータ層に持ち込まない★という既存の分担（`filterXxx()` / `sortXxx()` が
   *   ルート側にあるのと同じ。指示書AC C-5）にも合う。
   *
   * ★★サマリテーブルの面積を読まないこと★★（指示書AN §1-2 / 落とし穴1）—
   *   `facility_summaries` / `customer_summaries` の `task_area` は
   *   ★実データで全行 1000 固定★で、★期間内の合計になっていない★（第9回 Q2-1 で照会中）。
   *   ★D-102（サマリは再計算しない）の対象は KPI 4指標で、面積は対象外と読む★（指示書AN §1-2）。
   *
   * ★★戻り値の `duplicated_timetable_count` を握りつぶさないこと★★（★番人★。§2-5 / §6-3）—
   *   ★ルートは必ず `reportCleanAreaAnomalies()`（`lib/clean-area/anomaly.ts`）に渡すこと★。
   *   ★渡し忘れても例外もテスト失敗も出ない★（数字が静かに大きくなるだけ）。
   */
  cleanArea(query: CleanAreaDbQuery): Promise<CleanAreaSourceResult>;

  /**
   * レポートの4メソッド。
   *
   * ★★引数は必須★★（指示書AC ★段階5★ / C-4 項4。`cleanLogs()` と同じ作法）—
   *   ★以前は引数を取らず**全期間・全件**を読んでいた★（4API 合計で ★実測 7,587行★。
   *   指示書AC B-3 の約6,000行に、段階1 で分けた連続日数の1本が加わった値）。
   *   ★★レポート画面は必ず1期間を選んでいる★★ので、★「範囲なし」を表す値は用意しない★。
   *
   * ★★確定期間と当期で読むものが違う★★（`CLAUDE.md` §4.7）:
   *   - ★確定期間★ … サマリテーブルを `where: { year, month, week }` で引く。
   *     ★★履歴から再計算しない（D-102 / `CLAUDE.md` 禁止事項1）★★
   *   - ★当期（進行中の週・月）★ … サマリに行が無いのが正常なので、詳細テーブルを
   *     ★`periodRange()` が返す範囲★で絞ってその場集計する
   *
   * 戻り値が `Xxx[]` ではなく `XxxRow[]`（期間キー付きの行型）なのは、
   * ★ルート側の `selectXxxRows()` / `selectKpiRow()`（＝絞り込み規則の「正」。C-5）が
   * そのキーで照合するため★です（`ReportPeriodKey` のコメント参照）。
   * ★★`filterXxx()` / `sortXxx()` と同じく、あちらを外さないこと★★。
   */
  /**
   * レポートKPI。★前期比のため1つ前の期間のサマリも読む（2行ぶんだけ）★
   *
   * ★★このメソッドだけ絞り込みの軸が2つある★★:
   *   - ★確定期間★ … `customer_summaries`（★施設ではなく**顧客**単位★）を読むので
   *     ★`customer_id`★ で絞る（指示書AK ★段階6★ / §2-1。`ReportKpiDbQuery`）
   *   - ★★当期（進行中の週・月）★★ … 詳細テーブルをその場集計するので
   *     ★`facility_ids`★ で絞る（指示書AL ★段階1★。★以前はここを絞っておらず、
   *     ★★確定期間は1社・当期は全社合算★★ という食い違いが起きていた。D-138 の残課題）
   *
   * ★★2つの軸は同じ顧客を指していなければならない★★ — ★`facility_ids` は
   *   `customer_id` と同じ顧客から解決したものを渡す契約★（ルート側で解決する。
   *   ★リクエストの `group_id` から直接引かないこと★。指示書AL §2-3）。
   *
   * ★★`customer_id` は必ず1社★★ — ★「顧客が決まらない」ときはデータ層を呼ばず 422★
   *   （`resolveTargetCustomer()` → `app/api/reports/kpi/route.ts`）。
   *   ★★静かに1社を選ぶことも、全顧客を合算することもしない★★（D-133 / 落とし穴6）。
   *
   * ★★複数顧客の行が混ざっていたら例外にする安全弁（D-5 / D-133）は**残してある**★★ —
   *   `customerSummariesByPeriodKey()`（`lib/db/report-mapping.ts`）。
   *   ★`where` で絞れているので通常は発火しない★が、★同一顧客でも同じ期間キーの重複は
   *   起こりうる（一意制約が無い）★ので検知は必要（期待値表 6-8）。
   */
  reportKpis(query: ReportKpiDbQuery): Promise<ReportKpiRow[]>;
  /**
   * 施設別パフォーマンス。並び替え・`status` フィルタ・`serial` フィルタ・limit はAPIルート側。
   *
   * ★★施設名の絞り込みを持つのはこのメソッドだけ★★（`d-watch-api.yaml` の
   *   `/reports/facility-performance` にだけ `facility_name` がある）—
   *   ★段階2 と同じく「解決済みの施設ID配列」で受ける★。
   *   ★分類（`group_id` / `customer_id`）との積集合はルート側で取ってから渡す★
   *   （`intersectFacilityIds()`。指示書AK §6-2）。
   */
  facilityPerformance(query: FacilityPerformanceDbQuery): Promise<FacilityPerformanceRow[]>;
  /**
   * 連続問題施設。2日以上の絞り込みと limit はAPIルート側。
   * ★`query.facility_ids` で施設を絞る（`null` = 絞らない / `[]` = 0件。指示書AK 段階4b）★
   */
  continuousProblems(query: ReportDbQuery): Promise<ContinuousProblemRow[]>;
  /**
   * エラー種別内訳。降順ソートはAPIルート側。
   * ★`query.facility_ids` で施設を絞る（指示書AK 段階4b §7-3。D-133 申し送り③の解消）★ —
   * ★★ただし fixtures 版は `[]` 以外を絞れない★★（理由は `fixtureSource.errorBreakdown()`）。
   */
  errorBreakdown(query: ReportDbQuery): Promise<ErrorBreakdownRow[]>;
}

// ---------------------------------------------------------------- エラー一覧の共通部品（指示書AW）

/**
 * 並べ替え済みのキーから、そのページの `error_id` を切り出す（fixtures 版・Prisma 版で共有。★規則を2箇所に書かない★）。
 * ★範囲外のページは `[]`★（404 にしない。表2 F11）。
 */
function errorReportPageIds(keys: readonly ErrorReportKey[], query: ErrorReportDbQuery): string[] {
  const start = (query.page - 1) * query.per_page;
  return keys.slice(start, start + query.per_page).map((key) => key.error_id);
}

/**
 * ページの `error_id` の順に、行と予定を引き当てて `ErrorReportItem` にする（fixtures 版・Prisma 版で共有）。
 *
 * ★予定の引き当ては `.toString()` をキーにした Map★（`bigint` は Map のキーにならない。指示書AW 4-9）。
 *   ★`timetables` は呼び出し側で「同じ id は本表が勝つ」を済ませたもの★（Prisma 版は `findTimetablesByChunks()`。D5）。
 * ★`rows` に無い `error_id` は飛ばす（例外にしない）★ — Prisma 版では1段目（キー）と2段目（行）の間に
 *   行が消えうる（ステージングのデータ入れ替え。第10回 N-6）。例外にすると1行のために一覧全体が500になる。
 *   `meta.total` は1段目の件数のまま（1件ずれうる。承知のうえ。指示書AW 4-15）。
 */
function errorReportItemsInPageOrder(
  pageIds: readonly string[],
  rows: readonly ErrorReportRowSource[],
  timetables: readonly ErrorTimetableRef[]
): ErrorReportItem[] {
  const rowById = new Map(rows.map((row) => [row.error_id, row]));
  const timetableById = new Map(timetables.map((t) => [t.id.toString(), t]));
  const items: ErrorReportItem[] = [];
  for (const id of pageIds) {
    const row = rowById.get(id);
    if (!row) continue; // 1段目と2段目の間に消えた行（上の docstring）
    const timetable =
      row.clean_timetable_id === null ? undefined : timetableById.get(row.clean_timetable_id.toString());
    items.push(errorReportItemFromRow(row, timetable));
  }
  return items;
}

// ---------------------------------------------------------------- fixtures

const fixtureSource: DataSource = {
  /**
   * ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★（指示書AC 段階2 D-5）。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★（禁止事項6）。
   * 絞り込みの中身は `filterFixtureCleanLogs()`（純粋関数）に置いてある。
   */
  async cleanLogs(query: CleanLogDbQuery) {
    return filterFixtureCleanLogs(cleanLogs, query);
  },
  /**
   * ★fixtures には予定の終了も清掃エリアも無い★（`lib/fixtures/clean-logs.ts`。指示書AV §2 D10）。
   *   ★終了＝開始★（`scheduled_at`。落ち着き時間は開始から数える）、
   *   ★清掃エリア＝ロボット★（`serial_number`。P1 は1エリア＝1ロボット。`CLAUDE.md` §8）とみなす。
   *   ★★fixtures のデータに終了時刻・清掃エリアを足さないこと★★（`docs/fixtures.md` の正を崩さない）。
   * ★`cleanLogs()` と同じ `filterFixtureCleanLogs()` を通す★ので、`facility_ids` の
   *   `null`（絞らない）/ `[]`（0件）の扱いは `cleanLogs()` と同じになる。
   */
  async cleanLogRunMeta(query: CleanLogDbQuery) {
    return filterFixtureCleanLogs(cleanLogs, query).map((row) => ({
      id: row.id,
      section_key: `fixture-robot:${row.serial_number}`,
      planned_end_at: row.scheduled_at,
    }));
  },
  /**
   * ★`deleted_at` の概念が fixtures には無いので全件★（Prisma 版も絞らない。罠7）。
   * ★`facilities`（施設マスタの fixtures）から id と名前だけを写す★ —
   * ★`cleanLogs` の `facility_name` から作らないこと★（清掃ログが1件も無い施設が
   * 解決できなくなり、Prisma 版と結果が食い違う）。
   */
  async facilityNameIndex() {
    return facilities.map((f) => ({ id: f.id, facility_name: f.facility_name }));
  },
  /**
   * ★Prisma 版と**同じ意味**の行を返す★（指示書AK 段階3 §6-3）。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★（禁止事項6）。
   *
   * ★`deleted_at` の概念が fixtures には無いので全件★（Prisma 版も絞らない。罠7）。
   *
   * ★所属は `Facility` 自身が持っている★（指示書AK ★段階5★）—
   *   段階1〜4 は `lib/fixtures/customers.ts` の `customerIdByFacilityId` という暫定マップを
   *   引いていたが、★段階5 で `Facility` に所属の列が載ったのでマップごと消した★。
   *   ★所属の割り当てを2箇所に持たないこと★（片方だけ直すと静かにズレる）。
   *
   * ★fixtures の `group_id` は既に API の形（`null` = 所属なし）★なので変換しない
   *   （`groupIdFromDb()` を二重に通さないこと。`lib/fixtures/facilities.ts`）。
   */
  async facilityGroupIndex() {
    return facilities.map(
      (f): FacilityGroupRef => ({
        id: f.id,
        customer_id: f.customer_id,
        group_id: f.group_id,
      })
    );
  },
  /**
   * ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★（指示書AC 段階3）。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★（禁止事項6）。
   */
  async areaCompletions(query: AreaCompletionDbQuery) {
    return filterFixtureAreaCompletions(areaCompletions, query);
  },
  /**
   * ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★（指示書AC 段階4 / 段階2 D-5）。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★（禁止事項6）。
   */
  async prechecks(query: PrecheckDbQuery) {
    // ★対応状況の上書き（`PATCH /prechecks/{id}/status`）を反映してから絞る★（指示書AT 段階4-3）
    return filterFixturePrechecks(prechecks.map(withFixturePrecheckOverride), query);
  },
  /**
   * ★Prisma 版と同じ判定★（指示書AT §3-3）— id が一致し、かつ `severity === "precheck"` の行だけ。
   * ★`launch_failed` の id は null（＝404）★。
   */
  async precheckItemById(id: string) {
    const row = prechecks.find((p) => p.id === id && p.severity === "precheck");
    return row ? withFixturePrecheckOverride(row) : null;
  },
  async updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch) {
    return applyFixturePrecheckPatch(prechecks, errorId, patch);
  },
  /**
   * ★Prisma 版と同じ関数を通す★（指示書AW D13）— キー → `filterSortErrorReportKeys()` → ページ →
   * `errorReportItemsInPageOrder()`。★`USE_FIXTURES` で件数・並びが変わらない★（指示書AC 禁止事項6）。
   * ★対応状況の上書き（`PATCH /errors/{error_id}/status`）を反映してから絞る★。
   */
  async errorReports(query: ErrorReportDbQuery) {
    const rows = errorReportRows.map(withFixtureErrorReportOverride);
    const keys = filterSortErrorReportKeys(rows.map(errorReportKeyFromRow), query);
    const items = errorReportItemsInPageOrder(
      errorReportPageIds(keys, query),
      rows,
      errorReportTimetables
    );
    return { items, total: keys.length };
  },
  /** ★Prisma 版と同じ判定★ — `error_id === id`（大小文字を区別する。D-4）の行があれば返す */
  async errorReportById(id: string) {
    const row = errorReportRows.find((r) => r.error_id === id);
    if (!row) return null;
    const [item] = errorReportItemsInPageOrder(
      [id],
      [withFixtureErrorReportOverride(row)],
      errorReportTimetables
    );
    return item ?? null;
  },
  async updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch) {
    return applyFixtureErrorReportPatch(errorReportRows, errorId, patch);
  },
  async dsDevices() {
    return dsDevices;
  },
  /**
   * ★Prisma 版と**同じ規則**で判定する★ — ★完全一致（`===`）★（D-4）。
   * ★★`toUpperCase()` 等で緩めないこと★★ — `USE_FIXTURES` の true/false で
   * 404/200 が変わってはいけない（禁止事項6）うえ、緩めると孤児行が残る。
   * ★fixtures には論理削除の概念が無い★ので `deleted_at` 相当の絞りは無い。
   */
  async dsDeviceExists(serialNumber: string) {
    return dsDevices.some((d) => d.serial_number === serialNumber);
  },
  async dsAlerts() {
    return dsAlerts;
  },
  /**
   * ★★`notImplemented` にしないこと★★（指示書AK 落とし穴9）—
   *   絞り込みUI（段階7）は `USE_FIXTURES=true` でも開く。ここで落とすと
   *   ★選択肢が取れず、全画面の絞り込みが出なくなる★。
   */
  async groups() {
    return groups;
  },
  /**
   * ★fixtures は `group_id` を API の形（`null` = 所属なし）で持つ★（`lib/fixtures/customers.ts`）
   *
   * ★`id` 昇順にして返す★（指示書AL 段階3 §4-4）— ★Prisma 版の `orderBy` と揃える★。
   *   ★規則の「正」は `sortCustomers()`★（`lib/masters/query.ts`）なので、ここで
   *   ★比較関数を書き写さない★。★`customers` を破壊的に並べ替えないこと★（`sortCustomers()`
   *   は複製してから並べ替える）。
   */
  async customers() {
    return sortCustomers(customers);
  },
  async facilities() {
    return facilities;
  },
  async schedules() {
    return schedules;
  },
  async inspections() {
    return inspections;
  },
  async inspectionDetail(id: number) {
    return inspectionDetails.find((i) => i.id === id) ?? null;
  },
  /**
   * ★Prisma 版と**同じ判定**を通す★（指示書AI 段階4 / `CLAUDE.md` §9.5）—
   *   ★★fixtures 用に別の判定を書かないこと★★。判定は `buildRescueRequired()` →
   *   `judgeRescue()` の1本で、こちらは ★入力の作り方が違うだけ★。
   *
   * ★★`notImplemented` にしないこと★★ — ダッシュボードは `USE_FIXTURES=true` でも開く。
   *   ★ここで落とすと、実装した本人が最初に踏む★。
   *
   * ★入力は fixtures の清掃ログ全件★（★当日で絞らない★。R-1 は3日、R-3 は前回の日、
   *   R-2 は遡りぶんを必要とする）。
   *   ★R-3 は1回ごとの完了率の単純平均で判定する★（`meanCleanRatio()`。2026-09-30 D-151・
   *   2026-10-02 D-155）ので、
   *   ★面積が無くても成立する★。旧: 「面積は `areaCompletions` から引く — 仕込みのある施設×日でしか
   *   R-3 は成立しない」（R-3 が面積加重だった当時の制約。D-151 で解消）。
   *
   * ★施設名は `facilities`（施設マスタ）から★ — `facilityNameIndex()` と同じ作り方。
   *   ★清掃ログの `facility_name` から作らないこと★（Prisma 版と食い違う）。
   */
  async rescueRequired(query: RescueDbQuery) {
    // ★件数の上限は渡さない★（指示書AJ 段階1。該当する施設を全部返す）
    // ★★分類の絞り込みは「入力の行」にかける★★（指示書AK 段階4b）—
    //   `buildRescueRequired()` は ★渡された行に出てくる施設だけ★ を判定するので、
    //   ★施設名の索引（第2引数）を絞っても母集団は減らない★（あちらは名前の引き当てだけ）。
    //   ★`null`（絞らない）と `[]`（0件ヒット）の区別は `filterRowsByFacilityIds()` が持つ★。
    return buildRescueRequired(
      filterRowsByFacilityIds(
        rescueInputsFromCleanLogs(cleanLogs, areaCompletions),
        query.facility_ids
      ),
      facilities.map((f) => ({ id: f.id, facility_name: f.facility_name })),
      query.date
    );
  },
  /**
   * 清掃面積（指示書AN 段階3 §6-4）。
   *
   * ★★`notImplemented` にしないこと★★（指示書AN 落とし穴11）— 清掃面積の画面は
   *   `USE_FIXTURES=true` でも開く。★ここで落とすと、実装した本人が最初に踏む★。
   *
   * ★★ロールアップは Prisma 版と同じ `aggregateCleanArea()` を通す★★（`CLAUDE.md` §9.5）—
   *   ★fixtures 用に別の集計を書かないこと★。★違うのは材料の作り方だけ★
   *   （こちらは `lib/fixtures/areas.ts` の面積行から作る。`cleanAreaStatsFromFixtureAreas()`）。
   *
   * ★施設マスタは `facilities`（施設マスタの fixtures）から★ — ★所属は `Facility` 自身が
   *   持っている★（指示書AK 段階5）。★`group_id` は既に API の形（`null` = 所属なし）★なので
   *   `groupIdFromDb()` を二重に通さない。★fixtures に論理削除の概念は無い★ので全件
   *   （Prisma 版も `deleted_at` で絞らない。★罠7★。理由は `lib/db/clean-area-source.ts`）。
   *
   * ★番人は常に 0★ — fixtures の面積行は ★1エリア × 1日で1行★（`lib/fixtures/areas.ts` の
   *   `build()` が施設×日のエリアの顔ぶれを検査している）ので、
   *   ★1つの清掃に履歴が2行ある状態を作れない★。★★0 を返すのは「異常が無い」という事実であって、
   *   未実装の意味ではない★★（ここを `-1` 等にすると番人が誤発火する）。
   */
  async cleanArea(query: CleanAreaDbQuery) {
    const stats = cleanAreaStatsFromFixtureAreas(areaCompletions, query);
    return {
      stats,
      facilities: facilities.map((f) => ({
        id: f.id,
        facility_name: f.facility_name,
        customer_id: f.customer_id,
        customer_name: f.customer_name,
        group_id: f.group_id,
        group_name: f.group_name,
      })),
      duplicated_timetable_count: 0,
      // ★完了率の材料にした fixtures の行数★（Prisma 版は取り寄せた行数。ログ用。D-156 論点2）
      rate_rows: stats.reduce((sum, s) => sum + s.executed_count, 0),
    };
  },
  /**
   * ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★（指示書AC 段階5 / 段階2 D-5）。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★（禁止事項6）。
   * 絞り込みの中身は `lib/db/fixture-reports.ts`（純粋関数）に置いてある。
   */
  /**
   * ★Prisma 版と**同じ意味**で `customer_id` で絞る★（指示書AK ★段階6★ / §2-1）—
   *   ★`USE_FIXTURES` の true/false で数字が変わってはいけない★（禁止事項6）。
   *   ★fixtures のKPI行は屋号1 のもの★（`REPORT_KPI_CUSTOMER_ID`）なので、
   *   ★他の屋号を選ぶと 200・空のKPI★になる（6-9 と同じ形）。
   */
  async reportKpis(query: ReportKpiDbQuery) {
    return filterFixtureReportKpis(reportKpis, query);
  },
  /** ★施設IDの絞り込み（`null` と `[]` は別物。罠9）も Prisma 版と同じ意味で効かせる★ */
  async facilityPerformance(query: FacilityPerformanceDbQuery) {
    return filterFixtureFacilityPerformance(facilityPerformance, query);
  },
  /** ★施設IDの絞り込み（`null` と `[]` は別物。罠9）も Prisma 版と同じ意味で効かせる★ */
  async continuousProblems(query: ReportDbQuery) {
    return filterFixtureRowsByFacility(continuousProblems, query);
  },
  /**
   * ★★fixtures だけは施設で絞りきれない（★既知の制約★。指示書AK 段階4b）★★
   *
   * `errorBreakdown` の fixtures は ★施設をまたいで足し上げたあとの集計値★
   * （`lib/fixtures/reports.ts` の `errorCounts`。`type` / `label` / `count` / `rate` しか無く、
   * ★どの施設のエラーかという情報を持っていない★）ので、
   * ★「屋号1の施設ぶんだけ」に割り戻すことができません★。
   *
   * | `facility_ids` | ここでの扱い |
   * |---|---|
   * | `null`（絞らない） | ★期間の行をそのまま★ |
   * | ★`[]`（0件ヒット）★ | ★★空配列★★（★施設が1つも当たらない＝エラーも0件★。ここは正確） |
   * | ★1件以上★ | ★★全社ぶんを返す（＝絞り込みが効かない）★★ |
   *
   * ★★`USE_FIXTURES=true` のときだけ件数が多く出ます★★（禁止事項6 の例外）。
   * ★直すには fixtures のエラー内訳を「施設ごとの件数」から組み立て直す必要があり、
   *   `rate`（全エラー件数に対する割合）の分母も作り直しになる★ため、
   *   ★実データの分布が分からないうちは捏造しない★（`CLAUDE.md` §7「推測で実装しない」）。
   *   ★Prisma 版（`facility_error_summaries` / `robot_error_reports`）は施設で絞れています★。
   */
  async errorBreakdown(query: ReportDbQuery) {
    // ★★`!== null` で分岐すること★★（罠1）— `?.length` だと `null` も `[]` も同じ枝になる
    if (query.facility_ids !== null && query.facility_ids.length === 0) return [];
    return filterFixtureReportRows(errorBreakdown, query);
  },
};

// ---------------------------------------------------------------- prisma

/**
 * 実DB実装。
 *
 * TeamWorksがDBを構築し `prisma db pull` でスキーマを取り込んだあとに実装します。
 * それまでは呼ばれたら分かるように明示的にエラーを投げます
 * （黙って空配列を返すと「データがない」のか「未実装」なのか切り分けられないため）。
 */
function notImplemented(name: string): never {
  throw new Error(
    `[db] ${name} のPrisma実装はまだありません。USE_FIXTURES=true で開発してください。`
  );
}

/**
 * ★`prismaSource.cleanLogs()` と `prismaSource.cleanLogRunMeta()` が共有する `where`★（指示書AV 段階3-3 / 4-8）。
 *
 * ★★2本で別々に書かないこと★★ — 片方だけ直すと、`pickLatestRunPerSection()` の id の突き合わせが
 *   静かにずれる（行に対応するメタが無ければ例外、メタが余れば無視されるだけ）。
 * ★中身は `cleanLogs()` にあった `where` をそのまま移したもの★（1文字も変えていない。コメントも一緒に移した）。
 */
function mainTimetableWhere(
  query: CleanLogDbQuery
): NonNullable<TimetableFindManyArgs["where"]> {
  // ★★`parseDate()` の返す「真の瞬間」をそのまま渡さないこと★★ —
  //   DB の DATETIME は日本時間の壁時計値なので9時間ずれる（指示書AC C-6）。
  //   ★変換は `dbDateRange()` の1箇所に閉じ込めてある（`toDbJst()` を通す）★。
  //   ★★ローカルDB（深夜0〜3時のデータしか無い）では付け忘れを検出できない★★ので、
  //   `lib/db/__tests__/date-range.test.ts` の合成データで固定している。
  const range = dbDateRange(query.date_from, query.date_to);

  return {
    // ★本清掃だけを引く（メモリ側の filter をやめて SQL に降ろした）★
    precheck: CLEAN_TIMETABLE_PRECHECK.main,
    // ★★単一ID の取得経路（指示書AC 段階3）★★ — `/clean-logs/{id}/advice` と
    //   `/clean-logs/{id}/areas` はここを通って1件だけ引く。
    //   ★`clean_timetables.id` は `BigInt` 列なので `BigInt()` に包む★ —
    //   ★Prisma の型は `number` も受け付ける（実測。`tsc` は通る）★が、
    //   ★★このファイルの他の箇所（`key()` / `parent_id` / `clean_timetable_id`）が
    //   すべて `bigint` を扱っている★★ので、DB に渡す形をそこに揃えておく。
    //   ★`query.id` は `parsePathId()` が安全整数に限っている★ので変換で桁が落ちない。
    //   ★★`!== null` で分岐すること★★（罠1と同じ理由。`query.id ? ... : {}` と
    //   書くと `0` が「指定なし＝全件」に化ける）。
    ...(query.id !== null ? { id: BigInt(query.id) } : {}),
    // ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★
    ...(range.gte || range.lt ? { start_at: range } : {}),
    // ★★必ず `!== null` で分岐すること★★（★罠1★）—
    //   ★`in: undefined` は**条件が消えて全件を返す**★（生成SQL が `WHERE 1=1`）。
    //   ★`in: []` なら `WHERE 1=0` で0件★＝「施設名で照合したが1件も当たらなかった」。
    //   ★★`query.facility_ids?.length ? ... : {}` と書かないこと★★ —
    //   ★「0件ヒット」が「全件表示」に化け、例外もテスト失敗も出ません★。
    ...(query.facility_ids !== null
      ? { facility_id: { in: query.facility_ids } }
      : {}),
  };
}

const prismaSource: DataSource = {
  /**
   * 清掃詳細ログ（F-02）＋ ダッシュボード（F-01）＋ アラート（F-03）が使う一覧。
   *
   * ★★`include` が使えないので手動 join する★★（指示書R 落とし穴1 / D-092 項3-3）
   *   `clean_statuses.clean_timetable_id`（`prisma/schema.prisma:445`）/
   *   `clean_task_reports.clean_timetable_id`（`:524`。★nullable★。指示書AQ 決定2/3 で
   *   `clean_status_histories` から差し替えた）/
   *   `robot_error_reports.clean_timetable_id`（`:322`）には ★FK も KEY も無い★。
   *   Prisma のリレーションが張られていないため `include` で辿れない。そこで
   *   別クエリで取得し、`clean_timetable_id` をキーにメモリ上で突き合わせている。
   *   ★D-094 で TeamWorks に FK 追加を再依頼中★。FK が付いたらこの手動 join は
   *   そのまま `include` に書き換えられる（そのときは下の Map 3本が消える）。
   *
   * ★1回の清掃は `clean_timetables` の3行で表される★（D-092 項1）:
   *   `precheck = 0` … 本清掃（`parent_id = 0`）… これが `CleanLogItem` 1件の骨格
   *   `precheck = 1` … 1回目の事前チェック（60分前）… `parent_id` = 本清掃の id
   *   `precheck = 2` … 2回目の事前チェック（30分前）… 同上
   *
   * ★`deleted_at` はこの4テーブルのどれにも無い★ので論理削除で絞らない。
   *   `clean_timetables.inactive` でも絞らない（第1歩・第2歩と同じ方針。指示書R 禁止事項4/5）。
   *   ★★`include: { facility: true }` にも `deleted_at` の条件が無い★★ —
   *   ★論理削除された施設のログも返している★（それが現状の挙動）。
   *   ★★だから施設名の解決も `deleted_at` で絞らない★★（罠7。
   *   `lib/db/facility-name-source.ts`）。
   *
   * ★★性能（指示書AC 段階2 で解決）★★: 以前は `DataSource` が引数を取らない契約だったため
   *   ★全期間・全件★を読んでいた（D-092 項3-3。保持2年で `clean_timetables` は約26万行）。
   *   ★★いまは日付範囲・施設ID・単一ID・本清掃（`precheck = 0`）・並び順を SQL に降ろしている★★
   *   （★単一ID（`query.id`）は段階3。`/clean-logs/{id}/advice` と `/{id}/areas` が使う★）。
   *   ★索引は最初から張られている★（`@@index([start_at])` / `@@index([facility_id])` /
   *   `@@index([parent_id])`。`prisma/schema.prisma`）ので、TeamWorks への依頼は要らない。
   *
   * ★★降ろせないもの★★（指示書AC C-1。★取得後にメモリで絞る★）:
   *   - ★判定（◎○△×）★ … 完了率（`runCompletionRate()`）から `judgeGrade()` で計算しており DB に列が無い
   *   - ★連続日数 / エスカレーション★ … 日をまたいで数える（D-026）
   *   - ★機種 / シリアルの部分一致★ … 索引が効かず利得が薄いので据え置き
   *   - ★施設名の部分一致★ … ★記号を無視する照合★なので LIKE では書けない。
   *     ★API ルート側で `resolveFacilityIdsByName()` が施設IDに解決してから渡す★
   *   - ★`take` / `skip`★ … 判定フィルタが SQL で絞れないため、降ろすと `meta.total` が
   *     食い違う（指示書AC D-3。★P1ではやらないという決定★）
   *
   * ★★連続日数だけは「表示期間そのもの」で絞らない★★（★★指示書AC C-2 の地雷★★）—
   *   期間で絞ると前日のデータが消えて ★連続が途切れ、L3 が L1 に化ける
   *   （例外もテスト失敗も出ない）★。★★`loadConsecutiveInputs()` は表示期間の手前を
   *   「保存された表示上限 +1日」ぶん遡って読む★★（段階6）。★遡り幅の計算を
   *   ここに書き下さないこと★ — 3経路で食い違う（D-109 の再発防止）。
   */
  async cleanLogs(query: CleanLogDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★予定は `clean_timetables` と `clean_timetable_histories` の両方から読む★★
    //   （指示書AU 段階3b。第11回 P-7。★同じ `args` を2表に渡す・同じ id は本表を採る★のは `findTimetables()`）
    const mainTimetables = await findTimetables(prisma, {
      where: mainTimetableWhere(query),
      // ★リレーションがある結合先は include してよい（落とし穴1 は FK が無い3テーブルの話）★
      include: {
        facility: true,
        section: {
          include: {
            robots: {
              where: { deleted_at: null },
              include: { device: true },
            },
          },
        },
      },
      // ★並び順も SQL に降ろす（`start_at` に索引がある）★。
      //   ★下の `.sort()` は残す★ — 規則の正はデータソースの戻り値であって
      //   `orderBy` はその写し（C-5 と同じ理由。`sort` は安定なので二度手間にならない）。
      //   ★★同着の第2キーは `id` の**昇順**★★ — ★指示書AC D-4 の例は `id: "desc"` だが、
      //   ★★下の `.sort()` が以前から `a.id - b.id`（昇順）で並べている★★ので、
      //   そちらに合わせる（★段階2 はレスポンスを1バイトも変えない★。指示書AC 禁止事項14）。
      //   ★★2表を合わせるので、DB の順序はもう写しにもならない★★（指示書AU 段階3b-2 の 3）—
      //   ★順序の正は下の `.sort()` だけ★。
      orderBy: [{ start_at: "desc" }, { id: "asc" }],
    });

    // BigInt はキーとして === で比較できないので文字列化して引く
    const key = (id: bigint) => id.toString();

    // ★引いた本清掃の id で、関連する行を引き直す★（★全件読みに戻さないこと★）。
    //   ★`in` の要素数には上限がある★（`DB_IN_CLAUSE_MAX_ITEMS`。指示書AC §E-5）—
    //   ★★超える場合は分割する（`facility_id` + 日付での絞り直しは採らない）★★。
    //   理由は `DB_IN_CLAUSE_MAX_ITEMS` の docstring（別経路で行が選ばれると
    //   ずれたときに行が静かに落ちる）。
    //   ★塊が0個（本清掃が0件）ならクエリを1本も撃たない★。
    const mainIds = mainTimetables.map((row) => row.id);
    const mainIdChunks = chunkForInClause(mainIds);

    // ★事前チェック行（`precheck !== 0`）は `parent_id` で別クエリに分ける★（指示書AC D-4）—
    //   ★★1回のクエリで `precheck` 混在のまま引いて、あとでメモリで分けるやり方に
    //   戻さないこと★★（それだと本清掃の絞り込みが SQL で効かない）。
    //   ★`parent_id` には索引がある★（`clean_timetables_parent_id_index`）。
    //   ★読む列は3つだけ★ — 下で使うのは `parent_id` / `precheck` / `id` のみ
    //   （事前チェック行の `facility` や `section` は使わないので `include` しない）。
    //   ★★2表から読み、全塊を合わせてから1回だけ id で重複を落とす★★（指示書AU 段階3b-3）—
    //   2表で `parent_id` が違う同じ id は別の塊に入りうるので、塊の中の重複落としだけでは足りない。
    const precheckRows = await findTimetablesByChunks(prisma, mainIdChunks, (ids) => ({
      where: { parent_id: { in: ids } },
      select: { id: true, parent_id: true, precheck: true },
    }));

    // ★FK が無いテーブルは別クエリ＋メモリ上の手動 join★
    //   ★清掃結果（`clean_task_reports`）は**本清掃の id**、エラー報告は**事前チェック行の id**
    //   でしか引かない★（下の組み立てがそれぞれその id でしか Map を引かないため。★絞りすぎていない★）
    //   ★★清掃結果の取得元は `clean_task_reports`★★（指示書AQ 決定2/3。2026-09-30。
    //   `clean_status_histories` は使わない）。
    //   ★`clean_timetable_id` が NULL のレポートは `in` に一致しない★ので、ここで引けない
    //   （＝その本清掃は「レポートの行が無い」＝出発失敗として出る。指示書AQ 罠1 / 決定5）
    const precheckIds = precheckRows.map((row) => row.id);
    const [reportChunks, errorReportChunks] = await Promise.all([
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.cleanTaskReport.findMany({
            where: { clean_timetable_id: { in: ids } },
          })
        )
      ),
      Promise.all(
        chunkForInClause(precheckIds).map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            // ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
            select: ROBOT_ERROR_STATUS_SELECT,
          })
        )
      ),
    ]);
    const taskReports = reportChunks.flat();
    const errorReports = errorReportChunks.flat();

    // ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★（D-107 / 指示書AQ §1-4）—
    //   `prechecks()` と同じ関数を使う。★順序キー（`ended_at ?? started_at`）を決めるのもあちら★。
    //   ★ここで `orderBy` を足したり別の順序キーを書かないこと★（`/detail-log` と `/alerts` で
    //   選ばれる行が食い違う。D-103 が禁じた状態）
    const reportByTimetableId = latestReportByTimetableId(taskReports);

    const errorsByTimetableId = new Map<string, RobotErrorLike[]>();
    for (const report of errorReports) {
      // ★nullable（清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`）★
      if (report.clean_timetable_id === null) continue;
      const k = key(report.clean_timetable_id);
      const list = errorsByTimetableId.get(k);
      const entry: RobotErrorLike = {
        error_type: report.error_type,
        errored_at: report.errored_at,
      };
      if (list) list.push(entry);
      else errorsByTimetableId.set(k, [entry]);
    }

    // parent_id（本清掃の id）→ precheck コード → 事前チェック行
    // ★`precheckRows` は `parent_id: { in: 本清掃の id }` で引いた行★（＝本清掃行は入らない）。
    //   ★それでも `precheck === main` を弾く行は残す★ — `parent_id` が 0 の本清掃行が
    //   混ざる余地（`parent_id = 0` を指す行）を潰しておくため。★条件を緩めないこと★
    const precheckByParent = new Map<string, Map<number, (typeof precheckRows)[number]>>();
    for (const row of precheckRows) {
      if (row.precheck === CLEAN_TIMETABLE_PRECHECK.main) continue;
      const k = key(row.parent_id);
      const byCode = precheckByParent.get(k) ?? new Map();
      byCode.set(row.precheck, row);
      precheckByParent.set(k, byCode);
    }

    // ★予定時刻がまだ来ていない本清掃は**行ごと結果に含めない**★（`prechecks()` と同じ判定）
    //   清掃詳細ログ（F-02）は「実施済みの記録」であり、未実施の未来予定を
    //   「×・未実施」として出すと `computeConsecutiveProblemDays()` の連続日数まで水増しされる。
    //   ★★`isFutureSchedule()` は D-107（設計3-C）の下では**必須**。外さないこと★★ —
    //   未来の予定にはまだレポートの行が無く、`notExecutedFromReport()` は
    //   「レポートの行が無い」を出発失敗と判定するので、★外すと未来の清掃が全部「出発失敗」になる★。
    //   ★★逆に「レポートがあること」をこのフィルタに足さないこと★★（D-107 設計3-C）—
    //   ★レポートの行が無い過去の本清掃も**返す**（出発失敗として画面に出す）★。
    //   足すと、データ収集処理の障害で結果が書かれなかった清掃が
    //   ★件数がそっと減るだけで画面から消える★（fail-silent）。
    //   ★`now` は1回だけ作って全行で使い回す★（行ごとに作ると境界の行の扱いがぶれる）
    //   ★★`precheck === main` の絞り込みは SQL に降ろした★★（指示書AC 段階2 D-4）ので、
    //   ここに残っているのは ★未来の予定を外す条件だけ★。
    //   ★★SQL 側の `where` から `precheck` を外さないこと★★ — 外すと事前チェック行が
    //   一覧の行として並ぶ（`section.robots[0]` が同じなので例外にもならない）。
    const now = new Date();
    const mainRows = mainTimetables.filter(
      (row) => !isFutureSchedule(fromDbJst(row.start_at), now)
    );

    // 先に骨格（judge に渡す値まで）を作る。連続日数は全行が揃わないと数えられないため2段構え
    const drafts = mainRows.map((row) => {
      // ★P1は1清掃エリア=1ロボットの前提（CLAUDE.md §8）★。0台なら静かに空文字を返さず例外
      //   （`schedules()` の :301-306 と同じ作法）
      const robot = row.section.robots[0];
      if (!robot) {
        throw new Error(
          `[db] cleanLogs: clean_timetables(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
        );
      }

      const precheckRows = precheckByParent.get(key(row.id));
      // ★status_1h / status_30m の列の割り当ては「分数の一致」ではなく「順番」★（D-178。ユーザー判断 2026-10-16）—
      //   事前チェックの分数は設定で変わりうる（例: 60/10）ので、早い方を1回目・遅い方を2回目にする。
      //   `precheckRows`（`precheck` の生値 → 行）を「`precheck` の生値 → エラー配列」に
      //   詰め替えてから、規則を1箇所に持つ `statusColumnsFromPrecheckRows()` に渡す。
      //   ★予定行があればエラーが無くても空配列でキーを入れる★ — ★キーが無い回は null（予定が無い）になる★
      //   （ユーザー指示 2026-10-07。D-171）。★`?? []` を外したりエラーの有無で入れ分けたりしないこと★
      //   （入れ分けると、異常なしの回が消えて列がずれる）
      const errorsByPrecheck = new Map<number, RobotErrorLike[]>();
      if (precheckRows) {
        for (const [precheck, precheckRow] of precheckRows) {
          errorsByPrecheck.set(
            precheck,
            errorsByTimetableId.get(key(precheckRow.id)) ?? []
          );
        }
      }
      const precheckColumns = statusColumnsFromPrecheckRows(errorsByPrecheck);
      // ★列は2つしか無いので、3つ以上の事前チェックは開始に近い2つだけ残る★（1清掃2回で固定なので通常は起きない）。
      //   ★黙って捨てない★ — 捨てた分数をこの行の id と一緒に残す（原因の断定は書かない）。
      //   ★データ層はルートを知らないので `route` / `method` は載せない★（不明な情報は書かない）
      if (precheckColumns.dropped.length > 0) {
        logEvent("warn", {
          event: "data.anomaly",
          metric: "precheck_rounds_per_main_run",
          expected: PRECHECK_COLUMN_COUNT,
          actual: precheckColumns.dropped.length + PRECHECK_COLUMN_COUNT,
          clean_timetable_id: String(row.id),
          dropped_precheck_minutes: precheckColumns.dropped,
        });
      }

      // ★「出発したか」は DB の値で決める。「予定＋猶予30分」を当方で計算しない★
      //   （D-091 項1 / 指示書R 禁止事項3。LAUNCH_GRACE_MINUTES はここから読まない）
      //   ★★出発失敗（未実施）＝ 本清掃に対応する `clean_task_reports` の行が無い★★
      //   （指示書AQ §1-1。旧取得元の `clean_error === 1` の分岐は、列が無いので消えた）。
      //   ★判定そのものは `notExecutedFromReport()`（clean-log-mapping.ts）に切り出してある★ —
      //   `prechecks()` の `launch_failed` が同じ判定を共有するため（D-103 項1）。
      //   ★条件を**ここに書かないこと**★（書くと規則が2箇所に散る）。
      //   ★`status` を未実施の判定に使わないこと★（値の意味は第10回 N-2 で受領済みだが、
      //   判定には使わない。D-155 項2。ユーザー決定 2026-10-02。指示書AQ §1-2 / D-077）
      const report = reportByTimetableId.get(key(row.id));

      return {
        id: Number(row.id),
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        model_name: formatModelName(robot.device.maker_id, robot.device.model_name),
        serial_number: robot.serial_number,
        // ★DBのDATETIMEには日本時間の壁時計値が入っている（TeamWorks第8回A2。指示書Y）。
        //   Prismaから出たDateは必ずjstIsoFromDb()を通す（toJstIso()に直接渡さない）★
        scheduled_at: jstIsoFromDb(row.start_at),
        status_1h: precheckColumns.status[0],
        status_1h_minutes: precheckColumns.minutes[0],
        status_30m: precheckColumns.status[1],
        status_30m_minutes: precheckColumns.minutes[1],
        departed: !notExecutedFromReport(report),
        // ★`clean_task_reports.status` の生値をそのまま運ぶ（変換しない。指示書AQ §1-2 / D-077）★。
        //   ★レポートの行が無ければ null★。★旧 `clean_error` から改名した（指示書AQ §5-2）★ —
        //   ★中身が別の列になったので、旧コード表（0:正常 / 1:未実施 …）で読まれないようにするため★。
        //   ★★`??` を `||` にしないこと★★ — `status` の既定値は `0`（`prisma/schema.prisma`）で、
        //   `||` だと ★0 が null に化ける★（`tsc` も `lint` も捕まえない）
        task_status: report?.status ?? null,
        // ★結果待ち（ユーザー判断 2026-10-07。D-173）★ — 判定は `isResultPending()` の1本。
        //   ★`departed` / `task_status` / `completion_rate` / `grade` は結果待ちでも変えない★（別の旗で表す）。
        //   ★`end_at` は `start_at` と同じ JST の壁時計値なので `fromDbJst()` を通す★（9時間を足さない・引かない）。
        //   ★`now` は上で作った同じインスタンス★（`mainRows` の未来の予定の除外と基準時刻を揃える）
        result_pending: isResultPending({
          startAt: fromDbJst(row.start_at),
          endAt: fromDbJst(row.end_at),
          now,
          hasReport: !notExecutedFromReport(report),
        }),
        // ★丸めない生値（CLAUDE.md §4.3）。未実施（レポートなし）は null★
        //   ★★`report ? report.clean_ratio : null` の形に戻さないこと★★ — 判定を
        //   `completionRateFromReport()` の外に書くと、`departed` と規則が2箇所に散る
        //   （★`departed === false` ⟺ `completion_rate === null`★ の不変条件。P-5=B で `task_area = 0` が
        //   0 になり両向きに成り立つ。D-107 落とし穴1 / D-155 / D-156 / D-158）
        completion_rate: completionRateFromReport(report),
      };
    });

    // ★連続日数は施設ごとの全期間走査（D-026 / D-090 項15 / D-109）★
    //   ★★入力は `loadConsecutiveInputs()`（`lib/db/consecutive-source.ts`）の1本に集約してある★★
    //   （`facilityPerformance()` / `continuousProblems()` と同じ1本。D-109 の再発防止）。
    //   ★★`drafts` から作らないこと★★ — ★段階2 で `drafts` は期間・施設で絞られた★ので、
    //   そこから作ると期間の外にある前日のデータが消えて連続が途切れる
    //   （指示書AC C-2。★L3 が L1 に化ける fail-silent★）。
    //   ★★段階6: `loadConsecutiveInputs()` には「★表示したい期間そのもの★」を渡すこと★★ —
    //   ★遡りぶん（保存された表示上限 +1日）は同関数が足す★（★ここで足さないこと★。
    //   3経路が別々の遡り幅を持つと `/detail-log` と `/reports` で数字が食い違う。D-109）。
    //   ★★`query.date_from` が `null`（単一ID取得など）なら下限なし＝全期間のまま★★。
    //   ★`now` は上で作った同じインスタンスを渡す★（`mainRows` の絞り込みと基準時刻を揃える）
    const consecutiveRange: ConsecutiveDbRange = {
      date_from: query.date_from,
      date_to: query.date_to,
    };
    const consecutiveById = computeConsecutiveProblemDays(
      await loadConsecutiveInputs(prisma, now, consecutiveRange)
    );

    return drafts
      .map((draft): CleanLogItem => {
        const consecutive = consecutiveById.get(draft.id) ?? 0;
        return {
          ...draft,
          // ★judge は prismaSource の中で呼ぶ（D-090 項16）。fixtures 版も同じ関数を通している★
          grade: judgeGrade(draft.completion_rate),
          consecutive_problem_days: consecutive,
          escalation_level: judgeEscalationLevel(consecutive),
        };
      })
      // ★fixtures と同じ「新しい順」で返す（lib/fixtures/clean-logs.ts:227-233）★
      //   APIルート側は並べ替えないので、順序はデータソースの責任
      .sort((a, b) => {
        if (a.scheduled_at !== b.scheduled_at) {
          return a.scheduled_at < b.scheduled_at ? 1 : -1;
        }
        return a.id - b.id;
      });
  },
  /**
   * ★清掃ログの id → { 清掃エリア, 予定の終了 }★（ダッシュボードの直近モード専用。D-159 / 指示書AV 段階3-3）。
   *
   * ★★`where` は `cleanLogs()` と同じ `mainTimetableWhere()`★★（4-8）— 別に書くと、片方だけ直したときに
   *   `pickLatestRunPerSection()` の突き合わせが静かにずれる。
   * ★`findTimetables()` で2表から読む★（同じ id は本表が勝つ。指示書AU 段階3b）。
   *   ★`prisma.cleanTimetable.findMany` を直接呼ばないこと★ — 履歴側だけにある予定のメタが欠け、E1 の例外になる。
   * ★`end_at` は `start_at` と同じ DATETIME の JST 壁時計値★なので `jstIsoFromDb()` を通す
   *   （★9時間を足さない・引かない★。`CLAUDE.md` §3 / 指示書AV 4-3）。
   * ★`cleanLogs()` が外す未来の予定（`isFutureSchedule()`）もここには載る★ — 呼び出し側は無視する（表2 E6）。
   */
  async cleanLogRunMeta(query: CleanLogDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    const rows = await findTimetables(prisma, {
      where: mainTimetableWhere(query),
      // ★`id: true` は必須★（2表の重複を id で除くため。`TimetableFindManyArgs` が型で強制している）
      select: { id: true, section_id: true, end_at: true },
    });
    return rows.map((row) => ({
      id: Number(row.id),
      section_key: `section:${row.section_id.toString()}`,
      planned_end_at: jstIsoFromDb(row.end_at),
    }));
  },
  /**
   * ★施設名 → 施設ID の解決★用の最小限の一覧（指示書AC 段階2 D-6）。
   *
   * ★★`facilities()` を流用しないこと★★ — あちらは `where: { deleted_at: null }` を付けている。
   *   ★論理削除された施設のログは `cleanLogs()` にいま出ている★ので、ここで絞ると
   *   ★その施設名で検索したときにログが静かに消える★（★罠7★）。
   *   ★読み取り（`where` を付けない理由を含む）は `loadFacilityNameIndex()` に閉じ込めてある★。
   */
  async facilityNameIndex() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    return loadFacilityNameIndex(prisma);
  },
  /**
   * ★分類（ホールディングス／屋号）→ 施設ID の解決★用の最小限の一覧（指示書AK 段階3 §6-3）。
   *
   * ★★`facilities()` を流用しないこと★★ — あちらは `where: { deleted_at: null }` を付けている。
   *   ★論理削除された施設のログは `cleanLogs()` にいま出ている★ので、ここで絞ると
   *   ★屋号やホールディングスで絞った瞬間にそのログが静かに消える★（★罠7★）。
   *   ★読み取り（`where` を付けない理由・`group_id` の 0 → null 変換を含む）は
   *   `loadFacilityGroupIndex()` に閉じ込めてある★。
   */
  async facilityGroupIndex() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    return loadFacilityGroupIndex(prisma);
  },
  /**
   * エリア別完了率（F-02 のエリアモーダル。GET /clean-logs/{id}/areas）。
   *
   * ★出所★: TeamWorks 第8回 A9-2（2026-09-11 受領）—
   *   「エリア毎の清掃完了率は取得可能。正確に言えば清掃ロボット毎の清掃率だが、
   *   清掃ロボットはエリアに紐づいている」。つまり ★1エリア（=1ロボット）につき1つの完了率★
   *   であり、★1回の清掃の中を売り場・通路・レジ前に区切った区域別の内訳ではない★
   *   （確認事項#10 の本来の問いは未解決のまま。第9回で聞き直し中）。
   *
   * ★★データ元は `clean_task_reports`★★（指示書AQ 決定2/3。2026-09-30。
   *   ★旧: `clean_status_histories`。指示書Z 決定6 の「`clean_task_reports` は読まない」は
   *   ★取得元の一本化で失効した★）。
   *   ★`cleanLogs()` が読んでいるのと**同じテーブル・同じ規則**で完了率を作る★という理由は
   *   変わっていない — 別テーブルから作ると、★同じ画面（一覧の行 → そこから開くモーダル）に
   *   定義の違う2つの完了率が並ぶ★。★両方を同時に `clean_task_reports` へ切り替えた★。
   *
   * ★★行と全体の完了率★★:
   *   - ★各行の `completion_rate` は `completionRateFromReport()`＝1回ごとの完了率（`runCompletionRate()`）★。
   *     ★`cleanLogs()` と同じ関数を通す★（別の書き方をすると `cleanLogs()` の完了率と食い違う）。
   *   - ★全体率は行の `completion_rate` の単純平均（清掃1回＝1票。未実施の行は除く）★ —
   *     ★計算しているのは APIルート側の `buildAreaCompletions()`（`lib/clean-logs/areas.ts`）★。
   *     ここでは行を返すだけで、全体率は出さない。
   *   - ★この層が運んでいる面積（`clean_area` / `task_area`）は表示用★。全体率の分子・分母ではない
   *     （行の完了率は面積から作るが、それは `completionRateFromReport()` の中で1回ずつ行う）
   *     （`CLAUDE.md` §4.2。★以前は全体率だけ面積ベースの「意図した非対称」だった（指示書Z 決定7）が、
   *     2026-09-30 に単純平均へ揃えた。D-151 / 指示書AQ §5-5★）。
   *
   * ★`cleanLogs()` と同じ作法で書いてある★（新しい読み方を発明しない）:
   *   `section` はリレーションがあるので `include`、`clean_task_reports` は
   *   ★FK も KEY も無いので別クエリ＋手動 join★、レポートの1件への絞り込みは
   *   ★`latestReportByTimetableId()`（同じ関数）★、未来の予定は ★`isFutureSchedule()`★ で除外。
   *
   * ★★`cleanLogs()` のような「ロボット0台なら例外」の処理は入れない★★ —
   *   この API は ★ロボットではなくエリアを列挙する★ので、`section_name` があれば足りる
   *   （`AreaCompletionRow` は `serial_number` を持たない）。★ロボット0台の施設を落とさないこと★。
   *
   * ★★性能（指示書AC 段階3 で解決）★★: 以前は引数を取らず ★全期間・全件★ を読んでいた
   *   （実測 1,582行。しかもエリアモーダルは ★1施設の1日ぶんしか使わない★）。
   *   ★★いまは施設ID・日付1日ぶん・本清掃（`precheck = 0`）を SQL に降ろしている★★。
   *   ★索引は最初から張られている★（`@@index([facility_id])` / `@@index([start_at])`）。
   *
   * ★★連続日数は関係しない★★ — この API は連続日数を返さないので、
   *   `cleanLogs()` のような「全期間を読み続ける1本」（C-2 / C-3）は要らない。
   */
  async areaCompletions(query: AreaCompletionDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★`query.date` の生の文字列から `Date` を作らないこと★★ — DB の DATETIME は
    //   日本時間の壁時計値なので9時間ずれる（指示書AC C-6）。★変換は `dbDateRange()` の1箇所★。
    //   ★1日ぶんなので `from` と `to` に同じ日を渡す★（`to` はその日を含む＝翌日 00:00 未満）。
    const range = dbDateRange(query.date, query.date);

    // ★予定は2表から読む（指示書AU 段階3b。`findTimetables()`）★
    const timetables = await findTimetables(prisma, {
      where: {
        // ★事前チェック行を混ぜない（本清掃だけ）★ — ★メモリ側の filter をやめて SQL に降ろした★
        //   ★★SQL 側の `where` から `precheck` を外さないこと★★（`cleanLogs()` と同じ。
        //   外すと事前チェック行がエリアとして並ぶ。`section` が同じなので例外にもならない）
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        // ★★`!== null` で分岐すること★★（罠1と同じ理由）。
        //   ★`clean_timetables.facility_id` は `BigInt` 列なので包む★
        //   （理由は `cleanLogs()` の `query.id` と同じ）
        ...(query.facility_id !== null
          ? { facility_id: BigInt(query.facility_id) }
          : {}),
        ...(range.gte || range.lt ? { start_at: range } : {}),
      },
      // ★リレーションがある結合先は include してよい（FK が無いのはレポート側）★
      include: { section: true },
    });
    // ★FK が無いテーブルは別クエリ＋メモリ上の手動 join★（`cleanLogs()` と同じ）—
    //   ★★全件読みに戻さないこと★★。★引いた本清掃の id で引き直す★（指示書AC D-4 / §E-5。
    //   `in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある）。
    //   ★塊が0個（本清掃が0件）ならクエリを1本も撃たない★。
    //   ★取得元は `clean_task_reports`★（指示書AQ 決定2/3。`cleanLogs()` と同時に切り替えた）
    const reportChunks = await Promise.all(
      chunkForInClause(timetables.map((row) => row.id)).map((ids) =>
        prisma.cleanTaskReport.findMany({
          where: { clean_timetable_id: { in: ids } },
        })
      )
    );
    // ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★（D-107 / 指示書AQ §1-4）
    const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());

    // ★`now` は1回だけ作って全行で使い回す★（行ごとに作ると境界の行の扱いがぶれる）
    const now = new Date();
    return timetables
      .filter(
        (row) =>
          // ★★外すと未来の予定が「未実施」として並ぶ★★（D-107 / D-109。`cleanLogs()` と同じ理由）
          //   ★★`precheck === main` の絞り込みは SQL に降ろした★★ので、
          //   ここに残っているのは ★未来の予定を外す条件だけ★。
          !isFutureSchedule(fromDbJst(row.start_at), now)
      )
      .map((row): AreaCompletionRow => {
        const report = reportByTimetableId.get(row.id.toString());
        return {
          clean_log_id: Number(row.id),
          facility_id: Number(row.facility_id),
          // ★DBのDATETIMEは日本時間の壁時計値（指示書Y / D-115）★。
          //   ★★`row.start_at` の生の `Date` で日付を作らないこと★★（9時間ずれる）
          date: jstIsoFromDb(row.start_at).slice(0, 10),
          section_id: Number(row.section_id),
          // ★`sections.section_name` の生値。当方で整形しない★
          section_name: row.section.section_name,
          // ★未実施は `null`（`0`＝「0%で実施した」と混ぜない）★（D-107 落とし穴1）
          completion_rate: completionRateFromReport(report),
          // ★DBの値をそのまま足す（未実施だからといって当方で 0 に潰さない）★
          //   ★`aggregateCleanStatsByFacility()` と**同じ扱い**★（指示書Z 決定13）。
          //   ★★`??` を `||` にしないこと★★ — 面積は `0` が正当な値
          //   ★面積は残す（指示書AQ §1-3）。★全体率の分子・分母には使わない★
          //   （行の完了率は上の `completionRateFromReport()` が面積から1回ずつ出す。D-155）
          clean_area: report?.clean_area ?? 0,
          task_area: report?.task_area ?? 0,
        };
      });
  },
  /**
   * アラート（事前チェック）一覧（F-03）。
   *
   * ★★2種類の行を1つの配列にまとめて返す★★:
   *
   *   1. `severity: "precheck"`     … ★事前チェック行に紐づく `robot_error_reports` の行★
   *   2. `severity: "launch_failed"` … ★本清掃が実施されなかったこと★
   *      （★対応する `clean_task_reports` の行が無い★。D-103 / D-107 / 指示書AQ §1-1。
   *      ★旧取得元 `clean_status_histories` の `clean_error === 1` の分岐は、列が無いので消えた★）
   *
   * ★2番目がエラー行由来でない点に注意★ — `launch_failed` は
   *   `cleanLogs()` の `departed === false` と ★同じ出来事★ なので、
   *   ★同じ判定（`notExecutedFromReport()`）を共有する★（D-103。指示書S の B案）。
   *   ★「本清掃行を指すエラー行」を `launch_failed` に読み替えてはいけない★ —
   *   それをすると「エラー行があること」が出発失敗の条件になり、`departed` と別定義になる。
   *
   * ★★`include` が使えないので手動 join する★★（`cleanLogs()` と同じ。指示書S 落とし穴1）
   *   `robot_error_reports.clean_timetable_id`（`prisma/schema.prisma:322`）には
   *   ★FK も KEY も無く（しかも `BigInt?`）★、Prisma のリレーションが張られていないため
   *   `include` で `clean_timetables` を辿れない。そこで別クエリで取得し、
   *   `clean_timetables.id` をキーにメモリ上で突き合わせている。
   *   ★D-094 で TeamWorks に FK 追加を再依頼中★。FK が付いたら `include` に書き換えられる。
   *
   * ★`robot_error_reports` に `deleted_at` は無い★（`prisma/schema.prisma:309-353`）ので
   *   論理削除で絞らない。`clean_timetables.inactive` でも絞らない（第1〜3歩と同じ方針）。
   *
   * ★フィルタ・ソート・ページングはここでやらない★（`DataSource` の設計方針）—
   *   `app/api/prechecks/route.ts` が `sortPrechecks()`（D-052）等で行う。
   *
   * ★★性能（指示書AC 段階4 で解決）★★: 以前は引数を取らず ★全期間・全件★ を読んでいた
   *   （`robot_error_reports` 49 + `clean_timetables` 1,044 + `clean_status_histories` 538
   *   ＝ ★実測 1,631行★。指示書AC B-3）。★★いまは日付範囲・施設ID・本清掃
   *   （`precheck = 0`）を SQL に降ろしている★★。★索引は最初から張られている★
   *   （`@@index([start_at])` / `@@index([facility_id])` / `@@index([parent_id])`）。
   *
   * ★★2種類の行を**同じ期間**で絞る★★（指示書AC 段階4。★片方だけ絞ると静かに壊れる★）:
   *   ★どちらの行も期間の基準は「本清掃行の `start_at`」★（`PrecheckDbQuery` の型コメント）
   *   なので、★★本清掃行を1回絞れば両方に効く★★。引く順は
   *   ★本清掃行（期間・施設で絞る）→ その子の事前チェック行（`parent_id`）→
   *   そのエラー行（`clean_timetable_id`）★。
   *   ★★エラー行を `errored_at` で絞らないこと★★ — `scheduled_at` に入るのは
   *   ★親の本清掃行の `start_at`★（指示書S 3-2 項5）なので、
   *   `filterPrechecksByDate()`（`scheduled_at` で絞る）と食い違う。
   *
   * ★★連続日数は関係しない★★ — この API は連続日数を返さないので、
   *   `cleanLogs()` のような「全期間を読み続ける1本」（C-2 / C-3）は要らない。
   */
  async prechecks(query: PrecheckDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★`query.date_from` の生の文字列から `Date` を作らないこと★★ — DB の DATETIME は
    //   日本時間の壁時計値なので9時間ずれる（指示書AC C-6）。★変換は `dbDateRange()` の1箇所★。
    //   ★★ローカルDB（深夜0〜3時のデータしか無い）では付け忘れを検出できない★★ので、
    //   合成データのテストで固定している（`app/api/prechecks/__tests__/route.test.ts`）。
    const range = dbDateRange(query.date_from, query.date_to);

    // ★★本清掃行だけを、期間・施設で絞って引く★★（`cleanLogs()` と同じ作法）—
    //   ★★SQL 側の `where` から `precheck` を外さないこと★★。外すと事前チェック行が
    //   下の `launch_failed` のループに入り、★1回の清掃につき3件の出発失敗★ が並ぶ
    //   （`section.robots[0]` が同じなので例外にもならない）。
    //   ★予定は2表から読む（指示書AU 段階3b。`findTimetables()`）★
    const timetables = await findTimetables(prisma, {
      where: {
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        // ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★
        ...(range.gte || range.lt ? { start_at: range } : {}),
        // ★★`!== null` で分岐すること★★（★罠1★）—
        //   ★`in: undefined` は**条件が消えて全件を返す**★（生成SQL が `WHERE 1=1`）。
        //   ★`in: []` なら `WHERE 1=0` で0件★＝「分類で絞ったが1件も当たらなかった」。
        //   ★★`query.facility_ids?.length ? ... : {}` と書かないこと★★
        //   （`cleanLogs()` と同じ理由。0件ヒットが全件表示に化ける）。
        //   ★`clean_timetables.facility_id` は `BigInt` 列なので1件ずつ包む★
        ...(query.facility_ids !== null
          ? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
          : {}),
      },
      include: {
        facility: true,
        // 出発失敗の行の `serial_number` に必要（エラー行が無いので報告側から取れない）
        section: { include: { robots: { where: { deleted_at: null } } } },
      },
    });

    // BigInt はキーとして === で比較できないので文字列化して引く
    const key = (id: bigint) => id.toString();

    // ★★`timetableById` は**本清掃行だけ**を持つ★★（以前は全行を持っていた）—
    //   下の `mainRow` の引き当てはこの Map で行う。★期間の外の本清掃行は入らない★ので、
    //   ★親が期間外のエラー行は結果に出ない★（＝2種類の行が同じ期間で絞られる）。
    const timetableById = new Map(timetables.map((row) => [key(row.id), row]));

    // ★引いた本清掃の id で、関連する行を引き直す★（★全件読みに戻さないこと★）。
    //   ★`in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある★（§E-5）。
    //   ★塊が0個（本清掃が0件）ならクエリを1本も撃たない★。
    const mainIdChunks = chunkForInClause(timetables.map((row) => row.id));

    // ★事前チェック行は `parent_id` で別クエリに分けて引く★（`cleanLogs()` と同じ。指示書AC D-4）—
    //   ★★1回のクエリで `precheck` 混在のまま引いて、あとでメモリで分けるやり方に
    //   戻さないこと★★（それだと期間の絞り込みが SQL で効かない）。
    //   ★読む列は3つだけ★ — 下で使うのは `id` / `parent_id` / `precheck` のみ。
    //   ★★2表から読み、全塊を合わせてから1回だけ id で重複を落とす★★（`cleanLogs()` と同じ。指示書AU 段階3b-3）
    const precheckRows = await findTimetablesByChunks(prisma, mainIdChunks, (ids) => ({
      where: { parent_id: { in: ids } },
      select: { id: true, parent_id: true, precheck: true },
    }));
    const precheckRowById = new Map(precheckRows.map((row) => [key(row.id), row]));

    const [errorReportChunks, reportChunks] = await Promise.all([
      // ★エラー行は**事前チェック行の id**でしか引かない★ —
      //   ★本清掃行を指すエラー行は `launch_failed` に読み替えない★（D-104）ので要らない。
      //   ★facility は FK があるので include してよい（落とし穴1 は FK が無い列の話）★
      Promise.all(
        chunkForInClause(precheckRows.map((row) => row.id)).map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            // ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
            select: ROBOT_ERROR_PRECHECK_ROW_SELECT,
          })
        )
      ),
      // ★清掃結果（`clean_task_reports`）は**本清掃の id**でしか引かない★
      //   （下で本清掃行の id でしか Map を引かない）。★取得元は指示書AQ 決定2/3 で差し替えた★
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.cleanTaskReport.findMany({
            where: { clean_timetable_id: { in: ids } },
          })
        )
      ),
    ]);
    const errorReports = errorReportChunks.flat();
    const taskReports = reportChunks.flat();

    // ★★`cleanLogs()` と**同じ関数**でレポートを1件に絞る★★（D-107。落とし穴2 / 指示書AQ §1-4）—
    //   以前はここが `new Map(histories.map(...))`（＝DB返却順の後勝ち）で、
    //   `cleanLogs()` の「ended_at 最新」と規則が違っていた。
    //   ★いまの判定は「行があるか」だけなのでどの行を選んでも答えは同じだが★、
    //   ★規則を1箇所にしておかないと、判定材料が増えた瞬間に `/detail-log` と `/alerts` で
    //   出発失敗の件数が食い違う★（D-103 が禁じた状態）
    const reportByTimetableId = latestReportByTimetableId(taskReports);

    const items: PrecheckItem[] = [];

    // ---- 1. 事前チェック行のエラー → severity: "precheck"
    for (const report of errorReports) {
      // ★nullable（清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`）★
      //   ★事前チェックのアラートではないので対象外にする★（指示書S 3-2 項6 の推奨）。
      //   `scheduled_at`（本清掃の予定時刻）を決められないため、そもそも行を作れない。
      //   ★★`in: [...]` で引いているので SQL 側でも落ちるが、この行は残す★★ —
      //   型の絞り込み（`bigint | null` → `bigint`）に要るうえ、規則を1箇所で読めるようにする。
      if (report.clean_timetable_id === null) continue;

      // ★★引くのは**事前チェック行**の Map★★（本清掃行の Map ではない）—
      //   `precheckRows` は `parent_id: { in: 期間で絞った本清掃の id }` で引いた行なので、
      //   ★ここに当たる時点で「親が期間内」が保証される★。
      const row = precheckRowById.get(key(report.clean_timetable_id));
      // ★FK が無いため、参照先が消えている／存在しないことが起こりうる★
      //   例外にすると1行の不整合で `/alerts` 全体が落ちるので、対象外として飛ばす
      if (!row) continue;

      // ★本清掃行を指すエラーは `launch_failed` に読み替えない（D-104）★
      //   出発失敗は下の 2. で本清掃行のレポートの有無から作る。ここで拾うと二重計上になる。
      //   `precheckMinutesFromCode()` は本清掃コードに対して null を返す
      const precheckMinutes = precheckMinutesFromCode(row.precheck);
      if (precheckMinutes === null) continue;

      // ★`scheduled_at` は事前チェック行の `start_at` ではなく親の本清掃行の値★（指示書S 3-2 項5）
      //   画面は「予定」として清掃の開始時刻を出すので、60分前の時刻を入れてはいけない。
      //   ★★期間の基準もこの値★★（指示書AC 段階4。`errored_at` ではない）— だから
      //   ★上の SQL は本清掃行を期間で絞り、そこから子を辿っている★。
      const mainRow = timetableById.get(key(row.parent_id));
      if (!mainRow) {
        // 事前チェック行の `parent_id` は必ず本清掃行を指す設計（D-092 項1）。
        // 指していなければデータが構造的に壊れているので、静かに別の時刻を出さず気づけるようにする。
        // ★★段階4 以降、`precheckRows` は `parent_id: { in: 本清掃の id }` で引いているので
        //   構造上ここには来ない★★。★それでもこの行を消さないこと★ —
        //   引き方を変えたときに「親が見つからない行を静かに落とす」状態へ戻さないための歯止め。
        throw new Error(
          `[db] prechecks: clean_timetables(id=${row.id}) の parent_id=${row.parent_id} に対応する本清掃行がありません`
        );
      }

      // ★行の組み立ては `precheckItemFromErrorRow()` の1本★（指示書AT 段階4-1）—
      //   `precheckItemById()`（PATCH の前後の読み取り）も同じ関数を使う。★ここに書き戻さないこと★
      //   ★親の本清掃のレポートは上で本清掃の id でまとめて引いた Map から取る★
      //   （`main_run_succeeded`。D-171。★1行ずつクエリしないこと★＝N+1）
      items.push(
        precheckItemFromErrorRow(
          report,
          precheckMinutes,
          mainRow,
          reportByTimetableId.get(key(mainRow.id))
        )
      );
    }

    // ---- 2. 出発しなかった本清掃 → severity: "launch_failed"（D-103 / D-104）
    // ★`now` は1回だけ作って全行で使い回す★（行ごとに作ると境界の行の扱いがぶれる）
    const now = new Date();
    for (const row of timetables) {
      // ★★`precheck === main` の絞り込みは SQL に降ろした★★（指示書AC 段階4）ので、
      //   ここに残っているのは ★未来の予定を外す条件★ と ★出発したかの判定★ だけ。
      // ★★まだ予定時刻が来ていない清掃は「出発しなかった」とは言えないので対象外★★
      //   `clean_timetables` には1週間分の予定がまとめて登録される（ユーザー回答 2026-09-10）ため、
      //   これを外さないと最大7日先までの予定が全部「出発失敗・未対応」として並ぶ。
      //   ★猶予（`LAUNCH_GRACE_MINUTES`）は足さない★（D-091 項1）。判定材料は `start_at` だけ
      if (isFutureSchedule(fromDbJst(row.start_at), now)) continue;
      // ★`cleanLogs()` の `departed` と同じ判定を共有する★（D-103 項1 / D-107）—
      //   ★★出発失敗 ＝ 対応する `clean_task_reports` の行が無い★★（指示書AQ §1-1）。
      //   ★「レポートが無ければスキップ」を足さないこと★（D-107 設計3-C）—
      //   レポートの行が無い行は `notExecutedFromReport()` が true を返すので、
      //   そのまま `launch_failed` になる（★消さずに画面へ出す★。fail-loud）。
      //   ★`clean_timetable_id` が NULL のレポートしか無い清掃もここに来る★（指示書AQ 罠1 / 決定5）
      const mainReport = reportByTimetableId.get(key(row.id));
      if (!notExecutedFromReport(mainReport)) continue;
      // ★結果待ち（レポートがまだ届いていないだけかもしれない清掃）は出発失敗として出さない★
      //   （表2 C9。ユーザー判断 2026-10-07。D-173）。判定は `cleanLogs()` の `result_pending` と同じ
      //   `isResultPending()` の1本（★D-091 項1 はこの件に限りユーザー判断で上書き★）。
      //   ★確定した出発失敗（待ち時間を過ぎた）は今までどおり出す★
      if (
        isResultPending({
          startAt: fromDbJst(row.start_at),
          endAt: fromDbJst(row.end_at),
          now,
          hasReport: false,
        })
      ) {
        continue;
      }

      // ★P1は1清掃エリア=1ロボットの前提（CLAUDE.md §8）★。`cleanLogs()` と同じ作法で
      //   0台なら静かに空文字を返さず例外にする
      const robot = row.section.robots[0];
      if (!robot) {
        throw new Error(
          `[db] prechecks: clean_timetables(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
        );
      }

      const scheduledAt = jstIsoFromDb(row.start_at);
      items.push({
        // ★本清掃行の `clean_timetables.id`★（D-104）— `CleanLogItem.id` と同じ値なので、
        //   清掃詳細ログ（F-02）とアラート（F-03）に同じ清掃について同じ番号が出る。
        //   ★`BigInt` を `Number()` に通してから文字列化する★（`PrecheckItem.id` は string）
        id: String(Number(row.id)),
        severity: "launch_failed",
        // ★出発失敗は「何分前のチェックか」を持たない★（D-105）
        precheck_minutes: null,
        serial_number: robot.serial_number,
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        scheduled_at: scheduledAt,
        // ★エラー行が0件のときの値（`Standby.`）★ — 自分で文字列を書かず、
        //   既存の純粋関数に空配列を渡して得る（D-098 / D-104）
        robot_status: robotStatusFromErrors([]),
        // ★予定時刻をそのまま検知時刻にする★ —
        //   エラー行が無いので `errored_at` が存在せず、`detected_at` は nullable でない。
        //   ★「予定＋猶予30分」を当方で計算しない★（D-091 項1。`LAUNCH_GRACE_MINUTES` を読まない）
        //   ため、予定時刻を採る。★fixtures も同じ（`lib/fixtures/prechecks.ts` の
        //   `launch_failed` 2件は `detected_at === scheduled_at`）★
        detected_at: scheduledAt,
        // ★`unresolved` 固定★（D-104）— エラー行が無いので状態を持てない。
        //   ★「その他」「不明」のような語は使わない★（D-089 項3）
        resolution_status: "unresolved",
        // ★すべて null★（D-104）— 備考も日時も存在しない。
        //   ★将来「対応状況の更新機能」を作るときは書き込み先が無い★（申し送り。D-105）
        //   ★`PATCH /prechecks/{id}/status` は出発失敗の id に 404 を返す（指示書AT §3-3）★
        responded_at: null,
        resolved_at: null,
        note: null,
        battery_level: null,
        // ★出発失敗は常に false★（D-171 の M6）— レポート行が無いので `mainRunSucceededFromReport()` が false を返す。
        //   ★`false` を直書きせず同じ関数を通す★（成功の定義を1箇所に保つ）
        main_run_succeeded: mainRunSucceededFromReport(mainReport),
      });
    }

    return items;
  },
  /**
   * ★条件は `DataSource.precheckItemById()` の docstring★（指示書AT §3-3）。
   * ★`prechecks()` が事前チェック行を作る条件と同じ★ — 条件を自前で書き直さず、
   * 同じ関数（`precheckMinutesFromCode()` / `precheckItemFromErrorRow()`）を使う。
   */
  async precheckItemById(id: string) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // 1. `error_id` は UNIQUE（`prisma/schema.prisma` の `RobotErrorReport`）。主キーは無い
    const report = await prisma.robotErrorReport.findUnique({
      where: { error_id: id },
      // ★`prechecks()` と同じ列。`robot_id` / `robot` を読まない（指示書AU 段階0-4）★
      select: ROBOT_ERROR_PRECHECK_ROW_SELECT,
    });
    if (!report) return null;
    // ★★DB の照合順序は大小文字を区別しない（ci）★★ — `rer-1` で `RER-1` が当たる。
    //   ★当たり外れは JS の `===` で決める★（D-4。`dsDeviceExists()` と同じ）。
    //   ★書き込みの WHERE も同じ照合順序なので、ここで弾かないと別の文字列で同じ行を書き換えられる★
    if (report.error_id !== id) return null;

    // 2. 予定に紐づかないエラー（N-5 の D-S制御盤・セキュリティの行を含む）は対象外
    if (report.clean_timetable_id === null) return null;

    // 3. 事前チェック行であること（本清掃行を指すエラーは `launch_failed` に読み替えない。D-104）
    //   ★予定は2表から引く（本表 → 無ければ履歴。指示書AU 段階3b-2 の 5）★ —
    //   ★履歴の予定に紐づくエラー行も PATCH で書けるようになる（§3b-7 Q4。実在するアラートなので、それでよい）★
    const row = await findTimetableById(prisma, report.clean_timetable_id, {
      id: true,
      parent_id: true,
      precheck: true,
    });
    if (!row) return null;
    const precheckMinutes = precheckMinutesFromCode(row.precheck);
    if (precheckMinutes === null) return null;

    // 4. 親の本清掃行（`scheduled_at` に使う）。★無ければ例外★（`prechecks()` と同じ。データが構造的に壊れている）
    //   ★旧: `findFirst({ id: parent_id, precheck: 本清掃 })`★ — 2表から id で引き、`precheck` は JS で確かめる
    //   （条件は同じ。★本表に同じ id があれば本表が勝つ★ので、本表の行が本清掃でなければ履歴を見ずに例外）
    const parentRow = await findTimetableById(prisma, row.parent_id, {
      id: true,
      precheck: true,
      start_at: true,
    });
    const mainRow =
      parentRow && parentRow.precheck === CLEAN_TIMETABLE_PRECHECK.main ? parentRow : null;
    if (!mainRow) {
      throw new Error(
        `[db] precheckItemById: clean_timetables(id=${row.id}) の parent_id=${row.parent_id} に対応する本清掃行がありません`
      );
    }

    // 5. 親の本清掃のレポート（`main_run_succeeded`。D-171）。★`prechecks()` と同じ関数で1件に絞る★
    //   （`latestReportByTimetableId()`。★`orderBy` で別の順序キーを書かないこと★。D-107）
    const mainReports = await prisma.cleanTaskReport.findMany({
      where: { clean_timetable_id: mainRow.id },
    });
    const mainReport = latestReportByTimetableId(mainReports).get(mainRow.id.toString());

    return precheckItemFromErrorRow(report, precheckMinutes, mainRow, mainReport);
  },
  async updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch) {
    // ★書き込み用の接続★（`DATABASE_WRITE_USER` / `DATABASE_WRITE_PASSWORD`。未設定なら参照用と同じ接続。指示書AT §3-2）
    const { getWritePrisma } = await import("./prisma");
    const client = await getWritePrisma();
    const { matched } = await writeRobotErrorColumns(client, errorId, patch);
    return { matched };
  },
  /**
   * エラー一覧（指示書AW 段階3-5）。★2段階で読む★（D8）。
   *
   * ★なぜ SQL で並べ替え・ページ分けしないのか★ — 並びの基準は `errored_at ?? created_at`（D3）だが、
   *   Prisma の `orderBy` は ★列ごとの並べ替えしか書けず `COALESCE` を書けない★（生SQL は禁止。`CLAUDE.md` §3）。
   *   `[{ errored_at: { sort: "desc", nulls: "last" } }, { created_at: "desc" }]` で近似すると、
   *   ★`errored_at` が NULL の行（表1 E7）が検知時刻に関係なく最後に回る★（例外もテスト失敗も出ない）。
   *   そこで 1段目 = キーだけを SQL で `status IN`・`facility_id IN`・期間に絞って全件読み、
   *   アプリで ★絞り込みの正（`filterSortErrorReportKeys()`）→ 並べ替え → ページ分け★、
   *   2段目 = ★そのページの `error_id` だけ★ 列を足して読み直す（最大 `PAGINATION.maxPerPage` 件）。
   */
  async errorReports(query: ErrorReportDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // 1段目: キーだけ（★SQL で status / 施設 / 期間を絞る。並べ替え・ページ分けはしない★）。
    //   ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
    const keyRows = await prisma.robotErrorReport.findMany({
      where: errorReportListWhere(query),
      select: ROBOT_ERROR_LIST_KEY_SELECT,
    });
    const keys = filterSortErrorReportKeys(keyRows.map(errorReportKeyFromRow), query);
    const pageIds = errorReportPageIds(keys, query);
    // ★0件なら2段目も予定も撃たない★
    if (pageIds.length === 0) return { items: [], total: keys.length };

    // 2段目: そのページの行だけ列を足して読む（`in` の分割は `chunkForInClause()`）
    const rows = (
      await Promise.all(
        chunkForInClause(pageIds).map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { error_id: { in: ids } },
            select: ROBOT_ERROR_LIST_ROW_SELECT,
          })
        )
      )
    ).flat();

    // 予定は2表から id で（D5。★同じ id は本表が勝つ★）。★id が0件なら塊も0個でクエリを撃たない★
    const timetableIds = [
      ...new Set(
        rows.flatMap((row) => (row.clean_timetable_id === null ? [] : [row.clean_timetable_id.toString()]))
      ),
    ].map((id) => BigInt(id));
    const timetables = await findTimetablesByChunks(prisma, chunkForInClause(timetableIds), (ids) => ({
      where: { id: { in: ids } },
      select: { id: true, precheck: true },
    }));

    // ★pageIds の順に並べ、2段目で見つからない error_id は飛ばす（指示書AW 4-15。理由は関数の docstring）★
    return { items: errorReportItemsInPageOrder(pageIds, rows, timetables), total: keys.length };
  },
  /** ★条件は `DataSource.errorReportById()` の docstring★（指示書AW 段階3-5） */
  async errorReportById(id: string) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // `error_id` は UNIQUE。★一覧の2段目と同じ列（`robot_id` / `robot` を読まない）★
    const report = await prisma.robotErrorReport.findUnique({
      where: { error_id: id },
      select: ROBOT_ERROR_LIST_ROW_SELECT,
    });
    if (!report) return null;
    // ★★DB の照合順序は大小文字を区別しない（ci）★★ — `rer-1` で `RER-1` が当たる。
    //   ★当たり外れは JS の `===` で決める★（D-4。`precheckItemById()` と同じ）。
    //   ★書き込みの WHERE も同じ照合順序なので、ここで弾かないと別の文字列で同じ行を書き換えられる★
    if (report.error_id !== id) return null;

    // 予定は本表 → 無ければ履歴（`findTimetableById()`。本表が勝つ。D5）
    const timetable =
      report.clean_timetable_id === null
        ? undefined
        : ((await findTimetableById(prisma, report.clean_timetable_id, { id: true, precheck: true })) ??
          undefined);
    return errorReportItemFromRow(report, timetable);
  },
  async updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch) {
    // ★`updatePrecheckErrorColumns()` と同じ中身★ — 書き込み用の接続 → `writeRobotErrorColumns()`（`updateMany` の1文）。
    //   ★`updatePrecheckErrorColumns()` の挙動を変えないため、共通化せずに並べて置く★（指示書AW D12 と同じ考え方）
    const { getWritePrisma } = await import("./prisma");
    const client = await getWritePrisma();
    const { matched } = await writeRobotErrorColumns(client, errorId, patch);
    return { matched };
  },
  /**
   * D-S制御盤の一覧（F-06）。★最終通信（`last_reported_at`）は `groupBy` で取る★。
   *
   * ------------------------------------------------------------------------
   * ★★なぜ `control_reports` の入れ子 include をやめたか（指示書AH 段階1 / D-1）★★
   * ------------------------------------------------------------------------
   * 以前はこう書いていた:
   *   `include: { control_reports: { orderBy: { reported_at: "desc" }, take: 1 } }`
   *
   * ★★この `take: 1` は1文字もSQLに出ない★★（★実測★）。Prisma は
   * ★制御盤ぶんの `control_reports` を**全行**取り寄せてから、アプリ側（JS）で1件に切っている★。
   * ★`relationLoadStrategy`（LATERAL JOIN）は Prisma 7.10.0 / MySQL adapter に存在しない★ので
   * 書き換えでは回避できない。
   *
   * ★★破綻の性質は「遅い」ではなく「メモリ」★★（実測）:
   *   - 100万行 … 8.6秒 / ヒープ 2.7GB
   *   - ★300万行 … `JavaScript heap out of memory` でプロセス即死★
   *   Node の既定ヒープ上限は 4,288MB（実測）で、★想定1,100万行はその4倍★。
   * ★`groupBy` なら戻りは制御盤数（実測11〜12行）で固定★＝行数総量に依存しない。
   * ★往復数は増えない★（include も裏で複数本発行していた）。
   *
   * ★画面が使うのは `reported_at` 1個だけ★（`DsDevice.last_reported_at`。
   * `d-watch-api.yaml` も `components/features/ds-logs/DsLogScreen.tsx` も同じで、
   * フィルタ・ソート・サマリはどれも `control_reports` を見ていない）。
   *
   * ------------------------------------------------------------------------
   * ★★なぜ時間で足切り（`reported_at >= X`）しないか（D-2。★足さないこと★）★★
   * ------------------------------------------------------------------------
   * ★`reported_at` に索引が無い★ため、`EXPLAIN` 実測で ★`key_len` が `control_id` の
   * 8バイトのまま増えず、走査行数も足切り無しと同じ★だった（＝後段フィルタでしかない）。
   * ★`groupBy` の戻りはもともと制御盤数なので、足切りを足す意味が無い★。
   * ★★そのうえ新しいバグを作る★★ — ★足切りの外にしか心拍が無い制御盤は最終通信が空欄になり、
   * 「いつから切れているか」が見えなくなる★（通信断の判定自体は `controls.status` なので狂わない）。
   *
   * ------------------------------------------------------------------------
   * ★★なぜ `MAX(id)` を使わないか（D-3。★変えないこと★）★★
   * ------------------------------------------------------------------------
   * InnoDB の二次索引には主キーが暗黙に付くので `control_reports_control_id_foreign` は
   * 物理的に `(control_id, id)` であり、★`MAX(id)` なら `Using index for group-by` が効く★
   * （実測: `type=range` / `rows=3`。`MAX(reported_at)` は `type=index` / `rows=14`）。
   * ★★しかし `id` の大小順と `reported_at` の大小順が一致する保証はどこにも無い★★ —
   * 書き込むのは TeamWorks のデータ収集処理で、★その順序は仕様として確認していない★
   * （`CLAUDE.md` §7「仕様が不明な箇所を推測で実装しない」）。
   * ★確認が取れるまで `MAX(reported_at)` を使うこと★。
   * ★根本的な手当ては複合索引 `(control_id, reported_at)` の依頼★（`docs/handoff.md`）。
   */
  async dsDevices() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    // ★deleted_at IS NULL でのみ絞る。inactive では絞らない★
    //   inactive は「無効化」であって削除ではなく、画面が見せ分ける可能性がある
    //   （lib/schedules/query.ts:108-112 に前例あり。仕様が未確定な箇所。指示書P 禁止事項10 / D-086）。
    const controls = await prisma.control.findMany({
      where: { deleted_at: null },
      include: { facility: true },
    });
    // ★制御盤ごとの最終通信だけを取る（行は運ばない）★
    //   ★`control_id IN (...)` は制御盤数ぶん（実測11〜12）★。
    //   ★200台を超えたら実行計画を測り直すこと★（`docs/handoff.md` の申し送り）。
    const latest = await prisma.controlReport.groupBy({
      by: ["control_id"],
      where: { control_id: { in: controls.map((c) => c.id) } },
      _max: { reported_at: true },
    });
    // ★★キーは `String()` で揃えること★★（★罠1★）—
    //   `BigInt` をそのまま Map のキーにすると `1n` / `"1"` / `1` が別のキーになり、
    //   ★全制御盤が「心拍0件」に見えて例外になる★。
    const byControl = new Map(latest.map((g) => [String(g.control_id), g._max.reported_at]));
    return controls.map((row): DsDevice => {
      const lastReportedAt = byControl.get(String(row.id));
      if (!lastReportedAt) {
        // ★シードは全controlsにcontrol_reportsを1件以上入れる想定（指示書P 1-3）★
        //   0件が来た場合は黙って空文字を返すより、例外で気づけるほうを選ぶ
        //   （★「一覧に出るのに最終通信が無い」の検知。メッセージも含めて変えないこと★）
        throw new Error(
          `[db] dsDevices: controls(id=${row.id}) に control_reports が1件もありません`
        );
      }
      return {
        serial_number: row.serial_number,
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        // ★controls.status（0/1） → connection_status（"ok"/"error"）。D-065 Q1-2★
        connection_status: connectionStatusFromControlStatus(row.status),
        // ★control_reports.reported_at は TIMESTAMP だが、実測（指示書Y 段階3）で
        //   DATETIME と同じ往復（9時間ずれ）をすることを確認した（ローカルの
        //   @@system_time_zone が実質JST相当のため）。DATETIME と同じ補正でよい★
        last_reported_at: jstIsoFromDb(lastReportedAt),
        // ★note は常に null を返す。DBに対応カラムが無い（D-065 Q1-1）ため、実値は
        //   app/api/ds-devices/route.ts:75-78 がJSONストア（data/settings/ds-device-notes.json）から
        //   マージする。ここで埋めようとしないこと（後任が「実装漏れ」と誤解しないように明記）★
        note: null,
      };
    });
  },
  /**
   * ★シリアル1件の実在確認★（指示書AH 段階2 / D-4）。
   *
   * ------------------------------------------------------------------------
   * ★★なぜ `dsDevices()` を呼ばないか★★
   * ------------------------------------------------------------------------
   * 以前は `PATCH /ds-devices/{serial_number}/note` が `db.dsDevices()` を呼んでいた。
   * ★シリアル1件の実在を確かめるためだけに、全制御盤＋全施設（＋当時は全心拍）を読む★。
   * ★実在確認に施設名も最終通信も要らない★。
   * ★さらに、無関係な1台に心拍が0件だと例外が飛んで**全台の備考保存が500になる**★
   * （段階1 で心拍は読まなくなったが、★経路を分けること自体に価値がある★）。
   *
   * ------------------------------------------------------------------------
   * ★★当たり外れは JS の `===` で決める（★罠4★ / D-4。★変えないこと★）★★
   * ------------------------------------------------------------------------
   * ★DBの既定の照合順序は大小文字を区別しない（ci）★。SQL の一致をそのまま答えにすると:
   *   `ds-001` が `DS-001` に当たって 404 → 200
   *     → ★保存キーはリクエストの生文字列 `ds-001`★（`lib/settings/ds-device-notes.ts`）
   *     → 一覧は `notes["DS-001"]` で引く
   *     → ★★保存されたのに一生表示されない孤児行が残る★★
   * つまり ★実在チェックが防ぐはずの事故が、実在チェックのせいで起きる★。
   * ★★SQL には絞り込みだけさせ、最後は `row.serial_number === serialNumber` で判定する★★。
   *
   * - ★`findFirst` ではなく `findMany`★ — ci 照合で複数行が当たったとき、
   *   `findFirst` が「別の行」を返して ★偽404★ になる
   * - ★`select` は `serial_number` だけ★（実在確認に他の列は要らない）
   * - ★`take` は付けない★（★罠8★。段階1 と同じ「SQL に降りない」問題を踏みうる）
   * - ★`deleted_at: null` を `dsDevices()` と揃える★（★罠7★。上の `dsDeviceExists()` の
   *   インターフェース側コメント参照）
   *
   * ★`controls.serial_number` に索引は無い★が、★`controls` は実測11〜12行★なので
   * 索引の依頼はしない（`docs/handoff.md` の申し送り対象外）。
   */
  async dsDeviceExists(serialNumber: string) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const rows = await prisma.control.findMany({
      where: { deleted_at: null, serial_number: serialNumber },
      select: { serial_number: true },
    });
    // ★ここが「当たり外れ」を決める唯一の場所★（SQL の一致に任せない。D-4）
    return rows.some((row) => row.serial_number === serialNumber);
  },
  /**
   * ★このメソッドは disconnected（制御盤の生死報告なし）しか返しません★。
   * ★理由★: `security_off`（セキュリティ解除されたまま）に対応するカラムが
   *   `security_statuses` / `security_status_histories` に無いためです（D-090 項3-9）。
   *   `DsAlert` が要求する `id` / `device_serial` / `resolution_status` / `responded_at` /
   *   `resolved_at` / `memo` の6フィールドに対応するカラムが両テーブルどちらにも存在しません。
   *   また `security_statuses.reported_at` は `@updatedAt` のため、`detected_at`
   *  （いつからセキュリティが解除されたままか）も取れません（D-090 項3-10）。
   * ★帰結★: `/ds-alerts` の件数が fixtures より少なくなり（security_off分が返らない）、
   *   `/dashboard` のセキュリティバナー（`lib/dashboard/aggregate.ts` の `computeSecurityBanner()`）が
   *   出なくなります。★これは意図した挙動であり、実装漏れではありません★。
   *   `security_off` 側は第3歩（`docs/decisions.md` D-090 項2 の分割表）で扱います。
   */
  async dsAlerts() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    // ★control_error_histories には deleted_at が無い → 絞らない（段階3-5）★
    //   controls 側の deleted_at は結合先として絞ってよい
    const rows = await prisma.controlErrorHistory.findMany({
      where: { control: { deleted_at: null } },
      include: {
        control: true,
        facility: true,
        section: true,
      },
    });
    return rows.map((row): DsAlert => ({
      id: Number(row.id),
      // ★control_error_histories は制御盤異常（生死報告なし）しか持たない（D-067 項1）★
      alert_type: "disconnected",
      device_serial: row.control.serial_number,
      facility_id: Number(row.facility_id),
      facility_name: row.facility.facility_name,
      section_name: row.section.section_name,
      detected_at: jstIsoFromDb(row.occurred_at),
      // ★control_error_histories.status（0/1/2） → resolution_status。D-065 Q1-3★
      resolution_status: resolutionStatusFromControlErrorStatus(row.status),
      responded_at: row.responded_at ? jstIsoFromDb(row.responded_at) : null,
      resolved_at: row.resolved_at ? jstIsoFromDb(row.resolved_at) : null,
      // ★書き込み許可4カラムの1つ（D-042）★
      memo: row.comment,
    }));
  },
  /**
   * ホールディングス一覧（`GET /groups`。指示書AK 段階1）。
   *
   * ★`deleted_at IS NULL` でのみ絞り、`inactive` では絞らない★（`facilities()` と同じ判断）。
   * ★並べ替えはルート側（`sortGroups()`）★ — 規則の「正」を純粋関数に置いたまま
   *   （指示書AC C-5。ここでの `orderBy` はその写しになるので書かない）。
   */
  async groups() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const rows = await prisma.group.findMany({ where: { deleted_at: null } });
    return rows.map(
      (row): Group => ({
        id: Number(row.id),
        group_name: row.group_name,
      })
    );
  },
  /**
   * 屋号一覧（`GET /customers`。指示書AK 段階1）。
   *
   * ★★`contract` を読んでいないこと★★ — `customers.contract` は削除依頼済み
   *   （D-010 / 落とし穴5）。そもそも `prisma/schema.prisma` の `model Customer` に列が無い。
   *
   * ★`group_id` の 0 → `null` は `groupIdFromDb()` に任せる★（D-011 / 落とし穴3）—
   *   ★ここに `=== 0` を書かないこと★。`BigInt` のままだと `0n === 0` が `false` で
   *   静かに外れるので、先に `Number()` に落としてから渡す。
   *
   * ★★`orderBy` を付けてある★★（指示書AL 段階3 §4-4。★`groups()` と違う点★）—
   *   `GET /customers` はルート側の `sortCustomers()` で並べ替えるので、そちらだけを見ると
   *   ここの `orderBy` は写しに見える。★だが `GET /reports/kpi` の 422 は
   *   `db.customers()` の並びをそのまま `details.customer_ids` に流す★ので、
   *   ★無いと候補の並びがDB任せになる★。★テストはスタブの順序で通ってしまい気づけない★。
   */
  async customers() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const rows = await prisma.customer.findMany({
      where: { deleted_at: null },
      orderBy: { id: "asc" },
    });
    return rows.map(
      (row): Customer => ({
        id: Number(row.id),
        customer_name: row.customer_name,
        group_id: groupIdFromDb(Number(row.group_id)),
      })
    );
  },
  async facilities() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    // ★deleted_at IS NULL でのみ絞る。inactive では絞らない★
    //   inactive は「無効化」であって削除ではなく、画面が見せ分ける可能性がある
    //   （lib/schedules/query.ts:108-112 に前例あり。仕様が未確定な箇所。指示書M 3-4 / D-086）。
    const rows = await prisma.facility.findMany({
      where: { deleted_at: null },
      include: {
        robots: {
          where: { deleted_at: null },
          include: { device: true },
        },
        // ★所属（屋号・ホールディングス）は施設の行と一緒に読む★（指示書AK 段階5 §8-2）。
        //   ★施設マスタは実測12件なので N+1 にはならない★（`loadFacilityGroupIndex()` と同じ判断）。
        //   ★`deleted_at` で絞らないこと★ — 屋号側を絞ると、論理削除された屋号に属する施設が
        //   静かに落ちる（`lib/db/facility-group-source.ts` の docstring と同じ理由）。
        customer: { include: { group: true } },
      },
    });
    return rows.map((row): Facility => {
      const { monitoring_enabled, d_security_enabled } = parseContract(row.contract);
      // ★0 → null の変換を知っているのは `groupIdFromDb()` だけ★（D-011 / ★落とし穴3★）—
      //   ★ここに `=== 0` を書かないこと★。`BigInt` のままだと `0n === 0` が `false` で
      //   静かに外れるので、先に `Number()` に落としてから渡す。
      const group_id = groupIdFromDb(Number(row.customer.group_id));
      return {
        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` の行は存在しないので、名前を出す相手がいない。
        //   `?? null` は保険（FKが緩い環境で参照先を欠いた行が来ても「グループ0」を出さない）。
        group_name: group_id === null ? null : (row.customer.group?.group_name ?? null),
        prefecture: row.prefecture_name,
        address: formatAddress(row.addr1, row.addr2),
        phone: row.phone,
        manager_name: row.manager_name,
        charger_name: row.charger_name,
        email: row.email,
        monitoring_enabled,
        d_security_enabled,
        robots: row.robots.map((robot) => ({
          id: Number(robot.id),
          maker: MAKER_NAMES[robot.device.maker_id] ?? String(robot.device.maker_id),
          model: robot.device.model_name,
          serial_number: robot.serial_number,
        })),
      };
    });
  },
  async schedules() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const rows = await prisma.cleanSchedule.findMany({
      where: { deleted_at: null },
      include: {
        facility: true,
        section: {
          include: {
            robots: { where: { deleted_at: null } },
          },
        },
      },
    });
    return rows.map((row): Schedule => {
      // ★P1は1清掃エリア=1ロボットの前提（CLAUDE.md §8）★
      //   0台の section が来た場合は、静かに空文字を返すより例外で気づけるほうを選ぶ
      //   （指示書M 4-2）。段階2のシードで施設12（ロボット0台）のスケジュールを
      //   構造的に除外しているため、通常運用ではここには到達しない。
      const robot = row.section.robots[0];
      if (!robot) {
        throw new Error(
          `[db] schedules: clean_schedules(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
        );
      }
      return {
        id: Number(row.id),
        serial_number: robot.serial_number,
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        weekdays: row.weeks,
        weekdays_label: weekdaysLabel(row.weeks),
        start_time: formatStartTime(row.start_time),
        // ★inactiveの論理は反転している（0=有効）。Boolean(row.inactive)は書かない★
        //   （lib/schedules/query.ts:15-19 の警告どおり。全件逆になる典型的な地雷）
        end_time: formatEndTime(row.end_time),
        active: row.inactive === 0,
      };
    });
  },
  async inspections() {
    return notImplemented("inspections");
  },
  async inspectionDetail() {
    return notImplemented("inspectionDetail");
  },
  /**
   * 要レスキュー店舗（F-01。指示書AI 段階4）。
   *
   * ★★新しい全期間クエリを書いていない★★ — 入力は
   *   ★`loadConsecutiveInputs()`（`lib/db/consecutive-source.ts`）の1本★ から取る
   *   （★4本目の呼び出し元★。`cleanLogs()` / `facilityPerformance()` /
   *   `continuousProblems()` と同じ1本。D-109 / D-103 の再発防止）。
   *
   * ★★遡り幅を要レスキュー用に縮めないこと★★（指示書AI §6-2 / §13-7）—
   *   R-1 は3スケジュール日、R-3 は1スケジュール日で足りるが、
   *   ★R-2（L3）は既存の遡り幅をそのまま必要とする★。縮めると ★L3 が L1 に化ける★
   *   （例外もテスト失敗も出ない）。
   *
   * ★★`date_from` にも `date_to` にも対象日を渡す★★ — 遡りぶんは
   *   `loadConsecutiveInputs()` が足す（★ここで足さないこと★。3経路で食い違う）。
   *   ★当該日に予定が無い施設を拾えるのは、この遡りぶんに前日以前の行が入るから★
   *   （絞り込みは `buildRescueRequired()` が「当該日以下」で行う。指示書AI §6-3）。
   *
   * ★施設名は `loadFacilityNameIndex()`★（`facilityNameIndex()` と同じ1本）—
   *   ★`facilities()` で代用しないこと★（あちらは `deleted_at` で絞るので、
   *   論理削除された施設が要レスキューから静かに消える。★罠7★）。
   */
  async rescueRequired(query: RescueDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★`now` は1回だけ作って両方の読み取りで使い回す★（未来の予定の除外基準を揃える）
    const now = new Date();
    const [inputs, facilityNames] = await Promise.all([
      loadConsecutiveInputs(prisma, now, {
        date_from: query.date,
        date_to: query.date,
      }),
      loadFacilityNameIndex(prisma),
    ]);

    // ★判定は fixtures と同じ1本を通す（`USE_FIXTURES` で分岐させない）★
    // ★件数の上限は渡さない★（指示書AJ 段階1。該当する施設を全部返す）
    // ★★分類の絞り込みは fixtures 版と同じ場所（入力の行）にかける★★（指示書AK 段階4b）。
    //   ★★`loadConsecutiveInputs()` に `facility_id` の条件を足していない★★のは、
    //   ★あの1本を `cleanLogs()` / `facilityPerformance()` / `continuousProblems()` と
    //   共有しており、引数を増やすと3経路の遡り幅の約束（D-109 の再発防止）に触るため★。
    //   ★読み込み量は段階4b 以前と同じ（絞り込みで増えていない）★。
    //   ★★申し送り: SQL に降ろすなら4経路まとめて直すこと★★ —
    //   ★そのときも `null`（絞らない）と `[]`（0件ヒット）の区別を崩さないこと★。
    return buildRescueRequired(
      filterRowsByFacilityIds(inputs, query.facility_ids),
      facilityNames,
      query.date
    );
  },
  /**
   * 清掃面積（`GET /clean-area`。指示書AN 段階3 §6-2 / §6-3）。
   *
   * ------------------------------------------------------------------------
   * ★★面積は `groupBy` + `_sum`（行を運ばない）、完了率・実施回数は行を運ぶ★★（指示書AN §2-5 / 指示書AS 段階4）
   * ------------------------------------------------------------------------
   * ★面積の合計は最大でも施設数ぶん（200行程度）★で、★期間の広さに関係しません★。
   * ★集計元は `clean_task_reports`★（指示書AQ 決定2/3。2026-09-30 に `clean_status_histories`
   * から差し替えた）。★旧取得元は保持2年で約26万行★と見積もっていた。
   * ★★完了率・実施回数の材料だけは行を取り寄せる★★（2026-10-02。D-155 / 指示書AS 段階4）—
   *   1回ごとの完了率の上限100・小数第1位の切り捨て（`runCompletionRate()`）は `groupBy` で書けず、
   *   ★生SQL（`$queryRaw`）は禁止★（`CLAUDE.md` §3）のため。★運ぶのは `clean_timetable_id` が
   *   NULL でない行の5列だけ★（`facility_id` / `clean_timetable_id` / `clean_area` / `task_area` / `clean_ratio`）。
   *   ★★期間を指定しないと全期間（保持2年）を運ぶ★★ — 「行を取り寄せてJSで足すと期間を広げた瞬間に
   *   破綻する」懸念はこの取り寄せには当てはまる。★取り寄せた行数（`rate_rows`）と所要時間は
   *   ルート層が `judge.clean_area` ログに出す★（D-156 論点2。実データで重ければ別途手当てする）。
   *   予定（本清掃か）は id を集めて `chunkForInClause()` で分けて別クエリで引く（`include` は使わない）。
   *
   * ★`started_at` に索引が無い★（指示書AN §1-5。第9回 1-4 で依頼中）ため全走査になりますが、
   * ★★1本の集計クエリの全走査であり、詳細ログのような「塊ごとに全走査 × N回」ではありません★★。
   *
   * ★★`include` を使わないこと★★（§6-2 / 落とし穴8）— ★親行の数ぶん `IN` が膨らむ★
   *   （D-136 で実測済み）。★施設マスタは `loadCleanAreaFacilityMasters()` で1回読み、
   *   JSで突き合わせる★（突き合わせ自体は `aggregateCleanArea()` の仕事）。
   *
   * ------------------------------------------------------------------------
   * ★★番人（§2-5 / §6-3 / 落とし穴2）★★
   * ------------------------------------------------------------------------
   * ★1つの `clean_timetable_id` にレポートが2行以上あると `SUM()` が同じ清掃を2回数える★。
   * ★数え方（NULL 除外を含む）の正は `countDuplicatedTimetables()`★（指示書AQ §5-3）。
   * ★例外もテスト失敗も出ず、数字が大きくなるだけ★なので、件数だけ数えて返します。
   *
   * ★★`having` で「2行以上のものだけ」をDB側に絞らせること★★ —
   *   ★絞らないと `clean_timetable_id` ごとに1行（＝最大26万行）返ってきます★。
   *   ★異常が無ければ0行しか運びません★。
   * ★★集計と同じ `where` を使うこと★★（対象期間・対象施設に絞る。§6-3）—
   *   ★全期間・全施設を毎回数え直さない★。
   *
   * ★★ログはここで出さない★★ — `DataAnomalyLogEvent` の `route` / `method` はルート層なら必ず埋めるもので
   *   （型は D-178 で任意になったが、ルートを載せられる層で出すのが原則）、
   *   ★データ層はどのルートから呼ばれたかを知らない★（★不明な情報を書かない★）。
   *   ★件数を返し、`reportCleanAreaAnomalies()`（`lib/clean-area/anomaly.ts`）を
   *   ルート層が呼ぶ★（段階2 の `unmatched_facility_ids` と同じ分担）。
   */
  async cleanArea(query: CleanAreaDbQuery): Promise<CleanAreaSourceResult> {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★`date_from` / `date_to` から直接 `Date` を作らないこと★★ — `started_at` は
    //   DATETIME ＝ ★日本時間の壁時計値★なので9時間ずれる（指示書AC C-6）。
    //   ★変換は `dbDateRange()` の1箇所★。★どちらも `null` なら絞らない★（§2-3。上限も設けない）。
    const range = dbDateRange(query.date_from, query.date_to);
    // ★★集計と番人で**同じ `where`** を使う★★（§6-3）— 別々に書くと、
    //   ★片方だけ期間を絞り忘れても誰も気づけない★。
    const where: CleanAreaWhere = {
      // ★`gte` も `lt` も無いときは `started_at` のキーごと付けない★（`cleanLogs()` と同じ）
      //   ★`started_at` は `clean_task_reports` にも同じ名前で在る（NOT NULL）★
      ...(range.gte || range.lt ? { started_at: range } : {}),
      // ★★必ず `!== null` で分岐すること★★（★罠1 / 落とし穴3★）—
      //   ★`in: undefined` は条件が消えて**全件**、`in: []` は `WHERE 1=0` で0件★。
      //   ★`query.facility_ids?.length ? ... : {}` と書くと「0件ヒット」が「全件」に化けます★。
      //   ★`facility_id` は `BigInt` 列なので1件ずつ包む★（`CleanAreaWhere` が `bigint[]` を要求。
      //   `prechecks()` と同じ作法）
      ...(query.facility_ids !== null
        ? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
        : {}),
    };

    // ★★`groupBy` をここに直書きしないこと★★（指示書AQ §5-3）—
    //   ★集計元（`clean_task_reports`）と番人の `clean_timetable_id IS NOT NULL` 除外は
    //   `lib/db/clean-area-source.ts` の2関数にしか無い★。直書きすると除外が落ち、
    //   ★NULL の行が1グループにまとまって番人が毎回1件を誤報する（偽陽性）★。
    const [statsLoad, duplicatedTimetableCount, facilityMasters] = await Promise.all([
      // ★面積の合計・実施回数・完了率の材料（`BigInt` の `Number()` 変換と `_sum` の `null` 吸収は
      //   向こうで済ませる）★。★取り寄せた行数もログ用に受け取る★（D-156 論点2）
      loadCleanAreaFacilityStatsWithRowCount(prisma, where),
      // ★番人★: ★2行以上ある `clean_timetable_id` の件数★（★NULL を除いて数える★）
      countDuplicatedTimetables(prisma, where),
      // ★施設マスタは1回だけ読む★（★`deleted_at` で絞らない理由は
      //   `lib/db/clean-area-source.ts` の docstring★）
      loadCleanAreaFacilityMasters(prisma),
    ]);

    return {
      stats: statsLoad.stats,
      facilities: facilityMasters,
      duplicated_timetable_count: duplicatedTimetableCount,
      rate_rows: statsLoad.rate_rows,
    };
  },
  /**
   * レポートKPI（全社の4指標 + 前期比）。
   *
   * ★確定期間: `customer_summaries` をそのまま読む。履歴から再計算しない（D-102）★
   * ★当期（今週・今月）: 詳細テーブルからその場集計する★（インターフェース定義の
   *   コメント参照。D-090項14/項15の解決）
   *
   * ★★性能（指示書AC 段階5 で解決）★★: 以前は引数を取らず ★サマリ全件 ＋ 詳細テーブル全件★ を
   *   読んでいた（`customer_summaries` 2 + `clean_timetables` 1,044 +
   *   `clean_status_histories` 538 + `robot_error_reports` 49 ＝ ★実測 1,633行★）。
   *   ★★いまは「要求された期間 ＋ その1つ前」の2期間ぶんしか読まない★★。
   */
  async reportKpis(query: ReportKpiDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★期間の範囲を自分で計算しないこと★★（指示書AC 段階5「月跨ぎ週の地雷」）—
    //   週は月境界で切られ、★長さが1〜7日で変わる★（D-071 / `CLAUDE.md` §4.7）。
    //   ★帰属ルールを知っているのは `weekOfMonth()` だけ★なので、範囲は必ず
    //   `periodRange()` に作らせる。★`null` ＝ その月に存在しない週★（5週しかない月の第6週。
    //   当期にはなりえないので当期の集計は走らない）。
    const range = periodRange(query.period, query.year, query.month, query.week);

    // ★★前期比のために1つ前の期間の行も要る★★（`shiftPeriod()`）—
    //   ★2行ぶんだけ引く。全件読みに戻さないこと★（指示書AC 段階5）。
    //   ★★サマリの粒度は `week` が 0 かどうかで判別する★★（`week=0` が月次、
    //   `month=0 AND week=0` が年次。`CLAUDE.md` §4.7）ので、期間キー3つの一致で
    //   粒度まで決まる（`period` を別に渡す必要は無い）。
    const prevKey = shiftPeriod(query.period, query.year, query.month, query.week, -1);
    const summaryRows = await prisma.customerSummary.findMany({
      where: {
        // ★★対象顧客で絞る（指示書AK ★段階6★ / §2-1）★★ —
        //   ★`OR`（当期 or 前期）と**同じ階層**に置くこと★。Prisma は同階層のキーを
        //   ★AND で畳む★ので、これで「対象顧客 AND（当期 or 前期）」になる。
        //   ★★`OR` の各要素に `customer_id` を書き足す形にしないこと★★ — 同じ意味だが、
        //   ★条件が2箇所に散ると、片方だけ直したときに静かに全顧客へ戻る★。
        //   ★`customer_id` は `BigInt` 列★（`prisma/schema.prisma`）なので包む
        //   （★`Number` のまま渡すと型が合わない★。`cleanLogs()` の `facility_id` と同じ作法）。
        customer_id: BigInt(query.customer_id),
        OR: [
          { year: query.year, month: query.month, week: query.week },
          { year: prevKey.year, month: prevKey.month, week: prevKey.week },
        ],
      },
    });
    // ★★年次（month=0 && week=0）も読む★★（指示書AQ 決定1。2026-09-30 解禁）—
    //   ★以前はここで年次行を除外していた（「P1では読まない」D-072項4）が、
    //   確定期間のサマリに週・月・年の3粒度が入るようになったので filter を外した★。
    //   ★外せる前提★: `periodOfSummaryRow()` が `month` も見るようになった（指示書AQ 罠2）ので、
    //   ★年次行が「月次」として混入して `customerSummariesByPeriodKey()` が 500 になることはない★。
    //   ★`where` は期間キー3つの完全一致なので、週次/月次の要求に年次行が混ざることもない★。

    // ★★安全弁は残してある（D-5 / 指示書AH 段階3。指示書AK 段階6 でも**残す**と決めた）★★ —
    //   ★`where` で顧客を絞ったので「複数顧客が混ざる」枝は通常もう発火しない★が、
    //   ★★同じ期間キーの重複は同一顧客でも起こりうる★★（`customer_summaries` に一意制約が無い。
    //   期待値表 6-8）。★後勝ちで不定になるので、検知したら止める★。
    //   ★検知の中身（「全行の customer_id が1種類か」＋ 期間キーの衝突）は
    //   `customerSummariesByPeriodKey()` が正★ — ここで手書きのループに戻さないこと。
    const byPeriodKey = customerSummariesByPeriodKey(summaryRows);

    // ★要求された期間の行だけを組み立てる★（前期の行は `KpiValue.prev` の材料であって、
    //   ★それ自体を返り値の行にしない★。fixtures 版も同じ）
    //   ★`periodOfSummaryRow()` には行（`prevKey`）をそのまま渡す★ —
    //   `month` / `week` を位置引数で渡すと取り違えても型エラーにならない（同関数の docstring）
    const confirmedKpiRows: ReportKpiRow[] = summaryRows
      .filter(
        (row) =>
          row.year === query.year &&
          row.month === query.month &&
          row.week === query.week
      )
      .map((row) => {
        const period = periodOfSummaryRow(row);
        const prevRow = byPeriodKey.get(
          periodMapKey(
            periodOfSummaryRow(prevKey),
            prevKey.year,
            prevKey.month,
            prevKey.week
          )
        );
        return {
          period,
          year: row.year,
          month: row.month,
          week: row.week,
          kpi: kpiFromSummaryRatios(row, prevRow),
        };
      });

    // ★★当期（進行中の週・月）でなければ詳細テーブルを1行も読まない★★ —
    //   確定期間はサマリが正であり、★履歴から再計算しない（D-102 / `CLAUDE.md` 禁止事項1）★。
    //   ★`isCurrentPeriod()` は中で `todayJst()` を使う（当期判定の「正」はあちら1箇所）★
    if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
      return confirmedKpiRows;
    }

    // ★当期: clean_timetables × clean_task_reports × robot_error_reports を
    //   ★★`periodRange()` が返した範囲で SQL 側から絞って★★その場集計する
    //   （`cleanLogs()` が既に読んでいるものと同じテーブル・同じ絞り方）★。
    //   ★清掃結果の取得元は `clean_task_reports`★（指示書AQ 決定2/3。旧: `clean_status_histories`）。
    //   ★★年次の当期（今年）もここを通る★★（指示書AQ 決定1）— 範囲が ★1月1日〜12月31日★ になるので、
    //   ★本清掃行と `in` の塊の数が週次の約50倍になる★（`chunkForInClause()` で分割はされる）
    //   ★★`parseDate()` の「真の瞬間」を直接渡さないこと★★ — DB の DATETIME は
    //   日本時間の壁時計値なので9時間ずれる（指示書AC C-6）。★変換は `dbDateRange()` の1箇所★。
    const dbRange = dbDateRange(range.from, range.to);

    /**
     * ★★当期は「対象顧客の施設」だけを集計する★★（指示書AL ★段階1★）。
     *
     * ★確定期間は上の `customer_summaries` を `customer_id` で絞っている★のに対し、
     * ★当期は詳細テーブルなので軸が**施設**になる★。★同じ顧客から解決した施設IDが
     * 降りてくる契約★（`toReportKpiDbQuery()` / `app/api/reports/kpi/route.ts`）なので、
     * ★2つの経路は同じ母集団を指す★。
     *
     * ★★`!== null` で分岐すること★★（★罠1 / 落とし穴3★）—
     *   ★`?.length` で分岐すると `[]`（0件ヒット）が「条件なし＝全社合算」に化ける★。
     *   ★`in: undefined` も条件ごと消える★。★例外もテスト失敗も出ない★。
     */
    const facilityIdWhere =
      query.facility_ids !== null ? { in: query.facility_ids } : undefined;

    // ★予定は2表から読む（指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★）★
    const mainRows = await findTimetables(prisma, {
      where: {
        // ★本清掃だけを引く（メモリ側の filter をやめて SQL に降ろした）★
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        ...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
      },
      // ★読む列は `CleanStatsTimetableLike`（`lib/db/report-mapping.ts`）が要求する4つだけ★
      //   （★`end_at` は結果待ちの判定用。D-173★）
      select: { id: true, facility_id: true, start_at: true, end_at: true },
    });

    // ★引いた本清掃の id で関連行を引き直す★（★全件読みに戻さないこと★）。
    //   ★`in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある★（指示書AC §E-5）。
    //   ★塊が0個（本清掃が0件）ならクエリを1本も撃たない★。
    const mainIdChunks = chunkForInClause(mainRows.map((row) => row.id));
    const [reportChunks, errorReportChunks] = await Promise.all([
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.cleanTaskReport.findMany({ where: { clean_timetable_id: { in: ids } } })
        )
      ),
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            // ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
            select: ROBOT_ERROR_COUNT_SELECT,
          })
        )
      ),
    ]);
    // ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★（指示書AQ §1-4）
    const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());
    // ★エラー率の分子は★本清掃行を指す★robot_error_reports（TeamWorks 第8回 A7。D-114 項2）★ —
    //   事前チェック行を指すものは含めない。`null` / dangling も入らない（決定3）。
    //   ★★上の `in` で既に「本清掃行を指すもの」だけを引いているが、判定の「正」は
    //   `errorCountByMainTimetable()` のまま★★（SQL 側はその写し。指示書AC C-5）
    const mainStartAtById = mainTimetableStartAtById(mainRows);
    const errCountByMainId = errorCountByMainTimetable(errorReportChunks.flat(), mainStartAtById);

    // ★1回の集計で同じ now を使い回す（指示書V 落とし穴3。行ごとに作らない）★
    //   ★未実施の判定は `notExecutedFromReport()` を注入する★（`cleanLogs()` / `prechecks()` と
    //   同じ1本。★ここにラムダで規則を書かないこと★ — 規則が2箇所に散る。D-103 項1）
    const now = new Date();
    const bucket = sumCleanStatsBuckets(
      aggregateCleanStatsByFacility(
        mainRows,
        reportByTimetableId,
        notExecutedFromReport,
        errCountByMainId,
        range,
        now
      ).values()
    );

    // ★当期のprevは「1つ前の期間」＝確定期間のサマリ行から取る★
    //   （当期の前は先週/先月/★前年★なのでサマリに存在するはず。無ければ prev=null になるだけ）
    const prevRow = byPeriodKey.get(
      periodMapKey(
        periodOfSummaryRow(prevKey),
        prevKey.year,
        prevKey.month,
        prevKey.week
      )
    );

    const currentKpiRow: ReportKpiRow = {
      period: query.period,
      year: query.year,
      month: query.month,
      week: query.week,
      kpi: kpiFromCleanStats(bucket, prevRow),
    };

    // ★確定期間の行があればそれが先（`selectKpiRow()` は最初の一致を採る）★ —
    //   当期にサマリ行がある（先方のバッチが先走った）場合の優先順位を段階0 と揃える。
    return [...confirmedKpiRows, currentKpiRow];
  },
  /**
   * 施設別パフォーマンス。
   *
   * ★確定期間: `facility_summaries` をそのまま読む。履歴から再計算しない（D-102）★
   * ★当期（今週・今月）: 詳細テーブルからその場集計する★（`reportKpis()` と同じ方針。
   *   `CleanStatsBucket`（`lib/db/report-mapping.ts`）は元々「施設ごと」の集計を返すので、
   *   `reportKpis()` のように合算せずそのまま1施設1行として使う。指示書T 3-3の「共通化」）
   */
  async facilityPerformance(query: FacilityPerformanceDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★範囲を自分で計算しないこと★★（`reportKpis()` と同じ。月跨ぎ週の地雷。D-071）
    const range = periodRange(query.period, query.year, query.month, query.week);

    /**
     * ★施設名の条件は「解決済みの施設ID配列」で降りてくる★（指示書AC C-1 / 段階5）。
     * ★★`!== null` で分岐すること★★（★罠1★）— `in: undefined` は条件が消えて全件になる。
     * ★`[]`（1件も当たらなかった）はそのまま `in: []` として渡し、0件を返させる★。
     */
    const facilityIdWhere =
      query.facility_ids !== null ? { in: query.facility_ids } : undefined;

    // ---- 施設マスタ（facility_name / serial_number の結合元）----
    //   ★facility_summariesにserial_numberが無いのでrobotsを結合して引く（facilities()と同じ形）★
    //   ★★ロボットが0台の施設（fixturesの施設12）は行ごと除外する★★（推奨案。指示書T 3-1）—
    //   `cleanLogs()` / `schedules()` は例外を投げるが、レポートは「一覧の1行が欠ける」で
    //   済むので例外にしない（一覧APIが1施設の欠落で丸ごと500になるのは重い）。
    //   ★★`deleted_at: null` を外さないこと★★ — 段階0 から付いている条件で、
    //   ★論理削除された施設はもともとこの一覧に出ない★（施設名で解決したIDが論理削除済みでも、
    //   ここで落ちて段階0 と同じ結果になる。★罠7 の扱いは `cleanLogs()` とは事情が違う★）。
    const facilityRows = await prisma.facility.findMany({
      where: {
        deleted_at: null,
        ...(facilityIdWhere !== undefined ? { id: facilityIdWhere } : {}),
      },
      include: { robots: { where: { deleted_at: null } } },
    });
    const facilityById = new Map<
      string,
      { facility_name: string; serial_number: string }
    >();
    for (const facility of facilityRows) {
      const robot = facility.robots[0];
      if (!robot) continue; // ロボット0台の施設は除外
      facilityById.set(facility.id.toString(), {
        facility_name: facility.facility_name,
        serial_number: robot.serial_number,
      });
    }

    // ---- 確定期間: facility_summaries をそのまま読む ----
    //   ★★「要求された期間 ＋ その1つ前（前期比の材料）」の2期間ぶんだけ引く★★
    //   （指示書AC 段階5。★全件読みに戻さないこと★）。粒度は `week` が0かどうかで決まる
    //   （`CLAUDE.md` §4.7）ので、期間キー3つの一致で足りる。
    const prevKey = shiftPeriod(query.period, query.year, query.month, query.week, -1);
    const summaryRows = await prisma.facilitySummary.findMany({
      where: {
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
        OR: [
          { year: query.year, month: query.month, week: query.week },
          { year: prevKey.year, month: prevKey.month, week: prevKey.week },
        ],
      },
    });
    // ★★年次（month=0 && week=0）も読む★★（指示書AQ 決定1。2026-09-30 解禁）—
    //   ★以前はここで年次行を除外していた（「P1では読まない」D-072項4）★。
    //   ★`periodOfSummaryRow()` が `month` も見るようになった（指示書AQ 罠2）ので、
    //   年次行が月次のキーで Map に入って月次の行を上書きすることはない★（`reportKpis()` と同じ理由）
    const confirmedSummaryRows = summaryRows;
    const byFacilityPeriodKey = new Map<string, (typeof confirmedSummaryRows)[number]>();
    for (const row of confirmedSummaryRows) {
      const period = periodOfSummaryRow(row);
      byFacilityPeriodKey.set(
        `${row.facility_id}/${periodMapKey(period, row.year, row.month, row.week)}`,
        row
      );
    }

    // ---- 連続問題日数（★着手前確認A案。D-103。分割キーはfacility_id。指示書V 不具合B★）----
    //   `computeConsecutiveProblemDays()`（`cleanLogs()` と同じ関数・同じ入力の作り方）で
    //   施設単位・同一日OR畳み込み済みの連続日数を出し、`consecutiveProblemDaysAtPeriodEnd()` で
    //   「期間内でその施設の最終清掃ログ」の値を1施設1期間ぶん選ぶ。
    //   ★`ConsecutiveInput`は`facility_id`を持つのでFacilityConsecutiveEntryの代わりに使える★
    //   （以前あった`serial_number: String(facility_id)`という詰め替えは不要になった）
    // ★★入力の作り方は `loadConsecutiveInputs()` の1本に集約してある★★（指示書AC 段階1）—
    //   `cleanLogs()` / `continuousProblems()` と同じ1本。★ここに書き下さないこと★（D-109 の再発防止）。
    //   ★`now` は下の `aggregateCleanStatsByFacility()` とも共有する（指示書V 落とし穴3）★
    // ★★`loadConsecutiveInputs()` には「★要求された期間そのもの★」を渡すこと★★
    //   （★★指示書AC C-2 の地雷★★）— 連続日数は期間の手前まで遡って数えるので、
    //   ★期間で切り落とすと前日のデータが消えて連続が途切れ、L3 が L1 に化ける
    //   （例外もテスト失敗も出ない）★。★遡りぶん（保存された表示上限 +1日）は同関数が足す★
    //   （段階6。★ここで足さないこと★ — 3経路で遡り幅が食い違う。D-109）。
    //   ★`range` が `null`（その月に存在しない週）なら下限なし＝全期間のまま★
    const now = new Date();
    const consecutiveEntries: ConsecutiveInput[] = await loadConsecutiveInputs(
      prisma,
      now,
      { date_from: range?.from ?? null, date_to: range?.to ?? null }
    );
    const consecutiveById = computeConsecutiveProblemDays(consecutiveEntries);

    /**
     * 施設マスタ・判定・連続日数の組み立てを確定期間/当期で共有する。
     * ★引数名を `range` にしないこと★ — 外側の `range`（要求された期間）を隠してしまう。
     */
    const buildItem = (
      facilityId: number,
      kpi: ReportKpi,
      itemRange: { from: string; to: string }
    ): FacilityPerformance | null => {
      const facility = facilityById.get(String(facilityId));
      if (!facility) return null; // ロボット0台の施設は除外
      return {
        facility_id: facilityId,
        facility_name: facility.facility_name,
        serial_number: facility.serial_number,
        operation_rate: kpi.operation_rate,
        launch_rate: kpi.launch_rate,
        completion_rate: kpi.completion_rate,
        error_rate: kpi.error_rate,
        // ★status は judgeFacilityStatus() 経由で決める。直書きしない（CLAUDE.md §7）★
        status: judgeFacilityStatus(kpi.operation_rate.value),
        // ★consecutive_problem_days はA案。期間内に清掃ログが無ければ0★（着手前確認。D-103）
        consecutive_problem_days: consecutiveProblemDaysAtPeriodEnd(
          consecutiveEntries,
          consecutiveById,
          facilityId,
          itemRange
        ),
      };
    };

    /** 前期のサマリ行（前期比の材料）を施設ごとに引く。★確定期間と当期で同じキーを使う★ */
    const prevRowOf = (facilityId: bigint | number) =>
      byFacilityPeriodKey.get(
        `${facilityId}/${periodMapKey(
          periodOfSummaryRow(prevKey),
          prevKey.year,
          prevKey.month,
          prevKey.week
        )}`
      );

    // ---- 確定期間の行を組み立てる ----
    //   ★引いた2期間のうち「要求された期間」の行だけを行にする★
    //   （前期の行は `prevRowOf()` の材料であって、それ自体は返さない）
    const confirmedRows: FacilityPerformanceRow[] = [];
    for (const row of confirmedSummaryRows) {
      if (
        row.year !== query.year ||
        row.month !== query.month ||
        row.week !== query.week
      ) {
        continue;
      }
      const period = periodOfSummaryRow(row);
      if (!range) {
        // ★サマリ行が持つ期間キーは既に妥当なはず（DB由来。API入力の検証とは別）★
        //   ★行があるのに `periodRange()` が解けない＝先方のサマリかこちらの週の数え方が
        //   食い違っている★ので fail-loud のまま残す（行が無ければここには来ない）。
        //   ★★年次の解禁（指示書AQ 決定1）でも条件は緩めない★★ — `periodRange("yearly", …)` は
        //   ★整数の年なら必ず範囲を返す★（担当A。`lib/format/period.ts`）ので、年次の行はここに来ない。
        //   ★週次・月次で null が返るのは本当に異常★（存在しない週の行がサマリにある等）。
        throw new Error(
          `[db] facilityPerformance: facility_summaries(id=${row.id})の期間範囲を算出できませんでした`
        );
      }
      const item = buildItem(
        Number(row.facility_id),
        kpiFromSummaryRatios(row, prevRowOf(row.facility_id)),
        range
      );
      if (!item) continue;
      confirmedRows.push({
        period,
        year: row.year,
        month: row.month,
        week: row.week,
        item,
      });
    }

    // ★★当期（進行中の週・月）でなければ詳細テーブルを1行も読まない★★ —
    //   確定期間はサマリが正であり、★履歴から再計算しない（D-102 / `CLAUDE.md` 禁止事項1）★
    if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
      return confirmedRows;
    }

    // ---- 当期: 詳細テーブルを ★`periodRange()` の範囲で絞って★ その場集計する ----
    //   ★変換は `dbDateRange()`（`toDbJst()` を通す）の1箇所★（指示書AC C-6）。
    const dbRange = dbDateRange(range.from, range.to);
    // ★予定は2表から読む（指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★）★
    const mainRows = await findTimetables(prisma, {
      where: {
        // ★本清掃だけを引く（メモリ側の filter をやめて SQL に降ろした）★
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        ...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
        // ★★`!== null` の分岐は `facilityIdWhere` に閉じ込めてある★★（罠1）
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
      },
      // ★読む列は `CleanStatsTimetableLike` が要求する4つだけ★（★`end_at` は結果待ちの判定用。D-173★）
      select: { id: true, facility_id: true, start_at: true, end_at: true },
    });

    // ★引いた本清掃の id で関連行を引き直す★（`reportKpis()` と同じ作法。★全件読みに戻さない★）
    const mainIdChunks = chunkForInClause(mainRows.map((row) => row.id));
    //   ★清掃結果の取得元は `clean_task_reports`★（指示書AQ 決定2/3。`reportKpis()` と同じ）。
    //   ★★年次の当期（今年）もここを通る★★（指示書AQ 決定1。範囲が1年ぶんになる）
    const [reportChunks, errorReportChunks] = await Promise.all([
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.cleanTaskReport.findMany({ where: { clean_timetable_id: { in: ids } } })
        )
      ),
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            // ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
            select: ROBOT_ERROR_COUNT_SELECT,
          })
        )
      ),
    ]);
    // ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★（指示書AQ §1-4）
    const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());
    // ★エラー率の分子は★本清掃行を指す★robot_error_reports（TeamWorks 第8回 A7。D-114 項2）★ —
    //   事前チェック行を指すものは含めない。`null` / dangling も入らない（決定3）。
    //   ★判定の「正」は `errorCountByMainTimetable()` のまま（SQL 側はその写し。C-5）★
    const mainStartAtById = mainTimetableStartAtById(mainRows);
    const errCountByMainId = errorCountByMainTimetable(
      errorReportChunks.flat(),
      mainStartAtById
    );

    // ★段階2の`CleanStatsBucket`は元々「施設ごと」のMapを返す。ここでは合算せず、
    //   施設ごとにそのまま1行へ変換する（指示書T 3-3の「共通化」）★
    // ★now は上（連続日数の算出）で作った同じインスタンスを使い回す（指示書V 落とし穴3）★
    // ★未実施の判定は `notExecutedFromReport()` を注入する（`reportKpis()` と同じ1本）★
    const buckets = aggregateCleanStatsByFacility(
      mainRows,
      reportByTimetableId,
      notExecutedFromReport,
      errCountByMainId,
      range,
      now
    );

    const currentRows: FacilityPerformanceRow[] = [];
    for (const [facilityId, bucket] of buckets) {
      const item = buildItem(
        facilityId,
        kpiFromCleanStats(bucket, prevRowOf(facilityId)),
        range
      );
      if (!item) continue;
      currentRows.push({
        period: query.period,
        year: query.year,
        month: query.month,
        week: query.week,
        item,
      });
    }

    return [...confirmedRows, ...currentRows];
  },
  /**
   * 連続問題施設（監視側の最後の未実装。指示書W）。
   *
   * ★対応するサマリテーブルが無い（D-102項4）ため、確定期間（先週・先月）も当期
   *   （今週・今月）もどちらも詳細テーブルから算出する（決定2）。★`todayJst()` /
   *   `isCurrentPeriod()` を使った当期の場合分けは書かない★ — ★どちらも同じ1本の処理★。
   *
   * ★★2026-09-13（指示書AC 段階5）: 期間キーを「データの日付から列挙する」のをやめた★★ —
   *   ★引数で期間が決まるので、その1期間だけを組み立てる★（以前は全期間ぶんの行を作り、
   *   ルート側の `selectContinuousProblemRows()` が1期間を選んでいた）。
   *   ★`reportPeriodKeysFromDates()`（`lib/db/report-mapping.ts`）はこの経路では
   *   呼ばなくなった★（純粋関数とテストは残してある。理由はあちらの docstring）。
   */
  async continuousProblems(query: ReportDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★範囲を自分で計算しないこと★★（`reportKpis()` と同じ。月跨ぎ週の地雷。D-071）—
    //   ★`null` ＝ その月に存在しない週★（5週しかない月の第6週など）。
    //   ★★段階0 もその期間の行を1件も作っていない（データの日付から列挙していたため）★★ので、
    //   ★例外にせず空配列を返す★（ルート側は `items: []` を返す。404 にしない）。
    const range = periodRange(query.period, query.year, query.month, query.week);
    if (!range) return [];

    // ★robots を join しない（ContinuousProblem に serial_number は無い。決定5）★
    //   ロボット0台の施設を除外すると、機体を撤去・交換中の施設のエスカレーションが
    //   画面から消える（fail-silent）。facilityPerformance() の buildItem() とは事情が違う。
    // ★★分類の絞り込みはここにかける★★（指示書AK 段階4b）—
    //   ★`continuousProblemsInPeriod()` は `facilityNameById` を回して行を作る★ので、
    //   ★ここで母集団が決まる★（`facilityPerformance()` が `facilityById` で決めているのと同じ形）。
    //   ★★`!== null` で分岐すること★★（★罠1★。`in: undefined` は条件が消えて全件になる）。
    //   ★`[]`（1件も当たらなかった）はそのまま `in: []` として渡し、0件を返させる★。
    const facilityIdWhere =
      query.facility_ids !== null ? { in: query.facility_ids } : undefined;
    const facilityRows = await prisma.facility.findMany({
      where: {
        deleted_at: null,
        ...(facilityIdWhere !== undefined ? { id: facilityIdWhere } : {}),
      },
    });
    const facilityNameById = new Map<number, string>(
      facilityRows.map((f) => [Number(f.id), f.facility_name])
    );

    // ★★`ConsecutiveInput` の作り方は `loadConsecutiveInputs()` の1本に集約してある★★
    //   （`cleanLogs()` / `facilityPerformance()` と同じ1本。★2画面で連続日数が食い違わない
    //   ようにするため、ここに書き下さないこと★。D-103 / D-109 の再発防止。指示書AC 段階1）。
    //   ★`now` は1回だけ作って使い回す（行ごとに作ると境界の行の扱いがぶれる）★
    // ★★`isFutureSchedule()` を外さないこと — 外すと未来7日ぶんが全部「出発失敗」になり
    //   全施設が常時L3になる（D-106 / D-107 / D-110）★★（除外は同関数の中で行っている）
    // ★★段階6: 「★要求された期間そのもの★」を渡す★★（★遡りぶんは同関数が足す★）。
    //   ★`range` はこの上で `null` を弾いてあるので、ここでは必ず期間が決まっている★
    const now = new Date();
    const entries: ConsecutiveInput[] = await loadConsecutiveInputs(prisma, now, {
      date_from: range.from,
      date_to: range.to,
    });
    const consecutiveById = computeConsecutiveProblemDays(entries);

    // ★確定期間も当期も詳細から算出する（対応するサマリが無い。D-102項4 / 決定2）★
    //   ★要求された1期間ぶんだけ組み立てる（指示書AC 段階5）★
    const key: ReportPeriodKey = {
      period: query.period,
      year: query.year,
      month: query.month,
      week: query.week,
    };
    return continuousProblemsInPeriod(
      entries,
      consecutiveById,
      facilityNameById,
      range
    ).map((item): ContinuousProblemRow => ({ ...key, item }));
  },
  /**
   * エラー種別内訳（全社の発生件数・rate）。
   *
   * ★facility版で確定（D-102項4の解決）★ — `customer_error_summaries` は読まない。
   *   `facility_error_summaries`（`facilityPerformance()` と同じテーブル系統）を読み、
   *   同一期間・同一 `error_type` を全施設ぶん足し上げる（`errorBreakdownRowsFromSummary()`）。
   * ★確定期間: サマリの `error_count` をそのまま合算する。履歴から再計算しない（D-102）★
   * ★当期（今週・今月）: ★★本清掃行を指す★★ `robot_error_reports` を期間で絞って
   *   `error_type` ごとに数える★（`countMainErrorTypesInRange()`）—
   *   ★事前チェック行を指すもの / `clean_timetable_id` が `null` のもの / dangling は数えない★
   *   （TeamWorks 第8回 A7。2026-09-11。D-114 項2。決定2・決定3）。
   *   ★★率（`reportKpis()` / `facilityPerformance()`）と分子の集合を同じにするため★★ —
   *   同じレポート画面に定義の違う2つの数字を並べない。
   * ★★期間判定は「そのエラーが指す本清掃行の `start_at` の JST 日付」★★（決定7）—
   *   ★以前は `detectedAtFromError()`（`errored_at ?? created_at`）だった★。
   *   エラー率の分母が清掃予定回数（P-6=B）なので、分子も予定（本清掃）を基準にする。
   * ★`type` / `label` は生値のまま。7分類・日本語への変換はしない（D-077/D-088）★
   */
  async errorBreakdown(query: ReportDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // ★★範囲を自分で計算しないこと★★（`reportKpis()` と同じ。月跨ぎ週の地雷。D-071）
    const range = periodRange(query.period, query.year, query.month, query.week);

    // ★★施設の条件（分類）を `where` に降ろす★★（指示書AK ★段階4b / §7-3★）—
    //   ★D-133 の申し送り③「`facility_error_summaries` を `facility_id` で絞っていない」を
    //   ここで解消している★。★★`!== null` で分岐すること★★（★罠1★。
    //   `in: undefined` は条件が消えて全件になる）。★`[]` はそのまま `in: []` で0件★。
    //   ★`rate`（全エラー件数に対する割合）の分母も絞ったあとの母集団で計算される★ —
    //   ★それが正しい★（「この屋号のエラーのうち何%」が見たい）。
    const facilityIdWhere =
      query.facility_ids !== null ? { in: query.facility_ids } : undefined;

    // ---- 確定期間: facility_error_summaries を読み、施設ぶん足し上げる ----
    //   ★★要求された期間の行だけを引く★★（指示書AC 段階5。★全件読みに戻さないこと★）。
    //   ★★前期の行は要らない★★ — エラー種別内訳に前期比は無い（`ErrorBreakdownItem` に
    //   `prev` が無い）ので、`reportKpis()` / `facilityPerformance()` のような2期間読みはしない。
    const summaryRows = await prisma.facilityErrorSummary.findMany({
      where: {
        year: query.year,
        month: query.month,
        week: query.week,
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
      },
    });
    // ★★年次（month=0 && week=0）も読む★★（指示書AQ 決定1。2026-09-30 解禁）—
    //   ★以前はここで年次行を除外していた（「P1では読まない」D-072項4）★。
    //   ★`errorBreakdownRowsFromSummary()` は中で `periodOfSummaryRow()` を使い、
    //   それが `month` も見るようになった（指示書AQ 罠2）ので、★年次の件数が月次に
    //   足し込まれることはない★（`reportKpis()` と同じ理由）
    const confirmedGrouped = errorBreakdownRowsFromSummary(summaryRows);
    const confirmedRows: ErrorBreakdownRow[] = confirmedGrouped.flatMap((group) =>
      group.items.map((item) => ({
        period: group.period,
        year: group.year,
        month: group.month,
        week: group.week,
        item,
      }))
    );

    // ★★当期（進行中の週・月）でなければ詳細テーブルを1行も読まない★★ —
    //   確定期間はサマリが正であり、★履歴から再計算しない（D-102 / `CLAUDE.md` 禁止事項1）★
    if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
      return confirmedRows;
    }

    // ---- 当期: ★本清掃行を指す★ robot_error_reports を期間で絞って error_type ごとに数える ----
    //   ★期間帰属は「そのエラーが指す本清掃行の `start_at` の JST 日付」★（決定7）なので
    //   `clean_timetables` も読む。★★その本清掃行を先に期間で絞れば、エラー行は
    //   `clean_timetable_id` で引き直せる★★（全件読みに戻さない。指示書AC 段階5）。
    //   ★変換は `dbDateRange()`（`toDbJst()` を通す）の1箇所★（指示書AC C-6）。
    const dbRange = dbDateRange(range.from, range.to);
    // ★予定は2表から読む（指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★）★
    const mainRows = await findTimetables(prisma, {
      where: {
        // ★本清掃だけを引く（メモリ側の filter をやめて SQL に降ろした）★
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        ...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
        // ★★分類の絞り込み（指示書AK 段階4b）★★ — ★`facilityPerformance()` の当期と同じ形★。
        //   ★★`!== null` の分岐は `facilityIdWhere` に閉じ込めてある★★（罠1）
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
      },
      // ★読む列は `MainTimetableLike` が要求する3つだけ★
      select: { id: true, facility_id: true, start_at: true },
    });
    const mainStartAtById = mainTimetableStartAtById(mainRows);

    const errorReportChunks = await Promise.all(
      chunkForInClause(mainRows.map((row) => row.id)).map((ids) =>
        prisma.robotErrorReport.findMany({
          where: { clean_timetable_id: { in: ids } },
          // ★`robot_id` / `robot` を読まない（NULL の行で落ちないため。指示書AU 段階0-4）★
          select: ROBOT_ERROR_TYPE_SELECT,
        })
      )
    );

    // ★★分子は「本清掃行を指すエラー」だけ★★（D-118 / D-114 項2。★事前チェック行を数えない★）—
    //   ★SQL 側の `in` で既にそうなっているが、判定の「正」は
    //   `countMainErrorTypesInRange()` のまま★（SQL 側はその写し。指示書AC C-5）
    const currentItems = errorBreakdownItemsFromCounts(
      countMainErrorTypesInRange(errorReportChunks.flat(), mainStartAtById, range)
    );

    const currentRows: ErrorBreakdownRow[] = currentItems.map((item) => ({
      period: query.period,
      year: query.year,
      month: query.month,
      week: query.week,
      item,
    }));

    return [...confirmedRows, ...currentRows];
  },
};

export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;
