/**
* ローカル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 違反なので取らない。
*
* ★冪等: 実行するたびに対象7テーブルを削除してから入れ直す★
* ★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_PRECHECK,
GRADE_THRESHOLDS,
GROUP_ID_NONE,
MAKER_NAMES,
PRECHECK_MINUTES_BEFORE,
} from "../lib/config";
import { assertLocalDwatchDatabaseUrl } from "../lib/db/local-guard";
import {
controlErrorStatusFromResolutionStatus,
controlStatusFromConnectionStatus,
} from "../lib/db/ds-status";
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,
} from "../lib/fixtures/clean-task-reports";
import { customers } from "../lib/fixtures/customers";
import { facilities } from "../lib/fixtures/facilities";
import { dsAlerts } from "../lib/fixtures/ds-alerts";
import { dsDevices } from "../lib/fixtures/ds-devices";
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,
DsAlert,
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;
/**
* ★★決定3(`clean_timetable_id` が `null` / dangling のエラー行は分子に入れない)の検証用★★
*
* ★★ゴミデータではない。消さないこと★★ — この2行は
* ★率(`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)ので実データでも起こりうる。
*/
const NULL_CLEAN_TIMETABLE_ERROR_ID = "RER-NULL-1";
const DANGLING_CLEAN_TIMETABLE_ERROR_ID = "RER-DANGLING-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;
cleanTimeZero: number;
cleanAreaOverTaskArea: number;
sumTaskArea: number;
sumCleanArea: number;
}
function newCleanTaskReportStats(): CleanTaskReportStats {
return {
total: 0,
byStatus: {},
endedAtNull: 0,
timetableIdNull: 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側で新たな不整合が生まれている可能性があるため中断しました。`
);
}
// control_error_histories は disconnected のみ投入する(security_off はDBに置き場所が無い。
// 禁止事項1 / D-090 項3-9・3-10)。★facility_idではなくalert_typeで弾く★
const skippedAlerts: DsAlert[] = [];
const seedableAlerts = dsAlerts.filter((alert) => {
if (alert.alert_type === "disconnected") return true;
skippedAlerts.push(alert);
return false;
});
console.log(
`[seed-local-db] D-Secアラート ${dsAlerts.length}件中 ${seedableAlerts.length}件(disconnectedのみ)を投入対象にします`
);
console.log(
`[seed-local-db] skip: security_off ${skippedAlerts.length}件(対応カラムが無いため投入しない。D-090項3-9/3-10)`
);
if (skippedAlerts.length !== 7) {
throw new Error(
`[seed-local-db] スキップされたsecurity_offアラートの件数が想定(7件)と異なります: ${skippedAlerts.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: CleanLogItem[] = [];
const skippedLogsNoSchedule: CleanLogItem[] = [];
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: CleanLogItem[]): 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件 + スケジュール無し26件(1 + 25)= 27件(指示書R「調査済みの事実」5。実測済み)★
if (skippedLogsTotal !== 27) {
throw new Error(
`[seed-local-db] スキップされた清掃ログの件数が想定(27件)と異なります: ${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({});
// -------------------------------------------------- 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,
},
});
}
for (const alert of seedableAlerts) {
const control = controlBySerial.get(alert.device_serial);
if (!control) {
throw new Error(
`[seed-local-db] 想定外: device_serial=${alert.device_serial} のcontrolが見つかりません(disconnectedアラートなのに対応するD-Secデバイスが未シード)`
);
}
await prisma.controlErrorHistory.create({
data: {
id: BigInt(alert.id),
facility_id: BigInt(alert.facility_id),
section_id: BigInt(control.sectionId),
control_id: BigInt(control.id),
// ★逆変換: DsAlert.resolution_status → control_error_histories.status(D-065 Q1-3)★
status: controlErrorStatusFromResolutionStatus(alert.resolution_status),
comment: alert.memo,
occurred_at: toDbJst(new Date(alert.detected_at)),
responded_at: alert.responded_at ? toDbJst(new Date(alert.responded_at)) : null,
resolved_at: alert.resolved_at ? toDbJst(new Date(alert.resolved_at)) : null,
},
});
}
// ------------------------------------------------------------ 清掃ログ系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;
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({
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値)が効いているか確認できるだけ散らす★
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);
errorResolutionCounts[mainResolution] += 1;
await prisma.robotErrorReport.create({
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:
MAIN_CLEAN_ERROR_TYPE_ROTATION[
mainErrorReportCount % MAIN_CLEAN_ERROR_TYPE_ROTATION.length
],
error_detail: ERROR_DETAIL_PLACEHOLDER,
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}日ずらした)`
);
}
// ------------------------------------------------------------ 決定3 の検証用エラー2行(指示書AB 群3)
//
// ★★本ループの外・独立ブロックとして書く★★(落とし穴12 と同じ理由)—
// ループの中に混ぜると `errorReportCount` / `mainErrorReportCount` の通番がずれる。
// ★★この2行はゴミデータではない★★ — `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)],
] as const) {
await prisma.robotErrorReport.create({
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カラムは既定値のまま(ローテーションの通番を進めない)★
status: robotErrorStatusFromResolutionStatus("unresolved"),
comment: null,
responded_at: null,
resolved_at: null,
errored_at: decision3ErroredAt,
},
});
}
console.log(
`[seed-local-db] 決定3検証データ(指示書AB 群3)=2件(null / dangling)を投入しました`
);
}
// ------------------------------------------------------------ 画面確認用ボリュームデータ(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: ERROR_DETAIL_PLACEHOLDER,
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} control_error_histories=${seedableAlerts.length}`
);
console.log(
// ★`robot_error_reports` の内訳を出す(事前チェック行を指す分 / 本清掃行を指す分 / 決定3の検証2件)★
` clean_timetables=${timetableCount} clean_statuses=${statusCount} clean_status_histories=${historyCount} robot_error_reports=${errorReportCount + mainErrorReportCount + 2}(事前チェック=${errorReportCount} 本清掃=${mainErrorReportCount} null/dangling=2)`
);
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}`
);
// ★`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.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_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();
});