Newer
Older
client_dwatch / scripts / seed-local-db.ts
/**
 * ローカルDB(dwatch_local)へ fixtures を投入するシードスクリプト(指示書M 段階2)。
 *
 * ★実行方法: `npm run seed:local`(内部で `tsx scripts/seed-local-db.ts`)★
 *   素の `node` は生成された Prisma クライアントの拡張子なし相対import
 *   (例: `export * from './enums'`)を解決できず失敗する(実測。
 *   `node --experimental-transform-types` でも同じ `ERR_MODULE_NOT_FOUND`)。
 *   Prisma生成コードはバンドラでの解決(このプロジェクトの tsconfig の
 *   `moduleResolution: "bundler"`)を前提にしているため、素のNode ESMローダーとは
 *   合わない。`tsx`(esbuildベース)を使うことで解決する。
 *   `prisma/schema.prisma` の generator 設定を変える対応は D-056 違反なので取らない。
 *
 * ★冪等: 実行するたびに対象のテーブルを削除してから入れ直す★
 *   (`error_lists` も含む。★先に `scripts/local-db/apply-error-lists.ts` で表と `error_list_id` 列を作っておくこと★ —
 *   ローカルの dwatch_local には元々どちらも無い。指示書BD §8)
 *   ★続けて `scripts/local-db/apply-error-summaries.ts` も流すこと★(2026-10-29 の DDL 変更。
 *   エラーサマリ2表の `error_detail` を外し、`error_list_id` 列と error_lists への FK を足す)
 * ★id はすべて fixtures の id をそのまま使う(AUTO_INCREMENTに任せない)★
 *   (2回目以降の実行で id がずれると、段階5の「USE_FIXTURESのtrue/falseで一致」検証が
 *   idのズレで壊れるため)
 * ★dwatch_local 以外には書き込めないようコードでガードする(禁止事項1 / lib/db/local-guard.ts)★
 *
 * ★`@/` パスエイリアスは解決されない(tsconfig.jsonのpathsはTypeScriptとVitestだけの設定)ため、
 *   すべて相対パスで import する★(拡張子は付けない。`tsx` は esbuild ベースの解決を使うため
 *   拡張子なしの相対importも解決できる。これは素の`node`とは異なる — 実測して確認済み)
 *
 * ★DBには日本時間の壁時計値を書く(TeamWorks第8回A2。指示書Y)★ — アダプタの書き込み側
 *   (`formatDateTime()`)は `getUTC*` で文字列を組み立てるため、`Date` を作る「根っこ」
 *   (`toDbJst()` を通していない `new Date(...)`)はすべて `toDbJst()` で包むこと。
 *   `addMinutes()` で派生させた値には何もしなくてよい(根っこが直れば自動的に追従する)。
 */
import dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
dotenv.config();

import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "../lib/generated/prisma/client";
import {
  CLEAN_ERROR,
  CLEAN_TIMETABLE_ID_UNSCHEDULED,
  CLEAN_TIMETABLE_PRECHECK,
  ERROR_LIST_NOTE_REQUIRED_IDS,
  ERROR_LIST_UNSELECTABLE_ERROR_TYPES,
  GRADE_THRESHOLDS,
  GROUP_ID_NONE,
  MAKER_NAMES,
  PRECHECK_MINUTES_BEFORE,
} from "../lib/config";
import { assertLocalDwatchDatabaseUrl } from "../lib/db/local-guard";
import { isDsAlertRow } from "../lib/db/ds-alert-mapping";
import { controlStatusFromConnectionStatus } from "../lib/db/ds-status";
import { isVisibleErrorRow } from "../lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "../lib/db/precheck-mapping";
import { buildContract } from "../lib/format/contract";
import { todayJst, toDbJst } from "../lib/format/date";
import { shiftPeriod, weekOfMonth } from "../lib/format/period";
import { at, dateOnly } from "../lib/fixtures/_helpers";
import { cleanLogs } from "../lib/fixtures/clean-logs";
import {
  CLEAN_TASK_REPORT_STATUS_DISTRIBUTION,
  cleanTaskReportCleanArea,
  cleanTaskReportCleanTime,
  cleanTaskReportEndedAtIsNull,
  cleanTaskReportId,
  cleanTaskReportNullTimetableLogIds,
  cleanTaskReportStatus,
  cleanTaskReportTaskArea,
  cleanTaskReportZeroTimetableRows,
} from "../lib/fixtures/clean-task-reports";
import { customers } from "../lib/fixtures/customers";
import { facilities } from "../lib/fixtures/facilities";
import { dsDevices } from "../lib/fixtures/ds-devices";
import { errorLists } from "../lib/fixtures/error-lists";
import { errorReportRows } from "../lib/fixtures/error-reports";
import { groups } from "../lib/fixtures/groups";
import {
  errorBreakdown as errorBreakdownFixtureRows,
  facilityPerformance as facilityPerformanceFixtureRows,
  kpiMonthly,
  kpiWeekly,
} from "../lib/fixtures/reports";
import { schedules } from "../lib/fixtures/schedules";
import type {
  CleanLogItem,
  Customer,
  DsDevice,
  Facility,
  PrecheckStatus,
  Schedule,
} from "../lib/types";

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("[seed-local-db] DATABASE_URL が未設定です。.env.local を確認してください。");
}
assertLocalDwatchDatabaseUrl(databaseUrl);

const prisma = new PrismaClient({ adapter: new PrismaMariaDb(databaseUrl) });

/**
 * 都道府県コード(JIS X 0401)。fixturesに出てくる4県のみのローカル定数
 * (アプリのロジックではなく、シードのためだけの対応表)。
 * ★表に無い都道府県が来たら例外を投げて止める(黙って0を入れない)★
 */
const PREFECTURE_CODES: Record<string, number> = {
  東京都: 13,
  神奈川県: 14,
  三重県: 24,
  奈良県: 29,
};

function prefectureCode(name: string): number {
  const code = PREFECTURE_CODES[name];
  if (code === undefined) {
    throw new Error(`[seed-local-db] 都道府県コード対応表に無い都道府県です: ${name}`);
  }
  return code;
}

// devices.maker_id の逆引き("PUDU"→1 / "Keenon"→2。確認1 / D-085)
const MAKER_IDS: Record<string, number> = Object.fromEntries(
  Object.entries(MAKER_NAMES).map(([id, name]) => [name, Number(id)])
);

function makerId(maker: string): number {
  const id = MAKER_IDS[maker];
  if (id === undefined) {
    throw new Error(`[seed-local-db] maker_id対応表に無いメーカーです: ${maker}`);
  }
  return id;
}

/**
 * Prisma の外部キー違反(P2003)かどうか(指示書AK 段階8 / §11-4)。
 *
 * ★`customers.group_id = 0`(ホールディングスなし)が入るかどうかは「実際に試して判断する」★
 *   ため、例外の種類で分岐する必要がある。★他のエラーは握り潰さず投げ直すこと★
 *   (握り潰すと、住所カラムのNOT NULL違反などが「保留」として静かに消える)。
 * ★`instanceof` ではなく `code` の構造で見る★ — 生成された Prisma クライアントの
 *   エラークラスを import すると、シード専用スクリプトが生成物の内部構造に依存するため。
 */
function isForeignKeyViolation(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    (error as { code?: unknown }).code === "P2003"
  );
}

/**
 * fixtures の `group_id`(API の形。`null` = ホールディングスなし)を DB の形に直す。
 *
 * ★センチネル値は `lib/config` の `GROUP_ID_NONE` から読む★(桁・値をここに書かない。
 *   `lib/format/group-id.ts` が DB→API の一方向しか用意していないのは
 *   「API は 1 以上しか受け付けない」ため。★シードだけが逆方向を必要とする★)。
 */
function toDbGroupId(groupId: number | null): number {
  return groupId ?? GROUP_ID_NONE;
}

interface DeviceSeed {
  id: number;
  maker: string;
  model: string;
}

/** fixturesに出てくる (maker, model) の組み合わせごとに devices.id を割り当てる */
function collectDevices(items: Facility[]): DeviceSeed[] {
  const seen = new Map<string, DeviceSeed>();
  let nextId = 1;
  for (const facility of items) {
    for (const robot of facility.robots) {
      const key = `${robot.maker}|${robot.model}`;
      if (!seen.has(key)) {
        seen.set(key, { id: nextId++, maker: robot.maker, model: robot.model });
      }
    }
  }
  return [...seen.values()];
}

/** "HH:MM" → "HHMM"(コロンを取るだけ) */
function toHHMM(hm: string): string {
  return hm.replace(":", "");
}

/**
 * id27(施設5・港南総本店)は fixtures が D-060 より前の書き方のまま "00:30" を持っている
 * (23:30開始・日跨ぎ)。DBには D-060 に忠実な "2430" を書く。
 * ★段階5でfixtures版("00:30")とDB版("翌00:30")が1件だけ意図的に不一致になる(指示書M 4-6)★
 */
function toEndTimeHHMM(schedule: Schedule): string {
  if (schedule.id === 27) return "2430";
  return toHHMM(schedule.end_time);
}

/**
 * "DS-012" → 12(controls.id に使う)。
 * ★fixtures の serial_number 命名規則に依存する、シード専用のローカル関数★
 *   (アプリのロジックではない。形式が変わったら例外で気づけるようにする)
 */
function controlIdFromSerial(serial: string): number {
  const match = /^DS-(\d+)$/.exec(serial);
  if (!match) {
    throw new Error(`[seed-local-db] serial_number の形式が想定外です: ${serial}`);
  }
  return Number(match[1]);
}

/**
 * `controls.password` はNOT NULL(prisma/schema.prisma:205)だが fixtures に対応する値が無い。
 * ★実在しそうな文字列を入れない(CLAUDE.md §7 / D-043)ダミー固定値★
 */
const CONTROL_PASSWORD_PLACEHOLDER = "seed-local-placeholder-password";

// ------------------------------------------------------------------ 清掃ログ系
//
// ★指示書R 段階1 で追加した清掃ログ系4テーブル(clean_timetables / clean_statuses /
//   clean_status_histories / robot_error_reports)のシード規則★
//
// 【設計判断1】★`departed: false`(未実施)は ★`clean_error = 1`(異常(清掃未実施))★ で表す★
//   (D-107。★2026-09-10 に方針を変えた★)
//   ★出所★: 定義書 `docs/handoff/analysis/table-definitions-02-clean-error.md:213`
//   (`clean_status_histories` のカラム6「結果」)の原文
//   `0: 正常, 1: 異常(清掃未実施), 2: 異常(セキュリティ有効), 3: 異常(D-S制御盤異常), 4: 異常(清掃ロボット異常)`。
//   ★ユーザー回答(2026-09-10)★: ★「清掃が実施されなかった場合も履歴の行は作成される」★
//   (原文「書いてある以上は作成すると思うので、それを前提で実装してください」)/
//   ★「出発失敗(未実施)は `clean_error = 1` のみ」★ / ★`2` / `3` / `4` は「未実施とは違う扱い」★。
//   したがって ★未実施行にも履歴の行を作り、`clean_error = 1` を入れる★。
//   コード値は `lib/config` の `CLEAN_ERROR` から読む(★直書きしない★。`CLAUDE.md` §7)。
//
//   ★★旧方針(行を作らないことで未実施を表す)は破棄した★★ — 「行が無い=未出発」は
//   根拠のあるカラムを読んでいない当方の推測であり、D-099 §1 が自ら「実データでどのカラムが
//   未実施を表すかは依然として不明」と記録していた。★古い記述を残さない★(次に読む人が
//   両方を信じてしまう)。
//   ★なお読み出し側は「履歴の行が無い」場合も出発失敗として扱い続ける★
//   (`notExecutedFromHistory()`。D-107 設計3-C)— ただしそれは推測ではなく
//   「過去なのに結果行が無いのはデータ収集処理の障害だから黙って消さない」という決定。
//   ★当方で「予定時刻+猶予30分」を計算して出発判定を作らない★のは変わらない
//   (D-091 項1 / 指示書R 禁止事項3)。
//
// 【設計判断2】★`status_1h` / `status_30m` は precheck 行に紐づく robot_error_reports で表す★
//   fixtures の値が「エラーを表す値」(= fixtures の既定値 `待機中` 以外)のときだけ
//   エラー行を1件作る。★`error_type` には fixtures の値をそのまま入れる★(D-077 / D-088。
//   7分類への変換も日本語→英語の変換もしない)。エラー行が無い場合、読み出し側は
//   `ROBOT_STATUS_STANDBY`(`Standby.`。D-098)を当てる。
//
// 【設計判断3】★id の採番規則★
//   - `clean_timetables.id`: 本清掃 = fixtures の `CleanLogItem.id`(1001〜1280)。
//     事前チェック行 = 本清掃 id + `PRECHECK_TIMETABLE_ID_OFFSETS[i]`(10万 / 20万)。
//     fixtures の id が 4桁である限り衝突しない(範囲外になったら例外で止める)。
//   - `clean_statuses.id` / `clean_status_histories.id`: 1清掃 = 1行なので本清掃 id と同値。
//     (`clean_status_histories.id` は AUTO_INCREMENT ではない。`prisma/schema.prisma:482`)
//   - `robot_error_reports.id` / `error_id`: `RER-<本清掃id>-<precheckコード>`。
//     ★どちらを API の id にするかは未確定(`prisma/schema.prisma:313-314`)なので同値にしておく★
//
// 【設計判断4】★`end_at` の決め方★(NOT NULL。`prisma/schema.prisma:598`)
//   - 本清掃行 = `start_at` + `MAIN_CLEAN_DURATION_MINUTES`(固定90分。fixtures に実清掃時間の
//     情報が無いため。★KPI は面積ベースで時間を使わない(CLAUDE.md §4.2)ので判定に影響しない★)
//   - 事前チェック行 = `start_at` と同値(瞬間的な確認であり幅を持たない)

/** 本清掃の `end_at` を作るための固定長(分)。fixtures に実清掃時間が無いための便宜値 */
const MAIN_CLEAN_DURATION_MINUTES = 90;

/** 事前チェック行の `clean_timetables.id` のオフセット(1回目 / 2回目の順。D-178) */
const PRECHECK_TIMETABLE_ID_OFFSETS = [100_000, 200_000] as const;

/**
 * fixtures の `status_1h` / `status_30m` の既定値(=「エラーが無い」を表す暫定値)。
 *
 * ★これは fixtures 側の値であって DB の値ではない★(`lib/fixtures/clean-logs.ts:53-56` に
 * 「日本語6値は fixtures 由来の暫定値であり、実データから来る値ではない」と明記されている)。
 * ★`lib/fixtures/` は改変できない★(CLAUDE.md §9.5 / 指示書R 禁止事項12。ユーザー確認が
 * 取れていないため `Standby.` には寄せない)ので、シード側でこの値を「エラー無し」と読み替える。
 * ★帰結★: `USE_FIXTURES` の true / false で `status_1h` が
 * `待機中` ↔ `Standby.` と食い違うが、これは想定内(指示書R 着手前確認 実装上の注意3)。
 */
const FIXTURE_NO_ERROR_ROBOT_STATUS = "待機中";

/**
 * `clean_statuses` / `clean_status_histories` の `clean_status`(TeamWorks回答 A-2)。
 * ★符号あり tinyint。`-1`(清掃完了・セキュリティ無効)が**最も良い状態**★
 * (`>= 2` のような大小比較で「完了」を判定すると -1 が漏れる。指示書R 落とし穴3)。
 */
const CLEAN_STATUS_FINISHED_SECURITY_ENABLED = 2;
const CLEAN_STATUS_FINISHED_SECURITY_DISABLED = -1;

/**
 * 未実施行(`clean_error = 1`)の `clean_status` に入れる値。
 *
 * ★★これは当方の暫定値★★ — ★定義書の `clean_status_histories` 側の備考には
 * `2: 清掃終了(セキュリティ有効), -1: 清掃完了(セキュリティ無効)` の ★2値しか書かれていない★
 * (`docs/handoff/analysis/table-definitions-02-clean-error.md:212`。原文)。
 * 未実施の行に何を入れるかは記載が無い。`clean_statuses` 側の備考(同 `:179`)にある
 * `0: 清掃前` を借りて「清掃が始まらなかった」を表している。
 * ★★TeamWorks へ確認中★★(D-107 の申し送り 項2)— 回答が来たら差し替えること。
 * ★読み出し側(`prismaSource.cleanLogs()` / `prechecks()`)は `clean_status` を
 * 判定に使っていない★(判定は `clean_error`)ので、値が変わっても画面は動かない。
 */
const CLEAN_STATUS_BEFORE = 0;

