Newer
Older
client_dwatch / app / api / clean-logs / [id] / areas / route.ts
/**
 * GET /clean-logs/{id}/areas — エリア別完了率(施設×日付)
 *
 * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/areas です。
 *
 * ★★`{id}` はエリアを特定するキーではなく、「施設と日付を特定するための入口」★★
 *   (指示書Z 決定1)。返すのは ★その清掃ログと同じ施設・同じ日の全エリア★
 *   (その清掃ログ自身を含む)。画面(F-02 清掃詳細ログ)の行から開くモーダルなので、
 *   行の id を入口にしている。
 *
 * ★出所★: TeamWorks 第8回 A9-2(2026-09-11 受領)で
 *   ★1エリア(=1ロボット)につき1つの完了率★が取得可能と確定したため、
 *   ★501 の仮実装(D-034)をやめて本実装にした★。
 *   ★★ただし確認事項#10 の本来の問い(1回の清掃の中の区域別内訳)は依然として未解決★★。
 *
 * 絞り込み・並べ替え・全体率の計算は `lib/clean-logs/areas.ts`(純粋関数)、
 * 行の取得は `db.areaCompletions()`。このルートはその2つをつなぐだけにしてある。
 *
 * ★★取得は2段構え★★(指示書AC 段階3):
 *   1. `db.cleanLogs({ id })` … ★1件だけ★ 引いて `facility_id` と JST 日付を得る(404 の判定も兼ねる)
 *   2. `db.areaCompletions({ facility_id, date })` … ★その施設のその日だけ★ を引く
 *   ★以前は両方を全件読んでいた(1行の表示のために実測 4,760行。指示書AC B-3)★。
 */
import { NextResponse } from "next/server";
import { parsePathId } from "@/lib/api/path-params";
import {
  buildAreaCompletions,
  overallCompletionTally,
  parseAreaFilter,
  selectAreaRowsForLog,
} from "@/lib/clean-logs/areas";
import { GRADE_THRESHOLDS } from "@/lib/config";
import { db } from "@/lib/db";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type {
  ApiError,
  AreaCompletionRow,
  AreaCompletionsResponse,
  CleanLogItem,
  ErrorCode,
} from "@/lib/types";

const ROUTE = "/api/clean-logs/[id]/areas";

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;

    const { searchParams } = new URL(request.url);
    const parsed = parseAreaFilter(searchParams);
    if (!parsed.ok) {
      return NextResponse.json(parsed.error, { status: parsed.status });
    }

    // ★★2段構えにする★★(指示書AC 段階3)— ★並列に引けないのは承知の上★。
    //   1段目で `facility_id` と JST 日付が分かって初めて、2段目を絞れるため
    //   (★以前は両方を全件読んで並列に引いていた。1行の表示のために実測 4,760行★)。
    let log: CleanLogItem | undefined;
    try {
      // ★★1件だけ引く★★ — このルートが `cleanLogs()` を引くのは
      //   ★404 の判定と、施設・日付の特定★ のためだけ。
      //   ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
      //   ★全部 404 になります★(罠1)。
      const logs = await db.cleanLogs({
        id: logId,
        date_from: null,
        date_to: null,
        facility_ids: null,
      });
      // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)
      log = logs.find((l) => l.id === logId);
    } catch (e) {
      const body = errorBody("internal_error", "データの取得に失敗しました");
      logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
      return NextResponse.json(body, { status: 500 });
    }

    if (!log) {
      return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
    }

    // ★`scheduled_at` は既に JST の ISO 文字列(`jstIsoFromDb()` 由来)なので `.slice(0, 10)`★
    //   ★★`areaCompletions()` の `date` と**同じ作り方**★★(片方だけ生の `Date` から
    //   作ると9時間ずれて突き合わせが外れる。指示書Y / D-115)
    const date = log.scheduled_at.slice(0, 10);

    let rows: AreaCompletionRow[];
    try {
      // ★★その施設のその日だけを引く★★(指示書AC 段階3)—
      //   ★`clean_log_id` で引かないこと★。`{id}` はエリアではなく
      //   ★「施設と日付の入口」★ なので、同じ施設・同じ日の ★全エリア★ が要る(指示書Z 決定1)。
      rows = await db.areaCompletions({ facility_id: log.facility_id, date });
    } catch (e) {
      const body = errorBody("internal_error", "データの取得に失敗しました");
      logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
      return NextResponse.json(body, { status: 500 });
    }

    // ★★`selectAreaRowsForLog()` は**残す**★★(指示書AC C-5)— ★絞り込み規則の「正」はこの
    //   純粋関数★で、SQL 側はその写し。★★SQL が絞りすぎたときはここでは取り戻せない★★ので、
    //   「SQL 版 = 全件版 + `selectAreaRowsForLog()`」の等価テストで固定してある(G-3)。
    const selected = selectAreaRowsForLog(rows, {
      facility_id: log.facility_id,
      date,
    });
    const body: AreaCompletionsResponse = buildAreaCompletions(selected, parsed.query);
    // ★全体率と同じ関数・同じ行(`filter` で絞る前)で分子・分母を数える★(指示書AR 段階4)
    const tally = overallCompletionTally(selected);

    // 判定根拠の記録。記録条件は設けず無条件に出す
    // (このルートは「エリア別詳細を見る」のクリックで1回だけ呼ばれるため件数の問題が無い。
    //  `advice/route.ts` の judge.advice と同じ流儀)
    // ★★分子・分母を残すのは、「全体率が行の平均と違う」と言われたときに検算できるようにするため★★
    //   (全体率は行の完了率の単純平均。`raw_overall_completion_rate === sum / count`。
    //    ★自前で数え直さず `overallCompletionTally()` を使う★ — 別の数え方をするとログと画面が食い違う)
    logEvent("info", {
      event: "judge.areas",
      route: ROUTE,
      method: "GET",
      facility_id: log.facility_id,
      clean_log_id: log.id,
      date,
      filter: parsed.query,
      raw_overall_completion_rate: body.overall_completion_rate,
      overall_completion_rate_sum: tally.cleanRatioSum,
      overall_completion_rate_count: tally.cleanRatioCount,
      total_count: body.total_count,
      problem_count: body.problem_count,
      thresholds_used: {
        excellent: GRADE_THRESHOLDS.excellent,
        good: GRADE_THRESHOLDS.good,
        poor: GRADE_THRESHOLDS.poor,
      },
      result_overall_grade: body.overall_grade,
    });

    return NextResponse.json(body);
  });
}