Newer
Older
client_dwatch / app / api / reports / kpi / route.ts
/**
 * 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);
  });
}