Newer
Older
client_dwatch / lib / db / __tests__ / clean-area-source.test.ts
import { describe, expect, it } from "vitest";

import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import {
  cleanAreaStatsFromRows,
  countDuplicatedTimetables,
  loadCleanAreaFacilityMasters,
  loadCleanAreaFacilityStats,
  loadCleanAreaFacilityStatsWithRowCount,
  type CleanAreaDuplicateGroupRow,
  type CleanAreaDuplicateSourcePrisma,
  type CleanAreaFacilityGroupRow,
  type CleanAreaFacilityMasterRow,
  type CleanAreaFacilitySourcePrisma,
  type CleanAreaRateRow,
  type CleanAreaStatsSourcePrisma,
  type CleanAreaWhere,
} from "@/lib/db/clean-area-source";
import type { CleanAreaFacilityMaster } from "@/lib/types";

/**
 * 指示書AN ★段階3★ §6-1 / §6-2(★罠7 / 落とし穴1・3・5★)—
 * 清掃面積の集計材料の読み取り。
 *
 * ★★`deleted_at` で絞らない★★: 面積は履歴ベースで、★論理削除は過去の実績を消さない★。
 *   絞ると内訳から落ちるだけでなく、`aggregateCleanArea()` が
 *   ★マスタに無い施設を `total` からも外す★設計なので ★全体合計そのものが減ります★。
 *   ★★ローカルDBでは検出できません★★(実測で `facilities` 12件のうち論理削除は0件)—
 *   ★だから合成データで固定する★(`facility-group-source.test.ts` と同じ理由)。
 */

/** `findMany()` に渡された引数を控えるスタブ(★DBに接続しない★) */
function stubPrisma(rows: readonly CleanAreaFacilityMasterRow[]): {
  prisma: CleanAreaFacilitySourcePrisma;
  calls: unknown[];
} {
  const calls: unknown[] = [];
  const prisma: CleanAreaFacilitySourcePrisma = {
    facility: {
      async findMany(args) {
        calls.push(args);
        return [...rows];
      },
    },
  };
  return { prisma, calls };
}

function masterRow(
  id: number,
  customerId: number,
  groupId: number,
  groupName: string | null
): CleanAreaFacilityMasterRow {
  return {
    id: BigInt(id),
    facility_name: `施設${id}`,
    customer_id: BigInt(customerId),
    customer: {
      customer_name: `屋号${customerId}`,
      group_id: BigInt(groupId),
      group: groupName === null ? null : { group_name: groupName },
    },
  };
}

describe("loadCleanAreaFacilityMasters(指示書AN 段階3 §6-2)", () => {
  it("★★`where` を一切付けない★★(= `deleted_at` で絞らない。罠7 の唯一の検出手段)", async () => {
    const { prisma, calls } = stubPrisma([]);
    await loadCleanAreaFacilityMasters(prisma);

    for (const args of calls) {
      expect(Object.keys(args as object)).toEqual(["select"]);
    }
  });

  it("★★クエリは1本だけ★★(施設ごとにループで撃たないこと)", async () => {
    const { prisma, calls } = stubPrisma([
      masterRow(1, 1, 1, "ホールディングス1"),
      masterRow(2, 2, 1, "ホールディングス1"),
    ]);
    await loadCleanAreaFacilityMasters(prisma);
    expect(calls).toHaveLength(1);
  });

  it("読む列はロールアップに要るものだけ(★これ以上増やさない★)", async () => {
    const { prisma, calls } = stubPrisma([]);
    await loadCleanAreaFacilityMasters(prisma);
    const select = (calls[0] as { select: Record<string, unknown> }).select;
    expect(Object.keys(select).sort()).toEqual([
      "customer",
      "customer_id",
      "facility_name",
      "id",
    ]);
    expect(select.customer).toEqual({
      select: {
        customer_name: true,
        group_id: true,
        group: { select: { group_name: true } },
      },
    });
  });

  it("★BigInt を `number` に直して返す★(`NextResponse.json` が BigInt で例外を投げるため)", async () => {
    const { prisma } = stubPrisma([masterRow(7, 3, 2, "ホールディングス2")]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows).toEqual([
      {
        id: 7,
        facility_name: "施設7",
        customer_id: 3,
        customer_name: "屋号3",
        group_id: 2,
        group_name: "ホールディングス2",
      },
    ]);
    expect(typeof rows[0].id).toBe("number");
    expect(typeof rows[0].customer_id).toBe("number");
  });

  it("★★`customers.group_id = 0` は `null` に変換する★★(D-011 / 落とし穴5)", async () => {
    // ★0 は「ホールディングスなし」のセンチネル★ — そのまま出すと
    //   存在しない「グループ0」が画面に出る(`aggregateCleanArea()` は `null` を
    //   「グループなし」1行にまとめる契約)
    const { prisma } = stubPrisma([masterRow(12, 4, 0, null)]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows[0].group_id).toBeNull();
    // ★所属が無いのだから名前も `null`★(センチネルに対応する `groups` の行は存在しない)
    expect(rows[0].group_name).toBeNull();
  });

  it("★論理削除された施設も返す★(返す行を間引かないこと)", async () => {
    const { prisma } = stubPrisma([
      masterRow(1, 1, 1, "ホールディングス1"),
      masterRow(2, 1, 1, "ホールディングス1"),
    ]);
    const rows = await loadCleanAreaFacilityMasters(prisma);
    expect(rows.map((r) => r.id)).toEqual([1, 2]);
  });
});

