/**
* GET /reports/* で使うクエリ処理。
* 仕様の正は `d-watch-api.yaml` の /reports/kpi・/reports/facility-performance・
* /reports/continuous-problems・/reports/error-breakdown。
* ソート順(同値のときの安定化キー)は D-052 と同じ規則。
*
* 構成順は `lib/ds-alerts/query.ts` に合わせている(定数 → 型 → private → parse → filter → sort)。
*/
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { ESCALATION_THRESHOLDS, PAGINATION, REPORT_YEAR } from "@/lib/config";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { isCurrentPeriod } from "@/lib/format/period";
import { includesFoldName, includesFoldPlain } from "@/lib/format/search-text";
import type {
ContinuousProblem,
ContinuousProblemRow,
ErrorBreakdownItem,
ErrorBreakdownRow,
FacilityPerformance,
FacilityPerformanceRow,
FacilityStatus,
Period,
ReportDbQuery,
ReportKpi,
ReportKpiDbQuery,
ReportKpiRow,
ReportPeriodKey,
} from "@/lib/types";
const PERIODS: readonly Period[] = ["weekly", "monthly", "yearly"];
/** YAML の status パラメータ(enum + all)。`FacilityStatus` の定義順に `all` を足したもの */
const FACILITY_STATUS_FILTERS: readonly (FacilityStatus | "all")[] = [
"good",
"warning",
"bad",
"no_data",
"all",
];
/**
* 西暦の下限・上限(YAML `Year` の minimum/maximum)。
* ★値そのものは `lib/config` の `REPORT_YEAR` が正★(`CLAUDE.md` §7 ハードコード禁止)。
* ここは `MONTH_MIN` / `WEEK_MIN` と読み方を揃えるための別名。
*
* ★範囲が無いと何が起きるか(2026-09-14 実測)★:
* `year=-1`(monthly)は ★200で空のKPI★、`year=-1` / `0` / `1000000`(weekly)は
* ★`addDays` の RangeError で500★、`year=99999999999999999999` は
* ★Prisma の 64bit 超過エラーで500★(SQLは出ないが、その値がサーバログに載る)。
* どれも入力の誤りなので422で弾く。理由は `REPORT_YEAR` のコメント。
*/
const YEAR_MIN = REPORT_YEAR.min;
const YEAR_MAX = REPORT_YEAR.max;
/**
* 月の下限・上限(YAML `Month` の minimum/maximum)。
* ★`period=weekly` / `monthly` のときの範囲★。
*/
const MONTH_MIN = 1;
const MONTH_MAX = 12;
/**
* ★`period=yearly` のときの `month` の唯一の正解★(YAML `Month`。指示書AQ 落とし穴6)。
*
* 年次のサマリは `month=0 AND week=0` で表される(CLAUDE.md §4.7)ので、内部表現もそれに揃える。
* ★`MONTH_MIN` を 0 に下げないこと★ — 下げると `period=monthly&month=0` が通ってしまい、
* ★月次の要求が年次のサマリ行に当たる★(`matchesPeriod()` は4項目の単純比較なので
* `period` が違えば当たらないが、当期判定やデータ層の `where` には 0 がそのまま流れる)。
*/
const MONTH_YEARLY = 0;
/**
* 週番号の下限・上限(YAML `Week` の minimum/maximum)。
* ★上限は 6。D-071(月跨ぎ週は月境界で分割する)の下では第6週が発生する★
* (1日が金曜または土曜の30日/31日月。2026年は5月と8月)。
* 理由は `lib/format/period.ts` 冒頭のコメント。
*
* ★下限は 1 のまま。`week=0` は先方のサマリで「月の集計値」を表す予約値なので通さない★
*/
const WEEK_MIN = 1;
const WEEK_MAX = 6;
/** limit の既定値(YAML の `limit: { default: 10 }`) */
const DEFAULT_LIMIT = 10;
export interface ReportQuery {
period: Period;
year: number;
/**
* ★period=yearly のときは 0★(年次は月を持たない。CLAUDE.md §4.7 の
* 「`month=0 AND week=0` が年次」に内部表現を揃えている)。
*
* ★weekly / monthly は 1〜12。`month=0` は 422★(`MONTH_MIN = 1`)。
* 0 にするのは yearly だけで、しかも ★yearly では 0 以外が 422★(対称にしてある)。
*/
month: number;
/**
* ★period=monthly / yearly のときは 0★(どちらも週を持たない。D-072)
*
* サマリ4テーブルの `week` が NOT NULL で「月次は 0」なので、内部表現も 0 に揃えている。
* ★`period=weekly` で `week=0` は 422★(`WEEK_MIN = 1`)。
* 0 にするのは monthly / yearly の読み捨てだけ。
*/
week: number;
/** 施設名(部分一致)。未指定は空文字 */
facilityName: string;
/** シリアル番号(部分一致)。未指定は空文字 */
serial: string;
status: FacilityStatus | "all";
/**
* ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
* ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
*
* ★`ReportDbQuery`(`lib/types/`)には載せない★ — 期間の4つとは違い、
* データ層へは ★施設IDの配列に解決してから★ 渡す約束(指示書AK §2-2 / 段階3)。
* `facilityName` がデータ層に渡らないのと同じ理由(`toReportDbQuery()` の注記)。
*/
groupId: number | null;
/** 顧客(`customers.id`)で絞り込み。★`null` = 絞らない★ */
customerId: number | null;
/** 上位N件。page / per_page ではなく limit を持つ(YAML /reports/* の meta の説明を参照) */
limit: 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;
}
/**
* 必須の整数パラメータ用。欠落・整数以外は null(呼び出し側が422にする)。
*
* `toInt()` と分けている理由: period/year/month/week は「不正なら既定値へ丸める」のではなく
* **422で弾く**必要がある(YAML で required / minimum / maximum を持つ)。
* `Number.parseInt("2026abc")` が 2026 を返してしまうため、正規表現で全体一致を要求する。
*/
function toIntStrict(value: string | null): number | null {
if (value === null || !/^-?\d+$/.test(value.trim())) return null;
const n = Number.parseInt(value.trim(), 10);
return Number.isNaN(n) ? null : n;
}
/**
* クエリパラメータをパースする。
* 不正な値があれば `{ ok: false }` を返す(`error` / `status` をそのまま422応答に使う。
* どのパラメータが不正かは `error.error.details` に入る)。
*
* ★group_id / customer_id を読む(指示書AK 段階2。D-053 の「受け取っても無視する」は解除。
* /clean-logs・/prechecks・/ds-alerts など他の一覧APIと同じ扱い)★
* 検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
* ★`toIntStrict()` で書き直さないこと★ — あれは `^-?\d+$`(符号あり・0も通る)で、
* `group_id=0`(センチネル。D-011)と `group_id=-1` を素通しさせてしまう。
*
* period / year / month(と weekly のときの week)は **必須なので不正値は422**。
* limit は不正値でも失敗させず、既定値・上限へ丸める(/clean-logs の per_page と同じ挙動)。
*/
export function parseReportQuery(
searchParams: URLSearchParams
): QueryParseResult<ReportQuery> {
const periodRaw = searchParams.get("period");
if (periodRaw === null || !PERIODS.includes(periodRaw as Period)) {
return fail(422, "validation_failed", "periodの値が不正です", {
period: [`${PERIODS.join(" / ")} のいずれかを指定してください`],
});
}
const period = periodRaw as Period;
const year = toIntStrict(searchParams.get("year"));
if (year === null) {
return fail(422, "validation_failed", "yearの値が不正です", {
year: ["西暦を整数で指定してください"],
});
}
// ★整数であることだけでは足りない★ — 範囲外の年は month / week と同じく422で弾く。
// `toIntStrict()` は `99999999999999999999` を 1e20 として通してしまうので、
// ★この比較がデータ層へ渡さない最後の関門★(詳細は `YEAR_MIN` のコメント)。
if (year < YEAR_MIN || year > YEAR_MAX) {
return fail(422, "validation_failed", "yearの値が不正です", {
year: [`${YEAR_MIN}〜${YEAR_MAX}で指定してください`],
});
}
// ★`month` の正解は period で変わる★(YAML `Month`。指示書AQ 落とし穴6)
// - weekly / monthly … 1〜12(従来どおり。★`month=0` は422のまま★)
// - yearly … ★0 だけ★(年次のサマリは `month=0 AND week=0`。CLAUDE.md §4.7)
// ★「yearly なら何でも通す」にしないこと★ — `period=yearly&month=9` が通ると
// ★年次の要求が月次の期間キーを持ったままデータ層に流れる★(fail-silent)。
const month = toIntStrict(searchParams.get("month"));
if (period === "yearly") {
if (month !== MONTH_YEARLY) {
return fail(422, "validation_failed", "monthの値が不正です", {
month: [`period=yearly のときは${MONTH_YEARLY}を指定してください`],
});
}
} else if (month === null || month < MONTH_MIN || month > MONTH_MAX) {
return fail(422, "validation_failed", "monthの値が不正です", {
month: [`${MONTH_MIN}〜${MONTH_MAX}で指定してください`],
});
}
// ★period=monthly / yearly のときの week は無視する(YAML `Week`「monthly / yearly のときは無視」)★
// 値が付いていてもエラーにせず読み捨て、★0(月次・年次を表す予約値。D-072)★にする。
// monthly は暦月で、yearly は暦年で締めるため週の指定に意味がない。
// ★API契約は変えていない。クエリで week=0 を要求する形にはしないこと★
// (年次も `week` は送らせない。`month=0` だけで年次と判る)
let week = 0;
if (period === "weekly") {
const weekRaw = toIntStrict(searchParams.get("week"));
if (weekRaw === null || weekRaw < WEEK_MIN || weekRaw > WEEK_MAX) {
return fail(422, "validation_failed", "weekの値が不正です", {
week: [
`period=weekly のときは${WEEK_MIN}〜${WEEK_MAX}で指定してください`,
],
});
}
week = weekRaw;
}
const statusRaw = searchParams.get("status") ?? "all";
if (!FACILITY_STATUS_FILTERS.includes(statusRaw as FacilityStatus | "all")) {
return fail(422, "validation_failed", "statusの値が不正です", {
status: [
`${FACILITY_STATUS_FILTERS.join(" / ")} のいずれかを指定してください`,
],
});
}
const masters = parseMasterIdFilters(searchParams);
if (!masters.ok) return masters;
const limit = Math.min(
PAGINATION.maxPerPage,
Math.max(1, toInt(searchParams.get("limit"), DEFAULT_LIMIT))
);
return {
ok: true,
query: {
period,
year,
month,
week,
facilityName: searchParams.get("facility_name")?.trim() ?? "",
serial: searchParams.get("serial")?.trim() ?? "",
status: statusRaw as FacilityStatus | "all",
groupId: masters.query.groupId,
customerId: masters.query.customerId,
limit,
},
};
}
/**
* `ReportQuery`(HTTP のクエリ全体)から `ReportDbQuery`(データ層に渡す期間の4つ)を切り出す
* (指示書AC ★段階5★。`toDbQueryDate()`(`lib/clean-logs/query.ts`)と同じ役目)。
*
* ★★ここを通すこと★★ — ★生の `searchParams` の値を `where` に入れない★(指示書AC §E-2)。
* `parseReportQuery()` が 422 で弾いたあとの値だけがデータ層に届く形にする。
*
* ★`facilityName` / `serial` / `status` / `limit` / `groupId` / `customerId` は渡さない★ —
* ★施設に関する条件(施設名・分類)は ★施設IDの配列に解決してから★ 渡す約束
* (指示書AK §2-2 / 段階3)。解決は非同期(DBを読む)なので、★この純粋関数の中では行わず
* ルート側で解決した結果を第2引数で受け取る★。
*
* @param facilityIds ★解決済みの施設ID★。★`null` = 施設で絞らない / `[]` = 0件★(★罠1★)。
* ★`/reports/kpi` は確定期間を `customer_summaries`(★施設ではなく**顧客**単位★)で
* 読むので絞り込みの軸が ★`customer_id`★ になる(段階6。`toReportKpiDbQuery()`)が、
* ★★当期(進行中の週・月)は詳細テーブルをその場集計するのでこの値を使う★★
* (指示書AL ★段階1★。★以前は `null` 固定で、当期だけ全社合算になっていた★)。
*/
export function toReportDbQuery(
query: ReportQuery,
facilityIds: number[] | null = null
): ReportDbQuery {
return {
period: query.period,
year: query.year,
month: query.month,
week: query.week,
facility_ids: facilityIds,
};
}
/**
* `ReportQuery` + ★決まった対象顧客★ → `ReportKpiDbQuery`(指示書AK ★段階6★)。
*
* ★★`customerId` は `parsed.query.customerId` ではない★★ — ★`resolveTargetCustomer()`
* (`lib/reports/target-customer.ts`)が決めた値★を渡すこと。
* ★クエリの `customer_id` をそのまま渡すと、未指定(`null`)の経路が型で弾かれず、
* 「顧客が決まらないまま全顧客を読む」に戻ります★(§2-1 の決定の要)。
*
* ------------------------------------------------------------------------
* ★★`facility_ids` は当期(進行中の週・月)のためにある★★(指示書AL ★段階1★)
* ------------------------------------------------------------------------
* ★確定期間は `customer_summaries` を `customer_id` で絞る★(段階6)が、
* ★★当期は詳細テーブル(`clean_timetables`)をその場集計するので軸が**施設**になる★★。
* ★2つの軸は別経路だが、同じ顧客を指していなければならない★ので、
* ★★`facilityIds` は `customerId`(=`resolveTargetCustomer()` が決めた顧客)から
* 解決したものを渡すこと★★ — ★リクエストの `group_id` から直接引かないこと★
* (母集団が確定期間とずれる。指示書AL §2-3 / 落とし穴1)。
*
* ★★`null` と `[]` を取り違えないこと★★(★罠1★)— `null` = 施設で絞らない /
* `[]` = 0件ヒット。★存在しない顧客を指定したときは `[]` になり、当期が空になる★
* (期待値表 1-3。★ここが `null` に化けると全社の数字が出ます★)。
*
* ★既定値を持たせていない★ — ★省略できると「絞り忘れ」が型で通ってしまう★ため、
* 呼び出し側に必ず書かせる(`toReportDbQuery()` の第2引数とはそこが違う)。
*/
export function toReportKpiDbQuery(
query: ReportQuery,
customerId: number,
facilityIds: number[] | null
): ReportKpiDbQuery {
return {
...toReportDbQuery(query, facilityIds),
customer_id: customerId,
};
}
/** status のみで絞り込む。all は素通し */
export function filterFacilityPerformanceByStatus(
items: FacilityPerformance[],
status: FacilityStatus | "all"
): FacilityPerformance[] {
if (status === "all") return items;
return items.filter((item) => item.status === status);
}
/**
* 施設名の部分一致。空文字(未指定)は素通し(/clean-logs の facility_name と同じ規則)。
*
* ★照合は `lib/format/search-text.ts` の1本に寄せる(指示書AG D-1)★
* 記号(中点・長音・空白・ハイフン類)と大文字小文字を無視する。
* `ドンキホーテ` と打って `ドン・キホーテ戸塚` を引けるようにするため。
*/
export function filterFacilityPerformanceByName(
items: FacilityPerformance[],
facilityName: string
): FacilityPerformance[] {
const keyword = facilityName.trim();
if (keyword === "") return items;
return items.filter((item) => includesFoldName(item.facility_name, keyword));
}
/**
* シリアル番号の部分一致。空文字(未指定)は素通し。
*
* ★大文字小文字だけを無視する(記号は無視しない)★
* シリアルの記号は桁の境界なので、除くと `10001` が `PUDU-CC1-0001` に誤ヒットする
* (D-2。実例は `lib/format/search-text.ts` の冒頭)。施設名と揃っていないのは意図的。
*/
export function filterFacilityPerformanceBySerial(
items: FacilityPerformance[],
serial: string
): FacilityPerformance[] {
const keyword = serial.trim();
if (keyword === "") return items;
return items.filter((item) => includesFoldPlain(item.serial_number, keyword));
}
/**
* 稼働率(`operation_rate.value`)の昇順 = ワースト順(YAML /reports/facility-performance)。
* 同値のときは facility_id の昇順で安定させる(D-052と同じ規則)。
*
* ★`value` が null の行は末尾に置く★
* null は「清掃予定が1件もなかった期間」(分母0。`KpiValue` のコメント)であり、
* 「稼働率0%」とは意味が違う。昇順の先頭に来ると、実際に稼働できていない施設より
* 「そもそも予定が無い施設」が上に並び、ワースト順の一覧として読めなくなる。
* 降順に読み替える余地もないので、評価対象外として末尾へ寄せる。
*/
export function sortFacilityPerformance(
items: FacilityPerformance[]
): FacilityPerformance[] {
return [...items].sort((a, b) => {
const aRate = a.operation_rate.value;
const bRate = b.operation_rate.value;
if (aRate === null || bRate === null) {
// 片方だけ null なら null を後ろへ。両方 null なら facility_id で安定させる
if (aRate !== null) return -1;
if (bRate !== null) return 1;
return a.facility_id - b.facility_id;
}
if (aRate !== bRate) return aRate - bRate;
return a.facility_id - b.facility_id;
});
}
/**
* `FacilityPerformance[]` の `consecutive_problem_days` に★表示上限★をかける
* (指示書AF 段階6)。★純粋関数★。
*
* ★★呼ぶ場所は絞り込み・並べ替えの**後**★★(`sortFacilityPerformance()` は稼働率で
* 並べるので順序は潰れないが、「表示に載せる直前でだけ頭打ちにする」規律を
* `capCleanLogs()` / `capContinuousProblems()` と揃える)。
*
* ★★判定には一切かからない★★ — `status`(良好/注意/要改善)も L1〜L3 も
* ★上限をかける前の生値★で判定済み(`CLAUDE.md` §4.3 / 指示書AF 禁止事項9)。
*
* ★画面に出ないのに掛ける理由★: 契約の片方だけ頭打ちにすると、
* 「同じ施設・同じ日なのに画面ごとに連続日数が違う」(D-109 項2 / D-103)が復活する。
*
* @param maxDays 表示上限(`loadConsecutiveDaysMax()` の戻り値を渡す。既定値を直書きしない)
*/
export function capFacilityPerformance(
items: readonly FacilityPerformance[],
maxDays: number
): FacilityPerformance[] {
return items.map((item) => {
const { days, capped } = capConsecutiveDays(item.consecutive_problem_days, maxDays);
return {
...item,
consecutive_problem_days: days,
consecutive_problem_days_capped: capped,
};
});
}
/**
* 連続問題施設は2日以上のみ返す(YAML /reports/continuous-problems。L1(初回)は含めない)。
* 閾値は `lib/config` の `ESCALATION_THRESHOLDS.level2Days`(CLAUDE.md §7 ハードコード禁止)。
*/
export function filterContinuousProblems(
items: ContinuousProblem[]
): ContinuousProblem[] {
return items.filter(
(item) => item.consecutive_problem_days >= ESCALATION_THRESHOLDS.level2Days
);
}
/** 連続日数の降順。同値のときは facility_id の昇順で安定させる(D-052と同じ規則) */
export function sortContinuousProblems(
items: ContinuousProblem[]
): ContinuousProblem[] {
return [...items].sort((a, b) => {
if (a.consecutive_problem_days !== b.consecutive_problem_days) {
return b.consecutive_problem_days - a.consecutive_problem_days;
}
return a.facility_id - b.facility_id;
});
}
/**
* 件数の降順。★同値のときは type(生値)の文字列昇順★ で安定させる。
*
* ★localeCompare を使わない★ — ロケール依存を持ち込まないため。
* `error_type` は PUDU 由来の ASCII 識別子(`EmergencyStop` 等)で、
* 単純な `<` 比較で決定的な順序が得られる。**画面の順序がリロードで変わらないことが目的**で、
* 「人間にとって自然な並び」を作ることは目的ではない。
*/
export function sortErrorBreakdown(
items: ErrorBreakdownItem[]
): ErrorBreakdownItem[] {
return [...items].sort((a, b) => {
if (a.count !== b.count) return b.count - a.count;
if (a.type === b.type) return 0;
return a.type < b.type ? -1 : 1;
});
}
/**
* 行の期間キーが要求された期間と一致するか。
*
* ★monthly / yearly も `week` を 0 で持つ(D-072)ので、period による場合分けは不要★
* `parseReportQuery()` が monthly / yearly のとき `week = 0` を、yearly ではさらに
* `month = 0` を入れ、fixtures / サマリの月次行も `week: 0`、年次行は `month: 0, week: 0`
* (CLAUDE.md §4.7)なので、★4項目の単純比較のまま年次も一致する★。
*
* ★`row.period` の一致も条件に入っている★ — サマリ行の `period` を決めるのは
* `periodOfSummaryRow()`(`lib/db/report-mapping.ts`)。★そちらが年次行を `monthly` と
* 判定していると、ここでは弾けずに静かに混ざる★(指示書AQ 落とし穴2)。
*/
function matchesPeriod(row: ReportPeriodKey, query: ReportQuery): boolean {
return (
row.period === query.period &&
row.year === query.year &&
row.month === query.month &&
row.week === query.week
);
}
/**
* `facilityPerformance()` / `continuousProblems()` / `errorBreakdown()` が返す全期間の行から、
* 要求された期間に属する項目だけを取り出す(1期間に複数件あるのが通常。KPIの1行1期間とは違う)。
* 該当が無ければ空配列(当期でまだ行が無い場合や、対象期間のデータが無い場合と区別しない。
* 一覧が0件なのは呼び出し側にとって404にする理由にならないため。selectKpiRowとは事情が違う)。
*/
export function selectFacilityPerformanceRows(
rows: FacilityPerformanceRow[],
query: ReportQuery
): FacilityPerformance[] {
return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}
export function selectContinuousProblemRows(
rows: ContinuousProblemRow[],
query: ReportQuery
): ContinuousProblem[] {
return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}
export function selectErrorBreakdownRows(
rows: ErrorBreakdownRow[],
query: ReportQuery
): ErrorBreakdownItem[] {
return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}
/**
* `reportKpis()` が返す全期間の行から、要求された期間の行を選ぶ。該当が無ければ null。
*
* ★当期かどうかで集計方法を変える処理はここに書かないこと★
* 当期(進行中の週・月)はサマリテーブルに存在せず、詳細テーブルからその場集計する
* のが最終形(CLAUDE.md §4.7 / D-023)。ただし `DataSource` は引数を取らず全行を返す契約
* (`lib/db/index.ts`)なので、行の選び方は確定期間と当期で同じにしてある。
* 当期判定は「実DB接続時にその場集計へ差し替える分岐点」を示す器としてだけ置いている。
*/
export function selectKpiRow(
rows: ReportKpiRow[],
query: ReportQuery
): ReportKpi | null {
const matched = rows.find((row) => matchesPeriod(row, query));
if (matched) return matched.kpi;
if (isCurrentPeriod(query.period, query.year, query.month, query.week)) {
// 当期はサマリに行が無いのが正常。★ここで集計方法を変えない(器だけ)★
// 実DB接続時は、この分岐を「詳細テーブルからその場集計」に差し替える。
return null;
}
// 確定期間で行が無い = その期間のデータがまだ無い(不正な期間ではない)。
// 404 にせず null を返し、ルート側で空のKPIを組み立てる(指示書F 4-1)。
return null;
}