Newer
Older
client_dwatch / lib / types / index.ts
/**
 * D-Watch 型定義
 *
 * `d-watch-api.yaml`(API契約の正)から起こしています。
 * YAMLを変更したら必ずこのファイルも合わせてください(CLAUDE.md 作業ルール1)。
 */

// ★型だけを借りる(`import type` なので実行時の依存は生まれない)★ —
//   要レスキューの理由は ★判定層(`lib/judge/rescue.ts`)が定義する語彙★ なので、
//   ★ここで文字列リテラルを書き写さない★(写すと片方だけ増えても誰も気づけない)。
import type { RescueReason } from "@/lib/judge/rescue";
import type { TableSortOrder } from "@/lib/table-sort";

export type { RescueReason };

// ---------------------------------------------------------------- 共通

export type Grade = "◎" | "○" | "△" | "×";
export type EscalationLevel = "L1" | "L2" | "L3";

export interface Meta {
  total: number;
  page: number;
  per_page: number;
}

/**
 * クライアントへ返す `error.code`。
 * ログの `error_code` と同一の変数を再利用し、ログ側で別文字列を組み立てない(logging-design.md §3)。
 * YAML は `type: string` のまま据え置き、内部 TS のみ union で強制する。
 */
export type ErrorCode =
  | "validation_failed"
  | "not_found"
  | "internal_error"
  /* ★`"not_implemented"` は外した(指示書AE 段階3-1 ③)★ —
     ★501 を返している実装は0件★(エリア別完了率は D-119 で本実装済み)。
     ★`d-watch-api.yaml` にも `not_implemented` / `501` は出てこない★ので、
     外しても API 契約と乖離しない(むしろ `CLAUDE.md` §9.2「型はYAMLと揃える」に近づく)。
     ★再び必要になったら、先に `d-watch-api.yaml` を直すこと★(`CLAUDE.md` §6-1)。 */
  /** 未認証(`dwatch_session` cookie が無い/一致しない)。proxy.ts が返す */
  | "unauthorized"
  /** IP制限で拒否。proxy.ts が返す */
  | "forbidden"
  /** ログイン時のトークン不一致。★これ以上の情報を message に書かないこと★ */
  | "invalid_token";

export interface ApiError {
  error: {
    code: ErrorCode;
    message: string;
    /**
     * ★既定は「どのパラメータがなぜ不正か」の `{ パラメータ名: [理由] }`★(`fail()` が組み立てる)。
     *
     * ★数値の形も許すのは、CSVエクスポートの 422 だけ★(指示書AI 段階8)—
     *   `/clean-logs/export` と ★`/reports/facility-performance/export`(D-163。形も上限も共通)★。
     *   `{ total: 25000, max: 20000 }` を返し、★「あと何件減らせばよいか」を機械可読にする★。
     *   ★★文言から数字を読み取らせないため★★(EN切替で message が変わっても壊れない)。
     *
     * ★数値の配列の形も許すのは、`GET /reports/kpi` の 422 だけ★(指示書AK ★段階6★ / §2-1)—
     *   `{ customer_ids: [1, 2] }` を返し、★「どの顧客を選べばよいか」を機械可読にする★。
     *   ★同じ理由(文言から読み取らせない)で数値のまま返す★。
     *   ★この3つ以外の形を足さないこと★ — 形が増えるほどクライアント側の分岐が増える。
     */
    details?: ApiErrorDetails;
  };
}

/**
 * `ApiError.error.details` に入りうる形(★上の3つ★)。
 *
 * ★別名を切ってあるのは、受け取る側(`lib/api/client.ts` の `ApiFetchError`)が
 *   ★同じ union を持てるようにするため★★(指示書AL 段階2)—
 *   受け側だけ `Record<string, string[]>` に狭めてあると、
 *   ★`/reports/kpi` の 422 が返す `{ customer_ids: [1, 2] }` を
 *   「文字列の配列」だと思い込んだまま型検査を通ってしまう★。
 *   ★数値配列を読む側(`lib/reports/block-visibility.ts`)は、この union を
 *   実行時に絞ってから使うこと★(JSON は型を保証しない)。
 */
export type ApiErrorDetails =
  | Record<string, string[]>
  | Record<string, number>
  | Record<string, number[]>
  | null;

export interface ListResponse<T> {
  items: T[];
  meta: Meta;
}

export interface SummaryListResponse<TItem, TSummary> {
  summary: TSummary;
  items: TItem[];
  meta: Meta;
}

// ---------------------------------------------------------------- 清掃詳細ログ

/**
 * `DataSource.cleanLogs()` に渡す取得条件(★指示書AC 段階2 D-1★)。
 * ★YAML `/clean-logs` のクエリパラメータに対応する★(`CLAUDE.md` §9.2)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4)— 任意にすると
 *   「渡し忘れても動く(が全件読む)」状態が残り、直したつもりで直っていないことに
 *   誰も気づけません。★全件が要る呼び出し側は「範囲なし」(全部 `null`)を明示的に渡すこと★。
 *
 * ★★ここに入れないもの★★:
 *   - `page` / `per_page` … ★`take` / `skip` は SQL に降ろさない★(指示書AC D-3)。
 *     判定フィルタ(`result`)が SQL で絞れないため、SQL でページングすると
 *     `meta.total` が「判定フィルタ後の総件数」と食い違う。
 *   - `result`(◎○△×の絞り込み)… ★DB に判定の列が無い★(完了率(`runCompletionRate()`)から
 *     `judgeGrade()` で計算している。指示書AC C-1)。
 *   - `model` / `serial` … 結合先の列の部分一致で索引が効かず、利得が薄いので据え置き
 *     (日付で絞ったあとのメモリ絞り込みのまま。指示書AC C-1)。
 *   - `schedule`(予定あり/予定外)… ★`filterCleanLogs()` の正★(指示書BH §12-2)。
 *     データ層に降ろすとダッシュボードの数え方に波及する。
 *   - ★★`facility_name`(生の文字列)★★ … ★型の入口で「解決済みのID配列」しか
 *     受け取らない形にする★。文字列を持たせると、照合規則が
 *     `lib/format/search-text.ts` と `lib/db/` の2箇所に散る(C-5 と同じ理由)。
 */
export interface CleanLogDbQuery {
  /**
   * ★清掃ログ1件の id★(`clean_timetables.id`)。★指定なしは `null`★
   * (指示書AC ★段階3★「単一IDの取得経路」)。
   *
   * ------------------------------------------------------------------------
   * ★★なぜ `cleanLog(id)` という別メソッドにせず、この型に足したのか★★
   * ------------------------------------------------------------------------
   * `/clean-logs/{id}/advice` と `/clean-logs/{id}/areas` は ★1行の表示のために
   * 全件(実測 4,760行)を読んでいた★(指示書AC B-3)。これを1件だけ引く形にする。
   *
   * ★★別メソッドにすると `CleanLogItem` の組み立てが2箇所になる★★ —
   *   1行を作るには ★手動 join 3本(清掃レポート・エラー報告・事前チェック行)★ +
   *   ★`statusColumnsFromPrecheckRows()`★ + ★`notExecutedFromReport()`★ +
   *   ★judge 3種★ + ★連続日数★ が要り、`prismaSource.cleanLogs()` の約240行がそっくり要る。
   *   ★★写すと、片方だけ直したときにレスポンスが静かに食い違います★★
   *   (指示書AC 禁止事項14「レスポンスを1バイトも変えない」の一番の敵)。
   *   ★引数を1つ足すだけなら経路は1本のまま★なので、こちらを採った。
   *   ★fixtures 版も `filterFixtureCleanLogs()` の1本のままで済む★(禁止事項6)。
   *
   * ★★`id` を指定しても他の条件は無視されない★★(AND で重なる)— 呼び出し側は
   *   日付・施設を `null` にして渡すこと。★★`facility_ids: []` を渡さないこと★★
   *   (`[]` は「0件ヒット」の意味なので ★何を指定しても 404 になります★。罠1)。
   */
  id: number | null;
  /**
   * JST の `YYYY-MM-DD`。★下限なしは `null`★。
   * ★★`Date` で受けないこと★★ — DB の DATETIME は「日本時間の壁時計値」で、
   * `parseDate()` が返すのは「真の瞬間」。★型の入口で取り違えを起こさせないため★
   * (指示書AC C-6。変換は `dbDateRange()`(`lib/db/date-range.ts`)1箇所)。
   */
  date_from: string | null;
  /** JST の `YYYY-MM-DD`。★上限なしは `null`★(★その日を**含む**★) */
  date_to: string | null;
  /**
   * ★★施設名の条件を「施設IDの配列」に解決したもの★★
   * (指示書AC C-1「施設名だけは照合そのものをSQLに降ろせない」/ D-6 の解決ステップ)。
   *
   * ★★`null` = 施設名の条件が無い(=施設で絞らない。全施設が対象)★★
   * ★★`[]`   = 施設名で照合したが**1件も当たらなかった**(=結果は0件)★★
   *
   * ★★この2つを混同しないこと★★ — 混同すると「0件ヒット」が「全件表示」に化けます
   * (Prisma の `in: undefined` は条件が消えて全件を返す。指示書AC D-4 / 罠1)。
   * ★★`facility_id`(単数・`number | null`)にしないこと★★ — 施設名は部分一致なので
   * 複数の施設に当たりえます(`ドンキ` → `ドン・キホーテ戸塚` と `ドン・キホーテ四街道`)。
   */
  facility_ids: number[] | null;
}

/**
 * ★清掃ログ1件の「清掃エリア」と「予定の終了」★(ダッシュボードの直近モード専用。D-159 / 指示書AV 段階2-2)。
 *
 * ★★内部用の型。YAML には載せない(API に出ない)★★ — `DataSource.cleanLogRunMeta()` が返し、
 *   `pickLatestRunPerSection()`(`lib/dashboard/latest-run.ts`)が ★`row_key`★ で `CleanLogItem` と突き合わせる
 *   (指示書BG §2-2。予定外タスクの行は `id` が null なので id では突き合わせられない。旧: id で突き合わせていた)。
 * ★★`CleanLogItem` にこの2列を足さないこと★★(指示書AV §2 D9)— `CleanLogItem` は
 *   `/clean-logs`・`/clean-logs/export`・`/clean-logs/{id}/advice` などが共有しており、
 *   内部用の列を足すと ★どこかで剥がし忘れた瞬間に API に漏れる★(例外もテスト失敗も出ない)。
 *
 * ★`section_key` を文字列にした理由★: fixtures に清掃エリアの id が無く、
 *   数値を作り出すと実在の id と取り違えうるため(指示書AV §2 D10)。
 */
export interface CleanLogRunMeta {
  /**
   * ★`CleanLogItem.row_key`★(予定のある行 `"tt-<予定の id>"`・予定外の行 `"rp-<report_id>"`。指示書BG §2-1・§2-2)。
   * ★旧: `id`(`clean_timetables.id`)★ — 予定外の行は id が無いので鍵を変えた
   */
  row_key: string;
  /** 清掃エリアを区別するキー。Prisma: `section:${section_id}` / fixtures: `fixture-robot:${serial_number}`(D10) */
  section_key: string;
  /**
   * 予定の終了。ISO8601(+09:00)。Prisma: `jstIsoFromDb(end_at)` / fixtures: `scheduled_at` と同じ(D10)。
   * ★予定外の行は清掃結果の終了(`ended_at`。null なら `started_at`)★(指示書BG §2-2)
   */
  planned_end_at: string;
}

