Newer
Older
client_dwatch / lib / db / index.ts
/**
 * データソースの切替
 *
 * 環境変数 `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)と、
 * ★robot_error_reports の error_list_id(エラー一覧の PATCH だけ。D-184)★。
 * ★書き込みは `lib/db/robot-error-write.ts` の `writeRobotErrorColumns()`(4列。事前チェック)と
 *   `writeErrorReportColumns()`(5列。エラー一覧)だけ★(指示書AT / 指示書BD)。
 */
import type {
  AreaCompletionDbQuery,
  AreaCompletionRow,
  CleanAreaDbQuery,
  CleanLogDbQuery,
  CleanLogItem,
  CleanLogRunMeta,
  ContinuousProblemRow,
  Customer,
  DsAlert,
  DsAlertDbQuery,
  DsDevice,
  ErrorBreakdownRow,
  ErrorListItem,
  ErrorReportDbQuery,
  ErrorReportItem,
  ErrorStatusPatch,
  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,
  dsDevices,
  errorBreakdown,
  errorReportRows,
  errorReportTimetables,
  facilities,
  facilityPerformance,
  groups,
  inspectionDetails,
  inspections,
  prechecks,
  reportKpis,
  schedules,
  unscheduledCleanLogs,
} from "@/lib/fixtures";
// ★`lib/fixtures/index.ts` から再輸出していない★(`db.errorLists()` とも名前が重なるので別名で読む)
import { errorLists as errorListFixtureRows } from "@/lib/fixtures/error-lists";
import {
  activeErrorListItems,
  ACTIVE_ERROR_LIST_WHERE,
  ERROR_LIST_SELECT,
  errorListIdsOf,
  errorListIndex,
  loadErrorListIndexByIds,
  type ErrorListIndex,
} from "@/lib/db/error-list-source";
import { CLEAN_TIMETABLE_PRECHECK, MAKER_NAMES } from "@/lib/config";
import { judgeEscalationLevel, judgeFacilityStatus, judgeGrade } from "@/lib/judge";
import {
  compareCleanLogItems,
  completionRateFromReport,
  computeConsecutiveProblemDays,
  consecutiveProblemDaysByFacilityDate,
  facilityDateKey,
  scheduledCleanLogRowKey,
  unscheduledCleanLogItem,
  formatModelName,
  isFutureSchedule,
  isUnscheduledReport,
  latestReportByTimetableId,
  notExecutedFromReport,
  unscheduledCleanLogRowKey,
  type ConsecutiveInput,
  type RobotErrorLike,
} from "@/lib/db/clean-log-mapping";
import { chunkForInClause } from "@/lib/db/chunked";
import { scheduledTimetableId } from "@/lib/db/timetable-link";
import {
  loadUnscheduledCleanLogRows,
  loadUnscheduledCleanReportsByWhere,
  unscheduledCleanLogRowsFromReports,
  unscheduledCleanLogWhere,
} from "@/lib/db/unscheduled-clean-log-source";
import { UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link";
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,
  isVisibleErrorRow,
  type ErrorReportKey,
  type ErrorReportRowSource,
  type ErrorTimetableRef,
} from "@/lib/db/error-report-mapping";
import { dsAlertsFromErrorRows, dsAlertWhere } from "@/lib/db/ds-alert-mapping";
import { filterSortErrorReportKeys } from "@/lib/error-reports/query";
import { FACILITY_SECTION_VISIBLE_WHERE, isVisibleFacilitySection } from "@/lib/db/visibility";
import { writeErrorReportColumns, 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_DS_ALERT_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 {
  isKnownRobotErrorStatus,
  knownRobotErrorStatusCodes,
  PRECHECK_COLUMN_COUNT,
  precheckItemFromErrorRow,
  precheckMinutesFromCode,
  statusColumnsFromPrecheckRows,
} from "@/lib/db/precheck-mapping";
import { logEvent } from "@/lib/logger";
import { connectionStatusFromControlStatus, isListedControlStatus } from "@/lib/db/ds-status";
import {
  aggregateCleanStatsByFacility,
  aggregateUnscheduledStatsByFacility,
  consecutiveProblemDaysAtPeriodEnd,
  continuousProblemsInPeriod,
  countMainErrorTypesInRange,
  countUnscheduledErrorTypesInRange,
  customerSummariesByPeriodKey,
  errorBreakdownItemsFromCounts,
  errorBreakdownRowsFromSummary,
  errorCountByMainTimetable,
  kpiFromCleanStats,
  kpiFromSummaryRatios,
  mainTimetableStartAtById,
  mergeCleanStatsByFacility,
  mergeErrorTypeCounts,
  periodMapKey,
  periodOfSummaryRow,
  settledMainStartAtById,
  sumCleanStatsBuckets,
} from "@/lib/db/report-mapping";
import { findUnscheduledErrors, findUnscheduledReports } from "@/lib/db/unscheduled-report-source";
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()` に、終わった期間かは `isPastPeriod()` に、期間の範囲は `periodRange()` に任せる★。
//   ★★「今日」から期間キーを自分で組み立てないこと★★(月跨ぎ週の帰属を知っているのは
//   `weekOfMonth()` だけ。`CLAUDE.md` §4.7 / D-071)。
import { isCurrentPeriod, isPastPeriod, periodRange, shiftPeriod } from "@/lib/format/period";
import { stripMakerPrefix } from "@/lib/format/robot-model";
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()`)と終わった期間の判定(`isPastPeriod()`)の中だけで使う)。
 *
 * ★★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[]>;
  /**
   * 予定なしの清掃詳細ログ1行(`GET /clean-logs/unscheduled/{report_id}/advice`。指示書BI §2-3)。
   *
   * - `report_id` が一致する予定外の行(`clean_timetable_id` が 0 か NULL)で、施設・清掃エリアが削除済みでない行を返す
   * - 予定のあるレポート・存在しない id・削除済みの施設・清掃エリア → `null`(ルートが404)
   * - DB の照合順序で当たっても JS の `===` で一致しなければ `null`(D-4)
   */
  unscheduledCleanLog(reportId: string): Promise<CleanLogItem | null>;
  /**
   * ★清掃ログの id → { 清掃エリア, 予定の終了 }★(ダッシュボードの直近モード専用。D-159 / 指示書AV)。
   * ★`cleanLogs()` と同じ引数を渡すこと★(同じ `where`。突き合わせは `pickLatestRunPerSection()`)。
   * ★`cleanLogs()` が返さない id(未来の予定)を含んでよい★。逆(`cleanLogs()` の行に対応が無い)はその行が選ばれず、
   *   ルートが `data.anomaly` を出す(`missingRunMetaIds()`。指示書BB A2・D-182。旧: 例外)
   *
   * ★★`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()` で代用しないこと★★ — ★解決は行の可視性と別の責務★(指示書BB D3)。
   *   行は各メソッドの可視性の条件(`FACILITY_SECTION_VISIBLE_WHERE`)で外れる。
   */
  facilityNameIndex(): Promise<FacilityNameRef[]>;
  /**
   * ★分類(ホールディングス/屋号)→ 施設ID の解決★にだけ使う、施設マスタの最小限の一覧
   * (指示書AK 段階3 §6-3)。
   *
   * ★★`facilityNameIndex()` と同じ立ち位置★★ — ★`facilities()` で代用しないこと★
   *   (あちらは `deleted_at` で絞っている。★罠7★。★解決は行の可視性と別の責務★(指示書BB D3)。
   *   行は各メソッドの可視性の条件で外れる。理由は `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件も当たらなかった)。
   *
   * ★★返すのは事前チェック異常の行(`severity: "precheck"`)だけ★★(ユーザー判断 2026-10-29)—
   *   ★出発失敗(旧 `launch_failed`)は返さない★。出発失敗は `cleanLogs()` の `departed === false` で見る。
   *   ★期間の基準は「親の本清掃行の `start_at`」★(`PrecheckDbQuery` の型コメント)。
   *
   * ★★対応状況(`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・`0`。TeamWorks 回答 2026-10-27。`scheduledTimetableId()`)でない
   *   3. その `clean_timetables` 行があり、`precheckMinutesFromCode(precheck)` が null でない(=事前チェック行)
   *   4. その事前チェック行の `parent_id` が指す本清掃行がある(★無ければ例外=500★。`prechecks()` と同じ)
   *   5. ★その行の `status` が 0/1/2(`isKnownRobotErrorStatus()`)★(指示書BB U3・D-182。`prechecks()` の一覧に出ない行は404)
   *   6. ★施設・清掃エリアが削除済みでない(エラー行・親の本清掃のどちらも)★(指示書BB U1・D-182。一覧に出ない行は404)
   * ★1〜3・5・6 のどれかを満たさなければ `null`(=404)★。
   * ★期間・施設では絞らない★(一覧の絞り込みであって行の性質ではない)。
   * ★行の組み立ては `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 を問わない)。★ただし削除済みの施設・清掃エリアの行は外す(`isVisibleErrorRow()`。指示書AX U8・D-180 項11)★
   * ★2段階で読む★(D8): キーだけを SQL で `status IN`・`facility_id IN`・期間・★施設と清掃エリアの可視性(`ERROR_ROW_VISIBLE_WHERE`。写し)★で絞って読み、
   *   `filterSortErrorReportKeys()` で絞り込み(★見えない行(`visible: false`)を外すのもここ。正★)・並べ替え → ページの `error_id` だけ列を足して読む。
   * ★`total` は絞り込み後・ページ分け前の件数★(`meta.total`)。
   */
  errorReports(query: ErrorReportDbQuery): Promise<{ items: ErrorReportItem[]; total: number }>;
  /**
   * `PATCH /errors/{error_id}/status` の前後の読み取り。★`error_id === id` の行があり、見える行(`isVisibleErrorRow()`。削除済みの施設・清掃エリアでない。指示書AX U8)なら返す(事前チェックかどうかを問わない)★。
   * ★大小文字は JS の `===` で決める★(D-4。`precheckItemById()` と同じ)。★期間・状態・施設の絞り込み条件では絞らない★(★可視性だけは見る★)。★見えない行は null → `PATCH /errors/{error_id}/status` は404★
   * ★`status` が 0/1/2 以外の行も null(404)★(`isKnownRobotErrorStatus()`。指示書BB U3・D-182。★指示書AW §11 Q3 の「500(fail-loud)」を上書き★)
   */
  errorReportById(id: string): Promise<ErrorReportItem | null>;
  /**
   * 5列(4列+`error_list_id`。D-184)を書く(`writeErrorReportColumns()` の1本。★書き込み用の接続★)。
   * ★`error_list_id` を書けるのはこのメソッドだけ★(`updatePrecheckErrorColumns()` は4列のまま。指示書BD §5-4)。
   * ★`matched` で存在を判定しないこと★ — 存在確認は書く前に `errorReportById()` で行う
   */
  updateErrorReportColumns(errorId: string, patch: ErrorStatusPatch): 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★)—
   *   絞りをやめると ★一覧に出ないデバイスの備考が保存できてしまい、どこからも参照されない
   *   行がストアに残る★。★それはこのチェックが防いでいる当のもの★。
   *   ★一覧と同じく `controls.status` が 0/1 以外の制御盤は false(`isListedControlStatus()`。指示書BB A1・D-182)★
   *
   * ★★当たり外れは呼び出し先の実装が 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>;
  /**
   * D-Sec アラート(`GET /ds-alerts`・ダッシュボード)。
   * `robot_error_reports` の `D_SEC_ALERT_ERROR_TYPES` の行(指示書AX)。削除済みの施設・清掃エリアの行は出さない(U8)。
   * ★並べ替え・絞り込み(type / status / 分類)はルート側★。
   * ★引数は必須(指示書AC C-4 項4)★。ダッシュボードはバナー(解消でないものだけ見る)のため `open_only: true`、
   *   `/ds-alerts` は `summary.resolved` を数えるため `open_only: false`(指示書BB A6・D17)。
   */
  dsAlerts(query: DsAlertDbQuery): 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 /error-lists`。指示書BD §3-1・§5-3。D-184)。
   *
   * ★有効な行だけ(`inactive = 0` かつ `deleted_at IS NULL`)・★id の昇順★で返すこと★
   *   (規則の正は `activeErrorListItems()`。Prisma 版の `where` / `orderBy` は写し)。
   * ★名前解決(`ErrorReportItem.error_list`)は絞らない★ — こちらは `errorReports()` / `errorReportById()` の中
   *   (`lib/db/error-list-source.ts`)。★この関数の結果で名前解決しないこと★(無効化した id の行が「不明」に化ける)。
   * ★引数を取らない★(ページングしない。`groups()` と同じ)。
   */
  errorLists(): Promise<ErrorListItem[]>;
  /**
   * 屋号一覧(`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)。
 * ★見えない行(`isVisibleErrorRow()` が false。削除済みの施設・清掃エリア)も同じく飛ばす★ —
 *   1段目と2段目の間に施設・清掃エリアが削除された場合(指示書AX U8。`meta.total` の扱いは上と同じ)。
 * ★`status` が既知(0/1/2)でなくなった行も飛ばす★(`isKnownRobotErrorStatus()`。指示書BB U3・表3 S7。`meta.total` の扱いは上と同じ)。
 * ★`errorLists` は `rows` の `error_list_id` を★絞らずに★引いた索引★(指示書BD §5-3。fixtures 版は全行の索引)。
 */