/**
 * 完了行に `clean_error` = 2 / 3 / 4(★未実施ではない異常★)を散らすための規則。
 *
 * ★段階5 の検証用★ — 実データが来るまで `clean_error` は全件 0 になってしまい、
 * 「2/3/4 の行が `departed: true` / `completion_rate` 非null で出る」ことを確認できない。
 * ★ランダムは使わない(冪等にする)★ので、完了行の通番の剰余という決定的な規則にする。
 * ★★`clean_ratio` / `clean_area` / `task_area` / `clean_status` は変えない★★ —
 * 変えると253行の grade・連続日数・ダッシュボードが動いてしまう。
 * ★未実施行(`clean_error = 1`)には当てない★(別の分岐で作るため構造的に当たらない)。
 */
const ABNORMAL_CLEAN_ERROR_ROTATION = [
  CLEAN_ERROR.securityEnabled,
  CLEAN_ERROR.controlBoardError,
  CLEAN_ERROR.robotError,
] as const;
/** 完了行の何件ごとに 2/3/4 を当てるか(240行なら各5件ずつになる) */
const ABNORMAL_CLEAN_ERROR_EVERY = 17;

/**
 * `-1`(セキュリティ無効で完了)を混ぜる間隔。
 * ★落とし穴3 を段階2 で踏んでいないこと(-1 の行も「完了」として扱えていること)の検証用★。
 * 実行するたびに変わると確認しづらいので、日付昇順に並べた通番での剰余という決定的な規則にする。
 */
const SECURITY_DISABLED_EVERY = 5;

/**
 * `task_area`(清掃対象面積 m²)の固定値。
 * fixtures は完了率しか持たないため、`clean_area / task_area * 100 === clean_ratio` に
 * なるよう `clean_area` を逆算する(D-091 項2 の「面積から再計算する」検証を
 * 第4歩以降で試せるようにするため)。★`double(12,6)`(`prisma/schema.prisma:456-460`)★
 */
const TASK_AREA_SQM = 1000;

/**
 * ★★本清掃行を指す `robot_error_reports` を作るための規則(指示書AB 群3 決定4)★★
 *
 * ★なぜ必要か★: これまでシードは ★事前チェック行にだけ★ エラー行を作っていた。
 *   TeamWorks 第8回 A7(2026-09-11。D-114 項2)で
 *   ★`clean_timetable_id` は事前チェック行も本清掃行も指す/サマリには事前チェックを含めない★
 *   と判明したため、エラー率の分子は ★本清掃行を指すエラー★ に変わる。
 *   ★本清掃行を指すエラーがローカルDBに1件も無いと、直した後のエラー率が一律 0% になり
 *   「直ったのか壊したのか」を区別できない★。
 *
 * ★★`errorReportCount`(事前チェック側の通番)には絶対に混ぜない★★ —
 *   あれは `status` / `comment` / `responded_at` / `resolved_at` のローテーションの
 *   スロットを決めており、通番を進めると ★既存14件の値がずれる★。
 *   本清掃エラーは専用の `mainErrorReportCount` で数える。
 */
/**
 * ★実施済みの本清掃行の何件ごとにエラー行を作るか★。
 * ★★調整してよいモジュール定数★★ — 受け入れ条件は
 *   「当週に最低1件 / 当月に最低3件 / 当期に2種類以上の `error_type`」(指示書AB 段階3-1 1-4)。
 *   ★2026-09-11 実測で `7` のとき 当週3件 / 当月10件 / 全体37件★。
 */
const MAIN_CLEAN_ERROR_EVERY = 7;

/**
 * 本清掃エラーの `error_type` に入れる値(決定的に順繰りで選ぶ)。
 *
 * ★★新しい値を発明しない★★(D-077 / CLAUDE.md §4.8。生値をそのまま表示する方針)—
 *   `lib/fixtures/reports.ts` の `errorBreakdown` が持つ生値をそのまま使う。
 *   ★シードの `facility_error_summaries` 投入部も同じ語彙なので、当期の内訳と
 *   確定期間の内訳が同じ語彙になり画面で比較しやすい★。
 */
const MAIN_CLEAN_ERROR_TYPE_ROTATION = [
  "EmergencyStop",
  "LostLocalization",
  "RobotHardwareErrorToAdmin",
  "LowBatteryLevel",
] as const;

/** 本清掃エラーの `errored_at` を本清掃の開始時刻から何分後に置くか(清掃中に起きた体にする) */
const MAIN_CLEAN_ERROR_AFTER_MINUTES = 20;

/**
 * ★★`robot_error_reports.error_list_id`(エラー内容。指示書BD §5-2 / §8。D-184)を入れる行★★
 *
 * ★本清掃エラーの通番(`mainErrorReportCount`)→ 入れる id★。★表に無い通番は null(未選択)のまま★ — 大半は null。
 *   - 通番1(`LostLocalization`)= 38「自己位置推定の異常」 — ★有効な id★(`lib/fixtures/error-lists.ts` にある)
 *   - 通番2(`RobotHardwareErrorToAdmin`)= ★131「その他(該当なし)」★(`ERROR_LIST_NOTE_REQUIRED_IDS`)。
 *     ★備考は必ずある★ — 通番2 はローテーションのスロット2 で、備考を null にするのはスロット0 だけ(`ERROR_COMMENT_NULL_SLOT`)
 *   - 通番3(`LowBatteryLevel`)= ★9999 — `error_lists` に無い id★(画面は「不明(9999)」)
 * ★通番0 は入れない★(スロット0 は備考が null。131 をそこに置くと「その他なのに備考なし」になる)。
 * ★事前チェック行・D-Sec(`D-S_CONTROL_FAILURE`)・決定3の検証2件には入れない★(指示書BD §2。
 *   選べない種別は `ERROR_LIST_UNSELECTABLE_ERROR_TYPES` で投入前にも確かめる)。
 * ★`errorReportCount` / `mainErrorReportCount` の通番は進めない★(既存のローテーションを変えない)。
 */
const ERROR_LIST_VALID_ID = 38;
const ERROR_LIST_MISSING_ID = 9999;
const ERROR_LIST_ID_BY_MAIN_ERROR_SEQ: ReadonlyMap<number, number> = new Map([
  [1, ERROR_LIST_VALID_ID],
  [2, ERROR_LIST_NOTE_REQUIRED_IDS[0]],
  [3, ERROR_LIST_MISSING_ID],
]);

/**
 * 本清掃エラーの通番 → `error_list_id`(無ければ null)。
 * ★選べない種別(D-Sec・セキュリティ)に当たったら例外で止める★(ローテーションが変わったときに気づくため)。
 */
/**
 * ★`facility_error_summaries.error_list_id`(2026-10-29 の DDL 変更で `error_detail` の代わりに入った列)に入れる id★。
 *   ★NOT NULL・error_lists への FK あり★なので、error_lists にある id でなければならない。
 *   ★全行 131「その他(該当なし)」★ — Pudu の `error_type` と error_lists の対応表は無い
 *   (error_lists に error_type の列が無い。`lib/fixtures/error-lists.ts`)ので、★種別ごとに id を推測で振らない★(D-077 と同じ考え方)。
 *   ★アプリはこの列を読まない★(内訳は error_type で束ねる。`errorBreakdownRowsFromSummary()`)。
 */
const ERROR_SUMMARY_ERROR_LIST_ID = ERROR_LIST_NOTE_REQUIRED_IDS[0];

function seedErrorListId(mainSeq: number, errorType: string): bigint | null {
  const id = ERROR_LIST_ID_BY_MAIN_ERROR_SEQ.get(mainSeq);
  if (id === undefined) return null;
  if (ERROR_LIST_UNSELECTABLE_ERROR_TYPES.includes(errorType)) {
    throw new Error(
      `[seed-local-db] error_list_id を選べない種別の行に入れようとしました: ${errorType}(指示書BD §2)`
    );
  }
  return BigInt(id);
}

/**
 * ★★決定3(`clean_timetable_id` が `null` / dangling のエラー行は分子に入れない)の検証用★★
 *
 * ★★ゴミデータではない。消さないこと★★ — この3行は
 *   ★率(`reportKpis()` / `facilityPerformance()`)・内訳(`errorBreakdown()`)・
 *   アラート(`prechecks()`)の ★3箇所すべてで捨てられている★ ことを実データで確認するために置いてある。
 * ★B: `clean_timetable_id = null`★(TeamWorks 第8回 A6-2。条件が合わないと null のまま残る)
 * ★C: dangling(どの `clean_timetables.id` とも一致しない値)★ —
 *   `robot_error_reports.clean_timetable_id` には ★FK も KEY も無い★
 *   (`prisma/schema.prisma`。`lib/db/index.ts` の落とし穴1)ので実データでも起こりうる。
 * ★D: `clean_timetable_id = 0`★(予定外のタスク。TeamWorks 回答 2026-10-27)— ★B と同じく「予定なし」に出ること★
 *   (「予定不明」(dangling 扱い)に化けないこと。`lib/db/timetable-link.ts`)。
 */
const NULL_CLEAN_TIMETABLE_ERROR_ID = "RER-NULL-1";
const DANGLING_CLEAN_TIMETABLE_ERROR_ID = "RER-DANGLING-1";
const ZERO_CLEAN_TIMETABLE_ERROR_ID = "RER-ZERO-1";
/** ★どの `clean_timetables.id` とも一致しない値★。衝突しないことはコードで検査する */
const DANGLING_CLEAN_TIMETABLE_ID = 900_000;
/** B / C の2行を作る対象ロボット(施設7の1台目。`FACILITY7_VERIFICATION_ROWS` と同じ台) */
const DECISION3_VERIFICATION_SERIAL = "8111R5628060099";

/**
 * 指示書V 段階5(決定5)— 複数台の施設で「日替わりで別の台が問題→N日連続」を
 * 実データで再現するための検証データ。
 *
 * ★なぜ必要か★: 複数ロボットの施設は施設7(3台)だけで、かつ複数台の予定がある日は
 *   `daysBack: 1`(`lib/fixtures/clean-logs.ts:162-165`)の1日しか無く、「連続」を作れない
 *   (2026-09-11 実測)。★`lib/fixtures/` は変更禁止★なので、シード側だけに足す。
 *
 * ★施設7の section 107(A=8111R5628060099)と section 108(B=8111R5628060100)に、
 *   施設7の既存予定が無い `daysBack: 2 / 3 / 4` の3日を足す★
 *   (`daysBack: 5〜30` は `lib/fixtures/clean-logs.ts` の `buildHistory()` が使用済み、
 *   `daysBack: 1` は既存の3台特殊ケース。2026-09-11 実測で 2〜4 が空いていることを確認済み)。
 *
 * ★★ユーザー確定ケース(2026-09-11)そのまま★★: 1日目Aが問題 / 2日目Bが問題 /
 *   3日目Aが問題 → 3日連続。既存の `daysBack: 1`(3台とも同日)行はこの直後に連なるため、
 *   施設7の連続日数は 3 → **4** になる(段階6 で検証する)。
 *
 * ★id は 1501〜1506(fixturesのid帯 1001〜1280 / 事前チェックのオフセット 10万・20万を
 *   避けた明示採番)。事前チェック行・robot_error_reports・clean_statuses は作らない
 *   (落とし穴13。`prechecks()` の27件を変えないため)★。
 */
const FACILITY7_VERIFICATION_ROWS = [
  // 1日目(daysBack:4): A が問題(50)
  { id: 1501, serial: "8111R5628060099", daysBack: 4, time: "01:30", completionRate: 50 },
  { id: 1502, serial: "8111R5628060100", daysBack: 4, time: "01:30", completionRate: 95 },
  // 2日目(daysBack:3): B が問題(40)
  { id: 1503, serial: "8111R5628060099", daysBack: 3, time: "01:30", completionRate: 95 },
  { id: 1504, serial: "8111R5628060100", daysBack: 3, time: "01:30", completionRate: 40 },
  // 3日目(daysBack:2): A が問題(30)
  { id: 1505, serial: "8111R5628060099", daysBack: 2, time: "01:30", completionRate: 30 },
  { id: 1506, serial: "8111R5628060100", daysBack: 2, time: "01:30", completionRate: 99 },
] as const;

/**
 * ★★画面確認用のボリュームデータ(ユーザー要望 2026-09-13)★★
 *
 * ★なぜ必要か★: `lib/fixtures/` 由来の本清掃は 253件(+ 施設7検証6件)しか無く、しかも
 *   ★完了率が◎に偏る★(2026-09-13 実測: ◎169 / ○45 / △18 / ×27)。
 *   ページ送り(指示書AA)と判定(◎○△×)の見え方を実データで確かめられないため、
 *   ★シード側だけで★ 直近31日ぶんの本清掃を機械生成して足す。
 *   ★`lib/fixtures/` は変更禁止★(CLAUDE.md §9.5)なのでこちらに置く。
 *
 * ★★冪等(`Math.random()` を使わない)★★ — 完了率は `extraVolumeRandom()`(下記)という
 *   ★seed から導く決定的な疑似乱数★ で作り、判定(◎○△×)は日付と slot から決まる
 *   `EXTRA_VOLUME_GRADE_CYCLE` の剰余で決める。★同じ日に何度流しても同じ結果になる★。
 *
 * ★★既存の検証用データには一切触らない★★:
 *   - ★施設7(`FACILITY7_VERIFICATION_ROWS` / `DECISION3_VERIFICATION_SERIAL` の施設)は
 *     対象から外す★ — 「日替わりで別の台が問題→3日連続」(D-109)と
 *     null/dangling エラー(D-118)の検証が、行を足すと崩れるため。
 *   - ★事前チェック行・`robot_error_reports` は1件も作らない★ —
 *     `errorReportCount` / `mainErrorReportCount` の通番を進めないため
 *     (既存14件の `status` / `comment` / `responded_at` / `resolved_at` がずれる)。
 *   - ★本ループの外・独立ブロックで作る★(`historyIndex` を共有しない。落とし穴12)。
 *
 * ★★`clean_schedules` も同数だけ足す理由★★(ダッシュボードの整合):
 *   `/api/dashboard` は ★「その日の曜日に該当する有効スケジュール数」と「当日の清掃ログ行数」が
 *   食い違うと `data.anomaly` を記録し、画面も「18 / 9 完了」のような表示になる★
 *   (`app/api/dashboard/route.ts:83` / `lib/fixtures/schedules.ts` の D-050)。
 *   ★1ロボットにつき「毎日・有効」の予定を1件足し、その予定の行を31日ぶん毎日1件ずつ作る★ので、
 *   予定件数と当日のログ件数が同じだけ増え、★食い違いは生まれない★。
 */
/** 生成する日数(`daysBack` 0〜30。★当日を必ず含める★) */
const EXTRA_VOLUME_DAYS = 31;
/** 本清掃 `clean_timetables.id` の採番開始。★既存の最大 id より後ろであることはコードで検査する★ */
const EXTRA_VOLUME_ROW_ID_BASE = 3001;
/** 追加する `clean_schedules.id` の採番開始(fixtures は 21〜33) */
const EXTRA_VOLUME_SCHEDULE_ID_BASE = 9001;

/**
 * ★`clean_timetable_histories` の検証用データ(指示書AU 段階3b-5。TeamWorks 第11回 P-7。D-158)★。
 *
 * アプリは予定を ★`clean_timetables`(本表)と `clean_timetable_histories`(履歴)の両方★から読み、
 * ★同じ id は本表を採る★(`lib/db/timetable-source.ts`)。それをローカルDBで確かめるための3種:
 *   (a) ★履歴だけの予定★ — 過去の本清掃1件と事前チェック2行を本表に入れず履歴にだけ入れ、
 *       `clean_task_reports` を1行紐づける(画面に出れば履歴を読めている)
 *   (b) ★両方にある同じ予定(中身も同じ)★ — 画面には1件だけ出る
 *   (c) ★両方にある同じ id で中身が違う★(履歴側の `start_at` を1日ずらす)— 本表が採られ、
 *       `db.timetable_conflict` の warn が出る
 * ★履歴の id は本表の id のコピー★(ユーザー回答 2026-10-05)なので、(b)(c) は本表の既存の行の id を使う。
 */
/** (a) 履歴だけの本清掃の id(★本表のどの採番帯とも衝突しないことはコードで検査する★) */
const HISTORY_ONLY_MAIN_TIMETABLE_ID = 800_001;
/** (a) その事前チェック行の id(`PRECHECK_MINUTES_BEFORE` の順。★親 id + 1 / + 2★) */
const HISTORY_ONLY_PRECHECK_TIMETABLE_IDS = [800_002, 800_003] as const;
/** (a) 何日前の予定にするか(★当日を避ける★ — ダッシュボードの予定件数と当日のログ件数がずれるため) */
const HISTORY_ONLY_DAYS_BACK = 2;
/** (a) 開始時刻(★既存の清掃と時刻を重ねない★) */
const HISTORY_ONLY_START_TIME = "03:40";
/** (a) 完了率(○。境界値には当てない) */
const HISTORY_ONLY_COMPLETION_RATE = 85;
/** (c) 履歴側の `start_at` をずらす日数 */
const HISTORY_CONFLICT_SHIFT_DAYS = 1;
/** 1件目の開始時刻(0時からの分)と、ロボットごとにずらす幅 */
const EXTRA_VOLUME_FIRST_START_MINUTES = 10;
const EXTRA_VOLUME_START_STEP_MINUTES = 10;

