import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
//   最初の1本だけがモジュール読み込みを丸ごと抱え込み、フルスイート実行時に既定の
//   タイムアウトを踏んで不定期に落ちる（`app/api/clean-logs/__tests__/route.test.ts` の実測）。
import { GET as GET_CONTINUOUS } from "@/app/api/reports/continuous-problems/route";
import { GET as GET_BREAKDOWN } from "@/app/api/reports/error-breakdown/route";
import { GET as GET_PERFORMANCE } from "@/app/api/reports/facility-performance/route";
import { GET as GET_KPI } from "@/app/api/reports/kpi/route";
import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
// ★データ層を直接呼ぶテストが1つだけある★（理由はそのテストの docstring）
import { db } from "@/lib/db";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type {
  ApiError,
  ContinuousProblem,
  ErrorBreakdownItem,
  FacilityPerformance,
  ListResponse,
  ReportKpi,
} from "@/lib/types";

/**
 * 指示書AC ★段階5★ の期待値表（#1〜#9）。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★確定期間（サマリ由来）の値を履歴から再計算しない★（★★禁止事項10 / D-102★★）—
 *    #1 は「詳細テーブルを引けば別の数字になる」データを置いて、
 *    ★サマリの値がそのまま出ること★ と ★詳細テーブルを1行も読まないこと★ を固定する
 *  - ★★連続日数は期間で絞らない★★（★★C-2 の地雷★★）— #6 は
 *    ★期間をまたぐ連続（4日）★ を置き、期間で絞ったら出てしまう値（2日）にならないことを見る
 *  - ★月跨ぎ週（08-30〜08-31 の2日）／月の第1週・最終週の境界★（#3 / #4）—
 *    ★範囲は `periodRange()` に作らせる★ので、SQL に降りた `start_at` の境界で固定する
 *
 * ------------------------------------------------------------------------
 * ★★DBには接続しません★★
 * ------------------------------------------------------------------------
 * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
 * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
 * ★`prismaSource` の経路★ を走らせる（段階2 / 段階4 のテストと同じ作法）。
 *
 * ★★合成データを使う理由★★: ローカルDB（`dwatch_local`）では
 *   - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★（C-6）
 *   - ★月跨ぎ週（8月第6週）に清掃データが無い★
 *   - ★「サマリと詳細が食い違う」状態を作れない★（D-102 の検出ができない）
 *
 * ★ローカルDBとの突き合わせは別で実施済み★ — 段階0（引数なし）と段階5（引数あり）の
 * 出力を11期間ぶん採って ★完全一致★ を確認した（`consecutive_problem_days` 込み。
 * 指示書AC 段階5 期待値表 #1 / #2 / #6 / G-3）。
 */

/**
 * ★`include: { robots: { orderBy: { id: "asc" } } }` を再現する★（指示書BB D7・表2 R9）—
 *   ★`orderBy` があるときだけ並べ替える★。実装が付け忘れたら、スタブはデータの順のまま返す（R9 が赤くなる）。
 *   `app/api/clean-logs/__tests__/route.test.ts` の `sortIncludedRobots()`（R5）と同じ作法
 */
function sortIncludedFacilityRobots(
  row: Record<string, unknown>,
  args: Record<string, unknown> | undefined
): Record<string, unknown> {
  const include = args?.include as { robots?: { orderBy?: { id?: string } } } | undefined;
  if (include?.robots?.orderBy?.id !== "asc" || !Array.isArray(row.robots)) return row;
  const robots = [...(row.robots as StubFacility["robots"])].sort(
    (a, b) => Number(a.id ?? 0) - Number(b.id ?? 0)
  );
  return { ...row, robots };
}

// ---------------------------------------------------------------- スタブ（DBの代わり）

interface StubFacility {
  id: bigint;
  facility_name: string;
  deleted_at: Date | null;
  /** `id` は省略可（★2台以上のときの並び `orderBy: { id: "asc" }` を見るため。指示書BB D7・表2 R9★） */
  robots: { id?: bigint; serial_number: string; deleted_at: Date | null }[];
  /**
   * ★分類（ホールディングス／屋号）の解決に使う2列★（指示書AK 段階4b）。
   * `loadFacilityGroupIndex()` が `select: { id, customer_id, customer: { group_id } }` で読む。
   * ★`customers.group_id` は NOT NULL で 0 = ホールディングスなし★（D-011）。
   */
  customer_id: bigint;
  customer: { group_id: bigint };
}

interface StubTimetable {
  id: bigint;
  facility_id: bigint;
  parent_id: bigint;
  start_at: Date;
  /** ★予定の終了（結果待ちの判定用。D-173）★。既定は開始の1時間後 */
  end_at: Date;
  precheck: number;
}

/**
 * ★清掃結果の取得元は `clean_task_reports`★（指示書AQ 決定2/3。旧 `clean_status_histories`）。
 *   ★`clean_error` 列は無い★ — ★未実施は「行が無い」で表す★（指示書AQ §1-1）。
 *   `clean_timetable_id` / `ended_at` は nullable（指示書AQ 罠1 / 罠3）。
 */
interface StubReport {
  clean_timetable_id: bigint | null;
  /** ★予定外タスク（指示書BG）の施設はこの列で決まる★。予定のある行は予定の施設を使うので省略可 */
  facility_id?: bigint;
  clean_ratio: number;
  status: number;
  started_at: Date;
  ended_at: Date | null;
  task_area: number;
  clean_area: number;
}

interface StubErrorReport {
  clean_timetable_id: bigint | null;
  /** ★予定外のエラー（指示書BG）の施設はこの列で決まる★ */
  facility_id: bigint;
  error_type: string;
  errored_at: Date | null;
  created_at: Date;
  /** ★DDL では NULL 可（第11回 P-8 b）。読まないこと（指示書AU 段階0-4）★ */
  robot_id?: bigint | null;
}

interface StubSummary {
  id: bigint;
  facility_id: bigint;
  customer_id: bigint;
  year: number;
  month: number;
  week: number;
  operation_ratio: number;
  launch_ratio: number;
  clean_ratio: number;
  error_ratio: number;
}

interface StubErrorSummary {
  id: bigint;
  facility_id: bigint;
  year: number;
  month: number;
  week: number;
  error_type: string;
  /**
   * ★2026-10-29 の DDL 変更で `error_detail` の代わりに入った列（NOT NULL・error_lists への FK）★。
   *   ★アプリは読まない★（`select` に入れない。内訳は `error_type` で束ねる）
   */
  error_list_id: bigint;
  error_count: number;
}

/**
 * 顧客マスタ（`customers`）。★段階6 で `/reports/kpi` が読むようになった★ —
 * ★対象顧客を1社に決める材料★（`resolveTargetCustomer()`。指示書AK §2-1）。
 * ★既定は1社だけ★にしてある（＝P1の実データ相当）ので、
 * ★`customer_id` を付けない既存のテストは今までどおり 200★ になる。
 */
interface StubCustomer {
  id: bigint;
  customer_name: string;
  /** ★NOT NULL。0 = ホールディングスなしのセンチネル★（D-011） */
  group_id: bigint;
  deleted_at: Date | null;
}

interface StubData {
  facilities: StubFacility[];
  customers: StubCustomer[];
  timetables: StubTimetable[];
  /** ★`clean_timetable_histories` の行（指示書AU 段階3b）。無ければ空★ */
  historyTimetables?: StubTimetable[];
  reports: StubReport[];
  errorReports: StubErrorReport[];
  customerSummaries: StubSummary[];
  facilitySummaries: StubSummary[];
  facilityErrorSummaries: StubErrorSummary[];
}

/** `findMany()` に渡された引数の記録（★どんな `where` が発行されたかを検査する★） */
const calls: Record<string, Record<string, unknown>[]> = {
  facility: [],
  customer: [],
  cleanTimetable: [],
  cleanTimetableHistory: [],
  cleanTaskReport: [],
  robotErrorReport: [],
  customerSummary: [],
  facilitySummary: [],
  facilityErrorSummary: [],
};

let data: StubData;

/** BigInt / number / null をまたいで id を比較する（`===` では比較できない） */
function sameId(a: unknown, b: unknown): boolean {
  if (a === null || a === undefined || b === null || b === undefined) return false;
  return String(a) === String(b);
}

