/**
* `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 { matchesScheduleFilter, parseScheduleFilter } from "@/lib/api/schedule-filter";
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 { TableSortOrder } from "@/lib/table-sort";
import type {
ErrorReportDbQuery,
ErrorReportSortKey,
ScheduleFilter,
PrecheckStatus,
} from "@/lib/types";
export const ERROR_REPORT_SORT_KEYS: readonly ErrorReportSortKey[] = [
"detected_at",
"serial_number",
"error_type",
"error_level",
"resolution_status",
"note",
];
const ERROR_REPORT_SORT_ORDERS: readonly TableSortOrder[] = ["asc", "desc"];
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;
/** 並べ替え列。★省略時 null(既定順)★(指示書BH) */
sort: ErrorReportSortKey | null;
/** 並べ替えの向き。★省略時 asc★ */
order: TableSortOrder;
/** 紐づく予定の絞り込み。★null = すべて★ */
schedule: ScheduleFilter | 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;
}
function compareCodepoint(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function applyDirection(primary: number, order: TableSortOrder): number {
if (primary === 0) return 0;
return order === "desc" ? -primary : primary;
}
/**
* 空の備考を下に固定(向きの反転の外。指示書BH §2-1)。
* 両方空なら 0(主キー比較に進ませず既定順へ任せる呼び出し側の前段)。
*/
function comparePinnedToBottom(aPinned: boolean, bPinned: boolean): number | null {
if (aPinned && bPinned) return 0;
if (aPinned) return 1;
if (bPinned) return -1;
return null;
}
function isEmptyNote(note: string | null): boolean {
return note === null || note.trim() === "";
}
/** ★desc は主キーだけ反転★。`reverse()` しない(§2-1) */
function compareErrorReportPrimary(
a: ErrorReportKey,
b: ErrorReportKey,
sort: ErrorReportSortKey
): number {
switch (sort) {
case "detected_at":
return Date.parse(a.detected_at) - Date.parse(b.detected_at);
case "serial_number":
return compareCodepoint(a.serial_number, b.serial_number);
case "error_type":
return compareCodepoint(a.error_type, b.error_type);
case "error_level":
return compareCodepoint(a.error_level, b.error_level);
case "resolution_status":
return a.status - b.status;
case "note": {
const aEmpty = isEmptyNote(a.note);
const bEmpty = isEmptyNote(b.note);
const pin = comparePinnedToBottom(aEmpty, bEmpty);
// 空どうしは pin=0(上で return 0)。片方空は pin≠0。両方非空だけ文字比較
if (pin !== null) return pin;
return compareCodepoint(a.note!.trim(), b.note!.trim());
}
default: {
const _exhaustive: never = sort;
return _exhaustive;
}
}
}
/**
* クエリパラメータをパースする。不正な値があれば `{ 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)
* - ★`sort` / `order` / `schedule`: 空文字・空白は省略と同じ/不正値は 422(入力値をエコーしない)/
* `sort=detected_at&order=desc` は sort null に畳む/sort が null でも order は検証する★(指示書BH §11 R16)
*
* ★エラーに入力値をエコーしない★(`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 sortRaw = searchParams.get("sort")?.trim() || null;
if (sortRaw !== null && !ERROR_REPORT_SORT_KEYS.includes(sortRaw as ErrorReportSortKey)) {
return fail(422, "validation_failed", "sortの値が不正です", {
sort: [`${ERROR_REPORT_SORT_KEYS.join(" / ")} のいずれかを指定してください`],
});
}
const orderRaw = searchParams.get("order")?.trim() || null;
if (orderRaw !== null && !ERROR_REPORT_SORT_ORDERS.includes(orderRaw as TableSortOrder)) {
return fail(422, "validation_failed", "orderの値が不正です", {
order: [`${ERROR_REPORT_SORT_ORDERS.join(" / ")} のいずれかを指定してください`],
});
}
const schedule = parseScheduleFilter(searchParams);
if (!schedule.ok) return schedule;
let sort: ErrorReportSortKey | null = sortRaw as ErrorReportSortKey | null;
const order: TableSortOrder = (orderRaw as TableSortOrder | null) ?? "asc";
// ★検知時刻列の desc は既定順と同じ意味★ — 422 にせず sort を null に畳む(指示書BH §2-1)
if (sort === "detected_at" && order === "desc") {
sort = null;
}
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,
sort,
order,
schedule: schedule.query,
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 / BH)。ページ分けは呼び出し側(データ層)。
*
* 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. `schedule` が `scheduled` / `unscheduled` なら `key.scheduled` で絞る(指示書BH §4)
* 6. `sort` が null なら `compareErrorReportKeys()`。指定時は主キー比較のあと同関数をタイブレークに使う
* (★入力の配列は書き換えない★。`filter` が新しい配列を返す)
*/
export function filterSortErrorReportKeys(
keys: readonly ErrorReportKey[],
query: ErrorReportDbQuery
): ErrorReportKey[] {
const codes = errorReportStatusCodes(query.include_resolved);
const filtered = 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;
if (!matchesScheduleFilter(key.scheduled, query.schedule)) return false;
return true;
});
if (query.sort === null) {
return [...filtered].sort(compareErrorReportKeys);
}
const sort = query.sort;
const order = query.order;
return [...filtered].sort((a, b) => {
const primary = compareErrorReportPrimary(a, b, sort);
// ★備考の空行は向きの反転の外★(指示書BH §2-1)
if (sort === "note" && primary !== 0 && (isEmptyNote(a.note) || isEmptyNote(b.note))) {
return primary;
}
const directed = applyDirection(primary, order);
if (directed !== 0) return directed;
return compareErrorReportKeys(a, b);
});
}