| 項目 | 内容 | ||
|---|---|---|---|
| 実行環境 | ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照) |
||
| 実行状態 | ★★完了(2026-09-13)★★ — ★段階0〜段階7 をすべて実行しました★。★★決定番号 = D-131(docs/decisions.md:9156)/ docs/handoff.md 追記45★★。★コミット: 4d26d25 / 5077918 / adcb06e / b2d0f8c / d87a604 / 149447f / dcc11a7(段階7)★。★作業起点は 5031eca(★冒頭の「作成」行の 041a809 は作成時の HEAD であって作業起点ではありません★。D-131)★。★検証: npx tsc --noEmit = 0 / npm run lint = 0 / npm test = ★66ファイル・1,570テスト 全パス★★ |
||
| 位置づけ | ★★ユーザー提起(2026-09-11)への回答★★ — 「★全てのDBを読み込んでそこから条件にあるものを抽出してますが、日付や顧客などをキーにして各ページごとにDBに対してSQLを流したほうが後々軽くないですか?★」。★★そのとおりです★★。★現状の作りは本番のデータ量に耐えません★。★D-120 申し送り①(docs/decisions.md:7762 の §14)が「別課題として切り出すこと」と書いた、その別課題が本書です★ |
||
| ★着手前確認★ | ★★なし(解決済み)★★ — ★2026-09-13 にユーザー回答を得ました★。原文: 「★上限は設定してください★」「★ただしシステム管理画面で変更できるようにしてください★」、続く確認で「★連続日数の上限だけ先に編集可能にする。作法を確定させてから他へ広げる★」(★A案で確定★)。★★帰結: 段階6 は「任意・回答待ち」ではなく 実施する 段階になりました★★(詳細は「A. 着手前確認(★★解決済み★★)」) | ||
| ★★改訂★★ | ★★2026-09-13 — docs/handoff/指示書AG_店舗検索を記号ゆれに強くする.md の段階5 により改訂★★。★★施設名の絞り込みを「LIKE を SQL に降ろす」から「★★アプリ側で施設IDに解決してから facility_id: { in: [...] } で降ろす★★」に変えました★★。★改訂箇所: ★★C-1(新しい小節「施設名だけは照合そのものをSQLに降ろせない」)/ 段階2 の D-1・D-4・D-5・D-6・期待値表 / 段階5 / §E-5 / 申し送り③・⑩・⑫★★★。★★段階3 / 段階4 / 段階6 は影響を受けません★★ |
||
| ★★前提となる別の指示書★★ | ★★docs/handoff/指示書AF_連続日数の表示上限を設定できるようにする.md★★(★別担当が★★完了済み。D-130★★)が、★★上限値そのもの(lib/config/ の既定値)とシステム管理画面からの編集★★ を作りました。★★本書はその値を読むだけ★★。★★実行順は AF → AC を推奨★★。★ただし ★★段階0〜段階5 は AF を待たずに着手できます★★★ — ★★AF の完了を待つのは段階6 だけ★★ |
||
| 取得する決定番号 | ★★実測して D-131 を採番しました★★(★着手直前に `grep -nE "^### D-(0\ |
1)[0-9][0-9]" docs/decisions.md \ | tail -5を実行し、最終番号がD-130(docs/decisions.md:8943)であることを確認★)。★★^### D-0だけでは3桁台(D-100 以降)を拾えない★★(★2026-09-07 にD-068` の二重採番が実際に起きています★) |
| 段階数 | ★★8段階(段階0〜段階7)/ コミットは7つ★★(★段階0 はコミットなし★。★★段階6 は A-1 の回答により「実施する」に確定したので、コミットが1つ増えました★★) | ||
| 作成 | 2026-09-13 / HEAD = ★041a809★ / ブランチ = develop / 作成時点の決定事項最終番号 = ★D-120(docs/decisions.md:7762)★ / docs/handoff.md の最新 = ★追記40(docs/handoff.md:3363)★ |
実行後はこの表と、文末の「実行状態」表を更新してください。
★この指示書を読んでいるあなたへ★ —
.claude/commands/d-watch.mdの「実装は Cursor 側」は 2つの環境の役割分担です。Cursor の中で動いている Claude は実装担当です。 ★調査や改訂ではなく実装を行ってください。★ ★★途中で止めてよい指示書です★★ — ★各段階の終わりに「★ここで止めてもよい★」と書いてあります★。 ★★段階2 まで終われば、いちばん重い画面(F-02 清掃詳細ログ / F-01 ダッシュボード)は救われます★★。
★★本書の数字を鵜呑みにしないこと★★。着手直前に自分で測り、作業メモに控えてから始めてください。
npm test # 作成時点の実測: 48ファイル / 1137テスト(全パス) npx tsc --noEmit # 作成時点の実測: 0 npm run lint # 作成時点の実測: 0
★データ件数★(2026-09-13 の再シード後に本書が実測した値):
| 対象 | 件数 |
|---|---|
db.cleanLogs() |
★538件★ |
db.areaCompletions() |
538件 |
db.prechecks() |
★58件★ |
db.facilityPerformance() |
41件 |
db.continuousProblems() |
58件 |
db.errorBreakdown() |
24件 |
db.reportKpis() |
4件 |
db.facilities() / db.schedules() / db.dsDevices() / db.dsAlerts() |
12 / 21 / 11 / 5件 |
★テーブルの行数★(同上):
| テーブル | 行数 |
|---|---|
clean_timetables |
★1,044★(うち本清掃 precheck = 0 が ★538★) |
clean_status_histories |
538 |
robot_error_reports |
49 |
facilities |
12 / clean_schedules 21 / controls 11 / control_error_histories 5 |
customer_summaries 2 / facility_summaries 24 / facility_error_summaries 18 |
★★本書に書いた行番号は作成時点のものです★★。必ず自分で測り直してください。
grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | grep -E "D-(092|102|109|118|120)"
作成時点の実測: ★D-092 = :3599 / D-102 = :5139 / D-109 = :6328 / D-118 = :7485 / D-120 = :7762★
CLAUDE.md 全文★(特に ★§2 責任分界★ / ★§3 の MariaDB 注記★ / ★§7 禁止事項★ / ★§9.3 コーディング規約★)docs/decisions.md の ★D-092(性能の既知課題)★ / D-102(サマリは再計算しない)/ ★D-109(連続日数を施設単位に揃えた)★ / D-118 / ★D-120(ページ送り。申し送り①が本書)★lib/db/index.ts(★全体★。1,388行あります)/ lib/db/clean-log-mapping.ts / lib/db/report-mapping.ts / lib/clean-logs/query.ts / lib/clean-logs/areas.ts / lib/prechecks/query.ts / lib/reports/query.ts / app/api/**/route.ts / prisma/schema.prismalib/format/search-text.ts★★(★施設名の照合規則の「正」。★★段階2 の施設名→ID解決はこれを呼ぶこと★★) と ★docs/handoff/指示書AG_店舗検索を記号ゆれに強くする.md の段階5★(★本書を改訂した根拠★)docs/handoff/指示書AB_残りの実装を通しで行う.md★(段階の切り方・禁止事項の書き方)★★この節は経緯を残すために置いてあります★★。★★もう確認を取る必要はありません★★。
★レポート画面(F-04)の「連続問題施設」は
{item.consecutive_problem_days}日と 実際の日数を数字で出しています(components/features/reports/ReportsScreen.tsx:386)。★ ★★この数字に上限(たとえば「30日」で頭打ち。それ以上は30日以上と出す)を設けてよいですか?★★
★★上限は設定してください★★ ★★ただしシステム管理画面で変更できるようにしてください★★
★さらに続く確認で ★★A案で確定★★★ — ★原文★:
★★連続日数の上限だけ先に編集可能にする。作法を確定させてから他へ広げる★★
docs/handoff/指示書AF_連続日数の表示上限を設定できるようにする.md(別担当が★★完了済み。D-130★★)★★ が作りました。 ★★本書(AC)はその設定値を読むだけ★★| 影響先 | 変更前 | ★★変更後★★ |
|---|---|---|
| 段階6 | ★(旧)「任意・A-1 の回答待ち」★ | ★★実施する★★(★条件分岐は不要になりました★) |
| コミット数 | 6つ | ★★7つ★★ |
| 段階6 の上限値 | (未定) | ★★保存値を読む★★ — ★loadConsecutiveDaysMax()(lib/settings/display-limits.ts)を呼ぶ★。★★lib/config/ の既定値を AC から直接読まないこと★★(★画面で上げても効かなくなる。§段階6 参照★)。★★AC 側でハードコードしないこと★★(CLAUDE.md §7) |
| 実行順 | — | ★★AF → AC を推奨★★。★★段階0〜段階5 は AF を待たずに着手可。段階6 だけが AF の完了を待つ★★ |
★★なぜ上限が要るのか(背景。記録として残す)★★:
★★勝手に上限値を決めないこと★★ — ★★値は AF が設定値として定義します★★。 ★★本書は読むだけ★★(CLAUDE.md §7「閾値のハードコード禁止」)。
DataSource は引数を1つも取らない契約になっているlib/db/index.ts:115-150 の DataSource インターフェースは、★inspectionDetail(id) を除く 全メソッドが引数を取りません★。コメント自身がそう宣言しています(:104-114 / :119-123 / :133-141)。
★★帰結: 「全部返す」しかできません★★。
| API ルート | 読むもの | 絞り込み |
|---|---|---|
/clean-logs |
db.cleanLogs() ★全件★ |
filterCleanLogs()(lib/clean-logs/query.ts:106) |
/clean-logs/export |
db.cleanLogs() ★全件★ |
同上 |
★/clean-logs/{id}/areas★ |
★★db.cleanLogs() 全件 + db.areaCompletions() 全件★★ |
selectAreaRowsForLog() |
★/clean-logs/{id}/advice★ |
★★db.cleanLogs() 全件★★ |
配列から find() |
/dashboard |
★db.cleanLogs() 全件★ + schedules() + dsAlerts() |
logs.filter(... === date)(★1日ぶん★) |
/prechecks |
db.prechecks() ★全件★ |
filterPrechecksByDate() ほか |
/reports/kpi |
db.reportKpis() ★全件★ |
selectKpiRow() |
/reports/facility-performance |
db.facilityPerformance() ★全件★ |
selectFacilityPerformanceRows() ほか |
/reports/continuous-problems |
db.continuousProblems() ★全件★ |
selectContinuousProblemRows() |
/reports/error-breakdown |
db.errorBreakdown() ★全件★ |
selectErrorBreakdownRows() |
/facilities /schedules /ds-devices /ds-alerts |
各 ★全件★ | 各 filterXxx() |
★★Prisma の where が付いているのは「論理削除の除外(deleted_at: null)」だけです★★。 ★日付条件・施設条件は1つも SQL に降りていません★。
| メソッド | findMany() の内訳 |
★合計行数★ |
|---|---|---|
cleanLogs() |
clean_timetables 1,044 + clean_status_histories 538 + robot_error_reports 49 |
★1,631★ |
areaCompletions() |
1,044 + 538 | ★1,582★ |
prechecks() |
49 + 1,044 + 538 | ★1,631★ |
reportKpis() |
2 + 1,044 + 538 + 49 | ★1,633★ |
facilityPerformance() |
12 + 24 + 1,044 + 538 + 49 | ★1,667★ |
continuousProblems() |
12 + 1,044 + 538 | ★1,594★ |
errorBreakdown() |
18 + 49 + 1,044 | ★1,111★ |
★★いちばん重い2つ★★:
clean_timetables を ★★4回★★ 読んでいます★/clean-logs/{id}/areas)は1行ぶんの表示のために約3,200行を読みます★★ (cleanLogs() 1,631 + areaCompletions() 1,582)。★★{id} は1件しか使いません★★docs/spec.md:45: ★「データ量: 約360行/日、年間約13万行、保持2年」★。
clean_timetables は ★★約26万行★★★★lib/db/index.ts:232-234 のコメント自身が「年間13万行(docs/spec.md:45)の実データでは ここがボトルネックになりうる」と警告しています★(D-092 項3-3)。
★本書が prisma/schema.prisma で実測しました★(model CleanTimetable。:604-609):
@@index([start_at], map: "clean_timetables_start_at_index") ← ★日付で絞れる★ @@index([end_at], map: "clean_timetables_end_at_index") @@index([parent_id], map: "clean_timetables_parent_id_index") @@index([facility_id], map: "clean_timetables_facility_id_foreign") ← ★施設で絞れる★ @@index([section_id], map: "clean_timetables_section_id_foreign") @@index([clean_schedule_id], map: "clean_timetables_clean_schedule_id_foreign")
★★こちらが使っていなかっただけです★★。★★TeamWorks に何も依頼しなくても、SQL に日付条件を 降ろせば今すぐ効きます★★。
★★不足している索引★★(★本書の実測。★★申し送りに書くこと★★):
| テーブル | 列 | 状態 |
|---|---|---|
robot_error_reports |
★clean_timetable_id★ |
★★FK も KEY も無い★★(prisma/schema.prisma:320 / @@index は facility_id / robot_id / section_id の3つだけ) |
★clean_status_histories★ |
★clean_timetable_id★ |
★★FK も KEY も無い★★(:480 / @@index は facility_id / section_id の2つだけ)。★★本書が新たに見つけた分です★★ |
★★この2つは第9回で依頼する候補★★(★本作業では依頼しません。申し送りに書くだけ★)。 ★★無くても本作業は成立します★★ — ★clean_status_histories / robot_error_reports は facility_id の索引があるので、期間で絞った施設集合で引けます★(段階2 の「D. 降ろし方の作法」参照)。
| SQL に降ろせるか | 理由 | |
|---|---|---|
★日付範囲(start_at)★ |
★★降ろせる★★ | 索引あり(B-5) |
| ★施設ID★ | ★★降ろせる★★ | 索引あり |
論理削除(deleted_at IS NULL) |
★既に降りている★ | — |
並び順(start_at の降順) |
★★降ろせる★★ | 索引あり |
| ★施設名の部分一致★ | ★★施設IDに解決してから降ろす★★ | ★LIKE では降ろせない(下の「★施設名だけは照合をSQLに降ろせない★」)★。★代わりにアプリ側で施設マスタと照合し、当たった ★★施設IDの配列★★ を facility_id: { in: [...] } に渡す★。★施設マスタは12件と小さいので成立する★(指示書AG 段階5) |
| 機種 / シリアルの部分一致 | ★降ろせる(が本書ではやらない)★ | 結合先の列。★索引が効かない部分一致なので効果が薄い★。★★据え置き=日付で絞ったあとの配列にメモリで効かせる★★ |
| ★★判定(◎○△×)★★ | ★★降ろせない★★ | ★clean_ratio から judgeGrade() で計算している。DB に列が無い★ |
| ★★連続日数★★ | ★★降ろせない★★ | ★日をまたいで数える。DB に列が無い(D-026)★ |
| ★★エスカレーション(L1〜L3)★★ | ★★降ろせない★★ | ★連続日数から計算する★ |
★★帰結★★:
★★「日付で絞ってから取る」だけで効果の大半が出ます★★。 ★判定での絞り込み(
result=problem等)は取得後にメモリで残ります★。 ★★それでよい★★ — ★日付範囲はMAX_DATE_RANGE_DAYS = 31(lib/config/index.ts:196)で 上限が決まっているので、絞ったあとの行数は最大でも約11,000行(360行/日 × 31日)です★。
★指示書AG で、施設名の検索が ★★記号(中点・長音・空白・ハイフン類)を無視した部分一致★★ に 変わりました★(lib/format/search-text.ts の includesFoldName)。★ドンキホーテ と打って ドン・キホーテ戸塚 が引けます★。
★★この照合を MariaDB 側で再現する手段がありません★★:
CLAUDE.md §7 で禁止)★★★★★★「MySQL の ci(case-insensitive)照合順序なら中点も無視されるはず」は誤りです。実測済み★★★
'ドン・キホーテ戸塚' LIKE '%ドンキホーテ%'は ★utf8mb4_unicode_ci/utf8mb4_general_ci/utf8mb4_0900_ai_ciの3種すべてで 0★ (中点入りの'%ドン・キホーテ%'なら 1)。 ★★この行を消さないこと★★ — ★★後任が「照合順序でできるはずだ」と戻ってくるのを防ぐための実測記録です★★ (指示書AG 段階5 / 罠5)。
★★だから「名前で絞る」のではなく「IDで絞る」★★:
includesFoldName を使う。★★照合規則を SQL 側に写さない★★ = C-5 の「正は純粋関数1箇所」と同じ理由★)where: { facility_id: { in: [...] } } に渡す★★★★清掃ログ本体(本番想定で clean_timetables 約26万行)は SQL で絞ったまま★★なので、 ★本書の目的(読み込み行数を減らす)は損なわれません★。★★むしろ日付だけで絞るより縮みます★★。
★★施設マスタが小さいから成立する解き方です★★ — ★fixtures / seed / ローカルDB すべて ★★実測12件★★★。 ★件数が増えたときの注意は §E-5★。
★★機種・シリアルは据え置き★★(★メモリ絞り込みのまま★)。★施設マスタではなくロボット側の属性で、 かつ includesFoldPlain(大文字小文字のみ)なので、無理に降ろす利得がありません★。
★★Prisma の
containsを使う案に戻らないこと★★ — ★★containsは%と_をエスケープしません★★ (★実測:contains: "%"→ 施設12件が全部ヒット。JS の.includes("%")なら0件★)。 ★どうしても使う場面が出たら%_\を明示エスケープすること★。 ★★本書の案なら施設名を SQL に渡さないので、この問題自体が発生しません★★。
computeConsecutiveProblemDays()(lib/db/clean-log-mapping.ts:366)は ★★施設ごとに全期間を走査して各行の連続日数を埋めています★★。
★同関数の docstring 自身がこう書いています★(:362-365):
「★性能上の注意★: これは全期間走査であり、対象期間だけを読む形にはできない」
★★cleanLogs() が「期間で絞って読む」ようになると、期間の外にある前日のデータが消えて 連続が途切れます★★。
★具体例★:
9/8 × 9/9 × 9/10 × ← 実際は3日連続(L3) 画面の指定が「9/10 〜 9/10」だったら? → SQL が 9/10 の1行しか返さない → 連続日数 = 1 → ★★L1 に化ける★★ → ★エスカレーションが2段階下がる。画面もテストも赤くならない★
★★これは fail-silent です★★。★★必ず C-3 の解き方を守ってください★★。
★★採用: 「連続日数だけ専用のスリムなクエリに分ける」★★
ConsecutiveInput)を作るための ★★専用の読み取り関数を1本★★ 作る★clean_timetables は ★id / facility_id / start_at / precheck★ の4列だけ(select で絞る)clean_status_histories は ★clean_timetable_id / clean_ratio / clean_error / ended_at★ だけinclude(facility / section / robots / device)を一切しない★★cleanLogs() / facilityPerformance() / continuousProblems() の3箇所が、この 同じ1本の関数を呼ぶこと★★(★D-109 の再発防止。★★連続日数を2箇所で別々に計算しないこと★★)★★なぜこの案なのか★★:
| 案 | 採否 | 理由 |
|---|---|---|
| ★A案: 表示期間の前 N 日ぶんを余分に読む★ | ★★段階1〜5 では不採用。★★段階6 で採用★★★ | ★★画面に出る連続日数が N で頭打ちになる★★(ReportsScreen.tsx:386 が実数を表示している)。★仕様変更なので勝手にはやらない★ → ★★A-1 でユーザーが「上限は設定してください」と回答済み(2026-09-13)★★なので、★★段階6 で実施します★★。★N は AF の設定値から読む★ |
| ★★B案: 連続日数だけ専用のスリムなクエリにする★★ | ★★採用★★ | ★★表示される数字が1つも変わらない★★(無回帰)。★3画面が同じ1本を共有できる★。★段階1 のリスクを「切り出すこと」だけに限定できる★ |
| C案: 連続が途切れるまで遡って追加クエリを撃つ | 不採用 | ★正しいが、クエリ回数が読めない★。★段階を小さく保てない★ |
★★B案でも「全期間を読む」こと自体は残ります★★ — ★★段階5 まではそれでよい★★。 ★列を最小限にして include を外すだけで、引き上げるデータ量は大きく下がります★。 ★★完全に有界にするには A案が要ります★★ — ★★A-1 は解決済みなので、段階6 で A案を重ねます★★ (★★上限値は 指示書AF が作る設定値を読む★★)。
DataSource のメソッドに引数を足す★★。★inspectionDetail(id) という前例が既にあります★ (lib/db/index.ts:131)lib/types/index.ts に置き、YAML のクエリパラメータと名前を揃える★★ (CLAUDE.md §9.2「型は lib/types/ に集約し、YAML のスキーマ名と揃える」)USE_FIXTURES の true/false で件数が変わってはいけない★★args?)にせず必須にする★★★ — ★任意にすると「渡し忘れても動く(が全件読む)」 という状態が残り、直したつもりで直っていないことに誰も気づけません★。 ★全件が要る呼び出し側は、明示的に「範囲なし」を表す値を渡すこと★filterXxx() を外すか残すか — 本書の決定★★★★決定:
filterXxx()は残す。SQL に降ろした条件も API ルート側でそのまま絞り続ける★★
★★理由★★:
★★ただし「二重に絞っているから安全」ではありません★★ — ★★SQL が絞りすぎた場合は filterXxx() では取り戻せません★★(C-6)。 ★★だから各段階で「SQL 版 = 全件版 + filterXxx()」の等価テストを必ず書いてください★★。
★★clean_timetables.start_at は DATETIME で、中身は日本時間の壁時計値です★★ (TeamWorks 第8回 A2 / D-115 / lib/format/date.ts の fromDbJst() の docstring)。
一方 parseDate("2026-09-08")(lib/clean-logs/query.ts:26)が返すのは ★★真の瞬間★★ です。
★★この2つをそのまま比較してはいけません★★。
// ❌ 間違い — 9時間ずれる
where: { start_at: { gte: parseDate(from) } }
// ✅ 正しい — 「DBに書かれている壁時計値」に直してから渡す
where: { start_at: { gte: toDbJst(parseDate(from)) } }
★本書の実測★:
parseDate("2026-09-08") = 2026-09-07T15:00:00.000Z (真の瞬間)
toDbJst(parseDate("2026-09-08")) = 2026-09-08T00:00:00.000Z (DBの壁時計値として渡せる形)
★★toDbJst() は既にあります★★(lib/format/date.ts)。★★新しく書かないこと★★。 ★★ただしその docstring は「消費者は scripts/seed-local-db.ts のみ」と書いてあるので、 段階2 で更新してください★★。
★本書が実測しました★ — ローカルの clean_timetables 1,044行の start_at(壁時計)の分布:
0時:210 / 1時:516 / 2時:288 / 3時:30 ★15時以降の行数: 0件★
★★9時間ずらしても日付が変わらない時間帯(深夜0〜3時)にしかデータがありません★★。 ★★したがって toDbJst() を忘れても、ローカルでは件数が1件も変わりません★★ (★本書が実際に「補正なし 37件 / 補正あり 37件 / 現状 37件」と実測して確認しました★)。
★★対策(必ずやること)★★:
★★境界を作る処理を純粋関数に切り出し、★★合成データの単体テスト★★で固定すること★★。 ★★テストには「壁時計 20:00 の清掃」を必ず入れること★★(★補正を忘れると日付が1日ずれる行★)。 ★★ローカルDBとの突き合わせだけで済ませないこと★★ — ★通ってしまいます★。
★★一度に全メソッドを変えないこと★★。★★段階ごとに1コミット★★。 ★★各段階の終わりで npx tsc --noEmit / npm run lint / npm test の3つが通ること★★。
段階0 実測(コミットなし)
↓
段階1 ★連続日数を専用のスリムなクエリに切り出す(挙動不変のリファクタ)★ ← コミット1
↓
段階2 ★cleanLogs() に引数を入れて日付・施設をSQLに降ろす★(★作法の確定★) ← コミット2
↓
段階3 ★単一IDの取得経路を作る(/clean-logs/{id}/areas と /advice)★ ← コミット3
↓
段階4 ★prechecks() に日付引数を入れる★ ← コミット4
↓
段階5 ★レポート4メソッドに期間引数を入れる★ ← コミット5
↓
段階6 ★連続日数のクエリも有界にする(★★実施する★★。★指示書AF の完了後★)★ ← コミット6
↓
段階7 ★記録(docs/decisions.md / docs/handoff.md)★ ← コミット7
★★段階6 だけが外部依存を持ちます★★ — ★★docs/handoff/指示書AF_連続日数の表示上限を設定できるようにする.md (別担当が★★完了済み。D-130★★)が、上限の設定値とシステム管理画面からの編集を作りました★★。 ★★段階0〜段階5 は AF を待たずに着手できます★★。★実行順は ★★AF → AC★★ を推奨★。
★cleanLogs() を最初にやる理由★:
/dashboard /clean-logs /clean-logs/export /clean-logs/{id}/areas /clean-logs/{id}/advice の5つのルートが依存している★★★測り方★(★スクラッチのスクリプトで測ること。★★リポジトリにファイルを増やさない★★):
prisma.$on("query") のイベントか、各 findMany() の戻り値の .length を数えるprocess.exit(0) を書くこと★★ — ★Prisma の接続が残ってハングした事故があります★★★段階1 以降、各段階の終わりに同じ測り方でもう一度測り、下の表を作業メモに埋めること★★:
| 段階 | cleanLogs() が読む行数 |
prechecks() |
レポート4API 合計 | エリアモーダル |
|---|---|---|---|---|
| 段階0(現状) | ★1,631★ | ★1,631★ | ★約6,000★ | ★約3,200★ |
| 段階1 後 | ||||
| 段階2 後 | ||||
| 段階3 後 | ||||
| 段階4 後 | ||||
| 段階5 後 |
★★ローカルは538件なので速度差は出ません★★。★★測るのは「読み込む行数」です★★。 ★速度の測定は実データ接続後★(申し送り)。
★★挙動を1ミリも変えないリファクタです★★。★★ここで画面の値が変わったら失敗です★★。
★lib/db/consecutive-source.ts(新規。名前は任意)に、ConsecutiveInput[] を作る ★★読み取り関数を1本★★ 置く★
/** 連続日数の算出に必要な行だけを、★列を絞って★全期間ぶん読む */ export async function loadConsecutiveInputs(prisma: PrismaClientLike): Promise<ConsecutiveInput[]>
select で列を絞ること★★(include を一切しない):
clean_timetables: id / facility_id / start_at / precheckclean_status_histories: clean_timetable_id / clean_ratio / clean_error / ended_atprecheck === CLEAN_TIMETABLE_PRECHECK.main で本清掃だけに絞るisFutureSchedule(fromDbJst(row.start_at), now) で未来を外す★★ (★★外すと未来7日ぶんが全部「出発失敗」になり全施設が常時L3になります★★。D-106 / D-107 / D-110)now は1回だけ作って全行で使い回す★completionRateFromHistory() / latestHistoryByTimetableId() を使う(★別の書き方をしない★)★★cleanLogs() / facilityPerformance() / continuousProblems() の3箇所が、 この1本を呼ぶように置き換える★★
facilityPerformance()(lib/db/index.ts:1047-1057)と continuousProblems()(:1257-1269)は ★★ほぼ同じコードを2回書いています★★★ — それを1本にするcleanLogs() は自分が作った drafts から ConsecutiveInput を作っています(:372)★ — ★★段階2 で drafts が期間で絞られるので、ここも loadConsecutiveInputs() に切り替えること★★★★computeConsecutiveProblemDays() の docstring を更新する★★ — ★「対象期間だけを読む形にはできない」という記述は、★★切り出したあとの正しい説明に直すこと★★★
★★段階1 は「値が変わらないこと」を固定します★★。
| # | 入力 | 期待 |
|---|---|---|
| 1 | ローカルDB 全件 | ★loadConsecutiveInputs() の件数 = 段階0 の cleanLogs() の件数(★538★)★ |
| 2 | ローカルDB 全件 | ★computeConsecutiveProblemDays() の結果 Map が、★★段階0 で取った 538件ぶんのスナップショットと完全一致★★★ |
| 3 | ローカルDB 全件 | ★facilityPerformance() の consecutive_problem_days が段階0 と全件一致★ |
| 4 | ローカルDB 全件 | ★continuousProblems() の全行が段階0 と一致★ |
| 5 | 合成データ | ★同一施設・2台・同じ日にAが問題/翌日Bが問題 → ★★2日連続★★★(D-109 の A案。既存テストがあれば流用) |
| 6 | 合成データ | ★清掃予定のない日を挟んでも連続が途切れない★(D-026) |
★★#2〜#4 のスナップショットは段階0 で取っておくこと★★(★段階1 に入ってから取ると意味がありません★)。
★★それでよい★★ — ★減るのは「引き上げるデータ量」(include が消えるぶん)です★。 ★行数が減り始めるのは段階2 からです★。
★★ここで止めてもよい★★ — ★重複コードが1本になり、段階2 の準備ができた状態です★。
cleanLogs() に引数を入れて日付・施設を SQL に降ろす★(コミット2)★★本書の中心です。ここで作法を確定させ、段階4・5 はそれを真似します★★。
★lib/types/index.ts に置く★(★CLAUDE.md §9.2★)。★YAML のクエリパラメータ名と揃えること★:
/** `DataSource.cleanLogs()` に渡す取得条件。★YAML /clean-logs のクエリパラメータに対応★ */
export interface CleanLogDbQuery {
/** JST の YYYY-MM-DD。★下限なしは null★ */
date_from: string | null;
/** JST の YYYY-MM-DD。★上限なしは null★(★その日を**含む**★) */
date_to: string | null;
/**
* ★★施設名の条件を「施設IDの配列」に解決したもの★★(C-1「施設名だけは照合そのものを
* SQLに降ろせない」/ D-6 の解決ステップ)。
*
* ★★`null` = 施設名の条件が無い(=施設で絞らない。全施設が対象)★★
* ★★`[]` = 施設名で照合したが**1件も当たらなかった**(=結果は0件)★★
*
* ★★この2つを混同しないこと★★ — ★混同すると「0件ヒット」が「全件表示」に化けます★(D-4 / 罠1)。
* ★★`facility_id`(単数・`number | null`)にしないこと★★ — ★施設名は部分一致なので
* 複数の施設に当たりえます(`ドンキ` → `ドン・キホーテ戸塚` と `ドン・キホーテ四街道`)★。
*/
facility_ids: number[] | null;
}
string の YYYY-MM-DD で受けること★★(Date で受けない)。 ★★「真の瞬間」と「壁時計値」の取り違え(C-6)を、型の入口で起こさせないため★★page / per_page は入れない★★(D-3 参照)result(判定フィルタ)は入れない★★(C-1。SQL に降ろせない)facility_name(生の文字列)は入れない★★ — ★★型の入口で「解決済みのID配列」しか 受け取らない形にすること★★。★文字列を持たせると、照合規則が lib/format/search-text.ts と lib/db/ の2箇所に散ります★(C-5 と同じ理由)★lib/db/date-range.ts(新規。名前は任意)に置く★:
/**
* JST の YYYY-MM-DD の範囲を、★Prisma の where に渡せる「DBの壁時計値」★ に変換する。
* ★`to` はその日を含む(=翌日 00:00 未満)★
* ★★必ず `toDbJst()` を通すこと。理由は指示書AC C-6★★
*/
export function dbDateRange(
from: string | null,
to: string | null
): { gte?: Date; lt?: Date }
★★この関数に単体テストを書くこと★★。★★「壁時計 20:00 の清掃」を含めること★★(C-6)。
take / skip)は降ろさない★★★★決定:
cleanLogs()ではtake/skipを SQL に降ろさない★★
★★理由★★:
result=problem / result=critical(判定フィルタ)は ★★SQL で絞れません★★(C-1)meta.total が「判定フィルタ後の総件数」と食い違います★★★ (★1ページ目に10件しか出ないのに total が200、のような壊れ方をする★)MAX_DATE_RANGE_DAYS = 31 で上限が決まっているので、★★絞ったあとは最大でも 約11,000行★★。★メモリ上のページングで足ります★★★これは「あとでやる」ではなく「P1ではやらない」という決定です★★。 ★★申し送りに書いてください★★。
async cleanLogs(query: CleanLogDbQuery) {
const range = dbDateRange(query.date_from, query.date_to);
const timetables = await prisma.cleanTimetable.findMany({
where: {
// ★本清掃だけを引く(メモリ側の filter をやめて SQL に降ろす)★
precheck: CLEAN_TIMETABLE_PRECHECK.main,
...(range.gte || range.lt ? { start_at: range } : {}),
// ★★必ず `!== null` で分岐すること★★(罠1。下の警告を読むこと)
...(query.facility_ids !== null
? { facility_id: { in: query.facility_ids } }
: {}),
},
include: { facility: true, section: { include: { robots: { ... } } } },
// ★並び順も SQL に降ろす(start_at に索引がある)★
orderBy: [{ start_at: "desc" }, { id: "desc" }],
});
...
}
★★★
?.lengthや三項の「空配列なら undefined」で分岐しないこと★★★ — ★★in: undefinedは 条件が消えて全件を返します★★。★本書の実測:in: undefined→ 生成SQL がWHERE 1=1→ 施設12件ぶん全部。対してin: []→WHERE 1=0→ 0件(★こちらが正しい★)★。// ❌ 間違い — 「0件ヒット」が「全件表示」に化ける。★例外もテスト失敗も出ない★ ...(query.facility_ids?.length ? { facility_id: { in: query.facility_ids } } : {}) // ✅ 正しい — `[]` はそのまま `in: []` として渡し、0件を返させる ...(query.facility_ids !== null ? { facility_id: { in: query.facility_ids } } : {})★★期待値表に「
[]→ 0件」を必ず入れること★★(#6b)。★これが唯一の検出手段です★。
★★事前チェック行(precheck !== 0)はどうするか★★:
cleanLogs() は status_1h / status_30m を作るために、★★本清掃の子の事前チェック行★★ が要ります★ (lib/db/index.ts:283-291)parent_id に索引があります★★(B-5)ので、 ★★別クエリで where: { parent_id: { in: 引いた本清掃の id 配列 } } として引くこと★★precheck 混在のまま引いて、あとでメモリで分けるやり方に戻さないこと★★ (★それだと本清掃の絞り込みが効かない★)★★clean_status_histories / robot_error_reports の引き方★★:
where: { clean_timetable_id: { in: [...] } } で引くこと★★in の要素数に上限を設けること★★ — ★日付範囲が31日でも約11,000件になりうる★。 ★★in が巨大になるとクエリ自体が重くなるので、facility_id + 日付範囲での絞り込みを 併用するか、in を分割すること★★。★どちらにしたかをコメントに書くこと★clean_timetable_id の索引がありません★★(B-5)。 ★★facility_id の索引はあるので、facility_id の絞り込みも一緒に付けると効きます★★async cleanLogs(query: CleanLogDbQuery) {
return filterFixtureCleanLogs(cleanLogs, query); // ★同じ条件で絞って返す★
}
USE_FIXTURES=true と false で件数が変わってはいけません★★lib/fixtures/clean-logs.ts の各行)は変更しないこと★★facility_ids の null と [] を、Prisma 版と同じ意味で扱うこと★★(罠9):
null → 施設で絞らない(全件)★[] → 0件を返す★★(★★[] を「条件なし」と読んで全件返す実装にしないこと★★ — ★USE_FIXTURES の true/false で件数が変わり、F 禁止事項6 に違反します★)[] → 0件」のテストを書くこと★// ★施設名の照合はアプリ側。当たった施設IDだけを DB クエリに渡す★
const facility_ids =
query.facilityName === "" ? null : await resolveFacilityIdsByName(query.facilityName);
const logs = await db.cleanLogs({ date_from, date_to, facility_ids });
resolveFacilityIdsByName() 等。置き場所と名前は実装時に決めてよい)は 本書の段階2 で作ること★★。★照合には ★★lib/format/search-text.ts の includesFoldName★★ を 使い、★★照合規則を書き下さないこと★★(指示書AG D-1 / D-5)★null を返すこと★★。★★[] を返さないこと★★ — ★[] は「0件ヒット」の意味です(D-1 の型コメント / 罠1)★deleted_at で絞らないこと★★★(★★罠7★★)— ★db.facilities()(prismaSource.facilities())は where: { deleted_at: null } を付けています★。 ★一方いまの cleanLogs() の include: { facility: true } には ★★deleted_at の条件がありません★★★。 ★★db.facilities() をそのまま流用すると、論理削除された施設のログが検索から静かに消えます★★ (★★件数が減るだけでエラーもテスト失敗も出ません★★)。 ★★解決用の読み取りを別に用意し、「いまの挙動を変えないために deleted_at で絞らない」理由を コメントに残すこと★★filterCleanLogs() は残す★★(C-5)searchParams の値を where に直接入れないこと★★ — ★必ず parseCleanLogQuery() を通す★(§E)/dashboard は date 1日ぶんを渡す★(date_from = date_to = date)→ ★★26万行が360行になります★★/clean-logs/export も同じ引数を渡す★/clean-logs/{id}/areas と /{id}/advice は段階3 で扱う★ — ★★段階2 では「範囲なし」(全部 null)を渡して現状維持にしておくこと★★| # | 入力 | 期待 |
|---|---|---|
| 1 | {date_from: null, date_to: null, facility_ids: null} |
★段階1 と同じ ★★538件★★★ |
| 2 | {date_from: "2026-09-08", date_to: "2026-09-10"} |
★★SQL版の結果 = 全件版 + filterCleanLogs() の結果(完全一致)★★。★本書の実測では ★★37件★★★ |
| 3 | date_from = date_to =(データのある1日) |
★その日の行だけ★ |
| 4 | date_from がデータの最古日より前 |
★件数が #1 と同じ★ |
| 5 | date_from > date_to |
★★parseCleanLogQuery() が422 で弾く(db まで来ない)★★ |
| 6 | ★facility_ids: [A, B](2施設)★ |
★★その2施設だけ。件数が全件版 + メモリ絞り込みと一致★★(★1施設だけのケースも併せて書くこと★) |
| ★6b★ | ★★facility_ids: []★★ |
★★0件★★(★★in: undefined に化けていないことの検出。罠1。★これを書かないと「0件ヒット」が「全件表示」になっても誰も気づけません★★★) |
| ★6c★ | ★facility_ids: null★ |
★★全件(#1 と同じ 538件)★★ |
| ★6d★ | ★★施設名を記号違いで指定★★(例: 合成データの ドソ・キホーテ戸塚 に対して ドソキホーテ) |
★★解決関数がその施設のIDを返し、そのログがヒットする★★(★指示書AG の本命。★★API ルートから db までを通した経路でテストすること★★ — ★解決関数の単体テストだけだと、ルートが facility_ids を渡し忘れていても赤くなりません★) |
| ★6e★ | ★★論理削除された施設(deleted_at あり)の名前で検索★★ |
★★段階0 と同じ結果★★(★★そのログが消えないこと★★。罠7。★解決用の読み取りを deleted_at で絞ると静かに消えます★) |
| 7 | ★★合成データ: 壁時計 20:00 の清掃★★ | ★★dbDateRange() がその日を含む境界を作る★★(★C-6。toDbJst() 忘れの検出★) |
| 8 | ★★期間で絞った結果の consecutive_problem_days★★ |
★★段階0 のスナップショットと完全一致★★(★★C-2 の地雷。ここが本段階の最重要テスト★★) |
| 9 | USE_FIXTURES=true / false |
★同じ引数で ★★同じ件数★★★(fixtures の範囲内で比較できるケースを1つ作る)。★★null と [] の両方で比較すること★★(罠9) |
★★#8 を必ず書いてください★★ — ★連続日数が壊れたことを検出する唯一のテストです★。 ★★#6b も必ず書いてください★★ — ★in: undefined による「0件が全件に化ける」を検出する唯一のテストです★。
★★ここで止めてもよい★★ — ★★F-01 / F-02 と CSV 出力が救われた状態です★★。
★★1行ぶんの表示のために約3,200行を読むのをやめます★★(B-3)。
CleanLogDbQuery に id: number | null を足す★(★または cleanLog(id) を別メソッドにする。 ★★どちらにしたかをコメントに書くこと★★)/clean-logs/{id}/advice★ — ★db.cleanLogs({ id }) で1件だけ引く★
loadConsecutiveInputs() 経由なので正しいまま★★(段階1 の効果)/clean-logs/{id}/areas★ — ★2段構えにする★
db.cleanLogs({ id }) で ★★facility_id と JST 日付★★ を得る★db.areaCompletions({ facility_id, date }) で ★★その施設のその日だけ★★ を引く★selectAreaRowsForLog()(lib/clean-logs/areas.ts:55)は ★★残す★★★(C-5)areaCompletions() の引数型を lib/types/ に置く★(AreaCompletionDbQuery 等)DataSource のコメント(lib/db/index.ts:119-123)を更新すること★★ — ★「引数を取らない契約に従う」という記述が嘘になります★| # | 入力 | 期待 |
|---|---|---|
| 1 | 存在する {id} |
★★段階0 と同じレスポンス(items / overall_completion_rate / total_count / problem_count が全一致)★★ |
| 2 | 存在しない {id} |
★段階0 と同じ 404(★★挙動を変えないこと★★) |
| 3 | ★同じ施設・同じ日に複数エリア★ | ★★全エリアが返る★★(★{id} はエリアではなく「施設と日付の入口」。指示書Z 決定1★) |
| 4 | filter=problem |
★total_count / overall_* は絞る前の値のまま★(指示書Z 決定8 / 決定12) |
| 5 | 読み込み行数 | ★★約3,200行 → 数十行★★ / ★★【実測と食い違い】完了後の実測は 3,213 → 1,082〜1,084行 で「数十行」になっていません★★(★本体は数行まで減りましたが、★★連続日数用のクエリが date_from=null で全期間を読むため約1,080行残ります★★。★単一ID の経路は対象ログの日付が引くまで分からないため★。D-131 の7節 / 申し送り⑬) |
★★ここで止めてもよい★★ — ★★いちばん比率の悪かった無駄が消えた状態です★★。
prechecks() に日付引数を入れる★(コミット4)★段階2 の作法をそのまま真似します★。★新しい書き方を発明しないこと★。
PrecheckDbQuery(date_from / date_to / facility_id)を lib/types/ に置く★prechecks() は ★★2種類の行★★ を返します(lib/db/index.ts:473-501)— ★★両方を同じ期間で絞ること★★:
severity: "precheck" … ★エラー行★。★期間の基準は ★★親の本清掃行の start_at★★★ (★errored_at ではありません★ — 現状 scheduled_at に親の start_at を入れているため。 :552-561)severity: "launch_failed" … ★本清掃行★。★期間の基準は start_at★isFutureSchedule() を外さないこと★★(:607)notExecutedFromHistory() を使い続けること★★ — ★★2項の AND / OR を呼び出し側に書かないこと★★ (D-103 項1 / D-107)filterPrechecksByDate() / filterPrechecksByStatus() / sortPrechecks() は ★★残す★★★(C-5)| # | 入力 | 期待 |
|---|---|---|
| 1 | 範囲なし | ★★58件★★(段階0 と同じ) |
| 2 | 日付範囲あり | ★SQL版 = 全件版 + filterPrechecksByDate()(完全一致)★ |
| 3 | ★severity の内訳★ |
★★precheck と launch_failed の件数が段階0 の内訳と一致★★(★片方だけ絞れている、という壊れ方を検出する★) |
| 4 | ★親の本清掃行が期間外・エラー行が期間内★ | ★★親の start_at を基準に判定される★★(★scheduled_at と食い違わない★) |
| 5 | USE_FIXTURES true/false |
★同じ件数★ |
★★ここで止めてもよい★★。
★★レポート画面の4API 合計 約6,000行 を狙います★★。
ReportDbQuery(period / year / month / week)を lib/types/ に置く★ — ★★ReportQuery(lib/reports/query.ts:56)と名前を揃えるか、そこから作ること★★reportKpis / facilityPerformance / continuousProblems / errorBreakdown)に渡す★customer_summaries / facility_summaries / facility_error_summaries を ★★where: { year, month, week } で引く★★★shiftPeriod())— ★★2行ぶんだけ引くこと。全件読みに戻さないこと★★week が 0 かどうかで判別する★★(CLAUDE.md §4.7。 ★年次 month=0 AND week=0 は P1 では読まない。D-072項4★)CLAUDE.md 禁止事項):873-879 ほか)periodRange() で出した範囲を where に降ろすこと★★(★★dbDateRange() を通す。C-6★★)continuousProblems() は期間キーをデータの日付から列挙しています★★(:1272)— ★★引数で期間が決まるなら、その1期間だけを組み立てること★★loadConsecutiveInputs() のまま★★(★★期間で絞らないこと★★。C-2)selectXxxRows() / filterXxx() / sortXxx() は ★★残す★★★(C-5)facilityPerformance() だけです★★ (★YAML の /reports/facility-performance に facility_name / serial がある。 reportKpis / continuousProblems / errorBreakdown には施設名フィルタがありません★)— ★★段階2 と同じく facility_ids: number[] | null で受け、resolveFacilityIdsByName() で 解決してから渡すこと★★(★★段階2 で確定した作法をそのまま真似する。新しい書き方を発明しない★★)。 ★serial(シリアル)は据え置き=メモリ絞り込みのまま★(C-1)★★指示書AG(施設名の記号ゆれ)が影響するのは本書のうち★★: ★★段階2(
cleanLogs())★★ と ★★段階5 のfacilityPerformance()だけ★★です。 ★段階3 / 段階4 / 段階6 は影響を受けません★(★段階3 は単一ID、段階4 のprechecks()は 日付と施設IDだけ、段階6 は連続日数の遡り範囲の話★)。
★★週は月境界で分割されます★★(D-071 / CLAUDE.md §4.7)。 ★★週の長さは1〜7日で変わり、月の週番号は1〜6です★★。
periodRange()(lib/format/period.ts)に必ず任せること★★weekOfMonth() だけが帰属ルールを知っている★★(CLAUDE.md §4.7)| # | 入力 | 期待 |
|---|---|---|
| 1 | 確定期間(サマリに行がある週) | ★★段階0 と同じ KPI 値(分子・分母まで一致)★★ |
| 2 | ★当期(今週)★ | ★★段階0 と同じ値★★ |
| 3 | ★★月跨ぎ週(例: 2026年8月第6週 = 08-30〜08-31 の2日)★★ | ★★periodRange() の返す範囲どおりに絞られる★★ |
| 4 | ★月の第1週(必ず1日から)/最終週(必ず末日で終わる)★ | ★境界の行が落ちない★ |
| 5 | サマリに行が無い期間 | ★★段階0 と同じ「空のKPI」(404 にしない)★★(app/api/reports/kpi/route.ts:31-41) |
| 6 | ★★consecutive_problem_days★★ |
★★段階0 のスナップショットと完全一致★★(★★C-2★★) |
| 7 | period=weekly かつ week=0 |
★★422★★(WEEK_MIN = 1。week=0 は予約値) |
| 8 | ★エラー種別内訳の分子★ | ★★本清掃行を指す robot_error_reports だけ★★(D-118 / D-114 項2。★事前チェック行を数えない★) |
| 9 | 読み込み行数 | ★4API 合計が段階0 の 約6,000行 から大きく減る★ |
★★ここで止めてもよい★★ — ★★監視系の主要4画面がすべて期間で絞れた状態です★★。
★★A-1 は解決済みです★★(★ユーザー回答 2026-09-13「★上限は設定してください★ ★ただしシステム管理画面で変更できるようにしてください★」)。★★「任意」でも「回答待ち」でもありません★★。
★★★ただし着手のタイミングだけは
指示書AFに依存します★★★ — ★★docs/handoff/指示書AF_連続日数の表示上限を設定できるようにする.md(別担当が★★完了済み。D-130★★)★★ が ★★上限値(lib/config/の既定値)とシステム管理画面からの編集★★ を作りました。 ★★AF は完了したので、本段階に着手できます★★(★段階0〜段階5 は AF を待たずに進められます★)。
| 誰が | 何を作るか |
|---|---|
★★指示書AF(別担当。★★完了済み。D-130★★)★★ |
★★上限の設定値そのもの★★(lib/config/ の既定値)/ ★★システム管理画面からの編集・保存★★ / ★★保存値を読む関数★★(★loadConsecutiveDaysMax() — lib/settings/display-limits.ts:223★) |
| ★★本書(AC)の段階6★★ | ★★その値を読んで、連続日数のクエリの遡り範囲に使うだけ★★ |
★★★AC 側で上限値をハードコードしないこと★★★(CLAUDE.md §7「閾値のハードコード禁止」)。 ★★AC で新しい設定値を定義し直さないこと★★ — ★★AF が定義したものを読むこと★★ (★2箇所に定義が散ると、管理画面で変えても片方が古い値のまま残ります★)。
指示書AF が実装した★★保存値★★(システム管理画面で変更された値)を読む★★ — ★★loadConsecutiveDaysMax()(lib/settings/display-limits.ts:223)を呼ぶこと★★。 ★★lib/config/ の既定値(CONSECUTIVE_DAYS_DISPLAY.defaultMax)を AC から直接読まないこと★★ — ★運用担当が画面で上限を 90 に上げても、クエリが 30日ぶんしか読まず ★★上げた意味が無くなります★★。 ★例外もテスト失敗も出ません★(★指示書AF §C-3 申し送り1★)
loadConsecutiveDaysMax() の中にあります★★ — ★AC で条件分岐を書かない★ESCALATION_THRESHOLDS(level2Days: 2 / level3Days: 3)を上限に流用しないこと★★ — ★「連続」はカレンダー日ではなく ★★スケジュール存在日★★ の連続なので、 ★★level3Days をそのまま日数の上限にしてはいけません★★(清掃予定のない日を挟むため)★loadConsecutiveInputs() に { date_from, date_to } を渡し、 ★★date_from から ★★上限 +1日ぶん★★ を余分に遡って読む★★★
capConsecutiveDays() は ★★rawDays > maxDays★★ のときだけ capped: true にします★ (lib/format/consecutive-days.ts:36 — if (rawDays > maxDays) return { days: maxDays, capped: true };)。 ★★上限ぴったりまでしか読まないと rawDays が上限を超えられず、capped が永久に false になって 「30日以上」が二度と画面に出ません★★(★指示書AF §C-3 申し送り2★)。★★「ぶん」に戻さないこと★★/detail-log と /reports の両方に同じ上限が効くこと★★(★★連続日数の計算は段階1 で 1本に集約済み。そこに上限を入れれば両方に効きます★★。D-109 の再発防止)| # | 入力 | 期待 |
|---|---|---|
| 1 | 連続が上限未満 | ★★段階5 と同じ数字★★ |
| 2 | 連続が上限を超える | ★★上限で頭打ち。かつ「以上」と分かる表示★★ |
| 3 | ★清掃予定のない日を挟む★ | ★★連続が途切れない★★(D-026。★★カレンダー日で遡ると壊れるところ★★) |
| 4 | ★/detail-log と /reports★ |
★★同じ施設・同じ日で同じ数字★★(★★D-109 の再発防止★★) |
| 5 | ★★管理画面で上限を大きくした★★ | ★★クエリの遡り範囲も追随して広がる★★(★★設定だけ大きくしてクエリが古い範囲のままだと、表示が頭打ちのまま変わりません★★。★上限値を定数に写し取らず、毎回設定から読むこと★) |
| 6 | ★★管理画面で上限を小さくした★★ | ★★表示も遡り範囲も小さいほうに追随する★★ |
| 7 | ★★上限を超える連続日数のデータ★★ | ★★capped=true が実際に出ること★★(= ★★「上限 +1日ぶん」読めていることの検出テスト★★)。★★この行が無いと、+1 を忘れても期待値表は全部緑のままです★★ — ★rawDays が上限を超えられなくなるだけで、#1〜#6 はどれも落ちません★ |
docs/decisions.md に決定事項を1件追記する★★
grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3 で 最終番号を実測してから採番すること★★(★★^### D-0 だけでは3桁台を拾えない★★)filterXxx() を残す)★ / ★C-3 の決定(連続日数はB案)★ / ★D-3 の決定(take/skip は降ろさない)★ / ★C-6 の決定(toDbJst() 必須。 ローカルでは検出できないこと)★ / ★段階ごとの読み込み行数の実測表★ / ★申し送り★docs/handoff.md に追記する★(作成時点の最新は ★追記40(docs/handoff.md:3363)★)★ユーザー指示(2026-09-13)「SQLインジェクションなどのセキュリティ面も強化」★ ★★詳しいセキュリティの棚卸しは別の指示書(
指示書AE)が扱います★★。 ★★本書は「SQL を書くときの規律」だけに絞ります★★。
$queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe を使わないこと★★CLAUDE.md §3 の注記)。★★その状態を保つこと★★$queryRaw のタグ付きテンプレート($queryRaw`...${x}...` )だけを使うこと★★$queryRawUnsafe / $executeRawUnsafe と文字列連結は禁止★★CLAUDE.md §3 / D-100)。 ★★ローカル検証は MySQL 8.4 なので「ローカルで通っても本番で通らない」ことがあります★★docs/decisions.md に書くこと★★parseXxxQuery() を通す★★searchParams の値を where に直接入れないこと★★parseCleanLogQuery() / parsePrecheckQuery() / parseReportQuery() / parseFacilityQuery() / parseScheduleQuery() / parseDsAlertQuery() / parseDsDeviceQuery() が入口です★d-watch-api.yaml を直すこと★★(CLAUDE.md 作業ルール1)orderBy のカラム名をユーザー入力から組み立てない★★Record<許可された値, Prisma.SortOrder> のような形)searchParams.get("sort") をそのまま orderBy のキーにしないこと★★take / skip には必ず上限を設ける★★PAGINATION.maxPerPage(200。lib/config/index.ts:179)★★parseCleanLogQuery() は既に Math.min(maxPerPage, Math.max(1, toInt(...))) でクランプしています★ (lib/clean-logs/query.ts:84-87)— ★★この形を崩さないこと★★Number.parseInt("200abc") は 200 を返します★★ — ★厳密に弾きたいところは toIntStrict()(lib/reports/query.ts:88)の形を使うこと★take / skip を降ろしません(D-3)★★が、★段階3 の単一ID取得や、 将来降ろすときのための規律として守ること★in に入れる配列の長さに上限を設ける★★clean_timetable_id: { in: [...] } は、★★日付範囲次第で1万件規模になりえます★★★facility_id + 日付で絞り直すこと★★lib/config/ に置くこと★★(ハードコード禁止)facility_id: { in: [...] } の件数(施設名の解決結果)★★★★こちらは当面問題になりません★★ — ★施設マスタは ★★fixtures / seed / ローカルDB すべて実測12件★★★。 ★部分一致で全施設が当たっても12要素です★。★★clean_timetable_id の in とは桁が違うので、 同じ上限を当てはめて考えないこと★★。
★ただし将来の実測値として、本書が測った限界を2つ残します★:
| 項目 | ★実測値★ | 意味 |
|---|---|---|
★eq_range_index_dive_limit★ |
★★ローカル MySQL 8.4 = 200★★(★★MariaDB 10.11 の値は未確認★★) | ★in の要素数がこれを超えると、オプティマイザが索引ダイブをやめて統計値による推定に切り替えます★。★★実行計画が静かに劣化する(遅くなるだけでエラーは出ない)★★ |
★Prisma の maxBindValues★ |
★mysql で ★★65,535★★★ | ★超えると Prisma が自動でクエリを分割します(★本書の実測: 65,536要素 → 3クエリ★)★ |
★★つまり実害が出はじめるのは施設が200件を超えてからです★★(★現状12件★)。 ★★申し送りに「本番で SHOW VARIABLES LIKE 'eq_range_index_dive_limit' を実測する」と書くこと★★。
robot_error_reports / control_error_histories の status / comment / responded_at / resolved_at の4カラムだけ★★(D-042 / CLAUDE.md §3)— ★★本作業ではその4カラムにも書きません★★prisma/schema.prisma を手で書き換えないこと★★ (★TeamWorks の領域。CLAUDE.md §7★)git add -A 禁止★★(CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故あり★)— ★★必ずファイルパスを明示して git add すること★★CLAUDE.md §7 / §2)lib/fixtures/ の既存の値を変更しないこと★★ — ★引数を受け取るための関数追加は可★USE_FIXTURES の true/false で件数が変わってはいけない★★isFutureSchedule() を外さないこと★★(★★外すと未来7日ぶんが全部「出発失敗」になり 全施設が常時L3になります★★。D-106 / D-107 / D-110)notExecutedFromHistory() の2項(履歴なし OR clean_error === 1)を呼び出し側に 書き下さないこと★★(D-103 項1 / D-107)CLAUDE.md §4.3)CLAUDE.md §7)— ★段階6 の CONSECUTIVE_LOOKBACK_DAYS、 段階2 の in の上限も lib/config/ に置くこと★CLAUDE.md §4.8)d-watch-api.yaml を直すこと★★(CLAUDE.md 作業ルール1)— ★★本書は原則としてレスポンスを1バイトも変えません★★README.md★docs/code-map.md★docs/handoff/管理画面_設計検討_20260910.md★.claude/worktrees/**★★docs/handoff/ 配下の他の指示書★★(★別のエージェントが 指示書AE を並行作成中★)process.exit(0) を書くこと★★ (★Prisma の接続が残ってハングした事故あり★)clean_timetables は 1,044行)★★★ — ★本番想定より2桁少ない★cleanLogs() は現状でも 102ms で返ります★★(本書の実測)。★★段階2 のあとも「速くなった」ようには 見えません★★ — ★★それで正常です★★findMany() が返す行数を記録すること★★★各段階で「SQL で絞った結果」と「全件読み + filterXxx()」が ★★完全一致★★ すること★。 ★★件数だけでなく、id の集合と、consecutive_problem_days の値まで比べること★★。
| # | 申し送り | 状態 |
|---|---|---|
| ★①★ | ★★robot_error_reports.clean_timetable_id の索引が無い★★(FK も KEY も無い。prisma/schema.prisma:320) |
★★第9回で TeamWorks に依頼する候補★★。★本作業では依頼しない★。★無くても成立する(facility_id の索引で代替できる)が、付けば in での引き直しが効くようになる★ |
| ★②★ | ★★clean_status_histories.clean_timetable_id の索引も無い★★(:480。★★本書が新たに見つけた分★★) |
★同上。①と一緒に依頼すること★ |
| ★③★ | ★★clean_timetables に (facility_id, start_at) の複合索引が無い★★(★本書の実測。prisma/schema.prisma の @@index は ★★6本すべて単一列★★ = start_at / end_at / parent_id / facility_id / section_id / clean_schedule_id★) |
★★第9回で TeamWorks に依頼する候補★★(★①②と一緒に依頼すること★)。★本作業では依頼しない★。★★無くても成立します★★ — ★単一列の索引だけでも日付と施設で絞れます★。★ただし段階2 以降の主クエリは ★★「施設ID群 + 日付範囲」の AND★★ になるので、複合索引があればそこが1本の索引レンジで引けるようになります★(★施設名検索が施設IDに解決される形になったため、この組み合わせが主経路になりました。指示書AG 段階5★) |
| ④ | ★★速度の測定は実データ接続後★★ | ★ローカル538件では差が出ない(G-1)★。★ステージング接続時に、段階0 と段階5 の行数表を使って効果を確認すること★ |
| ⑤ | ★★ページ番号を URL に持たせる件★★(★D-120 申し送り①の後半★) | ★★本書とは別課題★★。★本書は「サーバが何行読むか」を変える作業で、URL の持ち方は画面側の構造(useSearchParams の Suspense 境界。6画面)の話★。★★同時にやらないこと★★。★ただし URL に page を持たせるなら、そのときに D-3(take/skip を降ろさない)の決定を見直す価値がある★ |
| ⑥ | ★★take / skip は P1 では SQL に降ろさない(D-3)★★ |
★判定フィルタが SQL で絞れないため、meta.total が食い違う★。★日付範囲が31日上限なので実害が無いという判断★。★★施設数が増えて1日の行数が跳ねたら見直すこと★★ |
| ⑦ | ★★連続日数のクエリは段階5 までは全期間のまま★★ | ★★【解消済み】段階6(149447f)で有界にしました★★(★D-131 の5節★)。★以下は着手時点の記述★: ★★A-1 は解決済み(2026-09-13)なので、段階6 で有界にします★★。★★段階5 で止めた場合はここが未完のまま残る★★ — ★列を絞ってあるので致命的ではないが、保持2年ぶんを毎回読む★。★★再開時は 指示書AF の完了を先に確認すること★★ |
| ⑧ | ★★ローカルDBの start_at は深夜0〜3時しかない★★(15時以降が0件) |
★★toDbJst() の付け忘れをローカルでは検出できない(C-6)★★。★★scripts/seed-local-db.ts に「壁時計20:00の清掃」を1件足すと検出できるようになる★★(★本作業では触っていない。足すなら既存の期待値テストへの影響を先に確認すること★) |
| ⑨ | ★レポート画面の「全{n}施設を表示 →」が200件で切れる潜在バグ★(SHOW_ALL_LIMIT) |
★★D-120 申し送り② のまま未解決★★。★本書では直していない★ |
| ⑩ | ★★施設名は「施設IDに解決してから」SQL に降ろす。機種 / シリアルは据え置き★★ | ★★【対応済み】段階2(5077918)で実装しました★★(★D-131 の1節。★★Prisma の contains に戻らないこと★★ — contains は % と _ をエスケープしません★)。★以下は着手時点の記述★: ★★施設名の照合は記号(中点等)を無視するので LIKE では書けません★★(★★照合順序でも無理。3種すべてで実測0★★。C-1 / 指示書AG)。★アプリ側で施設マスタと照合し、当たったIDを facility_id: { in: [...] } に渡します★。★施設マスタが12件と小さいから成立する解き方★。★★機種 / シリアルは索引が効かず利得が薄いので、日付で絞ったあとのメモリ絞り込みのまま★★ |
| ★★⑪★★ | ★★★連続日数の上限が変更されたとき、連続日数のクエリの範囲も追随すること★★★ | ★★【対応済み】段階6(149447f)で対応しました★★ — ★上限値を別の定数に写し取らず、毎回 loadConsecutiveDaysMax() から導いています(D-131 の5節)★。★以下は着手時点の記述★: ★★システム管理画面(指示書AF)で上限を変更できます★★。★★設定だけ大きくして、段階6 で入れたクエリの遡り範囲が古いままだと、表示が上限に届かず頭打ちになります★★(★★設定を変えたのに画面が変わらない、という静かな不整合★★)。★★対策: 上限値を別の定数に写し取らず、クエリの範囲を毎回設定値から導くこと★★。★★上限を変えたら /detail-log と /reports の両方で数字が追随することをテストで固定すること★★(段階6 の期待値表 #5 / #6)★ |
| ★⑫★ | ★★施設が200件を超えると facility_id: { in: [...] } の実行計画が静かに劣化する★★ |
★★遅くなるだけでエラーは出ません★★。★ローカル MySQL 8.4 で eq_range_index_dive_limit = 200 を実測(★★MariaDB 10.11 の値は未確認★★)。要素数がこれを超えると索引ダイブをやめて統計値による推定に切り替わります★。★★現状は施設12件なので当面問題になりません★★。★★本番接続時に SHOW VARIABLES LIKE 'eq_range_index_dive_limit' を実測すること★★(§E-5)。★参考: Prisma の maxBindValues は mysql で 65,535。超えると自動分割する(実測: 65,536要素 → 3クエリ)★ |
| ★★⑬(査読で追加。2026-09-13)★★ | ★★段階6 の期待値表 #7 は歯止めになっていない★★ — ★実際の歯止めは lib/db/__tests__/consecutive-source.test.ts:782-793 の不変条件テスト★ |
★期待値表 #7 は「この行が無いと +1 を忘れても全部緑」と書いていますが、★★実装が安全係数(CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay = 7)を掛けているため、plusDays を 0 にしても #7 は緑のまま通ります★★(★遡り幅が足りてしまうため★)。★★落ちるのは consecutiveLookbackCalendarDays(maxDays) >= maxDays + 1 かつ plusDays >= 1 を固定している不変条件テストのほうです★★。★★このテストを消さないこと★★(★消すと plusDays = 0 が無検出で通り、「{上限}日以上」が二度と画面に出なくなります★)。D-131 の申し送り⑮ |
| 段階 | 内容 | 状態 | コミット |
|---|---|---|---|
| 段階0 | 実測 | ★★完了★★ | (なし) |
| 段階1 | 連続日数をスリムなクエリに切り出す | ★★完了★★ | 4d26d25 |
| 段階2 | cleanLogs() に引数を入れる |
★★完了★★ | 5077918 |
| 段階3 | 単一IDの取得経路 | ★★完了★★ | adcb06e |
| 段階4 | prechecks() に日付引数 |
★★完了★★ | b2d0f8c |
| 段階5 | レポート4メソッドに期間引数 | ★★完了★★ | d87a604 |
| 段階6 | 連続日数を有界にする(★★実施する★★) | ★★完了★★(★指示書AF(D-130)の loadConsecutiveDaysMax() を読んでいます★) |
149447f |
| 段階7 | 記録 | ★★完了★★(★D-131 / docs/handoff.md 追記45 / 本表★) |
dcc11a7 |
★★A-1 は解決済みです★★(★ユーザー回答 2026-09-13★)。★★段階6 を「任意」に戻さないこと★★。
★★完了時の実測★★(2026-09-13): npx tsc --noEmit = ★0★ / npm run lint = ★0★ / npm test = ★★66ファイル・1,570テスト 全パス★★(★段階6 で +12テスト★)。 ★★読み込み行数の実測(前 5031eca → 後 dcc11a7。★測り方と条件つき★)と申し送りの最終状態は docs/decisions.md の D-131(7節 / 8節)★★。★★段階ごとの表は「測り方が残っておらず再現できない」ため 撤回され、条件つきの前後比較に作り直されています★★。
★★段階6 で決めたこと(後任が最初に読む3行)★★:
loadConsecutiveDaysMax()(保存値)から毎回読む★★(★lib/config/ の既定値を直接読まない★)capConsecutiveDays() は「超えた」ときだけ capped: true にするため★)CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay)★。 ★★スケジュール存在日ベースで SQL から絞る案は採っていません★★(施設ごとのスケジュール本数を 数えるクエリが要り、段階を小さく保てないため)。★この前提が崩れると連続日数が実際より短く出ます★