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