/** 面積の `groupBy` の1行(★面積の合計だけ★。指示書AS 段階4-1) */
function groupRow(
  facilityId: number,
  clean: number | null,
  task: number | null
): CleanAreaFacilityGroupRow {
  return { facility_id: BigInt(facilityId), _sum: { clean_area: clean, task_area: task } };
}

/** 完了率用に取り寄せる1行(★`clean_ratio` は読まないが `select` に入っている★。D6) */
function rateRow(
  facilityId: number,
  timetableId: number | null,
  cleanArea: number,
  taskArea: number,
  cleanRatio = 0
): CleanAreaRateRow {
  return {
    facility_id: BigInt(facilityId),
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    clean_area: cleanArea,
    task_area: taskArea,
    clean_ratio: cleanRatio,
  };
}

describe("cleanAreaStatsFromRows(指示書AN 段階3 §6-2 / §5-3 A-2・指示書AS 段階4)", () => {
  it("★`facility_id` を `Number()` に直す★(BigInt のままだと施設マスタと突き合わない)", () => {
    const stats = cleanAreaStatsFromRows(
      [groupRow(3, 120.5, 200)],
      [rateRow(3, 31, 80, 100), rateRow(3, 32, 120.5 - 80, 100)],
      new Set(["31", "32"])
    );
    expect(stats).toEqual([
      {
        facility_id: 3,
        clean_area: 120.5,
        task_area: 200,
        executed_count: 2,
        clean_ratio_sum: 80 + 40.5,
        clean_ratio_count: 2,
      },
    ]);
    expect(typeof stats[0].facility_id).toBe("number");
  });

  it("★★`_sum` の `null` は 0 にする★★(A-2。`null` を層をまたいで持ち回らない)", () => {
    const stats = cleanAreaStatsFromRows([groupRow(1, null, null)], [], new Set());
    expect(stats[0].clean_area).toBe(0);
    expect(stats[0].task_area).toBe(0);
    // ★完了率の Σ は常に数値(0)、件数も 0★
    expect(stats[0].clean_ratio_sum).toBe(0);
    expect(stats[0].clean_ratio_count).toBe(0);
    expect(stats[0].executed_count).toBe(0);
  });

  it("0行なら空配列(★行を作らない★)", () => {
    expect(cleanAreaStatsFromRows([], [], new Set())).toEqual([]);
  });

  it("★面積の集計に無い施設の行が来たら例外★(`where` の食い違い。黙って足さない)", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, 21, 10, 10)], new Set(["21"]))
    ).toThrow(/facility_id=2/);
  });

  it("★予定外(`clean_timetable_id` NULL)の行も、施設が面積集計に無ければ例外★(数えるので。指示書BG §2-5)", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, null, 10, 10)], new Set())
    ).toThrow(/facility_id=2/);
  });

  it("★予定外(`clean_timetable_id` 0。TeamWorks 回答 2026-10-27)の行も、施設が面積集計に無ければ例外★(指示書BG §2-5)", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, 0, 10, 10)], new Set())
    ).toThrow(/facility_id=2/);
  });

  it("★事前チェック・dangling の予定に紐づく行は、施設が面積集計に無くても例外にしない★(数えないので)", () => {
    expect(() =>
      cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, 9, 10, 10)], new Set())
    ).not.toThrow();
  });
});

