Newer
Older
client_dwatch / lib / db / clean-log-mapping.ts
/**
 * 清掃ログ(`CleanLogItem`)を組み立てるときの変換ロジック(純粋関数のみ)。
 *
 * ★`lib/db/ds-status.ts` と同じ作法★ — DB アクセスを混ぜず、`prismaSource.cleanLogs()`
 * から呼ぶだけにして、テストは fixtures と純粋関数だけで書けるようにする
 * (指示書R 2-5 / 禁止事項13)。
 *
 * ★ここは「判定ロジック」の置き場所ではない★ — ◎○△× と L1〜L3 の判定は
 * `lib/judge/` を呼ぶ(D-090 項16。`CLAUDE.md`「判定ロジックは切替の外側に置く」は
 * 「fixtures 用と Prisma 用に分岐させるな」の意味であり、「`lib/db/` の外で呼べ」ではない)。
 */
import { GRADE_THRESHOLDS, MAKER_NAMES, ROBOT_STATUS_STANDBY } from "@/lib/config";
import { countConsecutiveProblemDays, runCompletionRate } from "@/lib/judge";
import type { ScheduleDayResult } from "@/lib/judge/escalation";

/**
 * `devices.maker_id` + `devices.model_name` → `CleanLogItem.model_name`(D-090 項4 の決定)
 *
 * ★連結規則: `"<メーカー名> <機種名>"`(半角スペース1つ区切り)★ — 例 `"PUDU CC1"`。
 *   fixtures(`lib/fixtures/clean-logs.ts:50` / `lib/fixtures/facilities.ts`)と
 *   `lib/config/index.ts` の `ROBOT_MODELS` がこの形なので、それに合わせた。
 *
 * ★未知の `maker_id` は `String(maker_id)` をそのまま出す★ — `prismaSource.facilities()`
 * (`lib/db/index.ts` の `MAKER_NAMES[...] ?? String(...)`)と同じ扱い。「不明」等に潰すと
 * 対応表に追記が必要なことに誰も気づけない(`lib/config/index.ts:91-93` / D-077 と同じ思想)。
 */
export function formatModelName(makerId: number, modelName: string): string {
  return `${MAKER_NAMES[makerId] ?? String(makerId)} ${modelName}`;
}

/** `robot_error_reports` のうち、状態文字列を作るのに必要な最小限の形 */
export interface RobotErrorLike {
  /** ★DB の生値。7分類へのマッピングも日本語化もしない(D-077 / D-088)★ */
  error_type: string;
  /** `robot_error_reports.errored_at`(nullable。`prisma/schema.prisma:334`) */
  errored_at: Date | null;
}

/**
 * 事前チェック行に紐づくエラー群 → `status_1h` / `status_30m`(D-065 Q2 / D-098)
 *
 * ★エラーがあればその `error_type` を生値のまま★。★無ければ `ROBOT_STATUS_STANDBY`★。
 *
 * ★注意(重要)★: `Standby.` は「この行の `robots.status` を実際に読んで得た値」ではない。
 *   `robot_error_reports` に該当行が無いことを「エラーが無い=待機」と読み替えている
 *   (D-065 Q2)。値そのものは TeamWorks の `robots.status` コード表の原文
 *   (`docs/handoff/TeamWorks回答_第2回_20260903.md:56` の `'0' => 'Standby.'`)であり、
 *   当方が作った文言ではないので D-077(推測で変換しない)に反しない。
 *
 * ★同じ事前チェック行に複数のエラーがある場合は `errored_at` が最新の1件を採る★
 *   (指示書R 2-3 の推奨)。`errored_at` が null の行は「時刻不明」として最も古く扱い、
 *   時刻が同着なら入力順の先頭を採る(安定ソートなのでDBの返却順がそのまま効く)。
 */
export function robotStatusFromErrors(errors: readonly RobotErrorLike[]): string {
  if (errors.length === 0) return ROBOT_STATUS_STANDBY;
  const sorted = [...errors].sort(
    (a, b) => (b.errored_at?.getTime() ?? -Infinity) - (a.errored_at?.getTime() ?? -Infinity)
  );
  return sorted[0].error_type;
}

