Newer
Older
client_dwatch / lib / db / consecutive-source.ts
/**
 * 連続日数(`consecutive_problem_days`)の入力を作る★専用の読み取り★(指示書AC 段階1 / C-3 B案)。
 *
 * ★★なぜ切り出したのか★★
 *   連続日数は「その施設の**全期間**を遡って数える」ので、★対象期間だけを読むと連続が途切れます★
 *   (指示書AC C-2「最大の地雷」。9/8× 9/9× 9/10× を「9/10〜9/10」で引くと 1日 → L1 に化ける。
 *   ★例外もテスト失敗も出ない fail-silent★)。
 *   一方で一覧の本体(`cleanLogs()` など)は期間で絞って読みたい(指示書AC 段階2)。
 *   そこで ★「連続日数の入力を作る読み取り」だけを1本に切り出し、そこだけ広い範囲を読む★。
 *   ★★代わりに読む列を最小限にする★★ — `select` で4列/6列に絞り、`include` を一切しない
 *   (★レポート側の6列の内訳と、列を増やしてよい条件は `loadConsecutiveInputs()` の docstring★)。
 *   ★★段階6 でこの1本も有界にしました★★ — ★表示期間 +「保存された表示上限 +1日」ぶんの
 *   遡り★だけを読む(`loadConsecutiveInputs()` の docstring)。全期間走査ではなくなっています。
 *
 * ★★この1本を `cleanLogs()` / `facilityPerformance()` / `continuousProblems()` の3箇所が共有する★★
 *   (★連続日数を2箇所で別々に計算しないこと★。D-109 / D-103 の再発防止。
 *   以前は `facilityPerformance()` と `continuousProblems()` にほぼ同じコードが2回書かれていた)。
 *
 * ★★`isFutureSchedule()` を外さないこと★★ — 外すと未来7日ぶんの予定が全部「出発失敗」になり、
 *   全施設が常時 L3 になります(D-106 / D-107 / D-110)。
 *
 * ------------------------------------------------------------------------
 * ★★2026-09-30: 清掃実績の参照元を `clean_task_reports` に差し替えた★★(指示書AQ 決定2 / 決定3)
 * ------------------------------------------------------------------------
 * ★`clean_status_histories` は使わない★(指示書AQ 決定3。ユーザー指示 2026-09-30)。
 * 変わったのは次の3点だけで、★連続日数の数え方そのものは1ミリも変えていません★。
 *
 * 1. ★未実施の判定★(指示書AQ §1-1)— `clean_task_reports` に ★`clean_error` 相当の列が無い★。
 *    ★「本清掃の行に対応するレポートの行が無い=未実施」だけが判定規則★になる
 *    (=旧 `notExecutedFromHistory()`(`clean_status_histories` 版)の第1分岐だけが残った形。
 *    ★現行の関数は `notExecutedFromReport()`(`lib/db/clean-log-mapping.ts`)★)。
 * 2. ★順序キー★(指示書AQ §1-4)— `ended_at` が ★nullable★(`prisma/schema.prisma:541`)なので、
 *    ★`ended_at` があればそれ、無ければ `started_at`★。★決める場所は1関数だけ★。
 * 3. ★完了率は1回ごとの完了率(`runCompletionRate()`。`completionRateFromReport()` 経由)★
 *    (D-155。第11回で確定。D-158)。★旧: D-151 では `clean_ratio` 列の生値だった★(指示書AQ 決定4)。
 *    ★面積(`clean_area` / `task_area`)は R-3 の重み付けではなく、`runCompletionRate()` で
 *    1回ごとの完了率を出すために読んでいる★。★R-3 は1回ごとの完了率の単純平均
 *    (`lib/judge/kpi.ts` の `meanCleanRatio()`)★(指示書AQ 決定4 / §5-5。`lib/judge/rescue.ts`)。
 *    ★面積加重に戻さないこと★。★旧: R-3 が面積で重み付けしていたため、この2列を読んでいた(指示書AI 段階2)★。
 *
 * ------------------------------------------------------------------------
 * ★★2026-10-29: 予定外タスクも連続日数の入力に入れる★★(指示書BG §2-3。ユーザー指示「エラー一覧以外も、
 * 予定外タスクを計算して出すようにしてください」「連続日数・要レスキュー…はそれでOK」)
 * ------------------------------------------------------------------------
 * - ★予定外タスク = `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行★(判定は `scheduledTimetableId()` の1本)。
 *   ★3本目のクエリで読む★(`loadConsecutiveInputs()`。予定の id で引く2本目の `IN` には構造的に入らないため)
 * - ★1件=その施設のその日(`started_at` の JST の日付)の1回★。★常に出発済み(`departed: true`)★、
 *   完了率は予定のある清掃と同じ `completionRateFromReport()`(= `runCompletionRate()`)
 * - ★予定外だけがある日も「スケジュール存在日」として数える★(連続を作る日になる。指示書BG §2-3)
 * - ★未実施にはならない★ので、要レスキュー R-1(未実施3日連続)を押し上げない。R-3(急落)の日の平均には入る
 * - ★削除済みの施設・清掃エリアの行は入れない★(予定のある行と同じ `FACILITY_SECTION_VISIBLE_WHERE`)・★施設の絞り込みも同じ★
 * - ★事前チェックの回は入らない★(予定外には事前チェックの回が無い。事前チェックの回には `clean_task_reports` の行が入らない。TeamWorks 回答 2026-10-27)
 * - ★`id` は `null`★(予定の id が無い。`CleanLogItem.id` と同じ)。★連続日数を予定外の行について引くときは
 *   施設 × 日付で引く★(`consecutiveProblemDaysByFacilityDate()`。`computeConsecutiveProblemDays()` は id で引くので予定のある行だけ)
 */
