/**
* `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,
},
};
}