/**
* GET /clean-area — 清掃面積の集計(指示書AN ★段階4★ / §7-3)。
*
* ★仕様の正は `d-watch-api.yaml` の `/clean-area`★。形は
* `app/api/reports/facility-performance/route.ts` に合わせている(★新しい書き方を発明しない★)。
*
* ------------------------------------------------------------------------
* ★★この1本の分担★★
* ------------------------------------------------------------------------
* - 検証 … `parseCleanAreaQuery()`(`lib/clean-area/query.ts`。★純粋関数★)
* - 施設の解決 … `resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()` →
* ★積集合★ `intersectFacilityIds()`(★どちらか片方を捨てない★)
* - 読み取り … `db.cleanArea()`(`USE_FIXTURES` の切替はデータ層が吸収する)
* - 集計 … `aggregateCleanArea()`(★段階2 の純粋関数★。★ここで割り算を書かない★)
* - ★★番人のログ★★ … `reportCleanAreaAnomalies()`(★下の注記★)
* - 取り寄せ量のログ … `judge.clean_area`(完了率用に運んだ行数と所要時間。D-156 論点2)
*
* ★認証はここに書かない★ — `proxy.ts` が全ルートで横断的に判定する(401 / 403)。
* ★`GET` だけ★。
*/
import { NextResponse } from "next/server";
import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
import { reportCleanAreaAnomalies } from "@/lib/clean-area/anomaly";
import { parseCleanAreaQuery } from "@/lib/clean-area/query";
import { db } from "@/lib/db";
import type { CleanAreaSourceResult } from "@/lib/db/clean-area-source";
import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, CleanAreaResponse } from "@/lib/types";
const ROUTE = "/api/clean-area";
function errorResponse(error: ApiError, status: number) {
return NextResponse.json(error, { status });
}
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const parsed = parseCleanAreaQuery(searchParams);
if (!parsed.ok) {
return errorResponse(parsed.error, parsed.status);
}
const query = parsed.query;
let source: CleanAreaSourceResult;
// ★データ層の所要時間★(`judge.clean_area` ログ用。D-156 論点2)
let elapsedMs = 0;
try {
// ★★施設名の照合と分類の絞り込みは**積集合(AND)**にする★★(指示書AK §6-2)—
// ★どちらか片方を捨てないこと★。★積が空なら `[]` = 0件★
// (★`null` にすると条件ごと消えて全件表示に化ける★。例外もテスト失敗も出ない)。
// ★`facility_ids` の分岐は必ず `!== null`★(落とし穴3)— それを守っているのは
// `db.cleanArea()` 側で、ここは解決結果をそのまま渡すだけ。
const [byName, byMasters] = await Promise.all([
resolveFacilityIdsByName(query.facilityName),
resolveFacilityIdsByMasters(query),
]);
const startedAt = performance.now();
source = await db.cleanArea({
date_from: query.dateFrom,
date_to: query.dateTo,
facility_ids: intersectFacilityIds(byName, byMasters),
});
elapsedMs = performance.now() - startedAt;
} catch (e) {
const body: ApiError = {
error: {
code: "internal_error",
message: "データの取得に失敗しました",
details: null,
},
};
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return errorResponse(body, 500);
}
// ここから下は純粋な集計。すべて try の外(`facility-performance` と同じ方針)。
// ★粒度は集計時にだけ効く★ — 材料(`source`)は粒度に依存しないので読み直さない。
const aggregate = aggregateCleanArea(source.stats, source.facilities, query.groupBy);
// ★★番人(指示書AN §2-5 / §6-3 / 落とし穴2)★★ — ★呼び忘れても例外もテスト失敗も
// 出ない★(数字が静かに大きくなるだけ)ので、★消さないこと★。
// ★ログはルート層で出す★ — `DataAnomalyLogEvent` の `route` / `method` を埋められるのはルート層だけで
// (型は D-178 で任意になったが、埋められる層で出すのが原則)、
// データ層はどのルートから呼ばれたかを知らない(`lib/clean-area/anomaly.ts` の docstring)。
// ★異常が無ければ1行も出ない★ので、毎回の呼び出しでログが埋まることはない。
reportCleanAreaAnomalies({
route: ROUTE,
method: "GET",
duplicated_timetable_count: source.duplicated_timetable_count,
unmatched_facility_ids: aggregate.unmatched_facility_ids,
});
// ★★取り寄せた行数と所要時間を残す★★(D-156 論点2)— 完了率・実施回数の材料は行を運ぶ
// (期間を指定しないと全期間)ので、実データで重いかどうかをこのログで見る。
// 記録条件は設けない(`judge.areas` と同じ流儀。この画面の1回の表示で1行)
logEvent("info", {
event: "judge.clean_area",
route: ROUTE,
method: "GET",
rate_rows: source.rate_rows,
elapsed_ms: Math.round(elapsedMs),
total_executed_count: aggregate.total.executed_count,
raw_total_completion_rate: aggregate.total.completion_rate,
});
// ★`items` は全件返す★(★`limit` を持たない★。指示書AN §7-2 / 要レスキュー D-137 と同じ作法)—
// ★何件見せるかは画面の責務★(`cleanAreaVisibility()` / `CLEAN_AREA_INITIAL_VISIBLE`)。
// ★`meta.total` は `items` の件数★(★清掃回数ではない★。清掃回数は `total.executed_count`)。
// `page` / `per_page` パラメータを持たない一覧なので `page` は常に1、
// `per_page` は1ページに全件入る= `total` と同じ(`d-watch-api.yaml` の meta の説明)。
// ★★`unmatched_facility_ids` は返さない★★ — 異常の通知先はログであって画面ではない。
// ★★単位(`AREA_UNIT`)もレスポンスに入れない★★(指示書AN §2-4 / 落とし穴6)—
// ★`"㎡"` を持つのは `lib/config` の1箇所だけ★で、付けるのは画面。
// API が単位を返すと、TeamWorks の回答が違ったときに直す場所が散らばる。
const total = aggregate.items.length;
const body: CleanAreaResponse = {
total: aggregate.total,
items: aggregate.items,
meta: { total, page: 1, per_page: total },
};
return NextResponse.json(body);
});
}