/**
 * ★施設名 → 施設ID の解決★にだけ使う、施設マスタの最小限の行(指示書AC 段階2 D-6)。
 *
 * ★★`Facility` を流用しないこと★★ — あちらは画面に返す型で、契約フラグ・住所・
 *   ロボット一覧まで持っている。解決に要るのは ★id と施設名の2列だけ★。
 * ★★この読み取りは `deleted_at` で絞らない★★(罠7。理由は
 *   `loadFacilityNameIndex()`(`lib/db/facility-name-source.ts`)の docstring)。
 */
export interface FacilityNameRef {
  id: number;
  facility_name: string;
}

/**
 * ★分類(ホールディングス/顧客)→ 施設ID の解決★にだけ使う、施設マスタの最小限の行
 * (指示書AK 段階3 §6-3)。
 *
 * ★★`FacilityNameRef` と1本にまとめないこと★★ — 用途が違う(あちらは施設名の照合)。
 *   まとめると、どちらか片方の都合で読む列や `where` を足したときに
 *   ★もう片方の要件を黙って壊します★。
 * ★★この読み取りも `deleted_at` で絞らない★★(罠7。理由は
 *   `loadFacilityGroupIndex()`(`lib/db/facility-group-source.ts`)の docstring)。
 *
 * ★`group_id` は API の形(★`null` = ホールディングスなし★)で持つ★ —
 *   DB の `customers.group_id = 0`(センチネル。D-011)は
 *   `groupIdFromDb()`(`lib/format/group-id.ts`)が読み取り時に変換済み。
 *   ★`0` のまま比較しないこと★(★落とし穴3★)。
 */
export interface FacilityGroupRef {
  id: number;
  /** 顧客(`facilities.customer_id`)。NOT NULL なので `null` にならない */
  customer_id: number;
  /** ホールディングス(`customers.group_id`)。★`null` = 所属なし★ */
  group_id: number | null;
}

export interface CleanLogItem {
  /**
   * 予定のある行は本清掃の `clean_timetables.id`(`/clean-logs/{id}/advice`・`/areas` の `{id}`)。
   * ★予定なしの行は `null`★(指示書BG §2-1)— 予定の id が無く、`clean_task_reports.report_id` は19桁で
   * 安全な整数を超えるため数値の id にしない(`report_id` に文字列で持つ)。
   * アドバイスは △× なら画面で開く(`/clean-logs/unscheduled/{report_id}/advice`)。エリア別詳細は出さない(指示書BI)。
   * ★行の key・開閉状態は `row_key` で持つこと★(`id` は予定なしで重なる=どれも null)
   */
  id: number | null;
  /**
   * ★行の一意キー★(指示書BG §2-1)。予定のある行 `"tt-<予定の id>"`、予定外の行 `"rp-<report_id>"`。
   * 作るのは `cleanLogRowKey()`(`lib/db/clean-log-mapping.ts`)の1本
   */
  row_key: string;
  /**
   * `clean_task_reports.report_id`(★文字列★。19桁で安全な整数を超えるため)。★予定外の行だけ値がある★。
   * 予定のある行は `null`(レポートの有無は `departed` / `task_status` で見る)
   */
  report_id: string | null;
  /**
   * ★予定外タスクの行か★(`clean_task_reports.clean_timetable_id` が 0 か NULL。判定は `scheduledTimetableId()` の1本。
   * 指示書BG §1・§2-1)。予定なしの行は `scheduled_at` = `started_at`・`departed` = true・`result_pending` = false・
   * 事前チェック4列は null。画面は「予定」列に「予定なし」(`CLEAN_LOG_UNSCHEDULED_LABEL`。ユーザー指示 2026-11-04 で「予定外」から変更)。
   * △× なら ▼ でアドバイスを開く。エリア別詳細は出さない(指示書BI)
   */
  unscheduled: boolean;
  facility_id: number;
  facility_name: string;
  /**
   * 機種。★清掃エリアに有効なロボットがいないときは `null`★(指示書BB U2・D-182。画面は「-」・CSV は空のセル)。
   * 例外にしない(旧: 例外で一覧全体が500)
   */
  model_name: string | null;
  /** シリアル。★清掃エリアに有効なロボットがいないときは `null`★(`model_name` と同じ。U2・D-182) */
  serial_number: string | null;
  /** ISO8601(+09:00) */
  scheduled_at: string;
  /**
   * ★事前チェック1回目(早い方=分数の大きい方)★の時点のロボット状態(D-178。ユーザー判断 2026-10-16「案1」)。
   * ★フィールド名は互換のため `status_1h` のまま★だが ★60分前とは限らない★ — 何分前かは `status_1h_minutes`。
   * 列の割り当ては ★分数の一致ではなく順番★(`statusColumnsFromPrecheckRows()`。`lib/db/precheck-mapping.ts`)。
   *
   * 値: エラーが無ければ待機(`"Standby."`)、あれば `robot_error_reports.error_type` の生値(D-077。変換しない)。
   * 現在の日本語6値は fixtures 由来の暫定値(D-077 項3)
   *
   * ★`null` = 1回目の事前チェックの予定が無い(=予定が1つも無い)★
   * (ユーザー指示 2026-10-07「事前チェックがないものは-にしてください」。D-171)。
   * 文字列(`"Standby."` を含む)= 予定がある。★旧: 予定が無くても `"Standby."` で、異常なしと見分けられなかった★。
   * 画面は Pill を出さず「-」(`precheckStatusCell()`)、CSV は空のセル。★`statusColor()` に null を渡さないこと★。
   * ★判定には使わない★(D-170)
   */
  status_1h: string | null;
  /**
   * ★`status_1h`(事前チェック1回目)が本清掃開始の何分前か★(`clean_timetables.precheck` の生値。D-178)。
   * ★`status_1h` が null のときだけ null★。★生値を変換しない★(旧コード値 `1` / `2` も1分前・2分前のまま)
   */
  status_1h_minutes: number | null;
  /**
   * ★事前チェック2回目(遅い方=分数の小さい方)★。★30分前とは限らない★(`status_30m_minutes`。D-178)。
   * ★`null` = 2回目の予定が無い(予定が0〜1つ)★。予定が1つだけのときはその分数によらず `status_1h` 側に入る。
   * 値の決め方は `status_1h` と同じ(D-171)
   */
  status_30m: string | null;
  /** ★`status_30m`(事前チェック2回目)が何分前か★。★`status_30m` が null のときだけ null★(D-178) */
  status_30m_minutes: number | null;
  /**
   * スケジュール通り出発したか。
   * ★根拠は「本清掃の `clean_timetables` 行に対応する `clean_task_reports` の行があるか」★
   * (指示書AQ §1-1。D-107 設計3-C)— ★行が無い(`task_status === null`)とき `false`★
   * (=出発失敗。「出発失敗」と「未実施」は同一扱い)。
   * 判定は `notExecutedFromReport()`(`lib/db/clean-log-mapping.ts`)の1箇所。
   */
  departed: boolean;
  /**
   * ★`clean_task_reports.status` の生値★(★API では数字のまま。表示名にするのは画面・CSV★)
   *
   * ★★値の意味は第10回 N-2 で受領★★(D-155 項2): 0 清掃開始前 / 1 清掃中 / 2 清掃中断 /
   * 3 清掃キャンセル / 4 清掃正常終了 / 5 清掃異常終了(`lib/config` の `CLEAN_TASK_STATUS_LABELS`)。
   * CSV実測(出所: ユーザー提供 `clean_task_reports.csv` 189行。2026-09-30 受領)には ★`6`★ もある
   * (★6 は 3 と同じ「清掃キャンセル」★。N-2 の表には無く、ユーザー指示 2026-10-30。表に無い値(7 以上など)は「不明(7)」。表示名は `CLEAN_TASK_STATUS_LABELS`)。★判定には使わない★(D-155 項2)。
   * ★旧 `clean_error` のコード表(`lib/config` の `CLEAN_ERROR`)には対応づけない★(別のコード表)。
   *
   * ★★`null` は「対応する `clean_task_reports` の行が無い」★★(`status` 列自体は NOT NULL)—
   * ★その行は `departed: false` / `completion_rate: null`(=出発失敗)★。
   * 行ごと消さずに出発失敗として画面へ出す(D-107 設計3-C。fail-loud)。
   *
   * ★詳細ログの「清掃結果」列とCSVに `formatCleanTaskStatus()`(`lib/format/clean-result.ts`)で
   * 出す★ — ★`null` は「出発失敗」、それ以外は表示名(6 は清掃キャンセル、7 以上は「不明(7)」)★。画面は `t()` で訳し、
   * CSV は日本語のまま(指示書AS D11)。
   *
   * ★★旧名 `clean_error` から改名した★★(指示書AQ §5-2。2026-09-30)— 中身が
   * `clean_status_histories.clean_error` から `clean_task_reports.status` に変わったのに
   * 名前が同じだと、読み手が旧コード表(0:正常 / 1:異常(清掃未実施) …)で解釈する。
   * ★型が同じ `number | null` なので、名前を変えないと直し忘れが型エラーにならない★。
   */
  task_status: number | null;
  /**
   * ★結果待ちか★(ユーザー判断 2026-10-07。D-173)— ★レポートの行がまだ無いが、
   * 予定の終了(無ければ開始)から `RESULT_WAIT_MINUTES` 分以内の清掃★(清掃中も含む)。
   * 判定は `isResultPending()`(`lib/clean-logs/result-pending.ts`)の1箇所。
   *
   * ★★`true` でも `departed: false` / `task_status: null` / `completion_rate: null` / `grade: "×"` は今のまま★★
   * (不変条件 `departed === false ⟺ completion_rate === null` を壊さない)。
   * ★旗を見ない古い処理は今までどおり出発失敗として扱う(安全側)★。
   * 旗を見る側(画面・絞り込み・CSV・ダッシュボード・アラート・連続日数・当期の率)は、
   * 結果待ちの行を ★失敗に数えない★。レポートがある行・確定した出発失敗は `false`。
   */
  result_pending: boolean;
  /**
   * タスク完了率(%)。★1回ごとの完了率★ = `min(clean_area ÷ task_area × 100, 100)` を小数第1位まで
   * 切り捨て(`runCompletionRate()`。D-155。★第11回で確定。D-158★)。値は `completionRateFromReport()` の戻り。
   * ★旧: D-151 では `clean_task_reports.clean_ratio` 列の生値だった★。
   * ★対応する `clean_task_reports` の行が無い(`task_status === null`)ときは `null`★。
   * ★行があって `task_area = 0` なら `0`★(P-5=B。旧: `null`)。
   * ★行があって `clean_area = 0`(`task_area > 0`)なら `0`(`null` にしない)★。平均側では丸めない
   */
  completion_rate: number | null;
  /** サーバ側算出。fixtureでは期待値を直書きしている */
  grade: Grade;
  /**
   * △以下または出発失敗の連続日数(スケジュール存在日ベース)。
   * ★表示上限で頭打ちになる(`consecutive_problem_days_capped` を参照)★
   */
  consecutive_problem_days: number;
  /**
   * ★`consecutive_problem_days` が表示上限で頭打ちか★(指示書AF 段階6)。
   * ★上限ちょうどは false★。★`escalation_level` は上限をかける前の生値で判定する★。
   *
   * ★任意項目★ — `lib/fixtures/clean-logs.ts` の手書き行を変更しないため。
   * ★ルート層の `capCleanLogs()` が必ず値を入れる★。
   */
  consecutive_problem_days_capped?: boolean;
  /** 問題行のみ設定 */
  escalation_level: EscalationLevel | null;
}

export type AdviceRuleId = "R1" | "R2" | "R3" | "R4" | "B1" | "B2" | "B3";

