/**
* GET /clean-logs と GET /clean-logs/export で共有するクエリ処理。
* 仕様の正は `d-watch-api.yaml` の /clean-logs。
*/
import { parseMasterIdFilters } from "@/lib/api/master-id";
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 { CleanLogItem, Grade } from "@/lib/types";
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;
}
/**
* 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 は不正値でも失敗させず、既定値・上限へ丸める。
*/
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 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,
},
};
}
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;
}
// ★結果待ち(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()` → ページ切り出しの**後**★★ —
* 絞り込みとページングは生値のまま行い、表示に載せる直前でだけ頭打ちにする。
*
* ★★`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,
};
});
}