/**
 * ★「その清掃の結果が記録されているか」★ — ★`clean_task_reports` の行の有無だけを見る★
 *
 * ★★取得元が `clean_status_histories` → `clean_task_reports` に変わった★★
 *   (指示書AQ 決定2・決定3。2026-09-30 ユーザー確定)。★見ているものは変わっていない★ —
 *   どちらでも「対応する結果行があるか」だけを見る関数である。
 *
 * ★★これは「出発したか」の判定ではない★★(D-107 で改称した)—
 *   旧名 `departedFromHistory()` は ★「行があるから出発した」という当方の推測★ を
 *   関数名で主張していた。★出発したかどうかの判定は `notExecutedFromReport()` が正★。
 *
 * ★★「行が**無い**なら出発失敗」という側は残っている★★ — ただし推測ではなく
 *   ★「過去なのに結果行が無いのはデータ収集処理の障害だから、黙って消さずに見せる」★
 *   という決定として(D-107 設計3-C。fail-loud)。★その条件は
 *   `notExecutedFromReport()` の中に入っている★ので、★呼び出し側でこの関数と
 *   別の条件を AND / OR しないこと★(D-103 項1。2箇所に規則が散る)。
 *
 * ★帰結として `lib/db/index.ts` からは直接呼ばれない★(`notExecutedFromReport()` /
 *   `completionRateFromReport()` の内部でだけ使う)。★それでも関数として残してある★ —
 *   判定の言葉を1箇所に保ち、意味をテストで固定しておくため。
 *
 * ★型述語にしてあるのは呼び出し側で型を絞るため★(実行時の挙動は `report !== undefined`)。
 *
 * @param report `clean_task_reports` から `clean_timetable_id` で引いた行(無ければ undefined)
 */
export function hasCleanResult<T>(report: T | undefined): report is T {
  return report !== undefined;
}

/**
 * `clean_task_reports` のうち、★完了率の算出に必要な最小限の形★
 *
 * ★★完了率は `clean_area` / `task_area` から `runCompletionRate()` で出す★★(D-155。第11回で確定。D-158)—
 *   ★出所★: `task_area Float` / `clean_area Float`(`prisma/schema.prisma:533-534`。
 *   実DDLは `double(12,6)`。D-094)。★Prisma の型は `number`(Decimal ではない)★。
 *   ★旧: D-151(指示書AQ 決定4)では「`clean_area` / `task_area` を足さないこと。完了率は
 *   `clean_ratio` を正とする」だった★が、TeamWorks から「`clean_ratio` 列の計算式が間違っている」と
 *   回答があり(第10回 N-3)、サマリと同じ1回ごとの面積比に変えた(D-155 項1)。
 *
 * ★★`clean_error` は持っていない★★ — ★`clean_task_reports` にその列が無い★
 *   (`prisma/schema.prisma:517-558`)。旧 `CleanResultLike` は削除した。
 */
export interface CleanTaskReportLike {
  /** 清掃した面積の生値(同じ場所を複数回清掃した面積の総和。第10回 N-4) */
  clean_area: number;
  /** 清掃対象の面積の生値。★0 のとき完了率は 0★(`runCompletionRate()`。P-5=B。旧: null) */
  task_area: number;
  /**
   * ★完了率には使っていない★(D-155。指示書AS D6)。
   * ★`runCompletionRate()` を `clean_ratio` 列に戻すときのために残している★
   * (`prisma/schema.prisma:539`。実DDLは `double(4,1)`)。
   */
  clean_ratio: number;
}

