Newer
Older
client_dwatch / lib / db / unscheduled-report-source.ts
/**
 * レポートの当期(`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[];
}