/**
 * Prisma の `where` のうち、★本作業が使う分だけ★ を再現する（段階4 のテストの形 + `OR`）。
 *
 * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★（★罠1★）。
 *   ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
 */
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
  if (!where) return true;
  for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
    if (field === "OR") {
      // ★確定期間 ＋ 前期の2期間を1クエリで引くための `OR`★
      if (!(cond as unknown[]).some((sub) => matchesWhere(row, sub))) return false;
      continue;
    }
    const value = row[field];
    if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
      const c = cond as Record<string, unknown>;
      if ("in" in c) {
        if (c.in === undefined) {
          throw new Error(
            `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
          );
        }
        if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
      }
      if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
      if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
    } else if (typeof cond === "bigint" || typeof value === "bigint") {
      if (!sameId(value, cond)) return false;
    } else if (value !== cond) {
      return false;
    }
  }
  return true;
}

function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
  if (!select) return row;
  const out: Record<string, unknown> = {};
  for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
  return out;
}

function findMany(
  bucket: keyof typeof calls,
  rows: Record<string, unknown>[],
  args: Record<string, unknown> | undefined
) {
  calls[bucket].push(args ?? {});
  return rows
    .filter((row) => matchesWhere(row, args?.where))
    .map((row) => project(row, args?.select));
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));

// ★確定期間のサマリ0行の `data.anomaly`（指示書BB A11・表13）を見るため `logEvent` だけ差し替える（`withRequestId` はそのまま）★
vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

vi.mock("@/lib/db/prisma", () => ({
  getPrisma: async () => ({
    facility: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("facility", data.facilities as unknown as Record<string, unknown>[], args).map(
          (row) => sortIncludedFacilityRobots(row, args)
        ),
    },
    customer: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("customer", data.customers as unknown as Record<string, unknown>[], args),
    },
    cleanTimetable: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
    },
    // ★`clean_timetable_histories`（指示書AU 段階3b）。既定は空★
    cleanTimetableHistory: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "cleanTimetableHistory",
          (data.historyTimetables ?? []) as unknown as Record<string, unknown>[],
          args
        ),
    },
    cleanTaskReport: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "cleanTaskReport",
          data.reports as unknown as Record<string, unknown>[],
          args
        ),
    },
    robotErrorReport: {
      findMany: async (args?: Record<string, unknown>) => {
        // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる（指示書AU 段階0-4）★
        guardNullRobotId(data.errorReports as unknown as Record<string, unknown>[], args);
        return findMany(
          "robotErrorReport",
          data.errorReports as unknown as Record<string, unknown>[],
          args
        );
      },
    },
    customerSummary: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "customerSummary",
          data.customerSummaries as unknown as Record<string, unknown>[],
          args
        ),
    },
    facilitySummary: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "facilitySummary",
          data.facilitySummaries as unknown as Record<string, unknown>[],
          args
        ),
    },
    facilityErrorSummary: {
      findMany: async (args?: Record<string, unknown>) =>
        findMany(
          "facilityErrorSummary",
          data.facilityErrorSummaries as unknown as Record<string, unknown>[],
          args
        ),
    },
  }),
}));

// ---------------------------------------------------------------- 合成データ

/** DB に入っているのと同じ「壁時計値としての `Date`」（D-115。他のテストと同じヘルパー） */
function dbWallClock(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

/** ★実在の店名を使わない★（`CLAUDE.md` §7 / `docs/fixtures.md`） */
const FACILITY_1 = "バリューマート北町店";
const FACILITY_2 = "メガバリューマート緑野店";
/** ★中点入り★ — 記号を無視する照合（`includesFoldName`）の検証用（指示書AG） */
const FACILITY_3 = "バリュー・マート三つ角店";

const [FIRST_ROUND_MINUTES] = PRECHECK_MINUTES_BEFORE;

function timetable(
  id: number,
  facilityId: number,
  jstIso: string,
  precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
  parentId = 0
): StubTimetable {
  return {
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    parent_id: BigInt(parentId),
    start_at: dbWallClock(jstIso),
    end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
    precheck,
  };
}

/**
 * `clean_task_reports` の1行（＝その本清掃は実施された）。
 *
 * ★★「出発失敗（未実施）」はこの関数で作らない★★ — ★未実施 ＝ 対応するレポートの行が無い★
 *   （指示書AQ §1-1）。旧 `history(id, CLEAN_ERROR.notExecuted, …)` の行は ★行ごと置かない★ことで表す。
 */
function report(
  timetableId: number,
  cleanRatio: number,
  taskArea: number,
  cleanArea: number
): StubReport {
  return {
    clean_timetable_id: BigInt(timetableId),
    clean_ratio: cleanRatio,
    // ★稼働率の分子（正常終了）に使われる★（4 = 清掃正常終了。第11回 P-6=A。`isCompletedTask()`）。
    //   ★4 以外にすると当期の稼働率が変わる★（◎○△× と未実施の判定には使わない。D-155 項2）
    status: 4,
    started_at: dbWallClock("2026-09-29T23:00:00+09:00"),
    ended_at: dbWallClock("2026-09-30T00:00:00+09:00"),
    task_area: taskArea,
    clean_area: cleanArea,
  };
}

/** 顧客マスタ1行（`db.customers()` が `deleted_at IS NULL` で引く） */
function customer(id: number, name: string, groupId: number): StubCustomer {
  return {
    id: BigInt(id),
    customer_name: name,
    group_id: BigInt(groupId),
    deleted_at: null,
  };
}

function errorReport(timetableId: number | null, type: string, facilityId = 1): StubErrorReport {
  return {
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    facility_id: BigInt(facilityId),
    error_type: type,
    errored_at: dbWallClock("2026-09-13T02:30:00+09:00"),
    created_at: dbWallClock("2026-09-13T02:30:00+09:00"),
  };
}

/**
 * @param customerId ★既定は1社（＝P1の実データ相当）★。
 *   ★2社目を置きたいテストだけが明示的に渡す★（指示書AH 段階3 / D-5）
 */
function summary(
  id: number,
  facilityId: number,
  year: number,
  month: number,
  week: number,
  ratios: [number, number, number, number],
  customerId = 1
): StubSummary {
  return {
    id: BigInt(id),
    facility_id: BigInt(facilityId),
    customer_id: BigInt(customerId),
    year,
    month,
    week,
    operation_ratio: ratios[0],
    launch_ratio: ratios[1],
    clean_ratio: ratios[2],
    error_ratio: ratios[3],
  };
}

/**
 * ★2026年9月の週（`periodRange()` の実測）★:
 *   第1週 = 09-01〜09-05（★1日から。5日間★） / 第2週 = 09-06〜09-12 /
 *   ★第3週 = 09-13〜09-19（`NOW` の当期）★
 * ★2026年8月の週★: 第5週 = 08-23〜08-29 / ★第6週 = 08-30〜08-31（★月跨ぎ・2日★）
 */
function buildData(): StubData {
  return {
    // ★所属（指示書AK 段階4b）★: 施設1 = 屋号1（HD1）/ 施設2 = 屋号2（HD1）/
    //   施設3 = 屋号3（★ホールディングスなし。`group_id = 0` がセンチネル★。D-011）
    facilities: [
      { id: BigInt(1), facility_name: FACILITY_1, deleted_at: null, robots: [{ serial_number: "8111R56280600001", deleted_at: null }], customer_id: BigInt(1), customer: { group_id: BigInt(1) } },
      { id: BigInt(2), facility_name: FACILITY_2, deleted_at: null, robots: [{ serial_number: "8111R56280600002", deleted_at: null }], customer_id: BigInt(2), customer: { group_id: BigInt(1) } },
      { id: BigInt(3), facility_name: FACILITY_3, deleted_at: null, robots: [{ serial_number: "8111R56280600003", deleted_at: null }], customer_id: BigInt(3), customer: { group_id: BigInt(0) } },
    ],
    // ★★既定は屋号1の1社だけ★★（指示書AK 段階6）— ★P1 の実データ相当★。
    //   ★2社目を置きたいテスト（6-4 / 6-7）だけが `data.customers` を差し替える★。
    //   ★施設2・3 が指す屋号2・3 はマスタに置かない★ — 置くと「顧客が3社」になり、
    //   ★`customer_id` を付けていない既存のテストが全部 422 になる★。
    customers: [customer(1, "屋号1", 1)],
    timetables: [
      // --- 先週（第2週。★確定期間。サマリがある★）---
      //   ★詳細から集計すると稼働率 25% になるが、サマリは 90% と言っている★（D-102 の検出）
      timetable(201, 1, "2026-09-08T02:00:00+09:00"),
      timetable(202, 1, "2026-09-10T02:00:00+09:00"),
      // --- 当期（第3週）---
      timetable(301, 1, "2026-09-13T02:00:00+09:00"),
      timetable(302, 1, "2026-09-14T02:00:00+09:00"),
      // ★事前チェック行（301の子）★ — ★本清掃として数えてはいけない★
      timetable(3011, 1, "2026-09-13T01:00:00+09:00", FIRST_ROUND_MINUTES, 301),
      // ★★未来の予定★★（`isFutureSchedule()` を外すと分母に入る。禁止事項8）
      timetable(305, 1, "2026-09-18T02:00:00+09:00"),
      // --- 施設2: ★期間をまたぐ連続（09-10 / 09-12 / 09-14 / 09-15 の4日）★ ---
      timetable(311, 2, "2026-09-10T02:00:00+09:00"),
      timetable(312, 2, "2026-09-12T02:00:00+09:00"),
      timetable(313, 2, "2026-09-14T02:00:00+09:00"),
      // ★★壁時計 20:00 の清掃★★（C-6。★ローカルDBには1件も無い時間帯★）
      timetable(314, 2, "2026-09-15T20:00:00+09:00"),
      // --- 施設3（施設名の記号ゆれ検索用）---
      timetable(321, 3, "2026-09-16T02:00:00+09:00"),
      // --- 月境界（#3 / #4）---
      //   ★★施設3 に置いてある★★ — ★施設1・2 には第1週のサマリ行があり、
      //   確定期間の値が優先される（＝詳細の集計を観測できない）ため★。
      //   施設3 にはサマリが1行も無いので、★境界の行が入ったかどうかが数字に出る★。
      timetable(401, 3, "2026-08-29T02:00:00+09:00"), // 8月第5週
      timetable(402, 3, "2026-08-30T02:00:00+09:00"), // ★8月第6週の初日★
      timetable(403, 3, "2026-08-31T02:00:00+09:00"), // ★8月第6週の末日（＝月末）★
      timetable(404, 3, "2026-09-01T02:00:00+09:00"), // ★9月第1週の初日（＝1日）★
      timetable(405, 3, "2026-09-05T02:00:00+09:00"), // ★9月第1週の最終日★
      timetable(406, 3, "2026-09-06T02:00:00+09:00"), // 9月第2週
    ],
    // ★★未実施（202 / 302 / 311 / 312 / 313 / 403）はレポートの行を置かない★★（指示書AQ §1-1）—
    //   旧データでは `clean_error = notExecuted` の履歴行として置いていた。
    //   ★`clean_task_reports` には未実施を表す列が無いので「行が無い」で表す★
    reports: [
      report(201, 80, 100, 80),
      report(301, 95, 100, 95),
      // ★△（10以上70未満）＝ 問題日★
      report(314, 50, 100, 50),
      report(321, 100, 100, 100),
      report(401, 100, 100, 100),
      report(402, 90, 100, 90),
      report(404, 100, 100, 100),
      report(405, 60, 100, 60),
      report(406, 90, 100, 90),
    ],
    errorReports: [
      // ★★本清掃行（301）を指すエラー → 数える★★
      errorReport(301, "EmergencyStop"),
      // ★★事前チェック行（3011）を指すエラー → 数えない★★（D-118 / D-114 項2）
      errorReport(3011, "HandleUp"),
      // ★`clean_timetable_id` が null ＝ 予定外のエラー → ★当期の数に入る★（指示書BG §2-4・A案。旧: 数えない（決定3））。
      //   ★施設3（屋号3）に置く★ — 顧客1（施設1）の KPI の数字を変えないため。全社のエラー内訳（絞り込みなし）には出る
      errorReport(null, "LostLocation", 3),
      // ★dangling（存在しない本清掃を指す）→ 数えない★（決定3）
      errorReport(9999, "BrushError"),
      // ★先週の本清掃を指すエラー → 当期には入らない★
      errorReport(201, "EmergencyStop"),
    ],
    // ★全社サマリ: 第2週（確定）とその前期（第1週）★
    customerSummaries: [
      summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]),
      summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]),
      // ★年次（month=0 && week=0）の行★ — ★2026-09-30 に年次を解禁した（指示書AQ 決定1）★。
      //   旧コメントは「年次は読まない（D-072項4）」。★週次・月次の要求に混ざらないこと★は
      //   期間キーの完全一致と `periodOfSummaryRow()` が担う（年次のテストは末尾の describe）
      summary(3, 0, 2026, 0, 0, [11, 11, 11, 11]),
    ],
    facilitySummaries: [
      summary(11, 1, 2026, 9, 2, [95, 95, 88, 0]),
      summary(12, 2, 2026, 9, 2, [60, 60, 40, 20]),
      summary(13, 1, 2026, 9, 1, [85, 85, 80, 0]),
      summary(14, 2, 2026, 9, 1, [50, 50, 30, 25]),
    ],
    facilityErrorSummaries: [
      { id: BigInt(21), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "HandleUp", error_list_id: BigInt(131), error_count: 2 },
      { id: BigInt(22), facility_id: BigInt(2), year: 2026, month: 9, week: 2, error_type: "HandleUp", error_list_id: BigInt(131), error_count: 1 },
      { id: BigInt(23), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "EmergencyStop", error_list_id: BigInt(131), error_count: 1 },
      // ★別の期間の行は混ざらない★
      { id: BigInt(24), facility_id: BigInt(1), year: 2026, month: 9, week: 1, error_type: "BrushError", error_list_id: BigInt(131), error_count: 9 },
    ],
  };
}

/** ★当期 = 2026年9月第3週（09-13〜09-19）★。09-18 の予定はまだ未来 */
const NOW = new Date("2026-09-16T09:00:00+09:00");

/** 当期（第3週） */
const CURRENT = "period=weekly&year=2026&month=9&week=3";
/** 確定期間（第2週。サマリがある） */
const CONFIRMED = "period=weekly&year=2026&month=9&week=2";

async function kpi(search: string): Promise<ReportKpi> {
  const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`));
  return (await res.json()) as ReportKpi;
}

async function performance(search: string): Promise<ListResponse<FacilityPerformance>> {
  const res = await GET_PERFORMANCE(
    new Request(`http://localhost/api/reports/facility-performance?${search}`)
  );
  return (await res.json()) as ListResponse<FacilityPerformance>;
}

async function continuous(search: string): Promise<ListResponse<ContinuousProblem>> {
  const res = await GET_CONTINUOUS(
    new Request(`http://localhost/api/reports/continuous-problems?${search}`)
  );
  return (await res.json()) as ListResponse<ContinuousProblem>;
}

async function breakdown(search: string): Promise<{ items: ErrorBreakdownItem[] }> {
  const res = await GET_BREAKDOWN(
    new Request(`http://localhost/api/reports/error-breakdown?${search}`)
  );
  return (await res.json()) as { items: ErrorBreakdownItem[] };
}

/** 当期の集計で使われた `clean_timetables` の本体クエリ（＝ `where.precheck` を持つもの） */
function mainQueryWhere(): Record<string, unknown> {
  const call = calls.cleanTimetable.find((c) => !isConsecutiveQuery(c) && "where" in c);
  expect(call).toBeDefined();
  return (call as { where: Record<string, unknown> }).where;
}

/**
 * ★連続日数用のクエリか★（`loadConsecutiveInputs()` が発行する1本）。
 *
 * ★★`select` の中身で見分けること★★ — 段階6 でこのクエリにも
 *   `where: { precheck, start_at }` が付いたので、★「`where` を持たない」では
 *   当期の主クエリ（`select: { id, facility_id, start_at }`）と区別できない★。
 *   ★連続日数用だけが `precheck` を**読む**（本清掃の絞り込みをメモリ側でも行うため）★。
 */
function isConsecutiveQuery(call: Record<string, unknown>): boolean {
  const select = call.select as Record<string, unknown> | undefined;
  return select?.precheck === true && select.facility_id === true;
}

/** ★連続日数用のクエリの `where`★ */
function consecutiveQueryWhere(): Record<string, unknown> {
  const call = calls.cleanTimetable.find(isConsecutiveQuery);
  expect(call).toBeDefined();
  return (call as { where: Record<string, unknown> }).where;
}

beforeEach(() => {
  data = buildData();
  for (const key of Object.keys(calls)) calls[key] = [];
  // ★`new Date()` を固定する（Date だけを差し替え、タイマーは触らない）★
  vi.useFakeTimers({ toFake: ["Date"] });
  vi.setSystemTime(NOW);
});

afterEach(() => {
  vi.useRealTimers();
});

// ---------------------------------------------------------------- テスト

