Newer
Older
client_dwatch / lib / db / precheck-mapping.ts
/**
 * アラート(`PrecheckItem`)を組み立てるときの変換ロジック(純粋関数のみ)。
 *
 * ★`lib/db/ds-status.ts` / `lib/db/clean-log-mapping.ts` と同じ作法★ —
 * DB アクセスを混ぜず、`prismaSource.prechecks()` から呼ぶだけにして、
 * テストは純粋関数だけで書けるようにする(指示書S 3-5 / 禁止事項12)。
 *
 * ★`clean-log-mapping.ts` に足さず別ファイルにした理由★(指示書S 3-5 の判断事項):
 *   `cleanLogs()` と `prechecks()` は「同じ出来事の別の切り口」だが、
 *   変換規則は別物(あちらは判定・連続日数、こちらは severity と分数)。
 *   混ぜるとテストの期待値表がどちらのものか分からなくなる。
 *   ★両方が使う規則(`robotStatusFromErrors` / `departedFromHistory`)は
 *     `clean-log-mapping.ts` に置いたまま共有する★(D-103。2箇所に書かない)。
 */
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { SUCCESS_GRADES } from "@/lib/dashboard/aggregate";
import {
  completionRateFromReport,
  notExecutedFromReport,
  robotStatusFromErrors,
  type CleanTaskReportLike,
  type RobotErrorLike,
} from "@/lib/db/clean-log-mapping";
import { jstIsoFromDb } from "@/lib/format/date";
import { judgeGrade } from "@/lib/judge";
import type { PrecheckItem, PrecheckStatus } from "@/lib/types";

/**
 * `robot_error_reports.status`(0/1/2) → `PrecheckItem.resolution_status`
 *
 * ★値域は `control_error_histories.status`(D-065 Q1-3)と同じ 0/1/2★ だが、
 *   ★変換先の文字列は別★ — D-Sec 側は `pending` / `in_progress` / `done`
 *   (`DsAlertStatus`)、こちらは `unresolved` / `in_progress` / `resolved`
 *   (`PrecheckStatus`。`lib/types/index.ts` / `d-watch-api.yaml` の `PrecheckItem`)。
 *   ★同じ関数を使い回せないので、`ds-status.ts` と同じ作法で別に書く★。
 *
 * ★想定外の値は静かに握りつぶさず例外を投げる★(`ds-status.ts` と同じ)。
 *   `@db.UnsignedTinyInt` は 0〜255 を許すため、3以上が入りうる。
 *   既定値に倒すと「対応済みなのに未解消と出る」ような取り違えに誰も気づけない。
 */
export function resolutionStatusFromRobotErrorStatus(status: number): PrecheckStatus {
  switch (status) {
    case 0:
      return "unresolved";
    case 1:
      return "in_progress";
    case 2:
      return "resolved";
    default:
      throw new Error(
        `[precheck-mapping] robot_error_reports.status に想定外の値です: ${status}(0=未解消/1=対応中/2=解消のみ想定)`
      );
  }
}

/**
 * `PrecheckItem.resolution_status` → `robot_error_reports.status`(seed とアプリの書き込みで使う逆変換)
 *
 * ★アプリの書き込み★は `lib/db/robot-error-write.ts`(`PATCH /prechecks/{id}/status`。指示書AT)。
 */
export function robotErrorStatusFromResolutionStatus(status: PrecheckStatus): number {
  switch (status) {
    case "unresolved":
      return 0;
    case "in_progress":
      return 1;
    case "resolved":
      return 2;
  }
}

