/**
* 要レスキュー店舗(`GET /dashboard` の `rescue_required`)を組み立てる★純粋関数のみ★
* (指示書AI 段階4)。
*
* ★`lib/db/clean-log-mapping.ts` と同じ作法★ — DBアクセスを混ぜず、
* `fixtureSource` / `prismaSource` の ★両方から同じ関数を呼ぶ★
* (`CLAUDE.md` §9.5「判定ロジックは切替の外側に置く」。★fixtures 用に別の判定を書かない★)。
*
* ★★ここに判定は書かない★★ — 3条件は `lib/judge/rescue.ts` の `judgeRescue()` が持つ。
* このファイルの仕事は ★「どの行を、どの施設の、どの日の入力として渡すか」★ だけ。
*
* ★★連続日数をここで数え直さないこと★★(D-109 / 指示書AI §13-6)—
* `computeConsecutiveProblemDays()`(`lib/db/clean-log-mapping.ts`)の値をそのまま使う。
*/
import {
computeConsecutiveProblemDays,
type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import { judgeRescue } from "@/lib/judge/rescue";
import type {
AreaCompletionRow,
CleanLogItem,
FacilityNameRef,
RescueRequiredItem,
} from "@/lib/types";
/** ISO8601(+09:00)の先頭10文字= JST の日付(★`Date` に通さない★。実行環境のTZで1日ずれる) */
function dateOf(scheduledAt: string): string {
return scheduledAt.slice(0, 10);
}
/**
* fixtures の清掃ログから `ConsecutiveInput[]` を作る(★fixtureSource 専用★)。
*
* ★★Prisma 側は `loadConsecutiveInputs()` が同じ形を作る★★ —
* ★形を揃えることで、判定(`buildRescueRequired()` → `judgeRescue()`)が
* `USE_FIXTURES` の true/false で分岐しない★。
*
* ★面積は `areaCompletions`(fixtures)から `clean_log_id` で引く★ —
* ★★無い行は 0★★(`consecutiveInputsFromRows()` が履歴なしを 0 にするのと同じ扱い。
* 面積は「足し合わせる量」なので、無い=0 を足すのが正しい)。
* ★★2026-09-30 から R-3 は面積を読まない★★ — ★R-3 は1回ごとの完了率の単純平均
* (`lib/judge/kpi.ts` の `meanCleanRatio()`)★で、`judgeRescue()` が読むのは
* `completion_rate`(= `log.completion_rate`)と `departed`(R-1)だけ
* (指示書AQ 決定4 / §5-5・D-155 / D-156。`lib/judge/rescue.ts`)。
* ★面積の重み付けに戻さないこと★。面積を詰めているのは Prisma 版と形を揃えるためだけ。
* ★旧: R-3 が面積加重だったので「fixtures は面積を仕込んだ施設×日でしか R-3 が成立しない」
* という帰結があり、面積加重と単純平均の差(指示書AI 表3 3-3)を fixtures で確かめる
* ために面積を捏造しない決まりにしていた★。★今は面積が無くても R-3 は成立する★。
* ★★それでも `completion_rate` から面積を捏造しないこと★★(面積は表示用の実測値。§5-5)。
*
* ★★結果待ち(`result_pending === true`)の行は入力から外す★★(表2 C10。ユーザー判断 2026-10-07。D-173)—
* Prisma 版の `consecutiveInputsFromRows()` が未来の予定と同じく外すのと揃える
* (★問題日にも非問題日にも数えない★)。
*/
export function rescueInputsFromCleanLogs(
logs: readonly CleanLogItem[],
areas: readonly AreaCompletionRow[]
): ConsecutiveInput[] {
const areaByLogId = new Map(areas.map((a) => [a.clean_log_id, a]));
return logs.filter((log) => !log.result_pending).map((log) => {
const area = areaByLogId.get(log.id);
return {
id: log.id,
facility_id: log.facility_id,
scheduled_at: log.scheduled_at,
// ★丸めない生値。未実施は null(0% に潰さない)★
completion_rate: log.completion_rate,
// ★R-1 の未実施は `departed` で数える★(【未決1】案A。D-156)
departed: log.departed,
clean_area: area?.clean_area ?? 0,
task_area: area?.task_area ?? 0,
};
});
}
/**
* 施設ごとに3条件を判定し、要レスキューの施設だけを並べて返す。
*
* ★★当該日に予定が無い施設も対象に含める★★(ユーザー指示 2026-09-15 / 指示書AI §1-5・§6-3)—
* ★「当該日のスケジュール存在日だけ」で絞らないこと★。実データに例がある:
* ★施設7は 2026-09-12 時点で問題4日連続(L3)だが 2026-09-13 には予定が無い★。
* 当該日で母集団を絞ると ★一番出すべき施設が消える★(例外もテスト失敗も出ない)。
* そこで ★施設ごとに「当該日以前で最も新しいスケジュール存在日」を評価日とする★。
*
* ★★ダッシュボードの数値カード(成功/失敗/未実施/成功率)には影響しない★★ —
* あちらは `lib/dashboard/aggregate.ts` が当日のログだけから作る(★変更しない★)。
*
* ★★`judgeRescue()` には「評価日の行だけ」を渡さないこと★★ —
* R-1 は3日ぶん、R-3 は前回の日を必要とする。★当該日以下の行を丸ごと渡す★。
*
* ★★件数の上限を設けないこと★★(指示書AJ 段階1 / ユーザー指示 2026-09-15
* 「上限は設けずに、表示は5件、もっと表示のようなのもので全件」)—
* ★以前は `RESCUE_LIST_LIMIT`(5件)で切っていたが、実データで7件中2件が黙って
* 消えていた★(指示書AJ §1)。★切られたことに運用担当が気づけない★のが問題だった
* (カードの件数表示も切ったあとの数だったため、7件該当でも「5件」と出ていた)。
* ★初期表示を5件に絞るのは画面側の責務★(`lib/dashboard/rescue-visibility.ts`)。
* ★件数が青天井にならない理由★: 要レスキューは施設ごとに最大1行なので、
* 施設数が自然な上限になる。★`rescue_required_total` のようなキーは要らない★ —
* ★配列の長さがそのまま総数★。
*
* @param inputs 遡りぶんを含む行(Prisma は `loadConsecutiveInputs()`、
* fixtures は `rescueInputsFromCleanLogs()` が作る)。★施設で絞られていないこと★
* @param facilityNames 施設ID → 施設名(`facilityNameIndex()` と同じ形)
* @param date 対象日(JST `YYYY-MM-DD`)
* @returns ★理由の数の降順 → 連続問題日数の降順 → 施設IDの昇順★(§4-3 の docstring 参照。
* 同値で順番が揺れないよう最後のキーに `facility_id` を必ず持つ)
*/
export function buildRescueRequired(
inputs: readonly ConsecutiveInput[],
facilityNames: readonly FacilityNameRef[],
date: string
): RescueRequiredItem[] {
// ★当該日「以下」で切る★(文字列比較でよい。`YYYY-MM-DD` は辞書順=日付順)。
// ★★当該日ちょうどで絞らないこと★★(上の docstring)。
const rows = inputs.filter((row) => dateOf(row.scheduled_at) <= date);
// ★連続日数は既存の1本で数える(★ここで数え直さない★。D-109)★。
// ★★当該日以下に絞った行で数えること★★ — 当該日より後の行を含めると、
// ★「明日の予定」まで数に入って連続日数が未来にずれる★。
const consecutiveById = computeConsecutiveProblemDays(rows);
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 nameById = new Map(facilityNames.map((f) => [f.id, f.facility_name]));
const items: RescueRequiredItem[] = [];
for (const [facilityId, facilityRows] of byFacility) {
// ★評価日 = その施設の「当該日以前で最も新しいスケジュール存在日」★
let evaluatedOn = dateOf(facilityRows[0].scheduled_at);
for (const row of facilityRows) {
const d = dateOf(row.scheduled_at);
if (d > evaluatedOn) evaluatedOn = d;
}
// ★同じ日の全行に同じ連続日数が入る★(`computeConsecutiveProblemDays()` の契約)ので
// 評価日の行を1つ引けばよい。★ここで数え直さないこと★
const rowOfEvaluatedDay = facilityRows.find(
(row) => dateOf(row.scheduled_at) === evaluatedOn
);
const consecutive =
rowOfEvaluatedDay === undefined
? 0
: (consecutiveById.get(rowOfEvaluatedDay.id) ?? 0);
const judgement = judgeRescue({
rows: facilityRows,
consecutive_problem_days: consecutive,
});
if (!judgement.required) continue;
items.push({
facility_id: facilityId,
// ★見つからない施設IDは `String(id)` をそのまま出す★ — 「不明」等に潰さない
// (`MAKER_NAMES[maker_id] ?? String(maker_id)` と同じ思想。D-077)。
// ★行が消えるより、見慣れない値が出るほうが気づける★
facility_name: nameById.get(facilityId) ?? String(facilityId),
evaluated_on: evaluatedOn,
reasons: judgement.reasons,
not_executed_days: judgement.not_executed_days,
consecutive_problem_days: judgement.consecutive_problem_days,
// ★丸めない生値をそのまま運ぶ(丸めは表示層。`CLAUDE.md` §9.3)★
completion_rate: judgement.completion_rate,
previous_completion_rate: judgement.previous_completion_rate,
drop_ratio: judgement.drop_ratio,
});
}
/*
* ★並び順(指示書AJ §4-3)★
* 1) `reasons.length` の降順 ← 複数の条件に当たる施設ほど重い
* 2) `consecutive_problem_days` の降順
* 3) `facility_id` の昇順 ← 同値で順番が揺れないため
*
* ★★なぜ理由の数を最優先にするか★★:
* ★連続日数だけで並べると、急落(前日まで正常なので連続日数が浅いのが当たり前)が
* 下に沈む★(指示書AJ §1-1)。理由の数を先に見ることで、
* 「L3 かつ急落」の施設が「L3 だけ」の施設より上に来る。
*
* ★★これでも「急落だけ」の施設は下のほうに来る★★ — 理由が1つなので、
* 理由1つのグループの中で連続日数の浅さゆえに最後尾に並ぶ。
* ★そこは上限を撤廃し、画面で全件を開けるようにしたことで補っている★。
* ★「急落だけ」を上位に出す仕組みは今回作らない★(指示書AJ §10。
* 上限撤廃と「もっと表示」で足りるかを、まず実運用で見る)。
*
* ★★件数では切らない★★(上の docstring)。
*/
return items.sort((a, b) => {
if (a.reasons.length !== b.reasons.length) {
return b.reasons.length - a.reasons.length;
}
if (a.consecutive_problem_days !== b.consecutive_problem_days) {
return b.consecutive_problem_days - a.consecutive_problem_days;
}
return a.facility_id - b.facility_id;
});
}