diff --git a/.claude/commands/d-watch.md b/.claude/commands/d-watch.md new file mode 100644 index 0000000..3a523c6 --- /dev/null +++ b/.claude/commands/d-watch.md @@ -0,0 +1,242 @@ +--- +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` | 画面の正 | + +★★**本ファイルの数字は書いた瞬間から古くなります。必ず実測してください。**★★ + +--- + +## ★★最新 — 2026-10-30〜11-04 の作業(D-Watch 2セッション目のまとめ)★★ + +★この節が一番新しい★。下の「30秒で読む現在地」「最初の報告」の数字は 10-07 時点(古い)。 + +### 現在地(★2026-11-04 17:4x JST 実測★) + +| 項目 | 値 | +|---|---| +| ブランチ | `develop`。★HEAD = origin/develop = `619e21d`★(未 push 0) | +| GitBucket | ★develop は1コミット★(11-04 にユーザー指示でまとめ直した。中身は GitHub develop の先頭と同じ。GitHub の履歴は含まない)。★以後はその上に1つずつ積むので、サーバーは普通の `git pull` で取れる★。下の「GitBucket への上げ方」 | +| テスト | ★157ファイル / 3,855件 全パス★(tsc・lint も緑) | +| 指示書 | ★BH・BI まで(次は `BJ`)★。BH・BI は Cursor が実装し、監督(Claude Code)が点検・コミット | +| 決定 | ★コミット済みは D-181 まで★(`docs/decisions.md`)。★BH・BI の決定は未記録★(下の「残り」1) | +| 未コミット | ★別セッションの作業中の6ファイル★(`docs/decisions.md`・`docs/handoff/指示書AY_*`・`docs/handoff/設計_制御盤ログの取り出し_*`・`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト)。★触らない・コミットに入れない★(コードの3つは改行コード以外の差なし) | + +### この期間のコミット(古い順) + +| コミット | 内容 | +|---|---| +| `a2e240a`・`a1e4b59`(10-30) | 清掃結果 `status = 6` は 3 と同じ「清掃キャンセル」。表に無い値(7 以上など)は「不明(7)」と数字を残す(`CLAUDE.md` §4.7) | +| `38e8f5d`(10-30) | 施設ロボットマスタを「監視」「D-Sec」列で並べ替え(1回目 desc = ○ が上 → ▲ → 既定) | +| `4bb639e`(11-04) | ★指示書BH★: 清掃詳細ログ7列(機種・シリアル・日付・出発・清掃結果・完了率・判定)とエラー一覧6列(検知時刻・シリアル・種類・レベル・対応状況・備考)を見出しで並べ替え(1回目 asc = 悪いほうが上。サーバー側・ページングの前・CSV も同じ並び)。
★「予定:すべて/予定あり/予定なし」の絞り込み★をエラー一覧と清掃詳細ログ(API・CSV・画面)に(`lib/api/schedule-filter.ts`・`components/ui/ScheduleFilterSelect.tsx` の1か所)。
★並べ替えの印★: 並べていない列=薄い △、asc ▲・desc ▼。色はダーク白・ライト `#1e293b`(`--sort-mark`)。印の字は日本語フォント(`--font-ja`。body と共有)。
★表示の文言「予定外」→「予定なし」★(プルダウン・詳細ログの予定列・ダッシュボードの小さい表・CSV・清掃面積の注記。API の値 `unscheduled` は変えない。BH §13)。
YAML の清掃面積の行(`CleanAreaRow`)の説明を実装どおり「予定外も含む」に | +| `619e21d`(11-04) | ★指示書BI★: ★予定なしの △× の行にもアドバイスの ▼★(詳細ログ・ダッシュボード)。入口 `GET /clean-logs/unscheduled/{report_id}/advice`(`report_id` は varchar(20) の文字列。数値にしない)。エリア別詳細のボタンは予定ありの行だけ(`cleanLogRowView().areaDetail`)。データ層 `db.unscheduledCleanLog()`。応答づくりは `lib/clean-logs/advice-response.ts` に切り出し両ルートで共有 | + +### このセッションで固めた手順 + +- ★GitBucket への上げ方★(★サーバー(ステージング)が `git pull` して使う★。公開設定なので★GitHub の履歴は出さない★ — 履歴にステージングの DB ユーザー名が14コミット入っている。D-177): + 1. 先に GitHub(`git push origin develop` → `git fetch origin`) + 2. `git ls-remote https://git.d-sec.dfarobotics.com/git/develop/client_dwatch.git refs/heads/develop` で GitBucket の今の先頭 `` を取る(知らない版なら止めてユーザーに聞く) + 3. ★`S=$(git commit-tree "origin/develop^{tree}" -p -m "D-Watch develop のスナップショット。GitHub の履歴は含まない")`★(★`-p ` を必ず付ける★ — 付けないとサーバーで pull が効かない)→ `git branch -f gitbucket-snapshot $S` + 4. `gitbucket-snapshot^{tree}` と `origin/develop^{tree}` が一致、`git merge-base --is-ancestor $S` を確かめる + 5. ★`git push <上の URL> gitbucket-snapshot:develop`(`--force` を付けない)★。拒否されたら誰かが積んだので止めてユーザーに聞く + - 経緯: 11-04 まで毎回「親なし1コミット」を force push していたので、サーバーで pull が効かず古い版のまま残った(10-29・11-04)。11-04 にいったん合流コミット(`ecb9f68`)でつなぎ直したあと、ユーザー指示「rebase などでまとめて最新の状態に」で★1コミットにまとめ直した(最後の force push)★。サーバーはその1回だけ `git fetch origin` → `git reset --hard origin/develop`。★以後は force push しない★ + - ★サーバーへの反映★: サーバーで `git pull` → コンテナをイメージごと作り直して再起動 → ブラウザで Ctrl+Shift+R(`npm run build` だけではコンテナに届かない) +- ★コミットの作法★: ファイルを名前で指定(`git add -A` 禁止)・別セッションの6ファイルを入れない・★コミット前に差分へテスト用トークン/本物の `AUTH_TOKEN`/DB のパスワード/接続先 IP が入っていないことを grep で確かめる★(値は表示しない) +- ★fixtures の画面確認★: `USE_FIXTURES=true npm run build` → `AUTH_TOKEN=<テスト用トークン> USE_FIXTURES=true npx next start -p 3100` → アプリ内ブラウザでログイン(★テスト用トークンはスクラッチパッドに乱数で作る。本物のトークンは打たない★)→ 終わったら止め、3100 を listen しているプロセスのコマンド行を確かめてから kill +- ★ステージングでユーザーがローカル確認★: `preview_start` の `d-watch`(:3000。`.env.local` が `clean_watch`)で立て、★ログインはユーザー自身★。「止めて」と言われたら `preview_stop` → 3000 が空いたか確かめる +- ★ステージングを監督が画面で見るとき★: `USE_FIXTURES=false` とテスト用トークンで :3100 を立てる(読むだけ)。API だけなら `.env.local` の `AUTH_TOKEN` を cookie に付けて curl(値は表示しない) +- ★指示書と実装の点検★: Workflow で4観点(コードとの食い違い/期待値の実計算/画面の流れ/漏れと矛盾)→ 指摘ごとに別エージェントが反証 → 本物だけ反映(BH §12: 12件中6件、BI: 29件中26件、BI の実装: 14件中13件・全部軽微)。★結果ファイルは途中で切れるので `journal.jsonl` から組み立てる★(`started` の `agentId`→`label` で指摘と判定を対応づける) +- ★Cursor の担当と「こちらで直して」★: 指示書の実装は Cursor。★ユーザーが「こちらで直して」と言ったものだけ Claude Code 側でサブエージェントに直させる★(文言の統一・BI の軽微5項目)。メインは担当を分けて委譲し、差分とテストを自分で確かめる + +### このセッションで分かった罠 + +1. ★ステージングは予定なしの行が大半★(詳細ログ 636件中 596件が予定なし。予定ありは40件で全部出発失敗。11-04 実測・先方投入)→ ★「予定あり」が少ない・▼ が少ないのは不具合ではない★(ユーザーが「アドバイスが消えた」と感じた原因。BI で予定なしにも出すようにした) +2. ★詳細ログのツールバーは 1366px で2行になる★(予定のプルダウン 160px を足したため。承知のうえ。BH §12-2) +3. ★開いた行の見出し(`adviceHead()`)とアドバイス本文は EN でも日本語★(前から。本文は運用設定の日本語) +4. ★`d-watch-api.yaml` は js-yaml で読めない箇所が1つある★(`precheck_minutes` の説明の「(旧: …」の `: `。HEAD 以前から)。新しい変更で増えていないかは「この1件だけか」で確かめる +5. ★`clean_task_reports.report_id` は varchar(20) の文字列★(19桁で安全な整数を超える。`Number("1315386034733453312")` は `…300` に丸まる)。`parsePathId()` を流用しない(`parsePathReportId()`) +6. ★`UNSCHEDULED_TIMETABLE_ID_WHERE` はトップレベルの `OR`★。他の条件は展開して並べる(`AND` で包むのは OR が2つになるときだけ)。★`app/api/clean-logs/__tests__/route.test.ts` のスタブの `matchesWhere` は `AND` を黙って無視する★ +7. ★予定あり/予定なしの向き★: エラー一覧は `key.scheduled`(true=予定あり)、詳細ログは `r.unscheduled`(true=予定なし)。`matchesScheduleFilter()` には必ず「予定あり=true」を渡す +8. ★アドバイスを開けるかは表の側(`view.expandable`)が決める★。`cleanLogAdviceUrl()` は ID が欠けた行だけ null(◎・結果待ちでも URL を返す) + +### 残り(次にやること) + +| 順 | 内容 | +|---|---| +| 1 | ★`docs/decisions.md` に BH・BI の決定を記録★(D-182 以降。★別セッションが decisions.md を編集中なので、そのコミットを待ってから★)。中身は指示書BH §10・§12-8・§13、BI §9(10-29〜10-30 の「0 は予定外」「A案」「アラートは事前チェックだけ」「進み具合は前日」「status 6・不明(n)」「施設マスタの並べ替え」も) | +| 2 | ★`d-watch-api.yaml` を YAML として読めるように直す★(上の罠4。別タスク) | +| 3 | 必要なら: 開いた行の見出しの EN 対応(罠3) | +| 4 | ★施設69(MEGAUNY稲沢東店・ロボット2台)のデータが入ったら表示を確かめる★(10-30 の確認では問題なし。データ待ち) | +| 5 | 下の「次にやること」(10-07 時点)の PR #6・第12回の回答・点検記録画面(F-05)・契約によるメニューの出し分け・セキュリティはリリース直前、は★まだ残っている前提で、実測してから★進める | + +--- + +## ★★最初に知るべきこと — 接続先はステージングです★★ + +★★`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 実測。古い — 最新は上の「2026-10-30〜11-04 の作業」★) + +| 項目 | 値 | +|---|---| +| ブランチ | `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. ★テスト数と最終決定番号★が上の表(★2026-11-04 時点: 157ファイル / 3,855件 / コミット済みの決定は D-181。HEAD `619e21d`★)と一致するか +3. ★未 push のコミットがあるか★ / ★PR #6 の状態★(★最新の head の CI は緑か / レビューが通ったか / head はどこか★) +4. ★第12回(送付版。R-1〜R-3)を送ったか / 先方のデータ投入が終わったか(予定と実績がつながったか) / 回答が来ているか★(★回答が来ていたら受領日の記録が最優先★。レポートの最大の遅れの回答が来ていたら `RESULT_WAIT_MINUTES` を見直す。D-173) + +そのうえで、何から進めるかをユーザーに確認してください。 + +## この文書について + +**引き継ぎメモとこのコマンド定義は、次の担当者の行動をそのまま決めます。** +古い記述を残すと、次のセッションがその指示に従って誤った作業を始めます。 +**このファイルは要約と道案内に留めてあります。詳細は `docs/handoff.md` に書いてください。** +方針が変わったら、**引き継ぎメモへの追記と同時にこのファイルも直してください。** diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..62fa29b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "d-watch", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + }, + { + "name": "d-watch-fixtures", + "runtimeExecutable": "cmd", + "runtimeArgs": ["/c", "set USE_FIXTURES=true&& npm run dev -- -p 3100"], + "port": 3100 + } + ] +} diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..7d7e26d --- /dev/null +++ b/.cursorrules @@ -0,0 +1,6 @@ +# .cursorrules — D-Watch + +このファイルは Cursor の legacy 形式です。内容は CLAUDE.md に統合済みです。 + +- 実装規約(ディレクトリ構成・命名・コーディング規約・判定ロジックの境界値表など)を直す場合は CLAUDE.md を編集すること(特に第9章。同じ内容を二重に管理しない)。 +- Cursor は CLAUDE.md を自動で読むため、このファイルに実装規約を書き戻さないこと。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e35d5c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# 開発用サンプルデータで動かす(true の間はDBに接続しない) +USE_FIXTURES=true + +# TeamWorks構築のMySQL(USE_FIXTURES=false のときに使用) +# 参照用アカウント +DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE" + +# ★DATABASE_URL の代わりに部品変数で書いてもよい★(lib/db/prisma.ts) +# DATABASE_HOST と DATABASE_PASSWORD の両方があるとき、そちらが優先される。 +# +# ★部品変数を使う理由★: パスワードに `@` が含まれると、DATABASE_URL に書いた +# `%40` が dotenv の展開で壊れることがある(`p%40ss` → `p`)。 +# 部品変数なら URL エンコードが要らない。 +# +# ★DATABASE_HOST を使うなら DATABASE_USER と DATABASE_NAME は必須★。 +# 既定値は用意していない(実在のDBユーザー名をコードに書かないため。CLAUDE.md §9.3)。 +# 足りないと「どれが足りないか」を言って起動時に落ちる。 +# +# DATABASE_HOST= +# DATABASE_PORT=3306 +# DATABASE_USER= +# DATABASE_PASSWORD= +# DATABASE_NAME= + +# 更新用アカウント(robot_error_reports の4カラム status / comment / responded_at / resolved_at の UPDATE だけに使う。 +# lib/db/prisma.ts の getWritePrisma()。指示書AT §3-2) +# ★2つとも未設定なら、参照用アカウント(上の接続)で書く★。★片方だけだと書き込みが失敗する★。 +# ★接続先は参照用と同じDB★なので、使うときは DATABASE_HOST / DATABASE_PORT / DATABASE_NAME も必要 +# (URL 形式は設けない。%40 が壊れる問題を持ち込まないため)。★値はコミットしないこと★ +# DATABASE_WRITE_USER= +# DATABASE_WRITE_PASSWORD= + +# 点検記録用の独自ストア(SQLite) +INSPECTION_DB_URL="file:./data/inspections.db" + +# 簡易トークン認証(P1は社内利用のみ) +AUTH_TOKEN=changeme + +# 動作ログの出力レベル(docs/logging-design.md) +LOG_LEVEL=info # 開発は debug、本番は info。未設定なら NODE_ENV から判定 + +# nginx を通らない直接アクセス(localhost:3000 への直アクセス)を通す。 +# ★ローカル開発では必要★。これが無いと全ページ 403 になる(指示書I 段階5-B)。 +# ★本番では設定しないこと★(未設定=false=拒否)。 +# 本番用のテンプレートは .env.production.example(そちらでは意図的にコメントアウトしてある)。 +# このファイルと向きが逆なのは意図的で、.env.example は「ローカルで動くこと」優先。 +ALLOW_DIRECT_ACCESS=true diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..fa63a2d --- /dev/null +++ b/.env.production.example @@ -0,0 +1,74 @@ +# 本番で設定する環境変数のテンプレート(★docs/deploy.md を先に読むこと★) +# +# ★このファイルに実値を書かないこと★(CLAUDE.md §9.3「認証情報を絶対にコミットしない」) +# 実在のホスト名・ユーザー名・パスワード・トークンを書かない。 +# コピーして .env.local を作り、実値はそちらに書く(.env.local は .gitignore 対象)。 +# +# ★開発用は .env.example(別ファイル)★。向きが逆になっている項目があるので注意: +# .env.example は「ローカルで動くこと」優先、こちらは「本番で安全なこと」優先。 +# +# 検証: 値を埋めたら `npm run check:env` を実行し、エラーが出ないことを確認する。 + +# 実DBに接続する。★本番では必ず false★ +# 未設定だと true になり(lib/db/use-fixtures.ts の `!== "false"`)、 +# 実DBに繋がらないままサンプルデータを表示する。★例外もエラーも出ない事故★ +USE_FIXTURES=false + +# TeamWorks構築のDB(★参照用アカウント★。本番は MariaDB 10.11 / D-100)。 +# 未設定だと起動しても画面が全部エラーになる。 +# ★パスワードに % や @ が含まれる場合は URL エンコードが必須★ +DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE" + +# ★URL エンコードを避けたい場合は部品変数で書く★(lib/db/prisma.ts)。 +# DATABASE_HOST と DATABASE_PASSWORD の両方があるとき、DATABASE_URL より優先される。 +# ★`@` を含むパスワードは DATABASE_URL 側で壊れる実績がある★(`%40` が dotenv 展開で消える) +# ため、本番ではこちらを推奨する。 +# +# ★DATABASE_HOST を使うなら DATABASE_USER と DATABASE_NAME は必須★(既定値は無い)。 +# 実在のDBユーザー名をコードの既定値に書かない方針のため(CLAUDE.md §9.3 / コミット c832891)。 +# 足りないと起動時に「どれが足りないか」を言って落ちる。★黙って別のDBに繋がることはない★。 +# +# DATABASE_HOST= +# DATABASE_PORT=3306 +# DATABASE_USER= +# DATABASE_PASSWORD= +# DATABASE_NAME= + +# 更新用アカウント(robot_error_reports の4カラム status / comment / responded_at / resolved_at の UPDATE だけに使う。 +# lib/db/prisma.ts の getWritePrisma()。指示書AT §3-2) +# ★2つとも未設定(空)なら、参照用アカウント(上の接続)で書く★。★片方だけだと書き込みが失敗する★ +# (`npm run check:env` もエラーにする)。★4カラム以外には書かない★(CLAUDE.md §7)。 +# ★接続先は参照用と同じDB★なので、使うときは DATABASE_HOST / DATABASE_PORT / DATABASE_NAME も必要 +# (URL 形式は設けない。%40 が壊れる問題を持ち込まないため)。★実値はコミットしないこと★ +DATABASE_WRITE_USER= +DATABASE_WRITE_PASSWORD= + +# 簡易トークン認証の共有トークン(指示書I 段階1-2)。 +# ★必ず変更すること★。未設定・空なら誰もログインできない(=全機能が使えない)。 +# `changeme` のままだと `npm run check:env` がエラーで落ちる。 +AUTH_TOKEN=changeme + +# 動作ログの出力レベル。未設定なら NODE_ENV から判定(本番は info) +LOG_LEVEL=info + +# ★★ALLOW_DIRECT_ACCESS は本番では設定しないこと★★ +# +# nginx を通らない直接アクセスを素通りさせる開発専用スイッチ。 +# 未設定なら false(拒否)。ローカル開発の .env.local にだけ書く。 +# 設定すると `npm run check:env` が本番判定でエラーにする(検査2)。 +# +# ★行を消さずコメントのまま残してある★: +# 完全に書かないと「知らずに設定してしまう」ため、 +# 「設定するな」という指示として残すのが正しい。 +# +# ALLOW_DIRECT_ACCESS=true + +# --------------------------------------------------------------------------- +# ★環境変数ではないもの(コードにあるのでデプロイが必要)★ +# +# ALLOWED_IPS / TRUSTED_PROXY_HOPS / ALLOW_ALL_WHEN_EMPTY +# → lib/config/allowed-ips.ts +# +# この3つは「本番で意味のある値」なのでコードで管理し、レビューと履歴を残す。 +# ★.env.local を直しても変わらない★。変更にはデプロイが必要(docs/deploy.md 項5)。 +# ★本番では ALLOW_ALL_WHEN_EMPTY を false にし、ALLOWED_IPS を埋めること★ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caa5728 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +# PRを作ったとき / 更新したとき、および main に入ったときに走ります。 +# 現時点では「結果を表示するだけ」で、赤くてもマージはできます。 +# 運用が安定したら Ruleset の "Require status checks to pass" をONにして必須化します。 +on: + pull_request: + push: + branches: [main] + +jobs: + check: + name: Lint / Test / Build + runs-on: ubuntu-latest + + steps: + - name: リポジトリを取得 + uses: actions/checkout@v4 + + - name: Node.js をセットアップ + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: 依存パッケージをインストール + run: npm ci + + # ★Prisma クライアントを生成する★ + # + # 生成先の `lib/generated/prisma` は .gitignore 対象(`.gitignore:47`)なので、 + # ★チェックアウト直後には存在しない★。生成しないと `@/lib/generated/prisma/client` + # が解決できず、★ビルドが module-not-found で落ちる★(2026-09-17 に PR #6 で発生)。 + # + # ★DBには接続しない★ — `prisma generate` はスキーマだけを読む。 + # 接続先(`prisma7.config.ts` の `datasource.url`)が未設定でも生成できる。 + - name: Prisma クライアントを生成 + run: npx prisma generate + + - name: Lint + run: npm run lint --if-present + + # 判定ロジックの期待値表テスト。 + # `--if-present` は M1 以前の名残で、現在は test スクリプトが存在する。 + - name: テスト + run: npm test --if-present + + - name: ビルド + run: npm run build + env: + # DBには接続しない。サンプルデータでビルドが通ることを確認する + USE_FIXTURES: "true" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1329f3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example +# 本番で設定すべき環境変数のテンプレート(実値は書かない。docs/deploy.md 参照)。 +# ★この行が無いと上の .env* に飲まれて Git 管理外になる★ +!.env.production.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/lib/generated/prisma + +# 運用設定(実行時に生成される。環境ごとに異なるためコミットしない) +/data + + +# PDF は原稿とスクリプトから生成する(docs/handoff/pdf/README.md) +docs/handoff/pdf/*.pdf + +# Python のキャッシュ(docs/handoff/pdf/ のPDF生成スクリプトが作る) +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9fb7e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,526 @@ +# CLAUDE.md — D-Watch 開発コンテキスト + +このファイルは Cursor / Claude Code が最初に読むプロジェクトコンテキストです。 +**実装前に必ず全文を読んでください。** 特に「4. 確定仕様」と「7. 禁止事項」は仕様のズレが直接バグになる箇所です。 + +--- + +## 0. ルールの優先順位 + +セッション開始時に自動注入される全社共通の作業ルールと、本ファイルの記述が衝突した場合は +以下を優先する。 + +1. **仕様が不明なら止まる。** + 全社ルールの「自動モード中は許可待ちで止まらない」は*権限確認*についての取り決めであり、 + *仕様の不明点*には適用しない。§7 の通り、推測で実装せず確認を求める。 + 判定基準・閾値・API契約の不明点は特に危険(仕様のズレが直接バグになる)。 + +2. **判定ロジックは「期待値表の確定 → テスト作成 → 実装」の順を崩さない。** + 実装を分担・委譲する場合も、**期待値表の確定は分担しない**(§4.3 / §6.2)。 + 境界値(完了率 90.0 / 90.1 / 70.0 / 70.1 / 10.0 / 9.9 / 0 / 未実施、 + および `89.5 → 表示90% だが判定は ○`)は本ファイル §9.4 の表を正とする。 + +3. **日本語を含む成果物には日本語フォントを明示する。** + 欧文フォント(Geist 等)は日本語グリフを持たないため、指定しないと + フォールバックで中国語フォントに落ち、漢字が中華字形で表示される。 + `app/globals.css` のフォントスタックを正とし、日本語フォントを外さない。 + +--- + +## 1. プロダクト概要 + +**D-Watch(DFAロボット監視システム)** は、店舗等に配置された清掃ロボットの稼働を監視するSaaSビューアーです。 + +- 夜間に自動実行された清掃の結果を、**翌朝DFA運用担当が確認する**「翌朝確認型」の運用 +- 問題のある施設を検知し、AIが推定原因と対応アドバイスを提示する +- オプションモジュール **D-Security**(セキュリティ解除→清掃→復帰の自動化)の監視画面も含む + +**P1スコープ** +| 項目 | 範囲 | +|---|---| +| 対象顧客 | 株式会社ドン・キホーテのみ | +| ロボットメーカー | PUDU Roboticsのみ(KeenonはP2以降。Whiz iは機能的に劣るため取り扱わない) | +| 対応機種 | PUDU CC1のみ(将来候補: PUDU MT1 / Keenon C30 / C40。`lib/config/index.ts` の `ROBOT_MODELS` が正。D-087) | +| 利用者 | DFA社内のみ(顧客アカウントはP2以降) | +| セキュリティ会社 | SECOMのみ | + +--- + +## 2. 責任分界 — 何を作り、何を作らないか + +| 領域 | 担当 | +|---|---| +| MySQLデータベース本体(設計・構築・運用) | **TeamWorks**(外部エンジニア会社) | +| データ収集処理(Pudu Cloudポーリング/コールバック、D-S制御盤報告のDB書き込み) | **TeamWorks** | +| サマリ集計バッチ(顧客/店舗/障害サマリ) | **TeamWorks** | +| APIサーバー + ビューアー(本リポジトリ) | **DFA(内製)** | +| 点検記録機能(独自データストア・写真管理) | **DFA(内製)** | +| 当期(進行中の週/月/年)のレポート集計 | **DFA(内製)** | +| ★サーバー・ネットワークの用意と構築保守★(本番のアプリVM / DB / テスト環境) | ★**TeamWorks**★(D-143) | + +★**DFA が作るのはビューアー(本リポジトリ)だけです**★(2026-09-16 ユーザー確認。D-143)。 +★**サーバーを契約し費用を払うのは DFA ですが、構築・保守は TeamWorks です**★。 +★**D-100 項5 の「DFA が構築するのはアプリ側(VM)」は誤りなので従わないこと**★ +(★ただし TeamWorks 側の明示確認は未取得。第9回 Q12-1 で照会中★)。 + +**重要:** メインDBのスキーマ変更・データ投入処理は本プロジェクトの仕事ではありません。 +DBは基本的に**読み取り専用**で、書き込みは後述の4カラム(`robot_error_reports` は `error_list_id` を含む5カラム)のみです。 + +**「APIサーバー」の意味:** 本アプリ内の API ルート(`app/api/`)を指します。TeamWorks が API を +提供するわけではありません。DB へは Prisma で直接アクセスし、間に外部サービスは入りません。 +画面はブラウザから自アプリの API ルートを呼び、その中で Prisma が DB を読みます(D-046)。 + +--- + +## 3. 技術スタック + +| 項目 | 採用 | +|---|---| +| フレームワーク | **Next.js**(App Router、React + APIルート一体型) | +| 言語 | **TypeScript**(フロント・API共通) | +| DBアクセス | **Prisma**(MySQL) | +| メインDB | **MariaDB 10.11**(TeamWorks管理・**DFAはスキーマを変更しない**)。MySQL互換だが同一ではない。★下の注記★を必ず読むこと(D-100) | +| 点検記録ストア | SQLite + オブジェクトストレージ(**メインDBとは完全に分離**) | +| 認証 | P1は簡易トークン認証 + IP制限(社内利用のみ)。★IP制限は `X-Forwarded-For` の**末尾**を見る(nginx が追記する。D-077 項5)。**先頭は詐称可能なので絶対に使わない**★ | +| インフラ | アプリ: さくらのクラウド VM(**レンタルサーバではない**。Node.js常駐が必要なため)。DB: さくらインターネットの**データベースアプライアンス**(PostgreSQL か MariaDB の2択のため MariaDB)。★アプリ(VM)とDB(アプライアンス)が別サービスという理解だが、**明示の確認は未取得**★(D-100)。★**用意・構築保守はいずれも TeamWorks**★(D-143。第9回 Q12-1〜Q12-6 で照会中) | +| 開発用データ | **`lib/fixtures/` のサンプルデータ**(環境変数 `USE_FIXTURES` で実DBと切替) | + +> ★**メインDBについての注記**★(根拠: D-100。TeamWorks 回答 2026-09-10) +> - ★**なるべく MySQL 互換の SQL を使う**★ +> - ★★**MariaDB 特有の DML を使いたい場合は、その箇所を TeamWorks に知らせること**★★ +> - ★ローカル検証環境は **MySQL 8.4**(`dwatch_local`)。本番は MariaDB 10.11 なので、 +> ★**ローカルで通っても本番で通らない可能性がある**★。生SQL(`$queryRaw` / `$executeRaw`)を +> 書くときは特に注意する★(現状、アプリコードに生SQLは1件も無い。すべて Prisma のクエリビルダ経由) + +> ★★**接続先についての注記**★★(D-152。2026-10-01 ユーザー確認) +> - ★★`133.125.238.144` / `clean_watch` は **ステージング**★★。★**本番DBは別にある**★(2026-10-01 ユーザー回答 +> 「これはステージングです、本番は他にあります」) +> - ★★**D-148(「本番DBそのもの」2026-09-17)は破棄**★★。★第9回までの文書の「ステージング」「テスト環境」という呼称は +> ★正しかった★(D-148 項1 の「誤り」という訂正のほうが誤りだった) +> - ★★**2026-09-30 23:25〜10-01 00:09(JST)に実データが入った**★★(2026-10-01 実測・先方投入。読み取りのみ)。 +> ★当方のダミー(`scripts/seed-local-db.ts` 由来)は消えている★。 +> `facilities` は ★ドン・キホーテの実店舗10件+本社★。 +> - ★★**DB に入っている時刻の値は、列の型(TIMESTAMP / DATETIME)によらず JST の壁時計の値**★★ +> (ユーザー指摘 2026-10-01「データベース上の時間は全てJSTに変換済みです」。★実測で確認★: `robots.updated_at` の最新が生の値で +> 15:00、そのときの JST が 15:17・DB の `NOW()` が 06:17。生の値が UTC なら9時間後の未来になり、ありえない)。 +> ★**生の値に9時間を足さないこと**★。 +> - ★★**2026-10-02 TeamWorks 回答(第10回 N-14・N-6c)**★★: ★それまでサーバーの時計は UTC(`@@system_time_zone = UTC`)で、 +> 一部のテーブルはフレームワークの機能を切って書いていたため、`current_timestamp()` の既定値で UTC の値が入っていた★。 +> ★DB の既定のタイムゾーンを 'Asia/Tokyo' に変え、データを再登録済み★(先方の申告。★当方の再実測は未了★ — 10-02 は認証エラーで接続できず)。 +> ★先方の結論: 「timezone を JST で統一したので、以降はズレは生じない」★ +> - ★当時の9時間ずれの正体★: `control_reports.reported_at` は ★UTC で書かれていた★(N-14a)。 +> `robot_error_reports.errored_at` は ★正しい JST の値で、ずれていたのは `created_at`(UTC)の側★(N-14b)。 +> ★旧記述の「`errored_at` は二重補正・エラー検知時刻は9時間遅く出る」は誤り★ +> - ★**アプリのコードは変えない**★(2026-10-02 調査): アダプタは `dateStrings: true` で、サーバーが返した文字列を UTC とみなすだけ。 +> ★サーバーが JST になったことで、TIMESTAMP 列も JST の文字列で返り、`fromDbJst()` の前提どおりになる★。 +> ★残る危険は、再登録されなかった古い TIMESTAMP 行だけ★(そこだけ +9時間に見える)。★再実測で確かめること★ +> - ★一度「TIMESTAMP は UTC で返るので +9時間」と誤って書き(9c175a2)取り消した(d540335)経緯あり★。★推測で9時間ずらさないこと★ +> ★`clean_timetables` / `clean_schedules` / `clean_status_histories` は0行★、 +> `clean_task_reports` は2行(どちらも `clean_timetable_id` が NULL)。★サーバー本体は昨日と同一★(`@@hostname` 一致) +> - ★帰結★: ★予定が0行なので、清掃詳細ログ・ダッシュボード・アラートは空★。★総清掃面積だけは実績を直接集計するので2回と出る★ +> (画面どうしで食い違う。★詳細ログを予定起点・実績起点・両方のどれにするかは、データが入ってから検討★。D-151 項7) +> ★以上は 2026-10-01 時点★。★2026-10-06 は予定が入り始め(先方投入中。予定と実績はまだつながっていない。D-158 末尾)、 +> ダッシュボードは「各清掃エリアの直近の清掃(7日以内)」(D-159)で11エリアを出した★(先方投入。`docs/handoff.md` 追記61) +> - ★`robot_error_reports.serial_number` の実値★(2026-10-19 監督の SELECT・先方投入): セキュリティ(`SECURITY_ON`)・清掃開始失敗(`NOT_STARTED`)の行は ★`---`★(第11回 P-8 の回答は `0`)。★`serial_number` の値や `robot_id IS NULL` で区分を見分けない — `error_type` で見分ける★(D-180 項5)。どちらに揃うかは TeamWorks に照会予定(指示書AX §12-1-2) +> - ★接続元の制限・TLS(第9回12章)は★**検証段階のため保留。顧客へ配布するときに検討する**★(ユーザー判断 2026-10-01)。 +> ★**セキュリティの強化はリリース直前にまとめて行う**★(ユーザー判断 2026-10-01。D-154)。★その時の対象★: 接続元の制限・TLS(または閉域)、★ステージングDBのパスワード変更★(コメントに実物が入っていた。履歴に残る)、★`ALLOW_ALL_WHEN_EMPTY` を拒否に倒すか許可リストを入れる★(`lib/config/allowed-ips.ts`)、★ログインの試行回数制限と cookie の中身★(`lib/auth/cookie.ts`)、★制御盤のログ中継(作ったら): 読めるのはログのフォルダだけ・読み取り専用・制御盤ごとの認証★/★許可リストを入れると店舗の制御盤(`/api/ds-agent/`)が弾かれる — 店舗の IP を載せるか、その経路だけ IP 制限を外してトークン必須にするか★(D-179。未実装)/★CSP を入れるなら `app/layout.tsx` の `` のインラインスクリプト(表示設定の復元。D-183)に nonce が要る★ +> ★第10回では照会しない★。★本番を組むときは、アプリとDBを閉域でつなぐか、DBに TLS を入れるかのどちらかが要る★ +> - ★★**ステージングには、4カラム(`status` / `comment` / `responded_at` / `resolved_at`)の UPDATE を書き込んでよい**★★ +> (ユーザー指示 2026-10-01「書き込んでよい」)。★実データ(ドン・キホーテのエラー行)の対応状況が書き換わることを承知のうえ★。 +> ★**4カラム以外への書き込みは引き続き禁止**★(§7。権限も無い)。★例外は `robot_error_reports.error_list_id` だけ★(下の「DB接続アカウント」。D-184)。★`scripts/seed-local-db.ts` は★ステージングに絶対に向けない★ +> (テーブルを消してから入れ直すため、実データが消える。`lib/db/local-guard.ts` が `dwatch_local` 以外を拒否する) +> - ★旧: 「本番DBへは書き込まない(2026-09-17)」「書き込んで安全な環境ができるまで着手しない」★ — D-152 で解除 + +### DB接続アカウント(2種) +1. **参照用**: 全テーブル SELECT のみ +2. **更新用**: `robot_error_reports` / `control_error_histories` の + `status`, `comment`, `responded_at`, `resolved_at` **のみ** UPDATE 可 + - ★加えて `robot_error_reports.error_list_id`(エラー内容。`error_lists.id`)★(D-184。2026-10-27 ユーザー回答「すでに更新権限があると思う」。 + ★`SHOW GRANTS` での確認は未了★ — ステージングに接続できないため)。★`control_error_histories` には無い列★ + +この4カラム(`robot_error_reports` は `error_list_id` を含む5カラム)以外への書き込みコードを書かないでください。権限エラーになります。 +★`error_list_id` を書くのはエラー一覧の `PATCH /errors/{error_id}/status` だけ★(事前チェックの `/prechecks` と D-Sec の保存は4カラムのまま。`lib/db/robot-error-write.ts`)。 + +--- + +## 4. 確定仕様 — 実装時に必ず守るルール + +> ここに書かれた数値・ロジックは TeamWorks との合意事項です。 +> 変更したくなった場合は勝手に変えず、`docs/decisions.md` を確認し、必要なら合意を取り直してください。 + +### 4.1 判定基準(◎○△×) +清掃完了率にもとづく4段階判定。**閾値は設定値として外出しする**(ハードコード禁止)。 + +| 判定 | 条件 | +|---|---| +| ◎ | 完了率 90%以上 | +| ○ | 完了率 70%以上 | +| △ | 完了率 10%以上 70%未満 | +| × | 完了率 10%未満、または未実施 | + +★**境界はすべて「上のランクに入る」**★(ユーザー指示 2026-09-16「◯、×は90%以上のように境はふくめてください」。**D-139**)。 +90.0 は ◎、70.0 は ○。★**× の境界だけは「10%未満」のまま**★(同日「10%未満を×としてください」で確定)なので、10.0 は △。 +★**D-020b(境界はすべて下のランクに落ちる)は破棄**★。 + +### 4.2 KPI計算式(週次/月次/年次の集計期間内) + +``` +稼働率 = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100 ← ★第11回 P-6=A(2026-10-05)★ +出発成功率 = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100 ← ★時刻の条件なし(P-6=A)★ +清掃完了率 = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数 ← ★1回ごとの完了率 = min(clean_area ÷ task_area × 100, 100) を小数第1位までで切り捨て★ +エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100 ← ★分母は予定回数(P-6=B)★ +``` + +★**3つの率はサマリ(`operation_ratio` / `launch_ratio` / `error_ratio`)と同じ式**★(第11回 P-6。ユーザー指示 2026-10-05「合わせてください」。D-158)。★旧: 稼働率 = 実施回数 ÷ 予定・出発成功率 = 定刻出発回数 ÷ 予定(実際は同じ値になっていた)・エラー発生率 = ÷ 実施回数★。実装は指示書AU + +★**清掃完了率は「1回ごとの面積比(上限100・小数第1位まで切り捨て)」の単純平均**★(★**ユーザー判断 2026-10-02「一旦B」。D-155**★)。 +★TeamWorks のサマリと同じ式★(第10回 N-3 の回答)。★`clean_task_reports.clean_ratio` 列は使わない★(先方が「計算式が間違っている。訂正したら回答する」) +- ★**確定**★(第11回 P-2=A「今の式のまま」。P-1: 訂正後の `clean_ratio` 列もこの式と同じ値になる。2026-10-05。D-158)。★D-151 の「`clean_ratio` 列の単純平均」は破棄★。 + ★切り替えが1関数で済むよう、1回ごとの完了率を決める関数は1つにする★(`lib/judge/kpi.ts`。指示書AS) +- ★既知の弱点(承知のうえで採用)★: `clean_area` は★同じ場所を複数回清掃した面積の総和★(N-4)なので、面積比は清掃の網羅率ではない。 + ユーザー提供CSV 189行では ★`clean_area > task_area` が 129行(68.3%)で、これらは全部 100% になる★。★◎が多めに出る★ +- ★全画面で同じ定義★(レポートKPI 当期 / 総清掃面積 F-08 / エリア別完了率 / 要レスキュー R-3 / 詳細ログの1行) +- ★対象は本清掃(`precheck = 0`)に対応する行と、予定外のタスク★。★事前チェックの回は別の1件として数えない★。 + - ★★予定外のタスク(`clean_timetable_id` が 0 か NULL)も1件として数える(A案)★★(ユーザー指示 2026-10-29「エラー一覧以外も、予定外タスクを計算して出すようにしてください」「割合はA案で」。指示書BG): + 予定外1件 = 清掃予定回数 +1・開始できた回数 +1・`status = 4` なら正常終了 +1・1回ごとの完了率を平均に入れる。予定外のエラー(`clean_timetable_id` が 0 か NULL)もエラー発生回数・エラー内訳に入れる(期間は `errored_at ?? created_at`)。 + 予定外の期間の振り分けは `started_at`(実際に始めた時刻)。★予定外は未実施・結果待ちにならない★ + - ★今の期間(アプリが計算)だけ★。★確定期間は TeamWorks のサマリのまま★(ユーザー「サマリは変えて貰う予定です」)。サマリが変わるまで、今の期間と確定期間で予定外の扱いが違う + - ★旧(2026-10-02〜10-29): 予定外は完了率にも実施回数にも入れず、総清掃面積の面積の合計にだけ含めていた(D-155 項3)★ +- ★`task_area` が 0 の行は、1回ごとの完了率を 0% とする★(第11回 P-5=B。2026-10-05。実装は指示書AU)。★旧(D-155 の暫定): null として分子にも分母にも入れない★ +- ★未実施(レポート行が無い清掃)は分子にも分母にも入れない★(0% として混ぜない) +- ★切り捨ては「1回ごとの値」だけ★。★平均した値は丸めずに判定し、表示のときだけ整数に丸める★(§4.3) +- ★浮動小数の罠★: `0.57 / 1 * 100` は `56.99999…` になる。★切り捨ての前に誤差を吸収すること★(期待値表は指示書AS) + +#### 4.2.1 ダッシュボードの清掃結果(ユーザー指示 2026-10-29「4の計算式は仕様書に残して」。D-049 / D-173。実装は `lib/dashboard/aggregate.ts` の `computeSummary()`) + +``` +成功率 = 成功 ÷ (成功 + 失敗 + 未実施) × 100 ← ★結果待ちは分母に入れない★。分母0なら null(画面は「-」) +``` + +| 区分 | 数える清掃 | 成功率での扱い | +|---|---|---| +| 成功 | 清掃結果(`clean_task_reports` の行)があり、判定が ◎○ | 分子・分母 | +| 失敗 | 清掃結果があり、判定が △×(★出発した清掃だけ★。未実施を二重に数えないため) | 分母 | +| 未実施(=出発失敗) | 清掃結果が無く、予定の終了+`RESULT_WAIT_MINUTES`(120分)を過ぎた | 分母 | +| 結果待ち | 清掃結果が無く、予定の開始 ≤ 今 < 予定の終了+120分 | 入れない(件数だけ出す) | + +- ★対象の清掃★: 日付を指定しないとき(直近)は、★清掃エリアごとに1件★ — 開始が直近7日以内(`DASHBOARD_LATEST_RUN.lookbackDays`)で予定の終了を過ぎた本清掃のうち、開始が最新のもの(D-159)。日付を指定したときは、その日の本清掃すべて +- ★予定外のタスク(`clean_timetable_id` が NULL か 0)も1件として数える★(指示書BG。2026-10-29)。直近モードの候補は開始=`started_at`・終了=`ended_at`(null なら開始)。成功か失敗のどちらかになり、未実施・結果待ちにはならない。同じ清掃エリアで開始が同時刻なら予定のある行を採る(`lib/dashboard/latest-run.ts` の `preferOnTie()`) +- ★事前チェックの結果は使わない★(§4.4。D-170) +- ★丸めない★。表示のときだけ整数に四捨五入(§4.3) +- 進み具合(`schedule_progress`)= 対象日に出発した件数 ÷ 対象日の予定件数 × 100(予定件数0なら null)。★対象日は、日付を指定しないとき日本時間の前日、指定したときはその日★(ユーザー判断 2026-10-29「B」 — 清掃結果は前夜の分なので、今日の予定で数えない)。 + ★予定件数は予定の表(`clean_timetables` / `clean_timetable_histories` の本清掃)の行数★。★`clean_schedules` の曜日の設定は使わない★(旧。2026-10-29 ステージングで今日の予定が 7件 対 5件と食い違った)。結果待ち・未実施は予定件数にだけ入る。★対象日に始まった(`started_at`)予定外タスクは予定件数にも出発件数にも1件ずつ入る(A案。指示書BG)★。応答の `schedule_progress.date` が対象日。`lib/dashboard/aggregate.ts` の `scheduleProgressTargetDate()` / `computeScheduleProgress()`。★要レスキュー・セキュリティのバナーは当日のまま★ + +### 4.3 端数処理 ★事故りやすい★ + +- **表示**: 小数第1位を四捨五入して**整数**で表示する(例: 91.4% → `91%`) +- **判定**: ◎○△× と L1〜L3 の判定は、**丸める前の生値**で比較する + +```ts +// ✅ 正しい +const grade = judgeGrade(rawRate); // 生値で判定 +const display = Math.round(rawRate); // 表示用に丸める + +// ❌ 間違い(89.5%が丸めで90%になり、本当は○なのに◎になる) +const rounded = Math.round(rawRate); +const grade = judgeGrade(rounded); +``` + +### 4.4 清掃開始/終了/失敗の判定 + +| 事象 | 判定条件 | +|---|---| +| 清掃開始 | RobotWorkStatusコールバック(清掃開始)受信、またはポーリングでステータスが「清掃中」に変化 | +| 清掃終了 | タスク実行結果(タスクレポート)の受信 | +| 清掃失敗 | 予定開始時刻 + **猶予30分**を経過してもタスク開始が確認できない(未実施)、またはタスクレポートが中断で返る | + +猶予30分は暫定値。**設定値として外出しする。** + +★**レポートがまだ無い清掃は「結果待ち」**★(ユーザー判断 2026-10-07。D-173): 予定の開始 ≤ 今 < 予定の終了+`RESULT_WAIT_MINUTES`(120)のあいだは失敗に数えない(`lib/clean-logs/result-pending.ts` の1本。API は `CleanLogItem.result_pending`)。過ぎたら出発失敗(未実施)。★上の「猶予30分」(`LAUNCH_GRACE_MINUTES`)はコードで使っていない★(D-091) + +★**事前チェックの結果も有無も、成功・失敗の判定に使わない**★(ユーザー指示 2026-10-07「事前チェックを行っていない清掃や事前チェックが失敗した清掃でも本清掃が成功していれば成功にしてください」。D-170 / D-048)。判定は本清掃のレポート行の有無と完了率だけ。`lib/dashboard/__tests__/aggregate.test.ts` が見張っている + +★**「タスクレポートが中断で返る」は、いまは判定に使っていない**★(ユーザー判断 2026-10-02。D-155 項2)。`clean_task_reports.status`(2 清掃中断 / 3 清掃キャンセル / 5 清掃異常終了)は★表示名として出すだけ★で、◎○△× は完了率で決め、未実施(出発失敗)は★レポート行の有無★で決める(§4.7)。★実データが入ってから見直す★ + +### 4.5 エスカレーション(Phase A: R1〜R4) + +| レベル | 条件 | 想定対応 | +|---|---|---| +| L1 | 初回の問題 | 現場へ連絡(障害物確認・ブラシ清掃・再起動等) | +| L2 | 2日連続 | 現場責任者へのヒアリング・運用手順の再教育・消耗品交換 | +| L3 | 3日以上連続 | DFAオンサイト訪問・マップ再作成・機体交換検討 | + +| # | 条件 | アドバイス | +|---|---|---| +| R1 | 出発失敗(初回) | 事前チェックのアラート内容を確認 | +| R2 | 完了率×(10%未満) | 現場の通路状況+ロボット状態を確認 | +| R3 | 同一施設で2日以上連続(出発失敗 or △以下) | 現場責任者に状況をヒアリング | +| R4 | 同一施設で3日以上連続(出発失敗 or △以下) | DFAオンサイト訪問を検討 | + +**「連続」はカレンダー日ではなく「スケジュール存在日の連続」**で数えます。清掃予定がない日は連続を途切れさせません。 + +★**予定外のタスクも、その施設のその日(`started_at`)の結果に入れる**★(指示書BG。2026-10-29)— 連続日数・L1〜L3・要レスキュー・連続問題施設のすべて。★予定外だけの日も「スケジュール存在日」★(完了率 70 未満なら問題日、以上なら連続が切れる)。★予定外は未実施にならない★ので、要レスキューの「未実施の連続」は予定外だけの日で切れる。清掃詳細ログの予定外の行の連続日数は施設×日付で引く(`consecutiveProblemDaysByFacilityDate()`) + +### 4.6 契約フラグ + +- **`facilities.contract` のみが正**。★**`varchar(10)` の文字列**★であり、★**数値ではない**★(D-080) + - ★**桁位置がサービスに対応する**★。★1桁目 = D-Security / 2桁目 = D-Watch★。 + ★契約済み(有効)なサービスの桁を `1` にする★ + - 値の例: `0000000000` = 未設定(既定値) / `1000000000` = D-Securityのみ / + `0100000000` = D-Watchのみ / ★`1100000000` = 両方★ + - 3桁目以降は将来のサービス用の予備と読める(★定義書に明記なし。P1では読まない★) + - ❌ ★**数値として比較しないこと**★。`Number("0000000000")` は**例外を出さずに** `0` になり、 + `contract === 3` のような比較は**絶対に真にならない**。結果として + ★**全施設が「契約なし」になりメニューが全部消えるが、例外もテスト失敗も出ない**★ + - `boolean`(`monitoring_enabled` / `d_security_enabled`)への変換は + ★`lib/format/` か `lib/config/` の純粋関数1箇所に閉じ込め、桁位置をハードコードしない★(D-080) +- `customers.contract` は**削除依頼済み**。参照してはいけません +- ★★**`contract` という列は2つあります**★★(2026-09-17 実測。`prisma/schema.prisma:141` と `:571`。行番号は 2026-10-05 時点) + - ★`facilities.contract`★(`:141`)= ★**これが正**★ + - ★`clean_schedules.contract`★(`:571`)= ★**読まないこと**★。★型も既定値も `facilities` と同一★ + (`varchar(10)` / `"0000000000"`)なので、★**取り違えても型エラーにならない**★。 + ★現在のアプリは読んでいない(正しい状態)★ + - ★用途は未確認★。TeamWorks の定義書にも説明が無い +- メニューの表示制御もこのフラグに基づく(契約のないセクションはメニューごと非表示) + - ★**未実装**★(2026-10-06 確認。`components/layout/AppShell.tsx` は全メニューを無条件に描く)。★**P2 で作る。顧客に使わせる前に必ず入れる**★(ユーザー判断 2026-10-06。D-160 追記4) + +### 4.7 集計期間とバッチ + +| 種別 | 実行タイミング | 対象 | +|---|---|---| +| 週次 | 毎週日曜 6:00 | 前週(**日曜〜土曜**) | +| 月次 | 毎月1日 6:00 | 前月 | +| 年次 | 毎年1月1日 6:00 | 前年 | + +- バッチは **TeamWorks が実装**。DFAはサマリテーブルを**読むだけ** +- ★**週次バッチの「前週」は、月をまたぐと2行になる**(D-071 の帰結。**第5回 X-3 は TeamWorks 承認済み。D-074 項3。2026-09-08**)★ + 例: 2026-09-06(日) 6:00 のバッチは `2026/8/6週`(08-30〜08-31) と `2026/9/1週`(09-01〜09-05) の**2行** +- ★**サマリの粒度は `month` / `week` が 0 かどうかで判別する**(`week=0` が月次、`month=0 AND week=0` が年次。 + **第5回 X-2 は TeamWorks 承認済み。D-074 項3。2026-09-08**)。`week=0` は予約値なので、API の `week` は 1 以上のみ受け付ける★ +- **進行中の期間(当週・当月・当年)はサマリテーブルに存在しない。** 詳細テーブルからその場集計すること +- ★★**データの取得元**(ユーザー指示 2026-09-30。指示書AQ)★★ + + | 何を | どこから | + |---|---| + | ★確定期間(週・月・★年★)のKPI★ | ★`customer_summaries` / `facility_summaries`(率4列)/ `facility_error_summaries`★ | + | ★直近・昨日・当期の清掃実績★ | ★`clean_task_reports`★ | + | 清掃の予定 | ★`clean_timetables` と `clean_timetable_histories` の両方★(`precheck = 0` が本清掃)。★同じ id は `clean_timetables` 側を採る★(履歴の id は本表の id のコピー。D-158 項4・指示書AU 段階3b。読み口は `lib/db/timetable-source.ts` の1か所) | + | ★エラー(制御盤のエラーも含めて)★ | ★`robot_error_reports` に集約★(★制御盤・セキュリティ・清掃開始失敗のエラーも入る(第11回 P-8〜P-10。D-158)★。★先方の DDL で `robot_id` は NULL 可になったが、Prisma は未 db pull(NOT NULL のまま)★。読み取りは `lib/db/robot-error-select.ts` で `robot_id` / `robot` を読まない(指示書AU 段階0)。★D-Sec アラートは `robot_error_reports` のうち `error_type` が `D-S_CONTROL_FAILURE` の行だけ(完全一致。`SECURITY_ON`・`NOT_STARTED` は出さない)を読み、対応状況・備考はエラー一覧と同じ `PATCH /errors/{error_id}/status` で書く(D-180)★。★`control_error_histories` は読まない★。★削除済み(`deleted_at` が NULL でない)の施設・清掃エリアの行は、エラー一覧(`meta.total` も)にも D-Sec アラート(`summary`・ダッシュボードも)にも出さず、その PATCH は404(U8。D-180 項11)★。★施設名・分類の解決は従来どおり `deleted_at` で絞らない(指示書AX 罠7)★。★区分(ロボット/制御盤/セキュリティ/清掃開始失敗)を画面に作るのは次フェーズ★。★全行(削除済みの施設・清掃エリアの行を除く。U8)の一覧と4カラムの更新はエラー一覧(`/errors`。D-160・指示書AW)★) | + + - ★**`clean_status_histories` は読まない**★(`clean_task_reports` に一本化。テーブル自体は TeamWorks の管理下なので残る) + - ★**年次は解禁済み**★。★D-072 項4 の「P1では年次を読まない」は破棄★。 + ★`periodOfSummaryRow()`(`lib/db/report-mapping.ts`)は `month` も見ること★ — `week===0` だけで判定すると + ★年次行が月次に化けて混入し、重複検知で500になる★ + - ★**未実施 = 本清掃に対応する `clean_task_reports` の行が無いこと**★。`clean_task_reports` には + `clean_error` 相当の列が無い。★「出発失敗」と「未実施」は同じ扱い★(ユーザー確認 2026-09-30) + - ★**`clean_task_reports.status` は表示名に変換して表示する**★(第10回 N-2 で TeamWorks が定義。D-155): 0 清掃開始前 / 1 清掃中 / 2 清掃中断 / 3 清掃キャンセル / 4 清掃正常終了 / 5 清掃異常終了 / ★6 清掃キャンセル(3 と同じ名称)★(N-2 の表に無い値。ユーザー指示 2026-10-30「6はキャンセルだそうです」「6の名称も3と同じ清掃キャンセルにしてください」)。★表に無い値(7 以上など)は「不明(7)」と数字を残して出す★(ユーザー指示 2026-10-30。「その他」に潰さない。§4.8 と同じ考え方)。★判定には使わない★(中断・異常終了でも完了率で ◎○△× を出す。ユーザー判断 2026-10-02。§4.4 の「中断で返る=失敗」は実データを見て見直す) + - ★**`clean_task_reports.clean_timetable_id` は nullable**★。CSV実測では189行すべて NULL。 + ★NULL は「予定外のタスク」(下の項目。第10回 N-1 で TeamWorks も同意)★で、★欠損ではない★。★2026-10-29 から今の期間の集計・清掃詳細ログ・ダッシュボード・清掃エリアに1件として入れる(§4.2・指示書BG)★(旧: 完了率・実施回数には入れない。D-155 項3)。 + ★旧: 「NULL は TeamWorks が今後埋める前提」(2026-09-30)は、10-01 のユーザー回答で取り下げ★ + - ★**ステージングの `clean_task_reports` は 2026-10-01 に2行入った**★(それまでは0行。2026-09-30 実測で `update_time` が NULL=作成以来一度も書かれていなかった)。 + - ★★**`clean_timetable_id` が NULL の行は「予定外のタスク」**★★(ユーザー回答 2026-10-01「清掃予定をサーバーで決められるものは + 今後 `clean_timetable_id` が発行されるが、予定外のタスクは NULL になる」)。★NULL は欠損ではなく、実在する清掃★ + - ★★**`clean_timetable_id` が `0` の行も「予定外のタスク」**★★(TeamWorks 回答 2026-10-27「clean_timetable_id が0以外は予定/実行済タスクになりますし, 0は予定外タスクになります」。 + `clean_task_reports` と `robot_error_reports` の両方)。★NULL も引き続き予定外★。★判定は `lib/db/timetable-link.ts` の `scheduledTimetableId()` 1本★(`=== null` / `{ not: null }` を直書きしない)。 + ★予定タスクを持つのは `clean_timetables` / `clean_statuses` / `clean_timetable_histories` / `clean_status_histories` の4表★(同回答。アプリが予定として読むのは `clean_timetables` / `clean_timetable_histories` の2表)。 + ★事前チェックの回(`precheck ≠ 0`)には `clean_task_reports` / `robot_error_reports` の行が入らず、`clean_statuses` / `clean_status_histories` は `clean_status`=2・`clean_status_trans`=0・`clean_error`=0 になる予定★(同回答)。★事前チェックの表示の仕様は変えない★(ユーザー指示 2026-10-27「この仕様から特に変更はない」 — 事前チェックの回に紐づく `robot_error_reports` を失敗として出す) + - ★★**`clean_task_reports.started_at` に入る値**★★(ユーザー回答 2026-10-05「A」で確定): ★予定のある清掃は予定の `clean_timetables.start_at` と同じ値★、★予定外のタスクは実際に清掃を始めた時刻★。★過去形の列(`started_at` / `ended_at` 等)は実施後に書かれる★(2026-10-01 ユーザー回答) + 例: 9/30 23:50 の予定が 10/1 00:10 に始まっても `started_at` は 9/30 23:50。★帰結: 期間の振り分けは、レポート当期(予定の `start_at`)・総清掃面積(`started_at`)・サマリ(第10回 N-9=A `started_at`)で一致する★。★予定のある清掃の実際の開始時刻は、この列からは分からない★(定刻出発の判定には使えない)。★旧: 「`started_at` は実際に清掃を始めた時刻」(10-01)は、予定外のタスクについてだけ正しい★ + ★2行ともどちらも `clean_timetable_id` が NULL、status は 4★(2026-10-01 実測・先方投入。§3 の接続先注記も参照) + - ★ユーザー提供CSV(189行)は、当時のステージングとは別のDBから出たもの★(IDの体系が違う)。 + ★ただし 2026-10-01 に入った2行の対象面積(1377.39502 / 898.892509)がCSVの値と完全に一致する★ので、 + ★同じ収集処理のデータと考えられる★。★出所は第10回で確認する★ +- サマリには丸め前の小数と**分子・分母**が格納される(検算可能にするため) + - ★★**実DDLと食い違っています**★★(2026-09-30 実測・構造由来)。 + ★`task_count`(予定回数)/ `launch_count` / `error_count` はあるが、★**実施回数の列が無い**★★ + (`prisma/schema.prisma:694-696` / `:728-730`。2026-10-05 時点)。 + ★率は4つとも実在する★(`operation_ratio` / `launch_ratio` / `clean_ratio` / `error_ratio`) + - ★帰結★: ★**稼働率は検算できず、複数顧客の合算もできない**★(★率は加重平均できないため★)。 + ★第9回 Q6-2 で実施回数の列の追加を依頼中★ +- ★**月跨ぎ週は「月境界で分割する」**★(**D-071 で確定。2026-09-07。TeamWorks のサマリ集計に合わせた**) + - 週は日曜〜土曜。ただし**月をまたぐ週は1日で切り、それぞれの月に別々の週として数える** + - ★**週の長さは1〜7日で変わる。月の週番号は1〜6**★(第6週は年2〜3回発生する) + - 各月の第1週は**必ず1日から**始まり、最終週は**必ず末日で**終わる + - 例(2026年): 8月第1週 = **08-01 のみ(1日)** / 8月第6週 = **08-30〜08-31(2日)** / + 9月第1週 = **09-01〜09-05(5日)** / 9月第2週 = 09-06〜09-12(7日) + - 帰属ルールを知っているのは `lib/format/period.ts` の `weekOfMonth()` **だけ**にする + - ★**D-067 の旧DFA案(1日を含む週がその月の第1週)は破棄。同じ週を2つの月に分けない + 方式だったため、TeamWorks の週次サマリと帰属がズレる**★ + +### 4.8 エラー種別は変換しない(D-077) + +- ★**`robot_error_reports.error_type` も `robots.status` も、DBから来た値をそのまま表示する**★ +- ★**7分類(ハンドルアップ / LostLocation / オフライン / ブラシエラー / 緊急停止 / 充電不足 / その他) + への変換は行わない**★ +- 理由: 現状 Pudu API から何が返るか不明なため。★**推測でマッピングしない**★ + (§7 の「仕様が不明な箇所を推測で実装すること」の禁止に合致) +- ★**D-057 / D-067 の「サーバ側で7分類に寄せる/未知の値は `other` に寄せる」は破棄**★ +- ★**`robots.status = -200`(LostLoaction)→「LostLocation」の変換もしない**★ + (D-070 でそう確定していたが取り下げ。**「-200 が LostLocation である」という事実認識は取り下げない。 + 取り下げるのは画面に出す前に変換する実装方針だけ**) +- ★**`docs/handoff/analysis/error-type-mapping.md` は参考資料。実装の下敷きではない**★ +- ★**帰結: 画面には英語識別子(例 `EmergencyStop`)がそのまま出る。それが意図した挙動**★ +- ★**注意: `lib/theme-status.ts` の色分けは日本語6文字列の完全一致なので、生値では全部グレーに + フォールバックする**★ + +--- + +## 5. 参照資料 — 何が「正」か + +| 資料 | 何の正か | +|---|---| +| `d-watch-api.yaml` | **API契約の正**。エンドポイント・JSONスキーマ・enum・エラー形式 | +| `docs/spec.md` | **機能一覧と開発フェーズの正**。何をどの順で作るか | +| `docs/decisions.md` | **決定事項とその理由の正**。「なぜこの仕様か」を遡る時に読む | +| `docs/fixtures.md` | **サンプルデータの正**。DB接続前の開発で必ず用意するケース一覧 | +| モック(`d-watch-mock.jsx`) | **画面レイアウト・文言・遷移の正**。フロント実装はこれを移植する | +| 清掃ロボット監視テーブル定義書(28テーブル) | **DBスキーマの正**。Prismaスキーマの突き合わせ先 | +| UI仕様書 v6.0 | 画面ごとの詳細仕様。モックで迷った時の裏付け | +| 要件定義書 P1 v2.2 | 全体要件・Phase A/B/Cの線引き。★**DFA側の想定であり、TeamWorks の DB との整合は保証されない。型・値域・カラム名は実DDLが正**★(2026-09-08 ユーザー指示。D-074 / D-057)。**所在: Google Drive の「D-Watch / D-Security 要件定義書 (P1)」`1thSYHk-ODCht-K4WqFlCP-Es2OyAz4Wj`**。★**PHP/Laravel と書かれているが実装は Next.js(D-046)。トークン期限の記述も現行方針では禁止。古い箇所がある**★ | +| 運用ガイド | 誰がいつどう使うか。UXの判断に迷った時に読む | + +資料間で矛盾があったら、上の表の「正」に従ってください。それでも解決しない場合は**実装を止めて確認を求めてください**(推測で埋めない)。 + +--- + +## 6. 作業ルール + +1. **API契約を変えたくなったら、先に `d-watch-api.yaml` を直す。** 実装だけ変えてYAMLと乖離させない +2. **判定ロジック(◎○△×・L1〜L3・KPI計算・丸め)には必ずテストを書く。** 期待値表を先に作り、それをテストで固定する。目視確認だけで済ませない +3. **開発は `lib/fixtures/` のサンプルデータで行う。** 実DBの完成を待たない。 + - APIルートは `USE_FIXTURES` で fixtures / Prisma を切り替える。画面側のコードは切替を意識しない + - **判定ロジックは切替の外側**に置く(fixture開発中も本番同等のロジックが走る) + - 用意すべきケースは `docs/fixtures.md` を参照(境界値・異常系を先に仕込む) +4. **TeamWorks待ちの機能はスタブ/モックで先行し、解消後に差し替える。** ブロックされない +5. **PRは必ずもう1人がレビューしてからmainにマージする**(★レビュー担当は daichi。D-154★) +6. **「実測」と書くときは、その数字の出所を必ず併記する。** + 「当方のシード由来(`scripts/seed-local-db.ts` / `lib/fixtures/`)」/「先方投入」/ + 「構造(`information_schema` / `SHOW` / `EXPLAIN`)」のどれかを書く。**出所を書けない数字は + 「実測」と呼ばない。** + - 理由: テストDBの行が当方のダミーだと気づかないまま、それを根拠に外部(TeamWorks)へ + 照会する文書を作りかけた(2026-09-17。送付前に気づいた。 + `docs/handoff/テストDBのデータ出所_20260917.md`) + - この事故は `docs/decisions.md` D-111 項7(`:6683-6689`。2026-09-11)が5日前に予告していた + — 「**テストDBでは判定できない: 本番のデータ量 / サマリバッチの実挙動 / 本番の `time_zone` 設定**」。 + 表はあったのに守られなかった + +--- + +## 7. 禁止事項 + +- ❌ **メインDBへのマイグレーション作成・スキーマ変更**(TeamWorksの領域。`prisma db pull` で取り込むだけ。 + `db pull` 実施前の `prisma/schema.prisma` 手書きは対象外、D-056参照) +- ❌ **§3 で指定した4カラム(`robot_error_reports` は `error_list_id` を含む5カラム。D-184)以外へのDB書き込み** +- ❌ **表示用に丸めた値で判定すること**(4.3参照) +- ❌ **`customers.contract` の参照**(削除依頼済み。`facilities.contract` を使う) +- ❌ **閾値(90/70/10、猶予30分、L2=2日/L3=3日)のハードコード**(設定値として外出し) +- ❌ **点検記録データをメインDBに保存すること**(DFA独自ストアに分離) +- ❌ **トークン期限/残日数の実装**(仕様から除外済み。古い資料に記述が残っているが実装しない) +- ❌ **モックに残っている実在の住所・電話番号をそのまま使うこと**(ダミー値に置換する) +- ❌ **仕様が不明な箇所を推測で実装すること**(確認を求める) +- ❌ **エラー種別を推測で7分類に変換すること**(D-077。DBの値をそのまま表示する。§4.8参照) +- ❌ **`git add -A` の使用**(過去に別作業の途中ファイルを巻き込む事故が起きている。 + ★**必ずファイルパスを明示する**★) + +--- + +## 8. 用語 + +| 用語 | 意味 | +|---|---| +| 清掃エリア(sections) | 施設内の清掃対象区域。旧称「区画」。**「清掃エリア」に統一** | +| 施設(facilities) | ロボットが配置される店舗・拠点。旧称「店舗」 | +| D-Sデバイス / D-S制御盤 | セキュリティ会社システムと制御サーバ間を中継する物理デバイス | +| 事前チェック | 清掃開始の60分前/30分前にロボット状態を確認する仕組み | +| Pudu Cloud | PUDU Robotics のクラウドAPI。ロボット状態・タスク結果の取得元 | + +**P1の前提: 1清掃エリア = 1ロボット。** テーブル構造は複数台対応可能ですが、P1ではこの前提で実装します。 + +## 9. 実装規約 + +### 9.1 ディレクトリ構成 + +``` +app/ + (dashboard)/ 画面。モックの画面IDに合わせる + dashboard/ detail-log/ alerts/ ds-logs/ ds-alerts/ + facilities/ inspections/ reports/ schedules/ system/ + api/ APIルート + clean-logs/ + route.ts + export/route.ts + [id]/advice/route.ts + [id]/areas/route.ts + settings/ + advice/route.ts +components/ + ui/ Pill, FilterButton, GradeBadge 等の共通部品 + features/ 画面固有のコンポーネント(例: detail-log/) + layout/ AppShell 等 + theme/ ThemeProvider +lib/ + judge/ ★判定ロジック(◎○△×、L1〜L3、KPI計算、丸め)+ __tests__/ + fixtures/ サンプルデータ(DB接続前の開発用。docs/fixtures.md 参照) + db/ USE_FIXTURES で fixtures / Prisma を切り替える + config/ 閾値等の設定値 + clean-logs/ 清掃ログ関連のクエリ + settings/ 運用設定(アドバイス等) + types/ 型定義。YAMLのスキーマ名と揃える + format/ 表示整形(日時等) +docs/ spec.md, decisions.md, fixtures.md 等 +prisma/schema.prisma db pull実施後は生成物を正とし手で書き換えない(D-056)。実施前は定義書ベースで手書き +d-watch-api.yaml API契約 +d-watch-mock.jsx 画面レイアウト・文言・遷移の正 +``` + +### 9.2 命名 + +- ファイル/ディレクトリ: kebab-case(`detail-log/`) +- コンポーネント: PascalCase(`CleanLogTable.tsx`) +- 関数・変数: camelCase +- DBカラム: snake_case(テーブル定義書の物理名をそのまま使う。勝手にリネームしない) +- API のクエリパラメータ・レスポンスキー: snake_case(YAML準拠) +- 型: `lib/types/` に集約し、YAMLのスキーマ名と揃える(`CleanLogItem`, `PrecheckItem` 等) + +### 9.3 コーディング規約 + +- `any` 禁止。型が不明ならYAMLのスキーマから起こす +- APIレスポンスは必ず型を定義してから実装する +- 日時は ISO 8601(JST, +09:00)。表示整形はコンポーネント側 +- 数値の丸めは表示層でのみ行う。ロジック層には生値を渡す +- エラーレスポンスは `{ error: { code, message, details? } }` で統一 +- 一覧APIは必ず `meta: { total, page, per_page }` を返す +- 環境変数は `.env.local`。認証情報を絶対にコミットしない + +### 9.4 判定ロジック(`lib/judge/`) + +- **テストを先に書く。** 入力→期待値の表を作り、境界値を必ず含める + - 完了率: 90.0 / 90.1 / 70.0 / 70.1 / 10.0 / 9.9 / 0 / 未実施(null) + - 連続日数: 1日 / 2日 / 3日 / スケジュール非存在日を挟むケース + - 丸め: 90.4 → 表示90だが判定は◎、89.5 → 表示90だが判定は○ +- 閾値は `lib/config/` から読む。ハードコード禁止 +- 判定関数は純粋関数にする(DBアクセスを混ぜない) + +### 9.5 サンプルデータ(`lib/fixtures/`) + +- APIルートは `USE_FIXTURES` で fixtures / Prisma を切り替える。**画面側は切替を意識しない** +- 判定ロジックは切替の外側に置く(fixtureのデータにも本番同等の判定を通す) +- fixtures は DB接続後も削除しない(テスト・デモ用) + +### 9.6 フロント + +- モック(`d-watch-mock.jsx`)の画面レイアウト・文言・遷移を正とする +- 判定結果(◎○△×、L1〜L3、ソート順)はAPIから受け取る。フロントで再計算しない +- ダーク/ライト、JP/EN切替の器を壊さない + +--- + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe5b1c4 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# D-Watch + +清掃ロボット稼働モニタリングビューアー。DFA Robotics 内製開発。 + +- **はじめに読む**: [`docs/getting-started.md`](docs/getting-started.md) — 開発者向けセットアップ手順 +- **コードの地図**: [`docs/code-map.md`](docs/code-map.md) — 非エンジニア向け。どのファイル・関数・数値が何を司っているか +- **構成と依存の向き**: [`docs/architecture.md`](docs/architecture.md) — エンジニア向け。触ってはいけないものの正 +- **仕様**: [`docs/spec.md`](docs/spec.md) — 機能一覧と開発フェーズ +- **サンプルデータ**: [`docs/fixtures.md`](docs/fixtures.md) — DB接続前の開発用データ仕様 +- **開発コンテキスト**: [`CLAUDE.md`](CLAUDE.md) — Cursor / Claude Code は必ずこれを読む +- **決定事項**: [`docs/decisions.md`](docs/decisions.md) — なぜこの仕様なのか +- **API契約**: [`d-watch-api.yaml`](d-watch-api.yaml) — OpenAPI 3.0 + +--- + +## セットアップ + +```bash +npm install +cp .env.example .env.local # 接続情報を記入 +npm run dev # http://localhost:3000 +``` + +### サンプルデータで開発する(DB接続前) + +実DBが用意される前は、`lib/fixtures/` のサンプルデータで開発します。 + +```bash +# .env.local +USE_FIXTURES=true +``` + +APIルートがサンプルデータを返すため、DBなしで全画面を開発できます。 +DB接続時は `USE_FIXTURES=false` にするだけで、**画面側のコードは変更不要**です。 + +用意すべきデータのケース(判定の境界値、L1〜L3、契約フラグ4パターン、空配列など)は +[`docs/fixtures.md`](docs/fixtures.md) を参照してください。 + +### Prismaスキーマの取り込み + +DBはTeamWorksが構築します。**マイグレーションは作りません。** + +```bash +npx prisma db pull # 実スキーマを schema.prisma に取り込む +npx prisma generate +``` + +取り込んだ内容とテーブル定義書(28テーブル)に食い違いがあれば、実装を止めて確認してください。 + +**この突き合わせだけはM1で早めに実施します**(カラム名の食い違いが後から見つかると全画面に波及するため)。 +画面開発は引き続き fixture のまま進めます。 + +### テスト + +```bash +npm test # 判定ロジックの期待値表テストを含む +``` + +判定ロジック(◎○△×、L1〜L3、KPI計算、丸め)は**必ずテストで固定**します。 +[`CLAUDE.md`](CLAUDE.md) の 4.1〜4.5 が仕様の正です。 + +--- + +## 開発フロー + +- ブランチ: `main` + featureブランチ(GitHub Flow) +- **`main` への直接pushは禁止**(ブランチ保護。必ずPR経由) +- PRの承認は必須にしていないが、`lib/judge/`(判定ロジック)を触るPRは相互確認する +- PRを作るとCI(Lint / テスト / ビルド)が自動で走る。**赤いまま放置しない** +- API契約を変える時は `d-watch-api.yaml` を先に修正する + +### 担当分担 + +| 担当 | 範囲 | +|---|---| +| **Daigo**(環境) | リポジトリ/CI、Prisma接続、点検記録の独自ストア、デプロイ、PRレビュー | +| **たけうち りき**(開発) | 画面実装(モック移植 + API接続)、APIルート実装 | + +判定ロジック(`lib/judge/`)は両名で確認します。 + +### Cursor 利用時の注意 + +- **Privacy Mode を必ずONにする**(会社のコードを扱うため) +- アカウントは会社ドメインのメールアドレスで作成する + +--- + +## 責任分界 + +| 領域 | 担当 | +|---|---| +| MySQL DB本体 / データ収集 / サマリ集計バッチ | TeamWorks(外部) | +| APIサーバー / ビューアー / 点検記録 | DFA(本リポジトリ) | + +DBは原則**読み取り専用**です。書き込みは `robot_error_reports` / `control_error_histories` の +`status` / `comment` / `responded_at` / `resolved_at` の4カラムのみ。 + +--- + +## 現在の状況 + +**M0(環境準備)まで完了。** 次は M1(判定ロジック `lib/judge/` + 期待値表テスト)と M2(画面実装)。 + +| ディレクトリ | 内容 | +|---|---| +| `lib/types/` | `d-watch-api.yaml` から起こした型定義 | +| `lib/config/` | 閾値(90/70/10、猶予30分、L2=2日/L3=3日)。**ハードコード禁止** | +| `lib/fixtures/` | サンプルデータ。境界値・異常系を仕込んである | +| `lib/db/` | `USE_FIXTURES` による fixtures / Prisma の切替 | +| `app/api/clean-logs/` | **動く見本のAPIルート。** 他の一覧APIはこの形を真似する | + +TeamWorks待ちの項目は [`docs/spec.md` §4](docs/spec.md) を参照。 +待ちの間はモック/スタブで先行し、解消後に差し替えます。 diff --git "a/app/\050dashboard\051/alerts/page.tsx" "b/app/\050dashboard\051/alerts/page.tsx" new file mode 100644 index 0000000..96b1777 --- /dev/null +++ "b/app/\050dashboard\051/alerts/page.tsx" @@ -0,0 +1,5 @@ +import { AlertsScreen } from "@/components/features/alerts/AlertsScreen"; + +export default function AlertsPage() { + return ; +} diff --git "a/app/\050dashboard\051/clean-area/page.tsx" "b/app/\050dashboard\051/clean-area/page.tsx" new file mode 100644 index 0000000..a235bd8 --- /dev/null +++ "b/app/\050dashboard\051/clean-area/page.tsx" @@ -0,0 +1,5 @@ +import { CleanAreaScreen } from "@/components/features/clean-area/CleanAreaScreen"; + +export default function CleanAreaPage() { + return ; +} diff --git "a/app/\050dashboard\051/dashboard/page.tsx" "b/app/\050dashboard\051/dashboard/page.tsx" new file mode 100644 index 0000000..103aeb3 --- /dev/null +++ "b/app/\050dashboard\051/dashboard/page.tsx" @@ -0,0 +1,5 @@ +import { DashboardScreen } from "@/components/features/dashboard/DashboardScreen"; + +export default function DashboardPage() { + return ; +} diff --git "a/app/\050dashboard\051/detail-log/page.tsx" "b/app/\050dashboard\051/detail-log/page.tsx" new file mode 100644 index 0000000..65492c1 --- /dev/null +++ "b/app/\050dashboard\051/detail-log/page.tsx" @@ -0,0 +1,5 @@ +import { DetailLogScreen } from "@/components/features/detail-log/DetailLogScreen"; + +export default function DetailLogPage() { + return ; +} diff --git "a/app/\050dashboard\051/ds-alerts/page.tsx" "b/app/\050dashboard\051/ds-alerts/page.tsx" new file mode 100644 index 0000000..4c54c16 --- /dev/null +++ "b/app/\050dashboard\051/ds-alerts/page.tsx" @@ -0,0 +1,5 @@ +import { DsAlertsScreen } from "@/components/features/ds-alerts/DsAlertsScreen"; + +export default function DsAlertsPage() { + return ; +} diff --git "a/app/\050dashboard\051/ds-logs/page.tsx" "b/app/\050dashboard\051/ds-logs/page.tsx" new file mode 100644 index 0000000..a98b749 --- /dev/null +++ "b/app/\050dashboard\051/ds-logs/page.tsx" @@ -0,0 +1,5 @@ +import { DsLogScreen } from "@/components/features/ds-logs/DsLogScreen"; + +export default function DsLogsPage() { + return ; +} diff --git "a/app/\050dashboard\051/errors/page.tsx" "b/app/\050dashboard\051/errors/page.tsx" new file mode 100644 index 0000000..a26a712 --- /dev/null +++ "b/app/\050dashboard\051/errors/page.tsx" @@ -0,0 +1,5 @@ +import { ErrorsScreen } from "@/components/features/errors/ErrorsScreen"; + +export default function ErrorsPage() { + return ; +} diff --git "a/app/\050dashboard\051/facilities/page.tsx" "b/app/\050dashboard\051/facilities/page.tsx" new file mode 100644 index 0000000..262510c --- /dev/null +++ "b/app/\050dashboard\051/facilities/page.tsx" @@ -0,0 +1,5 @@ +import { FacilitiesScreen } from "@/components/features/facilities/FacilitiesScreen"; + +export default function FacilitiesPage() { + return ; +} diff --git "a/app/\050dashboard\051/inspections/page.tsx" "b/app/\050dashboard\051/inspections/page.tsx" new file mode 100644 index 0000000..4319885 --- /dev/null +++ "b/app/\050dashboard\051/inspections/page.tsx" @@ -0,0 +1,5 @@ +import { PlaceholderScreen } from "@/components/layout/PlaceholderScreen"; + +export default function InspectionsPage() { + return ; +} diff --git "a/app/\050dashboard\051/layout.tsx" "b/app/\050dashboard\051/layout.tsx" new file mode 100644 index 0000000..69d8271 --- /dev/null +++ "b/app/\050dashboard\051/layout.tsx" @@ -0,0 +1,9 @@ +import { AppShell } from "@/components/layout/AppShell"; + +export default function DashboardGroupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git "a/app/\050dashboard\051/reports/page.tsx" "b/app/\050dashboard\051/reports/page.tsx" new file mode 100644 index 0000000..d095a0a --- /dev/null +++ "b/app/\050dashboard\051/reports/page.tsx" @@ -0,0 +1,5 @@ +import { ReportsScreen } from "@/components/features/reports/ReportsScreen"; + +export default function ReportsPage() { + return ; +} diff --git "a/app/\050dashboard\051/schedules/page.tsx" "b/app/\050dashboard\051/schedules/page.tsx" new file mode 100644 index 0000000..66a173b --- /dev/null +++ "b/app/\050dashboard\051/schedules/page.tsx" @@ -0,0 +1,5 @@ +import { SchedulesScreen } from "@/components/features/schedules/SchedulesScreen"; + +export default function SchedulesPage() { + return ; +} diff --git "a/app/\050dashboard\051/system/page.tsx" "b/app/\050dashboard\051/system/page.tsx" new file mode 100644 index 0000000..c5299a8 --- /dev/null +++ "b/app/\050dashboard\051/system/page.tsx" @@ -0,0 +1,5 @@ +import { SystemScreen } from "@/components/features/system/SystemScreen"; + +export default function SystemPage() { + return ; +} diff --git a/app/api/__tests__/master-filter-dashboard-reports.test.ts b/app/api/__tests__/master-filter-dashboard-reports.test.ts new file mode 100644 index 0000000..1cbc840 --- /dev/null +++ b/app/api/__tests__/master-filter-dashboard-reports.test.ts @@ -0,0 +1,416 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は**静的 import** すること★(`app/api/__tests__/master-filter.test.ts` 冒頭と同じ理由。 +// `it()` の中で `await import()` すると、最初の1本がモジュール読み込みを抱え込み、 +// フルスイート実行時に既定のタイムアウトを踏んで不定期に落ちる) +import { GET as GET_DASHBOARD } from "@/app/api/dashboard/route"; +import { GET as GET_CONTINUOUS } from "@/app/api/reports/continuous-problems/route"; +import { GET as GET_BREAKDOWN } from "@/app/api/reports/error-breakdown/route"; +import { GET as GET_PERFORMANCE } from "@/app/api/reports/facility-performance/route"; +import { DASHBOARD_LATEST_RUN } from "@/lib/config"; +import { db } from "@/lib/db"; +import { customers, facilities } from "@/lib/fixtures"; +import { cleanLogs, unscheduledCleanLogs } from "@/lib/fixtures/clean-logs"; +import { todayJst, toJstIso } from "@/lib/format/date"; +import type { + ContinuousProblem, + DashboardResponse, + DsAlert, + ErrorBreakdownItem, + FacilityPerformance, + ListResponse, +} from "@/lib/types"; + +/** + * 指示書AK ★段階4b★ — 分類(`group_id` / `customer_id`)の絞り込みが + * ★`/dashboard` と `/reports/*` の3本★ でも効いていること の期待値表。 + * + * ★段階4a の `app/api/__tests__/master-filter.test.ts`(一覧系7本)の続き★で、 + * ★作法はあちらに合わせてある★(fixtures で走らせる/期待値は fixtures の割り当て表から作る)。 + * + * ------------------------------------------------------------------------ + * ★★このファイルが守るもの★★ + * ------------------------------------------------------------------------ + * 1. ★ルートが解決結果を渡し忘れていないこと★ — 単体テストは + * ★渡し忘れていても緑のまま★(★罠1 / 落とし穴1★。一覧が全件のまま返るのは + * 例外もテスト失敗も出ない壊れ方)なので ★ルート → データソース → レスポンス★ まで通す + * 2. ★★`/dashboard` の数値カード(`summary`)と要レスキュー(`rescue_required`)で + * 母集団がずれないこと★★(指示書AK §7-2)— + * ★★「4つの屋号ぶんを足すと絞らないときと一致する」(分割の検査)★★ で固定する。 + * ★片方にだけ `facility_ids` を渡し忘れると、この足し算が合わなくなる★ + * 3. ★同時指定(AND)の積が空なら0件★(★落とし穴2★。全件表示に化けないこと) + */ + +// ---------------------------------------------------------------- 期待値(fixtures 由来) + +/** + * 分類の条件に当たる施設IDの集合。 + * + * ★`resolveFacilityIdsByMasters()` の実装を写したものではない★ — + * ★fixtures の割り当てをそのまま読む★(段階4a のテストと同じ関数・同じ理由)。 + * ★所属は `Facility` 自身が持っている★(指示書AK ★段階5★。 + * 段階4b 当時の `customerIdByFacilityId` は削除済み)。 + */ +function allowedFacilityIds( + groupId: number | null, + customerId: number | null +): Set { + const ids = new Set(); + for (const facility of facilities) { + if (groupId !== null && facility.group_id !== groupId) continue; + if (customerId !== null && facility.customer_id !== customerId) continue; + ids.add(facility.id); + } + return ids; +} + +/** ★fixtures の屋号4件は施設12件を**過不足なく**分ける★(下の「分割の検査」の前提) */ +const ALL_CUSTOMER_IDS = customers.map((c) => c.id); + +const byNumber = (a: number, b: number) => a - b; + +async function jsonOf(res: Response): Promise { + expect(res.status).toBe(200); + return (await res.json()) as T; +} + +// ---------------------------------------------------------------- GET /dashboard + +async function dashboard(search: string): Promise { + return jsonOf( + await GET_DASHBOARD(new Request(`http://localhost/api/dashboard?${search}`)) + ); +} + +/** + * ★`/dashboard`(`date` 省略=直近モード)の `summary` の母集団★(D-159 / 指示書AV 段階4-5)。 + * + * ★`pickLatestRunPerSection()` を呼ばずに素直に書く★(実装を写すと、実装の誤りまで写る): + * fixtures の `cleanLogs` のうち、施設が `allowed` に入り、 + * ★開始 ≥ 現在 − 遡り日数★ かつ ★開始 ≤ 現在★ の行の ★`serial_number` の種類数★ + * (★D-173 で落ち着き時間(30分)をやめた★。候補は「予定の終了 ≤ 現在」。表2 C6)。 + * ★fixtures は「終了=開始」「清掃エリア=ロボット」とみなす★(指示書AV §2 D10)。 + * 1ロボットから1行(=その施設ID)を返すので、`length` が `summary` の合計になる。 + * ★日数は `DASHBOARD_LATEST_RUN` から読む★(直書きしない。`CLAUDE.md` §7)。 + * ★`new Date()` は下の `describe` で止めた時計を読む★。 + */ +function latestRunFacilityIds(allowed: Set | null): number[] { + const nowMs = new Date().getTime(); + const earliestMs = nowMs - DASHBOARD_LATEST_RUN.lookbackDays * 86_400_000; + const facilityBySerial = new Map(); + for (const row of cleanLogs) { + if (allowed !== null && !allowed.has(row.facility_id)) continue; + const startMs = Date.parse(row.scheduled_at); + if (startMs < earliestMs || startMs > nowMs) continue; + facilityBySerial.set(row.serial_number, row.facility_id); + } + return [...facilityBySerial.values()]; +} + +/** + * ★スケジュール進捗の期待値(fixtures 由来。2026-10-29 案B)★ — 直近モードの対象日は ★JST の前日★。 + * その日に開始予定の行(施設が `allowed` に入るもの)を数え、出発(`departed`)したものを completed にする。 + * ★結果待ち・未実施は planned にだけ入る★。★`new Date()` は下の `describe` で止めた時計を読む★。 + */ +function expectedProgress(allowed: Set | null): DashboardResponse["schedule_progress"] { + const yesterday = toJstIso(new Date(new Date().getTime() - 86_400_000)).slice(0, 10); + // ★予定外タスクの行(`unscheduledCleanLogs`。指示書BG §2-1・§2-2 A案)も予定1回・出発1回として数える★ + const rows = [...cleanLogs, ...unscheduledCleanLogs].filter( + (row) => + row.scheduled_at.slice(0, 10) === yesterday && + (allowed === null || allowed.has(row.facility_id)) + ); + const completed = rows.filter((row) => row.departed).length; + return { + date: yesterday, + planned: rows.length, + completed, + rate: rows.length === 0 ? null : (completed / rows.length) * 100, + }; +} + +/** `summary` の4つの数の合計=母集団の行数(`computeSummary()` の定義上そうなる。★結果待ちも入る。D-173★) */ +function summaryTotal(body: DashboardResponse): number { + return ( + body.summary.success + + body.summary.failed + + body.summary.not_executed + + body.summary.pending + ); +} + +describe("GET /dashboard の分類絞り込み", () => { + // ★★この `describe` だけ時計を止める★★(指示書AV 段階4-5)— + // 実時刻のままだと、深夜0時〜4時に走らせたとき当日の fixtures 行(1:30〜3:30)が + // 「終わっていない」扱いになり、直近モードの期待値が時刻で揺れる。 + // ★`todayJst()` は止める前に読む★(fixtures の日付は実時刻で作られているので、同じ日の 12:00 に止める)。 + beforeEach(() => { + const today = todayJst(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(`${today}T12:00:00+09:00`)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => { + const all = await dashboard(""); + expect(summaryTotal(all)).toBe(latestRunFacilityIds(null).length); + expect(summaryTotal(all)).toBeGreaterThan(0); + }); + + it("`customer_id=1` → 屋号1の施設ぶんだけが数値カードに入る", async () => { + const all = await dashboard(""); + const scoped = await dashboard("customer_id=1"); + const allowed = allowedFacilityIds(null, 1); + + // ★絞る対象が実在すること★(もともと全部が屋号1なら、何も実装しなくても緑になる) + expect(latestRunFacilityIds(null).some((id) => !allowed.has(id))).toBe(true); + expect(summaryTotal(scoped)).toBe(latestRunFacilityIds(allowed).length); + expect(summaryTotal(scoped)).toBeLessThan(summaryTotal(all)); + }); + + it("`group_id=2` → ホールディングス2の施設ぶんだけになる", async () => { + const scoped = await dashboard("group_id=2"); + const allowed = allowedFacilityIds(2, null); + expect(summaryTotal(scoped)).toBe(latestRunFacilityIds(allowed).length); + }); + + it("★要レスキューは分類の外の施設を含まない★", async () => { + const scoped = await dashboard("customer_id=1"); + const allowed = allowedFacilityIds(null, 1); + expect(scoped.rescue_required.length).toBeGreaterThan(0); + expect(scoped.rescue_required.every((r) => allowed.has(r.facility_id))).toBe(true); + }); + + it("★スケジュール進捗・セキュリティバナーも同じ母集団で作られる★", async () => { + const all = await dashboard(""); + const scoped = await dashboard("customer_id=1"); + const allowed = allowedFacilityIds(null, 1); + + // ★★予定件数(`planned`)は対象日(直近モードは JST の前日)の清掃ログの行から★★(2026-10-29 案B)。 + // ★期待値は fixtures の `cleanLogs` を素直に数える★(実装を写さない)。★絞ったぶんだけ必ず減る★ + expect(all.schedule_progress).toEqual(expectedProgress(null)); + expect(scoped.schedule_progress).toEqual(expectedProgress(allowed)); + expect(scoped.schedule_progress.planned).toBeLessThan(all.schedule_progress.planned); + expect(scoped.schedule_progress.planned).toBeGreaterThan(0); + + // ★fixtures にも DB にも `security_off` は無い(D-180。バナーは実データでは常に空)★。 + // ★それでもルートの絞り込み(`dsAlertsOfScope`)を守るため、`db.dsAlerts()` を差し替えて + // 分類の中と外に1件ずつ置く★(空と空を比べても絞り込みの検査にならない — 指示書AZ G3) + const inside = facilities.find((f) => allowed.has(f.id))!; + const outside = facilities.find((f) => !allowed.has(f.id))!; + const securityOff = (id: string, f: (typeof facilities)[number]): DsAlert => ({ + id, + alert_type: "security_off", + device_serial: "DS-TEST", + facility_id: f.id, + facility_name: f.facility_name, + section_name: "1F売り場", + detected_at: `${todayJst()}T03:10:00+09:00`, + resolution_status: "unresolved", + responded_at: null, + resolved_at: null, + note: null, + }); + vi.spyOn(db, "dsAlerts").mockResolvedValue([securityOff("T-1", inside), securityOff("T-2", outside)]); + const allB = await dashboard(""); + const scopedB = await dashboard("customer_id=1"); + expect(allB.security_banner.map((b) => b.facility_name).sort()).toEqual( + [inside.facility_name, outside.facility_name].sort() + ); + expect(scopedB.security_banner.map((b) => b.facility_name)).toEqual([inside.facility_name]); + }); + + /** + * ★★数値カードと要レスキューの母集団が一致していることの検査★★(指示書AK §7-2) + * + * ★fixtures の屋号4件は施設12件を**過不足なく**分ける★ので、 + * ★4回ぶんの結果を足すと「絞らないとき」と一致するはず★です。 + * + * ★★どちらか片方に `facility_ids` を渡し忘れると、この足し算が合いません★★ — + * 渡し忘れたほうは毎回「全件」を返すので、★4倍に膨らんで検出される★。 + * ★件数の比較だけでは、両方に渡し忘れても(両方とも全件で)気づけない★ので、 + * ★上の「屋号1だけ」のテストと合わせて初めて固定になる★。 + */ + it("★★屋号4件ぶんを足すと絞らないときと一致する(summary と rescue_required の両方)★★", async () => { + const all = await dashboard(""); + const scoped = await Promise.all( + ALL_CUSTOMER_IDS.map((id) => dashboard(`customer_id=${id}`)) + ); + + expect(scoped.reduce((n, b) => n + b.summary.success, 0)).toBe(all.summary.success); + expect(scoped.reduce((n, b) => n + b.summary.failed, 0)).toBe(all.summary.failed); + expect(scoped.reduce((n, b) => n + b.summary.not_executed, 0)).toBe( + all.summary.not_executed + ); + // ★結果待ち(D-173)も同じ足し算が合うこと★ + expect(scoped.reduce((n, b) => n + b.summary.pending, 0)).toBe(all.summary.pending); + // ★スケジュール進捗(2026-10-29 案B)も同じ足し算が合うこと★ + expect(scoped.reduce((n, b) => n + b.schedule_progress.planned, 0)).toBe(all.schedule_progress.planned); + expect(scoped.reduce((n, b) => n + b.schedule_progress.completed, 0)).toBe( + all.schedule_progress.completed + ); + + const rescueIds = scoped + .flatMap((b) => b.rescue_required.map((r) => r.facility_id)) + .sort(byNumber); + expect(rescueIds).toEqual(all.rescue_required.map((r) => r.facility_id).sort(byNumber)); + expect(rescueIds.length).toBeGreaterThan(0); + }); + + /** + * ★★積集合が空なら「0件の集計」★★(★落とし穴2★)— + * ★ここで全件が返ったら `[]` が `null`(絞らない)に化けている★。 + */ + it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => { + const body = await dashboard("group_id=1&customer_id=3"); + expect(summaryTotal(body)).toBe(0); + expect(body.summary.success_rate).toBeNull(); + expect(body.schedule_progress.planned).toBe(0); + expect(body.security_banner).toEqual([]); + expect(body.rescue_required).toEqual([]); + }); + + /** ★検証は共通の1本(`parseMasterIdFilters()`)に任せている★ — `0` はセンチネルで422(D-011) */ + it("★`group_id=0` は 422★(他の10エンドポイントと同じ規則)", async () => { + const res = await GET_DASHBOARD( + new Request("http://localhost/api/dashboard?group_id=0") + ); + expect(res.status).toBe(422); + }); +}); + +// ---------------------------------------------------------------- GET /reports/* + +/** fixtures のレポートは「今週 / 先週 / 今月 / 先月」の4期間を持つ。★今月を使う★ */ +const CURRENT_MONTH = (() => { + const today = todayJst(); + return `period=monthly&year=${Number(today.slice(0, 4))}&month=${Number( + today.slice(5, 7) + )}&limit=200`; +})(); + +async function performance(search: string): Promise { + const body = await jsonOf>( + await GET_PERFORMANCE( + new Request(`http://localhost/api/reports/facility-performance?${CURRENT_MONTH}&${search}`) + ) + ); + return body.items.map((i) => i.facility_id); +} + +async function continuous(search: string): Promise { + const body = await jsonOf>( + await GET_CONTINUOUS( + new Request(`http://localhost/api/reports/continuous-problems?${CURRENT_MONTH}&${search}`) + ) + ); + return body.items.map((i) => i.facility_id); +} + +async function breakdown(search: string): Promise { + const body = await jsonOf<{ items: ErrorBreakdownItem[] }>( + await GET_BREAKDOWN( + new Request(`http://localhost/api/reports/error-breakdown?${CURRENT_MONTH}&${search}`) + ) + ); + return body.items; +} + +for (const endpoint of [ + { name: "GET /reports/facility-performance", facilityIds: performance }, + { name: "GET /reports/continuous-problems", facilityIds: continuous }, +]) { + describe(`${endpoint.name} の分類絞り込み`, () => { + it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => { + expect((await endpoint.facilityIds("")).length).toBeGreaterThan(0); + }); + + it("`customer_id=1` → 屋号1の施設の行だけになる", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("customer_id=1"); + const allowed = allowedFacilityIds(null, 1); + + // ★絞る対象が実在すること★(無いと、何も実装していなくても緑になる) + expect(all.some((id) => !allowed.has(id))).toBe(true); + expect([...got].sort(byNumber)).toEqual( + all.filter((id) => allowed.has(id)).sort(byNumber) + ); + expect(got.length).toBeGreaterThan(0); + }); + + it("`group_id=2` → ホールディングス2の施設の行だけになる", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("group_id=2"); + const allowed = allowedFacilityIds(2, null); + + expect(all.some((id) => !allowed.has(id))).toBe(true); + expect([...got].sort(byNumber)).toEqual( + all.filter((id) => allowed.has(id)).sort(byNumber) + ); + }); + + it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => { + expect(await endpoint.facilityIds("")).not.toEqual([]); + expect(await endpoint.facilityIds("group_id=1&customer_id=3")).toEqual([]); + }); + }); +} + +/** + * ★施設名 × 分類の積集合(`/reports/facility-performance`)★ + * — ★施設名の条件を持つレポートAPIはここだけ★(`d-watch-api.yaml`)。 + * ★どちらか片方を捨てると、この2本のどちらかが落ちる★(指示書AK §6-2)。 + */ +describe("★施設名 × 分類 の積集合(`/reports/facility-performance`)★", () => { + it("施設名と屋号の両方に当たる施設だけが残る", async () => { + const byName = await performance("facility_name=バリューマート"); + const both = await performance("facility_name=バリューマート&customer_id=2"); + const allowed = allowedFacilityIds(null, 2); + + expect(byName.length).toBeGreaterThan(0); + expect(both.length).toBeGreaterThan(0); + // ★施設名だけの結果より狭いこと★(=分類の条件が捨てられていない) + expect(both.length).toBeLessThan(byName.length); + expect([...both].sort(byNumber)).toEqual( + byName.filter((id) => allowed.has(id)).sort(byNumber) + ); + }); + + it("★積集合が空なら0件(★片方だけ効かせないこと★)★", async () => { + const byName = await performance("facility_name=フレッシュタウン"); + const both = await performance("facility_name=フレッシュタウン&group_id=1"); + + expect(byName.length).toBeGreaterThan(0); + expect(both).toEqual([]); + }); +}); + +/** + * ★★`/reports/error-breakdown` は fixtures では「0件ヒット」しか確かめられない★★ + * + * fixtures のエラー種別内訳は ★施設をまたいで足し上げたあとの集計値★で、 + * ★どの施設のエラーかという情報を持っていません★(`lib/fixtures/reports.ts` の `errorCounts`)。 + * そのため ★`facility_ids` が1件以上のときは全社ぶんが返ります★ + * (既知の制約。`fixtureSource.errorBreakdown()` の docstring が正)。 + * + * ★★施設で本当に絞れているかは Prisma 版で固定してある★★ — + * `app/api/reports/__tests__/master-filter-prisma.test.ts` + * (`facility_error_summaries` を `facility_id` で絞る=D-133 申し送り③の解消)。 + */ +describe("GET /reports/error-breakdown の分類絞り込み(fixtures)", () => { + it("分類を指定しなければ全件", async () => { + expect((await breakdown("")).length).toBeGreaterThan(0); + }); + + it("★★`group_id=1&customer_id=3`(AND で該当施設なし)は0件★★(全件に化けないこと)", async () => { + expect(await breakdown("group_id=1&customer_id=3")).toEqual([]); + }); + + it("★既知の制約: 該当施設があるときは全社ぶんが返る(fixtures にエラーの施設帰属が無い)★", async () => { + expect(await breakdown("customer_id=1")).toEqual(await breakdown("")); + }); +}); diff --git a/app/api/__tests__/master-filter.test.ts b/app/api/__tests__/master-filter.test.ts new file mode 100644 index 0000000..dbb5970 --- /dev/null +++ b/app/api/__tests__/master-filter.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "vitest"; + +// ★ルート本体は**静的 import** すること★(`it()` の中で `await import()` すると、 +// 最初の1本だけがモジュール読み込みを抱え込み、フルスイート実行時に +// 既定のタイムアウトを踏んで不定期に落ちる。`app/api/clean-logs/__tests__/route.test.ts` の実測) +import { GET as GET_CLEAN_LOGS } from "@/app/api/clean-logs/route"; +import { GET as GET_CLEAN_LOGS_EXPORT } from "@/app/api/clean-logs/export/route"; +import { GET as GET_DS_ALERTS } from "@/app/api/ds-alerts/route"; +import { GET as GET_DS_DEVICES } from "@/app/api/ds-devices/route"; +import { GET as GET_FACILITIES } from "@/app/api/facilities/route"; +import { GET as GET_PRECHECKS } from "@/app/api/prechecks/route"; +import { GET as GET_PERFORMANCE_EXPORT } from "@/app/api/reports/facility-performance/export/route"; +import { GET as GET_SCHEDULES } from "@/app/api/schedules/route"; +import { facilities } from "@/lib/fixtures"; +import { todayJst } from "@/lib/format/date"; + +/** + * 指示書AK ★段階4a★ — 分類(`group_id` / `customer_id`)の絞り込みが + * ★7エンドポイントすべてで効いていること★ の期待値表 + * (★のちに施設別パフォーマンスの CSV `/reports/facility-performance/export` を足して8本。D-163★)。 + * + * ------------------------------------------------------------------------ + * ★★なぜルートまで通して試すのか★★ + * ------------------------------------------------------------------------ + * `resolveFacilityIdsByMasters()` / `intersectFacilityIds()` の単体テストは、 + * ★ルートが解決結果を**渡し忘れていても緑のまま**★です(指示書AC 期待値表 #6d と同じ理由)。 + * ★★「一覧が全件のまま返る」は例外もテスト失敗も出ない壊れ方★★(★罠1 / 落とし穴1★)なので、 + * ★ルート → データソース → レスポンス★ まで通して固定します。 + * + * ------------------------------------------------------------------------ + * ★★fixtures(`USE_FIXTURES` の既定 = true)で走らせる★★ + * ------------------------------------------------------------------------ + * 段階1 で `lib/fixtures/customers.ts` に ★複数ホールディングス × 複数屋号★ の + * 割り当てが入っている(落とし穴10)ので、★実DBなしで3階層の絞り込みを確かめられます★。 + * + * ホールディングス1(バリューHD) … 屋号1(施設 1/3/5/7/8/9)・屋号2(施設 2/4/6/11) + * ホールディングス2(フレッシュ) … 屋号3(施設 10) + * ホールディングスなし … 屋号4(施設 12) + * + * ★期待値は fixtures から組み立てる★ — 施設の割り当てが変わってもテストが + * 「件数だけ違う」で落ちないようにするため。★代わりに「絞る前後で必ず差が出ること」を + * 明示的に確かめる★(=絞り込みが本当に効いたことの確認。下の `hasOutsider`)。 + */ + +// ---------------------------------------------------------------- 期待値(fixtures 由来) + +/** + * 分類の条件に当たる施設IDの集合。 + * + * ★`resolveFacilityIdsByMasters()` の実装を写したものではない★ — + * こちらは ★fixtures の割り当てをそのまま読む★(実装が壊れたときに + * 一緒に壊れない書き方にする)。 + * + * ★所属は `Facility` 自身が持っている★(指示書AK ★段階5★。段階4a 当時の + * `customerIdByFacilityId`(`lib/fixtures/customers.ts`)は削除済み)。 + */ +function allowedFacilityIds( + groupId: number | null, + customerId: number | null +): Set { + const ids = new Set(); + for (const facility of facilities) { + // ★★AND で畳む★★(`d-watch-api.yaml` の `GroupId` / `CustomerId` の契約) + if (groupId !== null && facility.group_id !== groupId) continue; + if (customerId !== null && facility.customer_id !== customerId) continue; + ids.add(facility.id); + } + return ids; +} + +const byNumber = (a: number, b: number) => a - b; + +// ---------------------------------------------------------------- 各APIの呼び出し + +function url(path: string, search: string): Request { + // ★`per_page` は上限(200)まで寄せる★ + return new Request(`http://localhost${path}?per_page=200&${search}`); +} + +interface RowsBody { + items: { facility_id: number }[]; + summary?: unknown; +} +async function jsonOf(res: Response): Promise { + expect(res.status).toBe(200); + return (await res.json()) as T; +} + +/** + * ★★ページを最後まで手繰って全行を集める★★ + * + * ★1ページ目だけを見ないこと★ — `/clean-logs` は fixtures でも1ページ(上限200件)に + * 収まらず、★「絞り込み後のほうが件数が多い」★という比較にならない結果になる + * (絞り込みの有無で母数が変わるので、★途中で切れた一覧どうしは比べられない★)。 + */ +async function collect( + handler: (request: Request) => Promise, + path: string, + search: string, + idOf: (item: T) => number +): Promise { + const ids: number[] = []; + for (let page = 1; ; page += 1) { + const body = await jsonOf<{ items: T[]; meta: { total: number } }>( + await handler(url(path, `page=${page}&${search}`)) + ); + ids.push(...body.items.map(idOf)); + if (body.items.length === 0 || ids.length >= body.meta.total) return ids; + } +} + +/** CSV の1列目(施設名)を施設IDに戻す(★CSV にも同じ絞り込みが効いているか★) */ +const facilityIdByName = new Map(facilities.map((f) => [f.facility_name, f.id])); + +function csvFacilityIds(csv: string): number[] { + const lines = csv.replace(/^/, "").trim().split("\r\n"); + expect(lines[0].startsWith("施設名")).toBe(true); + return lines.slice(1).map((line) => { + const name = line.split(",")[0]; + const id = facilityIdByName.get(name); + if (id === undefined) throw new Error(`CSVの施設名が施設マスタに無い: ${name}`); + return id; + }); +} + +/** + * ★施設別パフォーマンスの CSV(D-163)は期間が必須★ — fixtures のレポートが持つ「今月」を使う + * (一覧のほうは `app/api/__tests__/master-filter-dashboard-reports.test.ts` の `CURRENT_MONTH` と同じ)。 + */ +const CURRENT_MONTH = (() => { + const today = todayJst(); + return `period=monthly&year=${Number(today.slice(0, 4))}&month=${Number(today.slice(5, 7))}`; +})(); + +interface Endpoint { + name: string; + /** その検索条件で返ってきた行の施設ID(★重複あり★) */ + facilityIds(search: string): Promise; +} + +const endpoints: Endpoint[] = [ + { + name: "GET /clean-logs", + facilityIds: (search) => + collect<{ facility_id: number }>( + GET_CLEAN_LOGS, + "/api/clean-logs", + search, + (i) => i.facility_id + ), + }, + { + name: "GET /clean-logs/export", + async facilityIds(search) { + const res = await GET_CLEAN_LOGS_EXPORT(url("/api/clean-logs/export", search)); + expect(res.status).toBe(200); + return csvFacilityIds(await res.text()); + }, + }, + { + // ★★分類の絞り込みが CSV にも効くこと★★(D-163)— 一覧(`/reports/facility-performance`)と + // 同じ読み込み関数を通るが、★ルートが条件を渡し忘れても単体テストは緑のまま★なので、ここでも固定する。 + name: "GET /reports/facility-performance/export", + async facilityIds(search) { + const res = await GET_PERFORMANCE_EXPORT( + url("/api/reports/facility-performance/export", `${CURRENT_MONTH}&${search}`) + ); + expect(res.status).toBe(200); + return csvFacilityIds(await res.text()); + }, + }, + { + name: "GET /prechecks", + // ★既定は `status=open`★ なので、母集団を全件にして比べる + facilityIds: (search) => + collect<{ facility_id: number }>( + GET_PRECHECKS, + "/api/prechecks", + `status=all&${search}`, + (i) => i.facility_id + ), + }, + { + name: "GET /ds-devices", + facilityIds: (search) => + collect<{ facility_id: number }>( + GET_DS_DEVICES, + "/api/ds-devices", + search, + (i) => i.facility_id + ), + }, + { + name: "GET /ds-alerts", + facilityIds: (search) => + collect<{ facility_id: number }>( + GET_DS_ALERTS, + "/api/ds-alerts", + `status=all&${search}`, + (i) => i.facility_id + ), + }, + { + name: "GET /facilities", + // ★★見る列が違う★★ — 施設の行そのものなので `facility_id` ではなく `id` + facilityIds: (search) => + collect<{ id: number }>(GET_FACILITIES, "/api/facilities", search, (i) => i.id), + }, + { + name: "GET /schedules", + facilityIds: (search) => + collect<{ facility_id: number }>( + GET_SCHEDULES, + "/api/schedules", + search, + (i) => i.facility_id + ), + }, +]; + +// ---------------------------------------------------------------- 期待値表 + +for (const endpoint of endpoints) { + describe(`${endpoint.name} の分類絞り込み`, () => { + it("分類を指定しなければ全件(★`null` は「絞らない」★)", async () => { + const all = await endpoint.facilityIds(""); + expect(all.length).toBeGreaterThan(0); + }); + + /** + * ★屋号(`customer_id`)で絞れること★ + * + * ★★`hasOutsider` を必ず確かめる★★ — 絞り込みの対象になる行が + * もともと1件も無いと、★何も実装していなくてもこのテストは緑になります★。 + */ + it("`customer_id=1` → 屋号1の施設の行だけになる", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("customer_id=1"); + const allowed = allowedFacilityIds(null, 1); + + const hasOutsider = all.some((id) => !allowed.has(id)); + expect(hasOutsider).toBe(true); // 絞る対象が実在すること + expect([...got].sort(byNumber)).toEqual( + all.filter((id) => allowed.has(id)).sort(byNumber) + ); + expect(got.length).toBeGreaterThan(0); + }); + + /** + * ★ホールディングス(`group_id`)で絞れること★ + * + * ★0件になる一覧がある★(fixtures の `prechecks` は施設10 の行を持たない)ので + * ★件数ではなく「全件から絞った結果と一致すること」で固定する★。 + */ + it("`group_id=2` → ホールディングス2の施設の行だけになる", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("group_id=2"); + const allowed = allowedFacilityIds(2, null); + + expect(all.some((id) => !allowed.has(id))).toBe(true); + expect([...got].sort(byNumber)).toEqual( + all.filter((id) => allowed.has(id)).sort(byNumber) + ); + }); + + /** + * ★★同時指定は AND。積集合が空なら**0件**★★(★落とし穴2★) + * + * 屋号3 はホールディングス2 の傘下なので、★ホールディングス1 と同時に指定すると + * 当たる施設は1件も無い★。★ここで全件が返ったら、`[]` が `null`(絞らない)に + * 化けている★ — ★例外もテスト失敗も出ない壊れ方なので、必ず固定する★。 + */ + it("★`group_id=1&customer_id=3` は AND で0件(★全件に化けないこと★)★", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("group_id=1&customer_id=3"); + + expect(all.length).toBeGreaterThan(0); + expect(got).toEqual([]); + }); + + /** ★ホールディングスに属さない屋号(fixtures の屋号4 = 施設12)でも絞れる★ */ + it("`customer_id=4`(ホールディングスなし)でも絞れる", async () => { + const all = await endpoint.facilityIds(""); + const got = await endpoint.facilityIds("customer_id=4"); + const allowed = allowedFacilityIds(null, 4); + + expect([...got].sort(byNumber)).toEqual( + all.filter((id) => allowed.has(id)).sort(byNumber) + ); + }); + }); +} + +// ---------------------------------------------------------------- 施設名との併用(積集合) + +/** + * ★★施設名の絞り込みと同時に指定されたら積集合(AND)★★(指示書AK §6-2)。 + * ★どちらか片方を捨てると、この2本のうちどちらかが落ちる★。 + */ +describe("★施設名 × 分類 の積集合(`/clean-logs`)★", () => { + const ids = (search: string) => + collect<{ facility_id: number }>( + GET_CLEAN_LOGS, + "/api/clean-logs", + search, + (i) => i.facility_id + ); + + it("施設名と屋号の両方に当たる施設だけが残る", async () => { + const byName = await ids("facility_name=バリューマート"); + const both = await ids("facility_name=バリューマート&customer_id=2"); + const allowed = allowedFacilityIds(null, 2); + + expect(byName.length).toBeGreaterThan(0); + expect(both.length).toBeGreaterThan(0); + // ★施設名だけの結果より狭いこと★(=分類の条件が捨てられていない) + expect(both.length).toBeLessThan(byName.length); + expect([...both].sort(byNumber)).toEqual( + byName.filter((id) => allowed.has(id)).sort(byNumber) + ); + }); + + it("★積集合が空なら0件(★片方だけ効かせないこと★)★", async () => { + // 施設名「フレッシュタウン」に当たるのは施設10(ホールディングス2)と施設12(所属なし)。 + // ★ホールディングス1 と同時指定すれば積集合は空★。 + const byName = await ids("facility_name=フレッシュタウン"); + const both = await ids("facility_name=フレッシュタウン&group_id=1"); + + expect(byName.length).toBeGreaterThan(0); + expect(both).toEqual([]); + }); +}); + +// ---------------------------------------------------------------- サマリの母集団 + +/** + * ★★サマリ(数値カード)にも分類の絞り込みが効くこと★★ + * + * ★q / status / type では絞らない★(D-054)が、★分類は「母集団」を選ぶ条件★なので + * ★効かせるのが正しい★(`lib/ds-devices/query.ts` の `summarizeDsDevices()` の + * 「フリートの部分集合(日付・グループ・会社)で絞る」/ 指示書AK §7-2)。 + * + * ★★一覧とサマリで母集団がずれていないこと★★ を、同じ条件の件数どうしで固定する。 + */ +describe("★サマリの母集団も分類で絞られる★", () => { + it("GET /ds-devices の `summary.total`", async () => { + interface Body extends RowsBody { + summary: { total: number }; + } + const all = await jsonOf( + await GET_DS_DEVICES(url("/api/ds-devices", "status=all")) + ); + const scoped = await jsonOf( + await GET_DS_DEVICES(url("/api/ds-devices", "status=all&customer_id=1")) + ); + const allowed = allowedFacilityIds(null, 1); + const expected = all.items.filter((i) => allowed.has(i.facility_id)).length; + + expect(all.summary.total).toBeGreaterThan(expected); + expect(scoped.summary.total).toBe(expected); + expect(scoped.items).toHaveLength(expected); + }); + + it("GET /ds-alerts の `summary`(unresolved+in_progress+resolved。指示書AX D10)", async () => { + interface Body extends RowsBody { + summary: { unresolved: number; in_progress: number; resolved: number }; + } + const sum = (s: Body["summary"]) => s.unresolved + s.in_progress + s.resolved; + const all = await jsonOf( + await GET_DS_ALERTS(url("/api/ds-alerts", "status=all")) + ); + const scoped = await jsonOf( + await GET_DS_ALERTS(url("/api/ds-alerts", "status=all&customer_id=1")) + ); + const allowed = allowedFacilityIds(null, 1); + const expected = all.items.filter((i) => allowed.has(i.facility_id)).length; + + expect(sum(all.summary)).toBeGreaterThan(expected); + expect(sum(scoped.summary)).toBe(expected); + }); + + it("GET /prechecks の `summary.total`", async () => { + interface Body extends RowsBody { + summary: { total: number }; + } + const all = await jsonOf( + await GET_PRECHECKS(url("/api/prechecks", "status=all")) + ); + const scoped = await jsonOf( + await GET_PRECHECKS(url("/api/prechecks", "status=all&customer_id=1")) + ); + const allowed = allowedFacilityIds(null, 1); + const expected = all.items.filter((i) => allowed.has(i.facility_id)).length; + + expect(all.summary.total).toBeGreaterThan(expected); + expect(scoped.summary.total).toBe(expected); + }); +}); diff --git a/app/api/auth/login/__tests__/route.test.ts b/app/api/auth/login/__tests__/route.test.ts new file mode 100644 index 0000000..33e50a9 --- /dev/null +++ b/app/api/auth/login/__tests__/route.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` の冒頭コメントと同じ理由) +import { POST } from "@/app/api/auth/login/route"; + +/** + * `POST /auth/login` — 本文がオブジェクトでない(指示書BB 表8 J1〜J3。A5・D19・D-182)。 + * + * | # | 本文 | 期待 | + * |---|---|---| + * | J1 | `null` | 422・validation_failed・「JSONの形式が不正です」(旧: TypeError で標準でない500) | + * | J2 | `[]` / `1` / `"x"` | 422(J1 と同じ。★数値・文字列のときの文言が「tokenを指定してください」から変わる — 承知のうえ★) | + * | J3 | `{}` | 今どおり(422「tokenを指定してください」) | + * + * ★トークンの照合まで行かない入力だけを使う★(認証情報をテストに書かない) + */ + +function postRaw(raw: string): Promise { + return POST(new Request("http://localhost/api/auth/login", { method: "POST", body: raw })); +} + +describe("POST /auth/login — 本文の形", () => { + it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」★", async (raw) => { + const res = await postRaw(raw); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "JSONの形式が不正です", details: null }, + }); + }); + + it("J3: 本文 {} → 今どおり(422「tokenを指定してください」)", async () => { + const res = await postRaw("{}"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { message: string } }).error.message).toBe("tokenを指定してください"); + }); + + it("JSON として読めない本文は今どおり 422「JSONの形式が不正です」", async () => { + const res = await postRaw("{"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { message: string } }).error.message).toBe("JSONの形式が不正です"); + }); +}); diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..3288a52 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,76 @@ +/** + * POST /api/auth/login — 共有トークンを検証して `dwatch_session` cookie を発行する。 + * + * ★このルートは認証をかけない★(`lib/proxy/authenticate.ts` の `isPublicPath()` で通す)。 + * 保護すると誰もログインできない。IP制限は proxy が別途かけるので、 + * 許可されないIPからはここに到達する前に 403 になる。 + * + * ★入力値(トークン)をログにも本文にも絶対に出さないこと★ + * ★失敗しても「トークンが違う」以上の情報を返さないこと★ + * (「そのトークンは存在するが期限切れ」等を返すと総当たりの手がかりになる) + */ +import { NextResponse } from "next/server"; +import { isJsonObject } from "@/lib/api/json-body"; +import { + SESSION_COOKIE_NAME, + SESSION_COOKIE_OPTIONS, +} from "@/lib/auth/cookie"; +import { readAuthToken, verifyToken } from "@/lib/auth/token"; +import { logEvent, withRequestId } from "@/lib/logger"; +import type { ApiError, ErrorCode } from "@/lib/types"; + +const ROUTE = "/api/auth/login"; + +function errorResponse(status: number, code: ErrorCode, message: string) { + const body: ApiError = { error: { code, message, details: null } }; + return NextResponse.json(body, { status }); +} + +export async function POST(request: Request) { + return withRequestId(request, async () => { + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言) + if (!isJsonObject(body)) { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + const token = body.token; + if (typeof token !== "string" || token.length === 0) { + return errorResponse(422, "validation_failed", "tokenを指定してください"); + } + + // ★定数時間比較。ここで === を書かないこと★ + if (!verifyToken(token, readAuthToken())) { + // ★入力されたトークンは載せない(成否だけ)★ + // ★client_ip も載せない★ — ここに来た時点でIP判定は通過済みで、 + // 接続元は定義上 ALLOWED_IPS に載っている既知の値(D-101) + logEvent("warn", { + event: "auth.login_failed", + route: ROUTE, + method: "POST", + }); + return errorResponse(401, "invalid_token", "トークンが違います"); + } + + logEvent("info", { + event: "auth.login_success", + route: ROUTE, + method: "POST", + }); + + // 本文なしの 204。cookie は Set-Cookie で返す + const response = new NextResponse(null, { status: 204 }); + // ★expires / maxAge は付けない(CLAUDE.md §7 でトークン期限は禁止)★ + response.cookies.set({ + name: SESSION_COOKIE_NAME, + value: token, + ...SESSION_COOKIE_OPTIONS, + }); + return response; + }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..3c0f8fc --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,30 @@ +/** + * POST /api/auth/logout — `dwatch_session` cookie を削除する。 + * + * ★このルートは認証が必要★(`isPublicPath()` に入れていない)。 + * cookie を消すだけなので公開しても実害は薄いが、公開にする理由も無い。 + * 未認証で叩けば proxy が 401 を返し、その時点で cookie は無効なので目的は達している。 + */ +import { NextResponse } from "next/server"; +import { + SESSION_COOKIE_NAME, + SESSION_COOKIE_OPTIONS, +} from "@/lib/auth/cookie"; +import { withRequestId } from "@/lib/logger"; + +export async function POST(request: Request) { + return withRequestId(request, async () => { + const response = new NextResponse(null, { status: 204 }); + // 発行時と同じ name / path / secure で消す(属性が違うと消えずに残る)。 + // ★`delete()` は内部で Max-Age=0 を出すが、これは「削除」であって + // 「トークンに期限を付ける」ことではない(CLAUDE.md §7 の禁止事項に当たらない)★ + response.cookies.delete({ + name: SESSION_COOKIE_NAME, + path: SESSION_COOKIE_OPTIONS.path, + secure: SESSION_COOKIE_OPTIONS.secure, + httpOnly: SESSION_COOKIE_OPTIONS.httpOnly, + sameSite: SESSION_COOKIE_OPTIONS.sameSite, + }); + return response; + }); +} diff --git a/app/api/clean-area/__tests__/route.fixtures.test.ts b/app/api/clean-area/__tests__/route.fixtures.test.ts new file mode 100644 index 0000000..1b12c9f --- /dev/null +++ b/app/api/clean-area/__tests__/route.fixtures.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +// ★ルート本体は**静的 import** すること★(`app/api/reports/__tests__/route.test.ts` の実測) +import { GET } from "@/app/api/clean-area/route"; +import { facilities } from "@/lib/fixtures"; +import { includesFoldName } from "@/lib/format/search-text"; +import type { CleanAreaResponse } from "@/lib/types"; + +/** + * `GET /clean-area` を ★fixtures(`USE_FIXTURES` の既定 = true)★ で通すテスト。 + * + * ------------------------------------------------------------------------ + * ★★なぜ `route.test.ts` と分けてあるのか★★ + * ------------------------------------------------------------------------ + * ルートは ★施設名の解決と分類の解決を `Promise.all` で並列に★ 走らせる + * (`resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()`。指示書AK §6-2)。 + * + * ★★`route.test.ts` の `vi.mock("@/lib/db/prisma")` ではこの経路を試せません★★ — + * `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読むため、 + * ★同じ瞬間に2本の動的 import が走ると、片方が差し替えをすり抜けて本物を掴み、 + * 「DATABASE_URL が無い」で 500 になります★(実測。テスト環境側の制約であって + * ルートの不具合ではない)。 + * ★fixtures 経路は動的 import を通らない★ので、★★積集合(AND)はこちらで固定します★★。 + * + * ------------------------------------------------------------------------ + * ★★期待値は fixtures から組み立てる★★(`app/api/__tests__/master-filter.test.ts` と同じ作法) + * ------------------------------------------------------------------------ + * 施設の割り当てが変わってもテストが「件数だけ違う」で落ちないようにするため。 + * ★代わりに「絞る前後で必ず差が出ること」を明示的に確かめる★ + * (=絞り込みが本当に効いたことの確認)。 + */ + +async function body(qs = ""): Promise { + const res = await GET(new Request(`http://localhost/api/clean-area${qs}`)); + expect(res.status).toBe(200); + return (await res.json()) as CleanAreaResponse; +} + +/** 面積を持つ施設だけ(fixtures の面積行が無い施設は内訳に現れない) */ +async function facilityIdsInResponse(qs = ""): Promise { + const res = await body(qs); + return res.items.map((row) => row.id as number).sort((a, b) => a - b); +} + +describe("GET /clean-area(fixtures)", () => { + it("#F1 ★粒度を変えても全体合計が変わらない★(§9-3 の 9-8 / B-5)", async () => { + const byFacility = await body("?group_by=facility"); + const byCustomer = await body("?group_by=customer"); + const byGroup = await body("?group_by=group"); + + expect(byFacility.total.clean_area).toBeGreaterThan(0); + expect(byCustomer.total).toEqual(byFacility.total); + expect(byGroup.total).toEqual(byFacility.total); + + // ★内訳の和 = 全体合計★(粒度ごとに確かめる。落とし穴4 / 落とし穴5) + for (const res of [byFacility, byCustomer, byGroup]) { + const sum = res.items.reduce((acc, row) => acc + row.clean_area, 0); + expect(sum).toBeCloseTo(res.total.clean_area, 6); + } + // 粒度を上げるほど行数は減る(同数のままなら畳めていない) + expect(byGroup.items.length).toBeLessThan(byFacility.items.length); + }); + + it("#F2 ★分類で絞ると内訳がその傘下だけになる★", async () => { + const all = await facilityIdsInResponse(); + const groupId = facilities[0].group_id; + expect(groupId).not.toBeNull(); + + const filtered = await facilityIdsInResponse(`?group_id=${groupId}`); + const allowed = new Set( + facilities.filter((f) => f.group_id === groupId).map((f) => f.id) + ); + expect(filtered.every((id) => allowed.has(id))).toBe(true); + // ★絞り込みが本当に効いたことの確認★ — 絞る前には傘下でない施設が居た + expect(all.some((id) => !allowed.has(id))).toBe(true); + expect(filtered.length).toBeLessThan(all.length); + }); + + it("#F3 ★★施設名 × 分類は AND(積集合)★★(指示書AK §6-2 / 落とし穴3)", async () => { + // ★`Promise.all` で並列解決される2つの条件★が両方効いていることを見る。 + // ★キーワードは「2つ以上の屋号にまたがる」ものを選ぶこと★ — + // 1屋号にしか当たらない語だと、AND を無視しても同じ結果になり ★テストが無意味になる★。 + const customerId = facilities[0].customer_id; + const keyword = "バリューマート"; + const byName = facilities.filter((f) => includesFoldName(f.facility_name, keyword)); + // ★前提そのものを固定する★(fixtures が変わったらここで落ちる) + expect(new Set(byName.map((f) => f.customer_id)).size).toBeGreaterThan(1); + + const expected = byName.filter((f) => f.customer_id === customerId).map((f) => f.id); + expect(expected.length).toBeGreaterThan(0); + + const got = await facilityIdsInResponse( + `?customer_id=${customerId}&facility_name=${encodeURIComponent(keyword)}` + ); + // ★面積行を持たない施設は内訳に出ない★ので「期待の部分集合であること」を見る + expect(got.every((id) => expected.includes(id))).toBe(true); + + // ★どちらか片方を捨てていないこと★ — + // 施設名だけで引くと、別の屋号の施設まで入ってくる(= AND が効いていれば入らない) + const byNameOnly = await facilityIdsInResponse( + `?facility_name=${encodeURIComponent(keyword)}` + ); + expect(byNameOnly.length).toBeGreaterThanOrEqual(got.length); + expect( + byNameOnly.every((id) => facilities.find((f) => f.id === id)?.customer_id === customerId) + ).toBe(false); + }); + + it("#F4 ★実在しない group_id は0件(全件に化けない)★(§9-2 の 9-4)", async () => { + const res = await body("?group_id=9999"); + expect(res.items).toEqual([]); + expect(res.total).toEqual({ + clean_area: 0, + task_area: 0, + // ★分母0は `null`★(0% ではない) + completion_rate: null, + executed_count: 0, + }); + expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 }); + }); + + it("#F5 期間で絞ると合計が減る(§9-2 の 9-3)", async () => { + const all = await body(); + // fixtures の1日だけを切り出す(★どの日でもよいので全期間より小さくなればよい★) + const narrowed = await body("?date_from=2026-09-10&date_to=2026-09-10"); + expect(narrowed.total.clean_area).toBeLessThan(all.total.clean_area); + expect(narrowed.total.executed_count).toBeLessThan(all.total.executed_count); + }); +}); diff --git a/app/api/clean-area/__tests__/route.test.ts b/app/api/clean-area/__tests__/route.test.ts new file mode 100644 index 0000000..a7c22f0 --- /dev/null +++ b/app/api/clean-area/__tests__/route.test.ts @@ -0,0 +1,637 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は★★静的 import★★すること★ — `it()` の中で `await import()` すると、 +// 最初の1本がモジュール読み込みを抱え込み、フルスイート実行時にタイムアウトしうる +// (`app/api/reports/__tests__/route.test.ts` の実測)。 +import { GET } from "@/app/api/clean-area/route"; +import { CLEAN_AREA_GROUP_NONE_LABEL } from "@/lib/clean-area/aggregate"; +import { CLEAN_AREA_ANOMALY_METRICS } from "@/lib/clean-area/anomaly"; +import { AREA_UNIT, CLEAN_TIMETABLE_PRECHECK } from "@/lib/config"; +import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { logEvent } from "@/lib/logger"; +import type { ApiError, CleanAreaResponse } from "@/lib/types"; + +/** + * `GET /clean-area` の期待値表(指示書AN ★段階4★ / §9-2 の確認項目をテストに落としたもの)。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★★粒度を変えても全体合計が変わらないこと★★(§9-2 の 9-2 / §5-3 B-5 / 落とし穴4)— + * ★変わったらロールアップが壊れている★。例外もテスト失敗も出ない類の壊れ方 + * - ★`group_id=9999` が0件になること★(§9-2 の 9-4 / ★落とし穴3★)— + * ★`facility_ids` を `?.length` で分岐すると「0件ヒット」が「全件」に化ける★ + * - ★★番人を呼んでいること★★(§2-5 / ★落とし穴2★)— ルートが + * `reportCleanAreaAnomalies()` を呼び忘れても ★例外もテスト失敗も出ない★ ので、 + * ★ここで固定しておかないと静かに消える★ + * - ★完了率を丸めないこと★(§9-2 の 9-5 / `CLAUDE.md` §4.3) + * - ★単位(`AREA_UNIT`)をレスポンスに入れないこと★(§9-2 の 9-6 / §2-4) + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、 + * `@/lib/db/use-fixtures` を ★`false`★ に固定して ★`prismaSource` の経路★ を走らせる + * (`app/api/customers/__tests__/route.test.ts` / `app/api/reports/__tests__/route.test.ts` + * と同じ作法)。実DB(テストDB/ローカルDB)との突き合わせは別途実施する(§9)。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubFacility { + id: bigint; + facility_name: string; + customer_id: bigint; + customer: { + customer_name: string; + /** ★NOT NULL で 0 = ホールディングスなしのセンチネル★(D-011) */ + group_id: bigint; + group: { group_name: string } | null; + }; +} + +/** + * `clean_task_reports` の1行(★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★)。 + */ +interface StubReport { + facility_id: bigint; + /** + * ★nullable★(`prisma/schema.prisma` の `CleanTaskReport.clean_timetable_id`)— + * ★TeamWorks がまだ埋めていない★(指示書AQ 決定5 / 罠1)。 + */ + clean_timetable_id: bigint | null; + /** + * ★DB の DATETIME は「日本時間の壁時計値」★なので、スタブも + * ★壁時計値を UTC のフィールドに載せた `Date`★ で持つ(`toDbJst()` の形)。 + * 例: JST 2026-09-05 02:00 の清掃 → `new Date("2026-09-05T02:00:00Z")`。 + */ + started_at: Date; + clean_area: number; + task_area: number; + /** + * ★完了率には使っていない★(D-155。1回ごとの完了率は面積から `runCompletionRate()`)。 + * 列を読んでいないことを確かめるため、面積比と★わざと食い違う値★を入れてある。 + */ + clean_ratio: number; +} + +interface Range { + gte?: Date; + lt?: Date; +} + +interface GroupByArgs { + by: string[]; + where?: { + started_at?: Range; + facility_id?: { in: bigint[] }; + /** + * ★番人だけが付ける★(`SCHEDULED_TIMETABLE_ID_WHERE`。`> 0`。0・NULL は予定外。 + * TeamWorks 回答 2026-10-27。旧: `{ not: null }`)。★完了率用の取り寄せは付けない★(予定外も取り寄せる。指示書BG §2-5) + */ + clean_timetable_id?: { gt: bigint }; + /** ★削除済みの施設・清掃エリアの行を外す★(指示書BB U1・表1 V17。スタブは評価しない — 下のテストが `where` を見る) */ + facility?: { deleted_at: null }; + section?: { deleted_at: null }; + }; + having?: { clean_timetable_id?: { _count?: { gt?: number } } }; +} + +interface FindManyArgs { + where?: GroupByArgs["where"]; + select?: Record; +} + +interface TimetableFindManyArgs { + where: { id: { in: bigint[] }; precheck: number }; + select: { id: true }; +} + +let data: { + facilities: StubFacility[]; + reports: StubReport[]; + /** + * ★本清掃でない(事前チェックの)予定の id★。★ここに無い id は本清掃(`precheck = 0`)として扱う★ + * (予定そのものが無い dangling は `danglingTimetableIds` で表す) + */ + precheckTimetableIds: Set; + /** ★予定の行が存在しない id★(dangling) */ + danglingTimetableIds: Set; + /** + * ★`clean_timetable_histories` にだけある本清掃の予定の id★(指示書AU 段階3b)。 + * ★本表(`clean_timetables`)はこの id を返さない★。無ければ空 + */ + historyOnlyTimetableIds?: Set; +}; +/** ★DBに何を投げたかを見る★(0件が全件に化けていないことは `where` でも確かめる) */ +const groupByCalls: GroupByArgs[] = []; +/** ★完了率用の取り寄せ(`cleanTaskReport.findMany`)と予定の引き直し(`cleanTimetable.findMany`)★ */ +const findManyCalls: FindManyArgs[] = []; +const timetableCalls: TimetableFindManyArgs[] = []; +/** ★`clean_timetable_histories` への引き直し(指示書AU 段階3b)★ */ +const historyTimetableCalls: TimetableFindManyArgs[] = []; + +function matches(row: StubReport, where: GroupByArgs["where"]): boolean { + if (!where) return true; + const { started_at: range, facility_id, clean_timetable_id } = where; + if (range?.gte && row.started_at < range.gte) return false; + if (range?.lt && row.started_at >= range.lt) return false; + // ★`in: []` は0件(`WHERE 1=0`)★。★条件そのものが無いときだけ全件★ + if (facility_id && !facility_id.in.map(Number).includes(Number(row.facility_id))) { + return false; + } + // ★`{ gt: 0 }` は `> 0`★ — ★SQL の比較で NULL は偽★なので NULL も 0 も落ちる + if (clean_timetable_id && (row.clean_timetable_id === null || !(row.clean_timetable_id > clean_timetable_id.gt))) { + return false; + } + return true; +} + +function groupBy(args: GroupByArgs) { + groupByCalls.push(args); + const rows = data.reports.filter((row) => matches(row, args.where)); + + if (args.by[0] === "clean_timetable_id") { + // ★番人のクエリ★: ★`having` で「2行以上」だけを返す★(絞らないと最大26万行返る) + // ★★SQL の `GROUP BY` と同じく NULL 同士は1つのグループにまとめる★★ — + // ★スタブが NULL を捨ててしまうと、実装が `IS NOT NULL` の除外を落としても + // テストが通ってしまう★(指示書AQ 罠1 / §5-3) + const counts = new Map(); + for (const row of rows) { + const key = row.clean_timetable_id === null ? "NULL" : String(row.clean_timetable_id); + const entry = counts.get(key) ?? { id: row.clean_timetable_id, n: 0 }; + entry.n += 1; + counts.set(key, entry); + } + // ★`having` が無ければ全グループを返す★(実DBと同じ。落とすと番人の件数が膨らんで検出できる) + const gt = args.having?.clean_timetable_id?._count?.gt; + return [...counts.values()] + .filter(({ n }) => gt === undefined || n > gt) + .map(({ id, n }) => ({ + clean_timetable_id: id, + _count: { clean_timetable_id: n }, + })); + } + + // ★面積の合計だけ★(指示書AS 段階4。完了率・実施回数は `findMany` の行から数える) + const buckets = new Map(); + for (const row of rows) { + const key = String(row.facility_id); + const bucket = buckets.get(key) ?? { clean: 0, task: 0 }; + bucket.clean += row.clean_area; + bucket.task += row.task_area; + buckets.set(key, bucket); + } + return [...buckets.entries()].map(([id, b]) => ({ + facility_id: BigInt(id), + _sum: { clean_area: b.clean, task_area: b.task }, + })); +} + +/** ★完了率用の取り寄せ★(`where` は集計と同じもの。予定外も取り寄せる。指示書BG §2-5) */ +function findManyReports(args: FindManyArgs) { + findManyCalls.push(args); + return data.reports.filter((row) => matches(row, args.where)); +} + +/** ★本清掃(`precheck = 0`)の予定だけを返す★(事前チェック・dangling・履歴だけの予定は返らない) */ +function findManyTimetables(args: TimetableFindManyArgs) { + const inMain = (id: bigint) => + !data.danglingTimetableIds.has(String(id)) && !(data.historyOnlyTimetableIds?.has(String(id)) ?? false); + // ★影チェック(`precheck` なしで id を引き直す。`lib/db/timetable-source.ts`)★ — 本表にある id を返す。 + // ★記録しない★(`timetableCalls` の `precheck` を見ている #13 を変えないため) + if (args.where.precheck === undefined) return args.where.id.in.filter(inMain).map((id) => ({ id })); + timetableCalls.push(args); + if (args.where.precheck !== 0) return []; + return args.where.id.in + .filter((id) => !data.precheckTimetableIds.has(String(id)) && inMain(id)) + .map((id) => ({ id })); +} + +/** ★`clean_timetable_histories` 側★: 履歴だけにある本清掃の予定を返す(指示書AU 段階3b) */ +function findManyHistoryTimetables(args: TimetableFindManyArgs) { + historyTimetableCalls.push(args); + if (args.where.precheck !== 0) return []; + return args.where.id.in + .filter((id) => data.historyOnlyTimetableIds?.has(String(id)) ?? false) + .map((id) => ({ id })); +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + // ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない)。見たいのは `logEvent` だけ + return { ...actual, logEvent: vi.fn() }; +}); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + // ★施設マスタは `select` で列を絞って読むが、スタブは絞らずに全列返す★ + // (`select` を適用しない=上位集合を返すので、どのローダーからでも使える) + facility: { findMany: async () => data.facilities }, + // ★集計元は `clean_task_reports`★(旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3) + cleanTaskReport: { + groupBy: async (args: GroupByArgs) => groupBy(args), + findMany: async (args: FindManyArgs) => findManyReports(args), + }, + // ★本清掃かどうかの引き直し★(指示書AS 段階4。`include` は使わない) + cleanTimetable: { + findMany: async (args: TimetableFindManyArgs) => findManyTimetables(args), + }, + // ★予定は `clean_timetable_histories` からも読む(指示書AU 段階3b)★ + cleanTimetableHistory: { + findMany: async (args: TimetableFindManyArgs) => findManyHistoryTimetables(args), + }, + }), +})); + +function facility( + id: number, + name: string, + customerId: number, + customerName: string, + groupId: number, + groupName: string | null +): StubFacility { + return { + id: BigInt(id), + facility_name: name, + customer_id: BigInt(customerId), + customer: { + customer_name: customerName, + group_id: BigInt(groupId), + group: groupName === null ? null : { group_name: groupName }, + }, + }; +} + +function report( + facilityId: number, + timetableId: number | null, + day: string, + cleanArea: number, + taskArea: number, + cleanRatio: number +): StubReport { + return { + facility_id: BigInt(facilityId), + clean_timetable_id: timetableId === null ? null : BigInt(timetableId), + started_at: new Date(`${day}T02:00:00Z`), + clean_area: cleanArea, + task_area: taskArea, + clean_ratio: cleanRatio, + }; +} + +/** ★番人のログ(`data.anomaly`)だけ★を取り出す(`judge.clean_area` は毎回1行出る) */ +function anomalyEvents() { + return vi + .mocked(logEvent) + .mock.calls.filter(([, payload]) => payload.event === "data.anomaly"); +} + +async function get(qs = ""): Promise { + return GET(new Request(`http://localhost/api/clean-area${qs}`)); +} + +async function body(qs = ""): Promise { + const res = await get(qs); + expect(res.status).toBe(200); + return (await res.json()) as CleanAreaResponse; +} + +beforeEach(() => { + vi.mocked(logEvent).mockClear(); + groupByCalls.length = 0; + findManyCalls.length = 0; + timetableCalls.length = 0; + historyTimetableCalls.length = 0; + data = { + precheckTimetableIds: new Set(), + danglingTimetableIds: new Set(), + facilities: [ + // ホールディングス1「バリューHD」→ 屋号10「バリューマート」→ 施設1・2 + facility(1, "バリューマート渋谷本店", 10, "バリューマート", 1, "バリューHD"), + facility(2, "バリューマート戸塚店", 10, "バリューマート", 1, "バリューHD"), + // 同じホールディングスの別屋号20「メガバリュー」→ 施設3 + facility(3, "メガバリューマート緑野店", 20, "メガバリュー", 1, "バリューHD"), + // ★ホールディングスなし(group_id=0 のセンチネル)★ → 屋号30 → 施設4 + facility(4, "ひとり商店", 30, "ひとり商店グループ", 0, null), + ], + // ★`clean_ratio` 列は1回ごとの面積比(clean_area÷task_area)と**わざと食い違う値**にしてある★ — + // ★完了率を `clean_ratio` 列で出していたら #1 / #7 が落ちる★(D-155。列は完了率に使わない)。 + // 1回ごとの完了率(`runCompletionRate()`): 90.0 / 80.0(80.05 を切り捨て)/ 70.0 / 50.0 / 10.0 + reports: [ + report(1, 1001, "2026-09-01", 900, 1000, 88.1), + report(1, 1002, "2026-09-10", 800.5, 1000, 70.05), + report(2, 1003, "2026-09-01", 700, 1000, 65), + report(3, 1004, "2026-09-10", 500, 1000, 40), + report(4, 1005, "2026-09-10", 100, 1000, 12.5), + ], + }; +}); + +describe("GET /clean-area", () => { + it("#1 期間未指定なら全期間の合計を返す(施設単位が既定)", async () => { + const res = await body(); + expect(res.total).toEqual({ + clean_area: 3000.5, + task_area: 5000, + // ★1回ごとの完了率の単純平均★(D-155。旧: `clean_ratio` 列の平均 → その前は面積比 3000.5/5000) + completion_rate: (90 + 80 + 70 + 50 + 10) / 5, + executed_count: 5, + }); + // ★既定の粒度は facility★(施設4件ぶんの内訳) + expect(res.items.map((r) => r.scope)).toEqual(["facility", "facility", "facility", "facility"]); + expect(res.meta).toEqual({ total: 4, page: 1, per_page: 4 }); + // ★期間未指定なら `started_at` の条件を付けない★(全期間) + expect(groupByCalls[0].where?.started_at).toBeUndefined(); + }); + + it("#2 ★粒度を変えても全体合計が変わらない★(§9-2 の 9-2 / B-5)", async () => { + // ★3本を `Promise.all` で並べないこと★ — ★このテストの制約★: + // `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読む。 + // ★同じ瞬間に2本以上の動的 import が走ると、`vi.mock` の差し替えを1本がすり抜けて + // 本物のクライアントを掴み、「DATABASE_URL が無い」で 500 になる★(実測)。 + // ★ルート側の `Promise.all`(施設名 × 分類の並列解決)は正しい★ので、 + // ★そちらの経路は fixtures で確かめる★(`route.fixtures.test.ts`)。 + const byFacility = await body("?group_by=facility"); + const byCustomer = await body("?group_by=customer"); + const byGroup = await body("?group_by=group"); + expect(byCustomer.total).toEqual(byFacility.total); + expect(byGroup.total).toEqual(byFacility.total); + // 内訳の行数だけが変わる(施設4 / 屋号3 / ホールディングス2) + expect([byFacility.items.length, byCustomer.items.length, byGroup.items.length]).toEqual([ + 4, 3, 2, + ]); + // ★内訳の和 = 全体合計★(どの粒度でも) + for (const res of [byFacility, byCustomer, byGroup]) { + const sum = res.items.reduce((acc, r) => acc + r.clean_area, 0); + expect(sum).toBeCloseTo(res.total.clean_area, 10); + } + }); + + it("#3 ★ホールディングスなしは「グループなし」1行★(『グループ0』を作らない。§9-3 の 9-9)", async () => { + const res = await body("?group_by=group"); + const none = res.items.find((r) => r.id === null); + expect(none?.name).toBe(CLEAN_AREA_GROUP_NONE_LABEL); + expect(res.items.some((r) => r.id === 0)).toBe(false); + // ★その面積が全体合計から落ちないこと★(B-3) + expect(none?.clean_area).toBe(100); + }); + + it("#4 期間で絞ると合計が減る(§9-2 の 9-3)", async () => { + const res = await body("?date_from=2026-09-01&date_to=2026-09-01"); + expect(res.total.clean_area).toBe(1600); + expect(res.total.executed_count).toBe(2); + // ★`to` はその日を含む★(翌日 00:00 未満) + expect(groupByCalls[0].where?.started_at?.gte).toEqual(new Date("2026-09-01T00:00:00Z")); + expect(groupByCalls[0].where?.started_at?.lt).toEqual(new Date("2026-09-02T00:00:00Z")); + }); + + it("#5 ★group_id=9999 は0件(全件に化けない)★(§9-2 の 9-4 / 落とし穴3)", async () => { + const res = await body("?group_id=9999"); + expect(res.items).toEqual([]); + expect(res.total).toEqual({ + clean_area: 0, + task_area: 0, + // ★分母0は `null`★(0% ではない。A-1 / 落とし穴7) + completion_rate: null, + executed_count: 0, + }); + expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 }); + // ★条件が消えていないこと★ — `in: []` が渡っている(`undefined` なら全件に化けている) + expect(groupByCalls[0].where?.facility_id).toEqual({ in: [] }); + }); + + // ★施設名 × 分類の積集合(AND)は `route.fixtures.test.ts` が見る★ — + // ルートがその2つを `Promise.all` で並列に解決するため、 + // ★このファイルの `vi.mock("@/lib/db/prisma")` では再現できない★(#2 の注記)。 + + it("#7 ★完了率を丸めない★(§9-2 の 9-5 / CLAUDE.md §4.3)", async () => { + const res = await body(); + const f1 = res.items.find((r) => r.id === 1); + // ★期待値の式が変わった★(D-155。1回ごとの完了率 90.0 / 80.0 の単純平均。 + // 旧: `clean_ratio` 列の平均 (88.1 + 70.05) / 2 → その前は面積比 1700.5/2000) + expect(f1?.completion_rate).toBe((90 + 80) / 2); + // ★期間の合計どうしの面積比(1700.5/2000 = 85.025)に戻っていないこと★(D-151。★戻っても例外は出ない★) + expect(f1?.completion_rate).not.toBe((1700.5 / 2000) * 100); + // ★`clean_ratio` 列を読んでいないこと★ + expect(f1?.completion_rate).not.toBe((88.1 + 70.05) / 2); + expect(res.total.completion_rate).toBe((90 + 80 + 70 + 50 + 10) / 5); + }); + + it("#8 ★単位をレスポンスに入れない★(§9-2 の 9-6 / §2-4)", async () => { + const res = await get(); + const text = await res.text(); + expect(text).not.toContain(AREA_UNIT); + // ★異常のIDも返さない★(通知先はログであって画面ではない) + expect(text).not.toContain("unmatched_facility_ids"); + }); + + it("#9 ★★番人を呼ぶ★★(1タスクにレポートが2行ある件数。§2-5 / 落とし穴2)", async () => { + // 同じ `clean_timetable_id` に2行目を足す(★SUM が同じ清掃を2回数える状態★) + data.reports.push(report(1, 1001, "2026-09-01", 900, 1000, 88.1)); + await body(); + expect(vi.mocked(logEvent)).toHaveBeenCalledWith( + "warn", + expect.objectContaining({ + event: "data.anomaly", + route: "/api/clean-area", + method: "GET", + metric: CLEAN_AREA_ANOMALY_METRICS.duplicatedTimetables, + expected: 0, + actual: 1, + }) + ); + }); + + it("#9b ★★`clean_timetable_id` が NULL の行は番人が数えない★★(指示書AQ 罠1 / §5-3)", async () => { + // ★TeamWorks がまだ埋めていない★ NULL の行を2行足す(★SQL の GROUP BY は NULL 同士を + // 1グループにまとめる★ので、除外を落とすと `having > 1` に当たって毎回誤報する) + data.reports.push(report(1, null, "2026-09-11", 10, 20, 50)); + data.reports.push(report(2, null, "2026-09-11", 30, 40, 50)); + const res = await body(); + // ★重複ではないので番人は黙っている★(#11 と同じく、`data.anomaly` は1行も出ない) + expect(anomalyEvents()).toEqual([]); + // ★番人のクエリに `> 0`(NULL・0 を除く)が付いている★ + const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id"); + expect(sentinel?.where?.clean_timetable_id).toEqual({ gt: BigInt(0) }); + // ★★NULL の行も集計の母集団には入る★★(番人の除外を集計側に持ち込まない。 + // `countDuplicatedTimetables()` の docstring) + const stats = groupByCalls.find((c) => c.by[0] === "facility_id"); + expect(stats?.where?.clean_timetable_id).toBeUndefined(); + expect(res.total.clean_area).toBe(3000.5 + 10 + 30); + // ★★実施回数にも予定外(NULL)を入れる★★(指示書BG §2-5。2026-10-29。旧: 入れない(D-155 項3)) + expect(res.total.executed_count).toBe(5 + 2); + }); + + it("#9c ★★`clean_timetable_id` が 0(予定外。TeamWorks 回答 2026-10-27)の行も番人が数えず、面積にも実施回数にも入る★★(指示書BG §2-5)", async () => { + // ★0 の行を2行足す★(★SQL の GROUP BY は 0 同士を1グループにまとめる★ので、除外を落とすと + // `having > 1` に当たって誤報する) + data.reports.push(report(1, 0, "2026-09-11", 10, 20, 50)); + data.reports.push(report(2, 0, "2026-09-11", 30, 40, 50)); + const res = await body(); + expect(anomalyEvents()).toEqual([]); + // ★★面積の合計には入る★★(予定外も実際に清掃した広さ。D-155) + expect(res.total.clean_area).toBe(3000.5 + 10 + 30); + // ★★実施回数・完了率にも入れる★★(NULL と同じ。指示書BG §2-5。旧: 入れない) + expect(res.total.executed_count).toBe(5 + 2); + // ★0 の行も完了率用に取り寄せる★(旧: `> 0` で取り寄せなかった) + expect(matches(report(1, 0, "2026-09-11", 10, 20, 50), findManyCalls[0].where)).toBe(true); + // ★完了率は1回ごとの完了率の単純平均(予定外 10/20 = 50・30/40 = 75 も入る)★ + expect(res.total.completion_rate).toBe((90 + 80 + 70 + 50 + 10 + 50 + 75) / 7); + // ★内訳の行にも予定外が入る★(行と合計は同じ施設ごとの集計から作る。d-watch-api.yaml の CleanAreaRow。2026-11-04) + expect(res.items.reduce((acc, r) => acc + r.executed_count, 0)).toBe(5 + 2); + }); + + it("#10 ★施設マスタに無い facility_id も番人が出す★(黙って消さない。B-4)", async () => { + data.reports.push(report(99, 1099, "2026-09-10", 50, 100, 50)); + const res = await body(); + // ★内訳にも全体合計にも入れない★(入れると「全体合計 ≠ 内訳の和」になる) + expect(res.items.some((r) => r.id === 99)).toBe(false); + expect(res.total.clean_area).toBe(3000.5); + expect(vi.mocked(logEvent)).toHaveBeenCalledWith( + "warn", + expect.objectContaining({ + metric: CLEAN_AREA_ANOMALY_METRICS.unmatchedFacilities, + expected: 0, + actual: 1, + }) + ); + }); + + it("#11 異常が無ければ `data.anomaly` を1行も出さない", async () => { + await body(); + expect(anomalyEvents()).toEqual([]); + }); + + it("#12 ★`judge.clean_area` に取り寄せた行数と所要時間を出す★(D-156 論点2)", async () => { + data.reports.push(report(1, null, "2026-09-11", 10, 20, 50)); + await body(); + const events = vi + .mocked(logEvent) + .mock.calls.filter(([, payload]) => payload.event === "judge.clean_area"); + expect(events).toHaveLength(1); + const [level, payload] = events[0]; + expect(level).toBe("info"); + expect(payload).toEqual( + expect.objectContaining({ + event: "judge.clean_area", + route: "/api/clean-area", + method: "GET", + // ★NULL の行(予定外)も取り寄せて数える★(指示書BG §2-5。旧: 除いて5行) + rate_rows: 6, + total_executed_count: 6, + raw_total_completion_rate: (90 + 80 + 70 + 50 + 10 + 50) / 6, + }) + ); + expect(typeof (payload as { elapsed_ms: unknown }).elapsed_ms).toBe("number"); + }); + + it("#13 ★完了率用の取り寄せは集計と同じ `where`★(予定外も取り寄せる。指示書BG §2-5。旧: `clean_timetable_id: { gt: 0 }` を足していた)", async () => { + await body("?date_from=2026-09-01&date_to=2026-09-01"); + const stats = groupByCalls.find((c) => c.by[0] === "facility_id"); + expect(findManyCalls).toHaveLength(1); + expect(findManyCalls[0].where).toEqual(stats?.where); + // ★予定の引き直しは `precheck = CLEAN_TIMETABLE_PRECHECK.main`★ + expect(timetableCalls.every((c) => c.where.precheck === CLEAN_TIMETABLE_PRECHECK.main)).toBe(true); + }); + + it("#14 ★事前チェック・dangling の予定に紐づく行は実施回数・完了率に入らない(面積には入る)★", async () => { + data.precheckTimetableIds.add("1001"); + data.danglingTimetableIds.add("1002"); + const res = await body(); + const f1 = res.items.find((r) => r.id === 1); + expect(f1?.clean_area).toBe(900 + 800.5); + expect(f1?.executed_count).toBe(0); + expect(f1?.completion_rate).toBeNull(); + expect(res.total.executed_count).toBe(3); + }); + + it("★履歴(clean_timetable_histories)だけにある予定に紐づく行も実施回数・完了率に入る★(指示書AU 段階3b-1 の 12)", async () => { + const baseline = await body(); + data.historyOnlyTimetableIds = new Set(["1001", "1004"]); + // ★旧(本表だけ)なら 1001 / 1004 は「本清掃に紐づかない行」になり、完了率・実施回数から黙って落ちていた★ + expect(await body()).toEqual(baseline); + // ★2表に同じ引数★ + expect(historyTimetableCalls).toEqual(timetableCalls); + }); + + it("#12 group_by が enum 外なら 422(DBを読まない)", async () => { + const res = await get("?group_by=facilities"); + expect(res.status).toBe(422); + const err = (await res.json()) as ApiError; + expect(err.error.code).toBe("validation_failed"); + expect(err.error.details).toEqual({ + group_by: ["group / customer / facility のいずれかを指定してください"], + }); + expect(groupByCalls).toHaveLength(0); + }); + + it("#13 date_from の形式違いは 422", async () => { + const res = await get("?date_from=2026/09/01"); + expect(res.status).toBe(422); + const err = (await res.json()) as ApiError; + expect(err.error.details).toEqual({ + date_from: ["YYYY-MM-DD形式で指定してください"], + }); + }); + + it("#14 date_from > date_to は 422(黙って0件にしない)", async () => { + const res = await get("?date_from=2026-09-10&date_to=2026-09-01"); + expect(res.status).toBe(422); + expect(groupByCalls).toHaveLength(0); + }); + + it("#15 ★日数の上限は無い★(10年ぶんを指定しても200。§2-3)", async () => { + const res = await get("?date_from=2016-01-01&date_to=2026-09-16"); + expect(res.status).toBe(200); + }); + + it("#16 group_id=0 は 422(0 はセンチネルで有効なIDではない。D-011)", async () => { + const res = await get("?group_id=0"); + expect(res.status).toBe(422); + expect(groupByCalls).toHaveLength(0); + }); + + it("#17 DBが落ちたら 500(共通のエラー形式・固定文言)", async () => { + data = { + get facilities(): StubFacility[] { + throw new Error("db down"); + }, + reports: [], + precheckTimetableIds: new Set(), + danglingTimetableIds: new Set(), + }; + const res = await get(); + expect(res.status).toBe(500); + const err = (await res.json()) as ApiError; + expect(err.error.code).toBe("internal_error"); + expect(err.error.message).toBe("データの取得に失敗しました"); + }); +}); + +describe("GET /clean-area — 削除済みの施設・清掃エリア(指示書BB U1・表1 V17)", () => { + it("★集計・完了率の行・番人の3本が同じ `where` を受け取り、可視性が入っている★(施設マスタは絞らない — 罠7)", async () => { + const res = await get(""); + expect(res.status).toBe(200); + const stats = groupByCalls.find((c) => c.by[0] === "facility_id"); + const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id"); + expect(stats?.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + expect(sentinel?.where).toEqual({ ...stats?.where, clean_timetable_id: { gt: BigInt(0) } }); + // ★完了率の行は集計と同じ `where`(予定外も取り寄せる。指示書BG §2-5)★ + expect(findManyCalls[0].where).toEqual(stats?.where); + }); +}); + +describe("GET /clean-area — 存在しない日付は422(指示書BB 表8 Q6)", () => { + it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => { + const bad = await get("?date_from=2026-02-31"); + const format = await get("?date_from=2026/02/03"); + expect(bad.status).toBe(422); + expect(format.status).toBe(422); + expect(await bad.json()).toEqual(await format.json()); + }); +}); diff --git a/app/api/clean-area/route.ts b/app/api/clean-area/route.ts new file mode 100644 index 0000000..ac934d8 --- /dev/null +++ b/app/api/clean-area/route.ts @@ -0,0 +1,128 @@ +/** + * GET /clean-area — 清掃面積の集計(指示書AN ★段階4★ / §7-3)。 + * + * ★仕様の正は `d-watch-api.yaml` の `/clean-area`★。形は + * `app/api/reports/facility-performance/route.ts` に合わせている(★新しい書き方を発明しない★)。 + * + * ------------------------------------------------------------------------ + * ★★この1本の分担★★ + * ------------------------------------------------------------------------ + * - 検証 … `parseCleanAreaQuery()`(`lib/clean-area/query.ts`。★純粋関数★) + * - 施設の解決 … `resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()` → + * ★積集合★ `intersectFacilityIds()`(★どちらか片方を捨てない★) + * - 読み取り … `db.cleanArea()`(`USE_FIXTURES` の切替はデータ層が吸収する) + * - 集計 … `aggregateCleanArea()`(★段階2 の純粋関数★。★ここで割り算を書かない★) + * - ★★番人のログ★★ … `reportCleanAreaAnomalies()`(★下の注記★) + * - 取り寄せ量のログ … `judge.clean_area`(完了率用に運んだ行数と所要時間。D-156 論点2) + * + * ★認証はここに書かない★ — `proxy.ts` が全ルートで横断的に判定する(401 / 403)。 + * ★`GET` だけ★。 + */ +import { NextResponse } from "next/server"; +import { aggregateCleanArea } from "@/lib/clean-area/aggregate"; +import { reportCleanAreaAnomalies } from "@/lib/clean-area/anomaly"; +import { parseCleanAreaQuery } from "@/lib/clean-area/query"; +import { db } from "@/lib/db"; +import type { CleanAreaSourceResult } from "@/lib/db/clean-area-source"; +import { intersectFacilityIds } from "@/lib/facilities/intersect-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, CleanAreaResponse } from "@/lib/types"; + +const ROUTE = "/api/clean-area"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseCleanAreaQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + const query = parsed.query; + + let source: CleanAreaSourceResult; + // ★データ層の所要時間★(`judge.clean_area` ログ用。D-156 論点2) + let elapsedMs = 0; + try { + // ★★施設名の照合と分類の絞り込みは**積集合(AND)**にする★★(指示書AK §6-2)— + // ★どちらか片方を捨てないこと★。★積が空なら `[]` = 0件★ + // (★`null` にすると条件ごと消えて全件表示に化ける★。例外もテスト失敗も出ない)。 + // ★`facility_ids` の分岐は必ず `!== null`★(落とし穴3)— それを守っているのは + // `db.cleanArea()` 側で、ここは解決結果をそのまま渡すだけ。 + const [byName, byMasters] = await Promise.all([ + resolveFacilityIdsByName(query.facilityName), + resolveFacilityIdsByMasters(query), + ]); + const startedAt = performance.now(); + source = await db.cleanArea({ + date_from: query.dateFrom, + date_to: query.dateTo, + facility_ids: intersectFacilityIds(byName, byMasters), + }); + elapsedMs = performance.now() - startedAt; + } catch (e) { + const body: ApiError = { + error: { + code: "internal_error", + message: "データの取得に失敗しました", + details: null, + }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return errorResponse(body, 500); + } + + // ここから下は純粋な集計。すべて try の外(`facility-performance` と同じ方針)。 + // ★粒度は集計時にだけ効く★ — 材料(`source`)は粒度に依存しないので読み直さない。 + const aggregate = aggregateCleanArea(source.stats, source.facilities, query.groupBy); + + // ★★番人(指示書AN §2-5 / §6-3 / 落とし穴2)★★ — ★呼び忘れても例外もテスト失敗も + // 出ない★(数字が静かに大きくなるだけ)ので、★消さないこと★。 + // ★ログはルート層で出す★ — `DataAnomalyLogEvent` の `route` / `method` を埋められるのはルート層だけで + // (型は D-178 で任意になったが、埋められる層で出すのが原則)、 + // データ層はどのルートから呼ばれたかを知らない(`lib/clean-area/anomaly.ts` の docstring)。 + // ★異常が無ければ1行も出ない★ので、毎回の呼び出しでログが埋まることはない。 + reportCleanAreaAnomalies({ + route: ROUTE, + method: "GET", + duplicated_timetable_count: source.duplicated_timetable_count, + unmatched_facility_ids: aggregate.unmatched_facility_ids, + }); + + // ★★取り寄せた行数と所要時間を残す★★(D-156 論点2)— 完了率・実施回数の材料は行を運ぶ + // (期間を指定しないと全期間)ので、実データで重いかどうかをこのログで見る。 + // 記録条件は設けない(`judge.areas` と同じ流儀。この画面の1回の表示で1行) + logEvent("info", { + event: "judge.clean_area", + route: ROUTE, + method: "GET", + rate_rows: source.rate_rows, + elapsed_ms: Math.round(elapsedMs), + total_executed_count: aggregate.total.executed_count, + raw_total_completion_rate: aggregate.total.completion_rate, + }); + + // ★`items` は全件返す★(★`limit` を持たない★。指示書AN §7-2 / 要レスキュー D-137 と同じ作法)— + // ★何件見せるかは画面の責務★(`cleanAreaVisibility()` / `CLEAN_AREA_INITIAL_VISIBLE`)。 + // ★`meta.total` は `items` の件数★(★清掃回数ではない★。清掃回数は `total.executed_count`)。 + // `page` / `per_page` パラメータを持たない一覧なので `page` は常に1、 + // `per_page` は1ページに全件入る= `total` と同じ(`d-watch-api.yaml` の meta の説明)。 + // ★★`unmatched_facility_ids` は返さない★★ — 異常の通知先はログであって画面ではない。 + // ★★単位(`AREA_UNIT`)もレスポンスに入れない★★(指示書AN §2-4 / 落とし穴6)— + // ★`"㎡"` を持つのは `lib/config` の1箇所だけ★で、付けるのは画面。 + // API が単位を返すと、TeamWorks の回答が違ったときに直す場所が散らばる。 + const total = aggregate.items.length; + const body: CleanAreaResponse = { + total: aggregate.total, + items: aggregate.items, + meta: { total, page: 1, per_page: total }, + }; + return NextResponse.json(body); + }); +} diff --git "a/app/api/clean-logs/\133id\135/__tests__/route.test.ts" "b/app/api/clean-logs/\133id\135/__tests__/route.test.ts" new file mode 100644 index 0000000..3958bd3 --- /dev/null +++ "b/app/api/clean-logs/\133id\135/__tests__/route.test.ts" @@ -0,0 +1,947 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` +// の冒頭コメントと同じ理由 — `it()` の中で `await import()` すると最初の1本が +// モジュール読み込みを丸ごと抱え込み、フルスイートで不定期にタイムアウトする)。 +import { GET as ADVICE } from "@/app/api/clean-logs/[id]/advice/route"; +import { GET as AREAS } from "@/app/api/clean-logs/[id]/areas/route"; +import { buildAreaCompletions, selectAreaRowsForLog } from "@/lib/clean-logs/areas"; +import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config"; +import { db } from "@/lib/db"; +import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions"; +import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { logEvent } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice"; +import type { JsonStore } from "@/lib/settings/json-store"; +import type { JudgeAreasLogEvent } from "@/lib/logger/events"; +import { capConsecutiveDays } from "@/lib/format/consecutive-days"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { + AreaCompletionRow, + AreaCompletionsResponse, + CleanLogAdviceResponse, +} from "@/lib/types"; + +/** + * ★★指示書AC 段階3 の期待値表★★(`/clean-logs/{id}/areas` / `/clean-logs/{id}/advice`)。 + * + * | # | 入力 | 期待 | + * |---|---|---| + * | 1 | 存在する `{id}` | ★段階0(=全件読み + 純粋関数)と同じレスポンス★ | + * | 2 | 存在しない `{id}` | ★段階0 と同じ 404★ | + * | 3 | 同じ施設・同じ日に複数エリア | ★全エリアが返る★(`{id}` は「施設と日付の入口」。指示書Z 決定1) | + * | 4 | `filter=problem` | ★`total_count` / `overall_*` は絞る**前**の値★(指示書Z 決定8 / 決定12) | + * | 5 | 読み込み行数 | ★全件読みから数十行に減る★ | + * + * ------------------------------------------------------------------------ + * ★★「段階0 と同じ」をどう固定するか★★ + * ------------------------------------------------------------------------ + * ★期待値を数値で書き写さないこと★ — 書き写すと、実装とテストが同時にずれたときに + * 気づけません。代わりに ★★同じデータに対する「全件版 + 純粋関数」を毎回その場で計算し、 + * SQL で絞った結果と突き合わせます★★(指示書AC G-3「等価テスト」)。 + * ★これが「レスポンスを1バイトも変えていない」ことの検出手段★(禁止事項14)。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に + * 固定して ★`prismaSource` の経路★ を走らせます + * (`app/api/clean-logs/__tests__/route.test.ts` と同じ作法)。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubTimetable { + id: bigint; + facility_id: bigint; + section_id: bigint; + parent_id: bigint; + start_at: Date; + /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */ + end_at: Date; + precheck: number; + /** ★`deleted_at` は省略可(省略は生きている)。削除済みの行を作るテストだけが入れる(指示書BB U1)★ */ + facility: { facility_name: string; deleted_at?: Date | null }; + section: { + deleted_at?: Date | null; + section_name: string; + robots: { + serial_number: string; + device: { maker_id: number; model_name: string }; + }[]; + }; +} + +/** + * `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。 + * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。 + * ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3) + */ +interface StubReport { + clean_timetable_id: bigint | null; + clean_ratio: number; + /** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */ + status: number; + clean_area: number; + task_area: number; + started_at: Date; + ended_at: Date | null; +} + +interface StubErrorReport { + clean_timetable_id: bigint | null; + error_type: string; + errored_at: Date; +} + +interface StubData { + timetables: StubTimetable[]; + /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */ + historyTimetables?: StubTimetable[]; + reports: StubReport[]; + errorReports: StubErrorReport[]; + facilities: { id: bigint; facility_name: string; deleted_at: Date | null }[]; +} + +/** 発行されたクエリの記録(★どんな `where` が降りたか★ / ★何行読んだか★) */ +interface CallRecord { + model: string; + args: Record; + rows: number; +} + +let calls: CallRecord[] = []; +let data: StubData; + +function sameId(a: unknown, b: unknown): boolean { + if (a === null || a === undefined || b === null || b === undefined) return false; + return String(a) === String(b); +} + +/** + * Prisma の `where` のうち★本作業が使う分だけ★を再現する。 + * ★`in: undefined` は例外にする★ — Prisma では「条件が消えて全件」になるため(罠1)。 + */ +function matchesWhere(row: Record, where: unknown): boolean { + if (!where) return true; + for (const [field, cond] of Object.entries(where as Record)) { + const value = row[field]; + if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) { + const c = cond as Record; + if ("in" in c) { + if (c.in === undefined) { + throw new Error( + `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります` + ); + } + if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false; + } + if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false; + if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false; + // ★リレーションの `{ deleted_at: null }`(指示書BB U1)は、その行の `deleted_at`(省略は生きている)で見る★ + if ("deleted_at" in c && c.deleted_at === null) { + const target = + value !== null && typeof value === "object" ? ((value as { deleted_at?: Date | null }).deleted_at ?? null) : value; + if (target !== null && target !== undefined) return false; + } + } else if (!sameId(value, cond) && value !== cond) { + // ★`bigint` と `number` をまたいで比較できるようにする★(`id` / `facility_id` は BigInt 列) + return false; + } + } + return true; +} + +function project(row: Record, select: unknown): Record { + if (!select) return row; + const out: Record = {}; + for (const key of Object.keys(select as Record)) out[key] = row[key]; + return out; +} + +function findMany( + model: string, + rows: Record[], + args: Record | undefined +) { + const filtered = rows.filter((row) => matchesWhere(row, args?.where)); + calls.push({ model, args: args ?? {}, rows: filtered.length }); + return filtered.map((row) => project(row, args?.select)); +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +// ★`api.error`(指示書BB 表8 T3)を数えるため `logApiError` を差し替える★ +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +// ★`judge.areas` ログの中身を見るため `logEvent` だけ差し替える★(指示書AR 段階4)。 +// ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない。`app/api/clean-area` のテストと同じ作法) +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + cleanTimetable: { + findMany: async (args?: Record) => + findMany("cleanTimetable", data.timetables as unknown as Record[], args), + }, + // ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★ + cleanTimetableHistory: { + findMany: async (args?: Record) => + findMany( + "cleanTimetableHistory", + (data.historyTimetables ?? []) as unknown as Record[], + args + ), + }, + cleanTaskReport: { + findMany: async (args?: Record) => + findMany("cleanTaskReport", data.reports as unknown as Record[], args), + }, + robotErrorReport: { + findMany: async (args?: Record) => + findMany("robotErrorReport", data.errorReports as unknown as Record[], args), + }, + facility: { + findMany: async (args?: Record) => + findMany("facility", data.facilities as unknown as Record[], args), + }, + }), +})); + +// ---------------------------------------------------------------- 合成データ + +/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */ +function dbWallClock(jstIso: string): Date { + return new Date(jstIso.replace("+09:00", "Z")); +} + +/** ★実在の店名・実在のエリア名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */ +const FACILITIES = [ + { id: 1, name: "ドソ・キホーテ戸塚" }, + { id: 2, name: "ドソ・キホーテ四街道" }, + { id: 3, name: "フレッシュタウソ大和店" }, +]; + +/** ★1施設あたり3エリア★(= 同じ施設・同じ日に複数エリアが並ぶ。#3) */ +const SECTIONS_PER_FACILITY = 3; + +/** ★全件版が「明らかに多い」と言える量にする★(#5 の読み込み行数の比較に要る) */ +const DAYS = 30; + +/** 対象日(★最終日★)。全件版と単一ID版の突き合わせはこの日で行う */ +const TARGET_DATE = "2026-09-30"; + +function dayIso(index: number): string { + // 2026-09-01 から index 日ぶん進める(DAYS=30 なので 09-01〜09-30) + const d = new Date(Date.UTC(2026, 8, 1 + index)); + return d.toISOString().slice(0, 10); +} + +/** + * `clean_timetables.id` の採番。★日とエリアから一意に決まる形にする★ + * (テスト側で「この日のこのエリアの id」を計算できるようにするため)。 + */ +function timetableId(facilityId: number, dayIndex: number, sectionIndex: number): number { + return facilityId * 100_000 + dayIndex * 10 + sectionIndex; +} + +/** + * 完了率。★エリアごとに変える★ — + * section 0 → 95(◎)/ section 1 → 5(×・問題)/ section 2 → `null`(未実施・問題) + * ★#4(`filter=problem` で絞られる行と絞られない行)を両方作るため★。 + */ +function ratioFor(sectionIndex: number): number | null { + if (sectionIndex === 0) return 95; + if (sectionIndex === 1) return 5; + return null; +} + +function buildData(): StubData { + const timetables: StubTimetable[] = []; + const reports: StubReport[] = []; + const errorReports: StubErrorReport[] = []; + + for (const facility of FACILITIES) { + for (let dayIndex = 0; dayIndex < DAYS; dayIndex++) { + for (let s = 0; s < SECTIONS_PER_FACILITY; s++) { + const id = timetableId(facility.id, dayIndex, s); + timetables.push({ + id: BigInt(id), + facility_id: BigInt(facility.id), + section_id: BigInt(facility.id * 10 + s), + parent_id: BigInt(0), + // ★★壁時計 20:00 の清掃★★(`toDbJst()` の付け忘れを検出する。指示書AC C-6。 + // ★ローカルDB(深夜0〜3時しかない)では検出できない時間帯★) + start_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`), + end_at: new Date(dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`).getTime() + 60 * 60_000), + precheck: CLEAN_TIMETABLE_PRECHECK.main, + facility: { facility_name: facility.name }, + section: { + section_name: `エリア${facility.id}-${s + 1}`, + robots: [ + { + serial_number: `8111R5628060${facility.id}${s}`, + device: { maker_id: 1, model_name: "CC1" }, + }, + ], + }, + }); + + const ratio = ratioFor(s); + // ★★未実施(`ratio === null`)はレポートの行を作らない★★(指示書AQ §1-1。 + // 旧取得元の「`clean_error = 1` の行」に当たるものは `clean_task_reports` に無い) + if (ratio === null) continue; + reports.push({ + clean_timetable_id: BigInt(id), + clean_ratio: ratio, + // ★生値の一例★(意味は第10回 N-2 で受領済み。指示書AQ §1-2) + status: 4, + // ★面積は完了率と独立に置く★(面積は表示用に残る。完了率の計算には使わない。 + // 指示書AQ 決定4 / §1-3) + clean_area: ratio * 10, + task_area: 1_000, + started_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`), + ended_at: dbWallClock(`${dayIso(dayIndex)}T21:00:00+09:00`), + }); + } + } + } + + // ★事前チェック行(本清掃の子)★ — `status_1h` / `status_30m` の材料。 + // ★対象日の1件にだけ付ける(`parent_id` の別クエリが走ることの確認も兼ねる)★ + const targetId = timetableId(1, DAYS - 1, 0); + for (const [precheck, minutes] of [ + [60, "19:00"], + [30, "19:30"], + ] as const) { + timetables.push({ + id: BigInt(targetId * 10 + precheck), + facility_id: BigInt(1), + section_id: BigInt(10), + parent_id: BigInt(targetId), + start_at: dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`), + end_at: new Date(dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`).getTime() + 60 * 60_000), + precheck, + facility: { facility_name: FACILITIES[0].name }, + section: { + section_name: "エリア1-1", + robots: [ + { serial_number: "8111R56280601", device: { maker_id: 1, model_name: "CC1" } }, + ], + }, + }); + } + errorReports.push({ + clean_timetable_id: BigInt(targetId * 10 + 60), + error_type: "EmergencyStop", + errored_at: dbWallClock(`${TARGET_DATE}T19:05:00+09:00`), + }); + + return { + timetables, + reports, + errorReports, + facilities: FACILITIES.map((f) => ({ + id: BigInt(f.id), + facility_name: f.name, + deleted_at: null, + })), + }; +} + +/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */ +const NOW = new Date("2026-10-15T00:00:00+09:00"); + +/** ★対象の清掃ログ★(施設1・最終日・エリア1)。同じ施設・同じ日に3エリアある */ +const TARGET_ID = timetableId(1, DAYS - 1, 0); + +const ALL_RANGE = { facility_id: null, date: null } as const; + +async function areas(id: number, search = ""): Promise { + return AREAS(new Request(`http://localhost/api/clean-logs/${id}/areas${search}`), { + params: Promise.resolve({ id: String(id) }), + }); +} + +async function advice(id: number): Promise { + return ADVICE(new Request(`http://localhost/api/clean-logs/${id}/advice`), { + params: Promise.resolve({ id: String(id) }), + }); +} + +/** + * ★連続日数用のクエリ★(`loadConsecutiveInputs()` が発行する2本)。 + * + * ★★`select` の中身で見分けること★★ — 段階6 でこの2本にも `where` が付いたので、 + * ★「`where` を持たない」では区別できなくなった★。 + * ★他の `select` 付きクエリ(事前チェック行の `parent_id` / `precheck` / `id`)と + * 取り違えないように、この関数が読む列そのもので判定する★。 + */ +function consecutiveCalls(): CallRecord[] { + return calls.filter((c) => { + const select = (c.args as { select?: Record }).select; + if (!select) return false; + // ★予定は2表から読む(指示書AU 段階3b)ので、履歴側の連続日数用クエリも同じ扱い★ + if (c.model === "cleanTimetable" || c.model === "cleanTimetableHistory") { + return select.facility_id === true && select.start_at === true; + } + if (c.model === "cleanTaskReport") return select.clean_ratio === true; + return false; + }); +} + +/** 連続日数用を除いた読み込み行数(★段階3 で減らしたいのはこちら★) */ +function rowsReadExcludingConsecutive(): number { + return calls + .filter((c) => !consecutiveCalls().includes(c)) + .reduce((sum, c) => sum + c.rows, 0); +} + +beforeEach(() => { + data = buildData(); + calls = []; + vi.mocked(logEvent).mockClear(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- データ層 + +describe("db.cleanLogs({ id }) — 単一IDの取得経路(指示書AC 段階3)", () => { + it("★★#1 `id` で引いた1件が、全件版の同じ行と**完全一致**する★★(★連続日数まで★)", async () => { + const all = await db.cleanLogs({ + id: null, + date_from: null, + date_to: null, + facility_ids: null, + }); + const expected = all.find((l) => l.id === TARGET_ID); + expect(expected).toBeDefined(); + + const one = await db.cleanLogs({ + id: TARGET_ID, + date_from: null, + date_to: null, + facility_ids: null, + }); + expect(one).toHaveLength(1); + // ★★1バイトも変わっていないこと★★(禁止事項14)— `consecutive_problem_days` / + // `escalation_level` / `status_1h` / `status_30m` まで含めて比べる + expect(one[0]).toEqual(expected); + // ★連続日数は実際に 1 より大きい★(= 全期間を読めている。★C-2 の地雷の検出★) + expect(one[0].consecutive_problem_days).toBeGreaterThan(1); + }); + + it("★主クエリの `where` に `id` が降りている★(★メモリ上の `find()` に戻っていない★)", async () => { + calls = []; + await db.cleanLogs({ + id: TARGET_ID, + date_from: null, + date_to: null, + facility_ids: null, + }); + const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args); + expect(main).toBeDefined(); + expect((main!.args.where as Record).id).toBe(BigInt(TARGET_ID)); + // ★★1行しか読んでいないこと★★ + expect(main!.rows).toBe(1); + }); + + it("★存在しない `id` → 0件★(★`id: null` に化けて全件になっていないことの検出★)", async () => { + const rows = await db.cleanLogs({ + id: 999_999_999, + date_from: null, + date_to: null, + facility_ids: null, + }); + expect(rows).toEqual([]); + }); + + it("★★単一ID取得では連続日数用のクエリに日付の下限が付かない★★(=全期間のまま。C-2 / 段階6 の申し送り)", async () => { + calls = []; + await db.cleanLogs({ + id: TARGET_ID, + date_from: null, + date_to: null, + facility_ids: null, + }); + // ★`clean_timetables` / `clean_timetable_histories`(指示書AU 段階3b)/ `clean_task_reports` の2本 + // (予定の id で引く本と、予定外タスクを読む本。指示書BG §2-3)の4本★ + expect(consecutiveCalls()).toHaveLength(4); + // ★予定外タスクの本も期間の下限が付かない★(`started_at` のキーごと無い) + const unscheduledCall = consecutiveCalls().find( + (c) => c.model === "cleanTaskReport" && (c.args as { select: Record }).select.facility_id === true + ); + expect((unscheduledCall?.args.where as Record).started_at).toBeUndefined(); + // ★履歴にも同じ引数★ + expect(consecutiveCalls().find((c) => c.model === "cleanTimetableHistory")?.args).toEqual( + consecutiveCalls().find((c) => c.model === "cleanTimetable")?.args + ); + // ★★対象ログの日付は引くまで分からないので、この経路だけは遡り範囲を決められない★★ + // (段階6 で有界にできたのは `date_from` が来る経路。★申し送りに残してある★) + const timetableCall = consecutiveCalls().find((c) => c.model === "cleanTimetable"); + expect((timetableCall?.args.where as Record).start_at).toBeUndefined(); + }); +}); + +describe("db.areaCompletions({ facility_id, date }) — 施設・日付をSQLに降ろす(段階3)", () => { + it("★★SQL版 = 全件版 + `selectAreaRowsForLog()`(完全一致)★★(指示書AC G-3)", async () => { + const all = await db.areaCompletions(ALL_RANGE); + const expected = selectAreaRowsForLog(all, { facility_id: 1, date: TARGET_DATE }); + + const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + expect(scoped).toEqual(expected); + // ★3エリアぶん(#3)★ + expect(scoped).toHaveLength(SECTIONS_PER_FACILITY); + }); + + it("★★`where` に施設ID・日付・本清掃が降りている★★(★壁時計値に直してあること★。C-6)", async () => { + calls = []; + await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + const main = calls.find((c) => c.model === "cleanTimetable")!; + const where = main.args.where as Record; + expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main); + expect(where.facility_id).toBe(BigInt(1)); + const startAt = where.start_at as { gte: Date; lt: Date }; + // ★★`toDbJst()` を通した壁時計値であること★★(★生の `parseDate()` なら9時間ずれる★) + expect(startAt.gte.toISOString()).toBe(`${TARGET_DATE}T00:00:00.000Z`); + expect(startAt.lt.toISOString()).toBe("2026-10-01T00:00:00.000Z"); + }); + + it("★★壁時計 20:00 の清掃がその日の範囲に**含まれる**★★(C-6。★ローカルDBでは検出できない★)", async () => { + const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + expect(scoped.every((r) => r.date === TARGET_DATE)).toBe(true); + // ★前日・翌日の指定では出てこない★ + expect(await db.areaCompletions({ facility_id: 1, date: "2026-09-29" })).not.toEqual(scoped); + expect(await db.areaCompletions({ facility_id: 1, date: "2026-10-01" })).toEqual([]); + }); + + it("★レポートも本清掃の id で引き直す(全件読みに戻さない)★", async () => { + calls = []; + await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + const report = calls.find((c) => c.model === "cleanTaskReport")!; + expect(report.args.where).toBeDefined(); + // ★未実施のエリア(section 2)はレポートの行が無いので1行少ない★(指示書AQ §1-1 の仕様変更。 + // 旧取得元は未実施でも `clean_error = 1` の行があったので SECTIONS_PER_FACILITY だった) + expect(report.rows).toBe(SECTIONS_PER_FACILITY - 1); + }); + + it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => { + calls = []; + const rows = await db.areaCompletions({ facility_id: 1, date: "2020-01-01" }); + expect(rows).toEqual([]); + expect(calls.filter((c) => c.model === "cleanTaskReport")).toEqual([]); + }); + + it("★fixtures 版と同じ意味(`null` は「絞らない」)★(禁止事項6)", async () => { + const all = await db.areaCompletions(ALL_RANGE); + const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + expect(filterFixtureAreaCompletions(all, { facility_id: 1, date: TARGET_DATE })).toEqual( + scoped + ); + expect(filterFixtureAreaCompletions(all, ALL_RANGE)).toHaveLength(all.length); + }); +}); + +// ---------------------------------------------------------------- /clean-logs/{id}/areas + +describe("GET /clean-logs/{id}/areas(指示書AC 段階3 期待値表)", () => { + /** ★段階0(全件読み + 純粋関数)と同じ計算★ をその場でやり直す */ + async function expectedBody( + id: number, + filter: "problem" | "all" + ): Promise { + const logs = await db.cleanLogs({ + id: null, + date_from: null, + date_to: null, + facility_ids: null, + }); + const log = logs.find((l) => l.id === id)!; + const all: AreaCompletionRow[] = await db.areaCompletions(ALL_RANGE); + const selected = selectAreaRowsForLog(all, { + facility_id: log.facility_id, + date: log.scheduled_at.slice(0, 10), + }); + return buildAreaCompletions(selected, filter); + } + + it("★★#1 存在する `{id}` → 段階0 と同じレスポンス★★", async () => { + const expected = await expectedBody(TARGET_ID, "problem"); + const res = await areas(TARGET_ID); + expect(res.status).toBe(200); + const body = (await res.json()) as AreaCompletionsResponse; + // ★★`items` / `overall_completion_rate` / `total_count` / `problem_count` が全一致★★ + expect(body).toEqual(expected); + }); + + it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => { + const res = await areas(999_999_999); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { + code: "not_found", + message: "指定された清掃ログが見つかりません", + details: null, + }, + }); + }); + + it("★#2(構造)404 のときは `areaCompletions()` を引かない★", async () => { + calls = []; + await areas(999_999_999); + // ★連続日数用の `clean_task_reports` は走るが、エリア用の引き直しは無い★ + // (★段階6 で連続日数用のクエリにも `where` が付いたので、そちらは除いて見る★) + expect( + calls.filter( + (c) => + c.model === "cleanTaskReport" && + "where" in c.args && + !consecutiveCalls().includes(c) + ) + ).toEqual([]); + }); + + it("★★#3 同じ施設・同じ日の**全エリア**が返る★★(`{id}` はエリアではなく入口。指示書Z 決定1)", async () => { + const res = await areas(TARGET_ID, "?filter=all"); + const body = (await res.json()) as AreaCompletionsResponse; + expect(body.total_count).toBe(SECTIONS_PER_FACILITY); + expect(body.items.map((i) => i.section_name).sort()).toEqual([ + "エリア1-1", + "エリア1-2", + "エリア1-3", + ]); + // ★自分自身(`{id}` の行)も含まれている★ + expect(body.items.some((i) => i.section_id === 10)).toBe(true); + }); + + it("★#3 別のエリアの `{id}` で開いても同じ3エリアが返る★(入口が変わるだけ)", async () => { + const other = timetableId(1, DAYS - 1, 1); + const a = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse; + const b = (await (await areas(other, "?filter=all")).json()) as AreaCompletionsResponse; + expect(b).toEqual(a); + }); + + it("★★#4 `filter=problem` でも `total_count` / `overall_*` は絞る**前**の値★★(決定8 / 決定12)", async () => { + const all = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse; + const problem = (await ( + await areas(TARGET_ID, "?filter=problem") + ).json()) as AreaCompletionsResponse; + + expect(problem.total_count).toBe(all.total_count); + expect(problem.overall_completion_rate).toBe(all.overall_completion_rate); + expect(problem.overall_grade).toBe(all.overall_grade); + expect(problem.problem_count).toBe(all.problem_count); + // ★★`items` だけが絞られる★★(◎のエリアが落ちる) + expect(problem.items.length).toBeLessThan(all.items.length); + expect(problem.items).toEqual(all.items.filter((i) => i.grade === "×")); + }); + + it("★★#5 読み込み行数が「全件」から数十行に減る★★", async () => { + // ★全件版が読む行数(段階0 相当)★ + calls = []; + await db.cleanLogs({ id: null, date_from: null, date_to: null, facility_ids: null }); + await db.areaCompletions(ALL_RANGE); + const before = calls.reduce((sum, c) => sum + c.rows, 0); + + calls = []; + await areas(TARGET_ID); + const after = calls.reduce((sum, c) => sum + c.rows, 0); + + // ★★連続日数用の全期間クエリ(段階6 で有界にする)を除いた行数★★ — + // ★段階3 で減らせるのはこちら★。★ここが数十行に収まること★ + const scoped = rowsReadExcludingConsecutive(); + expect(scoped).toBeLessThan(50); + // ★全件版より確実に少ない★(合成データは本清掃だけで + // 3施設 × 30日 × 3エリア = 270行ある) + expect(after).toBeLessThan(before); + expect(before).toBeGreaterThan(500); + }); +}); + +// ---------------------------------------------------------------- judge.areas ログ + +describe("GET /clean-logs/{id}/areas — judge.areas ログの分子・分母(指示書AR 段階4)", () => { + /** 直近の呼び出しで出た `judge.areas` を1件だけ取り出す */ + function judgeAreasEvent(): JudgeAreasLogEvent { + const events = vi + .mocked(logEvent) + .mock.calls.map((c) => c[1]) + .filter((p): p is JudgeAreasLogEvent => p.event === "judge.areas"); + expect(events).toHaveLength(1); + return events[0]; + } + + it("★`raw_overall_completion_rate` が `overall_completion_rate_sum / overall_completion_rate_count` と一致する★", async () => { + const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse; + const ev = judgeAreasEvent(); + expect(ev.raw_overall_completion_rate).toBe( + ev.overall_completion_rate_sum / ev.overall_completion_rate_count + ); + // ★画面(レスポンス)の全体率とも一致する★ + expect(ev.raw_overall_completion_rate).toBe(body.overall_completion_rate); + }); + + it("★未実施(完了率 null)の行は分子にも分母にも入らない★", async () => { + await areas(TARGET_ID, "?filter=all"); + const ev = judgeAreasEvent(); + // 対象日・施設1の3エリア: 95 / 5 / null(未実施)→ 分子 100・分母 2(★3 ではない★) + expect(ev.total_count).toBe(SECTIONS_PER_FACILITY); + expect(ev.overall_completion_rate_sum).toBe(95 + 5); + expect(ev.overall_completion_rate_count).toBe(2); + expect(ev.raw_overall_completion_rate).toBe(50); + }); + + it("★`filter=problem` でも分子・分母は絞る前の行で数える★(決定8 / 決定12)", async () => { + await areas(TARGET_ID, "?filter=problem"); + const ev = judgeAreasEvent(); + expect(ev.overall_completion_rate_sum).toBe(100); + expect(ev.overall_completion_rate_count).toBe(2); + }); + + it("★面積の合計(`overall_clean_area` / `overall_task_area`)はログに出さない★", async () => { + await areas(TARGET_ID); + const ev = judgeAreasEvent() as unknown as Record; + expect("overall_clean_area" in ev).toBe(false); + expect("overall_task_area" in ev).toBe(false); + // ★式の名前を入れた旧名(指示書AR 段階4)も出さない★(指示書AS D8 で改名) + expect("overall_clean_ratio_sum" in ev).toBe(false); + expect("overall_clean_ratio_count" in ev).toBe(false); + }); +}); + +// ---------------------------------------------------------------- /clean-logs/{id}/advice + +describe("GET /clean-logs/{id}/advice(指示書AC 段階3)", () => { + it("★★#1 存在する `{id}` → 1件だけ引いても中身が変わらない★★", async () => { + calls = []; + const res = await advice(TARGET_ID); + expect(res.status).toBe(200); + const body = (await res.json()) as CleanLogAdviceResponse; + + // ★★全件版の同じ行から作った入力と一致すること★★(★連続日数が壊れていない証拠★。C-2) + const all = await db.cleanLogs({ + id: null, + date_from: null, + date_to: null, + facility_ids: null, + }); + const log = all.find((l) => l.id === TARGET_ID)!; + // ★レスポンスに載るのは表示上限をかけた値★(指示書AF 段階6-2)なので、 + // ★ルートと同じ手順で期待値を作る★(数字を書き写さない) + const capped = capConsecutiveDays(log.consecutive_problem_days, await loadConsecutiveDaysMax()); + expect(body.consecutive_problem_days).toBe(capped.days); + expect(body.consecutive_problem_days_capped).toBe(capped.capped); + // ★★判定(L1〜L3)は上限をかける**前**の生値で行う★★(指示書AF 禁止事項9) + expect(body.escalation_level).toBe(log.escalation_level); + // ★連続日数が 1 より大きい=全期間を読めている(★C-2 の地雷の検出★)★ + expect(log.consecutive_problem_days).toBeGreaterThan(1); + expect(body.advices.length).toBeGreaterThan(0); + }); + + it("★主クエリが1行しか読んでいない★(★全件読み + `find()` に戻っていない★)", async () => { + calls = []; + await advice(TARGET_ID); + const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!; + expect((main.args.where as Record).id).toBe(BigInt(TARGET_ID)); + expect(main.rows).toBe(1); + // ★連続日数用の全期間クエリを除けば数十行★ + expect(rowsReadExcludingConsecutive()).toBeLessThan(50); + }); + + it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => { + const res = await advice(999_999_999); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { + code: "not_found", + message: "指定された清掃ログが見つかりません", + details: null, + }, + }); + }); + + it("★`{id}` の形式が不正なら 422(`db` まで来ない)★", async () => { + calls = []; + const res = await ADVICE(new Request("http://localhost/api/clean-logs/1.9/advice"), { + params: Promise.resolve({ id: "1.9" }), + }); + expect(res.status).toBe(422); + expect(calls).toEqual([]); + }); +}); + +describe("db.areaCompletions / db.cleanLogs({ id }) — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => { + /** ★履歴だけにある、対象と同じ施設・同じ日のエリア★ */ + const HISTORY_ID = 9_000_001; + function historyRow(): StubTimetable { + return { + id: BigInt(HISTORY_ID), + facility_id: BigInt(1), + section_id: BigInt(19), + parent_id: BigInt(0), + start_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`), + end_at: new Date(dbWallClock(`${TARGET_DATE}T20:00:00+09:00`).getTime() + 60 * 60_000), + precheck: CLEAN_TIMETABLE_PRECHECK.main, + facility: { facility_name: FACILITIES[0].name }, + section: { + section_name: "エリア1-9", + robots: [{ serial_number: "8111R56280619", device: { maker_id: 1, model_name: "CC1" } }], + }, + }; + } + + it("★履歴だけの予定がエリア別完了率に出る★。2表に同じ引数が渡る", async () => { + data.historyTimetables = [historyRow()]; + data.reports.push({ + clean_timetable_id: BigInt(HISTORY_ID), + clean_ratio: 95, + status: 4, + clean_area: 950, + task_area: 1_000, + started_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`), + ended_at: dbWallClock(`${TARGET_DATE}T21:00:00+09:00`), + }); + calls = []; + const rows = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE }); + const row = rows.find((r) => r.clean_log_id === HISTORY_ID); + expect(row).toMatchObject({ section_name: "エリア1-9", completion_rate: 95 }); + const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!; + const history = calls.find((c) => c.model === "cleanTimetableHistory" && "include" in c.args)!; + expect(history.args).toEqual(main.args); + }); + + it("★履歴だけの予定を id で1件引ける★(`/clean-logs/{id}/advice` / `/{id}/areas` の入口)", async () => { + data.historyTimetables = [historyRow()]; + const one = await db.cleanLogs({ id: HISTORY_ID, date_from: null, date_to: null, facility_ids: null }); + expect(one.map((l) => l.id)).toEqual([HISTORY_ID]); + }); +}); + +describe("削除済みの施設・清掃エリア(指示書BB U1・表1 V2/V3)", () => { + const DELETED = dbWallClock("2026-09-01T00:00:00+09:00"); + + function markDeleted(id: number, what: "facility" | "section") { + data.timetables = data.timetables.map((t) => + Number(t.id) !== id + ? t + : what === "facility" + ? { ...t, facility: { ...t.facility, deleted_at: DELETED } } + : { ...t, section: { ...t.section, deleted_at: DELETED } } + ); + } + + it.each(["facility", "section"] as const)( + "★V2(%s が削除済み): 見えない行の `{id}` は advice / areas とも404★", + async (what) => { + markDeleted(TARGET_ID, what); + expect((await advice(TARGET_ID)).status).toBe(404); + expect((await areas(TARGET_ID)).status).toBe(404); + } + ); + + it("★V3: 生きている行から開いたとき、同じ施設・同じ日の削除済みの清掃エリアは items・件数・平均に入らない★", async () => { + const before = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse; + expect(before.total_count).toBe(3); + // エリア1-2(完了率5%)を削除済みにする + markDeleted(timetableId(1, DAYS - 1, 1), "section"); + const res = await areas(TARGET_ID, "?filter=all"); + expect(res.status).toBe(200); + const body = (await res.json()) as AreaCompletionsResponse; + expect(body.items.map((i) => i.section_name).sort()).toEqual(["エリア1-1", "エリア1-3"]); + expect(body.total_count).toBe(2); + // ★平均は 95 と 5 の平均(50)から、95 だけ(1-3 は未実施で分母に入らない)に変わる★ + expect(body.overall_completion_rate).toBe(95); + }); + + it("V2・V3: `cleanLogs({ id })` と `areaCompletions()` の予定の `where` に可視性が入る", async () => { + await areas(TARGET_ID); + const mainCalls = calls.filter( + (c) => c.model === "cleanTimetable" && "include" in c.args + ); + expect(mainCalls.length).toBeGreaterThanOrEqual(2); + for (const c of mainCalls) { + expect((c.args as { where: Record }).where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + } + }); +}); + +/** + * ★表6 A7・A8(指示書BB A3)★ — 結果待ちはデータ層が `isResultPending()` で決める(Prisma 経路)。 + * 対象日(09-30)の施設1は 20:00〜21:00。エリア1-3(section 12)はレポートなし。 + * ★終了+120分=23:00★ より前なら結果待ち、ちょうどなら確定した未実施(表1 P6 と同じ境界)。 + */ +describe("GET /clean-logs/{id}/areas — 結果待ち(指示書BB 表6 A7・A8)", () => { + async function areaOf(sectionId: number) { + const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse; + return { body, item: body.items.find((i) => i.section_id === sectionId) }; + } + + it("A8: now=22:59(終了+120分より前)→ section 12 は result_pending: true。problem_count に数えない", async () => { + vi.setSystemTime(new Date(`${TARGET_DATE}T22:59:00+09:00`)); + const { body, item } = await areaOf(12); + expect(item).toMatchObject({ result_pending: true, grade: "×", completion_rate: null }); + // 問題は section 11(5%)だけ + expect(body.problem_count).toBe(1); + expect(body.total_count).toBe(3); + }); + + it("★A7: now=23:00(終了+120分ちょうど)→ result_pending: false(確定した未実施)★", async () => { + vi.setSystemTime(new Date(`${TARGET_DATE}T23:00:00+09:00`)); + const { body, item } = await areaOf(12); + expect(item).toMatchObject({ result_pending: false, grade: "×" }); + expect(body.problem_count).toBe(2); + }); + + it("レポートのある行は result_pending: false", async () => { + vi.setSystemTime(new Date(`${TARGET_DATE}T22:00:00+09:00`)); + const { item } = await areaOf(10); + expect(item?.result_pending).toBe(false); + }); +}); + +// ★表10 N6(指示書BB A6・U4)★ — 単一ID(`/advice`・`/areas`)の連続日数の入力は施設で絞らない(TeamWorks の索引の後) +describe("単一ID の連続日数の入力は施設で絞らない(指示書BB 表10 N6・U4)", () => { + it.each(["advice", "areas"] as const)("%s: 連続日数用の予定のクエリに `facility_id` の条件が無い", async (which) => { + calls = []; + const res = which === "advice" ? await advice(TARGET_ID) : await areas(TARGET_ID); + expect(res.status).toBe(200); + const timetableConsecutive = consecutiveCalls().filter((c) => c.model === "cleanTimetable"); + expect(timetableConsecutive.length).toBeGreaterThan(0); + for (const c of timetableConsecutive) { + expect((c.args as { where: Record }).where).not.toHaveProperty("facility_id"); + } + }); +}); + +// ★表8 T3(指示書BB A5・D18・D-182)★ — アドバイスの文言の読み込みが例外なら、標準の形の500と `api.error` が1件 +describe("GET /clean-logs/{id}/advice — 文言の読み込みの例外(指示書BB 表8 T3)", () => { + it("★T3: `loadAdviceRules()` が例外 → 500・internal_error・「データの取得に失敗しました」・api.error が1件★", async () => { + vi.mocked(logApiError).mockClear(); + const broken: JsonStore = { + read: async () => { + throw new Error("[stub] 読み込みに失敗"); + }, + write: async () => {}, + }; + __setAdviceStoreForTesting(broken); + try { + const res = await advice(TARGET_ID); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ method: "GET", status: 500 }); + } finally { + __setAdviceStoreForTesting(null); + } + }); +}); diff --git "a/app/api/clean-logs/\133id\135/advice/route.ts" "b/app/api/clean-logs/\133id\135/advice/route.ts" new file mode 100644 index 0000000..9675a0d --- /dev/null +++ "b/app/api/clean-logs/\133id\135/advice/route.ts" @@ -0,0 +1,74 @@ +/** + * GET /clean-logs/{id}/advice — 対応アドバイス(行展開時の推定原因+確認アクション) + * + * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/advice です。 + * + * 判定(どのルールが該当するか)は `lib/judge/advice.ts`、 + * 文言は `lib/settings/advice.ts`(運用設定で編集された内容)から取得します。 + */ +import { NextResponse } from "next/server"; +import { parsePathId } from "@/lib/api/path-params"; +import { adviceResponse } from "@/lib/clean-logs/advice-response"; +import { db } from "@/lib/db"; +import { logApiError } from "@/lib/logger/api"; +import { withRequestId } from "@/lib/logger"; +import type { ApiError, ErrorCode } from "@/lib/types"; + +const ROUTE = "/api/clean-logs/[id]/advice"; + +function errorBody(code: ErrorCode, message: string): ApiError { + return { error: { code, message, details: null } }; +} + +function errorResponse(status: number, code: ErrorCode, message: string) { + return NextResponse.json(errorBody(code, message), { status }); +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + return withRequestId(request, async () => { + const { id } = await params; + // ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。 + // `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123) + const parsedId = parsePathId(id); + if (!parsedId.ok) { + return NextResponse.json(parsedId.error, { status: parsedId.status }); + } + const logId = parsedId.query; + + let logs; + try { + // ★★1件だけ引く★★(指示書AC 段階3)— 以前は「範囲なし」で全件(実測3,178行)を + // 読んでから `find()` していた。 + // ★★連続日数は `loadConsecutiveInputs()` 経由なので正しいまま★★(段階1 の効果)— + // `cleanLogs()` の中で ★全期間を読む専用の1本★ が別に走るので、 + // ★`id` で絞っても L3 が L1 に化けない★(指示書AC C-2)。 + // ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので + // ★全部 404 になります★(罠1)。 + logs = await db.cleanLogs({ + id: logId, + date_from: null, + date_to: null, + facility_ids: null, + }); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)— + // ★絞り込み規則の「正」はここ★で、SQL 側はその写し。 + // ★★fixtures 版が `id` の扱いを間違えても 404 が壊れない保険★★にもなる。 + const log = logs.find((l) => l.id === logId); + if (!log) { + return errorResponse(404, "not_found", "指定された清掃ログが見つかりません"); + } + + // ★`clean_log_id` は `log.id` でなくパスの `logId` を渡す★ — `log.id` は型の上で null を含む + // (予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値 + return adviceResponse(log, { route: ROUTE, logIds: { clean_log_id: logId } }); + }); +} diff --git "a/app/api/clean-logs/\133id\135/areas/route.ts" "b/app/api/clean-logs/\133id\135/areas/route.ts" new file mode 100644 index 0000000..4483087 --- /dev/null +++ "b/app/api/clean-logs/\133id\135/areas/route.ts" @@ -0,0 +1,159 @@ +/** + * GET /clean-logs/{id}/areas — エリア別完了率(施設×日付) + * + * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/areas です。 + * + * ★★`{id}` はエリアを特定するキーではなく、「施設と日付を特定するための入口」★★ + * (指示書Z 決定1)。返すのは ★その清掃ログと同じ施設・同じ日の全エリア★ + * (その清掃ログ自身を含む)。画面(F-02 清掃詳細ログ)の行から開くモーダルなので、 + * 行の id を入口にしている。 + * + * ★出所★: TeamWorks 第8回 A9-2(2026-09-11 受領)で + * ★1エリア(=1ロボット)につき1つの完了率★が取得可能と確定したため、 + * ★501 の仮実装(D-034)をやめて本実装にした★。 + * ★★ただし確認事項#10 の本来の問い(1回の清掃の中の区域別内訳)は依然として未解決★★。 + * + * 絞り込み・並べ替え・全体率の計算は `lib/clean-logs/areas.ts`(純粋関数)、 + * 行の取得は `db.areaCompletions()`。このルートはその2つをつなぐだけにしてある。 + * + * ★★取得は2段構え★★(指示書AC 段階3): + * 1. `db.cleanLogs({ id })` … ★1件だけ★ 引いて `facility_id` と JST 日付を得る(404 の判定も兼ねる) + * 2. `db.areaCompletions({ facility_id, date })` … ★その施設のその日だけ★ を引く + * ★以前は両方を全件読んでいた(1行の表示のために実測 4,760行。指示書AC B-3)★。 + */ +import { NextResponse } from "next/server"; +import { parsePathId } from "@/lib/api/path-params"; +import { + buildAreaCompletions, + overallCompletionTally, + parseAreaFilter, + selectAreaRowsForLog, +} from "@/lib/clean-logs/areas"; +import { GRADE_THRESHOLDS } from "@/lib/config"; +import { db } from "@/lib/db"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { + ApiError, + AreaCompletionRow, + AreaCompletionsResponse, + CleanLogItem, + ErrorCode, +} from "@/lib/types"; + +const ROUTE = "/api/clean-logs/[id]/areas"; + +function errorBody(code: ErrorCode, message: string): ApiError { + return { error: { code, message, details: null } }; +} + +function errorResponse(status: number, code: ErrorCode, message: string) { + return NextResponse.json(errorBody(code, message), { status }); +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + return withRequestId(request, async () => { + const { id } = await params; + // ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。 + // `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123) + const parsedId = parsePathId(id); + if (!parsedId.ok) { + return NextResponse.json(parsedId.error, { status: parsedId.status }); + } + const logId = parsedId.query; + + const { searchParams } = new URL(request.url); + const parsed = parseAreaFilter(searchParams); + if (!parsed.ok) { + return NextResponse.json(parsed.error, { status: parsed.status }); + } + + // ★★2段構えにする★★(指示書AC 段階3)— ★並列に引けないのは承知の上★。 + // 1段目で `facility_id` と JST 日付が分かって初めて、2段目を絞れるため + // (★以前は両方を全件読んで並列に引いていた。1行の表示のために実測 4,760行★)。 + let log: CleanLogItem | undefined; + try { + // ★★1件だけ引く★★ — このルートが `cleanLogs()` を引くのは + // ★404 の判定と、施設・日付の特定★ のためだけ。 + // ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので + // ★全部 404 になります★(罠1)。 + const logs = await db.cleanLogs({ + id: logId, + date_from: null, + date_to: null, + facility_ids: null, + }); + // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5) + log = logs.find((l) => l.id === logId); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + if (!log) { + return errorResponse(404, "not_found", "指定された清掃ログが見つかりません"); + } + + // ★`scheduled_at` は既に JST の ISO 文字列(`jstIsoFromDb()` 由来)なので `.slice(0, 10)`★ + // ★★`areaCompletions()` の `date` と**同じ作り方**★★(片方だけ生の `Date` から + // 作ると9時間ずれて突き合わせが外れる。指示書Y / D-115) + const date = log.scheduled_at.slice(0, 10); + + let rows: AreaCompletionRow[]; + try { + // ★★その施設のその日だけを引く★★(指示書AC 段階3)— + // ★`clean_log_id` で引かないこと★。`{id}` はエリアではなく + // ★「施設と日付の入口」★ なので、同じ施設・同じ日の ★全エリア★ が要る(指示書Z 決定1)。 + rows = await db.areaCompletions({ facility_id: log.facility_id, date }); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + // ★★`selectAreaRowsForLog()` は**残す**★★(指示書AC C-5)— ★絞り込み規則の「正」はこの + // 純粋関数★で、SQL 側はその写し。★★SQL が絞りすぎたときはここでは取り戻せない★★ので、 + // 「SQL 版 = 全件版 + `selectAreaRowsForLog()`」の等価テストで固定してある(G-3)。 + const selected = selectAreaRowsForLog(rows, { + facility_id: log.facility_id, + date, + }); + const body: AreaCompletionsResponse = buildAreaCompletions(selected, parsed.query); + // ★全体率と同じ関数・同じ行(`filter` で絞る前)で分子・分母を数える★(指示書AR 段階4) + const tally = overallCompletionTally(selected); + + // 判定根拠の記録。記録条件は設けず無条件に出す + // (このルートは「エリア別詳細を見る」のクリックで1回だけ呼ばれるため件数の問題が無い。 + // `advice/route.ts` の judge.advice と同じ流儀) + // ★★分子・分母を残すのは、「全体率が行の平均と違う」と言われたときに検算できるようにするため★★ + // (全体率は行の完了率の単純平均。`raw_overall_completion_rate === sum / count`。 + // ★自前で数え直さず `overallCompletionTally()` を使う★ — 別の数え方をするとログと画面が食い違う) + logEvent("info", { + event: "judge.areas", + route: ROUTE, + method: "GET", + facility_id: log.facility_id, + // ★`log.id` は型の上で null を含む(予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値★ + clean_log_id: logId, + date, + filter: parsed.query, + raw_overall_completion_rate: body.overall_completion_rate, + overall_completion_rate_sum: tally.cleanRatioSum, + overall_completion_rate_count: tally.cleanRatioCount, + total_count: body.total_count, + problem_count: body.problem_count, + thresholds_used: { + excellent: GRADE_THRESHOLDS.excellent, + good: GRADE_THRESHOLDS.good, + poor: GRADE_THRESHOLDS.poor, + }, + result_overall_grade: body.overall_grade, + }); + + return NextResponse.json(body); + }); +} diff --git a/app/api/clean-logs/__tests__/route.test.ts b/app/api/clean-logs/__tests__/route.test.ts new file mode 100644 index 0000000..ebcaa45 --- /dev/null +++ b/app/api/clean-logs/__tests__/route.test.ts @@ -0,0 +1,1481 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、 +// ★最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、 +// フルスイート実行時に既定のタイムアウト(5秒)を踏んで★不定期に落ちます★ +// (実測: 単体実行 2.8秒 / フルスイート 7.1秒で #1・#2 が不定期に赤)。 +// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効きます。 +import { GET } from "@/app/api/clean-logs/route"; +import { GET as GET_UNSCHEDULED_ADVICE } from "@/app/api/clean-logs/unscheduled/[report_id]/advice/route"; +// ★出発失敗はアラートに出ない(2026-10-29)ことを、同じスタブの上で突き合わせるため★ +import { GET as GET_PRECHECKS } from "@/app/api/prechecks/route"; +import type { PrecheckSummary } from "@/lib/prechecks/query"; +import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config"; +import { db } from "@/lib/db"; +import { consecutiveLookbackCalendarDays } from "@/lib/db/consecutive-source"; +import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { getPrisma } from "@/lib/db/prisma"; +import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { UNSCHEDULED_TIMETABLE_ID_WHERE } from "@/lib/db/timetable-link"; +import { logEvent } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice"; +import type { JsonStore } from "@/lib/settings/json-store"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { CleanLogItem, ListResponse, PrecheckItem, SummaryListResponse } from "@/lib/types"; + +/** + * 指示書AC 段階2 の期待値表のうち、★★API ルートから `db` までを通した経路★★ + * でなければ固定できないもの(★#6b / #6c / #6d / #6e / #7 / #8 / #9★)。 + * + * ------------------------------------------------------------------------ + * ★★なぜ解決関数の単体テストだけでは不十分なのか★★(指示書AC 期待値表 #6d) + * ------------------------------------------------------------------------ + * ★ルートが `facility_ids` を `db.cleanLogs()` に渡し忘れていても、 + * `facilityIdsMatchingName()` の単体テストは緑のままです★。 + * ★★だからここでは「ルート → `db`(Prisma 実装)→ 発行されたクエリの `where`」まで + * 通して確かめます★★。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、 + * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して + * ★`prismaSource` の経路★ を走らせます(`lib/db/__tests__/consecutive-source.test.ts` と + * 同じ「DBに接続しないでDB実装を試す」作法)。 + * + * ★★合成データを使う理由★★: ローカルDB(`dwatch_local`)では + * - ★`start_at` が深夜0〜3時にしか無く、`toDbJst()` の付け忘れを検出できない★(#7 / C-6) + * - ★論理削除された施設が0件で、罠7 を検出できない★(#6e。2026-09-13 実測) + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +/** `clean_timetables` の1行(★`include` 済みの形で持つ★) */ +interface StubTimetable { + id: bigint; + facility_id: bigint; + section_id: bigint; + parent_id: bigint; + start_at: Date; + /** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */ + end_at: Date; + precheck: number; + /** ★`deleted_at` は省略可(省略は生きている)。削除済みの施設の行を作るテストだけが入れる(指示書BB U1)★ */ + facility: { facility_name: string; deleted_at?: Date | null }; + section: { + /** ★省略は生きている(指示書BB U1)★ */ + deleted_at?: Date | null; + robots: { + /** `robots.id`(★2台以上のときの並び `orderBy: { id: "asc" }` を見るため。省略可★。指示書BB D7) */ + id?: bigint; + serial_number: string; + device: { maker_id: number; model_name: string }; + }[]; + }; +} + +/** + * `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。 + * ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。 + * ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3) + */ +interface StubReport { + /** ★予定外の行(指示書BG)だけ使う★(予定のある行の読み取りは読まない) */ + report_id?: string; + facility_id?: bigint; + section_id?: bigint; + /** ★予定外の行の `include`(施設名・清掃エリアのロボット・削除済み)★。予定のある行には付けない */ + facility?: { facility_name: string; deleted_at: Date | null }; + section?: StubTimetable["section"]; + clean_timetable_id: bigint | null; + clean_ratio: number; + /** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */ + status: number; + started_at: Date; + ended_at: Date | null; + clean_area: number; + task_area: number; +} + +interface StubErrorReport { + clean_timetable_id: bigint | null; + error_type: string; + errored_at: Date; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id?: bigint | null; +} + +interface StubFacility { + id: bigint; + facility_name: string; + /** ★論理削除★(`null` 以外なら削除済み) */ + deleted_at: Date | null; +} + +interface StubData { + timetables: StubTimetable[]; + /** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */ + historyTimetables?: StubTimetable[]; + reports: StubReport[]; + errorReports: StubErrorReport[]; + facilities: StubFacility[]; +} + +/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */ +const calls: { + cleanTimetable: Record[]; + cleanTimetableHistory: Record[]; + cleanTaskReport: Record[]; + robotErrorReport: Record[]; + facility: Record[]; +} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], robotErrorReport: [], facility: [] }; + +let data: StubData; + +/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */ +function sameId(a: unknown, b: unknown): boolean { + if (a === null || a === undefined || b === null || b === undefined) return false; + return String(a) === String(b); +} + +/** + * Prisma の `where` のうち、★本作業が使う分だけ★ を再現する。 + * + * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。 + * ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。 + */ +function matchesWhere(row: Record, where: unknown): boolean { + if (!where) return true; + for (const [field, cond] of Object.entries(where as Record)) { + // ★`OR`(予定外の行の `UNSCHEDULED_TIMETABLE_ID_WHERE`。指示書BG)★ — どれか1つに当たれば通す + if (field === "OR") { + if (!(cond as unknown[]).some((sub) => matchesWhere(row, sub))) return false; + continue; + } + const value = row[field]; + if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) { + const c = cond as Record; + if ("in" in c) { + if (c.in === undefined) { + throw new Error( + `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります` + ); + } + if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false; + } + if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false; + if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false; + if ("deleted_at" in c) { + // ★このスタブは `deleted_at` での絞り込みを**わざと**実装している★ — + // 罠7(解決用の読み取りに `where` を足してしまう)が起きたら結果が変わるように。 + // ★リレーション(`facility` / `section`)の条件は、その行の `deleted_at`(省略は生きている)で見る★(指示書BB U1) + const target = + value !== null && typeof value === "object" && !(value instanceof Date) + ? ((value as { deleted_at?: Date | null }).deleted_at ?? null) + : value; + if (c.deleted_at === null && target !== null) return false; + } + } else if (field === "report_id" && typeof cond === "string") { + if (typeof value !== "string") return false; + const norm = (s: string) => s.replace(/ +$/, "").toLowerCase(); + if (norm(value) !== norm(cond)) return false; + } else if (value !== cond) { + return false; + } + } + return true; +} + +function project(row: Record, select: unknown): Record { + if (!select) return row; + const out: Record = {}; + for (const key of Object.keys(select as Record)) out[key] = row[key]; + return out; +} + +function applyOrderBy( + rows: Record[], + orderBy: unknown +): Record[] { + if (!Array.isArray(orderBy)) return rows; + return [...rows].sort((a, b) => { + for (const key of orderBy as Record[]) { + const [field, dir] = Object.entries(key)[0]; + const av = a[field]; + const bv = b[field]; + const an = av instanceof Date ? av.getTime() : Number(av); + const bn = bv instanceof Date ? bv.getTime() : Number(bv); + if (an !== bn) return (an < bn ? -1 : 1) * (dir === "desc" ? -1 : 1); + } + return 0; + }); +} + +function findMany( + bucket: keyof typeof calls, + rows: Record[], + args: Record | undefined +) { + calls[bucket].push(args ?? {}); + const filtered = rows.filter((row) => matchesWhere(row, args?.where)); + const ordered = applyOrderBy(filtered, args?.orderBy); + return ordered.map((row) => sortIncludedRobots(project(row, args?.select), args)); +} + +/** + * ★`include: { section: { include: { robots: { orderBy: { id: "asc" } } } } }` を再現する★(指示書BB D7・表2 R5)— + * 実装が `orderBy` を付け忘れたら、スタブはデータの順のまま返す(R5 が赤くなる) + */ +function sortIncludedRobots( + row: Record, + args: Record | undefined +): Record { + const include = args?.include as + | { section?: { include?: { robots?: { orderBy?: { id?: string } } } } } + | undefined; + if (include?.section?.include?.robots?.orderBy?.id !== "asc") return row; + const section = row.section as StubTimetable["section"]; + const robots = [...section.robots].sort((a, b) => Number(a.id ?? 0) - Number(b.id ?? 0)); + return { ...row, section: { ...section, robots } }; +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +// ★事前チェックが3つ以上のときの `data.anomaly`(D-178)を見るため `logEvent` だけ差し替える★ +// (`withRequestId` はそのまま使う。`app/api/clean-area/__tests__/route.test.ts` と同じ作法) +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + cleanTimetable: { + findMany: async (args?: Record) => + findMany("cleanTimetable", data.timetables as unknown as Record[], args), + }, + // ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★ + cleanTimetableHistory: { + findMany: async (args?: Record) => + findMany( + "cleanTimetableHistory", + (data.historyTimetables ?? []) as unknown as Record[], + args + ), + }, + cleanTaskReport: { + findMany: async (args?: Record) => + findMany("cleanTaskReport", data.reports as unknown as Record[], args), + }, + robotErrorReport: { + findMany: async (args?: Record) => { + // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★ + guardNullRobotId(data.errorReports as unknown as Record[], args); + return findMany("robotErrorReport", data.errorReports as unknown as Record[], args); + }, + }, + facility: { + findMany: async (args?: Record) => + findMany("facility", data.facilities as unknown as Record[], args), + }, + }), +})); + +// ---------------------------------------------------------------- 合成データ + +/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */ +function dbWallClock(jstIso: string): Date { + return new Date(jstIso.replace("+09:00", "Z")); +} + +/** + * ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`)。 + * ★中点入りの店名★を再現するため `ドン` ではなく `ドソ`。 + */ +const FACILITY_1 = "ドソ・キホーテ戸塚"; +/** ★論理削除された施設★(#6e / 罠7) */ +const FACILITY_2 = "ドソ・キホーテ四街道"; +const FACILITY_3 = "フレッシュタウン大和店"; + +function timetable( + id: number, + facilityId: number, + facilityName: string, + jstIso: string, + precheck: number = CLEAN_TIMETABLE_PRECHECK.main, + parentId = 0 +): StubTimetable { + return { + id: BigInt(id), + facility_id: BigInt(facilityId), + section_id: BigInt(facilityId * 10), + parent_id: BigInt(parentId), + start_at: dbWallClock(jstIso), + end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000), + precheck, + facility: { facility_name: facilityName }, + section: { + robots: [ + { + serial_number: `8111R56280600${facilityId}`, + device: { maker_id: 1, model_name: "CC1" }, + }, + ], + }, + }; +} + +/** + * 実施済みの清掃1回ぶんのレポート行。 + * ★未実施は行を作らないことで表す★(指示書AQ §1-1。旧 `clean_error = 1` の行は作らない)。 + * `started_at` は `ended_at` の1時間前(どちらも壁時計値)。`status` は生値の一例(4)。 + * ★完了率は面積から `runCompletionRate()` で決まる(D-155)★ので、`task_area = 1000` に対して + * `clean_area = cleanRatio × 10` とし、`clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)。 + */ +function report(timetableId: number, cleanRatio: number, endedJstIso: string): StubReport { + const endedAt = dbWallClock(endedJstIso); + return { + clean_timetable_id: BigInt(timetableId), + clean_ratio: cleanRatio, + status: 4, + started_at: new Date(endedAt.getTime() - 60 * 60 * 1000), + ended_at: endedAt, + clean_area: cleanRatio * 10, + task_area: 1000, + }; +} + +function buildData(): StubData { + return { + facilities: [ + { id: BigInt(1), facility_name: FACILITY_1, deleted_at: null }, + // ★★論理削除済み(施設マスタ側)★★ — 名前の解決(罠7)の検証用。 + // ★予定の行(201)の `facility.deleted_at` は既定では入れない(=生きている扱い)★ — + // 削除済みの施設の行が外れること(指示書BB U1)は #6e で行に `deleted_at` を入れて確かめる + { id: BigInt(2), facility_name: FACILITY_2, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") }, + { id: BigInt(3), facility_name: FACILITY_3, deleted_at: null }, + ], + timetables: [ + // 施設1: ★3日連続で×★(9/8・9/9・9/10)→ 9/10 の行の連続日数は ★3★ + timetable(101, 1, FACILITY_1, "2026-09-08T02:00:00+09:00"), + timetable(102, 1, FACILITY_1, "2026-09-09T02:00:00+09:00"), + timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"), + // 事前チェック行(★本清掃の子★)— `status_1h` / `status_30m` の材料 + timetable(1031, 1, FACILITY_1, "2026-09-10T01:00:00+09:00", 60, 103), + timetable(1032, 1, FACILITY_1, "2026-09-10T01:30:00+09:00", 30, 103), + // 施設2(★論理削除された施設★): 9/10 に1件 + timetable(201, 2, FACILITY_2, "2026-09-10T02:00:00+09:00"), + // 施設3: ★★壁時計 20:00 の清掃★★(#7。★ローカルDBには1件も無い時間帯★) + timetable(301, 3, FACILITY_3, "2026-09-10T20:00:00+09:00"), + ], + reports: [ + report(101, 5, "2026-09-08T03:00:00+09:00"), + report(102, 5, "2026-09-09T03:00:00+09:00"), + report(103, 5, "2026-09-10T03:00:00+09:00"), + report(201, 95, "2026-09-10T03:00:00+09:00"), + report(301, 80, "2026-09-10T21:00:00+09:00"), + ], + errorReports: [ + { + clean_timetable_id: BigInt(1031), + error_type: "EmergencyStop", + errored_at: dbWallClock("2026-09-10T01:05:00+09:00"), + }, + ], + }; +} + +/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */ +const NOW = new Date("2026-09-30T00:00:00+09:00"); + +async function get(search: string): Promise> { + const res = await GET(new Request(`http://localhost/api/clean-logs${search}`)); + return (await res.json()) as ListResponse; +} + +/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */ +function mainQueryWhere(): Record { + const call = calls.cleanTimetable.find((c) => "include" in c); + expect(call).toBeDefined(); + return (call as { where: Record }).where; +} + +beforeEach(() => { + data = buildData(); + calls.cleanTimetable = []; + calls.cleanTimetableHistory = []; + calls.cleanTaskReport = []; + calls.robotErrorReport = []; + calls.facility = []; + vi.mocked(logEvent).mockClear(); + // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★ + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- テスト + +describe("GET /clean-logs — 日付・施設をSQLに降ろす(指示書AC 段階2)", () => { + it("#1 範囲なし → 本清掃の全件(★事前チェック行は並ばない★)", async () => { + const body = await get(""); + expect(body.items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]); + expect(body.meta.total).toBe(5); + // ★本清掃の絞り込みが SQL に降りていること★ + expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main); + }); + + it("#2 日付範囲を指定すると SQL の `where` に `start_at` が降りる", async () => { + await get("?date_from=2026-09-09&date_to=2026-09-10"); + const startAt = mainQueryWhere().start_at as { gte: Date; lt: Date }; + // ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★) + expect(startAt.gte.toISOString()).toBe("2026-09-09T00:00:00.000Z"); + expect(startAt.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z"); + }); + + it("#3 `date_from = date_to` → その日の行だけ", async () => { + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + expect(body.items.map((i) => i.id)).toEqual([301, 103, 201]); + expect(body.meta.total).toBe(3); + }); + + it("#4 `date_from` がデータの最古日より前 → #1 と同じ件数", async () => { + const body = await get("?date_from=2000-01-01"); + expect(body.meta.total).toBe(5); + }); + + it("#5 `date_from > date_to` は 422 で弾かれ、`db` まで来ない", async () => { + const res = await GET( + new Request("http://localhost/api/clean-logs?date_from=2026-09-10&date_to=2026-09-08") + ); + expect(res.status).toBe(422); + // ★★クエリが1本も発行されていないこと★★ + expect(calls.cleanTimetable).toHaveLength(0); + }); + + it("★#7 壁時計 20:00 の清掃が、その日の範囲に**含まれる**★(C-6。★ローカルDBでは検出できない★)", async () => { + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const evening = body.items.find((i) => i.id === 301); + expect(evening).toBeDefined(); + expect(evening?.scheduled_at).toBe("2026-09-10T20:00:00+09:00"); + // ★前日・翌日の指定では出てこない★ + expect((await get("?date_from=2026-09-09&date_to=2026-09-09")).items.map((i) => i.id)).not.toContain(301); + expect((await get("?date_from=2026-09-11&date_to=2026-09-11")).items.map((i) => i.id)).not.toContain(301); + }); + + it("★★#8 期間で絞っても `consecutive_problem_days` が全期間版と完全一致する★★(★C-2 の地雷★)", async () => { + // ★施設1 は 9/8・9/9・9/10 の3日連続で×★ + const all = await get(""); + const expected = new Map(all.items.map((i) => [i.id, i.consecutive_problem_days])); + expect(expected.get(103)).toBe(3); + + // ★9/10 の1日だけを指定しても 3 のまま★ + // ★★期間で絞った行から連続日数を数えると 1 になり、L3 が L1 に化ける★★ + // (★例外もテスト失敗も出ない fail-silent★。だからこのテストが唯一の検出手段) + const oneDay = await get("?date_from=2026-09-10&date_to=2026-09-10"); + for (const item of oneDay.items) { + expect(item.consecutive_problem_days).toBe(expected.get(item.id)); + } + expect(oneDay.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3); + expect(oneDay.items.find((i) => i.id === 103)?.escalation_level).toBe("L3"); + }); + + it("★#8(構造)連続日数用のクエリは「表示期間 + 上限ぶんの遡り」で絞る★(指示書AC 段階6)", async () => { + await get("?date_from=2026-09-10&date_to=2026-09-10"); + // ★`loadConsecutiveInputs()` が発行する `select` 付きのクエリ★ + const consecutiveCall = calls.cleanTimetable.find( + (c) => "select" in c && (c.select as Record).facility_id === true + ); + expect(consecutiveCall).toBeDefined(); + + // ★★表示期間の開始日(9/10)をそのまま下限にしたら連続が途切れる(C-2)★★ — + // 遡り幅は ★保存された表示上限★ から導く(★定数に写し取らないこと★。申し送り⑪) + const start = (consecutiveCall as { where: { start_at: { gte: Date; lt: Date } } }) + .where.start_at; + const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax()); + expect(start.gte.toISOString().slice(0, 10)).toBe( + new Date(Date.parse("2026-09-10T00:00:00Z") - lookback * 86_400_000) + .toISOString() + .slice(0, 10) + ); + // ★上限側は表示期間の末日を含む(翌日 00:00 未満)★ + expect(start.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z"); + }); +}); + +describe("施設名 → 施設ID の解決(指示書AC C-1 / D-6)", () => { + it("★★#6d 記号違いで指定した施設名が解決され、SQL の `where` に施設IDで降りる★★", async () => { + const body = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`); + + // ★★ルートが `facility_ids` を渡していること(=渡し忘れの検出)★★ + expect(mainQueryWhere().facility_id).toEqual({ in: [1, 2] }); + // ★★そのログがヒットすること★★(`ドソ・キホーテ戸塚` に `ドソキホーテ` で当たる) + expect(body.items.map((i) => i.id).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([101, 102, 103, 201]); + expect(body.items.every((i) => i.facility_name.startsWith("ドソ"))).toBe(true); + }); + + it("★★#6e 削除済みの施設の名前で検索すると、施設ID には解決される(罠7)が、その行は可視性の条件で外れて0件★★(指示書BB D3)", async () => { + // ★予定の行(201)の施設を削除済みにする(施設マスタの施設2 と同じ状態)★ + for (const t of data.timetables) { + if (Number(t.facility_id) === 2) t.facility = { ...t.facility, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") }; + } + const body = await get(`?facility_name=${encodeURIComponent("四街道")}`); + + // ★解決用の読み取りに `where` を足していないこと★(罠7。解決は行の可視性と別の責務) + expect(calls.facility).toEqual([{ select: { id: true, facility_name: true } }]); + expect(mainQueryWhere().facility_id).toEqual({ in: [2] }); + // ★行は削除済みの施設・清掃エリアの条件で外れる(指示書BB U1)★ + expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("★#6e(対照)`deleted_at` で絞ると施設2 が解決できなくなる★(流用してはいけない理由)", async () => { + // ★解決用の読み取りは論理削除された施設2 も返す★ + expect((await db.facilityNameIndex()).map((f) => f.id)).toEqual([1, 2, 3]); + + // ★対照★: `prismaSource.facilities()` が付けている `where: { deleted_at: null }` を + // 同じスタブに投げると ★施設2 が消える★ → 名前の解決の結果が変わる + // (★解決は行の可視性と別の責務★。行は予定の `where` の可視性の条件で外す。指示書BB D3) + const prisma = (await getPrisma()) as unknown as { + facility: { + findMany(args: unknown): Promise<{ id: bigint }[]>; + }; + }; + const visible = await prisma.facility.findMany({ + where: { deleted_at: null }, + select: { id: true, facility_name: true }, + }); + expect(visible.map((f) => Number(f.id))).toEqual([1, 3]); + }); + + it("★★#6b 1件も当たらない施設名 → 0件★★(★`in: undefined` に化けていないことの検出。罠1★)", async () => { + const body = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`); + + // ★★`in: []` が渡っていること(`in: undefined` なら全件になる)★★ + expect(mainQueryWhere().facility_id).toEqual({ in: [] }); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("★#6c 施設名を指定しない → `facility_id` の条件を付けない(全件)★", async () => { + const body = await get(""); + // ★★`in: []` を渡さないこと★★ — 渡すと施設名未指定で0件になる + expect(mainQueryWhere()).not.toHaveProperty("facility_id"); + expect(body.meta.total).toBe(5); + // ★施設名が空なら施設マスタを読みにいかない(余計な往復を増やさない)★ + expect(calls.facility).toEqual([]); + }); + + it("★機種・シリアルは据え置き(SQL に降ろさずメモリで絞る)★", async () => { + const body = await get("?serial=8111R56280600"); + // ★`section` の条件は可視性(`deleted_at: null`)だけ★(シリアルの条件を SQL に降ろしていない。指示書BB U1 で可視性が入った) + expect(mainQueryWhere().section).toEqual({ deleted_at: null }); + expect(body.meta.total).toBe(5); + }); +}); + +describe("関連テーブルの引き直し(指示書AC 段階2 D-4 / §E-5)", () => { + it("★履歴は本清掃の id、エラー報告は事前チェック行の id で引く(全件読みに戻さない)★", async () => { + await get("?date_from=2026-09-10&date_to=2026-09-10"); + + // ★`select` の無い1本 = `cleanLogs()` 本体の引き直し★(`select` 付きは連続日数用。 + // 取得元が `clean_task_reports` になって連続日数用も `where` を持つので、`where` だけでは区別できない) + // ★予定外の行の読み取り(`include` 付き。指示書BG)は `clean_timetable_id` の `in` を持たないので除く★ + const reportCall = calls.cleanTaskReport.find( + (c) => "where" in c && !("select" in c) && !("include" in c) + ); + expect(reportCall).toBeDefined(); + const historyWhere = ( + reportCall as { + where: { clean_timetable_id: { in: bigint[] } }; + } + ).where; + expect(historyWhere.clean_timetable_id.in.map(String).sort()).toEqual( + ["103", "201", "301"].sort() + ); + + const errorWhere = ( + calls.robotErrorReport[0] as { where: { clean_timetable_id: { in: bigint[] } } } + ).where; + expect(errorWhere.clean_timetable_id.in.map(String).sort()).toEqual( + ["1031", "1032"].sort() + ); + }); + + it("★事前チェック行は `parent_id` で別クエリに分けて引く★", async () => { + await get("?date_from=2026-09-10&date_to=2026-09-10"); + const precheckCall = calls.cleanTimetable.find( + (c) => "where" in c && "parent_id" in (c.where as object) + ); + expect(precheckCall).toBeDefined(); + expect((precheckCall as { select: object }).select).toEqual({ + id: true, + parent_id: true, + precheck: true, + }); + }); + + it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => { + const body = await get("?date_from=2026-01-01&date_to=2026-01-02"); + expect(body.items).toEqual([]); + expect(calls.robotErrorReport).toEqual([]); + // ★レポートの引き直しは0本(本清掃・連続日数用とも `in` の塊が0個)★。 + // ★予定外の行の読み取り(`clean_timetable_id` の `in` を持たない。指示書BG)は予定の有無によらず撃つ★ので数えない + expect( + calls.cleanTaskReport.filter( + (c) => "where" in c && "clean_timetable_id" in (c.where as Record) + ) + ).toEqual([]); + expect(calls.cleanTaskReport.filter((c) => "include" in c)).toHaveLength(1); + }); +}); + +describe("清掃結果 `task_status` の取得元(指示書AQ §1-1 / §1-2 / §5-2 / 落とし穴1)", () => { + it("★`clean_task_reports.status` の生値をそのまま返す(変換しない)★", async () => { + data.reports = data.reports.map((r) => + r.clean_timetable_id === BigInt(201) ? { ...r, status: 6 } : r + ); + const body = await get(""); + expect(body.items.find((i) => i.id === 201)?.task_status).toBe(6); + expect(body.items.find((i) => i.id === 103)?.task_status).toBe(4); + }); + + it("★レポートの行が無い本清掃 → `task_status` は null・出発失敗・完了率 null★(§1-1)", async () => { + data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201)); + const body = await get(""); + const item = body.items.find((i) => i.id === 201); + // ★行ごと消えない(出発失敗として出る)★ + expect(item).toBeDefined(); + expect(item?.task_status).toBeNull(); + expect(item?.departed).toBe(false); + expect(item?.completion_rate).toBeNull(); + }); + + it("★★`clean_timetable_id` が NULL のレポートはどの清掃にも紐づかない★★(落とし穴1)", async () => { + data.reports = data.reports.map((r) => + r.clean_timetable_id === BigInt(201) ? { ...r, clean_timetable_id: null } : r + ); + const body = await get(""); + const item = body.items.find((i) => i.id === 201); + expect(item).toBeDefined(); + expect(item?.task_status).toBeNull(); + expect(item?.departed).toBe(false); + // ★他の清掃の結果を上書きしない★ + expect(body.items.find((i) => i.id === 301)?.completion_rate).toBe(80); + }); +}); + +describe("★#9 fixtures 版と Prisma 版が同じ引数で同じ結果を返す★(指示書AC D-5 / 禁止事項6)", () => { + /** + * ★同じデータに対して★、 + * - Prisma 版 = ★SQL で絞った結果★ + * - fixtures 版 = ★全件版 + `filterFixtureCleanLogs()`★ + * を突き合わせる(指示書AC G-3「等価テスト」)。 + * ★`null` と `[]` の両方で比べること★(罠9)。 + */ + it("日付範囲: SQL版 = 全件版 + fixtures 版の絞り込み", async () => { + const all = await get(""); + const sql = await get("?date_from=2026-09-09&date_to=2026-09-10"); + const viaFixtures = filterFixtureCleanLogs(all.items, { + id: null, + date_from: "2026-09-09", + date_to: "2026-09-10", + facility_ids: null, + }); + expect(sql.items).toEqual(viaFixtures); + expect(sql.items.length).toBeGreaterThan(0); + }); + + it("`facility_ids: null`(施設名なし): 件数が一致", async () => { + const all = await get(""); + expect( + filterFixtureCleanLogs(all.items, { + id: null, + date_from: null, + date_to: null, + facility_ids: null, + }) + ).toHaveLength(all.items.length); + }); + + it("★`facility_ids: []`: どちらも0件★(★片方だけ全件に化けないこと★)", async () => { + const all = await get(""); + const sql = await get(`?facility_name=${encodeURIComponent("存在しない店名")}`); + const viaFixtures = filterFixtureCleanLogs(all.items, { + id: null, + date_from: null, + date_to: null, + facility_ids: [], + }); + expect(sql.items).toEqual([]); + expect(viaFixtures).toEqual([]); + }); + + it("施設IDで絞った場合も一致", async () => { + const all = await get(""); + const sql = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`); + const viaFixtures = filterFixtureCleanLogs(all.items, { + id: null, + date_from: null, + date_to: null, + facility_ids: [1, 2], + }); + expect(sql.items).toEqual(viaFixtures); + }); +}); + +describe("GET /clean-logs — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => { + it("★robot_id が NULL の行を混ぜても落ちず、同じ行が出る★(select で robot_id / robot を読まない)", async () => { + const before = await get(""); + data.errorReports = data.errorReports.map((r) => ({ ...r, robot_id: null })); + const res = await GET(new Request("http://localhost/api/clean-logs")); + expect(res.status).toBe(200); + const after = (await res.json()) as ListResponse; + expect(after.items).toEqual(before.items); + }); +}); + +describe("GET /clean-logs — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => { + it("★履歴だけの予定が出る・両方にある同じ予定は1件★。2表に同じ引数が渡る", async () => { + data.historyTimetables = [ + // ★履歴だけの本清掃(9/11)とその事前チェック行★ + timetable(401, 3, FACILITY_3, "2026-09-11T02:00:00+09:00"), + timetable(4011, 3, FACILITY_3, "2026-09-11T01:00:00+09:00", 60, 401), + // ★両方にある同じ予定(中身も同じ)★ + timetable(103, 1, FACILITY_1, "2026-09-10T02:00:00+09:00"), + ]; + data.reports.push(report(401, 80, "2026-09-11T03:00:00+09:00")); + + const body = await get(""); + expect(body.items.map((i) => i.id)).toEqual([401, 301, 103, 201, 102, 101]); + expect(body.items.find((i) => i.id === 401)?.completion_rate).toBe(80); + + // ★本清掃の主クエリ(`include` 付き)は2表に同じ引数★ + const mainCall = calls.cleanTimetable.find((c) => "include" in c); + const historyCall = calls.cleanTimetableHistory.find((c) => "include" in c); + expect(historyCall).toEqual(mainCall); + // ★事前チェック行は合わせた後の本清掃の id(履歴の 401 を含む)で引く★ + const precheckHistoryCall = calls.cleanTimetableHistory.find( + (c) => "where" in c && "parent_id" in (c.where as object) + ) as { where: { parent_id: { in: bigint[] } } }; + expect(precheckHistoryCall.where.parent_id.in.map(String)).toContain("401"); + }); +}); + +/** + * ★出発失敗はアラートに出ない(ユーザー判断 2026-10-29「2で進めてください」)★ — + * 同じデータ(このファイルのスタブ)で、清掃詳細ログには出発失敗(未実施)として出て、 + * アラート(`GET /prechecks`)には行が出ないことを突き合わせる。 + * ★判定は `notExecutedFromReport()` の1本のまま★(清掃詳細ログの `departed`)。アラートはその判定を使わない。 + */ +describe("出発失敗は清掃詳細ログに出て、アラートには出ない(2026-10-29)", () => { + beforeEach(() => { + // ★9/9 02:00 の本清掃(102)からレポートを外す=確定した出発失敗(now は 9/30)★ + data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(102)); + // ★本清掃 103 の事前チェック(1031)のエラーを1件、アラートに出る形で足す★(アラートが空振りしていないことの確認) + data.errorReports.push({ + error_id: "E-PRE-1", + clean_timetable_id: BigInt(1031), + error_type: "EmergencyStop", + errored_at: dbWallClock("2026-09-10T01:06:00+09:00"), + created_at: dbWallClock("2026-09-10T01:06:00+09:00"), + serial_number: "8111R562806001", + facility_id: BigInt(1), + facility: { facility_name: FACILITY_1, deleted_at: null }, + section: { deleted_at: null }, + status: 0, + responded_at: null, + resolved_at: null, + comment: null, + } as StubErrorReport); + }); + + async function getPrechecks(search: string): Promise> { + const res = await GET_PRECHECKS(new Request(`http://localhost/api/prechecks${search}`)); + expect(res.status).toBe(200); + return (await res.json()) as SummaryListResponse; + } + + it("★清掃詳細ログ: 102 は出発失敗(未実施)★", async () => { + const item = (await get("")).items.find((i) => i.id === 102); + expect(item).toMatchObject({ + departed: false, + task_status: null, + completion_rate: null, + result_pending: false, + grade: "×", + }); + }); + + it("★アラート: 102 の行は無い。行は事前チェック異常だけ(summary・meta.total も)★", async () => { + const body = await getPrechecks("?status=all"); + expect(body.items.map((i) => i.id)).not.toContain("102"); + expect(body.items.map((i) => i.id)).toEqual(["E-PRE-1"]); + expect(body.items.every((i) => i.severity === "precheck")).toBe(true); + expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 }); + expect(body.meta.total).toBe(1); + + // ★9/9 だけに絞ると、出発失敗(102)があってもアラートは0件★ + const sep9 = await getPrechecks("?date_from=2026-09-09&date_to=2026-09-09&status=all"); + expect(sep9.items).toEqual([]); + expect(sep9.summary.total).toBe(0); + // 同じ日の清掃詳細ログには出発失敗として出る + const log9 = await get("?date_from=2026-09-09&date_to=2026-09-09"); + expect(log9.items.find((i) => i.id === 102)?.departed).toBe(false); + }); +}); + +describe("GET /clean-logs — 結果待ち `result_pending`(D-173。表1・表2 C1)", () => { + // ★施設2の 9/10 02:00〜03:00 の清掃(id 201)からレポートを外し、now を動かす★ + beforeEach(() => { + data.reports = data.reports.filter((r) => r.clean_timetable_id !== BigInt(201)); + }); + + it("★now=04:59(終了+120分より前)→ R: `result_pending: true`。出発失敗の値は変えない★", async () => { + vi.setSystemTime(new Date("2026-09-10T04:59:00+09:00")); + const body = await get(""); + const item = body.items.find((i) => i.id === 201); + expect(item).toMatchObject({ + result_pending: true, + departed: false, + task_status: null, + completion_rate: null, + grade: "×", + }); + // ★レポートがある行(S)は false★ + expect(body.items.find((i) => i.id === 103)?.result_pending).toBe(false); + // ★結果待ちは連続日数に数えない(表2 C10)★ + expect(item?.consecutive_problem_days).toBe(0); + expect(item?.escalation_level).toBeNull(); + }); + + it("★now=05:00(終了+120分ちょうど)→ F: `result_pending: false`(確定した出発失敗。表1 P6)★", async () => { + vi.setSystemTime(new Date("2026-09-10T05:00:00+09:00")); + const body = await get(""); + const item = body.items.find((i) => i.id === 201); + expect(item).toMatchObject({ result_pending: false, departed: false, completion_rate: null }); + expect(item?.consecutive_problem_days).toBe(1); + }); + + it("★清掃中(now=02:30。開始以降・終了前)も結果待ち(表1 P4)★", async () => { + vi.setSystemTime(new Date("2026-09-10T02:30:00+09:00")); + const body = await get(""); + expect(body.items.find((i) => i.id === 201)?.result_pending).toBe(true); + }); +}); + +describe("GET /clean-logs — 事前チェックの列は分数ではなく順番で割り当てる(D-178。ユーザー判断 2026-10-16)", () => { + // ★本清掃 103 の事前チェック行(1031: 60分前・EmergencyStop / 1032: 30分前・エラー無し)の分数だけを差し替える★ + function setPrecheckMinutes(byId: Record): void { + data.timetables = data.timetables.map((t) => + byId[Number(t.id)] === undefined ? t : { ...t, precheck: byId[Number(t.id)] } + ); + } + async function row103(): Promise { + const r = (await get("")).items.find((i) => i.id === 103); + if (!r) throw new Error("清掃 103 が無い"); + return r; + } + function precheckAnomalies() { + return vi + .mocked(logEvent) + .mock.calls.filter( + ([, p]) => p.event === "data.anomaly" && p.metric === "precheck_rounds_per_main_run" + ); + } + + it("60 / 30(設定どおり)→ 1回目 EmergencyStop・60、2回目 Standby.・30", async () => { + const r = await row103(); + expect(r).toMatchObject({ + status_1h: "EmergencyStop", + status_1h_minutes: 60, + status_30m: "Standby.", + status_30m_minutes: 30, + }); + expect(precheckAnomalies()).toHaveLength(0); + }); + + it("★60 / 10(設定と違う分数)でも10分前の結果を落とさない★(旧: 2列目が null で「-」だった)", async () => { + setPrecheckMinutes({ 1032: 10 }); + expect(await row103()).toMatchObject({ + status_1h: "EmergencyStop", + status_1h_minutes: 60, + status_30m: "Standby.", + status_30m_minutes: 10, + }); + }); + + it("★事前チェックが1回(30分前)だけなら1回目に入り、2回目は null★", async () => { + data.timetables = data.timetables.filter((t) => Number(t.id) !== 1031); + expect(await row103()).toMatchObject({ + status_1h: "Standby.", + status_1h_minutes: 30, + status_30m: null, + status_30m_minutes: null, + }); + }); + + it("事前チェックの無い本清掃は4つとも null", async () => { + const r = (await get("")).items.find((i) => i.id === 101); + expect(r).toMatchObject({ + status_1h: null, + status_1h_minutes: null, + status_30m: null, + status_30m_minutes: null, + }); + }); + + it("★3つあれば開始に近い2つを残し、捨てた分数を `data.anomaly` に1回出す★(route / method は載せない)", async () => { + data.timetables.push(timetable(1033, 1, FACILITY_1, "2026-09-10T00:30:00+09:00", 90, 103)); + expect(await row103()).toMatchObject({ + status_1h: "EmergencyStop", + status_1h_minutes: 60, + status_30m: "Standby.", + status_30m_minutes: 30, + }); + const events = precheckAnomalies(); + expect(events).toHaveLength(1); + expect(events[0][0]).toBe("warn"); + expect(events[0][1]).toEqual({ + event: "data.anomaly", + metric: "precheck_rounds_per_main_run", + expected: 2, + actual: 3, + clean_timetable_id: "103", + dropped_precheck_minutes: [90], + }); + }); + + it("★判定(出発・完了率・判定)は事前チェックの割り当てで変わらない★(D-170)", async () => { + const before = await row103(); + setPrecheckMinutes({ 1031: 2, 1032: 1 }); + const after = await row103(); + expect(after).toMatchObject({ status_1h_minutes: 2, status_30m_minutes: 1 }); + expect({ + departed: after.departed, + completion_rate: after.completion_rate, + grade: after.grade, + consecutive_problem_days: after.consecutive_problem_days, + }).toEqual({ + departed: before.departed, + completion_rate: before.completion_rate, + grade: before.grade, + consecutive_problem_days: before.consecutive_problem_days, + }); + }); +}); + +describe("GET /clean-logs — 有効なロボットがいない清掃エリア(指示書BB U2・表2)", () => { + /** `data.anomaly` のうち、清掃エリアのロボット台数のものだけ */ + function robotAnomalies() { + return vi + .mocked(logEvent) + .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "active_robots_per_section") + .map(([, p]) => p); + } + + it("★R2: 0台でも例外にならず、その行は出る。機種・シリアルは null。ログは清掃エリアごとに1回★", async () => { + // 施設1(清掃エリア 10)の本清掃3件(101〜103)からロボットを外す + for (const t of data.timetables) { + if (Number(t.facility_id) === 1) t.section = { robots: [] }; + } + const res = await GET(new Request("http://localhost/api/clean-logs")); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + expect(body.items.map((i) => [i.id, i.model_name, i.serial_number])).toEqual([ + [301, "PUDU CC1", "8111R562806003"], + [103, null, null], + [201, "PUDU CC1", "8111R562806002"], + [102, null, null], + [101, null, null], + ]); + // ★同じ清掃エリアの行が3行あっても1回★ + expect(robotAnomalies()).toEqual([ + { event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 0, section_id: "10" }, + ]); + }); + + it("R1: 1台なら今どおり(ログを出さない)。`robots` の並びは `id` 昇順を SQL に頼む", async () => { + await get(""); + expect(robotAnomalies()).toHaveLength(0); + const include = calls.cleanTimetable.find((c) => "include" in c)?.include as { + section: { include: { robots: { orderBy: unknown } } }; + }; + expect(include.section.include.robots.orderBy).toEqual({ id: "asc" }); + }); + + it("★R5: 有効なロボット2台(id 10 と id 7)→ id 7 を採る。ログ(actual: 2)は清掃エリアごとに1回★", async () => { + for (const t of data.timetables) { + if (Number(t.facility_id) !== 3) continue; + t.section = { + robots: [ + { id: BigInt(10), serial_number: "SERIAL-ID10", device: { maker_id: 1, model_name: "CC1" } }, + { id: BigInt(7), serial_number: "SERIAL-ID7", device: { maker_id: 1, model_name: "CC1" } }, + ], + }; + } + const body = await get(""); + expect(body.items.find((i) => i.id === 301)?.serial_number).toBe("SERIAL-ID7"); + expect(robotAnomalies()).toEqual([ + { event: "data.anomaly", metric: "active_robots_per_section", expected: 1, actual: 2, section_id: "30" }, + ]); + }); + + it("★R7: 0台の行に `serial=` / `model=` で絞ると当たらない(null は部分一致しない)。絞り込みなしなら出る★", async () => { + for (const t of data.timetables) { + if (Number(t.facility_id) === 1) t.section = { robots: [] }; + } + const bySerial = await get("?serial=8111R"); + expect(bySerial.items.map((i) => i.id)).toEqual([301, 201]); + const byModel = await get(`?model=${encodeURIComponent("pudu")}`); + expect(byModel.items.map((i) => i.id)).toEqual([301, 201]); + expect((await get("")).items.map((i) => i.id)).toEqual([301, 103, 201, 102, 101]); + }); +}); + +describe("GET /clean-logs — 削除済みの施設・清掃エリアの行は出さない(指示書BB U1・表1 V1)", () => { + const DELETED = dbWallClock("2026-09-01T00:00:00+09:00"); + + it("★V1: 予定の2表の `where` に施設・清掃エリアの可視性が入る★", async () => { + await get(""); + expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + const history = calls.cleanTimetableHistory.find((c) => "include" in c) as { where: Record }; + expect(history.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + }); + + it.each([ + ["施設削除", (t: StubTimetable) => ({ ...t, facility: { ...t.facility, deleted_at: DELETED } })], + ["エリア削除", (t: StubTimetable) => ({ ...t, section: { ...t.section, deleted_at: DELETED } })], + ] as const)("V1(%s): その行は出ない・meta.total に数えない(生きている行は出る)", async (_label, mark) => { + data.timetables = data.timetables.map((t) => (Number(t.id) === 301 ? mark(t) : t)); + const body = await get(""); + expect(body.items.map((i) => i.id)).toEqual([103, 201, 102, 101]); + expect(body.meta.total).toBe(4); + }); +}); + +describe("GET /clean-logs — 存在しない日付は422(指示書BB 表8 Q6)", () => { + it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => { + const bad = await GET(new Request("http://localhost/api/clean-logs?date_from=2026-02-31")); + const format = await GET(new Request("http://localhost/api/clean-logs?date_from=2026/02/03")); + expect(bad.status).toBe(422); + expect(format.status).toBe(422); + expect(await bad.json()).toEqual(await format.json()); + }); +}); + +/** + * ★表10 N5(指示書BB A6・D16)★ — 施設で絞ったとき、`cleanLogs()` は連続日数の入力(`loadConsecutiveInputs()`)にも + * 同じ施設IDを渡す。★応答の `consecutive_problem_days`・`escalation_level` は絞る前と同じ★。 + * (ルートの分類の解決はこのテストのスタブでは施設名で行う — 解決の経路は違っても渡る配列は同じ `query.facility_ids`) + */ +describe("GET /clean-logs — 連続日数の入力も施設で絞る(指示書BB 表10 N5)", () => { + /** 連続日数用の予定のクエリ(`precheck` を読む select 付き) */ + function consecutiveTimetableCalls() { + return calls.cleanTimetable.filter((c) => { + const select = c.select as Record | undefined; + return select !== undefined && select.precheck === true && select.facility_id === true; + }); + } + + it("★施設名で施設1・2に絞る → 連続日数の入力の `where` に `facility_id: { in: [1n, 2n] }`。値は絞る前と同じ★", async () => { + const before = await get(""); + const expected = new Map(before.items.map((i) => [i.id, [i.consecutive_problem_days, i.escalation_level]])); + calls.cleanTimetable = []; + + const scoped = await get(`?facility_name=${encodeURIComponent("ドソキホーテ")}`); + const consecutive = consecutiveTimetableCalls(); + expect(consecutive).toHaveLength(1); + expect((consecutive[0].where as Record).facility_id).toEqual({ in: [BigInt(1), BigInt(2)] }); + expect(scoped.items.length).toBeGreaterThan(0); + for (const item of scoped.items) { + expect([item.consecutive_problem_days, item.escalation_level]).toEqual(expected.get(item.id)); + } + expect(scoped.items.find((i) => i.id === 103)?.consecutive_problem_days).toBe(3); + }); + + it("施設で絞らない → 連続日数の入力にも `facility_id` の条件を付けない", async () => { + await get(""); + const consecutive = consecutiveTimetableCalls(); + expect(consecutive).toHaveLength(1); + expect(consecutive[0].where).not.toHaveProperty("facility_id"); + }); +}); + +/** + * ★予定外タスクの行(指示書BG §1・§2-1 / 表U1)★ — `clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行。 + * + * | # | 行 | 期待 | + * |---|---|---| + * | U1-1 | 予定外・`started_at` 9/10 21:30・完了率 95.9 | 1行。`unscheduled` true・`id` null・`row_key` "rp-…"・`departed` true・◎・事前チェック null | + * | U1-2 | 予定外・`clean_area` 0 | 完了率 0・× ・出発 true(未実施ではない) | + * | U1-3 | `clean_timetable_id` = 0 と NULL | どちらも予定外として同じ扱い | + * | U1-4 | 予定外・`task_area` 0 | 完了率 0 | + * | U1-5 | 予定外・削除済みの施設(清掃エリア) | 出さない・数えない | + * | U1-6 | 予定のある行 | 今と同じ(`unscheduled` false・`row_key` "tt-") | + * + * ★日付は表の「10-28 21:00」の代わりに、このファイルの合成データ(9/10)に合わせた★(扱いは同じ) + */ +describe("予定外タスクの行(指示書BG 表U1)", () => { + /** 予定外の清掃結果1行(壁時計値。完了率は `clean_area ÷ task_area × 100`) */ + function unscheduledReport( + reportId: string, + facilityId: number, + facilityName: string, + startedJstIso: string, + cleanArea: number, + taskArea: number, + timetableId: bigint | null = null, + status = 4 + ): StubReport { + const startedAt = dbWallClock(startedJstIso); + return { + report_id: reportId, + facility_id: BigInt(facilityId), + section_id: BigInt(facilityId * 10), + clean_timetable_id: timetableId, + clean_ratio: 0, + status, + started_at: startedAt, + ended_at: new Date(startedAt.getTime() + 60 * 60_000), + clean_area: cleanArea, + task_area: taskArea, + facility: { facility_name: facilityName, deleted_at: null }, + section: { + deleted_at: null, + robots: [{ serial_number: `8111R56280600${facilityId}`, device: { maker_id: 1, model_name: "CC1" } }], + }, + }; + } + const RP1 = "9200000000000000001"; + const RP2 = "9200000000000000002"; + + it("U1-1: 予定外の1行 — unscheduled true・id null・row_key rp-…・出発 true・◎・事前チェック null・scheduled_at は started_at", async () => { + data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000)); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const item = body.items.find((i) => i.unscheduled); + expect(item).toMatchObject({ + id: null, + row_key: `rp-${RP1}`, + report_id: RP1, + unscheduled: true, + facility_id: 3, + facility_name: FACILITY_3, + model_name: "PUDU CC1", + serial_number: "8111R562806003", + scheduled_at: "2026-09-10T21:30:00+09:00", + status_1h: null, + status_1h_minutes: null, + status_30m: null, + status_30m_minutes: null, + departed: true, + task_status: 4, + result_pending: false, + completion_rate: 95.9, + grade: "◎", + }); + // ★予定のある行と一緒に `scheduled_at` の新しい順で並び、`meta.total` にも数える★(9/10 の予定3行 + 予定外1行) + expect(body.items[0].row_key).toBe(`rp-${RP1}`); + expect(body.meta.total).toBe(4); + }); + + it("U1-2: 予定外・clean_area 0 → 完了率 0・×・出発 true(未実施ではない)", async () => { + data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 0, 800, null, 6)); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const item = body.items.find((i) => i.unscheduled); + expect(item).toMatchObject({ completion_rate: 0, grade: "×", departed: true, task_status: 6, result_pending: false }); + }); + + it("U1-3: clean_timetable_id が 0 と NULL のどちらも予定外。0 でも NULL でもないレポートは予定外の行にしない", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null), + unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0)), + // ★予定の id を持つ(予定の表に行が無い)レポート★ — 予定外の行にしない + unscheduledReport("9200000000000000003", 3, FACILITY_3, "2026-09-10T23:00:00+09:00", 500, 1000, BigInt(999)) + ); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const unscheduled = body.items.filter((i) => i.unscheduled); + expect(unscheduled.map((i) => i.row_key)).toEqual([`rp-${RP2}`, `rp-${RP1}`]); + expect(unscheduled.map((i) => [i.completion_rate, i.grade, i.departed])).toEqual([ + [50, "△", true], + [50, "△", true], + ]); + }); + + it("U1-4: 予定外・task_area 0 → 完了率 0(`runCompletionRate()` と同じ)", async () => { + data.reports.push(unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 300, 0)); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + expect(body.items.find((i) => i.unscheduled)).toMatchObject({ completion_rate: 0, grade: "×", departed: true }); + }); + + it("U1-5: 削除済みの施設・清掃エリアの予定外は出さない・数えない", async () => { + const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000); + deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") }; + const deletedSection = unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 959, 1000); + deletedSection.section = { + robots: deletedSection.section?.robots ?? [], + deleted_at: dbWallClock("2026-09-01T00:00:00+09:00"), + }; + data.reports.push(deletedFacility, deletedSection); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + expect(body.items.some((i) => i.unscheduled)).toBe(false); + expect(body.meta.total).toBe(3); + // ★SQL の `where` にも可視性の条件が載っている★ + const call = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record }; + expect(call.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + }); + + it("U1-6: 予定のある行は今と同じ(unscheduled false・row_key tt-・report_id null)", async () => { + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const item = body.items.find((i) => i.id === 103); + expect(item).toMatchObject({ unscheduled: false, row_key: "tt-103", report_id: null }); + expect(body.items.every((i) => !i.unscheduled)).toBe(true); + }); + + it("期間は started_at(JST の日付)で絞る・施設の絞り込みも効く", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-09T21:30:00+09:00", 959, 1000), + unscheduledReport(RP2, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000) + ); + const day10 = await get("?date_from=2026-09-10&date_to=2026-09-10"); + expect(day10.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]); + const facility3 = await get(`?facility_name=${encodeURIComponent(FACILITY_3)}`); + expect(facility3.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP1}`]); + }); + + it("予定外の行の連続日数・L1〜L3 は、施設のその日の値(同じ施設・同じ日の予定のある行と同じ)", async () => { + // 施設1 の 9/10 は 9/8・9/9・9/10 の × で3日連続(L3)。同じ日の予定外 ◎ も同じ値になる + data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)); + const body = await get("?date_from=2026-09-10&date_to=2026-09-10"); + const item = body.items.find((i) => i.unscheduled); + expect(item).toMatchObject({ grade: "◎", consecutive_problem_days: 3, escalation_level: "L3" }); + }); + + it("result=problem の絞り込みも予定のある行と同じ(判定で絞る)", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000), + unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000) + ); + const problem = await get("?date_from=2026-09-10&date_to=2026-09-10&result=problem"); + expect(problem.items.filter((i) => i.unscheduled).map((i) => i.row_key)).toEqual([`rp-${RP2}`]); + }); + + it("単一ID(`/clean-logs/{id}/*`)の取得では予定外の行を読まない", async () => { + data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)); + const rows = await db.cleanLogs({ id: 103, date_from: null, date_to: null, facility_ids: null }); + expect(rows.map((r) => r.row_key)).toEqual(["tt-103"]); + expect(calls.cleanTaskReport.filter((c) => "include" in c)).toEqual([]); + }); + + async function unscheduledAdvice(reportId: string): Promise { + return GET_UNSCHEDULED_ADVICE( + new Request( + `http://localhost/api/clean-logs/unscheduled/${encodeURIComponent(reportId)}/advice` + ), + { params: Promise.resolve({ report_id: reportId }) } + ); + } + + it("A-5: db.unscheduledCleanLog は一覧の予定なし行と toEqual で一致", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000), + unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000, null, 6) + ); + const list = await db.cleanLogs({ + id: null, + date_from: null, + date_to: null, + facility_ids: null, + }); + // ★予定なしの行が RP1・RP2 のちょうど2件★(0件だとループが空回りして素通りするため、先に件数と中身を固める) + const unscheduledRows = list.filter((r) => r.unscheduled); + expect(unscheduledRows.map((r) => r.report_id).sort()).toEqual([RP1, RP2].sort()); + for (const row of unscheduledRows) { + const one = await db.unscheduledCleanLog(row.report_id!); + expect(one).toEqual(row); + } + }); + + it("A-6: 施設1・9/10 の予定外 ◎ — consecutive 3・L3", async () => { + data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)); + const row = await db.unscheduledCleanLog(RP1); + expect(row).toMatchObject({ grade: "◎", consecutive_problem_days: 3, escalation_level: "L3" }); + }); + + it("A-7: 予定のあるレポートの report_id は null(例外にならない)", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, BigInt(103)) + ); + expect(await db.unscheduledCleanLog(RP1)).toBeNull(); + }); + + it("A-8: clean_timetable_id が 0 と NULL のどちらも行を返す", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null), + unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0)) + ); + expect(await db.unscheduledCleanLog(RP1)).not.toBeNull(); + expect(await db.unscheduledCleanLog(RP2)).not.toBeNull(); + }); + + it("A-9: 削除済みの施設の予定外は null・where に可視性と予定なし条件", async () => { + const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000); + deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") }; + data.reports.push(deletedFacility); + expect(await db.unscheduledCleanLog(RP1)).toBeNull(); + const call = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record }; + expect(call.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + expect(call.where).toMatchObject(UNSCHEDULED_TIMETABLE_ID_WHERE); + }); + + it("A-10: 連続日数の読み込みは行の JST 日付と施設に絞る", async () => { + data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)); + calls.cleanTimetable.length = 0; + calls.cleanTaskReport.length = 0; + await db.unscheduledCleanLog(RP1); + const d = "2026-09-10"; + const lookback = consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax()); + const timetableConsecutive = calls.cleanTimetable.find( + (c) => "select" in c && (c.select as Record).facility_id === true + ) as { where: { start_at: { gte: Date; lt: Date }; facility_id: { in: bigint[] } } }; + expect(timetableConsecutive).toBeDefined(); + expect(timetableConsecutive.where.start_at.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z"); + expect(timetableConsecutive.where.start_at.gte.toISOString().slice(0, 10)).toBe( + new Date(Date.parse(`${d}T00:00:00Z`) - lookback * 86_400_000).toISOString().slice(0, 10) + ); + expect(timetableConsecutive.where.facility_id).toEqual({ in: [BigInt(1)] }); + + const reportConsecutive = calls.cleanTaskReport.find( + (c) => "select" in c && (c.select as Record).facility_id === true + ) as { where: { started_at: { gte: Date; lt: Date }; facility_id: { in: bigint[] } } }; + expect(reportConsecutive).toBeDefined(); + expect(reportConsecutive.where.started_at.lt.toISOString()).toBe("2026-09-11T00:00:00.000Z"); + expect(reportConsecutive.where.started_at.gte.toISOString().slice(0, 10)).toBe( + timetableConsecutive.where.start_at.gte.toISOString().slice(0, 10) + ); + expect(reportConsecutive.where.facility_id).toEqual({ in: [BigInt(1)] }); + }); + + it("A-6′: 施設1・9/10 の予定外 ◎ — 200・R3/R4・max_level 3", async () => { + data.reports.push(unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)); + const res = await unscheduledAdvice(RP1); + expect(res.status).toBe(200); + const body = (await res.json()) as { + escalation_level: string; + consecutive_problem_days: number; + consecutive_problem_days_capped: boolean; + max_level: number; + advices: { rule: string }[]; + }; + expect(body.escalation_level).toBe("L3"); + expect(body.consecutive_problem_days).toBe(3); + expect(body.consecutive_problem_days_capped).toBe(false); + expect(body.advices.map((a) => a.rule)).toEqual(["R3", "R4"]); + expect(body.max_level).toBe(3); + }); + + it("A-7′: 予定のあるレポートの report_id は 404", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, BigInt(103)) + ); + const res = await unscheduledAdvice(RP1); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ + error: { code: "not_found", message: "指定された清掃ログが見つかりません" }, + }); + }); + + it("A-8′: clean_timetable_id 0 と NULL の行は 200", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null), + unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 500, 1000, BigInt(0)) + ); + expect((await unscheduledAdvice(RP1)).status).toBe(200); + expect((await unscheduledAdvice(RP2)).status).toBe(200); + }); + + it("A-9′: 削除済みの施設の予定外は 404", async () => { + const deletedFacility = unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 959, 1000); + deletedFacility.facility = { facility_name: FACILITY_3, deleted_at: dbWallClock("2026-09-01T00:00:00+09:00") }; + data.reports.push(deletedFacility); + const res = await unscheduledAdvice(RP1); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ + error: { code: "not_found", message: "指定された清掃ログが見つかりません" }, + }); + }); + + it("A-11: 末尾空白付き report_id は 404(スタブは行を返すが JS の === で弾く)", async () => { + data.reports.push(unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000)); + const padded = `${RP2} `; + expect(padded.length).toBe(20); + const res = await unscheduledAdvice(padded); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ + error: { code: "not_found", message: "指定された清掃ログが見つかりません" }, + }); + const row = data.reports.find((r) => r.report_id === RP2)!; + const includeCall = calls.cleanTaskReport.find((c) => "include" in c) as { where: Record }; + expect(matchesWhere(row as unknown as Record, includeCall.where)).toBe(true); + }); + + it("A-12: loadAdviceRules 例外 → 500・logApiError", async () => { + data.reports.push(unscheduledReport(RP2, 3, FACILITY_3, "2026-09-10T22:30:00+09:00", 0, 1000)); + vi.mocked(logApiError).mockClear(); + const broken: JsonStore = { + read: async () => { + throw new Error("[stub] 読み込みに失敗"); + }, + write: async () => {}, + }; + __setAdviceStoreForTesting(broken); + try { + const res = await unscheduledAdvice(RP2); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ + route: "/api/clean-logs/unscheduled/[report_id]/advice", + method: "GET", + status: 500, + }); + } finally { + __setAdviceStoreForTesting(null); + } + }); + + it("A-13: 200 時 judge.advice に report_id のみ(clean_log_id キーなし)", async () => { + data.reports.push( + unscheduledReport(RP1, 3, FACILITY_3, "2026-09-10T21:30:00+09:00", 500, 1000, null) + ); + vi.mocked(logEvent).mockClear(); + const res = await unscheduledAdvice(RP1); + expect(res.status).toBe(200); + const adviceCall = vi.mocked(logEvent).mock.calls.find( + (c) => (c[1] as { event?: string }).event === "judge.advice" + ); + expect(adviceCall).toBeDefined(); + const payload = adviceCall![1] as unknown as Record; + expect(payload).toMatchObject({ + route: "/api/clean-logs/unscheduled/[report_id]/advice", + report_id: RP1, + }); + expect("clean_log_id" in payload).toBe(false); + const escalationCall = vi.mocked(logEvent).mock.calls.find( + (c) => (c[1] as { event?: string }).event === "judge.escalation" + ); + expect(escalationCall?.[1]).toMatchObject({ + route: "/api/clean-logs/unscheduled/[report_id]/advice", + }); + }); +}); diff --git a/app/api/clean-logs/__tests__/sort-route.test.ts b/app/api/clean-logs/__tests__/sort-route.test.ts new file mode 100644 index 0000000..d887a4d --- /dev/null +++ b/app/api/clean-logs/__tests__/sort-route.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/api/clean-logs/route"; +import { bhS1RowsDefaultOrder } from "@/lib/clean-logs/__tests__/bh-s1-fixture"; +import type { CleanLogItem, ListResponse } from "@/lib/types"; + +let rows: CleanLogItem[] = []; + +vi.mock("@/lib/db", () => ({ + db: { + cleanLogs: async () => rows, + facilityNameIndex: async () => [], + }, +})); + +vi.mock("@/lib/settings/display-limits", () => ({ + loadConsecutiveDaysMax: async () => 90, +})); + +async function get(query: string): Promise> { + const res = await GET(new Request(`http://localhost/api/clean-logs?${query}`)); + return (await res.json()) as ListResponse; +} + +describe("GET /clean-logs — 並べ替えとページング(指示書BH 表S2 S2-10)", () => { + beforeEach(() => { + rows = bhS1RowsDefaultOrder(); + }); + + it("S2-10: sort=grade&order=asc・page=2 — 2ページ目にも並べ替えが効く", async () => { + const body = await get("sort=grade&order=asc&per_page=3&page=2&result=all"); + expect(body.meta.total).toBe(8); + expect(body.meta.page).toBe(2); + expect(body.items.map((i) => i.row_key)).toEqual(["tt-107", "tt-105", "tt-101"]); + }); + + it("S2-10b: result=problem で絞った件数の page=2", async () => { + const body = await get("result=problem&sort=grade&order=asc&per_page=2&page=2"); + expect(body.meta.total).toBe(3); + expect(body.meta.page).toBe(2); + expect(body.items.map((i) => i.row_key)).toEqual(["tt-102"]); + }); +}); + +describe("GET /clean-logs — schedule 絞り込み(指示書BH 表F)", () => { + beforeEach(() => { + rows = bhS1RowsDefaultOrder(); + }); + + function withoutUnscheduled(keys: string[]): string[] { + return keys.filter((k) => k !== "rp-9001"); + } + + it("F-1: result=all&schedule=unscheduled", async () => { + const body = await get("result=all&schedule=unscheduled"); + expect(body.meta.total).toBe(1); + expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]); + }); + + it("F-2: result=all&schedule=scheduled", async () => { + const baseline = await get("result=all"); + const body = await get("result=all&schedule=scheduled"); + expect(body.meta.total).toBe(7); + expect(body.items.map((i) => i.row_key)).toEqual( + withoutUnscheduled(baseline.items.map((i) => i.row_key)) + ); + }); + + it("F-3: result=problem&schedule=scheduled", async () => { + const baseline = await get("result=problem"); + const body = await get("result=problem&schedule=scheduled"); + expect(body.meta.total).toBe(2); + expect(body.items.map((i) => i.row_key)).toEqual( + withoutUnscheduled(baseline.items.map((i) => i.row_key)) + ); + }); + + it("F-4: result=problem&schedule=unscheduled", async () => { + const body = await get("result=problem&schedule=unscheduled"); + expect(body.meta.total).toBe(1); + expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]); + }); + + it("F-5: result=critical&schedule=unscheduled", async () => { + const body = await get("result=critical&schedule=unscheduled"); + expect(body.meta.total).toBe(1); + expect(body.items.map((i) => i.row_key)).toEqual(["rp-9001"]); + }); + + it("F-6: result=all&schedule=scheduled&sort=grade&order=asc&per_page=2&page=1", async () => { + const allSorted = await get("result=all&sort=grade&order=asc&per_page=8&page=1"); + const expectedHead = withoutUnscheduled(allSorted.items.map((i) => i.row_key)).slice( + 0, + 2 + ); + const body = await get( + "result=all&schedule=scheduled&sort=grade&order=asc&per_page=2&page=1" + ); + expect(body.meta.total).toBe(7); + expect(body.items.map((i) => i.row_key)).toEqual(expectedHead); + }); + + it("F-7: schedule=all → 422", async () => { + const res = await GET(new Request("http://localhost/api/clean-logs?schedule=all")); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: { details?: Record } }; + expect(Object.keys(body.error.details ?? {})).toEqual(["schedule"]); + }); + + it("F-8: result=all&schedule= → schedule なしと同じ", async () => { + const baseline = await get("result=all"); + const body = await get("result=all&schedule="); + expect(body.meta.total).toBe(8); + expect(body.items.map((i) => i.row_key)).toEqual( + baseline.items.map((i) => i.row_key) + ); + }); +}); diff --git a/app/api/clean-logs/export/__tests__/route.test.ts b/app/api/clean-logs/export/__tests__/route.test.ts new file mode 100644 index 0000000..003e464 --- /dev/null +++ b/app/api/clean-logs/export/__tests__/route.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、 +// 最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、 +// フルスイート実行時に既定のタイムアウトを踏んで不定期に落ちる +// (`app/api/clean-logs/__tests__/route.test.ts` の実測)。 +// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効く。 +import { GET } from "@/app/api/clean-logs/export/route"; +import { bhS1RowsDefaultOrder } from "@/lib/clean-logs/__tests__/bh-s1-fixture"; +import { MAX_CSV_ROWS } from "@/lib/config"; +import type { ApiError, CleanLogItem } from "@/lib/types"; + +/** + * CSVエクスポートの行数上限(ユーザー指示 2026-09-15 / 指示書AI 段階8) + * + * ★期待値表(指示書AI 10-5)★ + * + * | # | 状況 | サーバ | + * |-----|------------------------|-------------------------| + * | 8-1 | 0件 | 200(ヘッダ行だけのCSV)| + * | 8-2 | 19,999件 | 200 | + * | 8-3 | ★ちょうど 20,000件★ | ★★200(「上限」は含む)★★ | + * | 8-4 | ★20,001件★ | ★★422★★ | + * | 8-5 | 1店舗・2年・1,300件 | 200(期間が長くても行数が少なければ出せる)| + * + * ★★8-3 と 8-4 の両方を必ず入れること★★ — 境界の片側だけだと、 + * `>` と `>=` の取り違えが通ってしまう(`CLAUDE.md` §9.4)。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db` の `db` をスタブに差し替える。★`resolveFacilityIdsByName()` も + * 同じ `db`(`facilityNameIndex()`)を通る★ので、ここ1箇所で足りる。 + * + * ★行数だけが関心事なので、行の中身は使い回しの1件でよい★ — + * CSVの中身(列・エスケープ)は `lib/clean-logs/__tests__/csv.test.ts` の担当。 + */ + +/** `db.cleanLogs()` が返す行数。各テストが書き換える */ +let rowCount = 0; +/** 指定時はこの配列を返す(表S1 用。指示書BH S2-9) */ +let customRows: CleanLogItem[] | null = null; + +function row(id: number): CleanLogItem { + return { + id, + row_key: `tt-${id}`, + report_id: null, + unscheduled: false, + facility_id: 1, + facility_name: "メガバリューマート中央本店", + model_name: "PUDU CC1", + serial_number: "8111R5628060011", + scheduled_at: "2026-08-27T02:00:00+09:00", + status_1h: "待機中", + status_1h_minutes: 60, + status_30m: "待機中", + status_30m_minutes: 30, + departed: true, + // `clean_task_reports.status` の生値(CSV実測で最多の値。意味は第10回 N-2 で受領済み。指示書AQ §1-2) + task_status: 4, + // ★結果待ち(D-173)★ — レポートあり・確定の行 + result_pending: false, + completion_rate: 90.1, + grade: "◎", + consecutive_problem_days: 0, + escalation_level: null, + }; +} + +vi.mock("@/lib/db", () => ({ + db: { + cleanLogs: async () => + customRows ?? + Array.from({ length: rowCount }, (_unused, i) => row(i + 1)), + // 施設名の解決に使う(施設名を指定しないテストでは読まれない) + facilityNameIndex: async () => [ + { id: 1, facility_name: "メガバリューマート中央本店" }, + ], + }, +})); + +async function get(query = ""): Promise { + return GET(new Request(`http://localhost/api/clean-logs/export?${query}`)); +} + +describe("GET /clean-logs/export — 行数の上限", () => { + it("8-1: 0件は200(ヘッダ行だけのCSV)", async () => { + rowCount = 0; + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/csv"); + const body = await res.text(); + // ヘッダ行だけ(BOM + 1行)。★0件を422にしないこと★ + expect(body.trimEnd().split("\n")).toHaveLength(1); + }); + + it("8-2: 上限より1件少なければ200", async () => { + rowCount = MAX_CSV_ROWS - 1; + const res = await get(); + expect(res.status).toBe(200); + }); + + it("8-3: ★ちょうど上限は200★(「上限」は含む)", async () => { + rowCount = MAX_CSV_ROWS; + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/csv"); + }); + + it("8-4: ★上限+1件は422★(500ではない)", async () => { + rowCount = MAX_CSV_ROWS + 1; + const res = await get(); + expect(res.status).toBe(422); + + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + // ★details に実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる + expect(body.error.details).toEqual({ + total: MAX_CSV_ROWS + 1, + max: MAX_CSV_ROWS, + }); + // 件数は文言にも出す(画面に出さずAPIを直接叩いた人のため) + expect(body.error.message).toContain(String(MAX_CSV_ROWS + 1)); + expect(body.error.message).toContain(String(MAX_CSV_ROWS)); + }); + + it("8-5: 期間が2年でも行数が少なければ200(★上限は日数ではなく行数★)", async () => { + rowCount = 1_300; + const res = await get( + "date_from=2026-01-01&date_to=2028-01-01&facility_name=メガバリューマート中央本店" + ); + expect(res.status).toBe(200); + }); + + it("日付の範囲が上限(2年)を超えていれば、行数を数える前に422", async () => { + rowCount = 0; + const res = await get("date_from=2026-01-01&date_to=2028-01-02"); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + }); +}); + +describe("GET /clean-logs/export — 並べ替え(指示書BH 表S2 S2-9)", () => { + beforeEach(() => { + rowCount = 0; + customRows = null; + }); + + it("S2-9: sort=grade&order=asc — CSV のシリアル列が grade asc 順", async () => { + customRows = bhS1RowsDefaultOrder(); + const res = await get("sort=grade&order=asc&result=all"); + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text.trimEnd().split("\n"); + expect(lines.length).toBe(9); + const serials = lines.slice(1).map((line) => line.split(",")[2]?.replace(/^'/, "").replace(/'$/, "") ?? ""); + expect(serials).toEqual([ + "S-004", + "S-002", + "S-001", + "S-007", + "S-005", + "S-003", + "S-006", + "", + ]); + }); + + it("S2-9: 不正な sort で 422", async () => { + customRows = []; + const res = await get("sort=facility_name"); + expect(res.status).toBe(422); + }); +}); + +describe("GET /clean-logs/export — schedule 絞り込み(指示書BH 表X)", () => { + beforeEach(() => { + rowCount = 0; + customRows = bhS1RowsDefaultOrder(); + }); + + /** CSV ヘッダ「予定時刻」列(0-based index 4) */ + function scheduledTimeCells(text: string): string[] { + const lines = text.trimEnd().split("\n"); + return lines.slice(1).map((line) => line.split(",")[4]?.replace(/^'/, "").replace(/'$/, "") ?? ""); + } + + it("X-1: result=all&schedule=unscheduled — 1行・予定時刻は予定なし", async () => { + const res = await get("result=all&schedule=unscheduled"); + expect(res.status).toBe(200); + const text = await res.text(); + const dataLines = text.trimEnd().split("\n").length - 1; + expect(dataLines).toBe(1); + expect(scheduledTimeCells(text)).toEqual(["予定なし"]); + }); + + it("X-2: result=all&schedule=scheduled — 7行・予定時刻に「予定なし」が無い", async () => { + const res = await get("result=all&schedule=scheduled"); + expect(res.status).toBe(200); + const text = await res.text(); + const dataLines = text.trimEnd().split("\n").length - 1; + expect(dataLines).toBe(7); + expect(scheduledTimeCells(text).every((c) => c !== "予定なし")).toBe(true); + }); + + it("X-3: schedule=bad → 422", async () => { + const res = await get("schedule=bad"); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(Object.keys(body.error.details ?? {})).toEqual(["schedule"]); + }); +}); diff --git a/app/api/clean-logs/export/route.ts b/app/api/clean-logs/export/route.ts new file mode 100644 index 0000000..980091b --- /dev/null +++ b/app/api/clean-logs/export/route.ts @@ -0,0 +1,117 @@ +/** + * GET /clean-logs/export — 清掃詳細ログCSVエクスポート + * + * /clean-logs と同一のフィルタを受け付ける(`sort` / `order`・予定あり/予定外も同じ。CSV は画面と同じ並び順)。 + * page / per_page は無視。UTF-8 BOM付き、ヘッダ行あり。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +// ★CSVの組み立ては lib/clean-logs/csv.ts★ — App Router の route.ts は +// HTTPメソッド以外の export を想定しておらず、ここに置くとテストから呼べない +// (「全列が csvCell() を通っているか」をテストで固定したいため。D-125) +import { buildCleanLogCsv } from "@/lib/clean-logs/csv"; +import { + filterCleanLogs, + parseCleanLogQuery, + toDbQueryDate, +} from "@/lib/clean-logs/query"; +import { sortCleanLogs } from "@/lib/clean-logs/sort"; +import { MAX_CSV_ROWS } from "@/lib/config"; +import { intersectFacilityIds } from "@/lib/facilities/intersect-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, CleanLogItem } from "@/lib/types"; + +const ROUTE = "/api/clean-logs/export"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseCleanLogQuery(searchParams); + if (!parsed.ok) { + return NextResponse.json(parsed.error, { status: parsed.status }); + } + + let rows: CleanLogItem[]; + try { + // ★`/clean-logs` と**同じ引数**を渡す(指示書AC 段階2 D-6)★ — + // ★★片方だけ絞ると、画面の一覧と CSV の中身が食い違います★★。 + // `page` / `per_page` はここでも無視する(CSV は全件出す)。 + // ★★分類(`group_id` / `customer_id`)の解決と積集合も一覧と同じ★★(指示書AK 段階4)— + // ★ここだけ分類を無視すると、画面で屋号を選んで出した CSV に他社の行が混ざります★。 + const [nameIds, masterIds] = await Promise.all([ + resolveFacilityIdsByName(parsed.query.facilityName), + resolveFacilityIdsByMasters(parsed.query), + ]); + const facilityIds = intersectFacilityIds(nameIds, masterIds); + rows = await db.cleanLogs({ + // ★CSV も一覧と同じく単一ID の指定はしない★(指示書AC 段階3) + id: null, + date_from: toDbQueryDate(parsed.query.dateFrom), + date_to: toDbQueryDate(parsed.query.dateTo), + facility_ids: facilityIds, + }); + } catch (e) { + const body: ApiError = { + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + const filtered = filterCleanLogs(rows, parsed.query); + + // ★★行数の歯止め(ユーザー指示 2026-09-15 / 指示書AI 段階8)★★ + // + // ★絞り込みを適用したあと、CSVを組み立てる**前**に数える★ — + // ★★組み立ててから長さで判断しないこと★★。25万行ぶんの文字列を作ってから + // 捨てるのでは、★メモリを使い切る危険が残ったまま★になる。 + // + // ★★500 ではなく 422★★ — 利用者の指定が上限を超えているという話であって、 + // サーバの不具合ではない(`CLAUDE.md` §9.3)。 + // + // ★上限は「含む」★ — ちょうど `MAX_CSV_ROWS` 行は出せる(`>` であって `>=` ではない)。 + // + // ★画面(`DetailLogScreen`)も `meta.total` を見て同じ判断をしてボタンを止めるが、 + // ★★こちらを消さないこと★★ — APIを直接叩く経路が残る。 + // ★画面は「親切」、サーバは「歯止め」で役割が違う★。 + if (filtered.length > MAX_CSV_ROWS) { + const body: ApiError = { + error: { + code: "validation_failed", + message: + `この期間はCSVで出力できません。対象が${filtered.length}件で、` + + `上限の${MAX_CSV_ROWS}件を超えています。` + + `期間を短くするか、店舗で絞り込んでください。`, + // ★実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる + details: { total: filtered.length, max: MAX_CSV_ROWS }, + }, + }; + return NextResponse.json(body, { status: 422 }); + } + + // ★行数上限の判定の**後**に並べ替える(捨てる行を並べ替えない。入れ忘れると CSV だけ既定順)★ + const sorted = sortCleanLogs(filtered, parsed.query.sort, parsed.query.order); + const csv = buildCleanLogCsv(sorted); + // ★ここで生の searchParams を読んでよいのは、上の parseCleanLogQuery が + // `^\d{4}-\d{2}-\d{2}$`(`lib/clean-logs/query.ts` の parseDate)で既に弾いており、 + // 形式違いなら 422 で return 済みだから★。だから Content-Disposition に + // `"` や改行などの制御文字は入らない。 + // ★この検証を緩めるなら、ここも一緒に直すこと★(検証する場所と使う場所が + // 別の関数に分かれた暗黙の依存になっている)。 + // `parsed.query` 側は Date に正規化済みで元の文字列を持たないため、生値を使う。 + const from = searchParams.get("date_from") ?? ""; + const to = searchParams.get("date_to") ?? ""; + const range = from && to ? `_${from}_${to}` : ""; + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="clean-logs${range}.csv"`, + }, + }); + }); +} diff --git a/app/api/clean-logs/route.ts b/app/api/clean-logs/route.ts new file mode 100644 index 0000000..14abc0d --- /dev/null +++ b/app/api/clean-logs/route.ts @@ -0,0 +1,115 @@ +/** + * GET /clean-logs — 清掃詳細ログ一覧 + * + * ★これが「動く見本」です。他の一覧APIはこの形を真似して作ってください★ + * + * 仕様の正は `d-watch-api.yaml` の /clean-logs です。 + * APIを変えたくなったら、先にYAMLを直してからコードに反映してください(CLAUDE.md 作業ルール1)。 + * + * このルートがやっていること + * 1. クエリパラメータのパースとバリデーション(`MAX_DATE_RANGE_DAYS` 超は422。 + * ★上限は2年=731日★。指示書AI 段階7 で 31日 から広げた) + * 2. ★施設名 → 施設ID の解決★(指示書AC 段階2 D-6。★照合はアプリ側、絞り込みはSQL★) + * 3. データソース(fixtures または Prisma)からの取得(★日付・施設はSQLに降りている★) + * 4. フィルタ(日付範囲 / 施設名 / 機種 / シリアル / 予定あり/予定外 / 判定) + * 5. 並べ替え(`sortCleanLogs()`。`sort` 省略時は並べ替えない) + * 6. ページング + * 7. { items, meta } 形式で返却 + * + * ★★3 で絞ったのに 4 の `filterCleanLogs()` を残しているのは意図です★★(指示書AC C-5)— + * ★絞り込み規則の「正」を純粋関数1箇所に保つため★(SQL 側はその写し)。 + * ★fixtures 版が引数の扱いを間違えても画面の見え方が変わらない保険にもなる★。 + * ★★ただし「二重に絞っているから安全」ではありません★★ — + * ★SQL が**絞りすぎた**場合は `filterCleanLogs()` では取り戻せません★。 + */ +import { NextResponse } from "next/server"; +import { + capCleanLogs, + filterCleanLogs, + parseCleanLogQuery, + toDbQueryDate, +} from "@/lib/clean-logs/query"; +import { sortCleanLogs } from "@/lib/clean-logs/sort"; +import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config"; +import { db } from "@/lib/db"; +import { intersectFacilityIds } from "@/lib/facilities/intersect-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { ApiError, CleanLogItem, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/clean-logs"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseCleanLogQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + // ★表示上限(指示書AF 段階6)★ — `loadConsecutiveDaysMax()` は内部で既定値に倒れる + // 設計なので通常は例外にならないが、★「設定が読めない」だけで一覧を 500 にしない★。 + // ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型) + let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax; + try { + maxDays = await loadConsecutiveDaysMax(); + } catch { + // 既定値のまま続行する + } + + let rows: CleanLogItem[]; + try { + // ★★施設名の照合はアプリ側で施設IDに解決してから SQL に渡す★★(指示書AC C-1 / D-6)— + // ★記号(中点等)を無視する照合は LIKE では書けない★(照合順序でも無理。実測済み)。 + // ★★戻り値の `null` と `[]` は別物★★: `null` = 施設名の条件なし(全施設)/ + // `[]` = ★照合したが1件も当たらなかった(=0件)★。 + // ★★分類(`group_id` / `customer_id`)も**同じ形**(施設IDの配列)に解決する★★ + // (指示書AK 段階4)。★2本の解決結果は `intersectFacilityIds()` で AND に畳む★ — + // ★★どちらか片方を捨てないこと★★(`?? `で書くと、施設名と屋号を同時に + // 指定したときに片方の条件が静かに消えます)。 + // ★積集合が空になったら `[]`★(★`null` にしない★。`null` は「絞らない」の意味なので + // ★0件ヒットが全件表示に化けます★。罠1 / 落とし穴2)。 + const [nameIds, masterIds] = await Promise.all([ + resolveFacilityIdsByName(parsed.query.facilityName), + resolveFacilityIdsByMasters(parsed.query), + ]); + const facilityIds = intersectFacilityIds(nameIds, masterIds); + rows = await db.cleanLogs({ + // ★一覧なので単一ID の指定はしない★(`null` = 指定なし。指示書AC 段階3) + id: null, + // ★生の `searchParams` ではなく `parseCleanLogQuery()` を通った値を渡す★(§E-2) + date_from: toDbQueryDate(parsed.query.dateFrom), + date_to: toDbQueryDate(parsed.query.dateTo), + facility_ids: facilityIds, + }); + } catch (e) { + const body: ApiError = { + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return errorResponse(body, 500); + } + + const filtered = filterCleanLogs(rows, parsed.query); + const sorted = sortCleanLogs(filtered, parsed.query.sort, parsed.query.order); + const { page, perPage } = parsed.query; + const total = sorted.length; + const start = (page - 1) * perPage; + // ★表示上限は絞り込み → 並べ替え → ページ切り出しの**後**にかける★(指示書AF 段階6-2) + // 絞り込みとページングは生値のまま行う。`meta.total` も上限とは無関係。 + const items = capCleanLogs(sorted.slice(start, start + perPage), maxDays); + + const body: ListResponse = { + items, + meta: { total, page, per_page: perPage }, + }; + return NextResponse.json(body); + }); +} diff --git "a/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts" "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts" new file mode 100644 index 0000000..b01b441 --- /dev/null +++ "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/__tests__/route.fixtures.test.ts" @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/clean-logs/unscheduled/[report_id]/advice/route"; +import type { CleanLogAdviceResponse } from "@/lib/types"; + +/** + * 表A-fixtures(A-1〜A-4。指示書BI §4)— `USE_FIXTURES=true` 固定。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +const NOW_ISO = "2026-10-06T08:15:00+09:00"; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function advice(reportId: string): Promise { + return GET( + new Request( + `http://localhost/api/clean-logs/unscheduled/${encodeURIComponent(reportId)}/advice` + ), + { params: Promise.resolve({ report_id: reportId }) } + ); +} + +describe("GET /clean-logs/unscheduled/{report_id}/advice(USE_FIXTURES=true)", () => { + it("A-1: 9200000000000000002 — L1・R2 のみ", async () => { + const res = await advice("9200000000000000002"); + expect(res.status).toBe(200); + const body = (await res.json()) as CleanLogAdviceResponse; + expect(body.escalation_level).toBe("L1"); + expect(body.consecutive_problem_days).toBe(1); + expect(body.advices.map((a) => a.rule)).toEqual(["R2"]); + expect(body.max_level).toBe(1); + }); + + it("A-2: 9200000000000000001 — ◎・advices 空", async () => { + const res = await advice("9200000000000000001"); + expect(res.status).toBe(200); + const body = (await res.json()) as CleanLogAdviceResponse; + expect(body.escalation_level).toBeNull(); + expect(body.advices).toEqual([]); + }); + + it("A-3: 存在しない report_id は 404", async () => { + const res = await advice("123"); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ + error: { code: "not_found", message: "指定された清掃ログが見つかりません" }, + }); + }); + + it("A-4: 21文字は 422(details のキーは report_id)", async () => { + const res = await advice("1".repeat(21)); + expect(res.status).toBe(422); + const json = (await res.json()) as { error: { details: Record } }; + expect(json.error.details).toHaveProperty("report_id"); + }); +}); diff --git "a/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts" "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts" new file mode 100644 index 0000000..151893d --- /dev/null +++ "b/app/api/clean-logs/unscheduled/\133report_id\135/advice/route.ts" @@ -0,0 +1,51 @@ +/** + * GET /clean-logs/unscheduled/{report_id}/advice — 予定なしの行の対応アドバイス + * + * 仕様の正は `d-watch-api.yaml` の /clean-logs/unscheduled/{report_id}/advice です。 + */ +import { NextResponse } from "next/server"; +import { parsePathReportId } from "@/lib/api/path-params"; +import { adviceResponse } from "@/lib/clean-logs/advice-response"; +import { db } from "@/lib/db"; +import { logApiError } from "@/lib/logger/api"; +import { withRequestId } from "@/lib/logger"; +import type { ApiError, ErrorCode } from "@/lib/types"; + +const ROUTE = "/api/clean-logs/unscheduled/[report_id]/advice"; + +function errorBody(code: ErrorCode, message: string): ApiError { + return { error: { code, message, details: null } }; +} + +function errorResponse(status: number, code: ErrorCode, message: string) { + return NextResponse.json(errorBody(code, message), { status }); +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ report_id: string }> } +) { + return withRequestId(request, async () => { + const { report_id } = await params; + const parsed = parsePathReportId(report_id); + if (!parsed.ok) { + return NextResponse.json(parsed.error, { status: parsed.status }); + } + const reportId = parsed.query; + + let log; + try { + log = await db.unscheduledCleanLog(reportId); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + if (!log) { + return errorResponse(404, "not_found", "指定された清掃ログが見つかりません"); + } + + return adviceResponse(log, { route: ROUTE, logIds: { report_id: reportId } }); + }); +} diff --git a/app/api/customers/__tests__/route.test.ts b/app/api/customers/__tests__/route.test.ts new file mode 100644 index 0000000..d3b8736 --- /dev/null +++ b/app/api/customers/__tests__/route.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★(`it()` の中で `await import()` すると、最初の1本だけが +// モジュール読み込みを抱え込んでフルスイート実行時にタイムアウトしうる。 +// `app/api/reports/__tests__/route.test.ts` の実測) +import { GET as GET_CUSTOMERS } from "@/app/api/customers/route"; +import { GET as GET_GROUPS } from "@/app/api/groups/route"; +import type { ApiError, Customer, Group, ListResponse } from "@/lib/types"; + +/** + * 分類マスタAPI 2本(`GET /groups` / `GET /customers`。指示書AK 段階1)の期待値表。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、 + * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して + * ★`prismaSource` の経路★ を走らせる(`app/api/reports/__tests__/route.test.ts` と同じ作法)。 + * fixtures 側(`USE_FIXTURES=true`)の不変条件は + * `lib/fixtures/__tests__/masters.test.ts` が見る。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * - ★`group_id = 0` を API に漏らさないこと★(指示書AK 落とし穴3 / D-011)— + * ★漏らすと「グループ0」という存在しないホールディングスが画面に出る★ + * - ★`deleted_at` で絞ること/`inactive` では絞らないこと★(§4-1 #4)— + * ★どちらも「件数が静かに変わる」だけで例外は出ない★ + * - ★セレクトリストを切らないこと★(§4-1 #1)— 51件目以降が消えても誰も気づけない + * - ★`customers.contract` を読まないこと★(D-010 / 落とし穴5) + */ + +interface StubGroup { + id: bigint; + group_name: string; + inactive: number; + deleted_at: Date | null; +} + +interface StubCustomer { + id: bigint; + customer_name: string; + group_id: bigint; + inactive: number; + deleted_at: Date | null; +} + +const calls: { group: Record[]; customer: Record[] } = { + group: [], + customer: [], +}; + +let data: { groups: StubGroup[]; customers: StubCustomer[] }; + +function matchesWhere(row: Record, where: unknown): boolean { + if (!where) return true; + for (const [field, cond] of Object.entries(where as Record)) { + if (row[field] !== cond) return false; + } + return true; +} + +function findMany( + bucket: keyof typeof calls, + rows: Record[], + args: Record | undefined +) { + calls[bucket].push(args ?? {}); + return rows.filter((row) => matchesWhere(row, args?.where)); +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + group: { + findMany: async (args?: Record) => + findMany("group", data.groups as unknown as Record[], args), + }, + customer: { + findMany: async (args?: Record) => + findMany("customer", data.customers as unknown as Record[], args), + }, + }), +})); + +function group( + id: number, + name: string, + opts: { inactive?: number; deleted?: boolean } = {} +): StubGroup { + return { + id: BigInt(id), + group_name: name, + inactive: opts.inactive ?? 0, + deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null, + }; +} + +function customer( + id: number, + name: string, + groupId: number, + opts: { inactive?: number; deleted?: boolean } = {} +): StubCustomer { + return { + id: BigInt(id), + customer_name: name, + group_id: BigInt(groupId), + inactive: opts.inactive ?? 0, + deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null, + }; +} + +async function getGroups(qs = ""): Promise { + return GET_GROUPS(new Request(`http://localhost/api/groups${qs}`)); +} + +async function getCustomers(qs = ""): Promise { + return GET_CUSTOMERS(new Request(`http://localhost/api/customers${qs}`)); +} + +beforeEach(() => { + calls.group = []; + calls.customer = []; + data = { + groups: [ + group(2, "グループB"), + group(1, "グループA"), + // ★無効化(inactive=1)でも一覧に出る★ / ★論理削除は出ない★ + group(3, "グループC(無効)", { inactive: 1 }), + group(4, "グループD(削除済み)", { deleted: true }), + ], + customers: [ + customer(20, "屋号B", 1), + customer(10, "屋号A", 1), + customer(30, "屋号C", 2), + // ★group_id=0(ホールディングスなしのセンチネル。D-011)★ + customer(40, "屋号D", 0), + customer(50, "屋号E(無効)", 1, { inactive: 1 }), + customer(60, "屋号F(削除済み)", 1, { deleted: true }), + ], + }; +}); + +describe("GET /groups", () => { + it("#1 id の昇順で返し、meta は total / page=1 / per_page=total", async () => { + const res = await getGroups(); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + expect(body.items.map((g) => g.id)).toEqual([1, 2, 3]); + expect(body.meta).toEqual({ total: 3, page: 1, per_page: 3 }); + }); + + it("#2 deleted_at で絞る(論理削除は出ない)/inactive では絞らない", async () => { + const body = (await (await getGroups()).json()) as ListResponse; + expect(body.items.map((g) => g.group_name)).toContain("グループC(無効)"); + expect(body.items.map((g) => g.group_name)).not.toContain("グループD(削除済み)"); + expect(calls.group[0].where).toEqual({ deleted_at: null }); + }); + + it("#3 ★id=0 の行を作らない★(『グループ0』が現れないこと)", async () => { + const body = (await (await getGroups()).json()) as ListResponse; + expect(body.items.some((g) => g.id === 0)).toBe(false); + }); + + it("#4 返すのは id と group_name だけ(inactive / deleted_at を漏らさない)", async () => { + const body = (await (await getGroups()).json()) as ListResponse; + expect(Object.keys(body.items[0]).sort()).toEqual(["group_name", "id"]); + }); + + it("#5 DBが落ちたら 500(共通のエラー形式)", async () => { + data = { get groups(): StubGroup[] { throw new Error("db down"); }, customers: [] }; + const res = await getGroups(); + expect(res.status).toBe(500); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("internal_error"); + }); +}); + +describe("GET /customers", () => { + it("#6 id の昇順で返す(meta は /groups と同じ規則)", async () => { + const res = await getCustomers(); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + expect(body.items.map((c) => c.id)).toEqual([10, 20, 30, 40, 50]); + expect(body.meta).toEqual({ total: 5, page: 1, per_page: 5 }); + }); + + it("#7 ★group_id=0 は null になる★(『グループ0』を API に漏らさない。D-011)", async () => { + const body = (await (await getCustomers()).json()) as ListResponse; + const d = body.items.find((c) => c.id === 40); + expect(d?.group_id).toBeNull(); + expect(body.items.some((c) => c.group_id === 0)).toBe(false); + }); + + it("#8 deleted_at で絞る/inactive では絞らない", async () => { + const body = (await (await getCustomers()).json()) as ListResponse; + expect(body.items.map((c) => c.id)).toContain(50); + expect(body.items.map((c) => c.id)).not.toContain(60); + expect(calls.customer[0].where).toEqual({ deleted_at: null }); + }); + + it("#9 group_id=1 で傘下だけに絞れる", async () => { + const body = (await (await getCustomers("?group_id=1")).json()) as ListResponse; + expect(body.items.map((c) => c.id)).toEqual([10, 20, 50]); + expect(body.meta.total).toBe(3); + }); + + it("#10 ★実在しない group_id は 200・空★(404 にしない)", async () => { + const res = await getCustomers("?group_id=999"); + expect(res.status).toBe(200); + const body = (await res.json()) as ListResponse; + expect(body.items).toEqual([]); + expect(body.meta).toEqual({ total: 0, page: 1, per_page: 0 }); + }); + + it("#11 ★group_id=0 は 422★(0 はセンチネルで有効なIDではない)", async () => { + const res = await getCustomers("?group_id=0"); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + expect(body.error.details).toEqual({ + group_id: ["1以上の整数で指定してください"], + }); + }); + + it("#12 ★group_id=1abc は 422★(parseInt の前方一致で 1 にしない。D-123)", async () => { + const res = await getCustomers("?group_id=1abc"); + expect(res.status).toBe(422); + // ★422 のときは DB を読まない★ + expect(calls.customer).toHaveLength(0); + }); + + it("#13 group_id= (空)は未指定と同じ(全件)", async () => { + const body = (await (await getCustomers("?group_id=")).json()) as ListResponse; + expect(body.items).toHaveLength(5); + }); + + it("#14 返すのは id / customer_name / group_id だけ(★contract を読まない★。D-010)", async () => { + const body = (await (await getCustomers()).json()) as ListResponse; + expect(Object.keys(body.items[0]).sort()).toEqual([ + "customer_name", + "group_id", + "id", + ]); + // `select` で列を絞っていないので、`where` に contract が混ざっていないことも見ておく + expect(JSON.stringify(calls.customer[0])).not.toContain("contract"); + }); + + /** + * 指示書AL ★段階3★ §4-4 — ★`db.customers()` に `orderBy` があること★。 + * + * ★★結果の並びでは確かめられない★★ — このスタブは配列をそのまま返すので、 + * ★`orderBy` を消してもルートの `sortCustomers()` が並べ直して通ってしまう★。 + * ★だから「DBに何を投げたか」を見る★。 + * + * ★`/customers` 自身はこの `orderBy` に依存していない★(ルートで並べ替えるため)。 + * ★依存しているのは `GET /reports/kpi` の 422★ — `details.customer_ids` は + * `db.customers()` の並びをそのまま流す(`resolveTargetCustomer()` は並べ替えない)。 + */ + it("★段階3 §4-4★ id の昇順で DB から引く(★候補の並びをDB任せにしない★)", async () => { + await getCustomers(); + expect(calls.customer[0].orderBy).toEqual({ id: "asc" }); + }); +}); diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts new file mode 100644 index 0000000..a47b834 --- /dev/null +++ b/app/api/customers/route.ts @@ -0,0 +1,66 @@ +/** + * GET /customers — 屋号一覧(分類マスタ。指示書AK 段階1。読み取り専用) + * + * 形は `app/api/schedules/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /customers。 + * + * ★GET だけ★(理由は `app/api/groups/route.ts` の冒頭)。 + * ★認証はここに書かない★ — `proxy.ts` が横断して判定する。 + * + * ★`group_id` で絞れる★ — 画面(段階7)はホールディングスを選んだときに + * `GET /customers?group_id=X` を投げ、屋号の選択肢をその傘下だけに絞る。 + * ★実在しない `group_id` でも 404 にしない★(空配列 + `meta.total = 0`)。 + * 「該当なし」は正常な結果で、404 は「エンドポイントが無い」と区別できなくなる。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { + filterCustomersByGroup, + masterListResponse, + parseCustomerQuery, + sortCustomers, +} from "@/lib/masters/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, Customer, ListResponse } from "@/lib/types"; + +const ROUTE = "/api/customers"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + // ★`group_id=0` は 422★ — 0 は「ホールディングスなし」のセンチネルであって、 + // 有効なIDではない(D-011)。★`1abc` も 422★(D-123 で踏んだ罠)。 + const parsed = parseCustomerQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: Customer[]; + try { + rows = await db.customers(); + } catch (e) { + const body: ApiError = { + error: { + code: "internal_error", + message: "データの取得に失敗しました", + details: null, + }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return errorResponse(body, 500); + } + + // ここから下は純粋な絞り込み・並べ替え。すべて try の外 + // (`app/api/schedules/route.ts` と同じ方針)。 + const filtered = filterCustomersByGroup(rows, parsed.query.groupId); + const body: ListResponse = masterListResponse( + sortCustomers(filtered) + ); + return NextResponse.json(body); + }); +} diff --git a/app/api/dashboard/__tests__/route.test.ts b/app/api/dashboard/__tests__/route.test.ts new file mode 100644 index 0000000..238167a --- /dev/null +++ b/app/api/dashboard/__tests__/route.test.ts @@ -0,0 +1,660 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` 冒頭の +// 理由と同じ。`it()` の中で `await import()` するとフルスイートでタイムアウトを踏む) +import { GET } from "@/app/api/dashboard/route"; +import { CLEAN_TIMETABLE_PRECHECK, D_SEC_ALERT_ERROR_TYPES } from "@/lib/config"; +import { db } from "@/lib/db"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { logEvent } from "@/lib/logger"; +import { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select"; +import { dsAlertWhere } from "@/lib/db/ds-alert-mapping"; +import type { DashboardResponse } from "@/lib/types"; + +/** + * GET /dashboard が ★その1日ぶんだけ★ を引いていることを固定する(指示書AC 段階2 D-6)。 + * ★`date` 省略(=直近モード。D-159 / 指示書AV)では、表3 の窓(8日ぶん)に絞られていること★と、 + * 直近モードの `summary` / `problem_logs` / メタの `where` も固定する(ファイル末尾の `describe`)。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * ★`?date=`(★値が空★)のときに ★全件読みへ戻らない★ こと★。 + * `const date = dateRaw ?? todayJst()` と書くと ★`""` は null でも undefined でもない★ ため + * ★空文字がそのまま残り★、`db.cleanLogs({ date_from: "", date_to: "" })` が + * `dbDateRange("", "")` を通って ★`start_at` の条件ごと消え=テーブル全件読み★ になります + * (本番想定で `clean_timetables` 約26万行)。 + * ★★レスポンスは後段の日付一致(`logsOfDay`)で空になるので画面からは観測できません★★ — + * ★このテストが唯一の検出手段★です。 + * + * ------------------------------------------------------------------------ + * ★★DBには接続しません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して + * ★`prismaSource` の経路★ を走らせる(段階2 / 段階4 のルートテストと同じ作法)。 + * + * ★★ただしスタブの入れ方だけが兄弟テストと違います★★ — + * 兄弟(`/clean-logs` / `/prechecks`)は `vi.mock("@/lib/db/prisma")` で `getPrisma()` を + * 差し替えていますが、★このルートは3つのデータ源を `Promise.all` で**同時に**引きます★。 + * `prismaSource` の各メソッドは `await import("./prisma")` の★遅延読み込み★なので + * (`lib/db/prisma.ts` 冒頭の理由)、★★同じモジュールへの動的 import が同時に走ると + * 一部が差し替え前の実体を掴み★★、`DATABASE_URL` 未設定で 500 になります(実測)。 + * そこで ★`getPrisma()` が最初に見るシングルトン `globalThis.__dwatchPrisma` に + * 直接スタブを置きます★ — ★実装(`lib/db/prisma.ts`)の公開された仕組みをそのまま使うので + * DBにも接続しません★。★`afterEach` で必ず片付けること★。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubTimetable { + id: bigint; + facility_id: bigint; + section_id: bigint; + parent_id: bigint; + start_at: Date; + /** ★予定の終了★(直近モードの `cleanLogRunMeta()` が読む。指示書AV 段階4-5。開始+1時間) */ + end_at: Date; + precheck: number; + /** ★`deleted_at` は可視性(`FACILITY_SECTION_VISIBLE_WHERE`)の再現用★(指示書BB U1) */ + facility: { facility_name: string; deleted_at: Date | null }; + section: { + deleted_at: Date | null; + robots: { + serial_number: string; + device: { maker_id: number; model_name: string }; + }[]; + }; +} + +/** + * ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。旧 `clean_status_histories`)。 + * ★`clean_error` 列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。 + * `clean_timetable_id` / `ended_at` は nullable(指示書AQ 罠1 / 罠3)。 + */ +interface StubReport { + clean_timetable_id: bigint | null; + clean_ratio: number; + status: number; + started_at: Date; + ended_at: Date | null; + clean_area: number; + task_area: number; +} + +interface StubData { + timetables: StubTimetable[]; + reports: StubReport[]; + /** `robot_error_reports` のうち D-Sec アラートの読み取りに返す行(既定は空) */ + dsAlertRows: Record[]; +} + +/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */ +const calls: { + cleanTimetable: Record[]; + cleanTimetableHistory: Record[]; + cleanTaskReport: Record[]; + cleanSchedule: Record[]; +} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [], cleanSchedule: [] }; + +let data: StubData; +/** D-Sec アラートの読み取りの `where`(指示書BB 表10 N7) */ +let dsAlertWheres: unknown[] = []; + +/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */ +function sameId(a: unknown, b: unknown): boolean { + if (a === null || a === undefined || b === null || b === undefined) return false; + return String(a) === String(b); +} + +/** + * Prisma の `where` のうち、★このテストが使う分だけ★ を再現する。 + * + * ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。 + * ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。 + */ +function matchesWhere(row: Record, where: unknown): boolean { + if (!where) return true; + for (const [field, cond] of Object.entries(where as Record)) { + const value = row[field]; + if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) { + const c = cond as Record; + if ("in" in c) { + if (c.in === undefined) { + throw new Error( + `[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります` + ); + } + if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false; + } + if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false; + if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false; + // ★リレーションの条件(`facility: { deleted_at: null }` など。可視性 U1)★ — 行が結合先を持つときだけ再現する + // (持たない行=結合先を `include` しない読み取りは、従来どおり素通し) + if (!("in" in c) && !("gte" in c) && !("lt" in c) && value !== null && typeof value === "object" && !(value instanceof Date)) { + if (!matchesWhere(value as Record, c)) return false; + } + } else if (typeof cond === "bigint" || typeof value === "bigint") { + if (!sameId(value, cond)) return false; + } else if (value !== cond) { + return false; + } + } + return true; +} + +function project(row: Record, select: unknown): Record { + if (!select) return row; + const out: Record = {}; + for (const key of Object.keys(select as Record)) out[key] = row[key]; + return out; +} + +function findMany( + bucket: keyof typeof calls, + rows: Record[], + args: Record | undefined +) { + calls[bucket].push(args ?? {}); + return rows + .filter((row) => matchesWhere(row, args?.where)) + .map((row) => project(row, args?.select)); +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +// ★直近モードのメタ欠けの `data.anomaly`(指示書BB A2)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★ +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +/** `getPrisma()` が最初に見るシングルトンへ差し込むスタブ(上の「スタブの入れ方」を参照) */ +function prismaStub(): typeof globalThis.__dwatchPrisma { + return { + cleanTimetable: { + findMany: async (args?: Record) => + findMany("cleanTimetable", data.timetables as unknown as Record[], args), + }, + // ★`clean_timetable_histories`(指示書AU 段階3b)。★このテストでは空★(2表に同じ引数が渡ることだけ見る) + cleanTimetableHistory: { + findMany: async (args?: Record) => findMany("cleanTimetableHistory", [], args), + }, + cleanTaskReport: { + findMany: async (args?: Record) => + findMany("cleanTaskReport", data.reports as unknown as Record[], args), + }, + // ★要レスキュー(指示書AI 段階5)が施設名の解決に使う★ + // (`loadFacilityNameIndex()`。★`where` を付けない=全件★) + facility: { + findMany: async () => [ + { id: BigInt(1), facility_name: FACILITY_1 }, + { id: BigInt(2), facility_name: FACILITY_2 }, + ], + }, + // ★このテストで中身は要らない(`db.cleanLogs()` の `where` だけを見る)★。 + // ★D-Sec アラート(`dsAlerts()`)の読み取りにだけ `dsAlertRows` を返す★(`where` を無視。指示書AX 表1 R10) + robotErrorReport: { + findMany: async (args?: Record) => { + if (args?.select !== ROBOT_ERROR_DS_ALERT_SELECT) return []; + dsAlertWheres.push(args.where); + guardNullRobotId(data.dsAlertRows as unknown as Record[], args); + return data.dsAlertRows; + }, + }, + // ★`clean_schedules`(`schedules()`)。★ダッシュボードはもう読まない★(2026-10-29 案B)— 読まれたら記録に残る(S7)★ + cleanSchedule: { + findMany: async (args?: Record) => { + calls.cleanSchedule.push(args ?? {}); + return []; + }, + }, + // ★`controlErrorHistory` は置かない★ — D-Sec アラートは `robot_error_reports` から読む(指示書AX。P-10 = A) + } as unknown as typeof globalThis.__dwatchPrisma; +} + +// ---------------------------------------------------------------- 合成データ + +/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */ +function dbWallClock(jstIso: string): Date { + return new Date(jstIso.replace("+09:00", "Z")); +} + +/** ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */ +const FACILITY_1 = "バリューマート北町店"; +/** ★対象日に清掃予定が無い施設★(要レスキューの検証用。指示書AI §1-5) */ +const FACILITY_2 = "メガバリューマート緑野店"; + +function timetable(id: number, facilityId: number, jstIso: string): StubTimetable { + return { + id: BigInt(id), + facility_id: BigInt(facilityId), + section_id: BigInt(facilityId * 10), + parent_id: BigInt(0), + start_at: dbWallClock(jstIso), + // ★開始+1時間★(壁時計値のまま足す。直近モードの「終わった」の判定に使う。指示書AV 段階4-5) + end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000), + precheck: CLEAN_TIMETABLE_PRECHECK.main, + facility: { facility_name: FACILITY_1, deleted_at: null }, + section: { + deleted_at: null, + robots: [ + { + serial_number: `8111R56280600${facilityId}`, + device: { maker_id: 1, model_name: "CC1" }, + }, + ], + }, + }; +} + +function report(timetableId: number, cleanRatio: number, jstIso: string): StubReport { + return { + clean_timetable_id: BigInt(timetableId), + clean_ratio: cleanRatio, + // ★意味を持たない任意の生値★(CSV実測で最頻の値。status は判定に使わない。D-155 項2)。 + // ★未実施の判定には使われない★(行の有無で決まる。§1-1) + status: 4, + started_at: dbWallClock(jstIso), + ended_at: dbWallClock(jstIso), + clean_area: 100, + task_area: 100, + }; +} + +function buildData(): StubData { + return { + timetables: [ + timetable(101, 1, "2026-09-28T02:00:00+09:00"), + timetable(102, 1, "2026-09-29T02:00:00+09:00"), + // ★`NOW` と同じ日(`todayJst()` が返す日)★ + timetable(103, 1, "2026-09-30T02:00:00+09:00"), + // ★★施設2は 09-27〜09-29 が3日連続で未実施、当日(09-30)には予定が無い★★ + // (レポートの行を作らない=未実施。`notExecutedFromReport()`。指示書AQ §1-1)— + // ★要レスキューが「当該日のログ」で母集団を絞っていないことの検証用★(指示書AI §1-5) + timetable(201, 2, "2026-09-27T02:00:00+09:00"), + timetable(202, 2, "2026-09-28T02:00:00+09:00"), + timetable(203, 2, "2026-09-29T02:00:00+09:00"), + ], + reports: [ + report(101, 95, "2026-09-28T03:00:00+09:00"), + report(102, 95, "2026-09-29T03:00:00+09:00"), + report(103, 95, "2026-09-30T03:00:00+09:00"), + ], + dsAlertRows: [], + }; +} + +/** ★JST の 2026-09-30★(`todayJst()` がこの日を返す) */ +const NOW = new Date("2026-09-30T09:00:00+09:00"); +const TODAY = "2026-09-30"; + +async function get(search: string): Promise { + const res = await GET(new Request(`http://localhost/api/dashboard${search}`)); + return (await res.json()) as DashboardResponse; +} + +/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */ +function mainQueryWhere(): Record { + const call = calls.cleanTimetable.find((c) => "include" in c); + expect(call).toBeDefined(); + return (call as { where: Record }).where; +} + +/** ★その1日ぶんに絞られているか★ を「壁時計値の範囲」で確かめる */ +function expectSingleDayRange(where: Record, date: string): void { + expectDayRange(where, date, date); +} + +/** ★`from`〜`to`(両端を含む)に絞られているか★ を「壁時計値の範囲」で確かめる */ +function expectDayRange(where: Record, from: string, to: string): void { + expect(where).toHaveProperty("start_at"); + const startAt = where.start_at as { gte: Date; lt: Date }; + // ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★) + expect(startAt.gte.toISOString()).toBe(`${from}T00:00:00.000Z`); + expect(startAt.lt.toISOString()).toBe( + new Date(Date.parse(`${to}T00:00:00.000Z`) + 86_400_000).toISOString() + ); +} + +/** ★直近モードの窓(指示書AV 表3 W4。`NOW` = 2026-09-30 09:00 JST)★ */ +const LATEST_WINDOW = { from: "2026-09-23", to: TODAY } as const; + +/** + * ★メタ(`cleanLogRunMeta()`)の呼び出し★ = `select` のキーがちょうど `id` / `section_id` / `end_at` の3つ。 + * ★「`select` 付き」だけでは見分けられない★ — 要レスキュー(`loadConsecutiveInputs()` など)も + * `cleanTimetable.findMany` を `select` 付きで呼ぶ(指示書AV 段階4-5)。 + */ +function metaCalls(): Record[] { + return calls.cleanTimetable.filter((c) => { + const select = c.select as Record | undefined; + return select !== undefined && Object.keys(select).sort().join(",") === "end_at,id,section_id"; + }); +} + +beforeEach(() => { + data = buildData(); + calls.cleanTimetable = []; + calls.cleanTimetableHistory = []; + calls.cleanTaskReport = []; + calls.cleanSchedule = []; + dsAlertWheres = []; + globalThis.__dwatchPrisma = prismaStub(); + vi.mocked(logEvent).mockClear(); + // ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★ + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + // ★★他のテストへ漏らさないこと★★(`getPrisma()` はここを最初に見る) + globalThis.__dwatchPrisma = undefined; + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- テスト + +describe("GET /dashboard — その1日ぶんだけを引く(指示書AC 段階2 D-6)", () => { + it("`date` を指定すると SQL の `where` にその1日の `start_at` が降りる", async () => { + await get("?date=2026-09-29"); + expectSingleDayRange(mainQueryWhere(), "2026-09-29"); + }); + + it("★予定は2表から読み、履歴にも同じ引数が渡る★(指示書AU 段階3b。第11回 P-7)", async () => { + await get("?date=2026-09-29"); + const mainCall = calls.cleanTimetable.find((c) => "include" in c); + expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall); + }); + + it("直近モード(`date` 省略)は表3 W4 の窓(2026-09-23〜2026-09-30)で `start_at` が絞られる(指示書AV)", async () => { + await get(""); + expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to); + }); + + it("★★`?date=`(★空文字★)でも直近モードの窓に倒れ、全件読みに戻らない★★", async () => { + // ★`??` で書くと `""` がそのまま残り、`start_at` のキーごと消えて + // ★テーブル全件読み★ になる(本番想定で約26万行)。 + // ★★レスポンスからは観測しにくい★★ので、★`start_at` の条件が消えないこと★をここで見る + const body = await get("?date="); + expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to); + // ★当日の行(103)が拾えていること=空振りしていないこと★ + const { success, failed, not_executed } = body.summary; + expect(success + failed + not_executed).toBeGreaterThan(0); + }); + + it("★`?date=` と `date` 省略が同じ結果になる★", async () => { + const omitted = await get(""); + const empty = await get("?date="); + expect(empty).toEqual(omitted); + }); + + it("形式が不正な `date` は 422 で弾かれ、`db` まで来ない", async () => { + const res = await GET(new Request("http://localhost/api/dashboard?date=2026/09/30")); + expect(res.status).toBe(422); + expect(calls.cleanTimetable).toHaveLength(0); + expect(calls.cleanTimetableHistory).toHaveLength(0); + }); +}); + +describe("GET /dashboard — 要レスキュー店舗(指示書AI 段階5)", () => { + it("★当該日に清掃予定が無い施設も出る(評価日は直前のスケジュール存在日)★", async () => { + const body = await get(`?date=${TODAY}`); + + // ★施設2は 09-30 に予定が無い★ — 当該日のログで母集団を絞っていたら消える + expect(body.rescue_required).toHaveLength(1); + expect(body.rescue_required[0]).toMatchObject({ + facility_id: 2, + facility_name: FACILITY_2, + evaluated_on: "2026-09-29", + reasons: ["not_executed_streak", "escalation_l3"], + not_executed_days: 3, + consecutive_problem_days: 3, + }); + }); + + it("★数値カード(summary)は要レスキューの影響を受けない★", async () => { + const body = await get(`?date=${TODAY}`); + + // ★当日のログは施設1の1行だけ★(施設2の未実施3日は分母に入らない。ユーザー指示 2026-09-15) + const { success, failed, not_executed } = body.summary; + expect([success, failed, not_executed]).toEqual([1, 0, 0]); + }); + + it("要レスキューが0件でも `rescue_required` のキーは落とさない", async () => { + // ★施設2の3日連続が始まる前の日を指定する★(=どの施設も条件に当たらない) + const body = await get("?date=2026-09-28"); + + expect(body.rescue_required).toEqual([]); + }); +}); + +describe("GET /dashboard — 直近モード(D-159 / 指示書AV 段階4-5)", () => { + // ★期待値は指示書AV で導いた値(§11 Q10 で監督が確定)★: + // 施設1(エリア10)は 09-30 02:00〜03:00 が一番新しい終わった予定 = 成功(完了率100)。 + // 施設2(エリア20)は 09-29 02:00〜03:00 = レポートが無い = 未実施。 + it("summary は各清掃エリアの直近の清掃(成功1・未実施1)", async () => { + const body = await get(""); + expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 }); + }); + + it("同じデータで `?date=2026-09-30` はその日の行だけ([1, 0, 0] のまま)", async () => { + const body = await get(`?date=${TODAY}`); + const { success, failed, not_executed } = body.summary; + expect([success, failed, not_executed]).toEqual([1, 0, 0]); + }); + + it("problem_logs は直近モードで施設2の 09-29 の1行だけ。`?date=2026-09-30` では []", async () => { + const latest = await get(""); + expect(latest.problem_logs.map((r) => r.id)).toEqual([203]); + expect(latest.problem_logs[0]).toMatchObject({ + facility_id: 2, + scheduled_at: "2026-09-29T02:00:00+09:00", + departed: false, + // ★`capCleanLogs()` を通っている★(指示書AV D13) + consecutive_problem_days_capped: false, + }); + + const ofDay = await get(`?date=${TODAY}`); + expect(ofDay.problem_logs).toEqual([]); + }); + + it("★メタの `where` は清掃ログ(`include` 付きの1本目)の `where` と同じ★(4-8)", async () => { + await get(""); + const meta = metaCalls(); + expect(meta).toHaveLength(1); + expect(meta[0].where).toEqual(mainQueryWhere()); + }); + + it("★E1′: メタが1行ぶん欠けても 200。その行は選ばれず、`data.anomaly` を1回(row_ids つき)★(指示書BB A2)", async () => { + // ★`cleanLogs()` と `cleanLogRunMeta()` の間に行が増えた状態★ — 施設1の 09-30(103)のメタだけ返さない + const original = db.cleanLogRunMeta.bind(db); + const spy = vi + .spyOn(db, "cleanLogRunMeta") + .mockImplementation(async (query) => (await original(query)).filter((m) => m.row_key !== "tt-103")); + try { + const res = await GET(new Request("http://localhost/api/dashboard")); + expect(res.status).toBe(200); + const body = (await res.json()) as DashboardResponse; + // ★103 が選ばれないので、施設1は1つ前の 09-29(102。成功)が直近になる★ + expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 }); + const events = vi + .mocked(logEvent) + .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing"); + expect(events).toHaveLength(1); + expect(events[0][1]).toEqual({ + event: "data.anomaly", + route: "/api/dashboard", + method: "GET", + metric: "clean_log_run_meta_missing", + expected: 0, + actual: 1, + // ★`row_key` を出す★(指示書BG §2-2。予定外の行は id が無いため。旧: "103") + row_ids: ["tt-103"], + }); + } finally { + spy.mockRestore(); + } + }); + + it("メタが揃っていれば `clean_log_run_meta_missing` を出さない", async () => { + await get(""); + const events = vi + .mocked(logEvent) + .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "clean_log_run_meta_missing"); + expect(events).toHaveLength(0); + }); + + it("★`?date=2026-09-29` ではメタを読まない★(0回)", async () => { + await get("?date=2026-09-29"); + expect(metaCalls()).toHaveLength(0); + }); + +}); + +/** + * ★スケジュール進捗(ユーザー判断 2026-10-29「案B」)★ — 期待値表(`NOW` = 2026-09-30 09:00 JST): + * | # | 指定 | 対象日 | 対象日の本清掃 | planned | completed | rate | + * |---|---|---|---|---|---|---| + * | S1 | 省略(直近モード) | 09-29(前日) | 102(出発)/ 203(未実施) | 2 | 1 | 50 | + * | S2 | `?date=2026-09-29` | 09-29 | 同上 | 2 | 1 | 50 | + * | S3 | `?date=2026-09-30` | 09-30 | 103(出発) | 1 | 1 | 100 | + * | S4 | `?date=2026-09-30` + 施設3の結果待ち 301 | 09-30 | 103 / 301(結果待ち) | 2 | 1 | 50 | + * | S5 | `?date=2026-10-01`(予定なし) | 10-01 | なし | 0 | 0 | null | + * | S6 | 省略 + 施設2が削除済み | 09-29 | 102 だけ(203 は見えない) | 1 | 1 | 100 | + * | S7 | 省略 + `clean_schedules` に毎日の予定7件 | 09-29 | 102 / 203 | 2 | 1 | 50(★曜日の設定を数えない★) | + */ +describe("GET /dashboard — スケジュール進捗(2026-10-29 案B)", () => { + it("S1 直近モードの対象日は前日(09-29)。当日(09-30)の行で数えない", async () => { + const body = await get(""); + expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 2, completed: 1, rate: 50 }); + }); + + it("S2 `?date=2026-09-29` は S1 と同じ", async () => { + const latest = await get(""); + const ofDay = await get("?date=2026-09-29"); + expect(ofDay.schedule_progress).toEqual(latest.schedule_progress); + }); + + it("S3 `?date=2026-09-30` はその日(前日にしない)", async () => { + const body = await get(`?date=${TODAY}`); + expect(body.schedule_progress).toEqual({ date: TODAY, planned: 1, completed: 1, rate: 100 }); + }); + + it("S4 結果待ちは planned にだけ入る", async () => { + data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00")); + const body = await get(`?date=${TODAY}`); + expect(body.schedule_progress).toEqual({ date: TODAY, planned: 2, completed: 1, rate: 50 }); + }); + + it("S5 対象日に予定が無ければ planned 0・rate null", async () => { + const body = await get("?date=2026-10-01"); + expect(body.schedule_progress).toEqual({ date: "2026-10-01", planned: 0, completed: 0, rate: null }); + }); + + it("S6 削除済みの施設の予定は数えない", async () => { + for (const t of data.timetables) { + if (t.facility_id === BigInt(2)) t.facility.deleted_at = dbWallClock("2026-09-01T00:00:00+09:00"); + } + const body = await get(""); + expect(body.schedule_progress).toEqual({ date: "2026-09-29", planned: 1, completed: 1, rate: 100 }); + }); + + it("S7 `clean_schedules`(曜日の設定)は読まない — 予定件数は予定の表の行から", async () => { + const body = await get(""); + expect(calls.cleanSchedule).toHaveLength(0); + expect(body.schedule_progress.planned).toBe(2); + await get(`?date=${TODAY}`); + expect(calls.cleanSchedule).toHaveLength(0); + }); +}); + +/** + * ★結果待ち(D-173。ユーザー判断 2026-10-07)★ — 表2 C6・C7(監督確定)。 + * `NOW` = 09-30 09:00。★施設3の 09-30 07:30〜08:30 にレポートが無い★ — + * ★終了+`RESULT_WAIT_MINUTES`(120分)= 10:30 より前なので結果待ち★(`isResultPending()` 表1 P5 と同じ形)。 + * ★終了(08:30)≤ 現在(09:00)なので直近の候補になる★(C6。30分待ちはやめた)。 + * ★`result_pending` はデータソース(`prismaSource.cleanLogs()`)が詰める★ — ここでは数え方と `problem_logs` だけを見る。 + */ +describe("GET /dashboard — 結果待ち(表2 C6・C7。D-173)", () => { + beforeEach(() => { + data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00")); + }); + + it("C6 直近モード: 施設3の結果待ちは pending に数え、未実施・成功率の分母に入れない", async () => { + const body = await get(""); + expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 }); + }); + + it("C7 problem_logs に結果待ちの行を出さない(確定した出発失敗 203 は今までどおり出す)", async () => { + const body = await get(""); + expect(body.problem_logs.map((r) => r.id)).toEqual([203]); + }); + + it("C5・C7 `?date=2026-09-30` でも同じ(pending 1・problem_logs は空)", async () => { + const body = await get(`?date=${TODAY}`); + expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 0, pending: 1, success_rate: 100 }); + expect(body.problem_logs).toEqual([]); + }); +}); + +/** + * ★D-Sec アラートの想定外の状態でダッシュボードを落とさない★(指示書AX 表1 R10・D3)。 + * `db.dsAlerts()` はダッシュボードも呼ぶ。`status` 3 の行を状態の変換に通すと例外 → 全体が500になる。 + * ★スタブは where を無視して返す★(SQL の `status IN` が効かなかった状態。正は純粋関数)。 + */ +describe("GET /dashboard — D-Sec アラート(指示書AX 表1 R10)", () => { + it("★status 3 の D-S_CONTROL_FAILURE 行(robot_id NULL)が返っても 200・バナーは空★", async () => { + data.dsAlertRows = [ + { + error_id: "R-10", + robot_id: null, + clean_timetable_id: null, + serial_number: "DFAS010001", + facility_id: BigInt(1), + facility: { facility_name: FACILITY_1, deleted_at: null }, + section: { section_name: "1F売り場", deleted_at: null }, + error_type: D_SEC_ALERT_ERROR_TYPES[0], + errored_at: dbWallClock("2026-09-30T02:00:00+09:00"), + created_at: dbWallClock("2026-09-30T02:01:00+09:00"), + status: 3, + responded_at: null, + resolved_at: null, + comment: null, + }, + ]; + const res = await GET(new Request(`http://localhost/api/dashboard?date=${TODAY}`)); + expect(res.status).toBe(200); + const body = (await res.json()) as DashboardResponse; + expect(body.security_banner).toEqual([]); + }); +}); + +describe("GET /dashboard — 削除済みの施設・清掃エリア(指示書BB U1・表1 V4/V5)", () => { + it("★V4: 清掃ログ・直近のメタの `where` に可視性が入る★(V5 の予定件数も同じ清掃ログから数える。2026-10-29 案B)", async () => { + await get(""); + expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + for (const m of metaCalls()) expect(m.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + // ★予定件数は `clean_schedules` を読まない★(スケジュール進捗の describe の S6・S7) + expect(calls.cleanSchedule).toHaveLength(0); + }); +}); + +describe("GET /dashboard — 存在しない日付は422(指示書BB 表8 Q6)", () => { + it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => { + const bad = await GET(new Request("http://localhost/api/dashboard?date=2026-02-31")); + const format = await GET(new Request("http://localhost/api/dashboard?date=2026/02/03")); + expect(bad.status).toBe(422); + expect(format.status).toBe(422); + expect(await bad.json()).toEqual(await format.json()); + }); +}); + +// ★表10 N7(指示書BB A6・D17)★ — ダッシュボードの D-Sec は解消でないものだけ読む(バナーは解消でないものしか見ない) +describe("GET /dashboard — D-Sec アラートは未解消だけ読む(指示書BB 表10 N7)", () => { + it("★`dsAlerts({ open_only: true })` — SQL の status は未解消・対応中(0/1)だけ★", async () => { + await get(""); + expect(dsAlertWheres).toHaveLength(1); + expect(dsAlertWheres[0]).toEqual(dsAlertWhere({ open_only: true })); + expect((dsAlertWheres[0] as { status: { in: number[] } }).status.in).toEqual([0, 1]); + }); +}); diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..504dd1e --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,259 @@ +/** + * GET /dashboard — ダッシュボード(清掃結果サマリ / スケジュール進捗 / セキュリティバナー) + * + * 仕様の正は `d-watch-api.yaml` の /dashboard、各値の定義は `docs/decisions.md` D-049 です。 + * 形は `app/api/clean-logs/route.ts`(動く見本)に合わせています。 + * + * ★`failure_breakdown` は返しません(D-048)★ + * 4分類の分類元(エラーテーブル)が未定義のため M3 送りです。 + * 空配列ではなく**プロパティごと省略**します。空配列は「当日の失敗0件」を意味するため、 + * 未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。 + * + * ★`date` を省略すると「直近モード」★(D-159 / 指示書AV)— + * `summary` と `problem_logs` を ★清掃エリアごとの直近の清掃(7日以内・終わった予定のみ・出発失敗を含む)★ + * から作ります。選び方は `pickLatestRunPerSection()`(`lib/dashboard/latest-run.ts`。純粋関数)。 + * ★要レスキュー・セキュリティバナーは省略時も当日のまま★(D-049 / 指示書AV 4-1)。 + * ★スケジュール進捗は省略時は JST の前日★(ユーザー判断 2026-10-29「案B」。`scheduleProgressTargetDate()`)— + * ダッシュボードの結果は昨夜の清掃なので、まだ始まっていない今夜の予定を数えない。 + * + * ★コンパクト版清掃詳細ログは `problem_logs` で返します★(D-159 / 指示書AV §2 D7。旧: GET /clean-logs を再利用)— + * ★数値カードと表を同じ `now`・同じ行の集合から作るため★。別の API にすると2本のリクエストが + * 別々に「現在」を取るので、境界(予定の終了ちょうど付近)でカードと表の母集団がずれうる。 + * + * ★結果待ち(`result_pending`。ユーザー判断 2026-10-07。D-173)★ — + * `summary.pending` に数え、★`problem_logs` には出さない★(表2 C5・C7)。 + * 直近モードの候補は「予定の終了 ≤ 現在」(C6。旧: 終了+30分)。 + * + * ★予定外タスク(指示書BG §2-2。2026-10-29)★ — `db.cleanLogs()` が予定外の行(`unscheduled: true`・`id: null`)も返すので、 + * ★このルートは何も足さずに★ summary・problem_logs・進み具合(A案: 予定にも出発にも1件ずつ)に入る。 + * 直近モードのメタ(`cleanLogRunMeta()`)も予定外の行のぶんを返し、`row_key` で突き合わせる(`pickLatestRunPerSection()`)。 + */ +import { NextResponse } from "next/server"; +import { parseMasterIdFilters } from "@/lib/api/master-id"; +import { capCleanLogs, isProblemGrade, parseDate } from "@/lib/clean-logs/query"; +import { CONSECUTIVE_DAYS_DISPLAY, DASHBOARD_LATEST_RUN } from "@/lib/config"; +import { + computeScheduleProgress, + computeSecurityBanner, + computeSummary, + scheduleProgressTargetDate, +} from "@/lib/dashboard/aggregate"; +import { + latestRunWindow, + missingRunMetaIds, + pickLatestRunPerSection, +} from "@/lib/dashboard/latest-run"; +import { db } from "@/lib/db"; +import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { toJstIso } from "@/lib/format/date"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits"; +import type { + ApiError, + CleanLogDbQuery, + CleanLogItem, + CleanLogRunMeta, + DashboardResponse, + DsAlert, + RescueRequiredItem, +} from "@/lib/types"; + +const ROUTE = "/api/dashboard"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + + const dateRaw = searchParams.get("date"); + if (dateRaw && !parseDate(dateRaw)) { + const body: ApiError = { + error: { + code: "validation_failed", + message: "dateの形式が不正です", + details: { date: ["YYYY-MM-DD形式で指定してください"] }, + }, + }; + return NextResponse.json(body, { status: 422 }); + } + // ★★「現在」はここで1回だけ作る★★(指示書AV 4-5)— `today`・直近モードの窓・ + // `pickLatestRunPerSection()` に ★同じ `now`★ を渡す。2回作ると、日付の境目や + // 予定の終了ちょうど付近で、窓と選び方が別の「現在」を見てずれる。 + // ★`toJstIso()` で日付を切り出す★(実行環境のTZに依存しない。`todayJst()` と同じ日付になる)。 + const now = new Date(); + const today = toJstIso(now).slice(0, 10); + // 省略時は直近モード(D-159)。要レスキューの基準日は当日、スケジュール進捗の対象日は前日(2026-10-29 案B)。 + // ★★`??` ではなく `||` / `!` にすること★★ — ★`?date=`(値が空)だと `""` は null でも + // undefined でもないので `??` では既定値に倒れません★。★`?date=` は直近モードになる★(省略と同じ)。 + // ★`""` のまま下へ渡すと `dbDateRange("", "")` で `start_at` の条件ごと消え、 + // ★★テーブル全件読みに戻ります★★(本番想定で `clean_timetables` 約26万行)。 + // ★★レスポンスは後段の `logsOfDay` の日付一致で空になるので画面からは観測できません★★ + // (固定しているのは `app/api/dashboard/__tests__/route.test.ts`)。 + const latestMode = !dateRaw; + const date = dateRaw || today; + + // ★★分類(`group_id` / `customer_id`)の検証は共通の1本に任せる★★(指示書AK 段階2 / 段階4b)— + // ★このルートは自前でクエリを読んでいる★(`parseXxxQuery()` を持たない)ので、 + // ★★ここで検証規則を書き下ろさないこと★★。`0` / `1abc` / 安全整数超の扱いが + // 他の10エンドポイントとずれ、★どれか1つだけ緩い★状態になる(`lib/api/master-id.ts`)。 + const masters = parseMasterIdFilters(searchParams); + if (!masters.ok) { + return NextResponse.json(masters.error, { status: masters.status }); + } + + // ★表示上限(`problem_logs` の連続日数。指示書AV §2 D13)★ — `app/api/clean-logs/route.ts` と同じ作法。 + // `loadConsecutiveDaysMax()` は内部で既定値に倒れる設計なので通常は例外にならないが、 + // ★「設定が読めない」だけでダッシュボードを 500 にしない★。 + // ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型) + let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax; + try { + maxDays = await loadConsecutiveDaysMax(); + } catch { + // 既定値のまま続行する + } + + // ★★このAPIの母集団を決める1本★★(指示書AK §7-2)— + // ★`null` = 絞らない / `[]` = 0件ヒット★(★罠1★。`?.length` で分岐しないこと)。 + // ★★清掃ログ(`summary` / `problem_logs` / スケジュール進捗の元)・直近モードのメタ(`cleanLogRunMeta()`)・ + // セキュリティ(`dsAlerts`)・要レスキューの4本すべてに**同じ配列**を渡すこと★★ — + // ★どれか1つを渡し忘れると、「この屋号の昨夜の成績」と「要レスキュー」が別の集団の話になる★ + // (メタだけ漏れると、行に対応するメタが無く、その行が選ばれず `data.anomaly` が出る。指示書BB A2)。 + // ★例外もテスト失敗も出ない壊れ方★なので + // `app/api/__tests__/master-filter-dashboard-reports.test.ts` で固定してある。 + let facilityIds: number[] | null; + let logs: CleanLogItem[]; + let dsAlerts: DsAlert[]; + let rescueRequired: RescueRequiredItem[]; + /** 直近モードで選んだ行(★`date` 指定のときは `null`★) */ + let picked: CleanLogItem[] | null; + try { + facilityIds = await resolveFacilityIdsByMasters(masters.query); + // ★★清掃ログとメタには**同じオブジェクト**を渡す★★(指示書AV 4-2 / 4-8)— + // 別々に組み立てると、片方だけ条件が抜けても気づけない(突き合わせが静かにずれる)。 + // ★直近モード★: 表3 の窓(`latestRunWindow()`。今日を含む8日ぶん)で `start_at` を絞る。 + // ★`date_from` / `date_to` を `null` にしないこと★ — 全件読みに戻る(4-7)。 + // ★`date` 指定★: ★その1日ぶんだけを引く★(指示書AC 段階2 D-6)。 + // `date` は上の `parseDate()` で `^\d{4}-\d{2}-\d{2}$` の検証を通っている + // (= `todayJst()` と同じ形式)ので、そのまま渡してよい(§E-2)。 + // ★★施設の絞り込みは分類だけ★★(このAPIに施設名の条件は無い)— + // ★`resolveFacilityIdsByMasters()` の結果をそのまま渡す★ので積集合は要らない。 + // ★★分類を指定していないときに `[]` を渡さないこと★★ — `[]` は「0件ヒット」の + // 意味で、集計が ★全部0★ になります(罠1)。`null` が「絞らない」。 + // ★単一ID の指定はしない(期間の全件が要る)★(指示書AC 段階3) + const logQuery: CleanLogDbQuery = latestMode + ? { + id: null, + ...latestRunWindow(now, DASHBOARD_LATEST_RUN.lookbackDays), + facility_ids: facilityIds, + } + : { id: null, date_from: date, date_to: date, facility_ids: facilityIds }; + let meta: CleanLogRunMeta[] | null; + [logs, meta, dsAlerts, rescueRequired] = await Promise.all([ + // ★読む量★: `date` 指定は1日ぶん(本番想定で約26万行の読み込みが約360行)。 + // ★直近モードは8日ぶん★(約2,900行。指示書AV 4-7)。 + db.cleanLogs(logQuery), + // ★★日付指定ではメタを読まない★★(直近モード専用。指示書AV 段階4-4) + latestMode ? db.cleanLogRunMeta(logQuery) : Promise.resolve(null), + // ★★`clean_schedules`(`db.schedules()`)は読まない★★(2026-10-29 案B)— スケジュール進捗の予定件数は + // 上の清掃ログ(予定の表の本清掃)から数える。曜日の設定は予定の表と食い違いうる(ステージングで 7件 対 5件)。 + // ★★施設の絞り込みは下の `filterRowsByFacilityIds()` でルート側で行う★★(段階4a の申し送り。`lib/facilities/filter-by-ids.ts`)。 + // ★`dsAlerts()` は解消でないものだけ読む★(バナーは解消でないものしか見ない。指示書BB A6・D17) + db.dsAlerts({ open_only: true }), + // ★★上の `cleanLogs()` の結果から作らないこと★★(指示書AI §1-5 / 段階4)— + // ★当日に清掃予定が無い施設も要レスキューには出す★ので母集団が違ううえ、 + // R-1(未実施3日連続)も R-3(前回比の急落)も ★当日の行だけでは判定できない★。 + // ★遡りぶんの読み込みは `db.rescueRequired()` の中(`loadConsecutiveInputs()`)★。 + // ★★`summary` と**同じ** `facilityIds` を渡すこと★★(指示書AK §7-2)— + // ★別の配列を作り直さない★(作り直すと片方だけ条件が抜けても気づけない)。 + // ★基準日は直近モードでも当日(`date`)★(D-159。指示書AV 4-1) + db.rescueRequired({ date, facility_ids: facilityIds }), + ]); + // ★★`try` の中で呼ぶ★★(指示書AV 4-9)— メタの重複(E2)は例外になる(D9)。 + // 外で呼ぶと `{ error: ... }` の形にならない。 + // ★設定値は `DASHBOARD_LATEST_RUN` から渡す★(7 を直書きしない。`CLAUDE.md` §7)。 + // ★メタの無い行(E1′。並列に読む間に行が増えた)は選ばれず、ここでログを1回出す★(指示書BB A2・D-182) + if (meta !== null) { + const missingIds = missingRunMetaIds(logs, meta); + if (missingIds.length > 0) { + logEvent("warn", { + event: "data.anomaly", + route: ROUTE, + method: "GET", + metric: "clean_log_run_meta_missing", + expected: 0, + actual: missingIds.length, + // ★`row_key`("tt-<予定の id>" / "rp-")★(指示書BG §2-2。予定外の行は id が無い) + row_ids: missingIds, + }); + } + } + picked = + meta === null + ? null + : pickLatestRunPerSection(logs, meta, { now, ...DASHBOARD_LATEST_RUN }); + } catch (e) { + const body: ApiError = { + error: { + code: "internal_error", + message: "データの取得に失敗しました", + details: null, + }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + // 日付の切り出しは文字列で行う。scheduled_at は ISO8601(+09:00) なので + // 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない。 + // ★`logsOfDay` は `date` 指定のときの数値カードの母集団★(直近モードでは使わない)。 + const logsOfDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date); + // ★数値カードと `problem_logs` の母集団★: 直近モードは選んだ行、`date` 指定はその日の行(D-159 / D12) + const summaryRows = picked ?? logsOfDay; + + // ★★`dsAlerts()` は施設で絞らない契約なのでここで絞る★★ + // (段階4a が `/ds-alerts` で使っているのと同じ純粋関数)。 + // ★★集計より**前**に絞ること★★ — セキュリティバナーも ★同じ母集団から作る★ため。 + // ★`logs` は `db.cleanLogs()` の時点で絞られている★ので二重に絞らない。 + const dsAlertsOfScope = filterRowsByFacilityIds(dsAlerts, facilityIds); + + const summary = computeSummary(summaryRows); + // ★「問題あり」の規則は `isProblemGrade()` を共有する★(書き写さない。指示書AV 4-11)。 + // ★★結果待ちの行は出さない★★(ユーザー判断 2026-10-07。D-173 / 表2 C7)— 結果待ちの行は + // `grade: "×"` のままなので、旗を見ないと「問題あり」に入ってしまう(`result=problem` と同じ扱い。C3)。 + // ★★絞り込みの**後**に `capCleanLogs()` を通す★★(D13 / 4-10。`/clean-logs` と連続日数の表示を揃える) + const problemLogs = capCleanLogs( + summaryRows.filter((row) => !row.result_pending && isProblemGrade(row.grade)), + maxDays + ); + // ★★スケジュール進捗(2026-10-29 案B)★★ — 対象日は直近モードなら JST の前日、`date` 指定ならその日。 + // ★`logs` から数える★(施設の絞り込み・削除済みの除外・本清掃だけ、が済んだ同じ行)。 + // ★直近モードの窓(`latestRunWindow()`。今日を含む8日ぶん)は前日を含む★ので、前日の行は読めている。 + // ★前日の行は全部過去なので `isFutureSchedule()` で落ちない★。 + // ★`date` 指定でも `logs` はその1日ぶん★。★`now` は上で作った同じインスタンス★ + const scheduleProgress = computeScheduleProgress( + logs, + scheduleProgressTargetDate(now, latestMode ? null : date) + ); + // ★DB のデータでは `security_off` が出ないのでバナーは常に空(指示書AX D4。D-180)★ + const securityBanner = computeSecurityBanner(dsAlertsOfScope); + + // ★旧 `data.anomaly`(`clean_log_rows_vs_scheduled`。曜日の設定の件数と当日の行数の食い違い。D-049)は消した★ + // (2026-10-29 案B)— 予定件数を清掃ログの行そのものから数えるので、比べる相手が無くなった。 + + const body: DashboardResponse = { + summary, + schedule_progress: scheduleProgress, + security_banner: securityBanner, + // ★データソースが並べ替えまで済ませた配列をそのまま返す★ + // (★ここで並べ替え直さないこと★。順序の正は `buildRescueRequired()`。D-052 / D-055) + // ★★件数では切らない★★(指示書AJ 段階1。上限は無く、該当施設を全部返す。 + // 初期表示を5件に絞るのは画面側の責務 = `lib/dashboard/rescue-visibility.ts`) + rescue_required: rescueRequired, + // ★データソースの順のまま(scheduled_at 降順 → id 昇順)。並べ替え直さない★ + // ★★件数では切らない★★(指示書AV §2 D11。1清掃エリア最大1行。先頭5件を出すのは画面) + problem_logs: problemLogs, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/ds-alerts/__tests__/route.fixtures.test.ts b/app/api/ds-alerts/__tests__/route.fixtures.test.ts new file mode 100644 index 0000000..dc997ff --- /dev/null +++ b/app/api/ds-alerts/__tests__/route.fixtures.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/errors/[error_id]/status/route"; +import { GET } from "@/app/api/ds-alerts/route"; +import { resetFixtureErrorReportOverrides } from "@/lib/db/fixture-error-report-overrides"; +import type { DsAlertSummary } from "@/lib/ds-alerts/query"; +import type { DsAlert, SummaryListResponse } from "@/lib/types"; + +/** + * `GET /ds-alerts` → `PATCH /errors/{error_id}/status` → `GET /ds-alerts`(`USE_FIXTURES=true`。指示書AX 段階3-4)。 + * + * ★D-Sec アラートは `errorReportRows` の `D-S_CONTROL_FAILURE` 行から作る★(D13)ので、 + * エラー一覧の PATCH で書いた対応状況が D-Sec にも出る(U4。上書き表を共有)。 + * + * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、 + * 同じファイルで true/false の両方は試せない(`app/api/errors/__tests__/route.fixtures.test.ts` と同じ)。 + * ★テストごとに `resetFixtureErrorReportOverrides()` で空に戻す★。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +const NOW_ISO = "2026-10-06T08:15:00+09:00"; + +/** 出る7行(`FX-ERR-10`・`12`〜`17`)。未解消3(10・12・16)・対応中2(13・17)・解消2(14・15) */ +const VISIBLE_DS_ALERT_IDS = [ + "FX-ERR-10", + "FX-ERR-12", + "FX-ERR-13", + "FX-ERR-14", + "FX-ERR-15", + "FX-ERR-16", + "FX-ERR-17", +]; + +beforeEach(() => { + resetFixtureErrorReportOverrides(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); + resetFixtureErrorReportOverrides(); +}); + +async function list(qs = ""): Promise> { + const res = await GET(new Request(`http://localhost/api/ds-alerts${qs ? `?${qs}` : ""}`)); + expect(res.status).toBe(200); + return (await res.json()) as SummaryListResponse; +} + +async function patch(id: string, body: unknown): Promise { + return PATCH( + new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: JSON.stringify(body), + }), + { params: Promise.resolve({ error_id: id }) } + ); +} + +describe("GET /ds-alerts(USE_FIXTURES=true)", () => { + it("★既定(status=open)で5件・status=all で7件★", async () => { + const def = await list(); + expect(def.meta.total).toBe(5); + expect(def.items.map((i) => i.id).sort()).toEqual(["FX-ERR-10", "FX-ERR-12", "FX-ERR-13", "FX-ERR-16", "FX-ERR-17"]); + const all = await list("status=all"); + expect(all.meta.total).toBe(7); + expect(all.items.map((i) => i.id).sort()).toEqual(VISIBLE_DS_ALERT_IDS); + }); + + it("★summary は {unresolved:3, in_progress:2, resolved:2}(status に依らない)★", async () => { + for (const qs of ["", "status=all", "status=resolved"]) { + expect((await list(qs)).summary).toEqual({ unresolved: 3, in_progress: 2, resolved: 2 }); + } + }); + + it("★SECURITY_ON(FX-ERR-08)・NOT_STARTED(FX-ERR-11)・削除済みの清掃エリア(FX-ERR-19)は出ない★", async () => { + const ids = (await list("status=all")).items.map((i) => i.id); + expect(ids).not.toContain("FX-ERR-08"); + expect(ids).not.toContain("FX-ERR-11"); + expect(ids).not.toContain("FX-ERR-19"); + }); +}); + +describe("PATCH /errors → GET /ds-alerts(USE_FIXTURES=true)", () => { + it("★FX-ERR-10 を解消にすると status=open から消え、summary.resolved が3になる★", async () => { + expect((await patch("FX-ERR-10", { status: "resolved" })).status).toBe(200); + + const open = await list(); + expect(open.items.map((i) => i.id)).not.toContain("FX-ERR-10"); + expect(open.meta.total).toBe(4); + expect(open.summary).toEqual({ unresolved: 2, in_progress: 2, resolved: 3 }); + + const resolved = await list("status=resolved"); + expect(resolved.items.find((i) => i.id === "FX-ERR-10")).toMatchObject({ + resolution_status: "resolved", + responded_at: NOW_ISO, + resolved_at: NOW_ISO, + }); + }); +}); diff --git a/app/api/ds-alerts/__tests__/route.test.ts b/app/api/ds-alerts/__tests__/route.test.ts new file mode 100644 index 0000000..e5fc5eb --- /dev/null +++ b/app/api/ds-alerts/__tests__/route.test.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由) +import { GET } from "@/app/api/ds-alerts/route"; +import { D_SEC_ALERT_ERROR_TYPES } from "@/lib/config"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { dsAlertWhere } from "@/lib/db/ds-alert-mapping"; +import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping"; +import { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select"; +import type { DsAlertSummary } from "@/lib/ds-alerts/query"; +import { toDbJst } from "@/lib/format/date"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, DsAlert, SummaryListResponse } from "@/lib/types"; + +/** + * `GET /ds-alerts`(Prisma 経路。指示書AX 段階4-4-1)。 + * + * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して + * `prismaSource.dsAlerts()` の経路を走らせる(`app/api/errors/__tests__/route.test.ts` と同じ作法)。 + * + * ★スタブの `robotErrorReport.findMany` は★ + * - ★`guardNullRobotId()` を通す★(D-Sec の行は全部 `robot_id` NULL。`robot_id` / `robot` を読むと本物の Prisma は落ちる) + * - ★`where` を無視して全行返す★ — SQL が照合順序(ci・PAD SPACE)で `d-s_control_failure` や末尾空白の行まで + * 当ててしまい、可視性の条件も効かなかった状態を再現する。★正は純粋関数(`dsAlertsFromErrorRows()`)★(D2・D12) + * - `select` を見て返す列を絞る + * ★`controlErrorHistory` は呼ばれたら落ちる★(もう読まない。P-10 = A)。 + * + * 入力は表1 の13行(R1〜R13。出るのは R1〜R4・R11〜R13 の7行)。期待値は表2(監督判断 2026-10-19 で正)。 + * 施設: 1 = テスト施設A / 2 = テスト施設B(制御盤 DFAS010001 の所属)/ 3 = テスト施設C。 + * ★R11 は施設2の制御盤のシリアルだが、行の施設は3★(U3)。 + * ★R1 = "E-9"・R12 = "E-10" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆。D11)。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubErrorReport { + error_id: string; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id: bigint | null; + clean_timetable_id: bigint | null; + serial_number: string; + facility_id: bigint; + facility: { facility_name: string; deleted_at: Date | null }; + section_id: bigint; + section: { section_name: string; deleted_at: Date | null }; + error_type: string; + error_level: string; + errored_at: Date | null; + created_at: Date; + status: number; + responded_at: Date | null; + resolved_at: Date | null; + comment: string | null; +} + +type Args = Record & { where?: Record; select?: Record }; + +let errorReports: StubErrorReport[]; +/** `robotErrorReport.findMany` の引数の記録 */ +let errorCalls: Args[]; + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +/** `select` で返す列を絞る(入れ子の `{ select }` にも対応) */ +function project(row: Record, select: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(select)) { + if (value === true) out[key] = row[key]; + else if (value && typeof value === "object" && "select" in value) { + out[key] = project(row[key] as Record, (value as { select: Record }).select); + } + } + return out; +} + +vi.mock("@/lib/db/prisma", () => { + const read = { + robotErrorReport: { + findMany: async (args: Args) => { + errorCalls.push(args); + guardNullRobotId(errorReports as unknown as Record[], args); + // ★where を無視して全行返す★(上の docstring。SQL は写し) + return errorReports.map((row) => project(row as unknown as Record, args.select ?? {})); + }, + }, + // ★もう読まない(P-10 = A。指示書AX)★ — 呼ばれたらルートが 500 になり、テストが落ちる + controlErrorHistory: { + findMany: async () => { + throw new Error("[stub] control_error_histories は読まない(指示書AX。P-10 = A)"); + }, + }, + }; + return { + getPrisma: async () => read, + getWritePrisma: async () => { + throw new Error("[stub] 一覧で書き込み用の接続を取ってはいけない"); + }, + }; +}); + +// ---------------------------------------------------------------- 合成データ(表1) + +/** `+09:00` 付きの ISO → DB の壁時計値の `Date`(Prisma が返す形) */ +const db = (iso: string): Date => toDbJst(new Date(iso)); + +const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved"); +const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress"); +const RESOLVED = robotErrorStatusFromResolutionStatus("resolved"); +const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0]; +/** ★施設2の制御盤のシリアル★(R11 は施設3の行だがこのシリアル) */ +const SERIAL_FACILITY_2 = "DFAS010001"; + +// ★実在の店名を使わない★(`CLAUDE.md` §7) +const FACILITY_2 = { + facility_id: BigInt(2), + facility: { facility_name: "テスト施設B", deleted_at: null }, + section_id: BigInt(20), + section: { section_name: "1F売り場", deleted_at: null }, +}; +const FACILITY_3 = { + facility_id: BigInt(3), + facility: { facility_name: "テスト施設C", deleted_at: null }, + section_id: BigInt(30), + section: { section_name: "2F衣料品", deleted_at: null }, +}; + +/** 既定は D-Sec の行(R1 の形)。施設2・status 0・robot_id NULL */ +function row(overrides: Partial & Pick): StubErrorReport { + return { + robot_id: null, + clean_timetable_id: BigInt(100), + serial_number: SERIAL_FACILITY_2, + ...FACILITY_2, + error_type: DS_ALERT_TYPE, + error_level: "CRITICAL", + errored_at: db("2026-10-05T22:00:00+09:00"), + created_at: db("2026-10-05T22:01:00+09:00"), + status: UNRESOLVED, + responded_at: null, + resolved_at: null, + comment: null, + ...overrides, + }; +} + +/** ★出ない行(R5〜R10)は出る行のどれより新しい検知時刻にする★(漏れたら先頭に出て気づける) */ +const LEAK_AT = { errored_at: db("2026-10-06T05:00:00+09:00"), created_at: db("2026-10-06T05:01:00+09:00") }; + +function buildData() { + errorReports = [ + // R1 + row({ error_id: "E-9" }), + // R2: 対応中・備考あり + row({ + error_id: "R-2", + errored_at: db("2026-10-05T21:00:00+09:00"), + created_at: db("2026-10-05T21:01:00+09:00"), + status: IN_PROGRESS, + responded_at: db("2026-10-06T09:05:00+09:00"), + comment: "回線業者に確認中", + }), + // R3: 解消 + row({ + error_id: "R-3", + errored_at: db("2026-10-05T20:00:00+09:00"), + created_at: db("2026-10-05T20:01:00+09:00"), + status: RESOLVED, + responded_at: db("2026-10-06T08:40:00+09:00"), + resolved_at: db("2026-10-06T09:15:00+09:00"), + comment: "正常復帰を確認", + }), + // R4: errored_at NULL → detected_at は created_at(ステージングの16行すべてがこの形) + row({ error_id: "R-4", errored_at: null, created_at: db("2026-10-06T03:00:00+09:00") }), + // R5: SECURITY_ON(U1) + row({ error_id: "R-5", serial_number: "---", error_type: "SECURITY_ON", ...LEAK_AT }), + // R6: NOT_STARTED(U2) + row({ error_id: "R-6", serial_number: "---", error_type: "NOT_STARTED", ...LEAK_AT }), + // R7: ロボットのエラー(D1) + row({ error_id: "R-7", robot_id: BigInt(501), serial_number: "PD-CC1-0001", error_type: "EmergencyStop", error_level: "ERROR", ...LEAK_AT }), + // R8: ★わざと小文字★(SQL の ci では当たるが `===` で外れる。D2) + row({ error_id: "R-8", error_type: "d-s_control_failure", ...LEAK_AT }), + // R9: ★わざと末尾に空白★(SQL の PAD SPACE では当たるが `===` で外れる。D2) + row({ error_id: "R-9", error_type: "D-S_CONTROL_FAILURE ", ...LEAK_AT }), + // R10: status 3(出さない・例外にしない。D3) + row({ error_id: "R-10", status: 3, ...LEAK_AT }), + // R11: 施設2の制御盤のシリアル・行の施設は3(U3) + row({ + error_id: "R-11", + ...FACILITY_3, + errored_at: db("2026-10-05T23:00:00+09:00"), + created_at: db("2026-10-05T23:01:00+09:00"), + }), + // R12: R1 と同じ検知時刻・"E-10"(D11) + row({ error_id: "E-10", created_at: db("2026-10-05T22:02:00+09:00") }), + // R13: 予定 id NULL(予定の有無で絞らない) + row({ + error_id: "R-13", + clean_timetable_id: null, + errored_at: db("2026-10-05T19:00:00+09:00"), + created_at: db("2026-10-05T19:01:00+09:00"), + }), + ]; +} + +/** 出る7行の並び(`detected_at` 降順・同値は id の辞書順) */ +const ALL_ORDER = ["R-4", "R-11", "E-10", "E-9", "R-2", "R-3", "R-13"]; +/** 既定(`status=open`)。R3 以外の6行 */ +const OPEN_ORDER = ALL_ORDER.filter((id) => id !== "R-3"); +const HIDDEN = ["R-5", "R-6", "R-7", "R-8", "R-9", "R-10"]; + +type Body = SummaryListResponse; + +async function get(qs = ""): Promise { + return GET(new Request(`http://localhost/api/ds-alerts${qs ? `?${qs}` : ""}`)); +} + +async function list(qs = ""): Promise { + const res = await get(qs); + expect(res.status).toBe(200); + return (await res.json()) as Body; +} + +const ids = (items: DsAlert[]) => items.map((i) => i.id); + +beforeEach(() => { + buildData(); + errorCalls = []; + vi.mocked(logApiError).mockClear(); +}); + +// ---------------------------------------------------------------- 読み方 + +describe("読み方(指示書AX D1〜D3)", () => { + it("★findMany の where は dsAlertWhere({ open_only: false })(全件。指示書BB 表10 N8)、select は ROBOT_ERROR_DS_ALERT_SELECT★", async () => { + await list(); + expect(errorCalls).toHaveLength(1); + expect(errorCalls[0].where).toEqual(dsAlertWhere({ open_only: false })); + // ★解消(2)も SQL で読む(`summary.resolved` を数えるため)★ + expect((errorCalls[0].where as { status: { in: number[] } }).status.in).toContain(2); + expect(errorCalls[0].select).toEqual(ROBOT_ERROR_DS_ALERT_SELECT); + }); + + it("★robot_id が NULL の行があっても読み取りで落ちない・control_error_histories を読まない★", async () => { + await list(); + expect(vi.mocked(logApiError)).not.toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- 表2 + +describe("表2(パラメータ・summary)", () => { + it("A1: 既定(type=all&status=open)→ R3 以外の6行・meta.total = 6", async () => { + const body = await list(); + expect(ids(body.items)).toEqual(OPEN_ORDER); + expect(body.meta).toEqual({ total: 6, page: 1, per_page: 50 }); + }); + + it("A2: status=resolved → R3 だけ", async () => { + const body = await list("status=resolved"); + expect(ids(body.items)).toEqual(["R-3"]); + expect(body.meta.total).toBe(1); + }); + + it("A3: status=all → 7行", async () => { + const body = await list("status=all"); + expect(ids(body.items)).toEqual(ALL_ORDER); + expect(body.meta.total).toBe(7); + }); + + it("★A4: status=done(旧の値)→ 422。黙って倒さない(DB を読まない)★", async () => { + const res = await get("status=done"); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.code).toBe("validation_failed"); + expect(body.error.details).toEqual({ status: ["open / all / resolved のいずれかを指定してください"] }); + expect(errorCalls).toHaveLength(0); + }); + + it("A5: type=security_off → 200・0件(422 にしない。enum は残す。D4)", async () => { + const body = await list("type=security_off"); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + }); + + it("A6: type=disconnected → A1 と同じ", async () => { + const body = await list("type=disconnected"); + expect(ids(body.items)).toEqual(OPEN_ORDER); + expect(body.meta.total).toBe(6); + }); + + it.each(["", "status=all", "status=resolved", "type=security_off", "type=disconnected&status=all"])( + "A7: summary は type / status によらず { unresolved: 5, in_progress: 1, resolved: 1 }(%s)", + async (qs) => { + const body = await list(qs); + expect(body.summary).toEqual({ unresolved: 5, in_progress: 1, resolved: 1 }); + } + ); + + it("A9: per_page=2&page=2 → 並び順の3〜4行目・meta.total = 6", async () => { + const body = await list("per_page=2&page=2"); + expect(ids(body.items)).toEqual(["E-10", "E-9"]); + expect(body.meta).toEqual({ total: 6, page: 2, per_page: 2 }); + }); +}); + +// ---------------------------------------------------------------- 表1(ルートから見た値) + +describe("表1(ルートから見た値)", () => { + it("★R5〜R10 は出ない(status=all でも)★ — SQL が ci・PAD SPACE で当てても純粋関数の === で外れる", async () => { + const body = await list("status=all"); + for (const id of HIDDEN) expect(ids(body.items)).not.toContain(id); + }); + + it("★R10(status 3)が混ざっても 500 にならない★", async () => { + const res = await get("status=all"); + expect(res.status).toBe(200); + expect(vi.mocked(logApiError)).not.toHaveBeenCalled(); + }); + + it("R1: 各値(id は string・シリアルは生値・BigInt を残さない)", async () => { + const body = await list(); + expect(body.items.find((i) => i.id === "E-9")).toEqual({ + id: "E-9", + alert_type: "disconnected", + device_serial: SERIAL_FACILITY_2, + facility_id: 2, + facility_name: "テスト施設B", + section_name: "1F売り場", + detected_at: "2026-10-05T22:00:00+09:00", + resolution_status: "unresolved", + responded_at: null, + resolved_at: null, + note: null, + }); + }); + + it("R2: 対応中・responded_at・note", async () => { + const body = await list(); + expect(body.items.find((i) => i.id === "R-2")).toMatchObject({ + resolution_status: "in_progress", + responded_at: "2026-10-06T09:05:00+09:00", + resolved_at: null, + note: "回線業者に確認中", + }); + }); + + it("R3: 解消(status=all で出る)", async () => { + const body = await list("status=all"); + expect(body.items.find((i) => i.id === "R-3")).toMatchObject({ + resolution_status: "resolved", + responded_at: "2026-10-06T08:40:00+09:00", + resolved_at: "2026-10-06T09:15:00+09:00", + }); + }); + + it("★R4: errored_at が NULL → detected_at は created_at(9時間ずらさない)★", async () => { + const body = await list(); + expect(body.items.find((i) => i.id === "R-4")?.detected_at).toBe("2026-10-06T03:00:00+09:00"); + }); + + it("★R11: 行の施設3で出る(制御盤のシリアルの所属=施設2ではない)・シリアルはそのまま★", async () => { + const body = await list(); + expect(body.items.find((i) => i.id === "R-11")).toMatchObject({ + facility_id: 3, + facility_name: "テスト施設C", + section_name: "2F衣料品", + device_serial: SERIAL_FACILITY_2, + }); + }); + + it("★R12: 同じ検知時刻は辞書順で \"E-10\" が \"E-9\" より先★", async () => { + const order = ids((await list()).items); + expect(order.indexOf("E-10")).toBeLessThan(order.indexOf("E-9")); + }); + + it("R13: 予定 id が NULL でも出る", async () => { + expect(ids((await list()).items)).toContain("R-13"); + }); +}); + +// ---------------------------------------------------------------- 表5(指示書AX U8) + +/** + * ★表5 の D-Sec 列(V4〜V7)★。スタブは where を無視して全行返す(SQL の可視性の条件が効かなかった状態)ので、 + * ★純粋関数側の `isVisibleErrorRow()` が外す★ことを見る。★削除の日時は過去の値★(NULL か否かだけ。D12)。 + */ +describe("表5 — 削除済みの施設・清掃エリアの行は出ない(指示書AX U8)", () => { + const DELETED_AT = db("2026-10-01T10:00:00+09:00"); + + beforeEach(() => { + errorReports.push( + // V4: 両方生きている・status 0 + row({ error_id: "V-4", ...LEAK_AT }), + // V5: 施設が削除済み・status 0 + row({ error_id: "V-5", ...LEAK_AT, facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }), + // V6: 清掃エリアが削除済み・status 1 + row({ + error_id: "V-6", + ...LEAK_AT, + status: IN_PROGRESS, + responded_at: db("2026-10-06T09:00:00+09:00"), + section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT }, + }), + // V7: 両方削除済み・status 2 + row({ + error_id: "V-7", + ...LEAK_AT, + status: RESOLVED, + responded_at: db("2026-10-06T09:00:00+09:00"), + resolved_at: db("2026-10-06T09:30:00+09:00"), + facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT }, + section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT }, + }) + ); + }); + + it("V4: 出る・summary.unresolved に数える", async () => { + const body = await list(); + expect(ids(body.items)[0]).toBe("V-4"); + expect(body.summary.unresolved).toBe(6); + }); + + it.each(["", "status=all", "status=resolved"])("★V5〜V7: 一覧に出ない(%s)★", async (qs) => { + const body = await list(qs); + for (const id of ["V-5", "V-6", "V-7"]) expect(ids(body.items)).not.toContain(id); + }); + + it("★V5〜V7: summary にも数えない(in_progress・resolved は表1 のまま)★", async () => { + const body = await list("status=all"); + expect(body.summary).toEqual({ unresolved: 6, in_progress: 1, resolved: 1 }); + expect(body.meta.total).toBe(8); + }); +}); diff --git a/app/api/ds-alerts/route.ts b/app/api/ds-alerts/route.ts new file mode 100644 index 0000000..bee66be --- /dev/null +++ b/app/api/ds-alerts/route.ts @@ -0,0 +1,92 @@ +/** + * GET /ds-alerts — D-Secアラート一覧+サマリ + * + * 形は `app/api/ds-devices/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /ds-alerts。 + * + * ★対応状況・備考の更新は `PATCH /errors/{error_id}/status`(`robot_error_reports` の同じ行。 + * 指示書AX D6)。このルートは読むだけ★ + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + filterDsAlertsByStatus, + filterDsAlertsByType, + parseDsAlertQuery, + sortDsAlerts, + summarizeDsAlerts, +} from "@/lib/ds-alerts/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, DsAlert, SummaryListResponse } from "@/lib/types"; +import type { DsAlertSummary } from "@/lib/ds-alerts/query"; + +const ROUTE = "/api/ds-alerts"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseDsAlertQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: DsAlert[]; + // ★分類(`group_id` / `customer_id`)→ 施設IDの配列★(指示書AK 段階4)。 + // ★`null` = 絞らない / `[]` = 0件★(★罠1★。`?.length` で分岐しないこと)。 + let facilityIds: number[] | null; + try { + // ★解決(施設マスタの読み取り)も同じ try に入れる★ — 失敗したら 500。 + // ★黙って絞らずに返さないこと★(他社のアラートが混ざったまま画面に出る)。 + facilityIds = await resolveFacilityIdsByMasters(parsed.query); + // ★全件(`summary.resolved` に解消も数える。指示書BB D17)★ + rows = await db.dsAlerts({ open_only: false }); + } catch (e) { + const body: ApiError = { + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return errorResponse(body, 500); + } + + // ★★分類の絞り込みは summary より**前**にかける★★(指示書AK 段階4)— + // type / status は「見え方」の絞り込みなので summary に効かせない(D-054)が、 + // ★分類は「母集団」そのものを選ぶ★ので ★summary にも効かせる★ + // (指示書AK §7-2 の数値カードと同じ理由)。 + // ★★一覧と summary で母集団がずれないよう、必ず同じ配列から作ること★★。 + // ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。 + const scoped = filterRowsByFacilityIds(rows, facilityIds); + + // ★summaryはtype/statusフィルタの前(全件)に対して計算する(D-054と同じ理由)★ + // フィルタ後に計算すると、既定の絞り込み状態(status=open)によって + // サマリーが変わってしまう + const summary: DsAlertSummary = summarizeDsAlerts(scoped); + const filtered = filterDsAlertsByStatus( + filterDsAlertsByType(scoped, parsed.query.type), + parsed.query.status + ); + const sorted = sortDsAlerts(filtered); + + const { page, perPage } = parsed.query; + // ★meta.total と summary の合計は意味が違う★ + // meta.total = 一覧(type・statusフィルタ後)の総件数。ページング計算用 + // summary = 全アラート(type・statusの影響を受けない)に対する集計 + // 絞り込みが無いとき(type=all かつ status=all)だけ一致する + const total = sorted.length; + const start = (page - 1) * perPage; + const items = sorted.slice(start, start + perPage); + + const body: SummaryListResponse = { + summary, + items, + meta: { total, page, per_page: perPage }, + }; + return NextResponse.json(body); + }); +} diff --git "a/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts" "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts" new file mode 100644 index 0000000..8765129 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.fixtures.test.ts" @@ -0,0 +1,98 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/ds-devices/[serial_number]/note/route"; +import { dsDevices } from "@/lib/fixtures/ds-devices"; +import { + __setDeviceNotesStoreForTesting, + createDeviceNotesStore, + readDeviceNotes, +} from "@/lib/settings/ds-device-notes"; + +/** + * 指示書AH 段階2 の期待値表 ★#9★ — ★`USE_FIXTURES` の true/false で status が一致する★ + * (`CLAUDE.md` §9.5 / 指示書AC 禁止事項6「両系統で挙動を変えない」)。 + * + * ★★なぜ別ファイルなのか★★ + * `lib/db/index.ts` は ★`export const db = USE_FIXTURES ? fixtureSource : prismaSource;`★ で、 + * ★モジュール読み込み時に束縛される★。同じファイルで true/false の両方は試せない + * (`vi.mock` はファイル単位)。★Prisma 経路(#1〜#8)は同ディレクトリの `route.test.ts`★。 + * + * ★★#3(論理削除済み)・#4(ci 照合順序)は fixtures に表現できない★★ので、 + * ここでは扱わない(指示書AH 段階2 の注記どおり)。fixtures は `DsDevice[]` の配列で、 + * `deleted_at` も照合順序も存在しない。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +let dir = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-note-fx-")); + // 実ファイル(data/settings/ds-device-notes.json)を汚さない + __setDeviceNotesStoreForTesting( + createDeviceNotesStore(path.join(dir, "ds-device-notes.json")) + ); +}); + +afterEach(async () => { + __setDeviceNotesStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function patchNote(serialNumber: string, note: unknown): Promise { + return PATCH( + new Request( + `http://localhost/api/ds-devices/${encodeURIComponent(serialNumber)}/note`, + { method: "PATCH", body: JSON.stringify({ note }) } + ), + { params: Promise.resolve({ serial_number: serialNumber }) } + ); +} + +describe("PATCH /ds-devices/{serial_number}/note(USE_FIXTURES=true。指示書AH 段階2 #9)", () => { + it("★前提★: fixtures に `DS-001` があり `DS-999` は無い(#1・#2 の土台)", () => { + const serials = dsDevices.map((d) => d.serial_number); + expect(serials).toContain("DS-001"); + expect(serials).not.toContain("DS-999"); + }); + + it("#1 `DS-001`(存在する)→ 200 / 保存(★Prisma 経路と同じ status★)", async () => { + const res = await patchNote("DS-001", "fixtures 経路でも保存できる"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + serial_number: "DS-001", + note: "fixtures 経路でも保存できる", + }); + expect(await readDeviceNotes()).toEqual({ "DS-001": "fixtures 経路でも保存できる" }); + }); + + it("#2 `DS-999`(存在しない)→ 404(★Prisma 経路と同じ status★)", async () => { + const res = await patchNote("DS-999", "存在しない制御盤"); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { code: "not_found", message: "デバイスが見つかりません", details: null }, + }); + expect(await readDeviceNotes()).toEqual({}); + }); + + it("#5 `DS-001` → 200(fixtures には心拍の概念が無く、他台の欠損に左右されない)", async () => { + const res = await patchNote("DS-001", "他台の状態に左右されない"); + + expect(res.status).toBe(200); + expect(await readDeviceNotes()).toEqual({ "DS-001": "他台の状態に左右されない" }); + }); + + it("★照合は fixtures 経路でも `===`★(`ds-001` は 404。両系統で緩めない)", async () => { + // ★指示書の期待値表には無いが、★両系統で規則を変えない★ことの確認として置く★ — + // fixtures 側が `toUpperCase()` 等で緩むと、`USE_FIXTURES` の値で 404/200 が変わる + const res = await patchNote("ds-001", "小文字"); + + expect(res.status).toBe(404); + expect(await readDeviceNotes()).toEqual({}); + }); +}); diff --git "a/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts" "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts" new file mode 100644 index 0000000..1c8b7cf --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/note/__tests__/route.test.ts" @@ -0,0 +1,291 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` の +// 冒頭コメント参照。`it()` の中で `await import()` するとフルスイートでタイムアウトしうる) +import { PATCH } from "@/app/api/ds-devices/[serial_number]/note/route"; +import { DEVICE_NOTE_MAX_LENGTH, PATH_SERIAL_NUMBER_MAX_LENGTH } from "@/lib/config"; +import { + __setDeviceNotesStoreForTesting, + createDeviceNotesStore, + readDeviceNotes, +} from "@/lib/settings/ds-device-notes"; + +/** + * 指示書AH 段階2 — ★備考の保存の実在確認★(`PATCH /ds-devices/{serial_number}/note`)。 + * ★このルートにはテストが1本も無かったので新規★。 + * + * ------------------------------------------------------------------------ + * ★★何を守るテストか★★ + * ------------------------------------------------------------------------ + * 1. ★実在確認が `db.dsDevices()`(全件読み)に戻っていないこと★(#8)— + * シリアル1件の確認のために全制御盤+全施設を読む必要は無い。 + * 2. ★★照合が緩まないこと★★(#4)— ★DBの既定の照合順序は大小文字を区別しない(ci)★。 + * SQL の一致をそのまま答えにすると `ds-001` が `DS-001` に当たって 404 → 200 になり、 + * ★保存キーはリクエストの生文字列 `ds-001`★・★一覧は `notes["DS-001"]` で引く★ので、 + * ★★保存されたのに一生表示されない孤児行が残る★★(D-4)。 + * ★★だからスタブは ci 照合順序を**わざと**再現している★★ — 実装が JS の `===` で + * 決めるのをやめたら #4 が赤くなる。 + * 3. ★他台の欠損に巻き込まれないこと★(#5)— スタブは `control_reports` を読んだら + * 例外を投げる。以前の実装(`db.dsDevices()`)は心拍0件の制御盤が1台あるだけで + * ★全台の備考保存が500★ になっていた。 + * + * ------------------------------------------------------------------------ + * ★★DBにもデータディレクトリにも触りません★★ + * ------------------------------------------------------------------------ + * `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に + * 固定して ★`prismaSource` の経路★ を走らせます。備考の保存先は `fs.mkdtemp` で作った + * 一時ディレクトリに差し替えます(`lib/settings/__tests__/ds-device-notes.test.ts` と同じ作法)。 + * + * ★fixtures 経路(`USE_FIXTURES=true`)は同ディレクトリの `route.fixtures.test.ts`★ — + * ★`db` はモジュール読み込み時に束縛される★ので、同じファイルでは両方を試せない。 + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubControl { + serial_number: string; + deleted_at: Date | null; + /** `controls.status`(0=正常/1=異常。★0/1 以外は一覧に出ない → 備考も404★。指示書BB 表4 D4) */ + status: number; +} + +const calls: { controlFindMany: Record[] } = { controlFindMany: [] }; + +let controls: StubControl[] = []; + +/** + * ★★MySQL / MariaDB の既定の照合順序(ci)を再現する★★(D-4)— + * ★大小文字を区別せずに当たる★。★これがこのテストの肝★ で、 + * ★実装が JS の `===` で判定するのをやめたら #4 が赤くなる★。 + */ +function selectControlsCollationCi( + where: { deleted_at?: Date | null; serial_number?: string } = {} +): { serial_number: string; status: number }[] { + let rows = controls; + // ★`deleted_at` の絞りを**わざと**実装している★(★罠7★)— 条件を落としたら #3 が赤くなる + if ("deleted_at" in where && where.deleted_at === null) { + rows = rows.filter((c) => c.deleted_at === null); + } + if (where.serial_number !== undefined) { + const wanted = where.serial_number.toLowerCase(); + rows = rows.filter((c) => c.serial_number.toLowerCase() === wanted); + } + return rows.map((c) => ({ serial_number: c.serial_number, status: c.status })); +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + control: { + findMany: async (args?: Record) => { + calls.controlFindMany.push(args ?? {}); + return selectControlsCollationCi( + (args?.where ?? {}) as { deleted_at?: Date | null; serial_number?: string } + ); + }, + }, + // ★このルートは心拍を読んではいけない★(#5)— 読んだら例外で気づけるようにする + controlReport: { + findMany: async () => { + throw new Error("[stub] 備考の保存で control_reports を読んではいけません(指示書AH 段階2)"); + }, + groupBy: async () => { + throw new Error("[stub] 備考の保存で control_reports を読んではいけません(指示書AH 段階2)"); + }, + }, + }), +})); + +// ---------------------------------------------------------------- 前準備 + +let dir = ""; +let filePath = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-note-")); + filePath = path.join(dir, "ds-device-notes.json"); + // 実ファイル(data/settings/ds-device-notes.json)を汚さない + __setDeviceNotesStoreForTesting(createDeviceNotesStore(filePath)); + calls.controlFindMany = []; + controls = [ + { serial_number: "DS-001", deleted_at: null, status: 0 }, + { serial_number: "DS-002", deleted_at: null, status: 1 }, + // ★論理削除済み★(#3) + { serial_number: "DS-004", deleted_at: new Date("2026-09-01T00:00:00Z"), status: 0 }, + // ★`controls.status` が 0/1 以外★(#9。指示書BB 表4 D4) + { serial_number: "DS-005", deleted_at: null, status: 2 }, + ]; +}); + +afterEach(async () => { + __setDeviceNotesStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function patchNote(serialNumber: string, note: unknown): Promise { + return PATCH( + new Request( + `http://localhost/api/ds-devices/${encodeURIComponent(serialNumber)}/note`, + { method: "PATCH", body: JSON.stringify({ note }) } + ), + { params: Promise.resolve({ serial_number: serialNumber }) } + ); +} + +// ---------------------------------------------------------------- 期待値表 + +describe("PATCH /ds-devices/{serial_number}/note(指示書AH 段階2)", () => { + it("#1 `DS-001`(存在する)→ 200 / ★保存される★", async () => { + const res = await patchNote("DS-001", "制御盤の電源ユニット交換を手配中"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + serial_number: "DS-001", + note: "制御盤の電源ユニット交換を手配中", + }); + expect(await readDeviceNotes()).toEqual({ + "DS-001": "制御盤の電源ユニット交換を手配中", + }); + }); + + it("#2 `DS-999`(存在しない)→ 404(★実在確認が効く★)", async () => { + const res = await patchNote("DS-999", "存在しない制御盤"); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { code: "not_found", message: "デバイスが見つかりません", details: null }, + }); + // ★保存されないこと★(孤児行を作らない) + expect(await readDeviceNotes()).toEqual({}); + }); + + it("#3 `DS-004`(★論理削除済み★)→ 404(挙動を変えない。`deleted_at` を落とさない)", async () => { + const res = await patchNote("DS-004", "論理削除済みの制御盤"); + + expect(res.status).toBe(404); + // ★一覧(`dsDevices()`)に出ないデバイスの備考が保存できてしまうと、 + // どこからも参照されない行がストアに残る(★罠7★) + expect(await readDeviceNotes()).toEqual({}); + expect(calls.controlFindMany[0].where).toEqual({ + deleted_at: null, + serial_number: "DS-004", + }); + }); + + it("#4 ★`ds-001`(小文字)で DB は `DS-001` のみ → 404★(★★照合が緩まない★★)", async () => { + // ★スタブは ci 照合順序を再現しているので、SQL としては `DS-001` に**当たる**★。 + // それでも 404 になるのは ★実装が JS の `===` で判定しているから★(D-4)。 + const res = await patchNote("ds-001", "小文字で保存しようとした備考"); + + expect(res.status).toBe(404); + // ★★孤児行が残らないこと★★ — もし 200 になると、保存キーは生文字列 `ds-001` で、 + // 一覧は `notes["DS-001"]` を引くため ★保存されたのに一生表示されない行★ になる + expect(await readDeviceNotes()).toEqual({}); + // ★★SQL には**当たっていた**ことを確かめる★★ — + // = 404 の理由は「SQLが空を返したから」ではなく「JS の `===` が弾いたから」。 + // ★この行が緑である限り、#4 は照合順序の罠を本当に再現している★ + expect(calls.controlFindMany[0].where).toEqual({ + deleted_at: null, + serial_number: "ds-001", + }); + expect(selectControlsCollationCi({ deleted_at: null, serial_number: "ds-001" })).toEqual([ + { serial_number: "DS-001", status: 0 }, + ]); + }); + + it("#5 `DS-001` — ★別の制御盤に心拍が0件でも 200★(他台の欠損に巻き込まれない)", async () => { + // ★心拍が0件の制御盤を足す★。スタブは `control_reports` を読んだら例外を投げるので、 + // ★`db.dsDevices()` に戻したら 500 になって赤くなる★ + controls.push({ serial_number: "DS-777", deleted_at: null, status: 0 }); + + const res = await patchNote("DS-001", "他台が欠けていても保存できる"); + + expect(res.status).toBe(200); + expect(await readDeviceNotes()).toEqual({ "DS-001": "他台が欠けていても保存できる" }); + }); + + it("#6 101文字のシリアル → 422(★検証順序(形式→長さ→実在)を変えない★)", async () => { + const tooLong = "D".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH + 1); + const res = await patchNote(tooLong, "上限を超えたシリアル"); + + expect(res.status).toBe(422); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("validation_failed"); + // ★DBまで運ばないこと★(形式の検証が実在確認より前にある) + expect(calls.controlFindMany).toHaveLength(0); + }); + + it("#7 note が201文字(デバイスは存在する)→ 422", async () => { + const tooLong = "あ".repeat(DEVICE_NOTE_MAX_LENGTH + 1); + const res = await patchNote("DS-001", tooLong); + + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: "noteが長すぎます", + details: { note: [`${DEVICE_NOTE_MAX_LENGTH}文字以内で入力してください`] }, + }, + }); + // ★長さの検証も実在確認より前★(DBに照会しない) + expect(calls.controlFindMany).toHaveLength(0); + expect(await readDeviceNotes()).toEqual({}); + }); + + it("#8 `DS-001` — ★`control.findMany` 1本のみ★(全件読みに戻っていない)", async () => { + await patchNote("DS-001", "クエリ本数の検査"); + + expect(calls.controlFindMany).toHaveLength(1); + const args = calls.controlFindMany[0]; + // ★実在確認に施設名も最終通信も要らない★ — `include` を持たず、`serial_number` と状態(一覧と同じ規則。指示書BB A1)だけ選ぶ + expect(args.include).toBeUndefined(); + expect(args.select).toEqual({ serial_number: true, status: true }); + // ★★`take` を付けないこと★★(★罠8★。段階1 と同じ「SQL に降りない」問題を踏みうる) + expect(args.take).toBeUndefined(); + // ★シリアルで絞ってはいる(SQLは絞り込みまで。当たり外れは JS の `===`。D-4)★ + expect(args.where).toEqual({ deleted_at: null, serial_number: "DS-001" }); + }); + + it("#9 `DS-005`(★`controls.status` が 2★)→ 404(一覧に出ない制御盤の備考は保存しない。指示書BB 表4 D4)", async () => { + const res = await patchNote("DS-005", "状態が不明な制御盤"); + + expect(res.status).toBe(404); + // ★孤児行を作らない★(一覧 `dsDevices()` は状態 0/1 以外を出さない) + expect(await readDeviceNotes()).toEqual({}); + }); + + it("#10 `DS-002`(`controls.status` が 1=異常)→ 200(異常は一覧に出る)", async () => { + const res = await patchNote("DS-002", "異常の制御盤"); + + expect(res.status).toBe(200); + expect(await readDeviceNotes()).toEqual({ "DS-002": "異常の制御盤" }); + }); +}); + +// ★表8 J1〜J3(指示書BB A5・D19・D-182)★ — 本文がオブジェクトでなければ422(旧: `body.note` で TypeError → 標準でない500) +describe("PATCH /ds-devices/{serial_number}/note — 本文の形(指示書BB 表8 J)", () => { + function patchRaw(raw: string): Promise { + return PATCH(new Request("http://localhost/api/ds-devices/DS-001/note", { method: "PATCH", body: raw }), { + params: Promise.resolve({ serial_number: "DS-001" }), + }); + } + + it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」・保存しない★", async (raw) => { + const res = await patchRaw(raw); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "JSONの形式が不正です", details: null }, + }); + expect(await readDeviceNotes()).toEqual({}); + }); + + it("J3: 本文 {} → 今どおり(422「noteは文字列で指定してください」)", async () => { + const res = await patchRaw("{}"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { message: string } }).error.message).toBe("noteは文字列で指定してください"); + }); +}); diff --git "a/app/api/ds-devices/\133serial_number\135/note/route.ts" "b/app/api/ds-devices/\133serial_number\135/note/route.ts" new file mode 100644 index 0000000..f41c1bf --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/note/route.ts" @@ -0,0 +1,139 @@ +/** + * PATCH /ds-devices/{serial_number}/note — D-S制御盤の備考を保存する + * + * 仕様の正は `d-watch-api.yaml` の /ds-devices/{serial_number}/note です。 + * D-Secログ画面(F-06)の「備考」列に運用担当が入力したメモを保存します。 + * + * ★保存先はメインDBではありません★ + * `lib/settings/ds-device-notes.ts`(DFA独自のJSONストア)に書きます。 + * メインDBはTeamWorks管理で、DFAが更新できるのは + * `robot_error_reports` / `control_error_histories` の4カラムだけです(CLAUDE.md 7)。 + * + * 空文字(トリム後も含む)を送ると備考は削除され、レスポンスの `note` は null になります。 + * 削除を別エンドポイントにしないのは、画面が「テキストを空にして保存」しか操作を持たず、 + * 保存と削除を呼び分ける判断をフロントに持たせる意味が無いためです。 + */ +import { NextResponse } from "next/server"; +import { isJsonObject } from "@/lib/api/json-body"; +import { parsePathSerialNumber } from "@/lib/api/path-params"; +import { DEVICE_NOTE_MAX_LENGTH } from "@/lib/config"; +import { db } from "@/lib/db"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { saveDeviceNote } from "@/lib/settings/ds-device-notes"; +import type { ApiError, ErrorCode } from "@/lib/types"; + +const ROUTE = "/api/ds-devices/[serial_number]/note"; + +function errorBody( + code: ErrorCode, + message: string, + details: Record | null = null +): ApiError { + return { error: { code, message, details } }; +} + +function errorResponse( + status: number, + code: ErrorCode, + message: string, + details: Record | null = null +) { + return NextResponse.json(errorBody(code, message, details), { status }); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ serial_number: string }> } +) { + return withRequestId(request, async () => { + const { serial_number: serialNumberRaw } = await params; + // ★形式の検証を実在チェック(`db.dsDeviceExists()`)より前に置く★ + // DBのカラム幅(`controls.serial_number` = varchar(100))に入りえない長さの入力を + // DBの照会まで運ぶ意味が無い。指示書AE 段階2-2 / D-123 + // ★★検証順序(形式 → 長さ → 実在)を変えないこと★★(テスト #6 / #7 が固定している) + const parsedSerial = parsePathSerialNumber(serialNumberRaw); + if (!parsedSerial.ok) { + return NextResponse.json(parsedSerial.error, { status: parsedSerial.status }); + } + const serialNumber = parsedSerial.query; + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言) + if (!isJsonObject(body)) { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + const note = body.note; + if (typeof note !== "string") { + return errorResponse( + 422, + "validation_failed", + "noteは文字列で指定してください" + ); + } + + // 文字数はトリム前の入力そのもので数える。 + // 画面の入力欄が数えるのもトリム前の文字数なので、 + // ここでトリム後を見ると「画面上は上限内なのに保存が弾かれる」(逆も)ズレが出る + if (note.length > DEVICE_NOTE_MAX_LENGTH) { + return errorResponse( + 422, + "validation_failed", + "noteが長すぎます", + { note: [`${DEVICE_NOTE_MAX_LENGTH}文字以内で入力してください`] } + ); + } + + // 実在しないシリアル番号の備考を保存すると、どのデバイスからも参照されない + // 行がストアに残り続ける(ストアはメインDBを参照しないため自力で気付けない)ので、 + // 保存前に制御盤マスタと突き合わせる。 + // + // ★★`db.dsDevices()`(一覧)を呼ばないこと★★(指示書AH 段階2)— + // シリアル1件の実在確認のために全制御盤+全施設を読む必要は無く、 + // 旧: ★無関係な1台の欠損(心拍0件)で例外が飛び、全台の備考保存が500になる★ + // 経路も断てる(指示書BB で `dsDevices()` は心拍0件を例外にしなくなった。D-182)。★照合規則(ci 照合順序への対処。D-4)は `dsDeviceExists()` 側の正★。 + let exists: boolean; + try { + exists = await db.dsDeviceExists(serialNumber); + } catch (e) { + const errorPayload = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ + route: ROUTE, + method: "PATCH", + body: errorPayload, + status: 500, + err: e, + }); + return NextResponse.json(errorPayload, { status: 500 }); + } + + if (!exists) { + return errorResponse(404, "not_found", "デバイスが見つかりません"); + } + + let saved: string | null; + try { + saved = await saveDeviceNote(serialNumber, note); + } catch (e) { + const errorPayload = errorBody("internal_error", "備考の保存に失敗しました"); + logApiError({ + route: ROUTE, + method: "PATCH", + body: errorPayload, + status: 500, + err: e, + }); + return NextResponse.json(errorPayload, { status: 500 }); + } + + // 保存後の値をエコーバックする。ストア側がトリム・削除を行うため、 + // 画面はリクエストで送った文字列ではなくこの値で表示を更新する必要がある + return NextResponse.json({ serial_number: serialNumber, note: saved }); + }); +} diff --git a/app/api/ds-devices/route.ts b/app/api/ds-devices/route.ts new file mode 100644 index 0000000..a92f2d2 --- /dev/null +++ b/app/api/ds-devices/route.ts @@ -0,0 +1,119 @@ +/** + * GET /ds-devices — D-Secログ(D-S制御盤稼働状況一覧+サマリ) + * + * 形は `app/api/prechecks/route.ts` と同じ。 + * 仕様の正は `d-watch-api.yaml` の /ds-devices、ソート順は docs/decisions.md D-055。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids"; +import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master"; +import { + filterDsDevicesByQ, + filterDsDevicesByStatus, + parseDsDeviceQuery, + sortDsDevices, + summarizeDsDevices, +} from "@/lib/ds-devices/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { readDeviceNotes } from "@/lib/settings/ds-device-notes"; +import type { ApiError, DsDevice, SummaryListResponse } from "@/lib/types"; +import type { DsDeviceSummary } from "@/lib/ds-devices/query"; +import type { DeviceNotes } from "@/lib/settings/ds-device-notes"; + +const ROUTE = "/api/ds-devices"; + +function errorResponse(error: ApiError, status: number) { + return NextResponse.json(error, { status }); +} + +export async function GET(request: Request) { + return withRequestId(request, async () => { + const { searchParams } = new URL(request.url); + const parsed = parseDsDeviceQuery(searchParams); + if (!parsed.ok) { + return errorResponse(parsed.error, parsed.status); + } + + let rows: DsDevice[]; + let notes: DeviceNotes; + // ★分類(`group_id` / `customer_id`)→ 施設IDの配列★(指示書AK 段階4)。 + // ★`null` = 絞らない / `[]` = 0件★(★罠1★。`?.length` で分岐しないこと)。 + let facilityIds: number[] | null; + try { + // ★解決(施設マスタの読み取り)も同じ try に入れる★ — 失敗したら 500。 + // ★黙って絞らずに返さないこと★(他社のデバイスが混ざったまま画面に出る)。 + facilityIds = await resolveFacilityIdsByMasters(parsed.query); + rows = await db.dsDevices(); + // ★備考の読み込みも同じ try に含める=失敗したら一覧全体を500にする★ + // 「備考はおまけなので読めなくても200で返す」も一案だが、ここでは採らない: + // 1. `readDeviceNotes()` が例外を投げるのは**本物のサーバー障害のときだけ**。 + // 未保存(ENOENT)は空で返り、JSON破損は `.bak` へ退避したうえで空が返る + // (`lib/settings/json-store.ts`)。つまりここまで例外が上がってくるのは + // 権限エラー・I/O障害・退避失敗といった、放置してはいけない状態に限られる。 + // 2. 黙って備考なしで返すと、運用担当には「備考が消えた」としか見えず、 + // 保存済みの文言を空欄と誤認して上書き(=実質削除)しかねない。 + // 3. ログイベントは allowlist(`lib/logger/events.ts`)で、 + // 「縮退して返した」を表すイベントは存在しない。`api.error` は + // 「catch節で処理してこのステータスを返した」という意味なので、 + // 200を返しながらそれを出すとログとレスポンスがズレる。 + // よって他のtry節と同じ形(失敗=500 + `api.error`)に揃え、一貫性を保つ。 + notes = await readDeviceNotes(); + } catch (e) { + const body: ApiError = { + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return errorResponse(body, 500); + } + + // ★備考はDFA独自ストアの値を優先する★ + // 独自ストア(`data/settings/ds-device-notes.json`)の値は + // **運用担当がこの画面から上書きした最新の備考**であり、fixtures/DB 側の `note` は + // 初期値・フォールバックにすぎない。逆にすると、保存したそばから + // 古い値に戻って見えることになる。 + // (メインDBは読み取り専用で備考は書けないため、保存先は独自ストアしかない。 + // CLAUDE.md §3 / §7) + // ★フィルタ・ソート・サマリより前にマージする★。今の summary / q は + // `note` を見ていないが、後から「備考でも検索する」等が入ったときに + // マージ前の古い値で絞り込む事故を、順序で構造的に防いでおく。 + rows = rows.map((device) => ({ + ...device, + note: notes[device.serial_number] ?? device.note, + })); + + // ★★分類の絞り込みは summary より**前**にかける★★(指示書AK 段階4)— + // q / status は「見え方」の絞り込みなので summary に効かせない(D-054)が、 + // ★分類は「母集団」そのものを選ぶ★ので ★summary にも効かせる★ + // (「この屋号の異常◯件」が見たい。指示書AK §7-2 の数値カードと同じ理由)。 + // ★★一覧と summary で母集団がずれないよう、必ず同じ配列から作ること★★。 + // ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。 + rows = filterRowsByFacilityIds(rows, facilityIds); + + // ★summaryはq/statusフィルタの前(全件)に対して計算する(D-054と同じ理由)★ + // フィルタ後に計算すると、既定の絞り込み状態によってサマリーが変わってしまう + const summary: DsDeviceSummary = summarizeDsDevices(rows); + const filtered = filterDsDevicesByStatus( + filterDsDevicesByQ(rows, parsed.query.q), + parsed.query.status + ); + const sorted = sortDsDevices(filtered); + + const { page, perPage } = parsed.query; + // ★meta.total と summary.total は意味が違う★ + // meta.total = 一覧(q・statusフィルタ後)の総件数。ページング計算用 + // summary.total = 全デバイス数(q・statusの影響を受けない) + // 絞り込みが無いとき(q="" かつ status=all)だけ一致する + const total = sorted.length; + const start = (page - 1) * perPage; + const items = sorted.slice(start, start + perPage); + + const body: SummaryListResponse = { + summary, + items, + meta: { total, page, per_page: perPage }, + }; + return NextResponse.json(body); + }); +} diff --git a/app/api/error-lists/__tests__/route.fixtures.test.ts b/app/api/error-lists/__tests__/route.fixtures.test.ts new file mode 100644 index 0000000..b987ebf --- /dev/null +++ b/app/api/error-lists/__tests__/route.fixtures.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/error-lists/route"; +import { errorLists } from "@/lib/fixtures/error-lists"; +import type { ErrorListsResponse } from "@/lib/types"; + +/** + * `GET /error-lists`(`USE_FIXTURES=true`。指示書BD §3-1・§7)。 + * + * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、 + * 同じファイルで true/false の両方は試せない(`app/api/errors/__tests__/route.fixtures.test.ts` と同じ)。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +describe("GET /error-lists(USE_FIXTURES=true)", () => { + it("★決定稿の131行・id 1〜131 の昇順・meta {131, 1, 131}★", async () => { + const res = await GET(new Request("http://localhost/api/error-lists")); + expect(res.status).toBe(200); + const body = (await res.json()) as ErrorListsResponse; + expect(body.items).toHaveLength(131); + expect(body.items.map((i) => i.id)).toEqual(Array.from({ length: 131 }, (_, i) => i + 1)); + expect(body.meta).toEqual({ total: 131, page: 1, per_page: 131 }); + }); + + it("note_required は 131(その他)だけ・文字列は fixtures の値のまま", async () => { + const body = (await (await GET(new Request("http://localhost/api/error-lists"))).json()) as ErrorListsResponse; + expect(body.items.filter((i) => i.note_required).map((i) => i.id)).toEqual([131]); + const src = errorLists.find((r) => r.id === BigInt(40))!; + expect(body.items[39]).toEqual({ + id: 40, + error_category_main_en: src.error_category_main_en, + error_category_main_ja: src.error_category_main_ja, + error_category_sub_en: src.error_category_sub_en, + error_category_sub_ja: src.error_category_sub_ja, + error_detail_en: src.error_detail_en, + error_detail_ja: src.error_detail_ja, + note_required: false, + }); + expect(body.items[39].error_detail_ja).toBe("非常停止スイッチが作動しました"); + }); +}); diff --git a/app/api/error-lists/__tests__/route.test.ts b/app/api/error-lists/__tests__/route.test.ts new file mode 100644 index 0000000..ec30fa6 --- /dev/null +++ b/app/api/error-lists/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★(`app/api/customers/__tests__/route.test.ts` と同じ理由) +import { GET } from "@/app/api/error-lists/route"; +import { ERROR_LIST_SELECT } from "@/lib/db/error-list-source"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, ErrorListItem, ErrorListsResponse } from "@/lib/types"; + +/** + * `GET /error-lists`(Prisma 経路。指示書BD §3-1)の期待値表。 + * + * ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定する + * (`app/api/customers/__tests__/route.test.ts` と同じ作法)。fixtures 経路は `route.fixtures.test.ts`。 + * + * ★スタブの `errorList.findMany` は `where` / `orderBy` を★無視して★入力の順のまま全行返す★ — + * SQL の写し(`ACTIVE_ERROR_LIST_WHERE` / `orderBy`)が効かなかった場合でも、 + * ★純粋関数(`activeErrorListItems()`)で有効な行だけ・id 昇順になる★ことを見るため。 + * SQL 側の引数は別のテスト(#3)で固定する。 + * + * | # | 入力(stub の行。並びはこの順) | 期待 | + * |----|------------------------------------------------------------------|------| + * | 1 | 131, 2, 10(inactive 1), 3(deleted_at あり), 1, 40 | items の id = [1, 2, 40, 131]・meta {4, 1, 4} | + * | 2 | 同上 | 131 だけ note_required true・文字列は DB の値のまま・inactive / deleted_at を出さない | + * | 3 | 同上 | findMany は1回・where {inactive: 0, deleted_at: null}・orderBy {id: "asc"}・select = ERROR_LIST_SELECT | + * | 4 | 0行 | items []・meta {0, 1, 0} | + * | 5 | findMany が例外 | 500 internal_error「データの取得に失敗しました」・logApiError 1回 | + */ + +interface StubErrorList { + id: bigint; + error_category_main_en: string; + error_category_main_ja: string; + error_category_sub_en: string; + error_category_sub_ja: string; + error_detail_en: string; + error_detail_ja: string; + inactive: number; + deleted_at: Date | null; +} + +let rows: StubErrorList[]; +let calls: Record[]; +let throwOnFindMany: boolean; + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + errorList: { + findMany: async (args: Record) => { + calls.push(args); + if (throwOnFindMany) throw new Error("[stub] DB が落ちた"); + return rows; + }, + }, + }), + getWritePrisma: async () => { + throw new Error("[stub] 選択肢の取得で書き込み用の接続を取ってはいけない"); + }, +})); + +function listRow(id: number, overrides: Partial = {}): StubErrorList { + return { + id: BigInt(id), + error_category_main_en: `MAIN-EN-${id}`, + error_category_main_ja: `分類${id}`, + error_category_sub_en: `sub-en-${id}`, + error_category_sub_ja: `小分類${id}`, + error_detail_en: `detail-en-${id}`, + error_detail_ja: `詳細${id}`, + inactive: 0, + deleted_at: null, + ...overrides, + }; +} + +async function get(): Promise { + return GET(new Request("http://localhost/api/error-lists")); +} + +beforeEach(() => { + rows = [ + listRow(131, { + error_category_main_en: "OTHER ERROR", + error_category_main_ja: "その他(該当なし)", + error_category_sub_en: "other", + error_category_sub_ja: "その他(該当なし)", + error_detail_en: "There are no errors.", + error_detail_ja: "該当するエラーはありません", + }), + listRow(2), + listRow(10, { inactive: 1 }), + listRow(3, { deleted_at: new Date("2026-10-01T01:00:00Z") }), + listRow(1), + listRow(40), + ]; + calls = []; + throwOnFindMany = false; + vi.mocked(logApiError).mockClear(); +}); + +describe("GET /error-lists(Prisma 経路)", () => { + it("#1 ★有効な行だけ・id の昇順(SQL の where / orderBy が効かなくても)★・meta は total / page=1 / per_page=total", async () => { + const res = await get(); + expect(res.status).toBe(200); + const body = (await res.json()) as ErrorListsResponse; + expect(body.items.map((i) => i.id)).toEqual([1, 2, 40, 131]); + expect(body.meta).toEqual({ total: 4, page: 1, per_page: 4 }); + }); + + it("#2 131 だけ note_required・文字列は DB の値のまま・inactive / deleted_at を出さない", async () => { + const body = (await (await get()).json()) as ErrorListsResponse; + expect(body.items.filter((i) => i.note_required).map((i) => i.id)).toEqual([131]); + const other: ErrorListItem = { + id: 131, + error_category_main_en: "OTHER ERROR", + error_category_main_ja: "その他(該当なし)", + error_category_sub_en: "other", + error_category_sub_ja: "その他(該当なし)", + error_detail_en: "There are no errors.", + error_detail_ja: "該当するエラーはありません", + note_required: true, + }; + expect(body.items[3]).toEqual(other); + expect(body.items[0]).toEqual({ + id: 1, + error_category_main_en: "MAIN-EN-1", + error_category_main_ja: "分類1", + error_category_sub_en: "sub-en-1", + error_category_sub_ja: "小分類1", + error_detail_en: "detail-en-1", + error_detail_ja: "詳細1", + note_required: false, + }); + }); + + it("#3 ★findMany は1回・where は inactive 0 かつ deleted_at null・orderBy は id 昇順・select は ERROR_LIST_SELECT★", async () => { + await get(); + expect(calls).toEqual([ + { where: { inactive: 0, deleted_at: null }, orderBy: { id: "asc" }, select: ERROR_LIST_SELECT }, + ]); + }); + + it("#4 0行 → items []・meta {0, 1, 0}(404 にしない)", async () => { + rows = []; + const res = await get(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ items: [], meta: { total: 0, page: 1, per_page: 0 } }); + }); + + it("#5 DB の例外 → 500「データの取得に失敗しました」・logApiError 1回", async () => { + throwOnFindMany = true; + const res = await get(); + expect(res.status).toBe(500); + expect((await res.json()) as ApiError).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ route: "/api/error-lists", method: "GET", status: 500 }); + }); +}); diff --git a/app/api/error-lists/route.ts b/app/api/error-lists/route.ts new file mode 100644 index 0000000..2c655da --- /dev/null +++ b/app/api/error-lists/route.ts @@ -0,0 +1,46 @@ +/** + * GET /error-lists — エラー内容の選択肢(`error_lists`。エラー一覧のセレクトリスト用。指示書BD §3-1。D-184。読み取り専用) + * + * 形は `app/api/groups/route.ts` と同じ。仕様の正は `d-watch-api.yaml` の /error-lists。 + * + * ★GET だけ★ — `error_lists` は先方が管理する表(管理側UIなし。ユーザー回答① 2026-10-27)。 + * アプリが書くのは `robot_error_reports.error_list_id`(`PATCH /errors/{error_id}/status`)だけ。 + * + * ★有効な行だけ・id の昇順★ — データ層(`db.errorLists()`。規則の正は `activeErrorListItems()`)が済ませて返す。 + * ★`GET /groups` と違い `inactive` でも絞る★(無効化した選択肢を新たに選ばせない)。 + * ★文字列は DB の値のまま★(`t()` に通さない・変換しない。D-077 と同じ)。 + * + * ★認証はここに書かない★ — `proxy.ts` が全ルートを横断して判定する。 + * ★ページングしない★(`GET /groups` と同じ。`meta` は `masterListResponse()`)。 + */ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { masterListResponse } from "@/lib/masters/query"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { ApiError, ErrorListItem, ErrorListsResponse } from "@/lib/types"; + +const ROUTE = "/api/error-lists"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + // ★クエリパラメータを持たないので 422 の分岐が無い★(`app/api/groups/route.ts` と同じ) + let items: ErrorListItem[]; + try { + items = await db.errorLists(); + } catch (e) { + const body: ApiError = { + error: { + code: "internal_error", + message: "データの取得に失敗しました", + details: null, + }, + }; + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + + const body: ErrorListsResponse = masterListResponse(items); + return NextResponse.json(body); + }); +} diff --git "a/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts" "b/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts" new file mode 100644 index 0000000..deaf997 --- /dev/null +++ "b/app/api/errors/\133error_id\135/status/__tests__/route.fixtures.test.ts" @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/errors/[error_id]/status/route"; +import { GET } from "@/app/api/errors/route"; +import { ERROR_LIST_NOTE_REQUIRED_IDS } from "@/lib/config"; +import { db } from "@/lib/db"; +import { + applyFixtureErrorReportPatch, + resetFixtureErrorReportOverrides, +} from "@/lib/db/fixture-error-report-overrides"; +import { errorReportRows } from "@/lib/fixtures/error-reports"; +import type { ErrorReportItem, ListResponse } from "@/lib/types"; + +/** + * `PATCH /errors/{error_id}/status` のエラー内容(`error_list_id`)を `USE_FIXTURES=true` で叩く(指示書BD §5-4・§7。D-184)。 + * + * ★★何を守るか★★ — fixtures 版の上書き表(`lib/db/fixture-error-report-overrides.ts`)が `error_list_id` を写し忘れると、 + * ★PATCH は 200 を返すのに GET に反映されない★(例外もテスト失敗も出ない)。PATCH → GET で確かめる。 + * + * 行は `lib/fixtures/error-reports.ts`: FX-ERR-02(LowBattery・未選択)/ FX-ERR-05(★9999 = `error_lists` に無い id★)/ + * FX-ERR-10(★D-S_CONTROL_FAILURE — 選べない★)/ FX-ERR-11(★NOT_STARTED・その他(131)+備考あり★)。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +const OTHER_ID = ERROR_LIST_NOTE_REQUIRED_IDS[0]; + +beforeEach(() => { + resetFixtureErrorReportOverrides(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-06T08:15:00+09:00")); +}); + +afterEach(() => { + vi.useRealTimers(); + resetFixtureErrorReportOverrides(); +}); + +async function listItem(id: string): Promise { + const res = await GET(new Request("http://localhost/api/errors?include_resolved=true")); + expect(res.status).toBe(200); + return ((await res.json()) as ListResponse).items.find((i) => i.id === id); +} + +async function patch(id: string, body: unknown): Promise { + return PATCH( + new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: JSON.stringify(body), + }), + { params: Promise.resolve({ error_id: id }) } + ); +} + +describe("PATCH → GET(error_list_id・USE_FIXTURES=true)", () => { + it("★有効な id を書くと GET /errors にも新しい id と名前が出る★", async () => { + expect((await listItem("FX-ERR-02"))?.error_list_id).toBeNull(); + const res = await patch("FX-ERR-02", { status: "in_progress", error_list_id: 40 }); + expect(res.status).toBe(200); + expect((await res.json()) as ErrorReportItem).toMatchObject({ error_list_id: 40, error_list: { id: 40 } }); + + const after = await listItem("FX-ERR-02"); + expect(after).toMatchObject({ error_list_id: 40, error_list: { id: 40 }, resolution_status: "in_progress" }); + expect((await db.errorReportById("FX-ERR-02"))?.error_list_id).toBe(40); + }); + + it("★その他+備考 → 反映される★", async () => { + const res = await patch("FX-ERR-02", { status: "unresolved", error_list_id: OTHER_ID, note: "該当なし" }); + expect(res.status).toBe(200); + expect(await listItem("FX-ERR-02")).toMatchObject({ + error_list_id: OTHER_ID, + error_list: { note_required: true }, + note: "該当なし", + }); + }); + + it("★無い id(9999)が入った行で、同じ id を送って状態だけ変える → 200・id はそのまま★", async () => { + const res = await patch("FX-ERR-05", { status: "in_progress", error_list_id: 9999 }); + expect(res.status).toBe(200); + expect(await listItem("FX-ERR-05")).toMatchObject({ error_list_id: 9999, error_list: null, resolution_status: "in_progress" }); + }); + + it("422 の要求は何も変えない(選べない行・無効な id・その他で備考なし・その他の行で備考を消す)", async () => { + const cases: [string, unknown, string][] = [ + ["FX-ERR-10", { status: "unresolved", error_list_id: 40 }, "error_list_id"], + ["FX-ERR-02", { status: "unresolved", error_list_id: 9999 }, "error_list_id"], + ["FX-ERR-02", { status: "unresolved", error_list_id: OTHER_ID }, "note"], + ["FX-ERR-11", { status: "unresolved", note: "" }, "note"], + ]; + for (const [id, body, field] of cases) { + const before = await listItem(id); + const res = await patch(id, body); + expect(res.status).toBe(422); + expect(Object.keys(((await res.json()) as { error: { details: Record } }).error.details)).toEqual([ + field, + ]); + expect(await listItem(id)).toEqual(before); + } + }); + + it("★上書き表は error_list_id を重ねる(後のパッチで変えなければ前の値のまま)★", async () => { + applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { error_list_id: 41 }); + applyFixtureErrorReportPatch(errorReportRows, "FX-ERR-02", { status: "resolved", resolved_at: new Date() }); + expect(await db.errorReportById("FX-ERR-02")).toMatchObject({ error_list_id: 41, resolution_status: "resolved" }); + resetFixtureErrorReportOverrides(); + expect((await db.errorReportById("FX-ERR-02"))?.error_list_id).toBeNull(); + }); +}); diff --git "a/app/api/errors/\133error_id\135/status/__tests__/route.test.ts" "b/app/api/errors/\133error_id\135/status/__tests__/route.test.ts" new file mode 100644 index 0000000..ad31884 --- /dev/null +++ "b/app/api/errors/\133error_id\135/status/__tests__/route.test.ts" @@ -0,0 +1,872 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由) +import { PATCH } from "@/app/api/errors/[error_id]/status/route"; +import { + D_SEC_ALERT_ERROR_TYPES, + ERROR_LIST_NOTE_REQUIRED_IDS, + PRECHECK_MINUTES_BEFORE, + PRECHECK_NOTE_MAX_LENGTH, +} from "@/lib/config"; +import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard"; +import { ROBOT_ERROR_LIST_ROW_SELECT } from "@/lib/db/robot-error-select"; +import { toDbJst } from "@/lib/format/date"; +import { logEvent } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { DbWriteLogEvent } from "@/lib/logger/events"; +import type { ErrorReportItem } from "@/lib/types"; + +/** + * `PATCH /errors/{error_id}/status`(Prisma 経路。指示書AW 段階4-4-2)。 + * + * `/prechecks/{id}/status` の同名テスト(`app/api/prechecks/[id]/status/__tests__/route.test.ts`)の describe を写し、 + * 行を表1 にした。★違いは「`robot_error_reports` に行があれば書ける(事前チェック以外の行にも)」こと★(D-160 項2)。 + * + * ★★DBには接続しません★★ — `getPrisma()`(参照用)と `getWritePrisma()`(書き込み用)をスタブに差し替え、 + * `USE_FIXTURES` を `false` に固定して `prismaSource` の経路を走らせる。 + * ★`updateMany` の `data` を記録し、★5列(4列+`error_list_id`。D-184)のうち該当分だけ★が渡っていることを見る★。 + * (生成される SQL に `updated_at` が入らないことは `lib/db/__tests__/robot-error-write-sql.test.ts` が見る) + */ + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubTimetable { + id: bigint; + precheck: number; +} + +interface StubErrorReport { + error_id: string; + /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */ + robot_id: bigint | null; + clean_timetable_id: bigint | null; + serial_number: string; + facility_id: bigint; + /** ★`deleted_at` は可視性(`isVisibleErrorRow()`。指示書AX U8・表5)★ */ + facility: { facility_name: string; deleted_at: Date | null }; + section: { deleted_at: Date | null }; + error_type: string; + error_level: string; + errored_at: Date | null; + created_at: Date; + status: number; + responded_at: Date | null; + resolved_at: Date | null; + comment: string | null; + /** エラー内容(指示書BD。null = 未選択) */ + error_list_id: bigint | null; +} + +/** `error_lists` の行(選択肢の絞り込み `inactive = 0 AND deleted_at IS NULL` をスタブでも再現する) */ +interface StubErrorList { + id: bigint; + error_category_main_en: string; + error_category_main_ja: string; + error_category_sub_en: string; + error_category_sub_ja: string; + error_detail_en: string; + error_detail_ja: string; + inactive: number; + deleted_at: Date | null; +} + +interface UpdateManyArgs { + where: { error_id: string }; + data: Record; +} + +type Args = { where: Record; select?: Record }; + +let timetables: StubTimetable[]; +let historyTimetables: StubTimetable[]; +let errorReports: StubErrorReport[]; +let errorListRows: StubErrorList[]; +/** `errorList.findMany` の `where` の記録(選択肢を引いたか・名前解決か) */ +let errorListWheres: Record[]; +let updateCalls: UpdateManyArgs[]; +/** `robotErrorReport.findUnique` の `select` の記録 */ +let findUniqueSelects: (Record | undefined)[]; +/** 次の `updateMany` の振る舞い(既定は「一致した行に data を当てる」) */ +let updateBehavior: "apply" | "throw" | "no_match"; + +/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */ +function ciEqual(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** `select` で返す列を絞る(入れ子の `{ select }` にも対応) */ +function project(row: Record, select: Record | undefined): Record { + if (!select) return row; + const out: Record = {}; + for (const [key, value] of Object.entries(select)) { + if (value === true) out[key] = row[key]; + else if (value && typeof value === "object" && "select" in value) { + out[key] = project(row[key] as Record, (value as { select: Record }).select); + } + } + return out; +} + +class FakePrismaKnownError extends Error { + code = "P2039"; + meta = { + driverAdapterError: { + cause: { + originalCode: "1143", + originalMessage: "UPDATE command denied to user 'secret-db-user'@'host' for column 'updated_at'", + kind: "mysql", + }, + }, + }; +} + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + // ★`withRequestId` はそのまま使う★。見たいのは `logEvent`(db.write)だけ + return { ...actual, logEvent: vi.fn() }; +}); + +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +vi.mock("@/lib/db/prisma", () => { + const read = { + robotErrorReport: { + findUnique: async (args: Args) => { + findUniqueSelects.push(args.select); + // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★ + guardNullRobotId(errorReports as unknown as Record[], args); + const found = errorReports.find((r) => ciEqual(r.error_id, args.where.error_id as string)); + return found ? project(found as unknown as Record, args.select) : null; + }, + updateMany: async () => { + throw new Error("[stub] 参照用の接続で updateMany を呼んではいけない"); + }, + }, + // ★名前解決(`id IN`)と選択肢(`inactive = 0 AND deleted_at IS NULL`)の両方を `where` どおりに絞る★ + errorList: { + findMany: async (args: { where: Record }) => { + errorListWheres.push(args.where); + const where = args.where as { + id?: { in: bigint[] }; + inactive?: number; + deleted_at?: null; + }; + return errorListRows.filter( + (row) => + (where.id === undefined || where.id.in.includes(row.id)) && + (where.inactive === undefined || row.inactive === where.inactive) && + (!("deleted_at" in where) || row.deleted_at === where.deleted_at) + ); + }, + }, + // ★予定は本表 → 無ければ履歴の順に id で引く(`findTimetableById()`。指示書AU 段階3b-2 の 5)★ + cleanTimetable: { + findUnique: async (args: Args) => timetables.find((t) => t.id === args.where.id) ?? null, + }, + cleanTimetableHistory: { + findUnique: async (args: Args) => historyTimetables.find((t) => t.id === args.where.id) ?? null, + }, + }; + const write = { + robotErrorReport: { + updateMany: async (args: UpdateManyArgs) => { + updateCalls.push(args); + if (updateBehavior === "throw") throw new FakePrismaKnownError("Database error"); + if (updateBehavior === "no_match") return { count: 0 }; + const rows = errorReports.filter((r) => ciEqual(r.error_id, args.where.error_id)); + for (const row of rows) Object.assign(row, args.data); + return { count: rows.length }; + }, + }, + }; + return { getPrisma: async () => read, getWritePrisma: async () => write }; +}); + +// ---------------------------------------------------------------- 合成データ(表1 の行) + +/** `+09:00` 付きの ISO → DB の壁時計値の `Date`(Prisma が返す形) */ +const db = (iso: string): Date => toDbJst(new Date(iso)); + +const NOW_ISO = "2026-10-06T08:15:00+09:00"; +const T1_ISO = "2026-10-06T07:00:00+09:00"; +const T2_ISO = "2026-10-06T07:30:00+09:00"; + +function errorReport( + errorId: string, + timetableId: number | null, + overrides: Partial = {} +): StubErrorReport { + return { + error_id: errorId, + robot_id: BigInt(501), + clean_timetable_id: timetableId === null ? null : BigInt(timetableId), + serial_number: "PD-CC1-0001", + facility_id: BigInt(1), + // ★実在の店名を使わない★(`CLAUDE.md` §7) + facility: { facility_name: "テスト施設A", deleted_at: null }, + section: { deleted_at: null }, + error_type: "EmergencyStop", + error_level: "ERROR", + errored_at: db("2026-10-05T21:00:00+09:00"), + created_at: db("2026-10-05T21:01:00+09:00"), + status: 0, + responded_at: null, + resolved_at: null, + comment: null, + error_list_id: null, + ...overrides, + }; +} + +/** `error_lists` の1行(★文字列はダミー★) */ +function errorList(id: number, overrides: Partial = {}): StubErrorList { + return { + id: BigInt(id), + error_category_main_en: `MAIN ${id}`, + error_category_main_ja: `分類${id}`, + error_category_sub_en: `sub ${id}`, + error_category_sub_ja: `小分類${id}`, + error_detail_en: `detail ${id}`, + error_detail_ja: `詳細${id}`, + inactive: 0, + deleted_at: null, + ...overrides, + }; +} + +/** 備考必須の選択肢(その他) */ +const OTHER_ID = ERROR_LIST_NOTE_REQUIRED_IDS[0]; + +function buildData() { + errorListRows = [ + errorList(40), + errorList(41), + errorList(OTHER_ID), + // ★無効化済み・削除済み(選択肢に出ない。名前解決はする)★ + errorList(7, { inactive: 1 }), + errorList(8, { deleted_at: db("2026-10-01T10:00:00+09:00") }), + ]; + timetables = [ + { id: BigInt(100), precheck: 0 }, // 本清掃 + { id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[1] }, // 事前チェック(30分前) + ]; + historyTimetables = []; + errorReports = [ + // E1: 本清掃に紐づく(★/prechecks では404だった行★) + errorReport("E-1", 100), + // E2: 事前チェックに紐づく・対応中 + errorReport("E-2", 101, { status: 1, responded_at: db(T1_ISO), comment: "既存の備考" }), + // E3: 解消済み・本清掃 + errorReport("E-3", 100, { status: 2, responded_at: db(T1_ISO), resolved_at: db(T2_ISO) }), + // E4: 予定なし + errorReport("E-4", null), + // E5: 予定不明(どの予定表にも無い id) + errorReport("E-5", 999), + // E8: robot_id NULL・serial_number '0'・SECURITY_ON + errorReport("E-8", 100, { robot_id: null, serial_number: "0", error_type: "SECURITY_ON" }), + ]; +} + +async function patch(id: string, body: unknown, raw = false): Promise { + return PATCH( + new Request(`http://localhost/api/errors/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: raw ? (body as string) : JSON.stringify(body), + }), + { params: Promise.resolve({ error_id: id }) } + ); +} + +function dbWriteLogs(): { level: string; payload: DbWriteLogEvent }[] { + return vi + .mocked(logEvent) + .mock.calls.filter(([, payload]) => payload.event === "db.write") + .map(([level, payload]) => ({ level, payload: payload as DbWriteLogEvent })); +} + +beforeEach(() => { + buildData(); + updateCalls = []; + errorListWheres = []; + findUniqueSelects = []; + updateBehavior = "apply"; + vi.mocked(logEvent).mockClear(); + vi.mocked(logApiError).mockClear(); + // ★Date だけを止める★(`now` = 2026-10-06 08:15 JST) + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ---------------------------------------------------------------- 200 + +describe("200 — 事前チェック以外の行にも書ける(D-160 項2)", () => { + it.each([ + ["E1(本清掃)", "E-1", "main"], + ["E4(予定なし)", "E-4", "none"], + ["E5(予定不明)", "E-5", "unknown"], + ["E8(robot_id NULL)", "E-8", "main"], + ["E2(事前チェック)", "E-2", "precheck"], + ] as const)("%s → 200。updateMany の data は該当の列だけ", async (_label, id, link) => { + const res = await patch(id, { status: "resolved" }); + expect(res.status).toBe(200); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].where).toEqual({ error_id: id }); + const expectedColumns = id === "E-2" ? ["resolved_at", "status"] : ["resolved_at", "responded_at", "status"]; + expect(Object.keys(updateCalls[0].data).sort()).toEqual(expectedColumns); + const body = (await res.json()) as ErrorReportItem; + expect(body).toMatchObject({ id, schedule_link: link, resolution_status: "resolved", resolved_at: NOW_ISO }); + }); + + it("★読み取りは ROBOT_ERROR_LIST_ROW_SELECT(robot_id / robot を読まない)★", async () => { + await patch("E-8", { status: "in_progress" }); + expect(findUniqueSelects.length).toBeGreaterThan(0); + for (const select of findUniqueSelects) expect(select).toEqual(ROBOT_ERROR_LIST_ROW_SELECT); + }); + + it("E8 の生値(シリアル '0'・種類 SECURITY_ON)をそのまま返す", async () => { + const res = await patch("E-8", { status: "in_progress" }); + expect((await res.json()) as ErrorReportItem).toMatchObject({ serial_number: "0", error_type: "SECURITY_ON" }); + }); + + it("★予定が履歴にだけある行(E6 相当)も書け、本清掃と出る★", async () => { + historyTimetables = [{ id: BigInt(106), precheck: 0 }]; + errorReports.push(errorReport("E-6", 106)); + const res = await patch("E-6", { status: "in_progress" }); + expect(res.status).toBe(200); + expect(((await res.json()) as ErrorReportItem).schedule_link).toBe("main"); + }); +}); + +describe("200 — 状態の遷移(AT §3-4)", () => { + it("#1 未解消 → 対応中: status と responded_at だけを書き、更新後の行を返す", async () => { + const res = await patch("E-1", { status: "in_progress" }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]); + expect(updateCalls[0].data.status).toBe(1); + // ★`toDbJst()` が通っている★ — 08:15 JST は壁時計値 "2026-10-06T08:15:00Z" として渡る + expect((updateCalls[0].data.responded_at as Date).toISOString()).toBe("2026-10-06T08:15:00.000Z"); + const body = (await res.json()) as ErrorReportItem; + expect(body).toMatchObject({ + id: "E-1", + resolution_status: "in_progress", + responded_at: NOW_ISO, + resolved_at: null, + note: null, + }); + }); + + it("#2 未解消 → 解消: status / responded_at / resolved_at を書く", async () => { + const res = await patch("E-4", { status: "resolved" }); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "responded_at", "status"]); + expect((await res.json()) as ErrorReportItem).toMatchObject({ + resolution_status: "resolved", + responded_at: NOW_ISO, + resolved_at: NOW_ISO, + }); + }); + + it("#4 対応中 → 解消: responded_at は変えない", async () => { + const res = await patch("E-2", { status: "resolved" }); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "status"]); + expect((await res.json()) as ErrorReportItem).toMatchObject({ + resolution_status: "resolved", + responded_at: T1_ISO, + resolved_at: NOW_ISO, + }); + }); + + it("#8 解消 → 未解消: 両方の日時を null にする(戻すと消す)", async () => { + const res = await patch("E-3", { status: "unresolved" }); + expect(updateCalls[0].data).toEqual({ status: 0, responded_at: null, resolved_at: null }); + expect((await res.json()) as ErrorReportItem).toMatchObject({ + resolution_status: "unresolved", + responded_at: null, + resolved_at: null, + }); + }); + + it("#9 解消 → 対応中: resolved_at だけを消す", async () => { + await patch("E-3", { status: "in_progress" }); + expect(updateCalls[0].data).toEqual({ status: 1, resolved_at: null }); + }); + + it("備考だけ変える(現在の status を送る): comment だけを書く・前後の空白を除く", async () => { + const res = await patch("E-2", { status: "in_progress", note: " 店長に確認 " }); + expect(updateCalls[0].data).toEqual({ comment: "店長に確認" }); + expect(((await res.json()) as ErrorReportItem).note).toBe("店長に確認"); + }); + + it("備考を空にする → comment = null", async () => { + const res = await patch("E-2", { status: "in_progress", note: " " }); + expect(updateCalls[0].data).toEqual({ comment: null }); + expect(((await res.json()) as ErrorReportItem).note).toBeNull(); + }); + + it("★空パッチなら updateMany を呼ばず、現在の値を返す。db.write も出さない★", async () => { + for (const [id, body] of [ + ["E-1", { status: "unresolved" }], + ["E-2", { status: "in_progress", note: " 既存の備考 " }], + ["E-3", { status: "resolved" }], + ] as const) { + const res = await patch(id, body); + expect(res.status).toBe(200); + expect(((await res.json()) as ErrorReportItem).id).toBe(id); + } + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + }); + + it("★本文に updated_at・error_type・memo を混ぜても data に出ない★", async () => { + const res = await patch("E-1", { + status: "in_progress", + note: "a", + updated_at: "2000-01-01T00:00:00Z", + error_type: "Hacked", + memo: "旧名", + comment: "直接", + serial_number: "X", + }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["comment", "responded_at", "status"]); + expect(updateCalls[0].data.comment).toBe("a"); + }); +}); + +// ---------------------------------------------------------------- 422 + +describe("422 — 入力の形式", () => { + it.each([ + ["id が空", "", { status: "resolved" }, false], + ["id が51文字", "A".repeat(51), { status: "resolved" }, false], + ["JSON でない", "E-1", "{not json", true], + ["status が enum 外", "E-1", { status: "done" }, false], + ["status が無い", "E-1", { note: "a" }, false], + ["note が数値", "E-1", { status: "resolved", note: 1 }, false], + [`note が${PRECHECK_NOTE_MAX_LENGTH + 1}文字`, "E-1", { status: "resolved", note: "a".repeat(PRECHECK_NOTE_MAX_LENGTH + 1) }, false], + ] as const)("%s → 422(書き込まない)", async (_label, id, body, raw) => { + const res = await patch(id, body, raw); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation_failed"); + expect(updateCalls).toHaveLength(0); + }); +}); + +// ---------------------------------------------------------------- 404 / 500 + +describe("404 — 行が無い", () => { + it.each([ + ["行が無い", "E-NOT-EXIST"], + // ★DB は ci で当たるが JS の === で弾く★(D-4。別の文字列で同じ行を書き換えさせない) + ["大小文字だけ違う id", "e-1"], + ])("%s → 404「エラーが見つかりません」(書き込まない)", async (_label, id) => { + const res = await patch(id, { status: "resolved" }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { code: "not_found", message: "エラーが見つかりません", details: null }, + }); + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + }); + + it("★matched === 0(確認と書き込みの間に行が消えた)→ 404。db.write は success: false / no_matching_row★", async () => { + updateBehavior = "no_match"; + const res = await patch("E-1", { status: "in_progress" }); + expect(res.status).toBe(404); + const logs = dbWriteLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe("error"); + expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "no_matching_row" }); + }); + + it("★書き込みの例外 → 500。db.write(success: false)と api.error が1件ずつ。コードだけを載せる★", async () => { + updateBehavior = "throw"; + const res = await patch("E-1", { status: "in_progress" }); + expect(res.status).toBe(500); + const logs = dbWriteLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe("error"); + expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "P2039:1143" }); + // ★エラーメッセージ(DB のユーザー名が入る)を載せない★ + expect(JSON.stringify(logs[0].payload)).not.toContain("secret-db-user"); + // ★db.write には err を渡さない★ + const dbWriteCall = vi.mocked(logEvent).mock.calls.find(([, p]) => p.event === "db.write"); + expect((dbWriteCall as unknown[] | undefined)?.length).toBe(2); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + }); + + it("★status が 0/1/2 以外の行 → 404★(指示書BB U3・表3 S4。旧: 500(fail-loud。AW §11 Q3)を上書き)", async () => { + errorReports.push(errorReport("E-ODD", 100, { status: 3 })); + const res = await patch("E-ODD", { status: "resolved" }); + expect(res.status).toBe(404); + // ★書き込まない★(一覧に出ない行は404。`updateMany` を呼ばない) + expect(updateCalls).toHaveLength(0); + }); +}); + +/** + * ★表5(削除済みの施設・清掃エリア。指示書AX U8・D12。§11 Q9 で監督確認済み)の PATCH 列★。 + * ★404 は `db.errorReportById()` が null を返すことで出る★(存在しない id の404 と同じ扱い)。 + * ★削除の日時は過去の値★ — 「未来の日時なら生きている」とはしない(NULL か否かだけ。D12)。 + */ +describe("表5 — 削除済みの施設・清掃エリアの行は 404(指示書AX U8)", () => { + const DELETED_AT = db("2026-10-01T10:00:00+09:00"); + const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0]; + const dsAlert = (errorId: string, overrides: Partial) => + errorReport(errorId, null, { robot_id: null, serial_number: "DFAS010001", error_type: DS_ALERT_TYPE, ...overrides }); + + beforeEach(() => { + errorReports.push( + // V1: 両方生きている・ロボットのエラー + errorReport("V-1", 100), + // V2: 施設が削除済み + errorReport("V-2", 100, { facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }), + // V3: 清掃エリアが削除済み + errorReport("V-3", 100, { section: { deleted_at: DELETED_AT } }), + // V4: 両方生きている・D-Sec + dsAlert("V-4", {}), + // V5: 施設が削除済み・D-Sec + dsAlert("V-5", { facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }), + // V6: 清掃エリアが削除済み・D-Sec・対応中 + dsAlert("V-6", { status: 1, responded_at: db(T1_ISO), section: { deleted_at: DELETED_AT } }), + // V7: 両方削除済み・D-Sec・解消 + dsAlert("V-7", { + status: 2, + responded_at: db(T1_ISO), + resolved_at: db(T2_ISO), + facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT }, + section: { deleted_at: DELETED_AT }, + }) + ); + }); + + it.each(["V-2", "V-3", "V-5", "V-6", "V-7"])( + "%s → 404「エラーが見つかりません」・updateMany を呼ばない・db.write を出さない", + async (id) => { + const res = await patch(id, { status: "resolved" }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { code: "not_found", message: "エラーが見つかりません", details: null }, + }); + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + } + ); + + it.each(["V-1", "V-4"])("%s(両方生きている)→ 200", async (id) => { + const res = await patch(id, { status: "in_progress" }); + expect(res.status).toBe(200); + expect(updateCalls).toHaveLength(1); + expect(((await res.json()) as ErrorReportItem).id).toBe(id); + }); + + it("★読み取りの select が施設・清掃エリアの deleted_at を含む(可視性の判定に要る)★", async () => { + await patch("V-2", { status: "resolved" }); + expect(findUniqueSelects[0]).toMatchObject({ + facility: { select: { deleted_at: true } }, + section: { select: { deleted_at: true } }, + }); + }); +}); + +// ---------------------------------------------------------------- 監査ログ + +describe("db.write", () => { + it("★成功で1件。route は /api/errors/[error_id]/status・record_id は文字列・columns は実際に SET した列だけ・備考の中身を含まない★", async () => { + const res = await patch("E-4", { status: "in_progress", note: "秘密の備考メモ" }); + expect(res.status).toBe(200); + const logs = dbWriteLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe("info"); + expect(logs[0].payload).toEqual({ + event: "db.write", + route: "/api/errors/[error_id]/status", + method: "PATCH", + table: "robot_error_reports", + record_id: "E-4", + columns: ["status", "comment", "responded_at"], + status_old: "unresolved", + status_new: "in_progress", + responded_at_old: null, + responded_at_new: NOW_ISO, + resolved_at_old: null, + resolved_at_new: null, + error_list_id_old: null, + error_list_id_new: null, + comment_changed: true, + success: true, + }); + expect(typeof logs[0].payload.record_id).toBe("string"); + expect(JSON.stringify(logs[0].payload)).not.toContain("秘密の備考メモ"); + }); + + it("状態だけの変更では comment_changed: false・columns に comment が無い", async () => { + await patch("E-3", { status: "unresolved" }); + const [log] = dbWriteLogs(); + expect(log.payload).toMatchObject({ + columns: ["status", "responded_at", "resolved_at"], + status_old: "resolved", + status_new: "unresolved", + responded_at_old: T1_ISO, + responded_at_new: null, + resolved_at_old: T2_ISO, + resolved_at_new: null, + comment_changed: false, + }); + }); +}); + +// ---------------------------------------------------------------- エラー内容(error_list_id。指示書BD §3-3。D-184) + +/** + * ★手順の順(1 同じ id → 検証しない / 2 選べない行 → 422 / 3 有効な選択肢に無い → 422 / 4 適用後に備考必須で備考なし → 422)★。 + * ★422 では書き込まない・db.write を出さない★。 + */ +describe("error_list_id(エラー内容)", () => { + const DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0]; + + beforeEach(() => { + errorReports.push( + // L-1: 未選択・ロボットのエラー(選べる) + errorReport("L-1", 100), + // L-2: 有効な id(40)が入っている + errorReport("L-2", 100, { error_list_id: BigInt(40) }), + // L-3: ★無効化済みの id(7)が入っている★ + errorReport("L-3", 100, { error_list_id: BigInt(7) }), + // L-4: ★その他(備考必須)+備考あり★ + errorReport("L-4", 100, { error_list_id: BigInt(OTHER_ID), comment: "その他の理由" }), + // L-5: ★制御盤のエラー(選べない)★ + errorReport("L-5", null, { robot_id: null, serial_number: "DFAS010001", error_type: DS_ALERT_TYPE }), + // L-6: ★清掃開始失敗(選べる)★ + errorReport("L-6", 100, { robot_id: null, serial_number: "---", error_type: "NOT_STARTED" }) + ); + }); + + it("★有効な id → 200。updateMany の data は error_list_id(bigint)だけ・応答に新3項目★", async () => { + const res = await patch("L-1", { status: "unresolved", error_list_id: 40 }); + expect(res.status).toBe(200); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].data).toEqual({ error_list_id: BigInt(40) }); + const body = (await res.json()) as ErrorReportItem; + expect(body).toMatchObject({ id: "L-1", error_list_id: 40, error_list_selectable: true }); + expect(body.error_list).toMatchObject({ id: 40, error_detail_ja: "詳細40", note_required: false }); + }); + + it("★清掃開始失敗(NOT_STARTED)の行も選べる★", async () => { + const res = await patch("L-6", { status: "unresolved", error_list_id: 41 }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ error_list_id: BigInt(41) }); + }); + + it("状態と一緒に変える → 3列(status・responded_at・error_list_id)", async () => { + const res = await patch("L-2", { status: "in_progress", error_list_id: 41 }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["error_list_id", "responded_at", "status"]); + }); + + it("★現在値と同じ id だけ → 空パッチ(書かない・db.write も出さない・選択肢を引かない)★", async () => { + const res = await patch("L-2", { status: "unresolved", error_list_id: 40 }); + expect(res.status).toBe(200); + expect(((await res.json()) as ErrorReportItem).error_list_id).toBe(40); + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + // 名前解決の `id IN` は撃つが、選択肢(inactive / deleted_at の条件)は引かない + expect(errorListWheres.some((w) => "inactive" in w)).toBe(false); + }); + + it("★無効化済みの id が入った行で、同じ id を送って状態だけ変える → 200(手順1)★", async () => { + const res = await patch("L-3", { status: "in_progress", error_list_id: 7 }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]); + const body = (await res.json()) as ErrorReportItem; + // ★名前解決は絞らない★(無効化済みでも名前が出る) + expect(body).toMatchObject({ error_list_id: 7, error_list: { id: 7 } }); + }); + + it("★id を省略して状態だけ変える → error_list_id を書かない★", async () => { + await patch("L-2", { status: "resolved" }); + expect(updateCalls[0].data).not.toHaveProperty("error_list_id"); + }); + + it("★その他+備考 → 200(2列)★", async () => { + const res = await patch("L-1", { status: "unresolved", error_list_id: OTHER_ID, note: "該当なしの理由" }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ comment: "該当なしの理由", error_list_id: BigInt(OTHER_ID) }); + expect(((await res.json()) as ErrorReportItem).error_list).toMatchObject({ id: OTHER_ID, note_required: true }); + }); + + it("その他+備考ありの行で状態だけ変える → 200", async () => { + const res = await patch("L-4", { status: "in_progress" }); + expect(res.status).toBe(200); + }); + + it("★その他・備考が空(null / 空白だけ)の行で状態だけ変える → 200(手順4は見ない。監督判断 2026-10-27)★", async () => { + errorReports.push( + errorReport("L-7", 100, { error_list_id: BigInt(OTHER_ID), comment: null }), + errorReport("L-8", 100, { error_list_id: BigInt(OTHER_ID), comment: " " }) + ); + for (const id of ["L-7", "L-8"]) { + const res = await patch(id, { status: "in_progress" }); + expect(res.status).toBe(200); + } + expect(updateCalls.map((c) => Object.keys(c.data).sort())).toEqual([ + ["responded_at", "status"], + ["responded_at", "status"], + ]); + }); + + it("★既存の備考が空白だけの行でその他を選ぶ → 422 details.note(空白だけは空とみなす)★", async () => { + errorReports.push(errorReport("L-9", 100, { comment: " " })); + const res = await patch("L-9", { status: "unresolved", error_list_id: OTHER_ID }); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { details: unknown } }).error.details).toEqual({ + note: ["このエラー内容を選んだときは備考が必須です"], + }); + expect(updateCalls).toHaveLength(0); + }); + + it("★選べない行(その他が入っている)で備考を消す → 200(手順4を見ない)★", async () => { + errorReports.push( + errorReport("L-10", null, { + robot_id: null, + serial_number: "DFAS010001", + error_type: DS_ALERT_TYPE, + error_list_id: BigInt(OTHER_ID), + comment: "a", + }) + ); + const res = await patch("L-10", { status: "unresolved", note: "" }); + expect(res.status).toBe(200); + expect(updateCalls[0].data).toEqual({ comment: null }); + }); + + describe("422(書き込まない・db.write を出さない)", () => { + afterEach(() => { + expect(updateCalls).toHaveLength(0); + expect(dbWriteLogs()).toHaveLength(0); + }); + + it.each([ + ["null", null], + ["0", 0], + ["負数", -3], + ["小数", 40.5], + ["文字列", "40"], + ])("型違い(%s)→ 422・details は null", async (_label, value) => { + const res = await patch("L-1", { status: "unresolved", error_list_id: value }); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: "error_list_idは1以上の整数で指定してください", + details: null, + }, + }); + }); + + it("★選べない行(D-S_CONTROL_FAILURE・SECURITY_ON)→ 422 details.error_list_id(手順2)★", async () => { + for (const id of ["L-5", "E-8"]) { + const res = await patch(id, { status: "unresolved", error_list_id: 40 }); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: "この行ではエラー内容を選べません", + details: { error_list_id: ["この行ではエラー内容を選べません"] }, + }, + }); + } + // ★選べない行では選択肢を引かない★(手順2で止まる) + expect(errorListWheres.some((w) => "inactive" in w)).toBe(false); + }); + + it.each([ + ["error_lists に無い id", 9999], + ["inactive ≠ 0", 7], + ["deleted_at が非 NULL", 8], + ])("%s → 422 details.error_list_id(手順3)", async (_label, value) => { + const res = await patch("L-1", { status: "unresolved", error_list_id: value }); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: "error_list_idの値が不正です", + details: { error_list_id: ["有効なエラー内容を指定してください"] }, + }, + }); + // ★選択肢は `inactive = 0 AND deleted_at IS NULL` で引いている★ + expect(errorListWheres).toContainEqual({ inactive: 0, deleted_at: null }); + }); + + it("★その他を選んで備考を書かない → 422 details.note(手順4)★", async () => { + const res = await patch("L-1", { status: "unresolved", error_list_id: OTHER_ID }); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: "備考を入力してください", + details: { note: ["このエラー内容を選んだときは備考が必須です"] }, + }, + }); + }); + + it("★既にその他の行で備考を消す → 422 details.note(id を送らなくても・同じ id を送っても)★", async () => { + for (const body of [ + { status: "unresolved", note: "" }, + { status: "unresolved", note: " ", error_list_id: OTHER_ID }, + ]) { + const res = await patch("L-4", body); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { details: unknown } }).error.details).toEqual({ + note: ["このエラー内容を選んだときは備考が必須です"], + }); + } + }); + }); +}); + +describe("db.write — error_list_id の前後", () => { + it("★変えたとき: columns に error_list_id・error_list_id_old / _new は id の数値だけ★", async () => { + errorReports.push(errorReport("L-2", 100, { error_list_id: BigInt(40) })); + const res = await patch("L-2", { status: "unresolved", error_list_id: OTHER_ID, note: "秘密の理由" }); + expect(res.status).toBe(200); + const [log] = dbWriteLogs(); + expect(log.payload).toMatchObject({ + columns: ["comment", "error_list_id"], + error_list_id_old: 40, + error_list_id_new: OTHER_ID, + comment_changed: true, + success: true, + }); + // ★分類名・詳細の文字列・備考の中身を載せない★ + const json = JSON.stringify(log.payload); + expect(json).not.toContain("詳細"); + expect(json).not.toContain("秘密の理由"); + }); + + it("変えないとき: old と new は同じ現在値", async () => { + errorReports.push(errorReport("L-2", 100, { error_list_id: BigInt(40) })); + await patch("L-2", { status: "in_progress" }); + const [log] = dbWriteLogs(); + expect(log.payload).toMatchObject({ error_list_id_old: 40, error_list_id_new: 40 }); + expect(log.payload.columns).not.toContain("error_list_id"); + }); + + it("書き込みの例外でも error_list_id の前後と columns を載せる(success: false)", async () => { + updateBehavior = "throw"; + const res = await patch("E-1", { status: "unresolved", error_list_id: 41 }); + expect(res.status).toBe(500); + const [log] = dbWriteLogs(); + expect(log.payload).toMatchObject({ + columns: ["error_list_id"], + error_list_id_old: null, + error_list_id_new: 41, + success: false, + }); + }); +}); diff --git "a/app/api/errors/\133error_id\135/status/route.ts" "b/app/api/errors/\133error_id\135/status/route.ts" new file mode 100644 index 0000000..897de3c --- /dev/null +++ "b/app/api/errors/\133error_id\135/status/route.ts" @@ -0,0 +1,196 @@ +/** + * PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考・エラー内容を更新する(D-160 / 指示書AW 段階4-3 / 指示書BD §3-3。D-184) + * + * 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。 + * + * ★`app/api/prechecks/[id]/status/route.ts` と同じ順序★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。 + * ★違いの1つ目は `error_list_id`(エラー内容)を書けること★(D-184)— 本文・計画は `lib/errors/status-update.ts` + * (事前チェックの規則の上に `error_list_id` を足したもの)、計画の途中で有効な選択肢を `db.errorLists()` で引く(必要なときだけ)。 + * 違いの2つ目は ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★こと。★ただし削除済みの施設・清掃エリアの行は `db.errorReportById()` が null を返すので404(指示書AX U8。このルートのコードは変えていない)★ + * (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。 + * ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)— + * 同じ形の小さな関数をこのファイルに置く。 + * + * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の5列(`status` / `comment` / `responded_at` / `resolved_at` / + * `error_list_id`)だけ(D-042 / D-184 / `CLAUDE.md` §7)。★5列目の `error_list_id` を書けるのはこの経路だけ★(`/prechecks` は4列)。 + * ★書き込みの中身は `lib/db/robot-error-write.ts`(`writeErrorReportColumns()`)★、★パッチを作るのは + * `planErrorStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。 + * + * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。 + * ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。 + * ★エラー内容は id の前後だけ★(`error_list_id_old` / `error_list_id_new`。分類名・詳細の文字列は載せない)。 + */ +import { NextResponse } from "next/server"; +import { parsePathErrorId } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { columnsForErrorPatch } from "@/lib/db/robot-error-write"; +import { + errorListIdToLookUp, + isEmptyErrorPatch, + parseErrorStatusUpdateBody, + planErrorStatusUpdate, +} from "@/lib/errors/status-update"; +import { toJstIso } from "@/lib/format/date"; +import { withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import { dbErrorCode, logDbWrite } from "@/lib/logger/db-write"; +import type { DbWriteLogEvent } from "@/lib/logger/events"; +import type { ApiError, ErrorCode, ErrorReportItem, ErrorStatusPatch } from "@/lib/types"; + +const ROUTE = "/api/errors/[error_id]/status"; +const METHOD = "PATCH"; + +const NOT_FOUND_MESSAGE = "エラーが見つかりません"; + +function errorBody( + code: ErrorCode, + message: string, + details: Record | null = null +): ApiError { + return { error: { code, message, details } }; +} + +function internalError(message: string, err: unknown) { + const body = errorBody("internal_error", message); + logApiError({ route: ROUTE, method: METHOD, body, status: 500, err }); + return NextResponse.json(body, { status: 500 }); +} + +function notFound() { + return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 }); +} + +/** API の ISO(+09:00)→ 真の瞬間 */ +function instantOrNull(iso: string | null): Date | null { + return iso === null ? null : new Date(iso); +} + +/** パッチを当てた後の日時(監査ログの `*_new`)。キーが無ければ変わらない */ +function nextIso(patch: ErrorStatusPatch, key: "responded_at" | "resolved_at", before: string | null) { + if (!(key in patch) || patch[key] === undefined) return before; + const value = patch[key]; + return value === null ? null : toJstIso(value); +} + +/** 監査ログの共通部分(★備考の中身は載せない。`comment_changed` だけ★) */ +function auditBase( + id: string, + before: ErrorReportItem, + patch: ErrorStatusPatch +): Omit { + return { + route: ROUTE, + method: METHOD, + table: "robot_error_reports", + // ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2) + record_id: id, + columns: columnsForErrorPatch(patch), + status_old: before.resolution_status, + status_new: patch.status ?? before.resolution_status, + responded_at_old: before.responded_at, + responded_at_new: nextIso(patch, "responded_at", before.responded_at), + resolved_at_old: before.resolved_at, + resolved_at_new: nextIso(patch, "resolved_at", before.resolved_at), + error_list_id_old: before.error_list_id, + error_list_id_new: patch.error_list_id ?? before.error_list_id, + comment_changed: "note" in patch && patch.note !== undefined, + }; +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ error_id: string }> } +) { + return withRequestId(request, async () => { + // 1. 形式(`error_id` は varchar(50)) + const { error_id: idRaw } = await params; + const parsedId = parsePathErrorId(idRaw); + if (!parsedId.ok) { + return NextResponse.json(parsedId.error, { status: parsedId.status }); + } + const id = parsedId.query; + + // 2. 本文(★status / note は `/prechecks/{id}/status` と同じ検証★。加えて error_list_id の型) + let json: unknown; + try { + json = await request.json(); + } catch { + return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), { + status: 422, + }); + } + const parsedBody = parseErrorStatusUpdateBody(json); + if (!parsedBody.ok) { + return NextResponse.json(parsedBody.error, { status: parsedBody.status }); + } + + // ★現在時刻は1回だけ作る★(responded_at と resolved_at が同じ要求で別の時刻にならないように) + const now = new Date(); + + // 3. 実在(★参照用の接続で読む★。★大小文字は `===` で決める(D-4)★) + let before: ErrorReportItem | null; + try { + before = await db.errorReportById(id); + } catch (e) { + return internalError("データの取得に失敗しました", e); + } + if (!before) return notFound(); + + // 4. 計画(★検証に落ちたら422・何も変わらなければ書かずに現在の値を返す。どちらもログを出さない★) + // ★error_list_id の現在値と「選べるか」は `errorReportById()` の結果から取る★(別のクエリを撃たない) + const current = { + resolution_status: before.resolution_status, + responded_at: instantOrNull(before.responded_at), + resolved_at: instantOrNull(before.resolved_at), + note: before.note, + error_list_id: before.error_list_id, + error_list_selectable: before.error_list_selectable, + }; + // ★有効な選択肢は、id が変わり・選べる行のときだけ引く★(指示書BD §3-3 手順3。`db.errorLists()` は有効な行だけを返す) + let activeErrorListIds: ReadonlySet = new Set(); + if (errorListIdToLookUp(current, parsedBody.query) !== null) { + try { + activeErrorListIds = new Set((await db.errorLists()).map((item) => item.id)); + } catch (e) { + return internalError("データの取得に失敗しました", e); + } + } + const planned = planErrorStatusUpdate(current, parsedBody.query, now, (errorListId) => + activeErrorListIds.has(errorListId) + ); + if (!planned.ok) { + return NextResponse.json(planned.error, { status: planned.status }); + } + const patch = planned.query; + if (isEmptyErrorPatch(patch)) { + return NextResponse.json(before); + } + + // 5. 書き込み(★書き込み用の接続。updateMany の1文★) + const audit = auditBase(id, before, patch); + let matched: number; + try { + ({ matched } = await db.updateErrorReportColumns(id, patch)); + } catch (e) { + logDbWrite({ ...audit, success: false, db_error_code: dbErrorCode(e) }); + return internalError("対応状況の更新に失敗しました", e); + } + if (matched === 0) { + // 確認と書き込みの間に行が消えた(ステージングのデータ入れ替え等。第10回 N-6) + logDbWrite({ ...audit, success: false, db_error_code: "no_matching_row" }); + return notFound(); + } + // ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている) + logDbWrite({ ...audit, success: true }); + + // 6. 読み直し(画面はこの値でその1行を差し替える) + let after: ErrorReportItem | null; + try { + after = await db.errorReportById(id); + } catch (e) { + return internalError("データの取得に失敗しました", e); + } + if (!after) return notFound(); + return NextResponse.json(after); + }); +} diff --git a/app/api/errors/__tests__/route.fixtures.test.ts b/app/api/errors/__tests__/route.fixtures.test.ts new file mode 100644 index 0000000..d7d4955 --- /dev/null +++ b/app/api/errors/__tests__/route.fixtures.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PATCH } from "@/app/api/errors/[error_id]/status/route"; +import { GET } from "@/app/api/errors/route"; +import { resetFixtureErrorReportOverrides } from "@/lib/db/fixture-error-report-overrides"; +import type { ErrorReportItem, ListResponse } from "@/lib/types"; + +/** + * `GET /errors` → `PATCH /errors/{error_id}/status` → `GET /errors`(`USE_FIXTURES=true`。指示書AW 段階4-4-3)。 + * + * ★★なぜ別ファイルなのか★★ — `lib/db/index.ts` の `db` はモジュール読み込み時に束縛されるので、 + * 同じファイルで true/false の両方は試せない(`app/api/prechecks/[id]/status/__tests__/route.fixtures.test.ts` と同じ)。 + * + * fixtures 版は `lib/fixtures/error-reports.ts` を書き換えず、モジュール内の上書き表を持つ。 + * ★テストごとに `resetFixtureErrorReportOverrides()` で空に戻す★。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +const NOW_ISO = "2026-10-06T08:15:00+09:00"; + +beforeEach(() => { + resetFixtureErrorReportOverrides(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); + resetFixtureErrorReportOverrides(); +}); + +async function list(qs = ""): Promise> { + const res = await GET(new Request(`http://localhost/api/errors${qs ? `?${qs}` : ""}`)); + expect(res.status).toBe(200); + return (await res.json()) as ListResponse; +} + +async function patch(id: string, body: unknown): Promise { + 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 が出ない(15行)・include_resolved=true で18行★", async () => { + const def = await list(); + expect(def.meta.total).toBe(15); + expect(def.items.map((i) => i.id)).not.toContain("FX-ERR-03"); + const all = await list("include_resolved=true"); + expect(all.meta.total).toBe(18); + expect(all.items).toHaveLength(18); + }); + + it("★削除済みの施設(FX-ERR-18)・清掃エリア(FX-ERR-19)の行は既定でも include_resolved=true でも出ない★", async () => { + for (const qs of ["", "include_resolved=true"]) { + const ids = (await list(qs)).items.map((i) => i.id); + expect(ids).not.toContain("FX-ERR-18"); + expect(ids).not.toContain("FX-ERR-19"); + } + }); + + it("error_type=secur → FX-ERR-08 だけ", async () => { + expect((await list("error_type=secur")).items.map((i) => i.id)).toEqual(["FX-ERR-08"]); + }); + + it("★エラー内容の3項目(指示書BD §3-2・§5-2): 解決・不明・未選択・選べない行★", async () => { + const byId = new Map((await list("include_resolved=true")).items.map((i) => [i.id, i])); + expect(byId.get("FX-ERR-01")).toMatchObject({ + error_list_id: 40, + error_list: { id: 40, error_detail_ja: "非常停止スイッチが作動しました", note_required: false }, + error_list_selectable: true, + }); + expect(byId.get("FX-ERR-05")).toMatchObject({ error_list_id: 9999, error_list: null, error_list_selectable: true }); + expect(byId.get("FX-ERR-11")).toMatchObject({ + error_type: "NOT_STARTED", + error_list_id: 131, + error_list: { id: 131, note_required: true }, + error_list_selectable: true, + }); + expect(byId.get("FX-ERR-04")).toMatchObject({ error_list_id: null, error_list: null, error_list_selectable: true }); + expect(byId.get("FX-ERR-08")).toMatchObject({ error_type: "SECURITY_ON", error_list_selectable: false }); + expect(byId.get("FX-ERR-10")).toMatchObject({ error_type: "D-S_CONTROL_FAILURE", error_list_selectable: false }); + }); +}); + +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(16); + }); + + it.each([ + ["存在しない id", "FX-ERR-99"], + ["大小文字違い", "fx-err-01"], + ["★削除済みの施設の行(表5 V2)★", "FX-ERR-18"], + ["★削除済みの清掃エリアの行(表5 V6)★", "FX-ERR-19"], + ])("%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..c3cd09b --- /dev/null +++ b/app/api/errors/__tests__/route.test.ts @@ -0,0 +1,839 @@ +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 { ERROR_LIST_SELECT } from "@/lib/db/error-list-source"; +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; + /** ★`deleted_at` は可視性(`isVisibleErrorRow()`。指示書AX U8・表5)★ */ + facility: { facility_name: string; deleted_at: Date | null }; + section: { deleted_at: Date | null }; + error_type: string; + error_level: string; + errored_at: Date | null; + created_at: Date; + status: number; + responded_at: Date | null; + resolved_at: Date | null; + comment: string | null; + /** エラー内容(`error_lists.id`。指示書BD §3-2)。null = 未選択 */ + error_list_id: bigint | null; +} + +/** `error_lists` の行(指示書BD §5-3。名前解決は絞らない) */ +interface StubErrorList { + id: bigint; + error_category_main_en: string; + error_category_main_ja: string; + error_category_sub_en: string; + error_category_sub_ja: string; + error_detail_en: string; + error_detail_ja: string; + inactive: number; + deleted_at: Date | null; +} + +interface 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[]; +let errorListRows: StubErrorList[]; +/** `errorList.findMany` の引数の記録(★名前解決は一覧1回につき1回まで・id が無ければ0回★) */ +let errorListCalls: Args[]; +/** ★2段目で消えたことにする error_id★(1段目と2段目の間に行が消えた。指示書AW 4-15) */ +let vanishOnSecondStage: Set; +/** ★2段目で `status` が変わったことにする(error_id → 新しい status)★(指示書BB 表3 S7) */ +let statusOnSecondStage: Map; +/** 次の `robotErrorReport.findMany` を例外にする */ +let throwOnErrorFindMany: boolean; +/** + * ★1段目の `where` を無視して全行返す★(SQL の可視性の条件が効かなかった状態。指示書AX 表5 の注意)。 + * ★純粋関数側(`isVisibleErrorRow()`)で外れる★ことを見るため。2段目(`error_id IN`)は評価する + */ +let ignoreFirstStageWhere: 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; + } + // ★可視性(`ERROR_ROW_VISIBLE_WHERE`。指示書AX U8)— `{ deleted_at: null }` だけを受ける★ + case "facility": + case "section": { + const relation = cond as Record; + if (Object.keys(relation).join(",") !== "deleted_at" || relation.deleted_at !== null) { + throw new Error(`[stub] where.${key} に想定外の条件: ${JSON.stringify(relation)}`); + } + if ((key === "facility" ? row.facility : row.section).deleted_at !== null) 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; + const where = !isSecondStage && ignoreFirstStageWhere ? {} : (args.where ?? {}); + return errorReports + .filter((row) => matchErrorWhere(row, where)) + .filter((row) => !(isSecondStage && vanishOnSecondStage.has(row.error_id))) + .map((row) => { + const status = isSecondStage ? statusOnSecondStage.get(row.error_id) : undefined; + const changed = status === undefined ? row : { ...row, status }; + return project(changed 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); + }, + }, + // ★名前解決(`loadErrorListIndexByIds()`)— where は `id IN` だけを受ける(★他のキーは例外★。絞らないことの見張り)★ + errorList: { + findMany: async (args: Args) => { + errorListCalls.push(args); + const where = args.where ?? {}; + if (Object.keys(where).join(",") !== "id") { + throw new Error(`[stub] errorList.findMany の where に想定外のキー: ${Object.keys(where).join(",")}`); + } + const ids = (where.id as { in: bigint[] }).in.map((id) => id.toString()); + return errorListRows + .filter((r) => ids.includes(r.id.toString())) + .map((r) => project(r as unknown as Record, args.select ?? {})); + }, + }, + // ★施設名・分類の解決(`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", deleted_at: null }, + section: { deleted_at: null }, + 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, + error_list_id: null, + ...overrides, + }; +} + +function errorListRow(id: number, overrides: Partial = {}): StubErrorList { + return { + id: BigInt(id), + error_category_main_en: `MAIN-EN-${id}`, + error_category_main_ja: `分類${id}`, + error_category_sub_en: `sub-en-${id}`, + error_category_sub_ja: `小分類${id}`, + error_detail_en: `detail-en-${id}`, + error_detail_ja: `詳細${id}`, + inactive: 0, + deleted_at: null, + ...overrides, + }; +} + +const FACILITY_B = { facility_id: BigInt(2), facility: { facility_name: "テスト施設B", deleted_at: null } }; + +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 }]; + // ★表1 の9行はすべて error_list_id が null★(エラー内容の表 L は下の describe で行に値を入れる) + errorListRows = [ + errorListRow(40), + errorListRow(7, { inactive: 1 }), + errorListRow(8, { deleted_at: db("2026-10-01T10:00:00+09:00") }), + errorListRow(131), + ]; + 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 = []; + errorListCalls = []; + vanishOnSecondStage = new Set(); + statusOnSecondStage = new Map(); + throwOnErrorFindMany = false; + ignoreFirstStageWhere = 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: "対応済み", + error_list_id: null, + error_list: null, + error_list_selectable: true, + }); + }); + + 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", + sort: null, + order: "asc", + schedule: null, + 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("★S7: 1段目の後に `status` が 3 に変わった行は飛ばす(例外にしない)・meta.total は1段目の件数のまま★(指示書BB U3)", async () => { + statusOnSecondStage = new Map([["E-11", 3]]); + const body = await list(); + expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-11")); + expect(body.meta.total).toBe(8); + }); + + 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); + }); +}); + +// ---------------------------------------------------------------- 表5(指示書AX U8) + +/** + * ★表5(削除済みの施設・清掃エリア。指示書AX U8・D12。§11 Q9 で監督確認済み)のエラー一覧の列★。 + * V1 = 表1 の E-1(施設1・清掃エリアは生きている)。V2 = 施設3(削除済み)、V3 = 施設1の削除済みの清掃エリア。 + * ★V2・V3 は表1 のどの行より新しい検知時刻にする★(漏れたら先頭に出て気づける)。 + * ★削除の日時は過去の値★(NULL か否かだけで決まる。D12)。 + */ +describe("表5 — 削除済みの施設・清掃エリアの行は出ない(指示書AX U8)", () => { + const DELETED_AT = db("2026-10-01T10:00:00+09:00"); + const DELETED_FACILITY_NAME = "削除済みテスト施設"; + + beforeEach(() => { + errorReports.push( + row({ + error_id: "V-2", + facility_id: BigInt(3), + facility: { facility_name: DELETED_FACILITY_NAME, deleted_at: DELETED_AT }, + errored_at: db("2026-10-06T09:00:00+09:00"), + created_at: db("2026-10-06T09:01:00+09:00"), + }), + row({ + error_id: "V-3", + section: { deleted_at: DELETED_AT }, + errored_at: db("2026-10-06T09:10:00+09:00"), + created_at: db("2026-10-06T09:11:00+09:00"), + }) + ); + // ★施設名の解決は deleted_at で絞らない(罠7)★ — 削除済みの施設も索引に載る + facilityRows.push({ + id: BigInt(3), + facility_name: DELETED_FACILITY_NAME, + customer_id: BigInt(1), + customer: { group_id: BigInt(1) }, + }); + }); + + it("V2・V3: 既定で出ない・meta.total に数えない(V1 = E-1 と表1 の並びはそのまま)", async () => { + const body = await list(); + expect(ids(body.items)).toEqual(DEFAULT_ORDER); + expect(body.meta.total).toBe(8); + }); + + it("V2・V3: include_resolved=true でも出ない", async () => { + const body = await list("include_resolved=true"); + expect(ids(body.items)).not.toContain("V-2"); + expect(ids(body.items)).not.toContain("V-3"); + expect(body.meta.total).toBe(9); + }); + + it("★V9: 同じ施設1の別の清掃エリアが削除済みでも、施設1の生きている行(E-1)は出る★", async () => { + const body = await list(`facility_name=${encodeURIComponent("テスト施設A")}`); + expect(ids(body.items)).toContain("E-1"); + expect(ids(body.items)).not.toContain("V-3"); + }); + + it("★V8: 削除済みの施設の名前で絞る → 0件・200(422 / 500 にしない)★", async () => { + const body = await list(`facility_name=${encodeURIComponent(DELETED_FACILITY_NAME)}`); + expect(body.items).toEqual([]); + expect(body.meta.total).toBe(0); + // ★0件の理由が「可視性で外れた」ことを確かめる★ — 名前の解決が削除済みの施設を落とす後退(罠7)だと + // `facility_id: { in: [] }` になり、上の2行だけでは緑のまま + expect(errorCalls[0].where).toMatchObject({ facility_id: { in: [BigInt(3)] } }); + }); + + it("★SQL の where を無視して全行返しても V2・V3 は出ない(純粋関数 isVisibleErrorRow() で外れる)★", async () => { + ignoreFirstStageWhere = true; + const def = await list(); + expect(ids(def.items)).toEqual(DEFAULT_ORDER); + expect(def.meta.total).toBe(8); + const all = await list("include_resolved=true"); + expect(ids(all.items)).not.toContain("V-2"); + expect(ids(all.items)).not.toContain("V-3"); + expect(all.meta.total).toBe(9); + }); + + it("1段目の where に施設・清掃エリアの可視性が入っている", async () => { + await list(); + expect(errorCalls[0].where).toMatchObject({ + facility: { deleted_at: null }, + section: { deleted_at: null }, + }); + }); +}); + +describe("GET /errors — sort / schedule(指示書BH §11 R5)", () => { + it("include_resolved + schedule=unscheduled + sort=serial_number&order=desc → データ層へ届く", async () => { + const spy = vi.spyOn(dataSource, "errorReports"); + const body = await list( + "include_resolved=true&schedule=unscheduled&sort=serial_number&order=desc" + ); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + include_resolved: true, + schedule: "unscheduled", + sort: "serial_number", + order: "desc", + }) + ); + expect(body.meta.total).toBe(1); + expect(ids(body.items)).toEqual(["E-10"]); + }); + + it("sort=facility_name → 422", async () => { + const res = await get("sort=facility_name"); + expect(res.status).toBe(422); + }); +}); + +describe("GET /errors — 存在しない日付は422(指示書BB 表8 Q6)", () => { + it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => { + const bad = await get("date_from=2026-02-31"); + const format = await get("date_from=2026/02/03"); + expect(bad.status).toBe(422); + expect(format.status).toBe(422); + expect(await bad.json()).toEqual(await format.json()); + }); +}); + +// ---------------------------------------------------------------- エラー内容(指示書BD §3-2・§5-3) + +/** + * 表 L(GET /errors のエラー内容の3項目。Prisma 経路)。表1 の行に `error_list_id` を入れて見る。 + * | # | 行 | error_type | error_list_id | error_lists の行 | 期待 error_list | selectable | + * |----|------|---------------------|---------------|---------------------------|-----------------------------|------------| + * | L1 | E-1 | EmergencyStop | 40 | 有効 | 40 の行 | true | + * | L2 | E-2 | LowBattery | 9999 | 無い | null(id は 9999 のまま) | true | + * | L3 | E-6 | EmergencyStop | 7 | ★inactive 1★ | 7 の行(絞らない) | true | + * | L4 | E-7 | EmergencyStop | 8 | ★deleted_at あり★ | 8 の行(絞らない) | true | + * | L5 | E-11 | NOT_STARTED | 131 | 有効・note_required | 131 の行 | true | + * | L6 | E-8 | SECURITY_ON | null | — | null | ★false★ | + * | L7 | E-10 | D-S_CONTROL_FAILURE | 40 | 有効(データの食い違い) | 40 の行(値は出す) | ★false★ | + * | L8 | E-9 | EmergencyStop | 40 | ★E-1 と同じ id★ | 40 の行(in には1つ) | true | + */ +describe("GET /errors — エラー内容(表 L。指示書BD §3-2)", () => { + function setErrorLists(): void { + const set = (id: string, patch: Partial) => { + const target = errorReports.find((r) => r.error_id === id); + if (!target) throw new Error(`[test] ${id} がありません`); + Object.assign(target, patch); + }; + set("E-1", { error_list_id: BigInt(40) }); + set("E-2", { error_list_id: BigInt(9999) }); + set("E-6", { error_list_id: BigInt(7) }); + set("E-7", { error_list_id: BigInt(8) }); + set("E-11", { error_type: "NOT_STARTED", error_list_id: BigInt(131) }); + set("E-10", { error_type: "D-S_CONTROL_FAILURE", error_list_id: BigInt(40) }); + set("E-9", { error_list_id: BigInt(40) }); + } + + it.each([ + ["L1", "E-1", 40, 40, true], + ["L2", "E-2", 9999, null, true], + ["L3", "E-6", 7, 7, true], + ["L4", "E-7", 8, 8, true], + ["L5", "E-11", 131, 131, true], + ["L6", "E-8", null, null, false], + ["L7", "E-10", 40, 40, false], + ["L8", "E-9", 40, 40, true], + ] as const)("%s: %s", async (_no, id, listId, resolvedId, selectable) => { + setErrorLists(); + const item = (await list()).items.find((i) => i.id === id); + expect({ + error_list_id: item?.error_list_id, + resolved: item?.error_list?.id ?? null, + error_list_selectable: item?.error_list_selectable, + }).toEqual({ error_list_id: listId, resolved: resolvedId, error_list_selectable: selectable }); + }); + + it("L1・L5: error_list は行の形のまま(文字列は変換しない)・note_required は 131 だけ", async () => { + setErrorLists(); + const items = (await list()).items; + expect(items.find((i) => i.id === "E-1")?.error_list).toEqual({ + id: 40, + error_category_main_en: "MAIN-EN-40", + error_category_main_ja: "分類40", + error_category_sub_en: "sub-en-40", + error_category_sub_ja: "小分類40", + error_detail_en: "detail-en-40", + error_detail_ja: "詳細40", + note_required: false, + }); + expect(items.find((i) => i.id === "E-11")?.error_list?.note_required).toBe(true); + }); + + it("★名前解決は1回だけ・where は id IN だけ(重複なし・ページの行の id だけ)・select は ERROR_LIST_SELECT★", async () => { + setErrorLists(); + await list(); + expect(errorListCalls).toHaveLength(1); + expect(errorListCalls[0].select).toEqual(ERROR_LIST_SELECT); + const inIds = (errorListCalls[0].where as { id: { in: bigint[] } }).id.in.map((id) => id.toString()).sort(); + expect(inIds).toEqual(["131", "40", "7", "8", "9999"]); + }); + + it("★ページに無い行の id は引かない★(per_page=1 → 先頭の E-7 の 8 だけ)", async () => { + setErrorLists(); + await list("per_page=1"); + expect(errorListCalls).toHaveLength(1); + expect((errorListCalls[0].where as { id: { in: bigint[] } }).id.in).toEqual([BigInt(8)]); + }); + + it("★ページの行が全部 error_list_id null なら名前解決のクエリを撃たない★", async () => { + const body = await list(); + expect(body.items.every((i) => i.error_list_id === null && i.error_list === null)).toBe(true); + expect(errorListCalls).toHaveLength(0); + }); + + it("★0件のとき名前解決のクエリも撃たない★", async () => { + setErrorLists(); + await list("error_type=no-such-type"); + expect(errorListCalls).toHaveLength(0); + }); + + it("表1 の既定(error_list_id 全部 null): SECURITY_ON の E-8 だけ selectable false", async () => { + const body = await list(); + expect(body.items.filter((i) => !i.error_list_selectable).map((i) => i.id)).toEqual(["E-8"]); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts new file mode 100644 index 0000000..708a2bb --- /dev/null +++ b/app/api/errors/route.ts @@ -0,0 +1,82 @@ +/** + * 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` の行★(事前チェック・本清掃・予定なし・制御盤・セキュリティ・清掃開始失敗を問わない)。★削除済みの施設・清掃エリアの行はデータ層が外す(指示書AX U8。このルートは何もしない)★。 + * `/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, + sort: q.sort, + order: q.order, + schedule: q.schedule, + 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..39b9dce --- /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..d796c0e --- /dev/null +++ b/app/api/facilities/route.ts @@ -0,0 +1,83 @@ +/** + * 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 + ); + // ★並べ替えは絞り込みのあと・ページングの前★(`sort` / `order`。ユーザー指示 2026-10-30)— + // ページングのあとに並べると、並べ替えが1ページの中だけで閉じてしまう。 + const sorted = sortFacilities(filtered, query.sort, query.order); + + 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..286a0f5 --- /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..8528647 --- /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\"(旧: 出発失敗)は無い(2026-10-29 に消した)", () => { + const byId = new Map(prechecks.map((p) => [p.id, p])); + expect(byId.get("1")).toMatchObject({ severity: "precheck", resolution_status: "unresolved" }); + expect(byId.has("4")).toBe(false); + expect(byId.has("8")).toBe(false); + }); + + 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..831626b --- /dev/null +++ "b/app/api/prechecks/\133id\135/status/__tests__/route.test.ts" @@ -0,0 +1,611 @@ +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; + /** ★施設・清掃エリアの可視性(指示書BB U1・表1 V9)。省略は生きている★ */ + facility?: { deleted_at: Date | null }; + section?: { deleted_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; + /** ★`deleted_at` は可視性の判定用(指示書BB U1)★ */ + facility: { facility_name: string; deleted_at: Date | null }; + section: { deleted_at: Date | null }; + 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(); +} + +/** ★予定の行に施設・清掃エリアの `deleted_at` を詰める(省略は生きている)★(指示書BB U1・表1 V9) */ +function withVisibility(row: StubTimetable | undefined) { + if (!row) return null; + return { + ...row, + facility: row.facility ?? { deleted_at: null }, + section: row.section ?? { deleted_at: null }, + }; +} + +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 } }) => withVisibility(timetables.find((t) => t.id === args.where.id)), + }, + cleanTimetableHistory: { + findUnique: async (args: { where: { id: bigint } }) => + withVisibility(historyTimetables.find((t) => t.id === args.where.id)), + }, + // ★親の本清掃のレポート(`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: "バリューマート北町店", deleted_at: null }, + section: { deleted_at: null }, + 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), + // ★`status` が 0/1/2 以外★(一覧 `/prechecks` に出ない)→ 404(指示書BB U3・表3 S3) + errorReport("RER-ODD-STATUS", 1011, { status: 3 }), + ]; +} + +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"); + }); + + it("★error_list_id(エラー内容)はこの経路では書かない★ — 本文にあっても捨てる・不正な値でも422にしない(指示書BD §2。D-184)", async () => { + for (const value of [131, null, "x"]) { + updateCalls = []; + const res = await patch("RER-UNRESOLVED", { status: "in_progress", error_list_id: value }); + expect(res.status).toBe(200); + expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]); + buildData(); + } + }); +}); + +// ---------------------------------------------------------------- 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"], + // ★`status` が 0/1/2 以外★(指示書BB U3・表3 S3。500 にしない) + ["status が 3 の行", "RER-ODD-STATUS"], + ])("%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, + }); + }); +}); + +/** + * ★表1 V9(指示書BB U1・D-182)★ — `GET /prechecks` に出ない行(エラー行の施設・清掃エリア、または親の本清掃の施設が削除済み)は404。 + * ★`updateMany` を呼ばない★。削除の日時は過去の値(NULL か否かだけで決める)。 + */ +describe("404 — 削除済みの施設・清掃エリアの行(指示書BB U1・表1 V9)", () => { + const DELETED = dbWallClock("2026-09-01T00:00:00+09:00"); + + async function expect404(id: string) { + const res = await patch(id, { status: "resolved" }); + expect(res.status).toBe(404); + expect(updateCalls).toHaveLength(0); + } + + it("エラー行の施設が削除済み → 404", async () => { + errorReports = errorReports.map((r) => + r.error_id === "RER-UNRESOLVED" ? { ...r, facility: { ...r.facility, deleted_at: DELETED } } : r + ); + await expect404("RER-UNRESOLVED"); + }); + + it("エラー行の清掃エリアが削除済み → 404", async () => { + errorReports = errorReports.map((r) => + r.error_id === "RER-UNRESOLVED" ? { ...r, section: { deleted_at: DELETED } } : r + ); + await expect404("RER-UNRESOLVED"); + }); + + it("★親の本清掃の施設が削除済み(エラー行は生きている)→ 404★(一覧の予定の where で外れる行)", async () => { + timetables = timetables.map((t) => (t.id === BigInt(101) ? { ...t, facility: { deleted_at: DELETED } } : t)); + await expect404("RER-UNRESOLVED"); + }); + + it("親の本清掃の清掃エリアが削除済み → 404", async () => { + timetables = timetables.map((t) => (t.id === BigInt(101) ? { ...t, section: { deleted_at: DELETED } } : t)); + await expect404("RER-UNRESOLVED"); + }); + + it("生きている行は今どおり 200", async () => { + const res = await patch("RER-UNRESOLVED", { status: "resolved" }); + expect(res.status).toBe(200); + }); +}); 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..2b2163f --- /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` に事前チェック異常として出る行だけ★(§3-3。アラートは 2026-10-29 から出発失敗を出さない)。 + * + * ★監査ログ(`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..1551b71 --- /dev/null +++ b/app/api/prechecks/__tests__/route.test.ts @@ -0,0 +1,854 @@ +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 { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { logEvent } from "@/lib/logger"; +import { + filterPrechecksByDate, + parsePrecheckQuery, + type PrecheckQuery, + type PrecheckSummary, +} from "@/lib/prechecks/query"; +import type { PrecheckDbQuery, PrecheckItem, SummaryListResponse } from "@/lib/types"; + +/** + * 指示書AC ★段階4★ の期待値表(#1〜#5)。 + * + * ------------------------------------------------------------------------ + * ★★何を守るためのテストか★★ + * ------------------------------------------------------------------------ + * `prechecks()` が返すのは ★事前チェック異常の行(`severity: "precheck"`)だけ★(ユーザー判断 2026-10-29)。 + * ★期間の基準は**親の本清掃行**の `start_at`★(`PrecheckDbQuery` の型コメント)。 + * ★#4 で「親が期間外・エラー行が期間内」★ を固定する。 + * ★出発失敗(レポートの無い過去の本清掃)は行にならない★(旧: `severity: "launch_failed"`)— + * 「出発失敗はアラートに出ない」の describe と、清掃詳細ログ側(`app/api/clean-logs/__tests__/route.test.ts`)で固定する。 + * + * ------------------------------------------------------------------------ + * ★★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; + /** ★`deleted_at` は可視性の判定用(指示書BB U1。`ROBOT_ERROR_PRECHECK_ROW_SELECT` が読む)★ */ + facility: { facility_name: string; deleted_at: Date | null }; + section: { deleted_at: Date | null }; + 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; + +/** + * ★true のあいだ、エラー行の `where` を無視して全行を返す★(指示書BB 表3 S2 / 表1 V7)— + * SQL の写し(`status IN`・可視性)が外れても、純粋関数の側(正)で外れることを確かめる + */ +let ignoreErrorWhere = false; + +/** 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 })); + +// ★`data.anomaly`(指示書BB U2・U3)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★ +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 + ), + }, + // ★清掃結果の取得元は `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); + if (ignoreErrorWhere) { + // ★`clean_timetable_id IN` だけ残し、それ以外(`status IN`・可視性)を無視する★ + const where = (args?.where ?? {}) as Record; + return findMany("robotErrorReport", data.errorReports as unknown as Record[], { + ...args, + where: { clean_timetable_id: where.clean_timetable_id }, + }); + } + 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, deleted_at: null }, + section: { deleted_at: null }, + // ★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: [ + // --- 本清掃行 --- + // ★レポートなし(=出発失敗)★ — ★アラートには出ない★(2026-10-29。清掃詳細ログには出発失敗として出る) + 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"), + timetable(104, 1, FACILITY_1, "2026-09-11T00:30:00+09:00"), // 正常(#4 の親) + // ★★壁時計 20:00 の清掃★★(C-6。★ローカルDBには1件も無い時間帯★)。レポートなし(出発失敗)だが、事前チェックのエラー E5 の親 + timetable(301, 2, FACILITY_2, "2026-09-10T20:00:00+09:00"), + // ★未来の予定★(レポートなし)— 旧: `isFutureSchedule()` を外すと出発失敗として並んだ。いまは出発失敗そのものを出さない + 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), + // ★壁時計 20:00 の清掃(301)の事前チェック(C-6)★ + timetable(3011, 2, FACILITY_2, "2026-09-10T19:00:00+09:00", FIRST_ROUND_MINUTES, 301), + ], + // ★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)— ★行がある以上は実施済み★で、 + // `main_run_succeeded` の材料として読めること + 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"), + // ★★本清掃行を指すエラー行★★ — ★拾わない★(D-104)ので + // ★1件も出てはいけない★(出発失敗の代わりに本清掃のエラーが出ていないことの検出) + errorReport("E3", 103, 1, FACILITY_1, "2026-09-10T02:10:00+09:00"), + // ★壁時計 20:00 の清掃(301)の事前チェックのエラー(C-6)★ + errorReport("E5", 3011, 2, FACILITY_2, "2026-09-10T19:05: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; +} + +/** + * 本清掃行を引くクエリ(= `where.precheck` を持つ1本目)。 + * ★2026-10-29 から `include` を持たない★(`select: { id, start_at }`)ので、`where.precheck` で見分ける + */ +function isMainQuery(c: Record): boolean { + return "where" in c && "precheck" in (c.where as object); +} + +function mainQuery(): Record { + const call = calls.cleanTimetable.find(isMainQuery); + expect(call).toBeDefined(); + return call as Record; +} + +function mainQueryWhere(): Record { + return (mainQuery() as { where: Record }).where; +} + +/** `severity` ごとの件数(★`precheck` 以外が混ざっていないことの検出に使う★) */ +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(); + ignoreErrorWhere = false; + vi.mocked(logEvent).mockClear(); + 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 範囲なし → 事前チェック異常の全件(★出発失敗の行は無い★)", async () => { + const body = await get("?status=all"); + // ★detected_at の降順(D-052)★ + expect(body.items.map((i) => `${i.severity}:${i.id}`)).toEqual([ + "precheck:E2", + "precheck:E5", + "precheck:E1", + ]); + expect(body.meta.total).toBe(3); + expect(body.summary).toEqual({ unresolved: 3, resolved: 0, total: 3 }); + // ★本清掃の絞り込みが SQL に降りていること★ + expect(mainQueryWhere().precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main); + // ★範囲なしのときは `start_at` のキーごと付けない★ + expect(mainQueryWhere()).not.toHaveProperty("start_at"); + }); + + it("★D-104: 本清掃行を指すエラー行は出さない★(出発失敗の代わりにもしない)", 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: 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"); + // ★9/10 は E5(親 301 は 9/10 20:00)だけ★。出発失敗(103・301)は行にならない + expect(sep10.items.map((i) => i.id)).toEqual(["E5"]); + }); + + 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 () => { + // ★E5 の親(301)は 9/10 20:00★ + const sep10 = await get("?date_from=2026-09-10&date_to=2026-09-10&status=all"); + expect(sep10.items.map((i) => i.id)).toContain("E5"); + expect(sep10.items.find((i) => i.id === "E5")?.scheduled_at).toBe("2026-09-10T20:00:00+09:00"); + 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("E5"); + }); + + 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; + // ★9/10 の本清掃は 103・301 だが、行(E5)の親は 301 だけ★(旧: 出発失敗の判定のため 103 も引いていた) + expect(reportWhere.clean_timetable_id.in.map(String)).toEqual(["301"]); + }); + + it("★行が0件ならレポートを引かない★(出発失敗の判定のための読み取りは無い)", async () => { + // 9/8 の本清掃(101)は事前チェックのエラーが無い + const body = await get("?date_from=2026-09-08&date_to=2026-09-08&status=all"); + expect(body.items).toEqual([]); + expect(calls.cleanTimetable.length).toBeGreaterThan(0); + expect(calls.cleanTaskReport).toEqual([]); + }); + + 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"); + // ★9/10 は E5 の1件だけ★(旧: 出発失敗 103・301 の2件。2026-10-29 から数えない) + expect(body.summary).toEqual({ unresolved: 1, resolved: 0, total: 1 }); + expect(body.meta.total).toBe(1); + }); +}); + +/** + * ★出発失敗はアラートに出さない★(ユーザー判断 2026-10-29「2で進めてください」)。 + * ★過去の本清掃でレポートが無い(101・103・301)は、行・summary・meta.total のどれにも出ない★。 + * 清掃詳細ログには出発失敗として出ること(同じ判定 `notExecutedFromReport()`)は + * `app/api/clean-logs/__tests__/route.test.ts` の「出発失敗はアラートに出ない」で固定する。 + */ +describe("GET /prechecks — 出発失敗はアラートに出さない(2026-10-29)", () => { + const LAUNCH_FAILED_IDS = ["101", "103", "301", "901"]; + + it("★レポートの無い過去の本清掃は行にならない(summary・meta.total にも数えない)★", async () => { + const body = await get("?status=all"); + const ids = body.items.map((i) => i.id); + for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id); + expect(body.items.every((i) => i.severity === "precheck")).toBe(true); + // ★件数は事前チェック異常の3件だけ★ + expect(body.summary.total).toBe(3); + expect(body.meta.total).toBe(3); + }); + + it("★事前チェックのエラーが無ければ、出発失敗の清掃があっても0件★", async () => { + data.errorReports = []; + const body = await get("?status=all"); + expect(body.items).toEqual([]); + expect(body.summary).toEqual({ unresolved: 0, resolved: 0, total: 0 }); + expect(body.meta.total).toBe(0); + }); + + it("★結果待ちの時刻でも、待ち時間を過ぎた後でも出さない★(旧: 終了+120分を過ぎると出発失敗として出した。D-173)", async () => { + for (const now of ["2026-09-10T04:59:00+09:00", "2026-09-10T05:00:00+09:00"]) { + vi.setSystemTime(new Date(now)); + const ids = (await get("?status=all")).items.map((i) => i.id); + for (const id of LAUNCH_FAILED_IDS) expect(ids).not.toContain(id); + } + }); + + it("★親の本清掃が出発失敗の事前チェック異常は、行として出る(main_run_succeeded は false)★", async () => { + // E5 の親 301 はレポートなし(出発失敗) + const body = await get("?status=all"); + const e5 = body.items.find((i) => i.id === "E5"); + expect(e5).toBeDefined(); + expect(e5?.main_run_succeeded).toBe(false); + expect(e5?.severity).toBe("precheck"); + }); +}); + +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 = [ + // ★履歴だけの本清掃(レポートなし → 出発失敗)★ — アラートには出ない(2026-10-29) + 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.id); + expect(ids).toContain("E9"); + expect(ids).not.toContain("501"); + expect(ids).not.toContain("502"); + + const mainCall = calls.cleanTimetable.find(isMainQuery); + expect(mainCall).toBeDefined(); + expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainCall); + }); +}); + +/** + * ★`main_run_succeeded`(ユーザー指示 2026-10-07。D-171 の期待値表 M1〜M5)★ — Prisma 版の経路。 + * ★親の本清掃のレポートは `prechecks()` が行の親の id でまとめて1回引く★(N+1 にしない)。 + * (M6「出発失敗の行は常に false」は、2026-10-29 に出発失敗の行を外したので無い) + */ +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); + // ★出発失敗(101)は行にならない(2026-10-29)★ + expect(body.items.map((i) => i.id)).not.toContain("101"); + }); + + 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); + }); +}); + +/** `data.anomaly` のうち、指定の metric のものだけ */ +function anomalies(metric: string) { + return vi + .mocked(logEvent) + .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === metric) + .map(([, p]) => p); +} + +describe("GET /prechecks — 対応状況が 0/1/2 以外のエラー行(指示書BB U3・表3 S1/S2)", () => { + it("★S2: `status` 3 の行は SQL の写し(`status IN (0,1,2)`)で外れる★", async () => { + data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 }); + const res = await GET(new Request("http://localhost/api/prechecks?status=all")); + expect(res.status).toBe(200); + const where = calls.robotErrorReport[0].where as Record; + expect(where.status).toEqual({ in: [0, 1, 2] }); + }); + + it("★S2: スタブが `where` を無視しても、純粋関数の側で外れる(200・出ない・summary に数えない・ログ1回)★", async () => { + const before = await get("?status=all"); + vi.mocked(logEvent).mockClear(); + data.errorReports.push({ ...errorReport("E-ODD", 1022, 1, FACILITY_1, "2026-09-09T01:35:00+09:00"), status: 3 }); + ignoreErrorWhere = true; + + 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.map((i) => i.id)).not.toContain("E-ODD"); + expect(after.items).toEqual(before.items); + expect(after.summary).toEqual(before.summary); + expect(anomalies("robot_error_status_unknown")).toEqual([ + { event: "data.anomaly", metric: "robot_error_status_unknown", expected: "0/1/2", actual: 1 }, + ]); + }); + + it("S1: `status` 0/1/2 の行は今どおり出る(外した件数が0ならログを出さない)", async () => { + ignoreErrorWhere = true; + const body = await get("?status=all"); + expect(body.items.map((i) => i.id)).toContain("E1"); + expect(anomalies("robot_error_status_unknown")).toHaveLength(0); + }); +}); + +/** + * ★本清掃の予定は `id` と `start_at` だけ読む★(2026-10-29)— 旧: 出発失敗の行の施設名・シリアルのため + * `facility` / `section.robots` を include し、有効なロボットが1台でない清掃エリアのログ(指示書BB U2)を出していた。 + */ +describe("GET /prechecks — 本清掃の予定はロボット・施設を読まない(2026-10-29)", () => { + it("★本清掃の予定のクエリは `select: { id, start_at }`(include なし)。2表とも同じ★", async () => { + await get("?status=all"); + expect(mainQuery()).not.toHaveProperty("include"); + expect(mainQuery().select).toEqual({ id: true, start_at: true }); + expect(calls.cleanTimetableHistory.find(isMainQuery)).toEqual(mainQuery()); + }); + + it("★清掃エリアのロボットが0台でも、事前チェック異常の行は今どおり(シリアルはエラー行の生値・ログなし)★", async () => { + for (const t of data.timetables) { + if (Number(t.facility_id) === 1) t.section = { robots: [] }; + } + const res = await GET(new Request("http://localhost/api/prechecks?status=all")); + expect(res.status).toBe(200); + const body = (await res.json()) as SummaryListResponse; + // ★事前チェック異常の行は `robot_error_reports.serial_number` の生値のまま(null にならない)★ + expect(body.items.find((i) => i.id === "E1")?.serial_number).toBe("8111R562806001"); + expect(anomalies("active_robots_per_section")).toHaveLength(0); + }); +}); + +describe("GET /prechecks — 削除済みの施設・清掃エリアの行は出さない(指示書BB U1・表1 V7/V8)", () => { + const DELETED = dbWallClock("2026-09-01T00:00:00+09:00"); + + it("★V8: 予定(2表とも)の `where` に施設・清掃エリアの可視性が入る★(事前チェックの親の本清掃)", async () => { + await get("?status=all"); + expect(mainQueryWhere()).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + const historyMain = calls.cleanTimetableHistory.find(isMainQuery) as { where: Record }; + expect(historyMain.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + }); + + it("V7: エラー行の SQL(写し)にも `status IN` は入るが、可視性は純粋関数で外す(列を select する)", async () => { + await get("?status=all"); + const select = calls.robotErrorReport[0].select as Record }>; + expect(select.facility.select).toMatchObject({ deleted_at: true }); + expect(select.section.select).toMatchObject({ deleted_at: true }); + }); + + it.each([ + ["施設削除", { facility: { facility_name: FACILITY_1, deleted_at: DELETED } }], + ["エリア削除", { section: { deleted_at: DELETED } }], + ] as const)("★V7(%s): スタブが where を無視しても、そのエラー行は出ない・summary に数えない★", async (_label, patch) => { + const before = await get("?status=all"); + data.errorReports = data.errorReports.map((r) => (r.error_id === "E1" ? { ...r, ...patch } : r)); + ignoreErrorWhere = true; + + const after = await get("?status=all"); + expect(after.items.map((i) => i.id)).toEqual(before.items.map((i) => i.id).filter((id) => id !== "E1")); + expect(after.summary.total).toBe(before.summary.total - 1); + }); +}); + +describe("GET /prechecks — 存在しない日付は422(指示書BB 表8 Q6)", () => { + it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => { + const bad = await GET(new Request("http://localhost/api/prechecks?date_from=2026-02-31")); + const format = await GET(new Request("http://localhost/api/prechecks?date_from=2026/02/03")); + expect(bad.status).toBe(422); + expect(format.status).toBe(422); + expect(await bad.json()).toEqual(await format.json()); + }); +}); diff --git a/app/api/prechecks/route.ts b/app/api/prechecks/route.ts new file mode 100644 index 0000000..f9d181f --- /dev/null +++ b/app/api/prechecks/route.ts @@ -0,0 +1,99 @@ +/** + * GET /prechecks — アラート(事前チェック)一覧+サマリ + * + * ★返すのは事前チェック異常の行だけ★(ユーザー判断 2026-10-29。出発失敗は清掃詳細ログ・ダッシュボードで見る)。 + * summary・meta.total も事前チェック異常の行だけを数える(`db.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..04c2b21 --- /dev/null +++ b/app/api/reports/__tests__/route.test.ts @@ -0,0 +1,2169 @@ +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 { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility"; +import { logEvent } from "@/lib/logger"; +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)。 + */ + +/** + * ★`include: { robots: { orderBy: { id: "asc" } } }` を再現する★(指示書BB D7・表2 R9)— + * ★`orderBy` があるときだけ並べ替える★。実装が付け忘れたら、スタブはデータの順のまま返す(R9 が赤くなる)。 + * `app/api/clean-logs/__tests__/route.test.ts` の `sortIncludedRobots()`(R5)と同じ作法 + */ +function sortIncludedFacilityRobots( + row: Record, + args: Record | undefined +): Record { + const include = args?.include as { robots?: { orderBy?: { id?: string } } } | undefined; + if (include?.robots?.orderBy?.id !== "asc" || !Array.isArray(row.robots)) return row; + const robots = [...(row.robots as StubFacility["robots"])].sort( + (a, b) => Number(a.id ?? 0) - Number(b.id ?? 0) + ); + return { ...row, robots }; +} + +// ---------------------------------------------------------------- スタブ(DBの代わり) + +interface StubFacility { + id: bigint; + facility_name: string; + deleted_at: Date | null; + /** `id` は省略可(★2台以上のときの並び `orderBy: { id: "asc" }` を見るため。指示書BB D7・表2 R9★) */ + robots: { id?: bigint; 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; + /** ★予定外タスク(指示書BG)の施設はこの列で決まる★。予定のある行は予定の施設を使うので省略可 */ + facility_id?: bigint; + 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; + /** ★予定外のエラー(指示書BG)の施設はこの列で決まる★ */ + facility_id: bigint; + 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; + /** + * ★2026-10-29 の DDL 変更で `error_detail` の代わりに入った列(NOT NULL・error_lists への FK)★。 + * ★アプリは読まない★(`select` に入れない。内訳は `error_type` で束ねる) + */ + error_list_id: bigint; + 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 })); + +// ★確定期間のサマリ0行の `data.anomaly`(指示書BB A11・表13)を見るため `logEvent` だけ差し替える(`withRequestId` はそのまま)★ +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: async () => ({ + facility: { + findMany: async (args?: Record) => + findMany("facility", data.facilities as unknown as Record[], args).map( + (row) => sortIncludedFacilityRobots(row, 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, facilityId = 1): StubErrorReport { + return { + clean_timetable_id: timetableId === null ? null : BigInt(timetableId), + facility_id: BigInt(facilityId), + 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 = 予定外のエラー → ★当期の数に入る★(指示書BG §2-4・A案。旧: 数えない(決定3))。 + // ★施設3(屋号3)に置く★ — 顧客1(施設1)の KPI の数字を変えないため。全社のエラー内訳(絞り込みなし)には出る + errorReport(null, "LostLocation", 3), + // ★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_list_id: BigInt(131), error_count: 2 }, + { id: BigInt(22), facility_id: BigInt(2), year: 2026, month: 9, week: 2, error_type: "HandleUp", error_list_id: BigInt(131), error_count: 1 }, + { id: BigInt(23), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "EmergencyStop", error_list_id: BigInt(131), error_count: 1 }, + // ★別の期間の行は混ざらない★ + { id: BigInt(24), facility_id: BigInt(1), year: 2026, month: 9, week: 1, error_type: "BrushError", error_list_id: BigInt(131), 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("★R9: 施設にロボット2台(id 10 と id 7)→ id 7 のシリアルを採る。`robots` の並びは `id` 昇順を SQL に頼む。ログは出さない★", async () => { + // 施設に複数台は正常(指示書BB 表2 R9)なので `data.anomaly` は出さない + data.facilities = data.facilities.map((f) => + Number(f.id) === 1 + ? { + ...f, + robots: [ + { id: BigInt(10), serial_number: "SERIAL-ID10", deleted_at: null }, + { id: BigInt(7), serial_number: "SERIAL-ID7", deleted_at: null }, + ], + } + : f + ); + vi.mocked(logEvent).mockClear(); + const body = await performance(`${CONFIRMED}&limit=100`); + expect(body.items.find((item) => item.facility_id === 1)?.serial_number).toBe("SERIAL-ID7"); + const include = calls.facility.find((c) => "include" in c)?.include as { + robots: { orderBy: unknown }; + }; + expect(include.robots.orderBy).toEqual({ id: "asc" }); + expect( + vi.mocked(logEvent).mock.calls.filter(([, p]) => p.event === "data.anomaly") + ).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); + // ★削除済みの施設の行は数えない(指示書BB U1・表1 V14。清掃エリアの削除は外せない — D5)★ + expect(calls.facilityErrorSummary[0].where).toEqual({ + year: 2026, + month: 9, + week: 2, + facility: { deleted_at: null }, + }); + }); + + it("★同じ施設・同じ error_type で error_list_id が違う2行は1項目に足す★(2026-10-29 の DDL 変更)", async () => { + // ★サマリの行は error_list_id ごとにも分かれるようになった★が、API の出力(種別ごとの件数)は変えない + data.facilityErrorSummaries.push( + { id: BigInt(25), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "LostLocalization", error_list_id: BigInt(38), error_count: 4 }, + { id: BigInt(26), facility_id: BigInt(1), year: 2026, month: 9, week: 2, error_type: "LostLocalization", error_list_id: BigInt(131), error_count: 3 } + ); + const body = await breakdown(CONFIRMED); + expect(body.items.map((i) => [i.type, i.count])).toEqual([ + ["LostLocalization", 7], + ["HandleUp", 3], + ["EmergencyStop", 1], + ]); + // ★使う列だけ読む★ — ★`error_detail`(消えた列)も `error_list_id` も SELECT しない★ + // (`select` 無しの全列読みで、列の削除が500になった。2026-10-29) + expect(calls.facilityErrorSummary[0].select).toEqual({ + year: true, + month: true, + week: true, + error_type: true, + error_count: true, + }); + }); +}); + +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週)は 422★(指示書BB A8・表8 W2。旧: 200・空)", async () => { + const q = "period=weekly&year=2026&month=9&week=6"; + const expected = { + error: { + code: "validation_failed", + message: "weekの値が不正です", + details: { week: ["2026年9月に第6週はありません"] }, + }, + }; + for (const res of [ + await GET_KPI(new Request(`http://localhost/api/reports/kpi?${q}`)), + await GET_CONTINUOUS(new Request(`http://localhost/api/reports/continuous-problems?${q}`)), + await GET_PERFORMANCE(new Request(`http://localhost/api/reports/facility-performance?${q}`)), + await GET_BREAKDOWN(new Request(`http://localhost/api/reports/error-breakdown?${q}`)), + ]) { + expect(res.status).toBe(422); + expect(await res.json()).toEqual(expected); + } + // ★データ層まで来ない★ + expect(calls.cleanTimetable).toHaveLength(0); + }); +}); + +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("事前チェック行・dangling・別期間は数えない。★予定外(null)は数える(指示書BG §2-4)★", async () => { + const body = await breakdown(CURRENT); + expect(body.items).toEqual([ + { type: "EmergencyStop", label: "EmergencyStop", count: 1, rate: 50 }, + // ★施設3 の予定外のエラー(`clean_timetable_id` が null。errored_at 09-13 02:30 は当期)★ + { type: "LostLocation", label: "LostLocation", count: 1, rate: 50 }, + ]); + }); + + 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() + ); + }); +}); + +/** + * ★当期のエラー内訳は、未来・結果待ちの予定を指すエラーを数えない★(ユーザー指示 2026-10-29 + * 「結果待ちなので、まだエラーかどうか確定してない」)— ★エラー発生率の分子と同じ集合★。 + * + * 今 = 09-16 09:00(`NOW`)・結果待ち 120分・予定の終了は開始の1時間後。施設1(屋号1。KPI の母集団)に足す: + * + * | 予定 | 開始 | レポート | エラー | 内訳 | + * |---|---|---|---|---| + * | 301(既存) | 09-13 02:00 | あり | EmergencyStop | ★数える★ | + * | 302(既存) | 09-14 02:00 | 無し(未実施) | ★HandleUp を足す★ | ★数える★ | + * | 305(既存) | 09-18 02:00(未来) | 無し | ★BrushError を足す★ | 数えない | + * | 306 | 09-16 06:00(終了+120分 = 09:00 ちょうど) | 無し | ★LostLocation★ | ★数える★(境界は確定) | + * | 307 | 09-16 06:01(終了+120分 = 09:01) | 無し | ★Pending★ | 数えない(結果待ち) | + */ +describe("★当期のエラー内訳 — 未来・結果待ちの予定を数えない(ユーザー指示 2026-10-29)★", () => { + beforeEach(() => { + data.timetables.push( + timetable(306, 1, "2026-09-16T06:00:00+09:00"), + timetable(307, 1, "2026-09-16T06:01:00+09:00") + ); + data.errorReports.push( + errorReport(302, "HandleUp"), + errorReport(305, "BrushError"), + errorReport(306, "LostLocation"), + errorReport(307, "Pending") + ); + }); + + it("未実施・境界ちょうどは数え、未来・結果待ちは数えない", async () => { + const body = await breakdown(CURRENT); + expect(body.items.map((i) => [i.type, i.count]).sort()).toEqual( + [ + ["EmergencyStop", 1], + ["HandleUp", 1], + // ★306 の1件 + 施設3 の予定外のエラー(null)1件★(絞り込みなし。指示書BG §2-4) + ["LostLocation", 2], + ].sort() + ); + }); + + it("★★内訳の件数の合計 = KPI のエラー発生率の分子★★(同じ母集団・同じ now)", async () => { + const items = (await breakdown(`${CURRENT}&customer_id=1`)).items; + const total = items.reduce((sum, i) => sum + i.count, 0); + const body = await kpi(`${CURRENT}&customer_id=1`); + // ★予定回数は 301 / 302 / 306 の3件(305 は未来・307 は結果待ちで入らない)★。 + // 出発成功率 = 開始1件(301)÷ 予定 なので、予定回数は 100 ÷ 出発成功率 × 1 + expect(body.launch_rate.value).toBeCloseTo(100 / 3, 10); + expect(total).toBe(3); + expect(body.error_rate.value).toBeCloseTo((total / 3) * 100, 10); + }); + + it("★レポートは引いた本清掃の id でだけ、有無の判定に要る列だけ読む★", async () => { + await breakdown(CURRENT); + const call = calls.cleanTaskReport[0] as { + where: { clean_timetable_id: { in: bigint[] } }; + select: Record; + }; + expect(call.where.clean_timetable_id.in.map(String).sort()).toEqual( + ["301", "302", "305", "306", "307", "313", "314", "321"].sort() + ); + expect(call.select).toEqual({ clean_timetable_id: true, started_at: true, ended_at: true }); + }); +}); + +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("★K7: customer_id と group_id の同時指定・傘下2社のうちの1社 → その顧客(422 にしない)★(指示書BB 表9。AND)", 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); + }); + + it("★★K3: customer_id=2・group_id=1(顧客2は傘下でない)→ 200・空のKPI。サマリも詳細も読まない★★(指示書BB A17・表9)", async () => { + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 2), customer(3, "屋号3", 1)]; + const res = await rawKpi(`${CONFIRMED}&group_id=1&customer_id=2`); + expect(res.status).toBe(200); + expect(JSON.parse(res.text)).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 }, + }); + expect(calls.customerSummary).toHaveLength(0); + expect(calls.cleanTimetable).toHaveLength(0); + }); +}); + +/** + * 指示書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・屋号2 はどちらもホールディングス1★(指示書BB A17 で customer_id と group_id は AND になったので、 + // 傘下の顧客を名指しする形で確かめる。旧: group_id=2&customer_id=1 — いまは食い違いで 200・空になる)。 + data.customers = [customer(1, "屋号1", 1), customer(2, "屋号2", 1)]; + // ★`customer_id` が名指しされているので対象顧客は屋号1★。 + // ★★リクエストの `group_id`(=1。傘下は屋号1・2)から施設を引くと施設2 まで入り★★、 + // ★確定期間(屋号1の数字)とずれる★。 + const body = await kpi(`${CURRENT}&group_id=1&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 の同時指定では読む★(AND の傘下判定に要る。指示書BB A17・D15)", 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(1); + }); + + 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] }, + facility: { deleted_at: null }, + }); + }); + + 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, + facility: { deleted_at: null }, + }); + }); + + 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(と施設3 の予定外の LostLocation。指示書BG)が出る★(=この0件は「絞れた結果」) + expect((await breakdown(CURRENT)).items.map((i) => i.type)).toEqual([ + "EmergencyStop", + "LostLocation", + ]); + }); + + 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); + }); +}); + +/** + * ★表1 V11 / V13 / V15 / V16(指示書BB U1・D-182)★ — 当期の詳細テーブルの予定(2表とも)と、 + * 連続日数の入力(`loadConsecutiveInputs()`)の `where` に、削除済みの施設・清掃エリアの条件が入ること。 + * ★確定期間のサマリは外せる範囲だけ★(V14 は上の `facilityErrorSummary` の where。V10・V12 は変えない — D5) + */ +describe("削除済みの施設・清掃エリア — 当期の予定と連続日数の入力(指示書BB U1・表1)", () => { + function expectAllTimetableWheresVisible() { + const all = [...calls.cleanTimetable, ...calls.cleanTimetableHistory].filter((c) => "where" in c); + expect(all.length).toBeGreaterThan(0); + for (const c of all) { + const where = (c as { where: Record }).where; + // ★影チェック(`id IN` だけの引き直し)は対象外★(`findTimetables()` の内部) + if (!("precheck" in where)) continue; + expect(where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + } + } + + it("V11: `/reports/kpi` の当期", async () => { + await kpi(`${CURRENT}&customer_id=1`); + expectAllTimetableWheresVisible(); + }); + + it("V13・V15: `/reports/facility-performance` の当期(連続日数の入力を含む)", async () => { + await performance(`${CURRENT}&limit=100`); + expectAllTimetableWheresVisible(); + expect(calls.cleanTimetable.some((c) => isConsecutiveQuery(c))).toBe(true); + }); + + it("V16: `/reports/error-breakdown` の当期", async () => { + await breakdown(CURRENT); + expectAllTimetableWheresVisible(); + }); + + it("V15: `/reports/continuous-problems`(連続日数の入力)", async () => { + await continuous(`${CURRENT}&limit=100`); + expectAllTimetableWheresVisible(); + expect(calls.cleanTimetable.some((c) => isConsecutiveQuery(c))).toBe(true); + }); +}); + +/** + * ★表13(指示書BB A11・D-182)★ — 確定期間の `customer_summaries` が0行なら `data.anomaly` を1回(KPI は今どおり空)。 + * 当期にサマリが無いのは正常なのでログを出さない。★未来の期間(最終日が今日以降)も出さない★。 + * どの顧客・期間か追えるよう、顧客と期間キー(粒度を含む)を載せる。 + */ +describe("確定期間のサマリが0行(指示書BB 表13)", () => { + function summaryAnomalies() { + return vi + .mocked(logEvent) + .mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === "customer_summary_rows") + .map(([, p]) => p); + } + + it("★L1: 確定期間・サマリ0行 → KPI は空のまま。data.anomaly を1回★", async () => { + vi.mocked(logEvent).mockClear(); + const body = await kpi("period=weekly&year=2026&month=7&week=1&customer_id=1"); + expect(body.operation_rate.value).toBeNull(); + expect(summaryAnomalies()).toEqual([ + { + event: "data.anomaly", + metric: "customer_summary_rows", + expected: 1, + actual: 0, + period_mode: "summary_table", + customer_id: 1, + period: "weekly", + year: 2026, + month: 7, + week: 1, + }, + ]); + }); + + it("L1(月次・年次): 期間キーは粒度のまま載る(月次は week 0・年次は month も 0)", async () => { + vi.mocked(logEvent).mockClear(); + await kpi("period=monthly&year=2026&month=7&customer_id=1"); + await kpi("period=yearly&year=2024&month=0&customer_id=1"); + expect(summaryAnomalies()).toEqual([ + expect.objectContaining({ customer_id: 1, period: "monthly", year: 2026, month: 7, week: 0 }), + expect.objectContaining({ customer_id: 1, period: "yearly", year: 2024, month: 0, week: 0 }), + ]); + }); + + it("★L4: 未来の期間・サマリ0行 → ログを出さない(KPI は空のまま)★", async () => { + vi.mocked(logEvent).mockClear(); + // NOW = 2026-09-16(9月第3週)。第4週・10月・2027年はまだ来ていない + const body = await kpi("period=weekly&year=2026&month=9&week=4&customer_id=1"); + expect(body.operation_rate.value).toBeNull(); + expect((await kpi("period=monthly&year=2026&month=10&customer_id=1")).operation_rate.value).toBeNull(); + expect((await kpi("period=yearly&year=2027&month=0&customer_id=1")).operation_rate.value).toBeNull(); + expect(summaryAnomalies()).toHaveLength(0); + }); + + it("L2: 当期・サマリ0行 → ログを出さない", async () => { + vi.mocked(logEvent).mockClear(); + await kpi(`${CURRENT}&customer_id=1`); + expect(summaryAnomalies()).toHaveLength(0); + }); + + it("L3: 確定期間・1行 → ログを出さない", async () => { + vi.mocked(logEvent).mockClear(); + const body = await kpi(`${CONFIRMED}&customer_id=1`); + expect(body.operation_rate.value).toBe(90); + expect(summaryAnomalies()).toHaveLength(0); + }); +}); + +/** + * ★★表U2 — レポート(今の期間・A案)★★(指示書BG §3。監督が書いた期待値表をそのまま使う。数字を変えない)。 + * + * 今 = 2026-10-29 10:00 JST、`RESULT_WAIT_MINUTES` = 120。施設 F1(=施設1・屋号1)。期間 10-28 の1日 + * (API は週次: 2026年10月第5週 = 10-25〜10-31。材料は 10-28 にしか置かない)。 + * + * | 材料 | 内容 | + * |---|---| + * | 予定のある清掃 | 3回: 正常終了で完了率 80 / 正常終了で完了率 60 / 未実施 | + * | 予定外 | 2回: `status` 4・完了率 100(`clean_timetable_id` = 0)/ `status` 6・完了率 20(NULL)| + * | エラー | 予定の id に紐づく2件 + 予定外(id 0)に紐づく1件(`errored_at` 10-28 22:00)+ 事前チェックの回に紐づく1件 | + * + * | 指標 | 期待 | + * |---|---| + * | 予定回数 | 3 + 2 = 5 | + * | 開始できた回数 | 2 + 2 = 4 → 出発成功率 80 | + * | 正常終了 | 2 + 1 = 3 → 稼働率 60 | + * | 清掃完了率 | (80 + 60 + 100 + 20) ÷ 4 = 65 | + * | エラー発生回数 | 2 + 1 = 3(事前チェックの1件は入れない)→ エラー発生率 60 | + * | エラー内訳の合計 | 3(エラー発生回数と同じ) | + */ +describe("★★表U2 — 予定外タスクを今の期間のレポートに含める(A案。指示書BG §2-4)★★", () => { + const U2_NOW = new Date("2026-10-29T10:00:00+09:00"); + /** 10-28 を含む週(2026年10月第5週 = 10-25〜10-31)。★当期★ */ + const U2_PERIOD = "period=weekly&year=2026&month=10&week=5&customer_id=1"; + + /** 予定外タスク(`clean_task_reports` の行。予定の行は無い) */ + function unscheduledReport( + timetableId: bigint | null, + status: number, + cleanArea: number, + startedJst: string + ): StubReport { + return { + clean_timetable_id: timetableId, + facility_id: BigInt(1), + clean_ratio: cleanArea, + status, + started_at: dbWallClock(startedJst), + ended_at: new Date(dbWallClock(startedJst).getTime() + 30 * 60_000), + task_area: 100, + clean_area: cleanArea, + }; + } + + beforeEach(() => { + vi.setSystemTime(U2_NOW); + data.timetables = [ + timetable(501, 1, "2026-10-28T21:00:00+09:00"), + timetable(502, 1, "2026-10-28T22:00:00+09:00"), + // ★未実施★(レポートの行を置かない。終了 10-29 00:00 + 120分 < 今 なので結果待ちではない) + timetable(503, 1, "2026-10-28T23:00:00+09:00"), + // ★事前チェックの回(501 の子)★ — どの集計にも入れない + timetable(5011, 1, "2026-10-28T20:00:00+09:00", FIRST_ROUND_MINUTES, 501), + ]; + data.reports = [ + { ...report(501, 80, 100, 80), facility_id: BigInt(1) }, + { ...report(502, 60, 100, 60), facility_id: BigInt(1) }, + // ★`clean_timetable_id` = 0 と NULL はどちらも予定外(表U1 U1-3)★ + unscheduledReport(BigInt(0), 4, 100, "2026-10-28T20:00:00+09:00"), + unscheduledReport(null, 6, 20, "2026-10-28T23:30:00+09:00"), + ]; + data.errorReports = [ + errorReport(501, "EmergencyStop"), + errorReport(502, "HandleUp"), + { + clean_timetable_id: BigInt(0), + facility_id: BigInt(1), + // ★種類では絞らない★(予定のある清掃と同じ。指示書BG §1) + error_type: "SECURITY_ON", + errored_at: dbWallClock("2026-10-28T22:00:00+09:00"), + created_at: dbWallClock("2026-10-28T22:00:05+09:00"), + }, + // ★事前チェックの回に紐づくエラー → 入れない★ + errorReport(5011, "LostLocation"), + ]; + data.customerSummaries = []; + data.facilitySummaries = []; + data.facilityErrorSummaries = []; + }); + + it("KPI: 出発成功率 80・稼働率 60・清掃完了率 65・エラー発生率 60", async () => { + const body = await kpi(U2_PERIOD); + expect(body.launch_rate.value).toBe(80); + expect(body.operation_rate.value).toBe(60); + expect(body.completion_rate.value).toBe(65); + expect(body.error_rate.value).toBe(60); + }); + + it("施設別の成績も同じ集計(施設1 の行が同じ4率)", async () => { + const body = await performance(`${U2_PERIOD}&limit=100`); + const fac1 = body.items.find((item) => item.facility_id === 1); + expect(fac1?.launch_rate.value).toBe(80); + expect(fac1?.operation_rate.value).toBe(60); + expect(fac1?.completion_rate.value).toBe(65); + expect(fac1?.error_rate.value).toBe(60); + }); + + it("★★エラー内訳の合計 3 = エラー発生率の分子★★(事前チェックの1件は入れない。`aa4292d` の不変条件)", async () => { + const items = (await breakdown(U2_PERIOD)).items; + expect(items.map((i) => [i.type, i.count]).sort()).toEqual( + [ + ["EmergencyStop", 1], + ["HandleUp", 1], + ["SECURITY_ON", 1], + ].sort() + ); + const total = items.reduce((sum, i) => sum + i.count, 0); + expect(total).toBe(3); + // 分子 = エラー発生率 × 予定回数(5)÷ 100 + const body = await kpi(U2_PERIOD); + expect(((body.error_rate.value ?? Number.NaN) * 5) / 100).toBeCloseTo(total, 10); + }); + + it("★予定外の読み取りは期間・施設・可視性(削除済みを除く)を SQL に降ろし、`robot_id` を読まない★", async () => { + await kpi(U2_PERIOD); + const reportCall = calls.cleanTaskReport.find((c) => "OR" in (c.where as object)) as { + where: Record; + select: Record; + }; + expect(reportCall).toBeDefined(); + expect(reportCall.where.OR).toEqual([ + { clean_timetable_id: null }, + { clean_timetable_id: BigInt(0) }, + ]); + const startedAt = reportCall.where.started_at as { gte: Date; lt: Date }; + // ★壁時計値(10-25 00:00 以上 / 11-01 00:00 未満)★ + expect(startedAt.gte.toISOString()).toBe("2026-10-25T00:00:00.000Z"); + expect(startedAt.lt.toISOString()).toBe("2026-11-01T00:00:00.000Z"); + expect((reportCall.where.facility_id as { in: unknown[] }).in.map(Number)).toEqual([1]); + expect(reportCall.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + expect(reportCall.select.robot_id).toBeUndefined(); + + const errorCall = calls.robotErrorReport.find((c) => "AND" in (c.where as object)) as { + where: Record; + select: Record; + }; + expect(errorCall).toBeDefined(); + expect(errorCall.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE); + expect((errorCall.where.facility_id as { in: unknown[] }).in.map(Number)).toEqual([1]); + expect(errorCall.select.robot_id).toBeUndefined(); + expect(errorCall.select.robot).toBeUndefined(); + }); + + it("★確定期間(先週)では予定外の行も読まない★(サマリのまま。ユーザー「サマリは変えて貰う予定」)", async () => { + await kpi("period=weekly&year=2026&month=10&week=4&customer_id=1"); + await breakdown("period=weekly&year=2026&month=10&week=4&customer_id=1"); + expect(calls.cleanTaskReport).toEqual([]); + expect(calls.robotErrorReport).toEqual([]); + }); +}); diff --git a/app/api/reports/continuous-problems/route.ts b/app/api/reports/continuous-problems/route.ts new file mode 100644 index 0000000..7f8f3d6 --- /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..180c269 --- /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..b0239f1 --- /dev/null +++ b/app/api/reports/facility-performance/export/__tests__/route.test.ts @@ -0,0 +1,275 @@ +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("★W5: その月に存在しない週(2026年9月の第6週)は 422(一覧と同じ。指示書BB A8・表8)★", async () => { + facilityCount = 3; + const query = "period=weekly&year=2026&month=9&week=6"; + const res = await get(query); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiError; + expect(body.error.details).toEqual({ week: ["2026年9月に第6週はありません"] }); + 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..451bfda --- /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..1fc54d0 --- /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..9b54622 --- /dev/null +++ b/app/api/reports/kpi/route.ts @@ -0,0 +1,160 @@ +/** + * 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` だけなら顧客リストを一切見ない★ + // (★存在確認をしない★。指示書AK 期待値表 6-9)ので、読んでも捨てるだけの1クエリになる。 + // ★`group_id` も指定されていたら読む★(AND の傘下判定に要る。指示書BB A17・D15・D-182)。 + // ★★分岐させるのは「読むかどうか」だけ★★ — ★決め方は `resolveTargetCustomer()` に + // 任せたまま★(ここで顧客を決めてしまうと決定表が2箇所になり、片方だけ直すと静かにズレる)。 + // ★`!== null` で見ること★ — `parsed.query.customerId || ...` と書くと将来 0 が + // 入り込んだときに「未指定」に化ける(`resolveTargetCustomer()` と同じ作法)。 + let customers: Customer[] = []; + if (parsed.query.customerId === null || parsed.query.groupId !== 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); + } + // ★顧客とグループが食い違う(その顧客がそのグループの傘下でない)→ 該当なし(200・空)★(指示書BB A17・D-182)— + // ★サマリも詳細も読まない★(YAML の `GroupId`「AND。そうでなければ0件。404 にしない」) + if (target.customerId === null) { + return NextResponse.json(emptyKpi()); + } + + 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..cba463f --- /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/__tests__/route.test.ts b/app/api/settings/advice/__tests__/route.test.ts new file mode 100644 index 0000000..2423b72 --- /dev/null +++ b/app/api/settings/advice/__tests__/route.test.ts @@ -0,0 +1,105 @@ +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` の冒頭コメントと同じ理由) +import { GET, PATCH } from "@/app/api/settings/advice/route"; +import { logApiError } from "@/lib/logger/api"; +import { __setAdviceStoreForTesting, createAdviceStore } from "@/lib/settings/advice"; +import type { AdviceOverrides } from "@/lib/settings/advice"; +import type { JsonStore } from "@/lib/settings/json-store"; + +/** + * `GET / PATCH /settings/advice` の頑丈さ(指示書BB 表8 T1・H1〜H3・J1〜J3。A5・A10・D-182)。 + * + * | # | 入力 | 期待 | + * |---|---|---| + * | T1 | GET で `loadAdviceRules()` が例外 | 500・internal_error・「データの取得に失敗しました」・`api.error` が1件 | + * | H1 | PATCH `rule: "constructor"` | 422「ruleの値が不正です」 | + * | H2 | `"toString"` / `"__proto__"` / `"hasOwnProperty"` | 422 | + * | H3 | `"R1"` | 今どおり(200) | + * | J1 | 本文が `null` | 422・validation_failed・「JSONの形式が不正です」 | + * | J2 | 本文が `[]` / `1` / `"x"` | 422(J1 と同じ) | + * | J3 | 本文が `{}` | 今どおり(422「ruleの値が不正です」) | + * + * ★保存先は `fs.mkdtemp` の一時ディレクトリ★(実ファイル `data/settings/advice.json` を汚さない) + */ + +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +let dir = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-advice-route-")); + __setAdviceStoreForTesting(createAdviceStore(path.join(dir, "advice.json"), () => {})); + vi.mocked(logApiError).mockClear(); +}); + +afterEach(async () => { + __setAdviceStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +function patchRaw(raw: string): Promise { + return PATCH(new Request("http://localhost/api/settings/advice", { method: "PATCH", body: raw })); +} + +describe("GET /settings/advice — try の外の例外(指示書BB 表8 T1)", () => { + it("★T1: 読み込みが例外 → 500・標準の形・api.error が1件★", async () => { + const broken: JsonStore = { + read: async () => { + throw new Error("[stub] 読み込みに失敗"); + }, + write: async () => {}, + }; + __setAdviceStoreForTesting(broken); + const res = await GET(new Request("http://localhost/api/settings/advice")); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ route: "/api/settings/advice", method: "GET", status: 500 }); + }); + + it("読み込みが成功すれば今どおり 200", async () => { + const res = await GET(new Request("http://localhost/api/settings/advice")); + expect(res.status).toBe(200); + }); +}); + +describe("PATCH /settings/advice — rule の検証(指示書BB 表8 H1〜H3。A10)", () => { + it.each(["constructor", "toString", "__proto__", "hasOwnProperty"])( + "★H1・H2: rule=%s → 422「ruleの値が不正です」★(`Object.prototype` のキーを通さない)", + async (rule) => { + const res = await patchRaw(JSON.stringify({ rule, title: "x" })); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "ruleの値が不正です", details: null }, + }); + } + ); + + it("H3: rule=R1 → 今どおり 200", async () => { + const res = await patchRaw(JSON.stringify({ rule: "R1", title: "出発失敗の確認" })); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ rule: "R1", title: "出発失敗の確認" }); + }); +}); + +describe("PATCH /settings/advice — 本文がオブジェクトでない(指示書BB 表8 J1〜J3。A5・D19)", () => { + it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」★", async (raw) => { + const res = await patchRaw(raw); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "JSONの形式が不正です", details: null }, + }); + }); + + it("J3: 本文 {} → 今どおり(項目の検証で422「ruleの値が不正です」)", async () => { + const res = await patchRaw("{}"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { message: string } }).error.message).toBe("ruleの値が不正です"); + }); +}); diff --git a/app/api/settings/advice/route.ts b/app/api/settings/advice/route.ts new file mode 100644 index 0000000..7a6b5bc --- /dev/null +++ b/app/api/settings/advice/route.ts @@ -0,0 +1,155 @@ +/** + * GET /settings/advice — アドバイス文言の一覧(既定値+編集内容) + * PATCH /settings/advice — 1ルール分の文言を保存 + * + * システム管理 →「運用設定」から使います。 + * 編集できるのは title / text / actions のみ。 + * level / icon / condition(発火条件)は仕様なので画面からは変更できません。 + */ +import { NextResponse } from "next/server"; +import { isJsonObject } from "@/lib/api/json-body"; +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 () => { + // ★読み込みの例外は標準の形の500にする★(指示書BB A5・D18。`try` の外で投げると標準でない500・`api.error` なし) + let rules: Awaited>; + try { + rules = await loadAdviceRules(); + } catch (e) { + const errorPayload = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body: errorPayload, status: 500, err: e }); + return NextResponse.json(errorPayload, { status: 500 }); + } + 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 () => { + // ★`unknown` で受けて `isJsonObject()` で狭める★ — JSONで何が来るかは実行時まで分からない。 + // 型で嘘をつくと `filter` や `as` で握りつぶす実装に流れる + let body: unknown; + + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言) + if (!isJsonObject(body)) { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + // ★自前のプロパティだけを受ける★(指示書BB A10・D-182)— `DEFAULT_ADVICE_RULES[ruleId]` だと + // `"constructor"` / `"toString"` / `"__proto__"` が `Object.prototype` から引けて検証を通ってしまう + if (typeof body.rule !== "string" || !Object.hasOwn(DEFAULT_ADVICE_RULES, body.rule)) { + return errorResponse(422, "validation_failed", "ruleの値が不正です"); + } + // ★`as` は検証の後だけ★ + const ruleId = body.rule as AdviceRuleId; + + 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/__tests__/route.test.ts b/app/api/settings/display-limits/__tests__/route.test.ts new file mode 100644 index 0000000..dee3a2a --- /dev/null +++ b/app/api/settings/display-limits/__tests__/route.test.ts @@ -0,0 +1,89 @@ +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` の冒頭コメントと同じ理由) +import { GET, PATCH } from "@/app/api/settings/display-limits/route"; +import { logApiError } from "@/lib/logger/api"; +import { + __setDisplayLimitStoreForTesting, + createDisplayLimitStore, + type DisplayLimitOverrides, +} from "@/lib/settings/display-limits"; +import type { JsonStore } from "@/lib/settings/json-store"; + +/** + * `GET / PATCH /settings/display-limits` の頑丈さ(指示書BB 表8 T2・J1〜J3。A5・D-182)。 + * + * | # | 入力 | 期待 | + * |---|---|---| + * | T2 | GET で `loadDisplayLimitSettings()` が例外 | 500・internal_error・「データの取得に失敗しました」・`api.error` が1件 | + * | J1 | PATCH の本文が `null` | 422・validation_failed・「JSONの形式が不正です」 | + * | J2 | 本文が `[]` / `1` / `"x"` | 422(J1 と同じ) | + * | J3 | 本文が `{}` | 今どおり(422「keyの値が不正です」) | + */ + +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +let dir = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-display-limits-route-")); + const filePath = path.join(dir, "display-limits.json"); + __setDisplayLimitStoreForTesting(createDisplayLimitStore(filePath, () => {}), filePath); + vi.mocked(logApiError).mockClear(); +}); + +afterEach(async () => { + __setDisplayLimitStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +function patchRaw(raw: string): Promise { + return PATCH(new Request("http://localhost/api/settings/display-limits", { method: "PATCH", body: raw })); +} + +describe("GET /settings/display-limits — try の外の例外(指示書BB 表8 T2)", () => { + it("★T2: 読み込みが例外 → 500・標準の形・api.error が1件★", async () => { + const broken: JsonStore = { + read: async () => { + throw new Error("[stub] 読み込みに失敗"); + }, + write: async () => {}, + }; + __setDisplayLimitStoreForTesting(broken, path.join(dir, "display-limits.json")); + const res = await GET(new Request("http://localhost/api/settings/display-limits")); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { code: "internal_error", message: "データの取得に失敗しました", details: null }, + }); + expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1); + expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ + route: "/api/settings/display-limits", + method: "GET", + status: 500, + }); + }); + + it("読み込みが成功すれば今どおり 200", async () => { + const res = await GET(new Request("http://localhost/api/settings/display-limits")); + expect(res.status).toBe(200); + }); +}); + +describe("PATCH /settings/display-limits — 本文がオブジェクトでない(指示書BB 表8 J1〜J3。A5・D19)", () => { + it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」★", async (raw) => { + const res = await patchRaw(raw); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "JSONの形式が不正です", details: null }, + }); + }); + + it("J3: 本文 {} → 今どおり(項目の検証で422「keyの値が不正です」)", async () => { + const res = await patchRaw("{}"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { message: string } }).error.message).toBe("keyの値が不正です"); + }); +}); diff --git a/app/api/settings/display-limits/route.ts b/app/api/settings/display-limits/route.ts new file mode 100644 index 0000000..126d706 --- /dev/null +++ b/app/api/settings/display-limits/route.ts @@ -0,0 +1,171 @@ +/** + * 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 { isJsonObject } from "@/lib/api/json-body"; +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 () => { + // ★読み込みの例外は標準の形の500にする★(指示書BB A5・D18。`try` の外で投げると標準でない500・`api.error` なし) + try { + const items = await loadDisplayLimitSettings(); + return NextResponse.json({ items }); + } catch (e) { + const errorPayload = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body: errorPayload, status: 500, err: e }); + return NextResponse.json(errorPayload, { status: 500 }); + } + }); +} + +export async function PATCH(request: Request) { + return withRequestId(request, async () => { + // ★`unknown` で受けて `isJsonObject()` で狭める★ — JSONで何が来るかは実行時まで分からない + let body: unknown; + + try { + body = await request.json(); + } catch { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + // ★本文がオブジェクトでない(`null`・配列・数値・文字列)なら422★(指示書BB A5・D19。今の「JSON が読めない」と同じ文言) + if (!isJsonObject(body)) { + return errorResponse(422, "validation_failed", "JSONの形式が不正です"); + } + + // ★キーを知っているのは `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..b72295e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,100 @@ +: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; + --sort-mark: #ffffff; +} + +[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; + --sort-mark: #1e293b; +} + +:root { + --font-ja: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic UI", "Yu Gothic", + "Noto Sans JP", Meiryo, sans-serif; +} + +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), var(--font-ja); + /* ★行の高さを数値で固定する★(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..17e877b --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme/ThemeProvider"; +import { PREFS_INLINE_SCRIPT } from "@/lib/ui-prefs"; +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 ( + のスクリプトが data-theme / lang を描く前に書き換えるので、 の属性の食い違いは許す★ + (この要素の属性だけに効く。子には効かない。指示書BC 3-4・D-183) */ + suppressHydrationWarning + > + + {/* ★保存した表示設定(ダーク/ライト・JP/EN)を描く前に に入れる★(ユーザー決定 2026-10-19 U1)。 + ★CSP を入れるときは nonce が要る★(今は CSP が無い。指示書BC §7-7) */} +