Newer
Older
client_dwatch / lib / judge / advice.ts
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);
}