diff --git a/.claude/commands/d-watch.md b/.claude/commands/d-watch.md new file mode 100644 index 0000000..b645afa --- /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..fda1b5a --- /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..c8d2d5e --- /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..24cd36c --- /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..e8e292e --- /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..8da3e13 --- /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..afcfa3f --- /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..3bfa614 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,492 @@ +# 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カラムのみです。 + +**「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) +> - ★接続元の制限・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`) +> ★第10回では照会しない★。★本番を組むときは、アプリとDBを閉域でつなぐか、DBに TLS を入れるかのどちらかが要る★ +> - ★★**ステージングには、4カラム(`status` / `comment` / `responded_at` / `resolved_at`)の UPDATE を書き込んでよい**★★ +> (ユーザー指示 2026-10-01「書き込んでよい」)。★実データ(ドン・キホーテのエラー行)の対応状況が書き換わることを承知のうえ★。 +> ★**4カラム以外への書き込みは引き続き禁止**★(§7。権限も無い)。★`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 可 + +この4カラム以外への書き込みコードを書かないでください。権限エラーになります。 + +--- + +## 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`)に対応する行だけ★。★`clean_timetable_id` が NULL(予定外のタスク)は完了率にも実施回数にも入れない★(サマリと同じ。N-3)。 + ★ただし総清掃面積(F-08)の面積の合計には予定外のタスクも含める★(実際に清掃した広さのため。ユーザー判断 2026-10-02。D-155) +- ★`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.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オンサイト訪問を検討 | + +**「連続」はカレンダー日ではなく「スケジュール存在日の連続」**で数えます。清掃予定がない日は連続を途切れさせません。 + +### 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)。★3区分の見分けと D-Sec アラートの取得元の切り替えは次フェーズ★。★全行の一覧と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 清掃異常終了。★表に無い値(CSV実測の 6 など)は生値のまま出す★(「その他」に潰さない。§4.8 と同じ考え方)。★判定には使わない★(中断・異常終了でも完了率で ◎○△× を出す。ユーザー判断 2026-10-02。§4.4 の「中断で返る=失敗」は実データを見て見直す) + - ★**`clean_task_reports.clean_timetable_id` は nullable**★。CSV実測では189行すべて NULL。 + ★NULL は「予定外のタスク」(下の項目。第10回 N-1 で TeamWorks も同意)★で、★欠損ではない★。★完了率・実施回数には入れない★(サマリと同じ。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_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参照) +- ❌ **4.3で指定した4カラム以外への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..ba67396 --- /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..861a0ae --- /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..8f874e8 --- /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..e505d41 --- /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..3b49090 --- /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..784a48b --- /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..cf1d9a2 --- /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..5f0c1e6 --- /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..4777b4a --- /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..0aa480d --- /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..052ddc3 --- /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..f3c0c5e --- /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..0212c38 --- /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..15a2ab2 --- /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..2ae97c8 --- /dev/null +++ b/app/api/__tests__/master-filter-dashboard-reports.test.ts @@ -0,0 +1,365 @@ +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 { customers, facilities } from "@/lib/fixtures"; +import { cleanLogs } from "@/lib/fixtures/clean-logs"; +import { todayJst } from "@/lib/format/date"; +import type { + ContinuousProblem, + DashboardResponse, + 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()]; +} + +/** `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(); + }); + + 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`)はスケジュールマスタ由来★★(D-049)なので、 + // ★絞ったぶんだけ必ず減る★(減らなければ `db.schedules()` の結果を絞り忘れている) + expect(scoped.schedule_progress.planned).toBeLessThan(all.schedule_progress.planned); + expect(scoped.schedule_progress.planned).toBeGreaterThan(0); + + // ★バナーは施設名しか持たない★ので施設名で突き合わせる + const allowedNames = new Set( + facilities.filter((f) => allowed.has(f.id)).map((f) => f.facility_name) + ); + expect(all.security_banner.length).toBeGreaterThan(scoped.security_banner.length); + expect(scoped.security_banner.every((b) => allowedNames.has(b.facility_name))).toBe(true); + }); + + /** + * ★★数値カードと要レスキューの母集団が一致していることの検査★★(指示書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); + + 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..b0ffde4 --- /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`(pending+in_progress+done)", async () => { + interface Body extends RowsBody { + summary: { pending: number; in_progress: number; done: number }; + } + const sum = (s: Body["summary"]) => s.pending + s.in_progress + s.done; + 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/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..68d525b --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,71 @@ +/** + * POST /api/auth/login — 共有トークンを検証して `dwatch_session` cookie を発行する。 + * + * ★このルートは認証をかけない★(`lib/proxy/authenticate.ts` の `isPublicPath()` で通す)。 + * 保護すると誰もログインできない。IP制限は proxy が別途かけるので、 + * 許可されないIPからはここに到達する前に 403 になる。 + * + * ★入力値(トークン)をログにも本文にも絶対に出さないこと★ + * ★失敗しても「トークンが違う」以上の情報を返さないこと★ + * (「そのトークンは存在するが期限切れ」等を返すと総当たりの手がかりになる) + */ +import { NextResponse } from "next/server"; +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: { token?: unknown }; + try { + body = await request.json(); + } catch { + 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..9380aa7 --- /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..646f9ca --- /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..99fddd3 --- /dev/null +++ b/app/api/clean-area/__tests__/route.test.ts @@ -0,0 +1,586 @@ +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 { 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[] }; + /** ★番人だけが付ける★(`countDuplicatedTimetables()`。`IS NOT NULL`) */ + clean_timetable_id?: { not: 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; + } + // ★`{ not: null }` は `IS NOT NULL`★(Prisma の nullable フィールドの作法) + if (clean_timetable_id && row.clean_timetable_id === null) 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` は集計と同じものに `{ not: null }` を足したもの) */ +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([]); + // ★番人のクエリに `IS NOT NULL` が付いている★ + const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id"); + expect(sentinel?.where?.clean_timetable_id).toEqual({ not: null }); + // ★★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)を入れない★★(D-155 項3 / 指示書AS D9。面積の合計には入る) + expect(res.total.executed_count).toBe(5); + }); + + 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 の行は `where` で除くので取り寄せない★(5行) + rate_rows: 5, + total_executed_count: 5, + raw_total_completion_rate: (90 + 80 + 70 + 50 + 10) / 5, + }) + ); + expect(typeof (payload as { elapsed_ms: unknown }).elapsed_ms).toBe("number"); + }); + + it("#13 ★完了率用の取り寄せは集計と同じ `where` に `clean_timetable_id: { not: null }` を足したもの★", 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, clean_timetable_id: { not: null } }); + // ★予定の引き直しは `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("データの取得に失敗しました"); + }); +}); diff --git a/app/api/clean-area/route.ts b/app/api/clean-area/route.ts new file mode 100644 index 0000000..2f36fa9 --- /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..c6ee782 --- /dev/null +++ "b/app/api/clean-logs/\133id\135/__tests__/route.test.ts" @@ -0,0 +1,806 @@ +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 { logEvent } from "@/lib/logger"; +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; + facility: { facility_name: string }; + section: { + 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; + } 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 })); + +// ★`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` の3本★ + expect(consecutiveCalls()).toHaveLength(3); + // ★履歴にも同じ引数★ + 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]); + }); +}); 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..bdd7371 --- /dev/null +++ "b/app/api/clean-logs/\133id\135/advice/route.ts" @@ -0,0 +1,167 @@ +/** + * 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 { + CONSECUTIVE_DAYS_DISPLAY, + ESCALATION_THRESHOLDS, + GRADE_THRESHOLDS, +} from "@/lib/config"; +import { db } from "@/lib/db"; +import { capConsecutiveDays } from "@/lib/format/consecutive-days"; +import { judgeEscalationLevel, matchAdviceRules, maxAdviceLevel } from "@/lib/judge"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { loadAdviceRules } from "@/lib/settings/advice"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { ApiError, CleanLogAdviceResponse, 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", "指定された清掃ログが見つかりません"); + } + + // ★★入力は生値★★ — R1〜R4 の判定に表示上限をかけない(指示書AF 禁止事項9)。 + // ★このルートで `capConsecutiveDays()` を通してよいのはレスポンスに載せる値だけ★ + // (下の `body`)。判定の入力と、判定根拠を残す `logEvent` は生値のままにする。 + const matchedIds = matchAdviceRules({ + departed: log.departed, + completionRate: log.completion_rate, + consecutiveProblemDays: log.consecutive_problem_days, + // ★null(事前チェックの予定が無い。D-171)はそのまま渡す★ — `matchAdviceRules()` は null を + // どのルールにも当てない(例外にしない)。★ここで "Standby." 等に置き換えないこと★ + status1h: log.status_1h, + status30m: log.status_30m, + }); + + // 判定根拠の記録。記録条件は設けず無条件に出す + // (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、 + // 条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい) + logEvent("info", { + event: "judge.advice", + route: ROUTE, + method: "GET", + facility_id: log.facility_id, + clean_log_id: log.id, + departed: log.departed, + raw_completion_rate: log.completion_rate, + consecutive_problem_days: log.consecutive_problem_days, + status_1h: log.status_1h, + status_30m: log.status_30m, + thresholds_used: { + excellent: GRADE_THRESHOLDS.excellent, + good: GRADE_THRESHOLDS.good, + poor: GRADE_THRESHOLDS.poor, + level2Days: ESCALATION_THRESHOLDS.level2Days, + level3Days: ESCALATION_THRESHOLDS.level3Days, + }, + result_rules: matchedIds, + }); + + const rules = await loadAdviceRules(); + const advices = matchedIds.map((ruleId) => { + const r = rules[ruleId]; + return { + rule: r.id, + level: r.level, + icon: r.icon, + title: r.title, + estimated_cause: r.text, + recommended_actions: r.actions, + }; + }); + + // ★★入力は生値★★ — L1〜L3 の判定に表示上限をかけない(指示書AF 禁止事項9 / + // `CLAUDE.md` §4.3「表示用に丸めた値で判定すること」の禁止と同じ思想)。 + // 上限をかけると「30日以上」と出ているのに Lv3 が出ない画面になる。 + const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days); + logEvent("info", { + event: "judge.escalation", + route: ROUTE, + method: "GET", + facility_id: log.facility_id, + consecutive_problem_days: log.consecutive_problem_days, + thresholds_used: { + level2Days: ESCALATION_THRESHOLDS.level2Days, + level3Days: ESCALATION_THRESHOLDS.level3Days, + }, + result_level: escalationLevel, + }); + + // ★★ここだけが表示上限をかけてよい場所★★(指示書AF 段階6-2)— + // 判定(`matchAdviceRules` / `judgeEscalationLevel`)も判定根拠の `logEvent` も + // 生値のままで、レスポンスに載せる表示値だけを頭打ちにする。 + // `escalation_level` は生値で判定した結果をそのまま返す(判定し直さない)。 + // 設定が読めないだけでアドバイスを 500 にしない(既定値で続行する)。 + let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax; + try { + maxDays = await loadConsecutiveDaysMax(); + } catch { + // 既定値のまま続行する + } + const capped = capConsecutiveDays(log.consecutive_problem_days, maxDays); + + const body: CleanLogAdviceResponse = { + escalation_level: escalationLevel, + consecutive_problem_days: capped.days, + consecutive_problem_days_capped: capped.capped, + max_level: maxAdviceLevel(advices), + advices, + }; + return NextResponse.json(body); + }); +} 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..10d0941 --- /dev/null +++ "b/app/api/clean-logs/\133id\135/areas/route.ts" @@ -0,0 +1,158 @@ +/** + * 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, + clean_log_id: log.id, + 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..c811118 --- /dev/null +++ b/app/api/clean-logs/__tests__/route.test.ts @@ -0,0 +1,839 @@ +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 { 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 { logEvent } from "@/lib/logger"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { CleanLogItem, ListResponse } 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; + facility: { facility_name: string }; + section: { + 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; + 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)) { + 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` を足してしまう)が起きたら結果が変わるように + if (c.deleted_at === null && value !== null) 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) => project(row, args?.select)); +} + +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/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 }, + // ★★論理削除済み★★ — ★清掃ログ側には行が残っている(`cleanLogs()` は + // `include: { facility: true }` で `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 - b)).toEqual([101, 102, 103, 201]); + expect(body.items.every((i) => i.facility_name.startsWith("ドソ"))).toBe(true); + }); + + it("★★#6e 論理削除された施設の名前で検索しても、そのログが消えない★★(★罠7★)", async () => { + const body = await get(`?facility_name=${encodeURIComponent("四街道")}`); + + // ★解決用の読み取りに `where` を足していないこと★(足すと施設2 が解決されない) + expect(calls.facility).toEqual([{ select: { id: true, facility_name: true } }]); + expect(mainQueryWhere().facility_id).toEqual({ in: [2] }); + expect(body.items.map((i) => i.id)).toEqual([201]); + expect(body.meta.total).toBe(1); + }); + + 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 が消える★ → その名前で検索してもログが引けなくなる + 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"); + expect(mainQueryWhere()).not.toHaveProperty("section"); + 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` だけでは区別できない) + const reportCall = calls.cleanTaskReport.find((c) => "where" in c && !("select" 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個)★ + expect(calls.cleanTaskReport.filter((c) => "where" in c)).toEqual([]); + }); +}); + +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"); + }); +}); + +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, + }); + }); +}); 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..55e9a49 --- /dev/null +++ b/app/api/clean-logs/export/__tests__/route.test.ts @@ -0,0 +1,136 @@ +import { 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 { 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; + +function row(id: number): CleanLogItem { + return { + id, + 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 () => + 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"); + }); +}); diff --git a/app/api/clean-logs/export/route.ts b/app/api/clean-logs/export/route.ts new file mode 100644 index 0000000..867b364 --- /dev/null +++ b/app/api/clean-logs/export/route.ts @@ -0,0 +1,114 @@ +/** + * GET /clean-logs/export — 清掃詳細ログCSVエクスポート + * + * /clean-logs と同一のフィルタを受け付ける(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 { 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 }); + } + + const csv = buildCleanLogCsv(filtered); + // ★ここで生の 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..d4c5bdf --- /dev/null +++ b/app/api/clean-logs/route.ts @@ -0,0 +1,112 @@ +/** + * 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. ページング + * 6. { 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 { 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 { page, perPage } = parsed.query; + const total = filtered.length; + const start = (page - 1) * perPage; + // ★表示上限は絞り込み → ページ切り出しの**後**にかける★(指示書AF 段階6-2) + // 絞り込みとページングは生値のまま行う。`meta.total` も上限とは無関係。 + const items = capCleanLogs(filtered.slice(start, start + perPage), maxDays); + + const body: ListResponse = { + items, + meta: { total, page, per_page: perPage }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/customers/__tests__/route.test.ts b/app/api/customers/__tests__/route.test.ts new file mode 100644 index 0000000..91f58d2 --- /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..cd87f30 --- /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..b886013 --- /dev/null +++ b/app/api/dashboard/__tests__/route.test.ts @@ -0,0 +1,464 @@ +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 } from "@/lib/config"; +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; + facility: { facility_name: string }; + section: { + 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[]; +} + +/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */ +const calls: { + cleanTimetable: Record[]; + cleanTimetableHistory: Record[]; + cleanTaskReport: Record[]; +} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [] }; + +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)) { + 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; + } 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 })); + +/** `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` だけを見る)★ + robotErrorReport: { findMany: async () => [] }, + cleanSchedule: { findMany: async () => [] }, + controlErrorHistory: { findMany: async () => [] }, + } 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 }, + section: { + 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"), + ], + }; +} + +/** ★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 = []; + globalThis.__dwatchPrisma = prismaStub(); + // ★`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("★`?date=2026-09-29` ではメタを読まない★(0回)", async () => { + await get("?date=2026-09-29"); + expect(metaCalls()).toHaveLength(0); + }); + + it("★schedule_progress は直近モードと `?date=2026-09-30` で同じ★(当日のまま。4-1)", async () => { + const latest = await get(""); + const ofDay = await get(`?date=${TODAY}`); + expect(latest.schedule_progress).toEqual(ofDay.schedule_progress); + }); +}); + +/** + * ★結果待ち(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([]); + }); +}); diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..4f5512c --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,242 @@ +/** + * 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`。純粋関数)。 + * ★スケジュール進捗・要レスキュー・セキュリティバナー・`data.anomaly` は省略時も当日のまま★(D-049 / 指示書AV 4-1)。 + * + * ★コンパクト版清掃詳細ログは `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分)。 + */ +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, +} from "@/lib/dashboard/aggregate"; +import { latestRunWindow, 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, + Schedule, +} 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)。スケジュール進捗・要レスキューの基準日は当日(=昨夜分)。 + // ★★`??` ではなく `||` / `!` にすること★★ — ★`?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`)・要レスキューの5本すべてに**同じ配列**を渡すこと★★ — + // ★どれか1つを渡し忘れると、「この屋号の昨夜の成績」と「要レスキュー」が別の集団の話になる★ + // (メタだけ漏れると、行に対応するメタが無く `pickLatestRunPerSection()` が例外になる)。 + // ★例外もテスト失敗も出ない壊れ方★なので + // `app/api/__tests__/master-filter-dashboard-reports.test.ts` で固定してある。 + let facilityIds: number[] | null; + let logs: CleanLogItem[]; + let schedules: Schedule[]; + 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, schedules, 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), + // ★★この2本は引数を取らない契約★★(段階4a の申し送り。`lib/facilities/filter-by-ids.ts`)— + // ★絞り込みは下の `filterRowsByFacilityIds()` でルート側で行う★。 + db.schedules(), + db.dsAlerts(), + // ★★上の `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)— 行とメタの突き合わせに失敗すると例外になる(D9)。 + // 外で呼ぶと `{ error: ... }` の形にならない。 + // ★設定値は `DASHBOARD_LATEST_RUN` から渡す★(7 を直書きしない。`CLAUDE.md` §7)。 + 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` = `today`)の行★★ — + // スケジュール進捗と `data.anomaly` は当日のまま(D-049 / 指示書AV 4-1)。 + const logsOfDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date); + // ★数値カードと `problem_logs` の母集団★: 直近モードは選んだ行、`date` 指定はその日の行(D-159 / D12) + const summaryRows = picked ?? logsOfDay; + + // ★★`schedules()` / `dsAlerts()` は引数を取らない契約なのでここで絞る★★ + // (段階4a が `/schedules` / `/ds-alerts` で使っているのと同じ純粋関数)。 + // ★★集計より**前**に絞ること★★ — `planned`(予定件数)も + // セキュリティバナーも ★同じ母集団から作る★ため。 + // ★`logsOfDay` は `db.cleanLogs()` の時点で絞られている★ので二重に絞らない。 + const schedulesOfScope = filterRowsByFacilityIds(schedules, facilityIds); + 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 + ); + const scheduleProgress = computeScheduleProgress( + logsOfDay, + schedulesOfScope, + date + ); + const securityBanner = computeSecurityBanner(dsAlertsOfScope); + + // スケジュール由来の予定件数と当日の清掃ログ行数が合わないことの記録(D-049)。 + // 収集処理が止まると当日の行が作られないため、ここが最初の手がかりになる。 + // 原因の断定は書かない(logging-design.md §7)。差分だけを残す。 + if (logsOfDay.length !== scheduleProgress.planned) { + logEvent("warn", { + event: "data.anomaly", + route: ROUTE, + method: "GET", + metric: "clean_log_rows_vs_scheduled", + expected: scheduleProgress.planned, + actual: logsOfDay.length, + }); + } + + 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/route.ts b/app/api/ds-alerts/route.ts new file mode 100644 index 0000000..6703887 --- /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 /ds-alerts/{id}/status)は今回作らない。DB側の準備待ち + * (/prechecks と同じ扱い。依存: 更新系はDB更新用アカウントと responded_at/resolved_at + * カラム反映待ち)★ + */ +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); + rows = await db.dsAlerts(); + } 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..5fb2de2 --- /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..729bfb5 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts" @@ -0,0 +1,248 @@ +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; +} + +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 }[] { + 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 })); +} + +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 }, + { serial_number: "DS-002", deleted_at: null }, + // ★論理削除済み★(#3) + { serial_number: "DS-004", deleted_at: new Date("2026-09-01T00:00:00Z") }, + ]; +}); + +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" }, + ]); + }); + + it("#5 `DS-001` — ★別の制御盤に心拍が0件でも 200★(他台の欠損に巻き込まれない)", async () => { + // ★心拍が0件の制御盤を足す★。スタブは `control_reports` を読んだら例外を投げるので、 + // ★`db.dsDevices()` に戻したら 500 になって赤くなる★ + controls.push({ serial_number: "DS-777", deleted_at: null }); + + 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` だけ選ぶ + expect(args.include).toBeUndefined(); + expect(args.select).toEqual({ serial_number: true }); + // ★★`take` を付けないこと★★(★罠8★。段階1 と同じ「SQL に降りない」問題を踏みうる) + expect(args.take).toBeUndefined(); + // ★シリアルで絞ってはいる(SQLは絞り込みまで。当たり外れは JS の `===`。D-4)★ + expect(args.where).toEqual({ deleted_at: null, serial_number: "DS-001" }); + }); +}); 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..91ac542 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/note/route.ts" @@ -0,0 +1,134 @@ +/** + * 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 { 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: { note?: unknown }; + try { + body = await request.json(); + } catch { + 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になる★ + // 経路も断てる。★照合規則(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..19a0790 --- /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/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..5f361dc --- /dev/null +++ "b/app/api/errors/\133error_id\135/status/__tests__/route.test.ts" @@ -0,0 +1,482 @@ +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 { 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` を記録し、★4列のうち該当分だけ★が渡っていることを見る★。 + * (生成される 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; + facility: { facility_name: string }; + 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; +} + +interface UpdateManyArgs { + where: { error_id: string }; + data: Record; +} + +type Args = { where: Record; select?: Record }; + +let timetables: StubTimetable[]; +let historyTimetables: StubTimetable[]; +let errorReports: StubErrorReport[]; +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 で引く(`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" }, + 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, + ...overrides, + }; +} + +function buildData() { + 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 = []; + 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 以外の行 → 500(fail-loud。§11 Q3)", async () => { + errorReports.push(errorReport("E-ODD", 100, { status: 3 })); + const res = await patch("E-ODD", { status: "resolved" }); + expect(res.status).toBe(500); + expect(updateCalls).toHaveLength(0); + }); +}); + +// ---------------------------------------------------------------- 監査ログ + +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, + 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, + }); + }); +}); 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..6fd3424 --- /dev/null +++ "b/app/api/errors/\133error_id\135/status/route.ts" @@ -0,0 +1,174 @@ +/** + * PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考を更新する(D-160 / 指示書AW 段階4-3) + * + * 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。 + * + * ★`app/api/prechecks/[id]/status/route.ts` と同じ順序・同じ部品★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。 + * 違いは ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★ことだけ + * (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。 + * ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)— + * 同じ形の小さな関数をこのファイルに置く。 + * + * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の4列(`status` / `comment` / `responded_at` / `resolved_at`)だけ + * (D-042 / `CLAUDE.md` §7)。★書き込みの中身は `lib/db/robot-error-write.ts`★、★パッチを作るのは + * `planPrecheckStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。 + * + * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。 + * ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。 + */ +import { NextResponse } from "next/server"; +import { parsePathErrorId } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { columnsForPatch } from "@/lib/db/robot-error-write"; +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 { + isEmptyPatch, + parsePrecheckStatusUpdateBody, + planPrecheckStatusUpdate, +} from "@/lib/prechecks/status-update"; +import type { ApiError, ErrorCode, ErrorReportItem, PrecheckStatusPatch } 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: PrecheckStatusPatch, 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: PrecheckStatusPatch +): Omit { + return { + route: ROUTE, + method: METHOD, + table: "robot_error_reports", + // ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2) + record_id: id, + columns: columnsForPatch(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), + 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. 本文(★`/prechecks/{id}/status` と同じ検証★) + let json: unknown; + try { + json = await request.json(); + } catch { + return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), { + status: 422, + }); + } + const parsedBody = parsePrecheckStatusUpdateBody(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. 計画(★何も変わらなければ書かずに現在の値を返す。ログも出さない★) + const patch = planPrecheckStatusUpdate( + { + resolution_status: before.resolution_status, + responded_at: instantOrNull(before.responded_at), + resolved_at: instantOrNull(before.resolved_at), + note: before.note, + }, + parsedBody.query, + now + ); + if (isEmptyPatch(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..0ae60cb --- /dev/null +++ b/app/api/errors/__tests__/route.fixtures.test.ts @@ -0,0 +1,111 @@ +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 { + 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 /errors(USE_FIXTURES=true)", () => { + it("★既定で FX-ERR-03 が出ない(10行)・include_resolved=true で11行★", async () => { + const def = await list(); + expect(def.meta.total).toBe(10); + expect(def.items.map((i) => i.id)).not.toContain("FX-ERR-03"); + const all = await list("include_resolved=true"); + expect(all.meta.total).toBe(11); + expect(all.items).toHaveLength(11); + }); + + it("error_type=secur → FX-ERR-08 だけ", async () => { + expect((await list("error_type=secur")).items.map((i) => i.id)).toEqual(["FX-ERR-08"]); + }); +}); + +describe("PATCH → GET(USE_FIXTURES=true)", () => { + it("★事前チェック以外の行(FX-ERR-08: robot_id NULL)に書け、GET に反映される★", async () => { + const res = await patch("FX-ERR-08", { status: "in_progress", note: "警備会社に確認" }); + expect(res.status).toBe(200); + expect((await res.json()) as ErrorReportItem).toMatchObject({ + id: "FX-ERR-08", + resolution_status: "in_progress", + responded_at: NOW_ISO, + note: "警備会社に確認", + }); + const item = (await list()).items.find((i) => i.id === "FX-ERR-08"); + expect(item).toMatchObject({ resolution_status: "in_progress", responded_at: NOW_ISO, note: "警備会社に確認" }); + }); + + it("解消にすると既定の一覧から外れ、「解消済みも表示」では出る", async () => { + expect((await patch("FX-ERR-04", { status: "resolved" })).status).toBe(200); + expect((await list()).items.map((i) => i.id)).not.toContain("FX-ERR-04"); + const all = await list("include_resolved=true"); + expect(all.items.find((i) => i.id === "FX-ERR-04")).toMatchObject({ + resolution_status: "resolved", + responded_at: NOW_ISO, + resolved_at: NOW_ISO, + }); + }); + + it("解消 → 未解消で日時を消す(FX-ERR-03)", async () => { + const res = await patch("FX-ERR-03", { status: "unresolved" }); + expect((await res.json()) as ErrorReportItem).toMatchObject({ + resolution_status: "unresolved", + responded_at: null, + resolved_at: null, + }); + expect((await list()).meta.total).toBe(11); + }); + + it.each([["存在しない id", "FX-ERR-99"], ["大小文字違い", "fx-err-01"]])("%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 }, + }); + }); +}); diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts new file mode 100644 index 0000000..2db8a72 --- /dev/null +++ b/app/api/errors/__tests__/route.test.ts @@ -0,0 +1,523 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由) +import { GET } from "@/app/api/errors/route"; +import { PRECHECK_MINUTES_BEFORE } from "@/lib/config"; +import { db as dataSource } from "@/lib/db"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { errorReportListWhere } from "@/lib/db/error-report-mapping"; +import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping"; +import { ROBOT_ERROR_LIST_KEY_SELECT, ROBOT_ERROR_LIST_ROW_SELECT } from "@/lib/db/robot-error-select"; +import { toDbJst } from "@/lib/format/date"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, ErrorReportItem, ListResponse } from "@/lib/types"; + +/** + * `GET /errors`(Prisma 経路。指示書AW 段階4-4-1)。 + * + * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して + * `prismaSource.errorReports()` の経路を走らせる(`app/api/prechecks/[id]/status/__tests__/route.test.ts` と同じ作法)。 + * + * ★スタブの `robotErrorReport.findMany` は★ + * - ★`guardNullRobotId()` を通す★(`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる) + * - ★`select` を見て返す列を絞る★(1段目と2段目で返す形が違う) + * - `where` の ★`status.in` / `facility_id.in` / `OR`(期間)/ `error_id.in`★ を評価する(★知らないキーは例外★) + * + * ★表1 は監督確定(D-160 / 指示書AW §3)。変えないこと★。表2 は指示書AW で導いたもの(監督確認済み)。 + * 入力は表1 の9行(`facility_id` は E1〜E5 = 1、E6〜E9 = 2)。 + * ★E7 の `created_at`(10/6 08:00)は9行の中で一番新しい★ → E7 が先頭(`nulls: "last"` の並べ替えなら最後)。 + * ★E4 = "E-10"・E5 = "E-9" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆)。 + * ★E6 の予定は履歴(`clean_timetable_histories`)にだけ置く★。 + */ + +// ---------------------------------------------------------------- スタブ(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 }; + 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; +} + +interface StubTimetable { + id: bigint; + precheck: number; +} + +interface StubFacility { + id: bigint; + facility_name: string; + customer_id: bigint; + customer: { group_id: bigint }; +} + +type Args = Record & { where?: Record; select?: Record }; + +let errorReports: StubErrorReport[]; +let mainTimetables: StubTimetable[]; +let historyTimetables: StubTimetable[]; +let facilityRows: StubFacility[]; +/** `robotErrorReport.findMany` の引数の記録(1段目・2段目) */ +let errorCalls: Args[]; +/** `cleanTimetable.findMany` の引数と、返した id の記録 */ +let mainTimetableCalls: { args: Args; returned: string[] }[]; +let historyTimetableCalls: Args[]; +/** ★2段目で消えたことにする error_id★(1段目と2段目の間に行が消えた。指示書AW 4-15) */ +let vanishOnSecondStage: Set; +/** 次の `robotErrorReport.findMany` を例外にする */ +let throwOnErrorFindMany: boolean; + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */ +function ciEqual(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +interface DateRange { + gte?: Date; + lt?: Date; +} + +function inRange(value: Date | null, range: DateRange): boolean { + if (value === null) return false; + if (range.gte && value.getTime() < range.gte.getTime()) return false; + if (range.lt && value.getTime() >= range.lt.getTime()) return false; + return true; +} + +/** `where` を評価する(★知らないキーは例外★ — ルートが別の条件を組み立てたら気づけるように) */ +function matchErrorWhere(row: StubErrorReport, where: Record): boolean { + for (const [key, cond] of Object.entries(where)) { + switch (key) { + case "status": + if (!(cond as { in: number[] }).in.includes(row.status)) return false; + break; + case "facility_id": + if (!(cond as { in: bigint[] }).in.some((id) => id === row.facility_id)) return false; + break; + case "error_id": + if (!(cond as { in: string[] }).in.some((id) => ciEqual(id, row.error_id))) return false; + break; + case "OR": { + const ok = (cond as Record[]).some((c) => { + if (c.errored_at === null) { + return row.errored_at === null && inRange(row.created_at, c.created_at as DateRange); + } + return inRange(row.errored_at, c.errored_at as DateRange); + }); + if (!ok) return false; + break; + } + default: + throw new Error(`[stub] robotErrorReport.findMany の where に想定外のキー: ${key}`); + } + } + return true; +} + +/** `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; +} + +function timetableFindMany(rows: StubTimetable[], args: Args): StubTimetable[] { + const ids = ((args.where?.id as { in: bigint[] } | undefined)?.in ?? []).map((id) => id.toString()); + return rows.filter((t) => ids.includes(t.id.toString())); +} + +vi.mock("@/lib/db/prisma", () => { + const read = { + robotErrorReport: { + findMany: async (args: Args) => { + errorCalls.push(args); + if (throwOnErrorFindMany) throw new Error("[stub] DB が落ちた"); + guardNullRobotId(errorReports as unknown as Record[], args); + const isSecondStage = args.where !== undefined && "error_id" in args.where; + return errorReports + .filter((row) => matchErrorWhere(row, args.where ?? {})) + .filter((row) => !(isSecondStage && vanishOnSecondStage.has(row.error_id))) + .map((row) => project(row as unknown as Record, args.select ?? {})); + }, + findUnique: async () => { + throw new Error("[stub] 一覧で findUnique を呼んではいけない"); + }, + }, + cleanTimetable: { + findMany: async (args: Args) => { + const rows = timetableFindMany(mainTimetables, args); + mainTimetableCalls.push({ args, returned: rows.map((r) => r.id.toString()) }); + return rows; + }, + }, + cleanTimetableHistory: { + findMany: async (args: Args) => { + historyTimetableCalls.push(args); + return timetableFindMany(historyTimetables, args); + }, + }, + // ★施設名・分類の解決(`facility.findMany`)はここに置かない★ — 下の `beforeEach` で + // `db.facilityNameIndex()` / `db.facilityGroupIndex()` を差し替える(理由はそちらのコメント) + facility: { + findMany: async () => { + throw new Error("[stub] facility.findMany は呼ばない(db の2メソッドを差し替えてある)"); + }, + }, + }; + 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 PRECHECK_30 = PRECHECK_MINUTES_BEFORE[1]; +// ★10 は設定(60/30)に無い値であることが本題なので直書きする★(D-105 項1) +const PRECHECK_10 = 10; + +function row(overrides: Partial & Pick): StubErrorReport { + return { + robot_id: BigInt(501), + clean_timetable_id: BigInt(100), + serial_number: "PD-CC1-0001", + facility_id: BigInt(1), + facility: { facility_name: "テスト施設A" }, + error_type: "EmergencyStop", + error_level: "ERROR", + errored_at: db("2026-10-05T19:00:00+09:00"), + created_at: db("2026-10-05T19:01:00+09:00"), + status: UNRESOLVED, + responded_at: null, + resolved_at: null, + comment: null, + ...overrides, + }; +} + +const FACILITY_B = { facility_id: BigInt(2), facility: { facility_name: "テスト施設B" } }; + +function buildData() { + errorReports = [ + row({ error_id: "E-1", errored_at: db("2026-10-05T19:05:00+09:00"), created_at: db("2026-10-05T19:06:00+09:00") }), + row({ + error_id: "E-2", + clean_timetable_id: BigInt(101), + error_type: "LowBattery", + errored_at: db("2026-10-05T19:30:00+09:00"), + created_at: db("2026-10-05T19:31:00+09:00"), + status: IN_PROGRESS, + responded_at: db("2026-10-06T09:00:00+09:00"), + }), + row({ + error_id: "E-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-06T09:00:00+09:00"), + resolved_at: db("2026-10-06T10:00:00+09:00"), + comment: "対応済み", + }), + row({ error_id: "E-10", clean_timetable_id: null, errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:01:00+09:00") }), + row({ error_id: "E-9", clean_timetable_id: BigInt(999), errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:02:00+09:00") }), + row({ error_id: "E-6", ...FACILITY_B, clean_timetable_id: BigInt(106), errored_at: db("2026-10-05T20:30:00+09:00"), created_at: db("2026-10-05T20:31:00+09:00") }), + row({ error_id: "E-7", ...FACILITY_B, errored_at: null, created_at: db("2026-10-06T08:00:00+09:00") }), + row({ + error_id: "E-8", + ...FACILITY_B, + robot_id: null, + serial_number: "0", + error_type: "SECURITY_ON", + error_level: "CRITICAL", + errored_at: db("2026-10-05T21:30:00+09:00"), + created_at: db("2026-10-05T21:31:00+09:00"), + }), + row({ error_id: "E-11", ...FACILITY_B, clean_timetable_id: BigInt(109), errored_at: db("2026-10-05T22:00:00+09:00"), created_at: db("2026-10-05T22:01:00+09:00") }), + ]; + mainTimetables = [ + { id: BigInt(100), precheck: 0 }, + { id: BigInt(101), precheck: PRECHECK_30 }, + { id: BigInt(109), precheck: PRECHECK_10 }, + ]; + // ★E6 の予定は履歴にだけある★ + historyTimetables = [{ id: BigInt(106), precheck: 0 }]; + facilityRows = [ + { id: BigInt(1), facility_name: "テスト施設A", customer_id: BigInt(1), customer: { group_id: BigInt(1) } }, + { id: BigInt(2), facility_name: "テスト施設B", customer_id: BigInt(2), customer: { group_id: BigInt(1) } }, + ]; +} + +/** 表1 の並び順(既定。E3 を除く) */ +const DEFAULT_ORDER = ["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-2", "E-1"]; + +async function get(qs = ""): Promise { + return GET(new Request(`http://localhost/api/errors${qs ? `?${qs}` : ""}`)); +} + +async function list(qs = ""): Promise> { + const res = await get(qs); + expect(res.status).toBe(200); + return (await res.json()) as ListResponse; +} + +const ids = (items: ErrorReportItem[]) => items.map((i) => i.id); + +beforeEach(() => { + buildData(); + // ★施設名・分類の解決は `db` の2メソッドを差し替える(解決の規則 `resolveFacilityIdsBy*()` と + // `intersectFacilityIds()` は本物が走る)★。 + // ★スタブの `facility.findMany` で済ませない理由★: ルートは2本を `Promise.all` で同時に呼び、 + // それぞれが `await import("./prisma")` する。vitest では ★モックした `@/lib/db/prisma` を同時に動的 import すると + // 片方に本物のモジュールが返る★(2026-10-06 実測。本物は `DATABASE_URL` が無いと例外 → 500)。 + // テスト環境だけの挙動で、本番の経路(`/clean-logs` も同じ形)には関係ない。 + vi.spyOn(dataSource, "facilityNameIndex").mockImplementation(async () => + facilityRows.map((f) => ({ id: Number(f.id), facility_name: f.facility_name })) + ); + vi.spyOn(dataSource, "facilityGroupIndex").mockImplementation(async () => + facilityRows.map((f) => ({ + id: Number(f.id), + customer_id: Number(f.customer_id), + group_id: Number(f.customer.group_id), + })) + ); + errorCalls = []; + mainTimetableCalls = []; + historyTimetableCalls = []; + vanishOnSecondStage = new Set(); + throwOnErrorFindMany = false; + vi.mocked(logApiError).mockClear(); +}); + +// ---------------------------------------------------------------- 表1 + +describe("表1(既定・解消も表示・各行の値)", () => { + it("★既定は E3 以外の8行・並びが表1 のとおり(E7 が先頭・同時刻は \"E-10\" が先)★", async () => { + const body = await list(); + expect(ids(body.items)).toEqual(DEFAULT_ORDER); + expect(body.meta).toEqual({ total: 8, page: 1, per_page: 50 }); + }); + + it("include_resolved=true で9行(E3 は検知時刻の位置)", async () => { + const body = await list("include_resolved=true"); + expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-3", "E-2", "E-1"]); + expect(body.meta.total).toBe(9); + }); + + it.each([ + ["E-1", "main", null, "2026-10-05T19:05:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-2", "precheck", PRECHECK_30, "2026-10-05T19:30:00+09:00", "PD-CC1-0001", "LowBattery"], + ["E-3", "main", null, "2026-10-05T20:00:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-10", "none", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-9", "unknown", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-6", "main", null, "2026-10-05T20:30:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-7", "main", null, "2026-10-06T08:00:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ["E-8", "main", null, "2026-10-05T21:30:00+09:00", "0", "SECURITY_ON"], + ["E-11", "precheck", PRECHECK_10, "2026-10-05T22:00:00+09:00", "PD-CC1-0001", "EmergencyStop"], + ] as const)("%s: schedule_link / precheck_minutes / detected_at / serial_number / error_type", async (id, link, minutes, detectedAt, serial, errorType) => { + const body = await list("include_resolved=true"); + expect(body.items.find((i) => i.id === id)).toMatchObject({ + schedule_link: link, + precheck_minutes: minutes, + detected_at: detectedAt, + serial_number: serial, + error_type: errorType, + }); + }); + + it("E3: 解消の日時と備考を返す・BigInt を残さない", async () => { + const body = await list("include_resolved=true"); + expect(body.items.find((i) => i.id === "E-3")).toEqual({ + id: "E-3", + detected_at: "2026-10-05T20:00:00+09:00", + facility_id: 1, + facility_name: "テスト施設A", + serial_number: "PD-CC1-0001", + error_type: "EmergencyStop", + error_level: "ERROR", + clean_timetable_id: 100, + schedule_link: "main", + precheck_minutes: null, + resolution_status: "resolved", + responded_at: "2026-10-06T09:00:00+09:00", + resolved_at: "2026-10-06T10:00:00+09:00", + note: "対応済み", + }); + }); + + it("★E6: 本表の findMany は E6 の予定(106)を返さず、履歴から引く★", async () => { + const body = await list(); + expect(body.items.find((i) => i.id === "E-6")?.schedule_link).toBe("main"); + expect(mainTimetableCalls.flatMap((c) => c.returned)).not.toContain("106"); + expect(historyTimetableCalls.length).toBeGreaterThan(0); + }); + + it("★E8: robot_id が NULL の行があっても読み取りで落ちない(select で robot_id / robot を読まない)★", async () => { + const body = await list(); + expect(ids(body.items)).toContain("E-8"); + }); +}); + +// ---------------------------------------------------------------- 表2 + +describe("表2(絞り込み・ページ)", () => { + it("F3: include_resolved=yes → 422(DB を読まない)", async () => { + const res = await get("include_resolved=yes"); + expect(res.status).toBe(422); + expect(((await res.json()) as ApiError).error.code).toBe("validation_failed"); + expect(errorCalls).toHaveLength(0); + }); + + it("F4: error_type=secur → E8 だけ", async () => { + const body = await list("error_type=secur"); + expect(ids(body.items)).toEqual(["E-8"]); + expect(body.meta.total).toBe(1); + }); + + it("F5: error_type が空白だけ → F1 と同じ", async () => { + expect(ids((await list("error_type=%20%20")).items)).toEqual(DEFAULT_ORDER); + }); + + it("★F6: date_from = date_to = E7 の created_at の日付 → E7 が入る(SQL 側も created_at で絞る)★", async () => { + const body = await list("date_from=2026-10-06&date_to=2026-10-06"); + expect(ids(body.items)).toEqual(["E-7"]); + }); + + it("期間 10/5 → E7 以外の7行", async () => { + const body = await list("date_from=2026-10-05&date_to=2026-10-05"); + expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-7")); + }); + + it("F7: date_from > date_to → 422(parseCleanLogQuery() と同じ文言)", async () => { + const res = await get("date_from=2026-10-06&date_to=2026-10-05"); + expect(res.status).toBe(422); + expect(((await res.json()) as ApiError).error.message).toBe("date_fromはdate_to以前の日付を指定してください"); + }); + + it("★F8: 分類で絞って施設が0件 → 0件(全件にならない)・2段目と予定のクエリを撃たない★", async () => { + const body = await list("customer_id=99"); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + expect(errorCalls).toHaveLength(1); + expect(errorCalls[0].where).toMatchObject({ facility_id: { in: [] } }); + expect(mainTimetableCalls).toHaveLength(0); + expect(historyTimetableCalls).toHaveLength(0); + }); + + it("★F9: 施設名が当たり分類も当たるが積集合が空 → 0件(AND)★", async () => { + const body = await list(`facility_name=${encodeURIComponent("テスト施設A")}&customer_id=2`); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("施設名で絞る → 施設B の行だけ(並びは保つ)", async () => { + const body = await list(`facility_name=${encodeURIComponent("施設B")}`); + expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-6"]); + }); + + it("F10: per_page=3&page=2 → 並び順の4〜6行目・meta.total = 8", async () => { + const body = await list("per_page=3&page=2"); + expect(ids(body.items)).toEqual(["E-10", "E-9", "E-6"]); + expect(body.meta).toEqual({ total: 8, page: 2, per_page: 3 }); + }); + + it("★F11: page=99 → items: []・meta.total = 8(404 にしない)・2段目を撃たない★", async () => { + const body = await list("page=99"); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(8); + expect(errorCalls).toHaveLength(1); + }); + + it("★F12: status = 3 の行が混ざっても、既定でも「解消済みも表示」でも出ない・例外にならない★", async () => { + errorReports.push(row({ error_id: "E-X", status: 3, errored_at: db("2026-10-06T09:00:00+09:00") })); + expect(ids((await list()).items)).not.toContain("E-X"); + expect(ids((await list("include_resolved=true")).items)).not.toContain("E-X"); + }); +}); + +// ---------------------------------------------------------------- 2段階で読む(D8) + +describe("2段階で読む(指示書AW D8)", () => { + it("★1段目の where は errorReportListWhere() の戻りと同じ(ルートが別の条件を組み立てていない)★", async () => { + await list("date_from=2026-10-05&date_to=2026-10-06&customer_id=1&error_type=x&page=2&per_page=3"); + expect(errorCalls[0].where).toEqual( + errorReportListWhere({ + include_resolved: false, + facility_ids: [1], + date_from: "2026-10-05", + date_to: "2026-10-06", + error_type: "x", + page: 2, + per_page: 3, + }) + ); + }); + + it("★1段目の select は ROBOT_ERROR_LIST_KEY_SELECT、2段目は ROBOT_ERROR_LIST_ROW_SELECT★", async () => { + await list("per_page=3"); + expect(errorCalls).toHaveLength(2); + expect(errorCalls[0].select).toEqual(ROBOT_ERROR_LIST_KEY_SELECT); + expect(errorCalls[1].select).toEqual(ROBOT_ERROR_LIST_ROW_SELECT); + // ★2段目はそのページの error_id だけ★ + expect(errorCalls[1].where).toEqual({ error_id: { in: ["E-7", "E-11", "E-8"] } }); + }); + + it("★0件のとき2段目と予定のクエリを撃たない★", async () => { + const body = await list("error_type=no-such-type"); + expect(body).toEqual({ items: [], meta: { total: 0, page: 1, per_page: 50 } }); + expect(errorCalls).toHaveLength(1); + expect(mainTimetableCalls).toHaveLength(0); + expect(historyTimetableCalls).toHaveLength(0); + }); + + it("予定のないページ(E4 だけ)では予定のクエリを撃たない", async () => { + const body = await list("per_page=1&page=4"); + expect(ids(body.items)).toEqual(["E-10"]); + expect(mainTimetableCalls).toHaveLength(0); + expect(historyTimetableCalls).toHaveLength(0); + }); + + it("★1段目と2段目の間に消えた行は飛ばす(例外にしない)・meta.total は1段目の件数のまま★", async () => { + vanishOnSecondStage = new Set(["E-11"]); + const body = await list(); + expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-11")); + expect(body.meta.total).toBe(8); + }); + + it("DB の例外 → 500「データの取得に失敗しました」", async () => { + throwOnErrorFindMany = true; + const res = await get(); + expect(res.status).toBe(500); + expect(((await res.json()) as ApiError).error).toMatchObject({ + code: "internal_error", + message: "データの取得に失敗しました", + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts new file mode 100644 index 0000000..0aa3fe6 --- /dev/null +++ b/app/api/errors/route.ts @@ -0,0 +1,79 @@ +/** + * GET /errors — エラー一覧(`robot_error_reports` の未対応エラー。D-160 / 指示書AW 段階4-2) + * + * 形は `app/api/clean-logs/route.ts`(動く見本)と `app/api/prechecks/route.ts` に揃えている。 + * 仕様の正は `d-watch-api.yaml` の /errors。 + * + * ★対象は `robot_error_reports` の全行★(事前チェック・本清掃・予定なし・制御盤・セキュリティ・清掃開始失敗を問わない)。 + * `/prechecks`(アラート画面)は事前チェック行に紐づくエラーと出発失敗だけなので、そちらとは別のルートにした。 + * + * このルートがやっていること + * 1. クエリパラメータのパース(`parseErrorReportQuery()`。不正なら422) + * 2. ★施設名・分類 → 施設ID の解決★(`resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()` を + * `intersectFacilityIds()` で AND に畳む。`/clean-logs` と同じ。★`null` = 絞らない / `[]` = 0件★) + * 3. `db.errorReports()` — ★絞り込み・並べ替え・ページ分けはデータ層の中★(2段階で読む。指示書AW D8)。 + * ★`errored_at ?? created_at` の順は Prisma のクエリビルダで書けない★ので、ルートで並べ直す必要は無い + * (fixtures 版・Prisma 版とも `filterSortErrorReportKeys()` を通る) + * 4. `{ items, meta }` 形式で返却(`summary` は無い) + * + * ★対応状況の更新(PATCH /errors/{error_id}/status)は `app/api/errors/[error_id]/status/route.ts`★ + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { parseErrorReportQuery } from "@/lib/error-reports/query"; +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, ErrorReportItem, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/errors"; + +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 = parseErrorReportQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + const q = parsed.query; + + let result: { items: ErrorReportItem[]; total: number }; + try { + // ★★2本の解決結果は `intersectFacilityIds()` で AND に畳む★★(`/clean-logs` と同じ。指示書AW D10)— + // ★どちらか片方を捨てないこと★(`??` で書くと片方の条件が静かに消える)。 + // ★積集合が空なら `[]`(=0件)★。`null` にすると「絞らない」になり、0件ヒットが全件表示に化ける(罠1) + const [nameIds, masterIds] = await Promise.all([ + resolveFacilityIdsByName(q.facilityName), + resolveFacilityIdsByMasters(q), + ]); + result = await db.errorReports({ + include_resolved: q.includeResolved, + facility_ids: intersectFacilityIds(nameIds, masterIds), + // ★`parseErrorReportQuery()` を通った値を渡す★(空文字は `null` に正規化済み) + date_from: q.dateFrom, + date_to: q.dateTo, + error_type: q.errorType, + page: q.page, + per_page: q.perPage, + }); + } 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 body: ListResponse = { + items: result.items, + meta: { total: result.total, page: q.page, per_page: q.perPage }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/facilities/__tests__/route.test.ts b/app/api/facilities/__tests__/route.test.ts new file mode 100644 index 0000000..251b471 --- /dev/null +++ b/app/api/facilities/__tests__/route.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +// ★ルート本体は**静的 import** すること★(`app/api/__tests__/master-filter.test.ts` 冒頭と同じ理由) +import { GET } from "@/app/api/facilities/route"; +import { facilities } from "@/lib/fixtures"; +import type { Facility, ListResponse } from "@/lib/types"; + +/** + * `GET /facilities` のレスポンスに ★所属(屋号・ホールディングス)が載っていること★ + * (指示書AK ★段階5★)。 + * + * ------------------------------------------------------------------------ + * ★★なぜルートまで通すのか★★ + * ------------------------------------------------------------------------ + * `Facility` に列を足しても、★ルートが別の形に詰め替えていれば画面には出ません★。 + * ★「列が1つ静かに欠ける」は例外もテスト失敗も出ない壊れ方★(一覧は200で返り続ける)なので、 + * ★ルート → データソース → JSON★ まで通して固定します + * (`app/api/__tests__/master-filter.test.ts` と同じ考え方)。 + * + * ★絞り込み・並べ替え・ページングはここでは見ない★ — + * それぞれ `lib/facilities/__tests__/query.test.ts` と + * `app/api/__tests__/master-filter.test.ts` の担当。 + */ +describe("GET /facilities: 所属(指示書AK 段階5)", () => { + async function items(): Promise { + // ★`per_page` は上限(200)まで寄せる★(fixtures の12件を1ページに収める) + const res = await GET(new Request("http://localhost/api/facilities?per_page=200")); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + return body.items; + } + + it("全件に `customer_id` / `customer_name` / `group_id` / `group_name` が載る", async () => { + const rows = await items(); + expect(rows).toHaveLength(facilities.length); + for (const row of rows) { + expect(typeof row.customer_id).toBe("number"); + expect(typeof row.customer_name).toBe("string"); + // ★`group_id` は API の形★ — ★`0` を漏らさないこと★(D-011 / ★落とし穴3★) + expect(row.group_id).not.toBe(0); + } + }); + + it("★ホールディングスありの施設★は名前まで返る", async () => { + const row = (await items()).find((f) => f.id === 1); + expect(row).toMatchObject({ + customer_id: 1, + customer_name: "メガバリューマート", + group_id: 1, + group_name: "バリューホールディングス", + }); + }); + + it("★ホールディングスなしの施設★は `group_id` / `group_name` が両方 `null`", async () => { + const row = (await items()).find((f) => f.id === 12); + expect(row).toMatchObject({ + customer_id: 4, + customer_name: "フレッシュタウン湖畔", + group_id: null, + group_name: null, + }); + }); +}); diff --git a/app/api/facilities/route.ts b/app/api/facilities/route.ts new file mode 100644 index 0000000..926d073 --- /dev/null +++ b/app/api/facilities/route.ts @@ -0,0 +1,81 @@ +/** + * GET /facilities — 施設ロボットマスタ(F-08。読み取り専用) + * + * 形は `app/api/ds-devices/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /facilities。 + * + * ★登録・更新は D-Security 側の画面で行うため、D-Watch は GET のみ★(YAMLの description)。 + * ★summary は無い(items と meta だけ)★ — この画面は台帳の閲覧で、 + * /ds-devices のような「異常◯件」のサマリーカードを持たない。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { filterFacilitiesByIds } from "@/lib/facilities/filter-by-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + filterFacilitiesByFields, + filterFacilitiesByQ, + paginateFacilities, + parseFacilityQuery, + sortFacilities, +} from "@/lib/facilities/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, Facility, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/facilities"; + +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); + // ★422 分岐が付いた(指示書AK 段階2)★ — `group_id` / `customer_id` が + // このエンドポイント初の「不正値がありうるパラメータ」になった + // (理由は `parseFacilityQuery` の JSDoc。YAML の /facilities にも 422 を追記済み)。 + const parsed = parseFacilityQuery(searchParams); + if (!parsed.ok) return errorResponse(parsed.error, parsed.status); + const query = parsed.query; + + let rows: Facility[]; + // ★分類(`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.facilities(); + } 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/ds-devices/route.ts` と同じ方針)。 + // q と個別パラメータは AND なので、順に通すだけでよい(OR は q の内部だけ)。 + // ★★分類は `Facility.id` で突き合わせる★★(指示書AK 段階4)— + // ★このAPIが返すのは「施設の行そのもの」★なので、 + // `DsDevice` / `Schedule` のような `facility_id` 列を持たない。 + // ★`Facility` は段階5 で `customer_id` / `group_id` を持ったが、 + // 解決の入口は `resolveFacilityIdsByMasters()` のままにしてある★ — + // 施設名の解決と同じ「施設IDの配列」で積集合を取れる形を崩さないため(§2-2)。 + const filtered = filterFacilitiesByFields( + filterFacilitiesByQ(filterFacilitiesByIds(rows, facilityIds), query.q), + query + ); + const sorted = sortFacilities(filtered); + + const body: ListResponse = paginateFacilities( + sorted, + query.page, + query.perPage + ); + return NextResponse.json(body); + }); +} diff --git a/app/api/groups/route.ts b/app/api/groups/route.ts new file mode 100644 index 0000000..d3c55fa --- /dev/null +++ b/app/api/groups/route.ts @@ -0,0 +1,51 @@ +/** + * GET /groups — ホールディングス一覧(分類マスタ。指示書AK 段階1。読み取り専用) + * + * 形は `app/api/schedules/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /groups。 + * + * ★GET だけ★ — ★POST / PUT / DELETE を足さないこと★(指示書AK §4-2)。 + * グループ・顧客マスタのCRUDは D-Security 側の画面で行う(`docs/spec.md`)。 + * メインDBへの書き込みが許されているのは `robot_error_reports` / + * `control_error_histories` の4カラムだけ(`CLAUDE.md` §3 / D-042)。 + * + * ★認証はここに書かない★ — `proxy.ts` が全ルートを横断して判定する + * (`d-watch-api.yaml` の info。未認証は401、IP拒否は403)。 + * + * ★★ページングしない★★(指示書AK §4-1 #1)— セレクトリスト用なので全件を1ページで返す。 + * `meta` の意味は `lib/masters/query.ts` の冒頭に書いた。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { masterListResponse, sortGroups } from "@/lib/masters/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, Group, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/groups"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + // ★クエリパラメータを持たないので 422 の分岐が無い★ + // (`app/api/facilities/route.ts` と同じ。YAML も 200 のみを定義している) + let rows: Group[]; + try { + rows = await db.groups(); + } 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 }); + } + + // ここから下は純粋な並べ替え。すべて try の外 + // (`app/api/schedules/route.ts` と同じ方針)。 + const body: ListResponse = masterListResponse(sortGroups(rows)); + return NextResponse.json(body); + }); +} diff --git "a/app/api/prechecks/\133id\135/status/__tests__/route.fixtures.test.ts" "b/app/api/prechecks/\133id\135/status/__tests__/route.fixtures.test.ts" new file mode 100644 index 0000000..8b6d679 --- /dev/null +++ "b/app/api/prechecks/\133id\135/status/__tests__/route.fixtures.test.ts" @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/prechecks/[id]/status/route"; +import { GET } from "@/app/api/prechecks/route"; +import { resetFixturePrecheckOverrides } from "@/lib/db/fixture-precheck-overrides"; +import { prechecks } from "@/lib/fixtures/prechecks"; +import type { PrecheckItem, SummaryListResponse } from "@/lib/types"; +import type { PrecheckSummary } from "@/lib/prechecks/query"; + +/** + * `PATCH /prechecks/{id}/status`(`USE_FIXTURES=true`。指示書AT 段階4-3 / §5)。 + * + * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、 + * 同じファイルで true/false の両方は試せない(`app/api/ds-devices/[serial_number]/note/__tests__/` と同じ)。 + * + * fixtures 版は `lib/fixtures/prechecks.ts` を書き換えず、モジュール内の上書き表を持つ。 + * ★テストごとに `resetFixturePrecheckOverrides()` で空に戻す★。 + */ + +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-02T08:15:00+09:00"; + +beforeEach(() => { + resetFixturePrecheckOverrides(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); + resetFixturePrecheckOverrides(); +}); + +async function patch(id: string, body: unknown): Promise { + return PATCH( + new Request(`http://localhost/api/prechecks/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) } + ); +} + +async function listAll(): Promise> { + const res = await GET(new Request("http://localhost/api/prechecks?status=all&per_page=100")); + expect(res.status).toBe(200); + return (await res.json()) as SummaryListResponse; +} + +describe("PATCH /prechecks/{id}/status(USE_FIXTURES=true)", () => { + it("★前提★: id \"1\" は未解消の事前チェック、\"4\"・\"8\" は出発失敗", () => { + const byId = new Map(prechecks.map((p) => [p.id, p])); + expect(byId.get("1")).toMatchObject({ severity: "precheck", resolution_status: "unresolved" }); + expect(byId.get("4")?.severity).toBe("launch_failed"); + expect(byId.get("8")?.severity).toBe("launch_failed"); + }); + + it("PATCH → GET /prechecks に反映される(状態・日時・備考とサマリ)", async () => { + const before = await listAll(); + + const res = await patch("1", { status: "resolved", note: " 現場で解消 " }); + expect(res.status).toBe(200); + const updated = (await res.json()) as PrecheckItem; + expect(updated).toMatchObject({ + id: "1", + resolution_status: "resolved", + responded_at: NOW_ISO, + resolved_at: NOW_ISO, + note: "現場で解消", + }); + + const after = await listAll(); + expect(after.items.find((p) => p.id === "1")).toEqual(updated); + expect(after.summary).toEqual({ + unresolved: before.summary.unresolved - 1, + resolved: before.summary.resolved + 1, + total: before.summary.total, + }); + }); + + it("戻すと日時を消し、備考を空にすると null になる(元の4項目に戻せる)", async () => { + const original = prechecks.find((p) => p.id === "9"); + expect(original).toMatchObject({ resolution_status: "unresolved", note: null }); + + await patch("9", { status: "in_progress", note: "連絡済み" }); + await patch("9", { status: "resolved" }); + const res = await patch("9", { status: "unresolved", note: "" }); + expect(res.status).toBe(200); + const back = (await res.json()) as PrecheckItem; + expect(back).toEqual(original); + }); + + it("★元の fixtures 配列は書き換えない★", async () => { + const snapshot = structuredClone(prechecks); + await patch("1", { status: "in_progress" }); + expect(prechecks).toEqual(snapshot); + }); + + it.each(["4", "8"])("★`launch_failed` の id(%s)は 404★", async (id) => { + const res = await patch(id, { status: "resolved" }); + expect(res.status).toBe(404); + }); + + it("存在しない id は 404", async () => { + const res = await patch("RER-NOT-EXIST", { status: "resolved" }); + expect(res.status).toBe(404); + }); +}); diff --git "a/app/api/prechecks/\133id\135/status/__tests__/route.test.ts" "b/app/api/prechecks/\133id\135/status/__tests__/route.test.ts" new file mode 100644 index 0000000..f1fac14 --- /dev/null +++ "b/app/api/prechecks/\133id\135/status/__tests__/route.test.ts" @@ -0,0 +1,539 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由) +import { PATCH } from "@/app/api/prechecks/[id]/status/route"; +import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { logEvent } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { DbWriteLogEvent } from "@/lib/logger/events"; +import type { PrecheckItem } from "@/lib/types"; + +/** + * `PATCH /prechecks/{id}/status`(Prisma 経路。指示書AT 段階5 / §5 のテスト一覧)。 + * + * ★★DBには接続しません★★ — `getPrisma()`(参照用)と `getWritePrisma()`(書き込み用)を + * スタブに差し替え、`USE_FIXTURES` を `false` に固定して `prismaSource` の経路を走らせる + * (`app/api/prechecks/__tests__/route.test.ts` と同じ作法)。 + * ★`updateMany` の `data` を記録し、★4列のうち該当分だけ★が渡っていることを見る★。 + * (生成される SQL に `updated_at` が入らないことは `lib/db/__tests__/robot-error-write-sql.test.ts` が見る) + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubTimetable { + id: bigint; + parent_id: bigint; + precheck: number; + start_at: Date; +} + +interface StubErrorReport { + error_id: string; + clean_timetable_id: bigint | null; + error_type: string; + errored_at: Date | null; + created_at: Date; + serial_number: string; + facility_id: bigint; + facility: { facility_name: string }; + status: number; + responded_at: Date | null; + resolved_at: Date | null; + comment: string | null; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id?: bigint | null; +} + +/** + * `clean_task_reports` の1行(親の本清掃の結果。`main_run_succeeded` に使う。D-171)。 + * ★完了率は面積から出す★(`runCompletionRate()`。`clean_ratio` 列は読まない。D-155) + */ +interface StubTaskReport { + clean_timetable_id: bigint | null; + clean_area: number; + task_area: number; + clean_ratio: number; + started_at: Date; + ended_at: Date | null; +} + +interface UpdateManyArgs { + where: { error_id: string }; + data: Record; +} + +let timetables: StubTimetable[]; +/** ★`clean_timetable_histories` の行(指示書AU 段階3b)。`buildData()` は空にする★ */ +let historyTimetables: StubTimetable[]; +let errorReports: StubErrorReport[]; +let taskReports: StubTaskReport[]; +/** `cleanTaskReport.findMany` に渡された `where`(★1回だけ・親の本清掃の id で引くこと★。D-171) */ +let taskReportWheres: unknown[]; +let updateCalls: UpdateManyArgs[]; +/** 次の `updateMany` の振る舞い(既定は「一致した行に data を当てる」) */ +let updateBehavior: "apply" | "throw" | "no_match"; + +/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */ +function ciEqual(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +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: { where: { error_id: string }; select?: Record }) => { + // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★ + guardNullRobotId(errorReports as unknown as Record[], args); + return errorReports.find((r) => ciEqual(r.error_id, args.where.error_id)) ?? null; + }, + updateMany: async () => { + throw new Error("[stub] 参照用の接続で updateMany を呼んではいけない"); + }, + }, + // ★予定は本表 → 無ければ履歴の順に id で引く(`findTimetableById()`。指示書AU 段階3b-2 の 5)★。 + // 旧: 親の本清掃行は `findFirst({ id, precheck })` — いまは id で引いて `precheck` を JS で確かめる + cleanTimetable: { + findUnique: async (args: { where: { id: bigint } }) => + timetables.find((t) => t.id === args.where.id) ?? null, + }, + cleanTimetableHistory: { + findUnique: async (args: { where: { id: bigint } }) => + historyTimetables.find((t) => t.id === args.where.id) ?? null, + }, + // ★親の本清掃のレポート(`main_run_succeeded`。D-171)★ + cleanTaskReport: { + findMany: async (args: { where: { clean_timetable_id: bigint } }) => { + taskReportWheres.push(args.where); + return taskReports.filter((r) => r.clean_timetable_id === args.where.clean_timetable_id); + }, + }, + }; + 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 }; +}); + +// ---------------------------------------------------------------- 合成データ + +/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115) */ +function dbWallClock(jstIso: string): Date { + return new Date(jstIso.replace("+09:00", "Z")); +} + +const NOW_ISO = "2026-10-02T08:15:00+09:00"; +const T1_ISO = "2026-10-02T07:00:00+09:00"; +const T2_ISO = "2026-10-02T07:30:00+09:00"; + +function errorReport( + errorId: string, + timetableId: number | null, + overrides: Partial = {} +): StubErrorReport { + return { + error_id: errorId, + clean_timetable_id: timetableId === null ? null : BigInt(timetableId), + error_type: "EmergencyStop", + errored_at: dbWallClock("2026-10-02T01:00:00+09:00"), + created_at: dbWallClock("2026-10-02T01:00:00+09:00"), + serial_number: "8111R5628060011", + facility_id: BigInt(1), + // ★実在の店名を使わない★(`CLAUDE.md` §7) + facility: { facility_name: "バリューマート北町店" }, + status: 0, + responded_at: null, + resolved_at: null, + comment: null, + ...overrides, + }; +} + +function buildData() { + historyTimetables = []; + timetables = [ + // 本清掃行(親) + { id: BigInt(101), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") }, + // 事前チェック行(60分前 / 30分前) + { id: BigInt(1011), parent_id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") }, + { id: BigInt(1012), parent_id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[1], start_at: dbWallClock("2026-10-02T01:30:00+09:00") }, + // ★親が存在しない事前チェック行★(構造的に壊れたデータ → 500) + { id: BigInt(2011), parent_id: BigInt(999), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") }, + ]; + // ★親の本清掃 101 は 95%(◎)★ → `main_run_succeeded: true`(D-171 の M1) + taskReports = [ + { + clean_timetable_id: BigInt(101), + clean_area: 95, + task_area: 100, + clean_ratio: 0, + started_at: dbWallClock("2026-10-02T02:00:00+09:00"), + ended_at: dbWallClock("2026-10-02T03:00:00+09:00"), + }, + ]; + taskReportWheres = []; + errorReports = [ + errorReport("RER-UNRESOLVED", 1011), + errorReport("RER-IN-PROGRESS", 1012, { status: 1, responded_at: dbWallClock(T1_ISO), comment: "既存の備考" }), + errorReport("RER-RESOLVED", 1011, { status: 2, responded_at: dbWallClock(T1_ISO), resolved_at: dbWallClock(T2_ISO) }), + // ★予定に紐づかないエラー★(N-5 の D-S制御盤・セキュリティの行を含む)→ 404 + errorReport("RER-NO-TIMETABLE", null), + // ★本清掃行を指すエラー★(`launch_failed` に読み替えない。D-104)→ 404 + errorReport("RER-MAIN", 101), + // ★参照先の予定行が無い★ → 404 + errorReport("RER-DANGLING", 777), + // ★親の本清掃行が無い事前チェック行★ → 500 + errorReport("RER-ORPHAN", 2011), + ]; +} + +async function patch(id: string, body: unknown, raw = false): Promise { + return PATCH( + new Request(`http://localhost/api/prechecks/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: raw ? (body as string) : JSON.stringify(body), + }), + { params: Promise.resolve({ 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 = []; + updateBehavior = "apply"; + vi.mocked(logEvent).mockClear(); + vi.mocked(logApiError).mockClear(); + // ★Date だけを止める★(`now` = 2026-10-02 08:15 JST) + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- 200 + +describe("200 — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => { + it("★robot_id が NULL の行でも読み取りで落ちず、更新後の行を返す★(select で robot_id / robot を読まない)", async () => { + errorReports = errorReports.map((r) => (r.error_id === "RER-UNRESOLVED" ? { ...r, robot_id: null } : r)); + const res = await patch("RER-UNRESOLVED", { status: "in_progress" }); + expect(res.status).toBe(200); + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ id: "RER-UNRESOLVED", resolution_status: "in_progress" }); + }); +}); + +describe("200 — main_run_succeeded(ユーザー指示 2026-10-07。D-171)", () => { + it("★親の本清掃が ◎ なら true★。レポートは親の本清掃の id で1回だけ引く", async () => { + const res = await patch("RER-UNRESOLVED", { status: "in_progress" }); + expect(res.status).toBe(200); + const body = (await res.json()) as PrecheckItem; + // ★対応状況は本清掃の成否に関係なく、送った値のまま★ + expect(body).toMatchObject({ resolution_status: "in_progress", main_run_succeeded: true }); + for (const where of taskReportWheres) expect(where).toEqual({ clean_timetable_id: BigInt(101) }); + }); + + it("親の本清掃にレポートが無ければ false(M4)/ △ なら false(M3)", async () => { + const saved = taskReports; + taskReports = []; + const none = (await (await patch("RER-UNRESOLVED", { status: "in_progress" })).json()) as PrecheckItem; + expect(none.main_run_succeeded).toBe(false); + + taskReports = saved.map((r) => ({ ...r, clean_area: 50 })); + const poor = (await (await patch("RER-UNRESOLVED", { status: "resolved" })).json()) as PrecheckItem; + expect(poor.main_run_succeeded).toBe(false); + }); +}); + +describe("200 — 状態の遷移(§3-4)", () => { + it("#1 未解消 → 対応中: status と responded_at だけを書き、更新後の行を返す", async () => { + const res = await patch("RER-UNRESOLVED", { status: "in_progress" }); + expect(res.status).toBe(200); + + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].where).toEqual({ error_id: "RER-UNRESOLVED" }); + // ★4列のうち該当分だけ★ + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]); + expect(updateCalls[0].data.status).toBe(1); + // ★`toDbJst()` が通っている★ — 08:15 JST は壁時計値 "2026-10-02T08:15:00Z" として渡る + expect((updateCalls[0].data.responded_at as Date).toISOString()).toBe("2026-10-02T08:15:00.000Z"); + + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ + id: "RER-UNRESOLVED", + severity: "precheck", + 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("RER-UNRESOLVED", { status: "resolved" }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "responded_at", "status"]); + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ resolution_status: "resolved", responded_at: NOW_ISO, resolved_at: NOW_ISO }); + }); + + it("#4 対応中 → 解消: responded_at は変えない", async () => { + const res = await patch("RER-IN-PROGRESS", { status: "resolved" }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "status"]); + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ resolution_status: "resolved", responded_at: T1_ISO, resolved_at: NOW_ISO }); + }); + + it("#8 解消 → 未解消: 両方の日時を null にする(戻すと消す)", async () => { + const res = await patch("RER-RESOLVED", { status: "unresolved" }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ status: 0, responded_at: null, resolved_at: null }); + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ resolution_status: "unresolved", responded_at: null, resolved_at: null }); + }); + + it("#9 解消 → 対応中: resolved_at だけを消す", async () => { + const res = await patch("RER-RESOLVED", { status: "in_progress" }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ status: 1, resolved_at: null }); + }); + + it("備考だけ変える(現在の status を送る): comment だけを書く・前後の空白を除く", async () => { + const res = await patch("RER-IN-PROGRESS", { status: "in_progress", note: " 店長に確認 " }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ comment: "店長に確認" }); + expect(((await res.json()) as PrecheckItem).note).toBe("店長に確認"); + }); + + it("備考を空にする → comment = null", async () => { + const res = await patch("RER-IN-PROGRESS", { status: "in_progress", note: " " }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ comment: null }); + expect(((await res.json()) as PrecheckItem).note).toBeNull(); + }); + + it("★空パッチなら updateMany を呼ばず、現在の値を返す。db.write も出さない★", async () => { + for (const [id, body] of [ + ["RER-UNRESOLVED", { status: "unresolved" }], + ["RER-IN-PROGRESS", { status: "in_progress", note: " 既存の備考 " }], + ["RER-RESOLVED", { status: "resolved" }], + ] as const) { + const res = await patch(id, body); + expect(res.status).toBe(200); + expect(((await res.json()) as PrecheckItem).id).toBe(id); + } + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + }); + + it("★本文に updated_at・error_type・memo を混ぜても data に出ない★", async () => { + const res = await patch("RER-UNRESOLVED", { + 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 が51文字", "A".repeat(51), { status: "resolved" }, false], + ["JSON でない", "RER-UNRESOLVED", "{not json", true], + ["status が enum 外", "RER-UNRESOLVED", { status: "done" }, false], + ["status が無い", "RER-UNRESOLVED", { note: "a" }, false], + ["note が数値", "RER-UNRESOLVED", { status: "resolved", note: 1 }, false], + ["note が null", "RER-UNRESOLVED", { status: "resolved", note: null }, false], + [`note が${PRECHECK_NOTE_MAX_LENGTH + 1}文字`, "RER-UNRESOLVED", { 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("200 — 履歴(clean_timetable_histories)の予定に紐づくエラー行(指示書AU 段階3b。§3b-7 Q4)", () => { + it("★事前チェック行も親の本清掃行も履歴だけにある → 書ける★(実在するアラートなので)", async () => { + historyTimetables = [ + { id: BigInt(301), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-01T02:00:00+09:00") }, + { id: BigInt(3011), parent_id: BigInt(301), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-01T01:00:00+09:00") }, + ]; + errorReports.push(errorReport("RER-HISTORY", 3011)); + const res = await patch("RER-HISTORY", { status: "in_progress" }); + expect(res.status).toBe(200); + const body = (await res.json()) as PrecheckItem; + expect(body).toMatchObject({ id: "RER-HISTORY", scheduled_at: "2026-10-01T02:00:00+09:00" }); + }); + + it("★本表の事前チェック行の親が履歴だけにある → 書ける★", async () => { + historyTimetables = [ + { id: BigInt(999), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") }, + ]; + const res = await patch("RER-ORPHAN", { status: "in_progress" }); + expect(res.status).toBe(200); + }); + + it("★同じ id が本表にもあれば本表が勝つ★(本表の親が本清掃でなければ、履歴が本清掃でも 500)", async () => { + timetables.push({ id: BigInt(999), parent_id: BigInt(0), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") }); + historyTimetables = [ + { id: BigInt(999), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") }, + ]; + const res = await patch("RER-ORPHAN", { status: "in_progress" }); + expect(res.status).toBe(500); + }); +}); + +describe("404 — 対応状況を記録できる行ではない(§3-3)", () => { + it.each([ + ["行が無い", "RER-NOT-EXIST"], + ["clean_timetable_id が null", "RER-NO-TIMETABLE"], + ["本清掃行を指すエラー", "RER-MAIN"], + ["参照先の予定行が無い", "RER-DANGLING"], + // ★出発失敗の id(本清掃の clean_timetables.id の文字列)は error_id として当たらない★ + ["出発失敗の id", "101"], + // ★DB は ci で当たるが JS の === で弾く★(D-4。別の文字列で同じ行を書き換えさせない) + ["大小文字だけ違う id", "rer-unresolved"], + ])("%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("親の本清掃行が無い事前チェック行 → 500(prechecks() と同じく例外)", async () => { + const res = await patch("RER-ORPHAN", { status: "resolved" }); + expect(res.status).toBe(500); + expect(updateCalls).toHaveLength(0); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + }); + + it("★matched === 0(確認と書き込みの間に行が消えた)→ 404。db.write は success: false / no_matching_row★", async () => { + updateBehavior = "no_match"; + const res = await patch("RER-UNRESOLVED", { 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("RER-UNRESOLVED", { 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); + }); +}); + +// ---------------------------------------------------------------- 監査ログ + +describe("db.write(§3-7)", () => { + it("★成功で1件。record_id は文字列・columns は実際に SET した列だけ・備考の中身を含まない★", async () => { + const res = await patch("RER-UNRESOLVED", { 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/prechecks/[id]/status", + method: "PATCH", + table: "robot_error_reports", + record_id: "RER-UNRESOLVED", + 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, + 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("RER-RESOLVED", { 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, + }); + }); +}); diff --git "a/app/api/prechecks/\133id\135/status/route.ts" "b/app/api/prechecks/\133id\135/status/route.ts" new file mode 100644 index 0000000..29b3161 --- /dev/null +++ "b/app/api/prechecks/\133id\135/status/route.ts" @@ -0,0 +1,173 @@ +/** + * PATCH /prechecks/{id}/status — アラート(事前チェック)の対応状況・備考を更新する(指示書AT 段階5) + * + * 仕様の正は `d-watch-api.yaml` の /prechecks/{id}/status。 + * + * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の4列 + * (`status` / `comment` / `responded_at` / `resolved_at`)だけ(D-042 / `CLAUDE.md` §7)。 + * ★書き込みの中身は `lib/db/robot-error-write.ts`★、★パッチを作るのは `planPrecheckStatusUpdate()` だけ★ + * (リクエスト本文のキーが DB に届く経路を作らない)。 + * + * ★順序は 形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し★ + * (`app/api/ds-devices/[serial_number]/note/route.ts` と同じ考え方)。 + * ★書けるのは `/alerts` に事前チェック異常として出る行だけ。出発失敗の id は 404★(§3-3)。 + * + * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。 + */ +import { NextResponse } from "next/server"; +import { parsePathErrorId } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { columnsForPatch } from "@/lib/db/robot-error-write"; +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 { + isEmptyPatch, + parsePrecheckStatusUpdateBody, + planPrecheckStatusUpdate, +} from "@/lib/prechecks/status-update"; +import type { ApiError, ErrorCode, PrecheckItem, PrecheckStatusPatch } from "@/lib/types"; + +const ROUTE = "/api/prechecks/[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 }); +} + +/** API の ISO(+09:00)→ 真の瞬間 */ +function instantOrNull(iso: string | null): Date | null { + return iso === null ? null : new Date(iso); +} + +/** パッチを当てた後の日時(監査ログの `*_new`)。キーが無ければ変わらない */ +function nextIso(patch: PrecheckStatusPatch, 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: PrecheckItem, + patch: PrecheckStatusPatch +): Omit { + return { + route: ROUTE, + method: METHOD, + table: "robot_error_reports", + // ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2) + record_id: id, + columns: columnsForPatch(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), + comment_changed: "note" in patch && patch.note !== undefined, + }; +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + return withRequestId(request, async () => { + // 1. 形式(`error_id` は varchar(50)) + const { id: idRaw } = await params; + const parsedId = parsePathErrorId(idRaw); + if (!parsedId.ok) { + return NextResponse.json(parsedId.error, { status: parsedId.status }); + } + const id = parsedId.query; + + // 2. 本文 + let json: unknown; + try { + json = await request.json(); + } catch { + return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), { + status: 422, + }); + } + const parsedBody = parsePrecheckStatusUpdateBody(json); + if (!parsedBody.ok) { + return NextResponse.json(parsedBody.error, { status: parsedBody.status }); + } + + // ★現在時刻は1回だけ作る★(responded_at と resolved_at が同じ要求で別の時刻にならないように) + const now = new Date(); + + // 3. 実在(★参照用の接続で読む★) + let before: PrecheckItem | null; + try { + before = await db.precheckItemById(id); + } catch (e) { + return internalError("データの取得に失敗しました", e); + } + if (!before) { + return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 }); + } + + // 4. 計画(★何も変わらなければ書かずに現在の値を返す。ログも出さない★) + const patch = planPrecheckStatusUpdate( + { + resolution_status: before.resolution_status, + responded_at: instantOrNull(before.responded_at), + resolved_at: instantOrNull(before.resolved_at), + note: before.note, + }, + parsedBody.query, + now + ); + if (isEmptyPatch(patch)) { + return NextResponse.json(before); + } + + // 5. 書き込み(★書き込み用の接続。updateMany の1文★) + const audit = auditBase(id, before, patch); + let matched: number; + try { + ({ matched } = await db.updatePrecheckErrorColumns(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 NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 }); + } + // ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている) + logDbWrite({ ...audit, success: true }); + + // 6. 読み直し(画面はこの値でその1行を差し替える) + let after: PrecheckItem | null; + try { + after = await db.precheckItemById(id); + } catch (e) { + return internalError("データの取得に失敗しました", e); + } + if (!after) { + return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 }); + } + return NextResponse.json(after); + }); +} diff --git a/app/api/prechecks/__tests__/route.test.ts b/app/api/prechecks/__tests__/route.test.ts new file mode 100644 index 0000000..3e15a60 --- /dev/null +++ b/app/api/prechecks/__tests__/route.test.ts @@ -0,0 +1,668 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、 +// 最初の1本だけがモジュール読み込みを丸ごと抱え込み、フルスイート実行時に既定の +// タイムアウトを踏んで不定期に落ちる(`app/api/clean-logs/__tests__/route.test.ts` の実測)。 +import { GET } from "@/app/api/prechecks/route"; +import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config"; +import { filterFixturePrechecks } from "@/lib/db/fixture-prechecks"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { + filterPrechecksByDate, + parsePrecheckQuery, + type PrecheckQuery, + type PrecheckSummary, +} from "@/lib/prechecks/query"; +import type { PrecheckDbQuery, PrecheckItem, SummaryListResponse } from "@/lib/types"; + +/** + * 指示書AC ★段階4★ の期待値表(#1〜#5)。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * `prechecks()` は ★2種類の行★ を1つの配列で返す(`PrecheckDbQuery` の型コメント): + * 1. `severity: "precheck"` … エラー行。★期間の基準は**親の本清掃行**の `start_at`★ + * 2. `severity: "launch_failed"` … 本清掃行。★期間の基準は自身の `start_at`★ + * ★★片方だけ絞れている、という壊れ方は件数を見ても分かりにくい★★ので、 + * ★#3 で `severity` の内訳★ を、★#4 で「親が期間外・エラー行が期間内」★ を固定する。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、 + * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して + * ★`prismaSource` の経路★ を走らせる(段階2 のテストと同じ作法)。 + * + * ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では + * - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(C-6) + * - ★事前チェック行の親が日をまたぐケース(#4)が1件も無い★ + */ + +// ---------------------------------------------------------------- スタブ(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; + facility: { facility_name: string }; + section: { robots: { serial_number: string }[] }; +} + +/** + * `clean_task_reports` の1行(★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★)。 + * ★`clean_error` 列は無い★(`prisma/schema.prisma` の `CleanTaskReport`)。 + * ★`ended_at` は nullable、`started_at` は NOT NULL★(順序キー。指示書AQ §1-4)。 + */ +interface StubReport { + clean_timetable_id: bigint | null; + clean_ratio: number; + /** ★完了率は面積から出す★(`runCompletionRate()`。D-155)。`main_run_succeeded`(D-171)の判定に使う */ + clean_area: number; + task_area: number; + started_at: Date; + ended_at: Date | null; +} + +interface StubErrorReport { + error_id: string; + clean_timetable_id: bigint | null; + error_type: string; + errored_at: Date | null; + created_at: Date; + serial_number: string; + facility_id: bigint; + facility: { facility_name: string }; + status: number; + responded_at: Date | null; + resolved_at: Date | null; + comment: string | null; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id?: bigint | null; +} + +interface StubData { + timetables: StubTimetable[]; + /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */ + historyTimetables?: StubTimetable[]; + reports: StubReport[]; + errorReports: StubErrorReport[]; +} + +/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */ +const calls: { + cleanTimetable: Record[]; + cleanTimetableHistory: Record[]; + cleanTaskReport: Record[]; + robotErrorReport: Record[]; +} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [] }; + +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)) { + 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; + } 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 })); + +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 + ), + }, + // ★清掃結果の取得元は `clean_task_reports`★(旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3) + 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); + }, + }, + }), +})); + +// ---------------------------------------------------------------- 合成データ + +/** 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 = "バリューマート北町店"; +const FACILITY_2 = "メガバリューマート緑野店"; + +const [FIRST_ROUND_MINUTES, SECOND_ROUND_MINUTES] = PRECHECK_MINUTES_BEFORE; + +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}` }] }, + }; +} + +/** + * ★レポートの行がある = 実施済み★(指示書AQ §1-1。★行が無いときだけ未実施=出発失敗★)。 + * ★`status` は持たせない★ — 値の意味は受領済み(第10回 N-2)だが、判定には使わない(D-155 項2。指示書AQ §1-2)。 + */ +function report( + timetableId: number, + startedJst: string, + endedJst: string | null, + // ★既定は 80%(○)★ — 判定が成功になる値(D-171 の `main_run_succeeded`) + cleanArea = 80, + taskArea = 100 +): StubReport { + return { + clean_timetable_id: BigInt(timetableId), + clean_ratio: 80, + clean_area: cleanArea, + task_area: taskArea, + started_at: dbWallClock(startedJst), + ended_at: endedJst === null ? null : dbWallClock(endedJst), + }; +} + +function errorReport( + errorId: string, + timetableId: number, + facilityId: number, + facilityName: string, + erroredAtJst: string +): StubErrorReport { + return { + error_id: errorId, + clean_timetable_id: BigInt(timetableId), + error_type: "EmergencyStop", + errored_at: dbWallClock(erroredAtJst), + created_at: dbWallClock(erroredAtJst), + serial_number: `8111R56280600${facilityId}`, + facility_id: BigInt(facilityId), + facility: { facility_name: facilityName }, + // ★0 = 未解消★(`resolutionStatusFromRobotErrorStatus()`) + status: 0, + responded_at: null, + resolved_at: null, + comment: null, + }; +} + +/** + * ★★#4 の肝★★: `104` は ★9/11 00:30★ の本清掃で、その事前チェック行 `1041` と + * そのエラー行は ★9/10 23:30 / 23:35★(= ★日をまたぐ★)。 + * ★★`errored_at` や事前チェック行の `start_at` で絞ると 9/10 に現れてしまう★★。 + */ +function buildData(): StubData { + return { + timetables: [ + // --- 本清掃行 --- + timetable(101, 1, FACILITY_1, "2026-09-08T02:00:00+09:00"), // レポートなし → 出発失敗 + timetable(102, 1, FACILITY_1, "2026-09-09T02:00:00+09:00"), // 正常 → 出発失敗にならない + // ★レポートなし → 出発失敗★(旧: 履歴あり・`clean_error=1`。指示書AQ §1-1 で + // `clean_error` の分岐が消え、未実施は「レポートの行が無い」だけになった) + timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"), + timetable(104, 1, FACILITY_1, "2026-09-11T00:30:00+09:00"), // 正常(#4 の親) + // ★★壁時計 20:00 の清掃★★(C-6。★ローカルDBには1件も無い時間帯★) + timetable(301, 2, FACILITY_2, "2026-09-10T20:00:00+09:00"), // レポートなし → 出発失敗 + // ★★未来の予定★★(`isFutureSchedule()` を外すと「出発失敗」として並ぶ。禁止事項8) + timetable(901, 1, FACILITY_1, "2026-10-05T02:00:00+09:00"), + + // --- 事前チェック行(本清掃の子) --- + timetable(1021, 1, FACILITY_1, "2026-09-09T01:00:00+09:00", FIRST_ROUND_MINUTES, 102), + timetable(1022, 1, FACILITY_1, "2026-09-09T01:30:00+09:00", SECOND_ROUND_MINUTES, 102), + // ★★#4: 親(104)は 9/11、この行は 9/10★★ + timetable(1041, 1, FACILITY_1, "2026-09-10T23:30:00+09:00", FIRST_ROUND_MINUTES, 104), + ], + // ★103 の行は置かない★(旧: `clean_error=1` の履歴。`clean_task_reports` には + // 未実施を表す列が無いので、未実施は「行が無い」でしか表せない。指示書AQ §1-1) + reports: [ + report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00"), + // ★`ended_at` が NULL の行★(nullable。指示書AQ §1-4)— ★行がある以上は実施済み★で、 + // 出発失敗に化けないこと(化けると #1 の `launch_failed` が1件増える) + report(104, "2026-09-11T00:30:00+09:00", null), + ], + errorReports: [ + errorReport("E1", 1021, 1, FACILITY_1, "2026-09-09T01:05:00+09:00"), + // ★★#4: エラー行の時刻は 9/10 だが、親の本清掃は 9/11★★ + errorReport("E2", 1041, 1, FACILITY_1, "2026-09-10T23:35:00+09:00"), + // ★★本清掃行を指すエラー行★★ — ★`launch_failed` に読み替えない★(D-104)ので + // ★1件も出てはいけない★(段階4 の引き方でも読み替えが復活していないことの検出) + errorReport("E3", 103, 1, FACILITY_1, "2026-09-10T02:10:00+09:00"), + ], + }; +} + +/** ★9月の行が全部「過去」になる基準時刻★(10/05 の行だけが未来として残る) */ +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/prechecks${search}`)); + return (await res.json()) as SummaryListResponse; +} + +/** 最後に発行された `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; +} + +/** `severity` ごとの件数(★片方だけ絞れている壊れ方の検出に使う★) */ +function bySeverity(items: PrecheckItem[]): Record { + const out: Record = {}; + for (const item of items) out[item.severity] = (out[item.severity] ?? 0) + 1; + return out; +} + +function memoryQuery(partial: Partial): PrecheckQuery { + return { + dateFrom: null, + dateTo: null, + status: "all", + // ★絞り込まない(指示書AK 段階2 で足った2つ。`null` = 条件なし)★ + groupId: null, + customerId: null, + page: 1, + perPage: 50, + ...partial, + }; +} + +beforeEach(() => { + data = buildData(); + calls.cleanTimetable = []; + calls.cleanTimetableHistory = []; + calls.cleanTaskReport = []; + calls.robotErrorReport = []; + // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★ + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- テスト + +describe("GET /prechecks — 日付をSQLに降ろす(指示書AC 段階4)", () => { + it("#1 範囲なし → 段階0 と同じ全件(★2種類の行が両方そろう★)", async () => { + const body = await get("?status=all"); + // ★detected_at の降順(D-052)★ + expect(body.items.map((i) => `${i.severity}:${i.id}`)).toEqual([ + "precheck:E2", + "launch_failed:301", + "launch_failed:103", + "precheck:E1", + "launch_failed:101", + ]); + expect(body.meta.total).toBe(5); + // ★本清掃の絞り込みが SQL に降りていること★ + expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main); + // ★範囲なしのときは `start_at` のキーごと付けない★ + expect(mainQueryWhere()).not.toHaveProperty("start_at"); + }); + + it("★禁止事項8: 未来の予定は「出発失敗」にならない★(`isFutureSchedule()` を外さない)", async () => { + const body = await get("?status=all"); + // ★外すと `901` が `launch_failed` として並び、全施設が常時L3になる★ + expect(body.items.map((i) => i.id)).not.toContain("901"); + }); + + it("★D-104: 本清掃行を指すエラー行を `launch_failed` に読み替えない★", async () => { + const body = await get("?status=all"); + expect(body.items.map((i) => i.id)).not.toContain("E3"); + }); + + it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる(★`toDbJst()` を通す★)", async () => { + await get("?date_from=2026-09-09&date_to=2026-09-10&status=all"); + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + // ★★壁時計値であること★★(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("★★#2 SQL版 = 全件版 + `filterPrechecksByDate()`(完全一致)★★", async () => { + const all = await get("?status=all"); + const sql = await get("?date_from=2026-09-09&date_to=2026-09-10&status=all"); + const memory = filterPrechecksByDate( + all.items, + memoryQuery({ dateFrom: "2026-09-09", dateTo: "2026-09-10" }) + ); + // ★件数だけでなく中身まで比べる★(指示書AC G-3) + expect(sql.items).toEqual(memory); + expect(sql.items.length).toBeGreaterThan(0); + }); + + it("★★#3 `severity` の内訳が全件版と一致する★★(★片方だけ絞れている壊れ方の検出★)", async () => { + const all = await get("?status=all"); + expect(bySeverity(all.items)).toEqual({ precheck: 2, launch_failed: 3 }); + + for (const [from, to] of [ + ["2026-09-08", "2026-09-08"], + ["2026-09-09", "2026-09-09"], + ["2026-09-10", "2026-09-10"], + ["2026-09-11", "2026-09-11"], + ["2026-09-09", "2026-09-11"], + ]) { + const sql = await get(`?date_from=${from}&date_to=${to}&status=all`); + const memory = filterPrechecksByDate( + all.items, + memoryQuery({ dateFrom: from, dateTo: to }) + ); + expect(bySeverity(sql.items)).toEqual(bySeverity(memory)); + expect(sql.items).toEqual(memory); + } + }); + + it("★★#4 親の本清掃行が期間外・エラー行が期間内 → 親の `start_at` で判定される★★", async () => { + // ★E2 のエラー行と事前チェック行は 9/10、親の本清掃(104)は 9/11★ + const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all"); + expect(sep11.items.map((i) => i.id)).toEqual(["E2"]); + // ★`scheduled_at` は親の値(= `filterPrechecksByDate()` が見る値)★ + expect(sep11.items[0].scheduled_at).toBe("2026-09-11T00:30:00+09:00"); + // ★`detected_at` は 9/10 のまま(★絞り込みの基準ではない★)★ + expect(sep11.items[0].detected_at).toBe("2026-09-10T23:35:00+09:00"); + + // ★★9/10 を指定しても E2 は出ない★★ — + // ★`errored_at` や事前チェック行の `start_at` で絞っていると、ここで E2 が混ざる★ + const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all"); + expect(sep10.items.map((i) => i.id)).not.toContain("E2"); + expect(sep10.items.map((i) => i.id)).toEqual(["301", "103"]); + }); + + it("★#4(構造)事前チェック行は親の本清掃の id(`parent_id`)で引く★", async () => { + await get("?date_from=2026-09-11&date_to=2026-09-11&status=all"); + 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, + }); + // ★エラー行は事前チェック行の id でしか引かない(全件読みに戻さない)★ + const errorWhere = ( + calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } } + ).where; + expect(errorWhere.clean_timetable_id.in.map(String)).toEqual(["1041"]); + }); + + it("★壁時計 20:00 の清掃が、その日の範囲に**含まれる**★(C-6。★ローカルDBでは検出できない★)", async () => { + const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all"); + expect(sep10.items.map((i) => i.id)).toContain("301"); + const sep11 = await get("?date_from=2026-09-11&date_to=2026-09-11&status=all"); + expect(sep11.items.map((i) => i.id)).not.toContain("301"); + }); + + it("★レポートは本清掃の id で引く(全件読みに戻さない)★", async () => { + await get("?date_from=2026-09-10&date_to=2026-09-10&status=all"); + const reportWhere = ( + calls.cleanTaskReport.find((c) => "where" in c) as { + where: { clean_timetable_id: { in: bigint[] } }; + } + ).where; + expect(reportWhere.clean_timetable_id.in.map(String).sort()).toEqual(["103", "301"].sort()); + }); + + it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => { + const body = await get("?date_from=2026-01-01&date_to=2026-01-02&status=all"); + expect(body.items).toEqual([]); + expect(calls.robotErrorReport).toEqual([]); + expect(calls.cleanTaskReport).toEqual([]); + }); + + it("★summary は日付範囲で絞ったあとの件数になる★(D-054。SQL に降ろしても変わらない)", async () => { + const body = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all"); + expect(body.summary).toEqual({ unresolved: 2, resolved: 0, total: 2 }); + }); +}); + +describe("★#5 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★(禁止事項6)", () => { + /** + * ★同じデータに対して★、 + * - Prisma 版 = ★SQL で絞った結果★ + * - fixtures 版 = ★全件版 + `filterFixturePrechecks()`★ + * を突き合わせる(指示書AC G-3「等価テスト」)。 + */ + it("日付範囲: SQL版 = 全件版 + fixtures 版の絞り込み", async () => { + const all = await get("?status=all"); + const sql = await get("?date_from=2026-09-09&date_to=2026-09-11&status=all"); + const viaFixtures = filterFixturePrechecks(all.items, { + date_from: "2026-09-09", + date_to: "2026-09-11", + facility_ids: null, + }); + expect(sql.items).toEqual(viaFixtures); + expect(sql.items.length).toBeGreaterThan(0); + }); + + it("範囲なし: 件数が一致", async () => { + const all = await get("?status=all"); + expect( + filterFixturePrechecks(all.items, { date_from: null, date_to: null, facility_ids: null }) + ).toHaveLength(all.items.length); + }); + + /** + * ★★空文字のクエリ(`?date_to=`)で fixtures 版と Prisma 版が食い違わないこと★★(禁止事項6)。 + * + * `parsePrecheckQuery()` が生値をそのまま下へ渡していた頃、`""` は + * - Prisma 版 … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★ + * - fixtures 版 … `query.date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★ + * となり、★同じ入力で件数が変わっていました★(★例外もテスト失敗も出ない★)。 + * + * ★ルートと同じ経路で組み立てる★ — `parsePrecheckQuery()` の戻り値をそのまま + * `PrecheckDbQuery` に詰め、fixtures 版の絞り込みへ渡す(ルートは `db.prechecks()` に + * 同じ値を渡している)。★テスト側で `null` を書き足さないこと★(それでは穴を再現できない)。 + */ + describe("★空文字のクエリ(`?date_from=` / `?date_to=`)★", () => { + function dbQueryFromSearch(search: string): PrecheckDbQuery { + const parsed = parsePrecheckQuery(new URLSearchParams(search)); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw new Error("query"); + return { + date_from: parsed.query.dateFrom, + date_to: parsed.query.dateTo, + facility_ids: null, + }; + } + + for (const search of ["date_to=", "date_from=", "date_from=&date_to="]) { + it(`\`?${search}\`: SQL版 = fixtures 版 = 全件`, async () => { + const all = await get("?status=all"); + const sql = await get(`?${search}&status=all`); + const viaFixtures = filterFixturePrechecks(all.items, dbQueryFromSearch(search)); + + // ★空文字は「指定なし」=全件★(★片方だけ0件に化けないこと★) + expect(sql.items).toEqual(all.items); + expect(viaFixtures).toEqual(all.items); + expect(viaFixtures).toHaveLength(sql.items.length); + expect(all.items.length).toBeGreaterThan(0); + }); + } + + it("★空文字は `start_at` のキーごと付かない(範囲なしと同じ SQL)★", async () => { + await get("?date_from=&date_to=&status=all"); + expect(mainQueryWhere()).not.toHaveProperty("start_at"); + }); + }); +}); + +describe("GET /prechecks — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => { + it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★(select で robot_id / robot を読まない)", async () => { + const before = await get("?status=all"); + data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null })); + const res = await GET(new Request("http://localhost/api/prechecks?status=all")); + expect(res.status).toBe(200); + const after = (await res.json()) as SummaryListResponse; + expect(after.items).toEqual(before.items); + expect(after.items.map((i) => i.id)).toContain("E1"); + }); +}); + +describe("GET /prechecks — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => { + it("★履歴だけの予定も出る★(出発失敗・事前チェックのエラーとも)。2表に同じ引数が渡る", async () => { + data.historyTimetables = [ + // ★履歴だけの本清掃(レポートなし → 出発失敗)★ + timetable(501, 1, FACILITY_1, "2026-09-12T02:00:00+09:00"), + // ★履歴だけの本清掃(正常)とその事前チェック行(エラーあり)★ + timetable(502, 1, FACILITY_1, "2026-09-13T02:00:00+09:00"), + timetable(5021, 1, FACILITY_1, "2026-09-13T01:00:00+09:00", FIRST_ROUND_MINUTES, 502), + ]; + data.reports.push(report(502, "2026-09-13T02:00:00+09:00", "2026-09-13T03:00:00+09:00")); + data.errorReports.push(errorReport("E9", 5021, 1, FACILITY_1, "2026-09-13T01:05:00+09:00")); + + const body = await get("?status=all"); + const ids = body.items.map((i) => `${i.severity}:${i.id}`); + expect(ids).toContain("launch_failed:501"); + expect(ids).toContain("precheck:E9"); + expect(ids).not.toContain("launch_failed:502"); + + const mainCall = calls.cleanTimetable.find((c) => "include" in c); + expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall); + }); +}); + +/** + * ★`main_run_succeeded`(ユーザー指示 2026-10-07。D-171 の期待値表 M1〜M6)★ — Prisma 版の経路。 + * ★親の本清掃のレポートは `prechecks()` が既に本清掃の id でまとめて引いている★(クエリを増やさない)。 + */ +describe("GET /prechecks — main_run_succeeded(D-171)", () => { + it("親の本清掃が ◎○ なら true、△・レポートなし・出発失敗は false", async () => { + // E1 の親 102 は 95%(◎。M1)、E2 の親 104 は 50%(△。M3) + data.reports = [ + report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 95, 100), + report(104, "2026-09-11T00:30:00+09:00", null, 50, 100), + ]; + // ★M4: 親の本清掃にレポートが無い事前チェックのエラー★(親 101 は出発失敗としても並ぶ) + data.timetables.push( + timetable(1011, 1, FACILITY_1, "2026-09-08T01:00:00+09:00", FIRST_ROUND_MINUTES, 101) + ); + data.errorReports.push(errorReport("E4", 1011, 1, FACILITY_1, "2026-09-08T01:05:00+09:00")); + + const body = await get("?status=all"); + const byKey = new Map(body.items.map((i) => [`${i.severity}:${i.id}`, i.main_run_succeeded])); + expect(byKey.get("precheck:E1")).toBe(true); + expect(byKey.get("precheck:E2")).toBe(false); + expect(byKey.get("precheck:E4")).toBe(false); + // ★M6: 出発失敗の行は常に false★ + const launchFailed = body.items.filter((i) => i.severity === "launch_failed"); + expect(launchFailed.length).toBeGreaterThan(0); + for (const i of launchFailed) expect(i.main_run_succeeded).toBe(false); + }); + + it("M2: ○(72%)は true / M5: task_area 0(×)は false", async () => { + data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 72, 100)]; + expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(true); + + data.reports = [report(102, "2026-09-09T02:00:00+09:00", "2026-09-09T03:00:00+09:00", 500, 0)]; + expect((await get("?status=all")).items.find((i) => i.id === "E1")?.main_run_succeeded).toBe(false); + }); + + it("★レポートは本清掃の id でまとめて1回だけ引く★(N+1 にしない)", async () => { + await get("?status=all"); + expect(calls.cleanTaskReport).toHaveLength(1); + }); +}); + +describe("GET /prechecks — 結果待ちは出発失敗として出さない(D-173。表2 C9)", () => { + // ★103(9/10 02:00〜03:00、レポートなし)で確かめる。表1 と同じ時刻★ + it("★now=04:59(結果待ち)→ `launch_failed:103` を出さない。確定済みの 101 は出す★", async () => { + vi.setSystemTime(new Date("2026-09-10T04:59:00+09:00")); + const ids = (await get("?status=all")).items.map((i) => `${i.severity}:${i.id}`); + expect(ids).not.toContain("launch_failed:103"); + expect(ids).toContain("launch_failed:101"); + }); + + it("★now=05:00(終了+120分ちょうど)→ 確定した出発失敗として出す(表1 P6)★", async () => { + vi.setSystemTime(new Date("2026-09-10T05:00:00+09:00")); + const ids = (await get("?status=all")).items.map((i) => `${i.severity}:${i.id}`); + expect(ids).toContain("launch_failed:103"); + }); +}); diff --git a/app/api/prechecks/route.ts b/app/api/prechecks/route.ts new file mode 100644 index 0000000..98aa4c2 --- /dev/null +++ b/app/api/prechecks/route.ts @@ -0,0 +1,96 @@ +/** + * GET /prechecks — アラート(事前チェック)一覧+サマリ + * + * 形は `app/api/clean-logs/route.ts`(動く見本)に合わせている。 + * 仕様の正は `d-watch-api.yaml` の /prechecks、status=open の意味は docs/decisions.md D-054。 + * + * ★対応状況の更新(PATCH /prechecks/{id}/status)は `app/api/prechecks/[id]/status/route.ts`★(指示書AT) + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + filterPrechecksByDate, + filterPrechecksByStatus, + parsePrecheckQuery, + sortPrechecks, + summarizePrechecks, +} from "@/lib/prechecks/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, PrecheckItem, SummaryListResponse } from "@/lib/types"; +import type { PrecheckSummary } from "@/lib/prechecks/query"; + +const ROUTE = "/api/prechecks"; + +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 = parsePrecheckQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: PrecheckItem[]; + try { + // ★★日付範囲を SQL に降ろす★★(指示書AC 段階4)— + // ★`filterPrechecksByDate()` は**残す**★(指示書AC C-5。絞り込み規則の「正」は + // 純粋関数のまま。SQL 側はその写し)。 + // ★★生の `searchParams` を渡していない★★(§E-2)— `parsePrecheckQuery()` が + // `YYYY-MM-DD` の形式を検証し、不正なら 422 で上で返している。 + // ★`filterPrechecksByDate()` と**同じ文字列**を渡すこと★(片方だけ `Date` に + // 直すと、DBの壁時計値との取り違え(C-6)が入り込む)。 + // ★★分類(`group_id` / `customer_id`)は施設IDの配列に解決してから SQL に渡す★★ + // (指示書AK 段階4)— ★`null` = 絞らない / `[]` = 0件★(★罠1★)。 + // ★`/prechecks` には施設名の検索が無い★ので積集合は要らない + // (`d-watch-api.yaml` のクエリパラメータは date_from / date_to / group_id / + // customer_id / status / page / per_page のみ)。 + // ★★施設名で絞りたくなったら先に YAML を直し、`/clean-logs` と同じく + // `intersectFacilityIds()` で AND に畳むこと★★(`CLAUDE.md` 作業ルール1)。 + // + // ★★サマリ(`summary`)にも効く★★ — ここで母集団ごと絞るので、 + // ★一覧と「未対応◯件」のカードが同じ母集団から作られる★ + // (指示書AK §7-2 が `/dashboard` の数値カードについて言っているのと同じ)。 + rows = await db.prechecks({ + date_from: parsed.query.dateFrom, + date_to: parsed.query.dateTo, + facility_ids: await resolveFacilityIdsByMasters(parsed.query), + }); + } 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 dated = filterPrechecksByDate(rows, parsed.query); + // ★summaryは日付範囲で絞った後・対応状況フィルタの前に計算する(D-054)★ + // 状況フィルタの後だと、既定表示(status=open)で解消済みカードが常に0になり、 + // サマリーの役目を果たさなくなる。日付範囲を変えたときだけ summary が変わる + const summary: PrecheckSummary = summarizePrechecks(dated); + const sorted = sortPrechecks( + filterPrechecksByStatus(dated, parsed.query.status) + ); + + const { page, perPage } = parsed.query; + // ★meta.total と summary.total は意味が違う★ + // meta.total = 一覧(対応状況フィルタ後)の総件数。ページング計算用 + // summary.total = 日付範囲で絞った当日の全件(対応状況フィルタの影響を受けない) + // status=all のときだけ一致する。status=open 等で絞ると meta.total < summary.total になりうる + 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/reports/__tests__/route.test.ts b/app/api/reports/__tests__/route.test.ts new file mode 100644 index 0000000..618b2c3 --- /dev/null +++ b/app/api/reports/__tests__/route.test.ts @@ -0,0 +1,1713 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、 +// 最初の1本だけがモジュール読み込みを丸ごと抱え込み、フルスイート実行時に既定の +// タイムアウトを踏んで不定期に落ちる(`app/api/clean-logs/__tests__/route.test.ts` の実測)。 +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 { GET as GET_KPI } from "@/app/api/reports/kpi/route"; +import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE } from "@/lib/config"; +// ★データ層を直接呼ぶテストが1つだけある★(理由はそのテストの docstring) +import { db } from "@/lib/db"; +import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { + ApiError, + ContinuousProblem, + ErrorBreakdownItem, + FacilityPerformance, + ListResponse, + ReportKpi, +} from "@/lib/types"; + +/** + * 指示書AC ★段階5★ の期待値表(#1〜#9)。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★確定期間(サマリ由来)の値を履歴から再計算しない★(★★禁止事項10 / D-102★★)— + * #1 は「詳細テーブルを引けば別の数字になる」データを置いて、 + * ★サマリの値がそのまま出ること★ と ★詳細テーブルを1行も読まないこと★ を固定する + * - ★★連続日数は期間で絞らない★★(★★C-2 の地雷★★)— #6 は + * ★期間をまたぐ連続(4日)★ を置き、期間で絞ったら出てしまう値(2日)にならないことを見る + * - ★月跨ぎ週(08-30〜08-31 の2日)/月の第1週・最終週の境界★(#3 / #4)— + * ★範囲は `periodRange()` に作らせる★ので、SQL に降りた `start_at` の境界で固定する + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、 + * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して + * ★`prismaSource` の経路★ を走らせる(段階2 / 段階4 のテストと同じ作法)。 + * + * ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では + * - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(C-6) + * - ★月跨ぎ週(8月第6週)に清掃データが無い★ + * - ★「サマリと詳細が食い違う」状態を作れない★(D-102 の検出ができない) + * + * ★ローカルDBとの突き合わせは別で実施済み★ — 段階0(引数なし)と段階5(引数あり)の + * 出力を11期間ぶん採って ★完全一致★ を確認した(`consecutive_problem_days` 込み。 + * 指示書AC 段階5 期待値表 #1 / #2 / #6 / G-3)。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubFacility { + id: bigint; + facility_name: string; + deleted_at: Date | null; + robots: { serial_number: string; deleted_at: Date | null }[]; + /** + * ★分類(ホールディングス/屋号)の解決に使う2列★(指示書AK 段階4b)。 + * `loadFacilityGroupIndex()` が `select: { id, customer_id, customer: { group_id } }` で読む。 + * ★`customers.group_id` は NOT NULL で 0 = ホールディングスなし★(D-011)。 + */ + customer_id: bigint; + customer: { group_id: bigint }; +} + +interface StubTimetable { + id: bigint; + facility_id: bigint; + parent_id: bigint; + start_at: Date; + /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */ + end_at: Date; + precheck: number; +} + +/** + * ★清掃結果の取得元は `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; + task_area: number; + clean_area: number; +} + +interface StubErrorReport { + clean_timetable_id: bigint | null; + error_type: string; + errored_at: Date | null; + created_at: Date; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id?: bigint | null; +} + +interface StubSummary { + id: bigint; + facility_id: bigint; + customer_id: bigint; + year: number; + month: number; + week: number; + operation_ratio: number; + launch_ratio: number; + clean_ratio: number; + error_ratio: number; +} + +interface StubErrorSummary { + id: bigint; + facility_id: bigint; + year: number; + month: number; + week: number; + error_type: string; + error_count: number; +} + +/** + * 顧客マスタ(`customers`)。★段階6 で `/reports/kpi` が読むようになった★ — + * ★対象顧客を1社に決める材料★(`resolveTargetCustomer()`。指示書AK §2-1)。 + * ★既定は1社だけ★にしてある(=P1の実データ相当)ので、 + * ★`customer_id` を付けない既存のテストは今までどおり 200★ になる。 + */ +interface StubCustomer { + id: bigint; + customer_name: string; + /** ★NOT NULL。0 = ホールディングスなしのセンチネル★(D-011) */ + group_id: bigint; + deleted_at: Date | null; +} + +interface StubData { + facilities: StubFacility[]; + customers: StubCustomer[]; + timetables: StubTimetable[]; + /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */ + historyTimetables?: StubTimetable[]; + reports: StubReport[]; + errorReports: StubErrorReport[]; + customerSummaries: StubSummary[]; + facilitySummaries: StubSummary[]; + facilityErrorSummaries: StubErrorSummary[]; +} + +/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */ +const calls: Record[]> = { + facility: [], + customer: [], + cleanTimetable: [], + cleanTimetableHistory: [], + cleanTaskReport: [], + robotErrorReport: [], + customerSummary: [], + facilitySummary: [], + facilityErrorSummary: [], +}; + +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` のうち、★本作業が使う分だけ★ を再現する(段階4 のテストの形 + `OR`)。 + * + * ★★`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)) { + if (field === "OR") { + // ★確定期間 + 前期の2期間を1クエリで引くための `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; + } 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 })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + facility: { + findMany: async (args?: Record) => + findMany("facility", data.facilities as unknown as Record[], args), + }, + customer: { + findMany: async (args?: Record) => + findMany("customer", data.customers as unknown as Record[], args), + }, + 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 + ); + }, + }, + customerSummary: { + findMany: async (args?: Record) => + findMany( + "customerSummary", + data.customerSummaries as unknown as Record[], + args + ), + }, + facilitySummary: { + findMany: async (args?: Record) => + findMany( + "facilitySummary", + data.facilitySummaries as unknown as Record[], + args + ), + }, + facilityErrorSummary: { + findMany: async (args?: Record) => + findMany( + "facilityErrorSummary", + data.facilityErrorSummaries 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 = "バリューマート北町店"; +const FACILITY_2 = "メガバリューマート緑野店"; +/** ★中点入り★ — 記号を無視する照合(`includesFoldName`)の検証用(指示書AG) */ +const FACILITY_3 = "バリュー・マート三つ角店"; + +const [FIRST_ROUND_MINUTES] = PRECHECK_MINUTES_BEFORE; + +function timetable( + id: number, + facilityId: number, + jstIso: string, + precheck: number = CLEAN_TIMETABLE_PRECHECK.main, + parentId = 0 +): StubTimetable { + return { + id: BigInt(id), + facility_id: BigInt(facilityId), + parent_id: BigInt(parentId), + start_at: dbWallClock(jstIso), + end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000), + precheck, + }; +} + +/** + * `clean_task_reports` の1行(=その本清掃は実施された)。 + * + * ★★「出発失敗(未実施)」はこの関数で作らない★★ — ★未実施 = 対応するレポートの行が無い★ + * (指示書AQ §1-1)。旧 `history(id, CLEAN_ERROR.notExecuted, …)` の行は ★行ごと置かない★ことで表す。 + */ +function report( + timetableId: number, + cleanRatio: number, + taskArea: number, + cleanArea: number +): StubReport { + return { + clean_timetable_id: BigInt(timetableId), + clean_ratio: cleanRatio, + // ★稼働率の分子(正常終了)に使われる★(4 = 清掃正常終了。第11回 P-6=A。`isCompletedTask()`)。 + // ★4 以外にすると当期の稼働率が変わる★(◎○△× と未実施の判定には使わない。D-155 項2) + status: 4, + started_at: dbWallClock("2026-09-29T23:00:00+09:00"), + ended_at: dbWallClock("2026-09-30T00:00:00+09:00"), + task_area: taskArea, + clean_area: cleanArea, + }; +} + +/** 顧客マスタ1行(`db.customers()` が `deleted_at IS NULL` で引く) */ +function customer(id: number, name: string, groupId: number): StubCustomer { + return { + id: BigInt(id), + customer_name: name, + group_id: BigInt(groupId), + deleted_at: null, + }; +} + +function errorReport(timetableId: number | null, type: string): StubErrorReport { + return { + clean_timetable_id: timetableId === null ? null : BigInt(timetableId), + error_type: type, + errored_at: dbWallClock("2026-09-13T02:30:00+09:00"), + created_at: dbWallClock("2026-09-13T02:30:00+09:00"), + }; +} + +/** + * @param customerId ★既定は1社(=P1の実データ相当)★。 + * ★2社目を置きたいテストだけが明示的に渡す★(指示書AH 段階3 / D-5) + */ +function summary( + id: number, + facilityId: number, + year: number, + month: number, + week: number, + ratios: [number, number, number, number], + customerId = 1 +): StubSummary { + return { + id: BigInt(id), + facility_id: BigInt(facilityId), + customer_id: BigInt(customerId), + year, + month, + week, + operation_ratio: ratios[0], + launch_ratio: ratios[1], + clean_ratio: ratios[2], + error_ratio: ratios[3], + }; +} + +/** + * ★2026年9月の週(`periodRange()` の実測)★: + * 第1週 = 09-01〜09-05(★1日から。5日間★) / 第2週 = 09-06〜09-12 / + * ★第3週 = 09-13〜09-19(`NOW` の当期)★ + * ★2026年8月の週★: 第5週 = 08-23〜08-29 / ★第6週 = 08-30〜08-31(★月跨ぎ・2日★) + */ +function buildData(): StubData { + return { + // ★所属(指示書AK 段階4b)★: 施設1 = 屋号1(HD1)/ 施設2 = 屋号2(HD1)/ + // 施設3 = 屋号3(★ホールディングスなし。`group_id = 0` がセンチネル★。D-011) + facilities: [ + { id: BigInt(1), facility_name: FACILITY_1, deleted_at: null, robots: [{ serial_number: "8111R56280600001", deleted_at: null }], customer_id: BigInt(1), customer: { group_id: BigInt(1) } }, + { id: BigInt(2), facility_name: FACILITY_2, deleted_at: null, robots: [{ serial_number: "8111R56280600002", deleted_at: null }], customer_id: BigInt(2), customer: { group_id: BigInt(1) } }, + { id: BigInt(3), facility_name: FACILITY_3, deleted_at: null, robots: [{ serial_number: "8111R56280600003", deleted_at: null }], customer_id: BigInt(3), customer: { group_id: BigInt(0) } }, + ], + // ★★既定は屋号1の1社だけ★★(指示書AK 段階6)— ★P1 の実データ相当★。 + // ★2社目を置きたいテスト(6-4 / 6-7)だけが `data.customers` を差し替える★。 + // ★施設2・3 が指す屋号2・3 はマスタに置かない★ — 置くと「顧客が3社」になり、 + // ★`customer_id` を付けていない既存のテストが全部 422 になる★。 + customers: [customer(1, "屋号1", 1)], + timetables: [ + // --- 先週(第2週。★確定期間。サマリがある★)--- + // ★詳細から集計すると稼働率 25% になるが、サマリは 90% と言っている★(D-102 の検出) + timetable(201, 1, "2026-09-08T02:00:00+09:00"), + timetable(202, 1, "2026-09-10T02:00:00+09:00"), + // --- 当期(第3週)--- + timetable(301, 1, "2026-09-13T02:00:00+09:00"), + timetable(302, 1, "2026-09-14T02:00:00+09:00"), + // ★事前チェック行(301の子)★ — ★本清掃として数えてはいけない★ + timetable(3011, 1, "2026-09-13T01:00:00+09:00", FIRST_ROUND_MINUTES, 301), + // ★★未来の予定★★(`isFutureSchedule()` を外すと分母に入る。禁止事項8) + timetable(305, 1, "2026-09-18T02:00:00+09:00"), + // --- 施設2: ★期間をまたぐ連続(09-10 / 09-12 / 09-14 / 09-15 の4日)★ --- + timetable(311, 2, "2026-09-10T02:00:00+09:00"), + timetable(312, 2, "2026-09-12T02:00:00+09:00"), + timetable(313, 2, "2026-09-14T02:00:00+09:00"), + // ★★壁時計 20:00 の清掃★★(C-6。★ローカルDBには1件も無い時間帯★) + timetable(314, 2, "2026-09-15T20:00:00+09:00"), + // --- 施設3(施設名の記号ゆれ検索用)--- + timetable(321, 3, "2026-09-16T02:00:00+09:00"), + // --- 月境界(#3 / #4)--- + // ★★施設3 に置いてある★★ — ★施設1・2 には第1週のサマリ行があり、 + // 確定期間の値が優先される(=詳細の集計を観測できない)ため★。 + // 施設3 にはサマリが1行も無いので、★境界の行が入ったかどうかが数字に出る★。 + timetable(401, 3, "2026-08-29T02:00:00+09:00"), // 8月第5週 + timetable(402, 3, "2026-08-30T02:00:00+09:00"), // ★8月第6週の初日★ + timetable(403, 3, "2026-08-31T02:00:00+09:00"), // ★8月第6週の末日(=月末)★ + timetable(404, 3, "2026-09-01T02:00:00+09:00"), // ★9月第1週の初日(=1日)★ + timetable(405, 3, "2026-09-05T02:00:00+09:00"), // ★9月第1週の最終日★ + timetable(406, 3, "2026-09-06T02:00:00+09:00"), // 9月第2週 + ], + // ★★未実施(202 / 302 / 311 / 312 / 313 / 403)はレポートの行を置かない★★(指示書AQ §1-1)— + // 旧データでは `clean_error = notExecuted` の履歴行として置いていた。 + // ★`clean_task_reports` には未実施を表す列が無いので「行が無い」で表す★ + reports: [ + report(201, 80, 100, 80), + report(301, 95, 100, 95), + // ★△(10以上70未満)= 問題日★ + report(314, 50, 100, 50), + report(321, 100, 100, 100), + report(401, 100, 100, 100), + report(402, 90, 100, 90), + report(404, 100, 100, 100), + report(405, 60, 100, 60), + report(406, 90, 100, 90), + ], + errorReports: [ + // ★★本清掃行(301)を指すエラー → 数える★★ + errorReport(301, "EmergencyStop"), + // ★★事前チェック行(3011)を指すエラー → 数えない★★(D-118 / D-114 項2) + errorReport(3011, "HandleUp"), + // ★`clean_timetable_id` が null → 数えない★(決定3) + errorReport(null, "LostLocation"), + // ★dangling(存在しない本清掃を指す)→ 数えない★(決定3) + errorReport(9999, "BrushError"), + // ★先週の本清掃を指すエラー → 当期には入らない★ + errorReport(201, "EmergencyStop"), + ], + // ★全社サマリ: 第2週(確定)とその前期(第1週)★ + customerSummaries: [ + summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]), + summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]), + // ★年次(month=0 && week=0)の行★ — ★2026-09-30 に年次を解禁した(指示書AQ 決定1)★。 + // 旧コメントは「年次は読まない(D-072項4)」。★週次・月次の要求に混ざらないこと★は + // 期間キーの完全一致と `periodOfSummaryRow()` が担う(年次のテストは末尾の describe) + summary(3, 0, 2026, 0, 0, [11, 11, 11, 11]), + ], + facilitySummaries: [ + summary(11, 1, 2026, 9, 2, [95, 95, 88, 0]), + summary(12, 2, 2026, 9, 2, [60, 60, 40, 20]), + summary(13, 1, 2026, 9, 1, [85, 85, 80, 0]), + summary(14, 2, 2026, 9, 1, [50, 50, 30, 25]), + ], + facilityErrorSummaries: [ + { id: BigInt(21), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "HandleUp", error_count: 2 }, + { id: BigInt(22), facility_id: BigInt(2), year: 2026, month: 9, week: 2, error_type: "HandleUp", error_count: 1 }, + { id: BigInt(23), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "EmergencyStop", error_count: 1 }, + // ★別の期間の行は混ざらない★ + { id: BigInt(24), facility_id: BigInt(1), year: 2026, month: 9, week: 1, error_type: "BrushError", error_count: 9 }, + ], + }; +} + +/** ★当期 = 2026年9月第3週(09-13〜09-19)★。09-18 の予定はまだ未来 */ +const NOW = new Date("2026-09-16T09:00:00+09:00"); + +/** 当期(第3週) */ +const CURRENT = "period=weekly&year=2026&month=9&week=3"; +/** 確定期間(第2週。サマリがある) */ +const CONFIRMED = "period=weekly&year=2026&month=9&week=2"; + +async function kpi(search: string): Promise { + const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`)); + return (await res.json()) as ReportKpi; +} + +async function performance(search: string): Promise> { + const res = await GET_PERFORMANCE( + new Request(`http://localhost/api/reports/facility-performance?${search}`) + ); + return (await res.json()) as ListResponse; +} + +async function continuous(search: string): Promise> { + const res = await GET_CONTINUOUS( + new Request(`http://localhost/api/reports/continuous-problems?${search}`) + ); + return (await res.json()) as ListResponse; +} + +async function breakdown(search: string): Promise<{ items: ErrorBreakdownItem[] }> { + const res = await GET_BREAKDOWN( + new Request(`http://localhost/api/reports/error-breakdown?${search}`) + ); + return (await res.json()) as { items: ErrorBreakdownItem[] }; +} + +/** 当期の集計で使われた `clean_timetables` の本体クエリ(= `where.precheck` を持つもの) */ +function mainQueryWhere(): Record { + const call = calls.cleanTimetable.find((c) => !isConsecutiveQuery(c) && "where" in c); + expect(call).toBeDefined(); + return (call as { where: Record }).where; +} + +/** + * ★連続日数用のクエリか★(`loadConsecutiveInputs()` が発行する1本)。 + * + * ★★`select` の中身で見分けること★★ — 段階6 でこのクエリにも + * `where: { precheck, start_at }` が付いたので、★「`where` を持たない」では + * 当期の主クエリ(`select: { id, facility_id, start_at }`)と区別できない★。 + * ★連続日数用だけが `precheck` を**読む**(本清掃の絞り込みをメモリ側でも行うため)★。 + */ +function isConsecutiveQuery(call: Record): boolean { + const select = call.select as Record | undefined; + return select?.precheck === true && select.facility_id === true; +} + +/** ★連続日数用のクエリの `where`★ */ +function consecutiveQueryWhere(): Record { + const call = calls.cleanTimetable.find(isConsecutiveQuery); + expect(call).toBeDefined(); + return (call as { where: Record }).where; +} + +beforeEach(() => { + data = buildData(); + for (const key of Object.keys(calls)) calls[key] = []; + // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★ + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- テスト + +describe("#1 確定期間 — ★サマリの値をそのまま返す(履歴から再計算しない)★", () => { + it("KPI はサマリの4率そのもの。前期比は1つ前の期間のサマリから", async () => { + const body = await kpi(CONFIRMED); + // ★★詳細から集計すると稼働率は 25%(4件中1件)になる★★ — サマリの 90 が出ること + expect(body.operation_rate.value).toBe(90); + expect(body.operation_rate.prev).toBe(80); + expect(body.launch_rate.value).toBe(88); + expect(body.completion_rate.value).toBe(77.7); + expect(body.error_rate.value).toBe(5.5); + }); + + it("★★確定期間では詳細テーブルを1行も読まない★★(★禁止事項10 / D-102★)", async () => { + await kpi(CONFIRMED); + expect(calls.cleanTimetable).toEqual([]); + expect(calls.cleanTaskReport).toEqual([]); + expect(calls.robotErrorReport).toEqual([]); + }); + + it("★サマリは「要求された期間 + 前期」の2期間だけ引く(全件読みに戻さない)★", async () => { + await kpi(CONFIRMED); + expect(calls.customerSummary).toHaveLength(1); + const where = calls.customerSummary[0].where as { OR: Record[] }; + expect(where.OR).toEqual([ + { year: 2026, month: 9, week: 2 }, + { year: 2026, month: 9, week: 1 }, + ]); + }); + + it("施設別パフォーマンスもサマリの値。前期比つき", async () => { + const body = await performance(`${CONFIRMED}&limit=100`); + const fac1 = body.items.find((item) => item.facility_id === 1); + expect(fac1?.operation_rate.value).toBe(95); + expect(fac1?.operation_rate.prev).toBe(85); + expect(fac1?.completion_rate.value).toBe(88); + // ★★確定期間では詳細テーブルを読まない(連続日数の1本を除く)★★ + expect(calls.cleanTimetable.filter((c) => !isConsecutiveQuery(c))).toEqual([]); + }); + + it("エラー種別内訳はサマリを施設ぶん足し上げる(件数の降順)", async () => { + const body = await breakdown(CONFIRMED); + expect(body.items.map((i) => [i.type, i.count])).toEqual([ + ["HandleUp", 3], + ["EmergencyStop", 1], + ]); + // ★別の期間(第1週)の行が混ざっていない★ + expect(body.items.some((i) => i.type === "BrushError")).toBe(false); + expect(calls.facilityErrorSummary[0].where).toEqual({ year: 2026, month: 9, week: 2 }); + }); +}); + +describe("#2 当期(進行中の週)— ★詳細テーブルからその場集計する★", () => { + /** + * ★★2026-09-16(指示書AL ★段階1★)に期待値を作り直した★★。 + * + * ★以前は「全施設(1・2・3)の合算」を固定していた★(planned=5 / executed=3 / + * 完了率 49 / エラー率 33.333)。★当期KPIが顧客で絞られていなかったため★で、 + * ★★その数字は「確定期間は顧客1社・当期は全社」という食い違いそのもの★★だった。 + * + * ★いまは `customer_id` 未指定でも顧客は1社に定まる★(`buildData()` の顧客マスタは + * ★屋号1 の1社だけ=P1の実データ相当★)ので、★その顧客の全施設=施設1だけ★が母集団になる + * (期待値表 ★1-5★)。★テストを消したのではなく、正しい母集団に直した★。 + */ + it("KPI は詳細からの集計。前期比は先週のサマリから(★対象顧客=屋号1 の施設1だけ★)", async () => { + const body = await kpi(CURRENT); + // planned=2(301/302。★305 は未来なので入らない★)/ executed=1(301) + expect(body.operation_rate.value).toBe(50); + expect(body.launch_rate.value).toBe(50); + // 47.5 → 95: 面積比 (95+0)/200 → 1回ごとの完了率(`runCompletionRate()`)の平均(指示書AS。95/100 → 95)。 + // fixture は `clean_ratio` 列と clean_area÷task_area を揃えてあるので、列でも面積でも同じ値になる。 + // ★未実施(302)はレポート行が無いので平均に入らない★(§1-1)。0% として混ぜたら 47.5 になる。 + // ★全社合算なら (95+50+100)/3 ≒ 81.67 になる★ + expect(body.completion_rate.value).toBeCloseTo(95, 10); + // ★分子は本清掃行を指すエラーだけ(301 の1件)/ 分母は清掃予定回数(2件。第11回 P-6=B)★ + expect(body.error_rate.value).toBe(50); + expect(body.operation_rate.prev).toBe(90); + }); + + it("★★本清掃の絞り込みと期間の絞り込みが SQL に降りている★★(全件読みに戻さない)", async () => { + await kpi(CURRENT); + const where = mainQueryWhere(); + expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main); + const startAt = where.start_at as { gte: Date; lt: Date }; + // ★★壁時計値であること★★(C-6。★生の `parseDate()`(真の瞬間)ではない★) + expect(startAt.gte.toISOString()).toBe("2026-09-13T00:00:00.000Z"); + expect(startAt.lt.toISOString()).toBe("2026-09-20T00:00:00.000Z"); + }); + + it("★清掃結果(clean_task_reports)・エラー報告は引いた本清掃の id でしか引かない★", async () => { + await kpi(CURRENT); + const historyWhere = calls.cleanTaskReport.find((c) => "where" in c) as { + where: { clean_timetable_id: { in: bigint[] } }; + }; + // ★期間内の本清掃行すべて★(★305 は未来の予定だが、それを外すのは + // `isFutureSchedule()`(純粋関数)の仕事で SQL の仕事ではない★。 + // ★SQL 側で未来を外さないこと★ — 「いま何時か」を `where` に混ぜると、 + // 同じクエリが実行時刻で別の結果を返すようになる) + // ★★施設2・3 の行(313/314/321)は入らない★★ — ★当期も対象顧客の施設だけを読む★ + // (指示書AL 段階1。★以前は全施設ぶん読んでいた★)。 + expect(historyWhere.where.clean_timetable_id.in.map(String).sort()).toEqual( + ["301", "302", "305"].sort() + ); + }); + + it("★★事前チェック行が本清掃として数えられない★★(`precheck` を SQL から外さない)", async () => { + const body = await performance(`${CURRENT}&limit=100`); + const fac1 = body.items.find((item) => item.facility_id === 1); + // 301(実施)/ 302(未実施)の2件だけ。3011 が混ざると分母が3になる + expect(fac1?.operation_rate.value).toBe(50); + }); + + it("★壁時計 20:00 の清掃がその週に含まれる★(C-6。★ローカルDBでは検出できない★)", async () => { + const body = await performance(`${CURRENT}&limit=100`); + const fac2 = body.items.find((item) => item.facility_id === 2); + // 313(未実施)/ 314(20:00・完了率50)の2件 + expect(fac2?.operation_rate.value).toBe(50); + // 25 → 50: 面積比 50/200 → 1回ごとの完了率の平均(未実施 313 は入らない。50/100 → 50)。指示書AS + // ★314 が週から落ちていれば null になる★(C-6 の検出力は operation_rate と併せて保たれる) + expect(fac2?.completion_rate.value).toBe(50); + }); +}); + +describe("#3 月跨ぎ週(2026年8月第6週 = 08-30〜08-31 の2日)", () => { + /** ★その週を「当期」にして、詳細テーブルの絞り込みを走らせる★ */ + beforeEach(() => { + vi.setSystemTime(new Date("2026-08-31T23:00:00+09:00")); + }); + + it("★★`periodRange()` の返す範囲どおりに SQL が絞る★★(08-30 00:00 以上 / 09-01 00:00 未満)", async () => { + await kpi("period=weekly&year=2026&month=8&week=6"); + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + expect(startAt.gte.toISOString()).toBe("2026-08-30T00:00:00.000Z"); + expect(startAt.lt.toISOString()).toBe("2026-09-01T00:00:00.000Z"); + }); + + it("★2日ぶんだけが集計される★(08-29 も 09-01 も入らない)", async () => { + // ★★境界の行(401〜406)は施設3 に置いてある★★(`buildData()` の注記)ので、 + // ★施設3 の屋号(=3)を対象顧客にして引く★(指示書AL 段階1 で当期も顧客で + // 絞るようになったため。★`customer_id` の存在確認はしない契約★なので、 + // 顧客マスタに無い 3 でもそのまま対象顧客になる=期待値表 6-9 と同じ形)。 + const body = await kpi("period=weekly&year=2026&month=8&week=6&customer_id=3"); + // 402(実施・90/100)/ 403(未実施)の2件 + expect(body.operation_rate.value).toBe(50); + // 45 → 90: 面積比 90/200 → 1回ごとの完了率の平均(未実施 403 は入らない。90/100 → 90)。指示書AS。 + // ★08-29(401=100)や 09-01(404=100)が混ざれば 95 になる★ + expect(body.completion_rate.value).toBe(90); + }); +}); + +describe("#4 月の第1週(必ず1日から)/最終週(必ず末日で終わる)", () => { + it("★第1週は 09-01 から 09-05★(前月の 08-31 も 第2週の 09-06 も入らない)", async () => { + vi.setSystemTime(new Date("2026-09-05T23:00:00+09:00")); + // ★施設3(サマリが1行も無い=詳細の集計がそのまま出る)で見る★ + const body = await performance("period=weekly&year=2026&month=9&week=1&limit=100"); + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + expect(startAt.gte.toISOString()).toBe("2026-09-01T00:00:00.000Z"); + expect(startAt.lt.toISOString()).toBe("2026-09-06T00:00:00.000Z"); + // 404(100/100)/ 405(60/100)の2件。★境界の行が落ちていない★ + // (★完了率 80 は期間の面積比でも1回ごとの完了率の平均でも同じ値★なので期待値は据え置き) + const fac3 = body.items.find((item) => item.facility_id === 3); + expect(fac3?.operation_rate.value).toBe(100); + expect(fac3?.completion_rate.value).toBe(80); + }); + + it("★最終週は月末で終わる★(8月第6週の `lt` が 09-01。#3 と同じ境界)", async () => { + vi.setSystemTime(new Date("2026-08-31T23:00:00+09:00")); + await kpi("period=weekly&year=2026&month=8&week=6"); + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + expect(startAt.lt.toISOString()).toBe("2026-09-01T00:00:00.000Z"); + }); + + it("★その月に存在しない週(9月の第6週)は 200 で空★(404 にしない)", async () => { + const body = await kpi("period=weekly&year=2026&month=9&week=6"); + expect(body.operation_rate.value).toBeNull(); + expect((await continuous("period=weekly&year=2026&month=9&week=6")).items).toEqual([]); + expect((await performance("period=weekly&year=2026&month=9&week=6")).items).toEqual([]); + }); +}); + +describe("#5 サマリに行が無い期間 — ★空のKPI(404 にしない)★", () => { + it("KPI は4指標すべて null", async () => { + const body = await kpi("period=weekly&year=2026&month=7&week=1"); + expect(body).toEqual({ + operation_rate: { value: null, prev: null, diff: null }, + launch_rate: { value: null, prev: null, diff: null }, + completion_rate: { value: null, prev: null, diff: null }, + error_rate: { value: null, prev: null, diff: null }, + }); + }); + + it("一覧3本は空配列で 200", async () => { + const q = "period=weekly&year=2026&month=7&week=1"; + expect((await performance(q)).items).toEqual([]); + expect((await continuous(q)).items).toEqual([]); + expect((await breakdown(q)).items).toEqual([]); + }); +}); + +describe("★★#6 連続日数 — 期間で絞らない(C-2 の地雷)★★", () => { + it("★★期間をまたぐ連続が途切れない★★(09-10/09-12/09-14/09-15 → ★4日★)", async () => { + const body = await performance(`${CURRENT}&limit=100`); + const fac2 = body.items.find((item) => item.facility_id === 2); + // ★★期間(09-13〜09-19)で絞ると 2 になる★★ — そうなっていたらここで落ちる + expect(fac2?.consecutive_problem_days).toBe(4); + }); + + it("★連続問題施設も同じ数字・同じ判定★(L3。D-109 の再発防止)", async () => { + const body = await continuous(`${CURRENT}&limit=100`); + expect(body.items.map((i) => [i.facility_id, i.consecutive_problem_days, i.escalation_level])).toEqual([ + [2, 4, "L3"], + ]); + }); + + it("★★連続日数の読み取りは期間の手前を「上限 +1日ぶん」余分に遡る★★(構造。指示書AC 段階6)", async () => { + await performance(`${CURRENT}&limit=100`); + const consecutiveCall = calls.cleanTimetable.find(isConsecutiveQuery); + expect(consecutiveCall).toBeDefined(); + expect((consecutiveCall as { select: object }).select).toEqual({ + id: true, + facility_id: true, + start_at: true, + // ★結果待ちの判定用(D-173)★ + end_at: true, + precheck: true, + }); + + // ★★要求された期間の開始日をそのまま下限にしたら連続が途切れる(C-2 の地雷)★★ — + // 遡り幅は ★保存された表示上限★ から導く(★定数に写し取らないこと★。申し送り⑪) + const main = mainQueryWhere().start_at as { gte: Date; lt: Date }; + const consecutive = consecutiveQueryWhere().start_at as { gte: Date; lt: Date }; + expect(consecutive.lt.getTime()).toBe(main.lt.getTime()); + expect((main.gte.getTime() - consecutive.gte.getTime()) / 86_400_000).toBe( + consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax()) + ); + }); + + it("★禁止事項8: 未来の予定は連続日数にも分母にも入らない★(`isFutureSchedule()`)", async () => { + const body = await performance(`${CURRENT}&limit=100`); + const fac1 = body.items.find((item) => item.facility_id === 1); + // 09-14(未実施)だけが問題日。09-18 の未来の予定を数えると 2 になる + expect(fac1?.consecutive_problem_days).toBe(1); + }); +}); + +describe("#7 `period=weekly` かつ `week=0` → ★422★(`week=0` は月次の予約値)", () => { + it("4本とも 422 を返し、DB を1本も引かない", async () => { + for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) { + const res = await get( + new Request("http://localhost/api/reports/x?period=weekly&year=2026&month=9&week=0") + ); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + } + for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]); + }); +}); + +/** + * ★`year` の範囲外 → 422★(`d-watch-api.yaml` の `Year` / `lib/config` の `REPORT_YEAR`) + * + * ★直す前の実測(2026-09-14 / develop `3e48d30`。ローカルDB接続の dev サーバ)★: + * - `period=monthly&year=-1&month=9` → ★200★(空のKPI。紀元前が有効な期間になっていた) + * - `period=weekly&year=-1` / `year=0` / `year=1000000` → ★500★ + * (`lib/format/period.ts` の `addDays` が `00-1-09-05` 等で RangeError) + * - `year=99999999999999999999` → ★500★(Prisma: Unable to fit value into a + * 64-bit signed integer for field `year`。SQLは出ないが、 + * ★攻撃者が選んだ値が `logApiError` 経由でサーバログに載る★) + * いずれも入力の誤りなので、DBに触る前に422で弾く。 + */ +describe("★`year` が範囲外 → 422★(month / week と同じ作法。DBに降ろさない)", () => { + it("4本とも 422 を返し、DB を1本も引かない", async () => { + const outOfRange = ["-1", "0", "1999", "2101", "1000000", "99999999999999999999"]; + for (const year of outOfRange) { + for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) { + const res = await get( + new Request( + `http://localhost/api/reports/x?period=weekly&year=${year}&month=9&week=2` + ) + ); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + expect(body.error.details).toEqual({ + year: ["2000〜2100で指定してください"], + }); + } + } + for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]); + }); + + it("★period=monthly でも422★(旧実装ではここだけ200で通っていた)", async () => { + const res = await GET_KPI( + new Request("http://localhost/api/reports/kpi?period=monthly&year=-1&month=9") + ); + expect(res.status).toBe(422); + for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]); + }); + + it("境界ちょうど(2000 / 2100)は422にならない(データが無いだけ)", async () => { + for (const year of ["2000", "2100"]) { + const res = await GET_KPI( + new Request( + `http://localhost/api/reports/kpi?period=weekly&year=${year}&month=9&week=2` + ) + ); + expect(res.status).toBe(200); + } + }); +}); + +describe("★★#8 エラー種別内訳の分子は「本清掃行を指す」エラーだけ★★(D-118 / D-114 項2)", () => { + it("事前チェック行・null・dangling・別期間は数えない", async () => { + const body = await breakdown(CURRENT); + expect(body.items).toEqual([ + { type: "EmergencyStop", label: "EmergencyStop", count: 1, rate: 100 }, + ]); + }); + + it("★エラー行は本清掃の id でしか引かない(全件読みに戻さない)★", async () => { + await breakdown(CURRENT); + const where = calls.robotErrorReport[0].where as { + clean_timetable_id: { in: bigint[] }; + }; + // ★期間内の本清掃行すべて(事前チェック行 3011 は入らない)★ + expect(where.clean_timetable_id.in.map(String).sort()).toEqual( + ["301", "302", "305", "313", "314", "321"].sort() + ); + }); +}); + +describe("#9 読み込み行数 — ★期間で絞ったぶんしか読まない★", () => { + it("当期でも `clean_timetables` は本清掃かつ期間内の行だけ", async () => { + await kpi(CURRENT); + // ★`where` 付きのクエリはすべて `start_at` を持つ(=全期間を読むクエリが無い)★ + // ★例外は連続日数の1本だけ(`where` を持たない `select` 専用クエリ。C-2)★ + for (const call of calls.cleanTimetable) { + if (!("where" in call)) continue; + expect(call.where).toHaveProperty("start_at"); + } + }); + + it("確定期間なら詳細テーブルのクエリは0本(`kpi` / `error-breakdown`)", async () => { + await kpi(CONFIRMED); + await breakdown(CONFIRMED); + expect(calls.cleanTimetable).toEqual([]); + expect(calls.robotErrorReport).toEqual([]); + }); +}); + +describe("★施設名の絞り込みを持つのは facility-performance だけ★(段階2 と同じ作法)", () => { + it("★記号ゆれを無視して施設IDに解決し、`facility_id: { in: [...] }` で降ろす★", async () => { + // ★`バリュー・マート三つ角店` を中点なしで検索する(`includesFoldName`。指示書AG)★ + const body = await performance(`${CURRENT}&facility_name=バリューマート三つ角&limit=100`); + expect(body.items.map((i) => i.facility_id)).toEqual([3]); + const facilityCall = calls.facility.find( + (c) => "where" in c && "id" in (c.where as object) + ); + expect(facilityCall).toBeDefined(); + expect( + ((facilityCall as { where: { id: { in: number[] } } }).where.id.in).map(Number) + ).toEqual([3]); + }); + + it("★★1件も当たらない施設名 → `in: []` で0件★★(★罠1。「全件表示」に化けていないか★)", async () => { + const body = await performance(`${CURRENT}&facility_name=存在しない店&limit=100`); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("★施設名が未指定なら施設で絞らない★(`null`。`[]` と混同しない)", async () => { + const body = await performance(`${CURRENT}&limit=100`); + expect(body.items.map((i) => i.facility_id).sort()).toEqual([1, 2, 3]); + const facilityCall = calls.facility.find( + (c) => "where" in c && "id" in (c.where as object) + ); + expect(facilityCall).toBeUndefined(); + }); + + /** + * ★★ここだけデータ層(`db.facilityPerformance()`)を直接呼ぶ★★。 + * + * ★理由: ルート層の `filterFacilityPerformanceByName()`(C-5 の「保険」)が + * ★罠1 の壊れ方を隠してしまう★★ — `facility_ids: []` が `in: undefined` に化けて + * SQL が全施設を返しても、★施設名で再度メモリ絞り込みされるので画面は0件のまま★。 + * ★★つまりルート経由のテストでは「0件ヒットが全件読みに化けた」ことを検出できない★★ + * (実測: `!== null` を `?.length` に書き換えてもルートのテストは全部緑のままだった)。 + * ★読み込み行数を減らすのが本作業の目的★なので、ここはデータ層で固定する。 + */ + it("★★`facility_ids: []` はデータ層で0件★★(★罠1。`in: undefined` に化けていないか★)", async () => { + const rows = await db.facilityPerformance({ + period: "weekly", + year: 2026, + month: 9, + week: 3, + facility_ids: [], + }); + expect(rows).toEqual([]); + // ★`in: []` がそのまま SQL に渡っていること(条件が消えていない)★ + const facilityCall = calls.facility.find( + (c) => "where" in c && "id" in (c.where as object) + ); + expect((facilityCall as { where: { id: { in: number[] } } }).where.id.in).toEqual([]); + }); + + it("★`facility_ids: null` はデータ層で全施設★(`[]` と混同しない)", async () => { + const rows = await db.facilityPerformance({ + period: "weekly", + year: 2026, + month: 9, + week: 3, + facility_ids: null, + }); + expect(rows.map((row) => row.item.facility_id).sort()).toEqual([1, 2, 3]); + }); + + it("★`serial` は据え置き=メモリ絞り込みのまま★(SQL には降ろさない)", async () => { + const body = await performance(`${CURRENT}&serial=600003&limit=100`); + expect(body.items.map((i) => i.facility_id)).toEqual([3]); + // ★シリアルを SQL に降ろしていないこと(施設の `where` に `id` が現れない)★ + const facilityCall = calls.facility.find( + (c) => "where" in c && "id" in (c.where as object) + ); + expect(facilityCall).toBeUndefined(); + }); +}); + +/** + * 指示書AH ★段階3★ の期待値表 #9〜#13(ルート側)を、 + * ★★指示書AK ★段階6★ の決定(§2-1)に合わせて置き換えたもの★★。 + * + * ------------------------------------------------------------------------ + * ★★何が変わったか★★ + * ------------------------------------------------------------------------ + * ★以前は「複数顧客の行が混ざったら 500」★だった(D-133 の安全弁)。 + * ★分類が復活して「どの顧客か」を決める根拠ができた★ので、 + * ★★`where` で対象顧客を絞る → そもそも混ざらない★★ に変わった(= ★2社目のデータが + * 入っても画面が常時500にならない★。§2-1 の却下案「500 のまま」の理由)。 + * + * ★★何を守るためのテストか★★ + * - ★★`customer_id` の絞り込みが本当に効いていること★★(#10 = 期待値表 ★6-2★。 + * ★2社ぶんの `customer_summaries` を置いて、片方の数字だけが出ることを見る★) + * - ★前期比が別会社の行で汚染されないこと★(#11。★罠5 の形をそのまま流用★) + * - ★★同じ期間キーの重複は今も 500★★(#14 = 期待値表 ★6-8★。 + * ★一意制約が無いので同一顧客でも起こりうる★)。 + * ★body に `customer_id` が1バイトも漏れないこと★も併せて固定する + * (ルートは★定数の固定文言★を返す契約。`d-watch-api.yaml` は変更しない。D-7) + * - ★0行は 200 のまま(404 にしない)★(#12。既存挙動を変えない) + * + * ★ローカルDBでは絶対に再現しない★ — ★顧客は1件しか無い★ので合成データでしか作れない。 + */ +describe("★★指示書AK 段階6 — 確定期間KPIを対象顧客で絞る★★(D-5 / D-133 の置き換え)", () => { + /** ★2社目★(1社目は `summary()` の既定 = 1) */ + const OTHER_CUSTOMER = 777; + + /** ルートの生レスポンス(body を★文字列のまま★検査するため JSON.parse しない) */ + async function rawKpi(search: string): Promise<{ status: number; text: string }> { + const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`)); + return { status: res.status, text: await res.text() }; + } + + it("#9 1顧客・当期+前期 → 200", async () => { + // buildData() の既定(どちらも customer_id=1) + const res = await rawKpi(CONFIRMED); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90); + }); + + it("★★#10(=6-2)DBに2社ぶんある・customer_id=1 → 200。顧客1だけの数字★★", async () => { + data.customerSummaries = [ + summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]), + summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]), + // ★★2社目(絞れていなければ 500 になるか、別会社の数字が混ざる)★★ + summary(3, 0, 2026, 9, 2, [10, 10, 10, 10], OTHER_CUSTOMER), + summary(4, 0, 2026, 9, 1, [11, 11, 11, 11], OTHER_CUSTOMER), + ]; + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(200); + const body = JSON.parse(res.text) as ReportKpi; + // ★顧客1の値そのもの★(★合算も平均もしない★) + expect(body.operation_rate.value).toBe(90); + expect(body.operation_rate.prev).toBe(80); + // ★2社目の値(10 / 11)が1つも出ていないこと★ + expect(body.completion_rate.value).toBe(77.7); + expect(body.error_rate.value).toBe(5.5); + // ★★`where` に `customer_id` が降りていること★★(★`OR` と同じ階層 = AND★) + const where = calls.customerSummary[0].where as Record; + expect(String(where.customer_id)).toBe("1"); + expect(where.OR).toEqual([ + { year: 2026, month: 9, week: 2 }, + { year: 2026, month: 9, week: 1 }, + ]); + }); + + it("★★#11 当期はA社・前期はB社(キーは衝突しない)→ 前期比が汚染されない★★(★罠5★)", async () => { + data.customerSummaries = [ + summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]), + summary(2, 0, 2026, 9, 1, [10, 10, 10, 10], OTHER_CUSTOMER), + ]; + // ★以前は 500 で止めていた★(どちらの顧客か決められなかったため)。 + // ★いまは B社の行が `where` で落ちるので、前期比は「前期の行が無い」= null★。 + // ★★両社の差という無意味な数字が出ないこと★★が要。 + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(200); + const body = JSON.parse(res.text) as ReportKpi; + expect(body.operation_rate.value).toBe(90); + expect(body.operation_rate.prev).toBeNull(); + expect(body.operation_rate.diff).toBeNull(); + }); + + it("#12 0行(確定期間)→ 200(★404 にしない★。既存挙動)", async () => { + data.customerSummaries = []; + const res = await rawKpi(CONFIRMED); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({ + value: null, + prev: null, + diff: null, + }); + }); + + it("#13 当期0行 + 前期が2顧客 → 200・空のKPI(★B社の行を拾わない★)", async () => { + data.customerSummaries = [ + summary(2, 0, 2026, 9, 1, [80, 70, 60, 10]), + summary(3, 0, 2026, 9, 1, [10, 10, 10, 10], OTHER_CUSTOMER), + ]; + // ★当期(要求された期間)の行が1つも無いので空のKPI★。 + // ★前期の行が2顧客ぶんあっても、絞り込みで A社だけが残る★ので 500 にはならない。 + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({ + value: null, + prev: null, + diff: null, + }); + }); + + it("★★#14(=6-8)同じ期間キーが重複したら例外のまま(500)/body に `customer_id` が漏れない★★", async () => { + // ★★同一顧客でも起こりうる★★ — `customer_summaries` に一意制約が無い(指示書AK §9-2)。 + // ★後勝ちで不定になるので、絞り込みが入った後もこの検知は残す★。 + data.customerSummaries = [ + summary(1, 0, 2026, 9, 2, [90, 88, 77.7, 5.5]), + summary(2, 0, 2026, 9, 2, [10, 10, 10, 10]), + ]; + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(500); + // ★body は定数(`app/api/reports/kpi/route.ts`)★ — 例外メッセージを混ぜない + expect(JSON.parse(res.text)).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + // ★★文字列としても顧客IDが出ていないこと★★(`details` に例外を詰め始めたら落ちる) + expect(res.text).not.toContain("customer_id"); + }); + + /** + * ★★罠6(2026-09-30 に書き直し。指示書AQ 決定1 / §3 罠2・罠6)★★ + * + * ★旧テスト★は「★P1では年次サマリを読まない契約(D-072項4)★なので、年次の行が同じ期間キーで + * 2行あっても 500 にしてはいけない(`resolves.toEqual([])`)」を固定していた。 + * ★★年次を解禁した(指示書AQ 決定1)ので、その前提が消えた★★ — 年次の行は ★読む行★ になり、 + * ★読む行の重複は #14(週次)と同じく検知して止めるのが正しい★(後勝ちで不定になるため)。 + * そこで次の2本に分けた: + * - 罠6-1: ★年次の行は `yearly` として返る★(`periodOfSummaryRow()` が `month` を見ていること。 + * `monthly` と取り違えると `selectKpiRow()` で弾かれて空のKPIになる=罠2) + * - 罠6-2: ★同じ年次キーの重複は例外★(旧テストと期待値が逆になる。理由は上のとおり決定1) + * + * ★データ層を直接呼ぶ★(`facility_ids: []` のテストと同じ作法。ルート経由の年次は末尾の describe)。 + * ★年は 2025(確定期間)にする★ — 2026 は `NOW` の当年(=当期)なので詳細テーブルの集計に入る。 + */ + it("★★罠6-1: 年次(month=0 && week=0)の行は `yearly` として返り、前年が前期比になる★★", async () => { + data.customerSummaries = [ + summary(1, 0, 2025, 0, 0, [11, 12, 13, 14]), + // ★前期(前年)★ — `shiftPeriod("yearly", 2025, 0, 0, -1)` = 2024/0/0 + summary(2, 0, 2024, 0, 0, [22, 22, 22, 22]), + ]; + const rows = await db.reportKpis({ + period: "yearly", + year: 2025, + month: 0, + week: 0, + // ★`facility_ids: null` = 施設で絞らない★(`reportKpis()` の確定期間はこの値を見ない。 + // 絞り込みの軸は `customer_id`) + facility_ids: null, + customer_id: 1, + }); + expect(rows).toHaveLength(1); + // ★`monthly` になっていたら罠2(年次が月次として混入)★ + expect(rows[0]).toMatchObject({ period: "yearly", year: 2025, month: 0, week: 0 }); + expect(rows[0].kpi.operation_rate).toEqual({ value: 11, prev: 22, diff: -11 }); + expect(rows[0].kpi.completion_rate.value).toBe(13); + // ★確定期間なので詳細テーブルは読まない★(D-102) + expect(calls.cleanTimetable).toEqual([]); + expect(calls.cleanTaskReport).toEqual([]); + }); + + it("★★罠6-2: 同じ年次キーの行が2行あれば例外(#14 と同じ検知)★★", async () => { + // ★旧テストは resolves.toEqual([]) だった★ — 年次を読まない前提(D-072項4)が + // 指示書AQ 決定1 で消えたため、★読む行の重複として検知するのが正しい★(期待値が逆転) + data.customerSummaries = [ + summary(1, 0, 2025, 0, 0, [11, 11, 11, 11]), + summary(2, 0, 2025, 0, 0, [22, 22, 22, 22]), + ]; + await expect( + db.reportKpis({ + period: "yearly", + year: 2025, + month: 0, + week: 0, + facility_ids: null, + customer_id: 1, + }) + ).rejects.toThrow("yearly/2025/0/0"); + }); +}); + +/** + * 指示書AK ★段階6★ の期待値表 ★6-1 / 6-3 〜 6-7 / 6-9★(ルート側)。 + * (★6-2 は上の #10★、★6-8 は上の #14★ で固定している) + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★★候補が2つ以上のときに「静かに1社を選ぶ」ことをしない★★(★落とし穴6★。D-133)— + * ★6-4 / 6-7 が 200 で通ってしまったら、誰かが暗黙の選択を入れたということ★ + * - ★★500 ではなく 422★★(6-4)— ★サーバの不具合ではなく「指定が足りない」★(§2-1) + * - ★候補を `details.customer_ids` で返す★(6-5)— ★画面が文言を読まずに選択肢を出せる★ + * - ★存在しない `customer_id` は 200・空★(6-9)— ★「該当なし」は正常★(404/422 にしない) + */ +describe("★★指示書AK 段階6 — 対象顧客の決定(期待値表 6-1 / 6-3〜6-7 / 6-9)★★", () => { + async function rawKpi(search: string): Promise<{ status: number; text: string }> { + const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`)); + return { status: res.status, text: await res.text() }; + } + + it("★6-1★ customer_id=1 指定・顧客1社のデータ → 200。その顧客の数字", async () => { + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90); + }); + + it("★6-3★ 指定なし・顧客が1社だけ → 200(★P1 はここ★)", async () => { + // `buildData()` の既定(顧客マスタは屋号1 の1社だけ) + const res = await rawKpi(CONFIRMED); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90); + // ★暗黙に決まった1社が `where` に降りていること★ + expect(String((calls.customerSummary[0].where as Record).customer_id)).toBe("1"); + }); + + it("★★6-4 / 6-5★★ 指定なし・顧客が2社 → 422(★500 ではない★)。details に候補が入る", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)]; + const res = await rawKpi(CONFIRMED); + expect(res.status).toBe(422); + expect(JSON.parse(res.text)).toEqual({ + error: { + code: "validation_failed", + message: + "顧客またはグループを指定してください。複数の顧客のデータを合算した数値は出せません。", + details: { customer_ids: [1, 2] }, + }, + }); + // ★★422 のときはサマリを1行も読まない★★(★どの顧客か決まっていないのだから読めない★) + expect(calls.customerSummary).toHaveLength(0); + }); + + it("★6-6★ group_id 指定・傘下1社 → 200(その顧客の数字)", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)]; + data.customerSummaries = [summary(10, 0, 2026, 9, 2, [55, 55, 55, 55], 2)]; + const res = await rawKpi(`${CONFIRMED}&group_id=2`); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(55); + expect(String((calls.customerSummary[0].where as Record).customer_id)).toBe("2"); + }); + + it("★★6-7★★ group_id 指定・傘下2社 → 422(★傘下の候補だけを返す★)", async () => { + data.customers = [ + customer(1, "屋号1", 1), + customer(2, "屋号2", 1), + // ★別ホールディングスの顧客は候補に入らない★ + customer(3, "屋号3", 2), + ]; + const res = await rawKpi(`${CONFIRMED}&group_id=1`); + expect(res.status).toBe(422); + expect((JSON.parse(res.text) as ApiError).error.details).toEqual({ + customer_ids: [1, 2], + }); + }); + + it("★6-9★ 存在しない customer_id → 200・空のKPI(★422 にしない★。「該当なし」は正常)", async () => { + const res = await rawKpi(`${CONFIRMED}&customer_id=999`); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate).toEqual({ + value: null, + prev: null, + diff: null, + }); + // ★存在確認をしない=顧客マスタに無くても `where` に降りる★ + expect(String((calls.customerSummary[0].where as Record).customer_id)).toBe( + "999" + ); + }); + + it("★group_id 指定・傘下0社 → 422(候補は空配列)★(★指示書に明記が無く、起草者が決めた★)", async () => { + data.customers = [customer(1, "屋号1", 1)]; + const res = await rawKpi(`${CONFIRMED}&group_id=9`); + expect(res.status).toBe(422); + expect((JSON.parse(res.text) as ApiError).error.details).toEqual({ customer_ids: [] }); + expect(calls.customerSummary).toHaveLength(0); + }); + + it("★customer_id と group_id の同時指定は customer_id が勝つ★(存在確認をしない契約の帰結)", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)]; + // ★傘下2社のホールディングスでも、顧客が名指しされていれば 422 にならない★ + const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=1`); + expect(res.status).toBe(200); + expect((JSON.parse(res.text) as ReportKpi).operation_rate.value).toBe(90); + }); +}); + +/** + * 指示書AL ★段階1★ の期待値表 ★1-1 〜 1-5★ — ★★当期(進行中の週)のKPIを顧客で絞る★★。 + * (★1-6(指定なし・顧客2社 → 422)は段階6 の「6-4 / 6-5」で固定済み★) + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★★存在しない顧客を指定したら当期が空になること★★(★1-3。今回の要★)— + * ★数字が出たら絞れていない★。★★`facility_ids: []` が `null`(=全社合算)に + * 化けていないか★★を見る(★罠1 / 落とし穴3★) + * - ★確定期間(`customer_summaries` を `customer_id` で絞る)と当期 + * (`clean_timetables` を `facility_id` で絞る)が★★同じ顧客を指すこと★★(★1-7★) + * - ★★施設IDを `target.customerId` から解決していること★★(★落とし穴1★)— + * ★リクエストの `group_id` から直接引くと母集団がずれる★ + * + * ★★D-138 の残課題★★(「確定期間は選んだ顧客の数字・当期は全社合算」)がここで解消する。 + * ★fixtures 経路(`USE_FIXTURES=true`)は元から顧客単位で正しかった★ + * (`filterFixtureReportKpis()` の注記)。★このファイルは Prisma 経路★。 + */ +describe("★★指示書AL 段階1 — 当期KPIを顧客で絞る(期待値表 1-1〜1-5)★★", () => { + /** 当期の集計に降りた `facility_id` の条件(★無ければ `undefined`=絞っていない★) */ + function currentFacilityIdIn(): number[] | undefined { + const cond = mainQueryWhere().facility_id as { in: unknown[] } | undefined; + return cond?.in.map(Number); + } + + it("★1-1★ 当期・customer_id=1 → ★顧客1(施設1)だけの数字★(★全社の数字にならない★)", async () => { + const body = await kpi(`${CURRENT}&customer_id=1`); + // 301(実施・95)/ 302(未実施)。 + // 47.5 → 95: 面積比 → 1回ごとの完了率の平均(指示書AS)。★全社合算なら 49 → ≒81.67★ + expect(body.completion_rate.value).toBeCloseTo(95, 10); + // ★エラー1件 ÷ 予定2件(第11回 P-6=B。旧: ÷ 実施1件で 100)★ + expect(body.error_rate.value).toBe(50); + expect(currentFacilityIdIn()).toEqual([1]); + }); + + it("★1-2★ 当期・customer_id=2 → ★1-1 と違う数字★(施設2 だけ)", async () => { + const body = await kpi(`${CURRENT}&customer_id=2`); + // 313(未実施)/ 314(20:00・50)。★1-1 の 95 とも全社の ≒81.67 とも違う★ + // 25 → 50: 面積比 50/200 → 1回ごとの完了率の平均(指示書AS) + expect(body.completion_rate.value).toBe(50); + // ★施設2 はエラー報告を1件も持たない★(施設1 の EmergencyStop が混ざれば 100 になる) + expect(body.error_rate.value).toBe(0); + expect(currentFacilityIdIn()).toEqual([2]); + }); + + it("★★1-3★★ 当期・customer_id=9999 → ★★空(value が null)★★(★数字が出たら不具合★)", async () => { + const body = await kpi(`${CURRENT}&customer_id=9999`); + expect(body).toEqual({ + operation_rate: { value: null, prev: null, diff: null }, + launch_rate: { value: null, prev: null, diff: null }, + completion_rate: { value: null, prev: null, diff: null }, + error_rate: { value: null, prev: null, diff: null }, + }); + // ★★`in: []` がそのまま降りていること★★ — ★`null` に化けると全社の数字が出る★(罠1) + expect(currentFacilityIdIn()).toEqual([]); + }); + + it("★1-4★ 当期・group_id 指定(傘下1社)→ ★その顧客の数字★", async () => { + // `buildData()` の顧客マスタは屋号1(ホールディングス1)の1社だけ + const body = await kpi(`${CURRENT}&group_id=1`); + // 47.5 → 95: 面積比 → 1回ごとの完了率の平均(指示書AS。1-1 と同じ母集団) + expect(body.completion_rate.value).toBeCloseTo(95, 10); + expect(currentFacilityIdIn()).toEqual([1]); + }); + + it("★1-5★ 当期・指定なし・顧客1社 → ★その顧客の全施設★(★P1 はここ★)", async () => { + const body = await kpi(CURRENT); + // 47.5 → 95: 面積比 → 1回ごとの完了率の平均(指示書AS。1-1 と同じ母集団) + expect(body.completion_rate.value).toBeCloseTo(95, 10); + expect(currentFacilityIdIn()).toEqual([1]); + }); + + it("★★落とし穴1: 施設IDは `target.customerId` から解決する(リクエストの group_id からではない)★★", async () => { + // ★屋号1 はホールディングス1、屋号2 はホールディングス2★ + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)]; + // ★`customer_id` が名指しされているので対象顧客は屋号1★(`group_id` は効かない。 + // ★段階6 の「同時指定は customer_id が勝つ」と同じ★)。 + // ★★リクエストの `group_id`(=2)と `customer_id`(=1)の**積集合**で施設を引くと + // `[]` になり、当期が空に化ける★★ — ★確定期間(屋号1の数字)とずれる★。 + const body = await kpi(`${CURRENT}&group_id=2&customer_id=1`); + expect(currentFacilityIdIn()).toEqual([1]); + // 47.5 → 95: 面積比 → 1回ごとの完了率の平均(指示書AS。1-1 と同じ母集団) + expect(body.completion_rate.value).toBeCloseTo(95, 10); + }); + + it("★★1-7 / 1-8 確定期間と当期・前期比が、どれも同じ顧客の数字★★", async () => { + // ★確定期間(第2週)は `customer_summaries` を `customer_id` で絞る★ + const confirmed = await kpi(`${CONFIRMED}&customer_id=1`); + expect(confirmed.operation_rate.value).toBe(90); + // ★当期(第3週)は `clean_timetables` を `facility_id` で絞る★。 + // ★★`prev` は同じ顧客の確定期間サマリ(第2週)から来る★★ので、 + // ★当期=顧客1の詳細 / 前期=顧客1のサマリ★ で前期比が計算されている。 + const current = await kpi(`${CURRENT}&customer_id=1`); + expect(current.operation_rate.value).toBe(50); + expect(current.operation_rate.prev).toBe(90); + expect(current.operation_rate.diff).toBe(-40); + + // ★別の顧客を指定すると、当期も前期も**その顧客**の数字に揃って入れ替わる★ + data.customerSummaries = [ + ...data.customerSummaries, + summary(5, 0, 2026, 9, 2, [30, 30, 30, 30], 2), + ]; + const other = await kpi(`${CURRENT}&customer_id=2`); + expect(other.operation_rate.value).toBe(50); // 施設2: 313/314 の2件中1件 + // ★★前期に顧客1の 90 が混ざらないこと★★(混ざれば diff が -40 になる) + expect(other.operation_rate.prev).toBe(30); + expect(other.operation_rate.diff).toBe(20); + }); +}); + +/** + * 指示書AL ★段階3★ §4-3 — ★`customer_id` が明示されているときは顧客マスタを読まない★。 + * + * `resolveTargetCustomer()` は ★`customerId !== null` なら顧客リストを一切見ない★ + * (★存在確認をしない★契約。段階6 期待値表 6-9)ので、読んでも捨てるだけの1クエリになる。 + * + * ★★挙動は1ミリも変わらない直し★★なので、★発行されたクエリを数えないと固定できない★ + * (レスポンスだけを見るテストでは、読んでも読まなくても同じ結果になる)。 + */ +describe("★★指示書AL 段階3 §4-3 — 不要な顧客マスタの読み込みをやめる★★", () => { + // 段階6 の describe 内と同じ形(`kpi()` は 200 を前提にしているので 422 を見られない) + async function rawKpi(search: string): Promise<{ status: number; text: string }> { + const res = await GET_KPI(new Request(`http://localhost/api/reports/kpi?${search}`)); + return { status: res.status, text: await res.text() }; + } + + it("★customer_id 明示 → `customer.findMany` を1回も呼ばない★", async () => { + const res = await rawKpi(`${CONFIRMED}&customer_id=1`); + expect(res.status).toBe(200); + expect(calls.customer).toHaveLength(0); + }); + + it("★customer_id と group_id の同時指定でも読まない★(customer_id が勝つ契約の帰結)", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)]; + const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=1`); + expect(res.status).toBe(200); + expect(calls.customer).toHaveLength(0); + }); + + it("★★customer_id 未指定のときは読む★★(★読まなくしすぎると 422 の候補が出せなくなる★)", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2)]; + const res = await rawKpi(CONFIRMED); + expect(res.status).toBe(422); + expect(calls.customer).toHaveLength(1); + expect((JSON.parse(res.text) as ApiError).error.details).toEqual({ + customer_ids: [1, 2], + }); + }); + + it("★group_id だけの指定でも読む★(傘下の候補を数えるのに要る)", async () => { + const res = await rawKpi(`${CONFIRMED}&group_id=1`); + expect(res.status).toBe(200); + expect(calls.customer).toHaveLength(1); + }); +}); + +/** + * 指示書AK ★段階4b★ — 分類(`group_id` / `customer_id`)を `where` に降ろす。 + * + * ------------------------------------------------------------------------ + * ★★なぜ fixtures ではなくここ(Prisma 版)で固定するのか★★ + * ------------------------------------------------------------------------ + * ★`/reports/error-breakdown` は fixtures では確かめられません★ — + * fixtures のエラー種別内訳は ★施設をまたいで足し上げたあとの集計値★で、 + * ★どの施設のエラーかという情報を持っていない★ためです + * (`fixtureSource.errorBreakdown()` の docstring が正)。 + * ★★`facility_error_summaries` を `facility_id` で絞る(=D-133 申し送り③の解消)★★ かどうかは、 + * ★このスタブで発行された `where` と、足し上げた件数の両方で見ます★。 + * + * ルート経由の絞り込み(fixtures 側)は + * `app/api/__tests__/master-filter-dashboard-reports.test.ts` にあります。 + */ +describe("★★指示書AK 段階4b — 分類で `facility_id` を絞る★★", () => { + /** 施設の `where` に降りた `id: { in: [...] }`(無ければ `undefined`) */ + function facilityIdIn(): number[] | undefined { + const call = calls.facility.find((c) => "where" in c && "id" in (c.where as object)); + if (!call) return undefined; + return (call as { where: { id: { in: unknown[] } } }).where.id.in.map(Number); + } + + it("★`customer_id=1` → `facility_error_summaries` を施設1だけで引く★(D-133 申し送り③の解消)", async () => { + const body = await breakdown(`${CONFIRMED}&customer_id=1`); + // ★施設2 の HandleUp(1件) が混ざらない★ — 絞れていなければ 3 になる + expect(body.items.map((i) => [i.type, i.count])).toEqual([ + ["HandleUp", 2], + ["EmergencyStop", 1], + ]); + expect(calls.facilityErrorSummary[0].where).toEqual({ + year: 2026, + month: 9, + week: 2, + // ★施設IDは `Number`(`loadFacilityGroupIndex()` が BigInt を落としている)★ + facility_id: { in: [1] }, + }); + }); + + it("★`group_id=1` → 傘下2社(施設1・2)ぶんを足し上げる★", async () => { + const body = await breakdown(`${CONFIRMED}&group_id=1`); + expect(body.items.map((i) => [i.type, i.count])).toEqual([ + ["HandleUp", 3], + ["EmergencyStop", 1], + ]); + }); + + it("★分類を指定しなければ `facility_id` の条件を付けない★(`in: undefined` にもしない。罠1)", async () => { + await breakdown(CONFIRMED); + expect(calls.facilityErrorSummary[0].where).toEqual({ year: 2026, month: 9, week: 2 }); + }); + + it("★★積集合が空(`group_id=2&customer_id=1`)→ `in: []` で0件★★(全件に化けないこと)", async () => { + const body = await breakdown(`${CONFIRMED}&customer_id=1&group_id=2`); + expect(body.items).toEqual([]); + expect( + (calls.facilityErrorSummary[0].where as { facility_id: { in: unknown[] } }) + .facility_id.in + ).toEqual([]); + }); + + it("★当期(詳細テーブル経由)でも `clean_timetables` を施設で絞る★", async () => { + // 施設2 は当期に本清掃があるが、★エラー行を1つも持たない★ + const body = await breakdown(`${CURRENT}&customer_id=2`); + expect(body.items).toEqual([]); + expect((mainQueryWhere().facility_id as { in: unknown[] }).in.map(Number)).toEqual([2]); + // ★絞らなければ施設1 の EmergencyStop が出る★(=この0件は「絞れた結果」) + expect((await breakdown(CURRENT)).items.map((i) => i.type)).toEqual(["EmergencyStop"]); + }); + + it("★`/reports/continuous-problems` も施設で絞る★(連続4日の施設2だけが残る)", async () => { + const scoped = await continuous(`${CURRENT}&customer_id=2&limit=100`); + expect(scoped.items.map((i) => i.facility_id)).toEqual([2]); + expect(facilityIdIn()).toEqual([2]); + + // ★屋号1(施設1)には2日以上の連続が無い★ — 絞れていなければ施設2 が出てしまう + const other = await continuous(`${CURRENT}&customer_id=1&limit=100`); + expect(other.items).toEqual([]); + expect(other.meta.total).toBe(0); + }); + + it("★`/reports/facility-performance` も分類で絞る★(屋号3 = 施設3 だけ)", async () => { + const scoped = await performance(`${CURRENT}&customer_id=3&limit=100`); + expect(scoped.items.map((i) => i.facility_id)).toEqual([3]); + expect(facilityIdIn()).toEqual([3]); + }); +}); + +/** + * ★★申し送り(テストの制約。実装の制約ではない)★★ + * + * ★施設名 × 分類の積集合★(`/reports/facility-performance` に両方を同時指定するケース)は + * ★このファイルでは固定できません★ — ルートが + * `Promise.all([resolveFacilityIdsByName(), resolveFacilityIdsByMasters()])` で + * ★2本のデータ取得を**同時に**走らせる★ため、★`prismaSource` の `await import("./prisma")` + * が同時に2回走り、`vi.mock()` の差し替え前の実体を掴んで 500 になります★(実測。 + * ★同じ現象の記録が `app/api/dashboard/__tests__/route.test.ts` の冒頭にあります★)。 + * ★★本番では起きません★★(`getPrisma()` はシングルトンで、差し替えも無いため)。 + * + * ★積集合そのものは fixtures 側で固定してあります★ — + * `app/api/__tests__/master-filter-dashboard-reports.test.ts` の + * 「★施設名 × 分類 の積集合★」。 + */ + +/** + * ★★年次(`period=yearly`)★★(指示書AQ 決定1 / §3 罠2・罠6。2026-09-30 解禁) + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★`period=yearly&year=…&month=0` がルートを通って 200 を返す★(旧: `month >= 1` で 422) + * - ★★年次サマリ行を月次と取り違えない★★(罠2)— `periodOfSummaryRow()` が年次行を + * `monthly` と判定すると、`selectKpiRow()` の `row.period === query.period` で弾かれ + * ★例外もなく空のKPIになる★(fail-silent)。★値が出ること★で固定する + * - ★当年(`NOW` = 2026-09-16 の 2026年)は詳細テーブルからその場集計★し、 + * ★範囲は `periodRange()` の 1月1日〜12月31日★。★前期比は前年の年次サマリ★から + * - ★`period=yearly` で `month` が 0 以外なら 422★(落とし穴6。「yearly なら何でも通す」にしない) + */ +describe("★★年次(period=yearly)— 指示書AQ 決定1★★", () => { + it("★Y-1★ 当年 `period=yearly&year=2026&month=0` → 200。詳細から集計し、前期比は前年の年次サマリ", async () => { + // ★2026年の年次サマリ行は置かない★(置くと「確定期間の行が先」の規則でそちらが採られ、 + // 詳細の集計を観測できない。`reportKpis()` の末尾) + data.customerSummaries = [summary(60, 0, 2025, 0, 0, [70, 71, 72, 73])]; + const res = await GET_KPI( + new Request("http://localhost/api/reports/kpi?period=yearly&year=2026&month=0") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReportKpi; + + // 施設1(対象顧客=屋号1)の 2026年の本清掃: 201(80)/ 202(未実施)/ 301(95)/ 302(未実施)。 + // ★305 は未来の予定なので入らない★。★3011 は事前チェック行なので入らない★ + expect(body.operation_rate).toEqual({ value: 50, prev: 70, diff: -20 }); + expect(body.launch_rate.value).toBe(50); + // ★1回ごとの完了率の平均 (80+95)/2★(指示書AS。未実施はレポート行が無いので入らない) + expect(body.completion_rate.value).toBeCloseTo(87.5, 10); + expect(body.completion_rate.prev).toBe(72); + // 本清掃を指すエラー 201 / 301 の2件 ÷ ★予定4件★(第11回 P-6=B。旧: ÷ 実施2件で 100) + expect(body.error_rate.value).toBe(50); + + // ★範囲は `periodRange("yearly", 2026)` = 2026-01-01 〜 2026-12-31(壁時計値。C-6)★ + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + expect(startAt.gte.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(startAt.lt.toISOString()).toBe("2027-01-01T00:00:00.000Z"); + // ★サマリは当年 + 前年の年次キーだけを引く★(月次・週次のキーを混ぜない) + expect((calls.customerSummary[0].where as { OR: unknown[] }).OR).toEqual([ + { year: 2026, month: 0, week: 0 }, + { year: 2025, month: 0, week: 0 }, + ]); + }); + + it("★★Y-2★★ 確定した年 `period=yearly&year=2025&month=0` → ★年次サマリ行の値そのもの★(月次と取り違えない)", async () => { + data.customerSummaries = [ + summary(61, 0, 2025, 0, 0, [70, 71, 72, 73]), + summary(62, 0, 2024, 0, 0, [60, 61, 62, 63]), + // ★同じ年の月次行(12月)★ — `where` が期間キー3つの一致なので届かないはず。 + // ★届いたら 500(重複)か別の値になる★ + summary(63, 0, 2025, 12, 0, [1, 1, 1, 1]), + ]; + const res = await GET_KPI( + new Request("http://localhost/api/reports/kpi?period=yearly&year=2025&month=0") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReportKpi; + // ★★年次行が `monthly` と判定されていたら、ここは null(空のKPI)になる★★(罠2) + expect(body.operation_rate).toEqual({ value: 70, prev: 60, diff: 10 }); + expect(body.launch_rate.value).toBe(71); + expect(body.completion_rate.value).toBe(72); + expect(body.error_rate.value).toBe(73); + // ★確定期間では詳細テーブルを1行も読まない★(D-102) + expect(calls.cleanTimetable).toEqual([]); + expect(calls.cleanTaskReport).toEqual([]); + }); + + it("★Y-3★ 施設別パフォーマンスも年次サマリを `yearly` として読む(前期比は前年)", async () => { + data.facilitySummaries = [ + ...data.facilitySummaries, + summary(71, 1, 2025, 0, 0, [66, 66, 66, 0]), + summary(72, 1, 2024, 0, 0, [55, 55, 55, 0]), + ]; + const body = await performance("period=yearly&year=2025&month=0&limit=100"); + const fac1 = body.items.find((item) => item.facility_id === 1); + // ★年次行が `monthly` のキーで Map に入っていたら、ここは見つからないか値が null になる★ + expect(fac1?.operation_rate).toEqual({ value: 66, prev: 55, diff: 11 }); + expect(fac1?.completion_rate.value).toBe(66); + }); + + it("★Y-4★ `period=yearly` で `month` が 0 以外 → 422(DB を1本も引かない)", async () => { + for (const get of [GET_KPI, GET_PERFORMANCE, GET_CONTINUOUS, GET_BREAKDOWN]) { + const res = await get( + new Request("http://localhost/api/reports/x?period=yearly&year=2026&month=9") + ); + expect(res.status).toBe(422); + expect(((await res.json()) as ApiError).error.code).toBe("validation_failed"); + } + for (const key of Object.keys(calls)) expect(calls[key]).toEqual([]); + }); +}); + +describe("当期 — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => { + it("★robot_id が NULL の行を混ぜても KPI・施設別成績・エラー内訳が落ちず、値も変わらない★", async () => { + const before = [ + await kpi(CURRENT), + await performance(`${CURRENT}&limit=100`), + await breakdown(CURRENT), + ]; + data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null })); + for (const [handler, path] of [ + [GET_KPI, "kpi"], + [GET_PERFORMANCE, "facility-performance"], + [GET_BREAKDOWN, "error-breakdown"], + ] as const) { + const res = await handler(new Request(`http://localhost/api/reports/${path}?${CURRENT}&limit=100`)); + expect(res.status).toBe(200); + } + const after = [ + await kpi(CURRENT), + await performance(`${CURRENT}&limit=100`), + await breakdown(CURRENT), + ]; + expect(after).toEqual(before); + }); +}); + +describe("当期 — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => { + /** 当期の4本(KPI・施設別成績・連続問題・エラー内訳)をまとめて取る */ + async function all() { + return [ + await kpi(CURRENT), + await performance(`${CURRENT}&limit=100`), + await continuous(`${CURRENT}&limit=100`), + await breakdown(CURRENT), + ]; + } + + it("★予定が履歴にだけあっても、本表にあるときと同じ値になる★(レポート・エラー・出発失敗とも)", async () => { + const baseline = await all(); + // ★301(レポートとエラーあり)と 302(出発失敗)を履歴へ移す★ — 事前チェック行 3011 は本表に残す + const moved = new Set(["301", "302"]); + data.historyTimetables = data.timetables.filter((t) => moved.has(t.id.toString())); + data.timetables = data.timetables.filter((t) => !moved.has(t.id.toString())); + expect(await all()).toEqual(baseline); + }); + + it("★両方にある同じ予定は1件として数える★(本表のまま)", async () => { + const baseline = await all(); + data.historyTimetables = data.timetables.map((t) => ({ ...t })); + expect(await all()).toEqual(baseline); + }); + + it("★2表に同じ引数が渡る★(`where` を2回書かない)", async () => { + await kpi(CURRENT); + await breakdown(CURRENT); + expect(calls.cleanTimetableHistory).toEqual(calls.cleanTimetable); + }); +}); diff --git a/app/api/reports/continuous-problems/route.ts b/app/api/reports/continuous-problems/route.ts new file mode 100644 index 0000000..a66c195 --- /dev/null +++ b/app/api/reports/continuous-problems/route.ts @@ -0,0 +1,112 @@ +/** + * GET /reports/continuous-problems — 連続問題施設一覧(2日以上連続で△以下 or 出発失敗) + * + * 形は `app/api/ds-alerts/route.ts` に合わせている。 + * 仕様の正は `d-watch-api.yaml` の /reports/continuous-problems。 + */ +import { NextResponse } from "next/server"; +import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config"; +import { db } from "@/lib/db"; +import { capContinuousProblems } from "@/lib/db/report-mapping"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + filterContinuousProblems, + parseReportQuery, + selectContinuousProblemRows, + sortContinuousProblems, + toReportDbQuery, +} from "@/lib/reports/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { + ApiError, + ContinuousProblem, + ContinuousProblemRow, + ListResponse, +} from "@/lib/types"; + +const ROUTE = "/api/reports/continuous-problems"; + +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 = parseReportQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: ContinuousProblemRow[]; + // ★表示上限も try の中で取る★(ファイル読み込みは失敗しうる)。 + // `loadConsecutiveDaysMax()` は内部で既定値に倒れる設計(指示書AF 段階3-3)なので + // 通常は例外にならないが、★「設定が読めない」だけで一覧を 500 にしない★ため、 + // 下の catch とは分けて既定値で続行する。 + // ★型注釈 `: number` が要る★ — `CONSECUTIVE_DAYS_DISPLAY` は `as const` なので + // 注釈が無いとリテラル型 `30` に推論され、`loadConsecutiveDaysMax()` の戻りを代入できない + let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax; + try { + maxDays = await loadConsecutiveDaysMax(); + } catch { + // 既定値のまま続行する(一覧そのものは返せる) + } + try { + // ★★期間を SQL に降ろす★★(指示書AC 段階5)— ★`parseReportQuery()` を通った値を渡す★。 + // ★★`selectContinuousProblemRows()` / `filterContinuousProblems()` / + // `sortContinuousProblems()` は残す★★(絞り込み規則の「正」。指示書AC C-5)。 + // ★★分類(`group_id` / `customer_id`)は施設IDの配列に解決してから渡す★★ + // (指示書AK 段階4b)— ★`null` = 絞らない / `[]` = 0件★(★罠1★)。 + // ★このAPIに施設名の条件は無い★(`d-watch-api.yaml` の + // `/reports/continuous-problems` のパラメータは period/year/month/week/ + // group_id/customer_id/limit のみ)ので積集合は要らない。 + // ★★施設名で絞りたくなったら先に YAML を直し、`/reports/facility-performance` と + // 同じく `intersectFacilityIds()` で AND に畳むこと★★(`CLAUDE.md` 作業ルール1)。 + rows = await db.continuousProblems( + toReportDbQuery(parsed.query, await resolveFacilityIdsByMasters(parsed.query)) + ); + } 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/ds-alerts/route.ts` と同じ方針)。 + const inPeriod: ContinuousProblem[] = selectContinuousProblemRows( + rows, + parsed.query + ); + // 2日以上のみ返す(L1(初回)は含めない)。閾値は `lib/config` から読む + // (CLAUDE.md §7 ハードコード禁止。判断は `filterContinuousProblems` 側)。 + const filtered = filterContinuousProblems(inPeriod); + const sorted = sortContinuousProblems(filtered); + + // ★meta.total は「2日以上の絞り込み後・limit 適用前」の件数★(YAML の meta の説明) + // 画面の「他N件を表示」の算出元がこれ。 + const total = sorted.length; + const { limit } = parsed.query; + // ★表示上限は「絞り込み → 並べ替え → 切り出し」の**最後**にかける★(指示書AF 段階5-2) + // 先にかけると上限に達した施設どうしの降順が潰れる。`meta.total` も上限とは無関係 + // (件数は変わらない)。 + // ★データソース(`lib/db/`)の中ではなくルート層でかける★ — + // `USE_FIXTURES` の両方で同じ挙動にするため(`CLAUDE.md` §9.5)。 + const items = capContinuousProblems(sorted.slice(0, limit), maxDays); + + // page / per_page パラメータを持たない一覧なので meta.page は常に1、 + // meta.per_page は実際に適用された limit を返す(YAML の meta の説明)。 + const body: ListResponse = { + items, + meta: { total, page: 1, per_page: limit }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/reports/error-breakdown/route.ts b/app/api/reports/error-breakdown/route.ts new file mode 100644 index 0000000..91cd682 --- /dev/null +++ b/app/api/reports/error-breakdown/route.ts @@ -0,0 +1,90 @@ +/** + * GET /reports/error-breakdown — エラー種別の内訳(発生件数の降順・生値。D-077) + * + * 形は `app/api/ds-alerts/route.ts` に合わせている。 + * 仕様の正は `d-watch-api.yaml` の /reports/error-breakdown。 + * `type` は `robot_error_reports.error_type` の生値。サーバ側で変換しない(D-077)。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + parseReportQuery, + selectErrorBreakdownRows, + sortErrorBreakdown, + toReportDbQuery, +} from "@/lib/reports/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { + ApiError, + ErrorBreakdownItem, + ErrorBreakdownRow, +} from "@/lib/types"; + +const ROUTE = "/api/reports/error-breakdown"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +/** + * ★meta を返さない(一覧APIの例外。D-088で再判断)★ + * CLAUDE.md §9.3 は「一覧APIは必ず meta を返す」としているが、本エンドポイントは + * **意図的に meta を返さない**。理由: エラー種別の内訳は集計結果であり全件返す。 + * ページングが要るほどの種別数にはならない見込み(limit パラメータも無い)。 + * YAML の /reports/error-breakdown も `{ items }` のみで定義済み。 + * `ListResponse` を使わないのはこの理由(型を合わせるために meta を捏造しない)。 + * ★生値方針(D-077)では種別数が固定されない。実データで数十に膨らむ可能性があるため、 + * 実DB接続後に件数を実測して再判断すること(D-088)★ + */ +interface ErrorBreakdownResponse { + items: ErrorBreakdownItem[]; +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + // `parseReportQuery` は /reports/* 共通なので status / facility_name / serial / limit も + // 解釈するが、本エンドポイントにそれらのパラメータは存在しない。使わないフィールドは + // 単に無視する(period / year / month / week の検証だけが必要)。 + const parsed = parseReportQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: ErrorBreakdownRow[]; + try { + // ★★期間を SQL に降ろす★★(指示書AC 段階5)— ★`parseReportQuery()` を通った値を渡す★。 + // ★★`selectErrorBreakdownRows()` / `sortErrorBreakdown()` は残す★★(指示書AC C-5)。 + // ★★分類(`group_id` / `customer_id`)は施設IDの配列に解決してから渡す★★ + // (指示書AK 段階4b §7-3。★`facility_error_summaries` を `facility_id` で + // 絞っていなかった D-133 申し送り③の解消★)— ★`null` = 絞らない / `[]` = 0件★。 + // ★`rate` の分母も絞ったあとの母集団になる★(「この屋号のエラーのうち何%」)。 + rows = await db.errorBreakdown( + toReportDbQuery(parsed.query, await resolveFacilityIdsByMasters(parsed.query)) + ); + } 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/ds-alerts/route.ts` と同じ方針)。 + const inPeriod: ErrorBreakdownItem[] = selectErrorBreakdownRows( + rows, + parsed.query + ); + const items = sortErrorBreakdown(inPeriod); + + const body: ErrorBreakdownResponse = { items }; + return NextResponse.json(body); + }); +} diff --git a/app/api/reports/facility-performance/export/__tests__/route.test.ts b/app/api/reports/facility-performance/export/__tests__/route.test.ts new file mode 100644 index 0000000..ea6c115 --- /dev/null +++ b/app/api/reports/facility-performance/export/__tests__/route.test.ts @@ -0,0 +1,263 @@ +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 as GET_LIST } from "@/app/api/reports/facility-performance/route"; +import { GET } from "@/app/api/reports/facility-performance/export/route"; +import { MAX_CSV_ROWS } from "@/lib/config"; +import { logApiError } from "@/lib/logger/api"; +import type { + ApiError, + FacilityPerformance, + FacilityPerformanceRow, + FacilityStatus, + ListResponse, +} from "@/lib/types"; + +/** + * 施設別パフォーマンスの CSV エクスポート(D-163)— ルートの期待値表 R1〜R8。 + * + * | # | 入力 | 期待 | + * |----|----------------------------------------------|------| + * | R1 | 正しいパラメータ・250施設 | 200・`text/csv; charset=utf-8`・データ行 250(★limit の 200 で切れない★) | + * | R2 | `limit=5` を付けても | 全件 | + * | R3 | `status=bad` / `facility_name` / `serial` | 一覧ルートと同じ施設だけ(★同じ入力で一覧(limit=200)と CSV の並びが一致★) | + * | R4 | period 不正・month 不正 | 422(一覧と同じ) | + * | R5 | MAX_CSV_ROWS+1 施設 | 422 `validation_failed`・details `{ total, max }` | + * | R6 | MAX_CSV_ROWS ちょうど | 200 | + * | R7 | db が例外 | 500・`api.error`(`logApiError`) | + * | R8 | ファイル名 | 週/月/年で `facility-performance_{period}_{key}.csv` | + * + * ★★R5 と R6 の両方を必ず入れること★★ — 境界の片側だけだと、 + * `>` と `>=` の取り違えが通ってしまう(`CLAUDE.md` §9.4)。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db` の `db` をスタブに差し替える(`app/api/clean-logs/export/__tests__/route.test.ts` と同じ作法)。 + * ★施設名の解決(`facilityNameIndex()`)・分類の解決(`facilityGroupIndex()`)も同じ `db` を通る★ので、 + * ここ1箇所で足りる。表示上限の設定(JSON ファイル)は既定値を返すスタブにする。 + * + * ★CSV の中身(列・エスケープ・変化の文字)は `lib/reports/__tests__/facility-performance-csv.test.ts` の担当★。 + * ここでは「どの施設が・どの順で・何行出るか」と HTTP の形だけを見る。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +const STATUSES: readonly FacilityStatus[] = ["good", "warning", "bad", "no_data"]; + +/** 2026年9月(月次)の行。施設名・シリアルは一意(CSV の施設名から施設IDへ戻すため) */ +function item(id: number): FacilityPerformance { + return { + facility_id: id, + facility_name: `テスト施設${id}`, + serial_number: `SN${String(id).padStart(6, "0")}`, + // ★同値(ID 違い)と null を混ぜる★ — 並び順(稼働率の昇順・同値は ID 昇順・null は最後)を試すため + operation_rate: { value: id % 7 === 0 ? null : (id * 37) % 101, prev: null, diff: null }, + launch_rate: { value: 90, prev: null, diff: null }, + completion_rate: { value: 80, prev: null, diff: null }, + error_rate: { value: 5, prev: null, diff: null }, + status: STATUSES[id % STATUSES.length], + consecutive_problem_days: id % 5, + }; +} + +/** `db.facilityPerformance()` が返す施設数。各テストが書き換える */ +let facilityCount = 0; +/** true のとき `db.facilityPerformance()` が例外を投げる(R7) */ +let dbThrows = false; + +function rows(): FacilityPerformanceRow[] { + return Array.from({ length: facilityCount }, (_unused, i) => ({ + period: "monthly", + year: 2026, + month: 9, + week: 0, + item: item(i + 1), + })); +} + +vi.mock("@/lib/db", () => ({ + db: { + facilityPerformance: async () => { + if (dbThrows) throw new Error("db down"); + return rows(); + }, + // 施設名の解決に使う(施設名を指定しないテストでは読まれない) + facilityNameIndex: async () => + Array.from({ length: facilityCount }, (_unused, i) => ({ + id: i + 1, + facility_name: `テスト施設${i + 1}`, + })), + // 分類の解決に使う(分類を指定しないテストでは読まれない) + facilityGroupIndex: async () => [], + }, +})); + +vi.mock("@/lib/settings/display-limits", async () => { + const { CONSECUTIVE_DAYS_DISPLAY } = await import("@/lib/config"); + return { loadConsecutiveDaysMax: async () => CONSECUTIVE_DAYS_DISPLAY.defaultMax }; +}); + +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +const MONTHLY = "period=monthly&year=2026&month=9"; + +async function get(query: string): Promise { + return GET(new Request(`http://localhost/api/reports/facility-performance/export?${query}`)); +} + +/** + * レスポンスの本文を ★BOM を残したまま★ 文字列にする。 + * ★`Response.text()` は先頭の BOM を黙って捨てる★(`TextDecoder` の既定)ので、BOM の有無を見るには使えない。 + */ +async function csvText(res: Response): Promise { + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await res.arrayBuffer()); +} + +/** CSV のデータ行の施設名(1列目)。BOM とヘッダ行を除く */ +function csvNames(csv: string): string[] { + expect(csv.startsWith("")).toBe(true); + const lines = csv.slice(1).split("\r\n"); + // 末尾も CRLF なので、最後の要素は空文字 + expect(lines[lines.length - 1]).toBe(""); + expect(lines[0].startsWith("施設名,シリアル,")).toBe(true); + return lines.slice(1, -1).map((line) => line.split(",")[0]); +} + +/** 同じ条件で一覧ルートを叩いた結果の施設名(並び順そのまま) */ +async function listNames(query: string): Promise { + const res = await GET_LIST( + new Request(`http://localhost/api/reports/facility-performance?${query}&limit=200`) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + return body.items.map((i) => i.facility_name); +} + +beforeEach(() => { + facilityCount = 0; + dbThrows = false; + vi.mocked(logApiError).mockClear(); +}); + +// ---------------------------------------------------------------- 期待値表 + +describe("GET /reports/facility-performance/export", () => { + it("R1: 250施設 → 200・text/csv・データ行250(★limit の 200 で切れない★)", async () => { + facilityCount = 250; + const res = await get(MONTHLY); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/csv; charset=utf-8"); + const names = csvNames(await csvText(res)); + expect(names).toHaveLength(250); + }); + + it("R2: `limit=5` を付けても全件", async () => { + facilityCount = 30; + const res = await get(`${MONTHLY}&limit=5`); + expect(res.status).toBe(200); + expect(csvNames(await csvText(res))).toHaveLength(30); + }); + + it("R3: 絞り込みなしでも、一覧ルート(limit=200)と施設の並びが一致する", async () => { + facilityCount = 150; + const res = await get(MONTHLY); + const names = csvNames(await csvText(res)); + expect(names).toEqual(await listNames(MONTHLY)); + // ★並べ替えが本当に効いていること★(ID 順のままなら、何も並べ替えなくても一致してしまう) + expect(names).not.toEqual(Array.from({ length: 150 }, (_u, i) => `テスト施設${i + 1}`)); + }); + + for (const filter of ["status=bad", "facility_name=テスト施設1", "serial=SN0000", "status=good&serial=SN00002"]) { + it(`R3: \`${filter}\` → 一覧ルートと同じ施設が同じ順で出る`, async () => { + facilityCount = 150; + const query = `${MONTHLY}&${filter}`; + const res = await get(query); + expect(res.status).toBe(200); + const names = csvNames(await csvText(res)); + const fromList = await listNames(query); + expect(names).toEqual(fromList); + // ★絞り込みが効いていること★(全件のままなら、何も実装しなくても一致してしまう) + expect(names.length).toBeGreaterThan(0); + expect(names.length).toBeLessThan(150); + }); + } + + it("R4: period 不正・month 不正は 422(一覧と同じ)", async () => { + facilityCount = 3; + for (const query of ["period=daily&year=2026&month=9", "period=monthly&year=2026&month=13"]) { + const res = await get(query); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + + const list = await GET_LIST( + new Request(`http://localhost/api/reports/facility-performance?${query}`) + ); + expect(list.status).toBe(422); + expect(body).toEqual(await list.json()); + } + }); + + it("R5: ★MAX_CSV_ROWS+1 施設は 422★(500ではない)・details `{ total, max }`", async () => { + facilityCount = MAX_CSV_ROWS + 1; + const res = await get(MONTHLY); + 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("R6: ★MAX_CSV_ROWS ちょうどは 200★(「上限」は含む)", async () => { + facilityCount = MAX_CSV_ROWS; + const res = await get(MONTHLY); + expect(res.status).toBe(200); + expect(csvNames(await csvText(res))).toHaveLength(MAX_CSV_ROWS); + }); + + it("R7: db が例外 → 500・api.error(logApiError が1回)", async () => { + facilityCount = 3; + dbThrows = true; + const res = await get(MONTHLY); + expect(res.status).toBe(500); + const body = (await res.json()) as ApiError; + expect(body).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/reports/facility-performance/export", + method: "GET", + status: 500, + body, + }); + }); + + it("R8: ファイル名(週/月/年。ASCII のみ・月は2桁)", async () => { + const cases: [string, string][] = [ + ["period=weekly&year=2026&month=10&week=1", "facility-performance_weekly_2026-10-w1.csv"], + ["period=monthly&year=2026&month=9", "facility-performance_monthly_2026-09.csv"], + ["period=yearly&year=2025&month=0", "facility-performance_yearly_2025.csv"], + ]; + for (const [query, filename] of cases) { + const res = await get(query); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Disposition")).toBe(`attachment; filename="${filename}"`); + } + }); + + it("0施設は 200(ヘッダ行だけの CSV。★0件を 422 にしないこと★)", async () => { + facilityCount = 0; + const res = await get(MONTHLY); + expect(res.status).toBe(200); + expect(csvNames(await csvText(res))).toEqual([]); + }); +}); diff --git a/app/api/reports/facility-performance/export/route.ts b/app/api/reports/facility-performance/export/route.ts new file mode 100644 index 0000000..6f67cde --- /dev/null +++ b/app/api/reports/facility-performance/export/route.ts @@ -0,0 +1,83 @@ +/** + * GET /reports/facility-performance/export — 施設別パフォーマンスの CSV エクスポート(D-163) + * + * /reports/facility-performance と同一のパラメータを受け付ける(★`limit` は無視して全件を出す★)。 + * UTF-8 BOM付き、ヘッダ行あり、★画面の言語によらず日本語★。 + * 仕様の正は `d-watch-api.yaml` の /reports/facility-performance/export。 + */ +import { NextResponse } from "next/server"; +import { MAX_CSV_ROWS } from "@/lib/config"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +// ★CSVの組み立ては lib/reports/facility-performance-csv.ts★ — App Router の route.ts は +// HTTPメソッド以外の export を想定しておらず、ここに置くとテストから呼べない +// (`app/api/clean-logs/export/route.ts` と同じ理由。D-125) +import { + buildFacilityPerformanceCsv, + facilityPerformanceCsvFilename, +} from "@/lib/reports/facility-performance-csv"; +import { loadFacilityPerformance } from "@/lib/reports/facility-performance-load"; +import { parseReportQuery } from "@/lib/reports/query"; +import type { ApiError, FacilityPerformance } from "@/lib/types"; + +const ROUTE = "/api/reports/facility-performance/export"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + // ★検証は一覧と同じ★(period / year / month / week / group_id / customer_id が不正なら 422)。 + // `limit` も読まれるが、不正値でも 422 にはならない(丸めるだけ)うえ、下で使わない。 + const parsed = parseReportQuery(searchParams); + if (!parsed.ok) { + return NextResponse.json(parsed.error, { status: parsed.status }); + } + + let items: FacilityPerformance[]; + try { + // ★★一覧(`/reports/facility-performance`)と**同じ1本**を呼ぶ★★(D-163)— + // ★★片方だけ絞ると、画面の表と CSV の中身が食い違います★★。 + // ★`limit` で切らない★ — 画面の「全施設表示」の200件上限に縛られず、絞り込み後の全施設を出す。 + items = await loadFacilityPerformance(parsed.query); + } 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 }); + } + + // ★★行数の歯止め(清掃詳細ログの CSV と揃える。D-163)★★ + // + // 施設数なので実際にはまず起きないが、★上限・形・境界は `/clean-logs/export` と同じ★にする + // (`MAX_CSV_ROWS` を共有。★上限は「含む」= `>` であって `>=` ではない★)。 + // ★CSV を組み立てる**前**に数える★(組み立ててから長さで判断しない)。 + // ★500 ではなく 422★ — 利用者の指定が上限を超えているという話で、サーバの不具合ではない。 + if (items.length > MAX_CSV_ROWS) { + const body: ApiError = { + error: { + code: "validation_failed", + message: + `CSVで出力できません。対象の施設が${items.length}件で、` + + `上限の${MAX_CSV_ROWS}件を超えています。` + + `分類・施設名・ステータスで絞り込んでください。`, + // ★実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる + details: { total: items.length, max: MAX_CSV_ROWS }, + }, + }; + return NextResponse.json(body, { status: 422 }); + } + + const csv = buildFacilityPerformanceCsv(items, parsed.query.period); + // ★ファイル名は parse 済みの数値から組み立てる★(生のクエリ文字列を入れない)ので、 + // Content-Disposition に `"` や改行などの制御文字は入らない。 + const filename = facilityPerformanceCsvFilename(parsed.query); + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + }); +} diff --git a/app/api/reports/facility-performance/route.ts b/app/api/reports/facility-performance/route.ts new file mode 100644 index 0000000..4188650 --- /dev/null +++ b/app/api/reports/facility-performance/route.ts @@ -0,0 +1,60 @@ +/** + * GET /reports/facility-performance — 施設別パフォーマンス一覧(稼働率ワースト順) + * + * 形は `app/api/ds-alerts/route.ts` に合わせている。 + * 仕様の正は `d-watch-api.yaml` の /reports/facility-performance。 + */ +import { NextResponse } from "next/server"; +import { loadFacilityPerformance } from "@/lib/reports/facility-performance-load"; +import { parseReportQuery } from "@/lib/reports/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, FacilityPerformance, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/reports/facility-performance"; + +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 = parseReportQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + // ★★取得 → 絞り込み → 並べ替え → 連続日数の上限処理 は CSV(`/export`)と同じ1本★★(D-163)— + // ★ここで独自に絞り込みを足さないこと★(画面の表と CSV が食い違う)。 + // 中身の注記(施設名の解決・分類との積集合・表示上限)は `lib/reports/facility-performance-load.ts`。 + let sorted: FacilityPerformance[]; + try { + sorted = await loadFacilityPerformance(parsed.query); + } 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); + } + + // ★meta.total は「絞り込み後・limit 適用前」の件数★(YAML の meta の説明) + // 画面の「全N施設を表示」の N がこれ。limit で切った後の件数ではない。 + const total = sorted.length; + const { limit } = parsed.query; + const items = sorted.slice(0, limit); + + // page / per_page パラメータを持たない一覧なので meta.page は常に1、 + // meta.per_page は実際に適用された limit を返す(YAML の meta の説明)。 + const body: ListResponse = { + items, + meta: { total, page: 1, per_page: limit }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts new file mode 100644 index 0000000..6141ef4 --- /dev/null +++ b/app/api/reports/kpi/route.ts @@ -0,0 +1,154 @@ +/** + * GET /reports/kpi — レポート画面のKPI4指標(稼働率/出発成功率/清掃完了率/エラー発生率)+ 前期比 + * + * 形は `app/api/ds-alerts/route.ts` に合わせている。 + * 仕様の正は `d-watch-api.yaml` の /reports/kpi。 + * + * レスポンスは `ReportKpi` の素のオブジェクト(`items` / `meta` で包まない。/dashboard と同じ形)。 + * + * ------------------------------------------------------------------------ + * ★★このルートだけ 422 の理由が2つある★★(指示書AK ★段階6★ / §2-1) + * ------------------------------------------------------------------------ + * 1. パラメータが不正(`parseReportQuery()`) + * 2. ★★対象顧客が1社に定まらない★★(`resolveTargetCustomer()`)— + * ★確定期間のKPIは `customer_summaries` を読み、複数顧客ぶんを合算する材料がDBに無い★。 + * ★★合算も「静かに1社を選ぶ」こともせず、候補を返して選ばせる★★(D-133 / 落とし穴6)。 + * + * ★P1 の対象顧客は1社だけ(`CLAUDE.md` §1)なので、実データでは 2. は当面発火しない★。 + * ★★ただし `USE_FIXTURES=true` では発火する★★ — fixtures は3階層のUIを確かめるために + * ★屋号を4件持っている★(`lib/fixtures/customers.ts`)ので、★屋号かホールディングスを + * 選ばずにKPIを開くと 422★ になる(=画面が「選んでください」と言う。段階7 の選択UIの前提)。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { parseReportQuery, selectKpiRow, toReportKpiDbQuery } from "@/lib/reports/query"; +import { + resolveTargetCustomer, + TARGET_CUSTOMER_REQUIRED_MESSAGE, +} from "@/lib/reports/target-customer"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, Customer, ReportKpi, ReportKpiRow } from "@/lib/types"; + +const ROUTE = "/api/reports/kpi"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +/** + * 該当期間の行が無いときに返す空のKPI(4指標すべて null)。 + * + * ★404 にしない★(指示書F 4-1 の推奨) + * 404 にすると「データがまだ無い期間」と「不正な期間」の区別がクライアントから + * つかなくなる。不正な期間は `parseReportQuery` が422で弾く役割を負っているので、 + * ここに来た期間は「妥当だがデータがまだ無い」だけ。当期(進行中の週・月)はサマリ + * テーブルに行が無いのが正常(CLAUDE.md §4.7 / D-023)なので、それも同じ扱いになる。 + * `KpiValue.value` は分母0で null を取る型なので、空のKPIは契約どおりの値で表現できる。 + */ +function emptyKpi(): ReportKpi { + const empty = { value: null, prev: null, diff: null }; + return { + operation_rate: { ...empty }, + launch_rate: { ...empty }, + completion_rate: { ...empty }, + error_rate: { ...empty }, + }; +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseReportQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + // ★★確定期間のKPIは「対象顧客が1社に定まるときだけ」出す★★(指示書AK ★段階6★ / §2-1)— + // `customer_summaries` は顧客単位で、★4指標のうち2つは合算する材料がDBに無い★。 + // ★決め方は `resolveTargetCustomer()`(純粋関数)が正★ — ★ここに決定表を写さないこと★。 + // + // ★★`customer_id` が明示されているときは顧客マスタを読まない★★(指示書AL ★段階3★ / §4-3)— + // `resolveTargetCustomer()` は ★`customerId !== null` なら顧客リストを一切見ない★ + // (★存在確認をしない★。指示書AK 期待値表 6-9)ので、読んでも捨てるだけの1クエリになる。 + // ★★分岐させるのは「読むかどうか」だけ★★ — ★決め方は `resolveTargetCustomer()` に + // 任せたまま★(ここで顧客を決めてしまうと決定表が2箇所になり、片方だけ直すと静かにズレる)。 + // ★`!== null` で見ること★ — `parsed.query.customerId || ...` と書くと将来 0 が + // 入り込んだときに「未指定」に化ける(`resolveTargetCustomer()` と同じ作法)。 + let customers: Customer[] = []; + if (parsed.query.customerId === null) { + try { + customers = 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); + } + } + + const target = resolveTargetCustomer(customers, parsed.query); + if (!target.ok) { + // ★★500 ではなく 422★★ — サーバの不具合ではなく「指定が足りない」という話(§2-1)。 + // ★候補を `details.customer_ids` に入れる★ — 画面が「どれを選べばよいか」を + // ★文言を読まずに★ 示せるようにするため(`d-watch-api.yaml` の /reports/kpi 422 に明記)。 + // ★候補が空配列になるのは `group_id` 指定で傘下0社のときだけ★ + // (理由は `resolveTargetCustomer()` の docstring)。 + const body: ApiError = { + error: { + code: "validation_failed", + message: TARGET_CUSTOMER_REQUIRED_MESSAGE, + details: { customer_ids: target.candidateCustomerIds }, + }, + }; + return errorResponse(body, 422); + } + + let rows: ReportKpiRow[]; + try { + // ★★期間を SQL に降ろす★★(指示書AC 段階5)— ★生の `searchParams` ではなく + // `parseReportQuery()` を通った値を渡す★(§E-2)。 + // ★★`selectKpiRow()` は残す★★(絞り込み規則の「正」。指示書AC C-5)。 + // ★★顧客は `parsed.query.customerId` ではなく `target.customerId` を渡す★★ — + // ★未指定でも1社に定まっている★(§2-1 の決定表)。 + // + // ★★当期(進行中の週・月)は施設で絞る★★(指示書AL ★段階1★)— + // 確定期間は `customer_summaries` を `customer_id` で絞るが、★当期は詳細テーブルを + // その場集計する★ので軸が施設になる。★両者が同じ顧客を指していないと、 + // 同じ画面の中で「確定期間は1社・当期は全社」という食い違いが起きる★(D-138 の残課題)。 + // ★★だから `parsed.query`(=リクエストの `group_id` / `customer_id`)ではなく + // `target.customerId` から解決する★★ — ★ここに来た時点で顧客は1社に定まっている★ + // (2社以上なら上の 422 で止まっている)。★`groupId` は渡さない★(§2-3 / 落とし穴1)。 + // ★戻り値は `null`(絞らない)にはならない★ — `customerId` が必ず入るので、 + // ★該当施設が無ければ `[]` になり当期は空になる★(期待値表 1-3。★罠1★)。 + const facilityIds = await resolveFacilityIdsByMasters({ + groupId: null, + customerId: target.customerId, + }); + rows = await db.reportKpis( + toReportKpiDbQuery(parsed.query, target.customerId, 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); + } + + // 行の選択と空KPIの組み立ては DBアクセスではないので try の外で行う + // (`app/api/ds-alerts/route.ts` と同じ方針。try の中は `await db.xxx()` だけ)。 + const body: ReportKpi = selectKpiRow(rows, parsed.query) ?? emptyKpi(); + return NextResponse.json(body); + }); +} diff --git a/app/api/schedules/route.ts b/app/api/schedules/route.ts new file mode 100644 index 0000000..99731b6 --- /dev/null +++ b/app/api/schedules/route.ts @@ -0,0 +1,89 @@ +/** + * GET /schedules — スケジュール一覧(F-09。読み取り専用) + * + * 形は `app/api/ds-devices/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /schedules。 + * + * ★このAPIに summary は無い★(`items` と `meta` のみ。YAML の /schedules 参照)。 + * F-09 は「登録済みスケジュールの台帳」を見る画面で、 + * 異常件数のような集計を出す画面ではないため。 + * + * ★登録・更新は D-Security 側の画面で行うため、D-Watch は GET のみ★(YAML の description)。 + * POST/PUT/DELETE をここに足さないこと(CLAUDE.md §3 のとおり、メインDBへの書き込みは + * `robot_error_reports` / `control_error_histories` の4カラムだけが許可されている)。 + */ +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 { + filterSchedules, + paginateSchedules, + parseScheduleQuery, + sortSchedules, +} from "@/lib/schedules/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, ListResponse, Schedule } from "@/lib/types"; + +const ROUTE = "/api/schedules"; + +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 = parseScheduleQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: Schedule[]; + // ★分類(`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.schedules(); + } 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/ds-alerts/route.ts` と同じ方針)。 + // + // ★`active` では絞り込まない★ 有効・無効の両方を返し、画面が「有効」列で見せ分ける。 + // 詳細は `filterSchedules` の JSDoc。 + // ★分類は `Schedule.facility_id` で突き合わせる★(指示書AK 段階4)。 + // ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。 + const filtered = filterSchedules( + filterRowsByFacilityIds(rows, facilityIds), + parsed.query + ); + const sorted = sortSchedules(filtered); + + const { page, perPage } = parsed.query; + // meta.total は「絞り込み後・ページング前」の総件数(ページャの算出元)。 + // page が範囲外なら items は空になるが、total は全件数のまま返す。 + const total = sorted.length; + const items = paginateSchedules(sorted, page, perPage); + + const body: ListResponse = { + items, + meta: { total, page, per_page: perPage }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/settings/advice/route.ts b/app/api/settings/advice/route.ts new file mode 100644 index 0000000..5511bb5 --- /dev/null +++ b/app/api/settings/advice/route.ts @@ -0,0 +1,145 @@ +/** + * GET /settings/advice — アドバイス文言の一覧(既定値+編集内容) + * PATCH /settings/advice — 1ルール分の文言を保存 + * + * システム管理 →「運用設定」から使います。 + * 編集できるのは title / text / actions のみ。 + * level / icon / condition(発火条件)は仕様なので画面からは変更できません。 + */ +import { NextResponse } from "next/server"; +import { + DEFAULT_ADVICE_RULES, + type AdviceRuleId, + type EditableAdviceFields, +} from "@/lib/config/advice-rules"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { loadAdviceRules, resetAdviceRule, saveAdviceRule } from "@/lib/settings/advice"; +import { + isStringArray, + validateAdviceFields, + type AdviceFieldsToValidate, +} from "@/lib/settings/advice-validation"; +import type { AdviceSetting, ApiError, ErrorCode } from "@/lib/types"; + +const ROUTE = "/api/settings/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) { + return withRequestId(request, async () => { + const rules = await loadAdviceRules(); + const items: AdviceSetting[] = Object.values(rules).map((r) => ({ + rule: r.id, + phase: r.phase, + level: r.level, + icon: r.icon, + title: r.title, + text: r.text, + actions: r.actions, + condition: r.condition, + /** 既定値から変更されているか(画面に「編集済み」バッジを出す用) */ + customized: + r.title !== DEFAULT_ADVICE_RULES[r.id].title || + r.text !== DEFAULT_ADVICE_RULES[r.id].text || + JSON.stringify(r.actions) !== + JSON.stringify(DEFAULT_ADVICE_RULES[r.id].actions), + })); + return NextResponse.json({ items }); + }); +} + +export async function PATCH(request: Request) { + return withRequestId(request, async () => { + let body: { + rule?: string; + title?: string; + text?: string; + /** ★`string[]` と書かない★ — JSONで何が来るかは実行時まで分からない。 + * 型で嘘をつくと `filter` や `as` で握りつぶす実装に流れる */ + actions?: unknown; + reset?: boolean; + }; + + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + const ruleId = body.rule as AdviceRuleId | undefined; + if (!ruleId || !DEFAULT_ADVICE_RULES[ruleId]) { + return errorResponse(422, "validation_failed", "ruleの値が不正です"); + } + + try { + if (body.reset) { + const reverted = await resetAdviceRule(ruleId); + return NextResponse.json({ rule: reverted.id, reset: true }); + } + + const fields: AdviceFieldsToValidate = {}; + if (typeof body.title === "string") fields.title = body.title; + if (typeof body.text === "string") fields.text = body.text; + // ★要素の型は見ない★ — 文字列以外が混ざっていたら + // `validateAdviceFields()` が 422 にする(落として保存すると + // `actions: [1,2,3]` が「全消去」になり、黙って既存の編集が消える) + if (Array.isArray(body.actions)) fields.actions = body.actions; + + if (Object.keys(fields).length === 0) { + return errorResponse( + 422, + "validation_failed", + "title / text / actions のいずれかを指定してください" + ); + } + + // ★長さ・件数の上限(指示書AE 段階2-3 / D-124)★ + // 保存先が ds-device-notes とまったく同じ「全件読み込み→書き戻し」の + // JSON ストアなので、DEVICE_NOTE_MAX_LENGTH と同じ理由で上限が要る。 + // 上限値は lib/config/index.ts(CLAUDE.md §7。直書きしない) + const invalid = validateAdviceFields(fields); + if (invalid) { + return NextResponse.json(invalid.error, { status: invalid.status }); + } + + // ★ここに来た時点で actions は全要素が文字列★(そうでなければ上で422)。 + // その事実を型の世界へ渡すための型ガード。`as` で潰すと、検証を外したときに + // 型エラーで気付けなくなる。 + // ★キーは「指定されたものだけ」入れる★ — `title: undefined` を混ぜると + // 保存側の `{ ...既定値, ...fields }` で既定値を undefined で上書きしてしまう。 + const toSave: Partial = {}; + if (fields.title !== undefined) toSave.title = fields.title; + if (fields.text !== undefined) toSave.text = fields.text; + const actions = fields.actions; + if (actions !== undefined && isStringArray(actions)) { + // 複製して保存側に呼び出し元の配列参照を渡さない + toSave.actions = [...actions]; + } + + const saved = await saveAdviceRule(ruleId, toSave); + return NextResponse.json({ + rule: saved.id, + title: saved.title, + text: saved.text, + actions: saved.actions, + }); + } catch (e) { + const errorPayload = errorBody("internal_error", "設定の保存に失敗しました"); + logApiError({ + route: ROUTE, + method: "PATCH", + body: errorPayload, + status: 500, + err: e, + }); + return NextResponse.json(errorPayload, { status: 500 }); + } + }); +} diff --git a/app/api/settings/display-limits/route.ts b/app/api/settings/display-limits/route.ts new file mode 100644 index 0000000..d3c387c --- /dev/null +++ b/app/api/settings/display-limits/route.ts @@ -0,0 +1,163 @@ +/** + * GET /settings/display-limits — 表示上限の一覧(既定値+編集内容) + * PATCH /settings/display-limits — 表示上限を1件更新する / 既定値に戻す + * + * システム管理 →「運用設定」から使います(指示書AF 段階4 / ユーザー指示 2026-09-13)。 + * + * ★保存先はメインDBではありません★ + * `lib/settings/display-limits.ts`(DFA独自のJSONストア。 + * `data/settings/display-limits.json`)に書きます。メインDBはTeamWorks管理で、 + * DFAが更新できるのは `robot_error_reports` / `control_error_histories` の + * 4カラムだけです(`CLAUDE.md` §3 / §7)。 + * + * ★検証はここに書きません★ + * `validateConsecutiveDaysMax()`(`lib/settings/display-limits.ts`)が唯一の正で、 + * PATCH と保存済み値の読み込みの両方が同じ関数を通ります。 + * APIルートの単体テストが1件も無いリポジトリなので、検証は純粋関数に寄せてあります。 + * + * ★どのキーがあるかも、ここには書きません★ + * `lib/settings/display-limits.ts` の `DISPLAY_LIMIT_HANDLERS` + * (`Record`)が唯一の正です。このルートは + * `isDisplayLimitKey()` / `validateDisplayLimit()` / `saveDisplayLimit()` / + * `resetDisplayLimit()` に `key` を渡すだけで、キー名で分岐しません。 + * ★ここにキー一覧を持つと、2つ目のキーを足したときに + * 「別キーへの PATCH が `consecutive_days_max` を書き換える」★ 形になります + * (例外もテスト失敗も出ません)。 + * + * ★`export const dynamic = "force-dynamic"` は付けていません(実測して不要と確認した)★ + * このGETはクエリパラメータを持たないので静的化を疑うのが正しい(静的化されると + * 保存しても画面に反映されない)。Next.js 16.3.3 の + * `node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md:51`: + * ★「Route Handlers are not cached by default」★ — キャッシュしたいときに + * `export const dynamic = 'force-static'` を**足す**側の設計になっている。 + * Cache Components(有効時は GET が prerender されうる)は `next.config.ts` で + * 有効化していない。★`npm run build` の出力で `ƒ /api/settings/display-limits` + * (Dynamic)であることを確認済み★(2026-09-13。`/api/settings/advice` も同じ)。 + * ★Next を上げたときは build 出力で `○ (Static)` に変わっていないか見ること★。 + * + * ★`min` / `max` をここで組み立てないこと★ + * 範囲は `lib/config` の `CONSECUTIVE_DAYS_DISPLAY` が正で、GET のレスポンスに + * そのまま載ります。画面の入力欄はその値を使います(`CLAUDE.md` §7 ハードコード禁止)。 + */ +import { NextResponse } from "next/server"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { + isDisplayLimitKey, + loadDisplayLimitSettings, + resetDisplayLimit, + saveDisplayLimit, + validateDisplayLimit, +} from "@/lib/settings/display-limits"; +import type { + ApiError, + DisplayLimitKey, + DisplayLimitSetting, + ErrorCode, +} from "@/lib/types"; + +const ROUTE = "/api/settings/display-limits"; + +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 }); +} + +/** + * 更新後の1件を返す。 + * + * ★保存した値をそのままエコーバックしないこと★ — 画面は「送った値」ではなく + * 「実際に有効になった値」で表示を更新する必要があります(`customized` も + * ストアを読まないと決まりません。`ds-devices/[serial_number]/note` と同じ規律)。 + */ +async function settingFor(key: DisplayLimitKey): Promise { + const items = await loadDisplayLimitSettings(); + const item = items.find((i) => i.key === key); + if (!item) { + // `DISPLAY_LIMIT_HANDLERS` から一覧を作っているので、通常は到達しません + // (`isDisplayLimitKey()` が通った key = 表にあるキー)。 + // 黙って200を返すと、画面には何も反映されないのに成功したように見えるので残しています + throw new Error(`表示上限の定義が見つかりません: ${key}`); + } + return item; +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const items = await loadDisplayLimitSettings(); + return NextResponse.json({ items }); + }); +} + +export async function PATCH(request: Request) { + return withRequestId(request, async () => { + let body: { + /** ★`string` と書かない★ — JSONで何が来るかは実行時まで分からない */ + key?: unknown; + value?: unknown; + reset?: unknown; + }; + + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + // ★キーを知っているのは `lib/settings/display-limits.ts` の対応表だけ★ — + // ここに独自のキー一覧を置くと、表に足したキーがルートでは弾かれる(またはその逆)。 + // 未知のキーはここで 422(`d-watch-api.yaml` の `DisplayLimitSetting.key` の enum 外) + const key = body.key; + if (!isDisplayLimitKey(key)) { + return errorResponse(422, "validation_failed", "keyの値が不正です"); + } + + try { + // ★検証も保存もリセットも `key` を渡すこと★ — 特定のキー専用の関数 + // (`saveConsecutiveDaysMax()` 等)をここから直接呼ばない。 + // 呼ぶと、2つ目のキーへの PATCH が `consecutive_days_max` を書き換えます + // (例外もテスト失敗も出ない。指示書AF §H-1 の「他の閾値へ広げる」で必ず踏む) + // ★`reset` を `value` より先に見る★ — 既定値に戻す操作では value を送らないため + if (body.reset === true) { + await resetDisplayLimit(key); + return NextResponse.json(await settingFor(key)); + } + + const outcome = validateDisplayLimit(key, body.value); + if (!outcome.ok) { + // details は field 別のメッセージ。★入力値をエコーしない★ + // (`validateConsecutiveDaysMax()` の reason は範囲の説明だけで、 + // 送られた値を含まない) + return errorResponse(422, "validation_failed", "valueの値が不正です", { + value: [outcome.reason], + }); + } + + await saveDisplayLimit(key, outcome.value); + return NextResponse.json(await settingFor(key)); + } catch (e) { + // ★`err.message` を本文に載せないこと★(保存先の絶対パス等が画面へ出る) + const errorPayload = errorBody("internal_error", "設定の保存に失敗しました"); + logApiError({ + route: ROUTE, + method: "PATCH", + body: errorPayload, + status: 500, + err: e, + }); + return NextResponse.json(errorPayload, { status: 500 }); + } + }); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe --- /dev/null +++ b/app/favicon.ico Binary files differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..89ef6ba --- /dev/null +++ b/app/globals.css @@ -0,0 +1,94 @@ +:root, +[data-theme="dark"] { + --bg: #0f1419; + --sf: #1a2332; + --sf-h: #1e2a3a; + --bd: #2a3a4e; + --pr: #3b82f6; + --pr-d: #1e3a5f; + --ok: #22c55e; + --ok-d: #0a3d1f; + --wn: #f59e0b; + --wn-d: #3d2a06; + --ng: #ef4444; + --ng-d: #3d1111; + --ng-b: #ff6b6b; + --tx: #e2e8f0; + --tx-d: #94a3b8; + --tx-m: #64748b; + --ac: #06b6d4; +} + +[data-theme="light"] { + --bg: #f1f5f9; + --sf: #ffffff; + --sf-h: #f8fafc; + --bd: #e2e8f0; + --pr: #2563eb; + --pr-d: #dbeafe; + --ok: #16a34a; + --ok-d: #dcfce7; + --wn: #d97706; + --wn-d: #fef3c7; + --ng: #dc2626; + --ng-d: #fee2e2; + --ng-b: #dc2626; + --tx: #1e293b; + --tx-d: #475569; + --tx-m: #94a3b8; + --ac: #0891b2; +} + +html { + height: 100%; +} + +html, +body { + max-width: 100vw; +} + +body { + min-height: 100%; + color: var(--tx); + background: var(--bg); + /* 欧文は Geist、日本語は各OS標準の日本語フォントへ明示的にフォールバックさせる。 + Geist は日本語グリフを持たないため、日本語フォントを列挙しないと sans-serif に落ち、 + 閲覧環境に中国語フォントがある場合に漢字が中華字形で表示される。 */ + font-family: var(--font-geist-sans), "Hiragino Sans", "Hiragino Kaku Gothic ProN", + "Yu Gothic UI", "Yu Gothic", "Noto Sans JP", Meiryo, sans-serif; + /* ★行の高さを数値で固定する★(2026-10-01 ユーザー指摘「言語切り替え時に表などの位置も動く」)。 + 既定の `normal` はフォントごとに高さが違い、日本語(Yu Gothic 等)と英語(Geist)で + 同じ文字サイズでも行が高くなったり低くなったりして、★言語を切り替えると画面全体が上下に動いていた★ + (実測: システム管理 10px / レポート 7px / ダッシュボード 5px)。数値にすれば文字サイズだけで決まる */ + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select { + font-family: inherit; + /* ★フォーム部品は行の高さを親から受け継がない★ので明示する(`body` の line-height を参照) */ + line-height: inherit; +} + +html { + color-scheme: dark; +} + +[data-theme="light"] { + color-scheme: light; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..eb6e67f --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme/ThemeProvider"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "D-Watch", + description: "DFAロボット監視システム", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + {children} + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..a112d28 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,142 @@ +/** + * /login — 共有トークンの入力画面(P1 の簡易トークン認証)。 + * + * ★このパスは認証をかけない★(`lib/proxy/authenticate.ts` の `isPublicPath()`)。 + * ただし ★IP制限はかかる★ ので、許可されないIPからは 403 になる。 + * + * ★入力値をログに出さないこと★(`console.log` も禁止。`no-console` で lint が落ちる) + * ★リダイレクトに絶対URLを組まないこと★ — LB が HTTPS を終端するので、 + * アプリ側で組むと `http://` になる。相対パスで済ませる。 + * + * `AppShell`(サイドバー付きの器)は使わない。未認証の画面にメニューを出さない。 + */ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; +import { safeNextPath } from "@/lib/auth/next-path"; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (response.ok) { + // 入力欄をすぐ空にする(画面に残さない) + setToken(""); + router.replace(safeNextPath(searchParams.get("next"))); + router.refresh(); + return; + } + // ★「トークンが違う」以上の情報を出さない★ + setError("トークンが違います"); + } catch { + setError("通信に失敗しました。時間をおいて再度お試しください"); + } finally { + setSubmitting(false); + } + } + + return ( +
+