export interface AdviceItem {
  rule: AdviceRuleId;
  /** 1=注意 2=警告 3=重大 */
  level: 1 | 2 | 3;
  icon: string;
  title: string;
  estimated_cause: string;
  recommended_actions: string[];
}

/**
 * GET /clean-logs/{id}/areas の1件(YAML: `AreaCompletionItem`)。
 *
 * ★1エリア = 1清掃ロボットにつき1件★(`CLAUDE.md` §8 / TeamWorks 第8回 A9-2)。
 * ★1回の清掃の中を売り場・通路・レジ前に区切った内訳ではない★ — その粒度は取得できない
 * (確認事項#10 の本来の問いは未解決)。
 */
export interface AreaCompletionItem {
  section_id: number;
  /** ★`sections.section_name` の生値★。当方で整形・生成しない */
  section_name: string;
  /**
   * ★1回ごとの完了率(`runCompletionRate()`。D-155)★(`CLAUDE.md` §4.3。平均側では丸めない)
   * ★★未実施は `null`。`0`(0%で実施した)と混ぜないこと★★(D-107 落とし穴1)
   * ★★`cleanLogs()` の `completion_rate` と同じ出し方に揃えてある★★(`completionRateFromReport()`。
   *   指示書Z 決定7)。
   *   ★`AreaCompletionsResponse.overall_completion_rate` は、この値の単純平均★
   *   (指示書AQ 決定4 / 追補 5-5。★行ごとと全体で式の土台が揃っている★)。
   *   ★旧: 全体だけ面積ベース(Σclean_area ÷ Σtask_area)で、行ごととは一致しないことがある
   *   「意図した非対称」だった★(指示書Z 決定7。2026-09-30 に廃止)。
   */
  completion_rate: number | null;
  grade: Grade;
  /**
   * ★結果待ち(D-173。`isResultPending()`)★(指示書BB A3・D-182)— `grade` は × のまま・`completion_rate` は null のまま(別の旗)。
   * ★`problem_count` と `filter=problem` から外す★(`total_count` には数える)
   */
  result_pending: boolean;
}

/** GET /clean-logs/{id}/areas のレスポンス */
export interface AreaCompletionsResponse {
  /**
   * ★★`items[].completion_rate`(=1回ごとの完了率。`runCompletionRate()`)の単純平均(清掃1回=1票)★★
   * (指示書AQ 決定4 / 追補 5-5「完了率の定義は全画面で1つ」・D-155。`lib/judge/kpi.ts` の `meanCleanRatio()`)。
   * ★Σclean_area ÷ Σtask_area ではない★ — ★2026-09-30 に面積ベースから変更した★(期間の合計どうしの
   *   面積比は廃止したまま。1回ずつ割って上限100で切るのは `runCompletionRate()`)。
   * ★★未実施(`completion_rate: null`)のエリアは平均の分母に入れない★★。
   * ★全エリア未実施(平均を取れるエリアが0)のときは `null`★。
   * ★`filter` に関係なく**全エリアから**計算する★
   */
  overall_completion_rate: number | null;
  overall_grade: Grade;
  /** ★`filter` で絞る**前**に数えた全エリア数★ */
  total_count: number;
  /** ★`filter` で絞る**前**に数えた △× のエリア数★ */
  problem_count: number;
  items: AreaCompletionItem[];
}

/**
 * データ層が返すエリア別完了率の行(`ReportKpiRow` と同じ流儀)。
 *
 * ★API に出る形ではない★ — 絞り込み・並べ替え・全体率の計算は APIルート側
 * (`lib/clean-logs/areas.ts` の純粋関数)で行う。
 */
export interface AreaCompletionRow {
  /** 本清掃の `clean_timetables.id`(= `CleanLogItem.id`) */
  clean_log_id: number;
  facility_id: number;
  /** ★JST の日付 `YYYY-MM-DD`★(`jstIsoFromDb()` の先頭10文字) */
  date: string;
  section_id: number;
  section_name: string;
  /** ★`completionRateFromReport()` の戻り(未実施は `null`・`task_area = 0` は `0`。1回ごとの完了率。D-155 / D-158)★ */
  completion_rate: number | null;
  /**
   * ★`clean_task_reports.clean_area`。レポートの行が無ければ `0`★。
   * ★面積の表示用(fixtures 版の総清掃面積 F-08 の材料。`cleanAreaStatsFromFixtureAreas()`)★。
   * ★完了率の分子ではない★(旧: 面積ベース全体率の分子。指示書AQ 決定4 / 追補 5-5 で廃止)
   */
  clean_area: number;
  /**
   * ★`clean_task_reports.task_area`。レポートの行が無ければ `0`★。
   * ★表示用。完了率の分母ではない★(旧: 面積ベース全体率の分母。指示書AQ 決定4 / 追補 5-5 で廃止)
   */
  task_area: number;
  /**
   * ★結果待ち(D-173)★(指示書BB A3・D-182)。★データ層が `isResultPending()` の1本で決める★
   * (`cleanLogs()` の `result_pending` と同じ。`now` は `areaCompletions()` の中で1回だけ作る)
   */
  result_pending: boolean;
}

/**
 * `DataSource.areaCompletions()` に渡す取得条件(★指示書AC 段階3★)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4。`CleanLogDbQuery` と同じ作法)—
 *   全件が要る呼び出し側は「範囲なし」(全部 `null`)を明示的に渡すこと。
 *
 * ★★`{id}` は「施設と日付の入口」★★(指示書Z 決定1)— エリアモーダルは
 *   ★その清掃ログと同じ施設・同じ日の**全エリア**★ を出すので、
 *   `clean_log_id` ではなく ★施設と日付★ で引く。
 *   ★`clean_log_id` で引くと、同じ施設・同じ日の他のエリアが消えます★。
 *
 * ★絞り込み規則の「正」は `selectAreaRowsForLog()`(`lib/clean-logs/areas.ts`)のまま★ —
 *   ここは ★その写し★(指示書AC C-5。★だから `selectAreaRowsForLog()` を外さないこと★)。
 */
export interface AreaCompletionDbQuery {
  /** `facilities.id`。★施設で絞らないときは `null`★ */
  facility_id: number | null;
  /**
   * ★JST の `YYYY-MM-DD`(1日ぶん)★。★日付で絞らないときは `null`★。
   * ★★`Date` で受けないこと★★ — DB の DATETIME は「日本時間の壁時計値」
   * (`CleanLogDbQuery.date_from` と同じ理由。指示書AC C-6。変換は `dbDateRange()` 1箇所)。
   */
  date: string | null;
}

export interface CleanLogAdviceResponse {
  /** ★上限をかける前の生値で判定した結果★(指示書AF 禁止事項9) */
  escalation_level: EscalationLevel | null;
  /** ★表示上限で頭打ちになる(`consecutive_problem_days_capped` を参照)★ */
  consecutive_problem_days: number;
  /** ★`consecutive_problem_days` が表示上限で頭打ちか★。上限ちょうどは false */
  consecutive_problem_days_capped: boolean;
  max_level: number;
  advices: AdviceItem[];
}

/**
 * GET /settings/advice の1件。システム管理→「運用設定」のアドバイス文言。
 * 編集できるのは title / text / actions のみ(level / icon / condition は仕様)。
 */
export interface AdviceSetting {
  rule: AdviceRuleId;
  phase: "A" | "B";
  /** 1=注意 2=警告 3=重大 */
  level: 1 | 2 | 3;
  icon: string;
  title: string;
  text: string;
  actions: string[];
  /** 発火条件の説明。編集不可 */
  condition: string;
  /** 既定値から変更されているか */
  customized: boolean;
}

/**
 * 表示上限の設定キー(`d-watch-api.yaml` の `DisplayLimitSetting.key` の enum)。
 *
 * ★P1 で編集できるのは1つだけ★(指示書AF §A-2 / ユーザー指示 2026-09-13 の A案)。
 * `GRADE_THRESHOLDS` / `ESCALATION_THRESHOLDS` / `PRECHECK_MINUTES_BEFORE` は
 * ★判定に使う閾値★なので、ここに足さないこと(作法を1件で確定させるのが A案の目的)。
 */
export type DisplayLimitKey = "consecutive_days_max";

/**
 * GET /settings/display-limits の1件。システム管理→「運用設定」の表示上限。
 *
 * ★`min` / `max` はサーバ側の検証範囲そのもの★ — 画面の入力欄はこの値を使う
 * (画面に数値を直書きすると、サーバの検証とずれる。指示書AF 禁止事項4)。
 * ★プロパティ名は `d-watch-api.yaml` の `DisplayLimitSetting` と揃えること★
 * (`default` は予約語ではないのでプロパティ名に使える。`default_value` に改名しない)。
 */
export interface DisplayLimitSetting {
  key: DisplayLimitKey;
  /** 現在有効な値(保存が無い・不正なら既定値) */
  value: number;
  /** `lib/config` の既定値 */
  default: number;
  /** 設定できる下限(L3の日数。これを下回らせない) */
  min: number;
  /** 設定できる上限 */
  max: number;
  /** 既定値から変更されているか */
  customized: boolean;
}

// ---------------------------------------------------------------- アラート(事前チェック)

/**
 * アラートの種別(`d-watch-api.yaml` の `PrecheckItem.severity`)。
 *
 * ★`precheck` の1値★(ユーザー判断 2026-10-29「2で進めてください」)— アラート画面は事前チェック異常だけを出す。
 *   ★旧 `launch_failed`(出発失敗)は型から外した★ — 出発失敗は清掃詳細ログ(`CleanLogItem.departed`)と
 *   ダッシュボード(未実施)で見る。型から外したので、読み残しは `tsc` が見つける。
 *   キー(`severity`)は API の形を変えないために残す。
 *
 * 「何分前のチェックか」は `PrecheckItem.precheck_minutes` が数値で持つ(D-105)。
 *   `clean_timetables.precheck` は ★設定値に応じた任意の分数★ を取りうる(D-093 項2。
 *   `20` なら20分前)ため、`precheck_1h` / `precheck_30m` のように分数を enum に
 *   焼き込むと設定を変えるたびに API 契約の変更が要る(D-093 項2-2 の案B を採らない理由)。
 */
export type PrecheckSeverity = "precheck";
export type PrecheckStatus = "unresolved" | "in_progress" | "resolved";

/**
 * GET /prechecks の `status` クエリパラメータ。
 * `open` = 未解消+対応中(D-054)。`PrecheckStatus` のスーパーセット
 */
export type PrecheckStatusFilter =
  | "open"
  | "unresolved"
  | "in_progress"
  | "resolved"
  | "all";

