import { COMPLETION_DROP, RESCUE_THRESHOLDS } from "@/lib/config";
import { judgeEscalationLevel } from "./escalation";
import { meanCleanRatio } from "./kpi";
/**
* 要レスキュー店舗の判定(ユーザー指示 2026-09-15 / 指示書AI 段階3)
*
* ★★純粋関数だけを置く★★(`CLAUDE.md` §9.4「判定関数は純粋関数にする」)—
* DBアクセスも `new Date()` も混ぜない。入力は呼び出し側(`lib/db/`)が作る。
*
* 「翌朝DFA運用担当が確認する」運用(`CLAUDE.md` §1)で、★真っ先に見るべき施設★を選ぶ。
* 確定した3条件(指示書AI §1-1。ユーザー確定 2026-09-15):
*
* | # | 条件 | 判定 |
* |---|---|---|
* | R-1 | 未実施が3日以上連続(=出発失敗と同義) | `departed === false` が3スケジュール日以上連続 |
* | R-2 | 連続問題レベル3 | `judgeEscalationLevel() === "L3"` |
* | R-3 | 完了率が前回比20%以上の急落 | `(前回 − 当日) ÷ 前回 ≧ dropRatio` |
*
* ★★R-1 の「未実施」は `departed` で数える★★(【未決1】案A。D-156。指示書AS 段階3-4)—
* ★`completion_rate === null` で数えないこと★。いまは両者が同値(P-5=B で `task_area = 0` が 0% になった。
* D-158)だが、★完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため★(指示書AU D3)。
*
* ★★R-3 の「完了率」は1回ごとの完了率(`runCompletionRate()`)の単純平均★★
* (2026-09-30 指示書AQ 決定4 / §5-1・2026-10-02 D-155)—
* ★以前は面積加重(Σclean_area ÷ Σtask_area)だった★。レポート画面・F-08 と同じ
* ★`meanCleanRatio()`(`lib/judge/kpi.ts`)で畳む★。★面積加重にしないこと★。判定が変わるケースは
* `__tests__/rescue.test.ts` の表6。★この関数は面積を読まず、渡された `completion_rate` だけを使う★。
*
* ★★4つ目の条件(出発失敗を未実施と別に数える)は落としてある★★ —
* 実開始時刻がDBに無く、猶予30分の計算をする材料が無いため(指示書AI §1-1)。
* ★TeamWorks から所在の回答が来たら足す★(第9回 11章で照会中)。
*
* ★★このファイルは日本語を1文字も作らない★★(D-111)—
* 理由は識別子(`RescueReason`)で返し、文言は画面側の `t()` が作る。
* ★判定層で和文を組むと EN 切替で日本語が残る★。
*
* ★★連続日数をここで数え直さないこと★★(指示書AI §13-6 / D-109 の再発防止)—
* R-2 の `consecutive_problem_days` は ★引数で受け取る★。
* 算出の責務は `computeConsecutiveProblemDays()`(`lib/db/clean-log-mapping.ts`)の1本にある。
*/
/** 要レスキューの理由(★複数該当したら全部入る★) */
export type RescueReason = "not_executed_streak" | "escalation_l3" | "completion_drop";
export interface RescueJudgement {
/** 3条件のいずれかに当たったか */
required: boolean;
/** 当たった理由(★空配列なら required は false★) */
reasons: RescueReason[];
/** 表示用の付帯情報。★判定には使わない★ */
not_executed_days: number;
consecutive_problem_days: number;
/** ★1回ごとの完了率の単純平均★(`meanCleanRatio()`)。未実施・データなしは null */
completion_rate: number | null;
previous_completion_rate: number | null;
/** 低下の比(0.25 = 25%低下)。算出できないときは null */
drop_ratio: number | null;
}
/**
* 判定に必要な1行(=1清掃エリアの1回の清掃)。
*
* ★`ConsecutiveInput`(`lib/db/clean-log-mapping.ts`)がそのまま渡せる形にしてある★ —
* ★★`lib/judge/` から `lib/db/` を import しないため★★(判定層はDBの都合を知らない)。
* 詰め替えは不要で、`ConsecutiveInput[]` を直接渡せる(余分なキーは無視される)。
*
* ★★面積(`clean_area` / `task_area`)はもう受け取らない★★(2026-09-30。指示書AQ 決定4)—
* `ConsecutiveInput` には付いたまま届くが、★判定では読まない★。
* ★型から外したのは「面積で重み付けし直す」実装を型で書けなくするため★。
*/
export interface RescueRowInput {
/** ISO8601(+09:00)。★先頭10文字を JST の日付として使う★ */
scheduled_at: string;
/**
* 1回ごとの完了率(★`runCompletionRate()` の戻り★。D-155)。丸める前の生値。
* ★未実施は `null`★(`task_area = 0` は 0。P-5=B)。★R-3 は値そのもの(その日の単純平均)を読む★。
* ★R-1 はこれを読まない★(`departed` を読む)。
*/
completion_rate: number | null;
/**
* ★出発したか(=レポートの行があるか)★。★R-1 はこれで未実施を数える★(【未決1】案A。D-156)。
* ★必須(optional にしない)★ — 詰め忘れが `undefined`(=全部未実施)のまま通る。
*/
departed: boolean;
}
/**
* 1回ごとの完了率の合計と件数だけを持つ最小の形(`facilityDayRate()` の入力)。
* ★名前(`clean_ratio_*`)は変えていない★(指示書AS D7)— 中身は `clean_ratio` 列ではない。
*/
export interface RescueRatioLike {
/** その日の1回ごとの完了率の合計(★`completion_rate` が null の行は足さない★) */
clean_ratio_sum: number;
/** 上の合計に足した行数(★`completion_rate` が null の行(未実施)は数えない★) */
clean_ratio_count: number;
}
/** スケジュール存在日1日ぶんに畳んだ結果 */
export interface RescueDay extends RescueRatioLike {
/** JST の `YYYY-MM-DD` */
date: string;
/** ★その日に未実施(`departed === false`)の行が1つでもあれば true(OR)★ */
not_executed: boolean;
}
/** 入力(1施設ぶん) */
export interface RescueInput {
/**
* ★その施設の、評価日**以前**の行★(日付順でなくてよい。この関数が並べ替える)。
*
* ★★「評価日の行だけ」に絞って渡さないこと★★ — R-1 は3日、R-3 は前回の日を必要とする。
* ★★評価日は「渡された行のうち最も新しいスケジュール存在日」★★ になる(指示書AI §6-3)。
* ★当該日に予定が無い施設も要レスキューに出す★ため、
* 呼び出し側は「当該日以下」で切るだけでよく、当該日ちょうどで絞ってはいけない。
*/
rows: readonly RescueRowInput[];
/**
* ★既存の `computeConsecutiveProblemDays()` の値をそのまま渡す★
* (★ここで数え直さない★。D-109 / 指示書AI §13-6)。
*/
consecutive_problem_days: number;
}
/**
* 施設日完了率 = その日の1回ごとの完了率の単純平均(★清掃1回 = 1票★。指示書AQ 決定4 / §5-1・D-155)
*
* ★★面積加重にしないこと★★(2026-09-30 に面積加重から変更)— 理由は `meanCleanRatio()`
* (`lib/judge/kpi.ts`)の docstring。★レポート画面・F-08 と畳み方を揃える★ため、
* ★★自前で割り算を書かず `meanCleanRatio()` に任せる★★(分母0で `null` を返す規則もそちらが持つ。
* ここで書き直すと ★∞ や NaN が漏れる★)。
*
* @returns 丸める前の生値。★件数が0(=全行が未実施・行なし)は `null`★(0% ではない)
*/
export function facilityDayRate(rows: readonly RescueRatioLike[]): number | null {
let cleanRatioSum = 0;
let cleanRatioCount = 0;
for (const row of rows) {
cleanRatioSum += row.clean_ratio_sum;
cleanRatioCount += row.clean_ratio_count;
}
return meanCleanRatio({ cleanRatioSum, cleanRatioCount });
}
/**
* 同一日の複数行を1日に畳む(★OR。D-109 A案★)。
*
* ★施設に複数の清掃エリア(ロボット)があると同じ日に複数行が生まれる★ため、
* 畳まずに数えると「N日連続」が水増しされる(`foldProblemDaysByDate()` と同じ理由)。
* ★同一ロボットが同じ日に2回清掃した場合も1日に畳む★(決定2。D-026 は日単位であって回数単位ではない)。
*
* ★★未実施フラグ(`!departed`)は OR、完了率は `completion_rate` が null でない行だけの合計と件数★★ —
* 片方の台だけ未実施の日は ★「未実施の日」であり、かつ完了率も算出できる★
* (もう1台の完了率が残るため)。★未実施の行を 0% として件数に入れないこと★ —
* R-1 で既に数えている失敗を、R-3 でも二重に数えることになる(`aggregateCleanStatsByFacility()` と同じ扱い)。★これは矛盾ではない★ — R-1 と R-3 は別の条件で、
* 同じ日に両方立ちうる(指示書AI 表5 5-5)。
*
* @returns ★日付昇順・重複なし★
*/
export function foldRescueDays(rows: readonly RescueRowInput[]): RescueDay[] {
const byDate = new Map<string, RescueDay>();
for (const row of rows) {
// ★ISO8601(+09:00)の先頭10文字= JST の日付★(`Date` に通して getter を使わない。
// 実行環境のタイムゾーンで1日ずれる)
const date = row.scheduled_at.slice(0, 10);
// ★未実施は `departed` で数える★(【未決1】案A。D-156 / 指示書AU D3。完了率の規則がまた
// 変わっても、未実施の数え方が巻き込まれないため)
const notExecuted = !row.departed;
// ★完了率の Σ と件数は null を数えない★(未実施の清掃)
const ratio = row.completion_rate ?? 0;
const counted = row.completion_rate === null ? 0 : 1;
const current = byDate.get(date);
if (current) {
current.not_executed = current.not_executed || notExecuted;
current.clean_ratio_sum += ratio;
current.clean_ratio_count += counted;
} else {
byDate.set(date, {
date,
not_executed: notExecuted,
clean_ratio_sum: ratio,
clean_ratio_count: counted,
});
}
}
return [...byDate.values()].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0
);
}
/**
* R-1 — 直近から遡って、未実施が何日続いているかを数える。
*
* ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」★(D-026 / `CLAUDE.md` §4.5)—
* ここでは ★渡された `RescueDay` がスケジュール存在日そのもの★なので、
* 清掃予定のない日は自然に飛ばされ、連続が途切れない。
*
* ★★最新日が未実施でなければ 0★★(指示書AI 表1 1-7。過去に3日連続があっても拾わない)。
*
* @param days ★日付昇順★の畳み込み済みの日(`foldRescueDays()` の戻り値)
*/
export function countNotExecutedStreak(days: readonly RescueDay[]): number {
let count = 0;
for (let i = days.length - 1; i >= 0; i -= 1) {
if (!days[i].not_executed) break;
count += 1;
}
return count;
}
/**
* R-2 — 連続問題レベルが L3 か。
*
* ★★既存の `judgeEscalationLevel()` をそのまま使う★★(指示書AI 表2 / §13-6)—
* ★`>= 3` と書き直さないこと★。閾値が `ESCALATION_THRESHOLDS` から読まれなくなり、
* L3 の定義が2箇所に分かれる。
*/
export function isEscalationL3(consecutiveProblemDays: number): boolean {
return judgeEscalationLevel(consecutiveProblemDays) === "L3";
}
/**
* R-3 — 完了率が前回比で急落したか。
*
* ```
* (前回の完了率 − 当日の完了率) ÷ 前回の完了率 ≧ COMPLETION_DROP.dropRatio
* ```
*
* ★★比(%)であって差(ポイント)ではない★★(ユーザー確定 2026-09-15「比で」)—
* 前日30% → 当日18% は「12ポイント低下」だが「40%低下」。★ユーザーは後者を拾う側を選んだ★。
*
* ★★算出しない場合(指示書AI 決定B〜D)★★:
* - 前回が `null`(未実施・前回の日が無い)… 比べる相手が無い(決定C)
* - 当日が `null`(未実施)… 同上。★未実施を 0% とみなさない★(決定D / §13-5)
* - 前回が `0`… 0で割れないし、下がりようもない(決定B)
*
* ★上昇したときは `dropped: false`★(決定E。「急落」なので低下方向のみ。絶対値で見ない)。
* ★ただし `drop_ratio` は算出できる限り返す★(負値=上昇)— 表示の付帯情報であり、
* ★「算出できない」(`null`)と「低下していない」(負値・小さい値)は別の事実★だから。
*
* ★閾値 `dropRatio` は `COMPLETION_DROP`(`lib/config`)から読む★(ハードコード禁止。`CLAUDE.md` §7)。
*
* @param previous 前回のスケジュール存在日の完了率(`facilityDayRate()`。★カレンダー前日ではない★。決定A)
* @param current 当日(評価日)の完了率(`facilityDayRate()`)
*/
export function judgeCompletionDrop(
previous: number | null,
current: number | null
): { dropped: boolean; drop_ratio: number | null } {
if (previous === null || current === null || previous === 0) {
return { dropped: false, drop_ratio: null };
}
const dropRatio = (previous - current) / previous;
return { dropped: dropRatio >= COMPLETION_DROP.dropRatio, drop_ratio: dropRatio };
}
/**
* 3条件を合成して、要レスキューかどうかと ★その理由を全部★ 返す。
*
* ★★理由を1つに丸めないこと★★(指示書AI 表5 5-5)—
* 「なぜレスキューが要るのか」が運用担当の判断材料そのもので、
* ★丸めると現場に何を伝えるかが決まらない★。
*
* ★評価日は「渡された行のうち最も新しいスケジュール存在日」★(`RescueInput.rows` の docstring)。
* ★評価日そのものが要るなら `foldRescueDays(rows)` の末尾を見ること★
* (★呼び出し側で日付の畳み込みを書き直さない★)。
*/
export function judgeRescue(input: RescueInput): RescueJudgement {
const days = foldRescueDays(input.rows);
const today = days.at(-1);
const previous = days.at(-2);
const notExecutedDays = countNotExecutedStreak(days);
const currentRate = today ? facilityDayRate([today]) : null;
const previousRate = previous ? facilityDayRate([previous]) : null;
const drop = judgeCompletionDrop(previousRate, currentRate);
const reasons: RescueReason[] = [];
// ★順序は R-1 → R-2 → R-3 に固定する★(毎朝見る画面で理由の並びが揺れないため)
if (notExecutedDays >= RESCUE_THRESHOLDS.notExecutedDays) {
reasons.push("not_executed_streak");
}
if (isEscalationL3(input.consecutive_problem_days)) reasons.push("escalation_l3");
if (drop.dropped) reasons.push("completion_drop");
return {
required: reasons.length > 0,
reasons,
not_executed_days: notExecutedDays,
consecutive_problem_days: input.consecutive_problem_days,
// ★丸めない生値を返す★(`CLAUDE.md` §9.3「数値の丸めは表示層でのみ行う」)
completion_rate: currentRate,
previous_completion_rate: previousRate,
drop_ratio: drop.drop_ratio,
};
}