/**
 * GET /reports/* で使うクエリ処理。
 * 仕様の正は `d-watch-api.yaml` の /reports/kpi・/reports/facility-performance・
 * /reports/continuous-problems・/reports/error-breakdown。
 * ソート順（同値のときの安定化キー）は D-052 と同じ規則。
 *
 * 構成順は `lib/ds-alerts/query.ts` に合わせている（定数 → 型 → private → parse → filter → sort）。
 */
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { ESCALATION_THRESHOLDS, PAGINATION, REPORT_YEAR } from "@/lib/config";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { isCurrentPeriod } from "@/lib/format/period";
import { includesFoldName, includesFoldPlain } from "@/lib/format/search-text";
import type {
  ContinuousProblem,
  ContinuousProblemRow,
  ErrorBreakdownItem,
  ErrorBreakdownRow,
  FacilityPerformance,
  FacilityPerformanceRow,
  FacilityStatus,
  Period,
  ReportDbQuery,
  ReportKpi,
  ReportKpiDbQuery,
  ReportKpiRow,
  ReportPeriodKey,
} from "@/lib/types";

const PERIODS: readonly Period[] = ["weekly", "monthly", "yearly"];

/** YAML の status パラメータ（enum + all）。`FacilityStatus` の定義順に `all` を足したもの */
const FACILITY_STATUS_FILTERS: readonly (FacilityStatus | "all")[] = [
  "good",
  "warning",
  "bad",
  "no_data",
  "all",
];

/**
 * 西暦の下限・上限（YAML `Year` の minimum/maximum）。
 * ★値そのものは `lib/config` の `REPORT_YEAR` が正★（`CLAUDE.md` §7 ハードコード禁止）。
 * ここは `MONTH_MIN` / `WEEK_MIN` と読み方を揃えるための別名。
 *
 * ★範囲が無いと何が起きるか（2026-09-14 実測）★:
 *   `year=-1`（monthly）は ★200で空のKPI★、`year=-1` / `0` / `1000000`（weekly）は
 *   ★`addDays` の RangeError で500★、`year=99999999999999999999` は
 *   ★Prisma の 64bit 超過エラーで500★（SQLは出ないが、その値がサーバログに載る）。
 *   どれも入力の誤りなので422で弾く。理由は `REPORT_YEAR` のコメント。
 */
const YEAR_MIN = REPORT_YEAR.min;
const YEAR_MAX = REPORT_YEAR.max;

/**
 * 月の下限・上限（YAML `Month` の minimum/maximum）。
 * ★`period=weekly` / `monthly` のときの範囲★。
 */
const MONTH_MIN = 1;
const MONTH_MAX = 12;

/**
 * ★`period=yearly` のときの `month` の唯一の正解★（YAML `Month`。指示書AQ 落とし穴6）。
 *
 * 年次のサマリは `month=0 AND week=0` で表される（CLAUDE.md §4.7）ので、内部表現もそれに揃える。
 * ★`MONTH_MIN` を 0 に下げないこと★ — 下げると `period=monthly&month=0` が通ってしまい、
 * ★月次の要求が年次のサマリ行に当たる★（`matchesPeriod()` は4項目の単純比較なので
 * `period` が違えば当たらないが、当期判定やデータ層の `where` には 0 がそのまま流れる）。
 */
const MONTH_YEARLY = 0;

/**
 * 週番号の下限・上限（YAML `Week` の minimum/maximum）。
 * ★上限は 6。D-071（月跨ぎ週は月境界で分割する）の下では第6週が発生する★
 * （1日が金曜または土曜の30日/31日月。2026年は5月と8月）。
 * 理由は `lib/format/period.ts` 冒頭のコメント。
 *
 * ★下限は 1 のまま。`week=0` は先方のサマリで「月の集計値」を表す予約値なので通さない★
 */
const WEEK_MIN = 1;
const WEEK_MAX = 6;

/** limit の既定値（YAML の `limit: { default: 10 }`） */
const DEFAULT_LIMIT = 10;