/**
 * `clean_timetables.precheck` → `PrecheckItem.precheck_minutes`(何分前のチェックか)
 *
 * ★関数名の `Code` は歴史的な名前★ — 以前は `precheck` を回次のコード値として読んでいた
 * 名残で、現在の `precheck` はコード値ではなく分数そのもの。呼び出し側(`lib/db/index.ts`)に
 * `precheckMinutes` という局所変数があり素直な短い名前が取れないため、この作業では改名しない
 * (申し送り。次に本ファイルを触るときに一緒に直すのが安全)。
 *
 * ★`clean_timetables.precheck` は「本清掃開始の何分前か」を表す数値★
 *   (TeamWorks 第7回回答 A5-2「既にclean_timetablesに格納するデータは本清掃開始〇分前の
 *   〇部分の数値が入るように修正済みです」/ A5-5「0以外の予約語はありません。すべて本清掃を
 *   基準に何分前開始の値が格納されます」。2026-09-11 受領。D-112)。
 *
 * ★`CLEAN_TIMETABLE_PRECHECK.main`(=本清掃)だけが `null`★。
 *   呼び出し側はこの `null` を「アラートの対象外」として扱う
 *   (出発失敗は**エラー行からではなく**本清掃行の履歴の有無から作る。D-103)。
 *
 * ★★`1` は「1回目」ではなく「1分前」(A5-3「もし1や2が格納されていたら1分, 2分前として
 * ください」)★★ — ★★以前の実装は `1` を60分前・`2` を30分前と読んでおり、TeamWorks の指示と
 * **真逆**だった(指示書X)★★。
 *
 * ★新旧の混在は無い(A5-4「混在しません」)ので、値域を吸収する仕掛けは持たない★ —
 *   以前あった「コード値表(`CLEAN_TIMETABLE_PRECHECK.rounds`)を引き、無ければ生値」という
 *   2段構えの変換は撤回した。値は常に「本清掃だけ `null`、それ以外は生値そのまま」の1本。
 *
 * ★未知の値を「その他」に丸めない(D-077 / D-089 項3 / D-104 項2)★。
 *   A5-5 が「0以外の予約語はありません」と明言しており、丸める先が存在しない。
 * ★負の値も含めて生値をそのまま返す。例外を投げない★ —
 *   `precheck` は `UnsignedTinyInt`(`prisma/schema.prisma`)なので負の値はそもそも
 *   格納できず、負の値が来るとしたらこの関数より手前の異常である。これは3値の enum に
 *   変換する `resolutionStatusFromRobotErrorStatus()` とは事情が違う連続量であり、
 *   生値がそのまま画面(`precheckSeverityLabel()`)に出れば人が気づける(fail-loud)。
 */
export function precheckMinutesFromCode(precheck: number): number | null {
  if (precheck === CLEAN_TIMETABLE_PRECHECK.main) return null;
  return precheck;
}

/**
 * 詳細ログの事前チェック列の数(`status_1h` / `status_30m` の2列)。
 *
 * ★閾値ではなく API の形(フィールドが2つ)★なので `lib/config` には置かない。
 * 事前チェックは1清掃につき2回で固定と確定している
 * (ユーザー回答 2026-09-11「これは2回だけです、3回以上にはしません」)。
 */
export const PRECHECK_COLUMN_COUNT = 2;

/** `statusColumnsFromPrecheckRows()` の戻り(★添字0 = 列1(1回目)・添字1 = 列2(2回目)★) */
export interface PrecheckColumns {
  /** `[status_1h, status_30m]`。★予定行が無い回は `null`★(D-171) */
  status: [string | null, string | null];
  /** `[status_1h_minutes, status_30m_minutes]`(`precheck` の生値)。★`status` が null の回だけ `null`★ */
  minutes: [number | null, number | null];
  /**
   * ★列に入りきらず捨てた分数★(分数の大きい順)。3つ以上の事前チェックがあったときだけ空でない。
   * ★呼び出し側(`lib/db/index.ts`)は空でなければ `data.anomaly` を出す★(黙って捨てない)
   */
  dropped: number[];
}