describe("#1 確定期間 — ★サマリの値をそのまま返す（履歴から再計算しない）★", () => {
  it("KPI はサマリの4率そのもの。前期比は1つ前の期間のサマリから", async () => {
    const body = await kpi(CONFIRMED);
    // ★★詳細から集計すると稼働率は 25%（4件中1件）になる★★ — サマリの 90 が出ること
    expect(body.operation_rate.value).toBe(90);
    expect(body.operation_rate.prev).toBe(80);
    expect(body.launch_rate.value).toBe(88);
    expect(body.completion_rate.value).toBe(77.7);
    expect(body.error_rate.value).toBe(5.5);
  });

  it("★★確定期間では詳細テーブルを1行も読まない★★（★禁止事項10 / D-102★）", async () => {
    await kpi(CONFIRMED);
    expect(calls.cleanTimetable).toEqual([]);
    expect(calls.cleanTaskReport).toEqual([]);
    expect(calls.robotErrorReport).toEqual([]);
  });

  it("★サマリは「要求された期間 ＋ 前期」の2期間だけ引く（全件読みに戻さない）★", async () => {
    await kpi(CONFIRMED);
    expect(calls.customerSummary).toHaveLength(1);
    const where = calls.customerSummary[0].where as { OR: Record<string, number>[] };
    expect(where.OR).toEqual([
      { year: 2026, month: 9, week: 2 },
      { year: 2026, month: 9, week: 1 },
    ]);
  });

  it("施設別パフォーマンスもサマリの値。前期比つき", async () => {
    const body = await performance(`${CONFIRMED}&limit=100`);
    const fac1 = body.items.find((item) => item.facility_id === 1);
    expect(fac1?.operation_rate.value).toBe(95);
    expect(fac1?.operation_rate.prev).toBe(85);
    expect(fac1?.completion_rate.value).toBe(88);
    // ★★確定期間では詳細テーブルを読まない（連続日数の1本を除く）★★
    expect(calls.cleanTimetable.filter((c) => !isConsecutiveQuery(c))).toEqual([]);
  });

  it("★R9: 施設にロボット2台（id 10 と id 7）→ id 7 のシリアルを採る。`robots` の並びは `id` 昇順を SQL に頼む。ログは出さない★", async () => {
    // 施設に複数台は正常（指示書BB 表2 R9）なので `data.anomaly` は出さない
    data.facilities = data.facilities.map((f) =>
      Number(f.id) === 1
        ? {
            ...f,
            robots: [
              { id: BigInt(10), serial_number: "SERIAL-ID10", deleted_at: null },
              { id: BigInt(7), serial_number: "SERIAL-ID7", deleted_at: null },
            ],
          }
        : f
    );
    vi.mocked(logEvent).mockClear();
    const body = await performance(`${CONFIRMED}&limit=100`);
    expect(body.items.find((item) => item.facility_id === 1)?.serial_number).toBe("SERIAL-ID7");
    const include = calls.facility.find((c) => "include" in c)?.include as {
      robots: { orderBy: unknown };
    };
    expect(include.robots.orderBy).toEqual({ id: "asc" });
    expect(
      vi.mocked(logEvent).mock.calls.filter(([, p]) => p.event === "data.anomaly")
    ).toEqual([]);
  });

  it("エラー種別内訳はサマリを施設ぶん足し上げる（件数の降順）", async () => {
    const body = await breakdown(CONFIRMED);
    expect(body.items.map((i) => [i.type, i.count])).toEqual([
      ["HandleUp", 3],
      ["EmergencyStop", 1],
    ]);
    // ★別の期間（第1週）の行が混ざっていない★
    expect(body.items.some((i) => i.type === "BrushError")).toBe(false);
    // ★削除済みの施設の行は数えない（指示書BB U1・表1 V14。清掃エリアの削除は外せない — D5）★
    expect(calls.facilityErrorSummary[0].where).toEqual({
      year: 2026,
      month: 9,
      week: 2,
      facility: { deleted_at: null },
    });
  });

  it("★同じ施設・同じ error_type で error_list_id が違う2行は1項目に足す★（2026-10-29 の DDL 変更）", async () => {
    // ★サマリの行は error_list_id ごとにも分かれるようになった★が、API の出力（種別ごとの件数）は変えない
    data.facilityErrorSummaries.push(
      { id: BigInt(25), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "LostLocalization", error_list_id: BigInt(38), error_count: 4 },
      { id: BigInt(26), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "LostLocalization", error_list_id: BigInt(131), error_count: 3 }
    );
    const body = await breakdown(CONFIRMED);
    expect(body.items.map((i) => [i.type, i.count])).toEqual([
      ["LostLocalization", 7],
      ["HandleUp", 3],
      ["EmergencyStop", 1],
    ]);
    // ★使う列だけ読む★ — ★`error_detail`（消えた列）も `error_list_id` も SELECT しない★
    //   （`select` 無しの全列読みで、列の削除が500になった。2026-10-29）
    expect(calls.facilityErrorSummary[0].select).toEqual({
      year: true,
      month: true,
      week: true,
      error_type: true,
      error_count: true,
    });
  });
});

describe("#2 当期（進行中の週）— ★詳細テーブルからその場集計する★", () => {
  /**
   * ★★2026-09-16（指示書AL ★段階1★）に期待値を作り直した★★。
   *
   * ★以前は「全施設（1・2・3）の合算」を固定していた★（planned=5 / executed=3 /
   * 完了率 49 / エラー率 33.333）。★当期KPIが顧客で絞られていなかったため★で、
   * ★★その数字は「確定期間は顧客1社・当期は全社」という食い違いそのもの★★だった。
   *
   * ★いまは `customer_id` 未指定でも顧客は1社に定まる★（`buildData()` の顧客マスタは
   * ★屋号1 の1社だけ＝P1の実データ相当★）ので、★その顧客の全施設＝施設1だけ★が母集団になる
   * （期待値表 ★1-5★）。★テストを消したのではなく、正しい母集団に直した★。
   */
  it("KPI は詳細からの集計。前期比は先週のサマリから（★対象顧客＝屋号1 の施設1だけ★）", async () => {
    const body = await kpi(CURRENT);
    // planned=2（301/302。★305 は未来なので入らない★）/ executed=1（301）
    expect(body.operation_rate.value).toBe(50);
    expect(body.launch_rate.value).toBe(50);
    // 47.5 → 95: 面積比 (95+0)/200 → 1回ごとの完了率（`runCompletionRate()`）の平均（指示書AS。95/100 → 95）。
    //   fixture は `clean_ratio` 列と clean_area÷task_area を揃えてあるので、列でも面積でも同じ値になる。
    //   ★未実施（302）はレポート行が無いので平均に入らない★（§1-1）。0% として混ぜたら 47.5 になる。
    //   ★全社合算なら (95+50+100)/3 ≒ 81.67 になる★
    expect(body.completion_rate.value).toBeCloseTo(95, 10);
    // ★分子は本清掃行を指すエラーだけ（301 の1件）/ 分母は清掃予定回数（2件。第11回 P-6=B）★
    expect(body.error_rate.value).toBe(50);
    expect(body.operation_rate.prev).toBe(90);
  });

  it("★★本清掃の絞り込みと期間の絞り込みが SQL に降りている★★（全件読みに戻さない）", async () => {
    await kpi(CURRENT);
    const where = mainQueryWhere();
    expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
    const startAt = where.start_at as { gte: Date; lt: Date };
    // ★★壁時計値であること★★（C-6。★生の `parseDate()`（真の瞬間）ではない★）
    expect(startAt.gte.toISOString()).toBe("2026-09-13T00:00:00.000Z");
    expect(startAt.lt.toISOString()).toBe("2026-09-20T00:00:00.000Z");
  });

  it("★清掃結果（clean_task_reports）・エラー報告は引いた本清掃の id でしか引かない★", async () => {
    await kpi(CURRENT);
    const historyWhere = calls.cleanTaskReport.find((c) => "where" in c) as {
      where: { clean_timetable_id: { in: bigint[] } };
    };
    // ★期間内の本清掃行すべて★（★305 は未来の予定だが、それを外すのは
    //   `isFutureSchedule()`（純粋関数）の仕事で SQL の仕事ではない★。
    //   ★SQL 側で未来を外さないこと★ — 「いま何時か」を `where` に混ぜると、
    //   同じクエリが実行時刻で別の結果を返すようになる）
    // ★★施設2・3 の行（313/314/321）は入らない★★ — ★当期も対象顧客の施設だけを読む★
    //   （指示書AL 段階1。★以前は全施設ぶん読んでいた★）。
    expect(historyWhere.where.clean_timetable_id.in.map(String).sort()).toEqual(
      ["301", "302", "305"].sort()
    );
  });

  it("★★事前チェック行が本清掃として数えられない★★（`precheck` を SQL から外さない）", async () => {
    const body = await performance(`${CURRENT}&limit=100`);
    const fac1 = body.items.find((item) => item.facility_id === 1);
    // 301（実施）/ 302（未実施）の2件だけ。3011 が混ざると分母が3になる
    expect(fac1?.operation_rate.value).toBe(50);
  });

  it("★壁時計 20:00 の清掃がその週に含まれる★（C-6。★ローカルDBでは検出できない★）", async () => {
    const body = await performance(`${CURRENT}&limit=100`);
    const fac2 = body.items.find((item) => item.facility_id === 2);
    // 313（未実施）/ 314（20:00・完了率50）の2件
    expect(fac2?.operation_rate.value).toBe(50);
    // 25 → 50: 面積比 50/200 → 1回ごとの完了率の平均（未実施 313 は入らない。50/100 → 50）。指示書AS
    //   ★314 が週から落ちていれば null になる★（C-6 の検出力は operation_rate と併せて保たれる）
    expect(fac2?.completion_rate.value).toBe(50);
  });
});

describe("#3 月跨ぎ週（2026年8月第6週 ＝ 08-30〜08-31 の2日）", () => {
  /** ★その週を「当期」にして、詳細テーブルの絞り込みを走らせる★ */
  beforeEach(() => {
    vi.setSystemTime(new Date("2026-08-31T23:00:00+09:00"));
  });

  it("★★`periodRange()` の返す範囲どおりに SQL が絞る★★（08-30 00:00 以上 / 09-01 00:00 未満）", async () => {
    await kpi("period=weekly&year=2026&month=8&week=6");
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    expect(startAt.gte.toISOString()).toBe("2026-08-30T00:00:00.000Z");
    expect(startAt.lt.toISOString()).toBe("2026-09-01T00:00:00.000Z");
  });

  it("★2日ぶんだけが集計される★（08-29 も 09-01 も入らない）", async () => {
    // ★★境界の行（401〜406）は施設3 に置いてある★★（`buildData()` の注記）ので、
    //   ★施設3 の屋号（＝3）を対象顧客にして引く★（指示書AL 段階1 で当期も顧客で
    //   絞るようになったため。★`customer_id` の存在確認はしない契約★なので、
    //   顧客マスタに無い 3 でもそのまま対象顧客になる＝期待値表 6-9 と同じ形）。
    const body = await kpi("period=weekly&year=2026&month=8&week=6&customer_id=3");
    // 402（実施・90/100）/ 403（未実施）の2件
    expect(body.operation_rate.value).toBe(50);
    // 45 → 90: 面積比 90/200 → 1回ごとの完了率の平均（未実施 403 は入らない。90/100 → 90）。指示書AS。
    //   ★08-29（401=100）や 09-01（404=100）が混ざれば 95 になる★
    expect(body.completion_rate.value).toBe(90);
  });
});

describe("#4 月の第1週（必ず1日から）／最終週（必ず末日で終わる）", () => {
  it("★第1週は 09-01 から 09-05★（前月の 08-31 も 第2週の 09-06 も入らない）", async () => {
    vi.setSystemTime(new Date("2026-09-05T23:00:00+09:00"));
    // ★施設3（サマリが1行も無い＝詳細の集計がそのまま出る）で見る★
    const body = await performance("period=weekly&year=2026&month=9&week=1&limit=100");
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    expect(startAt.gte.toISOString()).toBe("2026-09-01T00:00:00.000Z");
    expect(startAt.lt.toISOString()).toBe("2026-09-06T00:00:00.000Z");
    // 404（100/100）/ 405（60/100）の2件。★境界の行が落ちていない★
    //   （★完了率 80 は期間の面積比でも1回ごとの完了率の平均でも同じ値★なので期待値は据え置き）
    const fac3 = body.items.find((item) => item.facility_id === 3);
    expect(fac3?.operation_rate.value).toBe(100);
    expect(fac3?.completion_rate.value).toBe(80);
  });

  it("★最終週は月末で終わる★（8月第6週の `lt` が 09-01。#3 と同じ境界）", async () => {
    vi.setSystemTime(new Date("2026-08-31T23:00:00+09:00"));
    await kpi("period=weekly&year=2026&month=8&week=6");
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    expect(startAt.lt.toISOString()).toBe("2026-09-01T00:00:00.000Z");
  });

  it("★その月に存在しない週（9月の第6週）は 422★（指示書BB A8・表8 W2。旧: 200・空）", async () => {
    const q = "period=weekly&year=2026&month=9&week=6";
    const expected = {
      error: {
        code: "validation_failed",
        message: "weekの値が不正です",
        details: { week: ["2026年9月に第6週はありません"] },
      },
    };
    for (const res of [
      await GET_KPI(new Request(`http://localhost/api/reports/kpi?${q}`)),
      await GET_CONTINUOUS(new Request(`http://localhost/api/reports/continuous-problems?${q}`)),
      await GET_PERFORMANCE(new Request(`http://localhost/api/reports/facility-performance?${q}`)),
      await GET_BREAKDOWN(new Request(`http://localhost/api/reports/error-breakdown?${q}`)),
    ]) {
      expect(res.status).toBe(422);
      expect(await res.json()).toEqual(expected);
    }
    // ★データ層まで来ない★
    expect(calls.cleanTimetable).toHaveLength(0);
  });
});

describe("#5 サマリに行が無い期間 — ★空のKPI（404 にしない）★", () => {
  it("KPI は4指標すべて null", async () => {
    const body = await kpi("period=weekly&year=2026&month=7&week=1");
    expect(body).toEqual({
      operation_rate: { value: null, prev: null, diff: null },
      launch_rate: { value: null, prev: null, diff: null },
      completion_rate: { value: null, prev: null, diff: null },
      error_rate: { value: null, prev: null, diff: null },
    });
  });

  it("一覧3本は空配列で 200", async () => {
    const q = "period=weekly&year=2026&month=7&week=1";
    expect((await performance(q)).items).toEqual([]);
    expect((await continuous(q)).items).toEqual([]);
    expect((await breakdown(q)).items).toEqual([]);
  });
});

