Newer
Older
client_dwatch / lib / clean-area / query.ts
/**
 * `GET /clean-area` のクエリ処理(指示書AN ★段階4★ / §7)。
 *
 * ★仕様の正は `d-watch-api.yaml` の `/clean-area`★(`CLAUDE.md` §6-1。★YAML を先に直した★)。
 *
 * ------------------------------------------------------------------------
 * ★★なぜルートに直接書かずにここへ置くのか★★
 * ------------------------------------------------------------------------
 * 一覧系APIのクエリ検証は ★各ドメインの `query.ts` の純粋関数★ に置くのが既存の作法
 * (`lib/clean-logs/query.ts` / `lib/prechecks/query.ts` / `lib/reports/query.ts`)。
 * ★検証規則がルートに散ると、どれか1本だけ緩い状態ができても誰も気づけません★
 * (`lib/api/master-id.ts` の冒頭と同じ理由)。
 *
 * ★★日付の上限(731日)を持たない★★(指示書AN §2-3)— ★`/clean-logs` との違いはここ★。
 *   `/clean-logs` は ★塊ごとに全走査 × 件数ぶん★ 読むので上限が要るが、
 *   こちらは ★1本の集計クエリ★ で、★返るのは粒度ごとの行数(最大でも施設数)★。
 *   ★期間を広げても応答の行数は増えません★。
 *   ★`MAX_DATE_RANGE_DAYS` をここに持ち込まないこと★。
 */
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { parseDate, toDbQueryDate } from "@/lib/clean-logs/query";
import type { CleanAreaScope } from "@/lib/types";

/**
 * `group_by` の受け付ける値(★`d-watch-api.yaml` の enum と同じ並び★)。
 *
 * ★★文字列をここ1箇所に置く★★ — 422 のメッセージも YAML の enum もこの配列から作る。
 *   ★`if (v === "group" || v === "customer" ...)` とルートに書き下さないこと★
 *   (増えたときに片方だけ直る)。
 */
export const CLEAN_AREA_SCOPES: readonly CleanAreaScope[] = [
  "group",
  "customer",
  "facility",
];

/** ★既定は施設単位★(`d-watch-api.yaml` の `default: facility`) */
export const CLEAN_AREA_SCOPE_DEFAULT: CleanAreaScope = "facility";

export interface CleanAreaQuery {
  /**
   * JST の `YYYY-MM-DD`。★未指定は `null`(=下限なし)★。
   * ★空文字も `null` に正規化する★ — `?date_from=` で送られてくる経路があり、
   *   ★`""` のまま下へ渡すと Prisma 版と fixtures 版で意味が割れます★
   *   (`lib/prechecks/query.ts` の実測記録)。
   */
  dateFrom: string | null;
  /** JST の `YYYY-MM-DD`。★未指定は `null`(=上限なし)★。★その日を含む★ */
  dateTo: string | null;
  /** 施設名(部分一致)。★未指定は空文字★(`null` にしない。`/clean-logs` と同じ) */
  facilityName: string;
  /**
   * ホールディングス(`groups.id`)。★`null` = 絞らない★。
   * ★`customerId` と同時指定なら AND★(`d-watch-api.yaml` の `GroupId`)。
   */
  groupId: number | null;
  /** 顧客(`customers.id`)。★`null` = 絞らない★ */
  customerId: number | null;
  /** 集計の粒度。★`total` はこれに依存しない★(指示書AN §5-3 B-5) */
  groupBy: CleanAreaScope;
}

/**
 * クエリパラメータを検証する★純粋関数★(DBアクセスを混ぜない。`CLAUDE.md` §9.4)。
 *
 * ★★`group_id` / `customer_id` の検証は `parseMasterIdFilters()` に任せる★★ —
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 *
 * ★★`date_from` > `date_to` は422★★ — 黙って0件にすると
 *   「データが無い」のか「入力が逆」なのか画面から区別できません
 *   (`parseCleanLogQuery()` と同じ文言にしてある。★APIごとに言い回しを変えない★)。
 */
export function parseCleanAreaQuery(
  searchParams: URLSearchParams
): QueryParseResult<CleanAreaQuery> {
  // ★空文字は「指定なし」★(`?date_to=` で送られてくる経路がある)
  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形式で指定してください"],
    });
  }
  // ★日数の上限は検証しない★(§2-3。★`/clean-logs` の731日をここへ持ち込まないこと★)
  if (dateFrom && dateTo && dateFrom > dateTo) {
    return fail(
      422,
      "validation_failed",
      "date_fromはdate_to以前の日付を指定してください"
    );
  }

  const groupByRaw = searchParams.get("group_by")?.trim() || null;
  if (groupByRaw !== null && !CLEAN_AREA_SCOPES.includes(groupByRaw as CleanAreaScope)) {
    return fail(422, "validation_failed", "group_byの値が不正です", {
      group_by: [`${CLEAN_AREA_SCOPES.join(" / ")} のいずれかを指定してください`],
    });
  }

  const masters = parseMasterIdFilters(searchParams);
  if (!masters.ok) return masters;

  return {
    ok: true,
    query: {
      // ★検証を通った値だけをデータ層へ渡す★(`toDbQueryDate()` の注記。指示書AC §E-2)—
      //   ★`searchParams.get("date_from")` を `db.cleanArea()` に直接渡さないこと★
      dateFrom: toDbQueryDate(dateFrom),
      dateTo: toDbQueryDate(dateTo),
      facilityName: searchParams.get("facility_name")?.trim() ?? "",
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      groupBy: (groupByRaw as CleanAreaScope | null) ?? CLEAN_AREA_SCOPE_DEFAULT,
    },
  };
}