Newer
Older
client_dwatch / lib / error-reports / query.ts
/**
 * `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)。ページ分けは呼び出し側(データ層)。
 *
 * 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 (!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);
}