/**
* エラー一覧(`ErrorReportItem`。`GET /errors`。指示書AW)を組み立てるときの変換ロジック(純粋関数のみ)。
*
* ★`lib/db/precheck-mapping.ts` と同じ作法★ — DB アクセスを混ぜず、データ層(`lib/db/index.ts` の
* fixtures 版・Prisma 版)から呼ぶだけにして、テストは純粋関数だけで書けるようにする。
* ★fixtures と Prisma は同じ関数(行 → API の値・絞り込み・並べ替え)を通す★(指示書AW D13 / `CLAUDE.md` §9.5)。
*
* ★`precheck-mapping.ts` に足さず別ファイルにした理由★: `/alerts`(事前チェックの行だけ)とは対象が違う
* (`robot_error_reports` の行。D-160 項1。★削除済みの施設・清掃エリアの行は除く — `isVisibleErrorRow()`。指示書AX U8★)。★状態コード・検知時刻・分数の規則は `precheck-mapping.ts` のものを
* そのまま使う★(2箇所に書かない。指示書AW D2 / D3 / D4)。
*
* ★2段階で読む★(指示書AW D8):
* 1段目 = キーだけ(`ROBOT_ERROR_LIST_KEY_SELECT` → `errorReportKeyFromRow()`)で絞り込み・並べ替え・ページ分け、
* 2段目 = そのページの行だけ列を足して読む(`ROBOT_ERROR_LIST_ROW_SELECT` → `errorReportItemFromRow()`)。
* ★`errored_at ?? created_at` の順は Prisma のクエリビルダで書けない★(`COALESCE` の `orderBy` が無い。生SQL は禁止)。
*
* テストは `lib/db/__tests__/error-report-mapping.test.ts`(表1 は監督確定。変えないこと)。
*/
import { dbDateRange } from "@/lib/db/date-range";
import {
detectedAtFromError,
precheckMinutesFromCode,
resolutionStatusFromRobotErrorStatus,
type PrecheckDetectedAtSource,
} from "@/lib/db/precheck-mapping";
import {
FACILITY_SECTION_VISIBLE_WHERE,
isVisibleFacilitySection,
type FacilitySectionVisibilitySource,
} from "@/lib/db/visibility";
import {
isErrorListSelectable,
resolveErrorList,
type ErrorListIndex,
} from "@/lib/db/error-list-source";
import { errorReportStatusCodes } from "@/lib/error-reports/query";
import { scheduledTimetableId } from "@/lib/db/timetable-link";
import { jstIsoFromDb } from "@/lib/format/date";
import type { Prisma } from "@/lib/generated/prisma/client";
import type { ErrorReportDbQuery, ErrorReportItem, ErrorScheduleLink } from "@/lib/types";
/** 可視性の判定に要る列(`facility` / `section` の `deleted_at`)。★規則は `lib/db/visibility.ts`(指示書BB D1)★ */
export type ErrorRowVisibilitySource = FacilitySectionVisibilitySource;
/**
* ★削除済み(論理削除)の施設・清掃エリアの行は見せない★(ユーザー決定 2026-10-19「削除済みのものは表示する必要がないので、
* エラー一覧からもD-sec一覧からも消してください」。指示書AX U8・D12。D-180)。★`deleted_at` が NULL でなければ削除済み(日時によらない)★。
* ★これが正★。SQL の `ERROR_ROW_VISIBLE_WHERE` は写し(fixtures はこちらしか通らない)。
* ★施設名・分類の解決は `deleted_at` で絞らない(罠7)★ — この関数は `robot_error_reports` の行の可視性だけを決める。
* ★規則は `lib/db/visibility.ts` の `isVisibleFacilitySection()` の1か所★(指示書BB D1。2026-10-19 に全画面へ広げた。D-182)—
* この名前は D-180 の呼び出し元のために残す
*/
export function isVisibleErrorRow(row: ErrorRowVisibilitySource): boolean {
return isVisibleFacilitySection(row);
}
/** SQL 側の写し(`errorReportListWhere()` と `dsAlertWhere()` に混ぜる)。★中身は `FACILITY_SECTION_VISIBLE_WHERE`(指示書BB D1)★ */
export const ERROR_ROW_VISIBLE_WHERE = FACILITY_SECTION_VISIBLE_WHERE satisfies Prisma.RobotErrorReportWhereInput;
/** 1段目の行(`ROBOT_ERROR_LIST_KEY_SELECT` の結果がそのまま入る形) */
export interface ErrorReportKeySource extends PrecheckDetectedAtSource, ErrorRowVisibilitySource {
error_id: string;
facility_id: bigint;
error_type: string;
status: number;
}
/** 絞り込み・並べ替えに使うキー(★`status` は生の数値★。表2 F12) */
export interface ErrorReportKey {
error_id: string;
facility_id: number;
error_type: string;
/** `jstIsoFromDb(detectedAtFromError(row))` */
detected_at: string;
status: number;
/** `isVisibleErrorRow(row)`(★false の行は `filterSortErrorReportKeys()` が外す★。指示書AX U8) */
visible: boolean;
}
/** 2段目の行(`ROBOT_ERROR_LIST_ROW_SELECT` の結果がそのまま入る形) */
export interface ErrorReportRowSource extends PrecheckDetectedAtSource {
error_id: string;
clean_timetable_id: bigint | null;
serial_number: string;
facility_id: bigint;
facility: { facility_name: string; deleted_at: Date | null };
section: { deleted_at: Date | null };
error_type: string;
error_level: string;
status: number;
responded_at: Date | null;
resolved_at: Date | null;
comment: string | null;
/** エラー内容(`error_lists.id`)。★null = 未選択★(指示書BD §3-2。`ROBOT_ERROR_LIST_ROW_SELECT` で読む) */
error_list_id: bigint | null;
}
/** 予定(本表・履歴のどちらか。`findTimetables*()` の `select: { id: true, precheck: true }`) */
export interface ErrorTimetableRef {
id: bigint;
precheck: number;
}
/**
* 1段目の行 → 絞り込み・並べ替えのキー。
*
* ★`status` は生の数値のまま持つ★ — ここで `resolutionStatusFromRobotErrorStatus()` に通すと、
* 0/1/2 以外(`UnsignedTinyInt` なので3以上が入りうる)の行が1件あるだけで一覧全体が500になる。
* 一覧では `status IN`(SQL)と `filterSortErrorReportKeys()`(生の数値で比べる)で外れる(表2 F12。§11 Q3)。
* ★検知時刻は `errored_at ?? created_at`★(`detectedAtFromError()`。D3)を `jstIsoFromDb()` に通す —
* DB の値は JST の壁時計値(`CLAUDE.md` §3)。★9時間を足さない・引かない★。
*/
export function errorReportKeyFromRow(row: ErrorReportKeySource): ErrorReportKey {
return {
error_id: row.error_id,
// ★BigInt を残さない★(キーは number で比べる。`filterSortErrorReportKeys()` の facility_ids と揃える)
facility_id: Number(row.facility_id),
error_type: row.error_type,
detected_at: jstIsoFromDb(detectedAtFromError(row)),
status: row.status,
visible: isVisibleErrorRow(row),
};
}
/**
* エラー行の `clean_timetable_id` と引き当てた予定 → 「紐づく予定」の4区分(指示書AW D4)。
*
* | 条件 | `schedule_link` | `precheck_minutes` |
* |---|---|---|
* | `clean_timetable_id` が NULL ★または `0`★ | `none`(予定なし) | null |
* | ★本表にも履歴にも無い id★(`timetable` が `undefined`) | `unknown`(予定不明) | null |
* | `precheckMinutesFromCode(precheck)` が null | `main`(本清掃) | null |
* | それ以外 | `precheck`(事前チェック) | ★生の分数★(設定の 60/30 に無い値でも出す。D-105 項1) |
*
* ★★`0` も予定外(`none`)★★(TeamWorks 回答 2026-10-27「0は予定外タスク」)— ★判定は `scheduledTimetableId()` の1本★。
* `=== null` だけだと `0` が「予定の id」としてすり抜け、本表にも履歴にも無いので `unknown`(予定不明)に化ける。
* ★`0` 以外の id(dangling を含む)の扱いは変えない★。
* ★`CLEAN_TIMETABLE_PRECHECK.main` を自分で比べない★(`precheckMinutesFromCode()` の1本)。
* ★予定の id と `clean_timetable_id` が違えば例外★ — 呼び出し側の引き当ての誤り(別の行の予定を貼った)であり、
* 黙って区分を出すと誰も気づけない。
*
* @param cleanTimetableId `robot_error_reports.clean_timetable_id`
* @param timetable 本表・履歴から id で引いた予定(★同じ id は本表が勝つ★。D5)。見つからなければ `undefined`
*/
export function errorScheduleLink(
cleanTimetableId: bigint | null,
timetable: ErrorTimetableRef | undefined
): { schedule_link: ErrorScheduleLink; precheck_minutes: number | null } {
const timetableId = scheduledTimetableId(cleanTimetableId);
if (timetableId === null) return { schedule_link: "none", precheck_minutes: null };
if (timetable === undefined) return { schedule_link: "unknown", precheck_minutes: null };
if (timetable.id !== timetableId) {
throw new Error(
`[error-report-mapping] 予定の引き当てが食い違っています(clean_timetable_id=${timetableId.toString()} に id=${timetable.id.toString()} の予定が渡された)`
);
}
const minutes = precheckMinutesFromCode(timetable.precheck);
if (minutes === null) return { schedule_link: "main", precheck_minutes: null };
return { schedule_link: "precheck", precheck_minutes: minutes };
}
/**
* 2段目の行 → `ErrorReportItem`(指示書AW 2-4)。
*
* ★一覧(`errorReports()`)と PATCH の前後の読み取り(`errorReportById()`)がこの1本を使う★ —
* 同じ行の作り方を2箇所に書かない(片方だけ直すと、更新後に返す行と一覧の行が食い違う。AT 段階4-1 と同じ理由)。
* ★`precheckItemFromErrorRow()`(`lib/db/precheck-mapping.ts`)と同じ書き方で揃える★(日時・BigInt の扱い)。
*
* ★`status` が 0/1/2 以外なら例外★(`resolutionStatusFromRobotErrorStatus()`)— 一覧では1段目で外れているので来ない。
* 1件読み(`errorReportById()`)は呼ぶ前に `isKnownRobotErrorStatus()` で外して404(指示書BB U3。AW §11 Q3 を上書き)。
*
* ★エラー内容(指示書BD §3-2)★: `error_list_id` は DB の値のまま(`Number()`)、`error_list` は `errorLists`(★絞らずに引いた索引★)で解決、
* `error_list_selectable` は `isErrorListSelectable(error_type)`(`lib/db/error-list-source.ts` の1本)。
* ★索引に無い id は `error_list: null` だが `error_list_id` は値を保つ★(画面の「不明(id)」)。
*
* @param row エラー行(`ROBOT_ERROR_LIST_ROW_SELECT`)
* @param timetable `row.clean_timetable_id` で本表・履歴から引いた予定(無ければ `undefined`)
* @param errorLists `row.error_list_id` を含む `error_lists` の索引(`errorListIndex()` / `loadErrorListIndexByIds()`)
*/
export function errorReportItemFromRow(
row: ErrorReportRowSource,
timetable: ErrorTimetableRef | undefined,
errorLists: ErrorListIndex
): ErrorReportItem {
const link = errorScheduleLink(row.clean_timetable_id, timetable);
const timetableId = scheduledTimetableId(row.clean_timetable_id);
return {
// ★`error_id` を string のまま★(varchar(50)。D-105 項2)。★`Number()` を通さない★
id: row.error_id,
// ★`errored_at ?? created_at`(`detectedAtFromError()` に規則を閉じ込めてある)★
detected_at: jstIsoFromDb(detectedAtFromError(row)),
// ★BigInt を残すと `NextResponse.json` が例外を投げる★
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
// ★生値のまま★(`'0'` も・区分も作らない。D-077 / D-160 項3)
serial_number: row.serial_number,
error_type: row.error_type,
error_level: row.error_level,
// ★画面には出さない★(「予定不明」の調査用。D18)。`clean_timetables.id` は BigInt なので number にする。
// ★予定外(NULL・`0`)はどちらも `null`★(TeamWorks 回答 2026-10-27。`schedule_link: "none"` と揃える)
clean_timetable_id: timetableId === null ? null : Number(timetableId),
schedule_link: link.schedule_link,
precheck_minutes: link.precheck_minutes,
// ★アプリが書く列(4カラム+`error_list_id`。D-042 / D-184)。書くのは `lib/db/robot-error-write.ts`(指示書AT / BD)★
resolution_status: resolutionStatusFromRobotErrorStatus(row.status),
responded_at: row.responded_at ? jstIsoFromDb(row.responded_at) : null,
resolved_at: row.resolved_at ? jstIsoFromDb(row.resolved_at) : null,
note: row.comment,
// ★BigInt を残さない★(`facility_id` と同じく `Number()`)。null = 未選択
error_list_id: row.error_list_id === null ? null : Number(row.error_list_id),
error_list: resolveErrorList(row.error_list_id, errorLists),
error_list_selectable: isErrorListSelectable(row.error_type),
};
}
/**
* 1段目の `where`(★SQL 側の写し。絞り込みの「正」は `filterSortErrorReportKeys()`★。指示書AW 2-4)。
*
* - `status IN`: `errorReportStatusCodes()` から作る(★0/1/2 を直書きしない。D2★)
* - 施設・清掃エリアの可視性: `ERROR_ROW_VISIBLE_WHERE`(★削除済みの行を外す。指示書AX U8。正は `isVisibleErrorRow()`★)
* - `facility_id IN`: ★`!== null` で分岐する★(`null` = 絞らない / `[]` = 0件。罠1。`?.length` で書かない)
* - 期間: ★`dbDateRange()` の1箇所★(DB の壁時計値。9時間を足さない・引かない)。
* ★`errored_at` が NULL の行は `created_at` で絞る★(`OR`)— `errored_at` だけにかけると、
* `errored_at` が NULL の行(表1 E7)が期間指定で消える(表2 F6)
*
* ★`error_type` は SQL に降ろさない★(D9。`LIKE` は `%` `_` のエスケープと DB の照合順序で fixtures と結果が割れる)。
* ★`page` / `per_page` も降ろさない★(D8。並べ替えをアプリで行うため)。
*/
export function errorReportListWhere(query: ErrorReportDbQuery): Prisma.RobotErrorReportWhereInput {
const range = dbDateRange(query.date_from, query.date_to);
return {
status: { in: errorReportStatusCodes(query.include_resolved) },
...ERROR_ROW_VISIBLE_WHERE,
...(query.facility_ids !== null
? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
: {}),
...(range.gte || range.lt
? { OR: [{ errored_at: range }, { errored_at: null, created_at: range }] }
: {}),
};
}