/**
* `GET /errors`(エラー一覧。指示書AW)で使うクエリ処理(純粋関数のみ)。
*
* ★絞り込みの「正」は `filterSortErrorReportKeys()`★ — SQL 側(`errorReportListWhere()`。`lib/db/error-report-mapping.ts`)は
* その写し(指示書AC C-5 と同じ考え方)。★fixtures と Prisma は同じこの関数を通す★ので `USE_FIXTURES` で件数が変わらない。
* ★並べ替えとページ分けはアプリ側★(指示書AW D8)— 検知時刻(`errored_at ?? created_at`)の順は Prisma のクエリビルダで書けない。
*
* テストは `lib/error-reports/__tests__/query.test.ts`(表1 の並び順・表2)。
*/
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { parseDate } from "@/lib/clean-logs/query";
import { PAGINATION } from "@/lib/config";
import type { ErrorReportKey } from "@/lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { includesFoldPlain } from "@/lib/format/search-text";
import type { ErrorReportDbQuery, PrecheckStatus } from "@/lib/types";
export interface ErrorReportQuery {
/** JST の `YYYY-MM-DD`(検知時刻の日付)。★空文字は `null` に正規化★(`parsePrecheckQuery()` の注記と同じ理由) */
dateFrom: string | null;
/** JST の `YYYY-MM-DD`(★その日を含む★)。★空文字は `null`★ */
dateTo: string | null;
/** false = 未解消・対応中だけ(既定)/ true = 解消も含める */
includeResolved: boolean;
/** 施設名の部分一致(trim 済み)。★`""` = 絞らない★ */
facilityName: string;
/** 種類の部分一致(trim 済み)。★`""` = 絞らない★ */
errorType: string;
/** ホールディングス(`groups.id`)。★`null` = 絞らない★ */
groupId: number | null;
/** 顧客(`customers.id`)。★`null` = 絞らない★ */
customerId: number | null;
page: number;
perPage: number;
}
function toInt(value: string | null, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isNaN(n) ? fallback : n;
}
/**
* クエリパラメータをパースする。不正な値があれば `{ ok: false }`(422)を返す。
*
* - 日付の形式は `parseDate()`(`lib/clean-logs/query.ts`)。★`date_from > date_to` は 422(表2 F7)★。
* 文言は `parseCleanLogQuery()` と同じ。★期間の日数の上限は設けない★(`/prechecks` と同じ。§11 Q13)
* - `include_resolved`: ★無い・空・`"false"` → false、`"true"` → true、それ以外 → 422★(表2 F3。黙って false に倒さない)
* - 分類は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる(★`Number.parseInt()` で書き直さない。D-123★)
* - `page` / `per_page` は不正値でも失敗させず、既定値・上限へ丸める(`parsePrecheckQuery()` と同じ)
* - `facility_name` / `error_type` は前後の空白を除く(空白だけ → `""` = 絞らない。表2 F5)
*
* ★エラーに入力値をエコーしない★(`parseMasterIdParam()` と同じ理由)。
*/
export function parseErrorReportQuery(
searchParams: URLSearchParams
): QueryParseResult<ErrorReportQuery> {
// ★★空文字は「指定なし」として `null` に正規化する★★(`parsePrecheckQuery()` の注記と同じ理由)—
// `""` のまま下へ渡すと、Prisma 版(`dbDateRange("")` → 条件なし)と
// fixtures 版(文字列比較で全行除外)で件数が割れる(指示書AC 禁止事項6)
const dateFromRaw = searchParams.get("date_from") || null;
const dateToRaw = searchParams.get("date_to") || null;
const dateFrom = parseDate(dateFromRaw);
const dateTo = parseDate(dateToRaw);
if (dateFromRaw && !dateFrom) {
return fail(422, "validation_failed", "date_fromの形式が不正です", {
date_from: ["YYYY-MM-DD形式で指定してください"],
});
}
if (dateToRaw && !dateTo) {
return fail(422, "validation_failed", "date_toの形式が不正です", {
date_to: ["YYYY-MM-DD形式で指定してください"],
});
}
if (dateFrom && dateTo && dateFrom > dateTo) {
return fail(422, "validation_failed", "date_fromはdate_to以前の日付を指定してください");
}
const includeResolvedRaw = searchParams.get("include_resolved") ?? "";
if (includeResolvedRaw !== "" && includeResolvedRaw !== "true" && includeResolvedRaw !== "false") {
return fail(422, "validation_failed", "include_resolvedの値が不正です", {
include_resolved: ["true / false のいずれかを指定してください"],
});
}
const masters = parseMasterIdFilters(searchParams);
if (!masters.ok) return masters;
const page = Math.max(1, toInt(searchParams.get("page"), 1));
const perPage = Math.min(
PAGINATION.maxPerPage,
Math.max(1, toInt(searchParams.get("per_page"), PAGINATION.defaultPerPage))
);
return {
ok: true,
query: {
dateFrom: dateFromRaw,
dateTo: dateToRaw,
includeResolved: includeResolvedRaw === "true",
facilityName: searchParams.get("facility_name")?.trim() ?? "",
errorType: searchParams.get("error_type")?.trim() ?? "",
groupId: masters.query.groupId,
customerId: masters.query.customerId,
page,
perPage,
},
};
}
/** 既定(解消を含めない)で出す対応状況 */
const OPEN_STATUSES: readonly PrecheckStatus[] = ["unresolved", "in_progress"];
/** 「解消済みも表示」で出す対応状況 */
const ALL_STATUSES: readonly PrecheckStatus[] = ["unresolved", "in_progress", "resolved"];
/**
* 一覧に出す `robot_error_reports.status` のコード(指示書AW D1 / D2)。
*
* ★0/1/2 を直書きしない★ — 変換は `robotErrorStatusFromResolutionStatus()`(`lib/db/precheck-mapping.ts`)の1本。
* SQL 側(`errorReportListWhere()`)も純粋関数側(`filterSortErrorReportKeys()`)もここから作る。
*/
export function errorReportStatusCodes(includeResolved: boolean): number[] {
return (includeResolved ? ALL_STATUSES : OPEN_STATUSES).map(robotErrorStatusFromResolutionStatus);
}
/**
* 並び順(指示書AW D7): ★検知時刻の降順、同時刻は `error_id` の昇順(文字列の辞書順)★。
*
* ★`localeCompare` を使わない★ — 実行環境のロケールで順が変わる。`<` / `>`(コードポイント順)で比べる。
* ★`error_id` は数値である保証が無い★(varchar(50)。D-105 項2)ので数値順にはしない(`"E-10"` が `"E-9"` より先)。
* 検知時刻は `Date.parse` の差で比べる(`+09:00` 付きの ISO。`sortPrechecks()` と同じ並び)。
*/
export function compareErrorReportKeys(a: ErrorReportKey, b: ErrorReportKey): number {
const diff = Date.parse(b.detected_at) - Date.parse(a.detected_at);
if (diff !== 0) return diff;
return a.error_id < b.error_id ? -1 : a.error_id > b.error_id ? 1 : 0;
}
/**
* ★絞り込みの「正」★ → 並べ替え(指示書AW 2-5)。ページ分けは呼び出し側(データ層)。
*
* 0. ★`visible` が false の行(削除済みの施設・清掃エリア)は外す★(指示書AX U8。`meta.total` にも数えない)
* 1. `status` が `errorReportStatusCodes(include_resolved)` に入る(★生の数値で比べる★ — 0/1/2 以外の行は
* 例外にせず外れる。表2 F12)
* 2. `facility_ids !== null` なら `facility_id` が入る(★`[]` は0件。罠1★)
* 3. ★`detected_at` の先頭10文字(JST の日付)★ が `date_from` 以上・`date_to` 以下(★文字列で比べる★。
* `filterFixturePrechecks()` と同じ)— `errored_at` が NULL の行は `created_at` の日付で当たる(表2 F6)
* 4. `error_type !== ""` なら `includesFoldPlain()`(部分一致・大小文字を区別しない。D9)
* 5. `compareErrorReportKeys()` で並べる(★入力の配列は書き換えない★。`filter` が新しい配列を返す)
*/
export function filterSortErrorReportKeys(
keys: readonly ErrorReportKey[],
query: ErrorReportDbQuery
): ErrorReportKey[] {
const codes = errorReportStatusCodes(query.include_resolved);
return keys
.filter((key) => {
if (!key.visible) return false;
if (!codes.includes(key.status)) return false;
if (query.facility_ids !== null && !query.facility_ids.includes(key.facility_id)) return false;
const day = key.detected_at.slice(0, 10);
if (query.date_from !== null && day < query.date_from) return false;
if (query.date_to !== null && day > query.date_to) return false;
if (query.error_type !== "" && !includesFoldPlain(key.error_type, query.error_type)) return false;
return true;
})
.sort(compareErrorReportKeys);
}