/**
 * ★判定(◎○△×)の巡回表★ — `(日付の通番 + ロボットの通番) % 長さ` で引く。
 *
 * ★狙い1: 4段階がどれも十分な件数出ること★(ユーザー要望の目安「それぞれ25件以上」)。
 * ★狙い2: 同じ施設で問題が続きすぎないこと★ — 問題(`fair` / `poor` / `notExecuted`)が
 *   ★この並びで最大2連続になるよう配置してある★(L1/L2 が出て、L3 まで水増ししない)。
 *   ロボットごとに開始位置がずれるので、日ごとには4段階が必ず全種類そろう。
 * ★`notExecuted` = 出発失敗(`clean_error = 1`)★
 */
const EXTRA_VOLUME_GRADE_CYCLE = [
  "excellent",
  "fair",
  "good",
  "poor",
  "excellent",
  "fair",
  "notExecuted",
  "good",
  "poor",
] as const;
type ExtraVolumeGrade = (typeof EXTRA_VOLUME_GRADE_CYCLE)[number];

/** 追加行の何件ごとに `clean_error` 2/3/4 を当てるか(未実施行には当てない) */
const EXTRA_VOLUME_ABNORMAL_EVERY = 13;

/**
 * ★決定的な疑似乱数★(`lib/fixtures/clean-logs.ts` の `pseudoRandom()` と同じ式)。
 *
 * ★`lib/fixtures/` の関数は export されていない★ため、★同等のものをシード側に置く★
 * (fixtures は1行も変更しない)。★`Math.random()` は使わない★ — 実行のたびに
 * 完了率が変わると「昨日と同じ画面か」を確かめられず、冪等の確認もできなくなる。
 */
function extraVolumeRandom(seed: number): number {
  const x = Math.sin(seed * 12.9898) * 43758.5453;
  return x - Math.floor(x);
}

/**
 * 判定の段階 → 完了率(★閾値は `GRADE_THRESHOLDS` から読む。90/70/10 を直書きしない★。
 * CLAUDE.md §7)。★境界に載せない★ — 0.5 の余白を取り、小数第1位に丸めても
 * 段階が変わらない値だけを返す(境界値そのものの検証は fixtures 側の10ケースが持っている)。
 */
function extraVolumeCompletionRate(grade: ExtraVolumeGrade, r: number): number | null {
  const { excellent, good, poor } = GRADE_THRESHOLDS;
  const round1 = (value: number): number => Math.round(value * 10) / 10;
  switch (grade) {
    // ◎: 90%以上(90.5〜99.5)
    case "excellent":
      return round1(excellent + 0.5 + r * 9);
    // ○: 70%以上90%未満(70.5〜89.5)
    case "good":
      return round1(good + 0.5 + r * (excellent - good - 1));
    // △: 10%以上70%未満(10.5〜69.5)
    case "fair":
      return round1(poor + 0.5 + r * (good - poor - 1));
    // ×: 10%未満(0〜9.8)
    case "poor":
      return round1(r * (poor - 0.2));
    // ×: 未実施(completion_rate は null。`clean_error = 1` で表す)
    case "notExecuted":
      return null;
  }
}

/** 0時からの分 → "HH:MM"(`at()` に渡す形式) */
function minutesToHhMm(minutes: number): string {
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}

/**
 * `robot_error_reports` の NOT NULL 列に入れるダミー値。
 * ★実在しそうな文字列を入れない★(CLAUDE.md §7 / D-043)
 */
const ERROR_LEVEL_PLACEHOLDER = "seed-dummy";
const ERROR_DETAIL_PLACEHOLDER =
  "seed-local-db が生成したダミーの詳細。実在のエラー内容ではない。";

/**
 * `robot_error_reports` の対応状況(★書き込み許可4カラム。D-042★)を散らすための規則(指示書S 1-4)。
 *
 * ★なぜ必要か★: 以前は `status` / `comment` / `responded_at` / `resolved_at` を1件も入れて
 *   いなかったため、`status` の default `0` により ★全件が `unresolved` / `note` が null★ になり、
 *   `/alerts` の「対応中 / 解消」が1件も出なかった(`prismaSource.prechecks()` の
 *   `resolution_status` 変換が効いているか確認できない)。
 *
 * ★規則★: エラー行を作った通番(`errorReportCount`)を ★この表の長さで剰余★ して引く。
 *   ランダムにしないので ★2回実行しても同じ結果になる(冪等)★。
 *   ★分布は `lib/fixtures/prechecks.ts` に倣う★ — ★実測(2026-09-13)は**9件**で
 *   未解消4 / 対応中2 / 解消3★(後から境界値検証用の `id: "9"` が足されたぶん未解消が1件多い)。
 *   ★この表は**8要素**で、未解消3 / 対応中2 / 解消3★ — ★3つの状態が全部出ることが目的なので、
 *   件数を fixtures に合わせ直す必要はない★。★★この表を触らないこと★★(剰余の周期が変わる)。
 */
const ERROR_RESOLUTION_ROTATION = [
  "unresolved",
  "unresolved",
  "unresolved",
  "in_progress",
  "in_progress",
  "resolved",
  "resolved",
  "resolved",
] as const satisfies readonly PrecheckStatus[];

/**
 * `robot_error_reports.comment`(=`PrecheckItem.note`)のダミー値。
 * ★実在しそうな文言を入れない★(CLAUDE.md §7 / D-043)
 */
const ERROR_COMMENT_PLACEHOLDER =
  "seed-local-db が生成したダミーの備考。実在の対応内容ではない。";

/**
 * ★`comment` を null にする通番の剰余★(=ローテーション表の先頭スロット)。
 * `note` が nullable であること(画面が備考なしの行を出せること)も確認したいので、
 * 全件に備考を入れず、未解消の1スロットだけ null のままにする。
 */
const ERROR_COMMENT_NULL_SLOT = 0;

/** `responded_at` / `resolved_at` を `errored_at` から何分後に置くか(ダミー値) */
const RESPONDED_AFTER_MINUTES = 30;
const RESOLVED_AFTER_MINUTES = 90;

function addMinutes(base: Date, minutes: number): Date {
  return new Date(base.getTime() + minutes * 60_000);
}

/**
 * fixtures の事前チェック2列を、状態と分数の組にして1回目 → 2回目の順に並べる。
 * ★`null` = 事前チェックの予定が無い★(ユーザー指示 2026-10-07。D-171)— 呼び出し側は事前チェック行を作らない。
 * ★分数は fixtures の `status_1h_minutes` / `status_30m_minutes` を使う★(D-178)— `PRECHECK_MINUTES_BEFORE` を
 *   使うと、fixtures で 60/10 や 30 だけにした行が DB では 60/30 になり、`USE_FIXTURES` の true / false で列がずれる。
 */
function precheckStatuses(log: CleanLogItem): { status: string; minutesBefore: number }[] {
  const pairs = [
    { status: log.status_1h, minutesBefore: log.status_1h_minutes },
    { status: log.status_30m, minutesBefore: log.status_30m_minutes },
  ];
  return pairs.flatMap((p) => {
    if (p.status === null) return [];
    if (p.minutesBefore === null) {
      throw new Error(
        `[seed-local-db] 清掃ログ ${log.id} の事前チェックに状態があるのに分数が null です(D-178 の不変条件)`
      );
    }
    return [{ status: p.status, minutesBefore: p.minutesBefore }];
  });
}

/**
 * ★★`clean_task_reports` の投入(指示書AQ 決定2 / 担当F)★★
 *
 * ★なぜ必要か★: 指示書AQ で ★清掃実績の取得元が `clean_status_histories` →
 *   `clean_task_reports` に変わる★。★このテーブルはローカルDBに1行も無い★ため、
 *   ★担当F がシードを入れないと切替後に全画面が0件になり、他担当は動作確認できない★
 *   (落とし穴5)。
 *
 * ★★`clean_status_histories` の投入は消さない★★ — 切替の前後比較(同じ清掃について
 *   旧取得元と新取得元で画面が一致するか)に要るため。★同じ清掃に対応する行を作る★。
 *
 * ★値の作り方は `lib/fixtures/clean-task-reports.ts` の純粋関数に閉じ込めてある★ —
 *   ★シードと fixtures で同じ関数を呼ぶ★ので、★`USE_FIXTURES` の true/false で
 *   同じ値が出る★(ズレたらどちらかのバグ)。実データの形の出所・設計判断は
 *   あちらのファイル冒頭に書いてあるので ★直す前に必ず読むこと★。
 *
 * ★★未実施の清掃には行を作らない★★(指示書AQ 1-1)—
 *   `clean_task_reports` には `clean_error` 相当の列が無く、
 *   ★「対応する行が無い」ことだけが未実施の根拠★。呼び出し側が
 *   `completionRate !== null` のときだけ呼ぶ。
 */
interface CleanTaskReportStats {
  total: number;
  byStatus: Record<number, number>;
  endedAtNull: number;
  timetableIdNull: number;
  /** ★`clean_timetable_id = 0`(予定外のタスク。TeamWorks 回答 2026-10-27)の行数★ */
  timetableIdZero: number;
  cleanTimeZero: number;
  cleanAreaOverTaskArea: number;
  sumTaskArea: number;
  sumCleanArea: number;
}

function newCleanTaskReportStats(): CleanTaskReportStats {
  return {
    total: 0,
    byStatus: {},
    endedAtNull: 0,
    timetableIdNull: 0,
    timetableIdZero: 0,
    cleanTimeZero: 0,
    cleanAreaOverTaskArea: 0,
    sumTaskArea: 0,
    sumCleanArea: 0,
  };
}

async function createCleanTaskReport(
  params: {
    /** 本清掃の `clean_timetables.id`。★値の決め方はすべてこの id から決まる(冪等)★ */
    cleanTimetableId: number;
    facilityId: number;
    /** ★`sections.id` とロボットの id は同値に採番している(1清掃エリア=1ロボット)★ */
    sectionId: number;
    serialNumber: string;
    /**
     * ★清掃完了率★。★`clean_area` をここから作る★(`cleanTaskReportCleanArea()`。D-155 で完了率が
     * 面積から決まるようになったため)。`clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)
     */
    completionRate: number;
    startAt: Date;
    endAt: Date;
  },
  stats: CleanTaskReportStats
): Promise<void> {
  const { cleanTimetableId, sectionId } = params;
  const endedAtIsNull = cleanTaskReportEndedAtIsNull(cleanTimetableId);
  const timetableIdIsNull = cleanTaskReportNullTimetableLogIds.has(cleanTimetableId);
  const status = cleanTaskReportStatus(cleanTimetableId);
  const taskArea = cleanTaskReportTaskArea(sectionId);
  const cleanArea = cleanTaskReportCleanArea(taskArea, params.completionRate, cleanTimetableId);
  const cleanTime = cleanTaskReportCleanTime(cleanTimetableId, endedAtIsNull);

  await prisma.cleanTaskReport.create({
    data: {
      report_id: cleanTaskReportId(cleanTimetableId),
      facility_id: BigInt(params.facilityId),
      section_id: BigInt(sectionId),
      robot_id: BigInt(sectionId),
      // ★NULL は `IN` に一致せず「未実施」として静かに集計される(落とし穴1)★。
      //   ★対象は `lib/fixtures/clean-task-reports.ts` の Set が持っている★
      //   (境界値・エスカレーションのケースには当たらないようになっている)
      clean_timetable_id: timetableIdIsNull ? null : BigInt(cleanTimetableId),
      serial_number: params.serialNumber,
      // ★生値(3/4/5/6)をそのまま入れる(6 は意味が未受領。表示名への変換は画面側。指示書AQ 1-2 / D-155 項2)★
      status,
      task_area: taskArea,
      clean_area: cleanArea,
      // ★完了率には使っていない(D-155)。列に戻すときのために、清掃ログの完了率と同じ値を入れる★
      //   (`double(4,1)`。指示書AS D6)
      clean_ratio: params.completionRate,
      started_at: params.startAt,
      // ★nullable(落とし穴3)。順序キーは `ended_at ?? started_at`(指示書AQ 1-4)★
      ended_at: endedAtIsNull ? null : params.endAt,
      clean_time: cleanTime,
    },
  });

  stats.total += 1;
  stats.byStatus[status] = (stats.byStatus[status] ?? 0) + 1;
  if (endedAtIsNull) stats.endedAtNull += 1;
  if (timetableIdIsNull) stats.timetableIdNull += 1;
  if (cleanTime === 0) stats.cleanTimeZero += 1;
  if (cleanArea > taskArea) stats.cleanAreaOverTaskArea += 1;
  stats.sumTaskArea += taskArea;
  stats.sumCleanArea += cleanArea;
}