export interface PrecheckItem {
  /**
   * ★`string`★(D-070 1-2 / D-090 項7 の実装。D-105)
   *
   * 事前チェック由来は `robot_error_reports.error_id`(★`varchar(50)`★。
   * `prisma/schema.prisma:314`)。★`Number()` を通さない★ — `varchar` なので
   * `NaN` になっても例外が出ず、静かに壊れる(指示書S 落とし穴3)。
   * (旧: 出発失敗の行は本清掃行の `clean_timetables.id` の文字列だった。2026-10-29 に出発失敗の行を廃止)
   */
  id: string;
  severity: PrecheckSeverity;
  /**
   * 清掃開始予定時刻の何分前のチェックか(`clean_timetables.precheck` の生値。D-093 項2 / D-112)。
   * ★null にならない★(旧: 出発失敗の行のとき `null`。2026-10-29 に出発失敗の行を廃止)
   */
  precheck_minutes: number;
  /**
   * `robot_error_reports.serial_number` の生値(null にならない)。
   * (旧: 出発失敗の行で清掃エリアに有効なロボットがいないとき `null`。指示書BB U2・D-182。2026-10-29 に出発失敗の行を廃止)
   */
  serial_number: string;
  facility_id: number;
  facility_name: string;
  scheduled_at: string;
  robot_status: string;
  detected_at: string;
  resolution_status: PrecheckStatus;
  /** 対応開始日時(対応中以降で設定) */
  responded_at: string | null;
  /** 解消日時 */
  resolved_at: string | null;
  note: string | null;
  /**
   * 事前チェック時点のバッテリー残量(%)。
   *
   * ★実DB経由では常に `null`★(D-090 項8 / D-105)— `robots.battery`
   * (`prisma/schema.prisma:242`)は ★現在値のみで履歴が無い★ため、
   * 「事前チェック時点の残量」を取れない。fixtures には値が入っている。
   */
  battery_level: number | null;
  /**
   * ★親の本清掃が成功したか★(ユーザー指示 2026-10-07。D-171)。
   *
   * `true` = 親の本清掃に `clean_task_reports` の行があり、完了率の判定が ◎○
   * (`SUCCESS_GRADES`。ダッシュボードの「成功」と同じ定義)。それ以外(レポート行が無い=親の本清掃が出発失敗・△×)は `false`。
   * 決めるのは `mainRunSucceededFromReport()`(`lib/db/precheck-mapping.ts`)の1本。
   * ★表示のためだけの値★ — `resolution_status` もサマリの件数も変えない(画面がカードを控えめにするだけ)。
   */
  main_run_succeeded: boolean;
}

/**
 * `PATCH /prechecks/{id}/status` のリクエスト本文(指示書AT 段階1)。
 *
 * ★備考のキーは `note`★(旧契約の `memo` から改名。指示書AT §3-5)—
 *   レスポンスの `PrecheckItem.note` と同じ DB 列(`robot_error_reports.comment`)なので名前を揃えた。
 * ★`note` を省略すると備考は変えない★。空文字・空白だけは削除(`comment = null`)。
 */
export interface PrecheckStatusUpdateRequest {
  status: PrecheckStatus;
  note?: string;
}

/**
 * アラート(事前チェック)の対応状況を更新するときの「変える列だけ」のパッチ(指示書AT §3-4)。
 *
 * ★キーは4つだけ★(`robot_error_reports` の書き込み許可4カラムに1対1で対応。D-042 / `CLAUDE.md` §7):
 *   `status` → `status`(0/1/2) / `note` → `comment` / `responded_at` / `resolved_at`。
 * ★キーが無い列は書かない★。`null` は「その列を NULL にする」。
 * ★日時は「真の瞬間」の `Date`★ — DB に書く直前に `toDbJst()` を1回だけ通す
 *   (`lib/db/robot-error-write.ts`)。★作るのは `planPrecheckStatusUpdate()` だけ★。
 */
export interface PrecheckStatusPatch {
  status?: PrecheckStatus;
  note?: string | null;
  responded_at?: Date | null;
  resolved_at?: Date | null;
}

/**
 * `DataSource.prechecks()` に渡す取得条件(★指示書AC 段階4★)。
 * ★YAML `/prechecks` のクエリパラメータに対応する★(`CLAUDE.md` §9.2)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4。`CleanLogDbQuery` と同じ作法)—
 *   全件が要る呼び出し側は「範囲なし」(全部 `null`)を明示的に渡すこと。
 *
 * ------------------------------------------------------------------------
 * ★★期間の基準は**親の本清掃行**の `start_at`★★(指示書AC 段階4)
 * ------------------------------------------------------------------------
 * `prechecks()` が返すのは ★事前チェック行に紐づくエラー行(`severity: "precheck"`)だけ★
 *   (2026-10-29。旧: 出発失敗の行 `launch_failed` も返していた)。
 *   ★`errored_at` ではない★ — `PrecheckItem.scheduled_at` に親の `start_at` を入れているため(指示書S 3-2 項5)。
 *   ★ここを `errored_at` で絞ると `filterPrechecksByDate()`(`scheduled_at` で絞る)と
 *   食い違い、境界の行が静かに消える/増える★。
 *   ★だから Prisma 版は「本清掃行を期間で絞る → その子の事前チェック行 → そのエラー行」
 *   の順に引く★(`lib/db/index.ts` の `prismaSource.prechecks()`)。
 *
 * ★★ここに入れないもの★★:
 *   - `status`(対応状況)… ★`filterPrechecksByStatus()` のまま★(指示書AC C-5)。
 *   - `page` / `per_page` … ★`take` / `skip` は SQL に降ろさない★(指示書AC D-3)。
 */
export interface PrecheckDbQuery {
  /**
   * JST の `YYYY-MM-DD`。★下限なしは `null`★。
   * ★★`Date` で受けないこと★★(`CleanLogDbQuery.date_from` と同じ理由。
   * 指示書AC C-6。変換は `dbDateRange()`(`lib/db/date-range.ts`)1箇所)。
   */
  date_from: string | null;
  /** JST の `YYYY-MM-DD`。★上限なしは `null`★(★その日を**含む**★) */
  date_to: string | null;
  /**
   * `facilities.id` の配列。★施設で絞らないときは `null`★。
   *
   * ★★`null` と `[]` は別物★★(`CleanLogDbQuery.facility_ids` と同じ契約。★罠1★)—
   *   `null` = 施設で絞らない / `[]` = ★0件★(分類で絞ったが1件も当たらなかった)。
   *   ★`in: undefined` は条件が消えて全件になる★ので、
   *   ★★実装側は必ず `!== null` で分岐すること★★。
   *
   * ★★単数(`facility_id: number | null`)から配列に変えた★★(指示書AK 段階4)—
   *   ★`group_id` / `customer_id` は複数の施設に当たりうる★(例: 屋号「バリューマート」は
   *   fixtures で4施設)ので、★単数では表せない★。解決は
   *   `resolveFacilityIdsByMasters()`(`lib/facilities/resolve-by-master.ts`)。
   *   ★★施設名で絞りたくなったら、先に `d-watch-api.yaml` を直すこと★★
   *   (`CLAUDE.md` 作業ルール1)。そのときは `resolveFacilityIdsByName()` の結果と
   *   `intersectFacilityIds()` で AND に畳んでから渡す(`/clean-logs` が前例)。
   *
   * ★★基準は「本清掃行の施設」★★ — `severity: "precheck"` の行が持つ
   *   `facility_id` は `robot_error_reports.facility_id` だが、絞り込みは
   *   ★親の本清掃行★ で行う(期間の基準と揃える)。
   */
  facility_ids: number[] | null;
}

// ---------------------------------------------------------------- エラー一覧(指示書AW)

/** エラー一覧の「紐づく予定」(指示書AW D4)。`d-watch-api.yaml` の `ErrorReportItem.schedule_link` */
export type ErrorScheduleLink = "main" | "precheck" | "none" | "unknown";

/**
 * `GET /errors` の1行(`robot_error_reports` の1行。指示書AW)。`d-watch-api.yaml` の `ErrorReportItem`
 *
 * ★`/alerts`(`PrecheckItem`)と違い、事前チェックの行に限らない★ — 本清掃・予定なし・`robot_id` が NULL の行も入る(D-160 項1)。
 * 組み立ては `errorReportItemFromRow()`(`lib/db/error-report-mapping.ts`)の1本。
 */
export interface ErrorReportItem {
  /** `robot_error_reports.error_id`(★string のまま。`Number()` を通さない。D-105 項2★) */
  id: string;
  /** `errored_at ?? created_at`(`detectedAtFromError()`)。ISO8601(+09:00) */
  detected_at: string;
  facility_id: number;
  facility_name: string;
  /** ★生値★(`'0'` もそのまま。D-077) */
  serial_number: string;
  /** ★生値★(区分を作らない。D-077 / D-160 項3) */
  error_type: string;
  /** ★生値★ */
  error_level: string;
  /** ★画面には出さない★(「予定不明」の調査用。D18) */
  clean_timetable_id: number | null;
  schedule_link: ErrorScheduleLink;
  /** `schedule_link === "precheck"` のときだけ数値(★生の分数★)。それ以外は null */
  precheck_minutes: number | null;
  /** ★`PrecheckStatus` を使い回す★(名前は歴史的なもの。§11 Q7) */
  resolution_status: PrecheckStatus;
  responded_at: string | null;
  resolved_at: string | null;
  note: string | null;
  /**
   * `robot_error_reports.error_list_id`(★DB の値のまま★。`Number()` で number にする)。★`null` = 未選択★(ユーザー回答③。指示書BD §3-2)
   */
  error_list_id: number | null;
  /**
   * `error_list_id` の名前解決(`error_lists` の行)。★無効化・削除済みの行でも解決する★(名前解決は絞らない)。
   * `error_list_id` が null、または `error_lists` に無い id なら null(画面は前者を「未選択」、後者を「不明(id)」と出す)
   */
  error_list: ErrorListItem | null;
  /**
   * この行でエラー内容を選べるか。`error_type` が `ERROR_LIST_UNSELECTABLE_ERROR_TYPES`(`lib/config`。完全一致)なら false。
   * ★画面は `error_type` を見ずにこれだけを見る★(`CLAUDE.md` §9.6。指示書BD §3-2)
   */
  error_list_selectable: boolean;
}

/**
 * エラー内容の選択肢(`error_lists` の1行)。`d-watch-api.yaml` の `ErrorListItem`(指示書BD §3-1。D-184)。
 *
 * ★文字列は DB の値のまま★(`t()` に通さない・変換しない。D-077 と同じ。日英は列を出し分ける)。
 * ★`inactive` / 日時の列は返さない★(`GET /error-lists` は有効な行だけを返し、名前解決は行の有無だけを見る)。
 */
export interface ErrorListItem {
  /** `error_lists.id`(bigint unsigned → number) */
  id: number;
  error_category_main_en: string;
  error_category_main_ja: string;
  error_category_sub_en: string;
  error_category_sub_ja: string;
  error_detail_en: string;
  error_detail_ja: string;
  /** この選択肢を選ぶと備考が必須(`id` が `ERROR_LIST_NOTE_REQUIRED_IDS` に入るか) */
  note_required: boolean;
}

/**
 * `GET /error-lists` の応答(ページングしない。`meta` は `GET /groups` と同じ書き方。指示書BD §3-1)
 */
export type ErrorListsResponse = ListResponse<ErrorListItem>;

/**
 * `PATCH /errors/{error_id}/status` のリクエスト本文(指示書BD §3-3)。
 * `PrecheckStatusUpdateRequest` に `error_list_id` を足したもの。★`error_list_id` を書けるのはこの経路だけ★
 * (`/prechecks/{id}/status` と D-Sec の編集からは書かない。指示書BD §2)。
 * ★省略すると変えない★。1以上の整数だけを受け付ける(null・0・負数・小数・文字列は 422。解除は作らない)。
 */
export interface ErrorStatusUpdateRequest extends PrecheckStatusUpdateRequest {
  error_list_id?: number;
}

/**
 * エラー一覧の対応状況を更新するときの「変える列だけ」のパッチ(指示書BD §5-4)。
 * ★`PrecheckStatusPatch`(4列)に `error_list_id` を足した5列★ — `robot_error_reports` の
 * status / comment / responded_at / resolved_at / error_list_id。★5列目はこの経路(`/errors`)だけ★。
 * ★キーが無い列は書かない★(`PrecheckStatusPatch` と同じ)。
 */
export type ErrorStatusPatch = PrecheckStatusPatch & { error_list_id?: number };

