Newer
Older
client_dwatch / lib / prechecks / query.ts
/**
 * GET /prechecks で使うクエリ処理。
 * 仕様の正は `d-watch-api.yaml` の /prechecks。ソート順は D-052、status=open の意味は D-054。
 */
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 { PrecheckItem, PrecheckStatus, PrecheckStatusFilter } from "@/lib/types";

const STATUS_FILTERS: readonly PrecheckStatusFilter[] = [
  "open",
  "unresolved",
  "in_progress",
  "resolved",
  "all",
];

export interface PrecheckQuery {
  /**
   * YYYY-MM-DD。scheduled_at の先頭10文字と文字列比較する(TZに依存しない)。
   * ★★指定なしは必ず `null`(★空文字を入れないこと★。`parsePrecheckQuery()` の注記)★★
   */
  dateFrom: string | null;
  dateTo: string | null;
  status: PrecheckStatusFilter;
  /**
   * ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
   * ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
   */
  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 }` を返す(`error` / `status` をそのまま422応答に使う。
 * どのパラメータが不正かは `error.error.details` に入る)。
 * ★group_id / customer_id を読む(指示書AK 段階2。D-053 の「受け取っても無視する」は解除)★
 *   検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 *
 * page / per_page は不正値でも失敗させず、既定値・上限へ丸める(/clean-logs と同じ挙動)。
 */
export function parsePrecheckQuery(
  searchParams: URLSearchParams
): QueryParseResult<PrecheckQuery> {
  // ★★空文字は「指定なし」として `null` に正規化する★★(`?date_to=` のように
  //   値だけ空で送られてくる経路がある)。
  //   ★`""` のまま下へ渡すと `db.prechecks()` の実装で意味が割れます★:
  //     - Prisma 版  … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
  //     - fixtures 版 … `date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★
  //   ★★`USE_FIXTURES` の true/false で件数が変わる★★(指示書AC 禁止事項6)。
  //   ★例外もテスト失敗も出ないので、入口で潰しておくこと★
  //   (`filterPrechecksByDate()` の truthy 判定とも意味が揃う)。
  const dateFromRaw = searchParams.get("date_from") || null;
  const dateToRaw = searchParams.get("date_to") || null;
  if (dateFromRaw && !parseDate(dateFromRaw)) {
    return fail(422, "validation_failed", "date_fromの形式が不正です", {
      date_from: ["YYYY-MM-DD形式で指定してください"],
    });
  }
  if (dateToRaw && !parseDate(dateToRaw)) {
    return fail(422, "validation_failed", "date_toの形式が不正です", {
      date_to: ["YYYY-MM-DD形式で指定してください"],
    });
  }

  const statusRaw = searchParams.get("status") ?? "open";
  if (!STATUS_FILTERS.includes(statusRaw as PrecheckStatusFilter)) {
    return fail(422, "validation_failed", "statusの値が不正です", {
      status: [`${STATUS_FILTERS.join(" / ")} のいずれかを指定してください`],
    });
  }

  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,
      status: statusRaw as PrecheckStatusFilter,
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      page,
      perPage,
    },
  };
}

/**
 * 日付範囲のみで絞り込む。status では絞らない。
 *
 * ★summary はこの関数の結果に対して計算する(status フィルタの前)★
 *   D-054: summary が対応状況フィルタに追従すると、既定表示(status=open)で
 *   解消済みカードが常に0になり、サマリーの役目を果たさなくなる
 *
 * 日付は scheduled_at の先頭10文字で比較する(ISO8601 +09:00 なので
 * 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない。
 * app/api/dashboard/route.ts と同じやり方)
 */
export function filterPrechecksByDate(
  items: PrecheckItem[],
  query: PrecheckQuery
): PrecheckItem[] {
  return items.filter((item) => {
    const day = item.scheduled_at.slice(0, 10);
    if (query.dateFrom && day < query.dateFrom) return false;
    if (query.dateTo && day > query.dateTo) return false;
    return true;
  });
}

/** 対応状況のみで絞り込む。一覧(items)はこの結果を使う。summary には使わない(D-054) */
export function filterPrechecksByStatus(
  items: PrecheckItem[],
  status: PrecheckStatusFilter
): PrecheckItem[] {
  return items.filter((item) => {
    switch (status) {
      case "unresolved":
        return item.resolution_status === "unresolved";
      case "in_progress":
        return item.resolution_status === "in_progress";
      case "resolved":
        return item.resolution_status === "resolved";
      case "open":
        return item.resolution_status !== "resolved";
      case "all":
        return true;
    }
  });
}

/**
 * detected_at の降順。同値のときは id の昇順で安定させる(D-052)
 *
 * ★`id` は `string`(D-070 1-2 / D-105)なので数値減算ではなく文字列比較で並べる★。
 *   D-052 の契約(detected_at 降順 / 同値は id 昇順)は変えていない。
 *   ★辞書順なので `"10" < "9"` になる★が、`id` は `error_id`(`varchar(50)`)由来で
 *   数値である保証がなく、数値としての昇順は定義できない。安定した順序であることが要件。
 */
export function sortPrechecks(items: PrecheckItem[]): PrecheckItem[] {
  return [...items].sort((a, b) => {
    if (a.detected_at !== b.detected_at) {
      return a.detected_at < b.detected_at ? 1 : -1;
    }
    if (a.id === b.id) return 0;
    return a.id < b.id ? -1 : 1;
  });
}

export interface PrecheckSummary {
  /** 解消していない件数(未解消+対応中)。resolved との合計が total になる(D-054) */
  unresolved: number;
  resolved: number;
  total: number;
}

export function summarizePrechecks(items: PrecheckItem[]): PrecheckSummary {
  const resolved = items.filter(
    (item) => item.resolution_status === "resolved"
  ).length;
  const total = items.length;
  return { unresolved: total - resolved, resolved, total };
}

/**
 * 1行の対応状況が `before` → `after` に変わったときのサマリを、差分だけ反映して返す(指示書AT §3-8)。
 *
 * 画面(`/alerts`)は更新に成功しても一覧を取り直さない(その1行だけ差し替える)ので、
 * サマリのカードもここで合わせる。★`unresolved` は「未解消+対応中」★(D-054)なので、
 * 未解消↔対応中では動かない。解消に入る・出るときだけ `unresolved` と `resolved` が1ずつ動く。
 * ★`total` は不変★。元のオブジェクトは書き換えない。
 */
export function applyResolutionChange(
  summary: PrecheckSummary,
  before: PrecheckStatus,
  after: PrecheckStatus
): PrecheckSummary {
  const wasResolved = before === "resolved";
  const isResolved = after === "resolved";
  if (wasResolved === isResolved) return { ...summary };
  const delta = isResolved ? 1 : -1;
  return {
    unresolved: summary.unresolved - delta,
    resolved: summary.resolved + delta,
    total: summary.total,
  };
}