describe("★★#6 連続日数 — 期間で絞らない（C-2 の地雷）★★", () => {
  it("★★期間をまたぐ連続が途切れない★★（09-10/09-12/09-14/09-15 → ★4日★）", async () => {
    const body = await performance(`${CURRENT}&limit=100`);
    const fac2 = body.items.find((item) => item.facility_id === 2);
    // ★★期間（09-13〜09-19）で絞ると 2 になる★★ — そうなっていたらここで落ちる
    expect(fac2?.consecutive_problem_days).toBe(4);
  });

  it("★連続問題施設も同じ数字・同じ判定★（L3。D-109 の再発防止）", async () => {
    const body = await continuous(`${CURRENT}&limit=100`);
    expect(body.items.map((i) => [i.facility_id, i.consecutive_problem_days, i.escalation_level])).toEqual([
      [2, 4, "L3"],
    ]);
  });

  it("★★連続日数の読み取りは期間の手前を「上限 +1日ぶん」余分に遡る★★（構造。指示書AC 段階6）", async () => {
    await performance(`${CURRENT}&limit=100`);
    const consecutiveCall = calls.cleanTimetable.find(isConsecutiveQuery);
    expect(consecutiveCall).toBeDefined();
    expect((consecutiveCall as { select: object }).select).toEqual({
      id: true,
      facility_id: true,
      start_at: true,
      // ★結果待ちの判定用（D-173）★
      end_at: true,
      precheck: true,
    });

    // ★★要求された期間の開始日をそのまま下限にしたら連続が途切れる（C-2 の地雷）★★ —
    //   遡り幅は ★保存された表示上限★ から導く（★定数に写し取らないこと★。申し送り⑪）
    const main = mainQueryWhere().start_at as { gte: Date; lt: Date };
    const consecutive = consecutiveQueryWhere().start_at as { gte: Date; lt: Date };
    expect(consecutive.lt.getTime()).toBe(main.lt.getTime());
    expect((main.gte.getTime() - consecutive.gte.getTime()) / 86_400_000).toBe(
      consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax())
    );
  });

  it("★禁止事項8: 未来の予定は連続日数にも分母にも入らない★（`isFutureSchedule()`）", async () => {
    const body = await performance(`${CURRENT}&limit=100`);
    const fac1 = body.items.find((item) => item.facility_id === 1);
    // 09-14（未実施）だけが問題日。09-18 の未来の予定を数えると 2 になる
    expect(fac1?.consecutive_problem_days).toBe(1);
  });
});

describe("#7 `period=weekly` かつ `week=0` → ★422★（`week=0` は月次の予約値）", () => {
  it("4本とも 422 を返し、DB を1本も引かない", async () => {
    for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) {
      const res = await get(
        new Request("http://localhost/api/reports/x?period=weekly&year=2026&month=9&week=0")
      );
      expect(res.status).toBe(422);
      const body = (await res.json()) as ApiError;
      expect(body.error.code).toBe("validation_failed");
    }
    for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]);
  });
});

/**
 * ★`year` の範囲外 → 422★（`d-watch-api.yaml` の `Year` / `lib/config` の `REPORT_YEAR`）
 *
 * ★直す前の実測（2026-09-14 / develop `3e48d30`。ローカルDB接続の dev サーバ）★:
 *   - `period=monthly&year=-1&month=9` → ★200★（空のKPI。紀元前が有効な期間になっていた）
 *   - `period=weekly&year=-1` / `year=0` / `year=1000000` → ★500★
 *     （`lib/format/period.ts` の `addDays` が `00-1-09-05` 等で RangeError）
 *   - `year=99999999999999999999` → ★500★（Prisma: Unable to fit value into a
 *     64-bit signed integer for field `year`。SQLは出ないが、
 *     ★攻撃者が選んだ値が `logApiError` 経由でサーバログに載る★）
 *   いずれも入力の誤りなので、DBに触る前に422で弾く。
 */
describe("★`year` が範囲外 → 422★（month / week と同じ作法。DBに降ろさない）", () => {
  it("4本とも 422 を返し、DB を1本も引かない", async () => {
    const outOfRange = ["-1", "0", "1999", "2101", "1000000", "99999999999999999999"];
    for (const year of outOfRange) {
      for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) {
        const res = await get(
          new Request(
            `http://localhost/api/reports/x?period=weekly&year=${year}&month=9&week=2`
          )
        );
        expect(res.status).toBe(422);
        const body = (await res.json()) as ApiError;
        expect(body.error.code).toBe("validation_failed");
        expect(body.error.details).toEqual({
          year: ["2000〜2100で指定してください"],
        });
      }
    }
    for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]);
  });

  it("★period=monthly でも422★（旧実装ではここだけ200で通っていた）", async () => {
    const res = await GET_KPI(
      new Request("http://localhost/api/reports/kpi?period=monthly&year=-1&month=9")
    );
    expect(res.status).toBe(422);
    for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]);
  });

  it("境界ちょうど（2000 / 2100）は422にならない（データが無いだけ）", async () => {
    for (const year of ["2000", "2100"]) {
      const res = await GET_KPI(
        new Request(
          `http://localhost/api/reports/kpi?period=weekly&year=${year}&month=9&week=2`
        )
      );
      expect(res.status).toBe(200);
    }
  });
});

describe("★★#8 エラー種別内訳の分子は「本清掃行を指す」エラーだけ★★（D-118 / D-114 項2）", () => {
  it("事前チェック行・dangling・別期間は数えない。★予定外（null）は数える（指示書BG §2-4）★", async () => {
    const body = await breakdown(CURRENT);
    expect(body.items).toEqual([
      { type: "EmergencyStop", label: "EmergencyStop", count: 1, rate: 50 },
      // ★施設3 の予定外のエラー（`clean_timetable_id` が null。errored_at 09-13 02:30 は当期）★
      { type: "LostLocation", label: "LostLocation", count: 1, rate: 50 },
    ]);
  });

  it("★エラー行は本清掃の id でしか引かない（全件読みに戻さない）★", async () => {
    await breakdown(CURRENT);
    const where = calls.robotErrorReport[0].where as {
      clean_timetable_id: { in: bigint[] };
    };
    // ★期間内の本清掃行すべて（事前チェック行 3011 は入らない）★
    expect(where.clean_timetable_id.in.map(String).sort()).toEqual(
      ["301", "302", "305", "313", "314", "321"].sort()
    );
  });
});

/**
 * ★当期のエラー内訳は、未来・結果待ちの予定を指すエラーを数えない★（ユーザー指示 2026-10-29
 * 「結果待ちなので、まだエラーかどうか確定してない」）— ★エラー発生率の分子と同じ集合★。
 *
 * 今 = 09-16 09:00（`NOW`）・結果待ち 120分・予定の終了は開始の1時間後。施設1（屋号1。KPI の母集団）に足す:
 *
 * | 予定 | 開始 | レポート | エラー | 内訳 |
 * |---|---|---|---|---|
 * | 301（既存） | 09-13 02:00 | あり | EmergencyStop | ★数える★ |
 * | 302（既存） | 09-14 02:00 | 無し（未実施） | ★HandleUp を足す★ | ★数える★ |
 * | 305（既存） | 09-18 02:00（未来） | 無し | ★BrushError を足す★ | 数えない |
 * | 306 | 09-16 06:00（終了＋120分 ＝ 09:00 ちょうど） | 無し | ★LostLocation★ | ★数える★（境界は確定） |
 * | 307 | 09-16 06:01（終了＋120分 ＝ 09:01） | 無し | ★Pending★ | 数えない（結果待ち） |
 */
describe("★当期のエラー内訳 — 未来・結果待ちの予定を数えない（ユーザー指示 2026-10-29）★", () => {
  beforeEach(() => {
    data.timetables.push(
      timetable(306, 1, "2026-09-16T06:00:00+09:00"),
      timetable(307, 1, "2026-09-16T06:01:00+09:00")
    );
    data.errorReports.push(
      errorReport(302, "HandleUp"),
      errorReport(305, "BrushError"),
      errorReport(306, "LostLocation"),
      errorReport(307, "Pending")
    );
  });

  it("未実施・境界ちょうどは数え、未来・結果待ちは数えない", async () => {
    const body = await breakdown(CURRENT);
    expect(body.items.map((i) => [i.type, i.count]).sort()).toEqual(
      [
        ["EmergencyStop", 1],
        ["HandleUp", 1],
        // ★306 の1件 ＋ 施設3 の予定外のエラー（null）1件★（絞り込みなし。指示書BG §2-4）
        ["LostLocation", 2],
      ].sort()
    );
  });

  it("★★内訳の件数の合計 ＝ KPI のエラー発生率の分子★★（同じ母集団・同じ now）", async () => {
    const items = (await breakdown(`${CURRENT}&customer_id=1`)).items;
    const total = items.reduce((sum, i) => sum + i.count, 0);
    const body = await kpi(`${CURRENT}&customer_id=1`);
    // ★予定回数は 301 / 302 / 306 の3件（305 は未来・307 は結果待ちで入らない）★。
    //   出発成功率 ＝ 開始1件（301）÷ 予定 なので、予定回数は 100 ÷ 出発成功率 × 1
    expect(body.launch_rate.value).toBeCloseTo(100 / 3, 10);
    expect(total).toBe(3);
    expect(body.error_rate.value).toBeCloseTo((total / 3) * 100, 10);
  });

  it("★レポートは引いた本清掃の id でだけ、有無の判定に要る列だけ読む★", async () => {
    await breakdown(CURRENT);
    const call = calls.cleanTaskReport[0] as {
      where: { clean_timetable_id: { in: bigint[] } };
      select: Record<string, boolean>;
    };
    expect(call.where.clean_timetable_id.in.map(String).sort()).toEqual(
      ["301", "302", "305", "306", "307", "313", "314", "321"].sort()
    );
    expect(call.select).toEqual({ clean_timetable_id: true, started_at: true, ended_at: true });
  });
});

describe("#9 読み込み行数 — ★期間で絞ったぶんしか読まない★", () => {
  it("当期でも `clean_timetables` は本清掃かつ期間内の行だけ", async () => {
    await kpi(CURRENT);
    // ★`where` 付きのクエリはすべて `start_at` を持つ（＝全期間を読むクエリが無い）★
    //   ★例外は連続日数の1本だけ（`where` を持たない `select` 専用クエリ。C-2）★
    for (const call of calls.cleanTimetable) {
      if (!("where" in call)) continue;
      expect(call.where).toHaveProperty("start_at");
    }
  });

  it("確定期間なら詳細テーブルのクエリは0本（`kpi` / `error-breakdown`）", async () => {
    await kpi(CONFIRMED);
    await breakdown(CONFIRMED);
    expect(calls.cleanTimetable).toEqual([]);
    expect(calls.robotErrorReport).toEqual([]);
  });
});

describe("★施設名の絞り込みを持つのは facility-performance だけ★（段階2 と同じ作法）", () => {
  it("★記号ゆれを無視して施設IDに解決し、`facility_id: { in: [...] }` で降ろす★", async () => {
    // ★`バリュー・マート三つ角店` を中点なしで検索する（`includesFoldName`。指示書AG）★
    const body = await performance(`${CURRENT}&facility_name=バリューマート三つ角&limit=100`);
    expect(body.items.map((i) => i.facility_id)).toEqual([3]);
    const facilityCall = calls.facility.find(
      (c) => "where" in c && "id" in (c.where as object)
    );
    expect(facilityCall).toBeDefined();
    expect(
      ((facilityCall as { where: { id: { in: number[] } } }).where.id.in).map(Number)
    ).toEqual([3]);
  });

  it("★★1件も当たらない施設名 → `in: []` で0件★★（★罠1。「全件表示」に化けていないか★）", async () => {
    const body = await performance(`${CURRENT}&facility_name=存在しない店&limit=100`);
    expect(body.items).toEqual([]);
    expect(body.meta.total).toBe(0);
  });

  it("★施設名が未指定なら施設で絞らない★（`null`。`[]` と混同しない）", async () => {
    const body = await performance(`${CURRENT}&limit=100`);
    expect(body.items.map((i) => i.facility_id).sort()).toEqual([1, 2, 3]);
    const facilityCall = calls.facility.find(
      (c) => "where" in c && "id" in (c.where as object)
    );
    expect(facilityCall).toBeUndefined();
  });

  /**
   * ★★ここだけデータ層（`db.facilityPerformance()`）を直接呼ぶ★★。
   *
   * ★理由: ルート層の `filterFacilityPerformanceByName()`（C-5 の「保険」）が
   *   ★罠1 の壊れ方を隠してしまう★★ — `facility_ids: []` が `in: undefined` に化けて
   *   SQL が全施設を返しても、★施設名で再度メモリ絞り込みされるので画面は0件のまま★。
   *   ★★つまりルート経由のテストでは「0件ヒットが全件読みに化けた」ことを検出できない★★
   *   （実測: `!== null` を `?.length` に書き換えてもルートのテストは全部緑のままだった）。
   *   ★読み込み行数を減らすのが本作業の目的★なので、ここはデータ層で固定する。
   */
  it("★★`facility_ids: []` はデータ層で0件★★（★罠1。`in: undefined` に化けていないか★）", async () => {
    const rows = await db.facilityPerformance({
      period: "weekly",
      year: 2026,
      month: 9,
      week: 3,
      facility_ids: [],
    });
    expect(rows).toEqual([]);
    // ★`in: []` がそのまま SQL に渡っていること（条件が消えていない）★
    const facilityCall = calls.facility.find(
      (c) => "where" in c && "id" in (c.where as object)
    );
    expect((facilityCall as { where: { id: { in: number[] } } }).where.id.in).toEqual([]);
  });

  it("★`facility_ids: null` はデータ層で全施設★（`[]` と混同しない）", async () => {
    const rows = await db.facilityPerformance({
      period: "weekly",
      year: 2026,
      month: 9,
      week: 3,
      facility_ids: null,
    });
    expect(rows.map((row) => row.item.facility_id).sort()).toEqual([1, 2, 3]);
  });

  it("★`serial` は据え置き＝メモリ絞り込みのまま★（SQL には降ろさない）", async () => {
    const body = await performance(`${CURRENT}&serial=600003&limit=100`);
    expect(body.items.map((i) => i.facility_id)).toEqual([3]);
    // ★シリアルを SQL に降ろしていないこと（施設の `where` に `id` が現れない）★
    const facilityCall = calls.facility.find(
      (c) => "where" in c && "id" in (c.where as object)
    );
    expect(facilityCall).toBeUndefined();
  });
});

