/**
* GET /reports/kpi — レポート画面のKPI4指標(稼働率/出発成功率/清掃完了率/エラー発生率)+ 前期比
*
* 形は `app/api/ds-alerts/route.ts` に合わせている。
* 仕様の正は `d-watch-api.yaml` の /reports/kpi。
*
* レスポンスは `ReportKpi` の素のオブジェクト(`items` / `meta` で包まない。/dashboard と同じ形)。
*
* ------------------------------------------------------------------------
* ★★このルートだけ 422 の理由が2つある★★(指示書AK ★段階6★ / §2-1)
* ------------------------------------------------------------------------
* 1. パラメータが不正(`parseReportQuery()`)
* 2. ★★対象顧客が1社に定まらない★★(`resolveTargetCustomer()`)—
* ★確定期間のKPIは `customer_summaries` を読み、複数顧客ぶんを合算する材料がDBに無い★。
* ★★合算も「静かに1社を選ぶ」こともせず、候補を返して選ばせる★★(D-133 / 落とし穴6)。
*
* ★P1 の対象顧客は1社だけ(`CLAUDE.md` §1)なので、実データでは 2. は当面発火しない★。
* ★★ただし `USE_FIXTURES=true` では発火する★★ — fixtures は3階層のUIを確かめるために
* ★屋号を4件持っている★(`lib/fixtures/customers.ts`)ので、★屋号かホールディングスを
* 選ばずにKPIを開くと 422★ になる(=画面が「選んでください」と言う。段階7 の選択UIの前提)。
*/
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import { parseReportQuery, selectKpiRow, toReportKpiDbQuery } from "@/lib/reports/query";
import {
resolveTargetCustomer,
TARGET_CUSTOMER_REQUIRED_MESSAGE,
} from "@/lib/reports/target-customer";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, Customer, ReportKpi, ReportKpiRow } from "@/lib/types";
const ROUTE = "/api/reports/kpi";
function errorResponse(error: ApiError, status: number) {
return NextResponse.json(error, { status });
}
/**
* 該当期間の行が無いときに返す空のKPI(4指標すべて null)。
*
* ★404 にしない★(指示書F 4-1 の推奨)
* 404 にすると「データがまだ無い期間」と「不正な期間」の区別がクライアントから
* つかなくなる。不正な期間は `parseReportQuery` が422で弾く役割を負っているので、
* ここに来た期間は「妥当だがデータがまだ無い」だけ。当期(進行中の週・月)はサマリ
* テーブルに行が無いのが正常(CLAUDE.md §4.7 / D-023)なので、それも同じ扱いになる。
* `KpiValue.value` は分母0で null を取る型なので、空のKPIは契約どおりの値で表現できる。
*/
function emptyKpi(): ReportKpi {
const empty = { value: null, prev: null, diff: null };
return {
operation_rate: { ...empty },
launch_rate: { ...empty },
completion_rate: { ...empty },
error_rate: { ...empty },
};
}
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const parsed = parseReportQuery(searchParams);
if (!parsed.ok) {
return errorResponse(parsed.error, parsed.status);
}
// ★★確定期間のKPIは「対象顧客が1社に定まるときだけ」出す★★(指示書AK ★段階6★ / §2-1)—
// `customer_summaries` は顧客単位で、★4指標のうち2つは合算する材料がDBに無い★。
// ★決め方は `resolveTargetCustomer()`(純粋関数)が正★ — ★ここに決定表を写さないこと★。
//
// ★★`customer_id` だけのときは顧客マスタを読まない★★(指示書AL ★段階3★ / §4-3)—
// `resolveTargetCustomer()` は ★`customerId` だけなら顧客リストを一切見ない★
// (★存在確認をしない★。指示書AK 期待値表 6-9)ので、読んでも捨てるだけの1クエリになる。
// ★`group_id` も指定されていたら読む★(AND の傘下判定に要る。指示書BB A17・D15・D-182)。
// ★★分岐させるのは「読むかどうか」だけ★★ — ★決め方は `resolveTargetCustomer()` に
// 任せたまま★(ここで顧客を決めてしまうと決定表が2箇所になり、片方だけ直すと静かにズレる)。
// ★`!== null` で見ること★ — `parsed.query.customerId || ...` と書くと将来 0 が
// 入り込んだときに「未指定」に化ける(`resolveTargetCustomer()` と同じ作法)。
let customers: Customer[] = [];
if (parsed.query.customerId === null || parsed.query.groupId !== null) {
try {
customers = await db.customers();
} 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);
}
}
const target = resolveTargetCustomer(customers, parsed.query);
if (!target.ok) {
// ★★500 ではなく 422★★ — サーバの不具合ではなく「指定が足りない」という話(§2-1)。
// ★候補を `details.customer_ids` に入れる★ — 画面が「どれを選べばよいか」を
// ★文言を読まずに★ 示せるようにするため(`d-watch-api.yaml` の /reports/kpi 422 に明記)。
// ★候補が空配列になるのは `group_id` 指定で傘下0社のときだけ★
// (理由は `resolveTargetCustomer()` の docstring)。
const body: ApiError = {
error: {
code: "validation_failed",
message: TARGET_CUSTOMER_REQUIRED_MESSAGE,
details: { customer_ids: target.candidateCustomerIds },
},
};
return errorResponse(body, 422);
}
// ★顧客とグループが食い違う(その顧客がそのグループの傘下でない)→ 該当なし(200・空)★(指示書BB A17・D-182)—
// ★サマリも詳細も読まない★(YAML の `GroupId`「AND。そうでなければ0件。404 にしない」)
if (target.customerId === null) {
return NextResponse.json(emptyKpi());
}
let rows: ReportKpiRow[];
try {
// ★★期間を SQL に降ろす★★(指示書AC 段階5)— ★生の `searchParams` ではなく
// `parseReportQuery()` を通った値を渡す★(§E-2)。
// ★★`selectKpiRow()` は残す★★(絞り込み規則の「正」。指示書AC C-5)。
// ★★顧客は `parsed.query.customerId` ではなく `target.customerId` を渡す★★ —
// ★未指定でも1社に定まっている★(§2-1 の決定表)。
//
// ★★当期(進行中の週・月)は施設で絞る★★(指示書AL ★段階1★)—
// 確定期間は `customer_summaries` を `customer_id` で絞るが、★当期は詳細テーブルを
// その場集計する★ので軸が施設になる。★両者が同じ顧客を指していないと、
// 同じ画面の中で「確定期間は1社・当期は全社」という食い違いが起きる★(D-138 の残課題)。
// ★★だから `parsed.query`(=リクエストの `group_id` / `customer_id`)ではなく
// `target.customerId` から解決する★★ — ★ここに来た時点で顧客は1社に定まっている★
// (2社以上なら上の 422 で止まっている)。★`groupId` は渡さない★(§2-3 / 落とし穴1)。
// ★戻り値は `null`(絞らない)にはならない★ — `customerId` が必ず入るので、
// ★該当施設が無ければ `[]` になり当期は空になる★(期待値表 1-3。★罠1★)。
const facilityIds = await resolveFacilityIdsByMasters({
groupId: null,
customerId: target.customerId,
});
rows = await db.reportKpis(
toReportKpiDbQuery(parsed.query, target.customerId, facilityIds)
);
} 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);
}
// 行の選択と空KPIの組み立ては DBアクセスではないので try の外で行う
// (`app/api/ds-alerts/route.ts` と同じ方針。try の中は `await db.xxx()` だけ)。
const body: ReportKpi = selectKpiRow(rows, parsed.query) ?? emptyKpi();
return NextResponse.json(body);
});
}