Newer
Older
client_dwatch / app / api / reports / continuous-problems / route.ts
/**
 * GET /reports/continuous-problems — 連続問題施設一覧(2日以上連続で△以下 or 出発失敗)
 *
 * 形は `app/api/ds-alerts/route.ts` に合わせている。
 * 仕様の正は `d-watch-api.yaml` の /reports/continuous-problems。
 */
import { NextResponse } from "next/server";
import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config";
import { db } from "@/lib/db";
import { capContinuousProblems } from "@/lib/db/report-mapping";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import {
  filterContinuousProblems,
  parseReportQuery,
  selectContinuousProblemRows,
  sortContinuousProblems,
  toReportDbQuery,
} from "@/lib/reports/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type {
  ApiError,
  ContinuousProblem,
  ContinuousProblemRow,
  ListResponse,
} from "@/lib/types";

const ROUTE = "/api/reports/continuous-problems";

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 = parseReportQuery(searchParams);
    if (!parsed.ok) {
      return errorResponse(parsed.error, parsed.status);
    }

    let rows: ContinuousProblemRow[];
    // ★表示上限も try の中で取る★(ファイル読み込みは失敗しうる)。
    //   `loadConsecutiveDaysMax()` は内部で既定値に倒れる設計(指示書AF 段階3-3)なので
    //   通常は例外にならないが、★「設定が読めない」だけで一覧を 500 にしない★ため、
    //   下の catch とは分けて既定値で続行する。
    // ★型注釈 `: number` が要る★ — `CONSECUTIVE_DAYS_DISPLAY` は `as const` なので
    //   注釈が無いとリテラル型 `30` に推論され、`loadConsecutiveDaysMax()` の戻りを代入できない
    let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
    try {
      maxDays = await loadConsecutiveDaysMax();
    } catch {
      // 既定値のまま続行する(一覧そのものは返せる)
    }
    try {
      // ★★期間を SQL に降ろす★★(指示書AC 段階5)— ★`parseReportQuery()` を通った値を渡す★。
      //   ★★`selectContinuousProblemRows()` / `filterContinuousProblems()` /
      //   `sortContinuousProblems()` は残す★★(絞り込み規則の「正」。指示書AC C-5)。
      //   ★★分類(`group_id` / `customer_id`)は施設IDの配列に解決してから渡す★★
      //   (指示書AK 段階4b)— ★`null` = 絞らない / `[]` = 0件★(★罠1★)。
      //   ★このAPIに施設名の条件は無い★(`d-watch-api.yaml` の
      //   `/reports/continuous-problems` のパラメータは period/year/month/week/
      //   group_id/customer_id/limit のみ)ので積集合は要らない。
      //   ★★施設名で絞りたくなったら先に YAML を直し、`/reports/facility-performance` と
      //   同じく `intersectFacilityIds()` で AND に畳むこと★★(`CLAUDE.md` 作業ルール1)。
      rows = await db.continuousProblems(
        toReportDbQuery(parsed.query, await resolveFacilityIdsByMasters(parsed.query))
      );
    } 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 の外
    // (`app/api/ds-alerts/route.ts` と同じ方針)。
    const inPeriod: ContinuousProblem[] = selectContinuousProblemRows(
      rows,
      parsed.query
    );
    // 2日以上のみ返す(L1(初回)は含めない)。閾値は `lib/config` から読む
    // (CLAUDE.md §7 ハードコード禁止。判断は `filterContinuousProblems` 側)。
    const filtered = filterContinuousProblems(inPeriod);
    const sorted = sortContinuousProblems(filtered);

    // ★meta.total は「2日以上の絞り込み後・limit 適用前」の件数★(YAML の meta の説明)
    //   画面の「他N件を表示」の算出元がこれ。
    const total = sorted.length;
    const { limit } = parsed.query;
    // ★表示上限は「絞り込み → 並べ替え → 切り出し」の**最後**にかける★(指示書AF 段階5-2)
    //   先にかけると上限に達した施設どうしの降順が潰れる。`meta.total` も上限とは無関係
    //   (件数は変わらない)。
    // ★データソース(`lib/db/`)の中ではなくルート層でかける★ —
    //   `USE_FIXTURES` の両方で同じ挙動にするため(`CLAUDE.md` §9.5)。
    const items = capContinuousProblems(sorted.slice(0, limit), maxDays);

    // page / per_page パラメータを持たない一覧なので meta.page は常に1、
    // meta.per_page は実際に適用された limit を返す(YAML の meta の説明)。
    const body: ListResponse<ContinuousProblem> = {
      items,
      meta: { total, page: 1, per_page: limit },
    };
    return NextResponse.json(body);
  });
}