/**
* GET /dashboard — ダッシュボード(清掃結果サマリ / スケジュール進捗 / セキュリティバナー)
*
* 仕様の正は `d-watch-api.yaml` の /dashboard、各値の定義は `docs/decisions.md` D-049 です。
* 形は `app/api/clean-logs/route.ts`(動く見本)に合わせています。
*
* ★`failure_breakdown` は返しません(D-048)★
* 4分類の分類元(エラーテーブル)が未定義のため M3 送りです。
* 空配列ではなく**プロパティごと省略**します。空配列は「当日の失敗0件」を意味するため、
* 未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。
*
* ★`date` を省略すると「直近モード」★(D-159 / 指示書AV)—
* `summary` と `problem_logs` を ★清掃エリアごとの直近の清掃(7日以内・終わった予定のみ・出発失敗を含む)★
* から作ります。選び方は `pickLatestRunPerSection()`(`lib/dashboard/latest-run.ts`。純粋関数)。
* ★スケジュール進捗・要レスキュー・セキュリティバナー・`data.anomaly` は省略時も当日のまま★(D-049 / 指示書AV 4-1)。
*
* ★コンパクト版清掃詳細ログは `problem_logs` で返します★(D-159 / 指示書AV §2 D7。旧: GET /clean-logs を再利用)—
* ★数値カードと表を同じ `now`・同じ行の集合から作るため★。別の API にすると2本のリクエストが
* 別々に「現在」を取るので、境界(予定の終了ちょうど付近)でカードと表の母集団がずれうる。
*
* ★結果待ち(`result_pending`。ユーザー判断 2026-10-07。D-173)★ —
* `summary.pending` に数え、★`problem_logs` には出さない★(表2 C5・C7)。
* 直近モードの候補は「予定の終了 ≤ 現在」(C6。旧: 終了+30分)。
*/
import { NextResponse } from "next/server";
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { capCleanLogs, isProblemGrade, parseDate } from "@/lib/clean-logs/query";
import { CONSECUTIVE_DAYS_DISPLAY, DASHBOARD_LATEST_RUN } from "@/lib/config";
import {
computeScheduleProgress,
computeSecurityBanner,
computeSummary,
} from "@/lib/dashboard/aggregate";
import { latestRunWindow, pickLatestRunPerSection } from "@/lib/dashboard/latest-run";
import { db } from "@/lib/db";
import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import { toJstIso } from "@/lib/format/date";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type {
ApiError,
CleanLogDbQuery,
CleanLogItem,
CleanLogRunMeta,
DashboardResponse,
DsAlert,
RescueRequiredItem,
Schedule,
} from "@/lib/types";
const ROUTE = "/api/dashboard";
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const dateRaw = searchParams.get("date");
if (dateRaw && !parseDate(dateRaw)) {
const body: ApiError = {
error: {
code: "validation_failed",
message: "dateの形式が不正です",
details: { date: ["YYYY-MM-DD形式で指定してください"] },
},
};
return NextResponse.json(body, { status: 422 });
}
// ★★「現在」はここで1回だけ作る★★(指示書AV 4-5)— `today`・直近モードの窓・
// `pickLatestRunPerSection()` に ★同じ `now`★ を渡す。2回作ると、日付の境目や
// 予定の終了ちょうど付近で、窓と選び方が別の「現在」を見てずれる。
// ★`toJstIso()` で日付を切り出す★(実行環境のTZに依存しない。`todayJst()` と同じ日付になる)。
const now = new Date();
const today = toJstIso(now).slice(0, 10);
// 省略時は直近モード(D-159)。スケジュール進捗・要レスキューの基準日は当日(=昨夜分)。
// ★★`??` ではなく `||` / `!` にすること★★ — ★`?date=`(値が空)だと `""` は null でも
// undefined でもないので `??` では既定値に倒れません★。★`?date=` は直近モードになる★(省略と同じ)。
// ★`""` のまま下へ渡すと `dbDateRange("", "")` で `start_at` の条件ごと消え、
// ★★テーブル全件読みに戻ります★★(本番想定で `clean_timetables` 約26万行)。
// ★★レスポンスは後段の `logsOfDay` の日付一致で空になるので画面からは観測できません★★
// (固定しているのは `app/api/dashboard/__tests__/route.test.ts`)。
const latestMode = !dateRaw;
const date = dateRaw || today;
// ★★分類(`group_id` / `customer_id`)の検証は共通の1本に任せる★★(指示書AK 段階2 / 段階4b)—
// ★このルートは自前でクエリを読んでいる★(`parseXxxQuery()` を持たない)ので、
// ★★ここで検証規則を書き下ろさないこと★★。`0` / `1abc` / 安全整数超の扱いが
// 他の10エンドポイントとずれ、★どれか1つだけ緩い★状態になる(`lib/api/master-id.ts`)。
const masters = parseMasterIdFilters(searchParams);
if (!masters.ok) {
return NextResponse.json(masters.error, { status: masters.status });
}
// ★表示上限(`problem_logs` の連続日数。指示書AV §2 D13)★ — `app/api/clean-logs/route.ts` と同じ作法。
// `loadConsecutiveDaysMax()` は内部で既定値に倒れる設計なので通常は例外にならないが、
// ★「設定が読めない」だけでダッシュボードを 500 にしない★。
// ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型)
let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
try {
maxDays = await loadConsecutiveDaysMax();
} catch {
// 既定値のまま続行する
}
// ★★このAPIの母集団を決める1本★★(指示書AK §7-2)—
// ★`null` = 絞らない / `[]` = 0件ヒット★(★罠1★。`?.length` で分岐しないこと)。
// ★★清掃ログ(`summary` / `problem_logs` / スケジュール進捗の元)・直近モードのメタ(`cleanLogRunMeta()`)・
// スケジュール・セキュリティ(`dsAlerts`)・要レスキューの5本すべてに**同じ配列**を渡すこと★★ —
// ★どれか1つを渡し忘れると、「この屋号の昨夜の成績」と「要レスキュー」が別の集団の話になる★
// (メタだけ漏れると、行に対応するメタが無く `pickLatestRunPerSection()` が例外になる)。
// ★例外もテスト失敗も出ない壊れ方★なので
// `app/api/__tests__/master-filter-dashboard-reports.test.ts` で固定してある。
let facilityIds: number[] | null;
let logs: CleanLogItem[];
let schedules: Schedule[];
let dsAlerts: DsAlert[];
let rescueRequired: RescueRequiredItem[];
/** 直近モードで選んだ行(★`date` 指定のときは `null`★) */
let picked: CleanLogItem[] | null;
try {
facilityIds = await resolveFacilityIdsByMasters(masters.query);
// ★★清掃ログとメタには**同じオブジェクト**を渡す★★(指示書AV 4-2 / 4-8)—
// 別々に組み立てると、片方だけ条件が抜けても気づけない(突き合わせが静かにずれる)。
// ★直近モード★: 表3 の窓(`latestRunWindow()`。今日を含む8日ぶん)で `start_at` を絞る。
// ★`date_from` / `date_to` を `null` にしないこと★ — 全件読みに戻る(4-7)。
// ★`date` 指定★: ★その1日ぶんだけを引く★(指示書AC 段階2 D-6)。
// `date` は上の `parseDate()` で `^\d{4}-\d{2}-\d{2}$` の検証を通っている
// (= `todayJst()` と同じ形式)ので、そのまま渡してよい(§E-2)。
// ★★施設の絞り込みは分類だけ★★(このAPIに施設名の条件は無い)—
// ★`resolveFacilityIdsByMasters()` の結果をそのまま渡す★ので積集合は要らない。
// ★★分類を指定していないときに `[]` を渡さないこと★★ — `[]` は「0件ヒット」の
// 意味で、集計が ★全部0★ になります(罠1)。`null` が「絞らない」。
// ★単一ID の指定はしない(期間の全件が要る)★(指示書AC 段階3)
const logQuery: CleanLogDbQuery = latestMode
? {
id: null,
...latestRunWindow(now, DASHBOARD_LATEST_RUN.lookbackDays),
facility_ids: facilityIds,
}
: { id: null, date_from: date, date_to: date, facility_ids: facilityIds };
let meta: CleanLogRunMeta[] | null;
[logs, meta, schedules, dsAlerts, rescueRequired] = await Promise.all([
// ★読む量★: `date` 指定は1日ぶん(本番想定で約26万行の読み込みが約360行)。
// ★直近モードは8日ぶん★(約2,900行。指示書AV 4-7)。
db.cleanLogs(logQuery),
// ★★日付指定ではメタを読まない★★(直近モード専用。指示書AV 段階4-4)
latestMode ? db.cleanLogRunMeta(logQuery) : Promise.resolve(null),
// ★★この2本は引数を取らない契約★★(段階4a の申し送り。`lib/facilities/filter-by-ids.ts`)—
// ★絞り込みは下の `filterRowsByFacilityIds()` でルート側で行う★。
db.schedules(),
db.dsAlerts(),
// ★★上の `cleanLogs()` の結果から作らないこと★★(指示書AI §1-5 / 段階4)—
// ★当日に清掃予定が無い施設も要レスキューには出す★ので母集団が違ううえ、
// R-1(未実施3日連続)も R-3(前回比の急落)も ★当日の行だけでは判定できない★。
// ★遡りぶんの読み込みは `db.rescueRequired()` の中(`loadConsecutiveInputs()`)★。
// ★★`summary` と**同じ** `facilityIds` を渡すこと★★(指示書AK §7-2)—
// ★別の配列を作り直さない★(作り直すと片方だけ条件が抜けても気づけない)。
// ★基準日は直近モードでも当日(`date`)★(D-159。指示書AV 4-1)
db.rescueRequired({ date, facility_ids: facilityIds }),
]);
// ★★`try` の中で呼ぶ★★(指示書AV 4-9)— 行とメタの突き合わせに失敗すると例外になる(D9)。
// 外で呼ぶと `{ error: ... }` の形にならない。
// ★設定値は `DASHBOARD_LATEST_RUN` から渡す★(7 を直書きしない。`CLAUDE.md` §7)。
picked =
meta === null
? null
: pickLatestRunPerSection(logs, meta, { now, ...DASHBOARD_LATEST_RUN });
} catch (e) {
const body: ApiError = {
error: {
code: "internal_error",
message: "データの取得に失敗しました",
details: null,
},
};
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return NextResponse.json(body, { status: 500 });
}
// 日付の切り出しは文字列で行う。scheduled_at は ISO8601(+09:00) なので
// 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない。
// ★★直近モードでも `logsOfDay` は当日(`date` = `today`)の行★★ —
// スケジュール進捗と `data.anomaly` は当日のまま(D-049 / 指示書AV 4-1)。
const logsOfDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date);
// ★数値カードと `problem_logs` の母集団★: 直近モードは選んだ行、`date` 指定はその日の行(D-159 / D12)
const summaryRows = picked ?? logsOfDay;
// ★★`schedules()` / `dsAlerts()` は引数を取らない契約なのでここで絞る★★
// (段階4a が `/schedules` / `/ds-alerts` で使っているのと同じ純粋関数)。
// ★★集計より**前**に絞ること★★ — `planned`(予定件数)も
// セキュリティバナーも ★同じ母集団から作る★ため。
// ★`logsOfDay` は `db.cleanLogs()` の時点で絞られている★ので二重に絞らない。
const schedulesOfScope = filterRowsByFacilityIds(schedules, facilityIds);
const dsAlertsOfScope = filterRowsByFacilityIds(dsAlerts, facilityIds);
const summary = computeSummary(summaryRows);
// ★「問題あり」の規則は `isProblemGrade()` を共有する★(書き写さない。指示書AV 4-11)。
// ★★結果待ちの行は出さない★★(ユーザー判断 2026-10-07。D-173 / 表2 C7)— 結果待ちの行は
// `grade: "×"` のままなので、旗を見ないと「問題あり」に入ってしまう(`result=problem` と同じ扱い。C3)。
// ★★絞り込みの**後**に `capCleanLogs()` を通す★★(D13 / 4-10。`/clean-logs` と連続日数の表示を揃える)
const problemLogs = capCleanLogs(
summaryRows.filter((row) => !row.result_pending && isProblemGrade(row.grade)),
maxDays
);
const scheduleProgress = computeScheduleProgress(
logsOfDay,
schedulesOfScope,
date
);
const securityBanner = computeSecurityBanner(dsAlertsOfScope);
// スケジュール由来の予定件数と当日の清掃ログ行数が合わないことの記録(D-049)。
// 収集処理が止まると当日の行が作られないため、ここが最初の手がかりになる。
// 原因の断定は書かない(logging-design.md §7)。差分だけを残す。
if (logsOfDay.length !== scheduleProgress.planned) {
logEvent("warn", {
event: "data.anomaly",
route: ROUTE,
method: "GET",
metric: "clean_log_rows_vs_scheduled",
expected: scheduleProgress.planned,
actual: logsOfDay.length,
});
}
const body: DashboardResponse = {
summary,
schedule_progress: scheduleProgress,
security_banner: securityBanner,
// ★データソースが並べ替えまで済ませた配列をそのまま返す★
// (★ここで並べ替え直さないこと★。順序の正は `buildRescueRequired()`。D-052 / D-055)
// ★★件数では切らない★★(指示書AJ 段階1。上限は無く、該当施設を全部返す。
// 初期表示を5件に絞るのは画面側の責務 = `lib/dashboard/rescue-visibility.ts`)
rescue_required: rescueRequired,
// ★データソースの順のまま(scheduled_at 降順 → id 昇順)。並べ替え直さない★
// ★★件数では切らない★★(指示書AV §2 D11。1清掃エリア最大1行。先頭5件を出すのは画面)
problem_logs: problemLogs,
};
return NextResponse.json(body);
});
}