/**
 * ★「清掃が実施されなかった(=出発失敗)」の判定★ — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★★判定式(指示書AQ §1-1。2026-09-30 ユーザー確定)★★:
 * ```
 * 未実施 = 本清掃の clean_timetables 行に対応する clean_task_reports の行が無い
 * ```
 *
 * ★★旧 `notExecutedFromHistory()` からの変更点★★ —
 *   旧実装は ★第1分岐(行が無い)★ と ★第2分岐(`clean_error === 1`)★ の OR だった。
 *   ★★第2分岐は消えた★★ — ★新しい取得元 `clean_task_reports` に `clean_error` 列が無い★
 *   (`prisma/schema.prisma:517-558`)ため、★条件として成立しない★。
 *   ★残った第1分岐は旧実装のものと同一の規則★なので、
 *   ★これは新しい推測ではなく既にあった規則の片方を残しただけ★(指示書AQ §1-1)。
 *   ★`clean_task_reports.status` を代わりに読まないこと★ —
 *   ★`status` は判定に使わない★(D-155 項2。ユーザー決定 2026-10-02。`prisma/schema.prisma:529`。
 *   値の意味は第10回 N-2 で受領済み)。未実施は「行が無いこと」だけで決める。
 *
 * ★★`undefined`(レポート行が無い)を `true`(出発失敗)にする理由★★ —
 *   ★これは推測ではなく決定★(D-107 / 設計3-C。指示書AQ §1-1 で取得元を変えても維持)。
 *   ★過去なのに結果行が無いのはデータ収集処理の障害★である。
 *   ★★行ごと結果から消すと(fail-silent)、運用担当は「何かが足りない」ことにすら
 *   気づけない★★ — 画面には何も出ず、件数がそっと減るだけ。出発失敗として並べば
 *   「翌朝確認型」の運用の中で異常として目に入る(`CLAUDE.md` §1)ので、
 *   ★fail-silent より fail-loud を採る★。
 *   ★帰結★: 前提が外れていても出発失敗が画面から消えることはなく、
 *   代わりに「結果がまだ書かれていないだけの清掃」まで出発失敗として出る(★過剰検知★)。
 *   ★意図的に過剰検知の側を選んでいる★。
 *
 * ★★「出発失敗」と「未実施」は同一扱いのまま★★(ユーザー確認 2026-09-30)。
 *   `CleanLogItem.departed === false`(清掃詳細ログ F-02)と
 *   `PrecheckItem.severity === "launch_failed"`(アラート F-03)は ★同じ出来事★であり、
 *   ★出発失敗の定義はこの関数1本だけ★(D-103 項1。2箇所に書かない)。
 *
 * ★★`clean_timetable_id` が NULL のレポート行の扱いに注意★★(指示書AQ 落とし穴1)—
 *   `clean_task_reports.clean_timetable_id` は ★nullable★(`prisma/schema.prisma:524`)。
 *   NULL は「予定外のタスク」(予定を持たない実在の清掃。TeamWorks 第10回 N-1 = A)で、
 *   `latestReportByTimetableId()` が Map に入れない(★完了率・実施回数に数えない★。D-155 項3)。
 *   予定(本清掃)側から見れば、★引き当たるレポートが無ければ「レポート行が無い」=未実施★として出る。
 *
 * ★「予定+猶予30分」を当方で計算しない★(D-091 項1。`LAUNCH_GRACE_MINUTES` は読まない)。
 *   ★未来の予定を除外するのは `isFutureSchedule()` の仕事★(必須。同関数参照)。
 *
 * ★★引数を総称型にしてある★★ — ★もう読むべき列が1つも無い★(行の有無だけが判定材料)ので、
 *   特定のカラムを要求する型にすると「その列を読んでいる」という誤解を型が主張してしまう。
 *
 * @param report `clean_task_reports` から `clean_timetable_id` で引いた行(無ければ undefined)
 */
export function notExecutedFromReport<T>(report: T | undefined): boolean {
  // ★`report === undefined` をインラインで書かず `hasCleanResult()` を通す★
  //   (判定の言葉を1箇所に保つため)
  return !hasCleanResult(report);
}

