Newer
Older
client_dwatch / lib / db / unscheduled-clean-log-source.ts
/**
 * ★清掃詳細ログの「予定外タスク」の行を読む★(指示書BG §1・§2-1。ユーザー指示 2026-10-29
 * 「エラー一覧以外も、予定外タスクを計算して出すようにしてください」)。
 *
 * 予定外タスク = `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行(TeamWorks 回答 2026-10-27)。
 * ★判定の正は `scheduledTimetableId()`(`isUnscheduledReport()`)★ — SQL の `UNSCHEDULED_TIMETABLE_ID_WHERE` はその写しで、
 * 取り寄せた行はメモリでも絞り直す(指示書BG §5「`=== null` / `{ not: null }` を直書きしない」)。
 *
 * | 条件 | 予定のある行(`mainTimetableWhere()`) | 予定外の行(ここ) |
 * |---|---|---|
 * | 起点 | 予定の行(`precheck = 0`) | 清掃結果の行 |
 * | 期間 | 予定の `start_at`(JST) | 清掃結果の `started_at`(JST。★実際に始めた時刻。CLAUDE.md §4.7★) |
 * | 施設 | `facility_id IN (...)` | 同じ(清掃結果の `facility_id`) |
 * | 削除済みの施設・清掃エリア | 出さない | ★同じく出さない★(`FACILITY_SECTION_VISIBLE_WHERE`。表U1 U1-5) |
 * | ロボット(機種・シリアル) | 清掃エリアの有効なロボットの先頭 | ★同じ出し方★(`section.robots`。P1 は1清掃エリア=1ロボット) |
 *
 * ★事前チェックの回は無い★(予定外タスクには予定の行が無い。事前チェックの4列は null)。
 */
import {
  formatModelName,
  isUnscheduledReport,
  type UnscheduledReportLike,
  type UnscheduledRowContext,
} from "@/lib/db/clean-log-mapping";
import { dbDateRange } from "@/lib/db/date-range";
import { UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { jstIsoFromDb } from "@/lib/format/date";
import type { Prisma, PrismaClient } from "@/lib/generated/prisma/client";
import type { CleanLogDbQuery } from "@/lib/types";

/**
 * ★`clean_task_reports` の `where`(予定外の行だけ)★。★純粋関数★(テストで形を固定する)。
 *
 * - ★`started_at` で期間を絞る★(`dbDateRange()` を通す。DB の DATETIME は JST の壁時計値。指示書AC C-6)
 * - ★`facility_ids` は `!== null` で分岐する★(罠1。`[]` は `in: []` で0件)
 * - ★単一ID(`query.id`)は見ない★ — 呼び出し側が単一ID の取得では予定外の行を引かない(予定外の行に `{id}` は無い)
 */
export function unscheduledCleanLogWhere(query: CleanLogDbQuery): Prisma.CleanTaskReportWhereInput {
  const range = dbDateRange(query.date_from, query.date_to);
  return {
    ...UNSCHEDULED_TIMETABLE_ID_WHERE,
    ...FACILITY_SECTION_VISIBLE_WHERE,
    ...(range.gte || range.lt ? { started_at: range } : {}),
    ...(query.facility_ids !== null
      ? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
      : {}),
  };
}

/** 読む行の形(`include` の結果のうち使う列だけ。テストから作れるようにしてある) */
export interface UnscheduledReportRow {
  report_id: string;
  facility_id: bigint;
  section_id: bigint;
  clean_timetable_id: bigint | null;
  status: number;
  task_area: number;
  clean_area: number;
  clean_ratio: number;
  started_at: Date;
  facility: { facility_name: string };
  section: {
    robots: ReadonlyArray<{
      serial_number: string;
      device: { maker_id: number; model_name: string };
    }>;
  };
}

/** `loadUnscheduledCleanLogRows()` の1件 */
export interface UnscheduledCleanLogRow {
  report: UnscheduledReportLike;
  context: UnscheduledRowContext;
  /** ★有効なロボットの台数のログ(`reportSectionRobotCount()`)に使う★ */
  section_id: bigint;
  robot_count: number;
}

/**
 * 読んだ行 → 清掃詳細ログの材料(★純粋関数★)。
 * ★予定のある行(`clean_timetable_id` が 0 でも NULL でもない)は落とす★(SQL の写しが外れていても混ぜない)。
 */
export function unscheduledCleanLogRowsFromReports(
  rows: readonly UnscheduledReportRow[]
): UnscheduledCleanLogRow[] {
  return rows.filter(isUnscheduledReport).map((row) => {
    // ★P1 は1清掃エリア=1ロボット(CLAUDE.md §8)★。予定のある行と同じく、0台なら null、2台以上は `id` 昇順の先頭(指示書BB U2・D7)
    const robot = row.section.robots[0] ?? null;
    return {
      report: {
        report_id: row.report_id,
        clean_timetable_id: row.clean_timetable_id,
        status: row.status,
        task_area: row.task_area,
        clean_area: row.clean_area,
        clean_ratio: row.clean_ratio,
        // ★DB の DATETIME は JST の壁時計値★ — 必ず `jstIsoFromDb()` を通す(9時間を足さない・引かない。CLAUDE.md §3)
        started_at_iso: jstIsoFromDb(row.started_at),
      },
      context: {
        facility_id: Number(row.facility_id),
        facility_name: row.facility.facility_name,
        model_name:
          robot === null ? null : formatModelName(robot.device.maker_id, robot.device.model_name),
        serial_number: robot === null ? null : robot.serial_number,
      },
      section_id: row.section_id,
      robot_count: row.section.robots.length,
    };
  });
}

/**
 * ★予定外タスクの行を読む★(`prismaSource.cleanLogs()` から呼ぶ)。
 *
 * ★`clean_task_reports` には `facility` / `section` のリレーションがある★(`prisma/schema.prisma` の `CleanTaskReport`)ので
 * `include` で辿る(FK が無いのは `clean_timetable_id` の側だけ)。
 */
export async function loadUnscheduledCleanLogRows(
  prisma: PrismaClient,
  query: CleanLogDbQuery
): Promise<UnscheduledCleanLogRow[]> {
  const rows = await prisma.cleanTaskReport.findMany({
    where: unscheduledCleanLogWhere(query),
    include: {
      facility: { select: { facility_name: true } },
      section: {
        include: {
          robots: {
            where: { deleted_at: null },
            // ★2台以上なら `id` 昇順の先頭を採る(予定のある行と同じ。指示書BB D7)★
            orderBy: { id: "asc" },
            include: { device: true },
          },
        },
      },
    },
    orderBy: [{ started_at: "desc" }, { report_id: "asc" }],
  });
  return unscheduledCleanLogRowsFromReports(rows);
}