diff --git a/.claude/commands/d-watch.md b/.claude/commands/d-watch.md
new file mode 100644
index 0000000..6c39d09
--- /dev/null
+++ b/.claude/commands/d-watch.md
@@ -0,0 +1,178 @@
+---
+description: D-Watch(清掃ロボット監視ビューアー)の作業を引き継いで再開する
+---
+
+D-Watch(清掃ロボット監視ビューアー)の作業を引き継ぎます。
+
+## ★役割分担(最初に読むこと)★
+
+**このリポジトリでは2つの環境で Claude が動きます。担当が違います。**
+
+| どこで動いているか | 担当 | 指示書を渡されたら |
+|---|---|---|
+| **Cursor の中**(エディタに統合された Claude) | ★**実装担当**★ `.ts` の実体(ロジック・型・テスト)を書く | ★**実装してください**★ |
+| **Claude Code**(デスクトップアプリ / ターミナル。このスラッシュコマンドを使う側) | 調査・検証・指示書の作成、`docs/*.md` などの文書 | **作るところまで。実行しない** |
+
+★**自分がどちらか分からないときの判定**★
+
+- このスラッシュコマンド(`/d-watch`)から起動された → **Claude Code 側**(実装しない)
+- ユーザーが `docs/handoff/指示書*.md` を指して「実行して」と言った → ★**あなたが実装者です。実装してください**★
+
+**「実装は Cursor 側」という記述を「Claude は実装しない」と読み違えないこと。**
+2026-09-07 に、Cursor 内の Claude がこの記述を読んで実装を拒否し、
+代わりに文書作業のメニューを出す事故が起きました。
+
+**Claude Code 側が守ること**: `docs/handoff/指示書*.md` は「作る」までが担当で、「実行」はしません。
+2026-09-03 に指示書D を Claude Code 側で実行してしまい、24ファイルの変更を作ったうえで
+**全部差し戻した**経緯があります(`docs/handoff.md` の「追記2」参照)。
+サブエージェントを使うのは**調査・検証まで**。実装を委譲しないこと。
+
+★**例外: ユーザーが Claude Code に「ここで実装して」と明示したとき**★は実装してよい(外出中で Cursor に渡せないため、指示書AR〜AT を Claude Code で実装した(`git log` の日時は 2026-10-02)。指示書AU も 2026-10-05 にユーザー指示「ここで実装してください」で同様。D-158 項9。★指示書AV・AW も 2026-10-06 にユーザー指示「ここで実装する」で同様(追記61・追記62)★。★D-161〜D-171・D-173 は指示書なしで、ユーザーの画面での指示をその場で実装した(追記63)★)。
+その場合も ★指示書の期待値表は変えない★・★監督(メイン)が型チェック・テスト・差分を自分で確かめてから push★。
+
+## 最初にやること
+
+★**入口はこの1枚です。先にこれを読んでください。**★
+
+- ★`docs/handoff/セッション引き継ぎ_20260917.md`★
+
+そのうえで、必要に応じて次を読みます。**いずれも本ファイルより優先される正の情報源です。**
+
+| 資料 | 何の正か |
+|---|---|
+| `CLAUDE.md` | ★仕様・規約・禁止事項の正★ |
+| `docs/decisions.md` | ★決定の正★。★D-174 まで(162件)★。★番号順に並んでいません★ |
+| `docs/handoff.md` | ★全履歴★。★追記63 まで★ |
+| `d-watch-api.yaml` | API契約の正 |
+| `d-watch-mock.jsx` | 画面の正 |
+
+★★**本ファイルの数字は書いた瞬間から古くなります。必ず実測してください。**★★
+
+---
+
+## ★★最初に知るべきこと — 接続先はステージングです★★
+
+★★`133.125.238.144` / `clean_watch` は**ステージング**。本番DBは別にある★★(D-152。D-148 は破棄)。
+
+- ★★4カラム(`status` / `comment` / `responded_at` / `resolved_at`)の UPDATE は書き込んでよい(D-152)。それ以外は禁止★★
+- ★2026-09-30〜10-01 に先方が実データ(ドン・キホーテ実店舗10件+本社)を入れた。当方のダミーは消えている★
+- ★2026-10-06 に接続が回復した★(`.env.local` 更新後。10-02〜10-05 は `1045`。D-157)。★10-06 は予定と実績がつながっていなかった★(レポート54行すべて `clean_timetable_id` が NULL。D-158 末尾)が、★10-07 はレポート87行のうち80行が本清掃の予定に紐づいていた★(予定外7行。D-170 2026-10-07 実測・先方投入)。★投入が終わったかは未確認★
+- ★4カラムの書き込みは 10-06 にエラー一覧(`/errors`)から確かめた★(1行を書いて戻し、4列は完全一致・権限エラーなし。先方投入データ。追記62 §3)。★エラー16行はすべて本清掃に紐づくので `/alerts` から書ける行は無い★
+- ★事前チェックの予定は本清掃とは別の行(別の id・`parent_id` あり)★だが、★事前チェックで見つかったエラーにどちらの id が入るかは未確認★(第12回で聞く。D-172)
+
+---
+
+## 30秒で読む現在地(★2026-10-07 11:3x JST 実測★)
+
+| 項目 | 値 |
+|---|---|
+| ブランチ | `develop` |
+| 最終決定番号 | ★D-174★(アドバイス B2 の根拠の進め方。記録のみ)。最後の実装は ★D-173★(レポートがまだ無い清掃は予定の終了から2時間「結果待ち」。`08ba9e9`) |
+| 最新の追記 | ★追記63★(D-161〜D-171・D-173 の実装と D-172・D-174 の記録) |
+| `main..develop` | ★533コミット★(`origin/main..origin/develop`。fetch 済み)。★HEAD = origin/develop = `04f974a`(第12回の送付版)。未 push 0★ |
+| ★PR★ | ★#6 open(`develop`→`main`)・レビュー未了★。★CI「Lint / Test / Build」は `04f974a` で緑(11:4x 確認)★。★レビュー担当は daichi(D-154)★。★積み上げ PR に分けて出し直す予定★(`docs/handoff/PRレビュー手順_20261001.md`。区切りは数え直す) |
+| テスト | ★125ファイル / 2,946件 全パス★(`8a657a2`。`929c5b9`・`04f974a` は資料だけ) |
+| 指示書 | 45本(★次は `AX`★)。★AR・AS・AT(2026-10-02。`git log` の日時)・AU(2026-10-05)・AV・AW(2026-10-06)は Claude Code で実装済み★。★D-161〜D-171・D-173 は指示書なし★ |
+
+### 完成しているもの
+
+★画面12枚中11枚が実装済み★(★エラー一覧 `/errors` を 10-06 に足した★)。★空なのは点検記録(F-05。`docs/spec.md`)だけ★。
+★2026-09-17 に全画面をブラウザで目視確認済み★(追記58)。★2026-10-05 に AR〜AT の変更(清掃結果の表示名・総清掃面積の注記・アラートの状態ボタンと備考)を fixtures で確認★。★AU(顧客の見出し・当期 KPI の式・予定の2表読み)は fixtures の API で境界値を確認済み、ブラウザ目視(EN・375px)は記録なし★(追記60)。★AV(ダッシュボードの直近モード・清掃詳細ログの既定を全期間)は fixtures の本番ビルドを撮影して確認、ステージングでも API を確認済み★(追記61)。★AW(エラー一覧)は fixtures の API と画面・ローカルDB・ステージング(1行を書いて戻した)で確認済み★(追記62)
+
+★2026-10-06〜07 に足したもの(D-161〜D-171・D-173。追記63)★:
+- ★対応状況はプルダウン(D-161)、対応状況と備考は「編集モード」でだけ変えて「保存(N件)」でまとめて送る(D-165)★ — エラー一覧とアラートの両方。即時保存は無くなった
+- ★レポートの施設別パフォーマンスを CSV で出す★(`GET /reports/facility-performance/export`。D-163)/★CSV の値が無い率は空欄★(D-164)
+- 一覧の見た目(分類のプルダウン固定幅・期間の枠全体でカレンダー・備考を1行。D-162)/列幅(エラー一覧 D-166・余りを全列に配る D-167)/見出しと中身の寄せ(D-168)
+- ★事前チェックの結果も有無も判定に使わない★ことを確かめ、テストで固定(D-170)/表示する事前チェックの回は後で設定値で運用(D-169。いまは 60/30)
+- ★事前チェックの予定が無い回の「1h前ST / 30分前ST」は「-」★(API は `null`、CSV は空。予定があって異常なしは "Standby.")/★本清掃が成功した事前チェック異常はアラートで薄く表示し「本清掃は成功」の Pill★(`main_run_succeeded`。対応状況・件数は変えない)/D-Secログの備考の文字の頭を見出しに揃えた(D-171)
+- ★レポートがまだ無い清掃は、予定の終了から2時間(`RESULT_WAIT_MINUTES`)まで「結果待ち」で、失敗に数えない★(D-173)— 詳細ログは出発「…」・「結果待ち」・判定なし/ダッシュボードに「結果待ち」カード(成功率の分母に入れない)・直近の清掃は予定が終了した最後の清掃(`settleMinutes` は削除)/アラートの出発失敗・連続日数・要レスキュー・当期の率も数えない
+
+---
+
+## 次にやること
+
+| 順 | 内容 |
+|---|---|
+| ★1★ | ★資料のコミットを push したあと、PR #6 の CI が緑のままか確認する★(`04f974a` では緑。11:4x 確認) |
+| ★2★ | ★第12回は送付版を作成済み★(`docs/handoff/TeamWorks確認事項_第12回_20261007_送付版.md`・`04f974a`。R-1 事前チェックで見つかったエラーの `clean_timetable_id` は事前チェックの行と本清掃の行のどちらの id か(D-172 P-9)/R-2 `clean_task_reports` は清掃の終了から最大どれくらい遅れて書かれるか(`RESULT_WAIT_MINUTES` の見直し。D-173)/R-3 お願い: データ投入(予定と実績の紐づけを含む)が終わったら一言ほしい)。★実際に送ったかは未確認★ → ★送付の確認と回答待ち。回答が来たら受領日を `docs/decisions.md` に記録★。★`serial_number` の拡張の再依頼は第12回には入れていない(急がない。D-172)★。P-13 と「予定が履歴に移る時期」も外した(D-172)。R-1 で本清掃の id が入るなら、アラートの「◯分前異常」は出なくなる(D-104)。R-2 の回答で `RESULT_WAIT_MINUTES`(120)を見直す |
+| ★3★ | ★ステージングの投入が終わったら再実測★: 予定と実績のつながり(10-07 は 87行中 80行が予定に紐づく。D-170)・`clean_timetable_histories` と `db.timetable_conflict`・ダッシュボードの直近モードの内訳・事前チェックに紐づくエラー行が入ったらアラートからの書き込み(指示書AT 段階9)と `main_run_succeeded`・結果待ち(D-173)の出方。★`prisma db pull` は `serial_number` の拡張を確かめてから・急がない★(D-172) |
+| ★4★ | ★PR #6 を積み上げ PR に分けて daichi がレビューし `main` に入れる★(先に CI 修正の PR。`main` は 2026-08-27 で停止中。D-154)。★ユーザー回答: レビューが終わったら全部 push する★(D-171 末尾) |
+| 5 | ★点検記録画面(F-05)の指示書を書く★(★唯一の未実装画面★。独自ストアなので TeamWorks 待ちが無い。`customer_name` の説明に残した「ブランド」列もここで合わせる) |
+| 6 | ★契約(`facilities.contract`)によるメニューの出し分け★(★P2。顧客に使わせる前に必ず★。D-160 追記4) |
+| 7 | ★セキュリティ強化はリリース直前にまとめて★(D-154。一覧は `CLAUDE.md` §3: `ALLOW_ALL_WHEN_EMPTY`・`AUTH_TOKEN` が `changeme`・パスワード変更・TLS 等) |
+| 8 | ★次フェーズ★: エラーの3区分・D-Sec アラートの取得元の切り替え(第11回 P-8〜P-10。D-158 当方の対応5)。★アドバイス B2 の根拠を本清掃のエラー内容に切り替える★(D-174 で決定。エラーコード(ユーザーが情報収集中)が決まってから) |
+| 9 | 後で・要望があれば: 表示する事前チェックの回を設定値で(D-169)/施設別パフォーマンスの CSV の追加列(D-163)/D-160 追記の残り(「解消済みも表示」の性能・375px でサイドバーが畳まれない)/★EN の文言と D-Secログの備考の見出しが EN で "NOTE" の件は、運用が始まって必要になったら直す★(D-171 末尾のユーザー回答)/指示書AV・AW で範囲外にしたもの(AV: 遷移先の条件・Q6・Q7 / AW §10: 件数カード・バッジ・名前の一般化・部品の共通化)/★fixtures で `data.anomaly` の「実績が予定を上回る」側を再現し直す★(D-173 で有効スケジュールが 11件になり再現できなくなった。追記63 §5) |
+
+---
+
+## 外部待ち — ★第11回は 2026-10-05 に回答受領(D-157 / D-158)。第12回は送付版を作成済み★
+
+- ★第12回は送付版を作成済み★(`docs/handoff/TeamWorks確認事項_第12回_20261007_送付版.md`・`04f974a`。R-1 事前チェックで見つかったエラーの `clean_timetable_id` は事前チェックの行と本清掃の行のどちらの id か(D-172 P-9)/R-2 `clean_task_reports` は清掃の終了から最大どれくらい遅れて書かれるか(`RESULT_WAIT_MINUTES` の見直し。D-173)/R-3 お願い: データ投入(予定と実績の紐づけを含む)が終わったら一言ほしい)。★実際に送ったかは未確認★ → ★送付の確認と回答待ち。回答が来たら受領日を `docs/decisions.md` に記録★。★`serial_number` の拡張の再依頼は第12回には入れていない(急がない。D-172)★。P-13 と「予定が履歴に移る時期」も外した(D-172)
+- ★先方のデータ投入の完了★(10-06 は `clean_task_reports` 54行すべて `clean_timetable_id` が NULL(D-158 末尾)→ 10-07 は 87行中 80行が予定に紐づく(D-170))。★ログインできない件(1045)は 10-06 に解消★
+- ★DDL の取り込み★: `robot_id` は NULL 可になった(10-06 構造で確認)が、★`serial_number` は varchar(20) のまま★(第11回 P-8 c の拡張は未反映。10-07 も同じ。D-171)。★ユーザー判断: 急がない・db pull もそれまで待つ★(D-172)。それまでは `robot_error_reports` を `select` で絞って読む(`lib/db/robot-error-select.ts`)
+- ★`clean_ratio` の訂正(N-3)は待たなくてよい★: 第11回 P-1・P-2=A で★今の式のまま確定★(D-158)。訂正後の列も同じ値になる
+- 第11回で解決: 屋号 → `customers` は法人で「顧客」(P-15)/本番DBも JST(P-11)/`task_area = 0` は 0%(P-5)/当期 KPI はサマリの式(P-6)。★どれも指示書AU で実装済み★
+- ★次フェーズ★: エラーの3区分・D-Sec アラートの取得元の切り替え(P-8〜P-10。D-158 当方の対応5)
+
+★回答が来たら**受領日を `docs/decisions.md` に記録すること**★。
+
+---
+
+## 進めるときの注意
+
+★引き継ぎ書 §6 の地雷を必ず読むこと。★ 特に★例外もテスト失敗も出ないまま結果だけ間違う★もの:
+
+1. ★`facility_ids` の `null`(全件)と `[]`(0件)は別物★
+2. ★`facilities.contract` は `varchar(10)` の桁位置★(数値比較すると全施設が「契約なし」)
+3. ★表示用に丸めた値で判定しない★
+4. ★DATETIME は JST の壁時計値★(素で読むと9時間進む)
+ - ★生の値に9時間を足さない★。★先方が 2026-10-02 に DB のタイムゾーンを JST に統一し再登録した(N-14)。本番も JST(第11回 P-11)。アプリのコードは変えない。再実測は未了★(`CLAUDE.md` §3)
+5. ★★4カラム(`status` / `comment` / `responded_at` / `resolved_at`)の UPDATE は書き込んでよい(D-152)。それ以外は禁止★★
+6. ★「実測」と書くときは出所を併記する★(`CLAUDE.md` §6-6)
+7. ★予定は `lib/db/timetable-source.ts` を通して読む★(`clean_timetables` と `clean_timetable_histories` の両方。直接 `cleanTimetable` を読むと履歴が抜ける)
+8. ★`robot_error_reports` は `lib/db/robot-error-select.ts` の `select` で読む★(`robot_id` / `robot` を読まない)
+9. ★ダッシュボードの数値カード・表は直近モード、スケジュール進捗・要レスキューは当日★(D-159)。★`cleanLogs()` と `cleanLogRunMeta()` は `mainTimetableWhere()` を共有する★(別々に書くと突き合わせが静かにずれる)
+10. ★4カラムを書くルートは `/prechecks/{id}/status` と `/errors/{error_id}/status` の2本★(どちらも `writeRobotErrorColumns()` を通る)。★エラー一覧は2段階で読む★(`errored_at ?? created_at` の順を Prisma で書けないため。D-160 追記2)
+ - ★画面からは「編集モード」で下書き → 「保存」でまとめて送る★(変えた行だけ1行ずつ順に。1行の失敗で止めず、失敗行の下書きだけ残る。D-165)。★下書きの扱いは `lib/prechecks/edit-drafts.ts` の1本★(エラー一覧とアラートが共有)。★巡回順の `lib/prechecks/status-cycle.ts` は削除済み★(D-161)
+11. ★i18n のキーは全画面で共有★(「備考」→ "Note" を足したら D-Secログの見出しも変わった)。★vitest でモックした `@/lib/db/prisma` の同時の動的 import は片方に本物が返る★(テストだけ。`vi.spyOn` で避ける。追記62 §4)
+12. ★事前チェックの結果も有無も、成功・失敗の判定に使わない★(判定は本清掃のレポート行の有無と完了率だけ。D-170 / `CLAUDE.md` §4.4。`lib/dashboard/__tests__/aggregate.test.ts` が見張る)
+13. ★`CleanLogItem.status_1h` / `status_30m` の `null` は「その分数の事前チェックの予定が無い」、"Standby." は「予定あり・異常なし」★(別物。D-171)。★`PrecheckItem.main_run_succeeded` は見た目を薄くするだけで、対応状況・未解消の件数は変えない★(D-171)
+14. ★結果待ち(`CleanLogItem.result_pending`)は `departed` / `grade` を変えない旗★(結果待ちの行も `departed: false`・`grade: "×"` のまま)。★行を数える・△×を拾う処理を足すときは必ず旗を見る★(見ないと出発失敗に数える)。判定は `lib/clean-logs/result-pending.ts` の `isResultPending()` 1本、待つ時間は `RESULT_WAIT_MINUTES`(★`LAUNCH_GRACE_MINUTES` とは別物・流用しない★。D-173)
+
+作法:
+- ★検証が通ったら即コミット★ / ★`git add -A` は禁止★
+- ★決定番号は `grep -n "^### D-" docs/decisions.md | tail -3` で実測★(`^### D-0` では3桁台を拾えない)
+- ★指示書を2本同時に走らせない★
+- ★dev サーバーは `preview_start`★(fixtures は `d-watch-fixtures`、ポート3100)。★ログインは `.env.example` の開発用トークン★(ローカルの開発サーバーに限る)
+- ★サブエージェントが起動した dev サーバーは止めてから終わらせる★(2026-10-02 に残ったサーバーの作業プロセスが落ち、PATCH が500になった)
+- ★新しいクローンでは `npx prisma generate` が要る★
+- ★ワークツリーでは `npm run build` が落ちる★(Turbopack が node_modules のジャンクションを拒否)。★ビルドは本体で★
+- ★Bash ツールはヒアドキュメントの中でも `\\` を `\` に縮める★(正規表現の `\S` 等を書くスクリプトは Write ツールで書く)
+- ★別の Claude セッションが同じ develop に docs のコミットを積むことがある★。コミット直前に `git status --short` と `git log --oneline -3` を見る
+
+---
+
+## 最初の報告
+
+★次を実際に実行して★現状を確認し、簡潔に報告してください。★推測で答えないこと。★
+
+```
+git log --oneline | head -10
+git status
+git rev-list --count origin/develop..develop
+npm test
+```
+
+1. ★未コミットの変更があるか★(★別の Claude セッションの作業中の変更なら触らない・stash しない★)
+2. ★テスト数と最終決定番号★が上の表(★125ファイル / 2,946件 / D-174★)と一致するか
+3. ★未 push のコミットがあるか★ / ★PR #6 の状態★(★最新の head の CI は緑か / レビューが通ったか / head はどこか★)
+4. ★第12回(送付版。R-1〜R-3)を送ったか / 先方のデータ投入が終わったか(予定と実績がつながったか) / 回答が来ているか★(★回答が来ていたら受領日の記録が最優先★。レポートの最大の遅れの回答が来ていたら `RESULT_WAIT_MINUTES` を見直す。D-173)
+
+そのうえで、何から進めるかをユーザーに確認してください。
+
+## この文書について
+
+**引き継ぎメモとこのコマンド定義は、次の担当者の行動をそのまま決めます。**
+古い記述を残すと、次のセッションがその指示に従って誤った作業を始めます。
+**このファイルは要約と道案内に留めてあります。詳細は `docs/handoff.md` に書いてください。**
+方針が変わったら、**引き継ぎメモへの追記と同時にこのファイルも直してください。**
diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..62fa29b
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,17 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "d-watch",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev"],
+ "port": 3000
+ },
+ {
+ "name": "d-watch-fixtures",
+ "runtimeExecutable": "cmd",
+ "runtimeArgs": ["/c", "set USE_FIXTURES=true&& npm run dev -- -p 3100"],
+ "port": 3100
+ }
+ ]
+}
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..7d7e26d
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,6 @@
+# .cursorrules — D-Watch
+
+このファイルは Cursor の legacy 形式です。内容は CLAUDE.md に統合済みです。
+
+- 実装規約(ディレクトリ構成・命名・コーディング規約・判定ロジックの境界値表など)を直す場合は CLAUDE.md を編集すること(特に第9章。同じ内容を二重に管理しない)。
+- Cursor は CLAUDE.md を自動で読むため、このファイルに実装規約を書き戻さないこと。
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e35d5c7
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,47 @@
+# 開発用サンプルデータで動かす(true の間はDBに接続しない)
+USE_FIXTURES=true
+
+# TeamWorks構築のMySQL(USE_FIXTURES=false のときに使用)
+# 参照用アカウント
+DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE"
+
+# ★DATABASE_URL の代わりに部品変数で書いてもよい★(lib/db/prisma.ts)
+# DATABASE_HOST と DATABASE_PASSWORD の両方があるとき、そちらが優先される。
+#
+# ★部品変数を使う理由★: パスワードに `@` が含まれると、DATABASE_URL に書いた
+# `%40` が dotenv の展開で壊れることがある(`p%40ss` → `p`)。
+# 部品変数なら URL エンコードが要らない。
+#
+# ★DATABASE_HOST を使うなら DATABASE_USER と DATABASE_NAME は必須★。
+# 既定値は用意していない(実在のDBユーザー名をコードに書かないため。CLAUDE.md §9.3)。
+# 足りないと「どれが足りないか」を言って起動時に落ちる。
+#
+# DATABASE_HOST=
+# DATABASE_PORT=3306
+# DATABASE_USER=
+# DATABASE_PASSWORD=
+# DATABASE_NAME=
+
+# 更新用アカウント(robot_error_reports の4カラム status / comment / responded_at / resolved_at の UPDATE だけに使う。
+# lib/db/prisma.ts の getWritePrisma()。指示書AT §3-2)
+# ★2つとも未設定なら、参照用アカウント(上の接続)で書く★。★片方だけだと書き込みが失敗する★。
+# ★接続先は参照用と同じDB★なので、使うときは DATABASE_HOST / DATABASE_PORT / DATABASE_NAME も必要
+# (URL 形式は設けない。%40 が壊れる問題を持ち込まないため)。★値はコミットしないこと★
+# DATABASE_WRITE_USER=
+# DATABASE_WRITE_PASSWORD=
+
+# 点検記録用の独自ストア(SQLite)
+INSPECTION_DB_URL="file:./data/inspections.db"
+
+# 簡易トークン認証(P1は社内利用のみ)
+AUTH_TOKEN=changeme
+
+# 動作ログの出力レベル(docs/logging-design.md)
+LOG_LEVEL=info # 開発は debug、本番は info。未設定なら NODE_ENV から判定
+
+# nginx を通らない直接アクセス(localhost:3000 への直アクセス)を通す。
+# ★ローカル開発では必要★。これが無いと全ページ 403 になる(指示書I 段階5-B)。
+# ★本番では設定しないこと★(未設定=false=拒否)。
+# 本番用のテンプレートは .env.production.example(そちらでは意図的にコメントアウトしてある)。
+# このファイルと向きが逆なのは意図的で、.env.example は「ローカルで動くこと」優先。
+ALLOW_DIRECT_ACCESS=true
diff --git a/.env.production.example b/.env.production.example
new file mode 100644
index 0000000..fa63a2d
--- /dev/null
+++ b/.env.production.example
@@ -0,0 +1,74 @@
+# 本番で設定する環境変数のテンプレート(★docs/deploy.md を先に読むこと★)
+#
+# ★このファイルに実値を書かないこと★(CLAUDE.md §9.3「認証情報を絶対にコミットしない」)
+# 実在のホスト名・ユーザー名・パスワード・トークンを書かない。
+# コピーして .env.local を作り、実値はそちらに書く(.env.local は .gitignore 対象)。
+#
+# ★開発用は .env.example(別ファイル)★。向きが逆になっている項目があるので注意:
+# .env.example は「ローカルで動くこと」優先、こちらは「本番で安全なこと」優先。
+#
+# 検証: 値を埋めたら `npm run check:env` を実行し、エラーが出ないことを確認する。
+
+# 実DBに接続する。★本番では必ず false★
+# 未設定だと true になり(lib/db/use-fixtures.ts の `!== "false"`)、
+# 実DBに繋がらないままサンプルデータを表示する。★例外もエラーも出ない事故★
+USE_FIXTURES=false
+
+# TeamWorks構築のDB(★参照用アカウント★。本番は MariaDB 10.11 / D-100)。
+# 未設定だと起動しても画面が全部エラーになる。
+# ★パスワードに % や @ が含まれる場合は URL エンコードが必須★
+DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE"
+
+# ★URL エンコードを避けたい場合は部品変数で書く★(lib/db/prisma.ts)。
+# DATABASE_HOST と DATABASE_PASSWORD の両方があるとき、DATABASE_URL より優先される。
+# ★`@` を含むパスワードは DATABASE_URL 側で壊れる実績がある★(`%40` が dotenv 展開で消える)
+# ため、本番ではこちらを推奨する。
+#
+# ★DATABASE_HOST を使うなら DATABASE_USER と DATABASE_NAME は必須★(既定値は無い)。
+# 実在のDBユーザー名をコードの既定値に書かない方針のため(CLAUDE.md §9.3 / コミット c832891)。
+# 足りないと起動時に「どれが足りないか」を言って落ちる。★黙って別のDBに繋がることはない★。
+#
+# DATABASE_HOST=
+# DATABASE_PORT=3306
+# DATABASE_USER=
+# DATABASE_PASSWORD=
+# DATABASE_NAME=
+
+# 更新用アカウント(robot_error_reports の4カラム status / comment / responded_at / resolved_at の UPDATE だけに使う。
+# lib/db/prisma.ts の getWritePrisma()。指示書AT §3-2)
+# ★2つとも未設定(空)なら、参照用アカウント(上の接続)で書く★。★片方だけだと書き込みが失敗する★
+# (`npm run check:env` もエラーにする)。★4カラム以外には書かない★(CLAUDE.md §7)。
+# ★接続先は参照用と同じDB★なので、使うときは DATABASE_HOST / DATABASE_PORT / DATABASE_NAME も必要
+# (URL 形式は設けない。%40 が壊れる問題を持ち込まないため)。★実値はコミットしないこと★
+DATABASE_WRITE_USER=
+DATABASE_WRITE_PASSWORD=
+
+# 簡易トークン認証の共有トークン(指示書I 段階1-2)。
+# ★必ず変更すること★。未設定・空なら誰もログインできない(=全機能が使えない)。
+# `changeme` のままだと `npm run check:env` がエラーで落ちる。
+AUTH_TOKEN=changeme
+
+# 動作ログの出力レベル。未設定なら NODE_ENV から判定(本番は info)
+LOG_LEVEL=info
+
+# ★★ALLOW_DIRECT_ACCESS は本番では設定しないこと★★
+#
+# nginx を通らない直接アクセスを素通りさせる開発専用スイッチ。
+# 未設定なら false(拒否)。ローカル開発の .env.local にだけ書く。
+# 設定すると `npm run check:env` が本番判定でエラーにする(検査2)。
+#
+# ★行を消さずコメントのまま残してある★:
+# 完全に書かないと「知らずに設定してしまう」ため、
+# 「設定するな」という指示として残すのが正しい。
+#
+# ALLOW_DIRECT_ACCESS=true
+
+# ---------------------------------------------------------------------------
+# ★環境変数ではないもの(コードにあるのでデプロイが必要)★
+#
+# ALLOWED_IPS / TRUSTED_PROXY_HOPS / ALLOW_ALL_WHEN_EMPTY
+# → lib/config/allowed-ips.ts
+#
+# この3つは「本番で意味のある値」なのでコードで管理し、レビューと履歴を残す。
+# ★.env.local を直しても変わらない★。変更にはデプロイが必要(docs/deploy.md 項5)。
+# ★本番では ALLOW_ALL_WHEN_EMPTY を false にし、ALLOWED_IPS を埋めること★
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..caa5728
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,52 @@
+name: CI
+
+# PRを作ったとき / 更新したとき、および main に入ったときに走ります。
+# 現時点では「結果を表示するだけ」で、赤くてもマージはできます。
+# 運用が安定したら Ruleset の "Require status checks to pass" をONにして必須化します。
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ check:
+ name: Lint / Test / Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: リポジトリを取得
+ uses: actions/checkout@v4
+
+ - name: Node.js をセットアップ
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: 依存パッケージをインストール
+ run: npm ci
+
+ # ★Prisma クライアントを生成する★
+ #
+ # 生成先の `lib/generated/prisma` は .gitignore 対象(`.gitignore:47`)なので、
+ # ★チェックアウト直後には存在しない★。生成しないと `@/lib/generated/prisma/client`
+ # が解決できず、★ビルドが module-not-found で落ちる★(2026-09-17 に PR #6 で発生)。
+ #
+ # ★DBには接続しない★ — `prisma generate` はスキーマだけを読む。
+ # 接続先(`prisma7.config.ts` の `datasource.url`)が未設定でも生成できる。
+ - name: Prisma クライアントを生成
+ run: npx prisma generate
+
+ - name: Lint
+ run: npm run lint --if-present
+
+ # 判定ロジックの期待値表テスト。
+ # `--if-present` は M1 以前の名残で、現在は test スクリプトが存在する。
+ - name: テスト
+ run: npm test --if-present
+
+ - name: ビルド
+ run: npm run build
+ env:
+ # DBには接続しない。サンプルデータでビルドが通ることを確認する
+ USE_FIXTURES: "true"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1329f3d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,58 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+!.env.example
+# 本番で設定すべき環境変数のテンプレート(実値は書かない。docs/deploy.md 参照)。
+# ★この行が無いと上の .env* に飲まれて Git 管理外になる★
+!.env.production.example
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+/lib/generated/prisma
+
+# 運用設定(実行時に生成される。環境ごとに異なるためコミットしない)
+/data
+
+
+# PDF は原稿とスクリプトから生成する(docs/handoff/pdf/README.md)
+docs/handoff/pdf/*.pdf
+
+# Python のキャッシュ(docs/handoff/pdf/ のPDF生成スクリプトが作る)
+__pycache__/
+*.pyc
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..d9fb7e6
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,526 @@
+# CLAUDE.md — D-Watch 開発コンテキスト
+
+このファイルは Cursor / Claude Code が最初に読むプロジェクトコンテキストです。
+**実装前に必ず全文を読んでください。** 特に「4. 確定仕様」と「7. 禁止事項」は仕様のズレが直接バグになる箇所です。
+
+---
+
+## 0. ルールの優先順位
+
+セッション開始時に自動注入される全社共通の作業ルールと、本ファイルの記述が衝突した場合は
+以下を優先する。
+
+1. **仕様が不明なら止まる。**
+ 全社ルールの「自動モード中は許可待ちで止まらない」は*権限確認*についての取り決めであり、
+ *仕様の不明点*には適用しない。§7 の通り、推測で実装せず確認を求める。
+ 判定基準・閾値・API契約の不明点は特に危険(仕様のズレが直接バグになる)。
+
+2. **判定ロジックは「期待値表の確定 → テスト作成 → 実装」の順を崩さない。**
+ 実装を分担・委譲する場合も、**期待値表の確定は分担しない**(§4.3 / §6.2)。
+ 境界値(完了率 90.0 / 90.1 / 70.0 / 70.1 / 10.0 / 9.9 / 0 / 未実施、
+ および `89.5 → 表示90% だが判定は ○`)は本ファイル §9.4 の表を正とする。
+
+3. **日本語を含む成果物には日本語フォントを明示する。**
+ 欧文フォント(Geist 等)は日本語グリフを持たないため、指定しないと
+ フォールバックで中国語フォントに落ち、漢字が中華字形で表示される。
+ `app/globals.css` のフォントスタックを正とし、日本語フォントを外さない。
+
+---
+
+## 1. プロダクト概要
+
+**D-Watch(DFAロボット監視システム)** は、店舗等に配置された清掃ロボットの稼働を監視するSaaSビューアーです。
+
+- 夜間に自動実行された清掃の結果を、**翌朝DFA運用担当が確認する**「翌朝確認型」の運用
+- 問題のある施設を検知し、AIが推定原因と対応アドバイスを提示する
+- オプションモジュール **D-Security**(セキュリティ解除→清掃→復帰の自動化)の監視画面も含む
+
+**P1スコープ**
+| 項目 | 範囲 |
+|---|---|
+| 対象顧客 | 株式会社ドン・キホーテのみ |
+| ロボットメーカー | PUDU Roboticsのみ(KeenonはP2以降。Whiz iは機能的に劣るため取り扱わない) |
+| 対応機種 | PUDU CC1のみ(将来候補: PUDU MT1 / Keenon C30 / C40。`lib/config/index.ts` の `ROBOT_MODELS` が正。D-087) |
+| 利用者 | DFA社内のみ(顧客アカウントはP2以降) |
+| セキュリティ会社 | SECOMのみ |
+
+---
+
+## 2. 責任分界 — 何を作り、何を作らないか
+
+| 領域 | 担当 |
+|---|---|
+| MySQLデータベース本体(設計・構築・運用) | **TeamWorks**(外部エンジニア会社) |
+| データ収集処理(Pudu Cloudポーリング/コールバック、D-S制御盤報告のDB書き込み) | **TeamWorks** |
+| サマリ集計バッチ(顧客/店舗/障害サマリ) | **TeamWorks** |
+| APIサーバー + ビューアー(本リポジトリ) | **DFA(内製)** |
+| 点検記録機能(独自データストア・写真管理) | **DFA(内製)** |
+| 当期(進行中の週/月/年)のレポート集計 | **DFA(内製)** |
+| ★サーバー・ネットワークの用意と構築保守★(本番のアプリVM / DB / テスト環境) | ★**TeamWorks**★(D-143) |
+
+★**DFA が作るのはビューアー(本リポジトリ)だけです**★(2026-09-16 ユーザー確認。D-143)。
+★**サーバーを契約し費用を払うのは DFA ですが、構築・保守は TeamWorks です**★。
+★**D-100 項5 の「DFA が構築するのはアプリ側(VM)」は誤りなので従わないこと**★
+(★ただし TeamWorks 側の明示確認は未取得。第9回 Q12-1 で照会中★)。
+
+**重要:** メインDBのスキーマ変更・データ投入処理は本プロジェクトの仕事ではありません。
+DBは基本的に**読み取り専用**で、書き込みは後述の4カラム(`robot_error_reports` は `error_list_id` を含む5カラム)のみです。
+
+**「APIサーバー」の意味:** 本アプリ内の API ルート(`app/api/`)を指します。TeamWorks が API を
+提供するわけではありません。DB へは Prisma で直接アクセスし、間に外部サービスは入りません。
+画面はブラウザから自アプリの API ルートを呼び、その中で Prisma が DB を読みます(D-046)。
+
+---
+
+## 3. 技術スタック
+
+| 項目 | 採用 |
+|---|---|
+| フレームワーク | **Next.js**(App Router、React + APIルート一体型) |
+| 言語 | **TypeScript**(フロント・API共通) |
+| DBアクセス | **Prisma**(MySQL) |
+| メインDB | **MariaDB 10.11**(TeamWorks管理・**DFAはスキーマを変更しない**)。MySQL互換だが同一ではない。★下の注記★を必ず読むこと(D-100) |
+| 点検記録ストア | SQLite + オブジェクトストレージ(**メインDBとは完全に分離**) |
+| 認証 | P1は簡易トークン認証 + IP制限(社内利用のみ)。★IP制限は `X-Forwarded-For` の**末尾**を見る(nginx が追記する。D-077 項5)。**先頭は詐称可能なので絶対に使わない**★ |
+| インフラ | アプリ: さくらのクラウド VM(**レンタルサーバではない**。Node.js常駐が必要なため)。DB: さくらインターネットの**データベースアプライアンス**(PostgreSQL か MariaDB の2択のため MariaDB)。★アプリ(VM)とDB(アプライアンス)が別サービスという理解だが、**明示の確認は未取得**★(D-100)。★**用意・構築保守はいずれも TeamWorks**★(D-143。第9回 Q12-1〜Q12-6 で照会中) |
+| 開発用データ | **`lib/fixtures/` のサンプルデータ**(環境変数 `USE_FIXTURES` で実DBと切替) |
+
+> ★**メインDBについての注記**★(根拠: D-100。TeamWorks 回答 2026-09-10)
+> - ★**なるべく MySQL 互換の SQL を使う**★
+> - ★★**MariaDB 特有の DML を使いたい場合は、その箇所を TeamWorks に知らせること**★★
+> - ★ローカル検証環境は **MySQL 8.4**(`dwatch_local`)。本番は MariaDB 10.11 なので、
+> ★**ローカルで通っても本番で通らない可能性がある**★。生SQL(`$queryRaw` / `$executeRaw`)を
+> 書くときは特に注意する★(現状、アプリコードに生SQLは1件も無い。すべて Prisma のクエリビルダ経由)
+
+> ★★**接続先についての注記**★★(D-152。2026-10-01 ユーザー確認)
+> - ★★`133.125.238.144` / `clean_watch` は **ステージング**★★。★**本番DBは別にある**★(2026-10-01 ユーザー回答
+> 「これはステージングです、本番は他にあります」)
+> - ★★**D-148(「本番DBそのもの」2026-09-17)は破棄**★★。★第9回までの文書の「ステージング」「テスト環境」という呼称は
+> ★正しかった★(D-148 項1 の「誤り」という訂正のほうが誤りだった)
+> - ★★**2026-09-30 23:25〜10-01 00:09(JST)に実データが入った**★★(2026-10-01 実測・先方投入。読み取りのみ)。
+> ★当方のダミー(`scripts/seed-local-db.ts` 由来)は消えている★。
+> `facilities` は ★ドン・キホーテの実店舗10件+本社★。
+> - ★★**DB に入っている時刻の値は、列の型(TIMESTAMP / DATETIME)によらず JST の壁時計の値**★★
+> (ユーザー指摘 2026-10-01「データベース上の時間は全てJSTに変換済みです」。★実測で確認★: `robots.updated_at` の最新が生の値で
+> 15:00、そのときの JST が 15:17・DB の `NOW()` が 06:17。生の値が UTC なら9時間後の未来になり、ありえない)。
+> ★**生の値に9時間を足さないこと**★。
+> - ★★**2026-10-02 TeamWorks 回答(第10回 N-14・N-6c)**★★: ★それまでサーバーの時計は UTC(`@@system_time_zone = UTC`)で、
+> 一部のテーブルはフレームワークの機能を切って書いていたため、`current_timestamp()` の既定値で UTC の値が入っていた★。
+> ★DB の既定のタイムゾーンを 'Asia/Tokyo' に変え、データを再登録済み★(先方の申告。★当方の再実測は未了★ — 10-02 は認証エラーで接続できず)。
+> ★先方の結論: 「timezone を JST で統一したので、以降はズレは生じない」★
+> - ★当時の9時間ずれの正体★: `control_reports.reported_at` は ★UTC で書かれていた★(N-14a)。
+> `robot_error_reports.errored_at` は ★正しい JST の値で、ずれていたのは `created_at`(UTC)の側★(N-14b)。
+> ★旧記述の「`errored_at` は二重補正・エラー検知時刻は9時間遅く出る」は誤り★
+> - ★**アプリのコードは変えない**★(2026-10-02 調査): アダプタは `dateStrings: true` で、サーバーが返した文字列を UTC とみなすだけ。
+> ★サーバーが JST になったことで、TIMESTAMP 列も JST の文字列で返り、`fromDbJst()` の前提どおりになる★。
+> ★残る危険は、再登録されなかった古い TIMESTAMP 行だけ★(そこだけ +9時間に見える)。★再実測で確かめること★
+> - ★一度「TIMESTAMP は UTC で返るので +9時間」と誤って書き(9c175a2)取り消した(d540335)経緯あり★。★推測で9時間ずらさないこと★
+> ★`clean_timetables` / `clean_schedules` / `clean_status_histories` は0行★、
+> `clean_task_reports` は2行(どちらも `clean_timetable_id` が NULL)。★サーバー本体は昨日と同一★(`@@hostname` 一致)
+> - ★帰結★: ★予定が0行なので、清掃詳細ログ・ダッシュボード・アラートは空★。★総清掃面積だけは実績を直接集計するので2回と出る★
+> (画面どうしで食い違う。★詳細ログを予定起点・実績起点・両方のどれにするかは、データが入ってから検討★。D-151 項7)
+> ★以上は 2026-10-01 時点★。★2026-10-06 は予定が入り始め(先方投入中。予定と実績はまだつながっていない。D-158 末尾)、
+> ダッシュボードは「各清掃エリアの直近の清掃(7日以内)」(D-159)で11エリアを出した★(先方投入。`docs/handoff.md` 追記61)
+> - ★`robot_error_reports.serial_number` の実値★(2026-10-19 監督の SELECT・先方投入): セキュリティ(`SECURITY_ON`)・清掃開始失敗(`NOT_STARTED`)の行は ★`---`★(第11回 P-8 の回答は `0`)。★`serial_number` の値や `robot_id IS NULL` で区分を見分けない — `error_type` で見分ける★(D-180 項5)。どちらに揃うかは TeamWorks に照会予定(指示書AX §12-1-2)
+> - ★接続元の制限・TLS(第9回12章)は★**検証段階のため保留。顧客へ配布するときに検討する**★(ユーザー判断 2026-10-01)。
+> ★**セキュリティの強化はリリース直前にまとめて行う**★(ユーザー判断 2026-10-01。D-154)。★その時の対象★: 接続元の制限・TLS(または閉域)、★ステージングDBのパスワード変更★(コメントに実物が入っていた。履歴に残る)、★`ALLOW_ALL_WHEN_EMPTY` を拒否に倒すか許可リストを入れる★(`lib/config/allowed-ips.ts`)、★ログインの試行回数制限と cookie の中身★(`lib/auth/cookie.ts`)、★制御盤のログ中継(作ったら): 読めるのはログのフォルダだけ・読み取り専用・制御盤ごとの認証★/★許可リストを入れると店舗の制御盤(`/api/ds-agent/`)が弾かれる — 店舗の IP を載せるか、その経路だけ IP 制限を外してトークン必須にするか★(D-179。未実装)/★CSP を入れるなら `app/layout.tsx` の `
` のインラインスクリプト(表示設定の復元。D-183)に nonce が要る★
+> ★第10回では照会しない★。★本番を組むときは、アプリとDBを閉域でつなぐか、DBに TLS を入れるかのどちらかが要る★
+> - ★★**ステージングには、4カラム(`status` / `comment` / `responded_at` / `resolved_at`)の UPDATE を書き込んでよい**★★
+> (ユーザー指示 2026-10-01「書き込んでよい」)。★実データ(ドン・キホーテのエラー行)の対応状況が書き換わることを承知のうえ★。
+> ★**4カラム以外への書き込みは引き続き禁止**★(§7。権限も無い)。★例外は `robot_error_reports.error_list_id` だけ★(下の「DB接続アカウント」。D-184)。★`scripts/seed-local-db.ts` は★ステージングに絶対に向けない★
+> (テーブルを消してから入れ直すため、実データが消える。`lib/db/local-guard.ts` が `dwatch_local` 以外を拒否する)
+> - ★旧: 「本番DBへは書き込まない(2026-09-17)」「書き込んで安全な環境ができるまで着手しない」★ — D-152 で解除
+
+### DB接続アカウント(2種)
+1. **参照用**: 全テーブル SELECT のみ
+2. **更新用**: `robot_error_reports` / `control_error_histories` の
+ `status`, `comment`, `responded_at`, `resolved_at` **のみ** UPDATE 可
+ - ★加えて `robot_error_reports.error_list_id`(エラー内容。`error_lists.id`)★(D-184。2026-10-27 ユーザー回答「すでに更新権限があると思う」。
+ ★`SHOW GRANTS` での確認は未了★ — ステージングに接続できないため)。★`control_error_histories` には無い列★
+
+この4カラム(`robot_error_reports` は `error_list_id` を含む5カラム)以外への書き込みコードを書かないでください。権限エラーになります。
+★`error_list_id` を書くのはエラー一覧の `PATCH /errors/{error_id}/status` だけ★(事前チェックの `/prechecks` と D-Sec の保存は4カラムのまま。`lib/db/robot-error-write.ts`)。
+
+---
+
+## 4. 確定仕様 — 実装時に必ず守るルール
+
+> ここに書かれた数値・ロジックは TeamWorks との合意事項です。
+> 変更したくなった場合は勝手に変えず、`docs/decisions.md` を確認し、必要なら合意を取り直してください。
+
+### 4.1 判定基準(◎○△×)
+清掃完了率にもとづく4段階判定。**閾値は設定値として外出しする**(ハードコード禁止)。
+
+| 判定 | 条件 |
+|---|---|
+| ◎ | 完了率 90%以上 |
+| ○ | 完了率 70%以上 |
+| △ | 完了率 10%以上 70%未満 |
+| × | 完了率 10%未満、または未実施 |
+
+★**境界はすべて「上のランクに入る」**★(ユーザー指示 2026-09-16「◯、×は90%以上のように境はふくめてください」。**D-139**)。
+90.0 は ◎、70.0 は ○。★**× の境界だけは「10%未満」のまま**★(同日「10%未満を×としてください」で確定)なので、10.0 は △。
+★**D-020b(境界はすべて下のランクに落ちる)は破棄**★。
+
+### 4.2 KPI計算式(週次/月次/年次の集計期間内)
+
+```
+稼働率 = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100 ← ★第11回 P-6=A(2026-10-05)★
+出発成功率 = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100 ← ★時刻の条件なし(P-6=A)★
+清掃完了率 = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数 ← ★1回ごとの完了率 = min(clean_area ÷ task_area × 100, 100) を小数第1位までで切り捨て★
+エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100 ← ★分母は予定回数(P-6=B)★
+```
+
+★**3つの率はサマリ(`operation_ratio` / `launch_ratio` / `error_ratio`)と同じ式**★(第11回 P-6。ユーザー指示 2026-10-05「合わせてください」。D-158)。★旧: 稼働率 = 実施回数 ÷ 予定・出発成功率 = 定刻出発回数 ÷ 予定(実際は同じ値になっていた)・エラー発生率 = ÷ 実施回数★。実装は指示書AU
+
+★**清掃完了率は「1回ごとの面積比(上限100・小数第1位まで切り捨て)」の単純平均**★(★**ユーザー判断 2026-10-02「一旦B」。D-155**★)。
+★TeamWorks のサマリと同じ式★(第10回 N-3 の回答)。★`clean_task_reports.clean_ratio` 列は使わない★(先方が「計算式が間違っている。訂正したら回答する」)
+- ★**確定**★(第11回 P-2=A「今の式のまま」。P-1: 訂正後の `clean_ratio` 列もこの式と同じ値になる。2026-10-05。D-158)。★D-151 の「`clean_ratio` 列の単純平均」は破棄★。
+ ★切り替えが1関数で済むよう、1回ごとの完了率を決める関数は1つにする★(`lib/judge/kpi.ts`。指示書AS)
+- ★既知の弱点(承知のうえで採用)★: `clean_area` は★同じ場所を複数回清掃した面積の総和★(N-4)なので、面積比は清掃の網羅率ではない。
+ ユーザー提供CSV 189行では ★`clean_area > task_area` が 129行(68.3%)で、これらは全部 100% になる★。★◎が多めに出る★
+- ★全画面で同じ定義★(レポートKPI 当期 / 総清掃面積 F-08 / エリア別完了率 / 要レスキュー R-3 / 詳細ログの1行)
+- ★対象は本清掃(`precheck = 0`)に対応する行と、予定外のタスク★。★事前チェックの回は別の1件として数えない★。
+ - ★★予定外のタスク(`clean_timetable_id` が 0 か NULL)も1件として数える(A案)★★(ユーザー指示 2026-10-29「エラー一覧以外も、予定外タスクを計算して出すようにしてください」「割合はA案で」。指示書BG):
+ 予定外1件 = 清掃予定回数 +1・開始できた回数 +1・`status = 4` なら正常終了 +1・1回ごとの完了率を平均に入れる。予定外のエラー(`clean_timetable_id` が 0 か NULL)もエラー発生回数・エラー内訳に入れる(期間は `errored_at ?? created_at`)。
+ 予定外の期間の振り分けは `started_at`(実際に始めた時刻)。★予定外は未実施・結果待ちにならない★
+ - ★今の期間(アプリが計算)だけ★。★確定期間は TeamWorks のサマリのまま★(ユーザー「サマリは変えて貰う予定です」)。サマリが変わるまで、今の期間と確定期間で予定外の扱いが違う
+ - ★旧(2026-10-02〜10-29): 予定外は完了率にも実施回数にも入れず、総清掃面積の面積の合計にだけ含めていた(D-155 項3)★
+- ★`task_area` が 0 の行は、1回ごとの完了率を 0% とする★(第11回 P-5=B。2026-10-05。実装は指示書AU)。★旧(D-155 の暫定): null として分子にも分母にも入れない★
+- ★未実施(レポート行が無い清掃)は分子にも分母にも入れない★(0% として混ぜない)
+- ★切り捨ては「1回ごとの値」だけ★。★平均した値は丸めずに判定し、表示のときだけ整数に丸める★(§4.3)
+- ★浮動小数の罠★: `0.57 / 1 * 100` は `56.99999…` になる。★切り捨ての前に誤差を吸収すること★(期待値表は指示書AS)
+
+#### 4.2.1 ダッシュボードの清掃結果(ユーザー指示 2026-10-29「4の計算式は仕様書に残して」。D-049 / D-173。実装は `lib/dashboard/aggregate.ts` の `computeSummary()`)
+
+```
+成功率 = 成功 ÷ (成功 + 失敗 + 未実施) × 100 ← ★結果待ちは分母に入れない★。分母0なら null(画面は「-」)
+```
+
+| 区分 | 数える清掃 | 成功率での扱い |
+|---|---|---|
+| 成功 | 清掃結果(`clean_task_reports` の行)があり、判定が ◎○ | 分子・分母 |
+| 失敗 | 清掃結果があり、判定が △×(★出発した清掃だけ★。未実施を二重に数えないため) | 分母 |
+| 未実施(=出発失敗) | 清掃結果が無く、予定の終了+`RESULT_WAIT_MINUTES`(120分)を過ぎた | 分母 |
+| 結果待ち | 清掃結果が無く、予定の開始 ≤ 今 < 予定の終了+120分 | 入れない(件数だけ出す) |
+
+- ★対象の清掃★: 日付を指定しないとき(直近)は、★清掃エリアごとに1件★ — 開始が直近7日以内(`DASHBOARD_LATEST_RUN.lookbackDays`)で予定の終了を過ぎた本清掃のうち、開始が最新のもの(D-159)。日付を指定したときは、その日の本清掃すべて
+- ★予定外のタスク(`clean_timetable_id` が NULL か 0)も1件として数える★(指示書BG。2026-10-29)。直近モードの候補は開始=`started_at`・終了=`ended_at`(null なら開始)。成功か失敗のどちらかになり、未実施・結果待ちにはならない。同じ清掃エリアで開始が同時刻なら予定のある行を採る(`lib/dashboard/latest-run.ts` の `preferOnTie()`)
+- ★事前チェックの結果は使わない★(§4.4。D-170)
+- ★丸めない★。表示のときだけ整数に四捨五入(§4.3)
+- 進み具合(`schedule_progress`)= 対象日に出発した件数 ÷ 対象日の予定件数 × 100(予定件数0なら null)。★対象日は、日付を指定しないとき日本時間の前日、指定したときはその日★(ユーザー判断 2026-10-29「B」 — 清掃結果は前夜の分なので、今日の予定で数えない)。
+ ★予定件数は予定の表(`clean_timetables` / `clean_timetable_histories` の本清掃)の行数★。★`clean_schedules` の曜日の設定は使わない★(旧。2026-10-29 ステージングで今日の予定が 7件 対 5件と食い違った)。結果待ち・未実施は予定件数にだけ入る。★対象日に始まった(`started_at`)予定外タスクは予定件数にも出発件数にも1件ずつ入る(A案。指示書BG)★。応答の `schedule_progress.date` が対象日。`lib/dashboard/aggregate.ts` の `scheduleProgressTargetDate()` / `computeScheduleProgress()`。★要レスキュー・セキュリティのバナーは当日のまま★
+
+### 4.3 端数処理 ★事故りやすい★
+
+- **表示**: 小数第1位を四捨五入して**整数**で表示する(例: 91.4% → `91%`)
+- **判定**: ◎○△× と L1〜L3 の判定は、**丸める前の生値**で比較する
+
+```ts
+// ✅ 正しい
+const grade = judgeGrade(rawRate); // 生値で判定
+const display = Math.round(rawRate); // 表示用に丸める
+
+// ❌ 間違い(89.5%が丸めで90%になり、本当は○なのに◎になる)
+const rounded = Math.round(rawRate);
+const grade = judgeGrade(rounded);
+```
+
+### 4.4 清掃開始/終了/失敗の判定
+
+| 事象 | 判定条件 |
+|---|---|
+| 清掃開始 | RobotWorkStatusコールバック(清掃開始)受信、またはポーリングでステータスが「清掃中」に変化 |
+| 清掃終了 | タスク実行結果(タスクレポート)の受信 |
+| 清掃失敗 | 予定開始時刻 + **猶予30分**を経過してもタスク開始が確認できない(未実施)、またはタスクレポートが中断で返る |
+
+猶予30分は暫定値。**設定値として外出しする。**
+
+★**レポートがまだ無い清掃は「結果待ち」**★(ユーザー判断 2026-10-07。D-173): 予定の開始 ≤ 今 < 予定の終了+`RESULT_WAIT_MINUTES`(120)のあいだは失敗に数えない(`lib/clean-logs/result-pending.ts` の1本。API は `CleanLogItem.result_pending`)。過ぎたら出発失敗(未実施)。★上の「猶予30分」(`LAUNCH_GRACE_MINUTES`)はコードで使っていない★(D-091)
+
+★**事前チェックの結果も有無も、成功・失敗の判定に使わない**★(ユーザー指示 2026-10-07「事前チェックを行っていない清掃や事前チェックが失敗した清掃でも本清掃が成功していれば成功にしてください」。D-170 / D-048)。判定は本清掃のレポート行の有無と完了率だけ。`lib/dashboard/__tests__/aggregate.test.ts` が見張っている
+
+★**「タスクレポートが中断で返る」は、いまは判定に使っていない**★(ユーザー判断 2026-10-02。D-155 項2)。`clean_task_reports.status`(2 清掃中断 / 3 清掃キャンセル / 5 清掃異常終了)は★表示名として出すだけ★で、◎○△× は完了率で決め、未実施(出発失敗)は★レポート行の有無★で決める(§4.7)。★実データが入ってから見直す★
+
+### 4.5 エスカレーション(Phase A: R1〜R4)
+
+| レベル | 条件 | 想定対応 |
+|---|---|---|
+| L1 | 初回の問題 | 現場へ連絡(障害物確認・ブラシ清掃・再起動等) |
+| L2 | 2日連続 | 現場責任者へのヒアリング・運用手順の再教育・消耗品交換 |
+| L3 | 3日以上連続 | DFAオンサイト訪問・マップ再作成・機体交換検討 |
+
+| # | 条件 | アドバイス |
+|---|---|---|
+| R1 | 出発失敗(初回) | 事前チェックのアラート内容を確認 |
+| R2 | 完了率×(10%未満) | 現場の通路状況+ロボット状態を確認 |
+| R3 | 同一施設で2日以上連続(出発失敗 or △以下) | 現場責任者に状況をヒアリング |
+| R4 | 同一施設で3日以上連続(出発失敗 or △以下) | DFAオンサイト訪問を検討 |
+
+**「連続」はカレンダー日ではなく「スケジュール存在日の連続」**で数えます。清掃予定がない日は連続を途切れさせません。
+
+★**予定外のタスクも、その施設のその日(`started_at`)の結果に入れる**★(指示書BG。2026-10-29)— 連続日数・L1〜L3・要レスキュー・連続問題施設のすべて。★予定外だけの日も「スケジュール存在日」★(完了率 70 未満なら問題日、以上なら連続が切れる)。★予定外は未実施にならない★ので、要レスキューの「未実施の連続」は予定外だけの日で切れる。清掃詳細ログの予定外の行の連続日数は施設×日付で引く(`consecutiveProblemDaysByFacilityDate()`)
+
+### 4.6 契約フラグ
+
+- **`facilities.contract` のみが正**。★**`varchar(10)` の文字列**★であり、★**数値ではない**★(D-080)
+ - ★**桁位置がサービスに対応する**★。★1桁目 = D-Security / 2桁目 = D-Watch★。
+ ★契約済み(有効)なサービスの桁を `1` にする★
+ - 値の例: `0000000000` = 未設定(既定値) / `1000000000` = D-Securityのみ /
+ `0100000000` = D-Watchのみ / ★`1100000000` = 両方★
+ - 3桁目以降は将来のサービス用の予備と読める(★定義書に明記なし。P1では読まない★)
+ - ❌ ★**数値として比較しないこと**★。`Number("0000000000")` は**例外を出さずに** `0` になり、
+ `contract === 3` のような比較は**絶対に真にならない**。結果として
+ ★**全施設が「契約なし」になりメニューが全部消えるが、例外もテスト失敗も出ない**★
+ - `boolean`(`monitoring_enabled` / `d_security_enabled`)への変換は
+ ★`lib/format/` か `lib/config/` の純粋関数1箇所に閉じ込め、桁位置をハードコードしない★(D-080)
+- `customers.contract` は**削除依頼済み**。参照してはいけません
+- ★★**`contract` という列は2つあります**★★(2026-09-17 実測。`prisma/schema.prisma:141` と `:571`。行番号は 2026-10-05 時点)
+ - ★`facilities.contract`★(`:141`)= ★**これが正**★
+ - ★`clean_schedules.contract`★(`:571`)= ★**読まないこと**★。★型も既定値も `facilities` と同一★
+ (`varchar(10)` / `"0000000000"`)なので、★**取り違えても型エラーにならない**★。
+ ★現在のアプリは読んでいない(正しい状態)★
+ - ★用途は未確認★。TeamWorks の定義書にも説明が無い
+- メニューの表示制御もこのフラグに基づく(契約のないセクションはメニューごと非表示)
+ - ★**未実装**★(2026-10-06 確認。`components/layout/AppShell.tsx` は全メニューを無条件に描く)。★**P2 で作る。顧客に使わせる前に必ず入れる**★(ユーザー判断 2026-10-06。D-160 追記4)
+
+### 4.7 集計期間とバッチ
+
+| 種別 | 実行タイミング | 対象 |
+|---|---|---|
+| 週次 | 毎週日曜 6:00 | 前週(**日曜〜土曜**) |
+| 月次 | 毎月1日 6:00 | 前月 |
+| 年次 | 毎年1月1日 6:00 | 前年 |
+
+- バッチは **TeamWorks が実装**。DFAはサマリテーブルを**読むだけ**
+- ★**週次バッチの「前週」は、月をまたぐと2行になる**(D-071 の帰結。**第5回 X-3 は TeamWorks 承認済み。D-074 項3。2026-09-08**)★
+ 例: 2026-09-06(日) 6:00 のバッチは `2026/8/6週`(08-30〜08-31) と `2026/9/1週`(09-01〜09-05) の**2行**
+- ★**サマリの粒度は `month` / `week` が 0 かどうかで判別する**(`week=0` が月次、`month=0 AND week=0` が年次。
+ **第5回 X-2 は TeamWorks 承認済み。D-074 項3。2026-09-08**)。`week=0` は予約値なので、API の `week` は 1 以上のみ受け付ける★
+- **進行中の期間(当週・当月・当年)はサマリテーブルに存在しない。** 詳細テーブルからその場集計すること
+- ★★**データの取得元**(ユーザー指示 2026-09-30。指示書AQ)★★
+
+ | 何を | どこから |
+ |---|---|
+ | ★確定期間(週・月・★年★)のKPI★ | ★`customer_summaries` / `facility_summaries`(率4列)/ `facility_error_summaries`★ |
+ | ★直近・昨日・当期の清掃実績★ | ★`clean_task_reports`★ |
+ | 清掃の予定 | ★`clean_timetables` と `clean_timetable_histories` の両方★(`precheck = 0` が本清掃)。★同じ id は `clean_timetables` 側を採る★(履歴の id は本表の id のコピー。D-158 項4・指示書AU 段階3b。読み口は `lib/db/timetable-source.ts` の1か所) |
+ | ★エラー(制御盤のエラーも含めて)★ | ★`robot_error_reports` に集約★(★制御盤・セキュリティ・清掃開始失敗のエラーも入る(第11回 P-8〜P-10。D-158)★。★先方の DDL で `robot_id` は NULL 可になったが、Prisma は未 db pull(NOT NULL のまま)★。読み取りは `lib/db/robot-error-select.ts` で `robot_id` / `robot` を読まない(指示書AU 段階0)。★D-Sec アラートは `robot_error_reports` のうち `error_type` が `D-S_CONTROL_FAILURE` の行だけ(完全一致。`SECURITY_ON`・`NOT_STARTED` は出さない)を読み、対応状況・備考はエラー一覧と同じ `PATCH /errors/{error_id}/status` で書く(D-180)★。★`control_error_histories` は読まない★。★削除済み(`deleted_at` が NULL でない)の施設・清掃エリアの行は、エラー一覧(`meta.total` も)にも D-Sec アラート(`summary`・ダッシュボードも)にも出さず、その PATCH は404(U8。D-180 項11)★。★施設名・分類の解決は従来どおり `deleted_at` で絞らない(指示書AX 罠7)★。★区分(ロボット/制御盤/セキュリティ/清掃開始失敗)を画面に作るのは次フェーズ★。★全行(削除済みの施設・清掃エリアの行を除く。U8)の一覧と4カラムの更新はエラー一覧(`/errors`。D-160・指示書AW)★) |
+
+ - ★**`clean_status_histories` は読まない**★(`clean_task_reports` に一本化。テーブル自体は TeamWorks の管理下なので残る)
+ - ★**年次は解禁済み**★。★D-072 項4 の「P1では年次を読まない」は破棄★。
+ ★`periodOfSummaryRow()`(`lib/db/report-mapping.ts`)は `month` も見ること★ — `week===0` だけで判定すると
+ ★年次行が月次に化けて混入し、重複検知で500になる★
+ - ★**未実施 = 本清掃に対応する `clean_task_reports` の行が無いこと**★。`clean_task_reports` には
+ `clean_error` 相当の列が無い。★「出発失敗」と「未実施」は同じ扱い★(ユーザー確認 2026-09-30)
+ - ★**`clean_task_reports.status` は表示名に変換して表示する**★(第10回 N-2 で TeamWorks が定義。D-155): 0 清掃開始前 / 1 清掃中 / 2 清掃中断 / 3 清掃キャンセル / 4 清掃正常終了 / 5 清掃異常終了 / ★6 清掃キャンセル(3 と同じ名称)★(N-2 の表に無い値。ユーザー指示 2026-10-30「6はキャンセルだそうです」「6の名称も3と同じ清掃キャンセルにしてください」)。★表に無い値(7 以上など)は「不明(7)」と数字を残して出す★(ユーザー指示 2026-10-30。「その他」に潰さない。§4.8 と同じ考え方)。★判定には使わない★(中断・異常終了でも完了率で ◎○△× を出す。ユーザー判断 2026-10-02。§4.4 の「中断で返る=失敗」は実データを見て見直す)
+ - ★**`clean_task_reports.clean_timetable_id` は nullable**★。CSV実測では189行すべて NULL。
+ ★NULL は「予定外のタスク」(下の項目。第10回 N-1 で TeamWorks も同意)★で、★欠損ではない★。★2026-10-29 から今の期間の集計・清掃詳細ログ・ダッシュボード・清掃エリアに1件として入れる(§4.2・指示書BG)★(旧: 完了率・実施回数には入れない。D-155 項3)。
+ ★旧: 「NULL は TeamWorks が今後埋める前提」(2026-09-30)は、10-01 のユーザー回答で取り下げ★
+ - ★**ステージングの `clean_task_reports` は 2026-10-01 に2行入った**★(それまでは0行。2026-09-30 実測で `update_time` が NULL=作成以来一度も書かれていなかった)。
+ - ★★**`clean_timetable_id` が NULL の行は「予定外のタスク」**★★(ユーザー回答 2026-10-01「清掃予定をサーバーで決められるものは
+ 今後 `clean_timetable_id` が発行されるが、予定外のタスクは NULL になる」)。★NULL は欠損ではなく、実在する清掃★
+ - ★★**`clean_timetable_id` が `0` の行も「予定外のタスク」**★★(TeamWorks 回答 2026-10-27「clean_timetable_id が0以外は予定/実行済タスクになりますし, 0は予定外タスクになります」。
+ `clean_task_reports` と `robot_error_reports` の両方)。★NULL も引き続き予定外★。★判定は `lib/db/timetable-link.ts` の `scheduledTimetableId()` 1本★(`=== null` / `{ not: null }` を直書きしない)。
+ ★予定タスクを持つのは `clean_timetables` / `clean_statuses` / `clean_timetable_histories` / `clean_status_histories` の4表★(同回答。アプリが予定として読むのは `clean_timetables` / `clean_timetable_histories` の2表)。
+ ★事前チェックの回(`precheck ≠ 0`)には `clean_task_reports` / `robot_error_reports` の行が入らず、`clean_statuses` / `clean_status_histories` は `clean_status`=2・`clean_status_trans`=0・`clean_error`=0 になる予定★(同回答)。★事前チェックの表示の仕様は変えない★(ユーザー指示 2026-10-27「この仕様から特に変更はない」 — 事前チェックの回に紐づく `robot_error_reports` を失敗として出す)
+ - ★★**`clean_task_reports.started_at` に入る値**★★(ユーザー回答 2026-10-05「A」で確定): ★予定のある清掃は予定の `clean_timetables.start_at` と同じ値★、★予定外のタスクは実際に清掃を始めた時刻★。★過去形の列(`started_at` / `ended_at` 等)は実施後に書かれる★(2026-10-01 ユーザー回答)
+ 例: 9/30 23:50 の予定が 10/1 00:10 に始まっても `started_at` は 9/30 23:50。★帰結: 期間の振り分けは、レポート当期(予定の `start_at`)・総清掃面積(`started_at`)・サマリ(第10回 N-9=A `started_at`)で一致する★。★予定のある清掃の実際の開始時刻は、この列からは分からない★(定刻出発の判定には使えない)。★旧: 「`started_at` は実際に清掃を始めた時刻」(10-01)は、予定外のタスクについてだけ正しい★
+ ★2行ともどちらも `clean_timetable_id` が NULL、status は 4★(2026-10-01 実測・先方投入。§3 の接続先注記も参照)
+ - ★ユーザー提供CSV(189行)は、当時のステージングとは別のDBから出たもの★(IDの体系が違う)。
+ ★ただし 2026-10-01 に入った2行の対象面積(1377.39502 / 898.892509)がCSVの値と完全に一致する★ので、
+ ★同じ収集処理のデータと考えられる★。★出所は第10回で確認する★
+- サマリには丸め前の小数と**分子・分母**が格納される(検算可能にするため)
+ - ★★**実DDLと食い違っています**★★(2026-09-30 実測・構造由来)。
+ ★`task_count`(予定回数)/ `launch_count` / `error_count` はあるが、★**実施回数の列が無い**★★
+ (`prisma/schema.prisma:694-696` / `:728-730`。2026-10-05 時点)。
+ ★率は4つとも実在する★(`operation_ratio` / `launch_ratio` / `clean_ratio` / `error_ratio`)
+ - ★帰結★: ★**稼働率は検算できず、複数顧客の合算もできない**★(★率は加重平均できないため★)。
+ ★第9回 Q6-2 で実施回数の列の追加を依頼中★
+- ★**月跨ぎ週は「月境界で分割する」**★(**D-071 で確定。2026-09-07。TeamWorks のサマリ集計に合わせた**)
+ - 週は日曜〜土曜。ただし**月をまたぐ週は1日で切り、それぞれの月に別々の週として数える**
+ - ★**週の長さは1〜7日で変わる。月の週番号は1〜6**★(第6週は年2〜3回発生する)
+ - 各月の第1週は**必ず1日から**始まり、最終週は**必ず末日で**終わる
+ - 例(2026年): 8月第1週 = **08-01 のみ(1日)** / 8月第6週 = **08-30〜08-31(2日)** /
+ 9月第1週 = **09-01〜09-05(5日)** / 9月第2週 = 09-06〜09-12(7日)
+ - 帰属ルールを知っているのは `lib/format/period.ts` の `weekOfMonth()` **だけ**にする
+ - ★**D-067 の旧DFA案(1日を含む週がその月の第1週)は破棄。同じ週を2つの月に分けない
+ 方式だったため、TeamWorks の週次サマリと帰属がズレる**★
+
+### 4.8 エラー種別は変換しない(D-077)
+
+- ★**`robot_error_reports.error_type` も `robots.status` も、DBから来た値をそのまま表示する**★
+- ★**7分類(ハンドルアップ / LostLocation / オフライン / ブラシエラー / 緊急停止 / 充電不足 / その他)
+ への変換は行わない**★
+- 理由: 現状 Pudu API から何が返るか不明なため。★**推測でマッピングしない**★
+ (§7 の「仕様が不明な箇所を推測で実装すること」の禁止に合致)
+- ★**D-057 / D-067 の「サーバ側で7分類に寄せる/未知の値は `other` に寄せる」は破棄**★
+- ★**`robots.status = -200`(LostLoaction)→「LostLocation」の変換もしない**★
+ (D-070 でそう確定していたが取り下げ。**「-200 が LostLocation である」という事実認識は取り下げない。
+ 取り下げるのは画面に出す前に変換する実装方針だけ**)
+- ★**`docs/handoff/analysis/error-type-mapping.md` は参考資料。実装の下敷きではない**★
+- ★**帰結: 画面には英語識別子(例 `EmergencyStop`)がそのまま出る。それが意図した挙動**★
+- ★**注意: `lib/theme-status.ts` の色分けは日本語6文字列の完全一致なので、生値では全部グレーに
+ フォールバックする**★
+
+---
+
+## 5. 参照資料 — 何が「正」か
+
+| 資料 | 何の正か |
+|---|---|
+| `d-watch-api.yaml` | **API契約の正**。エンドポイント・JSONスキーマ・enum・エラー形式 |
+| `docs/spec.md` | **機能一覧と開発フェーズの正**。何をどの順で作るか |
+| `docs/decisions.md` | **決定事項とその理由の正**。「なぜこの仕様か」を遡る時に読む |
+| `docs/fixtures.md` | **サンプルデータの正**。DB接続前の開発で必ず用意するケース一覧 |
+| モック(`d-watch-mock.jsx`) | **画面レイアウト・文言・遷移の正**。フロント実装はこれを移植する |
+| 清掃ロボット監視テーブル定義書(28テーブル) | **DBスキーマの正**。Prismaスキーマの突き合わせ先 |
+| UI仕様書 v6.0 | 画面ごとの詳細仕様。モックで迷った時の裏付け |
+| 要件定義書 P1 v2.2 | 全体要件・Phase A/B/Cの線引き。★**DFA側の想定であり、TeamWorks の DB との整合は保証されない。型・値域・カラム名は実DDLが正**★(2026-09-08 ユーザー指示。D-074 / D-057)。**所在: Google Drive の「D-Watch / D-Security 要件定義書 (P1)」`1thSYHk-ODCht-K4WqFlCP-Es2OyAz4Wj`**。★**PHP/Laravel と書かれているが実装は Next.js(D-046)。トークン期限の記述も現行方針では禁止。古い箇所がある**★ |
+| 運用ガイド | 誰がいつどう使うか。UXの判断に迷った時に読む |
+
+資料間で矛盾があったら、上の表の「正」に従ってください。それでも解決しない場合は**実装を止めて確認を求めてください**(推測で埋めない)。
+
+---
+
+## 6. 作業ルール
+
+1. **API契約を変えたくなったら、先に `d-watch-api.yaml` を直す。** 実装だけ変えてYAMLと乖離させない
+2. **判定ロジック(◎○△×・L1〜L3・KPI計算・丸め)には必ずテストを書く。** 期待値表を先に作り、それをテストで固定する。目視確認だけで済ませない
+3. **開発は `lib/fixtures/` のサンプルデータで行う。** 実DBの完成を待たない。
+ - APIルートは `USE_FIXTURES` で fixtures / Prisma を切り替える。画面側のコードは切替を意識しない
+ - **判定ロジックは切替の外側**に置く(fixture開発中も本番同等のロジックが走る)
+ - 用意すべきケースは `docs/fixtures.md` を参照(境界値・異常系を先に仕込む)
+4. **TeamWorks待ちの機能はスタブ/モックで先行し、解消後に差し替える。** ブロックされない
+5. **PRは必ずもう1人がレビューしてからmainにマージする**(★レビュー担当は daichi。D-154★)
+6. **「実測」と書くときは、その数字の出所を必ず併記する。**
+ 「当方のシード由来(`scripts/seed-local-db.ts` / `lib/fixtures/`)」/「先方投入」/
+ 「構造(`information_schema` / `SHOW` / `EXPLAIN`)」のどれかを書く。**出所を書けない数字は
+ 「実測」と呼ばない。**
+ - 理由: テストDBの行が当方のダミーだと気づかないまま、それを根拠に外部(TeamWorks)へ
+ 照会する文書を作りかけた(2026-09-17。送付前に気づいた。
+ `docs/handoff/テストDBのデータ出所_20260917.md`)
+ - この事故は `docs/decisions.md` D-111 項7(`:6683-6689`。2026-09-11)が5日前に予告していた
+ — 「**テストDBでは判定できない: 本番のデータ量 / サマリバッチの実挙動 / 本番の `time_zone` 設定**」。
+ 表はあったのに守られなかった
+
+---
+
+## 7. 禁止事項
+
+- ❌ **メインDBへのマイグレーション作成・スキーマ変更**(TeamWorksの領域。`prisma db pull` で取り込むだけ。
+ `db pull` 実施前の `prisma/schema.prisma` 手書きは対象外、D-056参照)
+- ❌ **§3 で指定した4カラム(`robot_error_reports` は `error_list_id` を含む5カラム。D-184)以外へのDB書き込み**
+- ❌ **表示用に丸めた値で判定すること**(4.3参照)
+- ❌ **`customers.contract` の参照**(削除依頼済み。`facilities.contract` を使う)
+- ❌ **閾値(90/70/10、猶予30分、L2=2日/L3=3日)のハードコード**(設定値として外出し)
+- ❌ **点検記録データをメインDBに保存すること**(DFA独自ストアに分離)
+- ❌ **トークン期限/残日数の実装**(仕様から除外済み。古い資料に記述が残っているが実装しない)
+- ❌ **モックに残っている実在の住所・電話番号をそのまま使うこと**(ダミー値に置換する)
+- ❌ **仕様が不明な箇所を推測で実装すること**(確認を求める)
+- ❌ **エラー種別を推測で7分類に変換すること**(D-077。DBの値をそのまま表示する。§4.8参照)
+- ❌ **`git add -A` の使用**(過去に別作業の途中ファイルを巻き込む事故が起きている。
+ ★**必ずファイルパスを明示する**★)
+
+---
+
+## 8. 用語
+
+| 用語 | 意味 |
+|---|---|
+| 清掃エリア(sections) | 施設内の清掃対象区域。旧称「区画」。**「清掃エリア」に統一** |
+| 施設(facilities) | ロボットが配置される店舗・拠点。旧称「店舗」 |
+| D-Sデバイス / D-S制御盤 | セキュリティ会社システムと制御サーバ間を中継する物理デバイス |
+| 事前チェック | 清掃開始の60分前/30分前にロボット状態を確認する仕組み |
+| Pudu Cloud | PUDU Robotics のクラウドAPI。ロボット状態・タスク結果の取得元 |
+
+**P1の前提: 1清掃エリア = 1ロボット。** テーブル構造は複数台対応可能ですが、P1ではこの前提で実装します。
+
+## 9. 実装規約
+
+### 9.1 ディレクトリ構成
+
+```
+app/
+ (dashboard)/ 画面。モックの画面IDに合わせる
+ dashboard/ detail-log/ alerts/ ds-logs/ ds-alerts/
+ facilities/ inspections/ reports/ schedules/ system/
+ api/ APIルート
+ clean-logs/
+ route.ts
+ export/route.ts
+ [id]/advice/route.ts
+ [id]/areas/route.ts
+ settings/
+ advice/route.ts
+components/
+ ui/ Pill, FilterButton, GradeBadge 等の共通部品
+ features/ 画面固有のコンポーネント(例: detail-log/)
+ layout/ AppShell 等
+ theme/ ThemeProvider
+lib/
+ judge/ ★判定ロジック(◎○△×、L1〜L3、KPI計算、丸め)+ __tests__/
+ fixtures/ サンプルデータ(DB接続前の開発用。docs/fixtures.md 参照)
+ db/ USE_FIXTURES で fixtures / Prisma を切り替える
+ config/ 閾値等の設定値
+ clean-logs/ 清掃ログ関連のクエリ
+ settings/ 運用設定(アドバイス等)
+ types/ 型定義。YAMLのスキーマ名と揃える
+ format/ 表示整形(日時等)
+docs/ spec.md, decisions.md, fixtures.md 等
+prisma/schema.prisma db pull実施後は生成物を正とし手で書き換えない(D-056)。実施前は定義書ベースで手書き
+d-watch-api.yaml API契約
+d-watch-mock.jsx 画面レイアウト・文言・遷移の正
+```
+
+### 9.2 命名
+
+- ファイル/ディレクトリ: kebab-case(`detail-log/`)
+- コンポーネント: PascalCase(`CleanLogTable.tsx`)
+- 関数・変数: camelCase
+- DBカラム: snake_case(テーブル定義書の物理名をそのまま使う。勝手にリネームしない)
+- API のクエリパラメータ・レスポンスキー: snake_case(YAML準拠)
+- 型: `lib/types/` に集約し、YAMLのスキーマ名と揃える(`CleanLogItem`, `PrecheckItem` 等)
+
+### 9.3 コーディング規約
+
+- `any` 禁止。型が不明ならYAMLのスキーマから起こす
+- APIレスポンスは必ず型を定義してから実装する
+- 日時は ISO 8601(JST, +09:00)。表示整形はコンポーネント側
+- 数値の丸めは表示層でのみ行う。ロジック層には生値を渡す
+- エラーレスポンスは `{ error: { code, message, details? } }` で統一
+- 一覧APIは必ず `meta: { total, page, per_page }` を返す
+- 環境変数は `.env.local`。認証情報を絶対にコミットしない
+
+### 9.4 判定ロジック(`lib/judge/`)
+
+- **テストを先に書く。** 入力→期待値の表を作り、境界値を必ず含める
+ - 完了率: 90.0 / 90.1 / 70.0 / 70.1 / 10.0 / 9.9 / 0 / 未実施(null)
+ - 連続日数: 1日 / 2日 / 3日 / スケジュール非存在日を挟むケース
+ - 丸め: 90.4 → 表示90だが判定は◎、89.5 → 表示90だが判定は○
+- 閾値は `lib/config/` から読む。ハードコード禁止
+- 判定関数は純粋関数にする(DBアクセスを混ぜない)
+
+### 9.5 サンプルデータ(`lib/fixtures/`)
+
+- APIルートは `USE_FIXTURES` で fixtures / Prisma を切り替える。**画面側は切替を意識しない**
+- 判定ロジックは切替の外側に置く(fixtureのデータにも本番同等の判定を通す)
+- fixtures は DB接続後も削除しない(テスト・デモ用)
+
+### 9.6 フロント
+
+- モック(`d-watch-mock.jsx`)の画面レイアウト・文言・遷移を正とする
+- 判定結果(◎○△×、L1〜L3、ソート順)はAPIから受け取る。フロントで再計算しない
+- ダーク/ライト、JP/EN切替の器を壊さない
+
+---
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fe5b1c4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# D-Watch
+
+清掃ロボット稼働モニタリングビューアー。DFA Robotics 内製開発。
+
+- **はじめに読む**: [`docs/getting-started.md`](docs/getting-started.md) — 開発者向けセットアップ手順
+- **コードの地図**: [`docs/code-map.md`](docs/code-map.md) — 非エンジニア向け。どのファイル・関数・数値が何を司っているか
+- **構成と依存の向き**: [`docs/architecture.md`](docs/architecture.md) — エンジニア向け。触ってはいけないものの正
+- **仕様**: [`docs/spec.md`](docs/spec.md) — 機能一覧と開発フェーズ
+- **サンプルデータ**: [`docs/fixtures.md`](docs/fixtures.md) — DB接続前の開発用データ仕様
+- **開発コンテキスト**: [`CLAUDE.md`](CLAUDE.md) — Cursor / Claude Code は必ずこれを読む
+- **決定事項**: [`docs/decisions.md`](docs/decisions.md) — なぜこの仕様なのか
+- **API契約**: [`d-watch-api.yaml`](d-watch-api.yaml) — OpenAPI 3.0
+
+---
+
+## セットアップ
+
+```bash
+npm install
+cp .env.example .env.local # 接続情報を記入
+npm run dev # http://localhost:3000
+```
+
+### サンプルデータで開発する(DB接続前)
+
+実DBが用意される前は、`lib/fixtures/` のサンプルデータで開発します。
+
+```bash
+# .env.local
+USE_FIXTURES=true
+```
+
+APIルートがサンプルデータを返すため、DBなしで全画面を開発できます。
+DB接続時は `USE_FIXTURES=false` にするだけで、**画面側のコードは変更不要**です。
+
+用意すべきデータのケース(判定の境界値、L1〜L3、契約フラグ4パターン、空配列など)は
+[`docs/fixtures.md`](docs/fixtures.md) を参照してください。
+
+### Prismaスキーマの取り込み
+
+DBはTeamWorksが構築します。**マイグレーションは作りません。**
+
+```bash
+npx prisma db pull # 実スキーマを schema.prisma に取り込む
+npx prisma generate
+```
+
+取り込んだ内容とテーブル定義書(28テーブル)に食い違いがあれば、実装を止めて確認してください。
+
+**この突き合わせだけはM1で早めに実施します**(カラム名の食い違いが後から見つかると全画面に波及するため)。
+画面開発は引き続き fixture のまま進めます。
+
+### テスト
+
+```bash
+npm test # 判定ロジックの期待値表テストを含む
+```
+
+判定ロジック(◎○△×、L1〜L3、KPI計算、丸め)は**必ずテストで固定**します。
+[`CLAUDE.md`](CLAUDE.md) の 4.1〜4.5 が仕様の正です。
+
+---
+
+## 開発フロー
+
+- ブランチ: `main` + featureブランチ(GitHub Flow)
+- **`main` への直接pushは禁止**(ブランチ保護。必ずPR経由)
+- PRの承認は必須にしていないが、`lib/judge/`(判定ロジック)を触るPRは相互確認する
+- PRを作るとCI(Lint / テスト / ビルド)が自動で走る。**赤いまま放置しない**
+- API契約を変える時は `d-watch-api.yaml` を先に修正する
+
+### 担当分担
+
+| 担当 | 範囲 |
+|---|---|
+| **Daigo**(環境) | リポジトリ/CI、Prisma接続、点検記録の独自ストア、デプロイ、PRレビュー |
+| **たけうち りき**(開発) | 画面実装(モック移植 + API接続)、APIルート実装 |
+
+判定ロジック(`lib/judge/`)は両名で確認します。
+
+### Cursor 利用時の注意
+
+- **Privacy Mode を必ずONにする**(会社のコードを扱うため)
+- アカウントは会社ドメインのメールアドレスで作成する
+
+---
+
+## 責任分界
+
+| 領域 | 担当 |
+|---|---|
+| MySQL DB本体 / データ収集 / サマリ集計バッチ | TeamWorks(外部) |
+| APIサーバー / ビューアー / 点検記録 | DFA(本リポジトリ) |
+
+DBは原則**読み取り専用**です。書き込みは `robot_error_reports` / `control_error_histories` の
+`status` / `comment` / `responded_at` / `resolved_at` の4カラムのみ。
+
+---
+
+## 現在の状況
+
+**M0(環境準備)まで完了。** 次は M1(判定ロジック `lib/judge/` + 期待値表テスト)と M2(画面実装)。
+
+| ディレクトリ | 内容 |
+|---|---|
+| `lib/types/` | `d-watch-api.yaml` から起こした型定義 |
+| `lib/config/` | 閾値(90/70/10、猶予30分、L2=2日/L3=3日)。**ハードコード禁止** |
+| `lib/fixtures/` | サンプルデータ。境界値・異常系を仕込んである |
+| `lib/db/` | `USE_FIXTURES` による fixtures / Prisma の切替 |
+| `app/api/clean-logs/` | **動く見本のAPIルート。** 他の一覧APIはこの形を真似する |
+
+TeamWorks待ちの項目は [`docs/spec.md` §4](docs/spec.md) を参照。
+待ちの間はモック/スタブで先行し、解消後に差し替えます。
diff --git "a/app/\050dashboard\051/alerts/page.tsx" "b/app/\050dashboard\051/alerts/page.tsx"
new file mode 100644
index 0000000..96b1777
--- /dev/null
+++ "b/app/\050dashboard\051/alerts/page.tsx"
@@ -0,0 +1,5 @@
+import { AlertsScreen } from "@/components/features/alerts/AlertsScreen";
+
+export default function AlertsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/clean-area/page.tsx" "b/app/\050dashboard\051/clean-area/page.tsx"
new file mode 100644
index 0000000..a235bd8
--- /dev/null
+++ "b/app/\050dashboard\051/clean-area/page.tsx"
@@ -0,0 +1,5 @@
+import { CleanAreaScreen } from "@/components/features/clean-area/CleanAreaScreen";
+
+export default function CleanAreaPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/dashboard/page.tsx" "b/app/\050dashboard\051/dashboard/page.tsx"
new file mode 100644
index 0000000..103aeb3
--- /dev/null
+++ "b/app/\050dashboard\051/dashboard/page.tsx"
@@ -0,0 +1,5 @@
+import { DashboardScreen } from "@/components/features/dashboard/DashboardScreen";
+
+export default function DashboardPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/detail-log/page.tsx" "b/app/\050dashboard\051/detail-log/page.tsx"
new file mode 100644
index 0000000..65492c1
--- /dev/null
+++ "b/app/\050dashboard\051/detail-log/page.tsx"
@@ -0,0 +1,5 @@
+import { DetailLogScreen } from "@/components/features/detail-log/DetailLogScreen";
+
+export default function DetailLogPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/ds-alerts/page.tsx" "b/app/\050dashboard\051/ds-alerts/page.tsx"
new file mode 100644
index 0000000..4c54c16
--- /dev/null
+++ "b/app/\050dashboard\051/ds-alerts/page.tsx"
@@ -0,0 +1,5 @@
+import { DsAlertsScreen } from "@/components/features/ds-alerts/DsAlertsScreen";
+
+export default function DsAlertsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/ds-logs/page.tsx" "b/app/\050dashboard\051/ds-logs/page.tsx"
new file mode 100644
index 0000000..a98b749
--- /dev/null
+++ "b/app/\050dashboard\051/ds-logs/page.tsx"
@@ -0,0 +1,5 @@
+import { DsLogScreen } from "@/components/features/ds-logs/DsLogScreen";
+
+export default function DsLogsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/errors/page.tsx" "b/app/\050dashboard\051/errors/page.tsx"
new file mode 100644
index 0000000..a26a712
--- /dev/null
+++ "b/app/\050dashboard\051/errors/page.tsx"
@@ -0,0 +1,5 @@
+import { ErrorsScreen } from "@/components/features/errors/ErrorsScreen";
+
+export default function ErrorsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/facilities/page.tsx" "b/app/\050dashboard\051/facilities/page.tsx"
new file mode 100644
index 0000000..262510c
--- /dev/null
+++ "b/app/\050dashboard\051/facilities/page.tsx"
@@ -0,0 +1,5 @@
+import { FacilitiesScreen } from "@/components/features/facilities/FacilitiesScreen";
+
+export default function FacilitiesPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/inspections/page.tsx" "b/app/\050dashboard\051/inspections/page.tsx"
new file mode 100644
index 0000000..4319885
--- /dev/null
+++ "b/app/\050dashboard\051/inspections/page.tsx"
@@ -0,0 +1,5 @@
+import { PlaceholderScreen } from "@/components/layout/PlaceholderScreen";
+
+export default function InspectionsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/layout.tsx" "b/app/\050dashboard\051/layout.tsx"
new file mode 100644
index 0000000..69d8271
--- /dev/null
+++ "b/app/\050dashboard\051/layout.tsx"
@@ -0,0 +1,9 @@
+import { AppShell } from "@/components/layout/AppShell";
+
+export default function DashboardGroupLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
diff --git "a/app/\050dashboard\051/reports/page.tsx" "b/app/\050dashboard\051/reports/page.tsx"
new file mode 100644
index 0000000..d095a0a
--- /dev/null
+++ "b/app/\050dashboard\051/reports/page.tsx"
@@ -0,0 +1,5 @@
+import { ReportsScreen } from "@/components/features/reports/ReportsScreen";
+
+export default function ReportsPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/schedules/page.tsx" "b/app/\050dashboard\051/schedules/page.tsx"
new file mode 100644
index 0000000..66a173b
--- /dev/null
+++ "b/app/\050dashboard\051/schedules/page.tsx"
@@ -0,0 +1,5 @@
+import { SchedulesScreen } from "@/components/features/schedules/SchedulesScreen";
+
+export default function SchedulesPage() {
+ return ;
+}
diff --git "a/app/\050dashboard\051/system/page.tsx" "b/app/\050dashboard\051/system/page.tsx"
new file mode 100644
index 0000000..c5299a8
--- /dev/null
+++ "b/app/\050dashboard\051/system/page.tsx"
@@ -0,0 +1,5 @@
+import { SystemScreen } from "@/components/features/system/SystemScreen";
+
+export default function SystemPage() {
+ return ;
+}
diff --git a/app/api/__tests__/master-filter-dashboard-reports.test.ts b/app/api/__tests__/master-filter-dashboard-reports.test.ts
new file mode 100644
index 0000000..1cbc840
--- /dev/null
+++ b/app/api/__tests__/master-filter-dashboard-reports.test.ts
@@ -0,0 +1,416 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★ルート本体は**静的 import** すること★(`app/api/__tests__/master-filter.test.ts` 冒頭と同じ理由。
+// `it()` の中で `await import()` すると、最初の1本がモジュール読み込みを抱え込み、
+// フルスイート実行時に既定のタイムアウトを踏んで不定期に落ちる)
+import { GET as GET_DASHBOARD } from "@/app/api/dashboard/route";
+import { GET as GET_CONTINUOUS } from "@/app/api/reports/continuous-problems/route";
+import { GET as GET_BREAKDOWN } from "@/app/api/reports/error-breakdown/route";
+import { GET as GET_PERFORMANCE } from "@/app/api/reports/facility-performance/route";
+import { DASHBOARD_LATEST_RUN } from "@/lib/config";
+import { db } from "@/lib/db";
+import { customers, facilities } from "@/lib/fixtures";
+import { cleanLogs, unscheduledCleanLogs } from "@/lib/fixtures/clean-logs";
+import { todayJst, toJstIso } from "@/lib/format/date";
+import type {
+ ContinuousProblem,
+ DashboardResponse,
+ DsAlert,
+ ErrorBreakdownItem,
+ FacilityPerformance,
+ ListResponse,
+} from "@/lib/types";
+
+/**
+ * 指示書AK ★段階4b★ — 分類(`group_id` / `customer_id`)の絞り込みが
+ * ★`/dashboard` と `/reports/*` の3本★ でも効いていること の期待値表。
+ *
+ * ★段階4a の `app/api/__tests__/master-filter.test.ts`(一覧系7本)の続き★で、
+ * ★作法はあちらに合わせてある★(fixtures で走らせる/期待値は fixtures の割り当て表から作る)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★このファイルが守るもの★★
+ * ------------------------------------------------------------------------
+ * 1. ★ルートが解決結果を渡し忘れていないこと★ — 単体テストは
+ * ★渡し忘れていても緑のまま★(★罠1 / 落とし穴1★。一覧が全件のまま返るのは
+ * 例外もテスト失敗も出ない壊れ方)なので ★ルート → データソース → レスポンス★ まで通す
+ * 2. ★★`/dashboard` の数値カード(`summary`)と要レスキュー(`rescue_required`)で
+ * 母集団がずれないこと★★(指示書AK §7-2)—
+ * ★★「4つの屋号ぶんを足すと絞らないときと一致する」(分割の検査)★★ で固定する。
+ * ★片方にだけ `facility_ids` を渡し忘れると、この足し算が合わなくなる★
+ * 3. ★同時指定(AND)の積が空なら0件★(★落とし穴2★。全件表示に化けないこと)
+ */
+
+// ---------------------------------------------------------------- 期待値(fixtures 由来)
+
+/**
+ * 分類の条件に当たる施設IDの集合。
+ *
+ * ★`resolveFacilityIdsByMasters()` の実装を写したものではない★ —
+ * ★fixtures の割り当てをそのまま読む★(段階4a のテストと同じ関数・同じ理由)。
+ * ★所属は `Facility` 自身が持っている★(指示書AK ★段階5★。
+ * 段階4b 当時の `customerIdByFacilityId` は削除済み)。
+ */
+function allowedFacilityIds(
+ groupId: number | null,
+ customerId: number | null
+): Set {
+ const ids = new Set();
+ for (const facility of facilities) {
+ if (groupId !== null && facility.group_id !== groupId) continue;
+ if (customerId !== null && facility.customer_id !== customerId) continue;
+ ids.add(facility.id);
+ }
+ return ids;
+}
+
+/** ★fixtures の屋号4件は施設12件を**過不足なく**分ける★(下の「分割の検査」の前提) */
+const ALL_CUSTOMER_IDS = customers.map((c) => c.id);
+
+const byNumber = (a: number, b: number) => a - b;
+
+async function jsonOf(res: Response): Promise {
+ expect(res.status).toBe(200);
+ return (await res.json()) as T;
+}
+
+// ---------------------------------------------------------------- GET /dashboard
+
+async function dashboard(search: string): Promise {
+ return jsonOf(
+ await GET_DASHBOARD(new Request(`http://localhost/api/dashboard?${search}`))
+ );
+}
+
+/**
+ * ★`/dashboard`(`date` 省略=直近モード)の `summary` の母集団★(D-159 / 指示書AV 段階4-5)。
+ *
+ * ★`pickLatestRunPerSection()` を呼ばずに素直に書く★(実装を写すと、実装の誤りまで写る):
+ * fixtures の `cleanLogs` のうち、施設が `allowed` に入り、
+ * ★開始 ≥ 現在 − 遡り日数★ かつ ★開始 ≤ 現在★ の行の ★`serial_number` の種類数★
+ * (★D-173 で落ち着き時間(30分)をやめた★。候補は「予定の終了 ≤ 現在」。表2 C6)。
+ * ★fixtures は「終了=開始」「清掃エリア=ロボット」とみなす★(指示書AV §2 D10)。
+ * 1ロボットから1行(=その施設ID)を返すので、`length` が `summary` の合計になる。
+ * ★日数は `DASHBOARD_LATEST_RUN` から読む★(直書きしない。`CLAUDE.md` §7)。
+ * ★`new Date()` は下の `describe` で止めた時計を読む★。
+ */
+function latestRunFacilityIds(allowed: Set | null): number[] {
+ const nowMs = new Date().getTime();
+ const earliestMs = nowMs - DASHBOARD_LATEST_RUN.lookbackDays * 86_400_000;
+ const facilityBySerial = new Map();
+ for (const row of cleanLogs) {
+ if (allowed !== null && !allowed.has(row.facility_id)) continue;
+ const startMs = Date.parse(row.scheduled_at);
+ if (startMs < earliestMs || startMs > nowMs) continue;
+ facilityBySerial.set(row.serial_number, row.facility_id);
+ }
+ return [...facilityBySerial.values()];
+}
+
+/**
+ * ★スケジュール進捗の期待値(fixtures 由来。2026-10-29 案B)★ — 直近モードの対象日は ★JST の前日★。
+ * その日に開始予定の行(施設が `allowed` に入るもの)を数え、出発(`departed`)したものを completed にする。
+ * ★結果待ち・未実施は planned にだけ入る★。★`new Date()` は下の `describe` で止めた時計を読む★。
+ */
+function expectedProgress(allowed: Set | null): DashboardResponse["schedule_progress"] {
+ const yesterday = toJstIso(new Date(new Date().getTime() - 86_400_000)).slice(0, 10);
+ // ★予定外タスクの行(`unscheduledCleanLogs`。指示書BG §2-1・§2-2 A案)も予定1回・出発1回として数える★
+ const rows = [...cleanLogs, ...unscheduledCleanLogs].filter(
+ (row) =>
+ row.scheduled_at.slice(0, 10) === yesterday &&
+ (allowed === null || allowed.has(row.facility_id))
+ );
+ const completed = rows.filter((row) => row.departed).length;
+ return {
+ date: yesterday,
+ planned: rows.length,
+ completed,
+ rate: rows.length === 0 ? null : (completed / rows.length) * 100,
+ };
+}
+
+/** `summary` の4つの数の合計=母集団の行数(`computeSummary()` の定義上そうなる。★結果待ちも入る。D-173★) */
+function summaryTotal(body: DashboardResponse): number {
+ return (
+ body.summary.success +
+ body.summary.failed +
+ body.summary.not_executed +
+ body.summary.pending
+ );
+}
+
+describe("GET /dashboard の分類絞り込み", () => {
+ // ★★この `describe` だけ時計を止める★★(指示書AV 段階4-5)—
+ // 実時刻のままだと、深夜0時〜4時に走らせたとき当日の fixtures 行(1:30〜3:30)が
+ // 「終わっていない」扱いになり、直近モードの期待値が時刻で揺れる。
+ // ★`todayJst()` は止める前に読む★(fixtures の日付は実時刻で作られているので、同じ日の 12:00 に止める)。
+ beforeEach(() => {
+ const today = todayJst();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(`${today}T12:00:00+09:00`));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => {
+ const all = await dashboard("");
+ expect(summaryTotal(all)).toBe(latestRunFacilityIds(null).length);
+ expect(summaryTotal(all)).toBeGreaterThan(0);
+ });
+
+ it("`customer_id=1` → 屋号1の施設ぶんだけが数値カードに入る", async () => {
+ const all = await dashboard("");
+ const scoped = await dashboard("customer_id=1");
+ const allowed = allowedFacilityIds(null, 1);
+
+ // ★絞る対象が実在すること★(もともと全部が屋号1なら、何も実装しなくても緑になる)
+ expect(latestRunFacilityIds(null).some((id) => !allowed.has(id))).toBe(true);
+ expect(summaryTotal(scoped)).toBe(latestRunFacilityIds(allowed).length);
+ expect(summaryTotal(scoped)).toBeLessThan(summaryTotal(all));
+ });
+
+ it("`group_id=2` → ホールディングス2の施設ぶんだけになる", async () => {
+ const scoped = await dashboard("group_id=2");
+ const allowed = allowedFacilityIds(2, null);
+ expect(summaryTotal(scoped)).toBe(latestRunFacilityIds(allowed).length);
+ });
+
+ it("★要レスキューは分類の外の施設を含まない★", async () => {
+ const scoped = await dashboard("customer_id=1");
+ const allowed = allowedFacilityIds(null, 1);
+ expect(scoped.rescue_required.length).toBeGreaterThan(0);
+ expect(scoped.rescue_required.every((r) => allowed.has(r.facility_id))).toBe(true);
+ });
+
+ it("★スケジュール進捗・セキュリティバナーも同じ母集団で作られる★", async () => {
+ const all = await dashboard("");
+ const scoped = await dashboard("customer_id=1");
+ const allowed = allowedFacilityIds(null, 1);
+
+ // ★★予定件数(`planned`)は対象日(直近モードは JST の前日)の清掃ログの行から★★(2026-10-29 案B)。
+ // ★期待値は fixtures の `cleanLogs` を素直に数える★(実装を写さない)。★絞ったぶんだけ必ず減る★
+ expect(all.schedule_progress).toEqual(expectedProgress(null));
+ expect(scoped.schedule_progress).toEqual(expectedProgress(allowed));
+ expect(scoped.schedule_progress.planned).toBeLessThan(all.schedule_progress.planned);
+ expect(scoped.schedule_progress.planned).toBeGreaterThan(0);
+
+ // ★fixtures にも DB にも `security_off` は無い(D-180。バナーは実データでは常に空)★。
+ // ★それでもルートの絞り込み(`dsAlertsOfScope`)を守るため、`db.dsAlerts()` を差し替えて
+ // 分類の中と外に1件ずつ置く★(空と空を比べても絞り込みの検査にならない — 指示書AZ G3)
+ const inside = facilities.find((f) => allowed.has(f.id))!;
+ const outside = facilities.find((f) => !allowed.has(f.id))!;
+ const securityOff = (id: string, f: (typeof facilities)[number]): DsAlert => ({
+ id,
+ alert_type: "security_off",
+ device_serial: "DS-TEST",
+ facility_id: f.id,
+ facility_name: f.facility_name,
+ section_name: "1F売り場",
+ detected_at: `${todayJst()}T03:10:00+09:00`,
+ resolution_status: "unresolved",
+ responded_at: null,
+ resolved_at: null,
+ note: null,
+ });
+ vi.spyOn(db, "dsAlerts").mockResolvedValue([securityOff("T-1", inside), securityOff("T-2", outside)]);
+ const allB = await dashboard("");
+ const scopedB = await dashboard("customer_id=1");
+ expect(allB.security_banner.map((b) => b.facility_name).sort()).toEqual(
+ [inside.facility_name, outside.facility_name].sort()
+ );
+ expect(scopedB.security_banner.map((b) => b.facility_name)).toEqual([inside.facility_name]);
+ });
+
+ /**
+ * ★★数値カードと要レスキューの母集団が一致していることの検査★★(指示書AK §7-2)
+ *
+ * ★fixtures の屋号4件は施設12件を**過不足なく**分ける★ので、
+ * ★4回ぶんの結果を足すと「絞らないとき」と一致するはず★です。
+ *
+ * ★★どちらか片方に `facility_ids` を渡し忘れると、この足し算が合いません★★ —
+ * 渡し忘れたほうは毎回「全件」を返すので、★4倍に膨らんで検出される★。
+ * ★件数の比較だけでは、両方に渡し忘れても(両方とも全件で)気づけない★ので、
+ * ★上の「屋号1だけ」のテストと合わせて初めて固定になる★。
+ */
+ it("★★屋号4件ぶんを足すと絞らないときと一致する(summary と rescue_required の両方)★★", async () => {
+ const all = await dashboard("");
+ const scoped = await Promise.all(
+ ALL_CUSTOMER_IDS.map((id) => dashboard(`customer_id=${id}`))
+ );
+
+ expect(scoped.reduce((n, b) => n + b.summary.success, 0)).toBe(all.summary.success);
+ expect(scoped.reduce((n, b) => n + b.summary.failed, 0)).toBe(all.summary.failed);
+ expect(scoped.reduce((n, b) => n + b.summary.not_executed, 0)).toBe(
+ all.summary.not_executed
+ );
+ // ★結果待ち(D-173)も同じ足し算が合うこと★
+ expect(scoped.reduce((n, b) => n + b.summary.pending, 0)).toBe(all.summary.pending);
+ // ★スケジュール進捗(2026-10-29 案B)も同じ足し算が合うこと★
+ expect(scoped.reduce((n, b) => n + b.schedule_progress.planned, 0)).toBe(all.schedule_progress.planned);
+ expect(scoped.reduce((n, b) => n + b.schedule_progress.completed, 0)).toBe(
+ all.schedule_progress.completed
+ );
+
+ const rescueIds = scoped
+ .flatMap((b) => b.rescue_required.map((r) => r.facility_id))
+ .sort(byNumber);
+ expect(rescueIds).toEqual(all.rescue_required.map((r) => r.facility_id).sort(byNumber));
+ expect(rescueIds.length).toBeGreaterThan(0);
+ });
+
+ /**
+ * ★★積集合が空なら「0件の集計」★★(★落とし穴2★)—
+ * ★ここで全件が返ったら `[]` が `null`(絞らない)に化けている★。
+ */
+ it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => {
+ const body = await dashboard("group_id=1&customer_id=3");
+ expect(summaryTotal(body)).toBe(0);
+ expect(body.summary.success_rate).toBeNull();
+ expect(body.schedule_progress.planned).toBe(0);
+ expect(body.security_banner).toEqual([]);
+ expect(body.rescue_required).toEqual([]);
+ });
+
+ /** ★検証は共通の1本(`parseMasterIdFilters()`)に任せている★ — `0` はセンチネルで422(D-011) */
+ it("★`group_id=0` は 422★(他の10エンドポイントと同じ規則)", async () => {
+ const res = await GET_DASHBOARD(
+ new Request("http://localhost/api/dashboard?group_id=0")
+ );
+ expect(res.status).toBe(422);
+ });
+});
+
+// ---------------------------------------------------------------- GET /reports/*
+
+/** fixtures のレポートは「今週 / 先週 / 今月 / 先月」の4期間を持つ。★今月を使う★ */
+const CURRENT_MONTH = (() => {
+ const today = todayJst();
+ return `period=monthly&year=${Number(today.slice(0, 4))}&month=${Number(
+ today.slice(5, 7)
+ )}&limit=200`;
+})();
+
+async function performance(search: string): Promise {
+ const body = await jsonOf>(
+ await GET_PERFORMANCE(
+ new Request(`http://localhost/api/reports/facility-performance?${CURRENT_MONTH}&${search}`)
+ )
+ );
+ return body.items.map((i) => i.facility_id);
+}
+
+async function continuous(search: string): Promise {
+ const body = await jsonOf>(
+ await GET_CONTINUOUS(
+ new Request(`http://localhost/api/reports/continuous-problems?${CURRENT_MONTH}&${search}`)
+ )
+ );
+ return body.items.map((i) => i.facility_id);
+}
+
+async function breakdown(search: string): Promise {
+ const body = await jsonOf<{ items: ErrorBreakdownItem[] }>(
+ await GET_BREAKDOWN(
+ new Request(`http://localhost/api/reports/error-breakdown?${CURRENT_MONTH}&${search}`)
+ )
+ );
+ return body.items;
+}
+
+for (const endpoint of [
+ { name: "GET /reports/facility-performance", facilityIds: performance },
+ { name: "GET /reports/continuous-problems", facilityIds: continuous },
+]) {
+ describe(`${endpoint.name} の分類絞り込み`, () => {
+ it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => {
+ expect((await endpoint.facilityIds("")).length).toBeGreaterThan(0);
+ });
+
+ it("`customer_id=1` → 屋号1の施設の行だけになる", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("customer_id=1");
+ const allowed = allowedFacilityIds(null, 1);
+
+ // ★絞る対象が実在すること★(無いと、何も実装していなくても緑になる)
+ expect(all.some((id) => !allowed.has(id))).toBe(true);
+ expect([...got].sort(byNumber)).toEqual(
+ all.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ expect(got.length).toBeGreaterThan(0);
+ });
+
+ it("`group_id=2` → ホールディングス2の施設の行だけになる", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("group_id=2");
+ const allowed = allowedFacilityIds(2, null);
+
+ expect(all.some((id) => !allowed.has(id))).toBe(true);
+ expect([...got].sort(byNumber)).toEqual(
+ all.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ });
+
+ it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => {
+ expect(await endpoint.facilityIds("")).not.toEqual([]);
+ expect(await endpoint.facilityIds("group_id=1&customer_id=3")).toEqual([]);
+ });
+ });
+}
+
+/**
+ * ★施設名 × 分類の積集合(`/reports/facility-performance`)★
+ * — ★施設名の条件を持つレポートAPIはここだけ★(`d-watch-api.yaml`)。
+ * ★どちらか片方を捨てると、この2本のどちらかが落ちる★(指示書AK §6-2)。
+ */
+describe("★施設名 × 分類 の積集合(`/reports/facility-performance`)★", () => {
+ it("施設名と屋号の両方に当たる施設だけが残る", async () => {
+ const byName = await performance("facility_name=バリューマート");
+ const both = await performance("facility_name=バリューマート&customer_id=2");
+ const allowed = allowedFacilityIds(null, 2);
+
+ expect(byName.length).toBeGreaterThan(0);
+ expect(both.length).toBeGreaterThan(0);
+ // ★施設名だけの結果より狭いこと★(=分類の条件が捨てられていない)
+ expect(both.length).toBeLessThan(byName.length);
+ expect([...both].sort(byNumber)).toEqual(
+ byName.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ });
+
+ it("★積集合が空なら0件(★片方だけ効かせないこと★)★", async () => {
+ const byName = await performance("facility_name=フレッシュタウン");
+ const both = await performance("facility_name=フレッシュタウン&group_id=1");
+
+ expect(byName.length).toBeGreaterThan(0);
+ expect(both).toEqual([]);
+ });
+});
+
+/**
+ * ★★`/reports/error-breakdown` は fixtures では「0件ヒット」しか確かめられない★★
+ *
+ * fixtures のエラー種別内訳は ★施設をまたいで足し上げたあとの集計値★で、
+ * ★どの施設のエラーかという情報を持っていません★(`lib/fixtures/reports.ts` の `errorCounts`)。
+ * そのため ★`facility_ids` が1件以上のときは全社ぶんが返ります★
+ * (既知の制約。`fixtureSource.errorBreakdown()` の docstring が正)。
+ *
+ * ★★施設で本当に絞れているかは Prisma 版で固定してある★★ —
+ * `app/api/reports/__tests__/master-filter-prisma.test.ts`
+ * (`facility_error_summaries` を `facility_id` で絞る=D-133 申し送り③の解消)。
+ */
+describe("GET /reports/error-breakdown の分類絞り込み(fixtures)", () => {
+ it("分類を指定しなければ全件", async () => {
+ expect((await breakdown("")).length).toBeGreaterThan(0);
+ });
+
+ it("★★`group_id=1&customer_id=3`(AND で該当施設なし)は0件★★(全件に化けないこと)", async () => {
+ expect(await breakdown("group_id=1&customer_id=3")).toEqual([]);
+ });
+
+ it("★既知の制約: 該当施設があるときは全社ぶんが返る(fixtures にエラーの施設帰属が無い)★", async () => {
+ expect(await breakdown("customer_id=1")).toEqual(await breakdown(""));
+ });
+});
diff --git a/app/api/__tests__/master-filter.test.ts b/app/api/__tests__/master-filter.test.ts
new file mode 100644
index 0000000..dbb5970
--- /dev/null
+++ b/app/api/__tests__/master-filter.test.ts
@@ -0,0 +1,398 @@
+import { describe, expect, it } from "vitest";
+
+// ★ルート本体は**静的 import** すること★(`it()` の中で `await import()` すると、
+// 最初の1本だけがモジュール読み込みを抱え込み、フルスイート実行時に
+// 既定のタイムアウトを踏んで不定期に落ちる。`app/api/clean-logs/__tests__/route.test.ts` の実測)
+import { GET as GET_CLEAN_LOGS } from "@/app/api/clean-logs/route";
+import { GET as GET_CLEAN_LOGS_EXPORT } from "@/app/api/clean-logs/export/route";
+import { GET as GET_DS_ALERTS } from "@/app/api/ds-alerts/route";
+import { GET as GET_DS_DEVICES } from "@/app/api/ds-devices/route";
+import { GET as GET_FACILITIES } from "@/app/api/facilities/route";
+import { GET as GET_PRECHECKS } from "@/app/api/prechecks/route";
+import { GET as GET_PERFORMANCE_EXPORT } from "@/app/api/reports/facility-performance/export/route";
+import { GET as GET_SCHEDULES } from "@/app/api/schedules/route";
+import { facilities } from "@/lib/fixtures";
+import { todayJst } from "@/lib/format/date";
+
+/**
+ * 指示書AK ★段階4a★ — 分類(`group_id` / `customer_id`)の絞り込みが
+ * ★7エンドポイントすべてで効いていること★ の期待値表
+ * (★のちに施設別パフォーマンスの CSV `/reports/facility-performance/export` を足して8本。D-163★)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★なぜルートまで通して試すのか★★
+ * ------------------------------------------------------------------------
+ * `resolveFacilityIdsByMasters()` / `intersectFacilityIds()` の単体テストは、
+ * ★ルートが解決結果を**渡し忘れていても緑のまま**★です(指示書AC 期待値表 #6d と同じ理由)。
+ * ★★「一覧が全件のまま返る」は例外もテスト失敗も出ない壊れ方★★(★罠1 / 落とし穴1★)なので、
+ * ★ルート → データソース → レスポンス★ まで通して固定します。
+ *
+ * ------------------------------------------------------------------------
+ * ★★fixtures(`USE_FIXTURES` の既定 = true)で走らせる★★
+ * ------------------------------------------------------------------------
+ * 段階1 で `lib/fixtures/customers.ts` に ★複数ホールディングス × 複数屋号★ の
+ * 割り当てが入っている(落とし穴10)ので、★実DBなしで3階層の絞り込みを確かめられます★。
+ *
+ * ホールディングス1(バリューHD) … 屋号1(施設 1/3/5/7/8/9)・屋号2(施設 2/4/6/11)
+ * ホールディングス2(フレッシュ) … 屋号3(施設 10)
+ * ホールディングスなし … 屋号4(施設 12)
+ *
+ * ★期待値は fixtures から組み立てる★ — 施設の割り当てが変わってもテストが
+ * 「件数だけ違う」で落ちないようにするため。★代わりに「絞る前後で必ず差が出ること」を
+ * 明示的に確かめる★(=絞り込みが本当に効いたことの確認。下の `hasOutsider`)。
+ */
+
+// ---------------------------------------------------------------- 期待値(fixtures 由来)
+
+/**
+ * 分類の条件に当たる施設IDの集合。
+ *
+ * ★`resolveFacilityIdsByMasters()` の実装を写したものではない★ —
+ * こちらは ★fixtures の割り当てをそのまま読む★(実装が壊れたときに
+ * 一緒に壊れない書き方にする)。
+ *
+ * ★所属は `Facility` 自身が持っている★(指示書AK ★段階5★。段階4a 当時の
+ * `customerIdByFacilityId`(`lib/fixtures/customers.ts`)は削除済み)。
+ */
+function allowedFacilityIds(
+ groupId: number | null,
+ customerId: number | null
+): Set {
+ const ids = new Set();
+ for (const facility of facilities) {
+ // ★★AND で畳む★★(`d-watch-api.yaml` の `GroupId` / `CustomerId` の契約)
+ if (groupId !== null && facility.group_id !== groupId) continue;
+ if (customerId !== null && facility.customer_id !== customerId) continue;
+ ids.add(facility.id);
+ }
+ return ids;
+}
+
+const byNumber = (a: number, b: number) => a - b;
+
+// ---------------------------------------------------------------- 各APIの呼び出し
+
+function url(path: string, search: string): Request {
+ // ★`per_page` は上限(200)まで寄せる★
+ return new Request(`http://localhost${path}?per_page=200&${search}`);
+}
+
+interface RowsBody {
+ items: { facility_id: number }[];
+ summary?: unknown;
+}
+async function jsonOf(res: Response): Promise {
+ expect(res.status).toBe(200);
+ return (await res.json()) as T;
+}
+
+/**
+ * ★★ページを最後まで手繰って全行を集める★★
+ *
+ * ★1ページ目だけを見ないこと★ — `/clean-logs` は fixtures でも1ページ(上限200件)に
+ * 収まらず、★「絞り込み後のほうが件数が多い」★という比較にならない結果になる
+ * (絞り込みの有無で母数が変わるので、★途中で切れた一覧どうしは比べられない★)。
+ */
+async function collect(
+ handler: (request: Request) => Promise,
+ path: string,
+ search: string,
+ idOf: (item: T) => number
+): Promise {
+ const ids: number[] = [];
+ for (let page = 1; ; page += 1) {
+ const body = await jsonOf<{ items: T[]; meta: { total: number } }>(
+ await handler(url(path, `page=${page}&${search}`))
+ );
+ ids.push(...body.items.map(idOf));
+ if (body.items.length === 0 || ids.length >= body.meta.total) return ids;
+ }
+}
+
+/** CSV の1列目(施設名)を施設IDに戻す(★CSV にも同じ絞り込みが効いているか★) */
+const facilityIdByName = new Map(facilities.map((f) => [f.facility_name, f.id]));
+
+function csvFacilityIds(csv: string): number[] {
+ const lines = csv.replace(/^/, "").trim().split("\r\n");
+ expect(lines[0].startsWith("施設名")).toBe(true);
+ return lines.slice(1).map((line) => {
+ const name = line.split(",")[0];
+ const id = facilityIdByName.get(name);
+ if (id === undefined) throw new Error(`CSVの施設名が施設マスタに無い: ${name}`);
+ return id;
+ });
+}
+
+/**
+ * ★施設別パフォーマンスの CSV(D-163)は期間が必須★ — fixtures のレポートが持つ「今月」を使う
+ * (一覧のほうは `app/api/__tests__/master-filter-dashboard-reports.test.ts` の `CURRENT_MONTH` と同じ)。
+ */
+const CURRENT_MONTH = (() => {
+ const today = todayJst();
+ return `period=monthly&year=${Number(today.slice(0, 4))}&month=${Number(today.slice(5, 7))}`;
+})();
+
+interface Endpoint {
+ name: string;
+ /** その検索条件で返ってきた行の施設ID(★重複あり★) */
+ facilityIds(search: string): Promise;
+}
+
+const endpoints: Endpoint[] = [
+ {
+ name: "GET /clean-logs",
+ facilityIds: (search) =>
+ collect<{ facility_id: number }>(
+ GET_CLEAN_LOGS,
+ "/api/clean-logs",
+ search,
+ (i) => i.facility_id
+ ),
+ },
+ {
+ name: "GET /clean-logs/export",
+ async facilityIds(search) {
+ const res = await GET_CLEAN_LOGS_EXPORT(url("/api/clean-logs/export", search));
+ expect(res.status).toBe(200);
+ return csvFacilityIds(await res.text());
+ },
+ },
+ {
+ // ★★分類の絞り込みが CSV にも効くこと★★(D-163)— 一覧(`/reports/facility-performance`)と
+ // 同じ読み込み関数を通るが、★ルートが条件を渡し忘れても単体テストは緑のまま★なので、ここでも固定する。
+ name: "GET /reports/facility-performance/export",
+ async facilityIds(search) {
+ const res = await GET_PERFORMANCE_EXPORT(
+ url("/api/reports/facility-performance/export", `${CURRENT_MONTH}&${search}`)
+ );
+ expect(res.status).toBe(200);
+ return csvFacilityIds(await res.text());
+ },
+ },
+ {
+ name: "GET /prechecks",
+ // ★既定は `status=open`★ なので、母集団を全件にして比べる
+ facilityIds: (search) =>
+ collect<{ facility_id: number }>(
+ GET_PRECHECKS,
+ "/api/prechecks",
+ `status=all&${search}`,
+ (i) => i.facility_id
+ ),
+ },
+ {
+ name: "GET /ds-devices",
+ facilityIds: (search) =>
+ collect<{ facility_id: number }>(
+ GET_DS_DEVICES,
+ "/api/ds-devices",
+ search,
+ (i) => i.facility_id
+ ),
+ },
+ {
+ name: "GET /ds-alerts",
+ facilityIds: (search) =>
+ collect<{ facility_id: number }>(
+ GET_DS_ALERTS,
+ "/api/ds-alerts",
+ `status=all&${search}`,
+ (i) => i.facility_id
+ ),
+ },
+ {
+ name: "GET /facilities",
+ // ★★見る列が違う★★ — 施設の行そのものなので `facility_id` ではなく `id`
+ facilityIds: (search) =>
+ collect<{ id: number }>(GET_FACILITIES, "/api/facilities", search, (i) => i.id),
+ },
+ {
+ name: "GET /schedules",
+ facilityIds: (search) =>
+ collect<{ facility_id: number }>(
+ GET_SCHEDULES,
+ "/api/schedules",
+ search,
+ (i) => i.facility_id
+ ),
+ },
+];
+
+// ---------------------------------------------------------------- 期待値表
+
+for (const endpoint of endpoints) {
+ describe(`${endpoint.name} の分類絞り込み`, () => {
+ it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => {
+ const all = await endpoint.facilityIds("");
+ expect(all.length).toBeGreaterThan(0);
+ });
+
+ /**
+ * ★屋号(`customer_id`)で絞れること★
+ *
+ * ★★`hasOutsider` を必ず確かめる★★ — 絞り込みの対象になる行が
+ * もともと1件も無いと、★何も実装していなくてもこのテストは緑になります★。
+ */
+ it("`customer_id=1` → 屋号1の施設の行だけになる", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("customer_id=1");
+ const allowed = allowedFacilityIds(null, 1);
+
+ const hasOutsider = all.some((id) => !allowed.has(id));
+ expect(hasOutsider).toBe(true); // 絞る対象が実在すること
+ expect([...got].sort(byNumber)).toEqual(
+ all.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ expect(got.length).toBeGreaterThan(0);
+ });
+
+ /**
+ * ★ホールディングス(`group_id`)で絞れること★
+ *
+ * ★0件になる一覧がある★(fixtures の `prechecks` は施設10 の行を持たない)ので
+ * ★件数ではなく「全件から絞った結果と一致すること」で固定する★。
+ */
+ it("`group_id=2` → ホールディングス2の施設の行だけになる", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("group_id=2");
+ const allowed = allowedFacilityIds(2, null);
+
+ expect(all.some((id) => !allowed.has(id))).toBe(true);
+ expect([...got].sort(byNumber)).toEqual(
+ all.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ });
+
+ /**
+ * ★★同時指定は AND。積集合が空なら**0件**★★(★落とし穴2★)
+ *
+ * 屋号3 はホールディングス2 の傘下なので、★ホールディングス1 と同時に指定すると
+ * 当たる施設は1件も無い★。★ここで全件が返ったら、`[]` が `null`(絞らない)に
+ * 化けている★ — ★例外もテスト失敗も出ない壊れ方なので、必ず固定する★。
+ */
+ it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("group_id=1&customer_id=3");
+
+ expect(all.length).toBeGreaterThan(0);
+ expect(got).toEqual([]);
+ });
+
+ /** ★ホールディングスに属さない屋号(fixtures の屋号4 = 施設12)でも絞れる★ */
+ it("`customer_id=4`(ホールディングスなし)でも絞れる", async () => {
+ const all = await endpoint.facilityIds("");
+ const got = await endpoint.facilityIds("customer_id=4");
+ const allowed = allowedFacilityIds(null, 4);
+
+ expect([...got].sort(byNumber)).toEqual(
+ all.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ });
+ });
+}
+
+// ---------------------------------------------------------------- 施設名との併用(積集合)
+
+/**
+ * ★★施設名の絞り込みと同時に指定されたら積集合(AND)★★(指示書AK §6-2)。
+ * ★どちらか片方を捨てると、この2本のうちどちらかが落ちる★。
+ */
+describe("★施設名 × 分類 の積集合(`/clean-logs`)★", () => {
+ const ids = (search: string) =>
+ collect<{ facility_id: number }>(
+ GET_CLEAN_LOGS,
+ "/api/clean-logs",
+ search,
+ (i) => i.facility_id
+ );
+
+ it("施設名と屋号の両方に当たる施設だけが残る", async () => {
+ const byName = await ids("facility_name=バリューマート");
+ const both = await ids("facility_name=バリューマート&customer_id=2");
+ const allowed = allowedFacilityIds(null, 2);
+
+ expect(byName.length).toBeGreaterThan(0);
+ expect(both.length).toBeGreaterThan(0);
+ // ★施設名だけの結果より狭いこと★(=分類の条件が捨てられていない)
+ expect(both.length).toBeLessThan(byName.length);
+ expect([...both].sort(byNumber)).toEqual(
+ byName.filter((id) => allowed.has(id)).sort(byNumber)
+ );
+ });
+
+ it("★積集合が空なら0件(★片方だけ効かせないこと★)★", async () => {
+ // 施設名「フレッシュタウン」に当たるのは施設10(ホールディングス2)と施設12(所属なし)。
+ // ★ホールディングス1 と同時指定すれば積集合は空★。
+ const byName = await ids("facility_name=フレッシュタウン");
+ const both = await ids("facility_name=フレッシュタウン&group_id=1");
+
+ expect(byName.length).toBeGreaterThan(0);
+ expect(both).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------- サマリの母集団
+
+/**
+ * ★★サマリ(数値カード)にも分類の絞り込みが効くこと★★
+ *
+ * ★q / status / type では絞らない★(D-054)が、★分類は「母集団」を選ぶ条件★なので
+ * ★効かせるのが正しい★(`lib/ds-devices/query.ts` の `summarizeDsDevices()` の
+ * 「フリートの部分集合(日付・グループ・会社)で絞る」/ 指示書AK §7-2)。
+ *
+ * ★★一覧とサマリで母集団がずれていないこと★★ を、同じ条件の件数どうしで固定する。
+ */
+describe("★サマリの母集団も分類で絞られる★", () => {
+ it("GET /ds-devices の `summary.total`", async () => {
+ interface Body extends RowsBody {
+ summary: { total: number };
+ }
+ const all = await jsonOf(
+ await GET_DS_DEVICES(url("/api/ds-devices", "status=all"))
+ );
+ const scoped = await jsonOf(
+ await GET_DS_DEVICES(url("/api/ds-devices", "status=all&customer_id=1"))
+ );
+ const allowed = allowedFacilityIds(null, 1);
+ const expected = all.items.filter((i) => allowed.has(i.facility_id)).length;
+
+ expect(all.summary.total).toBeGreaterThan(expected);
+ expect(scoped.summary.total).toBe(expected);
+ expect(scoped.items).toHaveLength(expected);
+ });
+
+ it("GET /ds-alerts の `summary`(unresolved+in_progress+resolved。指示書AX D10)", async () => {
+ interface Body extends RowsBody {
+ summary: { unresolved: number; in_progress: number; resolved: number };
+ }
+ const sum = (s: Body["summary"]) => s.unresolved + s.in_progress + s.resolved;
+ const all = await jsonOf(
+ await GET_DS_ALERTS(url("/api/ds-alerts", "status=all"))
+ );
+ const scoped = await jsonOf(
+ await GET_DS_ALERTS(url("/api/ds-alerts", "status=all&customer_id=1"))
+ );
+ const allowed = allowedFacilityIds(null, 1);
+ const expected = all.items.filter((i) => allowed.has(i.facility_id)).length;
+
+ expect(sum(all.summary)).toBeGreaterThan(expected);
+ expect(sum(scoped.summary)).toBe(expected);
+ });
+
+ it("GET /prechecks の `summary.total`", async () => {
+ interface Body extends RowsBody {
+ summary: { total: number };
+ }
+ const all = await jsonOf(
+ await GET_PRECHECKS(url("/api/prechecks", "status=all"))
+ );
+ const scoped = await jsonOf(
+ await GET_PRECHECKS(url("/api/prechecks", "status=all&customer_id=1"))
+ );
+ const allowed = allowedFacilityIds(null, 1);
+ const expected = all.items.filter((i) => allowed.has(i.facility_id)).length;
+
+ expect(all.summary.total).toBeGreaterThan(expected);
+ expect(scoped.summary.total).toBe(expected);
+ });
+});
diff --git a/app/api/auth/login/__tests__/route.test.ts b/app/api/auth/login/__tests__/route.test.ts
new file mode 100644
index 0000000..33e50a9
--- /dev/null
+++ b/app/api/auth/login/__tests__/route.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` の冒頭コメントと同じ理由)
+import { POST } from "@/app/api/auth/login/route";
+
+/**
+ * `POST /auth/login` — 本文がオブジェクトでない(指示書BB 表8 J1〜J3。A5・D19・D-182)。
+ *
+ * | # | 本文 | 期待 |
+ * |---|---|---|
+ * | J1 | `null` | 422・validation_failed・「JSONの形式が不正です」(旧: TypeError で標準でない500) |
+ * | J2 | `[]` / `1` / `"x"` | 422(J1 と同じ。★数値・文字列のときの文言が「tokenを指定してください」から変わる — 承知のうえ★) |
+ * | J3 | `{}` | 今どおり(422「tokenを指定してください」) |
+ *
+ * ★トークンの照合まで行かない入力だけを使う★(認証情報をテストに書かない)
+ */
+
+function postRaw(raw: string): Promise {
+ return POST(new Request("http://localhost/api/auth/login", { method: "POST", body: raw }));
+}
+
+describe("POST /auth/login — 本文の形", () => {
+ it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」★", async (raw) => {
+ const res = await postRaw(raw);
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: { code: "validation_failed", message: "JSONの形式が不正です", details: null },
+ });
+ });
+
+ it("J3: 本文 {} → 今どおり(422「tokenを指定してください」)", async () => {
+ const res = await postRaw("{}");
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { message: string } }).error.message).toBe("tokenを指定してください");
+ });
+
+ it("JSON として読めない本文は今どおり 422「JSONの形式が不正です」", async () => {
+ const res = await postRaw("{");
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { message: string } }).error.message).toBe("JSONの形式が不正です");
+ });
+});
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..3288a52
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,76 @@
+/**
+ * POST /api/auth/login — 共有トークンを検証して `dwatch_session` cookie を発行する。
+ *
+ * ★このルートは認証をかけない★(`lib/proxy/authenticate.ts` の `isPublicPath()` で通す)。
+ * 保護すると誰もログインできない。IP制限は proxy が別途かけるので、
+ * 許可されないIPからはここに到達する前に 403 になる。
+ *
+ * ★入力値(トークン)をログにも本文にも絶対に出さないこと★
+ * ★失敗しても「トークンが違う」以上の情報を返さないこと★
+ * (「そのトークンは存在するが期限切れ」等を返すと総当たりの手がかりになる)
+ */
+import { NextResponse } from "next/server";
+import { isJsonObject } from "@/lib/api/json-body";
+import {
+ SESSION_COOKIE_NAME,
+ SESSION_COOKIE_OPTIONS,
+} from "@/lib/auth/cookie";
+import { readAuthToken, verifyToken } from "@/lib/auth/token";
+import { logEvent, withRequestId } from "@/lib/logger";
+import type { ApiError, ErrorCode } from "@/lib/types";
+
+const ROUTE = "/api/auth/login";
+
+function errorResponse(status: number, code: ErrorCode, message: string) {
+ const body: ApiError = { error: { code, message, details: null } };
+ return NextResponse.json(body, { status });
+}
+
+export async function POST(request: Request) {
+ return withRequestId(request, async () => {
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return errorResponse(422, "validation_failed", "JSONの形式が不正です");
+ }
+ // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言)
+ if (!isJsonObject(body)) {
+ return errorResponse(422, "validation_failed", "JSONの形式が不正です");
+ }
+
+ const token = body.token;
+ if (typeof token !== "string" || token.length === 0) {
+ return errorResponse(422, "validation_failed", "tokenを指定してください");
+ }
+
+ // ★定数時間比較。ここで === を書かないこと★
+ if (!verifyToken(token, readAuthToken())) {
+ // ★入力されたトークンは載せない(成否だけ)★
+ // ★client_ip も載せない★ — ここに来た時点でIP判定は通過済みで、
+ // 接続元は定義上 ALLOWED_IPS に載っている既知の値(D-101)
+ logEvent("warn", {
+ event: "auth.login_failed",
+ route: ROUTE,
+ method: "POST",
+ });
+ return errorResponse(401, "invalid_token", "トークンが違います");
+ }
+
+ logEvent("info", {
+ event: "auth.login_success",
+ route: ROUTE,
+ method: "POST",
+ });
+
+ // 本文なしの 204。cookie は Set-Cookie で返す
+ const response = new NextResponse(null, { status: 204 });
+ // ★expires / maxAge は付けない(CLAUDE.md §7 でトークン期限は禁止)★
+ response.cookies.set({
+ name: SESSION_COOKIE_NAME,
+ value: token,
+ ...SESSION_COOKIE_OPTIONS,
+ });
+ return response;
+ });
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..3c0f8fc
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,30 @@
+/**
+ * POST /api/auth/logout — `dwatch_session` cookie を削除する。
+ *
+ * ★このルートは認証が必要★(`isPublicPath()` に入れていない)。
+ * cookie を消すだけなので公開しても実害は薄いが、公開にする理由も無い。
+ * 未認証で叩けば proxy が 401 を返し、その時点で cookie は無効なので目的は達している。
+ */
+import { NextResponse } from "next/server";
+import {
+ SESSION_COOKIE_NAME,
+ SESSION_COOKIE_OPTIONS,
+} from "@/lib/auth/cookie";
+import { withRequestId } from "@/lib/logger";
+
+export async function POST(request: Request) {
+ return withRequestId(request, async () => {
+ const response = new NextResponse(null, { status: 204 });
+ // 発行時と同じ name / path / secure で消す(属性が違うと消えずに残る)。
+ // ★`delete()` は内部で Max-Age=0 を出すが、これは「削除」であって
+ // 「トークンに期限を付ける」ことではない(CLAUDE.md §7 の禁止事項に当たらない)★
+ response.cookies.delete({
+ name: SESSION_COOKIE_NAME,
+ path: SESSION_COOKIE_OPTIONS.path,
+ secure: SESSION_COOKIE_OPTIONS.secure,
+ httpOnly: SESSION_COOKIE_OPTIONS.httpOnly,
+ sameSite: SESSION_COOKIE_OPTIONS.sameSite,
+ });
+ return response;
+ });
+}
diff --git a/app/api/clean-area/__tests__/route.fixtures.test.ts b/app/api/clean-area/__tests__/route.fixtures.test.ts
new file mode 100644
index 0000000..1b12c9f
--- /dev/null
+++ b/app/api/clean-area/__tests__/route.fixtures.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+
+// ★ルート本体は**静的 import** すること★(`app/api/reports/__tests__/route.test.ts` の実測)
+import { GET } from "@/app/api/clean-area/route";
+import { facilities } from "@/lib/fixtures";
+import { includesFoldName } from "@/lib/format/search-text";
+import type { CleanAreaResponse } from "@/lib/types";
+
+/**
+ * `GET /clean-area` を ★fixtures(`USE_FIXTURES` の既定 = true)★ で通すテスト。
+ *
+ * ------------------------------------------------------------------------
+ * ★★なぜ `route.test.ts` と分けてあるのか★★
+ * ------------------------------------------------------------------------
+ * ルートは ★施設名の解決と分類の解決を `Promise.all` で並列に★ 走らせる
+ * (`resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()`。指示書AK §6-2)。
+ *
+ * ★★`route.test.ts` の `vi.mock("@/lib/db/prisma")` ではこの経路を試せません★★ —
+ * `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読むため、
+ * ★同じ瞬間に2本の動的 import が走ると、片方が差し替えをすり抜けて本物を掴み、
+ * 「DATABASE_URL が無い」で 500 になります★(実測。テスト環境側の制約であって
+ * ルートの不具合ではない)。
+ * ★fixtures 経路は動的 import を通らない★ので、★★積集合(AND)はこちらで固定します★★。
+ *
+ * ------------------------------------------------------------------------
+ * ★★期待値は fixtures から組み立てる★★(`app/api/__tests__/master-filter.test.ts` と同じ作法)
+ * ------------------------------------------------------------------------
+ * 施設の割り当てが変わってもテストが「件数だけ違う」で落ちないようにするため。
+ * ★代わりに「絞る前後で必ず差が出ること」を明示的に確かめる★
+ * (=絞り込みが本当に効いたことの確認)。
+ */
+
+async function body(qs = ""): Promise {
+ const res = await GET(new Request(`http://localhost/api/clean-area${qs}`));
+ expect(res.status).toBe(200);
+ return (await res.json()) as CleanAreaResponse;
+}
+
+/** 面積を持つ施設だけ(fixtures の面積行が無い施設は内訳に現れない) */
+async function facilityIdsInResponse(qs = ""): Promise {
+ const res = await body(qs);
+ return res.items.map((row) => row.id as number).sort((a, b) => a - b);
+}
+
+describe("GET /clean-area(fixtures)", () => {
+ it("#F1 ★粒度を変えても全体合計が変わらない★(§9-3 の 9-8 / B-5)", async () => {
+ const byFacility = await body("?group_by=facility");
+ const byCustomer = await body("?group_by=customer");
+ const byGroup = await body("?group_by=group");
+
+ expect(byFacility.total.clean_area).toBeGreaterThan(0);
+ expect(byCustomer.total).toEqual(byFacility.total);
+ expect(byGroup.total).toEqual(byFacility.total);
+
+ // ★内訳の和 = 全体合計★(粒度ごとに確かめる。落とし穴4 / 落とし穴5)
+ for (const res of [byFacility, byCustomer, byGroup]) {
+ const sum = res.items.reduce((acc, row) => acc + row.clean_area, 0);
+ expect(sum).toBeCloseTo(res.total.clean_area, 6);
+ }
+ // 粒度を上げるほど行数は減る(同数のままなら畳めていない)
+ expect(byGroup.items.length).toBeLessThan(byFacility.items.length);
+ });
+
+ it("#F2 ★分類で絞ると内訳がその傘下だけになる★", async () => {
+ const all = await facilityIdsInResponse();
+ const groupId = facilities[0].group_id;
+ expect(groupId).not.toBeNull();
+
+ const filtered = await facilityIdsInResponse(`?group_id=${groupId}`);
+ const allowed = new Set(
+ facilities.filter((f) => f.group_id === groupId).map((f) => f.id)
+ );
+ expect(filtered.every((id) => allowed.has(id))).toBe(true);
+ // ★絞り込みが本当に効いたことの確認★ — 絞る前には傘下でない施設が居た
+ expect(all.some((id) => !allowed.has(id))).toBe(true);
+ expect(filtered.length).toBeLessThan(all.length);
+ });
+
+ it("#F3 ★★施設名 × 分類は AND(積集合)★★(指示書AK §6-2 / 落とし穴3)", async () => {
+ // ★`Promise.all` で並列解決される2つの条件★が両方効いていることを見る。
+ // ★キーワードは「2つ以上の屋号にまたがる」ものを選ぶこと★ —
+ // 1屋号にしか当たらない語だと、AND を無視しても同じ結果になり ★テストが無意味になる★。
+ const customerId = facilities[0].customer_id;
+ const keyword = "バリューマート";
+ const byName = facilities.filter((f) => includesFoldName(f.facility_name, keyword));
+ // ★前提そのものを固定する★(fixtures が変わったらここで落ちる)
+ expect(new Set(byName.map((f) => f.customer_id)).size).toBeGreaterThan(1);
+
+ const expected = byName.filter((f) => f.customer_id === customerId).map((f) => f.id);
+ expect(expected.length).toBeGreaterThan(0);
+
+ const got = await facilityIdsInResponse(
+ `?customer_id=${customerId}&facility_name=${encodeURIComponent(keyword)}`
+ );
+ // ★面積行を持たない施設は内訳に出ない★ので「期待の部分集合であること」を見る
+ expect(got.every((id) => expected.includes(id))).toBe(true);
+
+ // ★どちらか片方を捨てていないこと★ —
+ // 施設名だけで引くと、別の屋号の施設まで入ってくる(= AND が効いていれば入らない)
+ const byNameOnly = await facilityIdsInResponse(
+ `?facility_name=${encodeURIComponent(keyword)}`
+ );
+ expect(byNameOnly.length).toBeGreaterThanOrEqual(got.length);
+ expect(
+ byNameOnly.every((id) => facilities.find((f) => f.id === id)?.customer_id === customerId)
+ ).toBe(false);
+ });
+
+ it("#F4 ★実在しない group_id は0件(全件に化けない)★(§9-2 の 9-4)", async () => {
+ const res = await body("?group_id=9999");
+ expect(res.items).toEqual([]);
+ expect(res.total).toEqual({
+ clean_area: 0,
+ task_area: 0,
+ // ★分母0は `null`★(0% ではない)
+ completion_rate: null,
+ executed_count: 0,
+ });
+ expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 });
+ });
+
+ it("#F5 期間で絞ると合計が減る(§9-2 の 9-3)", async () => {
+ const all = await body();
+ // fixtures の1日だけを切り出す(★どの日でもよいので全期間より小さくなればよい★)
+ const narrowed = await body("?date_from=2026-09-10&date_to=2026-09-10");
+ expect(narrowed.total.clean_area).toBeLessThan(all.total.clean_area);
+ expect(narrowed.total.executed_count).toBeLessThan(all.total.executed_count);
+ });
+});
diff --git a/app/api/clean-area/__tests__/route.test.ts b/app/api/clean-area/__tests__/route.test.ts
new file mode 100644
index 0000000..a7c22f0
--- /dev/null
+++ b/app/api/clean-area/__tests__/route.test.ts
@@ -0,0 +1,637 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★ルート本体は★★静的 import★★すること★ — `it()` の中で `await import()` すると、
+// 最初の1本がモジュール読み込みを抱え込み、フルスイート実行時にタイムアウトしうる
+// (`app/api/reports/__tests__/route.test.ts` の実測)。
+import { GET } from "@/app/api/clean-area/route";
+import { CLEAN_AREA_GROUP_NONE_LABEL } from "@/lib/clean-area/aggregate";
+import { CLEAN_AREA_ANOMALY_METRICS } from "@/lib/clean-area/anomaly";
+import { AREA_UNIT, CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
+import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
+import { logEvent } from "@/lib/logger";
+import type { ApiError, CleanAreaResponse } from "@/lib/types";
+
+/**
+ * `GET /clean-area` の期待値表(指示書AN ★段階4★ / §9-2 の確認項目をテストに落としたもの)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★何を守るためのテストか★★
+ * ------------------------------------------------------------------------
+ * - ★★粒度を変えても全体合計が変わらないこと★★(§9-2 の 9-2 / §5-3 B-5 / 落とし穴4)—
+ * ★変わったらロールアップが壊れている★。例外もテスト失敗も出ない類の壊れ方
+ * - ★`group_id=9999` が0件になること★(§9-2 の 9-4 / ★落とし穴3★)—
+ * ★`facility_ids` を `?.length` で分岐すると「0件ヒット」が「全件」に化ける★
+ * - ★★番人を呼んでいること★★(§2-5 / ★落とし穴2★)— ルートが
+ * `reportCleanAreaAnomalies()` を呼び忘れても ★例外もテスト失敗も出ない★ ので、
+ * ★ここで固定しておかないと静かに消える★
+ * - ★完了率を丸めないこと★(§9-2 の 9-5 / `CLAUDE.md` §4.3)
+ * - ★単位(`AREA_UNIT`)をレスポンスに入れないこと★(§9-2 の 9-6 / §2-4)
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
+ * `@/lib/db/use-fixtures` を ★`false`★ に固定して ★`prismaSource` の経路★ を走らせる
+ * (`app/api/customers/__tests__/route.test.ts` / `app/api/reports/__tests__/route.test.ts`
+ * と同じ作法)。実DB(テストDB/ローカルDB)との突き合わせは別途実施する(§9)。
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubFacility {
+ id: bigint;
+ facility_name: string;
+ customer_id: bigint;
+ customer: {
+ customer_name: string;
+ /** ★NOT NULL で 0 = ホールディングスなしのセンチネル★(D-011) */
+ group_id: bigint;
+ group: { group_name: string } | null;
+ };
+}
+
+/**
+ * `clean_task_reports` の1行(★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★)。
+ */
+interface StubReport {
+ facility_id: bigint;
+ /**
+ * ★nullable★(`prisma/schema.prisma` の `CleanTaskReport.clean_timetable_id`)—
+ * ★TeamWorks がまだ埋めていない★(指示書AQ 決定5 / 罠1)。
+ */
+ clean_timetable_id: bigint | null;
+ /**
+ * ★DB の DATETIME は「日本時間の壁時計値」★なので、スタブも
+ * ★壁時計値を UTC のフィールドに載せた `Date`★ で持つ(`toDbJst()` の形)。
+ * 例: JST 2026-09-05 02:00 の清掃 → `new Date("2026-09-05T02:00:00Z")`。
+ */
+ started_at: Date;
+ clean_area: number;
+ task_area: number;
+ /**
+ * ★完了率には使っていない★(D-155。1回ごとの完了率は面積から `runCompletionRate()`)。
+ * 列を読んでいないことを確かめるため、面積比と★わざと食い違う値★を入れてある。
+ */
+ clean_ratio: number;
+}
+
+interface Range {
+ gte?: Date;
+ lt?: Date;
+}
+
+interface GroupByArgs {
+ by: string[];
+ where?: {
+ started_at?: Range;
+ facility_id?: { in: bigint[] };
+ /**
+ * ★番人だけが付ける★(`SCHEDULED_TIMETABLE_ID_WHERE`。`> 0`。0・NULL は予定外。
+ * TeamWorks 回答 2026-10-27。旧: `{ not: null }`)。★完了率用の取り寄せは付けない★(予定外も取り寄せる。指示書BG §2-5)
+ */
+ clean_timetable_id?: { gt: bigint };
+ /** ★削除済みの施設・清掃エリアの行を外す★(指示書BB U1・表1 V17。スタブは評価しない — 下のテストが `where` を見る) */
+ facility?: { deleted_at: null };
+ section?: { deleted_at: null };
+ };
+ having?: { clean_timetable_id?: { _count?: { gt?: number } } };
+}
+
+interface FindManyArgs {
+ where?: GroupByArgs["where"];
+ select?: Record;
+}
+
+interface TimetableFindManyArgs {
+ where: { id: { in: bigint[] }; precheck: number };
+ select: { id: true };
+}
+
+let data: {
+ facilities: StubFacility[];
+ reports: StubReport[];
+ /**
+ * ★本清掃でない(事前チェックの)予定の id★。★ここに無い id は本清掃(`precheck = 0`)として扱う★
+ * (予定そのものが無い dangling は `danglingTimetableIds` で表す)
+ */
+ precheckTimetableIds: Set;
+ /** ★予定の行が存在しない id★(dangling) */
+ danglingTimetableIds: Set;
+ /**
+ * ★`clean_timetable_histories` にだけある本清掃の予定の id★(指示書AU 段階3b)。
+ * ★本表(`clean_timetables`)はこの id を返さない★。無ければ空
+ */
+ historyOnlyTimetableIds?: Set;
+};
+/** ★DBに何を投げたかを見る★(0件が全件に化けていないことは `where` でも確かめる) */
+const groupByCalls: GroupByArgs[] = [];
+/** ★完了率用の取り寄せ(`cleanTaskReport.findMany`)と予定の引き直し(`cleanTimetable.findMany`)★ */
+const findManyCalls: FindManyArgs[] = [];
+const timetableCalls: TimetableFindManyArgs[] = [];
+/** ★`clean_timetable_histories` への引き直し(指示書AU 段階3b)★ */
+const historyTimetableCalls: TimetableFindManyArgs[] = [];
+
+function matches(row: StubReport, where: GroupByArgs["where"]): boolean {
+ if (!where) return true;
+ const { started_at: range, facility_id, clean_timetable_id } = where;
+ if (range?.gte && row.started_at < range.gte) return false;
+ if (range?.lt && row.started_at >= range.lt) return false;
+ // ★`in: []` は0件(`WHERE 1=0`)★。★条件そのものが無いときだけ全件★
+ if (facility_id && !facility_id.in.map(Number).includes(Number(row.facility_id))) {
+ return false;
+ }
+ // ★`{ gt: 0 }` は `> 0`★ — ★SQL の比較で NULL は偽★なので NULL も 0 も落ちる
+ if (clean_timetable_id && (row.clean_timetable_id === null || !(row.clean_timetable_id > clean_timetable_id.gt))) {
+ return false;
+ }
+ return true;
+}
+
+function groupBy(args: GroupByArgs) {
+ groupByCalls.push(args);
+ const rows = data.reports.filter((row) => matches(row, args.where));
+
+ if (args.by[0] === "clean_timetable_id") {
+ // ★番人のクエリ★: ★`having` で「2行以上」だけを返す★(絞らないと最大26万行返る)
+ // ★★SQL の `GROUP BY` と同じく NULL 同士は1つのグループにまとめる★★ —
+ // ★スタブが NULL を捨ててしまうと、実装が `IS NOT NULL` の除外を落としても
+ // テストが通ってしまう★(指示書AQ 罠1 / §5-3)
+ const counts = new Map();
+ for (const row of rows) {
+ const key = row.clean_timetable_id === null ? "NULL" : String(row.clean_timetable_id);
+ const entry = counts.get(key) ?? { id: row.clean_timetable_id, n: 0 };
+ entry.n += 1;
+ counts.set(key, entry);
+ }
+ // ★`having` が無ければ全グループを返す★(実DBと同じ。落とすと番人の件数が膨らんで検出できる)
+ const gt = args.having?.clean_timetable_id?._count?.gt;
+ return [...counts.values()]
+ .filter(({ n }) => gt === undefined || n > gt)
+ .map(({ id, n }) => ({
+ clean_timetable_id: id,
+ _count: { clean_timetable_id: n },
+ }));
+ }
+
+ // ★面積の合計だけ★(指示書AS 段階4。完了率・実施回数は `findMany` の行から数える)
+ const buckets = new Map();
+ for (const row of rows) {
+ const key = String(row.facility_id);
+ const bucket = buckets.get(key) ?? { clean: 0, task: 0 };
+ bucket.clean += row.clean_area;
+ bucket.task += row.task_area;
+ buckets.set(key, bucket);
+ }
+ return [...buckets.entries()].map(([id, b]) => ({
+ facility_id: BigInt(id),
+ _sum: { clean_area: b.clean, task_area: b.task },
+ }));
+}
+
+/** ★完了率用の取り寄せ★(`where` は集計と同じもの。予定外も取り寄せる。指示書BG §2-5) */
+function findManyReports(args: FindManyArgs) {
+ findManyCalls.push(args);
+ return data.reports.filter((row) => matches(row, args.where));
+}
+
+/** ★本清掃(`precheck = 0`)の予定だけを返す★(事前チェック・dangling・履歴だけの予定は返らない) */
+function findManyTimetables(args: TimetableFindManyArgs) {
+ const inMain = (id: bigint) =>
+ !data.danglingTimetableIds.has(String(id)) && !(data.historyOnlyTimetableIds?.has(String(id)) ?? false);
+ // ★影チェック(`precheck` なしで id を引き直す。`lib/db/timetable-source.ts`)★ — 本表にある id を返す。
+ // ★記録しない★(`timetableCalls` の `precheck` を見ている #13 を変えないため)
+ if (args.where.precheck === undefined) return args.where.id.in.filter(inMain).map((id) => ({ id }));
+ timetableCalls.push(args);
+ if (args.where.precheck !== 0) return [];
+ return args.where.id.in
+ .filter((id) => !data.precheckTimetableIds.has(String(id)) && inMain(id))
+ .map((id) => ({ id }));
+}
+
+/** ★`clean_timetable_histories` 側★: 履歴だけにある本清掃の予定を返す(指示書AU 段階3b) */
+function findManyHistoryTimetables(args: TimetableFindManyArgs) {
+ historyTimetableCalls.push(args);
+ if (args.where.precheck !== 0) return [];
+ return args.where.id.in
+ .filter((id) => data.historyOnlyTimetableIds?.has(String(id)) ?? false)
+ .map((id) => ({ id }));
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ // ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない)。見たいのは `logEvent` だけ
+ return { ...actual, logEvent: vi.fn() };
+});
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ // ★施設マスタは `select` で列を絞って読むが、スタブは絞らずに全列返す★
+ // (`select` を適用しない=上位集合を返すので、どのローダーからでも使える)
+ facility: { findMany: async () => data.facilities },
+ // ★集計元は `clean_task_reports`★(旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3)
+ cleanTaskReport: {
+ groupBy: async (args: GroupByArgs) => groupBy(args),
+ findMany: async (args: FindManyArgs) => findManyReports(args),
+ },
+ // ★本清掃かどうかの引き直し★(指示書AS 段階4。`include` は使わない)
+ cleanTimetable: {
+ findMany: async (args: TimetableFindManyArgs) => findManyTimetables(args),
+ },
+ // ★予定は `clean_timetable_histories` からも読む(指示書AU 段階3b)★
+ cleanTimetableHistory: {
+ findMany: async (args: TimetableFindManyArgs) => findManyHistoryTimetables(args),
+ },
+ }),
+}));
+
+function facility(
+ id: number,
+ name: string,
+ customerId: number,
+ customerName: string,
+ groupId: number,
+ groupName: string | null
+): StubFacility {
+ return {
+ id: BigInt(id),
+ facility_name: name,
+ customer_id: BigInt(customerId),
+ customer: {
+ customer_name: customerName,
+ group_id: BigInt(groupId),
+ group: groupName === null ? null : { group_name: groupName },
+ },
+ };
+}
+
+function report(
+ facilityId: number,
+ timetableId: number | null,
+ day: string,
+ cleanArea: number,
+ taskArea: number,
+ cleanRatio: number
+): StubReport {
+ return {
+ facility_id: BigInt(facilityId),
+ clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
+ started_at: new Date(`${day}T02:00:00Z`),
+ clean_area: cleanArea,
+ task_area: taskArea,
+ clean_ratio: cleanRatio,
+ };
+}
+
+/** ★番人のログ(`data.anomaly`)だけ★を取り出す(`judge.clean_area` は毎回1行出る) */
+function anomalyEvents() {
+ return vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, payload]) => payload.event === "data.anomaly");
+}
+
+async function get(qs = ""): Promise {
+ return GET(new Request(`http://localhost/api/clean-area${qs}`));
+}
+
+async function body(qs = ""): Promise {
+ const res = await get(qs);
+ expect(res.status).toBe(200);
+ return (await res.json()) as CleanAreaResponse;
+}
+
+beforeEach(() => {
+ vi.mocked(logEvent).mockClear();
+ groupByCalls.length = 0;
+ findManyCalls.length = 0;
+ timetableCalls.length = 0;
+ historyTimetableCalls.length = 0;
+ data = {
+ precheckTimetableIds: new Set(),
+ danglingTimetableIds: new Set(),
+ facilities: [
+ // ホールディングス1「バリューHD」→ 屋号10「バリューマート」→ 施設1・2
+ facility(1, "バリューマート渋谷本店", 10, "バリューマート", 1, "バリューHD"),
+ facility(2, "バリューマート戸塚店", 10, "バリューマート", 1, "バリューHD"),
+ // 同じホールディングスの別屋号20「メガバリュー」→ 施設3
+ facility(3, "メガバリューマート緑野店", 20, "メガバリュー", 1, "バリューHD"),
+ // ★ホールディングスなし(group_id=0 のセンチネル)★ → 屋号30 → 施設4
+ facility(4, "ひとり商店", 30, "ひとり商店グループ", 0, null),
+ ],
+ // ★`clean_ratio` 列は1回ごとの面積比(clean_area÷task_area)と**わざと食い違う値**にしてある★ —
+ // ★完了率を `clean_ratio` 列で出していたら #1 / #7 が落ちる★(D-155。列は完了率に使わない)。
+ // 1回ごとの完了率(`runCompletionRate()`): 90.0 / 80.0(80.05 を切り捨て)/ 70.0 / 50.0 / 10.0
+ reports: [
+ report(1, 1001, "2026-09-01", 900, 1000, 88.1),
+ report(1, 1002, "2026-09-10", 800.5, 1000, 70.05),
+ report(2, 1003, "2026-09-01", 700, 1000, 65),
+ report(3, 1004, "2026-09-10", 500, 1000, 40),
+ report(4, 1005, "2026-09-10", 100, 1000, 12.5),
+ ],
+ };
+});
+
+describe("GET /clean-area", () => {
+ it("#1 期間未指定なら全期間の合計を返す(施設単位が既定)", async () => {
+ const res = await body();
+ expect(res.total).toEqual({
+ clean_area: 3000.5,
+ task_area: 5000,
+ // ★1回ごとの完了率の単純平均★(D-155。旧: `clean_ratio` 列の平均 → その前は面積比 3000.5/5000)
+ completion_rate: (90 + 80 + 70 + 50 + 10) / 5,
+ executed_count: 5,
+ });
+ // ★既定の粒度は facility★(施設4件ぶんの内訳)
+ expect(res.items.map((r) => r.scope)).toEqual(["facility", "facility", "facility", "facility"]);
+ expect(res.meta).toEqual({ total: 4, page: 1, per_page: 4 });
+ // ★期間未指定なら `started_at` の条件を付けない★(全期間)
+ expect(groupByCalls[0].where?.started_at).toBeUndefined();
+ });
+
+ it("#2 ★粒度を変えても全体合計が変わらない★(§9-2 の 9-2 / B-5)", async () => {
+ // ★3本を `Promise.all` で並べないこと★ — ★このテストの制約★:
+ // `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読む。
+ // ★同じ瞬間に2本以上の動的 import が走ると、`vi.mock` の差し替えを1本がすり抜けて
+ // 本物のクライアントを掴み、「DATABASE_URL が無い」で 500 になる★(実測)。
+ // ★ルート側の `Promise.all`(施設名 × 分類の並列解決)は正しい★ので、
+ // ★そちらの経路は fixtures で確かめる★(`route.fixtures.test.ts`)。
+ const byFacility = await body("?group_by=facility");
+ const byCustomer = await body("?group_by=customer");
+ const byGroup = await body("?group_by=group");
+ expect(byCustomer.total).toEqual(byFacility.total);
+ expect(byGroup.total).toEqual(byFacility.total);
+ // 内訳の行数だけが変わる(施設4 / 屋号3 / ホールディングス2)
+ expect([byFacility.items.length, byCustomer.items.length, byGroup.items.length]).toEqual([
+ 4, 3, 2,
+ ]);
+ // ★内訳の和 = 全体合計★(どの粒度でも)
+ for (const res of [byFacility, byCustomer, byGroup]) {
+ const sum = res.items.reduce((acc, r) => acc + r.clean_area, 0);
+ expect(sum).toBeCloseTo(res.total.clean_area, 10);
+ }
+ });
+
+ it("#3 ★ホールディングスなしは「グループなし」1行★(『グループ0』を作らない。§9-3 の 9-9)", async () => {
+ const res = await body("?group_by=group");
+ const none = res.items.find((r) => r.id === null);
+ expect(none?.name).toBe(CLEAN_AREA_GROUP_NONE_LABEL);
+ expect(res.items.some((r) => r.id === 0)).toBe(false);
+ // ★その面積が全体合計から落ちないこと★(B-3)
+ expect(none?.clean_area).toBe(100);
+ });
+
+ it("#4 期間で絞ると合計が減る(§9-2 の 9-3)", async () => {
+ const res = await body("?date_from=2026-09-01&date_to=2026-09-01");
+ expect(res.total.clean_area).toBe(1600);
+ expect(res.total.executed_count).toBe(2);
+ // ★`to` はその日を含む★(翌日 00:00 未満)
+ expect(groupByCalls[0].where?.started_at?.gte).toEqual(new Date("2026-09-01T00:00:00Z"));
+ expect(groupByCalls[0].where?.started_at?.lt).toEqual(new Date("2026-09-02T00:00:00Z"));
+ });
+
+ it("#5 ★group_id=9999 は0件(全件に化けない)★(§9-2 の 9-4 / 落とし穴3)", async () => {
+ const res = await body("?group_id=9999");
+ expect(res.items).toEqual([]);
+ expect(res.total).toEqual({
+ clean_area: 0,
+ task_area: 0,
+ // ★分母0は `null`★(0% ではない。A-1 / 落とし穴7)
+ completion_rate: null,
+ executed_count: 0,
+ });
+ expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 });
+ // ★条件が消えていないこと★ — `in: []` が渡っている(`undefined` なら全件に化けている)
+ expect(groupByCalls[0].where?.facility_id).toEqual({ in: [] });
+ });
+
+ // ★施設名 × 分類の積集合(AND)は `route.fixtures.test.ts` が見る★ —
+ // ルートがその2つを `Promise.all` で並列に解決するため、
+ // ★このファイルの `vi.mock("@/lib/db/prisma")` では再現できない★(#2 の注記)。
+
+ it("#7 ★完了率を丸めない★(§9-2 の 9-5 / CLAUDE.md §4.3)", async () => {
+ const res = await body();
+ const f1 = res.items.find((r) => r.id === 1);
+ // ★期待値の式が変わった★(D-155。1回ごとの完了率 90.0 / 80.0 の単純平均。
+ // 旧: `clean_ratio` 列の平均 (88.1 + 70.05) / 2 → その前は面積比 1700.5/2000)
+ expect(f1?.completion_rate).toBe((90 + 80) / 2);
+ // ★期間の合計どうしの面積比(1700.5/2000 = 85.025)に戻っていないこと★(D-151。★戻っても例外は出ない★)
+ expect(f1?.completion_rate).not.toBe((1700.5 / 2000) * 100);
+ // ★`clean_ratio` 列を読んでいないこと★
+ expect(f1?.completion_rate).not.toBe((88.1 + 70.05) / 2);
+ expect(res.total.completion_rate).toBe((90 + 80 + 70 + 50 + 10) / 5);
+ });
+
+ it("#8 ★単位をレスポンスに入れない★(§9-2 の 9-6 / §2-4)", async () => {
+ const res = await get();
+ const text = await res.text();
+ expect(text).not.toContain(AREA_UNIT);
+ // ★異常のIDも返さない★(通知先はログであって画面ではない)
+ expect(text).not.toContain("unmatched_facility_ids");
+ });
+
+ it("#9 ★★番人を呼ぶ★★(1タスクにレポートが2行ある件数。§2-5 / 落とし穴2)", async () => {
+ // 同じ `clean_timetable_id` に2行目を足す(★SUM が同じ清掃を2回数える状態★)
+ data.reports.push(report(1, 1001, "2026-09-01", 900, 1000, 88.1));
+ await body();
+ expect(vi.mocked(logEvent)).toHaveBeenCalledWith(
+ "warn",
+ expect.objectContaining({
+ event: "data.anomaly",
+ route: "/api/clean-area",
+ method: "GET",
+ metric: CLEAN_AREA_ANOMALY_METRICS.duplicatedTimetables,
+ expected: 0,
+ actual: 1,
+ })
+ );
+ });
+
+ it("#9b ★★`clean_timetable_id` が NULL の行は番人が数えない★★(指示書AQ 罠1 / §5-3)", async () => {
+ // ★TeamWorks がまだ埋めていない★ NULL の行を2行足す(★SQL の GROUP BY は NULL 同士を
+ // 1グループにまとめる★ので、除外を落とすと `having > 1` に当たって毎回誤報する)
+ data.reports.push(report(1, null, "2026-09-11", 10, 20, 50));
+ data.reports.push(report(2, null, "2026-09-11", 30, 40, 50));
+ const res = await body();
+ // ★重複ではないので番人は黙っている★(#11 と同じく、`data.anomaly` は1行も出ない)
+ expect(anomalyEvents()).toEqual([]);
+ // ★番人のクエリに `> 0`(NULL・0 を除く)が付いている★
+ const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id");
+ expect(sentinel?.where?.clean_timetable_id).toEqual({ gt: BigInt(0) });
+ // ★★NULL の行も集計の母集団には入る★★(番人の除外を集計側に持ち込まない。
+ // `countDuplicatedTimetables()` の docstring)
+ const stats = groupByCalls.find((c) => c.by[0] === "facility_id");
+ expect(stats?.where?.clean_timetable_id).toBeUndefined();
+ expect(res.total.clean_area).toBe(3000.5 + 10 + 30);
+ // ★★実施回数にも予定外(NULL)を入れる★★(指示書BG §2-5。2026-10-29。旧: 入れない(D-155 項3))
+ expect(res.total.executed_count).toBe(5 + 2);
+ });
+
+ it("#9c ★★`clean_timetable_id` が 0(予定外。TeamWorks 回答 2026-10-27)の行も番人が数えず、面積にも実施回数にも入る★★(指示書BG §2-5)", async () => {
+ // ★0 の行を2行足す★(★SQL の GROUP BY は 0 同士を1グループにまとめる★ので、除外を落とすと
+ // `having > 1` に当たって誤報する)
+ data.reports.push(report(1, 0, "2026-09-11", 10, 20, 50));
+ data.reports.push(report(2, 0, "2026-09-11", 30, 40, 50));
+ const res = await body();
+ expect(anomalyEvents()).toEqual([]);
+ // ★★面積の合計には入る★★(予定外も実際に清掃した広さ。D-155)
+ expect(res.total.clean_area).toBe(3000.5 + 10 + 30);
+ // ★★実施回数・完了率にも入れる★★(NULL と同じ。指示書BG §2-5。旧: 入れない)
+ expect(res.total.executed_count).toBe(5 + 2);
+ // ★0 の行も完了率用に取り寄せる★(旧: `> 0` で取り寄せなかった)
+ expect(matches(report(1, 0, "2026-09-11", 10, 20, 50), findManyCalls[0].where)).toBe(true);
+ // ★完了率は1回ごとの完了率の単純平均(予定外 10/20 = 50・30/40 = 75 も入る)★
+ expect(res.total.completion_rate).toBe((90 + 80 + 70 + 50 + 10 + 50 + 75) / 7);
+ // ★内訳の行にも予定外が入る★(行と合計は同じ施設ごとの集計から作る。d-watch-api.yaml の CleanAreaRow。2026-11-04)
+ expect(res.items.reduce((acc, r) => acc + r.executed_count, 0)).toBe(5 + 2);
+ });
+
+ it("#10 ★施設マスタに無い facility_id も番人が出す★(黙って消さない。B-4)", async () => {
+ data.reports.push(report(99, 1099, "2026-09-10", 50, 100, 50));
+ const res = await body();
+ // ★内訳にも全体合計にも入れない★(入れると「全体合計 ≠ 内訳の和」になる)
+ expect(res.items.some((r) => r.id === 99)).toBe(false);
+ expect(res.total.clean_area).toBe(3000.5);
+ expect(vi.mocked(logEvent)).toHaveBeenCalledWith(
+ "warn",
+ expect.objectContaining({
+ metric: CLEAN_AREA_ANOMALY_METRICS.unmatchedFacilities,
+ expected: 0,
+ actual: 1,
+ })
+ );
+ });
+
+ it("#11 異常が無ければ `data.anomaly` を1行も出さない", async () => {
+ await body();
+ expect(anomalyEvents()).toEqual([]);
+ });
+
+ it("#12 ★`judge.clean_area` に取り寄せた行数と所要時間を出す★(D-156 論点2)", async () => {
+ data.reports.push(report(1, null, "2026-09-11", 10, 20, 50));
+ await body();
+ const events = vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, payload]) => payload.event === "judge.clean_area");
+ expect(events).toHaveLength(1);
+ const [level, payload] = events[0];
+ expect(level).toBe("info");
+ expect(payload).toEqual(
+ expect.objectContaining({
+ event: "judge.clean_area",
+ route: "/api/clean-area",
+ method: "GET",
+ // ★NULL の行(予定外)も取り寄せて数える★(指示書BG §2-5。旧: 除いて5行)
+ rate_rows: 6,
+ total_executed_count: 6,
+ raw_total_completion_rate: (90 + 80 + 70 + 50 + 10 + 50) / 6,
+ })
+ );
+ expect(typeof (payload as { elapsed_ms: unknown }).elapsed_ms).toBe("number");
+ });
+
+ it("#13 ★完了率用の取り寄せは集計と同じ `where`★(予定外も取り寄せる。指示書BG §2-5。旧: `clean_timetable_id: { gt: 0 }` を足していた)", async () => {
+ await body("?date_from=2026-09-01&date_to=2026-09-01");
+ const stats = groupByCalls.find((c) => c.by[0] === "facility_id");
+ expect(findManyCalls).toHaveLength(1);
+ expect(findManyCalls[0].where).toEqual(stats?.where);
+ // ★予定の引き直しは `precheck = CLEAN_TIMETABLE_PRECHECK.main`★
+ expect(timetableCalls.every((c) => c.where.precheck === CLEAN_TIMETABLE_PRECHECK.main)).toBe(true);
+ });
+
+ it("#14 ★事前チェック・dangling の予定に紐づく行は実施回数・完了率に入らない(面積には入る)★", async () => {
+ data.precheckTimetableIds.add("1001");
+ data.danglingTimetableIds.add("1002");
+ const res = await body();
+ const f1 = res.items.find((r) => r.id === 1);
+ expect(f1?.clean_area).toBe(900 + 800.5);
+ expect(f1?.executed_count).toBe(0);
+ expect(f1?.completion_rate).toBeNull();
+ expect(res.total.executed_count).toBe(3);
+ });
+
+ it("★履歴(clean_timetable_histories)だけにある予定に紐づく行も実施回数・完了率に入る★(指示書AU 段階3b-1 の 12)", async () => {
+ const baseline = await body();
+ data.historyOnlyTimetableIds = new Set(["1001", "1004"]);
+ // ★旧(本表だけ)なら 1001 / 1004 は「本清掃に紐づかない行」になり、完了率・実施回数から黙って落ちていた★
+ expect(await body()).toEqual(baseline);
+ // ★2表に同じ引数★
+ expect(historyTimetableCalls).toEqual(timetableCalls);
+ });
+
+ it("#12 group_by が enum 外なら 422(DBを読まない)", async () => {
+ const res = await get("?group_by=facilities");
+ expect(res.status).toBe(422);
+ const err = (await res.json()) as ApiError;
+ expect(err.error.code).toBe("validation_failed");
+ expect(err.error.details).toEqual({
+ group_by: ["group / customer / facility のいずれかを指定してください"],
+ });
+ expect(groupByCalls).toHaveLength(0);
+ });
+
+ it("#13 date_from の形式違いは 422", async () => {
+ const res = await get("?date_from=2026/09/01");
+ expect(res.status).toBe(422);
+ const err = (await res.json()) as ApiError;
+ expect(err.error.details).toEqual({
+ date_from: ["YYYY-MM-DD形式で指定してください"],
+ });
+ });
+
+ it("#14 date_from > date_to は 422(黙って0件にしない)", async () => {
+ const res = await get("?date_from=2026-09-10&date_to=2026-09-01");
+ expect(res.status).toBe(422);
+ expect(groupByCalls).toHaveLength(0);
+ });
+
+ it("#15 ★日数の上限は無い★(10年ぶんを指定しても200。§2-3)", async () => {
+ const res = await get("?date_from=2016-01-01&date_to=2026-09-16");
+ expect(res.status).toBe(200);
+ });
+
+ it("#16 group_id=0 は 422(0 はセンチネルで有効なIDではない。D-011)", async () => {
+ const res = await get("?group_id=0");
+ expect(res.status).toBe(422);
+ expect(groupByCalls).toHaveLength(0);
+ });
+
+ it("#17 DBが落ちたら 500(共通のエラー形式・固定文言)", async () => {
+ data = {
+ get facilities(): StubFacility[] {
+ throw new Error("db down");
+ },
+ reports: [],
+ precheckTimetableIds: new Set(),
+ danglingTimetableIds: new Set(),
+ };
+ const res = await get();
+ expect(res.status).toBe(500);
+ const err = (await res.json()) as ApiError;
+ expect(err.error.code).toBe("internal_error");
+ expect(err.error.message).toBe("データの取得に失敗しました");
+ });
+});
+
+describe("GET /clean-area — 削除済みの施設・清掃エリア(指示書BB U1・表1 V17)", () => {
+ it("★集計・完了率の行・番人の3本が同じ `where` を受け取り、可視性が入っている★(施設マスタは絞らない — 罠7)", async () => {
+ const res = await get("");
+ expect(res.status).toBe(200);
+ const stats = groupByCalls.find((c) => c.by[0] === "facility_id");
+ const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id");
+ expect(stats?.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ expect(sentinel?.where).toEqual({ ...stats?.where, clean_timetable_id: { gt: BigInt(0) } });
+ // ★完了率の行は集計と同じ `where`(予定外も取り寄せる。指示書BG §2-5)★
+ expect(findManyCalls[0].where).toEqual(stats?.where);
+ });
+});
+
+describe("GET /clean-area — 存在しない日付は422(指示書BB 表8 Q6)", () => {
+ it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => {
+ const bad = await get("?date_from=2026-02-31");
+ const format = await get("?date_from=2026/02/03");
+ expect(bad.status).toBe(422);
+ expect(format.status).toBe(422);
+ expect(await bad.json()).toEqual(await format.json());
+ });
+});
diff --git a/app/api/clean-area/route.ts b/app/api/clean-area/route.ts
new file mode 100644
index 0000000..ac934d8
--- /dev/null
+++ b/app/api/clean-area/route.ts
@@ -0,0 +1,128 @@
+/**
+ * GET /clean-area — 清掃面積の集計(指示書AN ★段階4★ / §7-3)。
+ *
+ * ★仕様の正は `d-watch-api.yaml` の `/clean-area`★。形は
+ * `app/api/reports/facility-performance/route.ts` に合わせている(★新しい書き方を発明しない★)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★この1本の分担★★
+ * ------------------------------------------------------------------------
+ * - 検証 … `parseCleanAreaQuery()`(`lib/clean-area/query.ts`。★純粋関数★)
+ * - 施設の解決 … `resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()` →
+ * ★積集合★ `intersectFacilityIds()`(★どちらか片方を捨てない★)
+ * - 読み取り … `db.cleanArea()`(`USE_FIXTURES` の切替はデータ層が吸収する)
+ * - 集計 … `aggregateCleanArea()`(★段階2 の純粋関数★。★ここで割り算を書かない★)
+ * - ★★番人のログ★★ … `reportCleanAreaAnomalies()`(★下の注記★)
+ * - 取り寄せ量のログ … `judge.clean_area`(完了率用に運んだ行数と所要時間。D-156 論点2)
+ *
+ * ★認証はここに書かない★ — `proxy.ts` が全ルートで横断的に判定する(401 / 403)。
+ * ★`GET` だけ★。
+ */
+import { NextResponse } from "next/server";
+import { aggregateCleanArea } from "@/lib/clean-area/aggregate";
+import { reportCleanAreaAnomalies } from "@/lib/clean-area/anomaly";
+import { parseCleanAreaQuery } from "@/lib/clean-area/query";
+import { db } from "@/lib/db";
+import type { CleanAreaSourceResult } from "@/lib/db/clean-area-source";
+import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
+import { logEvent, withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, CleanAreaResponse } from "@/lib/types";
+
+const ROUTE = "/api/clean-area";
+
+function errorResponse(error: ApiError, status: number) {
+ return NextResponse.json(error, { status });
+}
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ const parsed = parseCleanAreaQuery(searchParams);
+ if (!parsed.ok) {
+ return errorResponse(parsed.error, parsed.status);
+ }
+ const query = parsed.query;
+
+ let source: CleanAreaSourceResult;
+ // ★データ層の所要時間★(`judge.clean_area` ログ用。D-156 論点2)
+ let elapsedMs = 0;
+ try {
+ // ★★施設名の照合と分類の絞り込みは**積集合(AND)**にする★★(指示書AK §6-2)—
+ // ★どちらか片方を捨てないこと★。★積が空なら `[]` = 0件★
+ // (★`null` にすると条件ごと消えて全件表示に化ける★。例外もテスト失敗も出ない)。
+ // ★`facility_ids` の分岐は必ず `!== null`★(落とし穴3)— それを守っているのは
+ // `db.cleanArea()` 側で、ここは解決結果をそのまま渡すだけ。
+ const [byName, byMasters] = await Promise.all([
+ resolveFacilityIdsByName(query.facilityName),
+ resolveFacilityIdsByMasters(query),
+ ]);
+ const startedAt = performance.now();
+ source = await db.cleanArea({
+ date_from: query.dateFrom,
+ date_to: query.dateTo,
+ facility_ids: intersectFacilityIds(byName, byMasters),
+ });
+ elapsedMs = performance.now() - startedAt;
+ } catch (e) {
+ const body: ApiError = {
+ error: {
+ code: "internal_error",
+ message: "データの取得に失敗しました",
+ details: null,
+ },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return errorResponse(body, 500);
+ }
+
+ // ここから下は純粋な集計。すべて try の外(`facility-performance` と同じ方針)。
+ // ★粒度は集計時にだけ効く★ — 材料(`source`)は粒度に依存しないので読み直さない。
+ const aggregate = aggregateCleanArea(source.stats, source.facilities, query.groupBy);
+
+ // ★★番人(指示書AN §2-5 / §6-3 / 落とし穴2)★★ — ★呼び忘れても例外もテスト失敗も
+ // 出ない★(数字が静かに大きくなるだけ)ので、★消さないこと★。
+ // ★ログはルート層で出す★ — `DataAnomalyLogEvent` の `route` / `method` を埋められるのはルート層だけで
+ // (型は D-178 で任意になったが、埋められる層で出すのが原則)、
+ // データ層はどのルートから呼ばれたかを知らない(`lib/clean-area/anomaly.ts` の docstring)。
+ // ★異常が無ければ1行も出ない★ので、毎回の呼び出しでログが埋まることはない。
+ reportCleanAreaAnomalies({
+ route: ROUTE,
+ method: "GET",
+ duplicated_timetable_count: source.duplicated_timetable_count,
+ unmatched_facility_ids: aggregate.unmatched_facility_ids,
+ });
+
+ // ★★取り寄せた行数と所要時間を残す★★(D-156 論点2)— 完了率・実施回数の材料は行を運ぶ
+ // (期間を指定しないと全期間)ので、実データで重いかどうかをこのログで見る。
+ // 記録条件は設けない(`judge.areas` と同じ流儀。この画面の1回の表示で1行)
+ logEvent("info", {
+ event: "judge.clean_area",
+ route: ROUTE,
+ method: "GET",
+ rate_rows: source.rate_rows,
+ elapsed_ms: Math.round(elapsedMs),
+ total_executed_count: aggregate.total.executed_count,
+ raw_total_completion_rate: aggregate.total.completion_rate,
+ });
+
+ // ★`items` は全件返す★(★`limit` を持たない★。指示書AN §7-2 / 要レスキュー D-137 と同じ作法)—
+ // ★何件見せるかは画面の責務★(`cleanAreaVisibility()` / `CLEAN_AREA_INITIAL_VISIBLE`)。
+ // ★`meta.total` は `items` の件数★(★清掃回数ではない★。清掃回数は `total.executed_count`)。
+ // `page` / `per_page` パラメータを持たない一覧なので `page` は常に1、
+ // `per_page` は1ページに全件入る= `total` と同じ(`d-watch-api.yaml` の meta の説明)。
+ // ★★`unmatched_facility_ids` は返さない★★ — 異常の通知先はログであって画面ではない。
+ // ★★単位(`AREA_UNIT`)もレスポンスに入れない★★(指示書AN §2-4 / 落とし穴6)—
+ // ★`"㎡"` を持つのは `lib/config` の1箇所だけ★で、付けるのは画面。
+ // API が単位を返すと、TeamWorks の回答が違ったときに直す場所が散らばる。
+ const total = aggregate.items.length;
+ const body: CleanAreaResponse = {
+ total: aggregate.total,
+ items: aggregate.items,
+ meta: { total, page: 1, per_page: total },
+ };
+ return NextResponse.json(body);
+ });
+}
diff --git "a/app/api/clean-logs/\133id\135/__tests__/route.test.ts" "b/app/api/clean-logs/\133id\135/__tests__/route.test.ts"
new file mode 100644
index 0000000..3958bd3
--- /dev/null
+++ "b/app/api/clean-logs/\133id\135/__tests__/route.test.ts"
@@ -0,0 +1,947 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts`
+// の冒頭コメントと同じ理由 — `it()` の中で `await import()` すると最初の1本が
+// モジュール読み込みを丸ごと抱え込み、フルスイートで不定期にタイムアウトする)。
+import { GET as ADVICE } from "@/app/api/clean-logs/[id]/advice/route";
+import { GET as AREAS } from "@/app/api/clean-logs/[id]/areas/route";
+import { buildAreaCompletions, selectAreaRowsForLog } from "@/lib/clean-logs/areas";
+import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
+import { db } from "@/lib/db";
+import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions";
+import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
+import { logEvent } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice";
+import type { JsonStore } from "@/lib/settings/json-store";
+import type { JudgeAreasLogEvent } from "@/lib/logger/events";
+import { capConsecutiveDays } from "@/lib/format/consecutive-days";
+import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
+import type {
+ AreaCompletionRow,
+ AreaCompletionsResponse,
+ CleanLogAdviceResponse,
+} from "@/lib/types";
+
+/**
+ * ★★指示書AC 段階3 の期待値表★★(`/clean-logs/{id}/areas` / `/clean-logs/{id}/advice`)。
+ *
+ * | # | 入力 | 期待 |
+ * |---|---|---|
+ * | 1 | 存在する `{id}` | ★段階0(=全件読み + 純粋関数)と同じレスポンス★ |
+ * | 2 | 存在しない `{id}` | ★段階0 と同じ 404★ |
+ * | 3 | 同じ施設・同じ日に複数エリア | ★全エリアが返る★(`{id}` は「施設と日付の入口」。指示書Z 決定1) |
+ * | 4 | `filter=problem` | ★`total_count` / `overall_*` は絞る**前**の値★(指示書Z 決定8 / 決定12) |
+ * | 5 | 読み込み行数 | ★全件読みから数十行に減る★ |
+ *
+ * ------------------------------------------------------------------------
+ * ★★「段階0 と同じ」をどう固定するか★★
+ * ------------------------------------------------------------------------
+ * ★期待値を数値で書き写さないこと★ — 書き写すと、実装とテストが同時にずれたときに
+ * 気づけません。代わりに ★★同じデータに対する「全件版 + 純粋関数」を毎回その場で計算し、
+ * SQL で絞った結果と突き合わせます★★(指示書AC G-3「等価テスト」)。
+ * ★これが「レスポンスを1バイトも変えていない」ことの検出手段★(禁止事項14)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に
+ * 固定して ★`prismaSource` の経路★ を走らせます
+ * (`app/api/clean-logs/__tests__/route.test.ts` と同じ作法)。
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubTimetable {
+ id: bigint;
+ facility_id: bigint;
+ section_id: bigint;
+ parent_id: bigint;
+ start_at: Date;
+ /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
+ end_at: Date;
+ precheck: number;
+ /** ★`deleted_at` は省略可(省略は生きている)。削除済みの行を作るテストだけが入れる(指示書BB U1)★ */
+ facility: { facility_name: string; deleted_at?: Date | null };
+ section: {
+ deleted_at?: Date | null;
+ section_name: string;
+ robots: {
+ serial_number: string;
+ device: { maker_id: number; model_name: string };
+ }[];
+ };
+}
+
+/**
+ * `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。
+ * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
+ * ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3)
+ */
+interface StubReport {
+ clean_timetable_id: bigint | null;
+ clean_ratio: number;
+ /** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */
+ status: number;
+ clean_area: number;
+ task_area: number;
+ started_at: Date;
+ ended_at: Date | null;
+}
+
+interface StubErrorReport {
+ clean_timetable_id: bigint | null;
+ error_type: string;
+ errored_at: Date;
+}
+
+interface StubData {
+ timetables: StubTimetable[];
+ /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
+ historyTimetables?: StubTimetable[];
+ reports: StubReport[];
+ errorReports: StubErrorReport[];
+ facilities: { id: bigint; facility_name: string; deleted_at: Date | null }[];
+}
+
+/** 発行されたクエリの記録(★どんな `where` が降りたか★ / ★何行読んだか★) */
+interface CallRecord {
+ model: string;
+ args: Record;
+ rows: number;
+}
+
+let calls: CallRecord[] = [];
+let data: StubData;
+
+function sameId(a: unknown, b: unknown): boolean {
+ if (a === null || a === undefined || b === null || b === undefined) return false;
+ return String(a) === String(b);
+}
+
+/**
+ * Prisma の `where` のうち★本作業が使う分だけ★を再現する。
+ * ★`in: undefined` は例外にする★ — Prisma では「条件が消えて全件」になるため(罠1)。
+ */
+function matchesWhere(row: Record, where: unknown): boolean {
+ if (!where) return true;
+ for (const [field, cond] of Object.entries(where as Record)) {
+ const value = row[field];
+ if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
+ const c = cond as Record;
+ if ("in" in c) {
+ if (c.in === undefined) {
+ throw new Error(
+ `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
+ );
+ }
+ if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
+ }
+ if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
+ if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
+ // ★リレーションの `{ deleted_at: null }`(指示書BB U1)は、その行の `deleted_at`(省略は生きている)で見る★
+ if ("deleted_at" in c && c.deleted_at === null) {
+ const target =
+ value !== null && typeof value === "object" ? ((value as { deleted_at?: Date | null }).deleted_at ?? null) : value;
+ if (target !== null && target !== undefined) return false;
+ }
+ } else if (!sameId(value, cond) && value !== cond) {
+ // ★`bigint` と `number` をまたいで比較できるようにする★(`id` / `facility_id` は BigInt 列)
+ return false;
+ }
+ }
+ return true;
+}
+
+function project(row: Record, select: unknown): Record {
+ if (!select) return row;
+ const out: Record = {};
+ for (const key of Object.keys(select as Record)) out[key] = row[key];
+ return out;
+}
+
+function findMany(
+ model: string,
+ rows: Record[],
+ args: Record | undefined
+) {
+ const filtered = rows.filter((row) => matchesWhere(row, args?.where));
+ calls.push({ model, args: args ?? {}, rows: filtered.length });
+ return filtered.map((row) => project(row, args?.select));
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+// ★`api.error`(指示書BB 表8 T3)を数えるため `logApiError` を差し替える★
+vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
+
+// ★`judge.areas` ログの中身を見るため `logEvent` だけ差し替える★(指示書AR 段階4)。
+// ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない。`app/api/clean-area` のテストと同じ作法)
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ cleanTimetable: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTimetable", data.timetables as unknown as Record[], args),
+ },
+ // ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★
+ cleanTimetableHistory: {
+ findMany: async (args?: Record) =>
+ findMany(
+ "cleanTimetableHistory",
+ (data.historyTimetables ?? []) as unknown as Record[],
+ args
+ ),
+ },
+ cleanTaskReport: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTaskReport", data.reports as unknown as Record[], args),
+ },
+ robotErrorReport: {
+ findMany: async (args?: Record) =>
+ findMany("robotErrorReport", data.errorReports as unknown as Record[], args),
+ },
+ facility: {
+ findMany: async (args?: Record) =>
+ findMany("facility", data.facilities as unknown as Record[], args),
+ },
+ }),
+}));
+
+// ---------------------------------------------------------------- 合成データ
+
+/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
+function dbWallClock(jstIso: string): Date {
+ return new Date(jstIso.replace("+09:00", "Z"));
+}
+
+/** ★実在の店名・実在のエリア名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
+const FACILITIES = [
+ { id: 1, name: "ドソ・キホーテ戸塚" },
+ { id: 2, name: "ドソ・キホーテ四街道" },
+ { id: 3, name: "フレッシュタウソ大和店" },
+];
+
+/** ★1施設あたり3エリア★(= 同じ施設・同じ日に複数エリアが並ぶ。#3) */
+const SECTIONS_PER_FACILITY = 3;
+
+/** ★全件版が「明らかに多い」と言える量にする★(#5 の読み込み行数の比較に要る) */
+const DAYS = 30;
+
+/** 対象日(★最終日★)。全件版と単一ID版の突き合わせはこの日で行う */
+const TARGET_DATE = "2026-09-30";
+
+function dayIso(index: number): string {
+ // 2026-09-01 から index 日ぶん進める(DAYS=30 なので 09-01〜09-30)
+ const d = new Date(Date.UTC(2026, 8, 1 + index));
+ return d.toISOString().slice(0, 10);
+}
+
+/**
+ * `clean_timetables.id` の採番。★日とエリアから一意に決まる形にする★
+ * (テスト側で「この日のこのエリアの id」を計算できるようにするため)。
+ */
+function timetableId(facilityId: number, dayIndex: number, sectionIndex: number): number {
+ return facilityId * 100_000 + dayIndex * 10 + sectionIndex;
+}
+
+/**
+ * 完了率。★エリアごとに変える★ —
+ * section 0 → 95(◎)/ section 1 → 5(×・問題)/ section 2 → `null`(未実施・問題)
+ * ★#4(`filter=problem` で絞られる行と絞られない行)を両方作るため★。
+ */
+function ratioFor(sectionIndex: number): number | null {
+ if (sectionIndex === 0) return 95;
+ if (sectionIndex === 1) return 5;
+ return null;
+}
+
+function buildData(): StubData {
+ const timetables: StubTimetable[] = [];
+ const reports: StubReport[] = [];
+ const errorReports: StubErrorReport[] = [];
+
+ for (const facility of FACILITIES) {
+ for (let dayIndex = 0; dayIndex < DAYS; dayIndex++) {
+ for (let s = 0; s < SECTIONS_PER_FACILITY; s++) {
+ const id = timetableId(facility.id, dayIndex, s);
+ timetables.push({
+ id: BigInt(id),
+ facility_id: BigInt(facility.id),
+ section_id: BigInt(facility.id * 10 + s),
+ parent_id: BigInt(0),
+ // ★★壁時計 20:00 の清掃★★(`toDbJst()` の付け忘れを検出する。指示書AC C-6。
+ // ★ローカルDB(深夜0〜3時しかない)では検出できない時間帯★)
+ start_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
+ end_at: new Date(dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`).getTime() + 60 * 60_000),
+ precheck: CLEAN_TIMETABLE_PRECHECK.main,
+ facility: { facility_name: facility.name },
+ section: {
+ section_name: `エリア${facility.id}-${s + 1}`,
+ robots: [
+ {
+ serial_number: `8111R5628060${facility.id}${s}`,
+ device: { maker_id: 1, model_name: "CC1" },
+ },
+ ],
+ },
+ });
+
+ const ratio = ratioFor(s);
+ // ★★未実施(`ratio === null`)はレポートの行を作らない★★(指示書AQ §1-1。
+ // 旧取得元の「`clean_error = 1` の行」に当たるものは `clean_task_reports` に無い)
+ if (ratio === null) continue;
+ reports.push({
+ clean_timetable_id: BigInt(id),
+ clean_ratio: ratio,
+ // ★生値の一例★(意味は第10回 N-2 で受領済み。指示書AQ §1-2)
+ status: 4,
+ // ★面積は完了率と独立に置く★(面積は表示用に残る。完了率の計算には使わない。
+ // 指示書AQ 決定4 / §1-3)
+ clean_area: ratio * 10,
+ task_area: 1_000,
+ started_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
+ ended_at: dbWallClock(`${dayIso(dayIndex)}T21:00:00+09:00`),
+ });
+ }
+ }
+ }
+
+ // ★事前チェック行(本清掃の子)★ — `status_1h` / `status_30m` の材料。
+ // ★対象日の1件にだけ付ける(`parent_id` の別クエリが走ることの確認も兼ねる)★
+ const targetId = timetableId(1, DAYS - 1, 0);
+ for (const [precheck, minutes] of [
+ [60, "19:00"],
+ [30, "19:30"],
+ ] as const) {
+ timetables.push({
+ id: BigInt(targetId * 10 + precheck),
+ facility_id: BigInt(1),
+ section_id: BigInt(10),
+ parent_id: BigInt(targetId),
+ start_at: dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`),
+ end_at: new Date(dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`).getTime() + 60 * 60_000),
+ precheck,
+ facility: { facility_name: FACILITIES[0].name },
+ section: {
+ section_name: "エリア1-1",
+ robots: [
+ { serial_number: "8111R56280601", device: { maker_id: 1, model_name: "CC1" } },
+ ],
+ },
+ });
+ }
+ errorReports.push({
+ clean_timetable_id: BigInt(targetId * 10 + 60),
+ error_type: "EmergencyStop",
+ errored_at: dbWallClock(`${TARGET_DATE}T19:05:00+09:00`),
+ });
+
+ return {
+ timetables,
+ reports,
+ errorReports,
+ facilities: FACILITIES.map((f) => ({
+ id: BigInt(f.id),
+ facility_name: f.name,
+ deleted_at: null,
+ })),
+ };
+}
+
+/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */
+const NOW = new Date("2026-10-15T00:00:00+09:00");
+
+/** ★対象の清掃ログ★(施設1・最終日・エリア1)。同じ施設・同じ日に3エリアある */
+const TARGET_ID = timetableId(1, DAYS - 1, 0);
+
+const ALL_RANGE = { facility_id: null, date: null } as const;
+
+async function areas(id: number, search = ""): Promise {
+ return AREAS(new Request(`http://localhost/api/clean-logs/${id}/areas${search}`), {
+ params: Promise.resolve({ id: String(id) }),
+ });
+}
+
+async function advice(id: number): Promise {
+ return ADVICE(new Request(`http://localhost/api/clean-logs/${id}/advice`), {
+ params: Promise.resolve({ id: String(id) }),
+ });
+}
+
+/**
+ * ★連続日数用のクエリ★(`loadConsecutiveInputs()` が発行する2本)。
+ *
+ * ★★`select` の中身で見分けること★★ — 段階6 でこの2本にも `where` が付いたので、
+ * ★「`where` を持たない」では区別できなくなった★。
+ * ★他の `select` 付きクエリ(事前チェック行の `parent_id` / `precheck` / `id`)と
+ * 取り違えないように、この関数が読む列そのもので判定する★。
+ */
+function consecutiveCalls(): CallRecord[] {
+ return calls.filter((c) => {
+ const select = (c.args as { select?: Record }).select;
+ if (!select) return false;
+ // ★予定は2表から読む(指示書AU 段階3b)ので、履歴側の連続日数用クエリも同じ扱い★
+ if (c.model === "cleanTimetable" || c.model === "cleanTimetableHistory") {
+ return select.facility_id === true && select.start_at === true;
+ }
+ if (c.model === "cleanTaskReport") return select.clean_ratio === true;
+ return false;
+ });
+}
+
+/** 連続日数用を除いた読み込み行数(★段階3 で減らしたいのはこちら★) */
+function rowsReadExcludingConsecutive(): number {
+ return calls
+ .filter((c) => !consecutiveCalls().includes(c))
+ .reduce((sum, c) => sum + c.rows, 0);
+}
+
+beforeEach(() => {
+ data = buildData();
+ calls = [];
+ vi.mocked(logEvent).mockClear();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(NOW);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------- データ層
+
+describe("db.cleanLogs({ id }) — 単一IDの取得経路(指示書AC 段階3)", () => {
+ it("★★#1 `id` で引いた1件が、全件版の同じ行と**完全一致**する★★(★連続日数まで★)", async () => {
+ const all = await db.cleanLogs({
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ const expected = all.find((l) => l.id === TARGET_ID);
+ expect(expected).toBeDefined();
+
+ const one = await db.cleanLogs({
+ id: TARGET_ID,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ expect(one).toHaveLength(1);
+ // ★★1バイトも変わっていないこと★★(禁止事項14)— `consecutive_problem_days` /
+ // `escalation_level` / `status_1h` / `status_30m` まで含めて比べる
+ expect(one[0]).toEqual(expected);
+ // ★連続日数は実際に 1 より大きい★(= 全期間を読めている。★C-2 の地雷の検出★)
+ expect(one[0].consecutive_problem_days).toBeGreaterThan(1);
+ });
+
+ it("★主クエリの `where` に `id` が降りている★(★メモリ上の `find()` に戻っていない★)", async () => {
+ calls = [];
+ await db.cleanLogs({
+ id: TARGET_ID,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args);
+ expect(main).toBeDefined();
+ expect((main!.args.where as Record).id).toBe(BigInt(TARGET_ID));
+ // ★★1行しか読んでいないこと★★
+ expect(main!.rows).toBe(1);
+ });
+
+ it("★存在しない `id` → 0件★(★`id: null` に化けて全件になっていないことの検出★)", async () => {
+ const rows = await db.cleanLogs({
+ id: 999_999_999,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ expect(rows).toEqual([]);
+ });
+
+ it("★★単一ID取得では連続日数用のクエリに日付の下限が付かない★★(=全期間のまま。C-2 / 段階6 の申し送り)", async () => {
+ calls = [];
+ await db.cleanLogs({
+ id: TARGET_ID,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ // ★`clean_timetables` / `clean_timetable_histories`(指示書AU 段階3b)/ `clean_task_reports` の2本
+ // (予定の id で引く本と、予定外タスクを読む本。指示書BG §2-3)の4本★
+ expect(consecutiveCalls()).toHaveLength(4);
+ // ★予定外タスクの本も期間の下限が付かない★(`started_at` のキーごと無い)
+ const unscheduledCall = consecutiveCalls().find(
+ (c) => c.model === "cleanTaskReport" && (c.args as { select: Record }).select.facility_id === true
+ );
+ expect((unscheduledCall?.args.where as Record).started_at).toBeUndefined();
+ // ★履歴にも同じ引数★
+ expect(consecutiveCalls().find((c) => c.model === "cleanTimetableHistory")?.args).toEqual(
+ consecutiveCalls().find((c) => c.model === "cleanTimetable")?.args
+ );
+ // ★★対象ログの日付は引くまで分からないので、この経路だけは遡り範囲を決められない★★
+ // (段階6 で有界にできたのは `date_from` が来る経路。★申し送りに残してある★)
+ const timetableCall = consecutiveCalls().find((c) => c.model === "cleanTimetable");
+ expect((timetableCall?.args.where as Record).start_at).toBeUndefined();
+ });
+});
+
+describe("db.areaCompletions({ facility_id, date }) — 施設・日付をSQLに降ろす(段階3)", () => {
+ it("★★SQL版 = 全件版 + `selectAreaRowsForLog()`(完全一致)★★(指示書AC G-3)", async () => {
+ const all = await db.areaCompletions(ALL_RANGE);
+ const expected = selectAreaRowsForLog(all, { facility_id: 1, date: TARGET_DATE });
+
+ const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ expect(scoped).toEqual(expected);
+ // ★3エリアぶん(#3)★
+ expect(scoped).toHaveLength(SECTIONS_PER_FACILITY);
+ });
+
+ it("★★`where` に施設ID・日付・本清掃が降りている★★(★壁時計値に直してあること★。C-6)", async () => {
+ calls = [];
+ await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ const main = calls.find((c) => c.model === "cleanTimetable")!;
+ const where = main.args.where as Record;
+ expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
+ expect(where.facility_id).toBe(BigInt(1));
+ const startAt = where.start_at as { gte: Date; lt: Date };
+ // ★★`toDbJst()` を通した壁時計値であること★★(★生の `parseDate()` なら9時間ずれる★)
+ expect(startAt.gte.toISOString()).toBe(`${TARGET_DATE}T00:00:00.000Z`);
+ expect(startAt.lt.toISOString()).toBe("2026-10-01T00:00:00.000Z");
+ });
+
+ it("★★壁時計 20:00 の清掃がその日の範囲に**含まれる**★★(C-6。★ローカルDBでは検出できない★)", async () => {
+ const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ expect(scoped.every((r) => r.date === TARGET_DATE)).toBe(true);
+ // ★前日・翌日の指定では出てこない★
+ expect(await db.areaCompletions({ facility_id: 1, date: "2026-09-29" })).not.toEqual(scoped);
+ expect(await db.areaCompletions({ facility_id: 1, date: "2026-10-01" })).toEqual([]);
+ });
+
+ it("★レポートも本清掃の id で引き直す(全件読みに戻さない)★", async () => {
+ calls = [];
+ await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ const report = calls.find((c) => c.model === "cleanTaskReport")!;
+ expect(report.args.where).toBeDefined();
+ // ★未実施のエリア(section 2)はレポートの行が無いので1行少ない★(指示書AQ §1-1 の仕様変更。
+ // 旧取得元は未実施でも `clean_error = 1` の行があったので SECTIONS_PER_FACILITY だった)
+ expect(report.rows).toBe(SECTIONS_PER_FACILITY - 1);
+ });
+
+ it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
+ calls = [];
+ const rows = await db.areaCompletions({ facility_id: 1, date: "2020-01-01" });
+ expect(rows).toEqual([]);
+ expect(calls.filter((c) => c.model === "cleanTaskReport")).toEqual([]);
+ });
+
+ it("★fixtures 版と同じ意味(`null` は「絞らない」)★(禁止事項6)", async () => {
+ const all = await db.areaCompletions(ALL_RANGE);
+ const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ expect(filterFixtureAreaCompletions(all, { facility_id: 1, date: TARGET_DATE })).toEqual(
+ scoped
+ );
+ expect(filterFixtureAreaCompletions(all, ALL_RANGE)).toHaveLength(all.length);
+ });
+});
+
+// ---------------------------------------------------------------- /clean-logs/{id}/areas
+
+describe("GET /clean-logs/{id}/areas(指示書AC 段階3 期待値表)", () => {
+ /** ★段階0(全件読み + 純粋関数)と同じ計算★ をその場でやり直す */
+ async function expectedBody(
+ id: number,
+ filter: "problem" | "all"
+ ): Promise {
+ const logs = await db.cleanLogs({
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ const log = logs.find((l) => l.id === id)!;
+ const all: AreaCompletionRow[] = await db.areaCompletions(ALL_RANGE);
+ const selected = selectAreaRowsForLog(all, {
+ facility_id: log.facility_id,
+ date: log.scheduled_at.slice(0, 10),
+ });
+ return buildAreaCompletions(selected, filter);
+ }
+
+ it("★★#1 存在する `{id}` → 段階0 と同じレスポンス★★", async () => {
+ const expected = await expectedBody(TARGET_ID, "problem");
+ const res = await areas(TARGET_ID);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as AreaCompletionsResponse;
+ // ★★`items` / `overall_completion_rate` / `total_count` / `problem_count` が全一致★★
+ expect(body).toEqual(expected);
+ });
+
+ it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
+ const res = await areas(999_999_999);
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "not_found",
+ message: "指定された清掃ログが見つかりません",
+ details: null,
+ },
+ });
+ });
+
+ it("★#2(構造)404 のときは `areaCompletions()` を引かない★", async () => {
+ calls = [];
+ await areas(999_999_999);
+ // ★連続日数用の `clean_task_reports` は走るが、エリア用の引き直しは無い★
+ // (★段階6 で連続日数用のクエリにも `where` が付いたので、そちらは除いて見る★)
+ expect(
+ calls.filter(
+ (c) =>
+ c.model === "cleanTaskReport" &&
+ "where" in c.args &&
+ !consecutiveCalls().includes(c)
+ )
+ ).toEqual([]);
+ });
+
+ it("★★#3 同じ施設・同じ日の**全エリア**が返る★★(`{id}` はエリアではなく入口。指示書Z 決定1)", async () => {
+ const res = await areas(TARGET_ID, "?filter=all");
+ const body = (await res.json()) as AreaCompletionsResponse;
+ expect(body.total_count).toBe(SECTIONS_PER_FACILITY);
+ expect(body.items.map((i) => i.section_name).sort()).toEqual([
+ "エリア1-1",
+ "エリア1-2",
+ "エリア1-3",
+ ]);
+ // ★自分自身(`{id}` の行)も含まれている★
+ expect(body.items.some((i) => i.section_id === 10)).toBe(true);
+ });
+
+ it("★#3 別のエリアの `{id}` で開いても同じ3エリアが返る★(入口が変わるだけ)", async () => {
+ const other = timetableId(1, DAYS - 1, 1);
+ const a = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
+ const b = (await (await areas(other, "?filter=all")).json()) as AreaCompletionsResponse;
+ expect(b).toEqual(a);
+ });
+
+ it("★★#4 `filter=problem` でも `total_count` / `overall_*` は絞る**前**の値★★(決定8 / 決定12)", async () => {
+ const all = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
+ const problem = (await (
+ await areas(TARGET_ID, "?filter=problem")
+ ).json()) as AreaCompletionsResponse;
+
+ expect(problem.total_count).toBe(all.total_count);
+ expect(problem.overall_completion_rate).toBe(all.overall_completion_rate);
+ expect(problem.overall_grade).toBe(all.overall_grade);
+ expect(problem.problem_count).toBe(all.problem_count);
+ // ★★`items` だけが絞られる★★(◎のエリアが落ちる)
+ expect(problem.items.length).toBeLessThan(all.items.length);
+ expect(problem.items).toEqual(all.items.filter((i) => i.grade === "×"));
+ });
+
+ it("★★#5 読み込み行数が「全件」から数十行に減る★★", async () => {
+ // ★全件版が読む行数(段階0 相当)★
+ calls = [];
+ await db.cleanLogs({ id: null, date_from: null, date_to: null, facility_ids: null });
+ await db.areaCompletions(ALL_RANGE);
+ const before = calls.reduce((sum, c) => sum + c.rows, 0);
+
+ calls = [];
+ await areas(TARGET_ID);
+ const after = calls.reduce((sum, c) => sum + c.rows, 0);
+
+ // ★★連続日数用の全期間クエリ(段階6 で有界にする)を除いた行数★★ —
+ // ★段階3 で減らせるのはこちら★。★ここが数十行に収まること★
+ const scoped = rowsReadExcludingConsecutive();
+ expect(scoped).toBeLessThan(50);
+ // ★全件版より確実に少ない★(合成データは本清掃だけで
+ // 3施設 × 30日 × 3エリア = 270行ある)
+ expect(after).toBeLessThan(before);
+ expect(before).toBeGreaterThan(500);
+ });
+});
+
+// ---------------------------------------------------------------- judge.areas ログ
+
+describe("GET /clean-logs/{id}/areas — judge.areas ログの分子・分母(指示書AR 段階4)", () => {
+ /** 直近の呼び出しで出た `judge.areas` を1件だけ取り出す */
+ function judgeAreasEvent(): JudgeAreasLogEvent {
+ const events = vi
+ .mocked(logEvent)
+ .mock.calls.map((c) => c[1])
+ .filter((p): p is JudgeAreasLogEvent => p.event === "judge.areas");
+ expect(events).toHaveLength(1);
+ return events[0];
+ }
+
+ it("★`raw_overall_completion_rate` が `overall_completion_rate_sum / overall_completion_rate_count` と一致する★", async () => {
+ const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
+ const ev = judgeAreasEvent();
+ expect(ev.raw_overall_completion_rate).toBe(
+ ev.overall_completion_rate_sum / ev.overall_completion_rate_count
+ );
+ // ★画面(レスポンス)の全体率とも一致する★
+ expect(ev.raw_overall_completion_rate).toBe(body.overall_completion_rate);
+ });
+
+ it("★未実施(完了率 null)の行は分子にも分母にも入らない★", async () => {
+ await areas(TARGET_ID, "?filter=all");
+ const ev = judgeAreasEvent();
+ // 対象日・施設1の3エリア: 95 / 5 / null(未実施)→ 分子 100・分母 2(★3 ではない★)
+ expect(ev.total_count).toBe(SECTIONS_PER_FACILITY);
+ expect(ev.overall_completion_rate_sum).toBe(95 + 5);
+ expect(ev.overall_completion_rate_count).toBe(2);
+ expect(ev.raw_overall_completion_rate).toBe(50);
+ });
+
+ it("★`filter=problem` でも分子・分母は絞る前の行で数える★(決定8 / 決定12)", async () => {
+ await areas(TARGET_ID, "?filter=problem");
+ const ev = judgeAreasEvent();
+ expect(ev.overall_completion_rate_sum).toBe(100);
+ expect(ev.overall_completion_rate_count).toBe(2);
+ });
+
+ it("★面積の合計(`overall_clean_area` / `overall_task_area`)はログに出さない★", async () => {
+ await areas(TARGET_ID);
+ const ev = judgeAreasEvent() as unknown as Record;
+ expect("overall_clean_area" in ev).toBe(false);
+ expect("overall_task_area" in ev).toBe(false);
+ // ★式の名前を入れた旧名(指示書AR 段階4)も出さない★(指示書AS D8 で改名)
+ expect("overall_clean_ratio_sum" in ev).toBe(false);
+ expect("overall_clean_ratio_count" in ev).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------- /clean-logs/{id}/advice
+
+describe("GET /clean-logs/{id}/advice(指示書AC 段階3)", () => {
+ it("★★#1 存在する `{id}` → 1件だけ引いても中身が変わらない★★", async () => {
+ calls = [];
+ const res = await advice(TARGET_ID);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as CleanLogAdviceResponse;
+
+ // ★★全件版の同じ行から作った入力と一致すること★★(★連続日数が壊れていない証拠★。C-2)
+ const all = await db.cleanLogs({
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ const log = all.find((l) => l.id === TARGET_ID)!;
+ // ★レスポンスに載るのは表示上限をかけた値★(指示書AF 段階6-2)なので、
+ // ★ルートと同じ手順で期待値を作る★(数字を書き写さない)
+ const capped = capConsecutiveDays(log.consecutive_problem_days, await loadConsecutiveDaysMax());
+ expect(body.consecutive_problem_days).toBe(capped.days);
+ expect(body.consecutive_problem_days_capped).toBe(capped.capped);
+ // ★★判定(L1〜L3)は上限をかける**前**の生値で行う★★(指示書AF 禁止事項9)
+ expect(body.escalation_level).toBe(log.escalation_level);
+ // ★連続日数が 1 より大きい=全期間を読めている(★C-2 の地雷の検出★)★
+ expect(log.consecutive_problem_days).toBeGreaterThan(1);
+ expect(body.advices.length).toBeGreaterThan(0);
+ });
+
+ it("★主クエリが1行しか読んでいない★(★全件読み + `find()` に戻っていない★)", async () => {
+ calls = [];
+ await advice(TARGET_ID);
+ const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
+ expect((main.args.where as Record).id).toBe(BigInt(TARGET_ID));
+ expect(main.rows).toBe(1);
+ // ★連続日数用の全期間クエリを除けば数十行★
+ expect(rowsReadExcludingConsecutive()).toBeLessThan(50);
+ });
+
+ it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
+ const res = await advice(999_999_999);
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "not_found",
+ message: "指定された清掃ログが見つかりません",
+ details: null,
+ },
+ });
+ });
+
+ it("★`{id}` の形式が不正なら 422(`db` まで来ない)★", async () => {
+ calls = [];
+ const res = await ADVICE(new Request("http://localhost/api/clean-logs/1.9/advice"), {
+ params: Promise.resolve({ id: "1.9" }),
+ });
+ expect(res.status).toBe(422);
+ expect(calls).toEqual([]);
+ });
+});
+
+describe("db.areaCompletions / db.cleanLogs({ id }) — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
+ /** ★履歴だけにある、対象と同じ施設・同じ日のエリア★ */
+ const HISTORY_ID = 9_000_001;
+ function historyRow(): StubTimetable {
+ return {
+ id: BigInt(HISTORY_ID),
+ facility_id: BigInt(1),
+ section_id: BigInt(19),
+ parent_id: BigInt(0),
+ start_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
+ end_at: new Date(dbWallClock(`${TARGET_DATE}T20:00:00+09:00`).getTime() + 60 * 60_000),
+ precheck: CLEAN_TIMETABLE_PRECHECK.main,
+ facility: { facility_name: FACILITIES[0].name },
+ section: {
+ section_name: "エリア1-9",
+ robots: [{ serial_number: "8111R56280619", device: { maker_id: 1, model_name: "CC1" } }],
+ },
+ };
+ }
+
+ it("★履歴だけの予定がエリア別完了率に出る★。2表に同じ引数が渡る", async () => {
+ data.historyTimetables = [historyRow()];
+ data.reports.push({
+ clean_timetable_id: BigInt(HISTORY_ID),
+ clean_ratio: 95,
+ status: 4,
+ clean_area: 950,
+ task_area: 1_000,
+ started_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
+ ended_at: dbWallClock(`${TARGET_DATE}T21:00:00+09:00`),
+ });
+ calls = [];
+ const rows = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
+ const row = rows.find((r) => r.clean_log_id === HISTORY_ID);
+ expect(row).toMatchObject({ section_name: "エリア1-9", completion_rate: 95 });
+ const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
+ const history = calls.find((c) => c.model === "cleanTimetableHistory" && "include" in c.args)!;
+ expect(history.args).toEqual(main.args);
+ });
+
+ it("★履歴だけの予定を id で1件引ける★(`/clean-logs/{id}/advice` / `/{id}/areas` の入口)", async () => {
+ data.historyTimetables = [historyRow()];
+ const one = await db.cleanLogs({ id: HISTORY_ID, date_from: null, date_to: null, facility_ids: null });
+ expect(one.map((l) => l.id)).toEqual([HISTORY_ID]);
+ });
+});
+
+describe("削除済みの施設・清掃エリア(指示書BB U1・表1 V2/V3)", () => {
+ const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");
+
+ function markDeleted(id: number, what: "facility" | "section") {
+ data.timetables = data.timetables.map((t) =>
+ Number(t.id) !== id
+ ? t
+ : what === "facility"
+ ? { ...t, facility: { ...t.facility, deleted_at: DELETED } }
+ : { ...t, section: { ...t.section, deleted_at: DELETED } }
+ );
+ }
+
+ it.each(["facility", "section"] as const)(
+ "★V2(%s が削除済み): 見えない行の `{id}` は advice / areas とも404★",
+ async (what) => {
+ markDeleted(TARGET_ID, what);
+ expect((await advice(TARGET_ID)).status).toBe(404);
+ expect((await areas(TARGET_ID)).status).toBe(404);
+ }
+ );
+
+ it("★V3: 生きている行から開いたとき、同じ施設・同じ日の削除済みの清掃エリアは items・件数・平均に入らない★", async () => {
+ const before = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
+ expect(before.total_count).toBe(3);
+ // エリア1-2(完了率5%)を削除済みにする
+ markDeleted(timetableId(1, DAYS - 1, 1), "section");
+ const res = await areas(TARGET_ID, "?filter=all");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as AreaCompletionsResponse;
+ expect(body.items.map((i) => i.section_name).sort()).toEqual(["エリア1-1", "エリア1-3"]);
+ expect(body.total_count).toBe(2);
+ // ★平均は 95 と 5 の平均(50)から、95 だけ(1-3 は未実施で分母に入らない)に変わる★
+ expect(body.overall_completion_rate).toBe(95);
+ });
+
+ it("V2・V3: `cleanLogs({ id })` と `areaCompletions()` の予定の `where` に可視性が入る", async () => {
+ await areas(TARGET_ID);
+ const mainCalls = calls.filter(
+ (c) => c.model === "cleanTimetable" && "include" in c.args
+ );
+ expect(mainCalls.length).toBeGreaterThanOrEqual(2);
+ for (const c of mainCalls) {
+ expect((c.args as { where: Record }).where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ }
+ });
+});
+
+/**
+ * ★表6 A7・A8(指示書BB A3)★ — 結果待ちはデータ層が `isResultPending()` で決める(Prisma 経路)。
+ * 対象日(09-30)の施設1は 20:00〜21:00。エリア1-3(section 12)はレポートなし。
+ * ★終了+120分=23:00★ より前なら結果待ち、ちょうどなら確定した未実施(表1 P6 と同じ境界)。
+ */
+describe("GET /clean-logs/{id}/areas — 結果待ち(指示書BB 表6 A7・A8)", () => {
+ async function areaOf(sectionId: number) {
+ const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
+ return { body, item: body.items.find((i) => i.section_id === sectionId) };
+ }
+
+ it("A8: now=22:59(終了+120分より前)→ section 12 は result_pending: true。problem_count に数えない", async () => {
+ vi.setSystemTime(new Date(`${TARGET_DATE}T22:59:00+09:00`));
+ const { body, item } = await areaOf(12);
+ expect(item).toMatchObject({ result_pending: true, grade: "×", completion_rate: null });
+ // 問題は section 11(5%)だけ
+ expect(body.problem_count).toBe(1);
+ expect(body.total_count).toBe(3);
+ });
+
+ it("★A7: now=23:00(終了+120分ちょうど)→ result_pending: false(確定した未実施)★", async () => {
+ vi.setSystemTime(new Date(`${TARGET_DATE}T23:00:00+09:00`));
+ const { body, item } = await areaOf(12);
+ expect(item).toMatchObject({ result_pending: false, grade: "×" });
+ expect(body.problem_count).toBe(2);
+ });
+
+ it("レポートのある行は result_pending: false", async () => {
+ vi.setSystemTime(new Date(`${TARGET_DATE}T22:00:00+09:00`));
+ const { item } = await areaOf(10);
+ expect(item?.result_pending).toBe(false);
+ });
+});
+
+// ★表10 N6(指示書BB A6・U4)★ — 単一ID(`/advice`・`/areas`)の連続日数の入力は施設で絞らない(TeamWorks の索引の後)
+describe("単一ID の連続日数の入力は施設で絞らない(指示書BB 表10 N6・U4)", () => {
+ it.each(["advice", "areas"] as const)("%s: 連続日数用の予定のクエリに `facility_id` の条件が無い", async (which) => {
+ calls = [];
+ const res = which === "advice" ? await advice(TARGET_ID) : await areas(TARGET_ID);
+ expect(res.status).toBe(200);
+ const timetableConsecutive = consecutiveCalls().filter((c) => c.model === "cleanTimetable");
+ expect(timetableConsecutive.length).toBeGreaterThan(0);
+ for (const c of timetableConsecutive) {
+ expect((c.args as { where: Record }).where).not.toHaveProperty("facility_id");
+ }
+ });
+});
+
+// ★表8 T3(指示書BB A5・D18・D-182)★ — アドバイスの文言の読み込みが例外なら、標準の形の500と `api.error` が1件
+describe("GET /clean-logs/{id}/advice — 文言の読み込みの例外(指示書BB 表8 T3)", () => {
+ it("★T3: `loadAdviceRules()` が例外 → 500・internal_error・「データの取得に失敗しました」・api.error が1件★", async () => {
+ vi.mocked(logApiError).mockClear();
+ const broken: JsonStore = {
+ read: async () => {
+ throw new Error("[stub] 読み込みに失敗");
+ },
+ write: async () => {},
+ };
+ __setAdviceStoreForTesting(broken);
+ try {
+ const res = await advice(TARGET_ID);
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ });
+ expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ method: "GET", status: 500 });
+ } finally {
+ __setAdviceStoreForTesting(null);
+ }
+ });
+});
diff --git "a/app/api/clean-logs/\133id\135/advice/route.ts" "b/app/api/clean-logs/\133id\135/advice/route.ts"
new file mode 100644
index 0000000..9675a0d
--- /dev/null
+++ "b/app/api/clean-logs/\133id\135/advice/route.ts"
@@ -0,0 +1,74 @@
+/**
+ * GET /clean-logs/{id}/advice — 対応アドバイス(行展開時の推定原因+確認アクション)
+ *
+ * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/advice です。
+ *
+ * 判定(どのルールが該当するか)は `lib/judge/advice.ts`、
+ * 文言は `lib/settings/advice.ts`(運用設定で編集された内容)から取得します。
+ */
+import { NextResponse } from "next/server";
+import { parsePathId } from "@/lib/api/path-params";
+import { adviceResponse } from "@/lib/clean-logs/advice-response";
+import { db } from "@/lib/db";
+import { logApiError } from "@/lib/logger/api";
+import { withRequestId } from "@/lib/logger";
+import type { ApiError, ErrorCode } from "@/lib/types";
+
+const ROUTE = "/api/clean-logs/[id]/advice";
+
+function errorBody(code: ErrorCode, message: string): ApiError {
+ return { error: { code, message, details: null } };
+}
+
+function errorResponse(status: number, code: ErrorCode, message: string) {
+ return NextResponse.json(errorBody(code, message), { status });
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return withRequestId(request, async () => {
+ const { id } = await params;
+ // ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。
+ // `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123)
+ const parsedId = parsePathId(id);
+ if (!parsedId.ok) {
+ return NextResponse.json(parsedId.error, { status: parsedId.status });
+ }
+ const logId = parsedId.query;
+
+ let logs;
+ try {
+ // ★★1件だけ引く★★(指示書AC 段階3)— 以前は「範囲なし」で全件(実測3,178行)を
+ // 読んでから `find()` していた。
+ // ★★連続日数は `loadConsecutiveInputs()` 経由なので正しいまま★★(段階1 の効果)—
+ // `cleanLogs()` の中で ★全期間を読む専用の1本★ が別に走るので、
+ // ★`id` で絞っても L3 が L1 に化けない★(指示書AC C-2)。
+ // ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
+ // ★全部 404 になります★(罠1)。
+ logs = await db.cleanLogs({
+ id: logId,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ } catch (e) {
+ const body = errorBody("internal_error", "データの取得に失敗しました");
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)—
+ // ★絞り込み規則の「正」はここ★で、SQL 側はその写し。
+ // ★★fixtures 版が `id` の扱いを間違えても 404 が壊れない保険★★にもなる。
+ const log = logs.find((l) => l.id === logId);
+ if (!log) {
+ return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
+ }
+
+ // ★`clean_log_id` は `log.id` でなくパスの `logId` を渡す★ — `log.id` は型の上で null を含む
+ // (予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値
+ return adviceResponse(log, { route: ROUTE, logIds: { clean_log_id: logId } });
+ });
+}
diff --git "a/app/api/clean-logs/\133id\135/areas/route.ts" "b/app/api/clean-logs/\133id\135/areas/route.ts"
new file mode 100644
index 0000000..4483087
--- /dev/null
+++ "b/app/api/clean-logs/\133id\135/areas/route.ts"
@@ -0,0 +1,159 @@
+/**
+ * GET /clean-logs/{id}/areas — エリア別完了率(施設×日付)
+ *
+ * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/areas です。
+ *
+ * ★★`{id}` はエリアを特定するキーではなく、「施設と日付を特定するための入口」★★
+ * (指示書Z 決定1)。返すのは ★その清掃ログと同じ施設・同じ日の全エリア★
+ * (その清掃ログ自身を含む)。画面(F-02 清掃詳細ログ)の行から開くモーダルなので、
+ * 行の id を入口にしている。
+ *
+ * ★出所★: TeamWorks 第8回 A9-2(2026-09-11 受領)で
+ * ★1エリア(=1ロボット)につき1つの完了率★が取得可能と確定したため、
+ * ★501 の仮実装(D-034)をやめて本実装にした★。
+ * ★★ただし確認事項#10 の本来の問い(1回の清掃の中の区域別内訳)は依然として未解決★★。
+ *
+ * 絞り込み・並べ替え・全体率の計算は `lib/clean-logs/areas.ts`(純粋関数)、
+ * 行の取得は `db.areaCompletions()`。このルートはその2つをつなぐだけにしてある。
+ *
+ * ★★取得は2段構え★★(指示書AC 段階3):
+ * 1. `db.cleanLogs({ id })` … ★1件だけ★ 引いて `facility_id` と JST 日付を得る(404 の判定も兼ねる)
+ * 2. `db.areaCompletions({ facility_id, date })` … ★その施設のその日だけ★ を引く
+ * ★以前は両方を全件読んでいた(1行の表示のために実測 4,760行。指示書AC B-3)★。
+ */
+import { NextResponse } from "next/server";
+import { parsePathId } from "@/lib/api/path-params";
+import {
+ buildAreaCompletions,
+ overallCompletionTally,
+ parseAreaFilter,
+ selectAreaRowsForLog,
+} from "@/lib/clean-logs/areas";
+import { GRADE_THRESHOLDS } from "@/lib/config";
+import { db } from "@/lib/db";
+import { logEvent, withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type {
+ ApiError,
+ AreaCompletionRow,
+ AreaCompletionsResponse,
+ CleanLogItem,
+ ErrorCode,
+} from "@/lib/types";
+
+const ROUTE = "/api/clean-logs/[id]/areas";
+
+function errorBody(code: ErrorCode, message: string): ApiError {
+ return { error: { code, message, details: null } };
+}
+
+function errorResponse(status: number, code: ErrorCode, message: string) {
+ return NextResponse.json(errorBody(code, message), { status });
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return withRequestId(request, async () => {
+ const { id } = await params;
+ // ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。
+ // `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123)
+ const parsedId = parsePathId(id);
+ if (!parsedId.ok) {
+ return NextResponse.json(parsedId.error, { status: parsedId.status });
+ }
+ const logId = parsedId.query;
+
+ const { searchParams } = new URL(request.url);
+ const parsed = parseAreaFilter(searchParams);
+ if (!parsed.ok) {
+ return NextResponse.json(parsed.error, { status: parsed.status });
+ }
+
+ // ★★2段構えにする★★(指示書AC 段階3)— ★並列に引けないのは承知の上★。
+ // 1段目で `facility_id` と JST 日付が分かって初めて、2段目を絞れるため
+ // (★以前は両方を全件読んで並列に引いていた。1行の表示のために実測 4,760行★)。
+ let log: CleanLogItem | undefined;
+ try {
+ // ★★1件だけ引く★★ — このルートが `cleanLogs()` を引くのは
+ // ★404 の判定と、施設・日付の特定★ のためだけ。
+ // ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
+ // ★全部 404 になります★(罠1)。
+ const logs = await db.cleanLogs({
+ id: logId,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)
+ log = logs.find((l) => l.id === logId);
+ } catch (e) {
+ const body = errorBody("internal_error", "データの取得に失敗しました");
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ if (!log) {
+ return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
+ }
+
+ // ★`scheduled_at` は既に JST の ISO 文字列(`jstIsoFromDb()` 由来)なので `.slice(0, 10)`★
+ // ★★`areaCompletions()` の `date` と**同じ作り方**★★(片方だけ生の `Date` から
+ // 作ると9時間ずれて突き合わせが外れる。指示書Y / D-115)
+ const date = log.scheduled_at.slice(0, 10);
+
+ let rows: AreaCompletionRow[];
+ try {
+ // ★★その施設のその日だけを引く★★(指示書AC 段階3)—
+ // ★`clean_log_id` で引かないこと★。`{id}` はエリアではなく
+ // ★「施設と日付の入口」★ なので、同じ施設・同じ日の ★全エリア★ が要る(指示書Z 決定1)。
+ rows = await db.areaCompletions({ facility_id: log.facility_id, date });
+ } catch (e) {
+ const body = errorBody("internal_error", "データの取得に失敗しました");
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ // ★★`selectAreaRowsForLog()` は**残す**★★(指示書AC C-5)— ★絞り込み規則の「正」はこの
+ // 純粋関数★で、SQL 側はその写し。★★SQL が絞りすぎたときはここでは取り戻せない★★ので、
+ // 「SQL 版 = 全件版 + `selectAreaRowsForLog()`」の等価テストで固定してある(G-3)。
+ const selected = selectAreaRowsForLog(rows, {
+ facility_id: log.facility_id,
+ date,
+ });
+ const body: AreaCompletionsResponse = buildAreaCompletions(selected, parsed.query);
+ // ★全体率と同じ関数・同じ行(`filter` で絞る前)で分子・分母を数える★(指示書AR 段階4)
+ const tally = overallCompletionTally(selected);
+
+ // 判定根拠の記録。記録条件は設けず無条件に出す
+ // (このルートは「エリア別詳細を見る」のクリックで1回だけ呼ばれるため件数の問題が無い。
+ // `advice/route.ts` の judge.advice と同じ流儀)
+ // ★★分子・分母を残すのは、「全体率が行の平均と違う」と言われたときに検算できるようにするため★★
+ // (全体率は行の完了率の単純平均。`raw_overall_completion_rate === sum / count`。
+ // ★自前で数え直さず `overallCompletionTally()` を使う★ — 別の数え方をするとログと画面が食い違う)
+ logEvent("info", {
+ event: "judge.areas",
+ route: ROUTE,
+ method: "GET",
+ facility_id: log.facility_id,
+ // ★`log.id` は型の上で null を含む(予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値★
+ clean_log_id: logId,
+ date,
+ filter: parsed.query,
+ raw_overall_completion_rate: body.overall_completion_rate,
+ overall_completion_rate_sum: tally.cleanRatioSum,
+ overall_completion_rate_count: tally.cleanRatioCount,
+ total_count: body.total_count,
+ problem_count: body.problem_count,
+ thresholds_used: {
+ excellent: GRADE_THRESHOLDS.excellent,
+ good: GRADE_THRESHOLDS.good,
+ poor: GRADE_THRESHOLDS.poor,
+ },
+ result_overall_grade: body.overall_grade,
+ });
+
+ return NextResponse.json(body);
+ });
+}
diff --git a/app/api/clean-logs/__tests__/route.test.ts b/app/api/clean-logs/__tests__/route.test.ts
new file mode 100644
index 0000000..ebcaa45
--- /dev/null
+++ b/app/api/clean-logs/__tests__/route.test.ts
@@ -0,0 +1,1481 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
+// ★最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、
+// フルスイート実行時に既定のタイムアウト(5秒)を踏んで★不定期に落ちます★
+// (実測: 単体実行 2.8秒 / フルスイート 7.1秒で #1・#2 が不定期に赤)。
+// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効きます。
+import { GET } from "@/app/api/clean-logs/route";
+import { GET as GET_UNSCHEDULED_ADVICE } from "@/app/api/clean-logs/unscheduled/[report_id]/advice/route";
+// ★出発失敗はアラートに出ない(2026-10-29)ことを、同じスタブの上で突き合わせるため★
+import { GET as GET_PRECHECKS } from "@/app/api/prechecks/route";
+import type { PrecheckSummary } from "@/lib/prechecks/query";
+import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
+import { db } from "@/lib/db";
+import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source";
+import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs";
+import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
+import { getPrisma } from "@/lib/db/prisma";
+import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
+import { UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link";
+import { logEvent } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice";
+import type { JsonStore } from "@/lib/settings/json-store";
+import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
+import type { CleanLogItem, ListResponse, PrecheckItem, SummaryListResponse } from "@/lib/types";
+
+/**
+ * 指示書AC 段階2 の期待値表のうち、★★API ルートから `db` までを通した経路★★
+ * でなければ固定できないもの(★#6b / #6c / #6d / #6e / #7 / #8 / #9★)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★なぜ解決関数の単体テストだけでは不十分なのか★★(指示書AC 期待値表 #6d)
+ * ------------------------------------------------------------------------
+ * ★ルートが `facility_ids` を `db.cleanLogs()` に渡し忘れていても、
+ * `facilityIdsMatchingName()` の単体テストは緑のままです★。
+ * ★★だからここでは「ルート → `db`(Prisma 実装)→ 発行されたクエリの `where`」まで
+ * 通して確かめます★★。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
+ * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
+ * ★`prismaSource` の経路★ を走らせます(`lib/db/__tests__/consecutive-source.test.ts` と
+ * 同じ「DBに接続しないでDB実装を試す」作法)。
+ *
+ * ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では
+ * - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(#7 / C-6)
+ * - ★論理削除された施設が0件で、罠7 を検出できない★(#6e。2026-09-13 実測)
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+/** `clean_timetables` の1行(★`include` 済みの形で持つ★) */
+interface StubTimetable {
+ id: bigint;
+ facility_id: bigint;
+ section_id: bigint;
+ parent_id: bigint;
+ start_at: Date;
+ /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
+ end_at: Date;
+ precheck: number;
+ /** ★`deleted_at` は省略可(省略は生きている)。削除済みの施設の行を作るテストだけが入れる(指示書BB U1)★ */
+ facility: { facility_name: string; deleted_at?: Date | null };
+ section: {
+ /** ★省略は生きている(指示書BB U1)★ */
+ deleted_at?: Date | null;
+ robots: {
+ /** `robots.id`(★2台以上のときの並び `orderBy: { id: "asc" }` を見るため。省略可★。指示書BB D7) */
+ id?: bigint;
+ serial_number: string;
+ device: { maker_id: number; model_name: string };
+ }[];
+ };
+}
+
+/**
+ * `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。
+ * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
+ * ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3)
+ */
+interface StubReport {
+ /** ★予定外の行(指示書BG)だけ使う★(予定のある行の読み取りは読まない) */
+ report_id?: string;
+ facility_id?: bigint;
+ section_id?: bigint;
+ /** ★予定外の行の `include`(施設名・清掃エリアのロボット・削除済み)★。予定のある行には付けない */
+ facility?: { facility_name: string; deleted_at: Date | null };
+ section?: StubTimetable["section"];
+ clean_timetable_id: bigint | null;
+ clean_ratio: number;
+ /** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */
+ status: number;
+ started_at: Date;
+ ended_at: Date | null;
+ clean_area: number;
+ task_area: number;
+}
+
+interface StubErrorReport {
+ clean_timetable_id: bigint | null;
+ error_type: string;
+ errored_at: Date;
+ /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
+ robot_id?: bigint | null;
+}
+
+interface StubFacility {
+ id: bigint;
+ facility_name: string;
+ /** ★論理削除★(`null` 以外なら削除済み) */
+ deleted_at: Date | null;
+}
+
+interface StubData {
+ timetables: StubTimetable[];
+ /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
+ historyTimetables?: StubTimetable[];
+ reports: StubReport[];
+ errorReports: StubErrorReport[];
+ facilities: StubFacility[];
+}
+
+/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
+const calls: {
+ cleanTimetable: Record[];
+ cleanTimetableHistory: Record[];
+ cleanTaskReport: Record[];
+ robotErrorReport: Record[];
+ facility: Record[];
+} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [], facility: [] };
+
+let data: StubData;
+
+/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */
+function sameId(a: unknown, b: unknown): boolean {
+ if (a === null || a === undefined || b === null || b === undefined) return false;
+ return String(a) === String(b);
+}
+
+/**
+ * Prisma の `where` のうち、★本作業が使う分だけ★ を再現する。
+ *
+ * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。
+ * ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
+ */
+function matchesWhere(row: Record, where: unknown): boolean {
+ if (!where) return true;
+ for (const [field, cond] of Object.entries(where as Record)) {
+ // ★`OR`(予定外の行の `UNSCHEDULED_TIMETABLE_ID_WHERE`。指示書BG)★ — どれか1つに当たれば通す
+ if (field === "OR") {
+ if (!(cond as unknown[]).some((sub) => matchesWhere(row, sub))) return false;
+ continue;
+ }
+ const value = row[field];
+ if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
+ const c = cond as Record;
+ if ("in" in c) {
+ if (c.in === undefined) {
+ throw new Error(
+ `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
+ );
+ }
+ if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
+ }
+ if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
+ if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
+ if ("deleted_at" in c) {
+ // ★このスタブは `deleted_at` での絞り込みを**わざと**実装している★ —
+ // 罠7(解決用の読み取りに `where` を足してしまう)が起きたら結果が変わるように。
+ // ★リレーション(`facility` / `section`)の条件は、その行の `deleted_at`(省略は生きている)で見る★(指示書BB U1)
+ const target =
+ value !== null && typeof value === "object" && !(value instanceof Date)
+ ? ((value as { deleted_at?: Date | null }).deleted_at ?? null)
+ : value;
+ if (c.deleted_at === null && target !== null) return false;
+ }
+ } else if (field === "report_id" && typeof cond === "string") {
+ if (typeof value !== "string") return false;
+ const norm = (s: string) => s.replace(/ +$/, "").toLowerCase();
+ if (norm(value) !== norm(cond)) return false;
+ } else if (value !== cond) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function project(row: Record, select: unknown): Record {
+ if (!select) return row;
+ const out: Record = {};
+ for (const key of Object.keys(select as Record)) out[key] = row[key];
+ return out;
+}
+
+function applyOrderBy(
+ rows: Record[],
+ orderBy: unknown
+): Record[] {
+ if (!Array.isArray(orderBy)) return rows;
+ return [...rows].sort((a, b) => {
+ for (const key of orderBy as Record[]) {
+ const [field, dir] = Object.entries(key)[0];
+ const av = a[field];
+ const bv = b[field];
+ const an = av instanceof Date ? av.getTime() : Number(av);
+ const bn = bv instanceof Date ? bv.getTime() : Number(bv);
+ if (an !== bn) return (an < bn ? -1 : 1) * (dir === "desc" ? -1 : 1);
+ }
+ return 0;
+ });
+}
+
+function findMany(
+ bucket: keyof typeof calls,
+ rows: Record[],
+ args: Record | undefined
+) {
+ calls[bucket].push(args ?? {});
+ const filtered = rows.filter((row) => matchesWhere(row, args?.where));
+ const ordered = applyOrderBy(filtered, args?.orderBy);
+ return ordered.map((row) => sortIncludedRobots(project(row, args?.select), args));
+}
+
+/**
+ * ★`include: { section: { include: { robots: { orderBy: { id: "asc" } } } } }` を再現する★(指示書BB D7・表2 R5)—
+ * 実装が `orderBy` を付け忘れたら、スタブはデータの順のまま返す(R5 が赤くなる)
+ */
+function sortIncludedRobots(
+ row: Record,
+ args: Record | undefined
+): Record {
+ const include = args?.include as
+ | { section?: { include?: { robots?: { orderBy?: { id?: string } } } } }
+ | undefined;
+ if (include?.section?.include?.robots?.orderBy?.id !== "asc") return row;
+ const section = row.section as StubTimetable["section"];
+ const robots = [...section.robots].sort((a, b) => Number(a.id ?? 0) - Number(b.id ?? 0));
+ return { ...row, section: { ...section, robots } };
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+// ★事前チェックが3つ以上のときの `data.anomaly`(D-178)を見るため `logEvent` だけ差し替える★
+// (`withRequestId` はそのまま使う。`app/api/clean-area/__tests__/route.test.ts` と同じ作法)
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ cleanTimetable: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTimetable", data.timetables as unknown as Record[], args),
+ },
+ // ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★
+ cleanTimetableHistory: {
+ findMany: async (args?: Record) =>
+ findMany(
+ "cleanTimetableHistory",
+ (data.historyTimetables ?? []) as unknown as Record[],
+ args
+ ),
+ },
+ cleanTaskReport: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTaskReport", data.reports as unknown as Record[], args),
+ },
+ robotErrorReport: {
+ findMany: async (args?: Record) => {
+ // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★
+ guardNullRobotId(data.errorReports as unknown as Record[], args);
+ return findMany("robotErrorReport", data.errorReports as unknown as Record[], args);
+ },
+ },
+ facility: {
+ findMany: async (args?: Record) =>
+ findMany("facility", data.facilities as unknown as Record[], args),
+ },
+ }),
+}));
+
+// ---------------------------------------------------------------- 合成データ
+
+/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
+function dbWallClock(jstIso: string): Date {
+ return new Date(jstIso.replace("+09:00", "Z"));
+}
+
+/**
+ * ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`)。
+ * ★中点入りの店名★を再現するため `ドン` ではなく `ドソ`。
+ */
+const FACILITY_1 = "ドソ・キホーテ戸塚";
+/** ★論理削除された施設★(#6e / 罠7) */
+const FACILITY_2 = "ドソ・キホーテ四街道";
+const FACILITY_3 = "フレッシュタウン大和店";
+
+function timetable(
+ id: number,
+ facilityId: number,
+ facilityName: string,
+ jstIso: string,
+ precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
+ parentId = 0
+): StubTimetable {
+ return {
+ id: BigInt(id),
+ facility_id: BigInt(facilityId),
+ section_id: BigInt(facilityId * 10),
+ parent_id: BigInt(parentId),
+ start_at: dbWallClock(jstIso),
+ end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
+ precheck,
+ facility: { facility_name: facilityName },
+ section: {
+ robots: [
+ {
+ serial_number: `8111R56280600${facilityId}`,
+ device: { maker_id: 1, model_name: "CC1" },
+ },
+ ],
+ },
+ };
+}
+
+/**
+ * 実施済みの清掃1回ぶんのレポート行。
+ * ★未実施は行を作らないことで表す★(指示書AQ §1-1。旧 `clean_error = 1` の行は作らない)。
+ * `started_at` は `ended_at` の1時間前(どちらも壁時計値)。`status` は生値の一例(4)。
+ * ★完了率は面積から `runCompletionRate()` で決まる(D-155)★ので、`task_area = 1000` に対して
+ * `clean_area = cleanRatio × 10` とし、`clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)。
+ */
+function report(timetableId: number, cleanRatio: number, endedJstIso: string): StubReport {
+ const endedAt = dbWallClock(endedJstIso);
+ return {
+ clean_timetable_id: BigInt(timetableId),
+ clean_ratio: cleanRatio,
+ status: 4,
+ started_at: new Date(endedAt.getTime() - 60 * 60 * 1000),
+ ended_at: endedAt,
+ clean_area: cleanRatio * 10,
+ task_area: 1000,
+ };
+}
+
+function buildData(): StubData {
+ return {
+ facilities: [
+ { id: BigInt(1), facility_name: FACILITY_1, deleted_at: null },
+ // ★★論理削除済み(施設マスタ側)★★ — 名前の解決(罠7)の検証用。
+ // ★予定の行(201)の `facility.deleted_at` は既定では入れない(=生きている扱い)★ —
+ // 削除済みの施設の行が外れること(指示書BB U1)は #6e で行に `deleted_at` を入れて確かめる
+ { id: BigInt(2), facility_name: FACILITY_2, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") },
+ { id: BigInt(3), facility_name: FACILITY_3, deleted_at: null },
+ ],
+ timetables: [
+ // 施設1: ★3日連続で×★(9/8・9/9・9/10)→ 9/10 の行の連続日数は ★3★
+ timetable(101, 1, FACILITY_1, "2026-09-08T02:00:00+09:00"),
+ timetable(102, 1, FACILITY_1, "2026-09-09T02:00:00+09:00"),
+ timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
+ // 事前チェック行(★本清掃の子★)— `status_1h` / `status_30m` の材料
+ timetable(1031, 1, FACILITY_1, "2026-09-10T01:00:00+09:00", 60, 103),
+ timetable(1032, 1, FACILITY_1, "2026-09-10T01:30:00+09:00", 30, 103),
+ // 施設2(★論理削除された施設★): 9/10 に1件
+ timetable(201, 2, FACILITY_2, "2026-09-10T02:00:00+09:00"),
+ // 施設3: ★★壁時計 20:00 の清掃★★(#7。★ローカルDBには1件も無い時間帯★)
+ timetable(301, 3, FACILITY_3, "2026-09-10T20:00:00+09:00"),
+ ],
+ reports: [
+ report(101, 5, "2026-09-08T03:00:00+09:00"),
+ report(102, 5, "2026-09-09T03:00:00+09:00"),
+ report(103, 5, "2026-09-10T03:00:00+09:00"),
+ report(201, 95, "2026-09-10T03:00:00+09:00"),
+ report(301, 80, "2026-09-10T21:00:00+09:00"),
+ ],
+ errorReports: [
+ {
+ clean_timetable_id: BigInt(1031),
+ error_type: "EmergencyStop",
+ errored_at: dbWallClock("2026-09-10T01:05:00+09:00"),
+ },
+ ],
+ };
+}
+
+/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */
+const NOW = new Date("2026-09-30T00:00:00+09:00");
+
+async function get(search: string): Promise> {
+ const res = await GET(new Request(`http://localhost/api/clean-logs${search}`));
+ return (await res.json()) as ListResponse;
+}
+
+/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */
+function mainQueryWhere(): Record {
+ const call = calls.cleanTimetable.find((c) => "include" in c);
+ expect(call).toBeDefined();
+ return (call as { where: Record }).where;
+}
+
+beforeEach(() => {
+ data = buildData();
+ calls.cleanTimetable = [];
+ calls.cleanTimetableHistory = [];
+ calls.cleanTaskReport = [];
+ calls.robotErrorReport = [];
+ calls.facility = [];
+ vi.mocked(logEvent).mockClear();
+ // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(NOW);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------- テスト
+
+describe("GET /clean-logs — 日付・施設をSQLに降ろす(指示書AC 段階2)", () => {
+ it("#1 範囲なし → 本清掃の全件(★事前チェック行は並ばない★)", async () => {
+ const body = await get("");
+ expect(body.items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]);
+ expect(body.meta.total).toBe(5);
+ // ★本清掃の絞り込みが SQL に降りていること★
+ expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
+ });
+
+ it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる", async () => {
+ await get("?date_from=2026-09-09&date_to=2026-09-10");
+ const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date };
+ // ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★)
+ expect(startAt.gte.toISOString()).toBe("2026-09-09T00:00:00.000Z");
+ expect(startAt.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
+ });
+
+ it("#3 `date_from = date_to` → その日の行だけ", async () => {
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ expect(body.items.map((i) => i.id)).toEqual([301, 103, 201]);
+ expect(body.meta.total).toBe(3);
+ });
+
+ it("#4 `date_from` がデータの最古日より前 → #1 と同じ件数", async () => {
+ const body = await get("?date_from=2000-01-01");
+ expect(body.meta.total).toBe(5);
+ });
+
+ it("#5 `date_from > date_to` は 422 で弾かれ、`db` まで来ない", async () => {
+ const res = await GET(
+ new Request("http://localhost/api/clean-logs?date_from=2026-09-10&date_to=2026-09-08")
+ );
+ expect(res.status).toBe(422);
+ // ★★クエリが1本も発行されていないこと★★
+ expect(calls.cleanTimetable).toHaveLength(0);
+ });
+
+ it("★#7 壁時計 20:00 の清掃が、その日の範囲に**含まれる**★(C-6。★ローカルDBでは検出できない★)", async () => {
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const evening = body.items.find((i) => i.id === 301);
+ expect(evening).toBeDefined();
+ expect(evening?.scheduled_at).toBe("2026-09-10T20:00:00+09:00");
+ // ★前日・翌日の指定では出てこない★
+ expect((await get("?date_from=2026-09-09&date_to=2026-09-09")).items.map((i) => i.id)).not.toContain(301);
+ expect((await get("?date_from=2026-09-11&date_to=2026-09-11")).items.map((i) => i.id)).not.toContain(301);
+ });
+
+ it("★★#8 期間で絞っても `consecutive_problem_days` が全期間版と完全一致する★★(★C-2 の地雷★)", async () => {
+ // ★施設1 は 9/8・9/9・9/10 の3日連続で×★
+ const all = await get("");
+ const expected = new Map(all.items.map((i) => [i.id, i.consecutive_problem_days]));
+ expect(expected.get(103)).toBe(3);
+
+ // ★9/10 の1日だけを指定しても 3 のまま★
+ // ★★期間で絞った行から連続日数を数えると 1 になり、L3 が L1 に化ける★★
+ // (★例外もテスト失敗も出ない fail-silent★。だからこのテストが唯一の検出手段)
+ const oneDay = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ for (const item of oneDay.items) {
+ expect(item.consecutive_problem_days).toBe(expected.get(item.id));
+ }
+ expect(oneDay.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3);
+ expect(oneDay.items.find((i) => i.id === 103)?.escalation_level).toBe("L3");
+ });
+
+ it("★#8(構造)連続日数用のクエリは「表示期間 + 上限ぶんの遡り」で絞る★(指示書AC 段階6)", async () => {
+ await get("?date_from=2026-09-10&date_to=2026-09-10");
+ // ★`loadConsecutiveInputs()` が発行する `select` 付きのクエリ★
+ const consecutiveCall = calls.cleanTimetable.find(
+ (c) => "select" in c && (c.select as Record).facility_id === true
+ );
+ expect(consecutiveCall).toBeDefined();
+
+ // ★★表示期間の開始日(9/10)をそのまま下限にしたら連続が途切れる(C-2)★★ —
+ // 遡り幅は ★保存された表示上限★ から導く(★定数に写し取らないこと★。申し送り⑪)
+ const start = (consecutiveCall as { where: { start_at: { gte: Date; lt: Date } } })
+ .where.start_at;
+ const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax());
+ expect(start.gte.toISOString().slice(0, 10)).toBe(
+ new Date(Date.parse("2026-09-10T00:00:00Z") - lookback * 86_400_000)
+ .toISOString()
+ .slice(0, 10)
+ );
+ // ★上限側は表示期間の末日を含む(翌日 00:00 未満)★
+ expect(start.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
+ });
+});
+
+describe("施設名 → 施設ID の解決(指示書AC C-1 / D-6)", () => {
+ it("★★#6d 記号違いで指定した施設名が解決され、SQL の `where` に施設IDで降りる★★", async () => {
+ const body = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
+
+ // ★★ルートが `facility_ids` を渡していること(=渡し忘れの検出)★★
+ expect(mainQueryWhere().facility_id).toEqual({ in: [1, 2] });
+ // ★★そのログがヒットすること★★(`ドソ・キホーテ戸塚` に `ドソキホーテ` で当たる)
+ expect(body.items.map((i) => i.id).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([101, 102, 103, 201]);
+ expect(body.items.every((i) => i.facility_name.startsWith("ドソ"))).toBe(true);
+ });
+
+ it("★★#6e 削除済みの施設の名前で検索すると、施設ID には解決される(罠7)が、その行は可視性の条件で外れて0件★★(指示書BB D3)", async () => {
+ // ★予定の行(201)の施設を削除済みにする(施設マスタの施設2 と同じ状態)★
+ for (const t of data.timetables) {
+ if (Number(t.facility_id) === 2) t.facility = { ...t.facility, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
+ }
+ const body = await get(`?facility_name=${encodeURIComponent("四街道")}`);
+
+ // ★解決用の読み取りに `where` を足していないこと★(罠7。解決は行の可視性と別の責務)
+ expect(calls.facility).toEqual([{ select: { id: true, facility_name: true } }]);
+ expect(mainQueryWhere().facility_id).toEqual({ in: [2] });
+ // ★行は削除済みの施設・清掃エリアの条件で外れる(指示書BB U1)★
+ expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ expect(body.items).toEqual([]);
+ expect(body.meta.total).toBe(0);
+ });
+
+ it("★#6e(対照)`deleted_at` で絞ると施設2 が解決できなくなる★(流用してはいけない理由)", async () => {
+ // ★解決用の読み取りは論理削除された施設2 も返す★
+ expect((await db.facilityNameIndex()).map((f) => f.id)).toEqual([1, 2, 3]);
+
+ // ★対照★: `prismaSource.facilities()` が付けている `where: { deleted_at: null }` を
+ // 同じスタブに投げると ★施設2 が消える★ → 名前の解決の結果が変わる
+ // (★解決は行の可視性と別の責務★。行は予定の `where` の可視性の条件で外す。指示書BB D3)
+ const prisma = (await getPrisma()) as unknown as {
+ facility: {
+ findMany(args: unknown): Promise<{ id: bigint }[]>;
+ };
+ };
+ const visible = await prisma.facility.findMany({
+ where: { deleted_at: null },
+ select: { id: true, facility_name: true },
+ });
+ expect(visible.map((f) => Number(f.id))).toEqual([1, 3]);
+ });
+
+ it("★★#6b 1件も当たらない施設名 → 0件★★(★`in: undefined` に化けていないことの検出。罠1★)", async () => {
+ const body = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);
+
+ // ★★`in: []` が渡っていること(`in: undefined` なら全件になる)★★
+ expect(mainQueryWhere().facility_id).toEqual({ in: [] });
+ expect(body.items).toEqual([]);
+ expect(body.meta.total).toBe(0);
+ });
+
+ it("★#6c 施設名を指定しない → `facility_id` の条件を付けない(全件)★", async () => {
+ const body = await get("");
+ // ★★`in: []` を渡さないこと★★ — 渡すと施設名未指定で0件になる
+ expect(mainQueryWhere()).not.toHaveProperty("facility_id");
+ expect(body.meta.total).toBe(5);
+ // ★施設名が空なら施設マスタを読みにいかない(余計な往復を増やさない)★
+ expect(calls.facility).toEqual([]);
+ });
+
+ it("★機種・シリアルは据え置き(SQL に降ろさずメモリで絞る)★", async () => {
+ const body = await get("?serial=8111R56280600");
+ // ★`section` の条件は可視性(`deleted_at: null`)だけ★(シリアルの条件を SQL に降ろしていない。指示書BB U1 で可視性が入った)
+ expect(mainQueryWhere().section).toEqual({ deleted_at: null });
+ expect(body.meta.total).toBe(5);
+ });
+});
+
+describe("関連テーブルの引き直し(指示書AC 段階2 D-4 / §E-5)", () => {
+ it("★履歴は本清掃の id、エラー報告は事前チェック行の id で引く(全件読みに戻さない)★", async () => {
+ await get("?date_from=2026-09-10&date_to=2026-09-10");
+
+ // ★`select` の無い1本 = `cleanLogs()` 本体の引き直し★(`select` 付きは連続日数用。
+ // 取得元が `clean_task_reports` になって連続日数用も `where` を持つので、`where` だけでは区別できない)
+ // ★予定外の行の読み取り(`include` 付き。指示書BG)は `clean_timetable_id` の `in` を持たないので除く★
+ const reportCall = calls.cleanTaskReport.find(
+ (c) => "where" in c && !("select" in c) && !("include" in c)
+ );
+ expect(reportCall).toBeDefined();
+ const historyWhere = (
+ reportCall as {
+ where: { clean_timetable_id: { in: bigint[] } };
+ }
+ ).where;
+ expect(historyWhere.clean_timetable_id.in.map(String).sort()).toEqual(
+ ["103", "201", "301"].sort()
+ );
+
+ const errorWhere = (
+ calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } }
+ ).where;
+ expect(errorWhere.clean_timetable_id.in.map(String).sort()).toEqual(
+ ["1031", "1032"].sort()
+ );
+ });
+
+ it("★事前チェック行は `parent_id` で別クエリに分けて引く★", async () => {
+ await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const precheckCall = calls.cleanTimetable.find(
+ (c) => "where" in c && "parent_id" in (c.where as object)
+ );
+ expect(precheckCall).toBeDefined();
+ expect((precheckCall as { select: object }).select).toEqual({
+ id: true,
+ parent_id: true,
+ precheck: true,
+ });
+ });
+
+ it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
+ const body = await get("?date_from=2026-01-01&date_to=2026-01-02");
+ expect(body.items).toEqual([]);
+ expect(calls.robotErrorReport).toEqual([]);
+ // ★レポートの引き直しは0本(本清掃・連続日数用とも `in` の塊が0個)★。
+ // ★予定外の行の読み取り(`clean_timetable_id` の `in` を持たない。指示書BG)は予定の有無によらず撃つ★ので数えない
+ expect(
+ calls.cleanTaskReport.filter(
+ (c) => "where" in c && "clean_timetable_id" in (c.where as Record)
+ )
+ ).toEqual([]);
+ expect(calls.cleanTaskReport.filter((c) => "include" in c)).toHaveLength(1);
+ });
+});
+
+describe("清掃結果 `task_status` の取得元(指示書AQ §1-1 / §1-2 / §5-2 / 落とし穴1)", () => {
+ it("★`clean_task_reports.status` の生値をそのまま返す(変換しない)★", async () => {
+ data.reports = data.reports.map((r) =>
+ r.clean_timetable_id === BigInt(201) ? { ...r, status: 6 } : r
+ );
+ const body = await get("");
+ expect(body.items.find((i) => i.id === 201)?.task_status).toBe(6);
+ expect(body.items.find((i) => i.id === 103)?.task_status).toBe(4);
+ });
+
+ it("★レポートの行が無い本清掃 → `task_status` は null・出発失敗・完了率 null★(§1-1)", async () => {
+ data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
+ const body = await get("");
+ const item = body.items.find((i) => i.id === 201);
+ // ★行ごと消えない(出発失敗として出る)★
+ expect(item).toBeDefined();
+ expect(item?.task_status).toBeNull();
+ expect(item?.departed).toBe(false);
+ expect(item?.completion_rate).toBeNull();
+ });
+
+ it("★★`clean_timetable_id` が NULL のレポートはどの清掃にも紐づかない★★(落とし穴1)", async () => {
+ data.reports = data.reports.map((r) =>
+ r.clean_timetable_id === BigInt(201) ? { ...r, clean_timetable_id: null } : r
+ );
+ const body = await get("");
+ const item = body.items.find((i) => i.id === 201);
+ expect(item).toBeDefined();
+ expect(item?.task_status).toBeNull();
+ expect(item?.departed).toBe(false);
+ // ★他の清掃の結果を上書きしない★
+ expect(body.items.find((i) => i.id === 301)?.completion_rate).toBe(80);
+ });
+});
+
+describe("★#9 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★(指示書AC D-5 / 禁止事項6)", () => {
+ /**
+ * ★同じデータに対して★、
+ * - Prisma 版 = ★SQL で絞った結果★
+ * - fixtures 版 = ★全件版 + `filterFixtureCleanLogs()`★
+ * を突き合わせる(指示書AC G-3「等価テスト」)。
+ * ★`null` と `[]` の両方で比べること★(罠9)。
+ */
+ it("日付範囲: SQL版 = 全件版 + fixtures 版の絞り込み", async () => {
+ const all = await get("");
+ const sql = await get("?date_from=2026-09-09&date_to=2026-09-10");
+ const viaFixtures = filterFixtureCleanLogs(all.items, {
+ id: null,
+ date_from: "2026-09-09",
+ date_to: "2026-09-10",
+ facility_ids: null,
+ });
+ expect(sql.items).toEqual(viaFixtures);
+ expect(sql.items.length).toBeGreaterThan(0);
+ });
+
+ it("`facility_ids: null`(施設名なし): 件数が一致", async () => {
+ const all = await get("");
+ expect(
+ filterFixtureCleanLogs(all.items, {
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ })
+ ).toHaveLength(all.items.length);
+ });
+
+ it("★`facility_ids: []`: どちらも0件★(★片方だけ全件に化けないこと★)", async () => {
+ const all = await get("");
+ const sql = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`);
+ const viaFixtures = filterFixtureCleanLogs(all.items, {
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: [],
+ });
+ expect(sql.items).toEqual([]);
+ expect(viaFixtures).toEqual([]);
+ });
+
+ it("施設IDで絞った場合も一致", async () => {
+ const all = await get("");
+ const sql = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
+ const viaFixtures = filterFixtureCleanLogs(all.items, {
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: [1, 2],
+ });
+ expect(sql.items).toEqual(viaFixtures);
+ });
+});
+
+describe("GET /clean-logs — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => {
+ it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★(select で robot_id / robot を読まない)", async () => {
+ const before = await get("");
+ data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null }));
+ const res = await GET(new Request("http://localhost/api/clean-logs"));
+ expect(res.status).toBe(200);
+ const after = (await res.json()) as ListResponse;
+ expect(after.items).toEqual(before.items);
+ });
+});
+
+describe("GET /clean-logs — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
+ it("★履歴だけの予定が出る・両方にある同じ予定は1件★。2表に同じ引数が渡る", async () => {
+ data.historyTimetables = [
+ // ★履歴だけの本清掃(9/11)とその事前チェック行★
+ timetable(401, 3, FACILITY_3, "2026-09-11T02:00:00+09:00"),
+ timetable(4011, 3, FACILITY_3, "2026-09-11T01:00:00+09:00", 60, 401),
+ // ★両方にある同じ予定(中身も同じ)★
+ timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"),
+ ];
+ data.reports.push(report(401, 80, "2026-09-11T03:00:00+09:00"));
+
+ const body = await get("");
+ expect(body.items.map((i) => i.id)).toEqual([401, 301, 103, 201, 102, 101]);
+ expect(body.items.find((i) => i.id === 401)?.completion_rate).toBe(80);
+
+ // ★本清掃の主クエリ(`include` 付き)は2表に同じ引数★
+ const mainCall = calls.cleanTimetable.find((c) => "include" in c);
+ const historyCall = calls.cleanTimetableHistory.find((c) => "include" in c);
+ expect(historyCall).toEqual(mainCall);
+ // ★事前チェック行は合わせた後の本清掃の id(履歴の 401 を含む)で引く★
+ const precheckHistoryCall = calls.cleanTimetableHistory.find(
+ (c) => "where" in c && "parent_id" in (c.where as object)
+ ) as { where: { parent_id: { in: bigint[] } } };
+ expect(precheckHistoryCall.where.parent_id.in.map(String)).toContain("401");
+ });
+});
+
+/**
+ * ★出発失敗はアラートに出ない(ユーザー判断 2026-10-29「2で進めてください」)★ —
+ * 同じデータ(このファイルのスタブ)で、清掃詳細ログには出発失敗(未実施)として出て、
+ * アラート(`GET /prechecks`)には行が出ないことを突き合わせる。
+ * ★判定は `notExecutedFromReport()` の1本のまま★(清掃詳細ログの `departed`)。アラートはその判定を使わない。
+ */
+describe("出発失敗は清掃詳細ログに出て、アラートには出ない(2026-10-29)", () => {
+ beforeEach(() => {
+ // ★9/9 02:00 の本清掃(102)からレポートを外す=確定した出発失敗(now は 9/30)★
+ data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(102));
+ // ★本清掃 103 の事前チェック(1031)のエラーを1件、アラートに出る形で足す★(アラートが空振りしていないことの確認)
+ data.errorReports.push({
+ error_id: "E-PRE-1",
+ clean_timetable_id: BigInt(1031),
+ error_type: "EmergencyStop",
+ errored_at: dbWallClock("2026-09-10T01:06:00+09:00"),
+ created_at: dbWallClock("2026-09-10T01:06:00+09:00"),
+ serial_number: "8111R562806001",
+ facility_id: BigInt(1),
+ facility: { facility_name: FACILITY_1, deleted_at: null },
+ section: { deleted_at: null },
+ status: 0,
+ responded_at: null,
+ resolved_at: null,
+ comment: null,
+ } as StubErrorReport);
+ });
+
+ async function getPrechecks(search: string): Promise> {
+ const res = await GET_PRECHECKS(new Request(`http://localhost/api/prechecks${search}`));
+ expect(res.status).toBe(200);
+ return (await res.json()) as SummaryListResponse;
+ }
+
+ it("★清掃詳細ログ: 102 は出発失敗(未実施)★", async () => {
+ const item = (await get("")).items.find((i) => i.id === 102);
+ expect(item).toMatchObject({
+ departed: false,
+ task_status: null,
+ completion_rate: null,
+ result_pending: false,
+ grade: "×",
+ });
+ });
+
+ it("★アラート: 102 の行は無い。行は事前チェック異常だけ(summary・meta.total も)★", async () => {
+ const body = await getPrechecks("?status=all");
+ expect(body.items.map((i) => i.id)).not.toContain("102");
+ expect(body.items.map((i) => i.id)).toEqual(["E-PRE-1"]);
+ expect(body.items.every((i) => i.severity === "precheck")).toBe(true);
+ expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 });
+ expect(body.meta.total).toBe(1);
+
+ // ★9/9 だけに絞ると、出発失敗(102)があってもアラートは0件★
+ const sep9 = await getPrechecks("?date_from=2026-09-09&date_to=2026-09-09&status=all");
+ expect(sep9.items).toEqual([]);
+ expect(sep9.summary.total).toBe(0);
+ // 同じ日の清掃詳細ログには出発失敗として出る
+ const log9 = await get("?date_from=2026-09-09&date_to=2026-09-09");
+ expect(log9.items.find((i) => i.id === 102)?.departed).toBe(false);
+ });
+});
+
+describe("GET /clean-logs — 結果待ち `result_pending`(D-173。表1・表2 C1)", () => {
+ // ★施設2の 9/10 02:00〜03:00 の清掃(id 201)からレポートを外し、now を動かす★
+ beforeEach(() => {
+ data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201));
+ });
+
+ it("★now=04:59(終了+120分より前)→ R: `result_pending: true`。出発失敗の値は変えない★", async () => {
+ vi.setSystemTime(new Date("2026-09-10T04:59:00+09:00"));
+ const body = await get("");
+ const item = body.items.find((i) => i.id === 201);
+ expect(item).toMatchObject({
+ result_pending: true,
+ departed: false,
+ task_status: null,
+ completion_rate: null,
+ grade: "×",
+ });
+ // ★レポートがある行(S)は false★
+ expect(body.items.find((i) => i.id === 103)?.result_pending).toBe(false);
+ // ★結果待ちは連続日数に数えない(表2 C10)★
+ expect(item?.consecutive_problem_days).toBe(0);
+ expect(item?.escalation_level).toBeNull();
+ });
+
+ it("★now=05:00(終了+120分ちょうど)→ F: `result_pending: false`(確定した出発失敗。表1 P6)★", async () => {
+ vi.setSystemTime(new Date("2026-09-10T05:00:00+09:00"));
+ const body = await get("");
+ const item = body.items.find((i) => i.id === 201);
+ expect(item).toMatchObject({ result_pending: false, departed: false, completion_rate: null });
+ expect(item?.consecutive_problem_days).toBe(1);
+ });
+
+ it("★清掃中(now=02:30。開始以降・終了前)も結果待ち(表1 P4)★", async () => {
+ vi.setSystemTime(new Date("2026-09-10T02:30:00+09:00"));
+ const body = await get("");
+ expect(body.items.find((i) => i.id === 201)?.result_pending).toBe(true);
+ });
+});
+
+describe("GET /clean-logs — 事前チェックの列は分数ではなく順番で割り当てる(D-178。ユーザー判断 2026-10-16)", () => {
+ // ★本清掃 103 の事前チェック行(1031: 60分前・EmergencyStop / 1032: 30分前・エラー無し)の分数だけを差し替える★
+ function setPrecheckMinutes(byId: Record): void {
+ data.timetables = data.timetables.map((t) =>
+ byId[Number(t.id)] === undefined ? t : { ...t, precheck: byId[Number(t.id)] }
+ );
+ }
+ async function row103(): Promise {
+ const r = (await get("")).items.find((i) => i.id === 103);
+ if (!r) throw new Error("清掃 103 が無い");
+ return r;
+ }
+ function precheckAnomalies() {
+ return vi
+ .mocked(logEvent)
+ .mock.calls.filter(
+ ([, p]) => p.event === "data.anomaly" && p.metric === "precheck_rounds_per_main_run"
+ );
+ }
+
+ it("60 / 30(設定どおり)→ 1回目 EmergencyStop・60、2回目 Standby.・30", async () => {
+ const r = await row103();
+ expect(r).toMatchObject({
+ status_1h: "EmergencyStop",
+ status_1h_minutes: 60,
+ status_30m: "Standby.",
+ status_30m_minutes: 30,
+ });
+ expect(precheckAnomalies()).toHaveLength(0);
+ });
+
+ it("★60 / 10(設定と違う分数)でも10分前の結果を落とさない★(旧: 2列目が null で「-」だった)", async () => {
+ setPrecheckMinutes({ 1032: 10 });
+ expect(await row103()).toMatchObject({
+ status_1h: "EmergencyStop",
+ status_1h_minutes: 60,
+ status_30m: "Standby.",
+ status_30m_minutes: 10,
+ });
+ });
+
+ it("★事前チェックが1回(30分前)だけなら1回目に入り、2回目は null★", async () => {
+ data.timetables = data.timetables.filter((t) => Number(t.id) !== 1031);
+ expect(await row103()).toMatchObject({
+ status_1h: "Standby.",
+ status_1h_minutes: 30,
+ status_30m: null,
+ status_30m_minutes: null,
+ });
+ });
+
+ it("事前チェックの無い本清掃は4つとも null", async () => {
+ const r = (await get("")).items.find((i) => i.id === 101);
+ expect(r).toMatchObject({
+ status_1h: null,
+ status_1h_minutes: null,
+ status_30m: null,
+ status_30m_minutes: null,
+ });
+ });
+
+ it("★3つあれば開始に近い2つを残し、捨てた分数を `data.anomaly` に1回出す★(route / method は載せない)", async () => {
+ data.timetables.push(timetable(1033, 1, FACILITY_1, "2026-09-10T00:30:00+09:00", 90, 103));
+ expect(await row103()).toMatchObject({
+ status_1h: "EmergencyStop",
+ status_1h_minutes: 60,
+ status_30m: "Standby.",
+ status_30m_minutes: 30,
+ });
+ const events = precheckAnomalies();
+ expect(events).toHaveLength(1);
+ expect(events[0][0]).toBe("warn");
+ expect(events[0][1]).toEqual({
+ event: "data.anomaly",
+ metric: "precheck_rounds_per_main_run",
+ expected: 2,
+ actual: 3,
+ clean_timetable_id: "103",
+ dropped_precheck_minutes: [90],
+ });
+ });
+
+ it("★判定(出発・完了率・判定)は事前チェックの割り当てで変わらない★(D-170)", async () => {
+ const before = await row103();
+ setPrecheckMinutes({ 1031: 2, 1032: 1 });
+ const after = await row103();
+ expect(after).toMatchObject({ status_1h_minutes: 2, status_30m_minutes: 1 });
+ expect({
+ departed: after.departed,
+ completion_rate: after.completion_rate,
+ grade: after.grade,
+ consecutive_problem_days: after.consecutive_problem_days,
+ }).toEqual({
+ departed: before.departed,
+ completion_rate: before.completion_rate,
+ grade: before.grade,
+ consecutive_problem_days: before.consecutive_problem_days,
+ });
+ });
+});
+
+describe("GET /clean-logs — 有効なロボットがいない清掃エリア(指示書BB U2・表2)", () => {
+ /** `data.anomaly` のうち、清掃エリアのロボット台数のものだけ */
+ function robotAnomalies() {
+ return vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "active_robots_per_section")
+ .map(([, p]) => p);
+ }
+
+ it("★R2: 0台でも例外にならず、その行は出る。機種・シリアルは null。ログは清掃エリアごとに1回★", async () => {
+ // 施設1(清掃エリア 10)の本清掃3件(101〜103)からロボットを外す
+ for (const t of data.timetables) {
+ if (Number(t.facility_id) === 1) t.section = { robots: [] };
+ }
+ const res = await GET(new Request("http://localhost/api/clean-logs"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ListResponse;
+ expect(body.items.map((i) => [i.id, i.model_name, i.serial_number])).toEqual([
+ [301, "PUDU CC1", "8111R562806003"],
+ [103, null, null],
+ [201, "PUDU CC1", "8111R562806002"],
+ [102, null, null],
+ [101, null, null],
+ ]);
+ // ★同じ清掃エリアの行が3行あっても1回★
+ expect(robotAnomalies()).toEqual([
+ { event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 0, section_id: "10" },
+ ]);
+ });
+
+ it("R1: 1台なら今どおり(ログを出さない)。`robots` の並びは `id` 昇順を SQL に頼む", async () => {
+ await get("");
+ expect(robotAnomalies()).toHaveLength(0);
+ const include = calls.cleanTimetable.find((c) => "include" in c)?.include as {
+ section: { include: { robots: { orderBy: unknown } } };
+ };
+ expect(include.section.include.robots.orderBy).toEqual({ id: "asc" });
+ });
+
+ it("★R5: 有効なロボット2台(id 10 と id 7)→ id 7 を採る。ログ(actual: 2)は清掃エリアごとに1回★", async () => {
+ for (const t of data.timetables) {
+ if (Number(t.facility_id) !== 3) continue;
+ t.section = {
+ robots: [
+ { id: BigInt(10), serial_number: "SERIAL-ID10", device: { maker_id: 1, model_name: "CC1" } },
+ { id: BigInt(7), serial_number: "SERIAL-ID7", device: { maker_id: 1, model_name: "CC1" } },
+ ],
+ };
+ }
+ const body = await get("");
+ expect(body.items.find((i) => i.id === 301)?.serial_number).toBe("SERIAL-ID7");
+ expect(robotAnomalies()).toEqual([
+ { event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 2, section_id: "30" },
+ ]);
+ });
+
+ it("★R7: 0台の行に `serial=` / `model=` で絞ると当たらない(null は部分一致しない)。絞り込みなしなら出る★", async () => {
+ for (const t of data.timetables) {
+ if (Number(t.facility_id) === 1) t.section = { robots: [] };
+ }
+ const bySerial = await get("?serial=8111R");
+ expect(bySerial.items.map((i) => i.id)).toEqual([301, 201]);
+ const byModel = await get(`?model=${encodeURIComponent("pudu")}`);
+ expect(byModel.items.map((i) => i.id)).toEqual([301, 201]);
+ expect((await get("")).items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]);
+ });
+});
+
+describe("GET /clean-logs — 削除済みの施設・清掃エリアの行は出さない(指示書BB U1・表1 V1)", () => {
+ const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");
+
+ it("★V1: 予定の2表の `where` に施設・清掃エリアの可視性が入る★", async () => {
+ await get("");
+ expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ const history = calls.cleanTimetableHistory.find((c) => "include" in c) as { where: Record };
+ expect(history.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ });
+
+ it.each([
+ ["施設削除", (t: StubTimetable) => ({ ...t, facility: { ...t.facility, deleted_at: DELETED } })],
+ ["エリア削除", (t: StubTimetable) => ({ ...t, section: { ...t.section, deleted_at: DELETED } })],
+ ] as const)("V1(%s): その行は出ない・meta.total に数えない(生きている行は出る)", async (_label, mark) => {
+ data.timetables = data.timetables.map((t) => (Number(t.id) === 301 ? mark(t) : t));
+ const body = await get("");
+ expect(body.items.map((i) => i.id)).toEqual([103, 201, 102, 101]);
+ expect(body.meta.total).toBe(4);
+ });
+});
+
+describe("GET /clean-logs — 存在しない日付は422(指示書BB 表8 Q6)", () => {
+ it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => {
+ const bad = await GET(new Request("http://localhost/api/clean-logs?date_from=2026-02-31"));
+ const format = await GET(new Request("http://localhost/api/clean-logs?date_from=2026/02/03"));
+ expect(bad.status).toBe(422);
+ expect(format.status).toBe(422);
+ expect(await bad.json()).toEqual(await format.json());
+ });
+});
+
+/**
+ * ★表10 N5(指示書BB A6・D16)★ — 施設で絞ったとき、`cleanLogs()` は連続日数の入力(`loadConsecutiveInputs()`)にも
+ * 同じ施設IDを渡す。★応答の `consecutive_problem_days`・`escalation_level` は絞る前と同じ★。
+ * (ルートの分類の解決はこのテストのスタブでは施設名で行う — 解決の経路は違っても渡る配列は同じ `query.facility_ids`)
+ */
+describe("GET /clean-logs — 連続日数の入力も施設で絞る(指示書BB 表10 N5)", () => {
+ /** 連続日数用の予定のクエリ(`precheck` を読む select 付き) */
+ function consecutiveTimetableCalls() {
+ return calls.cleanTimetable.filter((c) => {
+ const select = c.select as Record | undefined;
+ return select !== undefined && select.precheck === true && select.facility_id === true;
+ });
+ }
+
+ it("★施設名で施設1・2に絞る → 連続日数の入力の `where` に `facility_id: { in: [1n, 2n] }`。値は絞る前と同じ★", async () => {
+ const before = await get("");
+ const expected = new Map(before.items.map((i) => [i.id, [i.consecutive_problem_days, i.escalation_level]]));
+ calls.cleanTimetable = [];
+
+ const scoped = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`);
+ const consecutive = consecutiveTimetableCalls();
+ expect(consecutive).toHaveLength(1);
+ expect((consecutive[0].where as Record).facility_id).toEqual({ in: [BigInt(1), BigInt(2)] });
+ expect(scoped.items.length).toBeGreaterThan(0);
+ for (const item of scoped.items) {
+ expect([item.consecutive_problem_days, item.escalation_level]).toEqual(expected.get(item.id));
+ }
+ expect(scoped.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3);
+ });
+
+ it("施設で絞らない → 連続日数の入力にも `facility_id` の条件を付けない", async () => {
+ await get("");
+ const consecutive = consecutiveTimetableCalls();
+ expect(consecutive).toHaveLength(1);
+ expect(consecutive[0].where).not.toHaveProperty("facility_id");
+ });
+});
+
+/**
+ * ★予定外タスクの行(指示書BG §1・§2-1 / 表U1)★ — `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行。
+ *
+ * | # | 行 | 期待 |
+ * |---|---|---|
+ * | U1-1 | 予定外・`started_at` 9/10 21:30・完了率 95.9 | 1行。`unscheduled` true・`id` null・`row_key` "rp-…"・`departed` true・◎・事前チェック null |
+ * | U1-2 | 予定外・`clean_area` 0 | 完了率 0・× ・出発 true(未実施ではない) |
+ * | U1-3 | `clean_timetable_id` = 0 と NULL | どちらも予定外として同じ扱い |
+ * | U1-4 | 予定外・`task_area` 0 | 完了率 0 |
+ * | U1-5 | 予定外・削除済みの施設(清掃エリア) | 出さない・数えない |
+ * | U1-6 | 予定のある行 | 今と同じ(`unscheduled` false・`row_key` "tt-") |
+ *
+ * ★日付は表の「10-28 21:00」の代わりに、このファイルの合成データ(9/10)に合わせた★(扱いは同じ)
+ */
+describe("予定外タスクの行(指示書BG 表U1)", () => {
+ /** 予定外の清掃結果1行(壁時計値。完了率は `clean_area ÷ task_area × 100`) */
+ function unscheduledReport(
+ reportId: string,
+ facilityId: number,
+ facilityName: string,
+ startedJstIso: string,
+ cleanArea: number,
+ taskArea: number,
+ timetableId: bigint | null = null,
+ status = 4
+ ): StubReport {
+ const startedAt = dbWallClock(startedJstIso);
+ return {
+ report_id: reportId,
+ facility_id: BigInt(facilityId),
+ section_id: BigInt(facilityId * 10),
+ clean_timetable_id: timetableId,
+ clean_ratio: 0,
+ status,
+ started_at: startedAt,
+ ended_at: new Date(startedAt.getTime() + 60 * 60_000),
+ clean_area: cleanArea,
+ task_area: taskArea,
+ facility: { facility_name: facilityName, deleted_at: null },
+ section: {
+ deleted_at: null,
+ robots: [{ serial_number: `8111R56280600${facilityId}`, device: { maker_id: 1, model_name: "CC1" } }],
+ },
+ };
+ }
+ const RP1 = "9200000000000000001";
+ const RP2 = "9200000000000000002";
+
+ it("U1-1: 予定外の1行 — unscheduled true・id null・row_key rp-…・出発 true・◎・事前チェック null・scheduled_at は started_at", async () => {
+ data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000));
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const item = body.items.find((i) => i.unscheduled);
+ expect(item).toMatchObject({
+ id: null,
+ row_key: `rp-${RP1}`,
+ report_id: RP1,
+ unscheduled: true,
+ facility_id: 3,
+ facility_name: FACILITY_3,
+ model_name: "PUDU CC1",
+ serial_number: "8111R562806003",
+ scheduled_at: "2026-09-10T21:30:00+09:00",
+ status_1h: null,
+ status_1h_minutes: null,
+ status_30m: null,
+ status_30m_minutes: null,
+ departed: true,
+ task_status: 4,
+ result_pending: false,
+ completion_rate: 95.9,
+ grade: "◎",
+ });
+ // ★予定のある行と一緒に `scheduled_at` の新しい順で並び、`meta.total` にも数える★(9/10 の予定3行 + 予定外1行)
+ expect(body.items[0].row_key).toBe(`rp-${RP1}`);
+ expect(body.meta.total).toBe(4);
+ });
+
+ it("U1-2: 予定外・clean_area 0 → 完了率 0・×・出発 true(未実施ではない)", async () => {
+ data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 0, 800, null, 6));
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const item = body.items.find((i) => i.unscheduled);
+ expect(item).toMatchObject({ completion_rate: 0, grade: "×", departed: true, task_status: 6, result_pending: false });
+ });
+
+ it("U1-3: clean_timetable_id が 0 と NULL のどちらも予定外。0 でも NULL でもないレポートは予定外の行にしない", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null),
+ unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0)),
+ // ★予定の id を持つ(予定の表に行が無い)レポート★ — 予定外の行にしない
+ unscheduledReport("9200000000000000003", 3, FACILITY_3, "2026-09-10T23:00:00+09:00", 500, 1000, BigInt(999))
+ );
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const unscheduled = body.items.filter((i) => i.unscheduled);
+ expect(unscheduled.map((i) => i.row_key)).toEqual([`rp-${RP2}`, `rp-${RP1}`]);
+ expect(unscheduled.map((i) => [i.completion_rate, i.grade, i.departed])).toEqual([
+ [50, "△", true],
+ [50, "△", true],
+ ]);
+ });
+
+ it("U1-4: 予定外・task_area 0 → 完了率 0(`runCompletionRate()` と同じ)", async () => {
+ data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 300, 0));
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ expect(body.items.find((i) => i.unscheduled)).toMatchObject({ completion_rate: 0, grade: "×", departed: true });
+ });
+
+ it("U1-5: 削除済みの施設・清掃エリアの予定外は出さない・数えない", async () => {
+ const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000);
+ deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
+ const deletedSection = unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 959, 1000);
+ deletedSection.section = {
+ robots: deletedSection.section?.robots ?? [],
+ deleted_at: dbWallClock("2026-09-01T00:00:00+09:00"),
+ };
+ data.reports.push(deletedFacility, deletedSection);
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ expect(body.items.some((i) => i.unscheduled)).toBe(false);
+ expect(body.meta.total).toBe(3);
+ // ★SQL の `where` にも可視性の条件が載っている★
+ const call = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record };
+ expect(call.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ });
+
+ it("U1-6: 予定のある行は今と同じ(unscheduled false・row_key tt-・report_id null)", async () => {
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const item = body.items.find((i) => i.id === 103);
+ expect(item).toMatchObject({ unscheduled: false, row_key: "tt-103", report_id: null });
+ expect(body.items.every((i) => !i.unscheduled)).toBe(true);
+ });
+
+ it("期間は started_at(JST の日付)で絞る・施設の絞り込みも効く", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-09T21:30:00+09:00", 959, 1000),
+ unscheduledReport(RP2, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)
+ );
+ const day10 = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ expect(day10.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]);
+ const facility3 = await get(`?facility_name=${encodeURIComponent(FACILITY_3)}`);
+ expect(facility3.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP1}`]);
+ });
+
+ it("予定外の行の連続日数・L1〜L3 は、施設のその日の値(同じ施設・同じ日の予定のある行と同じ)", async () => {
+ // 施設1 の 9/10 は 9/8・9/9・9/10 の × で3日連続(L3)。同じ日の予定外 ◎ も同じ値になる
+ data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
+ const body = await get("?date_from=2026-09-10&date_to=2026-09-10");
+ const item = body.items.find((i) => i.unscheduled);
+ expect(item).toMatchObject({ grade: "◎", consecutive_problem_days: 3, escalation_level: "L3" });
+ });
+
+ it("result=problem の絞り込みも予定のある行と同じ(判定で絞る)", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000),
+ unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000)
+ );
+ const problem = await get("?date_from=2026-09-10&date_to=2026-09-10&result=problem");
+ expect(problem.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]);
+ });
+
+ it("単一ID(`/clean-logs/{id}/*`)の取得では予定外の行を読まない", async () => {
+ data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
+ const rows = await db.cleanLogs({ id: 103, date_from: null, date_to: null, facility_ids: null });
+ expect(rows.map((r) => r.row_key)).toEqual(["tt-103"]);
+ expect(calls.cleanTaskReport.filter((c) => "include" in c)).toEqual([]);
+ });
+
+ async function unscheduledAdvice(reportId: string): Promise {
+ return GET_UNSCHEDULED_ADVICE(
+ new Request(
+ `http://localhost/api/clean-logs/unscheduled/${encodeURIComponent(reportId)}/advice`
+ ),
+ { params: Promise.resolve({ report_id: reportId }) }
+ );
+ }
+
+ it("A-5: db.unscheduledCleanLog は一覧の予定なし行と toEqual で一致", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000),
+ unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000, null, 6)
+ );
+ const list = await db.cleanLogs({
+ id: null,
+ date_from: null,
+ date_to: null,
+ facility_ids: null,
+ });
+ // ★予定なしの行が RP1・RP2 のちょうど2件★(0件だとループが空回りして素通りするため、先に件数と中身を固める)
+ const unscheduledRows = list.filter((r) => r.unscheduled);
+ expect(unscheduledRows.map((r) => r.report_id).sort()).toEqual([RP1, RP2].sort());
+ for (const row of unscheduledRows) {
+ const one = await db.unscheduledCleanLog(row.report_id!);
+ expect(one).toEqual(row);
+ }
+ });
+
+ it("A-6: 施設1・9/10 の予定外 ◎ — consecutive 3・L3", async () => {
+ data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
+ const row = await db.unscheduledCleanLog(RP1);
+ expect(row).toMatchObject({ grade: "◎", consecutive_problem_days: 3, escalation_level: "L3" });
+ });
+
+ it("A-7: 予定のあるレポートの report_id は null(例外にならない)", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, BigInt(103))
+ );
+ expect(await db.unscheduledCleanLog(RP1)).toBeNull();
+ });
+
+ it("A-8: clean_timetable_id が 0 と NULL のどちらも行を返す", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null),
+ unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0))
+ );
+ expect(await db.unscheduledCleanLog(RP1)).not.toBeNull();
+ expect(await db.unscheduledCleanLog(RP2)).not.toBeNull();
+ });
+
+ it("A-9: 削除済みの施設の予定外は null・where に可視性と予定なし条件", async () => {
+ const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000);
+ deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
+ data.reports.push(deletedFacility);
+ expect(await db.unscheduledCleanLog(RP1)).toBeNull();
+ const call = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record };
+ expect(call.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ expect(call.where).toMatchObject(UNSCHEDULED_TIMETABLE_ID_WHERE);
+ });
+
+ it("A-10: 連続日数の読み込みは行の JST 日付と施設に絞る", async () => {
+ data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
+ calls.cleanTimetable.length = 0;
+ calls.cleanTaskReport.length = 0;
+ await db.unscheduledCleanLog(RP1);
+ const d = "2026-09-10";
+ const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax());
+ const timetableConsecutive = calls.cleanTimetable.find(
+ (c) => "select" in c && (c.select as Record).facility_id === true
+ ) as { where: { start_at: { gte: Date; lt: Date }; facility_id: { in: bigint[] } } };
+ expect(timetableConsecutive).toBeDefined();
+ expect(timetableConsecutive.where.start_at.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
+ expect(timetableConsecutive.where.start_at.gte.toISOString().slice(0, 10)).toBe(
+ new Date(Date.parse(`${d}T00:00:00Z`) - lookback * 86_400_000).toISOString().slice(0, 10)
+ );
+ expect(timetableConsecutive.where.facility_id).toEqual({ in: [BigInt(1)] });
+
+ const reportConsecutive = calls.cleanTaskReport.find(
+ (c) => "select" in c && (c.select as Record).facility_id === true
+ ) as { where: { started_at: { gte: Date; lt: Date }; facility_id: { in: bigint[] } } };
+ expect(reportConsecutive).toBeDefined();
+ expect(reportConsecutive.where.started_at.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z");
+ expect(reportConsecutive.where.started_at.gte.toISOString().slice(0, 10)).toBe(
+ timetableConsecutive.where.start_at.gte.toISOString().slice(0, 10)
+ );
+ expect(reportConsecutive.where.facility_id).toEqual({ in: [BigInt(1)] });
+ });
+
+ it("A-6′: 施設1・9/10 の予定外 ◎ — 200・R3/R4・max_level 3", async () => {
+ data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000));
+ const res = await unscheduledAdvice(RP1);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ escalation_level: string;
+ consecutive_problem_days: number;
+ consecutive_problem_days_capped: boolean;
+ max_level: number;
+ advices: { rule: string }[];
+ };
+ expect(body.escalation_level).toBe("L3");
+ expect(body.consecutive_problem_days).toBe(3);
+ expect(body.consecutive_problem_days_capped).toBe(false);
+ expect(body.advices.map((a) => a.rule)).toEqual(["R3", "R4"]);
+ expect(body.max_level).toBe(3);
+ });
+
+ it("A-7′: 予定のあるレポートの report_id は 404", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, BigInt(103))
+ );
+ const res = await unscheduledAdvice(RP1);
+ expect(res.status).toBe(404);
+ expect(await res.json()).toMatchObject({
+ error: { code: "not_found", message: "指定された清掃ログが見つかりません" },
+ });
+ });
+
+ it("A-8′: clean_timetable_id 0 と NULL の行は 200", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null),
+ unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0))
+ );
+ expect((await unscheduledAdvice(RP1)).status).toBe(200);
+ expect((await unscheduledAdvice(RP2)).status).toBe(200);
+ });
+
+ it("A-9′: 削除済みの施設の予定外は 404", async () => {
+ const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000);
+ deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") };
+ data.reports.push(deletedFacility);
+ const res = await unscheduledAdvice(RP1);
+ expect(res.status).toBe(404);
+ expect(await res.json()).toMatchObject({
+ error: { code: "not_found", message: "指定された清掃ログが見つかりません" },
+ });
+ });
+
+ it("A-11: 末尾空白付き report_id は 404(スタブは行を返すが JS の === で弾く)", async () => {
+ data.reports.push(unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000));
+ const padded = `${RP2} `;
+ expect(padded.length).toBe(20);
+ const res = await unscheduledAdvice(padded);
+ expect(res.status).toBe(404);
+ expect(await res.json()).toMatchObject({
+ error: { code: "not_found", message: "指定された清掃ログが見つかりません" },
+ });
+ const row = data.reports.find((r) => r.report_id === RP2)!;
+ const includeCall = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record };
+ expect(matchesWhere(row as unknown as Record, includeCall.where)).toBe(true);
+ });
+
+ it("A-12: loadAdviceRules 例外 → 500・logApiError", async () => {
+ data.reports.push(unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000));
+ vi.mocked(logApiError).mockClear();
+ const broken: JsonStore = {
+ read: async () => {
+ throw new Error("[stub] 読み込みに失敗");
+ },
+ write: async () => {},
+ };
+ __setAdviceStoreForTesting(broken);
+ try {
+ const res = await unscheduledAdvice(RP2);
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ });
+ expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({
+ route: "/api/clean-logs/unscheduled/[report_id]/advice",
+ method: "GET",
+ status: 500,
+ });
+ } finally {
+ __setAdviceStoreForTesting(null);
+ }
+ });
+
+ it("A-13: 200 時 judge.advice に report_id のみ(clean_log_id キーなし)", async () => {
+ data.reports.push(
+ unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null)
+ );
+ vi.mocked(logEvent).mockClear();
+ const res = await unscheduledAdvice(RP1);
+ expect(res.status).toBe(200);
+ const adviceCall = vi.mocked(logEvent).mock.calls.find(
+ (c) => (c[1] as { event?: string }).event === "judge.advice"
+ );
+ expect(adviceCall).toBeDefined();
+ const payload = adviceCall![1] as unknown as Record;
+ expect(payload).toMatchObject({
+ route: "/api/clean-logs/unscheduled/[report_id]/advice",
+ report_id: RP1,
+ });
+ expect("clean_log_id" in payload).toBe(false);
+ const escalationCall = vi.mocked(logEvent).mock.calls.find(
+ (c) => (c[1] as { event?: string }).event === "judge.escalation"
+ );
+ expect(escalationCall?.[1]).toMatchObject({
+ route: "/api/clean-logs/unscheduled/[report_id]/advice",
+ });
+ });
+});
diff --git a/app/api/clean-logs/__tests__/sort-route.test.ts b/app/api/clean-logs/__tests__/sort-route.test.ts
new file mode 100644
index 0000000..d887a4d
--- /dev/null
+++ b/app/api/clean-logs/__tests__/sort-route.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { GET } from "@/app/api/clean-logs/route";
+import { bhS1RowsDefaultOrder } from "@/lib/clean-logs/__tests__/bh-s1-fixture";
+import type { CleanLogItem, ListResponse } from "@/lib/types";
+
+let rows: CleanLogItem[] = [];
+
+vi.mock("@/lib/db", () => ({
+ db: {
+ cleanLogs: async () => rows,
+ facilityNameIndex: async () => [],
+ },
+}));
+
+vi.mock("@/lib/settings/display-limits", () => ({
+ loadConsecutiveDaysMax: async () => 90,
+}));
+
+async function get(query: string): Promise> {
+ const res = await GET(new Request(`http://localhost/api/clean-logs?${query}`));
+ return (await res.json()) as ListResponse;
+}
+
+describe("GET /clean-logs — 並べ替えとページング(指示書BH 表S2 S2-10)", () => {
+ beforeEach(() => {
+ rows = bhS1RowsDefaultOrder();
+ });
+
+ it("S2-10: sort=grade&order=asc・page=2 — 2ページ目にも並べ替えが効く", async () => {
+ const body = await get("sort=grade&order=asc&per_page=3&page=2&result=all");
+ expect(body.meta.total).toBe(8);
+ expect(body.meta.page).toBe(2);
+ expect(body.items.map((i) => i.row_key)).toEqual(["tt-107", "tt-105", "tt-101"]);
+ });
+
+ it("S2-10b: result=problem で絞った件数の page=2", async () => {
+ const body = await get("result=problem&sort=grade&order=asc&per_page=2&page=2");
+ expect(body.meta.total).toBe(3);
+ expect(body.meta.page).toBe(2);
+ expect(body.items.map((i) => i.row_key)).toEqual(["tt-102"]);
+ });
+});
+
+describe("GET /clean-logs — schedule 絞り込み(指示書BH 表F)", () => {
+ beforeEach(() => {
+ rows = bhS1RowsDefaultOrder();
+ });
+
+ function withoutUnscheduled(keys: string[]): string[] {
+ return keys.filter((k) => k !== "rp-9001");
+ }
+
+ it("F-1: result=all&schedule=unscheduled", async () => {
+ const body = await get("result=all&schedule=unscheduled");
+ expect(body.meta.total).toBe(1);
+ expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]);
+ });
+
+ it("F-2: result=all&schedule=scheduled", async () => {
+ const baseline = await get("result=all");
+ const body = await get("result=all&schedule=scheduled");
+ expect(body.meta.total).toBe(7);
+ expect(body.items.map((i) => i.row_key)).toEqual(
+ withoutUnscheduled(baseline.items.map((i) => i.row_key))
+ );
+ });
+
+ it("F-3: result=problem&schedule=scheduled", async () => {
+ const baseline = await get("result=problem");
+ const body = await get("result=problem&schedule=scheduled");
+ expect(body.meta.total).toBe(2);
+ expect(body.items.map((i) => i.row_key)).toEqual(
+ withoutUnscheduled(baseline.items.map((i) => i.row_key))
+ );
+ });
+
+ it("F-4: result=problem&schedule=unscheduled", async () => {
+ const body = await get("result=problem&schedule=unscheduled");
+ expect(body.meta.total).toBe(1);
+ expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]);
+ });
+
+ it("F-5: result=critical&schedule=unscheduled", async () => {
+ const body = await get("result=critical&schedule=unscheduled");
+ expect(body.meta.total).toBe(1);
+ expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]);
+ });
+
+ it("F-6: result=all&schedule=scheduled&sort=grade&order=asc&per_page=2&page=1", async () => {
+ const allSorted = await get("result=all&sort=grade&order=asc&per_page=8&page=1");
+ const expectedHead = withoutUnscheduled(allSorted.items.map((i) => i.row_key)).slice(
+ 0,
+ 2
+ );
+ const body = await get(
+ "result=all&schedule=scheduled&sort=grade&order=asc&per_page=2&page=1"
+ );
+ expect(body.meta.total).toBe(7);
+ expect(body.items.map((i) => i.row_key)).toEqual(expectedHead);
+ });
+
+ it("F-7: schedule=all → 422", async () => {
+ const res = await GET(new Request("http://localhost/api/clean-logs?schedule=all"));
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: { details?: Record } };
+ expect(Object.keys(body.error.details ?? {})).toEqual(["schedule"]);
+ });
+
+ it("F-8: result=all&schedule= → schedule なしと同じ", async () => {
+ const baseline = await get("result=all");
+ const body = await get("result=all&schedule=");
+ expect(body.meta.total).toBe(8);
+ expect(body.items.map((i) => i.row_key)).toEqual(
+ baseline.items.map((i) => i.row_key)
+ );
+ });
+});
diff --git a/app/api/clean-logs/export/__tests__/route.test.ts b/app/api/clean-logs/export/__tests__/route.test.ts
new file mode 100644
index 0000000..003e464
--- /dev/null
+++ b/app/api/clean-logs/export/__tests__/route.test.ts
@@ -0,0 +1,214 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
+// 最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、
+// フルスイート実行時に既定のタイムアウトを踏んで不定期に落ちる
+// (`app/api/clean-logs/__tests__/route.test.ts` の実測)。
+// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効く。
+import { GET } from "@/app/api/clean-logs/export/route";
+import { bhS1RowsDefaultOrder } from "@/lib/clean-logs/__tests__/bh-s1-fixture";
+import { MAX_CSV_ROWS } from "@/lib/config";
+import type { ApiError, CleanLogItem } from "@/lib/types";
+
+/**
+ * CSVエクスポートの行数上限(ユーザー指示 2026-09-15 / 指示書AI 段階8)
+ *
+ * ★期待値表(指示書AI 10-5)★
+ *
+ * | # | 状況 | サーバ |
+ * |-----|------------------------|-------------------------|
+ * | 8-1 | 0件 | 200(ヘッダ行だけのCSV)|
+ * | 8-2 | 19,999件 | 200 |
+ * | 8-3 | ★ちょうど 20,000件★ | ★★200(「上限」は含む)★★ |
+ * | 8-4 | ★20,001件★ | ★★422★★ |
+ * | 8-5 | 1店舗・2年・1,300件 | 200(期間が長くても行数が少なければ出せる)|
+ *
+ * ★★8-3 と 8-4 の両方を必ず入れること★★ — 境界の片側だけだと、
+ * `>` と `>=` の取り違えが通ってしまう(`CLAUDE.md` §9.4)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db` の `db` をスタブに差し替える。★`resolveFacilityIdsByName()` も
+ * 同じ `db`(`facilityNameIndex()`)を通る★ので、ここ1箇所で足りる。
+ *
+ * ★行数だけが関心事なので、行の中身は使い回しの1件でよい★ —
+ * CSVの中身(列・エスケープ)は `lib/clean-logs/__tests__/csv.test.ts` の担当。
+ */
+
+/** `db.cleanLogs()` が返す行数。各テストが書き換える */
+let rowCount = 0;
+/** 指定時はこの配列を返す(表S1 用。指示書BH S2-9) */
+let customRows: CleanLogItem[] | null = null;
+
+function row(id: number): CleanLogItem {
+ return {
+ id,
+ row_key: `tt-${id}`,
+ report_id: null,
+ unscheduled: false,
+ facility_id: 1,
+ facility_name: "メガバリューマート中央本店",
+ model_name: "PUDU CC1",
+ serial_number: "8111R5628060011",
+ scheduled_at: "2026-08-27T02:00:00+09:00",
+ status_1h: "待機中",
+ status_1h_minutes: 60,
+ status_30m: "待機中",
+ status_30m_minutes: 30,
+ departed: true,
+ // `clean_task_reports.status` の生値(CSV実測で最多の値。意味は第10回 N-2 で受領済み。指示書AQ §1-2)
+ task_status: 4,
+ // ★結果待ち(D-173)★ — レポートあり・確定の行
+ result_pending: false,
+ completion_rate: 90.1,
+ grade: "◎",
+ consecutive_problem_days: 0,
+ escalation_level: null,
+ };
+}
+
+vi.mock("@/lib/db", () => ({
+ db: {
+ cleanLogs: async () =>
+ customRows ??
+ Array.from({ length: rowCount }, (_unused, i) => row(i + 1)),
+ // 施設名の解決に使う(施設名を指定しないテストでは読まれない)
+ facilityNameIndex: async () => [
+ { id: 1, facility_name: "メガバリューマート中央本店" },
+ ],
+ },
+}));
+
+async function get(query = ""): Promise {
+ return GET(new Request(`http://localhost/api/clean-logs/export?${query}`));
+}
+
+describe("GET /clean-logs/export — 行数の上限", () => {
+ it("8-1: 0件は200(ヘッダ行だけのCSV)", async () => {
+ rowCount = 0;
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toContain("text/csv");
+ const body = await res.text();
+ // ヘッダ行だけ(BOM + 1行)。★0件を422にしないこと★
+ expect(body.trimEnd().split("\n")).toHaveLength(1);
+ });
+
+ it("8-2: 上限より1件少なければ200", async () => {
+ rowCount = MAX_CSV_ROWS - 1;
+ const res = await get();
+ expect(res.status).toBe(200);
+ });
+
+ it("8-3: ★ちょうど上限は200★(「上限」は含む)", async () => {
+ rowCount = MAX_CSV_ROWS;
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toContain("text/csv");
+ });
+
+ it("8-4: ★上限+1件は422★(500ではない)", async () => {
+ rowCount = MAX_CSV_ROWS + 1;
+ const res = await get();
+ expect(res.status).toBe(422);
+
+ const body = (await res.json()) as ApiError;
+ expect(body.error.code).toBe("validation_failed");
+ // ★details に実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる
+ expect(body.error.details).toEqual({
+ total: MAX_CSV_ROWS + 1,
+ max: MAX_CSV_ROWS,
+ });
+ // 件数は文言にも出す(画面に出さずAPIを直接叩いた人のため)
+ expect(body.error.message).toContain(String(MAX_CSV_ROWS + 1));
+ expect(body.error.message).toContain(String(MAX_CSV_ROWS));
+ });
+
+ it("8-5: 期間が2年でも行数が少なければ200(★上限は日数ではなく行数★)", async () => {
+ rowCount = 1_300;
+ const res = await get(
+ "date_from=2026-01-01&date_to=2028-01-01&facility_name=メガバリューマート中央本店"
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it("日付の範囲が上限(2年)を超えていれば、行数を数える前に422", async () => {
+ rowCount = 0;
+ const res = await get("date_from=2026-01-01&date_to=2028-01-02");
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as ApiError;
+ expect(body.error.code).toBe("validation_failed");
+ });
+});
+
+describe("GET /clean-logs/export — 並べ替え(指示書BH 表S2 S2-9)", () => {
+ beforeEach(() => {
+ rowCount = 0;
+ customRows = null;
+ });
+
+ it("S2-9: sort=grade&order=asc — CSV のシリアル列が grade asc 順", async () => {
+ customRows = bhS1RowsDefaultOrder();
+ const res = await get("sort=grade&order=asc&result=all");
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ const lines = text.trimEnd().split("\n");
+ expect(lines.length).toBe(9);
+ const serials = lines.slice(1).map((line) => line.split(",")[2]?.replace(/^'/, "").replace(/'$/, "") ?? "");
+ expect(serials).toEqual([
+ "S-004",
+ "S-002",
+ "S-001",
+ "S-007",
+ "S-005",
+ "S-003",
+ "S-006",
+ "",
+ ]);
+ });
+
+ it("S2-9: 不正な sort で 422", async () => {
+ customRows = [];
+ const res = await get("sort=facility_name");
+ expect(res.status).toBe(422);
+ });
+});
+
+describe("GET /clean-logs/export — schedule 絞り込み(指示書BH 表X)", () => {
+ beforeEach(() => {
+ rowCount = 0;
+ customRows = bhS1RowsDefaultOrder();
+ });
+
+ /** CSV ヘッダ「予定時刻」列(0-based index 4) */
+ function scheduledTimeCells(text: string): string[] {
+ const lines = text.trimEnd().split("\n");
+ return lines.slice(1).map((line) => line.split(",")[4]?.replace(/^'/, "").replace(/'$/, "") ?? "");
+ }
+
+ it("X-1: result=all&schedule=unscheduled — 1行・予定時刻は予定なし", async () => {
+ const res = await get("result=all&schedule=unscheduled");
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ const dataLines = text.trimEnd().split("\n").length - 1;
+ expect(dataLines).toBe(1);
+ expect(scheduledTimeCells(text)).toEqual(["予定なし"]);
+ });
+
+ it("X-2: result=all&schedule=scheduled — 7行・予定時刻に「予定なし」が無い", async () => {
+ const res = await get("result=all&schedule=scheduled");
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ const dataLines = text.trimEnd().split("\n").length - 1;
+ expect(dataLines).toBe(7);
+ expect(scheduledTimeCells(text).every((c) => c !== "予定なし")).toBe(true);
+ });
+
+ it("X-3: schedule=bad → 422", async () => {
+ const res = await get("schedule=bad");
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as ApiError;
+ expect(Object.keys(body.error.details ?? {})).toEqual(["schedule"]);
+ });
+});
diff --git a/app/api/clean-logs/export/route.ts b/app/api/clean-logs/export/route.ts
new file mode 100644
index 0000000..980091b
--- /dev/null
+++ b/app/api/clean-logs/export/route.ts
@@ -0,0 +1,117 @@
+/**
+ * GET /clean-logs/export — 清掃詳細ログCSVエクスポート
+ *
+ * /clean-logs と同一のフィルタを受け付ける(`sort` / `order`・予定あり/予定外も同じ。CSV は画面と同じ並び順)。
+ * page / per_page は無視。UTF-8 BOM付き、ヘッダ行あり。
+ */
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+// ★CSVの組み立ては lib/clean-logs/csv.ts★ — App Router の route.ts は
+// HTTPメソッド以外の export を想定しておらず、ここに置くとテストから呼べない
+// (「全列が csvCell() を通っているか」をテストで固定したいため。D-125)
+import { buildCleanLogCsv } from "@/lib/clean-logs/csv";
+import {
+ filterCleanLogs,
+ parseCleanLogQuery,
+ toDbQueryDate,
+} from "@/lib/clean-logs/query";
+import { sortCleanLogs } from "@/lib/clean-logs/sort";
+import { MAX_CSV_ROWS } from "@/lib/config";
+import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, CleanLogItem } from "@/lib/types";
+
+const ROUTE = "/api/clean-logs/export";
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ const parsed = parseCleanLogQuery(searchParams);
+ if (!parsed.ok) {
+ return NextResponse.json(parsed.error, { status: parsed.status });
+ }
+
+ let rows: CleanLogItem[];
+ try {
+ // ★`/clean-logs` と**同じ引数**を渡す(指示書AC 段階2 D-6)★ —
+ // ★★片方だけ絞ると、画面の一覧と CSV の中身が食い違います★★。
+ // `page` / `per_page` はここでも無視する(CSV は全件出す)。
+ // ★★分類(`group_id` / `customer_id`)の解決と積集合も一覧と同じ★★(指示書AK 段階4)—
+ // ★ここだけ分類を無視すると、画面で屋号を選んで出した CSV に他社の行が混ざります★。
+ const [nameIds, masterIds] = await Promise.all([
+ resolveFacilityIdsByName(parsed.query.facilityName),
+ resolveFacilityIdsByMasters(parsed.query),
+ ]);
+ const facilityIds = intersectFacilityIds(nameIds, masterIds);
+ rows = await db.cleanLogs({
+ // ★CSV も一覧と同じく単一ID の指定はしない★(指示書AC 段階3)
+ id: null,
+ date_from: toDbQueryDate(parsed.query.dateFrom),
+ date_to: toDbQueryDate(parsed.query.dateTo),
+ facility_ids: facilityIds,
+ });
+ } catch (e) {
+ const body: ApiError = {
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ const filtered = filterCleanLogs(rows, parsed.query);
+
+ // ★★行数の歯止め(ユーザー指示 2026-09-15 / 指示書AI 段階8)★★
+ //
+ // ★絞り込みを適用したあと、CSVを組み立てる**前**に数える★ —
+ // ★★組み立ててから長さで判断しないこと★★。25万行ぶんの文字列を作ってから
+ // 捨てるのでは、★メモリを使い切る危険が残ったまま★になる。
+ //
+ // ★★500 ではなく 422★★ — 利用者の指定が上限を超えているという話であって、
+ // サーバの不具合ではない(`CLAUDE.md` §9.3)。
+ //
+ // ★上限は「含む」★ — ちょうど `MAX_CSV_ROWS` 行は出せる(`>` であって `>=` ではない)。
+ //
+ // ★画面(`DetailLogScreen`)も `meta.total` を見て同じ判断をしてボタンを止めるが、
+ // ★★こちらを消さないこと★★ — APIを直接叩く経路が残る。
+ // ★画面は「親切」、サーバは「歯止め」で役割が違う★。
+ if (filtered.length > MAX_CSV_ROWS) {
+ const body: ApiError = {
+ error: {
+ code: "validation_failed",
+ message:
+ `この期間はCSVで出力できません。対象が${filtered.length}件で、` +
+ `上限の${MAX_CSV_ROWS}件を超えています。` +
+ `期間を短くするか、店舗で絞り込んでください。`,
+ // ★実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる
+ details: { total: filtered.length, max: MAX_CSV_ROWS },
+ },
+ };
+ return NextResponse.json(body, { status: 422 });
+ }
+
+ // ★行数上限の判定の**後**に並べ替える(捨てる行を並べ替えない。入れ忘れると CSV だけ既定順)★
+ const sorted = sortCleanLogs(filtered, parsed.query.sort, parsed.query.order);
+ const csv = buildCleanLogCsv(sorted);
+ // ★ここで生の searchParams を読んでよいのは、上の parseCleanLogQuery が
+ // `^\d{4}-\d{2}-\d{2}$`(`lib/clean-logs/query.ts` の parseDate)で既に弾いており、
+ // 形式違いなら 422 で return 済みだから★。だから Content-Disposition に
+ // `"` や改行などの制御文字は入らない。
+ // ★この検証を緩めるなら、ここも一緒に直すこと★(検証する場所と使う場所が
+ // 別の関数に分かれた暗黙の依存になっている)。
+ // `parsed.query` 側は Date に正規化済みで元の文字列を持たないため、生値を使う。
+ const from = searchParams.get("date_from") ?? "";
+ const to = searchParams.get("date_to") ?? "";
+ const range = from && to ? `_${from}_${to}` : "";
+
+ return new NextResponse(csv, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="clean-logs${range}.csv"`,
+ },
+ });
+ });
+}
diff --git a/app/api/clean-logs/route.ts b/app/api/clean-logs/route.ts
new file mode 100644
index 0000000..14abc0d
--- /dev/null
+++ b/app/api/clean-logs/route.ts
@@ -0,0 +1,115 @@
+/**
+ * GET /clean-logs — 清掃詳細ログ一覧
+ *
+ * ★これが「動く見本」です。他の一覧APIはこの形を真似して作ってください★
+ *
+ * 仕様の正は `d-watch-api.yaml` の /clean-logs です。
+ * APIを変えたくなったら、先にYAMLを直してからコードに反映してください(CLAUDE.md 作業ルール1)。
+ *
+ * このルートがやっていること
+ * 1. クエリパラメータのパースとバリデーション(`MAX_DATE_RANGE_DAYS` 超は422。
+ * ★上限は2年=731日★。指示書AI 段階7 で 31日 から広げた)
+ * 2. ★施設名 → 施設ID の解決★(指示書AC 段階2 D-6。★照合はアプリ側、絞り込みはSQL★)
+ * 3. データソース(fixtures または Prisma)からの取得(★日付・施設はSQLに降りている★)
+ * 4. フィルタ(日付範囲 / 施設名 / 機種 / シリアル / 予定あり/予定外 / 判定)
+ * 5. 並べ替え(`sortCleanLogs()`。`sort` 省略時は並べ替えない)
+ * 6. ページング
+ * 7. { items, meta } 形式で返却
+ *
+ * ★★3 で絞ったのに 4 の `filterCleanLogs()` を残しているのは意図です★★(指示書AC C-5)—
+ * ★絞り込み規則の「正」を純粋関数1箇所に保つため★(SQL 側はその写し)。
+ * ★fixtures 版が引数の扱いを間違えても画面の見え方が変わらない保険にもなる★。
+ * ★★ただし「二重に絞っているから安全」ではありません★★ —
+ * ★SQL が**絞りすぎた**場合は `filterCleanLogs()` では取り戻せません★。
+ */
+import { NextResponse } from "next/server";
+import {
+ capCleanLogs,
+ filterCleanLogs,
+ parseCleanLogQuery,
+ toDbQueryDate,
+} from "@/lib/clean-logs/query";
+import { sortCleanLogs } from "@/lib/clean-logs/sort";
+import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config";
+import { db } from "@/lib/db";
+import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
+import type { ApiError, CleanLogItem, ListResponse } from "@/lib/types";
+
+const ROUTE = "/api/clean-logs";
+
+function errorResponse(error: ApiError, status: number) {
+ return NextResponse.json(error, { status });
+}
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ const parsed = parseCleanLogQuery(searchParams);
+ if (!parsed.ok) {
+ return errorResponse(parsed.error, parsed.status);
+ }
+
+ // ★表示上限(指示書AF 段階6)★ — `loadConsecutiveDaysMax()` は内部で既定値に倒れる
+ // 設計なので通常は例外にならないが、★「設定が読めない」だけで一覧を 500 にしない★。
+ // ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型)
+ let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
+ try {
+ maxDays = await loadConsecutiveDaysMax();
+ } catch {
+ // 既定値のまま続行する
+ }
+
+ let rows: CleanLogItem[];
+ try {
+ // ★★施設名の照合はアプリ側で施設IDに解決してから SQL に渡す★★(指示書AC C-1 / D-6)—
+ // ★記号(中点等)を無視する照合は LIKE では書けない★(照合順序でも無理。実測済み)。
+ // ★★戻り値の `null` と `[]` は別物★★: `null` = 施設名の条件なし(全施設)/
+ // `[]` = ★照合したが1件も当たらなかった(=0件)★。
+ // ★★分類(`group_id` / `customer_id`)も**同じ形**(施設IDの配列)に解決する★★
+ // (指示書AK 段階4)。★2本の解決結果は `intersectFacilityIds()` で AND に畳む★ —
+ // ★★どちらか片方を捨てないこと★★(`?? `で書くと、施設名と屋号を同時に
+ // 指定したときに片方の条件が静かに消えます)。
+ // ★積集合が空になったら `[]`★(★`null` にしない★。`null` は「絞らない」の意味なので
+ // ★0件ヒットが全件表示に化けます★。罠1 / 落とし穴2)。
+ const [nameIds, masterIds] = await Promise.all([
+ resolveFacilityIdsByName(parsed.query.facilityName),
+ resolveFacilityIdsByMasters(parsed.query),
+ ]);
+ const facilityIds = intersectFacilityIds(nameIds, masterIds);
+ rows = await db.cleanLogs({
+ // ★一覧なので単一ID の指定はしない★(`null` = 指定なし。指示書AC 段階3)
+ id: null,
+ // ★生の `searchParams` ではなく `parseCleanLogQuery()` を通った値を渡す★(§E-2)
+ date_from: toDbQueryDate(parsed.query.dateFrom),
+ date_to: toDbQueryDate(parsed.query.dateTo),
+ facility_ids: facilityIds,
+ });
+ } catch (e) {
+ const body: ApiError = {
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return errorResponse(body, 500);
+ }
+
+ const filtered = filterCleanLogs(rows, parsed.query);
+ const sorted = sortCleanLogs(filtered, parsed.query.sort, parsed.query.order);
+ const { page, perPage } = parsed.query;
+ const total = sorted.length;
+ const start = (page - 1) * perPage;
+ // ★表示上限は絞り込み → 並べ替え → ページ切り出しの**後**にかける★(指示書AF 段階6-2)
+ // 絞り込みとページングは生値のまま行う。`meta.total` も上限とは無関係。
+ const items = capCleanLogs(sorted.slice(start, start + perPage), maxDays);
+
+ const body: ListResponse = {
+ items,
+ meta: { total, page, per_page: perPage },
+ };
+ return NextResponse.json(body);
+ });
+}
diff --git "a/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts" "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts"
new file mode 100644
index 0000000..b01b441
--- /dev/null
+++ "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts"
@@ -0,0 +1,70 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { GET } from "@/app/api/clean-logs/unscheduled/[report_id]/advice/route";
+import type { CleanLogAdviceResponse } from "@/lib/types";
+
+/**
+ * 表A-fixtures(A-1〜A-4。指示書BI §4)— `USE_FIXTURES=true` 固定。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+const NOW_ISO = "2026-10-06T08:15:00+09:00";
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(NOW_ISO));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+async function advice(reportId: string): Promise {
+ return GET(
+ new Request(
+ `http://localhost/api/clean-logs/unscheduled/${encodeURIComponent(reportId)}/advice`
+ ),
+ { params: Promise.resolve({ report_id: reportId }) }
+ );
+}
+
+describe("GET /clean-logs/unscheduled/{report_id}/advice(USE_FIXTURES=true)", () => {
+ it("A-1: 9200000000000000002 — L1・R2 のみ", async () => {
+ const res = await advice("9200000000000000002");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as CleanLogAdviceResponse;
+ expect(body.escalation_level).toBe("L1");
+ expect(body.consecutive_problem_days).toBe(1);
+ expect(body.advices.map((a) => a.rule)).toEqual(["R2"]);
+ expect(body.max_level).toBe(1);
+ });
+
+ it("A-2: 9200000000000000001 — ◎・advices 空", async () => {
+ const res = await advice("9200000000000000001");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as CleanLogAdviceResponse;
+ expect(body.escalation_level).toBeNull();
+ expect(body.advices).toEqual([]);
+ });
+
+ it("A-3: 存在しない report_id は 404", async () => {
+ const res = await advice("123");
+ expect(res.status).toBe(404);
+ expect(await res.json()).toMatchObject({
+ error: { code: "not_found", message: "指定された清掃ログが見つかりません" },
+ });
+ });
+
+ it("A-4: 21文字は 422(details のキーは report_id)", async () => {
+ const res = await advice("1".repeat(21));
+ expect(res.status).toBe(422);
+ const json = (await res.json()) as { error: { details: Record } };
+ expect(json.error.details).toHaveProperty("report_id");
+ });
+});
diff --git "a/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts" "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts"
new file mode 100644
index 0000000..151893d
--- /dev/null
+++ "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts"
@@ -0,0 +1,51 @@
+/**
+ * GET /clean-logs/unscheduled/{report_id}/advice — 予定なしの行の対応アドバイス
+ *
+ * 仕様の正は `d-watch-api.yaml` の /clean-logs/unscheduled/{report_id}/advice です。
+ */
+import { NextResponse } from "next/server";
+import { parsePathReportId } from "@/lib/api/path-params";
+import { adviceResponse } from "@/lib/clean-logs/advice-response";
+import { db } from "@/lib/db";
+import { logApiError } from "@/lib/logger/api";
+import { withRequestId } from "@/lib/logger";
+import type { ApiError, ErrorCode } from "@/lib/types";
+
+const ROUTE = "/api/clean-logs/unscheduled/[report_id]/advice";
+
+function errorBody(code: ErrorCode, message: string): ApiError {
+ return { error: { code, message, details: null } };
+}
+
+function errorResponse(status: number, code: ErrorCode, message: string) {
+ return NextResponse.json(errorBody(code, message), { status });
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ report_id: string }> }
+) {
+ return withRequestId(request, async () => {
+ const { report_id } = await params;
+ const parsed = parsePathReportId(report_id);
+ if (!parsed.ok) {
+ return NextResponse.json(parsed.error, { status: parsed.status });
+ }
+ const reportId = parsed.query;
+
+ let log;
+ try {
+ log = await db.unscheduledCleanLog(reportId);
+ } catch (e) {
+ const body = errorBody("internal_error", "データの取得に失敗しました");
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ if (!log) {
+ return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
+ }
+
+ return adviceResponse(log, { route: ROUTE, logIds: { report_id: reportId } });
+ });
+}
diff --git a/app/api/customers/__tests__/route.test.ts b/app/api/customers/__tests__/route.test.ts
new file mode 100644
index 0000000..d3b8736
--- /dev/null
+++ b/app/api/customers/__tests__/route.test.ts
@@ -0,0 +1,266 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★ルート本体は静的 import★(`it()` の中で `await import()` すると、最初の1本だけが
+// モジュール読み込みを抱え込んでフルスイート実行時にタイムアウトしうる。
+// `app/api/reports/__tests__/route.test.ts` の実測)
+import { GET as GET_CUSTOMERS } from "@/app/api/customers/route";
+import { GET as GET_GROUPS } from "@/app/api/groups/route";
+import type { ApiError, Customer, Group, ListResponse } from "@/lib/types";
+
+/**
+ * 分類マスタAPI 2本(`GET /groups` / `GET /customers`。指示書AK 段階1)の期待値表。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
+ * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
+ * ★`prismaSource` の経路★ を走らせる(`app/api/reports/__tests__/route.test.ts` と同じ作法)。
+ * fixtures 側(`USE_FIXTURES=true`)の不変条件は
+ * `lib/fixtures/__tests__/masters.test.ts` が見る。
+ *
+ * ------------------------------------------------------------------------
+ * ★★何を守るためのテストか★★
+ * ------------------------------------------------------------------------
+ * - ★`group_id = 0` を API に漏らさないこと★(指示書AK 落とし穴3 / D-011)—
+ * ★漏らすと「グループ0」という存在しないホールディングスが画面に出る★
+ * - ★`deleted_at` で絞ること/`inactive` では絞らないこと★(§4-1 #4)—
+ * ★どちらも「件数が静かに変わる」だけで例外は出ない★
+ * - ★セレクトリストを切らないこと★(§4-1 #1)— 51件目以降が消えても誰も気づけない
+ * - ★`customers.contract` を読まないこと★(D-010 / 落とし穴5)
+ */
+
+interface StubGroup {
+ id: bigint;
+ group_name: string;
+ inactive: number;
+ deleted_at: Date | null;
+}
+
+interface StubCustomer {
+ id: bigint;
+ customer_name: string;
+ group_id: bigint;
+ inactive: number;
+ deleted_at: Date | null;
+}
+
+const calls: { group: Record[]; customer: Record[] } = {
+ group: [],
+ customer: [],
+};
+
+let data: { groups: StubGroup[]; customers: StubCustomer[] };
+
+function matchesWhere(row: Record, where: unknown): boolean {
+ if (!where) return true;
+ for (const [field, cond] of Object.entries(where as Record)) {
+ if (row[field] !== cond) return false;
+ }
+ return true;
+}
+
+function findMany(
+ bucket: keyof typeof calls,
+ rows: Record[],
+ args: Record | undefined
+) {
+ calls[bucket].push(args ?? {});
+ return rows.filter((row) => matchesWhere(row, args?.where));
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ group: {
+ findMany: async (args?: Record) =>
+ findMany("group", data.groups as unknown as Record[], args),
+ },
+ customer: {
+ findMany: async (args?: Record) =>
+ findMany("customer", data.customers as unknown as Record[], args),
+ },
+ }),
+}));
+
+function group(
+ id: number,
+ name: string,
+ opts: { inactive?: number; deleted?: boolean } = {}
+): StubGroup {
+ return {
+ id: BigInt(id),
+ group_name: name,
+ inactive: opts.inactive ?? 0,
+ deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
+ };
+}
+
+function customer(
+ id: number,
+ name: string,
+ groupId: number,
+ opts: { inactive?: number; deleted?: boolean } = {}
+): StubCustomer {
+ return {
+ id: BigInt(id),
+ customer_name: name,
+ group_id: BigInt(groupId),
+ inactive: opts.inactive ?? 0,
+ deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
+ };
+}
+
+async function getGroups(qs = ""): Promise {
+ return GET_GROUPS(new Request(`http://localhost/api/groups${qs}`));
+}
+
+async function getCustomers(qs = ""): Promise {
+ return GET_CUSTOMERS(new Request(`http://localhost/api/customers${qs}`));
+}
+
+beforeEach(() => {
+ calls.group = [];
+ calls.customer = [];
+ data = {
+ groups: [
+ group(2, "グループB"),
+ group(1, "グループA"),
+ // ★無効化(inactive=1)でも一覧に出る★ / ★論理削除は出ない★
+ group(3, "グループC(無効)", { inactive: 1 }),
+ group(4, "グループD(削除済み)", { deleted: true }),
+ ],
+ customers: [
+ customer(20, "屋号B", 1),
+ customer(10, "屋号A", 1),
+ customer(30, "屋号C", 2),
+ // ★group_id=0(ホールディングスなしのセンチネル。D-011)★
+ customer(40, "屋号D", 0),
+ customer(50, "屋号E(無効)", 1, { inactive: 1 }),
+ customer(60, "屋号F(削除済み)", 1, { deleted: true }),
+ ],
+ };
+});
+
+describe("GET /groups", () => {
+ it("#1 id の昇順で返し、meta は total / page=1 / per_page=total", async () => {
+ const res = await getGroups();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ListResponse;
+ expect(body.items.map((g) => g.id)).toEqual([1, 2, 3]);
+ expect(body.meta).toEqual({ total: 3, page: 1, per_page: 3 });
+ });
+
+ it("#2 deleted_at で絞る(論理削除は出ない)/inactive では絞らない", async () => {
+ const body = (await (await getGroups()).json()) as ListResponse;
+ expect(body.items.map((g) => g.group_name)).toContain("グループC(無効)");
+ expect(body.items.map((g) => g.group_name)).not.toContain("グループD(削除済み)");
+ expect(calls.group[0].where).toEqual({ deleted_at: null });
+ });
+
+ it("#3 ★id=0 の行を作らない★(『グループ0』が現れないこと)", async () => {
+ const body = (await (await getGroups()).json()) as ListResponse;
+ expect(body.items.some((g) => g.id === 0)).toBe(false);
+ });
+
+ it("#4 返すのは id と group_name だけ(inactive / deleted_at を漏らさない)", async () => {
+ const body = (await (await getGroups()).json()) as ListResponse;
+ expect(Object.keys(body.items[0]).sort()).toEqual(["group_name", "id"]);
+ });
+
+ it("#5 DBが落ちたら 500(共通のエラー形式)", async () => {
+ data = { get groups(): StubGroup[] { throw new Error("db down"); }, customers: [] };
+ const res = await getGroups();
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as ApiError;
+ expect(body.error.code).toBe("internal_error");
+ });
+});
+
+describe("GET /customers", () => {
+ it("#6 id の昇順で返す(meta は /groups と同じ規則)", async () => {
+ const res = await getCustomers();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ListResponse;
+ expect(body.items.map((c) => c.id)).toEqual([10, 20, 30, 40, 50]);
+ expect(body.meta).toEqual({ total: 5, page: 1, per_page: 5 });
+ });
+
+ it("#7 ★group_id=0 は null になる★(『グループ0』を API に漏らさない。D-011)", async () => {
+ const body = (await (await getCustomers()).json()) as ListResponse;
+ const d = body.items.find((c) => c.id === 40);
+ expect(d?.group_id).toBeNull();
+ expect(body.items.some((c) => c.group_id === 0)).toBe(false);
+ });
+
+ it("#8 deleted_at で絞る/inactive では絞らない", async () => {
+ const body = (await (await getCustomers()).json()) as ListResponse;
+ expect(body.items.map((c) => c.id)).toContain(50);
+ expect(body.items.map((c) => c.id)).not.toContain(60);
+ expect(calls.customer[0].where).toEqual({ deleted_at: null });
+ });
+
+ it("#9 group_id=1 で傘下だけに絞れる", async () => {
+ const body = (await (await getCustomers("?group_id=1")).json()) as ListResponse;
+ expect(body.items.map((c) => c.id)).toEqual([10, 20, 50]);
+ expect(body.meta.total).toBe(3);
+ });
+
+ it("#10 ★実在しない group_id は 200・空★(404 にしない)", async () => {
+ const res = await getCustomers("?group_id=999");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ListResponse;
+ expect(body.items).toEqual([]);
+ expect(body.meta).toEqual({ total: 0, page: 1, per_page: 0 });
+ });
+
+ it("#11 ★group_id=0 は 422★(0 はセンチネルで有効なIDではない)", async () => {
+ const res = await getCustomers("?group_id=0");
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as ApiError;
+ expect(body.error.code).toBe("validation_failed");
+ expect(body.error.details).toEqual({
+ group_id: ["1以上の整数で指定してください"],
+ });
+ });
+
+ it("#12 ★group_id=1abc は 422★(parseInt の前方一致で 1 にしない。D-123)", async () => {
+ const res = await getCustomers("?group_id=1abc");
+ expect(res.status).toBe(422);
+ // ★422 のときは DB を読まない★
+ expect(calls.customer).toHaveLength(0);
+ });
+
+ it("#13 group_id= (空)は未指定と同じ(全件)", async () => {
+ const body = (await (await getCustomers("?group_id=")).json()) as ListResponse;
+ expect(body.items).toHaveLength(5);
+ });
+
+ it("#14 返すのは id / customer_name / group_id だけ(★contract を読まない★。D-010)", async () => {
+ const body = (await (await getCustomers()).json()) as ListResponse;
+ expect(Object.keys(body.items[0]).sort()).toEqual([
+ "customer_name",
+ "group_id",
+ "id",
+ ]);
+ // `select` で列を絞っていないので、`where` に contract が混ざっていないことも見ておく
+ expect(JSON.stringify(calls.customer[0])).not.toContain("contract");
+ });
+
+ /**
+ * 指示書AL ★段階3★ §4-4 — ★`db.customers()` に `orderBy` があること★。
+ *
+ * ★★結果の並びでは確かめられない★★ — このスタブは配列をそのまま返すので、
+ * ★`orderBy` を消してもルートの `sortCustomers()` が並べ直して通ってしまう★。
+ * ★だから「DBに何を投げたか」を見る★。
+ *
+ * ★`/customers` 自身はこの `orderBy` に依存していない★(ルートで並べ替えるため)。
+ * ★依存しているのは `GET /reports/kpi` の 422★ — `details.customer_ids` は
+ * `db.customers()` の並びをそのまま流す(`resolveTargetCustomer()` は並べ替えない)。
+ */
+ it("★段階3 §4-4★ id の昇順で DB から引く(★候補の並びをDB任せにしない★)", async () => {
+ await getCustomers();
+ expect(calls.customer[0].orderBy).toEqual({ id: "asc" });
+ });
+});
diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts
new file mode 100644
index 0000000..a47b834
--- /dev/null
+++ b/app/api/customers/route.ts
@@ -0,0 +1,66 @@
+/**
+ * GET /customers — 屋号一覧(分類マスタ。指示書AK 段階1。読み取り専用)
+ *
+ * 形は `app/api/schedules/route.ts` と同じ。
+ * 仕様の正は `d-watch-api.yaml` の /customers。
+ *
+ * ★GET だけ★(理由は `app/api/groups/route.ts` の冒頭)。
+ * ★認証はここに書かない★ — `proxy.ts` が横断して判定する。
+ *
+ * ★`group_id` で絞れる★ — 画面(段階7)はホールディングスを選んだときに
+ * `GET /customers?group_id=X` を投げ、屋号の選択肢をその傘下だけに絞る。
+ * ★実在しない `group_id` でも 404 にしない★(空配列 + `meta.total = 0`)。
+ * 「該当なし」は正常な結果で、404 は「エンドポイントが無い」と区別できなくなる。
+ */
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import {
+ filterCustomersByGroup,
+ masterListResponse,
+ parseCustomerQuery,
+ sortCustomers,
+} from "@/lib/masters/query";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, Customer, ListResponse } from "@/lib/types";
+
+const ROUTE = "/api/customers";
+
+function errorResponse(error: ApiError, status: number) {
+ return NextResponse.json(error, { status });
+}
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ // ★`group_id=0` は 422★ — 0 は「ホールディングスなし」のセンチネルであって、
+ // 有効なIDではない(D-011)。★`1abc` も 422★(D-123 で踏んだ罠)。
+ const parsed = parseCustomerQuery(searchParams);
+ if (!parsed.ok) {
+ return errorResponse(parsed.error, parsed.status);
+ }
+
+ let rows: Customer[];
+ try {
+ rows = await db.customers();
+ } catch (e) {
+ const body: ApiError = {
+ error: {
+ code: "internal_error",
+ message: "データの取得に失敗しました",
+ details: null,
+ },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return errorResponse(body, 500);
+ }
+
+ // ここから下は純粋な絞り込み・並べ替え。すべて try の外
+ // (`app/api/schedules/route.ts` と同じ方針)。
+ const filtered = filterCustomersByGroup(rows, parsed.query.groupId);
+ const body: ListResponse = masterListResponse(
+ sortCustomers(filtered)
+ );
+ return NextResponse.json(body);
+ });
+}
diff --git a/app/api/dashboard/__tests__/route.test.ts b/app/api/dashboard/__tests__/route.test.ts
new file mode 100644
index 0000000..238167a
--- /dev/null
+++ b/app/api/dashboard/__tests__/route.test.ts
@@ -0,0 +1,660 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` 冒頭の
+// 理由と同じ。`it()` の中で `await import()` するとフルスイートでタイムアウトを踏む)
+import { GET } from "@/app/api/dashboard/route";
+import { CLEAN_TIMETABLE_PRECHECK, D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
+import { db } from "@/lib/db";
+import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
+import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
+import { logEvent } from "@/lib/logger";
+import { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select";
+import { dsAlertWhere } from "@/lib/db/ds-alert-mapping";
+import type { DashboardResponse } from "@/lib/types";
+
+/**
+ * GET /dashboard が ★その1日ぶんだけ★ を引いていることを固定する(指示書AC 段階2 D-6)。
+ * ★`date` 省略(=直近モード。D-159 / 指示書AV)では、表3 の窓(8日ぶん)に絞られていること★と、
+ * 直近モードの `summary` / `problem_logs` / メタの `where` も固定する(ファイル末尾の `describe`)。
+ *
+ * ------------------------------------------------------------------------
+ * ★★何を守るためのテストか★★
+ * ------------------------------------------------------------------------
+ * ★`?date=`(★値が空★)のときに ★全件読みへ戻らない★ こと★。
+ * `const date = dateRaw ?? todayJst()` と書くと ★`""` は null でも undefined でもない★ ため
+ * ★空文字がそのまま残り★、`db.cleanLogs({ date_from: "", date_to: "" })` が
+ * `dbDateRange("", "")` を通って ★`start_at` の条件ごと消え=テーブル全件読み★ になります
+ * (本番想定で `clean_timetables` 約26万行)。
+ * ★★レスポンスは後段の日付一致(`logsOfDay`)で空になるので画面からは観測できません★★ —
+ * ★このテストが唯一の検出手段★です。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBには接続しません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
+ * ★`prismaSource` の経路★ を走らせる(段階2 / 段階4 のルートテストと同じ作法)。
+ *
+ * ★★ただしスタブの入れ方だけが兄弟テストと違います★★ —
+ * 兄弟(`/clean-logs` / `/prechecks`)は `vi.mock("@/lib/db/prisma")` で `getPrisma()` を
+ * 差し替えていますが、★このルートは3つのデータ源を `Promise.all` で**同時に**引きます★。
+ * `prismaSource` の各メソッドは `await import("./prisma")` の★遅延読み込み★なので
+ * (`lib/db/prisma.ts` 冒頭の理由)、★★同じモジュールへの動的 import が同時に走ると
+ * 一部が差し替え前の実体を掴み★★、`DATABASE_URL` 未設定で 500 になります(実測)。
+ * そこで ★`getPrisma()` が最初に見るシングルトン `globalThis.__dwatchPrisma` に
+ * 直接スタブを置きます★ — ★実装(`lib/db/prisma.ts`)の公開された仕組みをそのまま使うので
+ * DBにも接続しません★。★`afterEach` で必ず片付けること★。
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubTimetable {
+ id: bigint;
+ facility_id: bigint;
+ section_id: bigint;
+ parent_id: bigint;
+ start_at: Date;
+ /** ★予定の終了★(直近モードの `cleanLogRunMeta()` が読む。指示書AV 段階4-5。開始+1時間) */
+ end_at: Date;
+ precheck: number;
+ /** ★`deleted_at` は可視性(`FACILITY_SECTION_VISIBLE_WHERE`)の再現用★(指示書BB U1) */
+ facility: { facility_name: string; deleted_at: Date | null };
+ section: {
+ deleted_at: Date | null;
+ robots: {
+ serial_number: string;
+ device: { maker_id: number; model_name: string };
+ }[];
+ };
+}
+
+/**
+ * ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。旧 `clean_status_histories`)。
+ * ★`clean_error` 列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
+ * `clean_timetable_id` / `ended_at` は nullable(指示書AQ 罠1 / 罠3)。
+ */
+interface StubReport {
+ clean_timetable_id: bigint | null;
+ clean_ratio: number;
+ status: number;
+ started_at: Date;
+ ended_at: Date | null;
+ clean_area: number;
+ task_area: number;
+}
+
+interface StubData {
+ timetables: StubTimetable[];
+ reports: StubReport[];
+ /** `robot_error_reports` のうち D-Sec アラートの読み取りに返す行(既定は空) */
+ dsAlertRows: Record[];
+}
+
+/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
+const calls: {
+ cleanTimetable: Record[];
+ cleanTimetableHistory: Record[];
+ cleanTaskReport: Record[];
+ cleanSchedule: Record[];
+} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], cleanSchedule: [] };
+
+let data: StubData;
+/** D-Sec アラートの読み取りの `where`(指示書BB 表10 N7) */
+let dsAlertWheres: unknown[] = [];
+
+/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */
+function sameId(a: unknown, b: unknown): boolean {
+ if (a === null || a === undefined || b === null || b === undefined) return false;
+ return String(a) === String(b);
+}
+
+/**
+ * Prisma の `where` のうち、★このテストが使う分だけ★ を再現する。
+ *
+ * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。
+ * ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
+ */
+function matchesWhere(row: Record, where: unknown): boolean {
+ if (!where) return true;
+ for (const [field, cond] of Object.entries(where as Record)) {
+ const value = row[field];
+ if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
+ const c = cond as Record;
+ if ("in" in c) {
+ if (c.in === undefined) {
+ throw new Error(
+ `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
+ );
+ }
+ if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
+ }
+ if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
+ if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
+ // ★リレーションの条件(`facility: { deleted_at: null }` など。可視性 U1)★ — 行が結合先を持つときだけ再現する
+ // (持たない行=結合先を `include` しない読み取りは、従来どおり素通し)
+ if (!("in" in c) && !("gte" in c) && !("lt" in c) && value !== null && typeof value === "object" && !(value instanceof Date)) {
+ if (!matchesWhere(value as Record, c)) return false;
+ }
+ } else if (typeof cond === "bigint" || typeof value === "bigint") {
+ if (!sameId(value, cond)) return false;
+ } else if (value !== cond) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function project(row: Record, select: unknown): Record {
+ if (!select) return row;
+ const out: Record = {};
+ for (const key of Object.keys(select as Record)) out[key] = row[key];
+ return out;
+}
+
+function findMany(
+ bucket: keyof typeof calls,
+ rows: Record[],
+ args: Record | undefined
+) {
+ calls[bucket].push(args ?? {});
+ return rows
+ .filter((row) => matchesWhere(row, args?.where))
+ .map((row) => project(row, args?.select));
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+// ★直近モードのメタ欠けの `data.anomaly`(指示書BB A2)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+/** `getPrisma()` が最初に見るシングルトンへ差し込むスタブ(上の「スタブの入れ方」を参照) */
+function prismaStub(): typeof globalThis.__dwatchPrisma {
+ return {
+ cleanTimetable: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTimetable", data.timetables as unknown as Record[], args),
+ },
+ // ★`clean_timetable_histories`(指示書AU 段階3b)。★このテストでは空★(2表に同じ引数が渡ることだけ見る)
+ cleanTimetableHistory: {
+ findMany: async (args?: Record) => findMany("cleanTimetableHistory", [], args),
+ },
+ cleanTaskReport: {
+ findMany: async (args?: Record) =>
+ findMany("cleanTaskReport", data.reports as unknown as Record[], args),
+ },
+ // ★要レスキュー(指示書AI 段階5)が施設名の解決に使う★
+ // (`loadFacilityNameIndex()`。★`where` を付けない=全件★)
+ facility: {
+ findMany: async () => [
+ { id: BigInt(1), facility_name: FACILITY_1 },
+ { id: BigInt(2), facility_name: FACILITY_2 },
+ ],
+ },
+ // ★このテストで中身は要らない(`db.cleanLogs()` の `where` だけを見る)★。
+ // ★D-Sec アラート(`dsAlerts()`)の読み取りにだけ `dsAlertRows` を返す★(`where` を無視。指示書AX 表1 R10)
+ robotErrorReport: {
+ findMany: async (args?: Record) => {
+ if (args?.select !== ROBOT_ERROR_DS_ALERT_SELECT) return [];
+ dsAlertWheres.push(args.where);
+ guardNullRobotId(data.dsAlertRows as unknown as Record[], args);
+ return data.dsAlertRows;
+ },
+ },
+ // ★`clean_schedules`(`schedules()`)。★ダッシュボードはもう読まない★(2026-10-29 案B)— 読まれたら記録に残る(S7)★
+ cleanSchedule: {
+ findMany: async (args?: Record) => {
+ calls.cleanSchedule.push(args ?? {});
+ return [];
+ },
+ },
+ // ★`controlErrorHistory` は置かない★ — D-Sec アラートは `robot_error_reports` から読む(指示書AX。P-10 = A)
+ } as unknown as typeof globalThis.__dwatchPrisma;
+}
+
+// ---------------------------------------------------------------- 合成データ
+
+/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
+function dbWallClock(jstIso: string): Date {
+ return new Date(jstIso.replace("+09:00", "Z"));
+}
+
+/** ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
+const FACILITY_1 = "バリューマート北町店";
+/** ★対象日に清掃予定が無い施設★(要レスキューの検証用。指示書AI §1-5) */
+const FACILITY_2 = "メガバリューマート緑野店";
+
+function timetable(id: number, facilityId: number, jstIso: string): StubTimetable {
+ return {
+ id: BigInt(id),
+ facility_id: BigInt(facilityId),
+ section_id: BigInt(facilityId * 10),
+ parent_id: BigInt(0),
+ start_at: dbWallClock(jstIso),
+ // ★開始+1時間★(壁時計値のまま足す。直近モードの「終わった」の判定に使う。指示書AV 段階4-5)
+ end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
+ precheck: CLEAN_TIMETABLE_PRECHECK.main,
+ facility: { facility_name: FACILITY_1, deleted_at: null },
+ section: {
+ deleted_at: null,
+ robots: [
+ {
+ serial_number: `8111R56280600${facilityId}`,
+ device: { maker_id: 1, model_name: "CC1" },
+ },
+ ],
+ },
+ };
+}
+
+function report(timetableId: number, cleanRatio: number, jstIso: string): StubReport {
+ return {
+ clean_timetable_id: BigInt(timetableId),
+ clean_ratio: cleanRatio,
+ // ★意味を持たない任意の生値★(CSV実測で最頻の値。status は判定に使わない。D-155 項2)。
+ // ★未実施の判定には使われない★(行の有無で決まる。§1-1)
+ status: 4,
+ started_at: dbWallClock(jstIso),
+ ended_at: dbWallClock(jstIso),
+ clean_area: 100,
+ task_area: 100,
+ };
+}
+
+function buildData(): StubData {
+ return {
+ timetables: [
+ timetable(101, 1, "2026-09-28T02:00:00+09:00"),
+ timetable(102, 1, "2026-09-29T02:00:00+09:00"),
+ // ★`NOW` と同じ日(`todayJst()` が返す日)★
+ timetable(103, 1, "2026-09-30T02:00:00+09:00"),
+ // ★★施設2は 09-27〜09-29 が3日連続で未実施、当日(09-30)には予定が無い★★
+ // (レポートの行を作らない=未実施。`notExecutedFromReport()`。指示書AQ §1-1)—
+ // ★要レスキューが「当該日のログ」で母集団を絞っていないことの検証用★(指示書AI §1-5)
+ timetable(201, 2, "2026-09-27T02:00:00+09:00"),
+ timetable(202, 2, "2026-09-28T02:00:00+09:00"),
+ timetable(203, 2, "2026-09-29T02:00:00+09:00"),
+ ],
+ reports: [
+ report(101, 95, "2026-09-28T03:00:00+09:00"),
+ report(102, 95, "2026-09-29T03:00:00+09:00"),
+ report(103, 95, "2026-09-30T03:00:00+09:00"),
+ ],
+ dsAlertRows: [],
+ };
+}
+
+/** ★JST の 2026-09-30★(`todayJst()` がこの日を返す) */
+const NOW = new Date("2026-09-30T09:00:00+09:00");
+const TODAY = "2026-09-30";
+
+async function get(search: string): Promise {
+ const res = await GET(new Request(`http://localhost/api/dashboard${search}`));
+ return (await res.json()) as DashboardResponse;
+}
+
+/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */
+function mainQueryWhere(): Record {
+ const call = calls.cleanTimetable.find((c) => "include" in c);
+ expect(call).toBeDefined();
+ return (call as { where: Record }).where;
+}
+
+/** ★その1日ぶんに絞られているか★ を「壁時計値の範囲」で確かめる */
+function expectSingleDayRange(where: Record, date: string): void {
+ expectDayRange(where, date, date);
+}
+
+/** ★`from`〜`to`(両端を含む)に絞られているか★ を「壁時計値の範囲」で確かめる */
+function expectDayRange(where: Record, from: string, to: string): void {
+ expect(where).toHaveProperty("start_at");
+ const startAt = where.start_at as { gte: Date; lt: Date };
+ // ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★)
+ expect(startAt.gte.toISOString()).toBe(`${from}T00:00:00.000Z`);
+ expect(startAt.lt.toISOString()).toBe(
+ new Date(Date.parse(`${to}T00:00:00.000Z`) + 86_400_000).toISOString()
+ );
+}
+
+/** ★直近モードの窓(指示書AV 表3 W4。`NOW` = 2026-09-30 09:00 JST)★ */
+const LATEST_WINDOW = { from: "2026-09-23", to: TODAY } as const;
+
+/**
+ * ★メタ(`cleanLogRunMeta()`)の呼び出し★ = `select` のキーがちょうど `id` / `section_id` / `end_at` の3つ。
+ * ★「`select` 付き」だけでは見分けられない★ — 要レスキュー(`loadConsecutiveInputs()` など)も
+ * `cleanTimetable.findMany` を `select` 付きで呼ぶ(指示書AV 段階4-5)。
+ */
+function metaCalls(): Record[] {
+ return calls.cleanTimetable.filter((c) => {
+ const select = c.select as Record | undefined;
+ return select !== undefined && Object.keys(select).sort().join(",") === "end_at,id,section_id";
+ });
+}
+
+beforeEach(() => {
+ data = buildData();
+ calls.cleanTimetable = [];
+ calls.cleanTimetableHistory = [];
+ calls.cleanTaskReport = [];
+ calls.cleanSchedule = [];
+ dsAlertWheres = [];
+ globalThis.__dwatchPrisma = prismaStub();
+ vi.mocked(logEvent).mockClear();
+ // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(NOW);
+});
+
+afterEach(() => {
+ // ★★他のテストへ漏らさないこと★★(`getPrisma()` はここを最初に見る)
+ globalThis.__dwatchPrisma = undefined;
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------- テスト
+
+describe("GET /dashboard — その1日ぶんだけを引く(指示書AC 段階2 D-6)", () => {
+ it("`date` を指定すると SQL の `where` にその1日の `start_at` が降りる", async () => {
+ await get("?date=2026-09-29");
+ expectSingleDayRange(mainQueryWhere(), "2026-09-29");
+ });
+
+ it("★予定は2表から読み、履歴にも同じ引数が渡る★(指示書AU 段階3b。第11回 P-7)", async () => {
+ await get("?date=2026-09-29");
+ const mainCall = calls.cleanTimetable.find((c) => "include" in c);
+ expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall);
+ });
+
+ it("直近モード(`date` 省略)は表3 W4 の窓(2026-09-23〜2026-09-30)で `start_at` が絞られる(指示書AV)", async () => {
+ await get("");
+ expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
+ });
+
+ it("★★`?date=`(★空文字★)でも直近モードの窓に倒れ、全件読みに戻らない★★", async () => {
+ // ★`??` で書くと `""` がそのまま残り、`start_at` のキーごと消えて
+ // ★テーブル全件読み★ になる(本番想定で約26万行)。
+ // ★★レスポンスからは観測しにくい★★ので、★`start_at` の条件が消えないこと★をここで見る
+ const body = await get("?date=");
+ expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
+ // ★当日の行(103)が拾えていること=空振りしていないこと★
+ const { success, failed, not_executed } = body.summary;
+ expect(success + failed + not_executed).toBeGreaterThan(0);
+ });
+
+ it("★`?date=` と `date` 省略が同じ結果になる★", async () => {
+ const omitted = await get("");
+ const empty = await get("?date=");
+ expect(empty).toEqual(omitted);
+ });
+
+ it("形式が不正な `date` は 422 で弾かれ、`db` まで来ない", async () => {
+ const res = await GET(new Request("http://localhost/api/dashboard?date=2026/09/30"));
+ expect(res.status).toBe(422);
+ expect(calls.cleanTimetable).toHaveLength(0);
+ expect(calls.cleanTimetableHistory).toHaveLength(0);
+ });
+});
+
+describe("GET /dashboard — 要レスキュー店舗(指示書AI 段階5)", () => {
+ it("★当該日に清掃予定が無い施設も出る(評価日は直前のスケジュール存在日)★", async () => {
+ const body = await get(`?date=${TODAY}`);
+
+ // ★施設2は 09-30 に予定が無い★ — 当該日のログで母集団を絞っていたら消える
+ expect(body.rescue_required).toHaveLength(1);
+ expect(body.rescue_required[0]).toMatchObject({
+ facility_id: 2,
+ facility_name: FACILITY_2,
+ evaluated_on: "2026-09-29",
+ reasons: ["not_executed_streak", "escalation_l3"],
+ not_executed_days: 3,
+ consecutive_problem_days: 3,
+ });
+ });
+
+ it("★数値カード(summary)は要レスキューの影響を受けない★", async () => {
+ const body = await get(`?date=${TODAY}`);
+
+ // ★当日のログは施設1の1行だけ★(施設2の未実施3日は分母に入らない。ユーザー指示 2026-09-15)
+ const { success, failed, not_executed } = body.summary;
+ expect([success, failed, not_executed]).toEqual([1, 0, 0]);
+ });
+
+ it("要レスキューが0件でも `rescue_required` のキーは落とさない", async () => {
+ // ★施設2の3日連続が始まる前の日を指定する★(=どの施設も条件に当たらない)
+ const body = await get("?date=2026-09-28");
+
+ expect(body.rescue_required).toEqual([]);
+ });
+});
+
+describe("GET /dashboard — 直近モード(D-159 / 指示書AV 段階4-5)", () => {
+ // ★期待値は指示書AV で導いた値(§11 Q10 で監督が確定)★:
+ // 施設1(エリア10)は 09-30 02:00〜03:00 が一番新しい終わった予定 = 成功(完了率100)。
+ // 施設2(エリア20)は 09-29 02:00〜03:00 = レポートが無い = 未実施。
+ it("summary は各清掃エリアの直近の清掃(成功1・未実施1)", async () => {
+ const body = await get("");
+ expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
+ });
+
+ it("同じデータで `?date=2026-09-30` はその日の行だけ([1, 0, 0] のまま)", async () => {
+ const body = await get(`?date=${TODAY}`);
+ const { success, failed, not_executed } = body.summary;
+ expect([success, failed, not_executed]).toEqual([1, 0, 0]);
+ });
+
+ it("problem_logs は直近モードで施設2の 09-29 の1行だけ。`?date=2026-09-30` では []", async () => {
+ const latest = await get("");
+ expect(latest.problem_logs.map((r) => r.id)).toEqual([203]);
+ expect(latest.problem_logs[0]).toMatchObject({
+ facility_id: 2,
+ scheduled_at: "2026-09-29T02:00:00+09:00",
+ departed: false,
+ // ★`capCleanLogs()` を通っている★(指示書AV D13)
+ consecutive_problem_days_capped: false,
+ });
+
+ const ofDay = await get(`?date=${TODAY}`);
+ expect(ofDay.problem_logs).toEqual([]);
+ });
+
+ it("★メタの `where` は清掃ログ(`include` 付きの1本目)の `where` と同じ★(4-8)", async () => {
+ await get("");
+ const meta = metaCalls();
+ expect(meta).toHaveLength(1);
+ expect(meta[0].where).toEqual(mainQueryWhere());
+ });
+
+ it("★E1′: メタが1行ぶん欠けても 200。その行は選ばれず、`data.anomaly` を1回(row_ids つき)★(指示書BB A2)", async () => {
+ // ★`cleanLogs()` と `cleanLogRunMeta()` の間に行が増えた状態★ — 施設1の 09-30(103)のメタだけ返さない
+ const original = db.cleanLogRunMeta.bind(db);
+ const spy = vi
+ .spyOn(db, "cleanLogRunMeta")
+ .mockImplementation(async (query) => (await original(query)).filter((m) => m.row_key !== "tt-103"));
+ try {
+ const res = await GET(new Request("http://localhost/api/dashboard"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DashboardResponse;
+ // ★103 が選ばれないので、施設1は1つ前の 09-29(102。成功)が直近になる★
+ expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
+ const events = vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
+ expect(events).toHaveLength(1);
+ expect(events[0][1]).toEqual({
+ event: "data.anomaly",
+ route: "/api/dashboard",
+ method: "GET",
+ metric: "clean_log_run_meta_missing",
+ expected: 0,
+ actual: 1,
+ // ★`row_key` を出す★(指示書BG §2-2。予定外の行は id が無いため。旧: "103")
+ row_ids: ["tt-103"],
+ });
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("メタが揃っていれば `clean_log_run_meta_missing` を出さない", async () => {
+ await get("");
+ const events = vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing");
+ expect(events).toHaveLength(0);
+ });
+
+ it("★`?date=2026-09-29` ではメタを読まない★(0回)", async () => {
+ await get("?date=2026-09-29");
+ expect(metaCalls()).toHaveLength(0);
+ });
+
+});
+
+/**
+ * ★スケジュール進捗(ユーザー判断 2026-10-29「案B」)★ — 期待値表(`NOW` = 2026-09-30 09:00 JST):
+ * | # | 指定 | 対象日 | 対象日の本清掃 | planned | completed | rate |
+ * |---|---|---|---|---|---|---|
+ * | S1 | 省略(直近モード) | 09-29(前日) | 102(出発)/ 203(未実施) | 2 | 1 | 50 |
+ * | S2 | `?date=2026-09-29` | 09-29 | 同上 | 2 | 1 | 50 |
+ * | S3 | `?date=2026-09-30` | 09-30 | 103(出発) | 1 | 1 | 100 |
+ * | S4 | `?date=2026-09-30` + 施設3の結果待ち 301 | 09-30 | 103 / 301(結果待ち) | 2 | 1 | 50 |
+ * | S5 | `?date=2026-10-01`(予定なし) | 10-01 | なし | 0 | 0 | null |
+ * | S6 | 省略 + 施設2が削除済み | 09-29 | 102 だけ(203 は見えない) | 1 | 1 | 100 |
+ * | S7 | 省略 + `clean_schedules` に毎日の予定7件 | 09-29 | 102 / 203 | 2 | 1 | 50(★曜日の設定を数えない★) |
+ */
+describe("GET /dashboard — スケジュール進捗(2026-10-29 案B)", () => {
+ it("S1 直近モードの対象日は前日(09-29)。当日(09-30)の行で数えない", async () => {
+ const body = await get("");
+ expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 2, completed: 1, rate: 50 });
+ });
+
+ it("S2 `?date=2026-09-29` は S1 と同じ", async () => {
+ const latest = await get("");
+ const ofDay = await get("?date=2026-09-29");
+ expect(ofDay.schedule_progress).toEqual(latest.schedule_progress);
+ });
+
+ it("S3 `?date=2026-09-30` はその日(前日にしない)", async () => {
+ const body = await get(`?date=${TODAY}`);
+ expect(body.schedule_progress).toEqual({ date: TODAY, planned: 1, completed: 1, rate: 100 });
+ });
+
+ it("S4 結果待ちは planned にだけ入る", async () => {
+ data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
+ const body = await get(`?date=${TODAY}`);
+ expect(body.schedule_progress).toEqual({ date: TODAY, planned: 2, completed: 1, rate: 50 });
+ });
+
+ it("S5 対象日に予定が無ければ planned 0・rate null", async () => {
+ const body = await get("?date=2026-10-01");
+ expect(body.schedule_progress).toEqual({ date: "2026-10-01", planned: 0, completed: 0, rate: null });
+ });
+
+ it("S6 削除済みの施設の予定は数えない", async () => {
+ for (const t of data.timetables) {
+ if (t.facility_id === BigInt(2)) t.facility.deleted_at = dbWallClock("2026-09-01T00:00:00+09:00");
+ }
+ const body = await get("");
+ expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 1, completed: 1, rate: 100 });
+ });
+
+ it("S7 `clean_schedules`(曜日の設定)は読まない — 予定件数は予定の表の行から", async () => {
+ const body = await get("");
+ expect(calls.cleanSchedule).toHaveLength(0);
+ expect(body.schedule_progress.planned).toBe(2);
+ await get(`?date=${TODAY}`);
+ expect(calls.cleanSchedule).toHaveLength(0);
+ });
+});
+
+/**
+ * ★結果待ち(D-173。ユーザー判断 2026-10-07)★ — 表2 C6・C7(監督確定)。
+ * `NOW` = 09-30 09:00。★施設3の 09-30 07:30〜08:30 にレポートが無い★ —
+ * ★終了+`RESULT_WAIT_MINUTES`(120分)= 10:30 より前なので結果待ち★(`isResultPending()` 表1 P5 と同じ形)。
+ * ★終了(08:30)≤ 現在(09:00)なので直近の候補になる★(C6。30分待ちはやめた)。
+ * ★`result_pending` はデータソース(`prismaSource.cleanLogs()`)が詰める★ — ここでは数え方と `problem_logs` だけを見る。
+ */
+describe("GET /dashboard — 結果待ち(表2 C6・C7。D-173)", () => {
+ beforeEach(() => {
+ data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
+ });
+
+ it("C6 直近モード: 施設3の結果待ちは pending に数え、未実施・成功率の分母に入れない", async () => {
+ const body = await get("");
+ expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 });
+ });
+
+ it("C7 problem_logs に結果待ちの行を出さない(確定した出発失敗 203 は今までどおり出す)", async () => {
+ const body = await get("");
+ expect(body.problem_logs.map((r) => r.id)).toEqual([203]);
+ });
+
+ it("C5・C7 `?date=2026-09-30` でも同じ(pending 1・problem_logs は空)", async () => {
+ const body = await get(`?date=${TODAY}`);
+ expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 0, pending: 1, success_rate: 100 });
+ expect(body.problem_logs).toEqual([]);
+ });
+});
+
+/**
+ * ★D-Sec アラートの想定外の状態でダッシュボードを落とさない★(指示書AX 表1 R10・D3)。
+ * `db.dsAlerts()` はダッシュボードも呼ぶ。`status` 3 の行を状態の変換に通すと例外 → 全体が500になる。
+ * ★スタブは where を無視して返す★(SQL の `status IN` が効かなかった状態。正は純粋関数)。
+ */
+describe("GET /dashboard — D-Sec アラート(指示書AX 表1 R10)", () => {
+ it("★status 3 の D-S_CONTROL_FAILURE 行(robot_id NULL)が返っても 200・バナーは空★", async () => {
+ data.dsAlertRows = [
+ {
+ error_id: "R-10",
+ robot_id: null,
+ clean_timetable_id: null,
+ serial_number: "DFAS010001",
+ facility_id: BigInt(1),
+ facility: { facility_name: FACILITY_1, deleted_at: null },
+ section: { section_name: "1F売り場", deleted_at: null },
+ error_type: D_SEC_ALERT_ERROR_TYPES[0],
+ errored_at: dbWallClock("2026-09-30T02:00:00+09:00"),
+ created_at: dbWallClock("2026-09-30T02:01:00+09:00"),
+ status: 3,
+ responded_at: null,
+ resolved_at: null,
+ comment: null,
+ },
+ ];
+ const res = await GET(new Request(`http://localhost/api/dashboard?date=${TODAY}`));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DashboardResponse;
+ expect(body.security_banner).toEqual([]);
+ });
+});
+
+describe("GET /dashboard — 削除済みの施設・清掃エリア(指示書BB U1・表1 V4/V5)", () => {
+ it("★V4: 清掃ログ・直近のメタの `where` に可視性が入る★(V5 の予定件数も同じ清掃ログから数える。2026-10-29 案B)", async () => {
+ await get("");
+ expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ for (const m of metaCalls()) expect(m.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
+ // ★予定件数は `clean_schedules` を読まない★(スケジュール進捗の describe の S6・S7)
+ expect(calls.cleanSchedule).toHaveLength(0);
+ });
+});
+
+describe("GET /dashboard — 存在しない日付は422(指示書BB 表8 Q6)", () => {
+ it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => {
+ const bad = await GET(new Request("http://localhost/api/dashboard?date=2026-02-31"));
+ const format = await GET(new Request("http://localhost/api/dashboard?date=2026/02/03"));
+ expect(bad.status).toBe(422);
+ expect(format.status).toBe(422);
+ expect(await bad.json()).toEqual(await format.json());
+ });
+});
+
+// ★表10 N7(指示書BB A6・D17)★ — ダッシュボードの D-Sec は解消でないものだけ読む(バナーは解消でないものしか見ない)
+describe("GET /dashboard — D-Sec アラートは未解消だけ読む(指示書BB 表10 N7)", () => {
+ it("★`dsAlerts({ open_only: true })` — SQL の status は未解消・対応中(0/1)だけ★", async () => {
+ await get("");
+ expect(dsAlertWheres).toHaveLength(1);
+ expect(dsAlertWheres[0]).toEqual(dsAlertWhere({ open_only: true }));
+ expect((dsAlertWheres[0] as { status: { in: number[] } }).status.in).toEqual([0, 1]);
+ });
+});
diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts
new file mode 100644
index 0000000..504dd1e
--- /dev/null
+++ b/app/api/dashboard/route.ts
@@ -0,0 +1,259 @@
+/**
+ * GET /dashboard — ダッシュボード(清掃結果サマリ / スケジュール進捗 / セキュリティバナー)
+ *
+ * 仕様の正は `d-watch-api.yaml` の /dashboard、各値の定義は `docs/decisions.md` D-049 です。
+ * 形は `app/api/clean-logs/route.ts`(動く見本)に合わせています。
+ *
+ * ★`failure_breakdown` は返しません(D-048)★
+ * 4分類の分類元(エラーテーブル)が未定義のため M3 送りです。
+ * 空配列ではなく**プロパティごと省略**します。空配列は「当日の失敗0件」を意味するため、
+ * 未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。
+ *
+ * ★`date` を省略すると「直近モード」★(D-159 / 指示書AV)—
+ * `summary` と `problem_logs` を ★清掃エリアごとの直近の清掃(7日以内・終わった予定のみ・出発失敗を含む)★
+ * から作ります。選び方は `pickLatestRunPerSection()`(`lib/dashboard/latest-run.ts`。純粋関数)。
+ * ★要レスキュー・セキュリティバナーは省略時も当日のまま★(D-049 / 指示書AV 4-1)。
+ * ★スケジュール進捗は省略時は JST の前日★(ユーザー判断 2026-10-29「案B」。`scheduleProgressTargetDate()`)—
+ * ダッシュボードの結果は昨夜の清掃なので、まだ始まっていない今夜の予定を数えない。
+ *
+ * ★コンパクト版清掃詳細ログは `problem_logs` で返します★(D-159 / 指示書AV §2 D7。旧: GET /clean-logs を再利用)—
+ * ★数値カードと表を同じ `now`・同じ行の集合から作るため★。別の API にすると2本のリクエストが
+ * 別々に「現在」を取るので、境界(予定の終了ちょうど付近)でカードと表の母集団がずれうる。
+ *
+ * ★結果待ち(`result_pending`。ユーザー判断 2026-10-07。D-173)★ —
+ * `summary.pending` に数え、★`problem_logs` には出さない★(表2 C5・C7)。
+ * 直近モードの候補は「予定の終了 ≤ 現在」(C6。旧: 終了+30分)。
+ *
+ * ★予定外タスク(指示書BG §2-2。2026-10-29)★ — `db.cleanLogs()` が予定外の行(`unscheduled: true`・`id: null`)も返すので、
+ * ★このルートは何も足さずに★ summary・problem_logs・進み具合(A案: 予定にも出発にも1件ずつ)に入る。
+ * 直近モードのメタ(`cleanLogRunMeta()`)も予定外の行のぶんを返し、`row_key` で突き合わせる(`pickLatestRunPerSection()`)。
+ */
+import { NextResponse } from "next/server";
+import { parseMasterIdFilters } from "@/lib/api/master-id";
+import { capCleanLogs, isProblemGrade, parseDate } from "@/lib/clean-logs/query";
+import { CONSECUTIVE_DAYS_DISPLAY, DASHBOARD_LATEST_RUN } from "@/lib/config";
+import {
+ computeScheduleProgress,
+ computeSecurityBanner,
+ computeSummary,
+ scheduleProgressTargetDate,
+} from "@/lib/dashboard/aggregate";
+import {
+ latestRunWindow,
+ missingRunMetaIds,
+ pickLatestRunPerSection,
+} from "@/lib/dashboard/latest-run";
+import { db } from "@/lib/db";
+import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import { toJstIso } from "@/lib/format/date";
+import { logEvent, withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
+import type {
+ ApiError,
+ CleanLogDbQuery,
+ CleanLogItem,
+ CleanLogRunMeta,
+ DashboardResponse,
+ DsAlert,
+ RescueRequiredItem,
+} from "@/lib/types";
+
+const ROUTE = "/api/dashboard";
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+
+ const dateRaw = searchParams.get("date");
+ if (dateRaw && !parseDate(dateRaw)) {
+ const body: ApiError = {
+ error: {
+ code: "validation_failed",
+ message: "dateの形式が不正です",
+ details: { date: ["YYYY-MM-DD形式で指定してください"] },
+ },
+ };
+ return NextResponse.json(body, { status: 422 });
+ }
+ // ★★「現在」はここで1回だけ作る★★(指示書AV 4-5)— `today`・直近モードの窓・
+ // `pickLatestRunPerSection()` に ★同じ `now`★ を渡す。2回作ると、日付の境目や
+ // 予定の終了ちょうど付近で、窓と選び方が別の「現在」を見てずれる。
+ // ★`toJstIso()` で日付を切り出す★(実行環境のTZに依存しない。`todayJst()` と同じ日付になる)。
+ const now = new Date();
+ const today = toJstIso(now).slice(0, 10);
+ // 省略時は直近モード(D-159)。要レスキューの基準日は当日、スケジュール進捗の対象日は前日(2026-10-29 案B)。
+ // ★★`??` ではなく `||` / `!` にすること★★ — ★`?date=`(値が空)だと `""` は null でも
+ // undefined でもないので `??` では既定値に倒れません★。★`?date=` は直近モードになる★(省略と同じ)。
+ // ★`""` のまま下へ渡すと `dbDateRange("", "")` で `start_at` の条件ごと消え、
+ // ★★テーブル全件読みに戻ります★★(本番想定で `clean_timetables` 約26万行)。
+ // ★★レスポンスは後段の `logsOfDay` の日付一致で空になるので画面からは観測できません★★
+ // (固定しているのは `app/api/dashboard/__tests__/route.test.ts`)。
+ const latestMode = !dateRaw;
+ const date = dateRaw || today;
+
+ // ★★分類(`group_id` / `customer_id`)の検証は共通の1本に任せる★★(指示書AK 段階2 / 段階4b)—
+ // ★このルートは自前でクエリを読んでいる★(`parseXxxQuery()` を持たない)ので、
+ // ★★ここで検証規則を書き下ろさないこと★★。`0` / `1abc` / 安全整数超の扱いが
+ // 他の10エンドポイントとずれ、★どれか1つだけ緩い★状態になる(`lib/api/master-id.ts`)。
+ const masters = parseMasterIdFilters(searchParams);
+ if (!masters.ok) {
+ return NextResponse.json(masters.error, { status: masters.status });
+ }
+
+ // ★表示上限(`problem_logs` の連続日数。指示書AV §2 D13)★ — `app/api/clean-logs/route.ts` と同じ作法。
+ // `loadConsecutiveDaysMax()` は内部で既定値に倒れる設計なので通常は例外にならないが、
+ // ★「設定が読めない」だけでダッシュボードを 500 にしない★。
+ // ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型)
+ let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
+ try {
+ maxDays = await loadConsecutiveDaysMax();
+ } catch {
+ // 既定値のまま続行する
+ }
+
+ // ★★このAPIの母集団を決める1本★★(指示書AK §7-2)—
+ // ★`null` = 絞らない / `[]` = 0件ヒット★(★罠1★。`?.length` で分岐しないこと)。
+ // ★★清掃ログ(`summary` / `problem_logs` / スケジュール進捗の元)・直近モードのメタ(`cleanLogRunMeta()`)・
+ // セキュリティ(`dsAlerts`)・要レスキューの4本すべてに**同じ配列**を渡すこと★★ —
+ // ★どれか1つを渡し忘れると、「この屋号の昨夜の成績」と「要レスキュー」が別の集団の話になる★
+ // (メタだけ漏れると、行に対応するメタが無く、その行が選ばれず `data.anomaly` が出る。指示書BB A2)。
+ // ★例外もテスト失敗も出ない壊れ方★なので
+ // `app/api/__tests__/master-filter-dashboard-reports.test.ts` で固定してある。
+ let facilityIds: number[] | null;
+ let logs: CleanLogItem[];
+ let dsAlerts: DsAlert[];
+ let rescueRequired: RescueRequiredItem[];
+ /** 直近モードで選んだ行(★`date` 指定のときは `null`★) */
+ let picked: CleanLogItem[] | null;
+ try {
+ facilityIds = await resolveFacilityIdsByMasters(masters.query);
+ // ★★清掃ログとメタには**同じオブジェクト**を渡す★★(指示書AV 4-2 / 4-8)—
+ // 別々に組み立てると、片方だけ条件が抜けても気づけない(突き合わせが静かにずれる)。
+ // ★直近モード★: 表3 の窓(`latestRunWindow()`。今日を含む8日ぶん)で `start_at` を絞る。
+ // ★`date_from` / `date_to` を `null` にしないこと★ — 全件読みに戻る(4-7)。
+ // ★`date` 指定★: ★その1日ぶんだけを引く★(指示書AC 段階2 D-6)。
+ // `date` は上の `parseDate()` で `^\d{4}-\d{2}-\d{2}$` の検証を通っている
+ // (= `todayJst()` と同じ形式)ので、そのまま渡してよい(§E-2)。
+ // ★★施設の絞り込みは分類だけ★★(このAPIに施設名の条件は無い)—
+ // ★`resolveFacilityIdsByMasters()` の結果をそのまま渡す★ので積集合は要らない。
+ // ★★分類を指定していないときに `[]` を渡さないこと★★ — `[]` は「0件ヒット」の
+ // 意味で、集計が ★全部0★ になります(罠1)。`null` が「絞らない」。
+ // ★単一ID の指定はしない(期間の全件が要る)★(指示書AC 段階3)
+ const logQuery: CleanLogDbQuery = latestMode
+ ? {
+ id: null,
+ ...latestRunWindow(now, DASHBOARD_LATEST_RUN.lookbackDays),
+ facility_ids: facilityIds,
+ }
+ : { id: null, date_from: date, date_to: date, facility_ids: facilityIds };
+ let meta: CleanLogRunMeta[] | null;
+ [logs, meta, dsAlerts, rescueRequired] = await Promise.all([
+ // ★読む量★: `date` 指定は1日ぶん(本番想定で約26万行の読み込みが約360行)。
+ // ★直近モードは8日ぶん★(約2,900行。指示書AV 4-7)。
+ db.cleanLogs(logQuery),
+ // ★★日付指定ではメタを読まない★★(直近モード専用。指示書AV 段階4-4)
+ latestMode ? db.cleanLogRunMeta(logQuery) : Promise.resolve(null),
+ // ★★`clean_schedules`(`db.schedules()`)は読まない★★(2026-10-29 案B)— スケジュール進捗の予定件数は
+ // 上の清掃ログ(予定の表の本清掃)から数える。曜日の設定は予定の表と食い違いうる(ステージングで 7件 対 5件)。
+ // ★★施設の絞り込みは下の `filterRowsByFacilityIds()` でルート側で行う★★(段階4a の申し送り。`lib/facilities/filter-by-ids.ts`)。
+ // ★`dsAlerts()` は解消でないものだけ読む★(バナーは解消でないものしか見ない。指示書BB A6・D17)
+ db.dsAlerts({ open_only: true }),
+ // ★★上の `cleanLogs()` の結果から作らないこと★★(指示書AI §1-5 / 段階4)—
+ // ★当日に清掃予定が無い施設も要レスキューには出す★ので母集団が違ううえ、
+ // R-1(未実施3日連続)も R-3(前回比の急落)も ★当日の行だけでは判定できない★。
+ // ★遡りぶんの読み込みは `db.rescueRequired()` の中(`loadConsecutiveInputs()`)★。
+ // ★★`summary` と**同じ** `facilityIds` を渡すこと★★(指示書AK §7-2)—
+ // ★別の配列を作り直さない★(作り直すと片方だけ条件が抜けても気づけない)。
+ // ★基準日は直近モードでも当日(`date`)★(D-159。指示書AV 4-1)
+ db.rescueRequired({ date, facility_ids: facilityIds }),
+ ]);
+ // ★★`try` の中で呼ぶ★★(指示書AV 4-9)— メタの重複(E2)は例外になる(D9)。
+ // 外で呼ぶと `{ error: ... }` の形にならない。
+ // ★設定値は `DASHBOARD_LATEST_RUN` から渡す★(7 を直書きしない。`CLAUDE.md` §7)。
+ // ★メタの無い行(E1′。並列に読む間に行が増えた)は選ばれず、ここでログを1回出す★(指示書BB A2・D-182)
+ if (meta !== null) {
+ const missingIds = missingRunMetaIds(logs, meta);
+ if (missingIds.length > 0) {
+ logEvent("warn", {
+ event: "data.anomaly",
+ route: ROUTE,
+ method: "GET",
+ metric: "clean_log_run_meta_missing",
+ expected: 0,
+ actual: missingIds.length,
+ // ★`row_key`("tt-<予定の id>" / "rp-")★(指示書BG §2-2。予定外の行は id が無い)
+ row_ids: missingIds,
+ });
+ }
+ }
+ picked =
+ meta === null
+ ? null
+ : pickLatestRunPerSection(logs, meta, { now, ...DASHBOARD_LATEST_RUN });
+ } catch (e) {
+ const body: ApiError = {
+ error: {
+ code: "internal_error",
+ message: "データの取得に失敗しました",
+ details: null,
+ },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ // 日付の切り出しは文字列で行う。scheduled_at は ISO8601(+09:00) なので
+ // 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない。
+ // ★`logsOfDay` は `date` 指定のときの数値カードの母集団★(直近モードでは使わない)。
+ const logsOfDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date);
+ // ★数値カードと `problem_logs` の母集団★: 直近モードは選んだ行、`date` 指定はその日の行(D-159 / D12)
+ const summaryRows = picked ?? logsOfDay;
+
+ // ★★`dsAlerts()` は施設で絞らない契約なのでここで絞る★★
+ // (段階4a が `/ds-alerts` で使っているのと同じ純粋関数)。
+ // ★★集計より**前**に絞ること★★ — セキュリティバナーも ★同じ母集団から作る★ため。
+ // ★`logs` は `db.cleanLogs()` の時点で絞られている★ので二重に絞らない。
+ const dsAlertsOfScope = filterRowsByFacilityIds(dsAlerts, facilityIds);
+
+ const summary = computeSummary(summaryRows);
+ // ★「問題あり」の規則は `isProblemGrade()` を共有する★(書き写さない。指示書AV 4-11)。
+ // ★★結果待ちの行は出さない★★(ユーザー判断 2026-10-07。D-173 / 表2 C7)— 結果待ちの行は
+ // `grade: "×"` のままなので、旗を見ないと「問題あり」に入ってしまう(`result=problem` と同じ扱い。C3)。
+ // ★★絞り込みの**後**に `capCleanLogs()` を通す★★(D13 / 4-10。`/clean-logs` と連続日数の表示を揃える)
+ const problemLogs = capCleanLogs(
+ summaryRows.filter((row) => !row.result_pending && isProblemGrade(row.grade)),
+ maxDays
+ );
+ // ★★スケジュール進捗(2026-10-29 案B)★★ — 対象日は直近モードなら JST の前日、`date` 指定ならその日。
+ // ★`logs` から数える★(施設の絞り込み・削除済みの除外・本清掃だけ、が済んだ同じ行)。
+ // ★直近モードの窓(`latestRunWindow()`。今日を含む8日ぶん)は前日を含む★ので、前日の行は読めている。
+ // ★前日の行は全部過去なので `isFutureSchedule()` で落ちない★。
+ // ★`date` 指定でも `logs` はその1日ぶん★。★`now` は上で作った同じインスタンス★
+ const scheduleProgress = computeScheduleProgress(
+ logs,
+ scheduleProgressTargetDate(now, latestMode ? null : date)
+ );
+ // ★DB のデータでは `security_off` が出ないのでバナーは常に空(指示書AX D4。D-180)★
+ const securityBanner = computeSecurityBanner(dsAlertsOfScope);
+
+ // ★旧 `data.anomaly`(`clean_log_rows_vs_scheduled`。曜日の設定の件数と当日の行数の食い違い。D-049)は消した★
+ // (2026-10-29 案B)— 予定件数を清掃ログの行そのものから数えるので、比べる相手が無くなった。
+
+ const body: DashboardResponse = {
+ summary,
+ schedule_progress: scheduleProgress,
+ security_banner: securityBanner,
+ // ★データソースが並べ替えまで済ませた配列をそのまま返す★
+ // (★ここで並べ替え直さないこと★。順序の正は `buildRescueRequired()`。D-052 / D-055)
+ // ★★件数では切らない★★(指示書AJ 段階1。上限は無く、該当施設を全部返す。
+ // 初期表示を5件に絞るのは画面側の責務 = `lib/dashboard/rescue-visibility.ts`)
+ rescue_required: rescueRequired,
+ // ★データソースの順のまま(scheduled_at 降順 → id 昇順)。並べ替え直さない★
+ // ★★件数では切らない★★(指示書AV §2 D11。1清掃エリア最大1行。先頭5件を出すのは画面)
+ problem_logs: problemLogs,
+ };
+ return NextResponse.json(body);
+ });
+}
diff --git a/app/api/ds-alerts/__tests__/route.fixtures.test.ts b/app/api/ds-alerts/__tests__/route.fixtures.test.ts
new file mode 100644
index 0000000..dc997ff
--- /dev/null
+++ b/app/api/ds-alerts/__tests__/route.fixtures.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PATCH } from "@/app/api/errors/[error_id]/status/route";
+import { GET } from "@/app/api/ds-alerts/route";
+import { resetFixtureErrorReportOverrides } from "@/lib/db/fixture-error-report-overrides";
+import type { DsAlertSummary } from "@/lib/ds-alerts/query";
+import type { DsAlert, SummaryListResponse } from "@/lib/types";
+
+/**
+ * `GET /ds-alerts` → `PATCH /errors/{error_id}/status` → `GET /ds-alerts`(`USE_FIXTURES=true`。指示書AX 段階3-4)。
+ *
+ * ★D-Sec アラートは `errorReportRows` の `D-S_CONTROL_FAILURE` 行から作る★(D13)ので、
+ * エラー一覧の PATCH で書いた対応状況が D-Sec にも出る(U4。上書き表を共有)。
+ *
+ * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、
+ * 同じファイルで true/false の両方は試せない(`app/api/errors/__tests__/route.fixtures.test.ts` と同じ)。
+ * ★テストごとに `resetFixtureErrorReportOverrides()` で空に戻す★。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+const NOW_ISO = "2026-10-06T08:15:00+09:00";
+
+/** 出る7行(`FX-ERR-10`・`12`〜`17`)。未解消3(10・12・16)・対応中2(13・17)・解消2(14・15) */
+const VISIBLE_DS_ALERT_IDS = [
+ "FX-ERR-10",
+ "FX-ERR-12",
+ "FX-ERR-13",
+ "FX-ERR-14",
+ "FX-ERR-15",
+ "FX-ERR-16",
+ "FX-ERR-17",
+];
+
+beforeEach(() => {
+ resetFixtureErrorReportOverrides();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(NOW_ISO));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ resetFixtureErrorReportOverrides();
+});
+
+async function list(qs = ""): Promise> {
+ const res = await GET(new Request(`http://localhost/api/ds-alerts${qs ? `?${qs}` : ""}`));
+ expect(res.status).toBe(200);
+ return (await res.json()) as SummaryListResponse;
+}
+
+async function patch(id: string, body: unknown): Promise {
+ return PATCH(
+ new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ error_id: id }) }
+ );
+}
+
+describe("GET /ds-alerts(USE_FIXTURES=true)", () => {
+ it("★既定(status=open)で5件・status=all で7件★", async () => {
+ const def = await list();
+ expect(def.meta.total).toBe(5);
+ expect(def.items.map((i) => i.id).sort()).toEqual(["FX-ERR-10", "FX-ERR-12", "FX-ERR-13", "FX-ERR-16", "FX-ERR-17"]);
+ const all = await list("status=all");
+ expect(all.meta.total).toBe(7);
+ expect(all.items.map((i) => i.id).sort()).toEqual(VISIBLE_DS_ALERT_IDS);
+ });
+
+ it("★summary は {unresolved:3, in_progress:2, resolved:2}(status に依らない)★", async () => {
+ for (const qs of ["", "status=all", "status=resolved"]) {
+ expect((await list(qs)).summary).toEqual({ unresolved: 3, in_progress: 2, resolved: 2 });
+ }
+ });
+
+ it("★SECURITY_ON(FX-ERR-08)・NOT_STARTED(FX-ERR-11)・削除済みの清掃エリア(FX-ERR-19)は出ない★", async () => {
+ const ids = (await list("status=all")).items.map((i) => i.id);
+ expect(ids).not.toContain("FX-ERR-08");
+ expect(ids).not.toContain("FX-ERR-11");
+ expect(ids).not.toContain("FX-ERR-19");
+ });
+});
+
+describe("PATCH /errors → GET /ds-alerts(USE_FIXTURES=true)", () => {
+ it("★FX-ERR-10 を解消にすると status=open から消え、summary.resolved が3になる★", async () => {
+ expect((await patch("FX-ERR-10", { status: "resolved" })).status).toBe(200);
+
+ const open = await list();
+ expect(open.items.map((i) => i.id)).not.toContain("FX-ERR-10");
+ expect(open.meta.total).toBe(4);
+ expect(open.summary).toEqual({ unresolved: 2, in_progress: 2, resolved: 3 });
+
+ const resolved = await list("status=resolved");
+ expect(resolved.items.find((i) => i.id === "FX-ERR-10")).toMatchObject({
+ resolution_status: "resolved",
+ responded_at: NOW_ISO,
+ resolved_at: NOW_ISO,
+ });
+ });
+});
diff --git a/app/api/ds-alerts/__tests__/route.test.ts b/app/api/ds-alerts/__tests__/route.test.ts
new file mode 100644
index 0000000..e5fc5eb
--- /dev/null
+++ b/app/api/ds-alerts/__tests__/route.test.ts
@@ -0,0 +1,434 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由)
+import { GET } from "@/app/api/ds-alerts/route";
+import { D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
+import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
+import { dsAlertWhere } from "@/lib/db/ds-alert-mapping";
+import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
+import { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select";
+import type { DsAlertSummary } from "@/lib/ds-alerts/query";
+import { toDbJst } from "@/lib/format/date";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, DsAlert, SummaryListResponse } from "@/lib/types";
+
+/**
+ * `GET /ds-alerts`(Prisma 経路。指示書AX 段階4-4-1)。
+ *
+ * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して
+ * `prismaSource.dsAlerts()` の経路を走らせる(`app/api/errors/__tests__/route.test.ts` と同じ作法)。
+ *
+ * ★スタブの `robotErrorReport.findMany` は★
+ * - ★`guardNullRobotId()` を通す★(D-Sec の行は全部 `robot_id` NULL。`robot_id` / `robot` を読むと本物の Prisma は落ちる)
+ * - ★`where` を無視して全行返す★ — SQL が照合順序(ci・PAD SPACE)で `d-s_control_failure` や末尾空白の行まで
+ * 当ててしまい、可視性の条件も効かなかった状態を再現する。★正は純粋関数(`dsAlertsFromErrorRows()`)★(D2・D12)
+ * - `select` を見て返す列を絞る
+ * ★`controlErrorHistory` は呼ばれたら落ちる★(もう読まない。P-10 = A)。
+ *
+ * 入力は表1 の13行(R1〜R13。出るのは R1〜R4・R11〜R13 の7行)。期待値は表2(監督判断 2026-10-19 で正)。
+ * 施設: 1 = テスト施設A / 2 = テスト施設B(制御盤 DFAS010001 の所属)/ 3 = テスト施設C。
+ * ★R11 は施設2の制御盤のシリアルだが、行の施設は3★(U3)。
+ * ★R1 = "E-9"・R12 = "E-10" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆。D11)。
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubErrorReport {
+ error_id: string;
+ /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
+ robot_id: bigint | null;
+ clean_timetable_id: bigint | null;
+ serial_number: string;
+ facility_id: bigint;
+ facility: { facility_name: string; deleted_at: Date | null };
+ section_id: bigint;
+ section: { section_name: string; deleted_at: Date | null };
+ error_type: string;
+ error_level: string;
+ errored_at: Date | null;
+ created_at: Date;
+ status: number;
+ responded_at: Date | null;
+ resolved_at: Date | null;
+ comment: string | null;
+}
+
+type Args = Record & { where?: Record; select?: Record };
+
+let errorReports: StubErrorReport[];
+/** `robotErrorReport.findMany` の引数の記録 */
+let errorCalls: Args[];
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
+
+/** `select` で返す列を絞る(入れ子の `{ select }` にも対応) */
+function project(row: Record, select: Record): Record {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(select)) {
+ if (value === true) out[key] = row[key];
+ else if (value && typeof value === "object" && "select" in value) {
+ out[key] = project(row[key] as Record, (value as { select: Record }).select);
+ }
+ }
+ return out;
+}
+
+vi.mock("@/lib/db/prisma", () => {
+ const read = {
+ robotErrorReport: {
+ findMany: async (args: Args) => {
+ errorCalls.push(args);
+ guardNullRobotId(errorReports as unknown as Record[], args);
+ // ★where を無視して全行返す★(上の docstring。SQL は写し)
+ return errorReports.map((row) => project(row as unknown as Record, args.select ?? {}));
+ },
+ },
+ // ★もう読まない(P-10 = A。指示書AX)★ — 呼ばれたらルートが 500 になり、テストが落ちる
+ controlErrorHistory: {
+ findMany: async () => {
+ throw new Error("[stub] control_error_histories は読まない(指示書AX。P-10 = A)");
+ },
+ },
+ };
+ return {
+ getPrisma: async () => read,
+ getWritePrisma: async () => {
+ throw new Error("[stub] 一覧で書き込み用の接続を取ってはいけない");
+ },
+ };
+});
+
+// ---------------------------------------------------------------- 合成データ(表1)
+
+/** `+09:00` 付きの ISO → DB の壁時計値の `Date`(Prisma が返す形) */
+const db = (iso: string): Date => toDbJst(new Date(iso));
+
+const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved");
+const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress");
+const RESOLVED = robotErrorStatusFromResolutionStatus("resolved");
+const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0];
+/** ★施設2の制御盤のシリアル★(R11 は施設3の行だがこのシリアル) */
+const SERIAL_FACILITY_2 = "DFAS010001";
+
+// ★実在の店名を使わない★(`CLAUDE.md` §7)
+const FACILITY_2 = {
+ facility_id: BigInt(2),
+ facility: { facility_name: "テスト施設B", deleted_at: null },
+ section_id: BigInt(20),
+ section: { section_name: "1F売り場", deleted_at: null },
+};
+const FACILITY_3 = {
+ facility_id: BigInt(3),
+ facility: { facility_name: "テスト施設C", deleted_at: null },
+ section_id: BigInt(30),
+ section: { section_name: "2F衣料品", deleted_at: null },
+};
+
+/** 既定は D-Sec の行(R1 の形)。施設2・status 0・robot_id NULL */
+function row(overrides: Partial & Pick): StubErrorReport {
+ return {
+ robot_id: null,
+ clean_timetable_id: BigInt(100),
+ serial_number: SERIAL_FACILITY_2,
+ ...FACILITY_2,
+ error_type: DS_ALERT_TYPE,
+ error_level: "CRITICAL",
+ errored_at: db("2026-10-05T22:00:00+09:00"),
+ created_at: db("2026-10-05T22:01:00+09:00"),
+ status: UNRESOLVED,
+ responded_at: null,
+ resolved_at: null,
+ comment: null,
+ ...overrides,
+ };
+}
+
+/** ★出ない行(R5〜R10)は出る行のどれより新しい検知時刻にする★(漏れたら先頭に出て気づける) */
+const LEAK_AT = { errored_at: db("2026-10-06T05:00:00+09:00"), created_at: db("2026-10-06T05:01:00+09:00") };
+
+function buildData() {
+ errorReports = [
+ // R1
+ row({ error_id: "E-9" }),
+ // R2: 対応中・備考あり
+ row({
+ error_id: "R-2",
+ errored_at: db("2026-10-05T21:00:00+09:00"),
+ created_at: db("2026-10-05T21:01:00+09:00"),
+ status: IN_PROGRESS,
+ responded_at: db("2026-10-06T09:05:00+09:00"),
+ comment: "回線業者に確認中",
+ }),
+ // R3: 解消
+ row({
+ error_id: "R-3",
+ errored_at: db("2026-10-05T20:00:00+09:00"),
+ created_at: db("2026-10-05T20:01:00+09:00"),
+ status: RESOLVED,
+ responded_at: db("2026-10-06T08:40:00+09:00"),
+ resolved_at: db("2026-10-06T09:15:00+09:00"),
+ comment: "正常復帰を確認",
+ }),
+ // R4: errored_at NULL → detected_at は created_at(ステージングの16行すべてがこの形)
+ row({ error_id: "R-4", errored_at: null, created_at: db("2026-10-06T03:00:00+09:00") }),
+ // R5: SECURITY_ON(U1)
+ row({ error_id: "R-5", serial_number: "---", error_type: "SECURITY_ON", ...LEAK_AT }),
+ // R6: NOT_STARTED(U2)
+ row({ error_id: "R-6", serial_number: "---", error_type: "NOT_STARTED", ...LEAK_AT }),
+ // R7: ロボットのエラー(D1)
+ row({ error_id: "R-7", robot_id: BigInt(501), serial_number: "PD-CC1-0001", error_type: "EmergencyStop", error_level: "ERROR", ...LEAK_AT }),
+ // R8: ★わざと小文字★(SQL の ci では当たるが `===` で外れる。D2)
+ row({ error_id: "R-8", error_type: "d-s_control_failure", ...LEAK_AT }),
+ // R9: ★わざと末尾に空白★(SQL の PAD SPACE では当たるが `===` で外れる。D2)
+ row({ error_id: "R-9", error_type: "D-S_CONTROL_FAILURE ", ...LEAK_AT }),
+ // R10: status 3(出さない・例外にしない。D3)
+ row({ error_id: "R-10", status: 3, ...LEAK_AT }),
+ // R11: 施設2の制御盤のシリアル・行の施設は3(U3)
+ row({
+ error_id: "R-11",
+ ...FACILITY_3,
+ errored_at: db("2026-10-05T23:00:00+09:00"),
+ created_at: db("2026-10-05T23:01:00+09:00"),
+ }),
+ // R12: R1 と同じ検知時刻・"E-10"(D11)
+ row({ error_id: "E-10", created_at: db("2026-10-05T22:02:00+09:00") }),
+ // R13: 予定 id NULL(予定の有無で絞らない)
+ row({
+ error_id: "R-13",
+ clean_timetable_id: null,
+ errored_at: db("2026-10-05T19:00:00+09:00"),
+ created_at: db("2026-10-05T19:01:00+09:00"),
+ }),
+ ];
+}
+
+/** 出る7行の並び(`detected_at` 降順・同値は id の辞書順) */
+const ALL_ORDER = ["R-4", "R-11", "E-10", "E-9", "R-2", "R-3", "R-13"];
+/** 既定(`status=open`)。R3 以外の6行 */
+const OPEN_ORDER = ALL_ORDER.filter((id) => id !== "R-3");
+const HIDDEN = ["R-5", "R-6", "R-7", "R-8", "R-9", "R-10"];
+
+type Body = SummaryListResponse;
+
+async function get(qs = ""): Promise {
+ return GET(new Request(`http://localhost/api/ds-alerts${qs ? `?${qs}` : ""}`));
+}
+
+async function list(qs = ""): Promise {
+ const res = await get(qs);
+ expect(res.status).toBe(200);
+ return (await res.json()) as Body;
+}
+
+const ids = (items: DsAlert[]) => items.map((i) => i.id);
+
+beforeEach(() => {
+ buildData();
+ errorCalls = [];
+ vi.mocked(logApiError).mockClear();
+});
+
+// ---------------------------------------------------------------- 読み方
+
+describe("読み方(指示書AX D1〜D3)", () => {
+ it("★findMany の where は dsAlertWhere({ open_only: false })(全件。指示書BB 表10 N8)、select は ROBOT_ERROR_DS_ALERT_SELECT★", async () => {
+ await list();
+ expect(errorCalls).toHaveLength(1);
+ expect(errorCalls[0].where).toEqual(dsAlertWhere({ open_only: false }));
+ // ★解消(2)も SQL で読む(`summary.resolved` を数えるため)★
+ expect((errorCalls[0].where as { status: { in: number[] } }).status.in).toContain(2);
+ expect(errorCalls[0].select).toEqual(ROBOT_ERROR_DS_ALERT_SELECT);
+ });
+
+ it("★robot_id が NULL の行があっても読み取りで落ちない・control_error_histories を読まない★", async () => {
+ await list();
+ expect(vi.mocked(logApiError)).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------- 表2
+
+describe("表2(パラメータ・summary)", () => {
+ it("A1: 既定(type=all&status=open)→ R3 以外の6行・meta.total = 6", async () => {
+ const body = await list();
+ expect(ids(body.items)).toEqual(OPEN_ORDER);
+ expect(body.meta).toEqual({ total: 6, page: 1, per_page: 50 });
+ });
+
+ it("A2: status=resolved → R3 だけ", async () => {
+ const body = await list("status=resolved");
+ expect(ids(body.items)).toEqual(["R-3"]);
+ expect(body.meta.total).toBe(1);
+ });
+
+ it("A3: status=all → 7行", async () => {
+ const body = await list("status=all");
+ expect(ids(body.items)).toEqual(ALL_ORDER);
+ expect(body.meta.total).toBe(7);
+ });
+
+ it("★A4: status=done(旧の値)→ 422。黙って倒さない(DB を読まない)★", async () => {
+ const res = await get("status=done");
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as ApiError;
+ expect(body.error.code).toBe("validation_failed");
+ expect(body.error.details).toEqual({ status: ["open / all / resolved のいずれかを指定してください"] });
+ expect(errorCalls).toHaveLength(0);
+ });
+
+ it("A5: type=security_off → 200・0件(422 にしない。enum は残す。D4)", async () => {
+ const body = await list("type=security_off");
+ expect(body.items).toEqual([]);
+ expect(body.meta.total).toBe(0);
+ });
+
+ it("A6: type=disconnected → A1 と同じ", async () => {
+ const body = await list("type=disconnected");
+ expect(ids(body.items)).toEqual(OPEN_ORDER);
+ expect(body.meta.total).toBe(6);
+ });
+
+ it.each(["", "status=all", "status=resolved", "type=security_off", "type=disconnected&status=all"])(
+ "A7: summary は type / status によらず { unresolved: 5, in_progress: 1, resolved: 1 }(%s)",
+ async (qs) => {
+ const body = await list(qs);
+ expect(body.summary).toEqual({ unresolved: 5, in_progress: 1, resolved: 1 });
+ }
+ );
+
+ it("A9: per_page=2&page=2 → 並び順の3〜4行目・meta.total = 6", async () => {
+ const body = await list("per_page=2&page=2");
+ expect(ids(body.items)).toEqual(["E-10", "E-9"]);
+ expect(body.meta).toEqual({ total: 6, page: 2, per_page: 2 });
+ });
+});
+
+// ---------------------------------------------------------------- 表1(ルートから見た値)
+
+describe("表1(ルートから見た値)", () => {
+ it("★R5〜R10 は出ない(status=all でも)★ — SQL が ci・PAD SPACE で当てても純粋関数の === で外れる", async () => {
+ const body = await list("status=all");
+ for (const id of HIDDEN) expect(ids(body.items)).not.toContain(id);
+ });
+
+ it("★R10(status 3)が混ざっても 500 にならない★", async () => {
+ const res = await get("status=all");
+ expect(res.status).toBe(200);
+ expect(vi.mocked(logApiError)).not.toHaveBeenCalled();
+ });
+
+ it("R1: 各値(id は string・シリアルは生値・BigInt を残さない)", async () => {
+ const body = await list();
+ expect(body.items.find((i) => i.id === "E-9")).toEqual({
+ id: "E-9",
+ alert_type: "disconnected",
+ device_serial: SERIAL_FACILITY_2,
+ facility_id: 2,
+ facility_name: "テスト施設B",
+ section_name: "1F売り場",
+ detected_at: "2026-10-05T22:00:00+09:00",
+ resolution_status: "unresolved",
+ responded_at: null,
+ resolved_at: null,
+ note: null,
+ });
+ });
+
+ it("R2: 対応中・responded_at・note", async () => {
+ const body = await list();
+ expect(body.items.find((i) => i.id === "R-2")).toMatchObject({
+ resolution_status: "in_progress",
+ responded_at: "2026-10-06T09:05:00+09:00",
+ resolved_at: null,
+ note: "回線業者に確認中",
+ });
+ });
+
+ it("R3: 解消(status=all で出る)", async () => {
+ const body = await list("status=all");
+ expect(body.items.find((i) => i.id === "R-3")).toMatchObject({
+ resolution_status: "resolved",
+ responded_at: "2026-10-06T08:40:00+09:00",
+ resolved_at: "2026-10-06T09:15:00+09:00",
+ });
+ });
+
+ it("★R4: errored_at が NULL → detected_at は created_at(9時間ずらさない)★", async () => {
+ const body = await list();
+ expect(body.items.find((i) => i.id === "R-4")?.detected_at).toBe("2026-10-06T03:00:00+09:00");
+ });
+
+ it("★R11: 行の施設3で出る(制御盤のシリアルの所属=施設2ではない)・シリアルはそのまま★", async () => {
+ const body = await list();
+ expect(body.items.find((i) => i.id === "R-11")).toMatchObject({
+ facility_id: 3,
+ facility_name: "テスト施設C",
+ section_name: "2F衣料品",
+ device_serial: SERIAL_FACILITY_2,
+ });
+ });
+
+ it("★R12: 同じ検知時刻は辞書順で \"E-10\" が \"E-9\" より先★", async () => {
+ const order = ids((await list()).items);
+ expect(order.indexOf("E-10")).toBeLessThan(order.indexOf("E-9"));
+ });
+
+ it("R13: 予定 id が NULL でも出る", async () => {
+ expect(ids((await list()).items)).toContain("R-13");
+ });
+});
+
+// ---------------------------------------------------------------- 表5(指示書AX U8)
+
+/**
+ * ★表5 の D-Sec 列(V4〜V7)★。スタブは where を無視して全行返す(SQL の可視性の条件が効かなかった状態)ので、
+ * ★純粋関数側の `isVisibleErrorRow()` が外す★ことを見る。★削除の日時は過去の値★(NULL か否かだけ。D12)。
+ */
+describe("表5 — 削除済みの施設・清掃エリアの行は出ない(指示書AX U8)", () => {
+ const DELETED_AT = db("2026-10-01T10:00:00+09:00");
+
+ beforeEach(() => {
+ errorReports.push(
+ // V4: 両方生きている・status 0
+ row({ error_id: "V-4", ...LEAK_AT }),
+ // V5: 施設が削除済み・status 0
+ row({ error_id: "V-5", ...LEAK_AT, facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }),
+ // V6: 清掃エリアが削除済み・status 1
+ row({
+ error_id: "V-6",
+ ...LEAK_AT,
+ status: IN_PROGRESS,
+ responded_at: db("2026-10-06T09:00:00+09:00"),
+ section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT },
+ }),
+ // V7: 両方削除済み・status 2
+ row({
+ error_id: "V-7",
+ ...LEAK_AT,
+ status: RESOLVED,
+ responded_at: db("2026-10-06T09:00:00+09:00"),
+ resolved_at: db("2026-10-06T09:30:00+09:00"),
+ facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT },
+ section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT },
+ })
+ );
+ });
+
+ it("V4: 出る・summary.unresolved に数える", async () => {
+ const body = await list();
+ expect(ids(body.items)[0]).toBe("V-4");
+ expect(body.summary.unresolved).toBe(6);
+ });
+
+ it.each(["", "status=all", "status=resolved"])("★V5〜V7: 一覧に出ない(%s)★", async (qs) => {
+ const body = await list(qs);
+ for (const id of ["V-5", "V-6", "V-7"]) expect(ids(body.items)).not.toContain(id);
+ });
+
+ it("★V5〜V7: summary にも数えない(in_progress・resolved は表1 のまま)★", async () => {
+ const body = await list("status=all");
+ expect(body.summary).toEqual({ unresolved: 6, in_progress: 1, resolved: 1 });
+ expect(body.meta.total).toBe(8);
+ });
+});
diff --git a/app/api/ds-alerts/route.ts b/app/api/ds-alerts/route.ts
new file mode 100644
index 0000000..bee66be
--- /dev/null
+++ b/app/api/ds-alerts/route.ts
@@ -0,0 +1,92 @@
+/**
+ * GET /ds-alerts — D-Secアラート一覧+サマリ
+ *
+ * 形は `app/api/ds-devices/route.ts` と同じ。
+ * 仕様の正は `d-watch-api.yaml` の /ds-alerts。
+ *
+ * ★対応状況・備考の更新は `PATCH /errors/{error_id}/status`(`robot_error_reports` の同じ行。
+ * 指示書AX D6)。このルートは読むだけ★
+ */
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import {
+ filterDsAlertsByStatus,
+ filterDsAlertsByType,
+ parseDsAlertQuery,
+ sortDsAlerts,
+ summarizeDsAlerts,
+} from "@/lib/ds-alerts/query";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, DsAlert, SummaryListResponse } from "@/lib/types";
+import type { DsAlertSummary } from "@/lib/ds-alerts/query";
+
+const ROUTE = "/api/ds-alerts";
+
+function errorResponse(error: ApiError, status: number) {
+ return NextResponse.json(error, { status });
+}
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ const parsed = parseDsAlertQuery(searchParams);
+ if (!parsed.ok) {
+ return errorResponse(parsed.error, parsed.status);
+ }
+
+ let rows: DsAlert[];
+ // ★分類(`group_id` / `customer_id`)→ 施設IDの配列★(指示書AK 段階4)。
+ // ★`null` = 絞らない / `[]` = 0件★(★罠1★。`?.length` で分岐しないこと)。
+ let facilityIds: number[] | null;
+ try {
+ // ★解決(施設マスタの読み取り)も同じ try に入れる★ — 失敗したら 500。
+ // ★黙って絞らずに返さないこと★(他社のアラートが混ざったまま画面に出る)。
+ facilityIds = await resolveFacilityIdsByMasters(parsed.query);
+ // ★全件(`summary.resolved` に解消も数える。指示書BB D17)★
+ rows = await db.dsAlerts({ open_only: false });
+ } catch (e) {
+ const body: ApiError = {
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return errorResponse(body, 500);
+ }
+
+ // ★★分類の絞り込みは summary より**前**にかける★★(指示書AK 段階4)—
+ // type / status は「見え方」の絞り込みなので summary に効かせない(D-054)が、
+ // ★分類は「母集団」そのものを選ぶ★ので ★summary にも効かせる★
+ // (指示書AK §7-2 の数値カードと同じ理由)。
+ // ★★一覧と summary で母集団がずれないよう、必ず同じ配列から作ること★★。
+ // ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。
+ const scoped = filterRowsByFacilityIds(rows, facilityIds);
+
+ // ★summaryはtype/statusフィルタの前(全件)に対して計算する(D-054と同じ理由)★
+ // フィルタ後に計算すると、既定の絞り込み状態(status=open)によって
+ // サマリーが変わってしまう
+ const summary: DsAlertSummary = summarizeDsAlerts(scoped);
+ const filtered = filterDsAlertsByStatus(
+ filterDsAlertsByType(scoped, parsed.query.type),
+ parsed.query.status
+ );
+ const sorted = sortDsAlerts(filtered);
+
+ const { page, perPage } = parsed.query;
+ // ★meta.total と summary の合計は意味が違う★
+ // meta.total = 一覧(type・statusフィルタ後)の総件数。ページング計算用
+ // summary = 全アラート(type・statusの影響を受けない)に対する集計
+ // 絞り込みが無いとき(type=all かつ status=all)だけ一致する
+ const total = sorted.length;
+ const start = (page - 1) * perPage;
+ const items = sorted.slice(start, start + perPage);
+
+ const body: SummaryListResponse = {
+ summary,
+ items,
+ meta: { total, page, per_page: perPage },
+ };
+ return NextResponse.json(body);
+ });
+}
diff --git "a/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts" "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts"
new file mode 100644
index 0000000..8765129
--- /dev/null
+++ "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts"
@@ -0,0 +1,98 @@
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PATCH } from "@/app/api/ds-devices/[serial_number]/note/route";
+import { dsDevices } from "@/lib/fixtures/ds-devices";
+import {
+ __setDeviceNotesStoreForTesting,
+ createDeviceNotesStore,
+ readDeviceNotes,
+} from "@/lib/settings/ds-device-notes";
+
+/**
+ * 指示書AH 段階2 の期待値表 ★#9★ — ★`USE_FIXTURES` の true/false で status が一致する★
+ * (`CLAUDE.md` §9.5 / 指示書AC 禁止事項6「両系統で挙動を変えない」)。
+ *
+ * ★★なぜ別ファイルなのか★★
+ * `lib/db/index.ts` は ★`export const db = USE_FIXTURES ? fixtureSource : prismaSource;`★ で、
+ * ★モジュール読み込み時に束縛される★。同じファイルで true/false の両方は試せない
+ * (`vi.mock` はファイル単位)。★Prisma 経路(#1〜#8)は同ディレクトリの `route.test.ts`★。
+ *
+ * ★★#3(論理削除済み)・#4(ci 照合順序)は fixtures に表現できない★★ので、
+ * ここでは扱わない(指示書AH 段階2 の注記どおり)。fixtures は `DsDevice[]` の配列で、
+ * `deleted_at` も照合順序も存在しない。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+let dir = "";
+
+beforeEach(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-note-fx-"));
+ // 実ファイル(data/settings/ds-device-notes.json)を汚さない
+ __setDeviceNotesStoreForTesting(
+ createDeviceNotesStore(path.join(dir, "ds-device-notes.json"))
+ );
+});
+
+afterEach(async () => {
+ __setDeviceNotesStoreForTesting(null);
+ await fs.rm(dir, { recursive: true, force: true });
+});
+
+async function patchNote(serialNumber: string, note: unknown): Promise {
+ return PATCH(
+ new Request(
+ `http://localhost/api/ds-devices/${encodeURIComponent(serialNumber)}/note`,
+ { method: "PATCH", body: JSON.stringify({ note }) }
+ ),
+ { params: Promise.resolve({ serial_number: serialNumber }) }
+ );
+}
+
+describe("PATCH /ds-devices/{serial_number}/note(USE_FIXTURES=true。指示書AH 段階2 #9)", () => {
+ it("★前提★: fixtures に `DS-001` があり `DS-999` は無い(#1・#2 の土台)", () => {
+ const serials = dsDevices.map((d) => d.serial_number);
+ expect(serials).toContain("DS-001");
+ expect(serials).not.toContain("DS-999");
+ });
+
+ it("#1 `DS-001`(存在する)→ 200 / 保存(★Prisma 経路と同じ status★)", async () => {
+ const res = await patchNote("DS-001", "fixtures 経路でも保存できる");
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ serial_number: "DS-001",
+ note: "fixtures 経路でも保存できる",
+ });
+ expect(await readDeviceNotes()).toEqual({ "DS-001": "fixtures 経路でも保存できる" });
+ });
+
+ it("#2 `DS-999`(存在しない)→ 404(★Prisma 経路と同じ status★)", async () => {
+ const res = await patchNote("DS-999", "存在しない制御盤");
+
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: { code: "not_found", message: "デバイスが見つかりません", details: null },
+ });
+ expect(await readDeviceNotes()).toEqual({});
+ });
+
+ it("#5 `DS-001` → 200(fixtures には心拍の概念が無く、他台の欠損に左右されない)", async () => {
+ const res = await patchNote("DS-001", "他台の状態に左右されない");
+
+ expect(res.status).toBe(200);
+ expect(await readDeviceNotes()).toEqual({ "DS-001": "他台の状態に左右されない" });
+ });
+
+ it("★照合は fixtures 経路でも `===`★(`ds-001` は 404。両系統で緩めない)", async () => {
+ // ★指示書の期待値表には無いが、★両系統で規則を変えない★ことの確認として置く★ —
+ // fixtures 側が `toUpperCase()` 等で緩むと、`USE_FIXTURES` の値で 404/200 が変わる
+ const res = await patchNote("ds-001", "小文字");
+
+ expect(res.status).toBe(404);
+ expect(await readDeviceNotes()).toEqual({});
+ });
+});
diff --git "a/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts" "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts"
new file mode 100644
index 0000000..1c8b7cf
--- /dev/null
+++ "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts"
@@ -0,0 +1,291 @@
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` の
+// 冒頭コメント参照。`it()` の中で `await import()` するとフルスイートでタイムアウトしうる)
+import { PATCH } from "@/app/api/ds-devices/[serial_number]/note/route";
+import { DEVICE_NOTE_MAX_LENGTH, PATH_SERIAL_NUMBER_MAX_LENGTH } from "@/lib/config";
+import {
+ __setDeviceNotesStoreForTesting,
+ createDeviceNotesStore,
+ readDeviceNotes,
+} from "@/lib/settings/ds-device-notes";
+
+/**
+ * 指示書AH 段階2 — ★備考の保存の実在確認★(`PATCH /ds-devices/{serial_number}/note`)。
+ * ★このルートにはテストが1本も無かったので新規★。
+ *
+ * ------------------------------------------------------------------------
+ * ★★何を守るテストか★★
+ * ------------------------------------------------------------------------
+ * 1. ★実在確認が `db.dsDevices()`(全件読み)に戻っていないこと★(#8)—
+ * シリアル1件の確認のために全制御盤+全施設を読む必要は無い。
+ * 2. ★★照合が緩まないこと★★(#4)— ★DBの既定の照合順序は大小文字を区別しない(ci)★。
+ * SQL の一致をそのまま答えにすると `ds-001` が `DS-001` に当たって 404 → 200 になり、
+ * ★保存キーはリクエストの生文字列 `ds-001`★・★一覧は `notes["DS-001"]` で引く★ので、
+ * ★★保存されたのに一生表示されない孤児行が残る★★(D-4)。
+ * ★★だからスタブは ci 照合順序を**わざと**再現している★★ — 実装が JS の `===` で
+ * 決めるのをやめたら #4 が赤くなる。
+ * 3. ★他台の欠損に巻き込まれないこと★(#5)— スタブは `control_reports` を読んだら
+ * 例外を投げる。以前の実装(`db.dsDevices()`)は心拍0件の制御盤が1台あるだけで
+ * ★全台の備考保存が500★ になっていた。
+ *
+ * ------------------------------------------------------------------------
+ * ★★DBにもデータディレクトリにも触りません★★
+ * ------------------------------------------------------------------------
+ * `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に
+ * 固定して ★`prismaSource` の経路★ を走らせます。備考の保存先は `fs.mkdtemp` で作った
+ * 一時ディレクトリに差し替えます(`lib/settings/__tests__/ds-device-notes.test.ts` と同じ作法)。
+ *
+ * ★fixtures 経路(`USE_FIXTURES=true`)は同ディレクトリの `route.fixtures.test.ts`★ —
+ * ★`db` はモジュール読み込み時に束縛される★ので、同じファイルでは両方を試せない。
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubControl {
+ serial_number: string;
+ deleted_at: Date | null;
+ /** `controls.status`(0=正常/1=異常。★0/1 以外は一覧に出ない → 備考も404★。指示書BB 表4 D4) */
+ status: number;
+}
+
+const calls: { controlFindMany: Record[] } = { controlFindMany: [] };
+
+let controls: StubControl[] = [];
+
+/**
+ * ★★MySQL / MariaDB の既定の照合順序(ci)を再現する★★(D-4)—
+ * ★大小文字を区別せずに当たる★。★これがこのテストの肝★ で、
+ * ★実装が JS の `===` で判定するのをやめたら #4 が赤くなる★。
+ */
+function selectControlsCollationCi(
+ where: { deleted_at?: Date | null; serial_number?: string } = {}
+): { serial_number: string; status: number }[] {
+ let rows = controls;
+ // ★`deleted_at` の絞りを**わざと**実装している★(★罠7★)— 条件を落としたら #3 が赤くなる
+ if ("deleted_at" in where && where.deleted_at === null) {
+ rows = rows.filter((c) => c.deleted_at === null);
+ }
+ if (where.serial_number !== undefined) {
+ const wanted = where.serial_number.toLowerCase();
+ rows = rows.filter((c) => c.serial_number.toLowerCase() === wanted);
+ }
+ return rows.map((c) => ({ serial_number: c.serial_number, status: c.status }));
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ control: {
+ findMany: async (args?: Record) => {
+ calls.controlFindMany.push(args ?? {});
+ return selectControlsCollationCi(
+ (args?.where ?? {}) as { deleted_at?: Date | null; serial_number?: string }
+ );
+ },
+ },
+ // ★このルートは心拍を読んではいけない★(#5)— 読んだら例外で気づけるようにする
+ controlReport: {
+ findMany: async () => {
+ throw new Error("[stub] 備考の保存で control_reports を読んではいけません(指示書AH 段階2)");
+ },
+ groupBy: async () => {
+ throw new Error("[stub] 備考の保存で control_reports を読んではいけません(指示書AH 段階2)");
+ },
+ },
+ }),
+}));
+
+// ---------------------------------------------------------------- 前準備
+
+let dir = "";
+let filePath = "";
+
+beforeEach(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-note-"));
+ filePath = path.join(dir, "ds-device-notes.json");
+ // 実ファイル(data/settings/ds-device-notes.json)を汚さない
+ __setDeviceNotesStoreForTesting(createDeviceNotesStore(filePath));
+ calls.controlFindMany = [];
+ controls = [
+ { serial_number: "DS-001", deleted_at: null, status: 0 },
+ { serial_number: "DS-002", deleted_at: null, status: 1 },
+ // ★論理削除済み★(#3)
+ { serial_number: "DS-004", deleted_at: new Date("2026-09-01T00:00:00Z"), status: 0 },
+ // ★`controls.status` が 0/1 以外★(#9。指示書BB 表4 D4)
+ { serial_number: "DS-005", deleted_at: null, status: 2 },
+ ];
+});
+
+afterEach(async () => {
+ __setDeviceNotesStoreForTesting(null);
+ await fs.rm(dir, { recursive: true, force: true });
+});
+
+async function patchNote(serialNumber: string, note: unknown): Promise {
+ return PATCH(
+ new Request(
+ `http://localhost/api/ds-devices/${encodeURIComponent(serialNumber)}/note`,
+ { method: "PATCH", body: JSON.stringify({ note }) }
+ ),
+ { params: Promise.resolve({ serial_number: serialNumber }) }
+ );
+}
+
+// ---------------------------------------------------------------- 期待値表
+
+describe("PATCH /ds-devices/{serial_number}/note(指示書AH 段階2)", () => {
+ it("#1 `DS-001`(存在する)→ 200 / ★保存される★", async () => {
+ const res = await patchNote("DS-001", "制御盤の電源ユニット交換を手配中");
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ serial_number: "DS-001",
+ note: "制御盤の電源ユニット交換を手配中",
+ });
+ expect(await readDeviceNotes()).toEqual({
+ "DS-001": "制御盤の電源ユニット交換を手配中",
+ });
+ });
+
+ it("#2 `DS-999`(存在しない)→ 404(★実在確認が効く★)", async () => {
+ const res = await patchNote("DS-999", "存在しない制御盤");
+
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: { code: "not_found", message: "デバイスが見つかりません", details: null },
+ });
+ // ★保存されないこと★(孤児行を作らない)
+ expect(await readDeviceNotes()).toEqual({});
+ });
+
+ it("#3 `DS-004`(★論理削除済み★)→ 404(挙動を変えない。`deleted_at` を落とさない)", async () => {
+ const res = await patchNote("DS-004", "論理削除済みの制御盤");
+
+ expect(res.status).toBe(404);
+ // ★一覧(`dsDevices()`)に出ないデバイスの備考が保存できてしまうと、
+ // どこからも参照されない行がストアに残る(★罠7★)
+ expect(await readDeviceNotes()).toEqual({});
+ expect(calls.controlFindMany[0].where).toEqual({
+ deleted_at: null,
+ serial_number: "DS-004",
+ });
+ });
+
+ it("#4 ★`ds-001`(小文字)で DB は `DS-001` のみ → 404★(★★照合が緩まない★★)", async () => {
+ // ★スタブは ci 照合順序を再現しているので、SQL としては `DS-001` に**当たる**★。
+ // それでも 404 になるのは ★実装が JS の `===` で判定しているから★(D-4)。
+ const res = await patchNote("ds-001", "小文字で保存しようとした備考");
+
+ expect(res.status).toBe(404);
+ // ★★孤児行が残らないこと★★ — もし 200 になると、保存キーは生文字列 `ds-001` で、
+ // 一覧は `notes["DS-001"]` を引くため ★保存されたのに一生表示されない行★ になる
+ expect(await readDeviceNotes()).toEqual({});
+ // ★★SQL には**当たっていた**ことを確かめる★★ —
+ // = 404 の理由は「SQLが空を返したから」ではなく「JS の `===` が弾いたから」。
+ // ★この行が緑である限り、#4 は照合順序の罠を本当に再現している★
+ expect(calls.controlFindMany[0].where).toEqual({
+ deleted_at: null,
+ serial_number: "ds-001",
+ });
+ expect(selectControlsCollationCi({ deleted_at: null, serial_number: "ds-001" })).toEqual([
+ { serial_number: "DS-001", status: 0 },
+ ]);
+ });
+
+ it("#5 `DS-001` — ★別の制御盤に心拍が0件でも 200★(他台の欠損に巻き込まれない)", async () => {
+ // ★心拍が0件の制御盤を足す★。スタブは `control_reports` を読んだら例外を投げるので、
+ // ★`db.dsDevices()` に戻したら 500 になって赤くなる★
+ controls.push({ serial_number: "DS-777", deleted_at: null, status: 0 });
+
+ const res = await patchNote("DS-001", "他台が欠けていても保存できる");
+
+ expect(res.status).toBe(200);
+ expect(await readDeviceNotes()).toEqual({ "DS-001": "他台が欠けていても保存できる" });
+ });
+
+ it("#6 101文字のシリアル → 422(★検証順序(形式→長さ→実在)を変えない★)", async () => {
+ const tooLong = "D".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH + 1);
+ const res = await patchNote(tooLong, "上限を超えたシリアル");
+
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: { code: string } };
+ expect(body.error.code).toBe("validation_failed");
+ // ★DBまで運ばないこと★(形式の検証が実在確認より前にある)
+ expect(calls.controlFindMany).toHaveLength(0);
+ });
+
+ it("#7 note が201文字(デバイスは存在する)→ 422", async () => {
+ const tooLong = "あ".repeat(DEVICE_NOTE_MAX_LENGTH + 1);
+ const res = await patchNote("DS-001", tooLong);
+
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "validation_failed",
+ message: "noteが長すぎます",
+ details: { note: [`${DEVICE_NOTE_MAX_LENGTH}文字以内で入力してください`] },
+ },
+ });
+ // ★長さの検証も実在確認より前★(DBに照会しない)
+ expect(calls.controlFindMany).toHaveLength(0);
+ expect(await readDeviceNotes()).toEqual({});
+ });
+
+ it("#8 `DS-001` — ★`control.findMany` 1本のみ★(全件読みに戻っていない)", async () => {
+ await patchNote("DS-001", "クエリ本数の検査");
+
+ expect(calls.controlFindMany).toHaveLength(1);
+ const args = calls.controlFindMany[0];
+ // ★実在確認に施設名も最終通信も要らない★ — `include` を持たず、`serial_number` と状態(一覧と同じ規則。指示書BB A1)だけ選ぶ
+ expect(args.include).toBeUndefined();
+ expect(args.select).toEqual({ serial_number: true, status: true });
+ // ★★`take` を付けないこと★★(★罠8★。段階1 と同じ「SQL に降りない」問題を踏みうる)
+ expect(args.take).toBeUndefined();
+ // ★シリアルで絞ってはいる(SQLは絞り込みまで。当たり外れは JS の `===`。D-4)★
+ expect(args.where).toEqual({ deleted_at: null, serial_number: "DS-001" });
+ });
+
+ it("#9 `DS-005`(★`controls.status` が 2★)→ 404(一覧に出ない制御盤の備考は保存しない。指示書BB 表4 D4)", async () => {
+ const res = await patchNote("DS-005", "状態が不明な制御盤");
+
+ expect(res.status).toBe(404);
+ // ★孤児行を作らない★(一覧 `dsDevices()` は状態 0/1 以外を出さない)
+ expect(await readDeviceNotes()).toEqual({});
+ });
+
+ it("#10 `DS-002`(`controls.status` が 1=異常)→ 200(異常は一覧に出る)", async () => {
+ const res = await patchNote("DS-002", "異常の制御盤");
+
+ expect(res.status).toBe(200);
+ expect(await readDeviceNotes()).toEqual({ "DS-002": "異常の制御盤" });
+ });
+});
+
+// ★表8 J1〜J3(指示書BB A5・D19・D-182)★ — 本文がオブジェクトでなければ422(旧: `body.note` で TypeError → 標準でない500)
+describe("PATCH /ds-devices/{serial_number}/note — 本文の形(指示書BB 表8 J)", () => {
+ function patchRaw(raw: string): Promise {
+ return PATCH(new Request("http://localhost/api/ds-devices/DS-001/note", { method: "PATCH", body: raw }), {
+ params: Promise.resolve({ serial_number: "DS-001" }),
+ });
+ }
+
+ it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」・保存しない★", async (raw) => {
+ const res = await patchRaw(raw);
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: { code: "validation_failed", message: "JSONの形式が不正です", details: null },
+ });
+ expect(await readDeviceNotes()).toEqual({});
+ });
+
+ it("J3: 本文 {} → 今どおり(422「noteは文字列で指定してください」)", async () => {
+ const res = await patchRaw("{}");
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { message: string } }).error.message).toBe("noteは文字列で指定してください");
+ });
+});
diff --git "a/app/api/ds-devices/\133serial_number\135/note/route.ts" "b/app/api/ds-devices/\133serial_number\135/note/route.ts"
new file mode 100644
index 0000000..f41c1bf
--- /dev/null
+++ "b/app/api/ds-devices/\133serial_number\135/note/route.ts"
@@ -0,0 +1,139 @@
+/**
+ * PATCH /ds-devices/{serial_number}/note — D-S制御盤の備考を保存する
+ *
+ * 仕様の正は `d-watch-api.yaml` の /ds-devices/{serial_number}/note です。
+ * D-Secログ画面(F-06)の「備考」列に運用担当が入力したメモを保存します。
+ *
+ * ★保存先はメインDBではありません★
+ * `lib/settings/ds-device-notes.ts`(DFA独自のJSONストア)に書きます。
+ * メインDBはTeamWorks管理で、DFAが更新できるのは
+ * `robot_error_reports` / `control_error_histories` の4カラムだけです(CLAUDE.md 7)。
+ *
+ * 空文字(トリム後も含む)を送ると備考は削除され、レスポンスの `note` は null になります。
+ * 削除を別エンドポイントにしないのは、画面が「テキストを空にして保存」しか操作を持たず、
+ * 保存と削除を呼び分ける判断をフロントに持たせる意味が無いためです。
+ */
+import { NextResponse } from "next/server";
+import { isJsonObject } from "@/lib/api/json-body";
+import { parsePathSerialNumber } from "@/lib/api/path-params";
+import { DEVICE_NOTE_MAX_LENGTH } from "@/lib/config";
+import { db } from "@/lib/db";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { saveDeviceNote } from "@/lib/settings/ds-device-notes";
+import type { ApiError, ErrorCode } from "@/lib/types";
+
+const ROUTE = "/api/ds-devices/[serial_number]/note";
+
+function errorBody(
+ code: ErrorCode,
+ message: string,
+ details: Record | null = null
+): ApiError {
+ return { error: { code, message, details } };
+}
+
+function errorResponse(
+ status: number,
+ code: ErrorCode,
+ message: string,
+ details: Record | null = null
+) {
+ return NextResponse.json(errorBody(code, message, details), { status });
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ serial_number: string }> }
+) {
+ return withRequestId(request, async () => {
+ const { serial_number: serialNumberRaw } = await params;
+ // ★形式の検証を実在チェック(`db.dsDeviceExists()`)より前に置く★
+ // DBのカラム幅(`controls.serial_number` = varchar(100))に入りえない長さの入力を
+ // DBの照会まで運ぶ意味が無い。指示書AE 段階2-2 / D-123
+ // ★★検証順序(形式 → 長さ → 実在)を変えないこと★★(テスト #6 / #7 が固定している)
+ const parsedSerial = parsePathSerialNumber(serialNumberRaw);
+ if (!parsedSerial.ok) {
+ return NextResponse.json(parsedSerial.error, { status: parsedSerial.status });
+ }
+ const serialNumber = parsedSerial.query;
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return errorResponse(422, "validation_failed", "JSONの形式が不正です");
+ }
+ // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言)
+ if (!isJsonObject(body)) {
+ return errorResponse(422, "validation_failed", "JSONの形式が不正です");
+ }
+
+ const note = body.note;
+ if (typeof note !== "string") {
+ return errorResponse(
+ 422,
+ "validation_failed",
+ "noteは文字列で指定してください"
+ );
+ }
+
+ // 文字数はトリム前の入力そのもので数える。
+ // 画面の入力欄が数えるのもトリム前の文字数なので、
+ // ここでトリム後を見ると「画面上は上限内なのに保存が弾かれる」(逆も)ズレが出る
+ if (note.length > DEVICE_NOTE_MAX_LENGTH) {
+ return errorResponse(
+ 422,
+ "validation_failed",
+ "noteが長すぎます",
+ { note: [`${DEVICE_NOTE_MAX_LENGTH}文字以内で入力してください`] }
+ );
+ }
+
+ // 実在しないシリアル番号の備考を保存すると、どのデバイスからも参照されない
+ // 行がストアに残り続ける(ストアはメインDBを参照しないため自力で気付けない)ので、
+ // 保存前に制御盤マスタと突き合わせる。
+ //
+ // ★★`db.dsDevices()`(一覧)を呼ばないこと★★(指示書AH 段階2)—
+ // シリアル1件の実在確認のために全制御盤+全施設を読む必要は無く、
+ // 旧: ★無関係な1台の欠損(心拍0件)で例外が飛び、全台の備考保存が500になる★
+ // 経路も断てる(指示書BB で `dsDevices()` は心拍0件を例外にしなくなった。D-182)。★照合規則(ci 照合順序への対処。D-4)は `dsDeviceExists()` 側の正★。
+ let exists: boolean;
+ try {
+ exists = await db.dsDeviceExists(serialNumber);
+ } catch (e) {
+ const errorPayload = errorBody("internal_error", "データの取得に失敗しました");
+ logApiError({
+ route: ROUTE,
+ method: "PATCH",
+ body: errorPayload,
+ status: 500,
+ err: e,
+ });
+ return NextResponse.json(errorPayload, { status: 500 });
+ }
+
+ if (!exists) {
+ return errorResponse(404, "not_found", "デバイスが見つかりません");
+ }
+
+ let saved: string | null;
+ try {
+ saved = await saveDeviceNote(serialNumber, note);
+ } catch (e) {
+ const errorPayload = errorBody("internal_error", "備考の保存に失敗しました");
+ logApiError({
+ route: ROUTE,
+ method: "PATCH",
+ body: errorPayload,
+ status: 500,
+ err: e,
+ });
+ return NextResponse.json(errorPayload, { status: 500 });
+ }
+
+ // 保存後の値をエコーバックする。ストア側がトリム・削除を行うため、
+ // 画面はリクエストで送った文字列ではなくこの値で表示を更新する必要がある
+ return NextResponse.json({ serial_number: serialNumber, note: saved });
+ });
+}
diff --git a/app/api/ds-devices/route.ts b/app/api/ds-devices/route.ts
new file mode 100644
index 0000000..a92f2d2
--- /dev/null
+++ b/app/api/ds-devices/route.ts
@@ -0,0 +1,119 @@
+/**
+ * GET /ds-devices — D-Secログ(D-S制御盤稼働状況一覧+サマリ)
+ *
+ * 形は `app/api/prechecks/route.ts` と同じ。
+ * 仕様の正は `d-watch-api.yaml` の /ds-devices、ソート順は docs/decisions.md D-055。
+ */
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
+import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
+import {
+ filterDsDevicesByQ,
+ filterDsDevicesByStatus,
+ parseDsDeviceQuery,
+ sortDsDevices,
+ summarizeDsDevices,
+} from "@/lib/ds-devices/query";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { readDeviceNotes } from "@/lib/settings/ds-device-notes";
+import type { ApiError, DsDevice, SummaryListResponse } from "@/lib/types";
+import type { DsDeviceSummary } from "@/lib/ds-devices/query";
+import type { DeviceNotes } from "@/lib/settings/ds-device-notes";
+
+const ROUTE = "/api/ds-devices";
+
+function errorResponse(error: ApiError, status: number) {
+ return NextResponse.json(error, { status });
+}
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ const { searchParams } = new URL(request.url);
+ const parsed = parseDsDeviceQuery(searchParams);
+ if (!parsed.ok) {
+ return errorResponse(parsed.error, parsed.status);
+ }
+
+ let rows: DsDevice[];
+ let notes: DeviceNotes;
+ // ★分類(`group_id` / `customer_id`)→ 施設IDの配列★(指示書AK 段階4)。
+ // ★`null` = 絞らない / `[]` = 0件★(★罠1★。`?.length` で分岐しないこと)。
+ let facilityIds: number[] | null;
+ try {
+ // ★解決(施設マスタの読み取り)も同じ try に入れる★ — 失敗したら 500。
+ // ★黙って絞らずに返さないこと★(他社のデバイスが混ざったまま画面に出る)。
+ facilityIds = await resolveFacilityIdsByMasters(parsed.query);
+ rows = await db.dsDevices();
+ // ★備考の読み込みも同じ try に含める=失敗したら一覧全体を500にする★
+ // 「備考はおまけなので読めなくても200で返す」も一案だが、ここでは採らない:
+ // 1. `readDeviceNotes()` が例外を投げるのは**本物のサーバー障害のときだけ**。
+ // 未保存(ENOENT)は空で返り、JSON破損は `.bak` へ退避したうえで空が返る
+ // (`lib/settings/json-store.ts`)。つまりここまで例外が上がってくるのは
+ // 権限エラー・I/O障害・退避失敗といった、放置してはいけない状態に限られる。
+ // 2. 黙って備考なしで返すと、運用担当には「備考が消えた」としか見えず、
+ // 保存済みの文言を空欄と誤認して上書き(=実質削除)しかねない。
+ // 3. ログイベントは allowlist(`lib/logger/events.ts`)で、
+ // 「縮退して返した」を表すイベントは存在しない。`api.error` は
+ // 「catch節で処理してこのステータスを返した」という意味なので、
+ // 200を返しながらそれを出すとログとレスポンスがズレる。
+ // よって他のtry節と同じ形(失敗=500 + `api.error`)に揃え、一貫性を保つ。
+ notes = await readDeviceNotes();
+ } catch (e) {
+ const body: ApiError = {
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return errorResponse(body, 500);
+ }
+
+ // ★備考はDFA独自ストアの値を優先する★
+ // 独自ストア(`data/settings/ds-device-notes.json`)の値は
+ // **運用担当がこの画面から上書きした最新の備考**であり、fixtures/DB 側の `note` は
+ // 初期値・フォールバックにすぎない。逆にすると、保存したそばから
+ // 古い値に戻って見えることになる。
+ // (メインDBは読み取り専用で備考は書けないため、保存先は独自ストアしかない。
+ // CLAUDE.md §3 / §7)
+ // ★フィルタ・ソート・サマリより前にマージする★。今の summary / q は
+ // `note` を見ていないが、後から「備考でも検索する」等が入ったときに
+ // マージ前の古い値で絞り込む事故を、順序で構造的に防いでおく。
+ rows = rows.map((device) => ({
+ ...device,
+ note: notes[device.serial_number] ?? device.note,
+ }));
+
+ // ★★分類の絞り込みは summary より**前**にかける★★(指示書AK 段階4)—
+ // q / status は「見え方」の絞り込みなので summary に効かせない(D-054)が、
+ // ★分類は「母集団」そのものを選ぶ★ので ★summary にも効かせる★
+ // (「この屋号の異常◯件」が見たい。指示書AK §7-2 の数値カードと同じ理由)。
+ // ★★一覧と summary で母集団がずれないよう、必ず同じ配列から作ること★★。
+ // ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。
+ rows = filterRowsByFacilityIds(rows, facilityIds);
+
+ // ★summaryはq/statusフィルタの前(全件)に対して計算する(D-054と同じ理由)★
+ // フィルタ後に計算すると、既定の絞り込み状態によってサマリーが変わってしまう
+ const summary: DsDeviceSummary = summarizeDsDevices(rows);
+ const filtered = filterDsDevicesByStatus(
+ filterDsDevicesByQ(rows, parsed.query.q),
+ parsed.query.status
+ );
+ const sorted = sortDsDevices(filtered);
+
+ const { page, perPage } = parsed.query;
+ // ★meta.total と summary.total は意味が違う★
+ // meta.total = 一覧(q・statusフィルタ後)の総件数。ページング計算用
+ // summary.total = 全デバイス数(q・statusの影響を受けない)
+ // 絞り込みが無いとき(q="" かつ status=all)だけ一致する
+ const total = sorted.length;
+ const start = (page - 1) * perPage;
+ const items = sorted.slice(start, start + perPage);
+
+ const body: SummaryListResponse = {
+ summary,
+ items,
+ meta: { total, page, per_page: perPage },
+ };
+ return NextResponse.json(body);
+ });
+}
diff --git a/app/api/error-lists/__tests__/route.fixtures.test.ts b/app/api/error-lists/__tests__/route.fixtures.test.ts
new file mode 100644
index 0000000..b987ebf
--- /dev/null
+++ b/app/api/error-lists/__tests__/route.fixtures.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { GET } from "@/app/api/error-lists/route";
+import { errorLists } from "@/lib/fixtures/error-lists";
+import type { ErrorListsResponse } from "@/lib/types";
+
+/**
+ * `GET /error-lists`(`USE_FIXTURES=true`。指示書BD §3-1・§7)。
+ *
+ * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、
+ * 同じファイルで true/false の両方は試せない(`app/api/errors/__tests__/route.fixtures.test.ts` と同じ)。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+describe("GET /error-lists(USE_FIXTURES=true)", () => {
+ it("★決定稿の131行・id 1〜131 の昇順・meta {131, 1, 131}★", async () => {
+ const res = await GET(new Request("http://localhost/api/error-lists"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ErrorListsResponse;
+ expect(body.items).toHaveLength(131);
+ expect(body.items.map((i) => i.id)).toEqual(Array.from({ length: 131 }, (_, i) => i + 1));
+ expect(body.meta).toEqual({ total: 131, page: 1, per_page: 131 });
+ });
+
+ it("note_required は 131(その他)だけ・文字列は fixtures の値のまま", async () => {
+ const body = (await (await GET(new Request("http://localhost/api/error-lists"))).json()) as ErrorListsResponse;
+ expect(body.items.filter((i) => i.note_required).map((i) => i.id)).toEqual([131]);
+ const src = errorLists.find((r) => r.id === BigInt(40))!;
+ expect(body.items[39]).toEqual({
+ id: 40,
+ error_category_main_en: src.error_category_main_en,
+ error_category_main_ja: src.error_category_main_ja,
+ error_category_sub_en: src.error_category_sub_en,
+ error_category_sub_ja: src.error_category_sub_ja,
+ error_detail_en: src.error_detail_en,
+ error_detail_ja: src.error_detail_ja,
+ note_required: false,
+ });
+ expect(body.items[39].error_detail_ja).toBe("非常停止スイッチが作動しました");
+ });
+});
diff --git a/app/api/error-lists/__tests__/route.test.ts b/app/api/error-lists/__tests__/route.test.ts
new file mode 100644
index 0000000..ec30fa6
--- /dev/null
+++ b/app/api/error-lists/__tests__/route.test.ts
@@ -0,0 +1,162 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★ルート本体は静的 import★(`app/api/customers/__tests__/route.test.ts` と同じ理由)
+import { GET } from "@/app/api/error-lists/route";
+import { ERROR_LIST_SELECT } from "@/lib/db/error-list-source";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, ErrorListItem, ErrorListsResponse } from "@/lib/types";
+
+/**
+ * `GET /error-lists`(Prisma 経路。指示書BD §3-1)の期待値表。
+ *
+ * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定する
+ * (`app/api/customers/__tests__/route.test.ts` と同じ作法)。fixtures 経路は `route.fixtures.test.ts`。
+ *
+ * ★スタブの `errorList.findMany` は `where` / `orderBy` を★無視して★入力の順のまま全行返す★ —
+ * SQL の写し(`ACTIVE_ERROR_LIST_WHERE` / `orderBy`)が効かなかった場合でも、
+ * ★純粋関数(`activeErrorListItems()`)で有効な行だけ・id 昇順になる★ことを見るため。
+ * SQL 側の引数は別のテスト(#3)で固定する。
+ *
+ * | # | 入力(stub の行。並びはこの順) | 期待 |
+ * |----|------------------------------------------------------------------|------|
+ * | 1 | 131, 2, 10(inactive 1), 3(deleted_at あり), 1, 40 | items の id = [1, 2, 40, 131]・meta {4, 1, 4} |
+ * | 2 | 同上 | 131 だけ note_required true・文字列は DB の値のまま・inactive / deleted_at を出さない |
+ * | 3 | 同上 | findMany は1回・where {inactive: 0, deleted_at: null}・orderBy {id: "asc"}・select = ERROR_LIST_SELECT |
+ * | 4 | 0行 | items []・meta {0, 1, 0} |
+ * | 5 | findMany が例外 | 500 internal_error「データの取得に失敗しました」・logApiError 1回 |
+ */
+
+interface StubErrorList {
+ id: bigint;
+ error_category_main_en: string;
+ error_category_main_ja: string;
+ error_category_sub_en: string;
+ error_category_sub_ja: string;
+ error_detail_en: string;
+ error_detail_ja: string;
+ inactive: number;
+ deleted_at: Date | null;
+}
+
+let rows: StubErrorList[];
+let calls: Record[];
+let throwOnFindMany: boolean;
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
+
+vi.mock("@/lib/db/prisma", () => ({
+ getPrisma: async () => ({
+ errorList: {
+ findMany: async (args: Record) => {
+ calls.push(args);
+ if (throwOnFindMany) throw new Error("[stub] DB が落ちた");
+ return rows;
+ },
+ },
+ }),
+ getWritePrisma: async () => {
+ throw new Error("[stub] 選択肢の取得で書き込み用の接続を取ってはいけない");
+ },
+}));
+
+function listRow(id: number, overrides: Partial = {}): StubErrorList {
+ return {
+ id: BigInt(id),
+ error_category_main_en: `MAIN-EN-${id}`,
+ error_category_main_ja: `分類${id}`,
+ error_category_sub_en: `sub-en-${id}`,
+ error_category_sub_ja: `小分類${id}`,
+ error_detail_en: `detail-en-${id}`,
+ error_detail_ja: `詳細${id}`,
+ inactive: 0,
+ deleted_at: null,
+ ...overrides,
+ };
+}
+
+async function get(): Promise {
+ return GET(new Request("http://localhost/api/error-lists"));
+}
+
+beforeEach(() => {
+ rows = [
+ listRow(131, {
+ error_category_main_en: "OTHER ERROR",
+ error_category_main_ja: "その他(該当なし)",
+ error_category_sub_en: "other",
+ error_category_sub_ja: "その他(該当なし)",
+ error_detail_en: "There are no errors.",
+ error_detail_ja: "該当するエラーはありません",
+ }),
+ listRow(2),
+ listRow(10, { inactive: 1 }),
+ listRow(3, { deleted_at: new Date("2026-10-01T01:00:00Z") }),
+ listRow(1),
+ listRow(40),
+ ];
+ calls = [];
+ throwOnFindMany = false;
+ vi.mocked(logApiError).mockClear();
+});
+
+describe("GET /error-lists(Prisma 経路)", () => {
+ it("#1 ★有効な行だけ・id の昇順(SQL の where / orderBy が効かなくても)★・meta は total / page=1 / per_page=total", async () => {
+ const res = await get();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ErrorListsResponse;
+ expect(body.items.map((i) => i.id)).toEqual([1, 2, 40, 131]);
+ expect(body.meta).toEqual({ total: 4, page: 1, per_page: 4 });
+ });
+
+ it("#2 131 だけ note_required・文字列は DB の値のまま・inactive / deleted_at を出さない", async () => {
+ const body = (await (await get()).json()) as ErrorListsResponse;
+ expect(body.items.filter((i) => i.note_required).map((i) => i.id)).toEqual([131]);
+ const other: ErrorListItem = {
+ id: 131,
+ error_category_main_en: "OTHER ERROR",
+ error_category_main_ja: "その他(該当なし)",
+ error_category_sub_en: "other",
+ error_category_sub_ja: "その他(該当なし)",
+ error_detail_en: "There are no errors.",
+ error_detail_ja: "該当するエラーはありません",
+ note_required: true,
+ };
+ expect(body.items[3]).toEqual(other);
+ expect(body.items[0]).toEqual({
+ id: 1,
+ error_category_main_en: "MAIN-EN-1",
+ error_category_main_ja: "分類1",
+ error_category_sub_en: "sub-en-1",
+ error_category_sub_ja: "小分類1",
+ error_detail_en: "detail-en-1",
+ error_detail_ja: "詳細1",
+ note_required: false,
+ });
+ });
+
+ it("#3 ★findMany は1回・where は inactive 0 かつ deleted_at null・orderBy は id 昇順・select は ERROR_LIST_SELECT★", async () => {
+ await get();
+ expect(calls).toEqual([
+ { where: { inactive: 0, deleted_at: null }, orderBy: { id: "asc" }, select: ERROR_LIST_SELECT },
+ ]);
+ });
+
+ it("#4 0行 → items []・meta {0, 1, 0}(404 にしない)", async () => {
+ rows = [];
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ items: [], meta: { total: 0, page: 1, per_page: 0 } });
+ });
+
+ it("#5 DB の例外 → 500「データの取得に失敗しました」・logApiError 1回", async () => {
+ throwOnFindMany = true;
+ const res = await get();
+ expect(res.status).toBe(500);
+ expect((await res.json()) as ApiError).toEqual({
+ error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
+ });
+ expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ route: "/api/error-lists", method: "GET", status: 500 });
+ });
+});
diff --git a/app/api/error-lists/route.ts b/app/api/error-lists/route.ts
new file mode 100644
index 0000000..2c655da
--- /dev/null
+++ b/app/api/error-lists/route.ts
@@ -0,0 +1,46 @@
+/**
+ * GET /error-lists — エラー内容の選択肢(`error_lists`。エラー一覧のセレクトリスト用。指示書BD §3-1。D-184。読み取り専用)
+ *
+ * 形は `app/api/groups/route.ts` と同じ。仕様の正は `d-watch-api.yaml` の /error-lists。
+ *
+ * ★GET だけ★ — `error_lists` は先方が管理する表(管理側UIなし。ユーザー回答① 2026-10-27)。
+ * アプリが書くのは `robot_error_reports.error_list_id`(`PATCH /errors/{error_id}/status`)だけ。
+ *
+ * ★有効な行だけ・id の昇順★ — データ層(`db.errorLists()`。規則の正は `activeErrorListItems()`)が済ませて返す。
+ * ★`GET /groups` と違い `inactive` でも絞る★(無効化した選択肢を新たに選ばせない)。
+ * ★文字列は DB の値のまま★(`t()` に通さない・変換しない。D-077 と同じ)。
+ *
+ * ★認証はここに書かない★ — `proxy.ts` が全ルートを横断して判定する。
+ * ★ページングしない★(`GET /groups` と同じ。`meta` は `masterListResponse()`)。
+ */
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { masterListResponse } from "@/lib/masters/query";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { ApiError, ErrorListItem, ErrorListsResponse } from "@/lib/types";
+
+const ROUTE = "/api/error-lists";
+
+export async function GET(request: Request) {
+ return withRequestId(request, async () => {
+ // ★クエリパラメータを持たないので 422 の分岐が無い★(`app/api/groups/route.ts` と同じ)
+ let items: ErrorListItem[];
+ try {
+ items = await db.errorLists();
+ } catch (e) {
+ const body: ApiError = {
+ error: {
+ code: "internal_error",
+ message: "データの取得に失敗しました",
+ details: null,
+ },
+ };
+ logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
+ return NextResponse.json(body, { status: 500 });
+ }
+
+ const body: ErrorListsResponse = masterListResponse(items);
+ return NextResponse.json(body);
+ });
+}
diff --git "a/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts" "b/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts"
new file mode 100644
index 0000000..deaf997
--- /dev/null
+++ "b/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts"
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PATCH } from "@/app/api/errors/[error_id]/status/route";
+import { GET } from "@/app/api/errors/route";
+import { ERROR_LIST_NOTE_REQUIRED_IDS } from "@/lib/config";
+import { db } from "@/lib/db";
+import {
+ applyFixtureErrorReportPatch,
+ resetFixtureErrorReportOverrides,
+} from "@/lib/db/fixture-error-report-overrides";
+import { errorReportRows } from "@/lib/fixtures/error-reports";
+import type { ErrorReportItem, ListResponse } from "@/lib/types";
+
+/**
+ * `PATCH /errors/{error_id}/status` のエラー内容(`error_list_id`)を `USE_FIXTURES=true` で叩く(指示書BD §5-4・§7。D-184)。
+ *
+ * ★★何を守るか★★ — fixtures 版の上書き表(`lib/db/fixture-error-report-overrides.ts`)が `error_list_id` を写し忘れると、
+ * ★PATCH は 200 を返すのに GET に反映されない★(例外もテスト失敗も出ない)。PATCH → GET で確かめる。
+ *
+ * 行は `lib/fixtures/error-reports.ts`: FX-ERR-02(LowBattery・未選択)/ FX-ERR-05(★9999 = `error_lists` に無い id★)/
+ * FX-ERR-10(★D-S_CONTROL_FAILURE — 選べない★)/ FX-ERR-11(★NOT_STARTED・その他(131)+備考あり★)。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+const OTHER_ID = ERROR_LIST_NOTE_REQUIRED_IDS[0];
+
+beforeEach(() => {
+ resetFixtureErrorReportOverrides();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-10-06T08:15:00+09:00"));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ resetFixtureErrorReportOverrides();
+});
+
+async function listItem(id: string): Promise {
+ const res = await GET(new Request("http://localhost/api/errors?include_resolved=true"));
+ expect(res.status).toBe(200);
+ return ((await res.json()) as ListResponse).items.find((i) => i.id === id);
+}
+
+async function patch(id: string, body: unknown): Promise {
+ return PATCH(
+ new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ error_id: id }) }
+ );
+}
+
+describe("PATCH → GET(error_list_id・USE_FIXTURES=true)", () => {
+ it("★有効な id を書くと GET /errors にも新しい id と名前が出る★", async () => {
+ expect((await listItem("FX-ERR-02"))?.error_list_id).toBeNull();
+ const res = await patch("FX-ERR-02", { status: "in_progress", error_list_id: 40 });
+ expect(res.status).toBe(200);
+ expect((await res.json()) as ErrorReportItem).toMatchObject({ error_list_id: 40, error_list: { id: 40 } });
+
+ const after = await listItem("FX-ERR-02");
+ expect(after).toMatchObject({ error_list_id: 40, error_list: { id: 40 }, resolution_status: "in_progress" });
+ expect((await db.errorReportById("FX-ERR-02"))?.error_list_id).toBe(40);
+ });
+
+ it("★その他+備考 → 反映される★", async () => {
+ const res = await patch("FX-ERR-02", { status: "unresolved", error_list_id: OTHER_ID, note: "該当なし" });
+ expect(res.status).toBe(200);
+ expect(await listItem("FX-ERR-02")).toMatchObject({
+ error_list_id: OTHER_ID,
+ error_list: { note_required: true },
+ note: "該当なし",
+ });
+ });
+
+ it("★無い id(9999)が入った行で、同じ id を送って状態だけ変える → 200・id はそのまま★", async () => {
+ const res = await patch("FX-ERR-05", { status: "in_progress", error_list_id: 9999 });
+ expect(res.status).toBe(200);
+ expect(await listItem("FX-ERR-05")).toMatchObject({ error_list_id: 9999, error_list: null, resolution_status: "in_progress" });
+ });
+
+ it("422 の要求は何も変えない(選べない行・無効な id・その他で備考なし・その他の行で備考を消す)", async () => {
+ const cases: [string, unknown, string][] = [
+ ["FX-ERR-10", { status: "unresolved", error_list_id: 40 }, "error_list_id"],
+ ["FX-ERR-02", { status: "unresolved", error_list_id: 9999 }, "error_list_id"],
+ ["FX-ERR-02", { status: "unresolved", error_list_id: OTHER_ID }, "note"],
+ ["FX-ERR-11", { status: "unresolved", note: "" }, "note"],
+ ];
+ for (const [id, body, field] of cases) {
+ const before = await listItem(id);
+ const res = await patch(id, body);
+ expect(res.status).toBe(422);
+ expect(Object.keys(((await res.json()) as { error: { details: Record } }).error.details)).toEqual([
+ field,
+ ]);
+ expect(await listItem(id)).toEqual(before);
+ }
+ });
+
+ it("★上書き表は error_list_id を重ねる(後のパッチで変えなければ前の値のまま)★", async () => {
+ applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { error_list_id: 41 });
+ applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { status: "resolved", resolved_at: new Date() });
+ expect(await db.errorReportById("FX-ERR-02")).toMatchObject({ error_list_id: 41, resolution_status: "resolved" });
+ resetFixtureErrorReportOverrides();
+ expect((await db.errorReportById("FX-ERR-02"))?.error_list_id).toBeNull();
+ });
+});
diff --git "a/app/api/errors/\133error_id\135/status/__tests__/route.test.ts" "b/app/api/errors/\133error_id\135/status/__tests__/route.test.ts"
new file mode 100644
index 0000000..ad31884
--- /dev/null
+++ "b/app/api/errors/\133error_id\135/status/__tests__/route.test.ts"
@@ -0,0 +1,872 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由)
+import { PATCH } from "@/app/api/errors/[error_id]/status/route";
+import {
+ D_SEC_ALERT_ERROR_TYPES,
+ ERROR_LIST_NOTE_REQUIRED_IDS,
+ PRECHECK_MINUTES_BEFORE,
+ PRECHECK_NOTE_MAX_LENGTH,
+} from "@/lib/config";
+import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
+import { ROBOT_ERROR_LIST_ROW_SELECT } from "@/lib/db/robot-error-select";
+import { toDbJst } from "@/lib/format/date";
+import { logEvent } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import type { DbWriteLogEvent } from "@/lib/logger/events";
+import type { ErrorReportItem } from "@/lib/types";
+
+/**
+ * `PATCH /errors/{error_id}/status`(Prisma 経路。指示書AW 段階4-4-2)。
+ *
+ * `/prechecks/{id}/status` の同名テスト(`app/api/prechecks/[id]/status/__tests__/route.test.ts`)の describe を写し、
+ * 行を表1 にした。★違いは「`robot_error_reports` に行があれば書ける(事前チェック以外の行にも)」こと★(D-160 項2)。
+ *
+ * ★★DBには接続しません★★ — `getPrisma()`(参照用)と `getWritePrisma()`(書き込み用)をスタブに差し替え、
+ * `USE_FIXTURES` を `false` に固定して `prismaSource` の経路を走らせる。
+ * ★`updateMany` の `data` を記録し、★5列(4列+`error_list_id`。D-184)のうち該当分だけ★が渡っていることを見る★。
+ * (生成される SQL に `updated_at` が入らないことは `lib/db/__tests__/robot-error-write-sql.test.ts` が見る)
+ */
+
+// ---------------------------------------------------------------- スタブ(DBの代わり)
+
+interface StubTimetable {
+ id: bigint;
+ precheck: number;
+}
+
+interface StubErrorReport {
+ error_id: string;
+ /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
+ robot_id: bigint | null;
+ clean_timetable_id: bigint | null;
+ serial_number: string;
+ facility_id: bigint;
+ /** ★`deleted_at` は可視性(`isVisibleErrorRow()`。指示書AX U8・表5)★ */
+ facility: { facility_name: string; deleted_at: Date | null };
+ section: { deleted_at: Date | null };
+ error_type: string;
+ error_level: string;
+ errored_at: Date | null;
+ created_at: Date;
+ status: number;
+ responded_at: Date | null;
+ resolved_at: Date | null;
+ comment: string | null;
+ /** エラー内容(指示書BD。null = 未選択) */
+ error_list_id: bigint | null;
+}
+
+/** `error_lists` の行(選択肢の絞り込み `inactive = 0 AND deleted_at IS NULL` をスタブでも再現する) */
+interface StubErrorList {
+ id: bigint;
+ error_category_main_en: string;
+ error_category_main_ja: string;
+ error_category_sub_en: string;
+ error_category_sub_ja: string;
+ error_detail_en: string;
+ error_detail_ja: string;
+ inactive: number;
+ deleted_at: Date | null;
+}
+
+interface UpdateManyArgs {
+ where: { error_id: string };
+ data: Record;
+}
+
+type Args = { where: Record; select?: Record };
+
+let timetables: StubTimetable[];
+let historyTimetables: StubTimetable[];
+let errorReports: StubErrorReport[];
+let errorListRows: StubErrorList[];
+/** `errorList.findMany` の `where` の記録(選択肢を引いたか・名前解決か) */
+let errorListWheres: Record[];
+let updateCalls: UpdateManyArgs[];
+/** `robotErrorReport.findUnique` の `select` の記録 */
+let findUniqueSelects: (Record | undefined)[];
+/** 次の `updateMany` の振る舞い(既定は「一致した行に data を当てる」) */
+let updateBehavior: "apply" | "throw" | "no_match";
+
+/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */
+function ciEqual(a: string, b: string): boolean {
+ return a.toLowerCase() === b.toLowerCase();
+}
+
+/** `select` で返す列を絞る(入れ子の `{ select }` にも対応) */
+function project(row: Record, select: Record | undefined): Record {
+ if (!select) return row;
+ const out: Record = {};
+ for (const [key, value] of Object.entries(select)) {
+ if (value === true) out[key] = row[key];
+ else if (value && typeof value === "object" && "select" in value) {
+ out[key] = project(row[key] as Record, (value as { select: Record }).select);
+ }
+ }
+ return out;
+}
+
+class FakePrismaKnownError extends Error {
+ code = "P2039";
+ meta = {
+ driverAdapterError: {
+ cause: {
+ originalCode: "1143",
+ originalMessage: "UPDATE command denied to user 'secret-db-user'@'host' for column 'updated_at'",
+ kind: "mysql",
+ },
+ },
+ };
+}
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ // ★`withRequestId` はそのまま使う★。見たいのは `logEvent`(db.write)だけ
+ return { ...actual, logEvent: vi.fn() };
+});
+
+vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
+
+vi.mock("@/lib/db/prisma", () => {
+ const read = {
+ robotErrorReport: {
+ findUnique: async (args: Args) => {
+ findUniqueSelects.push(args.select);
+ // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★
+ guardNullRobotId(errorReports as unknown as Record[], args);
+ const found = errorReports.find((r) => ciEqual(r.error_id, args.where.error_id as string));
+ return found ? project(found as unknown as Record, args.select) : null;
+ },
+ updateMany: async () => {
+ throw new Error("[stub] 参照用の接続で updateMany を呼んではいけない");
+ },
+ },
+ // ★名前解決(`id IN`)と選択肢(`inactive = 0 AND deleted_at IS NULL`)の両方を `where` どおりに絞る★
+ errorList: {
+ findMany: async (args: { where: Record }) => {
+ errorListWheres.push(args.where);
+ const where = args.where as {
+ id?: { in: bigint[] };
+ inactive?: number;
+ deleted_at?: null;
+ };
+ return errorListRows.filter(
+ (row) =>
+ (where.id === undefined || where.id.in.includes(row.id)) &&
+ (where.inactive === undefined || row.inactive === where.inactive) &&
+ (!("deleted_at" in where) || row.deleted_at === where.deleted_at)
+ );
+ },
+ },
+ // ★予定は本表 → 無ければ履歴の順に id で引く(`findTimetableById()`。指示書AU 段階3b-2 の 5)★
+ cleanTimetable: {
+ findUnique: async (args: Args) => timetables.find((t) => t.id === args.where.id) ?? null,
+ },
+ cleanTimetableHistory: {
+ findUnique: async (args: Args) => historyTimetables.find((t) => t.id === args.where.id) ?? null,
+ },
+ };
+ const write = {
+ robotErrorReport: {
+ updateMany: async (args: UpdateManyArgs) => {
+ updateCalls.push(args);
+ if (updateBehavior === "throw") throw new FakePrismaKnownError("Database error");
+ if (updateBehavior === "no_match") return { count: 0 };
+ const rows = errorReports.filter((r) => ciEqual(r.error_id, args.where.error_id));
+ for (const row of rows) Object.assign(row, args.data);
+ return { count: rows.length };
+ },
+ },
+ };
+ return { getPrisma: async () => read, getWritePrisma: async () => write };
+});
+
+// ---------------------------------------------------------------- 合成データ(表1 の行)
+
+/** `+09:00` 付きの ISO → DB の壁時計値の `Date`(Prisma が返す形) */
+const db = (iso: string): Date => toDbJst(new Date(iso));
+
+const NOW_ISO = "2026-10-06T08:15:00+09:00";
+const T1_ISO = "2026-10-06T07:00:00+09:00";
+const T2_ISO = "2026-10-06T07:30:00+09:00";
+
+function errorReport(
+ errorId: string,
+ timetableId: number | null,
+ overrides: Partial = {}
+): StubErrorReport {
+ return {
+ error_id: errorId,
+ robot_id: BigInt(501),
+ clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
+ serial_number: "PD-CC1-0001",
+ facility_id: BigInt(1),
+ // ★実在の店名を使わない★(`CLAUDE.md` §7)
+ facility: { facility_name: "テスト施設A", deleted_at: null },
+ section: { deleted_at: null },
+ error_type: "EmergencyStop",
+ error_level: "ERROR",
+ errored_at: db("2026-10-05T21:00:00+09:00"),
+ created_at: db("2026-10-05T21:01:00+09:00"),
+ status: 0,
+ responded_at: null,
+ resolved_at: null,
+ comment: null,
+ error_list_id: null,
+ ...overrides,
+ };
+}
+
+/** `error_lists` の1行(★文字列はダミー★) */
+function errorList(id: number, overrides: Partial = {}): StubErrorList {
+ return {
+ id: BigInt(id),
+ error_category_main_en: `MAIN ${id}`,
+ error_category_main_ja: `分類${id}`,
+ error_category_sub_en: `sub ${id}`,
+ error_category_sub_ja: `小分類${id}`,
+ error_detail_en: `detail ${id}`,
+ error_detail_ja: `詳細${id}`,
+ inactive: 0,
+ deleted_at: null,
+ ...overrides,
+ };
+}
+
+/** 備考必須の選択肢(その他) */
+const OTHER_ID = ERROR_LIST_NOTE_REQUIRED_IDS[0];
+
+function buildData() {
+ errorListRows = [
+ errorList(40),
+ errorList(41),
+ errorList(OTHER_ID),
+ // ★無効化済み・削除済み(選択肢に出ない。名前解決はする)★
+ errorList(7, { inactive: 1 }),
+ errorList(8, { deleted_at: db("2026-10-01T10:00:00+09:00") }),
+ ];
+ timetables = [
+ { id: BigInt(100), precheck: 0 }, // 本清掃
+ { id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[1] }, // 事前チェック(30分前)
+ ];
+ historyTimetables = [];
+ errorReports = [
+ // E1: 本清掃に紐づく(★/prechecks では404だった行★)
+ errorReport("E-1", 100),
+ // E2: 事前チェックに紐づく・対応中
+ errorReport("E-2", 101, { status: 1, responded_at: db(T1_ISO), comment: "既存の備考" }),
+ // E3: 解消済み・本清掃
+ errorReport("E-3", 100, { status: 2, responded_at: db(T1_ISO), resolved_at: db(T2_ISO) }),
+ // E4: 予定なし
+ errorReport("E-4", null),
+ // E5: 予定不明(どの予定表にも無い id)
+ errorReport("E-5", 999),
+ // E8: robot_id NULL・serial_number '0'・SECURITY_ON
+ errorReport("E-8", 100, { robot_id: null, serial_number: "0", error_type: "SECURITY_ON" }),
+ ];
+}
+
+async function patch(id: string, body: unknown, raw = false): Promise {
+ return PATCH(
+ new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, {
+ method: "PATCH",
+ body: raw ? (body as string) : JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ error_id: id }) }
+ );
+}
+
+function dbWriteLogs(): { level: string; payload: DbWriteLogEvent }[] {
+ return vi
+ .mocked(logEvent)
+ .mock.calls.filter(([, payload]) => payload.event === "db.write")
+ .map(([level, payload]) => ({ level, payload: payload as DbWriteLogEvent }));
+}
+
+beforeEach(() => {
+ buildData();
+ updateCalls = [];
+ errorListWheres = [];
+ findUniqueSelects = [];
+ updateBehavior = "apply";
+ vi.mocked(logEvent).mockClear();
+ vi.mocked(logApiError).mockClear();
+ // ★Date だけを止める★(`now` = 2026-10-06 08:15 JST)
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(NOW_ISO));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------- 200
+
+describe("200 — 事前チェック以外の行にも書ける(D-160 項2)", () => {
+ it.each([
+ ["E1(本清掃)", "E-1", "main"],
+ ["E4(予定なし)", "E-4", "none"],
+ ["E5(予定不明)", "E-5", "unknown"],
+ ["E8(robot_id NULL)", "E-8", "main"],
+ ["E2(事前チェック)", "E-2", "precheck"],
+ ] as const)("%s → 200。updateMany の data は該当の列だけ", async (_label, id, link) => {
+ const res = await patch(id, { status: "resolved" });
+ expect(res.status).toBe(200);
+ expect(updateCalls).toHaveLength(1);
+ expect(updateCalls[0].where).toEqual({ error_id: id });
+ const expectedColumns = id === "E-2" ? ["resolved_at", "status"] : ["resolved_at", "responded_at", "status"];
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(expectedColumns);
+ const body = (await res.json()) as ErrorReportItem;
+ expect(body).toMatchObject({ id, schedule_link: link, resolution_status: "resolved", resolved_at: NOW_ISO });
+ });
+
+ it("★読み取りは ROBOT_ERROR_LIST_ROW_SELECT(robot_id / robot を読まない)★", async () => {
+ await patch("E-8", { status: "in_progress" });
+ expect(findUniqueSelects.length).toBeGreaterThan(0);
+ for (const select of findUniqueSelects) expect(select).toEqual(ROBOT_ERROR_LIST_ROW_SELECT);
+ });
+
+ it("E8 の生値(シリアル '0'・種類 SECURITY_ON)をそのまま返す", async () => {
+ const res = await patch("E-8", { status: "in_progress" });
+ expect((await res.json()) as ErrorReportItem).toMatchObject({ serial_number: "0", error_type: "SECURITY_ON" });
+ });
+
+ it("★予定が履歴にだけある行(E6 相当)も書け、本清掃と出る★", async () => {
+ historyTimetables = [{ id: BigInt(106), precheck: 0 }];
+ errorReports.push(errorReport("E-6", 106));
+ const res = await patch("E-6", { status: "in_progress" });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as ErrorReportItem).schedule_link).toBe("main");
+ });
+});
+
+describe("200 — 状態の遷移(AT §3-4)", () => {
+ it("#1 未解消 → 対応中: status と responded_at だけを書き、更新後の行を返す", async () => {
+ const res = await patch("E-1", { status: "in_progress" });
+ expect(res.status).toBe(200);
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]);
+ expect(updateCalls[0].data.status).toBe(1);
+ // ★`toDbJst()` が通っている★ — 08:15 JST は壁時計値 "2026-10-06T08:15:00Z" として渡る
+ expect((updateCalls[0].data.responded_at as Date).toISOString()).toBe("2026-10-06T08:15:00.000Z");
+ const body = (await res.json()) as ErrorReportItem;
+ expect(body).toMatchObject({
+ id: "E-1",
+ resolution_status: "in_progress",
+ responded_at: NOW_ISO,
+ resolved_at: null,
+ note: null,
+ });
+ });
+
+ it("#2 未解消 → 解消: status / responded_at / resolved_at を書く", async () => {
+ const res = await patch("E-4", { status: "resolved" });
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "responded_at", "status"]);
+ expect((await res.json()) as ErrorReportItem).toMatchObject({
+ resolution_status: "resolved",
+ responded_at: NOW_ISO,
+ resolved_at: NOW_ISO,
+ });
+ });
+
+ it("#4 対応中 → 解消: responded_at は変えない", async () => {
+ const res = await patch("E-2", { status: "resolved" });
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "status"]);
+ expect((await res.json()) as ErrorReportItem).toMatchObject({
+ resolution_status: "resolved",
+ responded_at: T1_ISO,
+ resolved_at: NOW_ISO,
+ });
+ });
+
+ it("#8 解消 → 未解消: 両方の日時を null にする(戻すと消す)", async () => {
+ const res = await patch("E-3", { status: "unresolved" });
+ expect(updateCalls[0].data).toEqual({ status: 0, responded_at: null, resolved_at: null });
+ expect((await res.json()) as ErrorReportItem).toMatchObject({
+ resolution_status: "unresolved",
+ responded_at: null,
+ resolved_at: null,
+ });
+ });
+
+ it("#9 解消 → 対応中: resolved_at だけを消す", async () => {
+ await patch("E-3", { status: "in_progress" });
+ expect(updateCalls[0].data).toEqual({ status: 1, resolved_at: null });
+ });
+
+ it("備考だけ変える(現在の status を送る): comment だけを書く・前後の空白を除く", async () => {
+ const res = await patch("E-2", { status: "in_progress", note: " 店長に確認 " });
+ expect(updateCalls[0].data).toEqual({ comment: "店長に確認" });
+ expect(((await res.json()) as ErrorReportItem).note).toBe("店長に確認");
+ });
+
+ it("備考を空にする → comment = null", async () => {
+ const res = await patch("E-2", { status: "in_progress", note: " " });
+ expect(updateCalls[0].data).toEqual({ comment: null });
+ expect(((await res.json()) as ErrorReportItem).note).toBeNull();
+ });
+
+ it("★空パッチなら updateMany を呼ばず、現在の値を返す。db.write も出さない★", async () => {
+ for (const [id, body] of [
+ ["E-1", { status: "unresolved" }],
+ ["E-2", { status: "in_progress", note: " 既存の備考 " }],
+ ["E-3", { status: "resolved" }],
+ ] as const) {
+ const res = await patch(id, body);
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as ErrorReportItem).id).toBe(id);
+ }
+ expect(updateCalls).toHaveLength(0);
+ expect(dbWriteLogs()).toHaveLength(0);
+ });
+
+ it("★本文に updated_at・error_type・memo を混ぜても data に出ない★", async () => {
+ const res = await patch("E-1", {
+ status: "in_progress",
+ note: "a",
+ updated_at: "2000-01-01T00:00:00Z",
+ error_type: "Hacked",
+ memo: "旧名",
+ comment: "直接",
+ serial_number: "X",
+ });
+ expect(res.status).toBe(200);
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["comment", "responded_at", "status"]);
+ expect(updateCalls[0].data.comment).toBe("a");
+ });
+});
+
+// ---------------------------------------------------------------- 422
+
+describe("422 — 入力の形式", () => {
+ it.each([
+ ["id が空", "", { status: "resolved" }, false],
+ ["id が51文字", "A".repeat(51), { status: "resolved" }, false],
+ ["JSON でない", "E-1", "{not json", true],
+ ["status が enum 外", "E-1", { status: "done" }, false],
+ ["status が無い", "E-1", { note: "a" }, false],
+ ["note が数値", "E-1", { status: "resolved", note: 1 }, false],
+ [`note が${PRECHECK_NOTE_MAX_LENGTH + 1}文字`, "E-1", { status: "resolved", note: "a".repeat(PRECHECK_NOTE_MAX_LENGTH + 1) }, false],
+ ] as const)("%s → 422(書き込まない)", async (_label, id, body, raw) => {
+ const res = await patch(id, body, raw);
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation_failed");
+ expect(updateCalls).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------- 404 / 500
+
+describe("404 — 行が無い", () => {
+ it.each([
+ ["行が無い", "E-NOT-EXIST"],
+ // ★DB は ci で当たるが JS の === で弾く★(D-4。別の文字列で同じ行を書き換えさせない)
+ ["大小文字だけ違う id", "e-1"],
+ ])("%s → 404「エラーが見つかりません」(書き込まない)", async (_label, id) => {
+ const res = await patch(id, { status: "resolved" });
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: { code: "not_found", message: "エラーが見つかりません", details: null },
+ });
+ expect(updateCalls).toHaveLength(0);
+ expect(dbWriteLogs()).toHaveLength(0);
+ });
+
+ it("★matched === 0(確認と書き込みの間に行が消えた)→ 404。db.write は success: false / no_matching_row★", async () => {
+ updateBehavior = "no_match";
+ const res = await patch("E-1", { status: "in_progress" });
+ expect(res.status).toBe(404);
+ const logs = dbWriteLogs();
+ expect(logs).toHaveLength(1);
+ expect(logs[0].level).toBe("error");
+ expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "no_matching_row" });
+ });
+
+ it("★書き込みの例外 → 500。db.write(success: false)と api.error が1件ずつ。コードだけを載せる★", async () => {
+ updateBehavior = "throw";
+ const res = await patch("E-1", { status: "in_progress" });
+ expect(res.status).toBe(500);
+ const logs = dbWriteLogs();
+ expect(logs).toHaveLength(1);
+ expect(logs[0].level).toBe("error");
+ expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "P2039:1143" });
+ // ★エラーメッセージ(DB のユーザー名が入る)を載せない★
+ expect(JSON.stringify(logs[0].payload)).not.toContain("secret-db-user");
+ // ★db.write には err を渡さない★
+ const dbWriteCall = vi.mocked(logEvent).mock.calls.find(([, p]) => p.event === "db.write");
+ expect((dbWriteCall as unknown[] | undefined)?.length).toBe(2);
+ expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
+ });
+
+ it("★status が 0/1/2 以外の行 → 404★(指示書BB U3・表3 S4。旧: 500(fail-loud。AW §11 Q3)を上書き)", async () => {
+ errorReports.push(errorReport("E-ODD", 100, { status: 3 }));
+ const res = await patch("E-ODD", { status: "resolved" });
+ expect(res.status).toBe(404);
+ // ★書き込まない★(一覧に出ない行は404。`updateMany` を呼ばない)
+ expect(updateCalls).toHaveLength(0);
+ });
+});
+
+/**
+ * ★表5(削除済みの施設・清掃エリア。指示書AX U8・D12。§11 Q9 で監督確認済み)の PATCH 列★。
+ * ★404 は `db.errorReportById()` が null を返すことで出る★(存在しない id の404 と同じ扱い)。
+ * ★削除の日時は過去の値★ — 「未来の日時なら生きている」とはしない(NULL か否かだけ。D12)。
+ */
+describe("表5 — 削除済みの施設・清掃エリアの行は 404(指示書AX U8)", () => {
+ const DELETED_AT = db("2026-10-01T10:00:00+09:00");
+ const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0];
+ const dsAlert = (errorId: string, overrides: Partial) =>
+ errorReport(errorId, null, { robot_id: null, serial_number: "DFAS010001", error_type: DS_ALERT_TYPE, ...overrides });
+
+ beforeEach(() => {
+ errorReports.push(
+ // V1: 両方生きている・ロボットのエラー
+ errorReport("V-1", 100),
+ // V2: 施設が削除済み
+ errorReport("V-2", 100, { facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }),
+ // V3: 清掃エリアが削除済み
+ errorReport("V-3", 100, { section: { deleted_at: DELETED_AT } }),
+ // V4: 両方生きている・D-Sec
+ dsAlert("V-4", {}),
+ // V5: 施設が削除済み・D-Sec
+ dsAlert("V-5", { facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }),
+ // V6: 清掃エリアが削除済み・D-Sec・対応中
+ dsAlert("V-6", { status: 1, responded_at: db(T1_ISO), section: { deleted_at: DELETED_AT } }),
+ // V7: 両方削除済み・D-Sec・解消
+ dsAlert("V-7", {
+ status: 2,
+ responded_at: db(T1_ISO),
+ resolved_at: db(T2_ISO),
+ facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT },
+ section: { deleted_at: DELETED_AT },
+ })
+ );
+ });
+
+ it.each(["V-2", "V-3", "V-5", "V-6", "V-7"])(
+ "%s → 404「エラーが見つかりません」・updateMany を呼ばない・db.write を出さない",
+ async (id) => {
+ const res = await patch(id, { status: "resolved" });
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: { code: "not_found", message: "エラーが見つかりません", details: null },
+ });
+ expect(updateCalls).toHaveLength(0);
+ expect(dbWriteLogs()).toHaveLength(0);
+ }
+ );
+
+ it.each(["V-1", "V-4"])("%s(両方生きている)→ 200", async (id) => {
+ const res = await patch(id, { status: "in_progress" });
+ expect(res.status).toBe(200);
+ expect(updateCalls).toHaveLength(1);
+ expect(((await res.json()) as ErrorReportItem).id).toBe(id);
+ });
+
+ it("★読み取りの select が施設・清掃エリアの deleted_at を含む(可視性の判定に要る)★", async () => {
+ await patch("V-2", { status: "resolved" });
+ expect(findUniqueSelects[0]).toMatchObject({
+ facility: { select: { deleted_at: true } },
+ section: { select: { deleted_at: true } },
+ });
+ });
+});
+
+// ---------------------------------------------------------------- 監査ログ
+
+describe("db.write", () => {
+ it("★成功で1件。route は /api/errors/[error_id]/status・record_id は文字列・columns は実際に SET した列だけ・備考の中身を含まない★", async () => {
+ const res = await patch("E-4", { status: "in_progress", note: "秘密の備考メモ" });
+ expect(res.status).toBe(200);
+ const logs = dbWriteLogs();
+ expect(logs).toHaveLength(1);
+ expect(logs[0].level).toBe("info");
+ expect(logs[0].payload).toEqual({
+ event: "db.write",
+ route: "/api/errors/[error_id]/status",
+ method: "PATCH",
+ table: "robot_error_reports",
+ record_id: "E-4",
+ columns: ["status", "comment", "responded_at"],
+ status_old: "unresolved",
+ status_new: "in_progress",
+ responded_at_old: null,
+ responded_at_new: NOW_ISO,
+ resolved_at_old: null,
+ resolved_at_new: null,
+ error_list_id_old: null,
+ error_list_id_new: null,
+ comment_changed: true,
+ success: true,
+ });
+ expect(typeof logs[0].payload.record_id).toBe("string");
+ expect(JSON.stringify(logs[0].payload)).not.toContain("秘密の備考メモ");
+ });
+
+ it("状態だけの変更では comment_changed: false・columns に comment が無い", async () => {
+ await patch("E-3", { status: "unresolved" });
+ const [log] = dbWriteLogs();
+ expect(log.payload).toMatchObject({
+ columns: ["status", "responded_at", "resolved_at"],
+ status_old: "resolved",
+ status_new: "unresolved",
+ responded_at_old: T1_ISO,
+ responded_at_new: null,
+ resolved_at_old: T2_ISO,
+ resolved_at_new: null,
+ comment_changed: false,
+ });
+ });
+});
+
+// ---------------------------------------------------------------- エラー内容(error_list_id。指示書BD §3-3。D-184)
+
+/**
+ * ★手順の順(1 同じ id → 検証しない / 2 選べない行 → 422 / 3 有効な選択肢に無い → 422 / 4 適用後に備考必須で備考なし → 422)★。
+ * ★422 では書き込まない・db.write を出さない★。
+ */
+describe("error_list_id(エラー内容)", () => {
+ const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0];
+
+ beforeEach(() => {
+ errorReports.push(
+ // L-1: 未選択・ロボットのエラー(選べる)
+ errorReport("L-1", 100),
+ // L-2: 有効な id(40)が入っている
+ errorReport("L-2", 100, { error_list_id: BigInt(40) }),
+ // L-3: ★無効化済みの id(7)が入っている★
+ errorReport("L-3", 100, { error_list_id: BigInt(7) }),
+ // L-4: ★その他(備考必須)+備考あり★
+ errorReport("L-4", 100, { error_list_id: BigInt(OTHER_ID), comment: "その他の理由" }),
+ // L-5: ★制御盤のエラー(選べない)★
+ errorReport("L-5", null, { robot_id: null, serial_number: "DFAS010001", error_type: DS_ALERT_TYPE }),
+ // L-6: ★清掃開始失敗(選べる)★
+ errorReport("L-6", 100, { robot_id: null, serial_number: "---", error_type: "NOT_STARTED" })
+ );
+ });
+
+ it("★有効な id → 200。updateMany の data は error_list_id(bigint)だけ・応答に新3項目★", async () => {
+ const res = await patch("L-1", { status: "unresolved", error_list_id: 40 });
+ expect(res.status).toBe(200);
+ expect(updateCalls).toHaveLength(1);
+ expect(updateCalls[0].data).toEqual({ error_list_id: BigInt(40) });
+ const body = (await res.json()) as ErrorReportItem;
+ expect(body).toMatchObject({ id: "L-1", error_list_id: 40, error_list_selectable: true });
+ expect(body.error_list).toMatchObject({ id: 40, error_detail_ja: "詳細40", note_required: false });
+ });
+
+ it("★清掃開始失敗(NOT_STARTED)の行も選べる★", async () => {
+ const res = await patch("L-6", { status: "unresolved", error_list_id: 41 });
+ expect(res.status).toBe(200);
+ expect(updateCalls[0].data).toEqual({ error_list_id: BigInt(41) });
+ });
+
+ it("状態と一緒に変える → 3列(status・responded_at・error_list_id)", async () => {
+ const res = await patch("L-2", { status: "in_progress", error_list_id: 41 });
+ expect(res.status).toBe(200);
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["error_list_id", "responded_at", "status"]);
+ });
+
+ it("★現在値と同じ id だけ → 空パッチ(書かない・db.write も出さない・選択肢を引かない)★", async () => {
+ const res = await patch("L-2", { status: "unresolved", error_list_id: 40 });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as ErrorReportItem).error_list_id).toBe(40);
+ expect(updateCalls).toHaveLength(0);
+ expect(dbWriteLogs()).toHaveLength(0);
+ // 名前解決の `id IN` は撃つが、選択肢(inactive / deleted_at の条件)は引かない
+ expect(errorListWheres.some((w) => "inactive" in w)).toBe(false);
+ });
+
+ it("★無効化済みの id が入った行で、同じ id を送って状態だけ変える → 200(手順1)★", async () => {
+ const res = await patch("L-3", { status: "in_progress", error_list_id: 7 });
+ expect(res.status).toBe(200);
+ expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]);
+ const body = (await res.json()) as ErrorReportItem;
+ // ★名前解決は絞らない★(無効化済みでも名前が出る)
+ expect(body).toMatchObject({ error_list_id: 7, error_list: { id: 7 } });
+ });
+
+ it("★id を省略して状態だけ変える → error_list_id を書かない★", async () => {
+ await patch("L-2", { status: "resolved" });
+ expect(updateCalls[0].data).not.toHaveProperty("error_list_id");
+ });
+
+ it("★その他+備考 → 200(2列)★", async () => {
+ const res = await patch("L-1", { status: "unresolved", error_list_id: OTHER_ID, note: "該当なしの理由" });
+ expect(res.status).toBe(200);
+ expect(updateCalls[0].data).toEqual({ comment: "該当なしの理由", error_list_id: BigInt(OTHER_ID) });
+ expect(((await res.json()) as ErrorReportItem).error_list).toMatchObject({ id: OTHER_ID, note_required: true });
+ });
+
+ it("その他+備考ありの行で状態だけ変える → 200", async () => {
+ const res = await patch("L-4", { status: "in_progress" });
+ expect(res.status).toBe(200);
+ });
+
+ it("★その他・備考が空(null / 空白だけ)の行で状態だけ変える → 200(手順4は見ない。監督判断 2026-10-27)★", async () => {
+ errorReports.push(
+ errorReport("L-7", 100, { error_list_id: BigInt(OTHER_ID), comment: null }),
+ errorReport("L-8", 100, { error_list_id: BigInt(OTHER_ID), comment: " " })
+ );
+ for (const id of ["L-7", "L-8"]) {
+ const res = await patch(id, { status: "in_progress" });
+ expect(res.status).toBe(200);
+ }
+ expect(updateCalls.map((c) => Object.keys(c.data).sort())).toEqual([
+ ["responded_at", "status"],
+ ["responded_at", "status"],
+ ]);
+ });
+
+ it("★既存の備考が空白だけの行でその他を選ぶ → 422 details.note(空白だけは空とみなす)★", async () => {
+ errorReports.push(errorReport("L-9", 100, { comment: " " }));
+ const res = await patch("L-9", { status: "unresolved", error_list_id: OTHER_ID });
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { details: unknown } }).error.details).toEqual({
+ note: ["このエラー内容を選んだときは備考が必須です"],
+ });
+ expect(updateCalls).toHaveLength(0);
+ });
+
+ it("★選べない行(その他が入っている)で備考を消す → 200(手順4を見ない)★", async () => {
+ errorReports.push(
+ errorReport("L-10", null, {
+ robot_id: null,
+ serial_number: "DFAS010001",
+ error_type: DS_ALERT_TYPE,
+ error_list_id: BigInt(OTHER_ID),
+ comment: "a",
+ })
+ );
+ const res = await patch("L-10", { status: "unresolved", note: "" });
+ expect(res.status).toBe(200);
+ expect(updateCalls[0].data).toEqual({ comment: null });
+ });
+
+ describe("422(書き込まない・db.write を出さない)", () => {
+ afterEach(() => {
+ expect(updateCalls).toHaveLength(0);
+ expect(dbWriteLogs()).toHaveLength(0);
+ });
+
+ it.each([
+ ["null", null],
+ ["0", 0],
+ ["負数", -3],
+ ["小数", 40.5],
+ ["文字列", "40"],
+ ])("型違い(%s)→ 422・details は null", async (_label, value) => {
+ const res = await patch("L-1", { status: "unresolved", error_list_id: value });
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "validation_failed",
+ message: "error_list_idは1以上の整数で指定してください",
+ details: null,
+ },
+ });
+ });
+
+ it("★選べない行(D-S_CONTROL_FAILURE・SECURITY_ON)→ 422 details.error_list_id(手順2)★", async () => {
+ for (const id of ["L-5", "E-8"]) {
+ const res = await patch(id, { status: "unresolved", error_list_id: 40 });
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "validation_failed",
+ message: "この行ではエラー内容を選べません",
+ details: { error_list_id: ["この行ではエラー内容を選べません"] },
+ },
+ });
+ }
+ // ★選べない行では選択肢を引かない★(手順2で止まる)
+ expect(errorListWheres.some((w) => "inactive" in w)).toBe(false);
+ });
+
+ it.each([
+ ["error_lists に無い id", 9999],
+ ["inactive ≠ 0", 7],
+ ["deleted_at が非 NULL", 8],
+ ])("%s → 422 details.error_list_id(手順3)", async (_label, value) => {
+ const res = await patch("L-1", { status: "unresolved", error_list_id: value });
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "validation_failed",
+ message: "error_list_idの値が不正です",
+ details: { error_list_id: ["有効なエラー内容を指定してください"] },
+ },
+ });
+ // ★選択肢は `inactive = 0 AND deleted_at IS NULL` で引いている★
+ expect(errorListWheres).toContainEqual({ inactive: 0, deleted_at: null });
+ });
+
+ it("★その他を選んで備考を書かない → 422 details.note(手順4)★", async () => {
+ const res = await patch("L-1", { status: "unresolved", error_list_id: OTHER_ID });
+ expect(res.status).toBe(422);
+ expect(await res.json()).toEqual({
+ error: {
+ code: "validation_failed",
+ message: "備考を入力してください",
+ details: { note: ["このエラー内容を選んだときは備考が必須です"] },
+ },
+ });
+ });
+
+ it("★既にその他の行で備考を消す → 422 details.note(id を送らなくても・同じ id を送っても)★", async () => {
+ for (const body of [
+ { status: "unresolved", note: "" },
+ { status: "unresolved", note: " ", error_list_id: OTHER_ID },
+ ]) {
+ const res = await patch("L-4", body);
+ expect(res.status).toBe(422);
+ expect(((await res.json()) as { error: { details: unknown } }).error.details).toEqual({
+ note: ["このエラー内容を選んだときは備考が必須です"],
+ });
+ }
+ });
+ });
+});
+
+describe("db.write — error_list_id の前後", () => {
+ it("★変えたとき: columns に error_list_id・error_list_id_old / _new は id の数値だけ★", async () => {
+ errorReports.push(errorReport("L-2", 100, { error_list_id: BigInt(40) }));
+ const res = await patch("L-2", { status: "unresolved", error_list_id: OTHER_ID, note: "秘密の理由" });
+ expect(res.status).toBe(200);
+ const [log] = dbWriteLogs();
+ expect(log.payload).toMatchObject({
+ columns: ["comment", "error_list_id"],
+ error_list_id_old: 40,
+ error_list_id_new: OTHER_ID,
+ comment_changed: true,
+ success: true,
+ });
+ // ★分類名・詳細の文字列・備考の中身を載せない★
+ const json = JSON.stringify(log.payload);
+ expect(json).not.toContain("詳細");
+ expect(json).not.toContain("秘密の理由");
+ });
+
+ it("変えないとき: old と new は同じ現在値", async () => {
+ errorReports.push(errorReport("L-2", 100, { error_list_id: BigInt(40) }));
+ await patch("L-2", { status: "in_progress" });
+ const [log] = dbWriteLogs();
+ expect(log.payload).toMatchObject({ error_list_id_old: 40, error_list_id_new: 40 });
+ expect(log.payload.columns).not.toContain("error_list_id");
+ });
+
+ it("書き込みの例外でも error_list_id の前後と columns を載せる(success: false)", async () => {
+ updateBehavior = "throw";
+ const res = await patch("E-1", { status: "unresolved", error_list_id: 41 });
+ expect(res.status).toBe(500);
+ const [log] = dbWriteLogs();
+ expect(log.payload).toMatchObject({
+ columns: ["error_list_id"],
+ error_list_id_old: null,
+ error_list_id_new: 41,
+ success: false,
+ });
+ });
+});
diff --git "a/app/api/errors/\133error_id\135/status/route.ts" "b/app/api/errors/\133error_id\135/status/route.ts"
new file mode 100644
index 0000000..897de3c
--- /dev/null
+++ "b/app/api/errors/\133error_id\135/status/route.ts"
@@ -0,0 +1,196 @@
+/**
+ * PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考・エラー内容を更新する(D-160 / 指示書AW 段階4-3 / 指示書BD §3-3。D-184)
+ *
+ * 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。
+ *
+ * ★`app/api/prechecks/[id]/status/route.ts` と同じ順序★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。
+ * ★違いの1つ目は `error_list_id`(エラー内容)を書けること★(D-184)— 本文・計画は `lib/errors/status-update.ts`
+ * (事前チェックの規則の上に `error_list_id` を足したもの)、計画の途中で有効な選択肢を `db.errorLists()` で引く(必要なときだけ)。
+ * 違いの2つ目は ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★こと。★ただし削除済みの施設・清掃エリアの行は `db.errorReportById()` が null を返すので404(指示書AX U8。このルートのコードは変えていない)★
+ * (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。
+ * ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)—
+ * 同じ形の小さな関数をこのファイルに置く。
+ *
+ * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の5列(`status` / `comment` / `responded_at` / `resolved_at` /
+ * `error_list_id`)だけ(D-042 / D-184 / `CLAUDE.md` §7)。★5列目の `error_list_id` を書けるのはこの経路だけ★(`/prechecks` は4列)。
+ * ★書き込みの中身は `lib/db/robot-error-write.ts`(`writeErrorReportColumns()`)★、★パッチを作るのは
+ * `planErrorStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。
+ *
+ * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。
+ * ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。
+ * ★エラー内容は id の前後だけ★(`error_list_id_old` / `error_list_id_new`。分類名・詳細の文字列は載せない)。
+ */
+import { NextResponse } from "next/server";
+import { parsePathErrorId } from "@/lib/api/path-params";
+import { db } from "@/lib/db";
+import { columnsForErrorPatch } from "@/lib/db/robot-error-write";
+import {
+ errorListIdToLookUp,
+ isEmptyErrorPatch,
+ parseErrorStatusUpdateBody,
+ planErrorStatusUpdate,
+} from "@/lib/errors/status-update";
+import { toJstIso } from "@/lib/format/date";
+import { withRequestId } from "@/lib/logger";
+import { logApiError } from "@/lib/logger/api";
+import { dbErrorCode, logDbWrite } from "@/lib/logger/db-write";
+import type { DbWriteLogEvent } from "@/lib/logger/events";
+import type { ApiError, ErrorCode, ErrorReportItem, ErrorStatusPatch } from "@/lib/types";
+
+const ROUTE = "/api/errors/[error_id]/status";
+const METHOD = "PATCH";
+
+const NOT_FOUND_MESSAGE = "エラーが見つかりません";
+
+function errorBody(
+ code: ErrorCode,
+ message: string,
+ details: Record | null = null
+): ApiError {
+ return { error: { code, message, details } };
+}
+
+function internalError(message: string, err: unknown) {
+ const body = errorBody("internal_error", message);
+ logApiError({ route: ROUTE, method: METHOD, body, status: 500, err });
+ return NextResponse.json(body, { status: 500 });
+}
+
+function notFound() {
+ return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
+}
+
+/** API の ISO(+09:00)→ 真の瞬間 */
+function instantOrNull(iso: string | null): Date | null {
+ return iso === null ? null : new Date(iso);
+}
+
+/** パッチを当てた後の日時(監査ログの `*_new`)。キーが無ければ変わらない */
+function nextIso(patch: ErrorStatusPatch, key: "responded_at" | "resolved_at", before: string | null) {
+ if (!(key in patch) || patch[key] === undefined) return before;
+ const value = patch[key];
+ return value === null ? null : toJstIso(value);
+}
+
+/** 監査ログの共通部分(★備考の中身は載せない。`comment_changed` だけ★) */
+function auditBase(
+ id: string,
+ before: ErrorReportItem,
+ patch: ErrorStatusPatch
+): Omit {
+ return {
+ route: ROUTE,
+ method: METHOD,
+ table: "robot_error_reports",
+ // ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2)
+ record_id: id,
+ columns: columnsForErrorPatch(patch),
+ status_old: before.resolution_status,
+ status_new: patch.status ?? before.resolution_status,
+ responded_at_old: before.responded_at,
+ responded_at_new: nextIso(patch, "responded_at", before.responded_at),
+ resolved_at_old: before.resolved_at,
+ resolved_at_new: nextIso(patch, "resolved_at", before.resolved_at),
+ error_list_id_old: before.error_list_id,
+ error_list_id_new: patch.error_list_id ?? before.error_list_id,
+ comment_changed: "note" in patch && patch.note !== undefined,
+ };
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ error_id: string }> }
+) {
+ return withRequestId(request, async () => {
+ // 1. 形式(`error_id` は varchar(50))
+ const { error_id: idRaw } = await params;
+ const parsedId = parsePathErrorId(idRaw);
+ if (!parsedId.ok) {
+ return NextResponse.json(parsedId.error, { status: parsedId.status });
+ }
+ const id = parsedId.query;
+
+ // 2. 本文(★status / note は `/prechecks/{id}/status` と同じ検証★。加えて error_list_id の型)
+ let json: unknown;
+ try {
+ json = await request.json();
+ } catch {
+ return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), {
+ status: 422,
+ });
+ }
+ const parsedBody = parseErrorStatusUpdateBody(json);
+ if (!parsedBody.ok) {
+ return NextResponse.json(parsedBody.error, { status: parsedBody.status });
+ }
+
+ // ★現在時刻は1回だけ作る★(responded_at と resolved_at が同じ要求で別の時刻にならないように)
+ const now = new Date();
+
+ // 3. 実在(★参照用の接続で読む★。★大小文字は `===` で決める(D-4)★)
+ let before: ErrorReportItem | null;
+ try {
+ before = await db.errorReportById(id);
+ } catch (e) {
+ return internalError("データの取得に失敗しました", e);
+ }
+ if (!before) return notFound();
+
+ // 4. 計画(★検証に落ちたら422・何も変わらなければ書かずに現在の値を返す。どちらもログを出さない★)
+ // ★error_list_id の現在値と「選べるか」は `errorReportById()` の結果から取る★(別のクエリを撃たない)
+ const current = {
+ resolution_status: before.resolution_status,
+ responded_at: instantOrNull(before.responded_at),
+ resolved_at: instantOrNull(before.resolved_at),
+ note: before.note,
+ error_list_id: before.error_list_id,
+ error_list_selectable: before.error_list_selectable,
+ };
+ // ★有効な選択肢は、id が変わり・選べる行のときだけ引く★(指示書BD §3-3 手順3。`db.errorLists()` は有効な行だけを返す)
+ let activeErrorListIds: ReadonlySet = new Set();
+ if (errorListIdToLookUp(current, parsedBody.query) !== null) {
+ try {
+ activeErrorListIds = new Set((await db.errorLists()).map((item) => item.id));
+ } catch (e) {
+ return internalError("データの取得に失敗しました", e);
+ }
+ }
+ const planned = planErrorStatusUpdate(current, parsedBody.query, now, (errorListId) =>
+ activeErrorListIds.has(errorListId)
+ );
+ if (!planned.ok) {
+ return NextResponse.json(planned.error, { status: planned.status });
+ }
+ const patch = planned.query;
+ if (isEmptyErrorPatch(patch)) {
+ return NextResponse.json(before);
+ }
+
+ // 5. 書き込み(★書き込み用の接続。updateMany の1文★)
+ const audit = auditBase(id, before, patch);
+ let matched: number;
+ try {
+ ({ matched } = await db.updateErrorReportColumns(id, patch));
+ } catch (e) {
+ logDbWrite({ ...audit, success: false, db_error_code: dbErrorCode(e) });
+ return internalError("対応状況の更新に失敗しました", e);
+ }
+ if (matched === 0) {
+ // 確認と書き込みの間に行が消えた(ステージングのデータ入れ替え等。第10回 N-6)
+ logDbWrite({ ...audit, success: false, db_error_code: "no_matching_row" });
+ return notFound();
+ }
+ // ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている)
+ logDbWrite({ ...audit, success: true });
+
+ // 6. 読み直し(画面はこの値でその1行を差し替える)
+ let after: ErrorReportItem | null;
+ try {
+ after = await db.errorReportById(id);
+ } catch (e) {
+ return internalError("データの取得に失敗しました", e);
+ }
+ if (!after) return notFound();
+ return NextResponse.json(after);
+ });
+}
diff --git a/app/api/errors/__tests__/route.fixtures.test.ts b/app/api/errors/__tests__/route.fixtures.test.ts
new file mode 100644
index 0000000..d7d4955
--- /dev/null
+++ b/app/api/errors/__tests__/route.fixtures.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PATCH } from "@/app/api/errors/[error_id]/status/route";
+import { GET } from "@/app/api/errors/route";
+import { resetFixtureErrorReportOverrides } from "@/lib/db/fixture-error-report-overrides";
+import type { ErrorReportItem, ListResponse } from "@/lib/types";
+
+/**
+ * `GET /errors` → `PATCH /errors/{error_id}/status` → `GET /errors`(`USE_FIXTURES=true`。指示書AW 段階4-4-3)。
+ *
+ * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、
+ * 同じファイルで true/false の両方は試せない(`app/api/prechecks/[id]/status/__tests__/route.fixtures.test.ts` と同じ)。
+ *
+ * fixtures 版は `lib/fixtures/error-reports.ts` を書き換えず、モジュール内の上書き表を持つ。
+ * ★テストごとに `resetFixtureErrorReportOverrides()` で空に戻す★。
+ */
+
+vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true }));
+
+vi.mock("@/lib/logger", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logEvent: vi.fn() };
+});
+
+const NOW_ISO = "2026-10-06T08:15:00+09:00";
+
+beforeEach(() => {
+ resetFixtureErrorReportOverrides();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(NOW_ISO));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ resetFixtureErrorReportOverrides();
+});
+
+async function list(qs = ""): Promise> {
+ const res = await GET(new Request(`http://localhost/api/errors${qs ? `?${qs}` : ""}`));
+ expect(res.status).toBe(200);
+ return (await res.json()) as ListResponse;
+}
+
+async function patch(id: string, body: unknown): Promise