/**
* アラート(`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);
}