export interface ReportQuery {
  period: Period;
  year: number;
  /**
   * ★period=yearly のときは 0★（年次は月を持たない。CLAUDE.md §4.7 の
   * 「`month=0 AND week=0` が年次」に内部表現を揃えている）。
   *
   * ★weekly / monthly は 1〜12。`month=0` は 422★（`MONTH_MIN = 1`）。
   * 0 にするのは yearly だけで、しかも ★yearly では 0 以外が 422★（対称にしてある）。
   */
  month: number;
  /**
   * ★period=monthly / yearly のときは 0★（どちらも週を持たない。D-072）
   *
   * サマリ4テーブルの `week` が NOT NULL で「月次は 0」なので、内部表現も 0 に揃えている。
   * ★`period=weekly` で `week=0` は 422★（`WEEK_MIN = 1`）。
   * 0 にするのは monthly / yearly の読み捨てだけ。
   */
  week: number;
  /** 施設名（部分一致）。未指定は空文字 */
  facilityName: string;
  /** シリアル番号（部分一致）。未指定は空文字 */
  serial: string;
  status: FacilityStatus | "all";
  /**
   * ホールディングス（`groups.id`）で絞り込み。★`null` = 絞らない★（指示書AK 段階2）。
   * ★`customerId` と同時に指定されたら AND★（`d-watch-api.yaml` の `GroupId`）。
   *
   * ★`ReportDbQuery`（`lib/types/`）には載せない★ — 期間の4つとは違い、
   *   データ層へは ★施設IDの配列に解決してから★ 渡す約束（指示書AK §2-2 / 段階3）。
   *   `facilityName` がデータ層に渡らないのと同じ理由（`toReportDbQuery()` の注記）。
   */
  groupId: number | null;
  /** 顧客（`customers.id`）で絞り込み。★`null` = 絞らない★ */
  customerId: number | null;
  /** 上位N件。page / per_page ではなく limit を持つ（YAML /reports/* の meta の説明を参照） */
  limit: number;
}

function toInt(value: string | null, fallback: number): number {
  if (!value) return fallback;
  const n = Number.parseInt(value, 10);
  return Number.isNaN(n) ? fallback : n;
}

/**
 * 必須の整数パラメータ用。欠落・整数以外は null（呼び出し側が422にする）。
 *
 * `toInt()` と分けている理由: period/year/month/week は「不正なら既定値へ丸める」のではなく
 * **422で弾く**必要がある（YAML で required / minimum / maximum を持つ）。
 * `Number.parseInt("2026abc")` が 2026 を返してしまうため、正規表現で全体一致を要求する。
 */
function toIntStrict(value: string | null): number | null {
  if (value === null || !/^-?\d+$/.test(value.trim())) return null;
  const n = Number.parseInt(value.trim(), 10);
  return Number.isNaN(n) ? null : n;
}

/**
 * クエリパラメータをパースする。
 * 不正な値があれば `{ ok: false }` を返す（`error` / `status` をそのまま422応答に使う。
 * どのパラメータが不正かは `error.error.details` に入る）。
 *
 * ★group_id / customer_id を読む（指示書AK 段階2。D-053 の「受け取っても無視する」は解除。
 *   /clean-logs・/prechecks・/ds-alerts など他の一覧APIと同じ扱い）★
 *   検証は `parseMasterIdFilters()`（`lib/api/master-id.ts`）の1本に任せる。
 *   ★`toIntStrict()` で書き直さないこと★ — あれは `^-?\d+$`（符号あり・0も通る）で、
 *   `group_id=0`（センチネル。D-011）と `group_id=-1` を素通しさせてしまう。
 *
 * period / year / month（と weekly のときの week）は **必須なので不正値は422**。
 * limit は不正値でも失敗させず、既定値・上限へ丸める（/clean-logs の per_page と同じ挙動）。
 */