/**
 * ★★表4(指示書AS §4)— 総清掃面積(F-08)の施設1件ぶんの集計★★(D-155 項3 / 指示書AS D9)
 *
 * | # | その施設のレポート行 | `clean_area` 合計 | `task_area` 合計 | `executed_count` | `completion_rate`(`aggregateCleanArea()` の結果) |
 * |---|---|---|---|---|---|
 * | F1 | 予定外(`clean_timetable_id` NULL)100/100 の1行だけ | 100 | 100 | ★1★ | ★100.0★ |
 * | F2 | 本清掃 50/100 + 予定外 100/100 | 150 | 200 | ★2★ | ★75.0★ |
 * | F3 | 事前チェック(`precheck ≠ 0`)の予定に紐づく 80/100 の1行だけ | 80 | 100 | 0 | null |
 * | F4 | 予定が見つからない id(dangling)80/100 の1行だけ | 80 | 100 | 0 | null |
 * | F5 | 本清掃 10/0 の1行だけ(`task_area = 0`) | 10 | 0 | 1 | ★0.0★(P-5=B。指示書AU 表3。旧: null) |
 * | F6 | 本清掃 150/100 + 本清掃 0/100 | 150 | 200 | 2 | 50.0((100.0 + 0.0) ÷ 2) |
 * | F7 | 本清掃の行の `clean_ratio` 列が 12.3、面積は 50/100 | 50 | 100 | 1 | ★50.0★(★`clean_ratio` 列を読んでいないことの確認★) |
 * | F8 | ★予定外(`clean_timetable_id` 0)★100/100 の1行だけ | 100 | 100 | ★1★ | ★100.0★ |
 * | F9 | 本清掃 50/100 + ★予定外(0)★100/100 + 予定外(NULL)100/100 | 250 | 300 | ★3★ | ★(50 + 100 + 100) ÷ 3★ |
 * | F10 | ★予定外(0)★80/100 の1行だけ、★`mainTimetableIds` に "0" が紛れていても★ | 80 | 100 | ★1★ | ★80.0★ |
 *
 * ★0 も予定外★(TeamWorks 回答 2026-10-27。`lib/db/timetable-link.ts`)。
 * ★★2026-10-29(指示書BG §2-5): 予定外も実施回数・完了率に入れる★★(★印の値を変えた)。
 *   ★旧: 面積の合計には入り、実施回数・完了率には入らない(F1 / F8 / F10 は 0・null、F2 / F9 は 1・50.0)★。
 *   ★事前チェック(F3)・dangling(F4)は今までどおり入れない★。
 *
 * ★面積の合計は `groupBy`(DB 側。全行)なので、ここでは `groupRows` に合計を置く★。
 * 本清掃の予定 id は 1 / 2、事前チェックの予定 id は 3(`mainTimetableIds` に入らない)、
 * dangling は 9(予定そのものが無いので `mainTimetableIds` に入らない)。
 */