/**
 * 事前チェック行(`precheck` の生値ごとのエラー配列) → 詳細ログの事前チェック2列(D-178。ユーザー判断 2026-10-16「案1」)
 *
 * ★`status_1h` / `status_30m` という**フィールド名は API 契約**(`d-watch-api.yaml`)なので互換のため残す★が、
 *   ★中身はもう「60分前 / 30分前」ではなく「1回目 / 2回目」★。何分前かは `minutes` で返し、
 *   API の `status_1h_minutes` / `status_30m_minutes` になる。
 *
 * ★割り当て規則(順番で割り当てる)★:
 *   1. Map のキー(`precheck` の生値)から本清掃(`CLEAN_TIMETABLE_PRECHECK.main`)を除く
 *      (呼び出し側も除いているが、ここでも弾く)
 *   2. ★分数の大きい順(=早い順)に並べる★。Map の挿入順には頼らない
 *   3. 3つ以上あれば ★開始に近い(分数の小さい)2つを残し★、残りを `dropped` で返す
 *      (1清掃2回で固定なので通常は起きない。★黙って捨てず、呼び出し側がログに出す★)
 *   4. 1つ目を列1(`status_1h`)、2つ目を列2(`status_30m`)に入れる。
 *      ★1つしか無ければ、その分数によらず列1に入り、列2は `null`★。無ければ両方 `null`
 *
 * ★なぜ分数の一致(旧: `PRECHECK_MINUTES_BEFORE` 基準)をやめたか★ —
 *   事前チェックを何分前に行うかは設定で変わりうる(例: 30分前を10分前に)。旧規則では
 *   DB が 60/10 になると ★10分前の結果が例外もテスト失敗も出ないまま落ち、2列目が「-」になった★。
 *   ★見出しを設定から作る案(案2)は採らなかった★ — 施設ごとに分数が違う(A店 60/30・B店 60/10)と、
 *   どちらかが必ず「-」になる(D-178)。
 *
 * ★値(列の中身)は今までどおり★:
 *   - ★予定行がある回は、エラーが無ければ待機(`robotStatusFromErrors([])`)、あれば `robotStatusFromErrors()`★
 *   - ★予定行が無い回は `null`★(ユーザー指示 2026-10-07「事前チェックがないものは-にしてください」。D-171)
 *   - ★「行がある」は Map にキーがあること★。呼び出し側(`lib/db/index.ts`)は予定行ごとに、
 *     エラーが無くても空配列でキーを入れる。★エラーの有無でキーを入れ分けないこと★
 *     (入れ分けると、予定があって異常なしの回が消え、列がずれる)
 *   - ★分数は変換しない(D-077)★。旧コード値の `1` / `2` も1分前・2分前のまま(`precheckMinutesFromCode()` と同じ。
 *     TeamWorks 第7回 A5-3)。★その結果 2分前が列1・1分前が列2 になる★(fail-loud。読み替えない)
 *
 * ★判定には使わない★(D-170)— 列の割り当てが変わっても ◎○△× と出発成功・失敗は変わらない。
 *
 * ★期待値表 P1〜P11(D-178)は `lib/db/__tests__/precheck-mapping.test.ts`★
 *
 * @param errorsByPrecheck `precheck` の生値 → その行に紐づくエラー配列(★予定行があればエラーが無くても空配列で入る★)
 */
export function statusColumnsFromPrecheckRows(
  errorsByPrecheck: ReadonlyMap<number, readonly RobotErrorLike[]>
): PrecheckColumns {
  // ★分数の大きい順(早い順)★
  const ordered = [...errorsByPrecheck.keys()]
    .filter((minutes) => minutes !== CLEAN_TIMETABLE_PRECHECK.main)
    .sort((a, b) => b - a);
  // ★3つ以上なら開始に近い(末尾の)2つを残す★
  const keptFrom = Math.max(0, ordered.length - PRECHECK_COLUMN_COUNT);
  const dropped = ordered.slice(0, keptFrom);
  const kept = ordered.slice(keptFrom);

  const column = (i: 0 | 1): { status: string | null; minutes: number | null } => {
    const minutes = kept[i];
    if (minutes === undefined) return { status: null, minutes: null };
    // ★`minutes` は Map のキーから取ったので `get()` は必ず配列を返す★(`?? []` は型を満たすためだけ。予定行の無い回は上で null 済み)
    return { status: robotStatusFromErrors(errorsByPrecheck.get(minutes) ?? []), minutes };
  };
  const first = column(0);
  const second = column(1);
  return {
    status: [first.status, second.status],
    minutes: [first.minutes, second.minutes],
    dropped,
  };
}