import { parseDate, toDbQueryDate } from "@/lib/clean-logs/query";
import { CLEAN_TIMETABLE_PRECHECK, CONSECUTIVE_LOOKBACK } from "@/lib/config";
import { chunkForInClause } from "@/lib/db/chunked";
import {
  completionRateFromReport,
  isFutureSchedule,
  latestReportByTimetableId,
  notExecutedFromReport,
  type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import { dbDateRange, type DbDateRange } from "@/lib/db/date-range";
import { scheduledTimetableId, UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link";
import { findTimetables } from "@/lib/db/timetable-source";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { isResultPending } from "@/lib/clean-logs/result-pending";
import { fromDbJst, jstIsoFromDb } from "@/lib/format/date";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";

/**
 * `clean_timetables` から読む列(★これ以上増やさないこと★)。
 * `id` / `facility_id` … 連続日数の分割キーと戻り値のキー
 * `start_at`           … 日付(`fromDbJst()` / `jstIsoFromDb()` を通す。壁時計値なので生で使わない)
 * `precheck`           … 本清掃だけに絞るため(`CLEAN_TIMETABLE_PRECHECK.main`)
 * `end_at`             … ★結果待ち(`isResultPending()`)の判定に使う★(ユーザー判断 2026-10-07。D-173。
 *                        列を足した理由と転送量は `loadConsecutiveInputs()` の docstring の「実績」)
 */
export interface ConsecutiveTimetableRow {
  id: bigint;
  facility_id: bigint;
  start_at: Date;
  /** ★`start_at` と同じ JST の壁時計値★(`fromDbJst()` を通す) */
  end_at: Date;
  precheck: number;
}

/**
 * ★`clean_task_reports` から読む列★(★これ以上増やさないこと★。指示書AQ 決定2)。
 *
 * `clean_timetable_id` … 突き合わせキー(★FK も KEY も無いので手動 join★。D-092 項3-3)。
 *                        ★★nullable★★(`prisma/schema.prisma:524`)— ★罠1 の項を必ず読むこと★
 * `clean_ratio`        … ★完了率には使っていない★(D-155)。`runCompletionRate()` を列に戻すときのために残す(指示書AS D6)
 * `started_at`         … ★順序キーのフォールバック★(`ended_at` が NULL のとき。指示書AQ §1-4)
 * `ended_at`           … 順序キーの第1候補。★★nullable★★(`prisma/schema.prisma:541`。罠3)
 * `clean_area` / `task_area`
 *                      … ★1回ごとの完了率(`runCompletionRate()`)の材料★(D-155)。
 *                        ★旧: 要レスキューの急落判定 R-3 の重み付けに使っていた(指示書AI 段階2)★。
 *                        ★R-3 は重み付けしない★(1回ごとの完了率の単純平均。`meanCleanRatio()`)。
 *                        列を増やしてよい条件は `loadConsecutiveInputs()` の docstring を見ること
 *
 * ★★`clean_error` は `clean_task_reports` に存在しない★★(指示書AQ §1-1)—
 *   ★足さないこと★。未実施は「レポートの行が無いこと」だけで判定する。
 *
 * ★★`status` を読んでいない★★(`prisma/schema.prisma:529`)— 値の意味は受領済み
 *   (第10回 N-2)だが、★判定には使わない★(D-155 項2。ユーザー決定 2026-10-02)ので、
 *   ★未実施に読み替えないこと★(指示書AQ §1-2 / D-077 / `CLAUDE.md` §4.8)。
 */
export interface ConsecutiveTaskReportRow {
  /** ★nullable★。NULL 行は `IN` に一致しないので、そもそもここまで返って来ない(罠1) */
  clean_timetable_id: bigint | null;
  clean_ratio: number;
  started_at: Date;
  /** ★nullable★。順序キーは `latestReportByTimetableId()` の中で決まる(指示書AQ §1-4) */
  ended_at: Date | null;
  clean_area: number;
  task_area: number;
}

/**
 * ★予定外タスクの `clean_task_reports` から読む列★(指示書BG §2-3。3本目のクエリ)。
 *
 * ★予定のある行の列(`ConsecutiveTaskReportRow`)+ `facility_id`★ — 予定外の行は予定の行を持たないので、
 *   ★施設をレポートの行から取るしかない★(`select` の列を増やしてよい条件1)。
 *   ★`section_id` は読まない★(連続日数は施設単位。`report_id` も読まない — `id` は `null` で、引くときは施設 × 日付)。
 * ★`clean_timetable_id` を読むのは、SQL の写し(`UNSCHEDULED_TIMETABLE_ID_WHERE`)を純粋関数側で
 *   `scheduledTimetableId()` によって確かめ直すため★(規則の正は純粋関数側。指示書AC C-5 と同じ作法)。
 */
export interface ConsecutiveUnscheduledReportRow extends ConsecutiveTaskReportRow {
  facility_id: bigint;
}

/**
 * 予定外タスクを読む `where`(★SQL の写し★。規則の正は `scheduledTimetableId()`)。
 * ★期間は `started_at`★(予定外は実際に始めた時刻で振り分ける。指示書BG §1 / `CLAUDE.md` §4.7)
 */
export type ConsecutiveUnscheduledReportWhere = typeof UNSCHEDULED_TIMETABLE_ID_WHERE &
  typeof FACILITY_SECTION_VISIBLE_WHERE & {
    started_at?: DbDateRange;
    facility_id?: { in: bigint[] };
  };

/** 予定外タスクを読む `select`(`ConsecutiveUnscheduledReportRow` の列) */
export interface ConsecutiveUnscheduledReportSelect {
  facility_id: true;
  clean_timetable_id: true;
  clean_ratio: true;
  started_at: true;
  ended_at: true;
  clean_area: true;
  task_area: true;
}

/**
 * ★予定外タスクを読む3本目のクエリが要る形★(指示書BG §2-3)。
 * ★`ConsecutiveScheduledSourcePrisma` と別の型にして交差させる★ — 1つの `findMany` に2つの引数の形を
 *   オーバーロードで書くと、Prisma の総称型の `findMany` が代入できなくなる(引数の型推論が `any` に倒れる)。
 *   交差なら各々の形ごとに代入を確かめるので通る。
 */
export interface ConsecutiveUnscheduledSourcePrisma {
  cleanTaskReport: {
    findMany(args: {
      where: ConsecutiveUnscheduledReportWhere;
      select: ConsecutiveUnscheduledReportSelect;
    }): Promise<ConsecutiveUnscheduledReportRow[]>;
  };
}

/**
 * `loadConsecutiveInputs()` が必要とする Prisma の最小限の形(★予定の行・予定に紐づくレポート・予定外タスク★)。
 * スタブの `cleanTaskReport.findMany` は ★`where` に `clean_timetable_id.in` があるか★で2つの形を見分けること。
 */
export type ConsecutiveSourcePrisma = ConsecutiveScheduledSourcePrisma &
  ConsecutiveUnscheduledSourcePrisma;

/**
 * この関数が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
 * ★`PrismaClient` をそのまま要求しないこと★ — 生成済みクライアントが無い環境でも
 * 単体テストが走るようにしてある(`lib/db/prisma.ts` の遅延 import と同じ理由)。
 * ★予定外タスクの読み取りは `ConsecutiveUnscheduledSourcePrisma`★(交差して `ConsecutiveSourcePrisma`)。
 */
export interface ConsecutiveScheduledSourcePrisma {
  cleanTimetable: {
    findMany(args: {
      where?: { precheck: number; start_at?: DbDateRange };
      select: { id: true; facility_id: true; start_at: true; end_at: true; precheck: true };
    }): Promise<ConsecutiveTimetableRow[]>;
  };
  /**
   * ★予定は `clean_timetable_histories` からも読む★(指示書AU 段階3b。第11回 P-7)。
   * ★`cleanTimetable` と同じ引数を受ける★(`findTimetables()` が同じ `args` を2表に渡す)。
   * ★`cleanTimetable.findMany` は影チェック(`{ where: { id: { in } }, select }`)も受ける★
   *   (`lib/db/timetable-source.ts`。履歴だけに出た id があるときだけ)
   */
  cleanTimetableHistory: {
    findMany(args: {
      where?: { precheck: number; start_at?: DbDateRange };
      select: { id: true; facility_id: true; start_at: true; end_at: true; precheck: true };
    }): Promise<ConsecutiveTimetableRow[]>;
  };
  cleanTaskReport: {
    findMany(args: {
      where: { clean_timetable_id: { in: bigint[] } };
      select: {
        clean_timetable_id: true;
        clean_ratio: true;
        started_at: true;
        ended_at: true;
        clean_area: true;
        task_area: true;
      };
    }): Promise<ConsecutiveTaskReportRow[]>;
  };
}

// ══════════════════════════════════════════════════════════════════════════════
// ★★未実施判定・順序キー・完了率の正は `lib/db/clean-log-mapping.ts`(担当C)★★
// ══════════════════════════════════════════════════════════════════════════════
// ★このファイルに同じ規則を書き直さないこと★(D-103 の再発防止。規則が2箇所に散る)。
//   ・未実施 = レポートの行が無い …… `notExecutedFromReport()`(`departed` に詰める)
//   ・順序キー(`ended_at` ?? `started_at`)…… `latestReportByTimetableId()` の中(★非公開★)
//   ・完了率は1回ごとの完了率 …… `completionRateFromReport()`(= `runCompletionRate()`。D-155)
// ★★`ended_at ?? started_at` をここに書かないこと★★ — 担当C が意図的に
//   `reportOrderKey()` を export していない(規則を1箇所に閉じるため)。

/**
 * 連続日数の入力を読む範囲(★表示したい期間そのもの★。★遡りぶんは含めない★)。
 *
 * ★★呼び出し側は「画面に出したい期間」をそのまま渡すこと★★ —
 *   ★上限ぶんの遡りは `loadConsecutiveInputs()` の中で足します★
 *   (★呼び出し側で遡りを計算すると、3経路が別々の遡り幅を持ちうる=
 *   `/detail-log` と `/reports` で連続日数が食い違う。D-109 の再発防止★)。
 *
 * ★`date_from: null` = 下限なし(全期間を読む)★ — 単一ID取得
 *   (`/clean-logs/{id}/advice` / `/{id}/areas`)は対象ログの日付が引くまで分からないため
 *   この形になる(申し送り)。
 */
export interface ConsecutiveDbRange {
  /** JST の `YYYY-MM-DD`。★下限なしは `null`★ */
  date_from: string | null;
  /** JST の `YYYY-MM-DD`。★上限なしは `null`★(★その日を**含む**★) */
  date_to: string | null;
  /**
   * ★施設で絞る★(指示書BB A6・D16・D-182。★必須★ — 任意にすると渡し忘れても動いてしまう。指示書AC C-4 項4)。
   * ★`null` = 絞らない/`[]` = 0行★(罠1。`!== null` で分岐する)。
   * ★連続日数は施設ごとに独立に数えるので、施設で絞っても絞った施設の値は変わらない★(`computeConsecutiveProblemDays()`)。
   * ★単一ID(`/clean-logs/{id}/advice`・`/areas`)は `null`★(U4。TeamWorks の索引の後に絞る)
   */
  facility_ids: number[] | null;
}

/** ★範囲を絞らない(全期間・全施設を読む)★ことを明示する値。★`null` を素で書かないための名前★ */
export const CONSECUTIVE_RANGE_UNBOUNDED: ConsecutiveDbRange = {
  date_from: null,
  date_to: null,
  facility_ids: null,
};

/** 1日(ミリ秒) */
const ONE_DAY_MS = 86_400_000;

/**
 * 表示上限(スケジュール存在日)から、★カレンダー日★の遡り幅を求める。★純粋関数★
 *
 * ★★スケジュール存在日をそのままカレンダー日にしないこと★★(D-026。清掃予定のない日は
 *   連続を途切れさせないので、30スケジュール存在日は30カレンダー日より長くなりうる)。
 *   ★係数と `+1` の理由は `CONSECUTIVE_LOOKBACK` の docstring★。
 *
 * ★★`ESCALATION_THRESHOLDS.level3Days` を上限に流用しないこと★★(指示書AC 段階6)—
 *   あれは「何日連続で L3 にするか」の判定閾値で、表示上限でも遡り幅でもない。
 */
export function consecutiveLookbackCalendarDays(maxDays: number): number {
  return (
    (maxDays + CONSECUTIVE_LOOKBACK.plusDays) *
    CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay
  );
}

/**
 * 表示したい期間の開始日から、★遡ったぶんを含む下限日★(JST `YYYY-MM-DD`)を返す。★純粋関数★
 *
 * @param dateFrom 表示したい期間の開始日。★`null`(下限なし)ならそのまま `null` を返す★
 * @param maxDays  ★保存された表示上限★(`loadConsecutiveDaysMax()` の戻り値)。
 *   ★`lib/config/` の既定値を直接読まないこと★ — 管理画面で上げても遡り幅が追随せず、
 *   ★上げた意味が無くなる(例外もテスト失敗も出ない)★(指示書AF §C-3 申し送り1)。
 */
export function consecutiveLookbackFrom(
  dateFrom: string | null,
  maxDays: number
): string | null {
  const from = parseDate(dateFrom);
  if (!from) return null;
  const days = consecutiveLookbackCalendarDays(maxDays);
  // ★`getDate()` 等の実行環境依存の getter を使わないこと★(CI で1日ずれる)。
  //   ★`parseDate()` → ミリ秒演算 → `toDbQueryDate()` の往復で JST の日付に閉じる★
  return toDbQueryDate(new Date(from.getTime() - days * ONE_DAY_MS));
}

/**
 * 読んだ行から `ConsecutiveInput[]` を組み立てる★純粋関数★(DBアクセスを混ぜない)。
 *
 *   1. `precheck === CLEAN_TIMETABLE_PRECHECK.main` で本清掃だけに絞る
 *   2. `isFutureSchedule(fromDbJst(row.start_at), now)` で未来の予定を外す(★外さないこと★)
 *   2b. ★結果待ち(`isResultPending()`)の予定も、未来の予定と同じく外す★(表2 C10。ユーザー判断 2026-10-07。D-173)—
 *      ★問題日にも非問題日にも数えない★(レポートがまだ届いていないだけかもしれないため)。
 *      ★`cleanLogs()` の `result_pending` と同じ関数・同じ `now`★
 *   3. レポートの1件への絞り込みは `latestReportByTimetableId()`、完了率は
 *      `completionRateFromReport()`、出発したかは `!notExecutedFromReport()`
 *      (担当C。★別の書き方をしない★。規則が2箇所に散る)
 *
 * ★★`clean_timetable_id` が NULL のレポート行の行方(★罠1★。指示書AQ §3)★★
 *   ★NULL は `IN` に一致しない★ので、そのレポート行は `loadConsecutiveInputs()` の
 *   2本目のクエリから ★返って来ません★。すると対応する `clean_timetables` の行には
 *   引き当たるレポートが無いことになり、★★その清掃は「未実施」として集計されます★★
 *   (指示書AQ §1-1 の規則が素直に適用された結果)。
 *   ★★行が消えるのではなく「未実施」に化けるのが危険なところ★★ —
 *   ★例外もテスト失敗も出ず、稼働率・出発成功率だけが静かに0へ落ちます(fail-silent)★。
 *   ★★NULL は「予定外のタスク」★★(ユーザー回答 2026-10-01 / TeamWorks 第10回 N-1 = A)—
 *   ★予定(`clean_timetables`)を持たない実在の清掃★であり、欠損でも「TeamWorks が後で埋める」
 *   ものでもない。★予定の行への引き当てから外れるのは仕様どおり★(予定外タスクは予定の行のものではない)。
 *   つまり ★この挙動は「直す」ものではなく「そういうもの」★です。
 *   (対応する予定の行が「未実施」に見えるのは、そのレポートが別の清掃のものであるため)。
 *   ★★2026-10-29 から予定外タスクは別の1回として入力に入る★★(3本目のクエリ → `unscheduledReports`。指示書BG §2-3)。
 *   ★旧: 「清掃完了率・実施回数には数えない(D-155 項3)」で、連続日数の入力にも入れていなかった★。
 *   ★★0 も予定外★★(TeamWorks 回答 2026-10-27。`lib/db/timetable-link.ts`)— ★0 の行も同じく返って来ない★
 *   (`IN` に渡すのは実在する予定の id だけで、★0 は `IN` に入れない★。`loadConsecutiveInputs()`)。
 *   件数を知りたいときは
 *   `SELECT COUNT(*) FROM clean_task_reports WHERE clean_timetable_id IS NULL OR clean_timetable_id = 0` を見ること。
 *
 * ★★呼び出し側へ: ここに「期間で絞った行」を渡さないこと★★ — 連続日数は全期間を遡って
 *   数えるので、期間の外にある前日の行が消えると ★連続が途切れて L3 が L1 に化けます★
 *   (指示書AC C-2。★例外もテスト失敗も出ない★)。
 *   ★★`lib/db/index.ts` は必ず `loadConsecutiveInputs()`(=専用の全期間クエリ)を経由すること★★。
 *   この純粋関数を export してあるのは ★単体テストのため★であり、
 *   「すでに読んである行を使い回す近道」として呼ぶためではありません。
 *
 * ★★予定外タスク(`unscheduledReports`)は別に受け取る★★(指示書BG §2-3)—
 *   ★`reports`(予定の id で引いた行)に混ぜないこと★。`latestReportByTimetableId()` は予定外を Map に入れない
 *   (予定の行に引き当てる材料ではない)ので、混ぜても黙って捨てられる(fail-silent)。
 *   - ★1件=1回★(同じ施設・同じ日に何件あってもそのまま並べる。日に畳むのは `foldProblemDaysByDate()` の仕事)
 *   - ★未来・結果待ちの判定はしない★(レポートの行があるので結果は届いている。予定外は未実施・結果待ちになりえない。指示書BG §1)
 *   - ★`scheduledTimetableId()` が予定の id を返す行は入れない★(SQL の写しが漏れても予定の行と二重に数えない)
 *
 * @param now ★1回だけ作って全行で使い回す★(行ごとに `new Date()` を作ると境界の行の扱いがぶれる)
 * @param unscheduledReports ★予定外タスクの行★(`loadConsecutiveInputs()` の3本目。★必須★ — 渡し忘れを型で止める)
 */
export function consecutiveInputsFromRows(
  timetables: readonly ConsecutiveTimetableRow[],
  reports: readonly ConsecutiveTaskReportRow[],
  now: Date,
  unscheduledReports: readonly ConsecutiveUnscheduledReportRow[]
): ConsecutiveInput[] {
  const reportByTimetableId = latestReportByTimetableId(reports);
  const scheduled = timetables
    .filter(
      (row) =>
        row.precheck === CLEAN_TIMETABLE_PRECHECK.main &&
        !isFutureSchedule(fromDbJst(row.start_at), now) &&
        // ★結果待ちは未来の予定と同じく入力から外す★(表2 C10。D-173)
        !isResultPending({
          startAt: fromDbJst(row.start_at),
          endAt: fromDbJst(row.end_at),
          now,
          hasReport: !notExecutedFromReport(reportByTimetableId.get(row.id.toString())),
        })
    )
    .map((row): ConsecutiveInput => {
      const report = reportByTimetableId.get(row.id.toString());
      return {
        id: Number(row.id),
        unscheduled: false,
        facility_id: Number(row.facility_id),
        scheduled_at: jstIsoFromDb(row.start_at),
        completion_rate: completionRateFromReport(report),
        // ★未実施は `departed` で数える★(【未決1】案A。D-156 / 指示書AU D3。完了率の規則がまた
        //   変わっても、未実施の数え方が巻き込まれないため)
        departed: !notExecutedFromReport(report),
        // ★レポートが無い行は 0★(指示書AI 段階2)。★`completion_rate` のように `null` に
        //   しないこと★ — 面積は「足し合わせる量」なので、無い=0 を足すのが正しい。
        //   ★★ここで面積から `completion_rate` を割り直さないこと★★ — `completion_rate` は
        //   `runCompletionRate()` で作ったもの(規則が2箇所に散る。D-155)
        clean_area: report?.clean_area ?? 0,
        task_area: report?.task_area ?? 0,
      };
    });

  // ★予定外タスク(指示書BG §2-3)★ — ★`scheduledTimetableId()` を通す★(`=== null` を直書きしない。指示書BG §5)
  const unscheduled = unscheduledReports
    .filter((report) => scheduledTimetableId(report.clean_timetable_id) === null)
    .map(
      (report): ConsecutiveInput => ({
        // ★予定の id は無い★(`CleanLogItem.id` と同じ。19桁の `report_id` を数値の id にしない)
        id: null,
        unscheduled: true,
        facility_id: Number(report.facility_id),
        // ★日付は実際に始めた時刻★(`started_at`。DATETIME の JST 壁時計値なので `jstIsoFromDb()` を通す)
        scheduled_at: jstIsoFromDb(report.started_at),
        // ★予定のある行と同じ1本★(`runCompletionRate()`。`task_area = 0` は 0)
        completion_rate: completionRateFromReport(report),
        // ★行があるので必ず出発済み★(`notExecutedFromReport()` を通す。規則を2箇所に書かない)
        departed: !notExecutedFromReport(report),
        clean_area: report.clean_area,
        task_area: report.task_area,
      })
    );

  return [...scheduled, ...unscheduled];
}

/**
 * 連続日数の算出に必要な行だけを、★列を絞って★★表示期間+遡りぶん★だけ読む。
 *
 * ★★`include` を一切しないこと★★(`facility` / `section` / `robots` / `device` は連続日数に不要)。
 *
 * ------------------------------------------------------------------------
 * ★★`select` の列を増やしてよい条件(指示書AI 段階2 でこの禁を1度だけ破った)★★
 * ------------------------------------------------------------------------
 * ★ここに列を足すとそのまま本番(保持2年・約26万行)の転送量になります★。
 * ★★したがって既定は「増やさない」★★。増やす前に次の3つを全部満たすか確かめること:
 *
 *   1. ★その値が**この1本の結果からは合成できない**こと★ —
 *      既にある列の四則演算で出せるなら列を足さない。
 *   2. ★足す列が固定長で小さいこと★(`double` / `int` 等。★文字列や日時の追加は要再検討★)。
 *      ★増分を「26万行 × バイト数」で見積もり、コメントに残すこと★。
 *   3. ★足す理由を**呼び出し元の機能名つきで**ここに書くこと★ —
 *      書かないと、次の人が「なぜ例外があるのか」を判断できず、
 *      ★禁止だけが残って必要な列まで消される★。
 *
 * ★実績(指示書AI 段階2 / 2026-09-15)★:
 *   `clean_area` / `task_area` の2列を足した。
 *   ・旧用途: 要レスキューの急落判定(R-3)の施設×日の完了率を面積で重み付けするため
 *     (当時は「率の平均では面積の重み付けを再現できない」ことを条件1の根拠にしていた)。
 *   ・増分: `double` 8バイト × 2列 × 26万行 = ★約4MB★(条件2を満たす)。
 *   ・2026-09-30 に R-3 の重み付けという用途は消えた(指示書AQ 決定4 / §5-5)— ★R-3 は
 *     1回ごとの完了率の単純平均(`lib/judge/kpi.ts` の `meanCleanRatio()`。清掃1回=1票)で、
 *     面積で重み付けしない★。★面積の重み付けに戻さないこと★(完了率の定義は全画面で1つ。§5-5)。
 *   ・★★2026-10-02 に条件3を満たし直した★★(D-155 / 指示書AS 段階3-3)— ★面積は R-3 の重み付けでは
 *     なく、`runCompletionRate()` で1回ごとの完了率を出すために読んでいる★(呼び出し元: 清掃詳細ログの
 *     連続日数・要レスキュー・連続問題施設。いずれも `completionRateFromReport()` 経由)。
 *
 * ★実績(指示書AQ / 2026-09-30)★:
 *   参照元を `clean_task_reports` に差し替えた際に ★`clean_error` が消え、`started_at` が増えた★。
 *   ・`clean_error`: ★`clean_task_reports` に存在しない★(指示書AQ §1-1)。
 *   ・`started_at`: ★`ended_at` が nullable★(罠3)で、★それだけでは「最新1件」に絞れない★
 *     = 条件1(既にある列から合成できない)を満たす。`datetime` は固定長5バイト
 *     (条件2。★日時の追加は要再検討★の但し書きに当たるが、順序キーの代替が無い)。
 *
 * ★実績(指示書BG / 2026-10-29)★:
 *   ★予定外タスクを読む3本目のクエリ★を足した(`clean_task_reports` の `clean_timetable_id` が 0 か NULL の行)。
 *   ・呼び出し元: 清掃詳細ログ・要レスキュー・施設別の成績・連続問題施設の連続日数(指示書BG §2-3)。
 *   ・列は2本目と同じ6列 + ★`facility_id`★(予定外の行は予定の行を持たないので施設をここから取るしかない=条件1。
 *     `bigint` 8バイト。条件2)。★`section_id` / `report_id` は読まない★(連続日数は施設単位で、`id` は `null`)。
 *   ・行数: 予定外タスクの数ぶん(ステージング 2026-10-29・先方投入で清掃結果15件中9件)。期間は2本目と同じ遡り範囲(`started_at`)。
 *
 * ★実績(D-173 / 2026-10-07)★:
 *   `clean_timetables`(と履歴)側に ★`end_at`★ を足した。
 *   ・呼び出し元: 清掃詳細ログの連続日数・要レスキュー・連続問題施設(表2 C10)。★結果待ち(`isResultPending()`)の
 *     予定を入力から外すため★。★予定の終了は `start_at` から合成できない★(条件1)。
 *   ・増分: `datetime` 5バイト × 26万行 = ★約1.3MB★(条件2。日時の追加だが、`cleanLogs()` と同じ基準
 *     (予定の終了+待ち時間)で外さないと、画面によって結果待ちの扱いが食い違う)。
 *

 * ------------------------------------------------------------------------
 * ★★遡り幅の決め方(指示書AC 段階6)★★
 * ------------------------------------------------------------------------
 * 段階5 まで、この1本だけは ★全期間★ を読み続けていました(C-3 B案。★表示される数字を
 * 1つも変えないため★)。★★上限が決まった(A-1。ユーザー回答 2026-09-13)ので有界にします★★。
 *
 * 1. ★★上限は `loadConsecutiveDaysMax()`(=システム管理画面で保存された値)から読む★★。
 *    ★`lib/config/` の既定値を直接読まないこと★ — 画面で 90 に上げても
 *    30日ぶんしか読まず ★上げた意味が無くなります(例外もテスト失敗も出ない)★。
 *    ★保存が無い/壊れている/範囲外のときに既定値へ倒れる判断は
 *    `loadConsecutiveDaysMax()` の中にあります★(★ここで条件分岐を書かないこと★)。
 * 2. ★★「上限 +1日ぶん」余分に読む★★(`CONSECUTIVE_LOOKBACK.plusDays`)—
 *    `capConsecutiveDays()` は ★`rawDays > maxDays` のときだけ★ `capped: true` にするので、
 *    ★上限ちょうどまでしか読まないと「30日以上」が二度と画面に出ません★。
 * 3. ★★遡り幅は「カレンダー日」で数え、安全側に**広めに**取る★★ —
 *    連続はスケジュール存在日の連続(D-026)で、清掃予定のない日は連続を途切れさせません。
 *    ★SQL で「スケジュール存在日ベースで N 日」を直接絞るのは複雑★なので、
 *    ★★1スケジュール存在日 = 最大7カレンダー日★★ として換算します
 *    (`CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay`。★そちらの docstring に前提と
 *    崩れたときの症状を書いてあります★)。
 *
 * ★★`date_from` が `null` のときは下限なし(=全期間)のままです★★ —
 *   単一ID取得(`/clean-logs/{id}/advice` / `/{id}/areas`)は対象ログの日付が
 *   引くまで分からないため(申し送り)。
 *
 * ★★3経路(`cleanLogs()` / `facilityPerformance()` / `continuousProblems()`)で
 *   表示される数字が食い違わない理由★★: 遡り幅は必ず ★上限+1スケジュール存在日ぶん以上★ あるので、
 *   ★真の連続が上限以下なら3経路とも全部読めて同じ値★、
 *   ★上限を超えるなら3経路とも `rawDays > maxDays` になり同じ「{上限}日以上」★になります
 *   (★生値そのものは経路で違いうるが、画面に出る値は一致する★。D-109 の再発防止)。
 *
 * @param now 判定の基準時刻。★呼び出し側が他の処理と共有している `now` があれば必ずそれを渡すこと★
 *   (`cleanLogs()` / `facilityPerformance()` は同じ `now` で別の絞り込みもしているため、
 *   別のインスタンスを使うと境界の行の扱いがぶれる)
 * @param range ★表示したい期間そのもの★(★遡りぶんを足して渡さないこと★。この関数が足します)。
 *   ★全期間が要るなら `CONSECUTIVE_RANGE_UNBOUNDED` を明示して渡すこと★(指示書AC C-4)。
 *   ★`facility_ids` は呼び出し側の分類の絞り込みと同じ配列★(`null` = 絞らない・`[]` = 0行。指示書BB A6)
 */
export async function loadConsecutiveInputs(
  prisma: ConsecutiveSourcePrisma,
  now: Date,
  range: ConsecutiveDbRange
): Promise<ConsecutiveInput[]> {
  // ★★毎回設定から読むこと★★ — 別の定数へ写し取ると、管理画面で上限を変えたときに
  //   表示だけ変わってクエリの範囲が古いまま残ります(★静かな不整合★。申し送り⑪)
  const maxDays = await loadConsecutiveDaysMax();
  const startAt = dbDateRange(
    consecutiveLookbackFrom(range.date_from, maxDays),
    range.date_to
  );

  // ★★予定は2表(`clean_timetables` / `clean_timetable_histories`)から読む★★(指示書AU 段階3b。
  //   `findTimetables()` が同じ `args` を2表に渡し、同じ id は本表を採る)。
  //   ★期間無指定(`CONSECUTIVE_RANGE_UNBOUNDED`)なら履歴も全期間読む★(今と同じ性質。3b-4)
  const timetables = await findTimetables(prisma, {
    where: {
      // ★本清掃だけを引く(`cleanLogs()` と同じ作法で SQL に降ろす)★。
      //   ★下の `consecutiveInputsFromRows()` の filter は残す★ — 規則の正は純粋関数側で、
      //   SQL はその写し(指示書AC C-5)
      precheck: CLEAN_TIMETABLE_PRECHECK.main,
      // ★削除済みの施設・清掃エリアの行は連続日数の入力にも入れない(指示書BB U1・D4・D-182)★ —
      //   表示から消えた行が、生きている施設の連続日数を押し上げないため(値が短くなりうるのは意図した結果)。
      //   ★`select` に列は足さない★(可視性はリレーション条件だけ。指示書BB D2・5-12)
      ...FACILITY_SECTION_VISIBLE_WHERE,
      // ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★(生成SQLの意図が読める)
      ...(startAt.gte || startAt.lt ? { start_at: startAt } : {}),
      // ★施設で絞る(指示書BB A6・D16)★ — ★★`!== null` で分岐すること★★(罠1。`[]` は `in: []` で0行)。
      //   連続日数は施設ごとに独立に数えるので、絞っても絞った施設の値は変わらない(`computeConsecutiveProblemDays()`)
      ...(range.facility_ids !== null
        ? { facility_id: { in: range.facility_ids.map((id) => BigInt(id)) } }
        : {}),
    },
    // ★`end_at` は結果待ちの判定用(D-173。足した理由は上の docstring の「実績」)★
    select: { id: true, facility_id: true, start_at: true, end_at: true, precheck: true },
  });

  // ★引いた本清掃の id でレポートを引き直す(★全件読みに戻さないこと★)★。
  //   ★`clean_task_reports.clean_timetable_id` には FK も KEY も無い★
  //   (`prisma/schema.prisma:524`。`clean_status_histories` 時代と同じ。申し送り②)が、
  //   ★`in` の要素数は上の期間絞り込みで有界★なので `cleanLogs()` と同じ作法で足りる。
  //   ★塊が0個(本清掃が0件)ならクエリを1本も撃たない★
  //
  // ★★`clean_timetable_id` が NULL のレポート行はここに返って来ない★★(★罠1★。指示書AQ §3)—
  //   ★NULL は `IN` に一致しない★。NULL は「予定外のタスク」(予定を持たない実在の清掃。
  //   TeamWorks 第10回 N-1 = A)で、予定の行に引き当てる材料ではないので、
  //   ★ここに返って来ないのは仕様どおり★(詳細は `consecutiveInputsFromRows()` の docstring)。
  //   ★予定外タスクは下の3本目のクエリで別に読む★(指示書BG §2-3。旧: 読んでいなかった(D-155 項3))。
  //   ★`clean_timetable_id: SCHEDULED_TIMETABLE_ID_WHERE` を足しても結果は1行も変わらない★ので足していない
  //   (★`IN` が既に落としている★。足すと「NULL を除けば直る」という誤解を生む)。
  // ★★0 も予定外★★(TeamWorks 回答 2026-10-27)— ★`IN` の要素に 0 が入らない限り、0 のレポート行は返って来ない★。
  //   予定の id は自動採番で 0 にはならないが、★`scheduledTimetableId()` を通して 0 を `IN` に入れないことを保証する★
  //   (入ると予定外のタスクのレポートが「id 0 の予定」に引き当たる)。
  const idChunks = chunkForInClause(
    timetables
      .map((row) => scheduledTimetableId(row.id))
      .filter((id): id is bigint => id !== null)
  );
  const [reportChunks, unscheduledReports] = await Promise.all([
    Promise.all(
      idChunks.map((ids) =>
        prisma.cleanTaskReport.findMany({
          where: { clean_timetable_id: { in: ids } },
          select: {
            clean_timetable_id: true,
            clean_ratio: true,
            // ★順序キーのフォールバック★(`ended_at` が NULL のとき。指示書AQ §1-4)。
            //   ★`ended_at` だけでは1件に絞れない★ので、この列は外せない
            started_at: true,
            ended_at: true,
            // ★1回ごとの完了率(`runCompletionRate()`)の材料★(D-155。指示書AS 段階3-3)。
            //   ★旧: 要レスキューの急落判定(R-3)が面積で重み付けしていたため読んでいた★
            //   (指示書AI 段階2)。★R-3 は1回ごとの完了率の単純平均(`meanCleanRatio()`)で、
            //   面積で重み付けしない★(指示書AQ 決定4 / §5-5)。転送量は上の docstring 参照。
            //   ★`clean_ratio` は完了率に使っていないが残す★(列に戻すとき用。指示書AS D6)
            clean_area: true,
            task_area: true,
          },
        })
      )
    ),
    // ★★3本目: 予定外タスク★★(指示書BG §2-3)— ★予定の id の `IN` には構造的に入らない★ので別に引く。
    //   ★期間は予定の行と同じ遡り範囲を `started_at` に当てる★(予定外は実際に始めた時刻で振り分ける。指示書BG §1)。
    //   ★削除済みの施設・清掃エリアは外す・施設の絞り込みも同じ★(予定の行と同じ条件。表U1 U1-5)。
    //   ★`UNSCHEDULED_TIMETABLE_ID_WHERE` はトップレベルの `OR` を持つ★ — 同じ階層に別の `OR` を置かないこと
    prisma.cleanTaskReport.findMany({
      where: {
        ...UNSCHEDULED_TIMETABLE_ID_WHERE,
        ...FACILITY_SECTION_VISIBLE_WHERE,
        ...(startAt.gte || startAt.lt ? { started_at: startAt } : {}),
        ...(range.facility_ids !== null
          ? { facility_id: { in: range.facility_ids.map((id) => BigInt(id)) } }
          : {}),
      },
      select: {
        facility_id: true,
        clean_timetable_id: true,
        clean_ratio: true,
        started_at: true,
        ended_at: true,
        clean_area: true,
        task_area: true,
      },
    }),
  ]);

  return consecutiveInputsFromRows(timetables, reportChunks.flat(), now, unscheduledReports);
}