/**
* GET /clean-logs と GET /clean-logs/export で共有するクエリ処理。
* 仕様の正は `d-watch-api.yaml` の /clean-logs。
*/
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 { MAX_DATE_RANGE_DAYS, PAGINATION } from "@/lib/config";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { toJstIso } from "@/lib/format/date";
import { includesFoldName, includesFoldPlain } from "@/lib/format/search-text";
import type { TableSortOrder } from "@/lib/table-sort";
import type { CleanLogItem, CleanLogSortKey, Grade, ScheduleFilter } from "@/lib/types";
/** `sort` の受け付ける値(★`d-watch-api.yaml` の enum と同じ並び★) */
export const CLEAN_LOG_SORT_KEYS: readonly CleanLogSortKey[] = [
"model_name",
"serial_number",
"scheduled_at",
"departed",
"task_status",
"completion_rate",
"grade",
];
/** `order` の受け付ける値(★YAML の enum と同じ並び★) */
export const CLEAN_LOG_SORT_ORDERS: readonly TableSortOrder[] = ["asc", "desc"];
export type ResultFilter = "problem" | "critical" | "all";
export interface CleanLogQuery {
dateFrom: Date | null;
dateTo: Date | null;
facilityName: string;
model: string;
serial: string;
result: ResultFilter;
/**
* ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
* ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
*
* ★`CleanLogDbQuery` には載せない★ — データ層へは
* ★施設IDの配列(`facility_ids`)に解決してから★ 渡す約束(指示書AK §2-2 / 段階3)。
* 施設名(`facilityName`)が `resolveFacilityIdsByName()` を通るのと同じ形。
*/
groupId: number | null;
/** 顧客(`customers.id`)で絞り込み。★`null` = 絞らない★ */
customerId: number | null;
page: number;
perPage: number;
/** 並べ替えの列。省略時は null(既定順。並べ替え関数は呼ばない) */
sort: CleanLogSortKey | null;
/** 並べ替えの向き。省略時は `"asc"`。`sort` が null のときは並びに使わない */
order: TableSortOrder;
/** 予定あり/予定外の絞り込み。★省略・空白は null=すべて★(指示書BH §12-2) */
schedule: ScheduleFilter | null;
}
/**
* YYYY-MM-DD を JST の 00:00 として解釈する。形式が違えば null。★存在しない日付(例 2026-02-31・2026-02-29)も null★(指示書BB A7・D-182)。
* `/dashboard` の `date` でも同じ規則を使うため export している(実装を二重に持たない)。
*/
export function parseDate(value: string | null): Date | null {
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const d = new Date(`${value}T00:00:00+09:00`);
if (Number.isNaN(d.getTime())) return null;
// ★存在しない日付(2026-02-31 は 03-03 になる)を弾く★ — JST の日付に戻して入力と一致するか(指示書BB A7・D-182)。
// ★`toJstIso()` で戻す(実行環境のタイムゾーンに依存しない)★
return toJstIso(d).slice(0, 10) === value ? d : null;
}
/**
* `parseDate()` で作った `Date` を、`CleanLogDbQuery` に渡す JST の `YYYY-MM-DD` に戻す
* (指示書AC 段階2 D-6 / ★§E-2★)。
*
* ★★生の `searchParams` の値を `where` に直接入れないための1段★★ —
* ★検証(形式・範囲)を通った値だけがデータ層に届くようにする★。
* ★`searchParams.get("date_from")` を `db.cleanLogs()` に渡さないこと★。
*
* ★`toJstIso()` を通す★ — `Date` は「真の瞬間」なので、`getFullYear()` 等の
* ★実行環境のタイムゾーンに依存する getter を使わないこと★(CI で日付が1日ずれる)。
*/
export function toDbQueryDate(date: Date | null): string | null {
return date ? toJstIso(date).slice(0, 10) : null;
}
function toInt(value: string | null, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isNaN(n) ? fallback : n;
}
/**
* クエリパラメータをパースする。
*
* ★group_id / customer_id を読む(指示書AK 段階2)★
* 検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
* ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
* ★このファイルには D-053 の「無視する」コメントが元から無かった★
* (YAML は最初から宣言していて、コードが黙って捨てていた)。
*
* page / per_page は不正値でも失敗させず、既定値・上限へ丸める。
*
* - ★`sort` / `order`: 空文字・空白だけは省略と同じ/不正値は 422(入力値をエコーしない)/
* `sort=scheduled_at&order=desc` は sort null に畳む/sort が null でも order は検証する★(指示書BH §11 R16)
* - ★`schedule`: 省略・空白は null(すべて)/`scheduled` / `unscheduled` のみ/不正値は 422★(指示書BH §12-2)
*/
export function parseCleanLogQuery(
searchParams: URLSearchParams
): QueryParseResult<CleanLogQuery> {
const dateFromRaw = searchParams.get("date_from");
const dateToRaw = searchParams.get("date_to");
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) {
if (dateFrom > dateTo) {
return fail(
422,
"validation_failed",
"date_fromはdate_to以前の日付を指定してください"
);
}
const diffDays =
Math.floor((dateTo.getTime() - dateFrom.getTime()) / 86_400_000) + 1;
if (diffDays > MAX_DATE_RANGE_DAYS) {
return fail(
422,
"validation_failed",
`date_from〜date_toは${MAX_DATE_RANGE_DAYS}日以内で指定してください`,
{ date_to: [`指定された範囲は${diffDays}日です`] }
);
}
}
const resultParam = (searchParams.get("result") ?? "all") as ResultFilter;
if (!["problem", "critical", "all"].includes(resultParam)) {
return fail(422, "validation_failed", "resultの値が不正です", {
result: ["problem / critical / all のいずれかを指定してください"],
});
}
const masters = parseMasterIdFilters(searchParams);
if (!masters.ok) return masters;
const sortRaw = searchParams.get("sort")?.trim() || null;
if (sortRaw !== null && !CLEAN_LOG_SORT_KEYS.includes(sortRaw as CleanLogSortKey)) {
return fail(422, "validation_failed", "sortの値が不正です", {
sort: [`${CLEAN_LOG_SORT_KEYS.join(" / ")} のいずれかを指定してください`],
});
}
const orderRaw = searchParams.get("order")?.trim() || null;
if (
orderRaw !== null &&
!CLEAN_LOG_SORT_ORDERS.includes(orderRaw as TableSortOrder)
) {
return fail(422, "validation_failed", "orderの値が不正です", {
order: [`${CLEAN_LOG_SORT_ORDERS.join(" / ")} のいずれかを指定してください`],
});
}
let sort: CleanLogSortKey | null = sortRaw as CleanLogSortKey | null;
const order: TableSortOrder =
(orderRaw as TableSortOrder | null) ?? "asc";
// ★日付列の desc は既定順と同じ意味★ — 422 にせず sort を null に畳む(指示書BH §2-1)
if (sort === "scheduled_at" && order === "desc") {
sort = null;
}
const schedule = parseScheduleFilter(searchParams);
if (!schedule.ok) return schedule;
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,
dateTo,
facilityName: searchParams.get("facility_name")?.trim() ?? "",
model: searchParams.get("model")?.trim() ?? "",
serial: searchParams.get("serial")?.trim() ?? "",
result: resultParam,
groupId: masters.query.groupId,
customerId: masters.query.customerId,
page,
perPage,
sort,
order,
schedule: schedule.query,
},
};
}
export function filterCleanLogs(
rows: CleanLogItem[],
query: CleanLogQuery
): CleanLogItem[] {
return rows.filter((r) => {
const scheduled = new Date(r.scheduled_at);
if (query.dateFrom && scheduled < query.dateFrom) return false;
if (query.dateTo) {
const end = new Date(query.dateTo.getTime() + 86_400_000);
if (scheduled >= end) return false;
}
// ★照合は `lib/format/search-text.ts` の1本に寄せる(指示書AG D-1)★
// 施設名だけ記号(中点・長音・空白・ハイフン類)を無視する = `includesFoldName`。
// 機種・シリアルは大文字小文字のみ = `includesFoldPlain`
// (記号を除くとシリアルの桁境界が消える。D-2。理由は search-text.ts 冒頭)。
if (
query.facilityName &&
!includesFoldName(r.facility_name, query.facilityName)
) {
return false;
}
// ★機種・シリアルが `null`(清掃エリアに有効なロボットがいない。指示書BB U2・D-182)の行は、絞り込みに当たらない★(表2 R7)
if (query.model && !(r.model_name !== null && includesFoldPlain(r.model_name, query.model))) {
return false;
}
if (query.serial && !(r.serial_number !== null && includesFoldPlain(r.serial_number, query.serial))) {
return false;
}
if (!matchesScheduleFilter(!r.unscheduled, query.schedule)) return false;
// ★結果待ち(D-173)は「問題あり」「×のみ」に出さない★(ユーザー判断 2026-10-07。失敗に数えない)。
// `grade` は "×" のまま(不変条件)なので、★旗を見ないと混ざる★。`all` には出す(画面で「結果待ち」と見える)
if (query.result !== "all" && r.result_pending) return false;
if (query.result === "problem" && !isProblemGrade(r.grade)) {
return false;
}
if (query.result === "critical" && r.grade !== "×") return false;
return true;
});
}
/**
* ★「問題あり」(△×)の規則★(指示書AV 段階4-3)。
*
* ★★書き写さず、この関数を呼ぶこと★★ — `filterCleanLogs()` の `result=problem` と
* ダッシュボードの `problem_logs`(`app/api/dashboard/route.ts`)が共有する。
* 片方だけ直すと、同じ「問題あり」が画面ごとに違う行を指す(例外もテスト失敗も出ない)。
* ★出発失敗の行も含まれる★ — 出発していない行は完了率が `null` で `judgeGrade()` が × を返すため。
* ★★結果待ち(`result_pending`。D-173)の行も `grade` は × なので、この関数だけでは除けない★★ —
* 呼び出し側で旗を見て外すこと(`filterCleanLogs()` は外している)。
* ★`critical`(× のみ)はここに含めない★(別の規則)。
*/
export function isProblemGrade(grade: Grade): boolean {
return grade === "△" || grade === "×";
}
/**
* `CleanLogItem[]` の `consecutive_problem_days` に★表示上限★をかける(指示書AF 段階6)。
* ★純粋関数★。
*
* ★★呼ぶ場所は `filterCleanLogs()` → `sortCleanLogs()` → ページ切り出しの**後**★★ —
* 絞り込みとページングは生値のまま行い、表示に載せる直前でだけ頭打ちにする。
*
* ★★`escalation_level` は触らない★★ — L1〜L3 は★上限をかける前の生値★で判定済み
* (`CLAUDE.md` §4.3 / 指示書AF 禁止事項9)。
*
* ★画面に出ないのに掛ける理由★(指示書AF 6-1):
* 「同じ施設・同じ日なのに清掃詳細ログとレポートで連続日数が違う」は実際に起きた不具合
* (D-109 項2 / D-103)。契約の片方だけ頭打ちにすると同じ形の食い違いが復活する。
*
* ★データソース(`lib/db/`)の中ではなくルート層から呼ぶ★ —
* `USE_FIXTURES` の両方で同じ挙動にするため(`CLAUDE.md` §9.5)。
*
* @param maxDays 表示上限(`loadConsecutiveDaysMax()` の戻り値を渡す。既定値を直書きしない)
*/
export function capCleanLogs(
items: readonly CleanLogItem[],
maxDays: number
): CleanLogItem[] {
return items.map((item) => {
const { days, capped } = capConsecutiveDays(item.consecutive_problem_days, maxDays);
return {
...item,
consecutive_problem_days: days,
consecutive_problem_days_capped: capped,
};
});
}