/**
 * 指示書AH ★段階3★ の期待値表 #9〜#13（ルート側）を、
 * ★★指示書AK ★段階6★ の決定（§2-1）に合わせて置き換えたもの★★。
 *
 * ------------------------------------------------------------------------
 * ★★何が変わったか★★
 * ------------------------------------------------------------------------
 * ★以前は「複数顧客の行が混ざったら 500」★だった（D-133 の安全弁）。
 * ★分類が復活して「どの顧客か」を決める根拠ができた★ので、
 * ★★`where` で対象顧客を絞る → そもそも混ざらない★★ に変わった（＝ ★2社目のデータが
 * 入っても画面が常時500にならない★。§2-1 の却下案「500 のまま」の理由）。
 *
 * ★★何を守るためのテストか★★
 *  - ★★`customer_id` の絞り込みが本当に効いていること★★（#10 ＝ 期待値表 ★6-2★。
 *    ★2社ぶんの `customer_summaries` を置いて、片方の数字だけが出ることを見る★）
 *  - ★前期比が別会社の行で汚染されないこと★（#11。★罠5 の形をそのまま流用★）
 *  - ★★同じ期間キーの重複は今も 500★★（#14 ＝ 期待値表 ★6-8★。
 *    ★一意制約が無いので同一顧客でも起こりうる★）。
 *    ★body に `customer_id` が1バイトも漏れないこと★も併せて固定する
 *    （ルートは★定数の固定文言★を返す契約。`d-watch-api.yaml` は変更しない。D-7）
 *  - ★0行は 200 のまま（404 にしない）★（#12。既存挙動を変えない）
 *
 * ★ローカルDBでは絶対に再現しない★ — ★顧客は1件しか無い★ので合成データでしか作れない。
 */
describe("★★指示書AK 段階6 — 確定期間KPIを対象顧客で絞る★★（D-5 / D-133 の置き換え）", () => {
  /** ★2社目★（1社目は `summary()` の既定 = 1） */
  const OTHER_CUSTOMER = 777;

  /** ルートの生レスポンス（body を★文字列のまま★検査するため JSON.parse しない） */
  async function rawKpi(search: string): Promise<{ status: number; text: string }> {
    const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`));
    return { status: res.status, text: await res.text() };
  }

  it("#9 1顧客・当期＋前期 → 200", async () => {
    // buildData() の既定（どちらも customer_id=1）
    const res = await rawKpi(CONFIRMED);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90);
  });

  it("★★#10（＝6-2）DBに2社ぶんある・customer_id=1 → 200。顧客1だけの数字★★", async () => {
    data.customerSummaries = [
      summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]),
      summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]),
      // ★★2社目（絞れていなければ 500 になるか、別会社の数字が混ざる）★★
      summary(3, 0, 2026, 9, 2, [10, 10, 10, 10], OTHER_CUSTOMER),
      summary(4, 0, 2026, 9, 1, [11, 11, 11, 11], OTHER_CUSTOMER),
    ];
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(200);
    const body = JSON.parse(res.text) as ReportKpi;
    // ★顧客1の値そのもの★（★合算も平均もしない★）
    expect(body.operation_rate.value).toBe(90);
    expect(body.operation_rate.prev).toBe(80);
    // ★2社目の値（10 / 11）が1つも出ていないこと★
    expect(body.completion_rate.value).toBe(77.7);
    expect(body.error_rate.value).toBe(5.5);
    // ★★`where` に `customer_id` が降りていること★★（★`OR` と同じ階層 ＝ AND★）
    const where = calls.customerSummary[0].where as Record<string, unknown>;
    expect(String(where.customer_id)).toBe("1");
    expect(where.OR).toEqual([
      { year: 2026, month: 9, week: 2 },
      { year: 2026, month: 9, week: 1 },
    ]);
  });

  it("★★#11 当期はA社・前期はB社（キーは衝突しない）→ 前期比が汚染されない★★（★罠5★）", async () => {
    data.customerSummaries = [
      summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]),
      summary(2, 0, 2026, 9, 1, [10, 10, 10, 10], OTHER_CUSTOMER),
    ];
    // ★以前は 500 で止めていた★（どちらの顧客か決められなかったため）。
    //   ★いまは B社の行が `where` で落ちるので、前期比は「前期の行が無い」＝ null★。
    //   ★★両社の差という無意味な数字が出ないこと★★が要。
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(200);
    const body = JSON.parse(res.text) as ReportKpi;
    expect(body.operation_rate.value).toBe(90);
    expect(body.operation_rate.prev).toBeNull();
    expect(body.operation_rate.diff).toBeNull();
  });

  it("#12 0行（確定期間）→ 200（★404 にしない★。既存挙動）", async () => {
    data.customerSummaries = [];
    const res = await rawKpi(CONFIRMED);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({
      value: null,
      prev: null,
      diff: null,
    });
  });

  it("#13 当期0行 + 前期が2顧客 → 200・空のKPI（★B社の行を拾わない★）", async () => {
    data.customerSummaries = [
      summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]),
      summary(3, 0, 2026, 9, 1, [10, 10, 10, 10], OTHER_CUSTOMER),
    ];
    // ★当期（要求された期間）の行が1つも無いので空のKPI★。
    //   ★前期の行が2顧客ぶんあっても、絞り込みで A社だけが残る★ので 500 にはならない。
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({
      value: null,
      prev: null,
      diff: null,
    });
  });

  it("★★#14（＝6-8）同じ期間キーが重複したら例外のまま（500）／body に `customer_id` が漏れない★★", async () => {
    // ★★同一顧客でも起こりうる★★ — `customer_summaries` に一意制約が無い（指示書AK §9-2）。
    //   ★後勝ちで不定になるので、絞り込みが入った後もこの検知は残す★。
    data.customerSummaries = [
      summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]),
      summary(2, 0, 2026, 9, 2, [10, 10, 10, 10]),
    ];
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(500);
    // ★body は定数（`app/api/reports/kpi/route.ts`）★ — 例外メッセージを混ぜない
    expect(JSON.parse(res.text)).toEqual({
      error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
    });
    // ★★文字列としても顧客IDが出ていないこと★★（`details` に例外を詰め始めたら落ちる）
    expect(res.text).not.toContain("customer_id");
  });

  /**
   * ★★罠6（2026-09-30 に書き直し。指示書AQ 決定1 / §3 罠2・罠6）★★
   *
   * ★旧テスト★は「★P1では年次サマリを読まない契約（D-072項4）★なので、年次の行が同じ期間キーで
   * 2行あっても 500 にしてはいけない（`resolves.toEqual([])`）」を固定していた。
   * ★★年次を解禁した（指示書AQ 決定1）ので、その前提が消えた★★ — 年次の行は ★読む行★ になり、
   * ★読む行の重複は #14（週次）と同じく検知して止めるのが正しい★（後勝ちで不定になるため）。
   * そこで次の2本に分けた:
   *   - 罠6-1: ★年次の行は `yearly` として返る★（`periodOfSummaryRow()` が `month` を見ていること。
   *     `monthly` と取り違えると `selectKpiRow()` で弾かれて空のKPIになる＝罠2）
   *   - 罠6-2: ★同じ年次キーの重複は例外★（旧テストと期待値が逆になる。理由は上のとおり決定1）
   *
   * ★データ層を直接呼ぶ★（`facility_ids: []` のテストと同じ作法。ルート経由の年次は末尾の describe）。
   * ★年は 2025（確定期間）にする★ — 2026 は `NOW` の当年（＝当期）なので詳細テーブルの集計に入る。
   */
  it("★★罠6-1: 年次（month=0 && week=0）の行は `yearly` として返り、前年が前期比になる★★", async () => {
    data.customerSummaries = [
      summary(1, 0, 2025, 0, 0, [11, 12, 13, 14]),
      // ★前期（前年）★ — `shiftPeriod("yearly", 2025, 0, 0, -1)` = 2024/0/0
      summary(2, 0, 2024, 0, 0, [22, 22, 22, 22]),
    ];
    const rows = await db.reportKpis({
      period: "yearly",
      year: 2025,
      month: 0,
      week: 0,
      // ★`facility_ids: null` = 施設で絞らない★（`reportKpis()` の確定期間はこの値を見ない。
      //   絞り込みの軸は `customer_id`）
      facility_ids: null,
      customer_id: 1,
    });
    expect(rows).toHaveLength(1);
    // ★`monthly` になっていたら罠2（年次が月次として混入）★
    expect(rows[0]).toMatchObject({ period: "yearly", year: 2025, month: 0, week: 0 });
    expect(rows[0].kpi.operation_rate).toEqual({ value: 11, prev: 22, diff: -11 });
    expect(rows[0].kpi.completion_rate.value).toBe(13);
    // ★確定期間なので詳細テーブルは読まない★（D-102）
    expect(calls.cleanTimetable).toEqual([]);
    expect(calls.cleanTaskReport).toEqual([]);
  });

  it("★★罠6-2: 同じ年次キーの行が2行あれば例外（#14 と同じ検知）★★", async () => {
    // ★旧テストは resolves.toEqual([]) だった★ — 年次を読まない前提（D-072項4）が
    //   指示書AQ 決定1 で消えたため、★読む行の重複として検知するのが正しい★（期待値が逆転）
    data.customerSummaries = [
      summary(1, 0, 2025, 0, 0, [11, 11, 11, 11]),
      summary(2, 0, 2025, 0, 0, [22, 22, 22, 22]),
    ];
    await expect(
      db.reportKpis({
        period: "yearly",
        year: 2025,
        month: 0,
        week: 0,
        facility_ids: null,
        customer_id: 1,
      })
    ).rejects.toThrow("yearly/2025/0/0");
  });
});

/**
 * 指示書AK ★段階6★ の期待値表 ★6-1 / 6-3 〜 6-7 / 6-9★（ルート側）。
 * （★6-2 は上の #10★、★6-8 は上の #14★ で固定している）
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★★候補が2つ以上のときに「静かに1社を選ぶ」ことをしない★★（★落とし穴6★。D-133）—
 *    ★6-4 / 6-7 が 200 で通ってしまったら、誰かが暗黙の選択を入れたということ★
 *  - ★★500 ではなく 422★★（6-4）— ★サーバの不具合ではなく「指定が足りない」★（§2-1）
 *  - ★候補を `details.customer_ids` で返す★（6-5）— ★画面が文言を読まずに選択肢を出せる★
 *  - ★存在しない `customer_id` は 200・空★（6-9）— ★「該当なし」は正常★（404/422 にしない）
 */
describe("★★指示書AK 段階6 — 対象顧客の決定（期待値表 6-1 / 6-3〜6-7 / 6-9）★★", () => {
  async function rawKpi(search: string): Promise<{ status: number; text: string }> {
    const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`));
    return { status: res.status, text: await res.text() };
  }

  it("★6-1★ customer_id=1 指定・顧客1社のデータ → 200。その顧客の数字", async () => {
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90);
  });

  it("★6-3★ 指定なし・顧客が1社だけ → 200（★P1 はここ★）", async () => {
    // `buildData()` の既定（顧客マスタは屋号1 の1社だけ）
    const res = await rawKpi(CONFIRMED);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90);
    // ★暗黙に決まった1社が `where` に降りていること★
    expect(String((calls.customerSummary[0].where as Record<string, unknown>).customer_id)).toBe("1");
  });

  it("★★6-4 / 6-5★★ 指定なし・顧客が2社 → 422（★500 ではない★）。details に候補が入る", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)];
    const res = await rawKpi(CONFIRMED);
    expect(res.status).toBe(422);
    expect(JSON.parse(res.text)).toEqual({
      error: {
        code: "validation_failed",
        message:
          "顧客またはグループを指定してください。複数の顧客のデータを合算した数値は出せません。",
        details: { customer_ids: [1, 2] },
      },
    });
    // ★★422 のときはサマリを1行も読まない★★（★どの顧客か決まっていないのだから読めない★）
    expect(calls.customerSummary).toHaveLength(0);
  });

  it("★6-6★ group_id 指定・傘下1社 → 200（その顧客の数字）", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)];
    data.customerSummaries = [summary(10, 0, 2026, 9, 2, [55, 55, 55, 55], 2)];
    const res = await rawKpi(`${CONFIRMED}&group_id=2`);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(55);
    expect(String((calls.customerSummary[0].where as Record<string, unknown>).customer_id)).toBe("2");
  });

  it("★★6-7★★ group_id 指定・傘下2社 → 422（★傘下の候補だけを返す★）", async () => {
    data.customers = [
      customer(1, "屋号1", 1),
      customer(2, "屋号2", 1),
      // ★別ホールディングスの顧客は候補に入らない★
      customer(3, "屋号3", 2),
    ];
    const res = await rawKpi(`${CONFIRMED}&group_id=1`);
    expect(res.status).toBe(422);
    expect((JSON.parse(res.text) as ApiError).error.details).toEqual({
      customer_ids: [1, 2],
    });
  });

  it("★6-9★ 存在しない customer_id → 200・空のKPI（★422 にしない★。「該当なし」は正常）", async () => {
    const res = await rawKpi(`${CONFIRMED}&customer_id=999`);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({
      value: null,
      prev: null,
      diff: null,
    });
    // ★存在確認をしない＝顧客マスタに無くても `where` に降りる★
    expect(String((calls.customerSummary[0].where as Record<string, unknown>).customer_id)).toBe(
      "999"
    );
  });

  it("★group_id 指定・傘下0社 → 422（候補は空配列）★（★指示書に明記が無く、起草者が決めた★）", async () => {
    data.customers = [customer(1, "屋号1", 1)];
    const res = await rawKpi(`${CONFIRMED}&group_id=9`);
    expect(res.status).toBe(422);
    expect((JSON.parse(res.text) as ApiError).error.details).toEqual({ customer_ids: [] });
    expect(calls.customerSummary).toHaveLength(0);
  });

  it("★K7: customer_id と group_id の同時指定・傘下2社のうちの1社 → その顧客（422 にしない）★（指示書BB 表9。AND）", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)];
    // ★傘下2社のホールディングスでも、顧客が名指しされていれば 422 にならない★
    const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=1`);
    expect(res.status).toBe(200);
    expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90);
  });

  it("★★K3: customer_id=2・group_id=1（顧客2は傘下でない）→ 200・空のKPI。サマリも詳細も読まない★★（指示書BB A17・表9）", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2), customer(3, "屋号3", 1)];
    const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=2`);
    expect(res.status).toBe(200);
    expect(JSON.parse(res.text)).toEqual({
      operation_rate: { value: null, prev: null, diff: null },
      launch_rate: { value: null, prev: null, diff: null },
      completion_rate: { value: null, prev: null, diff: null },
      error_rate: { value: null, prev: null, diff: null },
    });
    expect(calls.customerSummary).toHaveLength(0);
    expect(calls.cleanTimetable).toHaveLength(0);
  });
});

