Newer
Older
client_dwatch / lib / reports / query.ts
/**
 * 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, periodRange } 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;
    // ★その月に存在しない週は422★(200・空にしない。指示書BB A8・D-182)—
    //   ★週の数え方を知っているのは `periodRange()`(`weekOfMonth()`)だけ★(D-071)。ここで週数を数え直さない
    if (periodRange("weekly", year, month, week) === null) {
      return fail(422, "validation_failed", "weekの値が不正です", {
        week: [`${year}年${month}月に第${week}週はありません`],
      });
    }
  }

  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;
}