describe("表4(指示書AS)— 総清掃面積の施設1件ぶんの集計", () => {
  const MAIN = new Set(["1", "2"]);
  const MASTER: CleanAreaFacilityMaster[] = [
    {
      id: 1,
      facility_name: "施設1",
      customer_id: 10,
      customer_name: "屋号10",
      group_id: null,
      group_name: null,
    },
  ];

  it.each<[string, CleanAreaRateRow[], number, number, number, number | null]>([
    ["F1 予定外 100/100 の1行だけ", [rateRow(1, null, 100, 100)], 100, 100, 1, 100.0],
    ["F2 本清掃 50/100 + 予定外 100/100", [rateRow(1, 1, 50, 100), rateRow(1, null, 100, 100)], 150, 200, 2, 75.0],
    ["F3 事前チェックの予定に紐づく 80/100 の1行だけ", [rateRow(1, 3, 80, 100)], 80, 100, 0, null],
    ["F4 予定が見つからない id(dangling)80/100 の1行だけ", [rateRow(1, 9, 80, 100)], 80, 100, 0, null],
    ["F5 本清掃 10/0 の1行だけ(task_area = 0 は 0%。P-5=B)", [rateRow(1, 1, 10, 0)], 10, 0, 1, 0.0],
    ["F6 本清掃 150/100 + 本清掃 0/100", [rateRow(1, 1, 150, 100), rateRow(1, 2, 0, 100)], 150, 200, 2, 50.0],
    ["F7 clean_ratio 列 12.3・面積 50/100", [rateRow(1, 1, 50, 100, 12.3)], 50, 100, 1, 50.0],
    ["F8 予定外(0)100/100 の1行だけ", [rateRow(1, 0, 100, 100)], 100, 100, 1, 100.0],
    [
      "F9 本清掃 50/100 + 予定外(0)100/100 + 予定外(NULL)100/100",
      [rateRow(1, 1, 50, 100), rateRow(1, 0, 100, 100), rateRow(1, null, 100, 100)],
      250,
      300,
      3,
      (50 + 100 + 100) / 3,
    ],
  ])("%s", (_name, reports, cleanSum, taskSum, executed, completion) => {
    // ★面積の合計は全行(予定外を含む)★ — DB の `groupBy` が返すものをここで作る
    const groups = [
      groupRow(
        1,
        reports.reduce((sum, r) => sum + r.clean_area, 0),
        reports.reduce((sum, r) => sum + r.task_area, 0)
      ),
    ];
    const stats = cleanAreaStatsFromRows(groups, reports, MAIN);
    expect(stats).toHaveLength(1);
    expect(stats[0].clean_area).toBe(cleanSum);
    expect(stats[0].task_area).toBe(taskSum);
    expect(stats[0].executed_count).toBe(executed);
    const result = aggregateCleanArea(stats, MASTER, "facility");
    expect(result.items[0].completion_rate).toBe(completion);
    expect(result.total.completion_rate).toBe(completion);
    expect(result.total.executed_count).toBe(executed);
  });

  it("F10 ★予定外(0)の行は、`mainTimetableIds` に \"0\" が紛れていても予定外として1回だけ数える★(`scheduledTimetableId()` が先に見分ける。指示書BG §2-5)", () => {
    const reports = [rateRow(1, 0, 80, 100)];
    const stats = cleanAreaStatsFromRows([groupRow(1, 80, 100)], reports, new Set(["0", "1"]));
    expect(stats[0]).toMatchObject({ clean_area: 80, task_area: 100, executed_count: 1, clean_ratio_count: 1 });
    expect(aggregateCleanArea(stats, MASTER, "facility").total.completion_rate).toBe(80.0);
  });

  it("C1 ★予定外の行の完了率も `runCompletionRate()` を通す★(上限100・`task_area = 0` は 0。指示書BG 表U1 U1-4)", () => {
    const reports = [rateRow(1, null, 150, 100), rateRow(1, 0, 10, 0)];
    const stats = cleanAreaStatsFromRows([groupRow(1, 160, 100)], reports, new Set());
    // ★150/100 は上限で 100、10/0 は 0★
    expect(stats[0]).toMatchObject({ executed_count: 2, clean_ratio_sum: 100, clean_ratio_count: 2 });
  });
});

