/**
* GET /clean-logs/{id}/advice — 対応アドバイス(行展開時の推定原因+確認アクション)
*
* 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/advice です。
*
* 判定(どのルールが該当するか)は `lib/judge/advice.ts`、
* 文言は `lib/settings/advice.ts`(運用設定で編集された内容)から取得します。
*/
import { NextResponse } from "next/server";
import { parsePathId } from "@/lib/api/path-params";
import {
CONSECUTIVE_DAYS_DISPLAY,
ESCALATION_THRESHOLDS,
GRADE_THRESHOLDS,
} from "@/lib/config";
import { db } from "@/lib/db";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { judgeEscalationLevel, matchAdviceRules, maxAdviceLevel } from "@/lib/judge";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadAdviceRules } from "@/lib/settings/advice";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type { ApiError, CleanLogAdviceResponse, ErrorCode } from "@/lib/types";
const ROUTE = "/api/clean-logs/[id]/advice";
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;
let logs;
try {
// ★★1件だけ引く★★(指示書AC 段階3)— 以前は「範囲なし」で全件(実測3,178行)を
// 読んでから `find()` していた。
// ★★連続日数は `loadConsecutiveInputs()` 経由なので正しいまま★★(段階1 の効果)—
// `cleanLogs()` の中で ★全期間を読む専用の1本★ が別に走るので、
// ★`id` で絞っても L3 が L1 に化けない★(指示書AC C-2)。
// ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
// ★全部 404 になります★(罠1)。
logs = await db.cleanLogs({
id: logId,
date_from: null,
date_to: null,
facility_ids: null,
});
} catch (e) {
const body = errorBody("internal_error", "データの取得に失敗しました");
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return NextResponse.json(body, { status: 500 });
}
// ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)—
// ★絞り込み規則の「正」はここ★で、SQL 側はその写し。
// ★★fixtures 版が `id` の扱いを間違えても 404 が壊れない保険★★にもなる。
const log = logs.find((l) => l.id === logId);
if (!log) {
return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
}
// ★★入力は生値★★ — R1〜R4 の判定に表示上限をかけない(指示書AF 禁止事項9)。
// ★このルートで `capConsecutiveDays()` を通してよいのはレスポンスに載せる値だけ★
// (下の `body`)。判定の入力と、判定根拠を残す `logEvent` は生値のままにする。
const matchedIds = matchAdviceRules({
departed: log.departed,
completionRate: log.completion_rate,
consecutiveProblemDays: log.consecutive_problem_days,
// ★null(事前チェックの予定が無い。D-171)はそのまま渡す★ — `matchAdviceRules()` は null を
// どのルールにも当てない(例外にしない)。★ここで "Standby." 等に置き換えないこと★
status1h: log.status_1h,
status30m: log.status_30m,
});
// 判定根拠の記録。記録条件は設けず無条件に出す
// (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
// 条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)
logEvent("info", {
event: "judge.advice",
route: ROUTE,
method: "GET",
facility_id: log.facility_id,
// ★`log.id` は型の上で null を含む(予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値★
clean_log_id: logId,
departed: log.departed,
raw_completion_rate: log.completion_rate,
consecutive_problem_days: log.consecutive_problem_days,
status_1h: log.status_1h,
status_30m: log.status_30m,
thresholds_used: {
excellent: GRADE_THRESHOLDS.excellent,
good: GRADE_THRESHOLDS.good,
poor: GRADE_THRESHOLDS.poor,
level2Days: ESCALATION_THRESHOLDS.level2Days,
level3Days: ESCALATION_THRESHOLDS.level3Days,
},
result_rules: matchedIds,
});
// ★読み込みの例外は標準の形の500にする★(指示書BB A5・D18。`try` の外で投げると標準でない500・`api.error` なし)
let rules: Awaited<ReturnType<typeof loadAdviceRules>>;
try {
rules = await loadAdviceRules();
} catch (e) {
const body = errorBody("internal_error", "データの取得に失敗しました");
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return NextResponse.json(body, { status: 500 });
}
const advices = matchedIds.map((ruleId) => {
const r = rules[ruleId];
return {
rule: r.id,
level: r.level,
icon: r.icon,
title: r.title,
estimated_cause: r.text,
recommended_actions: r.actions,
};
});
// ★★入力は生値★★ — L1〜L3 の判定に表示上限をかけない(指示書AF 禁止事項9 /
// `CLAUDE.md` §4.3「表示用に丸めた値で判定すること」の禁止と同じ思想)。
// 上限をかけると「30日以上」と出ているのに Lv3 が出ない画面になる。
const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days);
logEvent("info", {
event: "judge.escalation",
route: ROUTE,
method: "GET",
facility_id: log.facility_id,
consecutive_problem_days: log.consecutive_problem_days,
thresholds_used: {
level2Days: ESCALATION_THRESHOLDS.level2Days,
level3Days: ESCALATION_THRESHOLDS.level3Days,
},
result_level: escalationLevel,
});
// ★★ここだけが表示上限をかけてよい場所★★(指示書AF 段階6-2)—
// 判定(`matchAdviceRules` / `judgeEscalationLevel`)も判定根拠の `logEvent` も
// 生値のままで、レスポンスに載せる表示値だけを頭打ちにする。
// `escalation_level` は生値で判定した結果をそのまま返す(判定し直さない)。
// 設定が読めないだけでアドバイスを 500 にしない(既定値で続行する)。
let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
try {
maxDays = await loadConsecutiveDaysMax();
} catch {
// 既定値のまま続行する
}
const capped = capConsecutiveDays(log.consecutive_problem_days, maxDays);
const body: CleanLogAdviceResponse = {
escalation_level: escalationLevel,
consecutive_problem_days: capped.days,
consecutive_problem_days_capped: capped.capped,
max_level: maxAdviceLevel(advices),
advices,
};
return NextResponse.json(body);
});
}