/**
* レポートの当期(`reportKpis()` / `facilityPerformance()` / `errorBreakdown()`)が読む
* ★予定外タスク★と★予定外のエラー★の読み口(指示書BG §2-4・A案。ユーザー指示 2026-10-29)。
*
* - 予定外タスク: `clean_task_reports` のうち `clean_timetable_id` が 0 / NULL の行。★期間は `started_at`★(実際に始めた時刻。`CLAUDE.md` §4.7)
* - 予定外のエラー: `robot_error_reports` のうち `clean_timetable_id` が 0 / NULL の行。
* ★期間は `errored_at ?? created_at`★(`detectedAtFromError()`。`errored_at` が NULL の行は `created_at` で絞る —
* エラー一覧の `errorReportListWhere()` と同じ書き方)。★種類(`error_type`)では絞らない★
*
* ★SQL の条件は写し★ — 予定外かどうか・期間に入るかの正は `lib/db/report-mapping.ts` の
* `aggregateUnscheduledStatsByFacility()` / `isUnscheduledErrorInRange()`(`scheduledTimetableId()` の1本)。
* ★取り寄せた行は必ずそちらを通す★。
* ★事前チェックの回に紐づくエラーはここに来ない★ — `clean_timetable_id` が事前チェックの予定の id(0 でも NULL でもない)のため。
* ★削除済みの施設・清掃エリアの行は読まない★(`FACILITY_SECTION_VISIBLE_WHERE`。D-182)。
* ★`robot_id` / `robot` を読まない★(`select` で列を絞る。NULL の行で落ちないため。指示書AU 段階0-4)。
* ★生SQL は使わない★(`CLAUDE.md` §3)。
*/
import type { DbDateRange } from "@/lib/db/date-range";
import type { UnscheduledErrorLike, UnscheduledReportLike } from "@/lib/db/report-mapping";
import { UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
/**
* この読み口が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
* 戻りは `unknown`(列は下の `select` で決まる。`lib/db/timetable-source.ts` の `TimetableSourcePrisma` と同じ作法)。
*/
export interface UnscheduledReportSourcePrisma {
cleanTaskReport: { findMany(args: object): Promise<readonly unknown[]> };
robotErrorReport: { findMany(args: object): Promise<readonly unknown[]> };
}
/** 絞り込み(★`facility_ids` は `!== null` で分岐する★。`null` = 絞らない / `[]` = 0件。罠1) */
export interface UnscheduledReportQuery {
/** `dbDateRange()` の戻り値(DB の壁時計値。★9時間を足さない・引かない★) */
dbRange: DbDateRange;
facility_ids: number[] | null;
}
/** 予定外タスクで読む列(`UnscheduledReportLike` が要求する列だけ) */
const UNSCHEDULED_REPORT_SELECT = {
clean_timetable_id: true,
facility_id: true,
started_at: true,
status: true,
task_area: true,
clean_area: true,
clean_ratio: true,
} as const;
/** 予定外のエラーで読む列(`UnscheduledErrorLike` が要求する列だけ) */
const UNSCHEDULED_ERROR_SELECT = {
clean_timetable_id: true,
facility_id: true,
error_type: true,
errored_at: true,
created_at: true,
} as const;
function hasRange(range: DbDateRange): boolean {
return range.gte !== undefined || range.lt !== undefined;
}
function facilityWhere(facilityIds: number[] | null) {
// ★★`?.length` で分岐しないこと★★(罠1)— `[]`(0件ヒット)が「全施設」に化ける
return facilityIds !== null
? { facility_id: { in: facilityIds.map((id) => BigInt(id)) } }
: {};
}
/** 期間内(`started_at`)の予定外タスク(`clean_task_reports`)を読む */
export async function findUnscheduledReports(
prisma: UnscheduledReportSourcePrisma,
query: UnscheduledReportQuery
): Promise<UnscheduledReportLike[]> {
const rows = await prisma.cleanTaskReport.findMany({
where: {
...UNSCHEDULED_TIMETABLE_ID_WHERE,
...FACILITY_SECTION_VISIBLE_WHERE,
...(hasRange(query.dbRange) ? { started_at: query.dbRange } : {}),
...facilityWhere(query.facility_ids),
},
select: UNSCHEDULED_REPORT_SELECT,
});
return rows as UnscheduledReportLike[];
}
/** 期間内(`errored_at ?? created_at`)の予定外のエラー(`robot_error_reports`)を読む */
export async function findUnscheduledErrors(
prisma: UnscheduledReportSourcePrisma,
query: UnscheduledReportQuery
): Promise<UnscheduledErrorLike[]> {
const range = query.dbRange;
const rows = await prisma.robotErrorReport.findMany({
where: {
// ★予定外の条件(`OR`)と期間の条件(`OR`)が同じ階層でぶつからないよう `AND` で包む★
AND: [
UNSCHEDULED_TIMETABLE_ID_WHERE,
...(hasRange(range)
? [{ OR: [{ errored_at: range }, { errored_at: null, created_at: range }] }]
: []),
],
...FACILITY_SECTION_VISIBLE_WHERE,
...facilityWhere(query.facility_ids),
},
select: UNSCHEDULED_ERROR_SELECT,
});
return rows as UnscheduledErrorLike[];
}