/**
 * `DataSource.errorReports()` に渡す取得条件(指示書AW)。★引数は必須★(`PrecheckDbQuery` と同じ作法)。
 *
 * ★SQL に降ろすのは `include_resolved`(status IN)・`facility_ids`・期間だけ★(`errorReportListWhere()`)。
 * ★`error_type`・`sort`・`order`・`schedule` と `page` / `per_page` はアプリ側★(`filterSortErrorReportKeys()`。指示書AW D8 / D9 / BH)。
 */
export interface ErrorReportDbQuery {
  /** false = 未解消・対応中だけ / true = 解消も含める */
  include_resolved: boolean;
  /** ★`null` = 施設で絞らない / `[]` = 0件★(罠1。★実装側は必ず `!== null` で分岐する★) */
  facility_ids: number[] | null;
  /** JST の `YYYY-MM-DD`(検知時刻の日付)。★下限なしは `null`★ */
  date_from: string | null;
  /** JST の `YYYY-MM-DD`(★その日を含む★)。★上限なしは `null`★ */
  date_to: string | null;
  /** 種類の部分一致(大小文字を区別しない)。★`""` = 絞らない★ */
  error_type: string;
  /** 並べ替え列。★省略時は `null`(既定順)★ */
  sort: ErrorReportSortKey | null;
  order: TableSortOrder;
  /** 予定あり/予定外の絞り込み。★`null` = すべて★ */
  schedule: ScheduleFilter | null;
  page: number;
  per_page: number;
}

// ---------------------------------------------------------------- レポート

/**
 * 集計期間の種別(YAML の `Period`)。
 *
 * ★週次/月次/年次の3粒度★(2026-09-30 ユーザー指示。指示書AQ 決定1)。
 * 確定期間のサマリには週・月・年の3粒度が入るので、★年次(yearly)も
 * レポート画面の表示単位として扱う★。
 * ★以前ここには「年次は範囲外」と書いてあったが、その前提は撤回された★ —
 * CLAUDE.md §4.7 の年次バッチはサマリテーブルの生成単位だが、画面の表示単位にもする。
 *
 * ★期間キーの持ち方★(サマリの粒度判別と同じ。CLAUDE.md §4.7):
 *   - `weekly`  … `month` は 1〜12 / `week` は 1〜6
 *   - `monthly` … `month` は 1〜12 / `week` は ★0★(D-072)
 *   - `yearly`  … `month` は ★0★ / `week` は ★0★
 */
export type Period = "weekly" | "monthly" | "yearly";

export interface KpiValue {
  /**
   * 丸める前の生値。
   *
   * ★分母が0の期間(全施設定休日の週など)は null★
   * `lib/judge/kpi.ts` の4指標が分母0で null を返すため(「予定が1件もない」と
   * 「予定はあったが全部失敗」を区別する)、それをそのまま表現できる型にしてある。
   */
  value: number | null;
  /** 前期値。前期データなしは null */
  prev: number | null;
  /** value - prev。value / prev のどちらかが null なら null */
  diff: number | null;
}

/**
 * no_data: 稼働率が算出できない(その期間の清掃予定が0件、または予定が全部これから)ときの値。
 * 週の初め・月の初めは「予定はあるがまだ実施0件」が正常な状態になる(D-106)ため、
 * 「要改善」に寄せず区別する(決定1。2026-09-11 ユーザー確定)
 */
export type FacilityStatus = "good" | "warning" | "bad" | "no_data";

export interface FacilityPerformance {
  facility_id: number;
  facility_name: string;
  /** `serial` での絞り込みキーが結果にも出るようにする(指示書F 0-6) */
  serial_number: string;
  operation_rate: KpiValue;
  launch_rate: KpiValue;
  completion_rate: KpiValue;
  error_rate: KpiValue;
  status: FacilityStatus;
  /**
   * △以下または出発失敗の連続日数。
   * ★表示上限で頭打ちになる(`consecutive_problem_days_capped` を参照)★
   */
  consecutive_problem_days: number;
  /**
   * ★`consecutive_problem_days` が表示上限で頭打ちか★(指示書AF 段階6)。
   * ★上限ちょうどは false★。
   *
   * ★任意項目★ — `lib/fixtures/reports.ts` の手書き行を変更しないため。
   * ★ルート層の `capFacilityPerformance()` が必ず値を入れる★。
   */
  consecutive_problem_days_capped?: boolean;
}

export interface ContinuousProblem {
  facility_id: number;
  facility_name: string;
  consecutive_problem_days: number;
  /**
   * ★`consecutive_problem_days` が表示上限で頭打ちか★(指示書AF)。true のとき実際の
   * 連続日数はこの値より大きい(画面は「{n}日以上」と出す)。★上限ちょうどは false★。
   * ★`escalation_level` は上限をかける前の生値で判定する(上限は表示のためだけ)★。
   *
   * ★任意項目★ — `lib/fixtures/reports.ts` の手書き行を変更しないため
   * (`launch_failed_days` と同じ理由。`CLAUDE.md` §9.5)。
   * ★ルート層の `capContinuousProblems()` が必ず値を入れる★。
   */
  consecutive_problem_days_capped?: boolean;
  escalation_level: EscalationLevel;
  last_problem_on: string;
  /**
   * 事実だけを述べたテンプレート文字列(D-111)。`{n}` / `{m}` を埋めずに返す(方式A)。
   * 完成した日本語文を入れないこと(EN 切替で和文が残る)。
   */
  summary: string;
  /**
   * 連続の中で出発失敗(未実施)があった日の数(summary の {m})。
   * ★任意項目にしてあるのは `lib/fixtures/reports.ts` の手書き12件を変更しないため★
   *   (`CLAUDE.md` §9.5 / D-111 項5)。★`prismaSource` は常に数値を入れる(0 を含む)★。
   */
  launch_failed_days?: number;
}

export interface ErrorBreakdownItem {
  /**
   * `robot_error_reports.error_type` の生値。★変換しない★(D-077)。
   * 既知の値の一覧は `docs/handoff/analysis/error-type-mapping.md`(★参考資料。enum ではない★)。
   */
  type: string;
  label: string;
  count: number;
  /**
   * 全エラー件数に対する割合(%)。★丸める前の生値★
   *
   * サーバが返す(YAML §「判定はサーバ側、フロントは表示のみ」)。フロントで再計算しない。
   */
  rate: number;
}

export interface ReportKpi {
  /** 稼働率 = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100(第11回 P-6=A。サマリと同じ式) */
  operation_rate: KpiValue;
  /** 出発成功率 = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100(時刻の条件なし。P-6=A) */
  launch_rate: KpiValue;
  /** 清掃完了率 = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数(D-155 / D-158) */
  completion_rate: KpiValue;
  /** エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100(P-6=B。上限で切らない) */
  error_rate: KpiValue;
}

// -------------------------------------------------- レポートの内部行型(★APIレスポンスではない★)

/**
 * `DataSource` が返すレポート行の期間キー。
 *
 * ★★2026-09-13(指示書AC 段階5)以降、レポート4メソッドは `ReportDbQuery` を受け取り、
 *   ★要求された期間の行だけ★を返します★★(以前は「引数なしで全件返す」契約だった)。
 * ★それでも期間キーは残してあります★ — ルート側の `selectXxxRows()` /
 * `selectKpiRow()`(=絞り込み規則の「正」。指示書AC C-5)がこのキーで照合するためで、
 * ★SQL 側の `where` はその写し★です。
 *
 * ★これは API レスポンスの型ではありません★
 * API は期間で絞り込んだ結果だけを返すので、`d-watch-api.yaml` には追加しません。
 */
export interface ReportPeriodKey {
  period: Period;
  year: number;
  month: number;
  /**
   * ★monthly のときは 0★(D-072。段階5で `number | null` から変更)
   *
   * サマリ4テーブルの `week` は NOT NULL で、**月次は 0、年次は month=0 AND week=0**
   * という予約値で粒度を表す(テーブル定義書)。内部表現を `0` に揃えることで、
   * API境界(`period=monthly` では `week` を無視)から DB まで一直線になり、
   * `prismaSource` での null ↔ 0 の変換が不要になる。
   * 週次は 1〜6(`lib/format/period.ts` の `weekOfMonth()`)。
   */
  week: number;
}

/** DataSource が返す KPI の行。period/year/month/week で1行が特定される */
export interface ReportKpiRow extends ReportPeriodKey {
  kpi: ReportKpi;
}

/**
 * ★設計判断: 期間キーは「行型でラップする」(各型に直接持たせない)★
 *
 * `FacilityPerformance` / `ContinuousProblem` / `ErrorBreakdownItem` も同じ理由で期間キーが要るが、
 * これらは **YAML のレスポンススキーマそのもの**なので、期間キーを直接生やすと
 *
 *   1. 内部行型と API 契約の型が同一物になり、YAML に無いフィールドを持った型が
 *      レスポンスの型として使われてしまう(`d-watch-api.yaml` を正とする D-040 に反する)
 *   2. ルート側で毎回 `period` などを削ぎ落とす必要があり、忘れると契約違反が漏れる
 *
 * という2点で危ない。ラッパーなら `row.item` がそのままレスポンスの1件になるので、
 * 「内部の行」と「契約の1件」の境界がコード上で見える。
 * 指示書F が示した `ReportKpiRow`(`kpi: ReportKpi` を包む形)と同じ流儀に揃えている。
 */
export interface FacilityPerformanceRow extends ReportPeriodKey {
  item: FacilityPerformance;
}

export interface ContinuousProblemRow extends ReportPeriodKey {
  item: ContinuousProblem;
}

export interface ErrorBreakdownRow extends ReportPeriodKey {
  item: ErrorBreakdownItem;
}

/**
 * レポート4メソッド(`reportKpis()` / `facilityPerformance()` / `continuousProblems()` /
 * `errorBreakdown()`)に渡す取得条件(★指示書AC 段階5★)。
 * ★YAML `/reports/*` の `period` / `year` / `month` / `week` に対応する★(`CLAUDE.md` §9.2)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4。`CleanLogDbQuery` と同じ作法)—
 *   ★レポートは「全期間が要る呼び出し側」が存在しない★(画面は必ず1期間を選んでいる)ので、
 *   ★★「範囲なし」を表す値も用意しない★★。
 *
 * ------------------------------------------------------------------------
 * ★★`ReportQuery`(`lib/reports/query.ts`)との関係★★
 * ------------------------------------------------------------------------
 * ★フィールド名は `ReportQuery` と1文字違わず揃えてある★(指示書AC 段階5)。
 * `ReportQuery` は ★HTTP のクエリ全体★(`facilityName` / `serial` / `status` / `limit` を含む)で、
 * この型は ★そのうち SQL に降ろせる期間の4つだけ★ を持つ ★部分集合★ です。
 *
 * ★★`Pick<ReportQuery, ...>` にしないこと★★ — `lib/reports/query.ts` はこのファイル
 *   (`lib/types`)から型を import しているので、逆向きに参照すると ★循環 import★ になります。
 *   ★`CleanLogQuery` と `CleanLogDbQuery` が別々に定義されているのと同じ形★。
 *
 * ★★ここに入れないもの★★:
 *   - `status` / `limit` … ★判定(良好/注意/要改善)と上位N件の切り出しは SQL に降ろせない★
 *     (`status` は `judgeFacilityStatus()` の計算結果で DB に列が無い。指示書AC C-1)。
 *   - `serial` … ★結合先の列の部分一致で索引が効かず、利得が薄いので据え置き★
 *     (メモリ絞り込みのまま。指示書AC C-1 / 段階5)。
 *   - ★★`facility_name`(生の文字列)★★ … ★照合規則を `lib/db/` に写さないため、
 *     ★「解決済みのID配列」(`facility_ids`)に直してから渡す★
 *     (`CleanLogDbQuery.facility_ids` と同じ理由)。
 */
