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)★。
 */
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 { findTimetables } from "@/lib/db/timetable-source";
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;
}

/**
 * この関数が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
 * ★`PrismaClient` をそのまま要求しないこと★ — 生成済みクライアントが無い環境でも
 * 単体テストが走るようにしてある(`lib/db/prisma.ts` の遅延 import と同じ理由)。
 */
export interface ConsecutiveSourcePrisma {
  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;
}

/** ★範囲を絞らない(全期間を読む)★ことを明示する値。★`null` を素で書かないための名前★ */
export const CONSECUTIVE_RANGE_UNBOUNDED: ConsecutiveDbRange = {
  date_from: null,
  date_to: 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 が後で埋める」
 *   ものでもない。★清掃完了率・実施回数には数えない★(D-155 項3)ので、★この引き当てから
 *   外れるのは仕様どおり★。つまり ★この挙動は「直す」ものではなく「そういうもの」★です。
 *   (対応する予定の行が「未実施」に見えるのは、そのレポートが別の清掃のものであるため)。
 *   件数を知りたいときは
 *   `SELECT COUNT(*) FROM clean_task_reports WHERE clean_timetable_id IS NULL` を見ること。
 *
 * ★★呼び出し側へ: ここに「期間で絞った行」を渡さないこと★★ — 連続日数は全期間を遡って
 *   数えるので、期間の外にある前日の行が消えると ★連続が途切れて L3 が L1 に化けます★
 *   (指示書AC C-2。★例外もテスト失敗も出ない★)。
 *   ★★`lib/db/index.ts` は必ず `loadConsecutiveInputs()`(=専用の全期間クエリ)を経由すること★★。
 *   この純粋関数を export してあるのは ★単体テストのため★であり、
 *   「すでに読んである行を使い回す近道」として呼ぶためではありません。
 *
 * @param now ★1回だけ作って全行で使い回す★(行ごとに `new Date()` を作ると境界の行の扱いがぶれる)
 */
export function consecutiveInputsFromRows(
  timetables: readonly ConsecutiveTimetableRow[],
  reports: readonly ConsecutiveTaskReportRow[],
  now: Date
): ConsecutiveInput[] {
  const reportByTimetableId = latestReportByTimetableId(reports);
  return 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) => {
      const report = reportByTimetableId.get(row.id.toString());
      return {
        id: Number(row.id),
        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,
      };
    });
}

/**
 * 連続日数の算出に必要な行だけを、★列を絞って★★表示期間+遡りぶん★だけ読む。
 *
 * ★★`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。★日時の追加は要再検討★の但し書きに当たるが、順序キーの代替が無い)。
 *
 * ★実績(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)
 */
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,
      // ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★(生成SQLの意図が読める)
      ...(startAt.gte || startAt.lt ? { start_at: startAt } : {}),
    },
    // ★`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)で、★完了率・実施回数に数えない★(D-155 項3)ので、
  //   ★ここに返って来ないのは仕様どおり★(詳細は `consecutiveInputsFromRows()` の docstring)。
  //   ★`clean_timetable_id: { not: null }` を足しても結果は1行も変わらない★ので足していない
  //   (★`IN` が既に落としている★。足すと「NULL を除けば直る」という誤解を生む)。
  const idChunks = chunkForInClause(timetables.map((row) => row.id));
  const reportChunks = await 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,
        },
      })
    )
  );

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