export function parseReportQuery(
  searchParams: URLSearchParams
): QueryParseResult<ReportQuery> {
  const periodRaw = searchParams.get("period");
  if (periodRaw === null || !PERIODS.includes(periodRaw as Period)) {
    return fail(422, "validation_failed", "periodの値が不正です", {
      period: [`${PERIODS.join(" / ")} のいずれかを指定してください`],
    });
  }
  const period = periodRaw as Period;

  const year = toIntStrict(searchParams.get("year"));
  if (year === null) {
    return fail(422, "validation_failed", "yearの値が不正です", {
      year: ["西暦を整数で指定してください"],
    });
  }
  // ★整数であることだけでは足りない★ — 範囲外の年は month / week と同じく422で弾く。
  //   `toIntStrict()` は `99999999999999999999` を 1e20 として通してしまうので、
  //   ★この比較がデータ層へ渡さない最後の関門★（詳細は `YEAR_MIN` のコメント）。
  if (year < YEAR_MIN || year > YEAR_MAX) {
    return fail(422, "validation_failed", "yearの値が不正です", {
      year: [`${YEAR_MIN}〜${YEAR_MAX}で指定してください`],
    });
  }

  // ★`month` の正解は period で変わる★（YAML `Month`。指示書AQ 落とし穴6）
  //   - weekly / monthly … 1〜12（従来どおり。★`month=0` は422のまま★）
  //   - yearly           … ★0 だけ★（年次のサマリは `month=0 AND week=0`。CLAUDE.md §4.7）
  //   ★「yearly なら何でも通す」にしないこと★ — `period=yearly&month=9` が通ると
  //   ★年次の要求が月次の期間キーを持ったままデータ層に流れる★（fail-silent）。
  const month = toIntStrict(searchParams.get("month"));
  if (period === "yearly") {
    if (month !== MONTH_YEARLY) {
      return fail(422, "validation_failed", "monthの値が不正です", {
        month: [`period=yearly のときは${MONTH_YEARLY}を指定してください`],
      });
    }
  } else if (month === null || month < MONTH_MIN || month > MONTH_MAX) {
    return fail(422, "validation_failed", "monthの値が不正です", {
      month: [`${MONTH_MIN}〜${MONTH_MAX}で指定してください`],
    });
  }

  // ★period=monthly / yearly のときの week は無視する（YAML `Week`「monthly / yearly のときは無視」）★
  //   値が付いていてもエラーにせず読み捨て、★0（月次・年次を表す予約値。D-072）★にする。
  //   monthly は暦月で、yearly は暦年で締めるため週の指定に意味がない。
  //   ★API契約は変えていない。クエリで week=0 を要求する形にはしないこと★
  //   （年次も `week` は送らせない。`month=0` だけで年次と判る）
  let week = 0;
  if (period === "weekly") {
    const weekRaw = toIntStrict(searchParams.get("week"));
    if (weekRaw === null || weekRaw < WEEK_MIN || weekRaw > WEEK_MAX) {
      return fail(422, "validation_failed", "weekの値が不正です", {
        week: [
          `period=weekly のときは${WEEK_MIN}〜${WEEK_MAX}で指定してください`,
        ],
      });
    }
    week = weekRaw;
  }

  const statusRaw = searchParams.get("status") ?? "all";
  if (!FACILITY_STATUS_FILTERS.includes(statusRaw as FacilityStatus | "all")) {
    return fail(422, "validation_failed", "statusの値が不正です", {
      status: [
        `${FACILITY_STATUS_FILTERS.join(" / ")} のいずれかを指定してください`,
      ],
    });
  }

  const masters = parseMasterIdFilters(searchParams);
  if (!masters.ok) return masters;

  const limit = Math.min(
    PAGINATION.maxPerPage,
    Math.max(1, toInt(searchParams.get("limit"), DEFAULT_LIMIT))
  );

  return {
    ok: true,
    query: {
      period,
      year,
      month,
      week,
      facilityName: searchParams.get("facility_name")?.trim() ?? "",
      serial: searchParams.get("serial")?.trim() ?? "",
      status: statusRaw as FacilityStatus | "all",
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      limit,
    },
  };
}

/**
 * `ReportQuery`（HTTP のクエリ全体）から `ReportDbQuery`（データ層に渡す期間の4つ）を切り出す
 * （指示書AC ★段階5★。`toDbQueryDate()`（`lib/clean-logs/query.ts`）と同じ役目）。
 *
 * ★★ここを通すこと★★ — ★生の `searchParams` の値を `where` に入れない★（指示書AC §E-2）。
 *   `parseReportQuery()` が 422 で弾いたあとの値だけがデータ層に届く形にする。
 *
 * ★`facilityName` / `serial` / `status` / `limit` / `groupId` / `customerId` は渡さない★ —
 *   ★施設に関する条件（施設名・分類）は ★施設IDの配列に解決してから★ 渡す約束
 *   （指示書AK §2-2 / 段階3）。解決は非同期（DBを読む）なので、★この純粋関数の中では行わず
 *   ルート側で解決した結果を第2引数で受け取る★。
 *
 * @param facilityIds ★解決済みの施設ID★。★`null` = 施設で絞らない / `[]` = 0件★（★罠1★）。
 *   ★`/reports/kpi` は確定期間を `customer_summaries`（★施設ではなく**顧客**単位★）で
 *   読むので絞り込みの軸が ★`customer_id`★ になる（段階6。`toReportKpiDbQuery()`）が、
 *   ★★当期（進行中の週・月）は詳細テーブルをその場集計するのでこの値を使う★★
 *   （指示書AL ★段階1★。★以前は `null` 固定で、当期だけ全社合算になっていた★）。
 */