export interface ReportDbQuery {
  period: Period;
  year: number;
  month: number;
  /**
   * ★`period=monthly` のときは 0★(`ReportPeriodKey.week` と同じ表し方。D-072)。
   * ★`period=weekly` のときは 1〜6★ — ★★`week=0` はサマリ4テーブルで「月次」を表す
   * 予約値なので、API は 1 以上しか受け付けない★★(`WEEK_MIN`。`parseReportQuery()` が422で弾く)。
   */
  week: number;
  /**
   * ★★施設の条件(施設名 + 分類)を「施設IDの配列」に解決したもの★★
   * (`CleanLogDbQuery.facility_ids` と同じ作法。指示書AC C-1 / 指示書AK 段階4b)。
   *
   * ★★`null` = 施設の条件が無い(=施設で絞らない)★★
   * ★★`[]`   = 条件で照合したが**1件も当たらなかった**(=結果は0件)★★
   *
   * ★★この2つを混同しないこと★★ — Prisma の `in: undefined` は条件が消えて全件を返します
   * (罠1。分岐は必ず `!== null` で書くこと)。
   *
   * ------------------------------------------------------------------------
   * ★★2026-09-16(指示書AK 段階4b)に `FacilityPerformanceDbQuery` から移した★★
   * ------------------------------------------------------------------------
   * 以前は「施設の絞り込みを持つのは `facilityPerformance()` だけ(`facility_name` が
   * あるのはそこだけ)」だったので、★共通の型に置くと3メソッドが黙って無視する★という
   * 理由で分けていました。★分類(`group_id` / `customer_id`)は `/reports/*` の4本すべてに
   * あるので、その前提が変わりました★(`d-watch-api.yaml` の各 `GroupId` / `CustomerId`)。
   *
   * ★★`reportKpis()` はこの値を「当期だけ」見ます★★(指示書AL ★段階1★)—
   *   確定期間KPIは `customer_summaries`(=★施設ではなく**顧客**単位★)を読むので
   *   絞り込みの軸が ★`customer_id`★ ですが(★段階6★。`ReportKpiDbQuery` を参照)、
   *   ★★当期(進行中の週・月)は詳細テーブルをその場集計するので軸が施設★★になります。
   *   ★★2つの軸は同じ顧客を指していなければなりません★★ — `/reports/kpi` は
   *   `resolveTargetCustomer()` が決めた顧客IDから施設IDを解決して渡します
   *   (★リクエストの `group_id` から直接引かないこと★。指示書AL §2-3)。
   *   ★★以前(D-138 時点)はここが `null` 固定で、`customer_id` を指定しても
   *   「確定期間はその顧客の数字・当期は全社合算」という食い違いが起きていました★★。
   */
  facility_ids: number[] | null;
}

/**
 * `DataSource.reportKpis()` に渡す取得条件(指示書AK ★段階6★ / §2-1)。
 *
 * ★★`ReportDbQuery` に `customer_id` を足すのではなく、この1本だけの拡張型にしてある★★ —
 *   ★顧客単位のサマリ(`customer_summaries`)を読むのは `reportKpis()` だけ★で、
 *   残り3本は ★施設単位★(`facility_summaries` / `facility_error_summaries` / 詳細テーブル)です。
 *   共通の型に置くと ★3本が黙って無視する値★ ができ、段階4b 以前の
 *   `facility_ids` と同じ混乱(「渡したのに効かない」)が再発します。
 *   ★`FacilityPerformanceDbQuery extends ReportDbQuery` と同じ作法★。
 *
 * ★★`number | null` にしないこと★★ — ★「顧客が決まらない」状態でデータ層を呼ばない★のが
 *   この設計の要です(`resolveTargetCustomer()` が 422 を先に決める。指示書AK §2-1)。
 *   ★`null` を許すと「絞らない=全顧客の合算」が型の上で表現でき、
 *   ★★静かに複数顧客が混ざった数字★★ に戻ります。
 */
export interface ReportKpiDbQuery extends ReportDbQuery {
  /**
   * ★確定期間のKPIを出す対象顧客(`customer_summaries.customer_id`)★。
   * ★★必ず1社★★(`resolveTargetCustomer()` が決めた値。`lib/reports/target-customer.ts`)。
   * ★存在しないIDでも構わない★ — サマリが1行も当たらず 200・空になる(期待値表 6-9)。
   */
  customer_id: number;
}

/**
 * `DataSource.facilityPerformance()` に渡す取得条件。
 *
 * ★★中身は `ReportDbQuery` と同じ★★ — ★`facility_ids` は `ReportDbQuery` に移した★
 *   (指示書AK 段階4b。分類の絞り込みが `continuousProblems()` / `errorBreakdown()` にも
 *   要るようになったため。移した経緯はあちらの docstring)。
 *   ★名前は残す★ — 呼び出し側(`lib/db/index.ts` / `lib/db/fixture-reports.ts`)と
 *   テストが使っており、★「このメソッドは施設で絞る」という読み手への合図にもなっている★。
 */
export type FacilityPerformanceDbQuery = ReportDbQuery;

// ---------------------------------------------------------------- D-Security

/**
 * D-S制御盤の通信状態。
 * ★2状態しかない(D-065)★ `controls.status` は 0=正常 / 1=異常 の2値で、
 * 「異常」は生死報告が届いていない状態を指す。つまり通信断と異常は同一であり、
 * 3状態目(disconnected)は存在しない。
 */
export type DsConnectionStatus = "ok" | "error";

/** GET /ds-devices の `status` クエリパラメータ。error = "error"(通信断は異常に含まれる。D-065) */
export type DsDeviceStatusFilter = "all" | "error" | "ok";

export interface DsDevice {
  serial_number: string;
  facility_id: number;
  facility_name: string;
  connection_status: DsConnectionStatus;
  /** 最終通信日時。★まだ一度も通信(`control_reports`)が無い制御盤は `null`★(指示書BB A1・D-182) */
  last_reported_at: string | null;
  note: string | null;
}

export type DsAlertType = "security_off" | "disconnected";

/** `DataSource.dsAlerts()` の引数(指示書BB A6・D17・D-182。★必須★ — 任意引数は渡し忘れても動いてしまう。指示書AC C-4 項4) */
export interface DsAlertDbQuery {
  /** ★true = 解消でないものだけ(ダッシュボード。バナーは解消でないものしか見ない)★。false = 全件(`/ds-alerts`。summary に解消も数える) */
  open_only: boolean;
}

/** GET /ds-alerts の `type` クエリパラメータ */
export type DsAlertTypeFilter = "all" | "security_off" | "disconnected";
/** GET /ds-alerts の `status` クエリパラメータ。open = unresolved + in_progress(指示書AX D10) */
export type DsAlertStatusFilter = "open" | "all" | "resolved";

/**
 * D-Sec アラート(`robot_error_reports` のうち `error_type` が `D_SEC_ALERT_ERROR_TYPES` の行。指示書AX。D-180)。
 * ★`resolution_status` / `note` はエラー一覧(`ErrorReportItem`)と同じ語彙・名前★(D8 / D9)—
 *   `lib/prechecks/edit-drafts.ts` の `EditableRow` としてそのまま使える。
 */
export interface DsAlert {
  /** `robot_error_reports.error_id`(★string のまま。`Number()` を通さない。D-105★) */
  id: string;
  /** ★DB からは `disconnected` しか出ない★(`security_off` は互換のため残す。D-180) */
  alert_type: DsAlertType;
  /** `robot_error_reports.serial_number` の生値(D-077)。制御盤の SN */
  device_serial: string;
  /** 行の `facility_id`(★制御盤のシリアルの所属ではない★。指示書AX U3) */
  facility_id: number;
  facility_name: string;
  /** 行の `section_id` の清掃エリア名。`/dashboard` の `security_banner` も使う */
  section_name: string;
  /** `errored_at ?? created_at`(`detectedAtFromError()`) */
  detected_at: string;
  resolution_status: PrecheckStatus;
  responded_at: string | null;
  resolved_at: string | null;
  /** `robot_error_reports.comment` */
  note: string | null;
}

// ---------------------------------------------------------------- 分類マスタ

/**
 * ホールディングス(`groups`)。★セレクトリスト用の最小限の形★
 * (`d-watch-api.yaml` の `Group`。指示書AK 段階1)。
 *
 * ★画面の見出し語は「ホールディングス」★(2026-09-15 ユーザー指示)。
 *   ★型名とAPIのキーは DB の物理名のまま `group`★(`CLAUDE.md` §9.2
 *   「DBカラムは勝手にリネームしない」)— 見出し語は `lib/i18n.ts` の1箇所で当てる。
 *
 * ★`inactive` は持たない★ — 一覧は `deleted_at IS NULL` でのみ絞る
 *   (`db.facilities()` と揃える。指示書AK §4-1 #4)。
 */
export interface Group {
  id: number;
  group_name: string;
}

/**
 * 顧客(`customers`)。★セレクトリスト用の最小限の形★
 * (`d-watch-api.yaml` の `Customer`。指示書AK 段階1)。
 *
 * ★★`contract` を持たせないこと★★ — `customers.contract` は削除依頼済みで、
 *   契約フラグは `facilities.contract` のみが正(D-010 / `CLAUDE.md` §4.6)。
 */
export interface Customer {
  id: number;
  customer_name: string;
  /**
   * 所属ホールディングス。★`null` = 所属なし★。
   *
   * ★DB は NOT NULL で 0 がセンチネル★(D-011)。★API には 0 を漏らさない★ ——
   * 変換は `lib/format/group-id.ts` の `groupIdFromDb()` の1箇所だけが知っている。
   */
  group_id: number | null;
}

// ---------------------------------------------------------------- マスタ

export interface RobotSummary {
  id: number;
  maker: string;
  model: string;
  serial_number: string;
}

export interface Facility {
  id: number;
  facility_name: string;
  /**
   * 所属する顧客(`facilities.customer_id`)。★NOT NULL なので `null` にならない★
   * (指示書AK 段階5。`FacilityGroupRef.customer_id` と同じ扱い)。
   */
  customer_id: number;
  /** 一覧の「ブランド」列(`InspectionListItem.customer_name` と同じ文言) */
  customer_name: string;
  /**
   * 所属ホールディングス(`customers.group_id`)。★`null` = 所属なし★。
   * DB の `0`(センチネル。D-011)は `groupIdFromDb()`(`lib/format/group-id.ts`)が
   * 読み取り時に変換済み。★`0` のまま比較しないこと★(★落とし穴3★)。
   */
  group_id: number | null;
  /** ホールディングス名。★`group_id` が `null` のときは `null`★ */
  group_name: string | null;
  prefecture: string;
  address: string;
  phone: string | null;
  manager_name: string | null;
  charger_name: string | null;
  email: string | null;
  /**
   * D-Watch契約。facilities.contract 由来(customers.contract は参照しない)。
   * facilities.contract は varchar(10) の「文字列」で、桁位置がサービスに対応する(D-080)。
   * D-Watch は ★2桁目★。契約済み(有効)なら "1"。例: "0100000000" = D-Watchのみ / "1100000000" = 両方。
   * ★数値として比較しないこと★ — Number("0000000000") は例外を出さずに 0 になり、
   * 全施設が「契約なし」になってもエラーもテスト失敗も出ない。
   */
  monitoring_enabled: boolean;
  /**
   * D-Security契約。同じく facilities.contract 由来で、D-Security は ★1桁目★(D-080)。
   * 例: "1000000000" = D-Securityのみ。
   */
  d_security_enabled: boolean;
  robots: RobotSummary[];
}