/**
 * `robot_error_reports` のうち、`detected_at` を決めるのに必要な最小限の形
 * (`RobotErrorLike`(`clean-log-mapping.ts`)とは用途が違うので別に定義する)
 */
export interface PrecheckDetectedAtSource {
  /** `robot_error_reports.errored_at`(★nullable★。`prisma/schema.prisma:334`) */
  errored_at: Date | null;
  /** `robot_error_reports.created_at`(★NOT NULL / default now()★。`:337`) */
  created_at: Date;
}

/**
 * `PrecheckItem.detected_at` に使う日時を選ぶ(指示書S 3-4 / D-105)
 *
 * ★`errored_at ?? created_at`★ —
 *   `errored_at`(エラーが起きた時刻)が意味的に正しいが ★nullable★ なので、
 *   NOT NULL の `created_at`(行が作られた時刻)を保険にする。
 *   `PrecheckItem.detected_at` は `string`(nullable ではない)ため、
 *   どちらかを必ず選べる形にしておく必要がある。
 *
 * ★`created_at` を優先しない理由★: 収集処理の遅延がそのまま「検知時刻」になり、
 *   実際にエラーが起きた時刻より後ろにずれる。
 *
 * ★★指示書Y(タイムゾーン補正)との関係★★ — `errored_at` は DATETIME、`created_at` は
 *   TIMESTAMP で型が違う。★本番はどちらも JST の壁時計値なので、呼び出し側は戻り値を
 *   同じ `jstIsoFromDb()` に通せばよい(同じ補正でよい)★(TeamWorks第8回A2)。
 *   ★★ただしローカルのシードは `errored_at` を必ず入れるため、`created_at` への
 *   フォールバックが1度も発火しない=この関数の `created_at` 側の補正はローカルでは
 *   検証できていない★★(指示書Y 申し送り③)。実DB接続時に
 *   `SELECT COUNT(*) FROM robot_error_reports WHERE errored_at IS NULL` を流し、
 *   0件でなければ `detected_at` の値を目で確かめること。
 */
export function detectedAtFromError(source: PrecheckDetectedAtSource): Date {
  return source.errored_at ?? source.created_at;
}

/**
 * `robot_error_reports` の1行のうち、`PrecheckItem`(`severity: "precheck"`)を組み立てるのに要る列。
 * ★Prisma の行(`include: { facility: true }`)がそのまま入る形★。
 */
export interface PrecheckErrorRowSource extends PrecheckDetectedAtSource {
  error_id: string;
  serial_number: string;
  facility_id: bigint;
  facility: { facility_name: string };
  error_type: string;
  status: number;
  responded_at: Date | null;
  resolved_at: Date | null;
  comment: string | null;
}

/**
 * 事前チェック行に紐づくエラー行 → `PrecheckItem`(`severity: "precheck"`)(指示書AT 段階4-1)。
 *
 * ★★`prismaSource.prechecks()`(一覧)と `prismaSource.precheckItemById()`(PATCH の前後の読み取り)が
 *   この1本を使う★★ — ★同じ行の作り方を2箇所に書かない★(片方だけ直すと、更新後に返す行と
 *   一覧の行が食い違う)。
 *
 * @param report エラー行(`include: { facility: true }`)
 * @param precheckMinutes `precheckMinutesFromCode(事前チェック行.precheck)`(★null でないことは呼び出し側が確かめる★)
 * @param mainRow 事前チェック行の `parent_id` が指す本清掃行(`scheduled_at` に使う)
 * @param mainReport その本清掃行の `clean_task_reports`(`latestReportByTimetableId()` で引いた1件。無ければ undefined)。
 *   ★`main_run_succeeded` に使う★(D-171)。★省略可にしない★ — 渡し忘れが「常に false」に化けて気づけないため
 */