D-Watch

+

DFAロボット監視システム

+ + + setToken(e.target.value)} + style={styles.input} + disabled={submitting} + required + /> + + {error ? ( +

+ {error} +

+ ) : null} + + + +

社内利用のみ。接続元IPも制限されています。

+
+ ); +} + +export default function LoginPage() { + return ( +
+ {/* useSearchParams はプリレンダリング時に Suspense 境界が要る */} + + + +
+ ); +} + +const styles: Record = { + page: { + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + background: "var(--bg)", + }, + card: { + width: "100%", + maxWidth: 360, + display: "flex", + flexDirection: "column", + gap: 8, + padding: 32, + borderRadius: 12, + border: "1px solid var(--bd)", + background: "var(--sf)", + }, + title: { fontSize: 24, fontWeight: 700, color: "var(--tx)" }, + lead: { fontSize: 13, color: "var(--tx-d)", marginBottom: 16 }, + label: { fontSize: 12, color: "var(--tx-d)" }, + input: { + padding: "10px 12px", + borderRadius: 8, + border: "1px solid var(--bd)", + background: "var(--bg)", + color: "var(--tx)", + fontSize: 14, + }, + error: { fontSize: 13, color: "var(--ng-b)", marginTop: 4 }, + button: { + marginTop: 12, + padding: "10px 12px", + borderRadius: 8, + border: "none", + background: "var(--pr)", + color: "#fff", + fontSize: 14, + fontWeight: 600, + cursor: "pointer", + }, + note: { fontSize: 11, color: "var(--tx-m)", marginTop: 12 }, +}; diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b5b109c --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/dashboard"); +} diff --git a/components/features/alerts/AlertsScreen.module.css b/components/features/alerts/AlertsScreen.module.css new file mode 100644 index 0000000..0eeab44 --- /dev/null +++ b/components/features/alerts/AlertsScreen.module.css @@ -0,0 +1,428 @@ +.page { + min-width: 0; +} + +.header { + margin-bottom: 4px; +} + +.title { + font-size: 22px; + font-weight: 700; + margin: 0 0 4px; +} + +.subtitle { + font-size: 13px; + color: var(--tx-d); + margin-bottom: 16px; +} + +.statGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.toolbar { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; + align-items: center; +} + +/* ★絞り込みのまとまり(D-165)★ — 編集モード中は `
` で中の入力・ボタンをまとめて無効にする。 + fieldset の既定の枠・余白は外し、並びは .toolbar と同じ横並び */ +.filterSet { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.filterSet:disabled, +.pagerSet:disabled { + opacity: 0.5; +} + +.filterSet:disabled *, +.pagerSet:disabled * { + cursor: not-allowed; +} + +/* ページ送りを包む fieldset(編集モード中に無効にするためだけ。見た目は変えない) */ +.pagerSet { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +/* ★編集 / 保存 / キャンセル(D-165)★ — 絞り込みの行の右端。 + ★幅は固定★(「編集」⇔「保存({n}件)・キャンセル」、JP/EN で行の折り返しが変わらないように) */ +.editActions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-left: auto; + width: 220px; +} + +/* 見た目は `SystemScreen.module.css` の .saveButton / .resetButton に揃えてある */ +.editButton { + min-width: 96px; + font-size: 12px; + font-weight: 600; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--bd); + background: transparent; + color: var(--tx-d); + cursor: pointer; + white-space: nowrap; +} + +.editButtonPrimary { + min-width: 112px; + border-color: transparent; + background: var(--pr-d); + color: var(--pr); +} + +.editButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 編集モード中だけ出す案内(絞り込みとページ送りを使えない理由)と、保存に失敗した件数 */ +.editNotice { + margin: -8px 0 12px; + font-size: 12px; + color: var(--tx-m); +} + +.editFailed { + margin-top: 4px; + font-weight: 600; + color: var(--ng); +} + +.dateGroup { + display: flex; + align-items: center; + gap: 6px; +} + +.dateInput { + font-size: 12px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--bd); + background: var(--sf-h); + color: var(--tx); + /* 枠のどこを押してもカレンダーが開くので、枠全体を押せる見た目にする(ユーザー指示 2026-10-06。D-162) */ + cursor: pointer; +} + +.dateSep { + font-size: 12px; + color: var(--tx-m); +} + +/* 分類(ホールディングス / 屋号)のセレクト。モック:609/:612 と同じ見た目。 + ★`font-family` は書かない★(CLAUDE.md §0-3。日本語フォントは globals.css が正) */ +.select { + font-size: 12px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--bd); + background: var(--sf-h); + color: var(--tx); +} + +/* ★分類(ホールディングス / 顧客)のセレクトは固定幅(ユーザー指示 2026-10-06。D-162)★ — + 選んだ名前の長さで幅が変わらないように。はみ出す名前は「…」で切り、全文は `title`(hover)で読む */ +.masterSelect { + width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusButtons { + display: flex; + gap: 6px; +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.itemCard { + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 12px; + border-left: 3px solid var(--severity-color, var(--bd)); + padding: 16px 20px; +} + +/* ★変えた行(下書きがある行)の目印(D-165)★ — 背景をうっすら色付けする。 + ★左の帯(border-left)は分類の色なので変えない★ */ +.itemCardChanged { + background: color-mix(in srgb, var(--pr) 8%, var(--sf)); +} + +/* ★本清掃が成功した事前チェック異常のカード(ユーザー指示 2026-10-07。D-171 の案2)★ — 中身を控えめにする。 + ★カード全体(.itemCard)に opacity をかけないこと★ — 子で 1 に戻せず、編集モードのプルダウン(.statusSelect)と + 備考の入力欄(.noteInput)まで薄くなる。★操作部品は不透明度 1 のまま★(ここに足さない)。 + 保存失敗の文言(.rowError)も薄くしない(読み落とさないため) */ +.itemCardSubdued .itemBadges, +.itemCardSubdued .facility, +.itemCardSubdued .scheduleLine, +.itemCardSubdued .batteryLine, +.itemCardSubdued .detectedLine, +.itemCardSubdued .statusBadge, +.itemCardSubdued .statusTime { + opacity: 0.6; +} + +.itemRow { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; +} + +.itemMain { + flex: 1; + min-width: 250px; +} + +.itemBadges { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.serial { + font-size: 12px; + font-family: var(--font-geist-mono), ui-monospace, monospace; + color: var(--tx-m); +} + +.facility { + font-size: 14px; + font-weight: 600; + color: var(--tx); +} + +.scheduleLine { + font-size: 13px; + color: var(--tx-d); + margin-top: 4px; +} + +/* 事前チェックの行の「予定: HH:MM — 備考」(通常モードで備考があるときだけ付ける。D-165)。 + ★備考を1行に収める(ユーザー指示 2026-10-06。D-162)★ — 前置き「予定: HH:MM —」は縮めず、 + 備考(.noteText)だけが残りの幅で縮んで「…」になる */ +.scheduleLineNote { + display: flex; + align-items: baseline; + gap: 0.25em; + min-width: 0; +} + +.schedulePrefix { + flex-shrink: 0; + white-space: nowrap; +} + +.detectedLine { + font-size: 11px; + color: var(--tx-m); + margin-top: 4px; +} + +.batteryLine { + font-size: 11px; + color: var(--tx-m); + margin-top: 4px; +} + +.statusBadge { + font-size: 12px; + padding: 4px 14px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; +} + +/* ★通常モードの事前チェックの行のバッジ(D-165)★ — 編集モードのプルダウン(.statusSelect)と同じ幅・高さにして、 + モードを切り替えても列の幅と行の高さが変わらないようにする */ +.statusBadgeFixed { + display: block; + box-sizing: border-box; + width: 120px; + padding: 4px 8px; + border: 1px solid transparent; + text-align: center; +} + +/* ★事前チェックの行の対応状況プルダウン(ユーザー指示 2026-10-06。D-161)★ + 色は .statusBadge と同じ(.statusResolved 等を重ねて付ける)。 + ★幅は固定★(JP/EN や選んだ値で列の幅が変わらないように。"In progress" と ▼ が収まる幅)。 + ブラウザ既定の見た目は外し、▼ を自前で描く(`currentColor` なので状態の文字色に揃う。 + `ErrorsScreen.module.css` の .statusSelect と同じ作り)。 + ★`font-family` は書かない★(globals.css が select に inherit 済み。CLAUDE.md §0-3) */ +.statusSelect { + display: block; + width: 120px; + font-size: 12px; + font-weight: 600; + /* 右の padding は ▼ のぶん */ + padding: 4px 24px 4px 8px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 13px) 50%, + calc(100% - 9px) 50%; + background-size: 4px 4px; + background-repeat: no-repeat; +} + +.statusSelect:hover { + border-color: currentColor; +} + +.statusSelect:focus-visible { + outline: 2px solid var(--ac); + outline-offset: 1px; +} + +.statusSelect:disabled { + opacity: 0.6; + cursor: progress; +} + +/* 選択肢の一覧は OS 描画なので、色付き背景を引き継がず通常の配色で出す */ +.statusSelect option { + background: var(--sf); + color: var(--tx); +} + +/* 解消のときだけプルダウンの下に出す解消日時(旧ボタンの「解消 (日時)」の日時部分) */ +.statusTime { + margin-top: 2px; + /* 11px では読みにくいので 12px(ユーザー指示 2026-10-06。D-162) */ + font-size: 12px; + color: var(--tx-m); + white-space: nowrap; +} + +.statusColumn { + flex-shrink: 0; +} + +/* ★通常モードの備考(D-165)★ — 文字だけ(押しても何も起きない)。 + ★文字の長さで枠の大きさを変えない(D-162 / D-164)★ — `.scheduleLineNote` の中で「予定: HH:MM —」の + 残りの幅を全部取り、はみ出す分は「…」で切る。全文は `title`(hover)で読む */ +.noteText { + display: block; + flex: 1 1 0%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; +} + +.noteInput { + display: block; + width: 100%; + margin-top: 4px; + font-size: 12px; + /* textarea は globals.css の font-family: inherit の対象外。指定しないと日本語がブラウザ既定フォントに落ちる */ + font-family: inherit; + line-height: 1.5; + padding: 5px 7px; + color: var(--tx); + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 6px; + /* ★大きさは固定(D-162 / D-165)★ — 2行ぶんの高さ・幅は列いっぱい。長い入力は中でスクロールし、つまみで広げさせない */ + box-sizing: border-box; + height: calc(2 * 1.5em + 12px); + overflow-y: auto; + resize: none; +} + +.noteInput:focus { + outline: none; + border-color: var(--ac); +} + +.noteInput:disabled { + opacity: 0.6; + cursor: progress; +} + +.rowError { + margin-top: 6px; + font-size: 11px; + color: var(--ng); +} + +/* ★`background` の一括指定にしないこと★ — `.statusSelect` の ▼(background-image)が消える。 + .statusBadge は背景画像を持たないので、background-color でも見た目は変わらない */ +.statusResolved { + background-color: var(--ok-d); + color: var(--ok); +} + +.statusInProgress { + background-color: var(--wn-d); + color: var(--wn); +} + +.statusUnresolved { + background-color: var(--ng-d); + color: var(--ng); +} + +.empty { + padding: 24px 20px; + font-size: 13px; + color: var(--tx-m); + text-align: center; + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 12px; +} + +.error { + padding: 16px 20px; + font-size: 13px; + color: var(--ng); +} + +.loading { + padding: 24px 20px; + font-size: 13px; + color: var(--tx-m); + text-align: center; +} diff --git a/components/features/alerts/AlertsScreen.tsx b/components/features/alerts/AlertsScreen.tsx new file mode 100644 index 0000000..74e7f1f --- /dev/null +++ b/components/features/alerts/AlertsScreen.tsx @@ -0,0 +1,684 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { openDatePicker } from "@/components/ui/date-picker"; +import { FilterButton } from "@/components/ui/FilterButton"; +import { Pagination } from "@/components/ui/Pagination"; +import { Pill } from "@/components/ui/Pill"; +import { StatCard } from "@/components/ui/StatCard"; +import { useTheme } from "@/components/theme/ThemeProvider"; +import { apiFetch, ApiFetchError } from "@/lib/api/client"; +import { PAGINATION, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config"; +import { formatHm, formatMd } from "@/lib/format/date"; +import { useClassificationFilter } from "@/lib/hooks/use-classification-filter"; +import { + ALL_OPTION_VALUE, + appendClassificationParams, + type MasterOption, +} from "@/lib/masters/options"; +import { totalPageCount } from "@/lib/pagination/page-items"; +import { + draftNote, + draftStatus, + saveDrafts, + setDraftNote, + setDraftStatus, + type ResolutionDrafts, +} from "@/lib/prechecks/edit-drafts"; +import { precheckSeverityLabel } from "@/lib/prechecks/label"; +import { applyResolutionChange, type PrecheckSummary } from "@/lib/prechecks/query"; +import { THEME_TOKENS, type ThemeName } from "@/lib/theme"; +import { statusColor } from "@/lib/theme-status"; +import type { + PrecheckItem, + PrecheckSeverity, + PrecheckStatus, + PrecheckStatusUpdateRequest, + SummaryListResponse, +} from "@/lib/types"; +import styles from "./AlertsScreen.module.css"; + +type ThemeTokens = (typeof THEME_TOKENS)[ThemeName]; +type StatusButtonFilter = "open" | "all" | "resolved"; + +/** + * ★`launch_failed` だけ赤(`c.ng`)、事前チェック異常は警告色(`c.wn`)★ + * `severity` が2値化されても分岐は変わらない(D-105 で確認のみ)。 + */ +function severityColor(c: ThemeTokens, severity: PrecheckSeverity): string { + return severity === "launch_failed" ? c.ng : c.wn; +} + +function formatDateTime(iso: string): string { + return `${formatMd(iso)} ${formatHm(iso)}`; +} + +/** + * 選んでいる選択肢のラベル(分類のセレクトの `title` に使う)。 + * ★セレクトは固定幅で長い名前は「…」で切れる★ので、hover で全文を読めるようにする(ユーザー指示 2026-10-06。D-162) + */ +function selectedOptionLabel(options: readonly MasterOption[], value: string): string | undefined { + return options.find((o) => o.value === value)?.label; +} + +function statusBadgeClass(status: PrecheckStatus): string { + switch (status) { + case "resolved": + return styles.statusResolved; + case "in_progress": + return styles.statusInProgress; + case "unresolved": + return styles.statusUnresolved; + } +} + +/** + * 対応状況のプルダウンの選択肢(この順で並べる)。ラベルは `t()` に通す辞書キー。 + * ★`value` は `PrecheckStatus` そのもの★ — onChange で受け取った文字列をこの表で引き直して型を戻す + */ +const RESOLUTION_STATUS_OPTIONS: readonly { value: PrecheckStatus; label: string }[] = [ + { value: "unresolved", label: "未解消" }, + { value: "in_progress", label: "対応中" }, + { value: "resolved", label: "解消" }, +]; + +/** 対応状況バッジの文言(解消は「解消 (日時)」)。読み取り専用のバッジ(出発失敗の行)で使う */ +function StatusBadgeText({ item }: { item: PrecheckItem }) { + const { t } = useTheme(); + switch (item.resolution_status) { + case "resolved": + return ( + <> + {t("解消")} ({item.resolved_at ? formatDateTime(item.resolved_at) : "-"}) + + ); + case "in_progress": + return <>{t("対応中")}; + case "unresolved": + return <>{t("未解消")}; + } +} + +const EMPTY_SUMMARY: PrecheckSummary = { unresolved: 0, resolved: 0, total: 0 }; + +/** 対応状況のバッジ・プルダウンの文言(辞書キー)。表は `RESOLUTION_STATUS_OPTIONS` が正 */ +function resolutionStatusLabel(status: PrecheckStatus): string { + return RESOLUTION_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status; +} + +/** + * カードの class(★変えた行の目印★ と ★本清掃が成功した行を控えめにする★ を重ねる)。 + * + * ★本清掃が成功した事前チェック異常(`main_run_succeeded`)は控えめに出す★(ユーザー指示 2026-10-07。D-171 の案2)— + * ★対応状況(未解消など)・件数は変えない★。見た目だけ。★編集モードでも同じ見た目★で、 + * プルダウン・備考は今までどおり使える(不透明度は操作部品にかけない。`.itemCardSubdued` の CSS) + */ +function itemCardClass(item: PrecheckItem, changed: boolean): string { + const classes = [styles.itemCard]; + if (item.main_run_succeeded) classes.push(styles.itemCardSubdued); + if (changed) classes.push(styles.itemCardChanged); + return classes.join(" "); +} + +/** ★編集できるのは事前チェックの行だけ★(出発失敗には記録先が無い。D-104 / D-105 / 指示書AT §3-3) */ +function isEditable(item: PrecheckItem): boolean { + return item.severity === "precheck"; +} + +/** + * 1行ぶんの PATCH(`PATCH /prechecks/{id}/status`。指示書AT §3-8)。 + * ★失敗は例外のまま投げる★(`saveDrafts()` が行ごとに受け止めて下書きを残す。D-165) + */ +function sendPrecheckPatch( + item: PrecheckItem, + request: PrecheckStatusUpdateRequest +): Promise { + return apiFetch(`/api/prechecks/${encodeURIComponent(item.id)}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +/** 行の下に出す保存失敗の文言。★サーバーの文言を優先★(無ければ汎用の文言) */ +function saveErrorMessage(e: unknown): string { + return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました"; +} + +export function AlertsScreen() { + const { t, theme } = useTheme(); + const c = THEME_TOKENS[theme]; + + /** + * 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。 + * + * ★この画面に「検索」ボタンは無い★ — モック:602-620 のツールバーは + * 日付2つ・セレクト2つ・ステータスボタン3つだけで、★モック全体で「検索」ボタンが + * あるのはレポート画面(:679)1箇所だけ★(実測)。 + * セレクトの変更は日付入力やステータスボタンと同じ「1操作=1確定」なので、 + * ★フックの `draft` をそのまま確定値として使う★(`DetailLogScreen.tsx` と同じ判断。 + * `ReportsScreen.tsx` のような draft/確定の二重持ちはしない)。 + */ + const classification = useClassificationFilter(t); + + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [statusFilter, setStatusFilter] = useState("open"); + + /** + * 現在ページ(1始まり。指示書AA 段階4) + * + * ★フィルタを変えたら必ず 1 に戻すこと★ — 件数が減ったときにページ3へ + * 取り残されると、ユーザーが名指しで禁じた「押したら有りません」の状態になる。 + * ★`useEffect` で後追いしないこと★ — 古いページのままフェッチが1回余計に飛ぶ。 + * state を変えるその場で一緒に戻す。 + */ + const [page, setPage] = useState(1); + + const [items, setItems] = useState([]); + const [summary, setSummary] = useState(EMPTY_SUMMARY); + /** + * 一覧(対応状況フィルタ後)の総件数。★`meta.total`★ を入れる。 + * + * ★`summary.total` を流用しないこと★(D-054 / 指示書AA 禁止事項4)— + * `summary` は対応状況フィルタの**前**に計算されているので、`status=open` で絞ると + * `summary.total > meta.total` になり、**存在しないページのボタンが出る**。 + */ + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * ★対応状況・備考の編集モード(ユーザー指示 2026-10-06。D-165)★ — 画面全体で切り替える。 + * ・通常モードでは表示だけ(押しても何も起きない) + * ・編集モードで変えた内容は ★`drafts`(下書き)に貯めるだけで送らない★。「保存」でまとめて送る + * ・★編集モード中は絞り込みとページ送りを無効にする★(取り直すと下書きの行が一覧から消えるため) + * ・旧(D-161 / 指示書AT §3-8)の「プルダウンを選んだら即送信」「備考はフォーカスが外れたら送信」は廃止 + */ + const [editing, setEditing] = useState(false); + const [drafts, setDrafts] = useState(() => new Map()); + const [saving, setSaving] = useState(false); + /** 行ごとの保存失敗(id → 文言)。★失敗した行は下書きを残し、行の下に出す★ */ + const [rowErrors, setRowErrors] = useState>(() => new Map()); + /** 直前の保存で失敗した件数(一覧の上に「{n}件を保存できませんでした」)。失敗が無ければ 0 */ + const [failedCount, setFailedCount] = useState(0); + + const queryString = useMemo(() => { + const params = new URLSearchParams(); + if (dateFrom) params.set("date_from", dateFrom); + if (dateTo) params.set("date_to", dateTo); + params.set("status", statusFilter); + // ★分類は手書きしない★(`group_id=0` を漏らさない・空のキーを並べないための + // 判断が `appendClassificationParams()` に入っている。指示書AK 落とし穴3) + appendClassificationParams(params, classification.draft); + params.set("per_page", String(PAGINATION.defaultPerPage)); + params.set("page", String(page)); + return params.toString(); + // ★`page` を依存に入れること★ — 入れ忘れると `queryString` が変わらず、 + // `fetchAlerts` も変わらないので、ボタンを押しても取り直さない + }, [dateFrom, dateTo, statusFilter, classification.draft, page]); + + const fetchAlerts = useCallback(async () => { + setLoading(true); + setError(null); + try { + const body = await apiFetch>( + `/api/prechecks?${queryString}` + ); + setItems(body.items); + setSummary(body.summary); + // ★総ページ数は `meta.total` から出す(`summary.total` ではない)★ + setTotal(body.meta.total); + // ★フィルタ外の要因(データ更新など)で総ページ数が減り、存在しないページに + // 取り残された場合の保険。最終ページに寄せて取り直す★ + // `pages > 0` の判定を落とさないこと(0件のとき setPage(0) で無限ループになる) + const pages = totalPageCount(body.meta.total, body.meta.per_page); + if (pages > 0 && body.meta.page > pages) setPage(pages); + } catch (e) { + setError( + (e instanceof ApiFetchError ? e.serverMessage : null) ?? + "データの取得に失敗しました" + ); + setItems([]); + setSummary(EMPTY_SUMMARY); + setTotal(0); + } finally { + setLoading(false); + } + }, [queryString]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- フィルタ変更に合わせた一覧の取り直し + void fetchAlerts(); + }, [fetchAlerts]); + + /** + * ★下書きがあるあいだはページを離れる前に確認する★(D-165)。 + * 下書きが無くなったら(保存・キャンセル)外す。 + */ + const hasDrafts = drafts.size > 0; + useEffect(() => { + if (!hasDrafts) return; + const onBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [hasDrafts]); + + /** 下書き・失敗表示を捨てて通常モードに戻る */ + const exitEditMode = useCallback(() => { + setEditing(false); + setDrafts(new Map()); + setRowErrors(new Map()); + setFailedCount(0); + }, []); + + const startEditMode = useCallback(() => { + setRowErrors(new Map()); + setFailedCount(0); + setEditing(true); + }, []); + + /** ★下書きがあれば確認してから捨てる★(無ければ確認なしで戻る) */ + const cancelEditMode = useCallback(() => { + if (drafts.size > 0 && !window.confirm(t("変更を破棄しますか?"))) return; + exitEditMode(); + }, [drafts, exitEditMode, t]); + + /** + * 下書きをまとめて保存する(D-165)。★変えた行だけを1行ずつ順に★ PATCH する(`saveDrafts()`)。 + * + * ・★成功した行はレスポンスの `PrecheckItem` で差し替える★。サマリは `applyResolutionChange()` で差分だけ反映する + * ・★`fetchAlerts()` を呼ばないこと★ — 取り直すとローディング表示に入れ替わって位置を見失う + * (`DsLogScreen.tsx` の `commitNote()` と同じ理由)。 + * ・★絞り込み(未解消のみ)に合わなくなっても、次に取り直すまで行を消さない★(押し間違いをその場で戻せるように) + * ・★全部成功 → 通常モード★。★1件でも失敗 → 編集モードのまま★、失敗した行の下書きと文言を残す + * ・変更が0件なら何も送らずに通常モードに戻る(「保存」が「編集を終了」の意味になる) + */ + const saveEdits = useCallback(async () => { + if (drafts.size === 0) { + exitEditMode(); + return; + } + setSaving(true); + setRowErrors(new Map()); + setFailedCount(0); + try { + const rows = items.filter(isEditable); + const result = await saveDrafts(rows, drafts, sendPrecheckPatch); + if (result.updated.size > 0) { + setItems((prev) => prev.map((p) => result.updated.get(p.id) ?? p)); + setSummary((prev) => + rows.reduce((acc, row) => { + const updated = result.updated.get(row.id); + return updated + ? applyResolutionChange(acc, row.resolution_status, updated.resolution_status) + : acc; + }, prev) + ); + } + setDrafts(result.remaining); + if (result.failed.size === 0) { + setEditing(false); + } else { + setRowErrors( + new Map([...result.failed].map(([id, e]) => [id, saveErrorMessage(e)] as const)) + ); + setFailedCount(result.failed.size); + } + } finally { + setSaving(false); + } + }, [drafts, exitEditMode, items]); + + const changeDraftStatus = useCallback((item: PrecheckItem, value: string) => { + const next = RESOLUTION_STATUS_OPTIONS.find((o) => o.value === value)?.value; + // 表に無い値なら何もしない(`value` を `PrecheckStatus` に戻すため表で引き直す) + if (next === undefined) return; + setDrafts((prev) => setDraftStatus(prev, item, next)); + }, []); + + const changeDraftNote = useCallback((item: PrecheckItem, note: string) => { + setDrafts((prev) => setDraftNote(prev, item, note)); + }, []); + + const editableCount = items.filter(isEditable).length; + + // 分類のセレクトの値(`value` と `title` の両方で使う) + const groupValue = + classification.draft.groupId === null + ? ALL_OPTION_VALUE + : String(classification.draft.groupId); + const customerValue = + classification.draft.customerId === null + ? ALL_OPTION_VALUE + : String(classification.draft.customerId); + + return ( +
+
+

{t("アラート(事前チェック)")}

+
{t("スケジュール開始前のエラー検知と出発失敗")}
+
+ +
+ + + +
+ +
+ {/* ★編集モード中は絞り込みを無効にする(ユーザー指示 2026-10-06。D-165)★ — + 取り直すと下書きの行が一覧から消えるため。`
` で中の入力・ボタンをまとめて無効にする + (`FilterButton` に disabled を足さずに済む) */} +
+
+ { + setDateFrom(e.target.value); + setPage(1); + }} + /> + 〜 + { + setDateTo(e.target.value); + setPage(1); + }} + /> +
+ {/* ★分類の絞り込み(モック:609-614。指示書AK 段階7 で D-053 を解除)★ + ・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★。 + モックの "PPIH" / "ドン・キホーテ" を写し取ったのが D-053 で消されたUI) + ・★取得に失敗した/屋号が0件のときは出さない★(`available` が false)— + ★空の ` { + classification.selectGroup(e.target.value); + // ★絞り込みを変えたらその場で1ページ目に戻す★ + // (件数が減ったときにページ3へ取り残されないため) + setPage(1); + }} + > + {classification.groupOptions.map((o) => ( + + ))} + + )} + + + )} +
+ { + setStatusFilter("open"); + setPage(1); + }} + > + {t("未解消のみ")} + + { + setStatusFilter("all"); + setPage(1); + }} + > + {t("全件")} + + { + setStatusFilter("resolved"); + setPage(1); + }} + > + {t("解消済み")} + +
+
+ {/* ★編集 / 保存({n}件)/ キャンセル(D-165)★ — 絞り込みの行の右端。 + ★幅は固定(`.editActions` / `.editButton`)★(JP/EN や件数で行の折り返しが変わらないように) */} +
+ {editing ? ( + <> + + + + ) : ( + + )} +
+
+ + {editing && ( +
+
{t("編集中は絞り込みとページ送りを使えません")}
+ {failedCount > 0 && ( +
+ {t("{n}件を保存できませんでした").replace("{n}", String(failedCount))} +
+ )} +
+ )} + + {loading &&
読み込み中…
} + {!loading && error &&
{error}
} + + {!loading && !error && items.length === 0 && ( +
{t("該当するアラートはありません")}
+ )} + + {!loading && !error && items.length > 0 && ( +
+ {items.map((item) => { + const editable = isEditable(item); + const draft = editable ? drafts.get(item.id) : undefined; + const status = draftStatus(item, draft); + const note = draftNote(item, draft); + const rowError = rowErrors.get(item.id); + return ( +
+
+
+
+ + + {/* ★本清掃が成功した事前チェック異常の目印(D-171)★ — 対応状況のバッジは変えない */} + {item.main_run_succeeded && } + {item.serial_number} +
+
{item.facility_name}
+ {/* ★備考を編集できるのは事前チェックの行だけ★(出発失敗には記録先が無い。D-104 / 指示書AT §3-3) */} + {editable && editing ? ( + /* ★編集モード(D-165)★ — 入力欄は「予定: HH:MM」の下。2行ぶんの固定の高さ・幅は列いっぱい */ + <> +
+ {t("予定")}: {formatHm(item.scheduled_at)} +
+