/**
 * GET /facilities の `sort`(`d-watch-api.yaml` の enum と同じ並び)。
 * ユーザー指示 2026-10-30「ロボットマスタを監視またはD-SECでソートできるようにしてください」。
 *   - `id` … 施設IDの昇順(既定。★`order` は無視する★)
 *   - `monitoring` … `Facility.monitoring_enabled`(画面の「監視」列)
 *   - `d_security` … `Facility.d_security_enabled`(画面の「D-Sec」列)
 */
export type FacilitySortKey = "id" | "monitoring" | "d_security";

/**
 * GET /facilities の `order`。★`desc` = true(契約あり・画面の○)が先★、`asc` = false(ー)が先。
 * ★Prisma の `SortOrder` と名前を分けている★(`lib/generated/prisma` に同名の型があり、
 *   補完で取り違えても `"asc" | "desc"` 同士なので型エラーにならない)。
 */
export type FacilitySortOrder = "desc" | "asc";

/** GET /clean-logs の `sort`(指示書BH) */
export type CleanLogSortKey =
  | "model_name"
  | "serial_number"
  | "scheduled_at"
  | "departed"
  | "task_status"
  | "completion_rate"
  | "grade";

/** GET /errors の `sort`(指示書BH) */
export type ErrorReportSortKey =
  | "detected_at"
  | "serial_number"
  | "error_type"
  | "error_level"
  | "resolution_status"
  | "note";

/** GET /errors と GET /clean-logs・/clean-logs/export の `schedule` 絞り込み(指示書BH) */
export type ScheduleFilter = "scheduled" | "unscheduled";

export interface Schedule {
  id: number;
  /** ★清掃エリアに有効なロボットがいないときは `null`★(指示書BB U2・D-182。画面は「-」) */
  serial_number: string | null;
  facility_id: number;
  facility_name: string;
  /** 日月火水木金土(清掃する曜日=1)の7桁 */
  weekdays: string;
  weekdays_label: string;
  start_time: string;
  end_time: string;
  active: boolean;
}

// ---------------------------------------------------------------- 点検記録

export type InspectionItemName =
  | "ブラシ"
  | "ダストボックス"
  | "フィルター"
  | "スクイジー"
  | "吸引ホース"
  | "汚水タンク"
  | "ブラシ裏面";

export interface InspectionPhoto {
  item: InspectionItemName;
  phase: "before" | "after";
  url: string;
}

export interface InspectionListItem {
  id: number;
  visited_on: string;
  inspector_name: string;
  inspector_company: string;
  /** 一覧の「ブランド」列 */
  customer_name: string;
  facility_name: string;
  serial_number: string;
  has_notes: boolean;
}

export interface InspectionDetail extends InspectionListItem {
  notes: string | null;
  photos: InspectionPhoto[];
}

// ---------------------------------------------------------------- ダッシュボード

/**
 * 要レスキュー店舗を1件取り出すための引数(指示書AI 段階4)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4。`CleanLogDbQuery` と同じ作法)。
 */
export interface RescueDbQuery {
  /**
   * 対象日(JST `YYYY-MM-DD`)。★`Date` で受けないこと★
   * (`CleanLogDbQuery.date_from` と同じ理由。変換は `dbDateRange()` 1箇所)。
   *
   * ★★「この日に清掃予定がある施設」に絞る意味ではない★★(指示書AI §1-5)—
   *   各施設の ★「この日以前で最も新しいスケジュール存在日」★ の状態で判定する。
   */
  date: string;
  /**
   * ★★分類(`group_id` / `customer_id`)を「施設IDの配列」に解決したもの★★
   * (指示書AK 段階4b。`CleanLogDbQuery.facility_ids` と同じ作法)。
   *
   * ★★`null` = 絞らない / `[]` = 0件ヒット★★(★罠1★。分岐は必ず `!== null` で書く)。
   *
   * ★★`/dashboard` は数値カード(`summary`)と同じ配列をここにも渡すこと★★
   *   (指示書AK §7-2)— ★母集団がずれると「この屋号の昨夜の成績」と
   *   「要レスキュー」が別の集団の話になる★。★例外もテスト失敗も出ない★ので、
   *   `app/api/__tests__/master-filter-dashboard-reports.test.ts` で固定してある。
   */
  facility_ids: number[] | null;
}

/**
 * 要レスキュー店舗(`GET /dashboard` の `rescue_required` の1件)。
 *
 * ★判定そのものは `lib/judge/rescue.ts` の `judgeRescue()`★(3条件)。
 * ★★数値はすべて丸めない生値★★(`CLAUDE.md` §9.3「数値の丸めは表示層でのみ行う」)。
 */
export interface RescueRequiredItem {
  facility_id: number;
  facility_name: string;
  /**
   * 判定に使った日(JST `YYYY-MM-DD`)。
   * ★★リクエストの `date` と一致するとは限らない★★ — その日に清掃予定が無い施設は
   *   ★それ以前で最も新しいスケジュール存在日★ が入る(指示書AI §1-5 / §6-3)。
   */
  evaluated_on: string;
  /**
   * 当たった条件。★複数該当したら全部入る★(1つに丸めない。指示書AI 表5 5-5)。
   * ★★識別子の正は `lib/judge/rescue.ts` の `RescueReason`★★ — ここで文字列を
   *   書き写さず型ごと借りている(増減したときにこの型が黙ってズレないため)。
   *   ★日本語にするのは画面側★(D-111。EN切替で和文が残るのを避ける)。
   */
  reasons: RescueReason[];
  /** 直近から遡って未実施が続いている日数(スケジュール存在日。★判定の付帯情報★) */
  not_executed_days: number;
  consecutive_problem_days: number;
  /**
   * 評価日の施設日完了率 = ★その日の1回ごとの完了率(`runCompletionRate()`)の単純平均(清掃1回=1票)★
   * (指示書AQ 決定4 / 追補 5-5。`lib/judge/rescue.ts` の `facilityDayRate()` → `meanCleanRatio()`)。
   * ★2026-09-30 に面積加重(`Σclean_area ÷ Σtask_area × 100`)から変更した★。
   * ★未実施の清掃は分母に入れない★。算出できない(全部未実施)ときは null
   */
  completion_rate: number | null;
  /** 前回のスケジュール存在日の同じ値(★同じ式。カレンダー前日ではない★。指示書AI 決定A) */
  previous_completion_rate: number | null;
  /** 低下の比(0.25 = 25%低下)。算出できないときは null */
  drop_ratio: number | null;
}

export type FailureType =
  | "not_executed"
  | "security_active"
  | "control_error"
  | "robot_error";

export interface DashboardResponse {
  /**
   * 清掃結果サマリ(数値カード)。★母集団は `date` の有無で変わる★(D-159 / 指示書AV):
   *   - `date` 省略(空文字を含む)= ★直近モード★: 清掃エリアごとに、終わった本清掃のうち
   *     開始が一番新しい1件(7日以内・出発失敗を含む)。選び方は `pickLatestRunPerSection()`
   *   - `date` 指定: その日の清掃
   * ★`problem_logs` と同じ母集団★(`schedule_progress` / `rescue_required` とは違う)。
   */
  summary: {
    success: number;
    failed: number;
    not_executed: number;
    /**
     * ★結果待ち★(`CleanLogItem.result_pending === true` の行。ユーザー判断 2026-10-07。D-173)。
     * ★success / failed / not_executed には入れない★
     */
    pending: number;
    /**
     * 分母(success + failed + not_executed)が0なら null。
     * ★`pending` は分母に入れない★(D-173)
     */
    success_rate: number | null;
  };
  /**
   * スケジュール進捗(ユーザー判断 2026-10-29「案B」)。★対象日は `date` 省略(直近モード)なら JST の前日、
   * 指定ならその日★(`scheduleProgressTargetDate()`)。ダッシュボードの結果は昨夜の清掃なので、当日にしない
   */
  schedule_progress: {
    /** ★対象日★(YYYY-MM-DD, JST)。カードの見出しに出す */
    date: string;
    /**
     * 対象日に開始予定の本清掃の件数 — 予定の表(`clean_timetables` + `clean_timetable_histories`、`precheck = 0`)。
     * ★`clean_schedules` の曜日の設定ではない★(旧 D-049)。結果待ち・未実施も含む
     */
    planned: number;
    /** 出発した件数(departed === true)。成功+失敗であり「完了した件数」ではない(D-049) */
    completed: number;
    /** completed / planned * 100。planned が 0 なら null */
    rate: number | null;
  };
  /**
   * M2 では返さない(D-048。M3 でエラーテーブルを扱うときに実装する)。
   *
   * ★optional なのは「未実装」と「当日の失敗0件」を区別するため★
   * 空配列は「失敗0件」を意味するので、未実装をそれで表すと
   * UI が「失敗なし」として正常表示してしまう。プロパティ自体を持たせない。
   */
  failure_breakdown?: Array<{
    type: FailureType;
    label: string;
    count: number;
  }>;
  security_banner: Array<{
    facility_name: string;
    section_name: string;
    since: string;
  }>;
  /**
   * 要レスキュー店舗(ユーザー指示 2026-09-15 / 指示書AI 段階5)。
   *
   * ★★`summary` とは母集団が違う★★ — ★指定日に清掃予定が無い施設も含む★
   *   (指示書AI §1-5)。★数値カードの分母には入れない★ので、
   *   `summary.success + failed + not_executed` と件数が合わなくてよい。
   *
   * ★0件は「要レスキューの施設が無い」という事実★(`failure_breakdown` のような
   *   「未実装」の意味は持たない)。★だから optional にしない★。
   */
  rescue_required: RescueRequiredItem[];
  /**
   * ダッシュボードのコンパクト版清掃詳細ログ(D-159 / 指示書AV §2 D7・D11・D12・D13)。
   *
   * ★`summary` と同じ母集団★のうち、判定が △× の行(`isProblemGrade()`)。
   * ★並びはデータソースの順のまま★(`scheduled_at` 降順 → `id` 昇順。ルートで並べ替え直さない)。
   * ★★件数では切らない★★ — 直近モードは1清掃エリア最大1行なので清掃エリア数が自然な上限。
   *   画面が先頭5件を出す。★連続日数は `capCleanLogs()` で表示上限に頭打ち★(`/clean-logs` と同じ)。
   * ★0件は「問題の行が無い」という事実★なので optional にしない。
   */
  problem_logs: CleanLogItem[];
}

// ---------------------------------------------------------------- 清掃面積

/**
 * `DataSource.cleanArea()` に渡す取得条件(指示書AN ★段階3★ / §6-1)。
 *
 * ★★引数は必須(`args?` にしない)★★(指示書AC C-4 項4。`CleanLogDbQuery` と同じ作法)—
 *   全期間が要る呼び出し側は「範囲なし」(`date_from` / `date_to` が `null`)を明示的に渡すこと。
 *
 * ★★期間の既定は「全期間」★★(指示書AN §2-2 / §2-3)— ★開いた瞬間が「累計」★になる。
 *   ★日数の上限は設けない★(★1本の集計クエリで、返るのは施設数ぶん★なので
 *   期間の広さで行数が増えない)。★ただしデータの保持は2年なので、実際には
 *   「直近2年の合計」★(`docs/spec.md`。★画面にその旨を明記すること★)。
 *
 * ★★絞る対象は `clean_task_reports.started_at`★★(指示書AQ 決定2・決定3。旧:
 *   `clean_status_histories.started_at` / 指示書AN §1-1 / §2-5)—
 *   ★`clean_timetables.start_at`(予定時刻)ではない★。面積を持っているのはレポート側で、
 *   ★レポート行が無い清掃(未実施)はそもそも面積を持たない★。
 */
