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 { 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);
  });
}