import { RUN_COMPLETION_RATE } from "@/lib/config";
import { cleanLogs, history } from "./clean-logs";
import { facilities } from "./facilities";
import { cleanTaskReportStatus } from "./task-status-rule";
/**
* `clean_task_reports` のサンプルデータ(指示書AQ 決定2 / 担当F)
*
* ★何のためのファイルか★: 指示書AQ の決定2 で ★清掃実績の取得元が
* `clean_status_histories` → `clean_task_reports` に変わる★。
* ローカルDBにも fixtures にも ★1行も無い★ ため、切替後は全画面が0件になる(落とし穴5)。
* ★このファイルと `scripts/seed-local-db.ts` の投入が、他担当の動作確認の土台★。
*
* ★担当F の時点では `lib/fixtures/` の既存ファイルを変更せずに追加した★(`lib/fixtures/index.ts` への
* `export *` 1行を除く)。その後、担当J2 が `clean-logs.ts` に `task_status` を足し、担当M が
* ★`status` の規則を `./task-status-rule.ts` に切り出して両ファイルから import する形にした★(循環 import の解消)。
*
* ---------------------------------------------------------------------------
* ★★実データの形(出所: ユーザー提供CSV `clean_task_reports.csv` 189行。2026-09-30 受領)★★
*
* | 実測 | 値 |
* |---|---|
* | `report_id` | ★19桁の数値文字列★(例 `1295486100215435264`)。`varchar(20)` |
* | `clean_timetable_id` | ★CSV では189行すべて NULL★。★NULL は予定外のタスク★(予定のある清掃には id が入る。`CLAUDE.md` §4.7。旧: 「今後 TeamWorks が埋める前提」— 誤り) |
* | `status` | ★3:2件 / 4:161件 / 5:4件 / 6:22件★(値の意味は第10回 N-2 で受領。★6 は Pudu の値で、先方も意味が分からない(第11回 P-4。D-158)★。分かれば先方がデータを入れる。★数字のまま表示する★) |
* | `task_area` | ★清掃エリアごとにほぼ一定★(例: あるエリアは19行すべて `878.524992`) |
* | `clean_area` | ★毎回変動し、68.3%(129/189行)で `task_area` を超える★(★fixtures では再現しない。約束3★) |
* | `clean_ratio` | ★面積比とは一致しない★(例: 面積比130.0% のとき `clean_ratio` は 76.0)。★列は完了率に使っていない★(D-155) |
* | `ended_at` | ★nullable★ |
* | `clean_time` | 秒(0 の行もある) |
*
* ---------------------------------------------------------------------------
* ★★設計上の約束(読まずに直さないこと)★★
*
* 1. ★未実施の清掃には行を作らない★ — 指示書AQ 1-1 の
* 「未実施 = 本清掃の `clean_timetables` 行に対応する `clean_task_reports` の行が無い」。
* ★`clean_task_reports` には `clean_error` 相当の列が無い★ので、行の有無だけが未実施の根拠。
* ★既存 `lib/fixtures/clean-logs.ts` は `clean_error` を `departed ? normal : notExecuted`
* で導出していて `null` の行を作れない構造だった(`:66`)★が、ここは ★そもそも
* `clean_error` 列を持たない★ ので同じ轍は踏まない。
*
* 2. ★`clean_ratio` 列は完了率に使っていない★(D-155)。★列に戻すときのために、清掃ログ(`cleanLogs`)の
* `completion_rate` と同じ値を入れてある★(戻すときに fixtures を作り直さずに済む。指示書AS D6)。
*
* 3. ★`clean_area` は清掃ログの完了率から作る★(2026-10-02。D-155 / 指示書AS 段階6-1)—
* 完了率は ★面積から `runCompletionRate()`(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで
* 切り捨て)★ で決まるようになったので、面積を完了率と独立に作ると
* ★DB 経由(`USE_FIXTURES=false`)で `docs/fixtures.md` §2.1 の境界値(90.1 / 90.0 / 89.5 / 70.1 /
* 70.0 / 10.1 / 10.0 / 9.9 / 0)が全部壊れる★。`cleanTaskReportCleanArea()` の docstring を参照。
* ★旧約束3(面積を `clean_ratio` から逆算しない。実測の分布 68.3% が `task_area` 超えに寄せる)は廃止★ —
* D-151 の「完了率 = `clean_ratio` 列」が本当に効いているかを確かめるための約束だったが、
* D-155 で完了率が面積から決まるようになり、前提が逆になった。
*
* 4. ★`status` は生値(数字)で持つ★ — 表示名にするのは画面・CSV(`formatCleanTaskStatus()`。D-155 項2)。
* ここで `CLEAN_ERROR` に対応づけたり日本語に直したりしない。
*
* 5. ★冪等★ — `Math.random()` を使わず、すべて ★清掃ログの id から決まる純粋関数★ にする。
* ★`scripts/seed-local-db.ts` は同じ関数を import する★ので、
* ★`USE_FIXTURES` の true/false で同じ値が出る★(ズレたらどちらかのバグ)。
*
* 用意したケースの一覧は `docs/fixtures.md` §2.6、期待値の固定は
* `lib/fixtures/__tests__/clean-task-reports.test.ts`。
*/
/**
* `clean_task_reports` の1行(`prisma/schema.prisma:517-558` の実DDL準拠)。
*
* ★`lib/types/index.ts` には置かない★ — あれは ★API のスキーマ名に揃える場所★
* (`CLAUDE.md` §9.2)であり、これは ★DBテーブルの形★。加えて
* `lib/types/index.ts` は指示書AQ の担当B の所有ファイルなので触らない。
*/
export interface CleanTaskReportFixture {
/** ★19桁の数値文字列★(`varchar(20)`)。`cleanTaskReportId()` が作る */
report_id: string;
facility_id: number;
section_id: number;
/** ★シードは `sections.id` とロボットの id を同値に採番している★(1清掃エリア=1ロボット) */
robot_id: number;
/** ★nullable★。★NULL は `IN` に一致せず「未実施」として集計される(落とし穴1)★ */
clean_timetable_id: number | null;
serial_number: string;
/** ★生値(3 / 4 / 5 / 6)。数字のまま持つ★(表示名は画面・CSV が付ける。D-155 項2。6 は Pudu の値で、先方も意味が分からない(第11回 P-4。D-158)。分かれば先方がデータを入れる。数字のまま表示する) */
status: number;
/** 清掃対象面積 m²(`double(12,6)`)。★清掃エリアごとに一定★ */
task_area: number;
/**
* 清掃面積 m²(`double(12,6)`)。★清掃ログの完了率から作る★(`runCompletionRate()` で元の完了率に戻る)。
* ★完了率100の行だけ `task_area` を超える★(上限100の確認)。
*/
clean_area: number;
/** 清掃完了率 %(`double(4,1)`)。★完了率には使っていない★(D-155)。清掃ログの完了率と同じ値 */
clean_ratio: number;
/** ISO 8601(JST, +09:00) */
started_at: string;
/** ★nullable★(`prisma/schema.prisma:541`)。順序キーは `ended_at ?? started_at`(1-4) */
ended_at: string | null;
/** 清掃時間(秒)。★0 の行もある★ */
clean_time: number;
}
// ---------------------------------------------------------------------------
// report_id
// ---------------------------------------------------------------------------
/**
* `report_id` の先頭。★CSV実測の `1295486100215435264` と同じ19桁の帯★に収まるよう選んだ。
*
* ★`BigInt` リテラル(`123n`)は使わない★ — `tsconfig.json` の `target` が `ES2017` のため。
*/
const CLEAN_TASK_REPORT_ID_BASE = BigInt("1295486100000000000");
/** `report_id` の桁数(`varchar(20)` に収まることをテストで固定する) */
export const CLEAN_TASK_REPORT_ID_DIGITS = 19;
/**
* 清掃(`clean_timetables.id`)から `report_id` を決定的に作る。
*
* ★`clean_timetable_id` を NULL にする行でも、report_id は元の清掃 id から作る★ —
* 「どの清掃の実績か」は当方が分かっていて、★NULL にしているのは join キーだけ★
* (落とし穴1 の再現)なので、id が衝突しない採番を保てる。
*/
export function cleanTaskReportId(cleanTimetableId: number): string {
return String(CLEAN_TASK_REPORT_ID_BASE + BigInt(cleanTimetableId));
}
// ---------------------------------------------------------------------------
// status(3 / 4 / 5 / 6)
// ---------------------------------------------------------------------------
/*
* ★status の規則(分布と巡回列)は `./task-status-rule.ts` が唯一の定義★。
* ここでは再エクスポートするだけ(`scripts/seed-local-db.ts` と
* `lib/fixtures/__tests__/clean-task-reports.test.ts` がこのファイルから import しているため)。
*
* ★ここに規則を書き戻さないこと★ — `lib/fixtures/clean-logs.ts` の `task_status` も同じ規則を使う。
* `clean-logs.ts` はこのファイルを import できない(このファイルが読み込み時に `cleanLogs` / `history`
* を読むため循環 import になる)ので、依存の無い葉モジュールに切り出してある。
*/
export {
CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH,
CLEAN_TASK_REPORT_STATUS_DISTRIBUTION,
cleanTaskReportStatus,
} from "./task-status-rule";
// ---------------------------------------------------------------------------
// task_area / clean_area
// ---------------------------------------------------------------------------
/** `double(12,6)` に収める(小数第6位まで) */
function round6(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
/**
* ★決定的な疑似乱数★(`lib/fixtures/clean-logs.ts` の `pseudoRandom()` と同じ式)。
* ★`Math.random()` は使わない★ — 実行のたびに面積が変わると冪等性を確認できない。
*/
function pseudoRandom(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
const TASK_AREA_MIN_SQM = 600;
const TASK_AREA_STEP_SQM = 25;
const TASK_AREA_STEPS = 17;
/**
* 清掃エリア(= ロボット)ごとの `task_area`。★同じエリアなら常に同じ値★
* (実測「あるエリアは19行すべて `878.524992`」に合わせた)。
*
* ★既存シードの `TASK_AREA_SQM = 1000`(全エリア一律)とは別物★ —
* 一律だと「エリアごとに面積が違う」ことに起因する不具合(総清掃面積の按分など)が
* ローカルでは絶対に出ない。600〜1000 m² の範囲で散らし、小数第6位まで持たせる。
*/
export function cleanTaskReportTaskArea(sectionId: number): number {
const base = TASK_AREA_MIN_SQM + (sectionId % TASK_AREA_STEPS) * TASK_AREA_STEP_SQM;
const fraction = ((sectionId * 524_992) % 1_000_000) / 1_000_000;
return round6(base + fraction);
}
/** 完了率100(上限)の行で `task_area` を超えさせる係数の幅(1.00〜1.40倍) */
const CLEAN_AREA_OVER_TASK_AREA_MAX_EXTRA = 0.4;
/**
* `clean_area`。★清掃ログの完了率から作る★(設計上の約束3。D-155 / 指示書AS 段階6-1)。
*
* - 完了率が上限(100)未満 … `round6(task_area × 完了率 ÷ 100)`
* - 完了率が上限(100) … `round6(task_area × 係数)`、係数は `pseudoRandom()` で 1.00〜1.40
* (★`task_area` を超える行の再現。`runCompletionRate()` の上限100の確認になる★)
*
* ★この作り方で `runCompletionRate({ clean_area, task_area })` が元の完了率に一致する★ —
* `lib/fixtures/__tests__/clean-task-reports.test.ts` が全行で確かめている
* (2026-10-02 に node で、小数第6位までの `task_area` 200通り × 完了率 0.0〜100.0 の0.1刻みで不一致0件。指示書AS 6-1)。
* ★帰結★: fixtures の清掃ログに完了率100の行は無い(最大 99.5)ので、★`clean_area > task_area` の行は0件★。
* ★CSV の分布(68.3%)の再現はやめた★。
* ★旧: `CLEAN_AREA_BELOW_TASK_AREA_SHARE`(0.278)で約3割を下回り・約7割を上回りに振り分け、
* 完了率と独立に作っていた★(旧約束3。D-155 で廃止)。
*/
export function cleanTaskReportCleanArea(
taskArea: number,
completionRate: number,
cleanTimetableId: number
): number {
if (completionRate < RUN_COMPLETION_RATE.cap) {
return round6((taskArea * completionRate) / 100);
}
const factor =
1.0 + pseudoRandom(cleanTimetableId * 7 + 3) * CLEAN_AREA_OVER_TASK_AREA_MAX_EXTRA;
return round6(taskArea * factor);
}
// ---------------------------------------------------------------------------
// ended_at(nullable)/ clean_time
// ---------------------------------------------------------------------------
/** 何件に1件 `ended_at` を NULL にするか(★落とし穴3 の再現★) */
const ENDED_AT_NULL_EVERY = 29;
/**
* ★`ended_at` を NULL にする行か★(落とし穴3 / 指示書AQ 1-4)。
*
* ★判定(◎○△×)には影響しない★ — 面積は入ったままなので、
* 壊れるとしたら ★「最新1件」の順序キー★ と ★非null を型で要求している箇所★ だけ。
* そのため境界値ケースの行に当たっても安全で、ブロックを選ばず一律の規則にしてある。
*/
export function cleanTaskReportEndedAtIsNull(cleanTimetableId: number): boolean {
return cleanTimetableId % ENDED_AT_NULL_EVERY === 0;
}
/** 本清掃の所要時間(分)。`scripts/seed-local-db.ts` の `MAIN_CLEAN_DURATION_MINUTES` と同値 */
export const CLEAN_TASK_REPORT_DURATION_MINUTES = 90;
/** `clean_time` の上限(秒) */
const CLEAN_TIME_MAX_SECONDS = CLEAN_TASK_REPORT_DURATION_MINUTES * 60;
/**
* `clean_time`(秒)。★`ended_at` が NULL の行は 0★ —
* 実測に「0 の行」があり、終了時刻が無い行に所要時間だけ入っているのは不自然なため。
*/
export function cleanTaskReportCleanTime(
cleanTimetableId: number,
endedAtIsNull: boolean
): number {
if (endedAtIsNull) return 0;
const r = pseudoRandom(cleanTimetableId * 13 + 5);
return Math.round(CLEAN_TIME_MAX_SECONDS * (0.3 + 0.7 * r));
}
// ---------------------------------------------------------------------------
// clean_timetable_id(nullable)★落とし穴1★
// ---------------------------------------------------------------------------
/** 何件に1件 `clean_timetable_id` を NULL にするか */
const NULL_TIMETABLE_ID_EVERY = 37;
/**
* ★`clean_timetable_id` を NULL にする清掃ログの id★(落とし穴1 の検証用)。
*
* ★★対象を `history`(daysBack 6〜30)に限っているのは意図★★ —
* NULL の行は ★行が消えるのではなく「未実施」として集計される(fail-silent)★ ので、
* `boundaryCases` / `escalationCases` / `statusCases` に当てると
* ★`docs/fixtures.md` §2.1 の境界値10ケースと §2.2 のエスカレーションが壊れ、
* 他担当が「90.0 が ◎ になる」ことを画面で確かめられなくなる★。
*
* ★`scripts/seed-local-db.ts` はこの Set をそのまま import して使う★ので、
* ★ローカルDBと fixtures で NULL になる行が完全に一致する★。
*
* ★★既知の構造上の制約(意図した非対称。直さないこと)★★ —
* この Set の清掃は、★DB 経由(`USE_FIXTURES=false`)では「出発失敗・`task_status` null」★に、
* ★fixtures 経由(`cleanLogs`)では「実施済み・`task_status` あり」★に見える。
* - DB 経由: `clean_timetable_id` が NULL の行は `IN` に一致しないので、対応するレポート行が
* 「無い」扱いになり、指示書AQ 1-1 により未実施(=出発失敗と同一扱い)になる(落とし穴1 そのもの)。
* - fixtures 経由: `cleanLogs` の `departed` / `completion_rate` / `task_status` は
* `clean-logs.ts` の入力から決まり、このファイルの結果を見ない。
* ★fixtures 側を DB 経由に合わせられない理由★: この Set は `completion_rate !== null` の行から作っている。
* `cleanLogs` 側でその行を未実施(`completion_rate: null`)にすると、★Set に入る行が無くなり(空になり)、
* 落とし穴1 を再現するケースそのものが消える★。
* ★したがって、この数行について2経路の画面表示が違うのは不具合ではない★(`docs/fixtures.md` §2.6)。
* `lib/fixtures/__tests__/areas.test.ts` の一致テストは `report_id`(元の清掃 id から作る)で突き合わせるため、
* この数行でも `task_status` と `status` は一致する。
*/
export const cleanTaskReportNullTimetableLogIds: ReadonlySet<number> = new Set(
history
.filter((log) => log.completion_rate !== null && log.id % NULL_TIMETABLE_ID_EVERY === 0)
.map((log) => log.id)
);
// ---------------------------------------------------------------------------
// 組み立て
// ---------------------------------------------------------------------------
/**
* シリアル → `{ facility_id, robot_id }`。
* ★シードと同じ規則★(`section_id` はロボットの id と同値。1清掃エリア=1ロボット)。
*/
const robotBySerial = new Map<string, { facilityId: number; robotId: number }>();
for (const facility of facilities) {
for (const robot of facility.robots) {
robotBySerial.set(robot.serial_number, {
facilityId: facility.id,
robotId: robot.id,
});
}
}
/**
* ISO 8601(JST)の時刻に分を足す。★実行環境の TZ に依存させない★
* (`getUTC*` で組み立てる。`scripts/seed-local-db.ts` の `toDbJst()` と同じ考え方)。
*/
function addMinutesJstIso(iso: string, minutes: number): string {
const shiftedJstMs = new Date(iso).getTime() + minutes * 60_000 + 9 * 60 * 60 * 1000;
const d = new Date(shiftedJstMs);
const p = (n: number): string => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}` +
`T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}+09:00`
);
}
function build(): CleanTaskReportFixture[] {
const out: CleanTaskReportFixture[] = [];
for (const log of cleanLogs) {
// ★未実施=行を作らない(指示書AQ 1-1)★。`clean_error` 相当の列が無いので行の有無が唯一の根拠
if (log.completion_rate === null) continue;
const robot = robotBySerial.get(log.serial_number);
if (!robot) {
// ★fail-loud★: 実施済みなのにロボットが引けないのは fixtures 側の不整合。
// (未実施の行だけは施設12=ロボット0台があるので、上の `continue` で先に抜けている)
throw new Error(
`[fixtures/clean-task-reports] serial=${log.serial_number}(clean_log id=${log.id})に対応するロボットが lib/fixtures/facilities.ts にありません`
);
}
const endedAtIsNull = cleanTaskReportEndedAtIsNull(log.id);
const taskArea = cleanTaskReportTaskArea(robot.robotId);
out.push({
report_id: cleanTaskReportId(log.id),
facility_id: log.facility_id,
section_id: robot.robotId,
robot_id: robot.robotId,
clean_timetable_id: cleanTaskReportNullTimetableLogIds.has(log.id) ? null : log.id,
serial_number: log.serial_number,
status: cleanTaskReportStatus(log.id),
task_area: taskArea,
// ★清掃ログの完了率から作る★(設計上の約束3。`runCompletionRate()` で元の完了率に戻る)
clean_area: cleanTaskReportCleanArea(taskArea, log.completion_rate, log.id),
// ★`clean_ratio` 列は完了率に使っていない。列に戻すときのために、清掃ログの完了率と同じ値を入れてある★
// (設計上の約束2。指示書AS D6)
clean_ratio: log.completion_rate,
started_at: log.scheduled_at,
ended_at: endedAtIsNull
? null
: addMinutesJstIso(log.scheduled_at, CLEAN_TASK_REPORT_DURATION_MINUTES),
clean_time: cleanTaskReportCleanTime(log.id, endedAtIsNull),
});
}
return out;
}
/**
* `clean_task_reports` のサンプルデータ。
* ★`cleanLogs` と同じ並び(`started_at` 降順)★。
*/
export const cleanTaskReports: CleanTaskReportFixture[] = build();