export interface CleanAreaDbQuery {
  /**
   * JST の `YYYY-MM-DD`。★下限なしは `null`★。
   * ★★`Date` で受けないこと★★ — DB の DATETIME は「日本時間の壁時計値」なので
   * 「真の瞬間」と取り違えると9時間ずれる(指示書AC C-6。変換は `dbDateRange()` 1箇所)。
   */
  date_from: string | null;
  /** JST の `YYYY-MM-DD`。★上限なしは `null`★(★その日を**含む**★) */
  date_to: string | null;
  /**
   * ★★分類(`group_id` / `customer_id`)を「施設IDの配列」に解決したもの★★
   * (`resolveFacilityIdsByMasters()`。`CleanLogDbQuery.facility_ids` と同じ作法)。
   *
   * ★★`null` = 絞らない / `[]` = 0件ヒット★★(★罠1 / 指示書AN 落とし穴3★)—
   *   ★分岐は必ず `!== null` で書くこと★。`?.length` で書くと
   *   ★「0件ヒット」が「全件」に化け、例外もテスト失敗も出ません★。
   */
  facility_ids: number[] | null;
}

/**
 * 集計の粒度(`d-watch-api.yaml` の `/clean-area` の `group_by`)。
 *
 * ★どの粒度でも `CleanAreaTotal` は変わらない★(内訳の分け方が変わるだけ。指示書AN §5-3 B-5)。
 */
export type CleanAreaScope = "group" | "customer" | "facility";

/**
 * 施設ごとの集計値(`DataSource.cleanArea()` の `stats`。面積は `groupBy` + `_sum`、
 * 実施回数・完了率の材料は取り寄せた行から。`cleanAreaStatsFromRows()`)。
 *
 * ★★API に出る形ではない★★(`d-watch-api.yaml` に対応スキーマは無い)— ロールアップ前の
 *   材料で、`aggregateCleanArea()` の入力。★`lib/types/` に置くのは、データ層(`lib/db/`)と
 *   集計層(`lib/clean-area/`)が同じ形を二重に書かないため★(`CLAUDE.md` §9.2)。
 *
 * ★`clean_area` / `task_area` / `clean_ratio_sum` が nullable なのは Prisma の `_sum` が
 *   ★対象行0件で `null` を返す★ため(指示書AN §5-3 A-2)。★`?? 0` は集計側で吸収する★ —
 *   ★`null` を画面まで漏らさない★。
 *
 * ★★面積と、実施回数・完了率で母集団が違う★★(D-155 項3 / 指示書AS D9。TeamWorks のサマリと同じ)—
 *   面積(`clean_area` / `task_area`)は ★予定外(`clean_timetable_id` が NULL か 0。TeamWorks 回答 2026-10-27)を含む全行★、
 *   実施回数・完了率は ★本清掃(`precheck = 0`)の予定に紐づく行だけ★。
 *
 * ★★完了率の材料は `clean_ratio_sum` / `clean_ratio_count`★★(指示書AQ 決定4 / 追補 5-1・5-5・D-155)—
 *   ★名前は変えていない★(指示書AS D7)が、★中身は1回ごとの完了率(`runCompletionRate()`)の合計と件数★。
 *   ★平均そのものを持たない★(率は加重平均できない。★Σ と件数を持ち回り、ロールアップの最後に
 *   1回だけ割る★。割るのは `meanCleanRatio()`(`lib/judge/kpi.ts`))。
 *   ★2つとも必須(optional にしない)★ — 詰め忘れが `undefined` のまま通ると
 *   ★完了率が `NaN` に化ける★(例外もテスト失敗も出ない)ので、型エラーで止める。
 */
export interface CleanAreaFacilityStats {
  facility_id: number;
  /**
   * Σ`clean_task_reports.clean_area`(★予定外を含む全行の合計★)。★0件なら `null`★。
   * ★総清掃面積の表示用★(指示書AQ §1-3)。★期間の合計どうしで割って完了率にしない★
   */
  clean_area: number | null;
  /** Σ`clean_task_reports.task_area`(★予定外を含む全行の合計★)。★0件なら `null`★。★表示用★ */
  task_area: number | null;
  /** 清掃実施回数(★本清掃に紐づくレポートの行数。予定外を含まない★。D-155 項3) */
  executed_count: number;
  /**
   * ★1回ごとの完了率(`runCompletionRate()`)の合計★(★完了率の分子★。平均側では丸めない)。
   * Prisma 版・fixtures 版とも常に数値を入れる(型の `null` は旧 `_sum` の名残)。
   */
  clean_ratio_sum: number | null;
  /**
   * 上の合計に足した件数(★完了率の分母★)。
   * ★いまは `executed_count` と同じ値★(`task_area = 0` も 0% として数えるため。P-5=B。D-158)。
   *   ★形を変えないために別に持つ★(旧: `task_area = 0` は `runCompletionRate()` が null でここに入らなかった)。
   */
  clean_ratio_count: number;
}

/**
 * ロールアップに必要な施設マスタの列だけ(`Facility` の部分集合)。
 *
 * ★`Facility` から `Pick` する★ — ★`group_id` の `null` = 所属なし★という契約を
 *   このファイルの1箇所に保つため(型を別に書き起こすと契約が二重管理になる)。
 */
export type CleanAreaFacilityMaster = Pick<
  Facility,
  "id" | "facility_name" | "customer_id" | "customer_name" | "group_id" | "group_name"
>;

/**
 * 清掃面積の内訳1行(`d-watch-api.yaml` の `CleanAreaRow`。`GET /clean-area` の `items[]`)。
 */
export interface CleanAreaRow {
  scope: CleanAreaScope;
  /**
   * その粒度でのID。
   * ★`scope === "group"` で「グループなし」の行だけ `null`★(指示書AN §5-3 B-3)。
   * 顧客・施設は NOT NULL なので `null` にならない。
   */
  id: number | null;
  /** 画面に出す名前。「グループなし」の行は `CLEAN_AREA_GROUP_NONE_LABEL` */
  name: string;
  /** 所属ホールディングス。★所属なしは `null`★(0 を返さない。D-011) */
  group_id: number | null;
  group_name: string | null;
  /**
   * 所属顧客。
   * ★★`scope === "group"` のときは `null`★★ — ★1つのホールディングスに顧客が
   *   複数ぶら下がりうるので「その行の顧客」が1つに決まらない★。
   *   ★適当に1つ選んで入れないこと★(どれが出るか入力順で変わり、画面が嘘をつく)。
   */
  customer_id: number | null;
  customer_name: string | null;
  /**
   * Σ清掃面積(`clean_task_reports.clean_area`。★予定外を含む全行の合計★)。★表示用。完了率の分子ではない★(指示書AQ §1-3)。
   * ★★単位は含めない★★ — 数値だけを返し、`㎡` は画面が `AREA_UNIT`(`lib/config`)から付ける
   *   (指示書AN §2-4 / 落とし穴6)。★`"㎡"` をここや API レスポンスに直書きしないこと★。
   */
  clean_area: number;
  /** Σ清掃対象面積(`clean_task_reports.task_area`。★予定外を含む全行の合計★)。★表示用。完了率の分母ではない★ */
  task_area: number;
  /**
   * 清掃完了率 = ★この行に含まれる本清掃の1回ごとの完了率(`runCompletionRate()`)の単純平均
   * (清掃1回=1票)★(指示書AQ 決定4 / 追補 5-1・D-155)。★Σ清掃面積 ÷ Σ清掃対象面積 ではない★。
   * ★予定外の清掃は含まない★(D-155 項3)。
   *
   * ★★単純平均(面積加重にしない)の根拠★★(出所: ユーザー提供CSV `clean_task_reports.csv` 189行。2026-09-30 受領)—
   *   ★`clean_area > task_area` が 129行(68.3%)★ある。
   *   ★面積で加重すると、廃止した面積を完了率に戻してしまう★
   *   (担当D の決定。`lib/judge/kpi.ts` の `meanCleanRatio()`。`lib/db/report-mapping.ts` から移設)。
   *   ★畳み方はその関数と同じにする(2箇所で別々に畳まない。追補 5-1)★。
   *   ★施設ごとの平均を平均しないこと★(Σ と件数を持ち回る)。
   *
   * ★★丸めない生値★★。★平均を取れる清掃が0回なら `null`★
   * (★`∞` / `NaN` にしない★。§5-3 A-4 / 落とし穴7)。
   */
  completion_rate: number | null;
  /** 清掃実施回数(★本清掃に紐づくレポートの行数。予定外を含まない★。D-155 項3) */
  executed_count: number;
}

/**
 * 清掃面積の全体合計(`d-watch-api.yaml` の `CleanAreaTotal`。`GET /clean-area` の `total`)。
 *
 * ★粒度を変えても変わらない★(指示書AN §5-3 B-5 / 落とし穴4)。
 */
export interface CleanAreaTotal {
  /** Σ`clean_task_reports.clean_area`(★予定外を含む全行★)。★表示用。完了率の分子ではない★ */
  clean_area: number;
  /** Σ`clean_task_reports.task_area`(★予定外を含む全行★)。★表示用。完了率の分母ではない★ */
  task_area: number;
  /**
   * 清掃完了率 = ★全本清掃の1回ごとの完了率(`runCompletionRate()`)の単純平均(清掃1回=1票)★
   * (`CleanAreaRow.completion_rate` と同じ式・同じ根拠。指示書AQ 決定4 / 追補 5-1・D-155)。
   * ★丸めない生値★。★平均を取れる清掃が0回なら `null`★。
   */
  completion_rate: number | null;
  /** 清掃実施回数(★本清掃に紐づくレポートの行数。予定外を含まない★。D-155 項3) */
  executed_count: number;
}

/**
 * `aggregateCleanArea()` の戻り値(★API のレスポンスそのものではない★)。
 *
 * ★`unmatched_facility_ids` は API に出さない★ — 異常の通知先はログ(番人)であって
 *   画面ではない。API に出す形は `CleanAreaResponse`。
 */
export interface CleanAreaAggregate {
  scope: CleanAreaScope;
  total: CleanAreaTotal;
  /** ★`clean_area` の降順 → 同値ならIDの昇順★(指示書AN §5-3 C-1 / C-2) */
  items: CleanAreaRow[];
  /**
   * ★施設マスタに無かった `facility_id`★(§5-3 B-4)。昇順・重複なし。
   *
   * ★★静かに消さないための戻り値★★ — 呼び出し側(ルート層)が
   *   `reportCleanAreaAnomalies()` を通して `logEvent("warn", ...)` を出せるようにする
   *   (★原因の断定は書かない★。`docs/logging-design.md` §7)。
   */
  unmatched_facility_ids: number[];
}

/**
 * `GET /clean-area` のレスポンス(`d-watch-api.yaml` の `/clean-area` の 200)。
 *
 * ★`ListResponse<T>` を使わない★ — ★全体合計(`total`)を持つ★のがこの一覧の要で、
 *   `items` だけの形に押し込むと画面が内訳から合計を再計算することになる
 *   (`CLAUDE.md` §9.6「フロントで再計算しない」)。
 *
 * ★`meta.total` は `items` の件数★(★清掃回数ではない★。清掃回数は `total.executed_count`)。
 * ★`limit` を持たない一覧★なので `meta.page` は常に1、`meta.per_page` は `meta.total` と同じ。
 */
export interface CleanAreaResponse {
  total: CleanAreaTotal;
  items: CleanAreaRow[];
  meta: Meta;
}