/**
 * `clean_task_reports` → `CleanLogItem.completion_rate`(★1回ごとの完了率 / 未実施は null★)
 *
 * | 入力 | 戻り |
 * |---|---|
 * | ★レポートの行が無い★ | ★`null`★(未実施) |
 * | それ以外 | ★`runCompletionRate()`★(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。★`task_area = 0` は 0★) |
 *
 * ★★経緯★★: D-151(2026-09-30。指示書AQ 決定4)では `clean_task_reports.clean_ratio` 列の生値だった。
 *   TeamWorks から「`clean_ratio` 列の計算式が間違っている」と回答があり(第10回 N-3。2026-10-02)、
 *   ★サマリと同じ1回ごとの面積比(上限100・小数第1位切り捨て)に変えた★(D-155 項1。★暫定★)。
 *   ★第11回で確定した★(P-2=A。`task_area = 0` は 0%: P-5=B。D-158。旧: `task_area = 0` は null)。
 *   ★1回ごとの完了率を決めるのは `runCompletionRate()` だけ★ — ここで割り算を書かないこと。
 *   ★行をそのまま渡す★(`clean_ratio` 列に戻すときに `runCompletionRate()` の1関数で済むように。D6)。
 *   ★期間の合計どうしの面積比(Σ`clean_area` ÷ Σ`task_area`)は廃止したまま★(ここは1回ずつ割る)。
 *
 * ★★「0%で実施した」と「未実施」は別物★★(★この区別は維持する★。指示書U 落とし穴1)—
 *   ★行があって `clean_area = 0`(`task_area > 0`)なら `0` を返す(`null` にしない)★(表1 R8)。
 *   潰すと「0%で実施した」に化け、
 *   ★判定(◎○△×)はどちらも × なので画面もテストも赤くならないまま壊れる★。
 *
 * ★★不変条件★★: `notExecutedFromReport()` と対になっており、
 *   ★`departed === false` ⟺ `completion_rate === null`★ が保たれる(★P-5=B で両向きに戻った★。D-158。
 *   旧: `task_area = 0` の清掃が `departed === true` で `completion_rate === null` になり、逆は成り立たなかった)。
 *   ★それでも「未実施」は `completion_rate` ではなく `departed` で数える★(【未決1】案A。D-156 / 指示書AU D3)
 *   — 完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため(要レスキュー R-1・連続問題施設の
 *   出発失敗日。`ConsecutiveInput.departed` / `RescueRowInput.departed`)。
 *   ★連続日数の問題日(`isProblemCompletion()`)は `null` を問題日のまま数える★(判定 × と整合する)。
 *   `lib/dashboard/aggregate.ts` の「予定 / 実施」は `departed` を数えている。
 *
 * ★丸めないこと★(`CLAUDE.md` §4.3)— 切り捨ては `runCompletionRate()` の中の1回ごとの値だけ。
 */
export function completionRateFromReport(
  report: CleanTaskReportLike | undefined
): number | null {
  // ★判定は `notExecutedFromReport()` を通す★(規則が2箇所に散らないように)。
  //   型述語を兼ねる `hasCleanResult()` で絞ってから返す
  if (notExecutedFromReport(report) || !hasCleanResult(report)) return null;
  return runCompletionRate(report);
}

/**
 * `clean_task_reports` を `clean_timetable_id` で引ける Map にする
 * — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★1本清掃 = 1レポートの想定だが、実データで複数あったときに黙って先頭を採らないよう
 *   ★順序キーが最大の1件★を残す★(「今の状態」は `clean_statuses` 側。D-093 項1)。
 *   ★同着は入力順の先頭★(`<` 比較なので後続で上書きされない)。
 *
 * ★★順序キー = `ended_at` があればそれ、無ければ `started_at`★★(指示書AQ §1-4)—
 *   ★`clean_task_reports.ended_at` は nullable★(`prisma/schema.prisma:541`。
 *   旧取得元 `clean_status_histories.ended_at` は NOT NULL だったので型ごと直した)。
 *   ★★この規則を決める場所はこの関数1箇所だけ★★(指示書AQ §1-4)—
 *   呼び出し側や `orderBy` で別の順序キーを書くと、
 *   `/detail-log` と `/alerts` で選ばれる行が食い違う(D-103 が禁じた状態)。
 *   そのため ★`reportOrderKey()` は export していない★。
 *
 * ★★`clean_timetable_id` が NULL の行は Map に入れない★★(指示書AQ 落とし穴1)—
 *   ★`clean_task_reports.clean_timetable_id` は nullable★(`prisma/schema.prisma:524`)。
 *   NULL をキーにすると `"null"` という実在しない `clean_timetable_id` のエントリが生まれ、
 *   ★たまたま同じキーになった複数の無関係なレポートが互いを上書きし合う★。
 *   ★落として構わない理由★: NULL は「予定外のタスク」(予定を持たない実在の清掃。
 *   TeamWorks 第10回 N-1 = A)で、本清掃 id で引く対象ではなく、★完了率・実施回数にも
 *   数えない★(D-155 項3)。★設計上ここから外れる行★であり、TeamWorks が後で埋める想定ではない。
 *
 * ★キーは `clean_timetable_id.toString()`★ — `BigInt` はキーとして `===` で比較できない。
 *
 * ★申し送り★: 1つの `clean_timetable_id` にレポートが必ず1行かは TeamWorks へ確認中
 *   (D-107 の申し送り 項8)。複数あり得る場合にこの規則が妥当かの裏取りが要る。
 */
