Newer
Older
client_dwatch / lib / db / rescue-mapping.ts
/**
 * 要レスキュー店舗(`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;
  });
}