async function main(): Promise<void> {
  const devices = collectDevices(facilities);

  // serial_number → {facilityId, sectionId} の逆引き。
  // ★section_id はロボットのidと同じにする(P1は1清掃エリア=1ロボット。CLAUDE.md §8)★
  const robotBySerial = new Map<string, { facilityId: number; sectionId: number }>();
  for (const facility of facilities) {
    for (const robot of facility.robots) {
      robotBySerial.set(robot.serial_number, {
        facilityId: facility.id,
        sectionId: robot.id,
      });
    }
  }

  // 施設12(ロボット0台)のスケジュールなど、ロボットが存在しない行は構造的にスキップする
  // (facility_id を名指ししない。指示書M 確認2 / 2-7)
  const skipped: Schedule[] = [];
  const seedableSchedules = schedules.filter((schedule) => {
    if (robotBySerial.has(schedule.serial_number)) return true;
    skipped.push(schedule);
    return false;
  });

  console.log(
    `[seed-local-db] スケジュール ${schedules.length}件中 ${seedableSchedules.length}件を投入対象にします`
  );
  for (const schedule of skipped) {
    console.log(
      `[seed-local-db] skip: schedule id=${schedule.id} serial=${schedule.serial_number}(施設${schedule.facility_id} にロボットが存在しない)`
    );
  }
  if (skipped.length !== 1) {
    throw new Error(
      `[seed-local-db] スキップ件数が想定(1件)と異なります: ${skipped.length}件。fixtures側で新たな不整合が生まれている可能性があるため中断しました。`
    );
  }

  // facility_id → その施設の section の1つ(instructions 1-2)。
  // ★1清掃エリア=1ロボットの前提(CLAUDE.md §8)なので、施設に複数ロボットがあっても
  //   D-Secデバイスの結び先としてはそのうち1つ(fixtures内で最初に定義されたロボット)を使う★
  const facilityIdToSectionId = new Map<number, number>();
  for (const facility of facilities) {
    if (facility.robots.length === 0) continue;
    facilityIdToSectionId.set(facility.id, facility.robots[0].id);
  }

  // 施設12(フレッシュタウン湖畔店。ロボット0台)は sections が0件のため、
  // DS-012 をそのままシードすると controls.section_id(NOT NULL FK)違反で止まる。
  // ★facility_id を名指ししない★。「section が存在しない施設のデバイス」を構造的に弾き、
  //   スキップ件数が想定と違ったら例外で止める(着手前確認A案。指示書P :87 / 1-2)
  const skippedDevices: DsDevice[] = [];
  const seedableDevices = dsDevices.filter((device) => {
    if (facilityIdToSectionId.has(device.facility_id)) return true;
    skippedDevices.push(device);
    return false;
  });

  console.log(
    `[seed-local-db] D-Secデバイス ${dsDevices.length}件中 ${seedableDevices.length}件を投入対象にします`
  );
  for (const device of skippedDevices) {
    console.log(
      `[seed-local-db] skip: device serial=${device.serial_number}(施設${device.facility_id} に清掃エリアが存在しない。着手前確認A案)`
    );
  }
  if (skippedDevices.length !== 1) {
    throw new Error(
      `[seed-local-db] スキップされたD-Secデバイスの件数が想定(1件)と異なります: ${skippedDevices.length}件。fixtures側で新たな不整合が生まれている可能性があるため中断しました。`
    );
  }

  // ★D-Sec アラートは `robot_error_reports` の行として入れる(指示書AX 段階6)★。
  //   `control_error_histories` には書かない(`deleteMany` だけ残して前回のシードの行を消す)。
  //   ★見えない行(FX-ERR-19 = 削除済みの清掃エリア)は入れない★ — ローカルでは施設の生きている
  //   清掃エリアに付け替わって出てしまうため
  const seedableDsAlertRows = errorReportRows.filter(
    (row) => isDsAlertRow(row) && isVisibleErrorRow(row)
  );
  console.log(
    `[seed-local-db] D-Secアラート(robot_error_reports)${seedableDsAlertRows.length}件を投入対象にします`
  );
  if (seedableDsAlertRows.length !== 7) {
    throw new Error(
      `[seed-local-db] D-Secアラートの件数が想定(7件)と異なります: ${seedableDsAlertRows.length}件。fixtures側で新たな不整合が生まれている可能性があるため中断しました。`
    );
  }

  // serial_number → 投入済み clean_schedules.id の逆引き。
  // ★clean_timetables.clean_schedule_id は NOT NULL の FK(prisma/schema.prisma:591)なので、
  //   対応するスケジュールが無い清掃ログは構造的にシードできない★
  const scheduleIdBySerial = new Map<string, number>();
  for (const schedule of seedableSchedules) {
    if (scheduleIdBySerial.has(schedule.serial_number)) continue;
    scheduleIdBySerial.set(schedule.serial_number, schedule.id);
  }

  // ★清掃ログのうち構造的にシードできない行を弾く(着手前確認A案。指示書R 1-2 / 「調査済みの事実」5)★
  //   ① ロボット(= section)が存在しない施設の行
  //   ② 対応する clean_schedules が存在しない serial の行
  //   ★facility_id や serial を名指ししないこと★(:154 / :187 と同じ作法)
  const skippedLogsNoRobot: typeof cleanLogs = [];
  const skippedLogsNoSchedule: typeof cleanLogs = [];
  const seedableCleanLogs = cleanLogs.filter((log) => {
    if (!robotBySerial.has(log.serial_number)) {
      skippedLogsNoRobot.push(log);
      return false;
    }
    if (!scheduleIdBySerial.has(log.serial_number)) {
      skippedLogsNoSchedule.push(log);
      return false;
    }
    return true;
  });
  const skippedLogsTotal =
    skippedLogsNoRobot.length + skippedLogsNoSchedule.length;

  console.log(
    `[seed-local-db] 清掃ログ ${cleanLogs.length}件中 ${seedableCleanLogs.length}件を投入対象にします`
  );
  const tallyBySerial = (items: typeof cleanLogs): string =>
    [
      ...items.reduce(
        (m, log) => m.set(log.serial_number, (m.get(log.serial_number) ?? 0) + 1),
        new Map<string, number>()
      ),
    ]
      .map(([serial, count]) => `${serial}×${count}`)
      .join(", ");
  console.log(
    `[seed-local-db] skip: clean_log ${skippedLogsNoRobot.length}件(ロボット/清掃エリアが存在しない施設): ${tallyBySerial(skippedLogsNoRobot)}`
  );
  console.log(
    `[seed-local-db] skip: clean_log ${skippedLogsNoSchedule.length}件(対応する清掃スケジュールが無い): ${tallyBySerial(skippedLogsNoSchedule)}`
  );
  // ★内訳: ロボット無し1件(8111R5628060166)+ スケジュール無し1件(8111R5628060088)= 2件(2026-10-27 実測。当方の fixtures 由来)★
  //   旧: 27件(スケジュール無し 1 + 25。指示書R「調査済みの事実」5)— その後 fixtures の予定が増え、25件の側は予定を持つようになった
  //   (スキップが減る=投入される行が増える向きの変化なので、想定値だけを直した。指示書BD のローカルDB 作業で判明)
  if (skippedLogsTotal !== 2) {
    throw new Error(
      `[seed-local-db] スキップされた清掃ログの件数が想定(2件)と異なります: ${skippedLogsTotal}件。fixtures側で新たな不整合が生まれている可能性があるため中断しました。`
    );
  }

  // 削除は挿入の逆順(外部キーの依存。指示書M「調査済みの事実」4)。
  // ★D-Secの3テーブルは既存7テーブルより後に挿入するため、削除は先頭側に置く(指示書P 1-5)★
  // ★清掃ログ系4テーブルはさらに後に挿入するため、削除はいちばん先頭に置く(指示書R 1-6)★
  await prisma.robotErrorReport.deleteMany({});
  // ★`clean_task_reports`(指示書AQ 決定2 / 担当F)★ — `facilities` / `sections` / `robots` への
  //   FK を持つので、それらの削除より前に消す(`clean_timetables` への FK は無い)。
  await prisma.cleanTaskReport.deleteMany({});
  await prisma.cleanStatusHistory.deleteMany({});
  await prisma.cleanStatus.deleteMany({});
  await prisma.cleanTimetable.deleteMany({});
  // ★`clean_timetable_histories`(指示書AU 段階3b-5)★ — `clean_schedules` への FK があるので、
  //   `cleanSchedule.deleteMany` より前に消す。★以前はこの表を一切書かず、消してもいなかった★
  await prisma.cleanTimetableHistory.deleteMany({});
  await prisma.controlErrorHistory.deleteMany({});
  await prisma.controlReport.deleteMany({});
  await prisma.control.deleteMany({});
  await prisma.cleanSchedule.deleteMany({});
  await prisma.robot.deleteMany({});
  await prisma.section.deleteMany({});
  // ★レポートサマリ3テーブル(指示書T 段階1)。facility / customer の削除より前に置く★
  await prisma.facilityErrorSummary.deleteMany({});
  await prisma.facilitySummary.deleteMany({});
  await prisma.customerSummary.deleteMany({});
  await prisma.facility.deleteMany({});
  await prisma.device.deleteMany({});
  await prisma.customer.deleteMany({});
  await prisma.group.deleteMany({});
  // ★`error_lists`(エラー内容の選択肢。指示書BD §8。D-184)★ — ★`facility_error_summaries` /
  //   `customer_error_summaries` の `error_list_id` から FK を張られている★(2026-10-29 の DDL 変更)ので、
  //   ★サマリより後に消す★(`robot_error_reports.error_list_id` には FK が無い)。
  //   ★表はローカルに元々無い★。無ければここで落ちるので、先に `scripts/local-db/apply-error-lists.ts` を流すこと。
  await prisma.errorList.deleteMany({});

  // -------------------------------------------------- error_lists(指示書BD §5-2 / §8。D-184)
  //
  // ★`lib/fixtures/error-lists.ts` の131行をそのまま入れる★(id も fixtures のまま。AUTO_INCREMENT に任せない)。
  //   `USE_FIXTURES` の true / false で `GET /error-lists` が同じになるように。
  const errorListResult = await prisma.errorList.createMany({ data: errorLists });
  if (errorListResult.count !== errorLists.length) {
    throw new Error(
      `[seed-local-db] error_lists の投入件数が fixtures と異なります: ${errorListResult.count}件 / ${errorLists.length}件`
    );
  }
  const errorListIds = new Set(errorLists.map((row) => Number(row.id)));
  if (!errorListIds.has(ERROR_LIST_VALID_ID) || errorListIds.has(ERROR_LIST_MISSING_ID)) {
    throw new Error(
      `[seed-local-db] error_list_id のシード用の id が前提と違います(${ERROR_LIST_VALID_ID} は error_lists にある・${ERROR_LIST_MISSING_ID} は無い、のはず)`
    );
  }
  // ★サマリの `error_list_id` は FK なので、error_lists に無い id を入れると INSERT で落ちる★
  if (!errorListIds.has(ERROR_SUMMARY_ERROR_LIST_ID)) {
    throw new Error(
      `[seed-local-db] facility_error_summaries に入れる error_list_id=${ERROR_SUMMARY_ERROR_LIST_ID} が error_lists にありません`
    );
  }

  // -------------------------------------------------- groups / customers(指示書AK 段階8 / §11-3)
  //
  // ★以前はここで「テストグループ001 / テスト顧客001」を1件ずつ作っていた★が、
  //   1グループ1顧客では ★3階層(ホールディングス→屋号→施設)の絞り込み★ も、
  //   ★§9-3 の 6-2(2社ぶんのデータで本当に絞れているか)/ 6-4(2社で422)★ も確かめられない。
  //   ★fixtures(`lib/fixtures/groups.ts` / `lib/fixtures/customers.ts`)をそのまま投入する★ —
  //   `USE_FIXTURES` の true/false で同じ顔ぶれが出るようにするため(禁止事項6)。
  for (const group of groups) {
    await prisma.group.create({
      data: { id: BigInt(group.id), group_name: group.group_name },
    });
  }
  console.log(
    `[seed-local-db] groups=${groups.length} を投入しました(${groups
      .map((g) => `id=${g.id} ${g.group_name}`)
      .join(" / ")})`
  );

  // ★`customers` の住所系(zip_cd / prefecture_cd / prefecture_name / addr1)は NOT NULL だが
  //   fixtures に対応する値が無い(API が返さない列)★のでダミー固定値を入れる。
  //   ★実在しそうな住所・法人名を書かないこと★(CLAUDE.md §7 / D-043)。
  //
  // ★★`group_id = 0`(ホールディングスなし)について★★(指示書AK §11-4)—
  //   `customers.group_id` は NOT NULL で、★実FK制約 `customers_group_id_foreign` が
  //   `groups.id` に張られている★(`prisma/schema.prisma` の `model Customer`)。
  //   `groups` に id=0 の行は無いので、★`group_id = 0` は外部キー違反になりうる★。
  //   ★実際に試し、入らなければその顧客だけ投入を保留する★ —
  //   ★FK制約を外す/「グループ0」という行を作る、といった回避はしない★
  //   (TeamWorks へ照会中: 第9回 Q7-4)。
  const seededCustomerIds = new Set<number>();
  const heldCustomers: Customer[] = [];
  for (const customer of customers) {
    const dbGroupId = toDbGroupId(customer.group_id);
    try {
      await prisma.customer.create({
        data: {
          id: BigInt(customer.id),
          group_id: BigInt(dbGroupId),
          customer_name: customer.customer_name,
          zip_cd: "0000000",
          prefecture_cd: 13,
          prefecture_name: "東京都",
          addr1: "東京都○○区0-0-0",
        },
      });
      seededCustomerIds.add(customer.id);
    } catch (error) {
      if (!isForeignKeyViolation(error)) throw error;
      heldCustomers.push(customer);
      console.log(
        `[seed-local-db] 保留: customer id=${customer.id}(group_id=${dbGroupId} に対応する groups の行が無く外部キー違反。` +
          `指示書AK §11-4 のとおり回避せず保留します)`
      );
    }
  }
  const describeCustomers = (items: readonly Customer[]): string =>
    items.length === 0 ? "なし" : items.map((c) => `id=${c.id}`).join(", ");
  console.log(
    `[seed-local-db] customers=${seededCustomerIds.size} を投入しました` +
      `(投入=${describeCustomers(customers.filter((c) => seededCustomerIds.has(c.id)))}` +
      ` / 保留=${describeCustomers(heldCustomers)})`
  );

  // ★投入を保留した顧客の施設は、`facilities.customer_id` の外部キーが通らないので一緒に保留する★。
  //   ★施設IDを名指ししないこと★(:154 / :187 と同じ作法。fixtures が変わっても構造的に追従する)。
  const skippedFacilities = facilities.filter(
    (f) => !seededCustomerIds.has(f.customer_id)
  );
  const seedableFacilities = facilities.filter((f) =>
    seededCustomerIds.has(f.customer_id)
  );
  for (const facility of skippedFacilities) {
    console.log(
      `[seed-local-db] skip: facility id=${facility.id}(顧客 id=${facility.customer_id} が保留のため)`
    );
    // ★ロボットを持つ施設が保留になったら止める★ — 上流で作った `robotBySerial` /
    //   `facilityIdToSectionId` / スキップ件数の検算は ★全施設が投入される前提★ で
    //   組み立てられており、静かに外部キー違反の山になるため。
    if (facility.robots.length > 0) {
      throw new Error(
        `[seed-local-db] ロボットを持つ施設 id=${facility.id} が保留になりました(顧客 id=${facility.customer_id})。` +
          `清掃エリア・清掃ログ側の前提が崩れるため中断します。`
      );
    }
  }
  const seededFacilityIds = new Set(seedableFacilities.map((f) => f.id));

  for (const device of devices) {
    await prisma.device.create({
      data: {
        id: BigInt(device.id),
        maker_id: makerId(device.maker),
        model_name: device.model,
      },
    });
  }

  for (const facility of seedableFacilities) {
    await prisma.facility.create({
      data: {
        id: BigInt(facility.id),
        // ★fixtures の割り当て(`lib/fixtures/facilities.ts`)をそのまま使う★(指示書AK 段階8)—
        //   以前は全施設 `customer_id = 1` 固定だったため、屋号で絞っても結果が変わらなかった。
        customer_id: BigInt(facility.customer_id),
        facility_name: facility.facility_name,
        zip_cd: "0000000",
        prefecture_cd: prefectureCode(facility.prefecture),
        prefecture_name: facility.prefecture,
        addr1: facility.address,
        phone: facility.phone,
        email: facility.email,
        manager_name: facility.manager_name,
        charger_name: facility.charger_name,
        contract: buildContract({
          monitoring_enabled: facility.monitoring_enabled,
          d_security_enabled: facility.d_security_enabled,
        }),
      },
    });

    for (const robot of facility.robots) {
      const device = devices.find(
        (d) => d.maker === robot.maker && d.model === robot.model
      );
      if (!device) {
        throw new Error(
          `[seed-local-db] devices対応表に無い組み合わせです: ${robot.maker}/${robot.model}`
        );
      }

      // ★sections.id はロボットのidと同じにする(対応が一目で分かる)★
      await prisma.section.create({
        data: {
          id: BigInt(robot.id),
          facility_id: BigInt(facility.id),
          section_name: `清掃エリア(ロボット${robot.id})`,
        },
      });

      await prisma.robot.create({
        data: {
          id: BigInt(robot.id),
          facility_id: BigInt(facility.id),
          section_id: BigInt(robot.id),
          device_id: BigInt(device.id),
          serial_number: robot.serial_number,
        },
      });
    }
  }

  for (const schedule of seedableSchedules) {
    const robotInfo = robotBySerial.get(schedule.serial_number);
    if (!robotInfo) {
      throw new Error(
        `[seed-local-db] 想定外: serial=${schedule.serial_number} のロボットが見つかりません`
      );
    }
    await prisma.cleanSchedule.create({
      data: {
        id: BigInt(schedule.id),
        facility_id: BigInt(schedule.facility_id),
        section_id: BigInt(robotInfo.sectionId),
        weeks: schedule.weekdays,
        start_time: toHHMM(schedule.start_time),
        end_time: toEndTimeHHMM(schedule),
        // ★API はこの列を読まない。施設側の contract と混同しないよう、あえて既定値のままにする★
        inactive: schedule.active ? 0 : 1,
      },
    });
  }

  // ★facility_name / section_name はシードに渡さない★(1-6)。施設名・清掃エリア名は
  //   段階2/3で facilities / sections を結合して取ることで、fixtures の手書き文字列の
  //   不整合(城南店→大和店、緑野第二店→湖畔店)が構造的に消える(D-090 項4)。
  const controlBySerial = new Map<
    string,
    { id: number; facilityId: number; sectionId: number }
  >();
  for (const device of seedableDevices) {
    const sectionId = facilityIdToSectionId.get(device.facility_id);
    if (sectionId === undefined) {
      throw new Error(
        `[seed-local-db] 想定外: facility_id=${device.facility_id} の清掃エリアが見つかりません`
      );
    }
    const controlId = controlIdFromSerial(device.serial_number);
    await prisma.control.create({
      data: {
        id: BigInt(controlId),
        serial_number: device.serial_number,
        password: CONTROL_PASSWORD_PLACEHOLDER,
        facility_id: BigInt(device.facility_id),
        section_id: BigInt(sectionId),
        // ★逆変換: DsDevice.connection_status → controls.status(D-065 Q1-2)★
        status: controlStatusFromConnectionStatus(device.connection_status),
      },
    });
    controlBySerial.set(device.serial_number, {
      id: controlId,
      facilityId: device.facility_id,
      sectionId,
    });
  }

  // ★「最新1件を選ぶ」ロジック(段階2)を検証するため、一部のデバイスには
  //   last_reported_at より前の行をもう1件足す(先頭3件。決定的な選び方)★
  const OLDER_REPORT_SERIALS = new Set(
    seedableDevices.slice(0, 3).map((d) => d.serial_number)
  );
  const OLDER_REPORT_OFFSET_MS = 12 * 60 * 60 * 1000; // 12時間前

  let nextControlReportId = 1;
  for (const device of seedableDevices) {
    const control = controlBySerial.get(device.serial_number);
    if (!control) {
      throw new Error(
        `[seed-local-db] 想定外: serial=${device.serial_number} のcontrolが見つかりません`
      );
    }
    const latestReportedAt = toDbJst(new Date(device.last_reported_at));

    if (OLDER_REPORT_SERIALS.has(device.serial_number)) {
      await prisma.controlReport.create({
        data: {
          id: BigInt(nextControlReportId++),
          facility_id: BigInt(control.facilityId),
          section_id: BigInt(control.sectionId),
          control_id: BigInt(control.id),
          serial_number: device.serial_number,
          reported_at: new Date(latestReportedAt.getTime() - OLDER_REPORT_OFFSET_MS),
        },
      });
    }

    await prisma.controlReport.create({
      data: {
        id: BigInt(nextControlReportId++),
        facility_id: BigInt(control.facilityId),
        section_id: BigInt(control.sectionId),
        control_id: BigInt(control.id),
        serial_number: device.serial_number,
        reported_at: latestReportedAt,
      },
    });
  }

  // ★清掃エリア名は fixtures の `section.section_name` ではなく、ローカルの `sections` の名前で出る★
  //   (`USE_FIXTURES` の true/false で清掃エリア名だけ違う。承知のうえ。指示書AX 段階6-4)
  for (const row of seedableDsAlertRows) {
    const facilityId = Number(row.facility_id);
    const sectionId = facilityIdToSectionId.get(facilityId);
    if (sectionId === undefined) {
      throw new Error(
        `[seed-local-db] 想定外: facility_id=${facilityId} の清掃エリアが見つかりません(D-Secアラート ${row.error_id})`
      );
    }
    await prisma.robotErrorReport.create({
      select: { error_id: true }, // 戻り値は使わない。全列を読み戻さない(列の無い DB で P2022 になるため)
      data: {
        id: row.error_id,
        error_id: row.error_id,
        facility_id: row.facility_id,
        section_id: BigInt(sectionId),
        // ★ダミー★ — ローカルのスキーマは `robot_id` が NOT NULL(本番の DDL は NULL 可)。
        //   シードの採番規則「sections.id とロボットの id は同値」に合わせて section_id と同じ値を入れる
        robot_id: BigInt(sectionId),
        // ★fixtures の予定 id(9001 等)はローカルに無い★。予定不明として出さないため null
        clean_timetable_id: null,
        serial_number: row.serial_number,
        error_level: row.error_level,
        error_type: row.error_type,
        error_detail: ERROR_DETAIL_PLACEHOLDER,
        // ★`error_list_id`(D-184)は入れない(null)★ — D-Sec の行は選べない種別(`ERROR_LIST_UNSELECTABLE_ERROR_TYPES`)
        status: row.status,
        comment: row.comment,
        // ★日時は fixtures で既に `toDbJst()` 済み。もう一度通さない★
        errored_at: row.errored_at,
        created_at: row.created_at,
        responded_at: row.responded_at,
        resolved_at: row.resolved_at,
      },
    });
  }

  // ------------------------------------------------------------ 清掃ログ系4テーブル
  //
  // 挿入順は FK 依存のとおり:
  //   clean_timetables → clean_statuses / clean_status_histories → robot_error_reports
  // ★日付昇順で処理する★ — `-1`(セキュリティ無効で完了)を混ぜる位置を実行のたびに
  //   変えないため(冪等性の一部)。fixtures の `cleanLogs` は降順に並んでいるので並べ替える。
  const orderedCleanLogs = [...seedableCleanLogs].sort((a, b) => {
    if (a.scheduled_at !== b.scheduled_at) {
      return a.scheduled_at < b.scheduled_at ? -1 : 1;
    }
    return a.id - b.id;
  });

  // 当日分(`clean_statuses` = 「今の状態」に入れる行の判定。D-093 項1)。
  // ★fixtures と同じ「実行日からの相対」で当日を決める(lib/fixtures/_helpers.ts の dateOnly)★
  const todayJstDate = dateOnly(0);

  let timetableCount = 0;
  let statusCount = 0;
  let historyCount = 0;
  // ★`clean_task_reports`(指示書AQ 決定2 / 担当F)。3ブロック(本ループ / 施設7検証 /
  //   ボリューム)で共有し、最後に分布を出力する★
  const cleanTaskReportStats = newCleanTaskReportStats();
  let errorReportCount = 0;
  // ★本清掃行を指すエラーの通番(★`errorReportCount` とは別にする★。決定4)★ —
  //   同じカウンタを使うと `ERROR_RESOLUTION_ROTATION` のスロットがずれ、既存14件の値が変わる
  let mainErrorReportCount = 0;
  // ★本清掃エラーの `error_list_id` の分布("null" / id の文字列 → 件数。最後に検査して出力する。D-184)★
  const errorListIdCounts = new Map<string, number>();
  let historyIndex = 0;
  // ★`clean_error` の分布(段階5 の検証を楽にするため出力する。指示書U 3-1 項8)★
  const cleanErrorCounts: Record<number, number> = {};
  // 対応状況の分布(出力して確認できるようにする。指示書S 1-4)
  const errorResolutionCounts: Record<PrecheckStatus, number> = {
    unresolved: 0,
    in_progress: 0,
    resolved: 0,
  };

  for (const log of orderedCleanLogs) {
    const robotInfo = robotBySerial.get(log.serial_number);
    const scheduleId = scheduleIdBySerial.get(log.serial_number);
    if (!robotInfo || scheduleId === undefined) {
      throw new Error(
        `[seed-local-db] 想定外: serial=${log.serial_number} のロボット/スケジュールが見つかりません`
      );
    }
    // ★事前チェック行の id はオフセット加算で作る(設計判断3)。
    //   fixtures の id が 4桁を超えると衝突しうるので、黙って壊れないよう検査する★
    if (log.id <= 0 || log.id >= PRECHECK_TIMETABLE_ID_OFFSETS[0]) {
      throw new Error(
        `[seed-local-db] 清掃ログの id が採番規則の想定範囲外です: ${log.id}(1〜${PRECHECK_TIMETABLE_ID_OFFSETS[0] - 1} を想定)`
      );
    }

    const startAt = toDbJst(new Date(log.scheduled_at));
    const endAt = addMinutes(startAt, MAIN_CLEAN_DURATION_MINUTES);

    // 本清掃行(precheck = 0 / parent_id = 0。prisma/schema.prisma:593-604)
    await prisma.cleanTimetable.create({
      data: {
        id: BigInt(log.id),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_schedule_id: BigInt(scheduleId),
        parent_id: BigInt(0),
        start_at: startAt,
        end_at: endAt,
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
      },
    });
    timetableCount += 1;

    // 事前チェック行(1回目 / 2回目。分数は fixtures の値。D-178)とそれに紐づくエラー
    // ★fixtures が null(事前チェックの予定が無い。D-171)の回は `precheckStatuses()` が外す★ —
    //   予定行が無いことが `statusColumnsFromPrecheckRows()` で null になる条件なので、
    //   `USE_FIXTURES` の true / false で同じ null が出る。★null を error_type に入れないこと★(NOT NULL)
    for (const [i, { status, minutesBefore }] of precheckStatuses(log).entries()) {
      const precheckId = log.id + PRECHECK_TIMETABLE_ID_OFFSETS[i];
      const precheckStartAt = addMinutes(startAt, -minutesBefore);

      await prisma.cleanTimetable.create({
        data: {
          id: BigInt(precheckId),
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          clean_schedule_id: BigInt(scheduleId),
          // ★親(本清掃)の id。本清掃は 0(prisma/schema.prisma:593-595)★
          parent_id: BigInt(log.id),
          start_at: precheckStartAt,
          // ★事前チェックは瞬間的な確認なので end_at は start_at と同値(設計判断4)★
          end_at: precheckStartAt,
          // ★precheck は分数そのもの(指示書X 段階4。TeamWorks第7回 A5-2/A5-5。D-112)★
          precheck: minutesBefore,
        },
      });
      timetableCount += 1;

      // ★fixtures の値が「エラー無し」の既定値以外のときだけエラー行を作る(設計判断2)★
      //   error_type には fixtures の値を**そのまま**入れる(D-077 / D-088。変換しない)
      if (status === FIXTURE_NO_ERROR_ROBOT_STATUS) continue;
      const errorKey = `RER-${log.id}-${minutesBefore}`;

      // ★対応状況4カラムを決定的に散らす(指示書S 1-4)★
      //   通番の剰余なので、2回実行しても同じ行に同じ値が入る
      const slot = errorReportCount % ERROR_RESOLUTION_ROTATION.length;
      const resolution = ERROR_RESOLUTION_ROTATION[slot];
      const respondedAt =
        resolution === "unresolved"
          ? null
          : addMinutes(precheckStartAt, RESPONDED_AFTER_MINUTES);
      const resolvedAt =
        resolution === "resolved"
          ? addMinutes(precheckStartAt, RESOLVED_AFTER_MINUTES)
          : null;
      errorResolutionCounts[resolution] += 1;

      await prisma.robotErrorReport.create({
        select: { error_id: true }, // 戻り値は使わない。全列を読み戻さない(列の無い DB で P2022 になるため)
        data: {
          id: errorKey,
          error_id: errorKey,
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          // ★sections.id とロボットの id は同値に採番している(:142-149)★
          robot_id: BigInt(robotInfo.sectionId),
          // ★FK も KEY も無い列。手動 join のキーになる(prisma/schema.prisma:322。落とし穴1)★
          clean_timetable_id: BigInt(precheckId),
          serial_number: log.serial_number,
          error_level: ERROR_LEVEL_PLACEHOLDER,
          error_type: status,
          error_detail: ERROR_DETAIL_PLACEHOLDER,
          // ★書き込み許可4カラム(D-042)。本番では画面から更新される値なので、
          //   ローカルDBでは変換(0/1/2 → 3値)が効いているか確認できるだけ散らす★
          //   ★5列目の `error_list_id`(D-184)は入れない(null)★ — 事前チェックの経路からは書かないため(指示書BD §2)
          status: robotErrorStatusFromResolutionStatus(resolution),
          comment: slot === ERROR_COMMENT_NULL_SLOT ? null : ERROR_COMMENT_PLACEHOLDER,
          responded_at: respondedAt,
          resolved_at: resolvedAt,
          errored_at: precheckStartAt,
        },
      });
      errorReportCount += 1;
    }

    // ★★未実施(departed=false)も `clean_status_histories` の行を作る(設計判断1。D-107)★★
    //   ★未実施であることは `clean_error = 1` で表す★(旧方針の「行を作らない」は破棄)
    let cleanStatus: number;
    let cleanError: number;
    let areaFields: { task_area: number; clean_area: number; clean_ratio: number };

    if (log.completion_rate === null) {
      // ---- 未実施行(fixtures の `departed: false` = `completion_rate: null` の13行)
      cleanStatus = CLEAN_STATUS_BEFORE;
      cleanError = CLEAN_ERROR.notExecuted;
      // ★面積・率はすべて 0(=カラムの既定値のまま)★ —
      //   取得元(PUDU Cloud の clean task log)が存在しないため入れる値が無い。
      //   ★★併せて: 面積から率を再計算する処理は未実施行で `0 / 0` になる★★
      //   (D-091 項2 の「境界の厳密さが要る場面では面積から計算する」という手が使えない)。
      //   ★再計算する側は**先に `clean_error` を見ること**★(指示書U 落とし穴5)。
      //   ★★未実施行の面積が 0 なのか「予定していた清掃対象面積」なのかは TeamWorks へ確認中★★
      //   (D-107 の申し送り 項3。清掃完了率の分母が変わる)
      areaFields = { task_area: 0, clean_area: 0, clean_ratio: 0 };
    } else {
      // ---- 完了行
      cleanStatus =
        historyIndex % SECURITY_DISABLED_EVERY === 0
          ? CLEAN_STATUS_FINISHED_SECURITY_DISABLED
          : CLEAN_STATUS_FINISHED_SECURITY_ENABLED;
      // ★2/3/4(未実施ではない異常)を決定的に散らす★。★率・面積・clean_status は変えない★
      cleanError =
        historyIndex % ABNORMAL_CLEAN_ERROR_EVERY === 0
          ? ABNORMAL_CLEAN_ERROR_ROTATION[
              Math.floor(historyIndex / ABNORMAL_CLEAN_ERROR_EVERY) %
                ABNORMAL_CLEAN_ERROR_ROTATION.length
            ]
          : CLEAN_ERROR.normal;
      historyIndex += 1;

      // clean_area / task_area * 100 ≒ clean_ratio になるよう逆算する(指示書R 1-4)
      const cleanArea =
        Math.round(((TASK_AREA_SQM * log.completion_rate) / 100) * 1_000_000) /
        1_000_000;
      areaFields = {
        task_area: TASK_AREA_SQM,
        clean_area: cleanArea,
        // ★丸めない生値をそのまま入れる(CLAUDE.md §4.3。double(4,1) なので小数第1位まで保持される)★
        clean_ratio: log.completion_rate,
      };
    }
    cleanErrorCounts[cleanError] = (cleanErrorCounts[cleanError] ?? 0) + 1;

    await prisma.cleanStatusHistory.create({
      data: {
        // ★AUTO_INCREMENT ではないので明示採番(prisma/schema.prisma:482)。1清掃=1履歴★
        id: BigInt(log.id),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_timetable_id: BigInt(log.id),
        clean_status: cleanStatus,
        clean_error: cleanError,
        ...areaFields,
        // ★`started_at` / `ended_at` は NOT NULL(prisma/schema.prisma:506-507)なので
        //   未実施行でも null にできない★ → ★定義書の原文
        //   「清掃スケジュールの清掃開始日時を格納する」/「清掃スケジュールの清掃終了日時を
        //   格納する」(table-definitions-02-clean-error.md:217-218)に従い、
        //   ★予定の時刻をそのまま入れる★(完了行と同じ扱い。指示書U 落とし穴4)
        started_at: startAt,
        ended_at: endAt,
      },
    });
    historyCount += 1;

    // ------------------------------------------------ `clean_task_reports`(指示書AQ 決定2 / 担当F)
    //
    // ★★未実施(`completion_rate === null`)には行を作らない★★ — 指示書AQ 1-1 の
    //   「未実施 = 本清掃の `clean_timetables` 行に対応する `clean_task_reports` の行が無い」。
    //   ★上の `clean_status_histories` は未実施でも行を作る(`clean_error = 1`)★ので
    //   ★2つのテーブルで行数が違う。それが意図した状態★(切替の前後比較の材料になる)。
    if (log.completion_rate !== null) {
      await createCleanTaskReport(
        {
          cleanTimetableId: log.id,
          facilityId: robotInfo.facilityId,
          sectionId: robotInfo.sectionId,
          serialNumber: log.serial_number,
          completionRate: log.completion_rate,
          startAt,
          endAt,
        },
        cleanTaskReportStats
      );
    }

    // ------------------------------------------------ 本清掃行を指すエラー(指示書AB 群3 決定4)
    //
    // ★★下の「当日分だけ」フィルタ(`continue`)より**前**に置くこと★★ —
    //   後ろに置くと当日以外の行が全部飛ばされ、1〜2件しか作られない。
    // ★実施済み(`completion_rate !== null`)の本清掃行にだけ作る★ —
    //   未実施の清掃に「清掃中のロボット異常」があるのは不自然なため。
    // ★`errorReportCount` ではなく `mainErrorReportCount` を使う★(既存14件をずらさない)
    if (log.completion_rate !== null && log.id % MAIN_CLEAN_ERROR_EVERY === 0) {
      const mainErrorKey = `RER-${log.id}-main`;
      const mainSlot = mainErrorReportCount % ERROR_RESOLUTION_ROTATION.length;
      const mainResolution = ERROR_RESOLUTION_ROTATION[mainSlot];
      const mainErroredAt = addMinutes(startAt, MAIN_CLEAN_ERROR_AFTER_MINUTES);
      const mainErrorType =
        MAIN_CLEAN_ERROR_TYPE_ROTATION[
          mainErrorReportCount % MAIN_CLEAN_ERROR_TYPE_ROTATION.length
        ];
      // ★エラー内容(D-184)。数行だけ値を入れ、残りは null(ERROR_LIST_ID_BY_MAIN_ERROR_SEQ)★
      const mainErrorListId = seedErrorListId(mainErrorReportCount, mainErrorType);
      if (
        mainErrorListId !== null &&
        ERROR_LIST_NOTE_REQUIRED_IDS.includes(Number(mainErrorListId)) &&
        mainSlot === ERROR_COMMENT_NULL_SLOT
      ) {
        throw new Error(
          `[seed-local-db] 備考必須の error_list_id=${mainErrorListId} を備考なしの行(${mainErrorKey})に入れようとしました`
        );
      }
      errorResolutionCounts[mainResolution] += 1;
      const errorListIdKey = mainErrorListId === null ? "null" : String(mainErrorListId);
      errorListIdCounts.set(errorListIdKey, (errorListIdCounts.get(errorListIdKey) ?? 0) + 1);

      await prisma.robotErrorReport.create({
        // ★戻り値は使わない。`select` を絞る★ — 絞らないと Prisma が全列(`error_list_id` を含む)を読み戻し、
        //   列の無い DB では P2022 で落ちる(指示書BD のレビュー指摘)
        select: { error_id: true },
        data: {
          id: mainErrorKey,
          error_id: mainErrorKey,
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          robot_id: BigInt(robotInfo.sectionId),
          // ★★本清掃行の id を指す(=事前チェック行ではない)★★
          clean_timetable_id: BigInt(log.id),
          serial_number: log.serial_number,
          error_level: ERROR_LEVEL_PLACEHOLDER,
          error_type: mainErrorType,
          error_detail: ERROR_DETAIL_PLACEHOLDER,
          // ★アプリが書く5列目(D-184)。本番では画面(エラー一覧)から選ばれる値★
          error_list_id: mainErrorListId,
          status: robotErrorStatusFromResolutionStatus(mainResolution),
          comment:
            mainSlot === ERROR_COMMENT_NULL_SLOT ? null : ERROR_COMMENT_PLACEHOLDER,
          responded_at:
            mainResolution === "unresolved"
              ? null
              : addMinutes(mainErroredAt, RESPONDED_AFTER_MINUTES),
          resolved_at:
            mainResolution === "resolved"
              ? addMinutes(mainErroredAt, RESOLVED_AFTER_MINUTES)
              : null,
          errored_at: mainErroredAt,
        },
      });
      mainErrorReportCount += 1;
    }

    // ★`clean_statuses` は「今の状態」用(D-093 項1)なので当日分だけ入れる★
    if (log.scheduled_at.slice(0, 10) !== todayJstDate) continue;
    await prisma.cleanStatus.create({
      data: {
        id: BigInt(log.id),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_timetable_id: BigInt(log.id),
        clean_status: cleanStatus,
        // ★履歴と同じ値を入れる★ — 論理名は違う(履歴側「結果」/ こちら「エラー」)が
        //   コード表は同じ(定義書 `:181` と `:213`)。★2つで食い違わせない★
        //   (使い分けは TeamWorks へ確認中。D-107 の申し送り 項7。当方は履歴側だけを読んでいる)
        clean_error: cleanError,
        ...areaFields,
        started_at: startAt,
        ended_at: endAt,
      },
    });
    statusCount += 1;
  }

  // ------------------------------------------------------------ 施設7の連続日数検証データ(指示書V 段階5)
  //
  // ★★本ループ(清掃ログ系4テーブル)の外・独立ブロックとして書く★★(落とし穴12)—
  //   上のループの中に混ぜると `historyIndex` / `errorReportCount` の通番がずれ、
  //   既存253行の `clean_error` 分布(0=225 / 1=13 / 2=5 / 3=5 / 4=5)と対応状況が変わる。
  //   ★事前チェック行・`robot_error_reports`・`clean_statuses` は作らない★(落とし穴13。
  //   `prechecks()` の27件を変えないため)。
  let facility7VerificationCount = 0;
  for (const item of FACILITY7_VERIFICATION_ROWS) {
    const robotInfo = robotBySerial.get(item.serial);
    const scheduleId = scheduleIdBySerial.get(item.serial);
    if (!robotInfo || scheduleId === undefined) {
      throw new Error(
        `[seed-local-db] 想定外: serial=${item.serial}(施設7連続日数検証データ)のロボット/スケジュールが見つかりません`
      );
    }
    const startAt = toDbJst(new Date(at(item.daysBack, item.time)));
    const endAt = addMinutes(startAt, MAIN_CLEAN_DURATION_MINUTES);
    const cleanArea =
      Math.round(((TASK_AREA_SQM * item.completionRate) / 100) * 1_000_000) / 1_000_000;

    await prisma.cleanTimetable.create({
      data: {
        id: BigInt(item.id),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_schedule_id: BigInt(scheduleId),
        parent_id: BigInt(0),
        start_at: startAt,
        end_at: endAt,
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
      },
    });
    timetableCount += 1;

    await prisma.cleanStatusHistory.create({
      data: {
        id: BigInt(item.id),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_timetable_id: BigInt(item.id),
        clean_status: CLEAN_STATUS_FINISHED_SECURITY_ENABLED,
        clean_error: CLEAN_ERROR.normal,
        task_area: TASK_AREA_SQM,
        clean_area: cleanArea,
        clean_ratio: item.completionRate,
        started_at: startAt,
        ended_at: endAt,
      },
    });
    historyCount += 1;
    cleanErrorCounts[CLEAN_ERROR.normal] = (cleanErrorCounts[CLEAN_ERROR.normal] ?? 0) + 1;

    // ★`clean_task_reports` も同じ清掃ぶん作る(指示書AQ 決定2 / 担当F)★ —
    //   作らないと切替後に ★施設7の「日替わりで別の台が問題→3日連続」(D-109)が
    //   まるごと未実施になり、連続日数の検証ができなくなる★。
    //   `FACILITY7_VERIFICATION_ROWS` は全行 `completionRate` が非nullなので常に作る。
    await createCleanTaskReport(
      {
        cleanTimetableId: item.id,
        facilityId: robotInfo.facilityId,
        sectionId: robotInfo.sectionId,
        serialNumber: item.serial,
        completionRate: item.completionRate,
        startAt,
        endAt,
      },
      cleanTaskReportStats
    );

    facility7VerificationCount += 1;
  }
  console.log(
    `[seed-local-db] 施設7連続日数検証データ(指示書V 段階5)=${facility7VerificationCount}件を投入しました`
  );

  // ------------------------------------------------------------ clean_timetable_histories(指示書AU 段階3b-5)
  //
  // ★★本ループの外・独立ブロックとして書く★★(落とし穴12 と同じ理由。通番をずらさない)。
  // ★事前チェック行のエラー(`robot_error_reports`)・`clean_statuses` は作らない★(`prechecks()` の件数を変えない)。
  // ★検証用データを置いてある施設は対象から外す★(ボリュームデータと同じ作法)。
  const timetableHistoryCounts = { historyOnly: 0, sameContent: 0, differentContent: 0 };
  {
    const excludedFacilityIds = new Set<number>();
    for (const serial of [
      ...FACILITY7_VERIFICATION_ROWS.map((row) => row.serial),
      DECISION3_VERIFICATION_SERIAL,
    ]) {
      const info = robotBySerial.get(serial);
      if (info) excludedFacilityIds.add(info.facilityId);
    }
    // ★並びを固定する(冪等)★ — `orderedCleanLogs` は予定時刻・id の昇順
    const candidateLogs = orderedCleanLogs.filter((log) => {
      const info = robotBySerial.get(log.serial_number);
      return (
        info !== undefined &&
        scheduleIdBySerial.has(log.serial_number) &&
        !excludedFacilityIds.has(info.facilityId)
      );
    });
    const serial = candidateLogs[0]?.serial_number;
    const sameRobotLogs = candidateLogs.filter((log) => log.serial_number === serial);
    const robotInfo = serial === undefined ? undefined : robotBySerial.get(serial);
    const scheduleId = serial === undefined ? undefined : scheduleIdBySerial.get(serial);
    if (!robotInfo || scheduleId === undefined || sameRobotLogs.length < 2) {
      throw new Error(
        "[seed-local-db] clean_timetable_histories の検証データを置けるロボットがありません(清掃ログが2件以上ある台が必要。fixtures側の構成が変わった可能性があります)"
      );
    }

    // ★id の衝突検査★(指示から: 既存の採番と衝突しないことをコードで検査する)—
    //   本表(fixtures の本清掃・事前チェック・施設7検証・ボリューム)と dangling のどれとも一致しないこと
    const historyOnlyIds = [HISTORY_ONLY_MAIN_TIMETABLE_ID, ...HISTORY_ONLY_PRECHECK_TIMETABLE_IDS];
    if (historyOnlyIds.includes(DANGLING_CLEAN_TIMETABLE_ID)) {
      throw new Error(
        `[seed-local-db] 履歴だけの予定の id が DANGLING_CLEAN_TIMETABLE_ID=${DANGLING_CLEAN_TIMETABLE_ID} と衝突しています(定数を変えること)`
      );
    }
    for (const id of historyOnlyIds) {
      const collision = await prisma.cleanTimetable.findUnique({ where: { id: BigInt(id) } });
      if (collision) {
        throw new Error(
          `[seed-local-db] 履歴だけの予定の id=${id} が既存の clean_timetables と衝突しています(定数を変えること)`
        );
      }
    }

    // ---- (a) 履歴だけの予定(本清掃1件 + 事前チェック2行)と、それに紐づくレポート1行
    const startAt = toDbJst(new Date(at(HISTORY_ONLY_DAYS_BACK, HISTORY_ONLY_START_TIME)));
    const endAt = addMinutes(startAt, MAIN_CLEAN_DURATION_MINUTES);
    await prisma.cleanTimetableHistory.create({
      data: {
        id: BigInt(HISTORY_ONLY_MAIN_TIMETABLE_ID),
        facility_id: BigInt(robotInfo.facilityId),
        section_id: BigInt(robotInfo.sectionId),
        clean_schedule_id: BigInt(scheduleId),
        parent_id: BigInt(0),
        start_at: startAt,
        end_at: endAt,
        precheck: CLEAN_TIMETABLE_PRECHECK.main,
      },
    });
    timetableHistoryCounts.historyOnly += 1;
    for (const [i, minutesBefore] of PRECHECK_MINUTES_BEFORE.entries()) {
      const precheckStartAt = addMinutes(startAt, -minutesBefore);
      await prisma.cleanTimetableHistory.create({
        data: {
          id: BigInt(HISTORY_ONLY_PRECHECK_TIMETABLE_IDS[i]),
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          clean_schedule_id: BigInt(scheduleId),
          parent_id: BigInt(HISTORY_ONLY_MAIN_TIMETABLE_ID),
          start_at: precheckStartAt,
          // ★事前チェックは瞬間的な確認なので end_at は start_at と同値(本ループの設計判断4 と同じ)★
          end_at: precheckStartAt,
          precheck: minutesBefore,
        },
      });
      timetableHistoryCounts.historyOnly += 1;
    }
    await createCleanTaskReport(
      {
        cleanTimetableId: HISTORY_ONLY_MAIN_TIMETABLE_ID,
        facilityId: robotInfo.facilityId,
        sectionId: robotInfo.sectionId,
        serialNumber: serial as string,
        completionRate: HISTORY_ONLY_COMPLETION_RATE,
        startAt,
        endAt,
      },
      cleanTaskReportStats
    );

    // ---- (b) 両方にある同じ予定(中身も同じ)/ (c) 同じ id で中身が違う(履歴側の start_at を1日ずらす)
    for (const [log, shiftDays] of [
      [sameRobotLogs[0], 0],
      [sameRobotLogs[1], HISTORY_CONFLICT_SHIFT_DAYS],
    ] as const) {
      const main = await prisma.cleanTimetable.findUniqueOrThrow({ where: { id: BigInt(log.id) } });
      await prisma.cleanTimetableHistory.create({
        data: {
          id: main.id,
          facility_id: main.facility_id,
          section_id: main.section_id,
          clean_schedule_id: main.clean_schedule_id,
          parent_id: main.parent_id,
          start_at: addMinutes(main.start_at, shiftDays * 24 * 60),
          end_at: addMinutes(main.end_at, shiftDays * 24 * 60),
          precheck: main.precheck,
        },
      });
      if (shiftDays === 0) timetableHistoryCounts.sameContent += 1;
      else timetableHistoryCounts.differentContent += 1;
    }
    console.log(
      `[seed-local-db] clean_timetable_histories(指示書AU 段階3b-5): 履歴だけ=${timetableHistoryCounts.historyOnly}行(本清掃 id=${HISTORY_ONLY_MAIN_TIMETABLE_ID} + 事前チェック2行)` +
        ` 両方・同じ中身=${timetableHistoryCounts.sameContent}行(id=${sameRobotLogs[0].id})` +
        ` 両方・中身が違う=${timetableHistoryCounts.differentContent}行(id=${sameRobotLogs[1].id}。履歴の start_at を${HISTORY_CONFLICT_SHIFT_DAYS}日ずらした)`
    );
  }

  // ------------------------------------------------------------ clean_timetable_id = 0 のレポート(TeamWorks 回答 2026-10-27)
  //
  // ★★本ループの外・独立ブロックとして書く★★(落とし穴12 と同じ理由。通番をずらさない)。
  // ★`lib/fixtures/clean-task-reports.ts` の `cleanTaskReportZeroTimetableRows` をそのまま入れる★ —
  //   予定外のタスク。★総清掃面積(F-08)の面積の合計には入り、完了率・実施回数・重複の番人には入らない★ことを
  //   ローカルDBで確かめるため(`lib/db/clean-area-source.ts`)。`createCleanTaskReport()` は清掃ログの id から
  //   値を作る関数なので使わない(★値は fixtures で決まっている★)。
  for (const row of cleanTaskReportZeroTimetableRows) {
    const robotInfo = robotBySerial.get(row.serial_number);
    if (!robotInfo || robotInfo.sectionId !== row.section_id || robotInfo.facilityId !== row.facility_id) {
      throw new Error(
        `[seed-local-db] 想定外: clean_timetable_id=0 のレポート(report_id=${row.report_id})のロボット serial=${row.serial_number} が投入した施設・清掃エリアと合いません`
      );
    }
    await prisma.cleanTaskReport.create({
      data: {
        report_id: row.report_id,
        facility_id: BigInt(row.facility_id),
        section_id: BigInt(row.section_id),
        robot_id: BigInt(row.robot_id),
        clean_timetable_id: row.clean_timetable_id === null ? null : BigInt(row.clean_timetable_id),
        serial_number: row.serial_number,
        status: row.status,
        task_area: row.task_area,
        clean_area: row.clean_area,
        clean_ratio: row.clean_ratio,
        // ★fixtures の日時は JST の ISO 8601。DB には壁時計値で入れる(`toDbJst()`)★
        started_at: toDbJst(new Date(row.started_at)),
        ended_at: row.ended_at === null ? null : toDbJst(new Date(row.ended_at)),
        clean_time: row.clean_time,
      },
    });
    cleanTaskReportStats.total += 1;
    cleanTaskReportStats.byStatus[row.status] = (cleanTaskReportStats.byStatus[row.status] ?? 0) + 1;
    if (row.ended_at === null) cleanTaskReportStats.endedAtNull += 1;
    if (row.clean_timetable_id === null) cleanTaskReportStats.timetableIdNull += 1;
    if (row.clean_timetable_id === CLEAN_TIMETABLE_ID_UNSCHEDULED) cleanTaskReportStats.timetableIdZero += 1;
    if (row.clean_time === 0) cleanTaskReportStats.cleanTimeZero += 1;
    if (row.clean_area > row.task_area) cleanTaskReportStats.cleanAreaOverTaskArea += 1;
    cleanTaskReportStats.sumTaskArea += row.task_area;
    cleanTaskReportStats.sumCleanArea += row.clean_area;
  }

  // ------------------------------------------------------------ 決定3 の検証用エラー3行(指示書AB 群3)
  //
  // ★★本ループの外・独立ブロックとして書く★★(落とし穴12 と同じ理由)—
  //   ループの中に混ぜると `errorReportCount` / `mainErrorReportCount` の通番がずれる。
  // ★★この3行はゴミデータではない★★ — `NULL_CLEAN_TIMETABLE_ERROR_ID` の docstring を読むこと。
  {
    const robotInfo = robotBySerial.get(DECISION3_VERIFICATION_SERIAL);
    if (!robotInfo) {
      throw new Error(
        `[seed-local-db] 想定外: serial=${DECISION3_VERIFICATION_SERIAL}(決定3 検証データ)のロボットが見つかりません`
      );
    }
    // ★dangling が本当に dangling であること(どの clean_timetables.id とも一致しないこと)を
    //   コードで確かめる★ — 将来 id 帯が広がって衝突したら黙って「本清掃を指すエラー」に化ける
    const collision = await prisma.cleanTimetable.findUnique({
      where: { id: BigInt(DANGLING_CLEAN_TIMETABLE_ID) },
    });
    if (collision) {
      throw new Error(
        `[seed-local-db] DANGLING_CLEAN_TIMETABLE_ID=${DANGLING_CLEAN_TIMETABLE_ID} が既存の clean_timetables と衝突しています(定数を変えること)`
      );
    }
    // ★予定は `clean_timetable_histories` からも読む(指示書AU 段階3b)ので、履歴とも衝突しないこと★
    const historyCollision = await prisma.cleanTimetableHistory.findUnique({
      where: { id: BigInt(DANGLING_CLEAN_TIMETABLE_ID) },
    });
    if (historyCollision) {
      throw new Error(
        `[seed-local-db] DANGLING_CLEAN_TIMETABLE_ID=${DANGLING_CLEAN_TIMETABLE_ID} が既存の clean_timetable_histories と衝突しています(定数を変えること)`
      );
    }

    const decision3ErroredAt = toDbJst(new Date(at(1, "02:00")));
    for (const [errorId, cleanTimetableId] of [
      [NULL_CLEAN_TIMETABLE_ERROR_ID, null],
      [DANGLING_CLEAN_TIMETABLE_ERROR_ID, BigInt(DANGLING_CLEAN_TIMETABLE_ID)],
      // ★0 は予定外のタスク(TeamWorks 回答 2026-10-27)。`0` を直書きしない★
      [ZERO_CLEAN_TIMETABLE_ERROR_ID, BigInt(CLEAN_TIMETABLE_ID_UNSCHEDULED)],
    ] as const) {
      await prisma.robotErrorReport.create({
        select: { error_id: true }, // 戻り値は使わない。全列を読み戻さない(列の無い DB で P2022 になるため)
        data: {
          id: errorId,
          error_id: errorId,
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          robot_id: BigInt(robotInfo.sectionId),
          clean_timetable_id: cleanTimetableId,
          serial_number: DECISION3_VERIFICATION_SERIAL,
          error_level: ERROR_LEVEL_PLACEHOLDER,
          error_type: MAIN_CLEAN_ERROR_TYPE_ROTATION[0],
          error_detail: ERROR_DETAIL_PLACEHOLDER,
          // ★書き込み許可4カラムは既定値のまま(ローテーションの通番を進めない)★。`error_list_id`(D-184)も null
          status: robotErrorStatusFromResolutionStatus("unresolved"),
          comment: null,
          responded_at: null,
          resolved_at: null,
          errored_at: decision3ErroredAt,
        },
      });
    }
    console.log(
      `[seed-local-db] 決定3検証データ(指示書AB 群3)=3件(null / dangling / 0=予定外)を投入しました`
    );
  }

  // ------------------------------------------------------------ 画面確認用ボリュームデータ(2026-09-13)
  //
  // ★★本ループの外・独立ブロックとして書く★★(落とし穴12 と同じ理由)—
  //   上のループに混ぜると `historyIndex` / `errorReportCount` / `mainErrorReportCount` の
  //   通番がずれ、既存の `clean_error` 分布と `robot_error_reports` 14件の値が変わる。
  // ★事前チェック行・`robot_error_reports`・`clean_statuses`(当日以外)は作らない★
  let extraVolumeRowCount = 0;
  let extraVolumeStatusCount = 0;
  let extraVolumeScheduleCount = 0;
  const extraVolumeGradeCounts: Record<ExtraVolumeGrade, number> = {
    excellent: 0,
    good: 0,
    fair: 0,
    poor: 0,
    notExecuted: 0,
  };
  {
    // ★検証用データを置いてある施設は対象から外す★(施設idを直書きせず、検証データの
    //   serial から施設を引く。将来 serial が変わっても構造的に追従する)
    const excludedFacilityIds = new Set<number>();
    for (const serial of [
      ...FACILITY7_VERIFICATION_ROWS.map((row) => row.serial),
      DECISION3_VERIFICATION_SERIAL,
    ]) {
      const info = robotBySerial.get(serial);
      if (info) excludedFacilityIds.add(info.facilityId);
    }

    // 対象ロボット: ★ロボットと清掃スケジュールが両方あり★、検証用施設に属さない serial。
    // ★並びを固定する(冪等)★ — slot 番号が実行ごとに変わると完了率も変わってしまう
    const targetSerials = [...scheduleIdBySerial.keys()]
      .filter((serial) => {
        const info = robotBySerial.get(serial);
        return info !== undefined && !excludedFacilityIds.has(info.facilityId);
      })
      .sort();
    if (targetSerials.length === 0) {
      throw new Error(
        "[seed-local-db] ボリュームデータの対象ロボットが0台です(fixtures側の構成が変わった可能性があります)"
      );
    }

    // ★id 帯の衝突検査★(指示から: 既存の採番帯と衝突しないこと)。
    //   既存の最大 id(fixtures の本清掃 / 施設7検証)より後ろ、かつ
    //   事前チェックのオフセット(10万)と dangling(90万)より手前であること。
    const maxExistingRowId = Math.max(
      ...orderedCleanLogs.map((log) => log.id),
      ...FACILITY7_VERIFICATION_ROWS.map((row) => row.id)
    );
    const extraVolumeTotal = EXTRA_VOLUME_DAYS * targetSerials.length;
    const maxExtraRowId = EXTRA_VOLUME_ROW_ID_BASE + extraVolumeTotal - 1;
    if (EXTRA_VOLUME_ROW_ID_BASE <= maxExistingRowId) {
      throw new Error(
        `[seed-local-db] EXTRA_VOLUME_ROW_ID_BASE=${EXTRA_VOLUME_ROW_ID_BASE} が既存の清掃ログ id(最大 ${maxExistingRowId})と衝突します`
      );
    }
    if (maxExtraRowId >= PRECHECK_TIMETABLE_ID_OFFSETS[0]) {
      throw new Error(
        `[seed-local-db] ボリュームデータの id(最大 ${maxExtraRowId})が事前チェックのオフセット帯(${PRECHECK_TIMETABLE_ID_OFFSETS[0]}〜)に届いています`
      );
    }
    if (maxExtraRowId >= DANGLING_CLEAN_TIMETABLE_ID) {
      throw new Error(
        `[seed-local-db] ボリュームデータの id(最大 ${maxExtraRowId})が DANGLING_CLEAN_TIMETABLE_ID=${DANGLING_CLEAN_TIMETABLE_ID} に届いています`
      );
    }

    // ★予定(`clean_schedules`)をロボット1台につき1件足す★ — ダッシュボードの
    //   「予定件数 vs 当日のログ件数」を食い違わせないため(上のコメント参照)。
    //   ★毎日・有効★(`weeks` は「日月火水木金土」の7桁。`lib/fixtures/schedules.ts`)
    const extraScheduleIdBySerial = new Map<string, number>();
    for (const [slot, serial] of targetSerials.entries()) {
      const robotInfo = robotBySerial.get(serial);
      if (!robotInfo) {
        throw new Error(
          `[seed-local-db] 想定外: serial=${serial}(ボリュームデータ)のロボットが見つかりません`
        );
      }
      const scheduleId = EXTRA_VOLUME_SCHEDULE_ID_BASE + slot;
      const startMinutes =
        EXTRA_VOLUME_FIRST_START_MINUTES + slot * EXTRA_VOLUME_START_STEP_MINUTES;
      await prisma.cleanSchedule.create({
        data: {
          id: BigInt(scheduleId),
          facility_id: BigInt(robotInfo.facilityId),
          section_id: BigInt(robotInfo.sectionId),
          weeks: "1111111",
          start_time: toHHMM(minutesToHhMm(startMinutes)),
          end_time: toHHMM(minutesToHhMm(startMinutes + MAIN_CLEAN_DURATION_MINUTES)),
          inactive: 0,
        },
      });
      extraScheduleIdBySerial.set(serial, scheduleId);
      extraVolumeScheduleCount += 1;
    }

    let extraIndex = 0;
    // ★古い日 → 当日の順に作る(`daysBack` は 30 → 0)★。
    //   `clean_status` の `-1`(セキュリティ無効で完了)を混ぜる位置を実行のたびに
    //   変えないため、上の本ループと同じく日付昇順で回す。
    for (let dayIndex = 0; dayIndex < EXTRA_VOLUME_DAYS; dayIndex += 1) {
      const daysBack = EXTRA_VOLUME_DAYS - 1 - dayIndex;
      for (const [slot, serial] of targetSerials.entries()) {
        const robotInfo = robotBySerial.get(serial);
        const scheduleId = extraScheduleIdBySerial.get(serial);
        if (!robotInfo || scheduleId === undefined) {
          throw new Error(
            `[seed-local-db] 想定外: serial=${serial}(ボリュームデータ)のロボット/スケジュールが見つかりません`
          );
        }

        const id = EXTRA_VOLUME_ROW_ID_BASE + extraIndex;
        const startMinutes =
          EXTRA_VOLUME_FIRST_START_MINUTES + slot * EXTRA_VOLUME_START_STEP_MINUTES;
        // ★深夜(00:10〜)に置く★ — `cleanLogs()` は `isFutureSchedule()` で未来の予定を
        //   落とすので、当日ぶんが「まだ来ていない予定」にならない時刻にする
        const startAt = toDbJst(new Date(at(daysBack, minutesToHhMm(startMinutes))));
        const endAt = addMinutes(startAt, MAIN_CLEAN_DURATION_MINUTES);

        const grade =
          EXTRA_VOLUME_GRADE_CYCLE[(dayIndex + slot) % EXTRA_VOLUME_GRADE_CYCLE.length];
        const completionRate = extraVolumeCompletionRate(
          grade,
          extraVolumeRandom(dayIndex * 100 + slot)
        );
        extraVolumeGradeCounts[grade] += 1;

        await prisma.cleanTimetable.create({
          data: {
            id: BigInt(id),
            facility_id: BigInt(robotInfo.facilityId),
            section_id: BigInt(robotInfo.sectionId),
            clean_schedule_id: BigInt(scheduleId),
            parent_id: BigInt(0),
            start_at: startAt,
            end_at: endAt,
            precheck: CLEAN_TIMETABLE_PRECHECK.main,
          },
        });
        timetableCount += 1;

        let cleanStatus: number;
        let cleanError: number;
        let areaFields: { task_area: number; clean_area: number; clean_ratio: number };
        if (completionRate === null) {
          // 未実施行(上の本ループと同じ扱い。設計判断1 / D-107)
          cleanStatus = CLEAN_STATUS_BEFORE;
          cleanError = CLEAN_ERROR.notExecuted;
          areaFields = { task_area: 0, clean_area: 0, clean_ratio: 0 };
        } else {
          cleanStatus =
            extraIndex % SECURITY_DISABLED_EVERY === 0
              ? CLEAN_STATUS_FINISHED_SECURITY_DISABLED
              : CLEAN_STATUS_FINISHED_SECURITY_ENABLED;
          // ★2/3/4(未実施ではない異常)も決定的に散らす★(率・面積は変えない)
          cleanError =
            extraIndex % EXTRA_VOLUME_ABNORMAL_EVERY === 0
              ? ABNORMAL_CLEAN_ERROR_ROTATION[
                  Math.floor(extraIndex / EXTRA_VOLUME_ABNORMAL_EVERY) %
                    ABNORMAL_CLEAN_ERROR_ROTATION.length
                ]
              : CLEAN_ERROR.normal;
          areaFields = {
            task_area: TASK_AREA_SQM,
            clean_area:
              Math.round(((TASK_AREA_SQM * completionRate) / 100) * 1_000_000) / 1_000_000,
            // ★丸めない生値をそのまま入れる(CLAUDE.md §4.3)★
            clean_ratio: completionRate,
          };
        }
        cleanErrorCounts[cleanError] = (cleanErrorCounts[cleanError] ?? 0) + 1;

        await prisma.cleanStatusHistory.create({
          data: {
            id: BigInt(id),
            facility_id: BigInt(robotInfo.facilityId),
            section_id: BigInt(robotInfo.sectionId),
            clean_timetable_id: BigInt(id),
            clean_status: cleanStatus,
            clean_error: cleanError,
            ...areaFields,
            started_at: startAt,
            ended_at: endAt,
          },
        });
        historyCount += 1;

        // ★`clean_task_reports`(指示書AQ 決定2 / 担当F)★ —
        //   ★未実施(`completionRate === null`)には行を作らない(指示書AQ 1-1)★。
        //   ★ここを飛ばすと直近31日の画面が切替後にほぼ空になる★(ページ送りの確認ができない)。
        if (completionRate !== null) {
          await createCleanTaskReport(
            {
              cleanTimetableId: id,
              facilityId: robotInfo.facilityId,
              sectionId: robotInfo.sectionId,
              serialNumber: serial,
              completionRate,
              startAt,
              endAt,
            },
            cleanTaskReportStats
          );
        }

        extraVolumeRowCount += 1;
        extraIndex += 1;

        // ★`clean_statuses` は「今の状態」用なので当日分だけ(上の本ループと同じ規則)★
        if (daysBack !== 0) continue;
        await prisma.cleanStatus.create({
          data: {
            id: BigInt(id),
            facility_id: BigInt(robotInfo.facilityId),
            section_id: BigInt(robotInfo.sectionId),
            clean_timetable_id: BigInt(id),
            clean_status: cleanStatus,
            clean_error: cleanError,
            ...areaFields,
            started_at: startAt,
            ended_at: endAt,
          },
        });
        statusCount += 1;
        extraVolumeStatusCount += 1;
      }
    }

    console.log(
      `[seed-local-db] 画面確認用ボリュームデータ=${extraVolumeRowCount}件(${targetSerials.length}台×${EXTRA_VOLUME_DAYS}日。id=${EXTRA_VOLUME_ROW_ID_BASE}〜${maxExtraRowId})を投入しました`
    );
    console.log(
      `[seed-local-db]   判定の内訳: ◎=${extraVolumeGradeCounts.excellent} ○=${extraVolumeGradeCounts.good} △=${extraVolumeGradeCounts.fair} ×(低完了率)=${extraVolumeGradeCounts.poor} ×(未実施)=${extraVolumeGradeCounts.notExecuted}`
    );
    console.log(
      `[seed-local-db]   併せて clean_schedules=${extraVolumeScheduleCount}件(毎日・有効)を追加(ダッシュボードの予定件数と当日のログ件数を一致させるため)`
    );
  }

  // ------------------------------------------------------------ レポートサマリ3テーブル(指示書T 段階1)
  //
  // ★対象: customer_summaries / facility_summaries / facility_error_summaries★
  // ★continuousProblems相当のテーブルは存在しない・customer_error_summariesは投入しない★
  //   (D-102項4。errorBreakdownはfacility版で確定 — 段階4で読むテーブルと粒度を揃える)
  //
  // ★当期(今週・今月)は入れない。先週・先月の2期間だけ★(CLAUDE.md:193「進行中の期間は
  //   サマリテーブルに存在しない」を再現するため。入れると段階2の当期その場集計を検証できない)
  //
  // 期間キーは実行時刻からの相対で作る(`lib/fixtures/reports.ts:49-70` と同じ手順。
  // 日付を固定すると「先週」が時間経過で当期や2期間前にずれてしまう)。
  const todaySummary = todayJst();
  const thisWeekSummary = weekOfMonth(todaySummary);
  const lastWeekSummary = shiftPeriod(
    "weekly",
    thisWeekSummary.year,
    thisWeekSummary.month,
    thisWeekSummary.week,
    -1
  );
  const thisMonthSummary = {
    year: Number(todaySummary.slice(0, 4)),
    month: Number(todaySummary.slice(5, 7)),
  };
  const lastMonthSummary = shiftPeriod(
    "monthly",
    thisMonthSummary.year,
    thisMonthSummary.month,
    0,
    -1
  );

  /**
   * サマリの分子・分母(task_count / launch_count / error_count / task_area / clean_area)は
   * 定義書に計算式の記載が無い(D-090項12)。以下の対応で「率と辻褄が合う値」を作る:
   *   - task_count = 清掃予定回数の仮値(固定100)
   *   - launch_count = round(task_count * launch_ratio / 100)   … 出発成功率の分子
   *   - executedCount = round(task_count * operation_ratio / 100) … ★稼働率の分子=正常終了の回数★(第11回 P-6=A)。
   *     ★サマリに列が無いので保存しない★(エラー発生率の分母でなくなったので計算もしない)
   *   - error_count = round(task_count * error_ratio / 100) … エラー発生率の分子(分母=予定回数(第11回 P-6=B))
   *   - task_area = 清掃対象面積の仮値(固定1000㎡)。clean_area = task_area * clean_ratio / 100
   * ★TeamWorksの実際のバッチ計算式と一致する保証はない(D-090項12)。段階5で検算し、
   *   食い違いは記録するだけで当方の値で上書きしない(禁止事項1)★
   */
  const SUMMARY_TASK_COUNT = 100;
  const SUMMARY_TASK_AREA_SQM = 1000;

  const requireRatio = (value: number | null, field: string): number => {
    if (value === null) {
      throw new Error(`[seed-local-db] レポートfixturesの ${field} が null です(想定外)`);
    }
    // double(4,1) の桁に収める(fixturesはすでに小数第1位だが安全のため丸める)
    return Math.round(value * 10) / 10;
  };

  interface SummaryKpiLike {
    operation_rate: { value: number | null };
    launch_rate: { value: number | null };
    completion_rate: { value: number | null };
    error_rate: { value: number | null };
  }

  const summaryFields = (kpi: SummaryKpiLike) => {
    const operationRatio = requireRatio(kpi.operation_rate.value, "operation_rate");
    const launchRatio = requireRatio(kpi.launch_rate.value, "launch_rate");
    const cleanRatio = requireRatio(kpi.completion_rate.value, "completion_rate");
    const errorRatio = requireRatio(kpi.error_rate.value, "error_rate");

    const taskCount = SUMMARY_TASK_COUNT;
    const launchCount = Math.round((taskCount * launchRatio) / 100);
    const errorCount = Math.round((taskCount * errorRatio) / 100);
    const taskArea = SUMMARY_TASK_AREA_SQM;
    const cleanArea =
      Math.round(((taskArea * cleanRatio) / 100) * 1_000_000) / 1_000_000;

    return {
      task_count: taskCount,
      launch_count: launchCount,
      error_count: errorCount,
      task_area: taskArea,
      clean_area: cleanArea,
      operation_ratio: operationRatio,
      launch_ratio: launchRatio,
      clean_ratio: cleanRatio,
      error_ratio: errorRatio,
    };
  };

  // customer_summaries(投入できた顧客 × 先週・先月)。★指示書AK 段階8 / §11-3★
  //
  // ★以前は2件とも `customer_id = 1` だった★ため、
  //   §9-3 の ★6-2(`customer_id=1` 指定・DBに2社ぶん → 顧客1だけの数字)★ を
  //   ローカルDBで確かめようがなかった(絞り込みを外しても同じ数字が出てしまう)。
  //
  // ★★値の作り方(この段階の判断ポイント)★★:
  //   - ★顧客1 は fixtures の `kpiWeekly` / `kpiMonthly` をそのまま入れる★ —
  //     `lib/fixtures/reports.ts` の `REPORT_KPI_CUSTOMER_ID = 1`(「fixtures のKPIは屋号1の数字」)
  //     という約束を DB 側でも守り、★`USE_FIXTURES` を切り替えても顧客1の数字が一致する★ようにするため。
  //   - ★顧客2以降は比率を一律に縮めた別の数字にする★ —
  //     同じ値だと「絞り込みが効いていないのに効いて見える」ので 6-2 の検証にならない。
  //     ★機械的な係数(0.9 / 0.8 …)で作り、合成値であることが一目で分かるようにする★。
  //   - ★fixtures 側に顧客2以降のKPI行は無い★(`lib/fixtures/reports.ts` が
  //     「数字を捏造しない」として作っていない)ので、★顧客2以降は `USE_FIXTURES` の
  //     true/false で結果が食い違う★。ローカルDBでの手動確認専用と割り切る。
  //   - ★期間キーは顧客ごとに先週・先月の1件ずつ★ — 同一顧客内で期間キーが重複しないので、
  //     `customerSummariesByPeriodKey()` の重複検知(6-8)は壊さない。
  const scaleSummaryKpi = (kpi: SummaryKpiLike, factor: number): SummaryKpiLike => {
    const scale = (value: number | null): number | null =>
      value === null ? null : Math.round(value * factor * 10) / 10;
    return {
      operation_rate: { value: scale(kpi.operation_rate.value) },
      launch_rate: { value: scale(kpi.launch_rate.value) },
      completion_rate: { value: scale(kpi.completion_rate.value) },
      error_rate: { value: scale(kpi.error_rate.value) },
    };
  };

  const summaryCustomerIds = customers
    .filter((c) => seededCustomerIds.has(c.id))
    .map((c) => c.id);
  let customerSummaryCount = 0;
  for (const [index, customerId] of summaryCustomerIds.entries()) {
    const factor = 1 - index * 0.1;
    for (const [periodKey, kpi] of [
      [lastWeekSummary, kpiWeekly],
      [lastMonthSummary, kpiMonthly],
    ] as const) {
      customerSummaryCount += 1;
      await prisma.customerSummary.create({
        data: {
          id: BigInt(customerSummaryCount),
          customer_id: BigInt(customerId),
          year: periodKey.year,
          month: periodKey.month,
          week: periodKey.week,
          ...summaryFields(scaleSummaryKpi(kpi, factor)),
        },
      });
    }
  }
  console.log(
    `[seed-local-db] customer_summaries=${customerSummaryCount} を投入しました` +
      `(顧客 ${summaryCustomerIds.join(", ")} × 先週・先月。顧客1以外は比率を縮めた別の数字)`
  );

  // facility_summaries(施設数×2件)。
  // ★`facilityPerformance`(fixtures)は4期間ぶん同じ12施設を複製しているので、
  //   先頭1期間分(先頭 facilities.length 件)を切り出せば施設別の実測値が手に入る
  //   (`lib/fixtures/reports.ts` の forEachPeriod / PERIOD_KEYS を参照。fixtures自体は改変しない)★
  //
  // ★投入を保留した顧客の施設ぶんは外部キーが通らないので落とす★(指示書AK 段階8 / §11-4)。
  const facilityPerformanceItems = facilityPerformanceFixtureRows
    .slice(0, facilities.length)
    .map((row) => row.item)
    .filter((item) => seededFacilityIds.has(item.facility_id));
  if (facilityPerformanceItems.length !== seedableFacilities.length) {
    throw new Error(
      `[seed-local-db] facilityPerformance fixturesの件数が投入対象の施設数と一致しません: ${facilityPerformanceItems.length} / ${seedableFacilities.length}`
    );
  }

  let facilitySummaryCount = 0;
  for (const periodKey of [lastWeekSummary, lastMonthSummary]) {
    for (const item of facilityPerformanceItems) {
      facilitySummaryCount += 1;
      await prisma.facilitySummary.create({
        data: {
          id: BigInt(facilitySummaryCount),
          facility_id: BigInt(item.facility_id),
          year: periodKey.year,
          month: periodKey.month,
          week: periodKey.week,
          ...summaryFields(item),
        },
      });
    }
  }
  console.log(`[seed-local-db] facility_summaries=${facilitySummaryCount} を投入しました`);

  // facility_error_summaries(エラー種別9件×2期間=18件。customer_error_summariesは投入しない)。
  //
  // ★以前は1施設(facilities[0])にまとめて計上していた★ — errorBreakdown(段階4)は
  //   「同じ期間・同じerror_typeの行を全施設ぶん足し上げる」設計なので集約しても合計は変わらない、
  //   という理由だった。★指示書AK 段階8 でこれを改める★:
  //   ★屋号で絞ったときに内訳が変わることを確かめられない★(申し送り③の再現ができない)ため。
  //
  // ★★配り方★★: ★顧客ごとに代表の施設を1つ選び、エラー種別を順繰りに配る★。
  //   - ★施設IDを名指ししない★(fixtures の割り当てが変わっても構造的に追従する)
  //   - ★同じ (期間, error_type) の行は全体で1件のまま★なので、
  //     ★絞り込み無しの合計・rate は従来と1件も変わらない★(既存の検算をそのまま使える)
  //   - 9種別 ÷ 顧客数 なので、どの顧客を選んでも内訳が空にならない
  const errorBreakdownItems = errorBreakdownFixtureRows.slice(0, 9).map((row) => row.item);
  if (errorBreakdownItems.length !== 9) {
    throw new Error(
      `[seed-local-db] errorBreakdown fixturesの件数が想定(9件)と異なります: ${errorBreakdownItems.length}件`
    );
  }
  // 顧客ごとの代表施設(fixtures 内で最初に定義されたもの)。顧客の並び順は fixtures のまま。
  const errorSummaryFacilityIds = summaryCustomerIds
    .map((customerId) => seedableFacilities.find((f) => f.customer_id === customerId))
    .filter((f): f is Facility => f !== undefined)
    .map((f) => f.id);
  if (errorSummaryFacilityIds.length === 0) {
    throw new Error(
      "[seed-local-db] facility_error_summaries を紐づけられる施設がありません(投入対象の施設が0件)"
    );
  }

  let facilityErrorSummaryCount = 0;
  for (const periodKey of [lastWeekSummary, lastMonthSummary]) {
    for (const [index, item] of errorBreakdownItems.entries()) {
      facilityErrorSummaryCount += 1;
      await prisma.facilityErrorSummary.create({
        data: {
          id: BigInt(facilityErrorSummaryCount),
          facility_id: BigInt(
            errorSummaryFacilityIds[index % errorSummaryFacilityIds.length]
          ),
          year: periodKey.year,
          month: periodKey.month,
          week: periodKey.week,
          error_level: ERROR_LEVEL_PLACEHOLDER,
          // ★error_type は生値のまま入れる(D-077/D-088。7分類・日本語への変換をしない)★
          error_type: item.type,
          // ★`error_detail` 列は無くなった(2026-10-29 の DDL 変更)★
          error_list_id: BigInt(ERROR_SUMMARY_ERROR_LIST_ID),
          error_count: item.count,
        },
      });
    }
  }
  console.log(
    `[seed-local-db] facility_error_summaries=${facilityErrorSummaryCount} を投入しました` +
      `(顧客 ${summaryCustomerIds.join(", ")} の代表施設 facility_id=${errorSummaryFacilityIds.join(", ")} に順繰りに配分)`
  );

  const totalRobots = seedableFacilities.reduce((sum, f) => sum + f.robots.length, 0);
  console.log("[seed-local-db] 完了:");
  // ★件数をハードコードしない★(指示書AK §11-5)— 拡張したのにログだけ「1件」のままだと嘘になる。
  console.log(
    `  groups=${groups.length} customers=${seededCustomerIds.size} devices=${devices.length} facilities=${seedableFacilities.length}`
  );
  if (heldCustomers.length > 0 || skippedFacilities.length > 0) {
    console.log(
      `  ★保留★ customers=${heldCustomers.length}(${describeCustomers(heldCustomers)}。` +
        `group_id=${GROUP_ID_NONE} が外部キー違反。指示書AK §11-4 / TeamWorks 第9回 Q7-4 照会中)` +
        ` facilities=${skippedFacilities.length}(${skippedFacilities.map((f) => `id=${f.id}`).join(", ") || "なし"}。保留した顧客の傘下)`
    );
  }
  console.log(
    `  sections=${totalRobots} robots=${totalRobots} clean_schedules=${seedableSchedules.length + extraVolumeScheduleCount}(fixtures=${seedableSchedules.length} ボリューム用=${extraVolumeScheduleCount})`
  );
  console.log(
    `  controls=${seedableDevices.length} control_reports=${nextControlReportId - 1}`
  );
  console.log(
    // ★`robot_error_reports` の内訳を出す(事前チェック行を指す分 / 本清掃行を指す分 / 決定3の検証2件)★
    `  clean_timetables=${timetableCount} clean_statuses=${statusCount} clean_status_histories=${historyCount} robot_error_reports=${errorReportCount + mainErrorReportCount + 2 + seedableDsAlertRows.length}(事前チェック=${errorReportCount} 本清掃=${mainErrorReportCount} null/dangling=2 D-Sec=${seedableDsAlertRows.length})`
  );
  console.log(
    `  うち画面確認用ボリュームデータ: 本清掃=${extraVolumeRowCount} clean_statuses=${extraVolumeStatusCount}(エラー行は作らない)`
  );
  console.log(
    `  clean_timetable_histories=${timetableHistoryCounts.historyOnly + timetableHistoryCounts.sameContent + timetableHistoryCounts.differentContent}` +
      `(履歴だけ=${timetableHistoryCounts.historyOnly} 両方・同じ中身=${timetableHistoryCounts.sameContent} 両方・中身が違う=${timetableHistoryCounts.differentContent}。指示書AU 段階3b-5)`
  );
  console.log(
    `  robot_error_reports.status: unresolved=${errorResolutionCounts.unresolved} in_progress=${errorResolutionCounts.in_progress} resolved=${errorResolutionCounts.resolved}`
  );
  // ★`error_list_id`(D-184)★ — ★fail-loud★: 狙った3種(有効な id・その他・無い id)が1件ずつ入っていなければ止める
  for (const id of ERROR_LIST_ID_BY_MAIN_ERROR_SEQ.values()) {
    if (errorListIdCounts.get(String(id)) !== 1) {
      throw new Error(
        `[seed-local-db] error_list_id=${id} の行が1件ではありません(${errorListIdCounts.get(String(id)) ?? 0}件)。本清掃エラーが足りない可能性があります`
      );
    }
  }
  const errorListIdNullCount =
    errorReportCount + 2 + seedableDsAlertRows.length + (errorListIdCounts.get("null") ?? 0);
  console.log(
    `  error_lists=${errorLists.length} robot_error_reports.error_list_id: ` +
      [...ERROR_LIST_ID_BY_MAIN_ERROR_SEQ.values()].map((id) => `${id}=1`).join(" ") +
      ` null=${errorListIdNullCount}`
  );
  // ★`clean_error` の分布(1 が未実施の件数。段階5 で DB の実測値と突き合わせる)★
  const cleanErrorSummary = Object.keys(cleanErrorCounts)
    .map(Number)
    .sort((a, b) => a - b)
    .map((code) => `${code}=${cleanErrorCounts[code]}`)
    .join(", ");
  console.log(`  clean_status_histories.clean_error: ${cleanErrorSummary}`);

  // ------------------------------------------------------------ clean_task_reports の内訳(指示書AQ 担当F)
  //
  // ★★fail-loud★★ — 「入ったけれど狙ったケースが1件も無い」を黙って通さない。
  //   ★他担当はこのシードを土台に動作確認する★ので、ケースが欠けたまま進むと
  //   「実装が正しいのかデータが無いだけなのか」を切り分けられなくなる。
  const missingStatuses = CLEAN_TASK_REPORT_STATUS_DISTRIBUTION.map((s) => s.status).filter(
    (status) => (cleanTaskReportStats.byStatus[status] ?? 0) === 0
  );
  if (missingStatuses.length > 0) {
    throw new Error(
      `[seed-local-db] clean_task_reports に status=${missingStatuses.join("/")} の行が1件もありません(CSV実測の4種すべてが必要)`
    );
  }
  if (cleanTaskReportStats.endedAtNull === 0) {
    throw new Error(
      "[seed-local-db] clean_task_reports に ended_at が NULL の行が1件もありません(落とし穴3 の検証に必要)"
    );
  }
  if (cleanTaskReportStats.timetableIdNull === 0) {
    throw new Error(
      "[seed-local-db] clean_task_reports に clean_timetable_id が NULL の行が1件もありません(落とし穴1 の検証に必要)"
    );
  }
  if (cleanTaskReportStats.timetableIdZero === 0) {
    throw new Error(
      "[seed-local-db] clean_task_reports に clean_timetable_id が 0 の行が1件もありません(予定外のタスク。TeamWorks 回答 2026-10-27 の検証に必要)"
    );
  }
  if (cleanTaskReportStats.cleanTimeZero === 0) {
    throw new Error(
      "[seed-local-db] clean_task_reports に clean_time=0 の行が1件もありません(実測に 0 の行がある)"
    );
  }

  const cleanTaskReportStatusSummary = Object.keys(cleanTaskReportStats.byStatus)
    .map(Number)
    .sort((a, b) => a - b)
    .map((status) => `${status}=${cleanTaskReportStats.byStatus[status]}`)
    .join(", ");
  const overShare = (
    (cleanTaskReportStats.cleanAreaOverTaskArea / cleanTaskReportStats.total) *
    100
  ).toFixed(1);
  const areaRatio = (
    (cleanTaskReportStats.sumCleanArea / cleanTaskReportStats.sumTaskArea) *
    100
  ).toFixed(1);
  console.log(
    `  clean_task_reports=${cleanTaskReportStats.total}` +
      `(★未実施の清掃には行を作らない★ので clean_status_histories=${historyCount} より少ない。指示書AQ 1-1)`
  );
  console.log(`  clean_task_reports.status: ${cleanTaskReportStatusSummary}`);
  console.log(
    `  clean_task_reports: ended_at=NULL ${cleanTaskReportStats.endedAtNull}件 /` +
      ` clean_timetable_id=NULL ${cleanTaskReportStats.timetableIdNull}件(★落とし穴1★)/` +
      ` clean_timetable_id=0 ${cleanTaskReportStats.timetableIdZero}件(予定外のタスク)/` +
      ` clean_time=0 ${cleanTaskReportStats.cleanTimeZero}件`
  );
  // ★D-155 以降、`clean_area` は完了率から作る(完了率100の行だけ `task_area` を超える)ので、
  //   fixtures 由来の清掃(完了率は最大 99.5)では ★超過は0%になる★。CSV実測 68.3% の再現はやめた(指示書AS 6-1)
  console.log(
    `  clean_task_reports: clean_area > task_area は ${cleanTaskReportStats.cleanAreaOverTaskArea}件(${overShare}%。CSV実測 68.3%。D-155 以降は0%が正常)/` +
      ` Σclean_area ÷ Σtask_area = ${areaRatio}%(CSV実測 90.2%)`
  );
}

main()
  .catch((e: unknown) => {
    console.error(e);
    process.exitCode = 1;
  })
  .finally(async () => {
    await prisma.$disconnect();
  });