/**
 * 指示書AL ★段階1★ の期待値表 ★1-1 〜 1-5★ — ★★当期（進行中の週）のKPIを顧客で絞る★★。
 * （★1-6（指定なし・顧客2社 → 422）は段階6 の「6-4 / 6-5」で固定済み★）
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★★存在しない顧客を指定したら当期が空になること★★（★1-3。今回の要★）—
 *    ★数字が出たら絞れていない★。★★`facility_ids: []` が `null`（＝全社合算）に
 *    化けていないか★★を見る（★罠1 / 落とし穴3★）
 *  - ★確定期間（`customer_summaries` を `customer_id` で絞る）と当期
 *    （`clean_timetables` を `facility_id` で絞る）が★★同じ顧客を指すこと★★（★1-7★）
 *  - ★★施設IDを `target.customerId` から解決していること★★（★落とし穴1★）—
 *    ★リクエストの `group_id` から直接引くと母集団がずれる★
 *
 * ★★D-138 の残課題★★（「確定期間は選んだ顧客の数字・当期は全社合算」）がここで解消する。
 * ★fixtures 経路（`USE_FIXTURES=true`）は元から顧客単位で正しかった★
 *   （`filterFixtureReportKpis()` の注記）。★このファイルは Prisma 経路★。
 */
describe("★★指示書AL 段階1 — 当期KPIを顧客で絞る（期待値表 1-1〜1-5）★★", () => {
  /** 当期の集計に降りた `facility_id` の条件（★無ければ `undefined`＝絞っていない★） */
  function currentFacilityIdIn(): number[] | undefined {
    const cond = mainQueryWhere().facility_id as { in: unknown[] } | undefined;
    return cond?.in.map(Number);
  }

  it("★1-1★ 当期・customer_id=1 → ★顧客1（施設1）だけの数字★（★全社の数字にならない★）", async () => {
    const body = await kpi(`${CURRENT}&customer_id=1`);
    // 301（実施・95）/ 302（未実施）。
    // 47.5 → 95: 面積比 → 1回ごとの完了率の平均（指示書AS）。★全社合算なら 49 → ≒81.67★
    expect(body.completion_rate.value).toBeCloseTo(95, 10);
    // ★エラー1件 ÷ 予定2件（第11回 P-6=B。旧: ÷ 実施1件で 100）★
    expect(body.error_rate.value).toBe(50);
    expect(currentFacilityIdIn()).toEqual([1]);
  });

  it("★1-2★ 当期・customer_id=2 → ★1-1 と違う数字★（施設2 だけ）", async () => {
    const body = await kpi(`${CURRENT}&customer_id=2`);
    // 313（未実施）/ 314（20:00・50）。★1-1 の 95 とも全社の ≒81.67 とも違う★
    // 25 → 50: 面積比 50/200 → 1回ごとの完了率の平均（指示書AS）
    expect(body.completion_rate.value).toBe(50);
    // ★施設2 はエラー報告を1件も持たない★（施設1 の EmergencyStop が混ざれば 100 になる）
    expect(body.error_rate.value).toBe(0);
    expect(currentFacilityIdIn()).toEqual([2]);
  });

  it("★★1-3★★ 当期・customer_id=9999 → ★★空（value が null）★★（★数字が出たら不具合★）", async () => {
    const body = await kpi(`${CURRENT}&customer_id=9999`);
    expect(body).toEqual({
      operation_rate: { value: null, prev: null, diff: null },
      launch_rate: { value: null, prev: null, diff: null },
      completion_rate: { value: null, prev: null, diff: null },
      error_rate: { value: null, prev: null, diff: null },
    });
    // ★★`in: []` がそのまま降りていること★★ — ★`null` に化けると全社の数字が出る★（罠1）
    expect(currentFacilityIdIn()).toEqual([]);
  });

  it("★1-4★ 当期・group_id 指定（傘下1社）→ ★その顧客の数字★", async () => {
    // `buildData()` の顧客マスタは屋号1（ホールディングス1）の1社だけ
    const body = await kpi(`${CURRENT}&group_id=1`);
    // 47.5 → 95: 面積比 → 1回ごとの完了率の平均（指示書AS。1-1 と同じ母集団）
    expect(body.completion_rate.value).toBeCloseTo(95, 10);
    expect(currentFacilityIdIn()).toEqual([1]);
  });

  it("★1-5★ 当期・指定なし・顧客1社 → ★その顧客の全施設★（★P1 はここ★）", async () => {
    const body = await kpi(CURRENT);
    // 47.5 → 95: 面積比 → 1回ごとの完了率の平均（指示書AS。1-1 と同じ母集団）
    expect(body.completion_rate.value).toBeCloseTo(95, 10);
    expect(currentFacilityIdIn()).toEqual([1]);
  });

  it("★★落とし穴1: 施設IDは `target.customerId` から解決する（リクエストの group_id からではない）★★", async () => {
    // ★屋号1・屋号2 はどちらもホールディングス1★（指示書BB A17 で customer_id と group_id は AND になったので、
    //   傘下の顧客を名指しする形で確かめる。旧: group_id=2&customer_id=1 — いまは食い違いで 200・空になる）。
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)];
    // ★`customer_id` が名指しされているので対象顧客は屋号1★。
    //   ★★リクエストの `group_id`（=1。傘下は屋号1・2）から施設を引くと施設2 まで入り★★、
    //   ★確定期間（屋号1の数字）とずれる★。
    const body = await kpi(`${CURRENT}&group_id=1&customer_id=1`);
    expect(currentFacilityIdIn()).toEqual([1]);
    // 47.5 → 95: 面積比 → 1回ごとの完了率の平均（指示書AS。1-1 と同じ母集団）
    expect(body.completion_rate.value).toBeCloseTo(95, 10);
  });

  it("★★1-7 / 1-8 確定期間と当期・前期比が、どれも同じ顧客の数字★★", async () => {
    // ★確定期間（第2週）は `customer_summaries` を `customer_id` で絞る★
    const confirmed = await kpi(`${CONFIRMED}&customer_id=1`);
    expect(confirmed.operation_rate.value).toBe(90);
    // ★当期（第3週）は `clean_timetables` を `facility_id` で絞る★。
    //   ★★`prev` は同じ顧客の確定期間サマリ（第2週）から来る★★ので、
    //   ★当期＝顧客1の詳細 / 前期＝顧客1のサマリ★ で前期比が計算されている。
    const current = await kpi(`${CURRENT}&customer_id=1`);
    expect(current.operation_rate.value).toBe(50);
    expect(current.operation_rate.prev).toBe(90);
    expect(current.operation_rate.diff).toBe(-40);

    // ★別の顧客を指定すると、当期も前期も**その顧客**の数字に揃って入れ替わる★
    data.customerSummaries = [
      ...data.customerSummaries,
      summary(5, 0, 2026, 9, 2, [30, 30, 30, 30], 2),
    ];
    const other = await kpi(`${CURRENT}&customer_id=2`);
    expect(other.operation_rate.value).toBe(50); // 施設2: 313/314 の2件中1件
    // ★★前期に顧客1の 90 が混ざらないこと★★（混ざれば diff が -40 になる）
    expect(other.operation_rate.prev).toBe(30);
    expect(other.operation_rate.diff).toBe(20);
  });
});

/**
 * 指示書AL ★段階3★ §4-3 — ★`customer_id` が明示されているときは顧客マスタを読まない★。
 *
 * `resolveTargetCustomer()` は ★`customerId !== null` なら顧客リストを一切見ない★
 * （★存在確認をしない★契約。段階6 期待値表 6-9）ので、読んでも捨てるだけの1クエリになる。
 *
 * ★★挙動は1ミリも変わらない直し★★なので、★発行されたクエリを数えないと固定できない★
 * （レスポンスだけを見るテストでは、読んでも読まなくても同じ結果になる）。
 */
describe("★★指示書AL 段階3 §4-3 — 不要な顧客マスタの読み込みをやめる★★", () => {
  // 段階6 の describe 内と同じ形（`kpi()` は 200 を前提にしているので 422 を見られない）
  async function rawKpi(search: string): Promise<{ status: number; text: string }> {
    const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`));
    return { status: res.status, text: await res.text() };
  }

  it("★customer_id 明示 → `customer.findMany` を1回も呼ばない★", async () => {
    const res = await rawKpi(`${CONFIRMED}&customer_id=1`);
    expect(res.status).toBe(200);
    expect(calls.customer).toHaveLength(0);
  });

  it("★customer_id と group_id の同時指定では読む★（AND の傘下判定に要る。指示書BB A17・D15）", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)];
    const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=1`);
    expect(res.status).toBe(200);
    expect(calls.customer).toHaveLength(1);
  });

  it("★★customer_id 未指定のときは読む★★（★読まなくしすぎると 422 の候補が出せなくなる★）", async () => {
    data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)];
    const res = await rawKpi(CONFIRMED);
    expect(res.status).toBe(422);
    expect(calls.customer).toHaveLength(1);
    expect((JSON.parse(res.text) as ApiError).error.details).toEqual({
      customer_ids: [1, 2],
    });
  });

  it("★group_id だけの指定でも読む★（傘下の候補を数えるのに要る）", async () => {
    const res = await rawKpi(`${CONFIRMED}&group_id=1`);
    expect(res.status).toBe(200);
    expect(calls.customer).toHaveLength(1);
  });
});

/**
 * 指示書AK ★段階4b★ — 分類（`group_id` / `customer_id`）を `where` に降ろす。
 *
 * ------------------------------------------------------------------------
 * ★★なぜ fixtures ではなくここ（Prisma 版）で固定するのか★★
 * ------------------------------------------------------------------------
 * ★`/reports/error-breakdown` は fixtures では確かめられません★ —
 * fixtures のエラー種別内訳は ★施設をまたいで足し上げたあとの集計値★で、
 * ★どの施設のエラーかという情報を持っていない★ためです
 * （`fixtureSource.errorBreakdown()` の docstring が正）。
 * ★★`facility_error_summaries` を `facility_id` で絞る（＝D-133 申し送り③の解消）★★ かどうかは、
 * ★このスタブで発行された `where` と、足し上げた件数の両方で見ます★。
 *
 * ルート経由の絞り込み（fixtures 側）は
 * `app/api/__tests__/master-filter-dashboard-reports.test.ts` にあります。
 */