export function latestReportByTimetableId<
  T extends { clean_timetable_id: bigint | null; ended_at: Date | null; started_at: Date },
>(reports: readonly T[]): Map<string, T> {
  const byTimetableId = new Map<string, T>();
  for (const report of reports) {
    // ★NULL はキーにできない("null" という偽のキーを作らない)★
    if (report.clean_timetable_id === null) continue;
    const k = report.clean_timetable_id.toString();
    const current = byTimetableId.get(k);
    if (!current || reportOrderKey(current) < reportOrderKey(report)) {
      byTimetableId.set(k, report);
    }
  }
  return byTimetableId;
}

/**
 * ★「最新1件」を決める順序キー★(指示書AQ §1-4)— ★module-private。export しないこと★
 *
 * ★`ended_at` があればそれ、無ければ `started_at`★。
 * `started_at` は NOT NULL(`prisma/schema.prisma:540`)なので必ず値が決まる。
 *
 * ★`??` を `||` にしないこと★ — `Date` はオブジェクトなので今は差が出ないが、
 *   将来キーを数値に変えたときに `0`(1970-01-01)が落ちる。
 */
function reportOrderKey(report: { ended_at: Date | null; started_at: Date }): number {
  return (report.ended_at ?? report.started_at).getTime();
}

/**
 * ★「予定時刻がまだ来ていない本清掃」か★ — ★`cleanLogs()` と `prechecks()` が共有する★
 *
 * ★なぜ要るか★: `clean_timetables` には ★1週間分の清掃予定がまとめて登録される★
 *   (ユーザー回答 2026-09-10。「仮に予定が無い場合は『清掃予定なし』として異常にはしない」)。
 *   つまり ★最大7日先までの本清掃行が常時DBに存在する★。
 *   ★★`notExecutedFromReport()` は「レポートの行が無い」を出発失敗と判定する★★(D-107 設計3-C)ため、
 *   ★これから実施する予定の行まで全部「出発失敗」として並んでしまう★
 *   (実測: 3日後の予定を1行入れると `launch_failed` が 13 → 14 件に増えた)。
 *   ★まだ時刻が来ていない清掃は「出発しなかった」とは言えない★ので、
 *   出発失敗/未出発の判定対象から外す。
 *
 * ★★本関数は D-107(設計3-C)の下では**必須**★★ — ★消さないこと★。
 *   ★未来の予定にはまだレポートの行が無いので、外すと未来の清掃が**全部「出発失敗」になる**★。
 *   ★加えて逆向きの事故も止める★ — もし TeamWorks が「予定の行を作った時点でレポートの行も
 *   先に作る」実装だった場合、未来の予定に面積が既定値(`clean_area = 0`)のレポートが付き、
 *   ★未来の清掃が「完了率0%・×」として並ぶ★(`task_area` も既定値 0 なら完了率 0・×。P-5=B)。本関数はその事故も1行で止める。
 *   ★★取得元が `clean_task_reports` になって、この危険は増している★★ —
 *   ★未実施の判定材料が「行の有無」だけになった★ため、行が先に作られると
 *   ★全部「実施済み・完了率0%」に化け、例外もテスト失敗も出ない★(指示書AQ §1-1)。
 *
 * ★★判定材料は `start_at` と現在時刻だけ★★ —
 *   ★`LAUNCH_GRACE_MINUTES`(猶予30分)をここから読まない★(D-091 項1 / 指示書R 禁止事項3)。
 *   「予定+猶予30分」を当方で計算するのは禁止されている。
 *   ★本関数がやっているのは「まだ予定時刻に達していない」ことの判定であって猶予の話ではない★ —
 *   ★猶予分を足したくなっても足さないこと★。足した瞬間に、設定値を変えると過去のログの
 *   判定まで変わるという D-091 項1 が禁じた状態になる。
 *
 * ★`now` を引数にしてある理由★: テストが実行時刻で揺れないようにするため(`CLAUDE.md` §9.4)。
 *   呼び出し側は1回の集計で ★同じ `now` を使い回す★こと(行ごとに `new Date()` を作ると、
 *   走査の途中で時刻が進んで境界の行の扱いがぶれる)。
 *
 * ★同時刻(`start_at === now`)は「来ていない」に入れない★ — 予定時刻には達しているので
 *   出発失敗の判定対象に含める(`>` であって `>=` ではない)。
 *
 * @param startAt `clean_timetables.start_at`(本清掃の予定開始時刻)
 * @param now     判定の基準時刻。既定は現在時刻
 */
