import { ESCALATION_THRESHOLDS, GRADE_THRESHOLDS } from "@/lib/config";
import type { AdviceRuleId } from "@/lib/config/advice-rules";
import {
B2_HANDLE_UP_STATUSES,
B3_OFFLINE_STATUSES,
} from "@/lib/config/robot-status-match";
import { judgeGrade } from "./grade";
/**
* 対応アドバイスの発火判定(Phase A: R1〜R4 + Phase B: B1〜B3)
*
* ★ここは「どのルールが該当するか」だけを決めます★
* 表示する文言は `lib/config/advice-rules.ts` の既定値と、
* 運用設定での上書き(`lib/settings/advice.ts`)から取得します。
* 文言を変えてもこの関数とテストは影響を受けません。
*
* | # | 条件 |
* |---|---|
* | R1 | 出発失敗(初回) |
* | R2 | 完了率×(10%未満) |
* | R3 | 同一施設で2日以上連続△以下 |
* | R4 | 同一施設で3日以上連続(出発失敗 or △以下) |
* | B1 | 完了率10%以上70%未満(マップ更新の確認) |
* | B2 | ハンドルアップ検知 |
* | B3 | オフライン検知 |
*
* ★B2/B3 の発火条件は `lib/config/robot-status-match.ts` の設定値。値は暫定★(D-078 項2)
*
* ★「連続」はスケジュール存在日の連続★(D-026)
* `consecutiveProblemDays` には、データ取得側がスケジュール存在日ベースで
* 算出済みの連続日数を渡してください(どの日がスケジュール存在日かの判断は
* データ取得側の責任です)。
*
* 複数該当した場合はすべて返します(モックの挙動を踏襲)。
* 行バッジの色は `maxAdviceLevel()` で決めます。
*/
export interface AdviceInput {
/** スケジュール通り出発したか */
departed: boolean;
/** 清掃完了率(%)。未実施は null。★丸める前の生値★ */
completionRate: number | null;
/** スケジュール存在日ベースの連続問題日数 */
consecutiveProblemDays: number;
/**
* ★事前チェック1回目(早い方)★のステータス(API の `status_1h` そのまま)。
* ★名前は旧来の `status1h` のままだが、60分前とは限らない★(列は分数ではなく順番で割り当てる。D-178)。
* B2 / B3 は1回目・2回目のどちらかが一致すれば発火するので、何分前かは判定に関係しない。
* ★`null` = 1回目の事前チェックの予定が無い★(ユーザー指示 2026-10-07。D-171)。★null はどのルールにも当たらない(例外にしない)★
*/
status1h?: string | null;
/** ★事前チェック2回目(遅い方)★のステータス(API の `status_30m`。30分前とは限らない。D-178)。★`null` の扱いは `status1h` と同じ★(D-171) */
status30m?: string | null;
}
export function matchAdviceRules(input: AdviceInput): AdviceRuleId[] {
const matched: AdviceRuleId[] = [];
const {
departed,
completionRate,
consecutiveProblemDays,
status1h,
status30m,
} = input;
// R1: 出発失敗(初回)。2日目以降は R3 / R4 が担当する
if (!departed && consecutiveProblemDays <= 1) {
matched.push("R1");
}
// R2: 完了率×(10%未満)。出発しなかった場合は completionRate が null なので対象外
if (completionRate !== null && completionRate < GRADE_THRESHOLDS.poor) {
matched.push("R2");
}
// R3: 2日以上連続
if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level2Days) {
matched.push("R3");
}
// R4: 3日以上連続。R3と同時に出ることがある(段階的な対応を促すため意図的)
if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level3Days) {
matched.push("R4");
}
// B1: △判定(完了率10%以上70%未満)のときにマップ更新を疑う
if (completionRate !== null && judgeGrade(completionRate) === "△") {
matched.push("B1");
}
// B2 / B3: 事前チェックのステータス由来
if (matchesStatus(B2_HANDLE_UP_STATUSES, status1h, status30m)) {
matched.push("B2");
}
if (matchesStatus(B3_OFFLINE_STATUSES, status1h, status30m)) {
matched.push("B3");
}
return matched;
}
/**
* ロボット状態が発火条件にマッチするか。
* ★前後の空白を trim し、大小文字を無視して比較する★(表記ゆれ対策。D-078 項2)。
* ★マッチ配列が空なら常に false★(=値が未判明のルールは発火しない)。
* ★`null`(事前チェックの予定が無い。D-171)と `undefined` はマッチしない★。
*/
function matchesStatus(
statuses: readonly string[],
...values: Array<string | null | undefined>
): boolean {
if (statuses.length === 0) return false;
const normalized = statuses.map((s) => s.trim().toLowerCase());
return values.some(
(v) =>
v !== undefined && v !== null && normalized.includes(v.trim().toLowerCase())
);
}
/** 該当ルールの最大レベル。行バッジの色分けに使う。該当なしは0 */
export function maxAdviceLevel(
rules: Array<{ level: number }>
): number {
return rules.reduce((max, r) => Math.max(max, r.level), 0);
}