describe("★★指示書AK 段階4b — 分類で `facility_id` を絞る★★", () => {
  /** 施設の `where` に降りた `id: { in: [...] }`（無ければ `undefined`） */
  function facilityIdIn(): number[] | undefined {
    const call = calls.facility.find((c) => "where" in c && "id" in (c.where as object));
    if (!call) return undefined;
    return (call as { where: { id: { in: unknown[] } } }).where.id.in.map(Number);
  }

  it("★`customer_id=1` → `facility_error_summaries` を施設1だけで引く★（D-133 申し送り③の解消）", async () => {
    const body = await breakdown(`${CONFIRMED}&customer_id=1`);
    // ★施設2 の HandleUp(1件) が混ざらない★ — 絞れていなければ 3 になる
    expect(body.items.map((i) => [i.type, i.count])).toEqual([
      ["HandleUp", 2],
      ["EmergencyStop", 1],
    ]);
    expect(calls.facilityErrorSummary[0].where).toEqual({
      year: 2026,
      month: 9,
      week: 2,
      // ★施設IDは `Number`（`loadFacilityGroupIndex()` が BigInt を落としている）★
      facility_id: { in: [1] },
      facility: { deleted_at: null },
    });
  });

  it("★`group_id=1` → 傘下2社（施設1・2）ぶんを足し上げる★", async () => {
    const body = await breakdown(`${CONFIRMED}&group_id=1`);
    expect(body.items.map((i) => [i.type, i.count])).toEqual([
      ["HandleUp", 3],
      ["EmergencyStop", 1],
    ]);
  });

  it("★分類を指定しなければ `facility_id` の条件を付けない★（`in: undefined` にもしない。罠1）", async () => {
    await breakdown(CONFIRMED);
    expect(calls.facilityErrorSummary[0].where).toEqual({
      year: 2026,
      month: 9,
      week: 2,
      facility: { deleted_at: null },
    });
  });

  it("★★積集合が空（`group_id=2&customer_id=1`）→ `in: []` で0件★★（全件に化けないこと）", async () => {
    const body = await breakdown(`${CONFIRMED}&customer_id=1&group_id=2`);
    expect(body.items).toEqual([]);
    expect(
      (calls.facilityErrorSummary[0].where as { facility_id: { in: unknown[] } })
        .facility_id.in
    ).toEqual([]);
  });

  it("★当期（詳細テーブル経由）でも `clean_timetables` を施設で絞る★", async () => {
    // 施設2 は当期に本清掃があるが、★エラー行を1つも持たない★
    const body = await breakdown(`${CURRENT}&customer_id=2`);
    expect(body.items).toEqual([]);
    expect((mainQueryWhere().facility_id as { in: unknown[] }).in.map(Number)).toEqual([2]);
    // ★絞らなければ施設1 の EmergencyStop（と施設3 の予定外の LostLocation。指示書BG）が出る★（＝この0件は「絞れた結果」）
    expect((await breakdown(CURRENT)).items.map((i) => i.type)).toEqual([
      "EmergencyStop",
      "LostLocation",
    ]);
  });

  it("★`/reports/continuous-problems` も施設で絞る★（連続4日の施設2だけが残る）", async () => {
    const scoped = await continuous(`${CURRENT}&customer_id=2&limit=100`);
    expect(scoped.items.map((i) => i.facility_id)).toEqual([2]);
    expect(facilityIdIn()).toEqual([2]);

    // ★屋号1（施設1）には2日以上の連続が無い★ — 絞れていなければ施設2 が出てしまう
    const other = await continuous(`${CURRENT}&customer_id=1&limit=100`);
    expect(other.items).toEqual([]);
    expect(other.meta.total).toBe(0);
  });

  it("★`/reports/facility-performance` も分類で絞る★（屋号3 = 施設3 だけ）", async () => {
    const scoped = await performance(`${CURRENT}&customer_id=3&limit=100`);
    expect(scoped.items.map((i) => i.facility_id)).toEqual([3]);
    expect(facilityIdIn()).toEqual([3]);
  });
});

/**
 * ★★申し送り（テストの制約。実装の制約ではない）★★
 *
 * ★施設名 × 分類の積集合★（`/reports/facility-performance` に両方を同時指定するケース）は
 * ★このファイルでは固定できません★ — ルートが
 * `Promise.all([resolveFacilityIdsByName(), resolveFacilityIdsByMasters()])` で
 * ★2本のデータ取得を**同時に**走らせる★ため、★`prismaSource` の `await import("./prisma")`
 * が同時に2回走り、`vi.mock()` の差し替え前の実体を掴んで 500 になります★（実測。
 * ★同じ現象の記録が `app/api/dashboard/__tests__/route.test.ts` の冒頭にあります★）。
 * ★★本番では起きません★★（`getPrisma()` はシングルトンで、差し替えも無いため）。
 *
 * ★積集合そのものは fixtures 側で固定してあります★ —
 * `app/api/__tests__/master-filter-dashboard-reports.test.ts` の
 * 「★施設名 × 分類 の積集合★」。
 */

/**
 * ★★年次（`period=yearly`）★★（指示書AQ 決定1 / §3 罠2・罠6。2026-09-30 解禁）
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★`period=yearly&year=…&month=0` がルートを通って 200 を返す★（旧: `month >= 1` で 422）
 *  - ★★年次サマリ行を月次と取り違えない★★（罠2）— `periodOfSummaryRow()` が年次行を
 *    `monthly` と判定すると、`selectKpiRow()` の `row.period === query.period` で弾かれ
 *    ★例外もなく空のKPIになる★（fail-silent）。★値が出ること★で固定する
 *  - ★当年（`NOW` = 2026-09-16 の 2026年）は詳細テーブルからその場集計★し、
 *    ★範囲は `periodRange()` の 1月1日〜12月31日★。★前期比は前年の年次サマリ★から
 *  - ★`period=yearly` で `month` が 0 以外なら 422★（落とし穴6。「yearly なら何でも通す」にしない）
 */
describe("★★年次（period=yearly）— 指示書AQ 決定1★★", () => {
  it("★Y-1★ 当年 `period=yearly&year=2026&month=0` → 200。詳細から集計し、前期比は前年の年次サマリ", async () => {
    // ★2026年の年次サマリ行は置かない★（置くと「確定期間の行が先」の規則でそちらが採られ、
    //   詳細の集計を観測できない。`reportKpis()` の末尾）
    data.customerSummaries = [summary(60, 0, 2025, 0, 0, [70, 71, 72, 73])];
    const res = await GET_KPI(
      new Request("http://localhost/api/reports/kpi?period=yearly&year=2026&month=0")
    );
    expect(res.status).toBe(200);
    const body = (await res.json()) as ReportKpi;

    // 施設1（対象顧客＝屋号1）の 2026年の本清掃: 201（80）/ 202（未実施）/ 301（95）/ 302（未実施）。
    //   ★305 は未来の予定なので入らない★。★3011 は事前チェック行なので入らない★
    expect(body.operation_rate).toEqual({ value: 50, prev: 70, diff: -20 });
    expect(body.launch_rate.value).toBe(50);
    // ★1回ごとの完了率の平均 (80+95)/2★（指示書AS。未実施はレポート行が無いので入らない）
    expect(body.completion_rate.value).toBeCloseTo(87.5, 10);
    expect(body.completion_rate.prev).toBe(72);
    // 本清掃を指すエラー 201 / 301 の2件 ÷ ★予定4件★（第11回 P-6=B。旧: ÷ 実施2件で 100）
    expect(body.error_rate.value).toBe(50);

    // ★範囲は `periodRange("yearly", 2026)` = 2026-01-01 〜 2026-12-31（壁時計値。C-6）★
    const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
    expect(startAt.gte.toISOString()).toBe("2026-01-01T00:00:00.000Z");
    expect(startAt.lt.toISOString()).toBe("2027-01-01T00:00:00.000Z");
    // ★サマリは当年 ＋ 前年の年次キーだけを引く★（月次・週次のキーを混ぜない）
    expect((calls.customerSummary[0].where as { OR: unknown[] }).OR).toEqual([
      { year: 2026, month: 0, week: 0 },
      { year: 2025, month: 0, week: 0 },
    ]);
  });

  it("★★Y-2★★ 確定した年 `period=yearly&year=2025&month=0` → ★年次サマリ行の値そのもの★（月次と取り違えない）", async () => {
    data.customerSummaries = [
      summary(61, 0, 2025, 0, 0, [70, 71, 72, 73]),
      summary(62, 0, 2024, 0, 0, [60, 61, 62, 63]),
      // ★同じ年の月次行（12月）★ — `where` が期間キー3つの一致なので届かないはず。
      //   ★届いたら 500（重複）か別の値になる★
      summary(63, 0, 2025, 12, 0, [1, 1, 1, 1]),
    ];
    const res = await GET_KPI(
      new Request("http://localhost/api/reports/kpi?period=yearly&year=2025&month=0")
    );
    expect(res.status).toBe(200);
    const body = (await res.json()) as ReportKpi;
    // ★★年次行が `monthly` と判定されていたら、ここは null（空のKPI）になる★★（罠2）
    expect(body.operation_rate).toEqual({ value: 70, prev: 60, diff: 10 });
    expect(body.launch_rate.value).toBe(71);
    expect(body.completion_rate.value).toBe(72);
    expect(body.error_rate.value).toBe(73);
    // ★確定期間では詳細テーブルを1行も読まない★（D-102）
    expect(calls.cleanTimetable).toEqual([]);
    expect(calls.cleanTaskReport).toEqual([]);
  });

  it("★Y-3★ 施設別パフォーマンスも年次サマリを `yearly` として読む（前期比は前年）", async () => {
    data.facilitySummaries = [
      ...data.facilitySummaries,
      summary(71, 1, 2025, 0, 0, [66, 66, 66, 0]),
      summary(72, 1, 2024, 0, 0, [55, 55, 55, 0]),
    ];
    const body = await performance("period=yearly&year=2025&month=0&limit=100");
    const fac1 = body.items.find((item) => item.facility_id === 1);
    // ★年次行が `monthly` のキーで Map に入っていたら、ここは見つからないか値が null になる★
    expect(fac1?.operation_rate).toEqual({ value: 66, prev: 55, diff: 11 });
    expect(fac1?.completion_rate.value).toBe(66);
  });

  it("★Y-4★ `period=yearly` で `month` が 0 以外 → 422（DB を1本も引かない）", async () => {
    for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) {
      const res = await get(
        new Request("http://localhost/api/reports/x?period=yearly&year=2026&month=9")
      );
      expect(res.status).toBe(422);
      expect(((await res.json()) as ApiError).error.code).toBe("validation_failed");
    }
    for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]);
  });
});

describe("当期 — robot_id が NULL のエラー行（指示書AU 段階0-4。第11回 P-8 b）", () => {
  it("★robot_id が NULL の行を混ぜても KPI・施設別成績・エラー内訳が落ちず、値も変わらない★", async () => {
    const before = [
      await kpi(CURRENT),
      await performance(`${CURRENT}&limit=100`),
      await breakdown(CURRENT),
    ];
    data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
    for (const [handler, path] of [
      [GET_KPI, "kpi"],
      [GET_PERFORMANCE, "facility-performance"],
      [GET_BREAKDOWN, "error-breakdown"],
    ] as const) {
      const res = await handler(new Request(`http://localhost/api/reports/${path}?${CURRENT}&limit=100`));
      expect(res.status).toBe(200);
    }
    const after = [
      await kpi(CURRENT),
      await performance(`${CURRENT}&limit=100`),
      await breakdown(CURRENT),
    ];
    expect(after).toEqual(before);
  });
});

describe("当期 — 予定を2表から読む（指示書AU 段階3b。第11回 P-7）", () => {
  /** 当期の4本（KPI・施設別成績・連続問題・エラー内訳）をまとめて取る */
  async function all() {
    return [
      await kpi(CURRENT),
      await performance(`${CURRENT}&limit=100`),
      await continuous(`${CURRENT}&limit=100`),
      await breakdown(CURRENT),
    ];
  }

  it("★予定が履歴にだけあっても、本表にあるときと同じ値になる★（レポート・エラー・出発失敗とも）", async () => {
    const baseline = await all();
    // ★301（レポートとエラーあり）と 302（出発失敗）を履歴へ移す★ — 事前チェック行 3011 は本表に残す
    const moved = new Set(["301", "302"]);
    data.historyTimetables = data.timetables.filter((t) => moved.has(t.id.toString()));
    data.timetables = data.timetables.filter((t) => !moved.has(t.id.toString()));
    expect(await all()).toEqual(baseline);
  });

  it("★両方にある同じ予定は1件として数える★（本表のまま）", async () => {
    const baseline = await all();
    data.historyTimetables = data.timetables.map((t) => ({ ...t }));
    expect(await all()).toEqual(baseline);
  });

  it("★2表に同じ引数が渡る★（`where` を2回書かない）", async () => {
    await kpi(CURRENT);
    await breakdown(CURRENT);
    expect(calls.cleanTimetableHistory).toEqual(calls.cleanTimetable);
  });
});