export function toReportDbQuery(
  query: ReportQuery,
  facilityIds: number[] | null = null
): ReportDbQuery {
  return {
    period: query.period,
    year: query.year,
    month: query.month,
    week: query.week,
    facility_ids: facilityIds,
  };
}

/**
 * `ReportQuery` ＋ ★決まった対象顧客★ → `ReportKpiDbQuery`（指示書AK ★段階6★）。
 *
 * ★★`customerId` は `parsed.query.customerId` ではない★★ — ★`resolveTargetCustomer()`
 *   （`lib/reports/target-customer.ts`）が決めた値★を渡すこと。
 *   ★クエリの `customer_id` をそのまま渡すと、未指定（`null`）の経路が型で弾かれず、
 *   「顧客が決まらないまま全顧客を読む」に戻ります★（§2-1 の決定の要）。
 *
 * ------------------------------------------------------------------------
 * ★★`facility_ids` は当期（進行中の週・月）のためにある★★（指示書AL ★段階1★）
 * ------------------------------------------------------------------------
 * ★確定期間は `customer_summaries` を `customer_id` で絞る★（段階6）が、
 * ★★当期は詳細テーブル（`clean_timetables`）をその場集計するので軸が**施設**になる★★。
 * ★2つの軸は別経路だが、同じ顧客を指していなければならない★ので、
 * ★★`facilityIds` は `customerId`（＝`resolveTargetCustomer()` が決めた顧客）から
 * 解決したものを渡すこと★★ — ★リクエストの `group_id` から直接引かないこと★
 * （母集団が確定期間とずれる。指示書AL §2-3 / 落とし穴1）。
 *
 * ★★`null` と `[]` を取り違えないこと★★（★罠1★）— `null` = 施設で絞らない /
 *   `[]` = 0件ヒット。★存在しない顧客を指定したときは `[]` になり、当期が空になる★
 *   （期待値表 1-3。★ここが `null` に化けると全社の数字が出ます★）。
 *
 * ★既定値を持たせていない★ — ★省略できると「絞り忘れ」が型で通ってしまう★ため、
 *   呼び出し側に必ず書かせる（`toReportDbQuery()` の第2引数とはそこが違う）。
 */
export function toReportKpiDbQuery(
  query: ReportQuery,
  customerId: number,
  facilityIds: number[] | null
): ReportKpiDbQuery {
  return {
    ...toReportDbQuery(query, facilityIds),
    customer_id: customerId,
  };
}

/** status のみで絞り込む。all は素通し */
export function filterFacilityPerformanceByStatus(
  items: FacilityPerformance[],
  status: FacilityStatus | "all"
): FacilityPerformance[] {
  if (status === "all") return items;
  return items.filter((item) => item.status === status);
}

/**
 * 施設名の部分一致。空文字（未指定）は素通し（/clean-logs の facility_name と同じ規則）。
 *
 * ★照合は `lib/format/search-text.ts` の1本に寄せる（指示書AG D-1）★
 *   記号（中点・長音・空白・ハイフン類）と大文字小文字を無視する。
 *   `ドンキホーテ` と打って `ドン・キホーテ戸塚` を引けるようにするため。
 */
export function filterFacilityPerformanceByName(
  items: FacilityPerformance[],
  facilityName: string
): FacilityPerformance[] {
  const keyword = facilityName.trim();
  if (keyword === "") return items;
  return items.filter((item) => includesFoldName(item.facility_name, keyword));
}

