/**
* 連続日数(`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);
}