export function isFutureSchedule(startAt: Date, now: Date = new Date()): boolean {
  return startAt.getTime() > now.getTime();
}

/**
 * その清掃が「問題あり」か(△以下 または 未実施)。
 *
 * ★`CLAUDE.md` §4.1 の△は「10%以上70%未満」、§4.5 のR3/R4は「△以下」なので、
 *   完了率ちょうど 70.0(判定は ★○★)は「問題なし」。
 *   ★★`<` であって `<=` ではない★★(★D-139。ユーザー指示 2026-09-16
 *   「◯、×は90%以上のように境はふくめてください」で境界が上のランクに入るよう変わった★)。
 *   ★★旧 D-020b 下では `<=` が正しかった★★ — 当時は 70.0 が △ だったため
 *   (指示書V 決定3。2026-09-11 ユーザー確定)。★その前提ごと覆っている★。
 *
 * ★★`judgeGrade()`(`lib/judge/grade.ts`)と結果が常に一致する★★ —
 *   入力が違うだけの同じ規則なので、★片方の比較演算子だけを動かさないこと★
 *   (`judgeGrade()` の「○ = 70%以上」より、△× ⟺ `null` または 70 未満)。
 *
 * ★★閾値は `GRADE_THRESHOLDS.good` から読む。`70` を直書きしない★★(`CLAUDE.md` §7)。
 *
 * ★`lib/fixtures/clean-logs.ts:226` の `consecutive_problem_days` の生成式
 *   (`rate === null || rate < GRADE_THRESHOLDS.good`)は★同じ概念で、既に `<`★なので
 *   D-139 後はそのまま一致する(変更不要)★。
 */
export function isProblemCompletion(completionRate: number | null): boolean {
  return completionRate === null || completionRate < GRADE_THRESHOLDS.good;
}

/** 連続日数の算出に必要な最小限の形 */
export interface ConsecutiveInput {
  /** `CleanLogItem.id`(= 本清掃の `clean_timetables.id`) */
  id: number;
  /**
   * 施設の識別子(`CLAUDE.md` §4.5 の R3/R4「同一施設で」に合わせた分割キー)。
   * ★★以前は `serial_number: string` を分割キーにしていたが誤りだった★★ —
   * 「P1 は 1清掃エリア = 1ロボットなので serial_number で代用できる」という前提は、
   * 1施設に複数の清掃エリア(=複数ロボット)が実在する施設(施設5=2台 / 施設7=3台)が
   * あるため成り立たない(指示書V 不具合B)。
   */
  facility_id: number;
  /** ISO8601(+09:00)。日付昇順に並べ替えるキー */
  scheduled_at: string;
  /**
   * 1回ごとの完了率(`completionRateFromReport()` = `runCompletionRate()` の戻り)。丸める前の生値。
   * ★未実施は null★(未実施の数え方は `departed` で見る。指示書AU D3)
   */
  completion_rate: number | null;
  /**
   * ★出発したか(=レポートの行があるか)★ — `!notExecutedFromReport(report)`。
   *
   * ★★【未決1】案A(D-156。指示書AS 段階3-4)★★: ★要レスキュー R-1(未実施3日連続)と
   *   連続問題施設の出発失敗日(`launchFailedDaysInRun()`)は、`completion_rate === null` ではなく
   *   この値で「未実施」を数える★。★いまは `completion_rate === null` と同値★(P-5=B)だが、
   *   ★完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため★に別に持つ(指示書AU D3)。
   * ★★optional(`?`)にしないこと★★ — 詰め忘れが `undefined`(=偽=全部未実施)のまま通る。
   */
  departed: boolean;
  /**
   * 面積の生値(★旧: 急落判定 R-3 の面積加重に使っていた。指示書AI 段階2★)。
   *
   * ★★R-3 は1回ごとの完了率の単純平均★★(`meanCleanRatio()`。指示書AQ 決定4 / §5-5・D-155)—
   *   ★判定(`judgeRescue()`)はこの2列を読まない★(`RescueRowInput` が型から外している)。
   *   ★完了率の分子・分母(Σ面積 ÷ Σ面積)としてここを読み直さないこと★。
   *
   * ★レポートの行が無い行(=未実施)は 0★。
   * ★★`completion_rate` は `runCompletionRate()` で作ったもの。ここで割り直さないこと★★
   *   (規則が2箇所に散る)。1行(=1清掃エリア)の率で、未実施は `null`。
   * ★★optional(`?`)にしないこと★★ — 詰め忘れが `undefined` のまま通る。
   *   ★旧: 面積加重が静かに 0/0 → `null` に化ける(指示書AI §13-2)という理由だった★が、
   *   R-3 が単純平均に変わったのでその事故はもう起きない。★型を必須のまま残しているのは、
   *   Prisma 版(`loadConsecutiveInputs()`)と fixtures 版(`rescueInputsFromCleanLogs()`)の
   *   形を揃えておくため★(どちらも 0 を詰めている)。
   */
  clean_area: number;
  task_area: number;
}

