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([]);
});
});