import { todayJst } from "@/lib/format/date";
import { shiftPeriod, weekOfMonth } from "@/lib/format/period";
import { toKpiValue } from "@/lib/judge";
import type {
ContinuousProblem,
ContinuousProblemRow,
ErrorBreakdownItem,
ErrorBreakdownRow,
FacilityPerformance,
FacilityPerformanceRow,
ReportKpi,
ReportKpiRow,
ReportPeriodKey,
} from "@/lib/types";
import { dateOnly } from "./_helpers";
/**
* レポートのサンプルデータ
*
* docs/fixtures.md §4 のケースを網羅。
* - 3ステータス(良好 / 注意 / 要改善)が最低1件ずつ
* - 前期比 +(改善 ▲)/ −(悪化 ▼)
* - 前期データなし(prev が null。新規施設)
* - 11件以上(「全X施設を表示」「他N件を表示」の発火確認。docs/fixtures.md §10)
* - エラー種別(生値。未知の値・空文字を含む)
* - 連続問題施設 L1 / L2 / L3 が最低1件ずつ
* - 当期データ(進行中の週・月・年。サマリテーブルに存在しない前提のケース)
* - 週次 / 月次 / 年次 の3モード(年次は D-151 で解禁)
* - 空配列(該当0件のときの表示。docs/fixtures.md §10)
*
* ★ 率はすべて丸める前の生値。表示側で整数に丸めること(CLAUDE.md 4.3)★
*
* ★ KpiValue の組み立ては `lib/judge` の `toKpiValue()` に一本化している ★
* 以前はこのファイルにローカルの `kpi()` があり、`diff` を小数第1位で丸めていたため、
* 同じ `KpiValue` を作る実装が2つあって `diff` の値が食い違っていた。
* 判定・計算ロジックは USE_FIXTURES の切替の外側に置く(D-044)。
*/
// ---------------------------------------------------------------- 期間キー
//
// `DataSource` は「引数なしで全件返す」契約なので、行データ自身が
// 「どの期間のものか」を持っていないと週次/月次/年次/当期を出し分けられない
// (`ReportPeriodKey` のコメント参照)。
//
// fixtures は実行日からの相対で期間キーを作る(`_helpers.ts` の日付と同じ方針)。
// 日付を固定すると、時間が経つにつれ「当期」が存在しないデータになり、
// 当期/確定期の出し分けが画面から確認できなくなるため。
const today = todayJst();
const thisWeek = weekOfMonth(today);
const lastWeek = shiftPeriod(
"weekly",
thisWeek.year,
thisWeek.month,
thisWeek.week,
-1
);
const thisMonth = {
year: Number(today.slice(0, 4)),
month: Number(today.slice(5, 7)),
};
// 月次は week=0(D-072。サマリ4テーブルの「月の集計値」と同じ表し方)
const lastMonth = shiftPeriod(
"monthly",
thisMonth.year,
thisMonth.month,
0,
-1
);
// ★年次は month=0 / week=0(CLAUDE.md §4.7。`month=0 AND week=0` が年次。D-151)★
// `shiftPeriod("yearly", …)` は month / week を無視し、0 を返す(`lib/format/period.ts`)
const thisYear = Number(today.slice(0, 4));
const lastYear = shiftPeriod("yearly", thisYear, 0, 0, -1);
/** 今週(進行中の週)。`isCurrentPeriod()` が true になる */
const currentWeekKey: ReportPeriodKey = {
period: "weekly",
year: thisWeek.year,
month: thisWeek.month,
week: thisWeek.week,
};
/** 先週(確定済みの週。サマリテーブル由来を想定) */
const lastWeekKey: ReportPeriodKey = {
period: "weekly",
year: lastWeek.year,
month: lastWeek.month,
week: lastWeek.week,
};
/** 今月(進行中の月)。★月次は week=0(D-072)★ */
const currentMonthKey: ReportPeriodKey = {
period: "monthly",
year: thisMonth.year,
month: thisMonth.month,
week: 0,
};
/** 先月(確定済みの月)。★月次は week=0(D-072)★ */
const lastMonthKey: ReportPeriodKey = {
period: "monthly",
year: lastMonth.year,
month: lastMonth.month,
week: 0,
};
/**
* 今年(進行中の年)。★年次は month=0 / week=0(CLAUDE.md §4.7)★
* ★先月・今月の `week: 0` 行とは `period` と `month` が違うので別キーになる★
*/
const currentYearKey: ReportPeriodKey = {
period: "yearly",
year: thisYear,
month: 0,
week: 0,
};
/** 前年(確定済みの年。サマリテーブル由来を想定)。★年次は month=0 / week=0★ */
const lastYearKey: ReportPeriodKey = {
period: "yearly",
year: lastYear.year,
month: lastYear.month,
week: lastYear.week,
};
/**
* fixtures が用意する6期間(今週 / 先週 / 今月 / 先月 / 今年 / 前年)。
*
* ★今年・前年は D-151(年次の解禁)で足した★ — 無いと `USE_FIXTURES=true` でだけ
* 年次のKPI・一覧が空になり、Prisma 版と挙動が変わる(`CLAUDE.md` §9.5 / 禁止事項6)。
*
* 施設別パフォーマンス・連続問題施設・エラー種別内訳は、この6期間に
* **同じ配列データを使い回して**紐付ける。期間ごとに実データが変わる作りは
* `prismaSource` 実装時の課題であり、fixtures は「期間ナビで送っても
* 画面が壊れず描画される」ことを確認できれば足りるため。
*/
const PERIOD_KEYS: readonly ReportPeriodKey[] = [
currentWeekKey,
lastWeekKey,
currentMonthKey,
lastMonthKey,
currentYearKey,
lastYearKey,
];
/** 同じ item 配列を6期間ぶん複製して行データにする */
function forEachPeriod<T>(
items: readonly T[]
): Array<ReportPeriodKey & { item: T }> {
return PERIOD_KEYS.flatMap((key) =>
items.map((item) => ({ ...key, item }))
);
}
// ---------------------------------------------------------------- KPI 4指標
/** 確定期間(サマリテーブル由来を想定)。先週の値 */
export const kpiWeekly: ReportKpi = {
operation_rate: toKpiValue(91.4, 89.6),
launch_rate: toKpiValue(93.8, 94.2),
completion_rate: toKpiValue(87.1, 84.9),
error_rate: toKpiValue(4.6, 5.8),
};
/** 確定期間。先月の値 */
export const kpiMonthly: ReportKpi = {
operation_rate: toKpiValue(89.7, 90.3),
launch_rate: toKpiValue(92.5, 91.1),
completion_rate: toKpiValue(85.4, 85.0),
error_rate: toKpiValue(5.2, 4.8),
};
/**
* 確定期間。前年の値(D-151 で年次を解禁したときに足した)。
*
* ★作り値★ — `kpiWeekly` / `kpiMonthly` と同じく実データ由来ではない(docs/fixtures.md §4)。
* 「年次を選ぶと確定期間のKPIが出る」ことを画面で確かめられれば足りる。
*/
export const kpiYearly: ReportKpi = {
operation_rate: toKpiValue(90.6, 88.9),
launch_rate: toKpiValue(92.8, 91.5),
completion_rate: toKpiValue(84.7, 83.2),
error_rate: toKpiValue(5.3, 6.0),
};
/**
* 当期(進行中の週・月・年)のケース。
* サマリテーブルに存在しない前提で、DFA側APIが詳細テーブルから集計する(D-023)。
* 前期比は比較対象があるので prev は入る。
*
* ★今週・今月・今年の3つに同じ値を使い回している★
* 当期は「器だけ」作る段階(指示書F 先に決まっていること2)で、実際の集計は
* `prismaSource` 実装時に入れるため、fixtures では週/月/年で値を作り分けない。
*/
export const kpiCurrentPeriod: ReportKpi = {
operation_rate: toKpiValue(88.2, 91.4),
launch_rate: toKpiValue(90.0, 93.8),
completion_rate: toKpiValue(82.6, 87.1),
error_rate: toKpiValue(6.1, 4.6),
};
/**
* ★fixtures のKPIは「屋号1(`lib/fixtures/customers.ts` の id=1)の数字」として扱う★
* (指示書AK ★段階6★)。
*
* ★★理由★★: 確定期間のKPIは `customer_summaries`(★顧客単位★)由来で、
* ★★複数顧客ぶんを合算する材料がDBに無い★★(§2-1)。
* ★「どの顧客の数字か」を持たない行を置くと、`customer_id` で絞れているかを
* fixtures 側で確かめられない★(=`USE_FIXTURES` の切替で挙動が変わる。禁止事項6)。
*
* ★★屋号2〜4 のKPI行は**作らない**★★ — ★数字を捏造しないため★(`CLAUDE.md` §7)。
* ★屋号2〜4 を選ぶと 200・空のKPI★になる(=「その顧客のサマリがまだ無い」。6-9 と同じ形)。
*/
export const REPORT_KPI_CUSTOMER_ID = 1;
/**
* `DataSource.reportKpis()` が返す行データ。期間キーで1行が特定される。
*
* ★`customer_id` は `ReportKpiRow`(=データ層が返す行型)には無い★ —
* ★fixtures 側の絞り込み(`filterFixtureReportKpis()`)が見るためだけの列★で、
* ★API レスポンスには出ない★(ルートは `row.kpi` しか読まない)。
*/
export const reportKpis: (ReportKpiRow & { customer_id: number })[] = [
{ ...currentWeekKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiCurrentPeriod },
{ ...lastWeekKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiWeekly },
{ ...currentMonthKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiCurrentPeriod },
{ ...lastMonthKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiMonthly },
{ ...currentYearKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiCurrentPeriod },
{ ...lastYearKey, customer_id: REPORT_KPI_CUSTOMER_ID, kpi: kpiYearly },
];
// ---------------------------------------------------------------- 施設別パフォーマンス
/**
* 稼働率ワースト順(昇順)。
*
* `serial_number` は `lib/fixtures/facilities.ts` / `schedules.ts` の
* facility_id ↔ シリアルの対応をそのまま使っている(施設名で照合したとき食い違わないようにするため)。
*/
const facilityPerformanceItems: FacilityPerformance[] = [
{
facility_id: 9,
facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店",
serial_number: "8111R5628060133",
operation_rate: toKpiValue(68.0, 75.0),
launch_rate: toKpiValue(72.0, 80.0),
completion_rate: toKpiValue(55.0, 62.0),
error_rate: toKpiValue(12.5, 9.0),
status: "bad",
consecutive_problem_days: 5,
},
{
facility_id: 6,
facility_name: "バリューマート南丘駅前店",
serial_number: "8111R5628060077",
operation_rate: toKpiValue(72.4, 70.1),
// ★稼働率 ≤ 出発成功率(第11回 P-6 の不変条件)を守るため出発成功率の側を上げた★(旧 65.0 / 68.0。指示書AU §11 Q5)
launch_rate: toKpiValue(75.0, 71.0),
completion_rate: toKpiValue(63.2, 60.0),
error_rate: toKpiValue(9.8, 10.2),
status: "bad",
consecutive_problem_days: 3,
},
{
facility_id: 2,
facility_name: "バリューマート北町店",
serial_number: "8111R5628060022",
operation_rate: toKpiValue(78.5, 82.0),
launch_rate: toKpiValue(82.0, 85.0),
completion_rate: toKpiValue(71.3, 78.0),
error_rate: toKpiValue(7.2, 5.5),
status: "warning",
consecutive_problem_days: 1,
},
{
facility_id: 4,
facility_name: "バリューマート西通り店",
serial_number: "8111R5628060044",
operation_rate: toKpiValue(80.2, 79.4),
launch_rate: toKpiValue(84.5, 83.0),
completion_rate: toKpiValue(74.8, 72.1),
error_rate: toKpiValue(6.9, 7.4),
status: "warning",
// continuousProblems の同施設(L3・3日)と一致させる
consecutive_problem_days: 3,
},
{
facility_id: 7,
facility_name: "メガバリューマート緑野店",
serial_number: "8111R5628060099",
operation_rate: toKpiValue(85.3, 83.0),
launch_rate: toKpiValue(90.0, 88.0),
completion_rate: toKpiValue(80.1, 78.0),
error_rate: toKpiValue(5.1, 5.8),
status: "warning",
consecutive_problem_days: 0,
},
{
// 前期データなし(新規施設。差分表示なし)
facility_id: 12,
facility_name: "フレッシュタウン湖畔店",
serial_number: "8111R5628060166",
operation_rate: toKpiValue(86.0, null),
launch_rate: toKpiValue(88.4, null),
completion_rate: toKpiValue(81.0, null),
error_rate: toKpiValue(4.9, null),
status: "warning",
consecutive_problem_days: 0,
},
{
facility_id: 10,
facility_name: "フレッシュタウン大和店",
serial_number: "8111R5628060144",
operation_rate: toKpiValue(90.1, 88.2),
launch_rate: toKpiValue(92.0, 90.5),
completion_rate: toKpiValue(85.5, 83.0),
error_rate: toKpiValue(4.2, 4.9),
status: "good",
consecutive_problem_days: 0,
},
{
facility_id: 8,
facility_name: "メガバリューマート桜台店",
serial_number: "8111R5628060122",
operation_rate: toKpiValue(91.8, 90.0),
launch_rate: toKpiValue(94.0, 92.5),
completion_rate: toKpiValue(87.2, 85.4),
error_rate: toKpiValue(3.6, 4.1),
status: "good",
consecutive_problem_days: 0,
},
{
facility_id: 3,
facility_name: "メガバリューマート東口店",
serial_number: "8111R5628060033",
operation_rate: toKpiValue(92.4, 90.0),
launch_rate: toKpiValue(95.0, 93.0),
completion_rate: toKpiValue(88.1, 85.0),
error_rate: toKpiValue(3.2, 4.0),
status: "good",
// continuousProblems の同施設(L2・2日)と一致させる
consecutive_problem_days: 2,
},
{
facility_id: 5,
facility_name: "メガバリューマート港南総本店",
serial_number: "8111R5628060055",
operation_rate: toKpiValue(94.6, 93.8),
launch_rate: toKpiValue(96.2, 95.0),
completion_rate: toKpiValue(90.4, 89.1),
error_rate: toKpiValue(2.4, 2.9),
status: "good",
consecutive_problem_days: 0,
},
{
facility_id: 11,
facility_name: "バリューマート川下店",
serial_number: "8111R5628060155",
operation_rate: toKpiValue(95.2, 94.0),
launch_rate: toKpiValue(97.0, 96.1),
completion_rate: toKpiValue(92.0, 90.8),
error_rate: toKpiValue(2.0, 2.4),
status: "good",
consecutive_problem_days: 0,
},
{
// 12件目 — 「全X施設を表示」の発火確認用
facility_id: 1,
facility_name: "メガバリューマート中央本店",
serial_number: "8111R5628060011",
operation_rate: toKpiValue(96.3, 95.0),
launch_rate: toKpiValue(98.0, 97.0),
completion_rate: toKpiValue(94.1, 92.0),
error_rate: toKpiValue(1.8, 2.1),
status: "good",
consecutive_problem_days: 0,
},
];
/** `DataSource.facilityPerformance()` が返す行データ */
export const facilityPerformance: FacilityPerformanceRow[] =
forEachPeriod(facilityPerformanceItems);
/** 該当0件のケース(絞り込みで1件も残らないときの表示確認。docs/fixtures.md §10) */
export const facilityPerformanceEmpty: FacilityPerformanceRow[] = [];
// ---------------------------------------------------------------- 連続問題施設
/**
* 連続問題施設。
*
* ★12件ある(`limit` 既定10 → 「他N件を表示」の発火確認用。docs/fixtures.md §10)★
* うち2日以上(L2 / L3)が11件。**L1 は1件だけ意図的に残してある**:
* APIルート側の「2日以上で絞る」(指示書F 先に決まっていること6)が実際に効いているかを
* 確認するためのデータなので、消さないこと。
*
* facility_id 13 以降は「レポート用のダミー施設」で `lib/fixtures/facilities.ts` には存在しない。
* 施設マスタは12件しかなく、そのうち連続問題に相当するのは4件だけなので、
* 11件以上という要求を満たすには追加が必要だった。
* `/reports/continuous-problems` は施設マスタを引かない(連続日数と summary をそのまま出す)ため、
* この不一致で画面が壊れることはない。
*/
const continuousProblemItems: ContinuousProblem[] = [
{
facility_id: 9,
facility_name: "メガバリューマート三ツ橋中央ショッピングセンター店",
consecutive_problem_days: 5,
escalation_level: "L3",
last_problem_on: dateOnly(1),
summary: "完了率が5日連続で30%未満。マップ再作成またはオンサイト訪問を検討",
},
{
facility_id: 6,
facility_name: "バリューマート南丘駅前店",
consecutive_problem_days: 3,
escalation_level: "L3",
last_problem_on: dateOnly(1),
summary: "3日連続でオフライン。通信環境の確認が必要",
},
{
facility_id: 4,
facility_name: "バリューマート西通り店",
consecutive_problem_days: 3,
escalation_level: "L3",
last_problem_on: dateOnly(1),
summary: "スケジュール存在日ベースで3日連続。カレンダー上は5日間",
},
{
facility_id: 3,
facility_name: "メガバリューマート東口店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: dateOnly(1),
summary: "2日連続で完了率50%未満。現場責任者へのヒアリングを推奨",
},
{
// ★L1(1日)。「2日以上で絞る」が効いていることの確認用。消さないこと★
facility_id: 2,
facility_name: "バリューマート北町店",
consecutive_problem_days: 1,
escalation_level: "L1",
last_problem_on: dateOnly(1),
summary: "ハンドルアップ未解消により出発失敗。現場へ連絡",
},
{
facility_id: 13,
facility_name: "メガバリューマート白岩台店",
consecutive_problem_days: 6,
escalation_level: "L3",
last_problem_on: dateOnly(1),
summary: "6日連続で出発失敗。機体交換の検討が必要",
},
{
facility_id: 14,
facility_name: "バリューマート東雲町店",
consecutive_problem_days: 4,
escalation_level: "L3",
last_problem_on: dateOnly(1),
summary: "4日連続で完了率が△以下。通路の障害物が常設化している可能性",
},
{
facility_id: 15,
facility_name: "メガバリューマート新開地店",
consecutive_problem_days: 3,
escalation_level: "L3",
last_problem_on: dateOnly(2),
summary: "3日連続でブラシエラー。消耗品交換とオンサイト訪問を検討",
},
{
facility_id: 16,
facility_name: "フレッシュタウン栗ケ丘店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: dateOnly(1),
summary: "2日連続で緊急停止。運用手順の再教育を推奨",
},
{
facility_id: 17,
facility_name: "バリューマート宮の森店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: dateOnly(1),
summary: "2日連続で30分前チェックが未解消のまま出発失敗",
},
{
facility_id: 18,
facility_name: "フレッシュタウン浜通り店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: dateOnly(2),
summary: "2日連続でLostLocation。マップの再作成を検討",
},
{
facility_id: 19,
facility_name: "バリューマート辻堂北口店",
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: dateOnly(1),
summary: "2日連続で充電不足により未実施。充電器の設置環境を確認",
},
];
/** `DataSource.continuousProblems()` が返す行データ */
export const continuousProblems: ContinuousProblemRow[] =
forEachPeriod(continuousProblemItems);
/** 該当0件のケース(連続問題施設が1件もない期間の表示確認。docs/fixtures.md §10) */
export const continuousProblemsEmpty: ContinuousProblemRow[] = [];
// ---------------------------------------------------------------- エラー種別内訳
/**
* エラー種別(`robot_error_reports.error_type` の生値)の件数(★件数降順★)。
*
* ★変換しない(D-077)。`type` は生値をそのまま入れ、`label` にも同じ生値を入れる★
* (`label` は将来 TeamWorks から正式な表示名が来たときの受け皿。d-watch-api.yaml 参照)。
* ★値は docs/handoff/analysis/error-type-mapping.md の24値の「原文」から選んでいる★
* (同ファイルの「7分類」列・「推定される意味」列は参考資料であり使わない。禁止事項1)。
* ★境界値として「未知の値」と「空文字」を1件ずつ含む★(docs/fixtures.md 方針。段階5)。
* 未知の値: ERROR_TONE のフォールバック確認用。空文字: 画面が落ちないことの確認用
* (label が空になり行が視覚的に潰れるのが現状の挙動。直すには実データの値が要る)。
*
* `rate` は「全エラー件数に対する割合(%)」で、丸める前の生値。
* 合計件数を定数に置かず reduce で出しているのは、件数を1つ直したときに
* `rate` の分母が置き去りになるのを防ぐため(合計は常に100%になる)。
*/
const errorCounts: ReadonlyArray<Omit<ErrorBreakdownItem, "rate">> = [
{ type: "EmergencyStop", label: "EmergencyStop", count: 18 },
{ type: "LostLocalization", label: "LostLocalization", count: 12 },
{ type: "RobotHardwareErrorToAdmin", label: "RobotHardwareErrorToAdmin", count: 9 },
{ type: "LowBatteryLevel", label: "LowBatteryLevel", count: 7 },
{ type: "CleanWaterEmpty", label: "CleanWaterEmpty", count: 5 },
{ type: "TrashFull", label: "TrashFull", count: 3 },
{ type: "CanNotReach", label: "CanNotReach", count: 2 },
// ★同数(1件)の組。文字列昇順が守られていることの確認も兼ねる("" < "UnknownErrorFromCloud")★
// ★空文字。画面が落ちないこと・ソートが落ちないことの確認用(直すには実データが要る)★
{ type: "", label: "", count: 1 },
// ★未知の値(受領24値に無い)。ERROR_TONE のフォールバックが効くことの確認用★
{ type: "UnknownErrorFromCloud", label: "UnknownErrorFromCloud", count: 1 },
];
const errorTotal = errorCounts.reduce((sum, e) => sum + e.count, 0);
const errorBreakdownItems: ErrorBreakdownItem[] = errorCounts.map((e) => ({
...e,
rate: (e.count / errorTotal) * 100,
}));
/** `DataSource.errorBreakdown()` が返す行データ */
export const errorBreakdown: ErrorBreakdownRow[] =
forEachPeriod(errorBreakdownItems);
/**
* 該当0件のケース(エラーが1件もない期間の表示確認)。
*
* item が0件なので、6期間ぶん複製しても行は1件も生まれない(= 空配列)。
* `errorBreakdown` の代わりにこれを `DataSource` に返させると、
* どの期間を選んでも「該当なし」の表示になる。
*/
export const errorBreakdownEmpty: ErrorBreakdownRow[] = [];