/**
 * 同一日の複数行を OR で1日に畳む(A案。ユーザー確定 2026-09-11 / D-109)。
 *
 * ★なぜ畳むのか★ — `countConsecutiveProblemDays()`(`lib/judge/escalation.ts`)の契約は
 *   「スケジュール存在日1日分の結果」を★1日1エントリ★で渡すことである。
 *   施設に複数の清掃エリア(ロボット)があると、同じ日に複数行が生まれるため、
 *   畳まずに渡すと同じ日付の要素が複数になり「N日連続」と水増しされる(落とし穴1)。
 *
 * ★畳み方(A案)★: 同一日に問題ありの行が1つでもあれば、その日は「問題あり」とする(OR)。
 *   ★★ユーザー確定の原文(2026-09-11)★★: 「2台のうち 1日目Aが問題 / 2日目Bが問題 /
 *   3日目Aが問題 でも3日連続で問題発生と記録する」。
 *   ★B案(全台が問題のときだけ問題日とする。AND)は不採用★ —
 *   3台ある施設がほぼ永久にエスカレーションしなくなるため。
 *
 * ★★同一ロボットが同じ日に2回清掃した場合も同じ理由で1日に畳む(決定2)★★ —
 *   D-026「連続はスケジュール存在日の連続」は日単位であり、回数単位ではない。
 *
 * @returns 日付昇順・重複なしの `ScheduleDayResult[]`
 */
export function foldProblemDaysByDate(
  rows: readonly ConsecutiveInput[]
): ScheduleDayResult[] {
  const isProblemByDate = new Map<string, boolean>();
  for (const row of rows) {
    const date = row.scheduled_at.slice(0, 10);
    const isProblem = isProblemCompletion(row.completion_rate);
    isProblemByDate.set(date, (isProblemByDate.get(date) ?? false) || isProblem);
  }
  return [...isProblemByDate.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([date, isProblem]) => ({ date, isProblem }));
}