/**
 * ★★指示書AQ(集計元を `clean_task_reports` に差し替える)の期待値表(5行)★★
 *
 * | #  | 入力 | 期待 |
 * |----|---|---|
 * | A1 | 集計 | ★`clean_task_reports` の `groupBy(["facility_id"])`★ を撃つ(★`clean_status_histories` ではない★) |
 * | A2 | 集計 | `_sum` は `clean_area` / `task_area` の2列(★面積の合計★。§1-3)。★完了率・実施回数は `findMany` で取り寄せた行から★(指示書AS 段階4。旧: `clean_ratio` の `_sum` と `_count._all`) |
 * | A3 | 番人 | `where` に ★`clean_timetable_id: { gt: 0 }`★ が入る(★罠1★。0 も予定外。旧: `{ not: null }`) |
 * | A4 | 番人 | `having` で 2行以上だけを返させる(★絞らないと全 `clean_timetable_id` が返る★) |
 * | A5 | 番人 | 集計と ★同じ `where`★ を土台にする(期間・施設を数え直さない。§6-3) |
 */
describe("指示書AQ — 集計元を `clean_task_reports` に差し替える(A1〜A5)", () => {
  const WHERE: CleanAreaWhere = {
    started_at: { gte: new Date("2026-09-01T00:00:00Z"), lt: new Date("2026-09-08T00:00:00Z") },
    facility_id: { in: [BigInt(1), BigInt(2)] },
  };

  /**
   * 面積の `groupBy`・完了率用の `findMany`・予定の引き直しを控えるスタブ(★DBに接続しない★)。
   * ★`cleanTimetable.findMany` は `precheck` が本清掃のときだけ、`mainIds` に入っている id を返す★。
   */
  function statsStub(
    rows: readonly CleanAreaFacilityGroupRow[],
    reports: readonly CleanAreaRateRow[] = [],
    mainIds: readonly number[] = [],
    // ★`clean_timetable_histories` にある本清掃の id(指示書AU 段階3b。既定は空)★
    historyMainIds: readonly number[] = []
  ): {
    prisma: CleanAreaStatsSourcePrisma;
    calls: unknown[];
    findManyCalls: unknown[];
    timetableCalls: { where: { id: { in: bigint[] }; precheck: number } }[];
    historyCalls: { where: { id: { in: bigint[] }; precheck: number } }[];
  } {
    const calls: unknown[] = [];
    const findManyCalls: unknown[] = [];
    const timetableCalls: { where: { id: { in: bigint[] }; precheck: number } }[] = [];
    const historyCalls: { where: { id: { in: bigint[] }; precheck: number } }[] = [];
    return {
      calls,
      findManyCalls,
      timetableCalls,
      historyCalls,
      prisma: {
        cleanTaskReport: {
          async groupBy(args) {
            calls.push(args);
            return [...rows];
          },
          async findMany(args) {
            findManyCalls.push(args);
            return [...reports];
          },
        },
        cleanTimetable: {
          async findMany(args) {
            // ★影チェック(`precheck` なしで id を引き直す。`lib/db/timetable-source.ts`)は記録しない★
            if (args.where.precheck === undefined) {
              return args.where.id.in.filter((id) => mainIds.includes(Number(id))).map((id) => ({ id }));
            }
            timetableCalls.push(args);
            return args.where.precheck === CLEAN_TIMETABLE_PRECHECK.main
              ? args.where.id.in.filter((id) => mainIds.includes(Number(id))).map((id) => ({ id }))
              : [];
          },
        },
        cleanTimetableHistory: {
          async findMany(args) {
            historyCalls.push(args);
            return args.where.precheck === CLEAN_TIMETABLE_PRECHECK.main
              ? args.where.id.in.filter((id) => historyMainIds.includes(Number(id))).map((id) => ({ id }))
              : [];
          },
        },
      },
    };
  }

  function duplicateStub(rows: readonly CleanAreaDuplicateGroupRow[]): {
    prisma: CleanAreaDuplicateSourcePrisma;
    calls: { where: unknown; having: unknown }[];
  } {
    const calls: { where: unknown; having: unknown }[] = [];
    return {
      calls,
      prisma: {
        cleanTaskReport: {
          async groupBy(args) {
            calls.push({ where: args.where, having: args.having });
            return [...rows];
          },
        },
      },
    };
  }

  it("A1 / A2 ★面積2列を `_sum` し、完了率・実施回数は取り寄せた本清掃の行から★(指示書AS 段階4 / D9)", async () => {
    const { prisma, calls, findManyCalls, timetableCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 900, task_area: 1000 } }],
      [
        // ★`clean_ratio` 列は面積比とわざと食い違う値★(列を読んでいないことの確認。D6)
        rateRow(1, 11, 300, 300, 10),
        rateRow(1, 12, 150, 300, 20),
        rateRow(1, 13, 450, 400, 30),
      ],
      [11, 12, 13]
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);

    expect(calls).toEqual([
      {
        by: ["facility_id"],
        where: WHERE,
        _sum: { clean_area: true, task_area: true },
      },
    ]);
    // ★★完了率用の取り寄せは集計と同じ `where`★★(予定外も取り寄せる。指示書BG §2-5。
    //   旧: 「予定のタスクだけ」(`> 0`)を足していた)
    expect(findManyCalls).toEqual([
      {
        where: WHERE,
        select: {
          facility_id: true,
          clean_timetable_id: true,
          clean_area: true,
          task_area: true,
          clean_ratio: true,
        },
      },
    ]);
    // ★★予定の引き直しは `precheck = CLEAN_TIMETABLE_PRECHECK.main`★★(`0` を直書きしない)
    expect(timetableCalls).toEqual([
      {
        where: { id: { in: [BigInt(11), BigInt(12), BigInt(13)] }, precheck: CLEAN_TIMETABLE_PRECHECK.main },
        select: { id: true },
      },
    ]);
    // ★`executed_count` は「本清掃に紐づくレポートの行数 + 予定外の行数」★(指示書BG §2-5。ここは本清掃だけ)。
    //   1回ごとの完了率は 100.0 / 50.0 / 100.0(450/400 は上限で 100)
    expect(stats).toEqual([
      {
        facility_id: 1,
        clean_area: 900,
        task_area: 1000,
        executed_count: 3,
        clean_ratio_sum: 250,
        clean_ratio_count: 3,
      },
    ]);
  });

  it("★履歴(`clean_timetable_histories`)だけにある予定に紐づくレポートも、完了率・実施回数に入る★(指示書AU 段階3b-1 の 12)", async () => {
    const { prisma, timetableCalls, historyCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 150, task_area: 200 } }],
      [rateRow(1, 11, 100, 100), rateRow(1, 21, 50, 100)],
      [11],
      [21]
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);
    // ★両表に同じ引数(`where: { id: { in }, precheck }`)★
    expect(historyCalls).toEqual(timetableCalls);
    // ★旧(本表だけ)なら 21 は「本清掃に紐づかない行」で、実施1回・完了率 100 になっていた★
    expect(stats[0]).toMatchObject({ executed_count: 2, clean_ratio_sum: 150, clean_ratio_count: 2 });
  });

  it("★取り寄せた行が0件なら `cleanTimetable.findMany` を撃たない★(`chunkForInClause()` の塊0個)", async () => {
    const { prisma, timetableCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 10, task_area: 10 } }],
      []
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);
    expect(timetableCalls).toEqual([]);
    expect(stats[0].executed_count).toBe(0);
  });

  it("★`loadCleanAreaFacilityStatsWithRowCount()` は取り寄せた行数を返す★(D-156 論点2 のログ用)", async () => {
    const { prisma } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 60, task_area: 300 } }],
      [rateRow(1, 11, 30, 100), rateRow(1, 12, 30, 200)],
      [11]
    );
    const load = await loadCleanAreaFacilityStatsWithRowCount(prisma, WHERE);
    // ★行数は取り寄せた全行(本清掃に紐づくかどうかに関係なく)★
    expect(load.rate_rows).toBe(2);
    // ★id=12 は本清掃でない(`mainIds` に無い)ので実施回数に入らない★
    expect(load.stats[0].executed_count).toBe(1);
  });

  it("A3 ★★番人の `where` に `clean_timetable_id: { gt: 0 }` が入る★★(罠1。落とすと毎回偽陽性)", async () => {
    const { prisma, calls } = duplicateStub([]);
    await countDuplicatedTimetables(prisma, WHERE);

    // ★★NULL・0 を除かないと、`GROUP BY` が NULL 行(0 の行)を1つの巨大グループにまとめ、
    //   `having > 1` に必ず当たって「重複1件」を毎回報告します★★(偽陽性)。
    //   ★毎回出る警告は読まれなくなり、本物の重複を見逃すようになる★。
    //   ★`> 0` は NULL も落とす★(SQL の比較で NULL は偽)
    expect(calls[0].where).toEqual({
      ...WHERE,
      clean_timetable_id: { gt: BigInt(0) },
    });
  });

  it("★予定外(0・NULL)が何行あっても重複に数えない★(`where` を SQL と同じ意味で当てたスタブ)", async () => {
    // ★スタブが `where.clean_timetable_id.gt` と `having` を SQL と同じ意味で当てる★
    //   (0 が2行・NULL が2行・本物の重複が1組(id 5)・1行だけの id 6)
    const raw: (bigint | null)[] = [BigInt(0), BigInt(0), null, null, BigInt(5), BigInt(5), BigInt(6)];
    const prisma: CleanAreaDuplicateSourcePrisma = {
      cleanTaskReport: {
        async groupBy(args) {
          const gt = args.where.clean_timetable_id.gt;
          const counts = new Map<string, bigint>();
          const tally = new Map<string, number>();
          for (const id of raw) {
            // ★SQL の `>`: NULL は偽★
            if (id === null || !(id > gt)) continue;
            counts.set(id.toString(), id);
            tally.set(id.toString(), (tally.get(id.toString()) ?? 0) + 1);
          }
          return [...counts.entries()]
            .filter(([key]) => (tally.get(key) ?? 0) > args.having.clean_timetable_id._count.gt)
            .map(([, id]) => ({ clean_timetable_id: id }));
        },
      },
    };
    expect(await countDuplicatedTimetables(prisma, WHERE)).toBe(1);
  });

  it("★予定外(0・NULL)の行は 0 で予定を引かず、予定外として実施回数・完了率に入れる★(指示書BG §2-5)", async () => {
    const { prisma, timetableCalls } = statsStub(
      [{ facility_id: BigInt(1), _sum: { clean_area: 250, task_area: 300 } }],
      [rateRow(1, 11, 50, 100), rateRow(1, 0, 100, 100), rateRow(1, null, 100, 100)],
      [11]
    );
    const stats = await loadCleanAreaFacilityStats(prisma, WHERE);
    // ★予定の引き直しには予定の id だけ(0 で予定を引かない)★
    expect(timetableCalls.flatMap((c) => c.where.id.in)).toEqual([BigInt(11)]);
    expect(stats[0]).toMatchObject({ clean_area: 250, task_area: 300, executed_count: 3, clean_ratio_sum: 250, clean_ratio_count: 3 });
  });

  it("A4 ★`having` で2行以上だけを返させる★(絞らないと全 `clean_timetable_id` が返る)", async () => {
    const { prisma, calls } = duplicateStub([]);
    await countDuplicatedTimetables(prisma, WHERE);
    expect(calls[0].having).toEqual({ clean_timetable_id: { _count: { gt: 1 } } });
  });

  it("A5 ★集計と同じ `where` を土台にする★(§6-3。期間・施設を数え直さない)", async () => {
    const { prisma: statsPrisma, calls: statsCalls } = statsStub([]);
    const { prisma: dupPrisma, calls: dupCalls } = duplicateStub([]);
    await loadCleanAreaFacilityStats(statsPrisma, WHERE);
    await countDuplicatedTimetables(dupPrisma, WHERE);

    const statsWhere = (statsCalls[0] as { where: CleanAreaWhere }).where;
    expect(statsWhere.started_at).toEqual((dupCalls[0].where as CleanAreaWhere).started_at);
    expect(statsWhere.facility_id).toEqual((dupCalls[0].where as CleanAreaWhere).facility_id);
  });

  it("★返すのは「2行以上あった `clean_timetable_id`」の行数そのもの★", async () => {
    const { prisma } = duplicateStub([
      { clean_timetable_id: BigInt(11) },
      { clean_timetable_id: BigInt(22) },
    ]);
    expect(await countDuplicatedTimetables(prisma, WHERE)).toBe(2);
  });
});