/**
 * ★表1 V11 / V13 / V15 / V16（指示書BB U1・D-182）★ — 当期の詳細テーブルの予定（2表とも）と、
 * 連続日数の入力（`loadConsecutiveInputs()`）の `where` に、削除済みの施設・清掃エリアの条件が入ること。
 * ★確定期間のサマリは外せる範囲だけ★（V14 は上の `facilityErrorSummary` の where。V10・V12 は変えない — D5）
 */
describe("削除済みの施設・清掃エリア — 当期の予定と連続日数の入力（指示書BB U1・表1）", () => {
  function expectAllTimetableWheresVisible() {
    const all = [...calls.cleanTimetable, ...calls.cleanTimetableHistory].filter((c) => "where" in c);
    expect(all.length).toBeGreaterThan(0);
    for (const c of all) {
      const where = (c as { where: Record<string, unknown> }).where;
      // ★影チェック（`id IN` だけの引き直し）は対象外★（`findTimetables()` の内部）
      if (!("precheck" in where)) continue;
      expect(where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    }
  }

  it("V11: `/reports/kpi` の当期", async () => {
    await kpi(`${CURRENT}&customer_id=1`);
    expectAllTimetableWheresVisible();
  });

  it("V13・V15: `/reports/facility-performance` の当期（連続日数の入力を含む）", async () => {
    await performance(`${CURRENT}&limit=100`);
    expectAllTimetableWheresVisible();
    expect(calls.cleanTimetable.some((c) => isConsecutiveQuery(c))).toBe(true);
  });

  it("V16: `/reports/error-breakdown` の当期", async () => {
    await breakdown(CURRENT);
    expectAllTimetableWheresVisible();
  });

  it("V15: `/reports/continuous-problems`（連続日数の入力）", async () => {
    await continuous(`${CURRENT}&limit=100`);
    expectAllTimetableWheresVisible();
    expect(calls.cleanTimetable.some((c) => isConsecutiveQuery(c))).toBe(true);
  });
});

/**
 * ★表13（指示書BB A11・D-182）★ — 確定期間の `customer_summaries` が0行なら `data.anomaly` を1回（KPI は今どおり空）。
 *   当期にサマリが無いのは正常なのでログを出さない。★未来の期間（最終日が今日以降）も出さない★。
 *   どの顧客・期間か追えるよう、顧客と期間キー（粒度を含む）を載せる。
 */
describe("確定期間のサマリが0行（指示書BB 表13）", () => {
  function summaryAnomalies() {
    return vi
      .mocked(logEvent)
      .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "customer_summary_rows")
      .map(([, p]) => p);
  }

  it("★L1: 確定期間・サマリ0行 → KPI は空のまま。data.anomaly を1回★", async () => {
    vi.mocked(logEvent).mockClear();
    const body = await kpi("period=weekly&year=2026&month=7&week=1&customer_id=1");
    expect(body.operation_rate.value).toBeNull();
    expect(summaryAnomalies()).toEqual([
      {
        event: "data.anomaly",
        metric: "customer_summary_rows",
        expected: 1,
        actual: 0,
        period_mode: "summary_table",
        customer_id: 1,
        period: "weekly",
        year: 2026,
        month: 7,
        week: 1,
      },
    ]);
  });

  it("L1（月次・年次）: 期間キーは粒度のまま載る（月次は week 0・年次は month も 0）", async () => {
    vi.mocked(logEvent).mockClear();
    await kpi("period=monthly&year=2026&month=7&customer_id=1");
    await kpi("period=yearly&year=2024&month=0&customer_id=1");
    expect(summaryAnomalies()).toEqual([
      expect.objectContaining({ customer_id: 1, period: "monthly", year: 2026, month: 7, week: 0 }),
      expect.objectContaining({ customer_id: 1, period: "yearly", year: 2024, month: 0, week: 0 }),
    ]);
  });

  it("★L4: 未来の期間・サマリ0行 → ログを出さない（KPI は空のまま）★", async () => {
    vi.mocked(logEvent).mockClear();
    // NOW = 2026-09-16（9月第3週）。第4週・10月・2027年はまだ来ていない
    const body = await kpi("period=weekly&year=2026&month=9&week=4&customer_id=1");
    expect(body.operation_rate.value).toBeNull();
    expect((await kpi("period=monthly&year=2026&month=10&customer_id=1")).operation_rate.value).toBeNull();
    expect((await kpi("period=yearly&year=2027&month=0&customer_id=1")).operation_rate.value).toBeNull();
    expect(summaryAnomalies()).toHaveLength(0);
  });

  it("L2: 当期・サマリ0行 → ログを出さない", async () => {
    vi.mocked(logEvent).mockClear();
    await kpi(`${CURRENT}&customer_id=1`);
    expect(summaryAnomalies()).toHaveLength(0);
  });

  it("L3: 確定期間・1行 → ログを出さない", async () => {
    vi.mocked(logEvent).mockClear();
    const body = await kpi(`${CONFIRMED}&customer_id=1`);
    expect(body.operation_rate.value).toBe(90);
    expect(summaryAnomalies()).toHaveLength(0);
  });
});

/**
 * ★★表U2 — レポート（今の期間・A案）★★（指示書BG §3。監督が書いた期待値表をそのまま使う。数字を変えない）。
 *
 * 今 = 2026-10-29 10:00 JST、`RESULT_WAIT_MINUTES` = 120。施設 F1（＝施設1・屋号1）。期間 10-28 の1日
 * （API は週次: 2026年10月第5週 ＝ 10-25〜10-31。材料は 10-28 にしか置かない）。
 *
 * | 材料 | 内容 |
 * |---|---|
 * | 予定のある清掃 | 3回: 正常終了で完了率 80 ／ 正常終了で完了率 60 ／ 未実施 |
 * | 予定外 | 2回: `status` 4・完了率 100（`clean_timetable_id` = 0）／ `status` 6・完了率 20（NULL）|
 * | エラー | 予定の id に紐づく2件 ＋ 予定外（id 0）に紐づく1件（`errored_at` 10-28 22:00）＋ 事前チェックの回に紐づく1件 |
 *
 * | 指標 | 期待 |
 * |---|---|
 * | 予定回数 | 3 ＋ 2 ＝ 5 |
 * | 開始できた回数 | 2 ＋ 2 ＝ 4 → 出発成功率 80 |
 * | 正常終了 | 2 ＋ 1 ＝ 3 → 稼働率 60 |
 * | 清掃完了率 | (80 ＋ 60 ＋ 100 ＋ 20) ÷ 4 ＝ 65 |
 * | エラー発生回数 | 2 ＋ 1 ＝ 3（事前チェックの1件は入れない）→ エラー発生率 60 |
 * | エラー内訳の合計 | 3（エラー発生回数と同じ） |
 */
describe("★★表U2 — 予定外タスクを今の期間のレポートに含める（A案。指示書BG §2-4）★★", () => {
  const U2_NOW = new Date("2026-10-29T10:00:00+09:00");
  /** 10-28 を含む週（2026年10月第5週 ＝ 10-25〜10-31）。★当期★ */
  const U2_PERIOD = "period=weekly&year=2026&month=10&week=5&customer_id=1";

  /** 予定外タスク（`clean_task_reports` の行。予定の行は無い） */
  function unscheduledReport(
    timetableId: bigint | null,
    status: number,
    cleanArea: number,
    startedJst: string
  ): StubReport {
    return {
      clean_timetable_id: timetableId,
      facility_id: BigInt(1),
      clean_ratio: cleanArea,
      status,
      started_at: dbWallClock(startedJst),
      ended_at: new Date(dbWallClock(startedJst).getTime() + 30 * 60_000),
      task_area: 100,
      clean_area: cleanArea,
    };
  }

  beforeEach(() => {
    vi.setSystemTime(U2_NOW);
    data.timetables = [
      timetable(501, 1, "2026-10-28T21:00:00+09:00"),
      timetable(502, 1, "2026-10-28T22:00:00+09:00"),
      // ★未実施★（レポートの行を置かない。終了 10-29 00:00 ＋ 120分 ＜ 今 なので結果待ちではない）
      timetable(503, 1, "2026-10-28T23:00:00+09:00"),
      // ★事前チェックの回（501 の子）★ — どの集計にも入れない
      timetable(5011, 1, "2026-10-28T20:00:00+09:00", FIRST_ROUND_MINUTES, 501),
    ];
    data.reports = [
      { ...report(501, 80, 100, 80), facility_id: BigInt(1) },
      { ...report(502, 60, 100, 60), facility_id: BigInt(1) },
      // ★`clean_timetable_id` = 0 と NULL はどちらも予定外（表U1 U1-3）★
      unscheduledReport(BigInt(0), 4, 100, "2026-10-28T20:00:00+09:00"),
      unscheduledReport(null, 6, 20, "2026-10-28T23:30:00+09:00"),
    ];
    data.errorReports = [
      errorReport(501, "EmergencyStop"),
      errorReport(502, "HandleUp"),
      {
        clean_timetable_id: BigInt(0),
        facility_id: BigInt(1),
        // ★種類では絞らない★（予定のある清掃と同じ。指示書BG §1）
        error_type: "SECURITY_ON",
        errored_at: dbWallClock("2026-10-28T22:00:00+09:00"),
        created_at: dbWallClock("2026-10-28T22:00:05+09:00"),
      },
      // ★事前チェックの回に紐づくエラー → 入れない★
      errorReport(5011, "LostLocation"),
    ];
    data.customerSummaries = [];
    data.facilitySummaries = [];
    data.facilityErrorSummaries = [];
  });

  it("KPI: 出発成功率 80・稼働率 60・清掃完了率 65・エラー発生率 60", async () => {
    const body = await kpi(U2_PERIOD);
    expect(body.launch_rate.value).toBe(80);
    expect(body.operation_rate.value).toBe(60);
    expect(body.completion_rate.value).toBe(65);
    expect(body.error_rate.value).toBe(60);
  });

  it("施設別の成績も同じ集計（施設1 の行が同じ4率）", async () => {
    const body = await performance(`${U2_PERIOD}&limit=100`);
    const fac1 = body.items.find((item) => item.facility_id === 1);
    expect(fac1?.launch_rate.value).toBe(80);
    expect(fac1?.operation_rate.value).toBe(60);
    expect(fac1?.completion_rate.value).toBe(65);
    expect(fac1?.error_rate.value).toBe(60);
  });

  it("★★エラー内訳の合計 3 ＝ エラー発生率の分子★★（事前チェックの1件は入れない。`aa4292d` の不変条件）", async () => {
    const items = (await breakdown(U2_PERIOD)).items;
    expect(items.map((i) => [i.type, i.count]).sort()).toEqual(
      [
        ["EmergencyStop", 1],
        ["HandleUp", 1],
        ["SECURITY_ON", 1],
      ].sort()
    );
    const total = items.reduce((sum, i) => sum + i.count, 0);
    expect(total).toBe(3);
    // 分子 ＝ エラー発生率 × 予定回数（5）÷ 100
    const body = await kpi(U2_PERIOD);
    expect(((body.error_rate.value ?? Number.NaN) * 5) / 100).toBeCloseTo(total, 10);
  });

  it("★予定外の読み取りは期間・施設・可視性（削除済みを除く）を SQL に降ろし、`robot_id` を読まない★", async () => {
    await kpi(U2_PERIOD);
    const reportCall = calls.cleanTaskReport.find((c) => "OR" in (c.where as object)) as {
      where: Record<string, unknown>;
      select: Record<string, boolean>;
    };
    expect(reportCall).toBeDefined();
    expect(reportCall.where.OR).toEqual([
      { clean_timetable_id: null },
      { clean_timetable_id: BigInt(0) },
    ]);
    const startedAt = reportCall.where.started_at as { gte: Date; lt: Date };
    // ★壁時計値（10-25 00:00 以上 / 11-01 00:00 未満）★
    expect(startedAt.gte.toISOString()).toBe("2026-10-25T00:00:00.000Z");
    expect(startedAt.lt.toISOString()).toBe("2026-11-01T00:00:00.000Z");
    expect((reportCall.where.facility_id as { in: unknown[] }).in.map(Number)).toEqual([1]);
    expect(reportCall.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    expect(reportCall.select.robot_id).toBeUndefined();

    const errorCall = calls.robotErrorReport.find((c) => "AND" in (c.where as object)) as {
      where: Record<string, unknown>;
      select: Record<string, boolean>;
    };
    expect(errorCall).toBeDefined();
    expect(errorCall.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
    expect((errorCall.where.facility_id as { in: unknown[] }).in.map(Number)).toEqual([1]);
    expect(errorCall.select.robot_id).toBeUndefined();
    expect(errorCall.select.robot).toBeUndefined();
  });

  it("★確定期間（先週）では予定外の行も読まない★（サマリのまま。ユーザー「サマリは変えて貰う予定」）", async () => {
    await kpi("period=weekly&year=2026&month=10&week=4&customer_id=1");
    await breakdown("period=weekly&year=2026&month=10&week=4&customer_id=1");
    expect(calls.cleanTaskReport).toEqual([]);
    expect(calls.robotErrorReport).toEqual([]);
  });
});