/**
 * シリアル番号の部分一致。空文字（未指定）は素通し。
 *
 * ★大文字小文字だけを無視する（記号は無視しない）★
 *   シリアルの記号は桁の境界なので、除くと `10001` が `PUDU-CC1-0001` に誤ヒットする
 *   （D-2。実例は `lib/format/search-text.ts` の冒頭）。施設名と揃っていないのは意図的。
 */
export function filterFacilityPerformanceBySerial(
  items: FacilityPerformance[],
  serial: string
): FacilityPerformance[] {
  const keyword = serial.trim();
  if (keyword === "") return items;
  return items.filter((item) => includesFoldPlain(item.serial_number, keyword));
}

/**
 * 稼働率（`operation_rate.value`）の昇順 = ワースト順（YAML /reports/facility-performance）。
 * 同値のときは facility_id の昇順で安定させる（D-052と同じ規則）。
 *
 * ★`value` が null の行は末尾に置く★
 *   null は「清掃予定が1件もなかった期間」（分母0。`KpiValue` のコメント）であり、
 *   「稼働率0%」とは意味が違う。昇順の先頭に来ると、実際に稼働できていない施設より
 *   「そもそも予定が無い施設」が上に並び、ワースト順の一覧として読めなくなる。
 *   降順に読み替える余地もないので、評価対象外として末尾へ寄せる。
 */
export function sortFacilityPerformance(
  items: FacilityPerformance[]
): FacilityPerformance[] {
  return [...items].sort((a, b) => {
    const aRate = a.operation_rate.value;
    const bRate = b.operation_rate.value;
    if (aRate === null || bRate === null) {
      // 片方だけ null なら null を後ろへ。両方 null なら facility_id で安定させる
      if (aRate !== null) return -1;
      if (bRate !== null) return 1;
      return a.facility_id - b.facility_id;
    }
    if (aRate !== bRate) return aRate - bRate;
    return a.facility_id - b.facility_id;
  });
}

/**
 * `FacilityPerformance[]` の `consecutive_problem_days` に★表示上限★をかける
 * （指示書AF 段階6）。★純粋関数★。
 *
 * ★★呼ぶ場所は絞り込み・並べ替えの**後**★★（`sortFacilityPerformance()` は稼働率で
 *   並べるので順序は潰れないが、「表示に載せる直前でだけ頭打ちにする」規律を
 *   `capCleanLogs()` / `capContinuousProblems()` と揃える）。
 *
 * ★★判定には一切かからない★★ — `status`（良好/注意/要改善）も L1〜L3 も
 *   ★上限をかける前の生値★で判定済み（`CLAUDE.md` §4.3 / 指示書AF 禁止事項9）。
 *
 * ★画面に出ないのに掛ける理由★: 契約の片方だけ頭打ちにすると、
 *   「同じ施設・同じ日なのに画面ごとに連続日数が違う」（D-109 項2 / D-103）が復活する。
 *
 * @param maxDays 表示上限（`loadConsecutiveDaysMax()` の戻り値を渡す。既定値を直書きしない）
 */
export function capFacilityPerformance(
  items: readonly FacilityPerformance[],
  maxDays: number
): FacilityPerformance[] {
  return items.map((item) => {
    const { days, capped } = capConsecutiveDays(item.consecutive_problem_days, maxDays);
    return {
      ...item,
      consecutive_problem_days: days,
      consecutive_problem_days_capped: capped,
    };
  });
}

/**
 * 連続問題施設は2日以上のみ返す（YAML /reports/continuous-problems。L1(初回)は含めない）。
 * 閾値は `lib/config` の `ESCALATION_THRESHOLDS.level2Days`（CLAUDE.md §7 ハードコード禁止）。
 */
export function filterContinuousProblems(
  items: ContinuousProblem[]
): ContinuousProblem[] {
  return items.filter(
    (item) => item.consecutive_problem_days >= ESCALATION_THRESHOLDS.level2Days
  );
}

/** 連続日数の降順。同値のときは facility_id の昇順で安定させる（D-052と同じ規則） */
export function sortContinuousProblems(
  items: ContinuousProblem[]
): ContinuousProblem[] {
  return [...items].sort((a, b) => {
    if (a.consecutive_problem_days !== b.consecutive_problem_days) {
      return b.consecutive_problem_days - a.consecutive_problem_days;
    }
    return a.facility_id - b.facility_id;
  });
}

