Newer
Older
client_dwatch / lib / clean-logs / advice-response.ts
/**
 * 清掃詳細ログの行展開アドバイス応答(`/clean-logs/{id}/advice` と
 * `/clean-logs/unscheduled/{report_id}/advice` の共通処理。指示書BI §2-4)。
 */
import { NextResponse } from "next/server";
import {
  CONSECUTIVE_DAYS_DISPLAY,
  ESCALATION_THRESHOLDS,
  GRADE_THRESHOLDS,
} from "@/lib/config";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { judgeEscalationLevel, matchAdviceRules, maxAdviceLevel } from "@/lib/judge";
import { logEvent } 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, CleanLogItem, ErrorCode } from "@/lib/types";

export type AdviceResponseLogIds =
  | { clean_log_id: number }
  | { report_id: string };

function errorBody(code: ErrorCode, message: string): ApiError {
  return { error: { code, message, details: null } };
}

/**
 * 1行が決まった後のルール判定・ログ・設定読み込み・表示上限・JSON 応答。
 * ★判定とログには生値、`capConsecutiveDays()` は応答だけ★(指示書BI §2-4)。
 */
export async function adviceResponse(
  log: CleanLogItem,
  ctx: { route: string; logIds: AdviceResponseLogIds }
): Promise<NextResponse> {
  const { route, logIds } = ctx;

  // ★★入力は生値★★ — 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,
    method: "GET",
    facility_id: log.facility_id,
    ...logIds,
    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` なし)
  //   ★下の `loadConsecutiveDaysMax()` は失敗しても既定値で続ける — この非対称をそろえない(指示書BI 罠12)★
  let rules: Awaited<ReturnType<typeof loadAdviceRules>>;
  try {
    rules = await loadAdviceRules();
  } catch (e) {
    const body = errorBody("internal_error", "データの取得に失敗しました");
    logApiError({ 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,
    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 にしない(既定値で続行する。上の `loadAdviceRules()` の500と
  //   非対称なのは意図どおり。そろえない。指示書BI 罠12)。
  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);
}