Newer
Older
client_dwatch / app / api / clean-area / route.ts
/**
 * GET /clean-area — 清掃面積の集計(指示書AN ★段階4★ / §7-3)。
 *
 * ★仕様の正は `d-watch-api.yaml` の `/clean-area`★。形は
 * `app/api/reports/facility-performance/route.ts` に合わせている(★新しい書き方を発明しない★)。
 *
 * ------------------------------------------------------------------------
 * ★★この1本の分担★★
 * ------------------------------------------------------------------------
 * - 検証 … `parseCleanAreaQuery()`(`lib/clean-area/query.ts`。★純粋関数★)
 * - 施設の解決 … `resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()` →
 *   ★積集合★ `intersectFacilityIds()`(★どちらか片方を捨てない★)
 * - 読み取り … `db.cleanArea()`(`USE_FIXTURES` の切替はデータ層が吸収する)
 * - 集計 … `aggregateCleanArea()`(★段階2 の純粋関数★。★ここで割り算を書かない★)
 * - ★★番人のログ★★ … `reportCleanAreaAnomalies()`(★下の注記★)
 * - 取り寄せ量のログ … `judge.clean_area`(完了率用に運んだ行数と所要時間。D-156 論点2)
 *
 * ★認証はここに書かない★ — `proxy.ts` が全ルートで横断的に判定する(401 / 403)。
 * ★`GET` だけ★。
 */
import { NextResponse } from "next/server";
import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
import { reportCleanAreaAnomalies } from "@/lib/clean-area/anomaly";
import { parseCleanAreaQuery } from "@/lib/clean-area/query";
import { db } from "@/lib/db";
import type { CleanAreaSourceResult } from "@/lib/db/clean-area-source";
import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, CleanAreaResponse } from "@/lib/types";

const ROUTE = "/api/clean-area";

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

    let source: CleanAreaSourceResult;
    // ★データ層の所要時間★(`judge.clean_area` ログ用。D-156 論点2)
    let elapsedMs = 0;
    try {
      // ★★施設名の照合と分類の絞り込みは**積集合(AND)**にする★★(指示書AK §6-2)—
      //   ★どちらか片方を捨てないこと★。★積が空なら `[]` = 0件★
      //   (★`null` にすると条件ごと消えて全件表示に化ける★。例外もテスト失敗も出ない)。
      //   ★`facility_ids` の分岐は必ず `!== null`★(落とし穴3)— それを守っているのは
      //   `db.cleanArea()` 側で、ここは解決結果をそのまま渡すだけ。
      const [byName, byMasters] = await Promise.all([
        resolveFacilityIdsByName(query.facilityName),
        resolveFacilityIdsByMasters(query),
      ]);
      const startedAt = performance.now();
      source = await db.cleanArea({
        date_from: query.dateFrom,
        date_to: query.dateTo,
        facility_ids: intersectFacilityIds(byName, byMasters),
      });
      elapsedMs = performance.now() - startedAt;
    } 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 の外(`facility-performance` と同じ方針)。
    // ★粒度は集計時にだけ効く★ — 材料(`source`)は粒度に依存しないので読み直さない。
    const aggregate = aggregateCleanArea(source.stats, source.facilities, query.groupBy);

    // ★★番人(指示書AN §2-5 / §6-3 / 落とし穴2)★★ — ★呼び忘れても例外もテスト失敗も
    //   出ない★(数字が静かに大きくなるだけ)ので、★消さないこと★。
    //   ★ログはルート層で出す★ — `DataAnomalyLogEvent` の `route` / `method` を埋められるのはルート層だけで
    //   (型は D-178 で任意になったが、埋められる層で出すのが原則)、
    //   データ層はどのルートから呼ばれたかを知らない(`lib/clean-area/anomaly.ts` の docstring)。
    //   ★異常が無ければ1行も出ない★ので、毎回の呼び出しでログが埋まることはない。
    reportCleanAreaAnomalies({
      route: ROUTE,
      method: "GET",
      duplicated_timetable_count: source.duplicated_timetable_count,
      unmatched_facility_ids: aggregate.unmatched_facility_ids,
    });

    // ★★取り寄せた行数と所要時間を残す★★(D-156 論点2)— 完了率・実施回数の材料は行を運ぶ
    //   (期間を指定しないと全期間)ので、実データで重いかどうかをこのログで見る。
    //   記録条件は設けない(`judge.areas` と同じ流儀。この画面の1回の表示で1行)
    logEvent("info", {
      event: "judge.clean_area",
      route: ROUTE,
      method: "GET",
      rate_rows: source.rate_rows,
      elapsed_ms: Math.round(elapsedMs),
      total_executed_count: aggregate.total.executed_count,
      raw_total_completion_rate: aggregate.total.completion_rate,
    });

    // ★`items` は全件返す★(★`limit` を持たない★。指示書AN §7-2 / 要レスキュー D-137 と同じ作法)—
    //   ★何件見せるかは画面の責務★(`cleanAreaVisibility()` / `CLEAN_AREA_INITIAL_VISIBLE`)。
    // ★`meta.total` は `items` の件数★(★清掃回数ではない★。清掃回数は `total.executed_count`)。
    //   `page` / `per_page` パラメータを持たない一覧なので `page` は常に1、
    //   `per_page` は1ページに全件入る= `total` と同じ(`d-watch-api.yaml` の meta の説明)。
    // ★★`unmatched_facility_ids` は返さない★★ — 異常の通知先はログであって画面ではない。
    // ★★単位(`AREA_UNIT`)もレスポンスに入れない★★(指示書AN §2-4 / 落とし穴6)—
    //   ★`"㎡"` を持つのは `lib/config` の1箇所だけ★で、付けるのは画面。
    //   API が単位を返すと、TeamWorks の回答が違ったときに直す場所が散らばる。
    const total = aggregate.items.length;
    const body: CleanAreaResponse = {
      total: aggregate.total,
      items: aggregate.items,
      meta: { total, page: 1, per_page: total },
    };
    return NextResponse.json(body);
  });
}