function errorReportItemsInPageOrder(
  pageIds: readonly string[],
  rows: readonly ErrorReportRowSource[],
  timetables: readonly ErrorTimetableRef[],
  errorLists: ErrorListIndex
): 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);
    // 1段目と2段目の間に消えた行・見えなくなった行・状態が既知でなくなった行(上の docstring)
    if (!row || !isVisibleErrorRow(row) || !isKnownRobotErrorStatus(row.status)) continue;
    // ★予定外(NULL・`0`。TeamWorks 回答 2026-10-27)は予定を引かない(`scheduledTimetableId()` の1本)★
    const timetableId = scheduledTimetableId(row.clean_timetable_id);
    const timetable = timetableId === null ? undefined : timetableById.get(timetableId.toString());
    items.push(errorReportItemFromRow(row, timetable, errorLists));
  }
  return items;
}

// ---------------------------------------------------------------- fixtures

const fixtureSource: DataSource = {
  /**
   * ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★(指示書AC 段階2 D-5)。
   * ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
   * 絞り込みの中身は `filterFixtureCleanLogs()`(純粋関数)に置いてある。
   */
  async cleanLogs(query: CleanLogDbQuery) {
    // ★予定外タスクの行(`unscheduledCleanLogs`。指示書BG §2-1)も混ぜて、Prisma 版と同じ順に並べる★ —
    //   `cleanLogs`(予定のある行)には混ぜていない(清掃結果・エリア・シード等の fixtures が予定の id を鍵に作られているため)。
    //   ★`scheduled_at` だけで安定に並べ直す★(同着は今の並びのまま・予定外は後ろ)— 予定のある行どうしの順序を変えない
    return filterFixtureCleanLogs([...cleanLogs, ...unscheduledCleanLogs], query).sort((a, b) =>
      a.scheduled_at === b.scheduled_at ? 0 : a.scheduled_at < b.scheduled_at ? 1 : -1
    );
  },
  async unscheduledCleanLog(reportId: string) {
    return unscheduledCleanLogs.find((r) => r.report_id === reportId) ?? null;
  },
  /**
   * ★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) {
    // ★予定外タスクの行(`unscheduledCleanLogs`)のメタも返す★(指示書BG §2-2。`cleanLogs()` が混ぜる行と同じ集合)—
    //   返さないと予定外の行が「メタの無い行」になり、直近の候補から黙って落ちて `data.anomaly` が出る。
    //   ★鍵は `row_key`★(予定外の行は id が null)。★予定外の終了も開始とみなす★(fixtures に終了時刻が無い。D10 と同じ)
    return filterFixtureCleanLogs([...cleanLogs, ...unscheduledCleanLogs], query).map((row) => ({
      row_key: row.row_key,
      section_key: `fixture-robot:${row.serial_number}`,
      planned_end_at: row.scheduled_at,
    }));
  },
  /**
   * ★`deleted_at` の概念が fixtures には無いので全件★(Prisma 版も絞らない。罠7(行の可視性は別。D-182))。
   * ★`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(行の可視性は別。D-182))。
   *
   * ★所属は `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 が一致する行(★fixtures の行はすべて事前チェック異常★。2026-10-29)。
   */
  async precheckItemById(id: string) {
    const row = prechecks.find((p) => p.id === id);
    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,
      // ★名前解決は絞らない★(全行の索引。Prisma 版の `id IN` と結果は同じ。指示書BD §5-3)
      errorListIndex(errorListFixtureRows)
    );
    return { items, total: keys.length };
  },
  /**
   * ★Prisma 版と同じ判定★ — `error_id === id`(大小文字を区別する。D-4)の行があり、
   * ★見える行(`isVisibleErrorRow()`。削除済みの施設・清掃エリアでない。指示書AX U8)★なら返す
   */
  async errorReportById(id: string) {
    const found = errorReportRows.find((r) => r.error_id === id);
    // ★状態は上書き表を反映した後の値で見る★(`status` が 0/1/2 以外なら404。指示書BB U3)
    const row = found ? withFixtureErrorReportOverride(found) : undefined;
    if (!row || !isVisibleErrorRow(row) || !isKnownRobotErrorStatus(row.status)) return null;
    const [item] = errorReportItemsInPageOrder(
      [id],
      [row],
      errorReportTimetables,
      errorListIndex(errorListFixtureRows)
    );
    return item ?? null;
  },
  async updateErrorReportColumns(errorId: string, patch: ErrorStatusPatch) {
    // ★Prisma 版と同じ写し(`robotErrorColumnsFromErrorPatch()`。5列)を通す★
    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(query: DsAlertDbQuery) {
    // ★Prisma 版と同じ純粋関数★(指示書AX D13)。★エラー一覧の上書き表を反映してから作る★ —
    //   `PATCH /errors/{error_id}/status` で書いた対応状況が D-Sec にも出る(U4)
    return dsAlertsFromErrorRows(errorReportRows.map(withFixtureErrorReportOverride), query);
  },
  /**
   * ★★`notImplemented` にしないこと★★(指示書AK 落とし穴9)—
   *   絞り込みUI(段階7)は `USE_FIXTURES=true` でも開く。ここで落とすと
   *   ★選択肢が取れず、全画面の絞り込みが出なくなる★。
   */
  async groups() {
    return groups;
  },
  /** ★Prisma 版と同じ純粋関数★(有効な行だけ・id の昇順。指示書BD §3-1)。fixtures の131行は全部有効 */
  async errorLists() {
    return activeErrorListItems(errorListFixtureRows);
  },
  /**
   * ★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(
        // ★予定外タスクの行も入れる★(指示書BG §2-3。Prisma 版の `loadConsecutiveInputs()` の3本目と揃える)
        rescueInputsFromCleanLogs([...cleanLogs, ...unscheduledCleanLogs], 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`。
   *   ★面積の行は Prisma 版では削除済みの施設・清掃エリアを外す(fixtures に削除の概念は無い)。D-182★)。
   *
   * ★番人は常に 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 で開発してください。`
  );
}

/**
 * ★清掃エリアの有効なロボットが1台でなければ `data.anomaly` を出す(清掃エリアごとに1回)★(指示書BB U2・D7・D-182)。
 *
 * P1 は1清掃エリア=1ロボット(`CLAUDE.md` §8)。★0台でも例外にしない★(旧: 例外で一覧全体が500)—
 *   呼び出し側はシリアル・機種を `null` にする。★2台以上は `id` 昇順の先頭を採る★(`orderBy: { id: "asc" }`。意味を推測しない)。
 * ★`seen` は呼び出し1回(1リクエストの1メソッド)につき1つ★ — 同じ清掃エリアの行が10行あっても1回だけ出す。
 * ★データ層はルートを知らないので `route` / `method` は載せない★(`precheck_rounds_per_main_run` と同じ)。
 * `cleanLogs()`・`schedules()` の2か所がこれを呼ぶ(ログの出し方を1本にする)。
 * (旧: `prechecks()` の出発失敗の行も呼んでいた。2026-10-29 にアラートから出発失敗を外した)
 */
function reportSectionRobotCount(sectionId: bigint, count: number, seen: Set<string>): void {
  if (count === 1) return;
  const key = sectionId.toString();
  if (seen.has(key)) return;
  seen.add(key);
  logEvent("warn", {
    event: "data.anomaly",
    metric: "active_robots_per_section",
    expected: 1,
    actual: count,
    section_id: key,
  });
}

/**
 * ★`prismaSource.cleanLogs()` と `prismaSource.cleanLogRunMeta()` が共有する `where`★(指示書AV 段階3-3 / 4-8)。
 *
 * ★★2本で別々に書かないこと★★ — 片方だけ直すと、`pickLatestRunPerSection()` の id の突き合わせが
 *   静かにずれる(行に対応するメタが無ければその行が選ばれずログだけ、メタが余れば無視されるだけ)。
 * ★中身は `cleanLogs()` にあった `where` をそのまま移したもの★(1文字も変えていない。コメントも一緒に移した)。
 * ★2026-10-19 に削除済みの施設・清掃エリアの条件を足した(指示書BB U1・D-182)★。
 */
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,
    // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★ —
    //   単一ID(`/clean-logs/{id}/*`)も同じ条件なので、見えない行の `{id}` は0件 → 404
    ...FACILITY_SECTION_VISIBLE_WHERE,
    // ★★単一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本が消える)。
   *
   * ★★予定外タスクの行も返す★★(指示書BG §1・§2-1。ユーザー指示 2026-10-29)— `clean_task_reports` のうち
   *   `clean_timetable_id` が 0 か NULL の行を1件1行で(`loadUnscheduledCleanLogRows()`。`started_at` で期間を絞る・
   *   削除済みの施設・清掃エリアは除く)。`id: null`・`row_key: "rp-<report_id>"`・事前チェック4列は null。
   *   ★単一ID(`query.id`)の取得では読まない★(予定外の行に `{id}` は無い)。連続日数は施設 × 日付で引く。
   *   ★ダッシュボード(`app/api/dashboard/route.ts`)もこのメソッドを読むので、予定外の行が届く★(指示書BG §2-2)
   *
   * ★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分前)… 同上
   *
   * ★予定の4テーブルに `deleted_at` は無いが、行の施設・清掃エリアが削除済みなら出さない★
   *   (`mainTimetableWhere()` の `FACILITY_SECTION_VISIBLE_WHERE`。指示書BB U1・D-182)。
   *   `clean_timetables.inactive` では絞らない(第1歩・第2歩と同じ方針。指示書R 禁止事項4/5)。
   *   ★施設名の解決は絞らない(罠7。理由は `lib/db/visibility.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 },
              // ★2台以上なら `id` 昇順の先頭を採る(意味を推測しない。指示書BB D7)★
              orderBy: { id: "asc" },
              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・`0`(予定外。TeamWorks 回答 2026-10-27)のレポートは `in` に一致しない★ので、ここで引けない
    //   (`ids` は予定の行の id。`0` は入らない。入っても `latestReportByTimetableId()` が予定外として落とす)
    //   (=その本清掃は「レポートの行が無い」=出発失敗として出る。指示書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` の
    //   「本清掃は成功」(`main_run_succeeded`)で選ばれる行が食い違う。D-103 / D-171)
    const reportByTimetableId = latestReportByTimetableId(taskReports);

    const errorsByTimetableId = new Map<string, RobotErrorLike[]>();
    for (const report of errorReports) {
      // ★nullable(清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`)★
      //   ★`0` も予定外★(TeamWorks 回答 2026-10-27。判定は `scheduledTimetableId()` の1本)
      const timetableId = scheduledTimetableId(report.clean_timetable_id);
      if (timetableId === null) continue;
      const k = key(timetableId);
      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);
    }

    // ★予定時刻がまだ来ていない本清掃は**行ごと結果に含めない**★(`areaCompletions()` と同じ判定)
    //   清掃詳細ログ(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段構え
    // ★有効なロボットが1台でない清掃エリアのログは、清掃エリアごとに1回だけ出す(指示書BB U2)★
    const reportedSections = new Set<string>();
    const drafts = mainRows.map((row) => {
      // ★P1は1清掃エリア=1ロボットの前提(CLAUDE.md §8)★。★0台でも例外にしない(U2。D-182)。
      //   シリアル・機種は null、ログを清掃エリアごとに1回★(`reportSectionRobotCount()`。2台以上は `id` 昇順の先頭)
      reportSectionRobotCount(row.section_id, row.section.robots.length, reportedSections);
      const robot = row.section.robots[0] ?? null;

      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)に切り出してある★ —
      //   ダッシュボード・レポートの未実施・`main_run_succeeded` が同じ判定を共有するため(D-103 項1)。
      //   (旧: `prechecks()` の `launch_failed` も共有していた。2026-10-29 にアラートから出発失敗を外した)
      //   ★条件を**ここに書かないこと**★(書くと規則が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),
        // ★予定のある行(指示書BG §2-1)★ — キーは `scheduledCleanLogRowKey()` の1本
        row_key: scheduledCleanLogRowKey(row.id),
        report_id: null,
        unscheduled: false,
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        // ★清掃エリアに有効なロボットがいなければ null(画面は「-」・CSV は空のセル。指示書BB U2・D6)★
        model_name: robot === null ? null : formatModelName(robot.device.maker_id, robot.device.model_name),
        serial_number: robot === null ? null : 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` の絞り込みと基準時刻を揃える)
    //   ★施設の絞り込みも渡す(指示書BB A6)★ — 単一ID(`/advice`・`/areas`)はルートが `null` を渡している(U4。索引の後)
    const consecutiveRange: ConsecutiveDbRange = {
      date_from: query.date_from,
      date_to: query.date_to,
      facility_ids: query.facility_ids,
    };
    const consecutiveInputs = await loadConsecutiveInputs(prisma, now, consecutiveRange);
    const consecutiveById = computeConsecutiveProblemDays(consecutiveInputs);
    // ★予定外の行は予定の id を持たないので、施設 × 日付で引く★(指示書BG §2-1・§2-3。同じ施設の同じ日は全行同じ値)。
    //   ★予定のある行は今までどおり id で引く★(結果待ちの行は入力から外れて 0 のまま。表2 C10)
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(consecutiveInputs);

    const scheduledItems = 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),
      };
    });

    // ★予定外タスクの行(指示書BG §1・§2-1)★ — `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行。
    //   ★単一ID(`/clean-logs/{id}/*`)は予定の id なので予定外の行は引かない★(予定外の行に `{id}` は無い)
    const unscheduledItems =
      query.id !== null
        ? []
        : (await loadUnscheduledCleanLogRows(prisma, query)).map((row) => {
            // ★有効なロボットが1台でない清掃エリアのログは、予定のある行と同じ `seen` で清掃エリアごとに1回だけ★(指示書BB U2)
            reportSectionRobotCount(row.section_id, row.robot_count, reportedSections);
            const scheduledAt = row.report.started_at_iso;
            const consecutive =
              consecutiveByFacilityDate.get(facilityDateKey(row.context.facility_id, scheduledAt)) ?? 0;
            return unscheduledCleanLogItem(row.report, row.context, consecutive);
          });

    // ★fixtures と同じ「新しい順」で返す(lib/fixtures/clean-logs.ts:227-233)★
    //   APIルート側は並べ替えないので、順序はデータソースの責任。
    //   ★並べ方は `compareCleanLogItems()` の1本★(予定のある行どうしは今までと同じ: `scheduled_at` 降順 → `id` 昇順)
    return [...scheduledItems, ...unscheduledItems].sort(compareCleanLogItems);
  },
  async unscheduledCleanLog(reportId: string) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const now = new Date();

    const rows = await loadUnscheduledCleanReportsByWhere(prisma, {
      ...UNSCHEDULED_TIMETABLE_ID_WHERE,
      ...FACILITY_SECTION_VISIBLE_WHERE,
      report_id: reportId,
    });
    const mapped = unscheduledCleanLogRowsFromReports(rows);
    const row = mapped.find((r) => r.report.report_id === reportId);
    if (!row) return null;

    const d = row.report.started_at_iso.slice(0, 10);
    const consecutiveInputs = await loadConsecutiveInputs(prisma, now, {
      date_from: d,
      date_to: d,
      facility_ids: [row.context.facility_id],
    });
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(consecutiveInputs);
    const consecutive =
      consecutiveByFacilityDate.get(
        facilityDateKey(row.context.facility_id, row.report.started_at_iso)
      ) ?? 0;

    const reportedSections = new Set<string>();
    reportSectionRobotCount(row.section_id, row.robot_count, reportedSections);
    return unscheduledCleanLogItem(row.report, row.context, consecutive);
  },
  /**
   * ★清掃ログの 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, unscheduledRows] = await Promise.all([
      findTimetables(prisma, {
        where: mainTimetableWhere(query),
        // ★`id: true` は必須★(2表の重複を id で除くため。`TimetableFindManyArgs` が型で強制している)
        select: { id: true, section_id: true, end_at: true },
      }),
      // ★★予定外タスクのメタ★★(指示書BG §2-2)— ★`where` は `cleanLogs()` の予定外の行と同じ `unscheduledCleanLogWhere()`★
      //   (別に書くと突き合わせが静かにずれる。4-8 と同じ理由)。★単一ID(`query.id`)はダッシュボードでは使わない★
      //   開始 = `started_at`(行の `scheduled_at`)、終了 = `ended_at`(★null なら開始で代用★。指示書BG §2-2)
      prisma.cleanTaskReport.findMany({
        where: unscheduledCleanLogWhere(query),
        select: { report_id: true, clean_timetable_id: true, section_id: true, started_at: true, ended_at: true },
      }),
    ]);
    return [
      ...rows.map((row) => ({
        // ★鍵は `row_key`★(`cleanLogs()` と同じ `scheduledCleanLogRowKey()`。指示書BG §2-2)
        row_key: scheduledCleanLogRowKey(row.id),
        section_key: `section:${row.section_id.toString()}`,
        planned_end_at: jstIsoFromDb(row.end_at),
      })),
      // ★SQL の写しが外れても予定のある行を混ぜない★(`isUnscheduledReport()` = `scheduledTimetableId()`)
      ...unscheduledRows.filter(isUnscheduledReport).map((row) => ({
        row_key: unscheduledCleanLogRowKey(row.report_id),
        section_key: `section:${row.section_id.toString()}`,
        // ★DATETIME は JST の壁時計値★(`jstIsoFromDb()` を通す。9時間を足さない・引かない)
        planned_end_at: jstIsoFromDb(row.ended_at ?? row.started_at),
      })),
    ];
  },
  /**
   * ★施設名 → 施設ID の解決★用の最小限の一覧(指示書AC 段階2 D-6)。
   *
   * ★★`facilities()` を流用しないこと★★ — あちらは `where: { deleted_at: null }` を付けている。
   *   ★解決は行の可視性と別の責務★(指示書BB D3)— 行は各メソッドの可視性の条件で外れる(★罠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 }` を付けている。
   *   ★解決は行の可視性と別の責務★(指示書BB D3)— 行は各メソッドの可視性の条件で外れる(★罠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()`★ で除外。
   *
   * ★★ロボット0台の扱いは要らない★★ — この API は ★ロボットではなくエリアを列挙する★ので、`section_name` があれば足りる
   *   (`AreaCompletionRow` は `serial_number` を持たない)。★ロボット0台の施設を落とさないこと★。
   *   (`cleanLogs()` も 0台で例外にしなくなった。指示書BB U2・D-182)
   *
   * ★★性能(指示書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,
        // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★
        ...FACILITY_SECTION_VISIBLE_WHERE,
        // ★★`!== 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,
          // ★結果待ち(D-173。指示書BB A3)★ — 判定は `isResultPending()` の1本(`cleanLogs()` と同じ)。
          //   ★`now` は上で作った同じインスタンス★(未来の予定の除外と基準時刻を揃える。表6 A8)。
          //   ★`end_at` は `start_at` と同じ JST の壁時計値なので `fromDbJst()` を通す★
          result_pending: isResultPending({
            startAt: fromDbJst(row.start_at),
            endAt: fromDbJst(row.end_at),
            now,
            hasReport: !notExecutedFromReport(report),
          }),
        };
      });
  },
  /**
   * アラート(事前チェック)一覧(F-03)。
   *
   * ★★返すのは事前チェック異常の行(`severity: "precheck"`)だけ★★ —
   *   ★事前チェック行に紐づく `robot_error_reports` の行★を1件ずつ返す。
   *   ★出発失敗(本清掃に `clean_task_reports` の行が無い清掃)は返さない★(ユーザー判断 2026-10-29「2で進めてください」)。
   *   旧(指示書S / D-103)は出発失敗も `severity: "launch_failed"` の行として返していた。
   *   ★出発失敗は清掃詳細ログ(`cleanLogs()` の `departed === false`)とダッシュボード(未実施)で見る★ —
   *   判定は `notExecutedFromReport()` の1本のまま(ここからは呼ばない)。
   *   ★「本清掃行を指すエラー行」を拾って出発失敗の代わりに出さないこと★(D-104。事前チェック行のエラーだけ)。
   *
   * ★★`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` に書き換えられる。
   *
   * ★施設・清掃エリアが削除済みの行は出さない★(予定の `where` の `FACILITY_SECTION_VISIBLE_WHERE` と、
   *   エラー行の `isVisibleErrorRow()`。指示書BB U1・D-182)。`clean_timetables.inactive` では絞らない(第1〜3歩と同じ方針)。
   *
   * ★フィルタ・ソート・ページングはここでやらない★(`DataSource` の設計方針)—
   *   `app/api/prechecks/route.ts` が `sortPrechecks()`(D-052)等で行う。
   *
   * ★★性能(指示書AC 段階4)★★: 日付範囲・施設ID・本清掃(`precheck = 0`)を SQL に降ろしている。
   *   ★索引は最初から張られている★(`@@index([start_at])` / `@@index([facility_id])` / `@@index([parent_id])`)。
   *
   * ★★期間の基準は「親の本清掃行の `start_at`」★★(`PrecheckDbQuery` の型コメント)。引く順は
   *   ★本清掃行(期間・施設で絞る)→ その子の事前チェック行(`parent_id`)→
   *   そのエラー行(`clean_timetable_id`)→ 行になったエラーの親の本清掃のレポート(`main_run_succeeded`)★。
   *   ★★エラー行を `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` を外さないこと★★。外すと期間で絞った事前チェック行も「親」に入り、
    //   期間の基準(親の本清掃の `start_at`)が崩れる。
    //   ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`)★
    //   ★読む列は2つだけ★ — 下で使うのは `id`(子を引く・親を引き当てる)と `start_at`(`scheduled_at`)のみ
    //   (旧: 出発失敗の行の施設名・シリアルのために `facility` / `section.robots` を include していた。2026-10-29 に不要になった)
    const timetables = await findTimetables(prisma, {
      where: {
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
        // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★
        ...FACILITY_SECTION_VISIBLE_WHERE,
        // ★`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)) } }
          : {}),
      },
      select: { id: true, start_at: true },
    });

    // BigInt はキーとして === で比較できないので文字列化して引く
    const key = (id: bigint) => id.toString();

    // ★★`timetableById` は**本清掃行だけ**を持つ★★ —
    //   下の `mainRow` の引き当てはこの Map で行う。★期間の外の本清掃行は入らない★ので、
    //   ★親が期間外のエラー行は結果に出ない★。
    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]));

    // ★エラー行は**事前チェック行の id**でしか引かない★ —
    //   ★本清掃行を指すエラー行は拾わない★(D-104)ので要らない。
    //   ★facility は FK があるので include してよい(落とし穴1 は FK が無い列の話)★
    const errorReports = (
      await Promise.all(
        chunkForInClause(precheckRows.map((row) => row.id)).map((ids) =>
          prisma.robotErrorReport.findMany({
            // ★`status IN (0,1,2)` は写し★(正は下のループの `isKnownRobotErrorStatus()`。指示書BB U3)
            where: { clean_timetable_id: { in: ids }, status: { in: knownRobotErrorStatusCodes() } },
            // ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
            select: ROBOT_ERROR_PRECHECK_ROW_SELECT,
          })
        )
      )
    ).flat();

    // ---- 事前チェック行のエラー → severity: "precheck"(行の材料を先に集める)
    //   ★レポート(`main_run_succeeded` の材料)は、行になったエラーの親の本清掃の id でだけ引く★(下)
    const candidates: {
      report: (typeof errorReports)[number];
      precheckMinutes: number;
      mainRow: (typeof timetables)[number];
    }[] = [];
    // ★`status` が 0/1/2 以外で外した行の件数★(ループの後でログを1回。指示書BB U3・表3 S2)
    let unknownStatusCount = 0;
    for (const report of errorReports) {
      // ★nullable(清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`)★
      //   ★事前チェックのアラートではないので対象外にする★(指示書S 3-2 項6 の推奨)。
      //   `scheduled_at`(本清掃の予定時刻)を決められないため、そもそも行を作れない。
      //   ★★`in: [...]` で引いているので SQL 側でも落ちるが、この行は残す★★ —
      //   型の絞り込み(`bigint | null` → `bigint`)に要るうえ、規則を1箇所で読めるようにする。
      //   ★`0` も予定外★(TeamWorks 回答 2026-10-27。判定は `scheduledTimetableId()` の1本)
      const timetableId = scheduledTimetableId(report.clean_timetable_id);
      if (timetableId === null) continue;
      // ★`status` が 0/1/2 以外の行は出さない(例外にしない。`summary` にも数えない)★(指示書BB U3・D-182)—
      //   ★これが正★(上の SQL の `status IN` は写し)。`precheckItemFromErrorRow()` の変換は 0/1/2 以外で例外になるので、その前に外す
      if (!isKnownRobotErrorStatus(report.status)) {
        unknownStatusCount++;
        continue;
      }
      // ★エラー行の施設・清掃エリアが削除済みなら出さない(指示書BB U1・表1 V7。正は `isVisibleErrorRow()`)★ —
      //   ★親の本清掃の可視性は上の予定の `where` で効いている★(親が見えなければ `precheckRows` に入らず、下で `row` が無くて飛ぶ)
      if (!isVisibleErrorRow(report)) continue;

      // ★★引くのは**事前チェック行**の Map★★(本清掃行の Map ではない)—
      //   `precheckRows` は `parent_id: { in: 期間で絞った本清掃の id }` で引いた行なので、
      //   ★ここに当たる時点で「親が期間内」が保証される★。
      const row = precheckRowById.get(key(timetableId));
      // ★FK が無いため、参照先が消えている/存在しないことが起こりうる★
      //   例外にすると1行の不整合で `/alerts` 全体が落ちるので、対象外として飛ばす
      if (!row) continue;

      // ★本清掃行を指すエラーは拾わない(D-104)★ —
      //   `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} に対応する本清掃行がありません`
        );
      }
      candidates.push({ report, precheckMinutes, mainRow });
    }
    if (unknownStatusCount > 0) {
      logEvent("warn", {
        event: "data.anomaly",
        metric: "robot_error_status_unknown",
        expected: "0/1/2",
        actual: unknownStatusCount,
      });
    }

    // ★清掃結果(`clean_task_reports`)は**行になったエラーの親の本清掃の id**でしか引かない★ —
    //   ★使い道は `main_run_succeeded`(D-171)だけ★(旧: 出発失敗の判定のため期間内の本清掃を全部引いていた。2026-10-29)。
    //   ★1行ずつクエリしないこと★(N+1)。★行が0件ならクエリを撃たない★。
    const parentIds = [
      ...new Map(candidates.map(({ mainRow }) => [key(mainRow.id), mainRow.id])).values(),
    ];
    const taskReports = (
      await Promise.all(
        chunkForInClause(parentIds).map((ids) =>
          prisma.cleanTaskReport.findMany({
            where: { clean_timetable_id: { in: ids } },
          })
        )
      )
    ).flat();
    // ★★`cleanLogs()` と**同じ関数**でレポートを1件に絞る★★(D-107。落とし穴2 / 指示書AQ §1-4)—
    //   ★規則を1箇所にしておかないと、清掃詳細ログの判定と「本清掃は成功」の目印が食い違う★
    const reportByTimetableId = latestReportByTimetableId(taskReports);

    // ★行の組み立ては `precheckItemFromErrorRow()` の1本★(指示書AT 段階4-1)—
    //   `precheckItemById()`(PATCH の前後の読み取り)も同じ関数を使う。★ここに書き戻さないこと★
    return candidates.map(
      ({ report, precheckMinutes, mainRow }): PrecheckItem =>
        precheckItemFromErrorRow(
          report,
          precheckMinutes,
          mainRow,
          reportByTimetableId.get(key(mainRow.id))
        )
    );
  },
  /**
   * ★条件は `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;
    // ★`status` が 0/1/2 以外の行は一覧に出ないので404(500 にしない。指示書BB U3・表3 S3)★
    if (!isKnownRobotErrorStatus(report.status)) return null;
    // ★エラー行の施設・清掃エリアが削除済みなら404(一覧に出ない ⇔ 404。指示書BB U1・表1 V9)★
    if (!isVisibleErrorRow(report)) return null;

    // 2. 予定に紐づかないエラー(N-5 の D-S制御盤・セキュリティの行を含む)は対象外
    //   ★`0` も予定外★(TeamWorks 回答 2026-10-27。判定は `scheduledTimetableId()` の1本)
    const timetableId = scheduledTimetableId(report.clean_timetable_id);
    if (timetableId === null) return null;

    // 3. 事前チェック行であること(本清掃行を指すエラーは404。アラートは事前チェック異常だけ。D-104)
    //   ★予定は2表から引く(本表 → 無ければ履歴。指示書AU 段階3b-2 の 5)★ —
    //   ★履歴の予定に紐づくエラー行も PATCH で書けるようになる(§3b-7 Q4。実在するアラートなので、それでよい)★
    const row = await findTimetableById(prisma, timetableId, {
      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,
      // ★親の本清掃の施設・清掃エリアの可視性(指示書BB U1・表1 V9)★
      facility: { select: { deleted_at: true } },
      section: { select: { deleted_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} に対応する本清掃行がありません`
      );
    }
    // ★親の本清掃の施設・清掃エリアが削除済みなら404(`prechecks()` の予定の `where` で外れる行。一覧に出ない ⇔ 404)★
    if (!isVisibleFacilitySection(mainRow)) return null;

    // 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`・期間・可視性(`ERROR_ROW_VISIBLE_WHERE`)に絞って全件読み、
   *   アプリで ★絞り込みの正(`filterSortErrorReportKeys()`)→ 並べ替え → ページ分け★、
   *   2段目 = ★そのページの `error_id` だけ★ 列を足して読み直す(最大 `PAGINATION.maxPerPage` 件)。
   */
  async errorReports(query: ErrorReportDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();

    // 1段目: キーだけ(★SQL で status / 施設 / 期間 / 可視性(U8)を絞る。並べ替え・ページ分けはしない★)。
    //   ★`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個でクエリを撃たない★
    //   ★予定外(NULL・`0`。TeamWorks 回答 2026-10-27)は引かない(`scheduledTimetableId()` の1本)★
    const timetableIds = [
      ...new Set(
        rows.flatMap((row) => {
          const timetableId = scheduledTimetableId(row.clean_timetable_id);
          return timetableId === null ? [] : [timetableId.toString()];
        })
      ),
    ].map((id) => BigInt(id));
    // エラー内容の名前解決(★`id IN` を1回。絞らない。id が0件なら撃たない。指示書BD §5-3★)は予定と並べて読む
    const [timetables, errorLists] = await Promise.all([
      findTimetablesByChunks(prisma, chunkForInClause(timetableIds), (ids) => ({
        where: { id: { in: ids } },
        select: { id: true, precheck: true },
      })),
      loadErrorListIndexByIds(prisma, errorListIdsOf(rows)),
    ]);

    // ★pageIds の順に並べ、2段目で見つからない error_id は飛ばす(指示書AW 4-15。理由は関数の docstring)★
    return { items: errorReportItemsInPageOrder(pageIds, rows, timetables, errorLists), 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;
    // ★`status` が 0/1/2 以外の行は一覧に出ないので404(指示書BB U3・表3 S4。★AW §11 Q3 の500を上書き★)★
    if (!isKnownRobotErrorStatus(report.status)) return null;
    // ★削除済みの施設・清掃エリアの行は無いものとする(→ PATCH は404)★(指示書AX U8・4-21。`findUnique` の条件はいじらない)
    if (!isVisibleErrorRow(report)) return null;

    // 予定は本表 → 無ければ履歴(`findTimetableById()`。本表が勝つ。D5)。
    //   エラー内容の名前解決(★絞らない。`error_list_id` が null なら撃たない。指示書BD §5-3★)と並べて読む
    //   ★予定外(NULL・`0`。TeamWorks 回答 2026-10-27)は引かない(`scheduledTimetableId()` の1本)★
    const timetableId = scheduledTimetableId(report.clean_timetable_id);
    const [timetable, errorLists] = await Promise.all([
      timetableId === null
        ? Promise.resolve(undefined)
        : findTimetableById(prisma, timetableId, { id: true, precheck: true }).then(
            (found) => found ?? undefined
          ),
      loadErrorListIndexByIds(prisma, errorListIdsOf([report])),
    ]);
    return errorReportItemFromRow(report, timetable, errorLists);
  },
  async updateErrorReportColumns(errorId: string, patch: ErrorStatusPatch) {
    // ★`updatePrecheckErrorColumns()` と同じ形★ — 書き込み用の接続 → `updateMany` の1文。
    //   ★違いは写しの関数だけ★: こちらは `writeErrorReportColumns()`(5列。`error_list_id` を含む。D-184)、
    //   事前チェックは `writeRobotErrorColumns()`(4列)。★`updatePrecheckErrorColumns()` の挙動を変えないため、共通化せずに並べて置く★(指示書AW D12)
    const { getWritePrisma } = await import("./prisma");
    const client = await getWritePrisma();
    const { matched } = await writeErrorReportColumns(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件」に見え、全台の `last_reported_at` が null になり、
    //   制御盤ごとに `data.anomaly`(`control_reports_missing`)が出る★(指示書BB で例外はやめた。D-182)。
    const byControl = new Map(latest.map((g) => [String(g.control_id), g._max.reported_at]));
    return controls.flatMap((row): DsDevice[] => {
      // ★`controls.status` が 0/1 以外の制御盤は出さない(`summary` にも数えない。例外にしない)★(指示書BB A1・D9・D-182)—
      //   「異常」に寄せるのは推測(D-077 の考え方)。`connectionStatusFromControlStatus()` は 0/1 以外で例外なので、その前に外す
      if (!isListedControlStatus(row.status)) {
        logEvent("warn", {
          event: "data.anomaly",
          metric: "controls_status_unknown",
          expected: "0/1",
          actual: row.status,
          control_id: String(row.id),
        });
        return [];
      }
      const lastReportedAt = byControl.get(String(row.id));
      // ★心拍0件は新しい制御盤で正当に起きる(D-182)。null で出してログ★(旧: 例外で一覧全体が500)
      if (!lastReportedAt) {
        logEvent("warn", {
          event: "data.anomaly",
          metric: "control_reports_missing",
          expected: 1,
          actual: 0,
          control_id: String(row.id),
        });
      }
      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: lastReportedAt ? jstIsoFromDb(lastReportedAt) : null,
        // ★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 で心拍は読まなくなり、指示書BB で心拍0件も例外にしなくなったが、★経路を分けること自体に価値がある★)。
   *
   * ------------------------------------------------------------------------
   * ★★当たり外れは 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.status` が 0/1 以外も一覧と同じく false★(`isListedControlStatus()`。指示書BB A1・D-182)—
   *   揃えないと、一覧に出ない制御盤の備考が保存できて孤児行が残る
   *
   * ★`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, status: true },
    });
    // ★ここが「当たり外れ」を決める唯一の場所★(SQL の一致に任せない。D-4)。★状態は一覧と同じ規則(指示書BB A1)★
    return rows.some((row) => row.serial_number === serialNumber && isListedControlStatus(row.status));
  },
  /**
   * ★`robot_error_reports` の `D_SEC_ALERT_ERROR_TYPES` の行★(TeamWorks 第11回 P-10 = A。指示書AX)。
   *   ★`control_error_histories` は読まない★。施設・清掃エリアは行の `facility_id` / `section_id`(U3。`controls` を結合しない)。
   * ★このメソッドは disconnected(制御盤の生死報告なし)しか返しません★。
   * ★理由★: `SECURITY_ON`(セキュリティが有効になっていない)は D-Sec アラートに出さないため(ユーザー判断 U1。
   *   D-091 項3「セキュリティが無効であっても異常ではない」)。エラー一覧には今までどおり出ます。
   * ★帰結★: `/dashboard` のセキュリティバナー(`lib/dashboard/aggregate.ts` の `computeSecurityBanner()`)は
   *   DB のデータでは出ません。★これは意図した挙動であり、実装漏れではありません★(D4・U7)。
   * ★`robot_id` / `robot` を読まない★(D-Sec の行は全部 `robot_id` NULL。`ROBOT_ERROR_DS_ALERT_SELECT` だけを使う)。
   */
  async dsAlerts(query: DsAlertDbQuery) {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    // ★SQL は写し(`error_type IN`・`status IN`・可視性)。正は `dsAlertsFromErrorRows()` の `===`(D2・D3・U8)★。
    //   ★`open_only` なら SQL も純粋関数も解消を外す(指示書BB A6・D17)★
    const rows = await prisma.robotErrorReport.findMany({
      where: dsAlertWhere(query),
      select: ROBOT_ERROR_DS_ALERT_SELECT,
    });
    return dsAlertsFromErrorRows(rows, query);
  },
  /**
   * ホールディングス一覧(`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 /error-lists`。指示書BD §3-1)。
   * ★`where` / `orderBy` は SQL 側の写し★ — 正は `activeErrorListItems()`(fixtures 版と同じ関数を通す)。
   * ★ローカルDB(`dwatch_local`)には `error_lists` が無い★(指示書BD §8)— 実DBモードでローカルを使うとここで落ちる(500)。
   */
  async errorLists() {
    const { getPrisma } = await import("./prisma");
    const prisma = await getPrisma();
    const rows = await prisma.errorList.findMany({
      where: ACTIVE_ERROR_LIST_WHERE,
      orderBy: { id: "asc" },
      select: ERROR_LIST_SELECT,
    });
    return activeErrorListItems(rows);
  },
  /**
   * 屋号一覧(`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) => {
          const maker = MAKER_NAMES[robot.device.maker_id] ?? String(robot.device.maker_id);
          return {
            id: Number(robot.id),
            maker,
            // ★`model_name` がメーカー名込み("PUDU CC1")なら外す★ — 外さないと `isSupportedModel()` が
            //   `ROBOT_MODELS` の "CC1" と照合できず対応外になる(`stripMakerPrefix()`。ユーザー指示 2026-10-29)
            model: stripMakerPrefix(maker, 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({
      // ★`clean_schedules.deleted_at` に加えて、削除済みの施設・清掃エリアのスケジュールも出さない(指示書BB U1・D-182)★
      where: { deleted_at: null, ...FACILITY_SECTION_VISIBLE_WHERE },
      include: {
        facility: true,
        section: {
          include: {
            robots: { where: { deleted_at: null }, orderBy: { id: "asc" } },
          },
        },
      },
    });
    // ★有効なロボットが1台でない清掃エリアのログは、清掃エリアごとに1回だけ出す(`clean_schedule` の行ごとではない。指示書BB U2)★
    const reportedSections = new Set<string>();
    return rows.map((row): Schedule => {
      // ★P1は1清掃エリア=1ロボットの前提(CLAUDE.md §8)★。
      //   ★0台でも例外にしない(U2。D-182)。シリアルは null、ログを清掃エリアごとに1回★
      //   (旧: 例外。段階2のシードで施設12(ロボット0台)のスケジュールを構造的に除外している)
      reportSectionRobotCount(row.section_id, row.section.robots.length, reportedSections);
      const robot = row.section.robots[0] ?? null;
      return {
        id: Number(row.id),
        // ★清掃エリアに有効なロボットがいなければ null(画面は「-」。指示書BB U2・D6)★
        serial_number: robot?.serial_number ?? null,
        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()`(罠7)★(`facilityNameIndex()` と同じ1本)—
   *   ★`facilities()` で代用しないこと★。削除済みの施設・清掃エリアの行は入力(`loadConsecutiveInputs()`)の段階で外れる(D-182)。
   */
  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,
        facility_ids: query.facility_ids,
      }),
      loadFacilityNameIndex(prisma),
    ]);

    // ★判定は fixtures と同じ1本を通す(`USE_FIXTURES` で分岐させない)★
    // ★件数の上限は渡さない★(指示書AJ 段階1。該当する施設を全部返す)
    // ★★分類の絞り込みは fixtures 版と同じ場所(入力の行)にかける★★(指示書AK 段階4b)。
    //   ★2026-10-19 に4経路まとめて `facility_ids` を `loadConsecutiveInputs()` に渡すようにした(指示書BB A6)★。
    //   ★`filterRowsByFacilityIds()` は正として残す★(SQL の条件は写し。`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)のため。★運ぶのは5列だけ★
   *   (`facility_id` / `clean_timetable_id` / `clean_area` / `task_area` / `clean_ratio`)。
   *   ★予定外(`clean_timetable_id` が 0・NULL)の行も運び、実施回数・完了率に入れる★(指示書BG §2-5。2026-10-29)。
   *   ★★期間を指定しないと全期間(保持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)) } }
        : {}),
      // ★削除済みの施設・清掃エリアの面積の行は数えない(指示書BB U1・D-182)★ —
      //   ★集計・完了率の行・番人の3本とも、この同じ `where` を受け取る★。施設マスタ(名前の解決)は絞らない(罠7)
      ...FACILITY_SECTION_VISIBLE_WHERE,
    };

    // ★★`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★。面積の行は上の `where` で外す。D-182)
      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週。
    //   当期にはなりえないので当期の集計は走らない)。★ルートの検証で422 にしたので通常は来ない(指示書BB A8・D-182)★
    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)) {
      // ★確定期間なのにサマリが0行なら記録する(挙動は変えない=KPI は空のまま)★(指示書BB A11・表13・D-182)—
      //   当期にサマリが無いのは正常なのでここ(確定期間)でだけ出す。★データ層はルートを知らないので route / method は載せない★
      //   ★未来の期間(まだ始まっていない期間)も出さない★ — この枝は `!isCurrentPeriod()` なので未来の期間も通る。
      //   サマリがまだ無いのは正常で、期間ナビで先へ進めるたびに warn が出てしまう。判定は `isPastPeriod()` の1本。
      //   ★どの顧客・期間か追えるよう、顧客と期間キーを載せる★
      if (
        range &&
        confirmedKpiRows.length === 0 &&
        isPastPeriod(query.period, query.year, query.month, query.week)
      ) {
        logEvent("warn", {
          event: "data.anomaly",
          metric: "customer_summary_rows",
          expected: 1,
          actual: 0,
          period_mode: "summary_table",
          customer_id: query.customer_id,
          period: query.period,
          year: query.year,
          month: query.month,
          week: 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,
        // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★
        ...FACILITY_SECTION_VISIBLE_WHERE,
        ...(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` / `0`(予定外。TeamWorks 回答 2026-10-27)/ dangling も入らない(決定3)。
    //   ★★上の `in` で既に「本清掃行を指すもの」だけを引いているが、判定の「正」は
    //   `errorCountByMainTimetable()` のまま★★(SQL 側はその写し。指示書AC C-5)
    const mainStartAtById = mainTimetableStartAtById(mainRows);
    const errCountByMainId = errorCountByMainTimetable(errorReportChunks.flat(), mainStartAtById);

    // ★1回の集計で同じ now を使い回す(指示書V 落とし穴3。行ごとに作らない)★
    //   ★未実施の判定は `notExecutedFromReport()` を注入する★(`cleanLogs()` と
    //   同じ1本。★ここにラムダで規則を書かないこと★ — 規則が2箇所に散る。D-103 項1)
    const now = new Date();
    // ★★予定外タスク・予定外のエラーも足す(A案。指示書BG §2-4・ユーザー指示 2026-10-29)★★ —
    //   予定外タスク1件 = 予定1回・出発1回(`status = 4` なら正常終了1回)・完了率1件。予定外のエラーは件数に足す。
    //   ★読み口・集計は `facilityPerformance()` / `errorBreakdown()` の当期と同じ1本★(エラー内訳の合計 = エラー発生率の分子)。
    //   ★事前チェックの回とそれに紐づくエラーは入らない★(予定外は `clean_timetable_id` が 0 / NULL の行だけ)
    const unscheduledQuery = { dbRange, facility_ids: query.facility_ids };
    const [unscheduledReports, unscheduledErrors] = await Promise.all([
      findUnscheduledReports(prisma, unscheduledQuery),
      findUnscheduledErrors(prisma, unscheduledQuery),
    ]);
    const bucket = sumCleanStatsBuckets(
      mergeCleanStatsByFacility(
        aggregateCleanStatsByFacility(
          mainRows,
          reportByTimetableId,
          notExecutedFromReport,
          errCountByMainId,
          range,
          now
        ),
        aggregateUnscheduledStatsByFacility(unscheduledReports, unscheduledErrors, range)
      ).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()` はシリアルを null にして行を出す(指示書BB U2。旧: 例外)が、
    //   レポートは「一覧の1行が欠ける」で済むので今どおり除外する(例外にもしない。表2 R9)。
    //   ★★`deleted_at: null` を外さないこと★★ — 段階0 から付いている条件で、
    //   ★論理削除された施設はもともとこの一覧に出ない★(施設名で解決したIDが論理削除済みでも、
    //   ここで落ちて段階0 と同じ結果になる。★全画面で削除済みを出さない(D-182)。`cleanLogs()` とも揃った★)。
    const facilityRows = await prisma.facility.findMany({
      where: {
        deleted_at: null,
        ...(facilityIdWhere !== undefined ? { id: facilityIdWhere } : {}),
      },
      // ★2台以上なら `id` 昇順の先頭(指示書BB D7。施設に複数台は正常なのでログは出さない — 表2 R9)★
      include: { robots: { where: { deleted_at: null }, orderBy: { id: "asc" } } },
    });
    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,
      // ★施設の絞り込みも渡す(指示書BB A6)★
      { date_from: range?.from ?? null, date_to: range?.to ?? null, facility_ids: query.facility_ids }
    );
    // ★施設 × 日付で引く★(指示書BG §2-3。期間内の最終エントリが予定外タスク(id なし)でも引けるように。担当C)
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(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,
          consecutiveByFacilityDate,
          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,
        // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★
        ...FACILITY_SECTION_VISIBLE_WHERE,
        ...(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` / `0`(予定外。TeamWorks 回答 2026-10-27)/ 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本)★
    // ★★予定外タスク・予定外のエラーも足す(A案。指示書BG §2-4)★★ — `reportKpis()` の当期と同じ読み口・同じ集計。
    //   ★予定外だけがある施設も当期の行になる★(`mergeCleanStatsByFacility()` は片方にしか無い施設も残す)
    const unscheduledQuery = { dbRange, facility_ids: query.facility_ids };
    const [unscheduledReports, unscheduledErrors] = await Promise.all([
      findUnscheduledReports(prisma, unscheduledQuery),
      findUnscheduledErrors(prisma, unscheduledQuery),
    ]);
    const buckets = mergeCleanStatsByFacility(
      aggregateCleanStatsByFacility(
        mainRows,
        reportByTimetableId,
        notExecutedFromReport,
        errCountByMainId,
        range,
        now
      ),
      aggregateUnscheduledStatsByFacility(unscheduledReports, unscheduledErrors, range)
    );

    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週など)。
    //   ★ルートの検証(`parseReportQuery()`)で422 にしたので通常は来ない。歯止めとして残す★(指示書BB A8・D-182)。
    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,
      // ★施設の絞り込みも渡す(指示書BB A6)★
      facility_ids: query.facility_ids,
    });
    // ★施設 × 日付で引く★(指示書BG §2-3。予定外タスクも施設のその日の結果に入っている。予定外の行は id が無い)
    const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(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,
      consecutiveByFacilityDate,
      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()`)—
   *   ★事前チェック行を指すもの / dangling は数えない★
   *   (TeamWorks 第8回 A7。2026-09-11。D-114 項2。決定2・決定3)。
   *   ★★予定外のエラー(`clean_timetable_id` が `null`・`0`)は足す★★(指示書BG §2-4・A案。ユーザー指示 2026-10-29)—
   *   期間は `errored_at ?? created_at` の JST の日付、種類では絞らない(`isUnscheduledErrorInRange()`)。
   *   ★★率(`reportKpis()` / `facilityPerformance()`)と分子の集合を同じにするため★★ —
   *   同じレポート画面に定義の違う2つの数字を並べない。
   *   ★★未来の予定・結果待ちの予定を指すエラーも数えない★★(ユーザー指示 2026-10-29。率の分子と同じ
   *   `isSettledMainSchedule()` を `settledMainStartAtById()` 経由で通す)。
   * ★★期間判定は「そのエラーが指す本清掃行の `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 } : {}),
        // ★削除済みの施設の行は数えない(指示書BB U1・D5・D-182)★ — ★清掃エリアの削除は外せない★
        //   (サマリは施設単位で TeamWorks が集計済み。再計算しない。D-102)
        facility: { deleted_at: null },
      },
      // ★★使う列だけ読む★★(2026-10-29)— `select` 無しだと全列を SELECT するので、
      //   ★TeamWorks が使っていない列を消すと500になる★(実際に `error_detail` の削除で
      //   「The column `facility_error_summaries.error_detail` does not exist」になった)。
      //   ★`error_list_id` は読まない★ — 内訳は従来どおり `error_type` で束ね、
      //   同じ `error_type` で `error_list_id` が違う行は足し合わせる(`errorBreakdownRowsFromSummary()`)
      select: { year: true, month: true, week: true, error_type: true, error_count: true },
    });
    // ★★年次(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,
        // ★削除済みの施設・清掃エリアの行は出さない(指示書BB U1・D-182。規則は `lib/db/visibility.ts`)★
        ...FACILITY_SECTION_VISIBLE_WHERE,
        ...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
        // ★★分類の絞り込み(指示書AK 段階4b)★★ — ★`facilityPerformance()` の当期と同じ形★。
        //   ★★`!== null` の分岐は `facilityIdWhere` に閉じ込めてある★★(罠1)
        ...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
      },
      // ★読む列は `CleanStatsTimetableLike` が要求する4つだけ★
      //   (★`end_at` は結果待ちの判定用。`reportKpis()` の当期と同じ。D-173★)
      select: { id: true, facility_id: true, start_at: true, end_at: true },
    });

    // ★引いた本清掃の id で関連行を引き直す★(`reportKpis()` の当期と同じ形。★全件読みに戻さないこと★)
    const mainIdChunks = chunkForInClause(mainRows.map((row) => row.id));
    const [reportChunks, errorReportChunks] = await Promise.all([
      // ★レポートは「有無」だけを見る(結果待ちの判定)★ — ★1件に絞る規則(`latestReportByTimetableId()`)が
      //   要求する3列だけ読む★
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.cleanTaskReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            select: { clean_timetable_id: true, started_at: true, ended_at: true },
          })
        )
      ),
      Promise.all(
        mainIdChunks.map((ids) =>
          prisma.robotErrorReport.findMany({
            where: { clean_timetable_id: { in: ids } },
            // ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
            select: ROBOT_ERROR_TYPE_SELECT,
          })
        )
      ),
    ]);

    // ★★未来の予定・結果待ちの予定を指すエラーは数えない★★(ユーザー指示 2026-10-29
    //   「結果待ちなので、まだエラーかどうか確定してない」)— ★エラー発生率の分子(`reportKpis()` の
    //   `aggregateCleanStatsByFacility()`)と同じ述語 `isSettledMainSchedule()` を通す★(`settledMainStartAtById()`)。
    //   ★レポートの引き方(`latestReportByTimetableId()`)・未実施の述語(`notExecutedFromReport()`)も率と同じ★。
    //   ★1回の集計で同じ now を使い回す★(指示書V 落とし穴3)
    const now = new Date();
    const mainStartAtById = settledMainStartAtById(
      mainRows,
      latestReportByTimetableId(reportChunks.flat()),
      notExecutedFromReport,
      now
    );

    // ★★分子は「本清掃行を指すエラー」だけ★★(D-118 / D-114 項2。★事前チェック行を数えない★)—
    //   ★SQL 側の `in` で既にそうなっているが、判定の「正」は
    //   `countMainErrorTypesInRange()` のまま★(SQL 側はその写し。指示書AC C-5)
    //   ★★予定外のエラー(`clean_timetable_id` が 0 / NULL)も足す★★(指示書BG §2-4)— 期間は `errored_at ?? created_at` の JST の日付、
    //   ★集合は `isUnscheduledErrorInRange()` の1本★(エラー発生率の分子 `aggregateUnscheduledStatsByFacility()` と同じ)。
    //   ★読み口は `reportKpis()` の当期と同じ `findUnscheduledErrors()`(同じ施設の絞り込み・同じ可視性)★
    const unscheduledErrors = await findUnscheduledErrors(prisma, {
      dbRange,
      facility_ids: query.facility_ids,
    });
    const currentItems = errorBreakdownItemsFromCounts(
      mergeErrorTypeCounts(
        countMainErrorTypesInRange(errorReportChunks.flat(), mainStartAtById, range),
        countUnscheduledErrorTypesInRange(unscheduledErrors, 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;