export function precheckItemFromErrorRow(
  report: PrecheckErrorRowSource,
  precheckMinutes: number,
  mainRow: { start_at: Date },
  mainReport: CleanTaskReportLike | undefined
): PrecheckItem {
  return {
    // ★`error_id` を使い string のまま★(D-070 1-2)。★`Number()` を通さない★ —
    //   `varchar(50)` なので NaN になっても例外が出ず、静かに壊れる(落ち穴3)
    id: report.error_id,
    severity: "precheck",
    precheck_minutes: precheckMinutes,
    serial_number: report.serial_number,
    // ★BigInt を残すと `NextResponse.json` が例外を投げる(落とし穴4)★
    facility_id: Number(report.facility_id),
    facility_name: report.facility.facility_name,
    // ★`scheduled_at` は事前チェック行の `start_at` ではなく親の本清掃行の値★(指示書S 3-2 項5)
    // ★DBのDATETIMEには日本時間の壁時計値が入っている(TeamWorks第8回A2。指示書Y)★
    scheduled_at: jstIsoFromDb(mainRow.start_at),
    // ★`error_type` の生値。7分類にも日本語にも変換しない★(D-077 / D-088 / D-098)
    //   1件のエラー行なので配列に包んで既存の純粋関数へ渡す
    //   ★errored_at はDB由来の値どうしの相対比較(並べ替え)にしか使わないので補正しない★
    robot_status: robotStatusFromErrors([
      { error_type: report.error_type, errored_at: report.errored_at },
    ]),
    // ★`errored_at ?? created_at`(`detectedAtFromError()` に規則を閉じ込めてある)★
    detected_at: jstIsoFromDb(detectedAtFromError(report)),
    // ★書き込み許可4カラム(D-042)。書くのは `lib/db/robot-error-write.ts`(指示書AT)★
    resolution_status: resolutionStatusFromRobotErrorStatus(report.status),
    responded_at: report.responded_at ? jstIsoFromDb(report.responded_at) : null,
    resolved_at: report.resolved_at ? jstIsoFromDb(report.resolved_at) : null,
    note: report.comment,
    // ★常に null★ — `robots.battery`(`prisma/schema.prisma:242`)は現在値のみで
    //   履歴が無いため「事前チェック時点の残量」を取れない(D-090 項8)。
    //   ★現在値を代入すると、別時点の値を過去のアラートに貼ることになる★
    battery_level: null,
    // ★親の本清掃が成功したか(D-171)★ — 規則は `mainRunSucceededFromReport()` の1本
    main_run_succeeded: mainRunSucceededFromReport(mainReport),
  };
}

/**
 * ★親の本清掃が成功したか★(`PrecheckItem.main_run_succeeded`。ユーザー指示 2026-10-07。D-171)
 *
 * | 親の本清掃 | 戻り |
 * |---|---|
 * | レポート行が無い(未実施=出発失敗) | `false` |
 * | レポート行があり、完了率の判定が ◎○ | `true` |
 * | レポート行があり、完了率の判定が △× | `false`(`task_area = 0` は完了率 0 → ×) |
 *
 * ★成功の定義を新しく書かない★ — レポート行の有無は `notExecutedFromReport()`、完了率は
 *   `completionRateFromReport()`(=`runCompletionRate()`。`clean_ratio` 列は読まない。D-155)、
 *   等級は `judgeGrade()`(★丸める前の生値で判定★。`CLAUDE.md` §4.3)、成功とみなす等級は
 *   `SUCCESS_GRADES`(ダッシュボードの「成功」と同じ定数)。
 *
 * ★★表示のためだけの値★★ — 事前チェックは判定に使わない(D-170)のと同じく、
 *   ★この値で `resolution_status` やサマリの件数を変えないこと★(本清掃が成功しても未解消は未解消)。
 *
 * `launch_failed` の行(レポート行が無い本清掃)も、この関数に `undefined` を渡して `false` を得る。
 */
export function mainRunSucceededFromReport(report: CleanTaskReportLike | undefined): boolean {
  if (notExecutedFromReport(report)) return false;
  const grade = judgeGrade(completionRateFromReport(report));
  return (SUCCESS_GRADES as readonly string[]).includes(grade);
}