/**
* GET /clean-logs/{id}/areas — エリア別完了率(施設×日付)
*
* 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/areas です。
*
* ★★`{id}` はエリアを特定するキーではなく、「施設と日付を特定するための入口」★★
* (指示書Z 決定1)。返すのは ★その清掃ログと同じ施設・同じ日の全エリア★
* (その清掃ログ自身を含む)。画面(F-02 清掃詳細ログ)の行から開くモーダルなので、
* 行の id を入口にしている。
*
* ★出所★: TeamWorks 第8回 A9-2(2026-09-11 受領)で
* ★1エリア(=1ロボット)につき1つの完了率★が取得可能と確定したため、
* ★501 の仮実装(D-034)をやめて本実装にした★。
* ★★ただし確認事項#10 の本来の問い(1回の清掃の中の区域別内訳)は依然として未解決★★。
*
* 絞り込み・並べ替え・全体率の計算は `lib/clean-logs/areas.ts`(純粋関数)、
* 行の取得は `db.areaCompletions()`。このルートはその2つをつなぐだけにしてある。
*
* ★★取得は2段構え★★(指示書AC 段階3):
* 1. `db.cleanLogs({ id })` … ★1件だけ★ 引いて `facility_id` と JST 日付を得る(404 の判定も兼ねる)
* 2. `db.areaCompletions({ facility_id, date })` … ★その施設のその日だけ★ を引く
* ★以前は両方を全件読んでいた(1行の表示のために実測 4,760行。指示書AC B-3)★。
*/
import { NextResponse } from "next/server";
import { parsePathId } from "@/lib/api/path-params";
import {
buildAreaCompletions,
overallCompletionTally,
parseAreaFilter,
selectAreaRowsForLog,
} from "@/lib/clean-logs/areas";
import { GRADE_THRESHOLDS } from "@/lib/config";
import { db } from "@/lib/db";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type {
ApiError,
AreaCompletionRow,
AreaCompletionsResponse,
CleanLogItem,
ErrorCode,
} from "@/lib/types";
const ROUTE = "/api/clean-logs/[id]/areas";
function errorBody(code: ErrorCode, message: string): ApiError {
return { error: { code, message, details: null } };
}
function errorResponse(status: number, code: ErrorCode, message: string) {
return NextResponse.json(errorBody(code, message), { status });
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
return withRequestId(request, async () => {
const { id } = await params;
// ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。
// `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123)
const parsedId = parsePathId(id);
if (!parsedId.ok) {
return NextResponse.json(parsedId.error, { status: parsedId.status });
}
const logId = parsedId.query;
const { searchParams } = new URL(request.url);
const parsed = parseAreaFilter(searchParams);
if (!parsed.ok) {
return NextResponse.json(parsed.error, { status: parsed.status });
}
// ★★2段構えにする★★(指示書AC 段階3)— ★並列に引けないのは承知の上★。
// 1段目で `facility_id` と JST 日付が分かって初めて、2段目を絞れるため
// (★以前は両方を全件読んで並列に引いていた。1行の表示のために実測 4,760行★)。
let log: CleanLogItem | undefined;
try {
// ★★1件だけ引く★★ — このルートが `cleanLogs()` を引くのは
// ★404 の判定と、施設・日付の特定★ のためだけ。
// ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
// ★全部 404 になります★(罠1)。
const logs = await db.cleanLogs({
id: logId,
date_from: null,
date_to: null,
facility_ids: null,
});
// ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)
log = logs.find((l) => l.id === logId);
} catch (e) {
const body = errorBody("internal_error", "データの取得に失敗しました");
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return NextResponse.json(body, { status: 500 });
}
if (!log) {
return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
}
// ★`scheduled_at` は既に JST の ISO 文字列(`jstIsoFromDb()` 由来)なので `.slice(0, 10)`★
// ★★`areaCompletions()` の `date` と**同じ作り方**★★(片方だけ生の `Date` から
// 作ると9時間ずれて突き合わせが外れる。指示書Y / D-115)
const date = log.scheduled_at.slice(0, 10);
let rows: AreaCompletionRow[];
try {
// ★★その施設のその日だけを引く★★(指示書AC 段階3)—
// ★`clean_log_id` で引かないこと★。`{id}` はエリアではなく
// ★「施設と日付の入口」★ なので、同じ施設・同じ日の ★全エリア★ が要る(指示書Z 決定1)。
rows = await db.areaCompletions({ facility_id: log.facility_id, date });
} catch (e) {
const body = errorBody("internal_error", "データの取得に失敗しました");
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return NextResponse.json(body, { status: 500 });
}
// ★★`selectAreaRowsForLog()` は**残す**★★(指示書AC C-5)— ★絞り込み規則の「正」はこの
// 純粋関数★で、SQL 側はその写し。★★SQL が絞りすぎたときはここでは取り戻せない★★ので、
// 「SQL 版 = 全件版 + `selectAreaRowsForLog()`」の等価テストで固定してある(G-3)。
const selected = selectAreaRowsForLog(rows, {
facility_id: log.facility_id,
date,
});
const body: AreaCompletionsResponse = buildAreaCompletions(selected, parsed.query);
// ★全体率と同じ関数・同じ行(`filter` で絞る前)で分子・分母を数える★(指示書AR 段階4)
const tally = overallCompletionTally(selected);
// 判定根拠の記録。記録条件は設けず無条件に出す
// (このルートは「エリア別詳細を見る」のクリックで1回だけ呼ばれるため件数の問題が無い。
// `advice/route.ts` の judge.advice と同じ流儀)
// ★★分子・分母を残すのは、「全体率が行の平均と違う」と言われたときに検算できるようにするため★★
// (全体率は行の完了率の単純平均。`raw_overall_completion_rate === sum / count`。
// ★自前で数え直さず `overallCompletionTally()` を使う★ — 別の数え方をするとログと画面が食い違う)
logEvent("info", {
event: "judge.areas",
route: ROUTE,
method: "GET",
facility_id: log.facility_id,
clean_log_id: log.id,
date,
filter: parsed.query,
raw_overall_completion_rate: body.overall_completion_rate,
overall_completion_rate_sum: tally.cleanRatioSum,
overall_completion_rate_count: tally.cleanRatioCount,
total_count: body.total_count,
problem_count: body.problem_count,
thresholds_used: {
excellent: GRADE_THRESHOLDS.excellent,
good: GRADE_THRESHOLDS.good,
poor: GRADE_THRESHOLDS.poor,
},
result_overall_grade: body.overall_grade,
});
return NextResponse.json(body);
});
}