/**
 * `consecutive_problem_days` を全行ぶんまとめて算出する(D-026 / 指示書R 6-3 / 指示書V 不具合B)。
 *
 * ★DB に対応するカラムは無い★ので、施設ごとに全行を集め、`foldProblemDaysByDate()` で
 * 同一日を1エントリに畳んだうえで、各行について「その行の日付までの連続」を数える。
 *
 * ★★分割キーは `facility_id`★★(`CLAUDE.md` §4.5 の R3/R4「同一施設で」)。
 *   ★★畳み込みはこの関数の内部だけで行う★★(呼び出し側でやると規則が2箇所に散る。D-103)。
 *   ★★同じ日の全行に同じ連続日数が入る★★(畳んだ1エントリの値を全員で共有するため)。
 *
 * ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」★(D-026 / `CLAUDE.md` §4.5)。
 *   ここでは ★「清掃ログの行が存在する日」=「スケジュール存在日」★ とみなす
 *   (`clean_timetables` の行はスケジュールから生成されるため。D-049)。
 *   したがって清掃予定のない日は自然に飛ばされ、連続が途切れない(畳み込み後も同じ)。
 *   判定そのものは `lib/judge/escalation.ts` の `countConsecutiveProblemDays()` に渡す
 *   (どの日がスケジュール存在日かの判断は呼び出し側の責任、という同関数の契約どおり)。
 *
 * ★★施設単位にした帰結(決定4。意図した結果)★★ — 正常終了した行にも R3/R4 が出ることが
 *   あり(別の台のせいで施設の連続が2日以上になる)、逆に別の台のせいでその台にとって
 *   初回の出発失敗でも R1 が出ないことがある(`lib/judge/advice.ts` の
 *   `consecutiveProblemDays <= 1`)。★`lib/judge/advice.ts` は変更しない★。
 *
 * ★★入力は「一覧と同じ期間で絞った行」にしないこと★★(指示書AC C-2 / 段階1):
 *   この関数は ★渡された行の範囲でしか連続を数えられない★。一覧と同じ期間で絞った行を渡すと
 *   ★期間の外にある前日の行が消えて連続が途切れる★ — 「9/8× 9/9× 9/10×」(3日連続 = L3)を
 *   「9/10〜9/10」で絞って渡すと ★1日 = L1 に化ける★。
 *   ★★例外もテスト失敗も出ない fail-silent★★。
 *
 *   ★入力を作る責務は `loadConsecutiveInputs()`(`lib/db/consecutive-source.ts`)の1本に
 *   切り出してある★ — ★★`cleanLogs()` / `facilityPerformance()` / `continuousProblems()` は
 *   その1本を呼ぶこと★★(D-109 / D-103 の再発防止。連続日数を2箇所で別々に計算しない)。
 *   ★★この関数を呼ぶ前に自前で期間を絞り直さないこと★★。
 *
 *   ★★その1本は指示書AC 段階6 で有界になっている★★ — ★全期間読みではありません★。
 *   読む範囲は ★「表示期間 + 保存された表示上限 +1日ぶんの遡り」★ で、
 *   遡り幅は `loadConsecutiveDaysMax()` の値から導く(★定数に写し取らないこと★)。
 *   ★真の連続が上限以下なら全部読めて正しい値★、★上限を超えるなら「{上限}日以上」★になる
 *   (根拠と遡り幅の決め方は `loadConsecutiveInputs()` の docstring が正)。
 *   ★例外★: `date_from` が `null`(単一ID取得など下限なしの呼び出し)のときは下限が付かない。
 *   ★代わりに読む列を最小限にしてある★(`select` で4列/6列。`include` は一切しない。
 *   ★列を増やしてよい条件は `loadConsecutiveInputs()` の docstring が正★)。
 *   畳み込みは施設ごとの1パスを足すだけなので、走査回数は増えも減りもしない。
 *
 * @returns `CleanLogItem.id` → 連続問題日数
 */
export function computeConsecutiveProblemDays(
  rows: readonly ConsecutiveInput[]
): Map<number, number> {
  const byFacility = new Map<number, ConsecutiveInput[]>();
  for (const row of rows) {
    const list = byFacility.get(row.facility_id);
    if (list) list.push(row);
    else byFacility.set(row.facility_id, [row]);
  }

  const result = new Map<number, number>();
  for (const list of byFacility.values()) {
    // 同一日を1エントリに畳む(日付昇順で返る)
    const days = foldProblemDaysByDate(list);
    const dayIndexByDate = new Map<string, number>();
    days.forEach((day, i) => dayIndexByDate.set(day.date, i));

    for (const row of list) {
      const date = row.scheduled_at.slice(0, 10);
      // ★foldProblemDaysByDate() が同じ行から作った days なので必ず見つかる★
      const dayIndex = dayIndexByDate.get(date) as number;
      // 「その日まで」の連続なので、先頭からその日までを渡す(同じ日の全行が同じ値になる)
      result.set(row.id, countConsecutiveProblemDays(days.slice(0, dayIndex + 1)));
    }
  }
  return result;
}