/**
 * 件数の降順。★同値のときは type（生値）の文字列昇順★ で安定させる。
 *
 * ★localeCompare を使わない★ — ロケール依存を持ち込まないため。
 * `error_type` は PUDU 由来の ASCII 識別子（`EmergencyStop` 等）で、
 * 単純な `<` 比較で決定的な順序が得られる。**画面の順序がリロードで変わらないことが目的**で、
 * 「人間にとって自然な並び」を作ることは目的ではない。
 */
export function sortErrorBreakdown(
  items: ErrorBreakdownItem[]
): ErrorBreakdownItem[] {
  return [...items].sort((a, b) => {
    if (a.count !== b.count) return b.count - a.count;
    if (a.type === b.type) return 0;
    return a.type < b.type ? -1 : 1;
  });
}

/**
 * 行の期間キーが要求された期間と一致するか。
 *
 * ★monthly / yearly も `week` を 0 で持つ（D-072）ので、period による場合分けは不要★
 * `parseReportQuery()` が monthly / yearly のとき `week = 0` を、yearly ではさらに
 * `month = 0` を入れ、fixtures / サマリの月次行も `week: 0`、年次行は `month: 0, week: 0`
 * （CLAUDE.md §4.7）なので、★4項目の単純比較のまま年次も一致する★。
 *
 * ★`row.period` の一致も条件に入っている★ — サマリ行の `period` を決めるのは
 *   `periodOfSummaryRow()`（`lib/db/report-mapping.ts`）。★そちらが年次行を `monthly` と
 *   判定していると、ここでは弾けずに静かに混ざる★（指示書AQ 落とし穴2）。
 */
function matchesPeriod(row: ReportPeriodKey, query: ReportQuery): boolean {
  return (
    row.period === query.period &&
    row.year === query.year &&
    row.month === query.month &&
    row.week === query.week
  );
}

/**
 * `facilityPerformance()` / `continuousProblems()` / `errorBreakdown()` が返す全期間の行から、
 * 要求された期間に属する項目だけを取り出す（1期間に複数件あるのが通常。KPIの1行1期間とは違う）。
 * 該当が無ければ空配列（当期でまだ行が無い場合や、対象期間のデータが無い場合と区別しない。
 * 一覧が0件なのは呼び出し側にとって404にする理由にならないため。selectKpiRowとは事情が違う）。
 */
export function selectFacilityPerformanceRows(
  rows: FacilityPerformanceRow[],
  query: ReportQuery
): FacilityPerformance[] {
  return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}

export function selectContinuousProblemRows(
  rows: ContinuousProblemRow[],
  query: ReportQuery
): ContinuousProblem[] {
  return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}

export function selectErrorBreakdownRows(
  rows: ErrorBreakdownRow[],
  query: ReportQuery
): ErrorBreakdownItem[] {
  return rows.filter((row) => matchesPeriod(row, query)).map((row) => row.item);
}

/**
 * `reportKpis()` が返す全期間の行から、要求された期間の行を選ぶ。該当が無ければ null。
 *
 * ★当期かどうかで集計方法を変える処理はここに書かないこと★
 *   当期（進行中の週・月）はサマリテーブルに存在せず、詳細テーブルからその場集計する
 *   のが最終形（CLAUDE.md §4.7 / D-023）。ただし `DataSource` は引数を取らず全行を返す契約
 *   （`lib/db/index.ts`）なので、行の選び方は確定期間と当期で同じにしてある。
 *   当期判定は「実DB接続時にその場集計へ差し替える分岐点」を示す器としてだけ置いている。
 */
export function selectKpiRow(
  rows: ReportKpiRow[],
  query: ReportQuery
): ReportKpi | null {
  const matched = rows.find((row) => matchesPeriod(row, query));
  if (matched) return matched.kpi;

  if (isCurrentPeriod(query.period, query.year, query.month, query.week)) {
    // 当期はサマリに行が無いのが正常。★ここで集計方法を変えない（器だけ）★
    // 実DB接続時は、この分岐を「詳細テーブルからその場集計」に差し替える。
    return null;
  }
  // 確定期間で行が無い = その期間のデータがまだ無い（不正な期間ではない）。
  // 404 にせず null を返し、ルート側で空のKPIを組み立てる（指示書F 4-1）。
  return null;
}
