import type { CleanLogItem } from "@/lib/types";
import { GRADE_THRESHOLDS, PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { judgeEscalationLevel, judgeGrade } from "@/lib/judge";
import { at } from "./_helpers";
import { cleanTaskReportStatus } from "./task-status-rule";
/**
* 清掃詳細ログのサンプルデータ
*
* docs/fixtures.md §2 のケースを網羅しています。
* §2.1 判定の境界値10ケース ★最重要★
* §2.2 エスカレーション(L1〜L3、スケジュール非存在日を挟む連続)
* §2.3 ステータスの組み合わせ
* §2.4 データ量・表示崩れ(31日分 / 11件以上の△× / 該当0件 / 長い施設名 / 1施設2台)
*/
/**
* grade / escalation_level は `lib/judge/` の本番ロジックで埋めています。
*
* サンプルデータであっても判定は本番と同じ関数を通します。
* fixture専用の判定を書くと、画面で見えている判定とテストが通している判定が
* 食い違う可能性が出てくるためです(D-044)。
*/
// ---------------------------------------------------------------------------
// ★`task_status`(`clean_task_reports.status` の生値)★(指示書AQ §1-2 / §5-2)
//
// ★★規則は `./task-status-rule.ts` の `cleanTaskReportStatus()` が唯一の定義★★ —
// `lib/fixtures/clean-task-reports.ts` も同じ関数で `status` を作るので、
// `USE_FIXTURES` の true/false で同じ清掃に同じ `status` が出る(担当F の設計意図)。
//
// ★★`./clean-task-reports` から import しないこと(循環 import)★★ —
// あちらは ★モジュール評価時に★ このファイルの `cleanLogs` / `history` を読む。
// このファイルから import すると、このファイルが先に読まれた場合
// (`@/lib/fixtures/clean-logs` を直接 import するテストが複数ある)に `history` が undefined のまま
// あちらの本体が走り、`TypeError: Cannot read properties of undefined (reading 'filter')` で落ちる
// (2026-09-30 担当J2 が実測)。★依存の無い `./task-status-rule` から import する★。
// ★2つの一致は `lib/fixtures/__tests__/areas.test.ts` で引き続き固定している★。
//
// ★値の意味は第10回 N-2 で受領済み(表は `lib/config` の `CLEAN_TASK_STATUS_LABELS`)★だが、
// ここには「何番が何か」を書かない(表を2箇所に持たない)。
// 6 は Pudu の値で、先方も意味が分からない(第11回 P-4。D-158)。分かれば先方がデータを入れる。数字のまま表示する。
// ---------------------------------------------------------------------------
let seq = 1000;
function nextId(): number {
seq += 1;
return seq;
}
interface RowInput {
facility_id: number;
facility_name: string;
serial_number: string;
model_name?: string;
daysBack: number;
time: string;
/**
* 省略時は既定値「待機中」(fixtures は事前チェックがある前提の行)。
* ★`null` = 事前チェックの予定が無い★(ユーザー指示 2026-10-07。D-171)。明示した行だけ null にする
*/
status_1h?: string | null;
status_30m?: string | null;
/**
* ★事前チェック1回目・2回目が何分前か★(`CleanLogItem.status_1h_minutes` / `status_30m_minutes`。D-178)。
* ★省略時は、その回の status が null でなければ `PRECHECK_MINUTES_BEFORE[0]` / `[1]`(60 / 30)、null なら null★。
* ★status が null の回に数値を渡すと例外★(不変条件「status が null のときだけ minutes も null」を fixtures でも守る)
*/
status_1h_minutes?: number | null;
status_30m_minutes?: number | null;
departed: boolean;
completion_rate: number | null;
consecutive_problem_days?: number;
/**
* ★結果待ち★(`CleanLogItem.result_pending`。ユーザー判断 2026-10-07。D-173)。★省略は `false`★。
* ★fixtures は時刻で決めず、行に直書きする★(`isResultPending()` は `now` に依存するので、
* fixtures の行が実行時刻で変わらないように)。★`true` にしてよいのは `departed: false` の行だけ★
*/
result_pending?: boolean;
}
/**
* 事前チェック1回分の分数を決める(D-178)。★status が null なら null★、
* 指定が無ければ設定の分数(`PRECHECK_MINUTES_BEFORE` の同じ添字)。
*/
function precheckMinutes(
status: string | null,
given: number | null | undefined,
fallback: number,
label: string
): number | null {
if (status === null) {
if (given !== undefined && given !== null) {
throw new Error(`[fixtures] ${label} の status が null なのに分数 ${given} が指定されています(D-178 の不変条件)`);
}
return null;
}
// ★`??` にしないこと★ — 明示の null(status があるのに分数が無い)を既定値で隠さず、下で例外にする
if (given === undefined) return fallback;
if (given === null) {
throw new Error(`[fixtures] ${label} の status があるのに分数が null です(D-178 の不変条件)`);
}
return given;
}
function row(input: RowInput): CleanLogItem {
const consecutive = input.consecutive_problem_days ?? 0;
const id = nextId();
const status1h = input.status_1h === undefined ? "待機中" : input.status_1h;
const status30m = input.status_30m === undefined ? "待機中" : input.status_30m;
return {
id,
facility_id: input.facility_id,
facility_name: input.facility_name,
model_name: input.model_name ?? "PUDU CC1",
serial_number: input.serial_number,
scheduled_at: at(input.daysBack, input.time),
// ★日本語6値は fixtures 由来の暫定値であり、実データから来る値ではない★(D-077 項3)。
// `robots.status` の46値は動作フェーズのみでハンドルアップ・オフラインを表現していない。
// ★省略(undefined)だけを既定値にする★ — ★`??` にしないこと★(`null`(事前チェックの予定が無い。D-171)まで
// 「待機中」に化ける。tsc も lint も捕まえない)
status_1h: status1h,
status_1h_minutes: precheckMinutes(status1h, input.status_1h_minutes, PRECHECK_MINUTES_BEFORE[0], "status_1h"),
status_30m: status30m,
status_30m_minutes: precheckMinutes(status30m, input.status_30m_minutes, PRECHECK_MINUTES_BEFORE[1], "status_30m"),
departed: input.departed,
// ★`clean_task_reports.status` の生値(指示書AQ §1-2 / §5-2。旧 `clean_error` から改名)★ —
// ★実施済み(`departed`)なら `cleanTaskReports` の同じ清掃 id の行と同じ `status`、
// 未実施ならレポートの行が無いので `null`★(指示書AQ §1-1。`clean-task-reports.ts` も
// 未実施には行を作らない)。★旧コード表(`CLEAN_ERROR`)には対応づけない★(D-077)。
// ★★例外★★: `clean_timetable_id` を NULL にした `history` の数行(`cleanTaskReportNullTimetableLogIds`)は、
// ★DB 経由では「出発失敗・`task_status` null」になるが、ここでは実施済みのまま★
// (`departed` / `completion_rate` がこのファイルの入力で、あちらの Set はそれから作られるため、
// こちらを合わせると Set が空になり落とし穴1 の再現ケースが消える)。★意図した非対称★
// (`clean-task-reports.ts` の `cleanTaskReportNullTimetableLogIds` の docstring / `docs/fixtures.md` §2.6)。
task_status: input.departed ? cleanTaskReportStatus(id) : null,
result_pending: input.result_pending ?? false,
completion_rate: input.completion_rate,
grade: judgeGrade(input.completion_rate),
consecutive_problem_days: consecutive,
escalation_level: judgeEscalationLevel(consecutive),
};
}
// ---------------------------------------------------------------------------
// §2.1 判定の境界値 ★最重要★
//
// この10ケースは判定ロジックのテスト(M1)と同じ値を使うこと。
//
// ★境界はすべて「上のランクに入る」★(D-139。2026-09-16)。
// ★× の境界(10%未満)だけは変わらない★
//
// 完了率 期待判定 表示(整数丸め)
// 90.1 ◎ 90% ← 丸めると90だが◎(丸めてから判定すると消える)
// 90.0 ◎ 90% ← 「90%以上」なので90.0は◎(★D-139 で ○ から変わった★)
// 89.5 ○ 90% ← 表示は90%だが判定は○(★丸めた値で判定しない★)
// 70.1 ○ 70%
// 70.0 ○ 70% ← 「70%以上」なので70.0は○(★D-139 で △ から変わった★)
// 10.1 △ 10%
// 10.0 △ 10% ← 「10%未満」が×なので10.0は△(CLAUDE.md 4.1 が正)
// 9.9 × 10%
// 0 × 0%
// null × - ← 未実施
// ---------------------------------------------------------------------------
export const boundaryCases: CleanLogItem[] = [
row({ facility_id: 1, facility_name: "メガバリューマート中央本店", serial_number: "8111R5628060011", daysBack: 0, time: "02:00", departed: true, completion_rate: 90.1 }),
row({ facility_id: 2, facility_name: "バリューマート北町店", serial_number: "8111R5628060022", daysBack: 0, time: "02:00", departed: true, completion_rate: 90.0 }),
row({ facility_id: 3, facility_name: "メガバリューマート東口店", serial_number: "8111R5628060033", daysBack: 0, time: "01:30", departed: true, completion_rate: 89.5 }),
row({ facility_id: 4, facility_name: "バリューマート西通り店", serial_number: "8111R5628060044", daysBack: 0, time: "03:00", departed: true, completion_rate: 70.1 }),
// ★70.0 は D-139 で ○(問題なし)になった★ので連続日数は 0。
// `escalation_level` は `judgeEscalationLevel(0)` = null に自動で追従する。
row({ facility_id: 5, facility_name: "メガバリューマート港南総本店", serial_number: "8111R5628060055", daysBack: 0, time: "02:00", departed: true, completion_rate: 70.0, consecutive_problem_days: 0 }),
row({ facility_id: 6, facility_name: "バリューマート南丘駅前店", serial_number: "8111R5628060077", daysBack: 0, time: "02:30", departed: true, completion_rate: 10.1, consecutive_problem_days: 1 }),
row({ facility_id: 8, facility_name: "メガバリューマート桜台店", serial_number: "8111R5628060122", daysBack: 0, time: "02:00", departed: true, completion_rate: 10.0, consecutive_problem_days: 1 }),
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 0, time: "02:30", departed: true, completion_rate: 9.9, consecutive_problem_days: 1 }),
row({ facility_id: 10, facility_name: "フレッシュタウン大和店", serial_number: "8111R5628060144", daysBack: 0, time: "03:00", departed: true, completion_rate: 0, consecutive_problem_days: 1 }),
row({ facility_id: 12, facility_name: "フレッシュタウン湖畔店", serial_number: "8111R5628060166", daysBack: 0, time: "03:30", departed: false, completion_rate: null, consecutive_problem_days: 1 }),
];
// ---------------------------------------------------------------------------
// §2.2 エスカレーション
//
// ケース 連続 レベル ルール
// 初回の出発失敗 1 L1 R1
// 完了率×(初回) 1 L1 R2
// 2日連続△以下 2 L2 R3
// 3日連続 3 L3 R4
// 5日連続(長期化) 5 L3 R4
// スケジュール非存在日を挟む連続 3 L3 R4 ← カレンダー上は5日
//
// 最後のケースが重要。「月水金」スケジュールの施設で月・水・金と3回連続で失敗した場合、
// カレンダー上は5日間だが、スケジュール存在日の連続は3日なのでL3になる。
// ---------------------------------------------------------------------------
export const escalationCases: CleanLogItem[] = [
// R1: 初回の出発失敗(L1)
// ★B2 が発火することを保つため "Handrail Lifted"(B2の暫定マッチ値。D-078項2)にしてある★
row({ facility_id: 2, facility_name: "バリューマート北町店", serial_number: "8111R5628060022", daysBack: 1, time: "02:00", status_1h: "Handrail Lifted", status_30m: "Handrail Lifted", departed: false, completion_rate: null, consecutive_problem_days: 1 }),
// R3: 2日連続△以下(L2)
row({ facility_id: 3, facility_name: "メガバリューマート東口店", serial_number: "8111R5628060033", daysBack: 2, time: "01:30", departed: true, completion_rate: 45, consecutive_problem_days: 1 }),
row({ facility_id: 3, facility_name: "メガバリューマート東口店", serial_number: "8111R5628060033", daysBack: 1, time: "01:30", departed: true, completion_rate: 38, consecutive_problem_days: 2 }),
// R4: 3日連続(L3)
// ★この「オフライン」は fixtures 由来の暫定値で、実データには存在しない★(D-077項3 / D-078項2)。
// B3のマッチ配列(lib/config/robot-status-match.ts の B3_OFFLINE_STATUSES)は空なので、
// この行では B3 は発火しない。★それが現在の期待挙動★(R3/R4は連続日数ベースなので影響を受けない)。
// 実データでオフラインを表す値が判明したら、この値と設定値の両方を差し替えること。
row({ facility_id: 6, facility_name: "バリューマート南丘駅前店", serial_number: "8111R5628060077", daysBack: 3, time: "02:30", status_1h: "オフライン", status_30m: "オフライン", departed: false, completion_rate: null, consecutive_problem_days: 1 }),
row({ facility_id: 6, facility_name: "バリューマート南丘駅前店", serial_number: "8111R5628060077", daysBack: 2, time: "02:30", status_1h: "オフライン", status_30m: "オフライン", departed: false, completion_rate: null, consecutive_problem_days: 2 }),
row({ facility_id: 6, facility_name: "バリューマート南丘駅前店", serial_number: "8111R5628060077", daysBack: 1, time: "02:30", status_1h: "オフライン", status_30m: "オフライン", departed: false, completion_rate: null, consecutive_problem_days: 3 }),
// 5日連続(長期化。L3のまま)
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 5, time: "02:30", departed: true, completion_rate: 22, consecutive_problem_days: 1 }),
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 4, time: "02:30", departed: true, completion_rate: 18, consecutive_problem_days: 2 }),
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 3, time: "02:30", departed: true, completion_rate: 25, consecutive_problem_days: 3 }),
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 2, time: "02:30", departed: true, completion_rate: 15, consecutive_problem_days: 4 }),
row({ facility_id: 9, facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial_number: "8111R5628060133", daysBack: 1, time: "02:30", departed: true, completion_rate: 12, consecutive_problem_days: 5 }),
// ★スケジュール非存在日を挟む連続★
// 施設4は「月水金」スケジュール。5日前・3日前・1日前の3回だけ実行され、いずれも失敗。
// カレンダー上は5日間だが、スケジュール存在日の連続は3日なのでL3。
row({ facility_id: 4, facility_name: "バリューマート西通り店", serial_number: "8111R5628060044", daysBack: 5, time: "03:00", departed: true, completion_rate: 41, consecutive_problem_days: 1 }),
row({ facility_id: 4, facility_name: "バリューマート西通り店", serial_number: "8111R5628060044", daysBack: 3, time: "03:00", departed: true, completion_rate: 33, consecutive_problem_days: 2 }),
row({ facility_id: 4, facility_name: "バリューマート西通り店", serial_number: "8111R5628060044", daysBack: 1, time: "03:00", status_1h: "セキュリティ有効", status_30m: "セキュリティ有効", departed: false, completion_rate: null, consecutive_problem_days: 3 }),
];
// ---------------------------------------------------------------------------
// §2.3 ステータスの組み合わせ
// 待機中 → 待機中(正常)
// 充電中 → 待機中(1h前異常だが解消)
// ハンドルアップ → ハンドルアップ(未解消)
// 緊急停止 → 待機中(復旧)
// オフライン → オフライン
// セキュリティ有効(D-Sec契約施設のみ)
//
// ★事前チェックの分数(D-178。ユーザー判断 2026-10-16)★ — 列は分数ではなく順番で割り当てる:
// 施設7 ロボット0100 01:30 … 60分前・★10分前★(設定 [60,30] と違う分数でも2列目に出る)
// 施設7 ロボット0177 02:00 … ★30分前だけ★(1回しか無いので1列目に 30、2列目は null =「-」)
// ★行は足さず既存の2行の分数だけを変えた★(件数・id・完了率・判定は変えない。事前チェックは判定に使わない。D-170)
// 60 / 30 は `PRECHECK_MINUTES_BEFORE` から読む。★10 は「設定に無い分数」であることが本題なので直書きする★
// (`lib/db/__tests__/error-report-mapping.test.ts` の E9 と同じ考え方)
// ---------------------------------------------------------------------------
/** ★設定(`PRECHECK_MINUTES_BEFORE` = 60/30)に無い分数★(D-178 のケース) */
const PRECHECK_OFF_CONFIG_MINUTES = 10;
export const statusCases: CleanLogItem[] = [
row({ facility_id: 1, facility_name: "メガバリューマート中央本店", serial_number: "8111R5628060011", daysBack: 1, time: "02:00", status_1h: "充電中", status_30m: "待機中", departed: true, completion_rate: 92 }),
row({ facility_id: 7, facility_name: "メガバリューマート緑野店", serial_number: "8111R5628060099", daysBack: 1, time: "01:30", status_1h: "緊急停止", status_30m: "待機中", departed: true, completion_rate: 62, consecutive_problem_days: 1 }),
// 1施設に複数ロボット = 別タスクで2行になることの確認(施設7は3台)
// ★事前チェックが60分前・10分前(D-178)★
row({ facility_id: 7, facility_name: "メガバリューマート緑野店", serial_number: "8111R5628060100", daysBack: 1, time: "01:30", status_30m_minutes: PRECHECK_OFF_CONFIG_MINUTES, departed: true, completion_rate: 94 }),
// ★事前チェックが30分前の1回だけ(D-178)★ — 1列目に 30、2列目は null
row({ facility_id: 7, facility_name: "メガバリューマート緑野店", serial_number: "8111R5628060177", daysBack: 1, time: "02:00", status_1h_minutes: PRECHECK_MINUTES_BEFORE[1], status_30m: null, departed: true, completion_rate: 88 }),
row({ facility_id: 5, facility_name: "メガバリューマート港南総本店", serial_number: "8111R5628060055", daysBack: 1, time: "02:00", status_1h: "セキュリティ有効", status_30m: "セキュリティ有効", departed: false, completion_rate: null, consecutive_problem_days: 1 }),
row({ facility_id: 5, facility_name: "メガバリューマート港南総本店", serial_number: "8111R5628060088", daysBack: 1, time: "02:30", departed: true, completion_rate: 97 }),
];
// ---------------------------------------------------------------------------
// §2.4 データ量
//
// 6〜30日前の履歴を機械生成する。日付フィルタの上限(31日)テスト用。
// 7日前は「△×が1件もない日」= 全部◎(該当0件の表示確認)にしてある。
// ---------------------------------------------------------------------------
const historyFacilities: Array<{
id: number;
name: string;
serial: string;
time: string;
/** ★true = 事前チェックの無い施設(status_1h / status_30m が null。D-171)★ */
noPrecheck?: boolean;
}> = [
{ id: 1, name: "メガバリューマート中央本店", serial: "8111R5628060011", time: "02:00" },
{ id: 2, name: "バリューマート北町店", serial: "8111R5628060022", time: "02:00" },
{ id: 3, name: "メガバリューマート東口店", serial: "8111R5628060033", time: "01:30" },
{ id: 5, name: "メガバリューマート港南総本店", serial: "8111R5628060055", time: "02:00" },
{ id: 6, name: "バリューマート南丘駅前店", serial: "8111R5628060077", time: "02:30" },
{ id: 7, name: "メガバリューマート緑野店", serial: "8111R5628060099", time: "01:30" },
{ id: 8, name: "メガバリューマート桜台店", serial: "8111R5628060122", time: "02:00" },
{ id: 9, name: "メガバリューマート三ツ橋中央ショッピングセンター店", serial: "8111R5628060133", time: "02:30" },
{ id: 10, name: "フレッシュタウン大和店", serial: "8111R5628060144", time: "03:00" },
// ★川下店は「事前チェックの無い施設」★(status_1h / status_30m が null。ユーザー指示 2026-10-07。D-171)。
// ★件数・完了率・判定は変えない★(null にするのは事前チェックの2列だけ。事前チェックは判定に使わない。D-170)。
// ★川下店はアラートの fixtures(`./prechecks.ts`)に出てこない施設を選んだ★(予定が無いのにアラートがある、にならないように)
{ id: 11, name: "バリューマート川下店", serial: "8111R5628060155", time: "03:30", noPrecheck: true },
];
/** 決定的な擬似乱数(実行ごとに値が変わると表示確認しづらいため) */
function pseudoRandom(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function buildHistory(): CleanLogItem[] {
const out: CleanLogItem[] = [];
for (let d = 6; d <= 30; d += 1) {
const allSuccessDay = d === 7; // 該当0件の日
historyFacilities.forEach((f, i) => {
const r = pseudoRandom(d * 100 + i);
let rate: number | null;
let departed = true;
if (allSuccessDay) {
rate = 91 + r * 8; // 全部◎
} else if (r < 0.06) {
rate = null; // 未実施
departed = false;
} else if (r < 0.14) {
rate = 5 + r * 40; // △〜×
} else if (r < 0.3) {
rate = 72 + r * 15; // ○
} else {
rate = 90.5 + r * 9; // ◎
}
if (rate !== null) rate = Math.round(rate * 10) / 10;
out.push(
row({
facility_id: f.id,
facility_name: f.name,
serial_number: f.serial,
daysBack: d,
time: f.time,
...(f.noPrecheck ? { status_1h: null, status_30m: null } : {}),
departed,
completion_rate: rate,
consecutive_problem_days:
rate === null || rate < GRADE_THRESHOLDS.good ? 1 : 0,
})
);
});
}
return out;
}
export const history: CleanLogItem[] = buildHistory();
// ---------------------------------------------------------------------------
// ★結果待ち(D-173。ユーザー判断 2026-10-07)★
//
// ★今日の行のうち、結果待ち(`result_pending: true`)はこの1行だけ★。
// ★施設11(川下店)の今日 03:30★ — 今日の行が無かった施設を選んだ(既存の行・境界値ケースを変えないため)。
// ★施設12 03:30(§2.1 の「未実施(null)」)は確定した出発失敗のまま★(監督判断 2026-10-07)。
// ★`departed: false` / `task_status: null` / `completion_rate: null`(→ grade ×)は出発失敗と同じ値★
// (結果待ちは別の旗で表す)。★連続日数は 0★(結果待ちは連続日数の入力から外す。表2 C10。
// Prisma 版は `consecutiveById` に載らず 0 になる)。川下店は事前チェックの無い施設なので2列は null(D-171)。
// ★`history` の後ろで作る★ — 前に置くと `nextId()` の採番がずれ、既存の行の id が全部変わる。
// ---------------------------------------------------------------------------
export const pendingCases: CleanLogItem[] = [
row({ facility_id: 11, facility_name: "バリューマート川下店", serial_number: "8111R5628060155", daysBack: 0, time: "03:30", status_1h: null, status_30m: null, departed: false, completion_rate: null, consecutive_problem_days: 0, result_pending: true }),
];
/** 新しい順(scheduled_at 降順) */
export const cleanLogs: CleanLogItem[] = [
...boundaryCases,
...escalationCases,
...statusCases,
...history,
...pendingCases,
].sort((a, b) => (a.scheduled_at < b.scheduled_at ? 1 : -1));