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)の行は施設が面積集計に無くても例外にしない★(数えないので)", () => {
expect(() =>
cleanAreaStatsFromRows([groupRow(1, 10, 10)], [rateRow(2, null, 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 | 0 | null |
* | F2 | 本清掃 50/100 + 予定外 100/100 | 150 | 200 | 1 | 50.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` 列を読んでいないことの確認★) |
*
* ★面積の合計は `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, 0, null],
["F2 本清掃 50/100 + 予定外 100/100", [rateRow(1, 1, 50, 100), rateRow(1, null, 100, 100)], 150, 200, 1, 50.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],
])("%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);
});
});
/**
* ★★指示書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: { not: null }`★ が入る(★罠1★) |
* | 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` に `{ not: null }` を足しただけ★★(5-10)
expect(findManyCalls).toEqual([
{
where: { ...WHERE, clean_timetable_id: { not: null } },
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` は「本清掃に紐づくレポートの行数」★(予定外を含まない)。
// 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: { not: null }` が入る★★(罠1。落とすと毎回偽陽性)", async () => {
const { prisma, calls } = duplicateStub([]);
await countDuplicatedTimetables(prisma, WHERE);
// ★★NULL を除かないと、`GROUP BY` が NULL 行を1つの巨大グループにまとめ、
// `having > 1` に必ず当たって「重複1件」を毎回報告します★★(偽陽性)。
// ★毎回出る警告は読まれなくなり、本物の重複を見逃すようになる★
expect(calls[0].where).toEqual({
...WHERE,
clean_timetable_id: { not: null },
});
});
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);
});
});