Newer
Older
client_dwatch / docs / handoff / 指示書AC_絞り込みをSQLに降ろす.md

指示書AC — 絞り込みをSQLに降ろす(全件読み込みをやめる)

項目 内容
実行環境 ★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 ダッシュボード)は救われます★★。


0. ★★着手前に必ずやること★★

0-1. 基準値を自分で測り直す

★★本書の数字を鵜呑みにしないこと★★。着手直前に自分で測り、作業メモに控えてから始めてください。

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

0-2. 決定事項の行番号を実測する

★★本書に書いた行番号は作成時点のものです★★。必ず自分で測り直してください。

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★

0-3. 必ず読むもの

  • ★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.prisma
  • ★★lib/format/search-text.ts★★(★施設名の照合規則の「正」。★★段階2 の施設名→ID解決はこれを呼ぶこと★★) と ★docs/handoff/指示書AG_店舗検索を記号ゆれに強くする.md の段階5★(★本書を改訂した根拠★)
  • ★直前の同シリーズ docs/handoff/指示書AB_残りの実装を通しで行う.md★(段階の切り方・禁止事項の書き方)

A. ★★着手前確認(★★★解決済み — 残っている確認はありません★★★)★★

★★この節は経緯を残すために置いてあります★★。★★もう確認を取る必要はありません★★。

A-1. ★★連続日数の表示を「上限つき」にしてよいか★★ → ★★★解決済み(2026-09-13)★★★

★聞いたこと★

★レポート画面(F-04)の「連続問題施設」は {item.consecutive_problem_days}日 と 実際の日数を数字で出しています(components/features/reports/ReportsScreen.tsx:386)。★ ★★この数字に上限(たとえば「30日」で頭打ち。それ以上は 30日以上 と出す)を設けてよいですか?★★

★★ユーザー回答(2026-09-13)★★ — ★原文★

★★上限は設定してください★★ ★★ただしシステム管理画面で変更できるようにしてください★★

★さらに続く確認で ★★A案で確定★★★ — ★原文★:

★★連続日数の上限だけ先に編集可能にする。作法を確定させてから他へ広げる★★

★★何が決まったか★★

  1. ★★上限を設ける★★(★「30日以上」のように頭打ちにしてよい★)
  2. ★★上限値は固定値ではなく、システム管理画面から変更できる設定値にする★★
  3. ★★今回編集可能にするのは「連続日数の上限」だけ★★ — ★★他の閾値(90/70/10、猶予30分、 L2=2日 / L3=3日 等)へは広げない★★。★まずここで作法を確定させる★
  4. ★★上限値と管理画面の編集機能は本書では作りません★★ — ★★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 の完了を待つ★★

★★なぜ上限が要るのか(背景。記録として残す)★★:

  • ★連続日数は「その施設の全期間を遡って数える」ので、★★期間で絞り込むと計算できなくなります★★★ (詳細は「C-2 最大の地雷」)
  • ★本書の段階1〜5 は ★★上限を設けない解き方(B案)★★ を採ります★ — ★表示される数字が1つも変わらないため★
  • ★ただしその解き方は「連続日数のためのスリムなクエリ」だけは全期間を読み続けます★。 ★★実データ(年13万行・保持2年)で、そのスリムなクエリだけが残りのボトルネックになります★★
  • ★★上限が決まったので、段階6 でそのクエリも期間で絞れます★★(★完全に有界になる★)

★★勝手に上限値を決めないこと★★ — ★★値は AF が設定値として定義します★★。 ★★本書は読むだけ★★(CLAUDE.md §7「閾値のハードコード禁止」)。


B. ★★いま何が起きているか(本書が実測した事実)★★

B-1. DataSource は引数を1つも取らない契約になっている

lib/db/index.ts:115-150 の DataSource インターフェースは、★inspectionDetail(id) を除く 全メソッドが引数を取りません★。コメント自身がそう宣言しています(:104-114 / :119-123 / :133-141)。

★★帰結: 「全部返す」しかできません★★。

B-2. 絞り込みは全部メモリ上にある

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 に降りていません★。

B-3. 1リクエストで DB から引き上げている行数(★ローカル538件時点の実測★)

メソッド 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つ★★:

  • ★★レポート画面(F-04)は4つのAPIを同時に叩きます★★ → ★1,633 + 1,667 + 1,594 + 1,111 = 約6,000行★。 ★同じ clean_timetables を ★★4回★★ 読んでいます★
  • ★★エリアモーダル(/clean-logs/{id}/areas)は1行ぶんの表示のために約3,200行を読みます★★ (cleanLogs() 1,631 + areaCompletions() 1,582)。★★{id} は1件しか使いません★★

B-4. 本番のデータ量

docs/spec.md:45: ★「データ量: 約360行/日、年間約13万行、保持2年」★。

  • ★保持2年なら clean_timetables は ★★約26万行★★★
  • ★★清掃詳細ログを1回開くだけで26万行をアプリに引き上げます★★
  • ★★レポート画面を1回開くと、それが4回重なります★★
  • ★ローカルは今 ★★1,044行★★ — ★本番より2桁少ない★。★★ローカルでは速度差が出ません★★

★lib/db/index.ts:232-234 のコメント自身が「年間13万行(docs/spec.md:45)の実データでは ここがボトルネックになりうる」と警告しています★(D-092 項3-3)。

B-5. ★★索引は最初から張られている(追加依頼は要りません)★★

★本書が 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. 降ろし方の作法」参照)。


C. ★★設計の要点(実装前に必ず理解すること)★★

C-1. ★何が降ろせて、何が降ろせないか★

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日)です★。

★★施設名だけは「照合そのもの」をSQLに降ろせない★★(★指示書AG の帰結。2026-09-13★)

★指示書AG で、施設名の検索が ★★記号(中点・長音・空白・ハイフン類)を無視した部分一致★★ に 変わりました★(lib/format/search-text.ts の includesFoldName)。★ドンキホーテ と打って ドン・キホーテ戸塚 が引けます★。

★★この照合を MariaDB 側で再現する手段がありません★★:

  • ★中点を無視して比較する関数が MariaDB に無い★
  • ★用意するには生成カラム+索引=★★スキーマ変更(CLAUDE.md §7 で禁止)★★★
  • ★生SQL も禁止★(§E-1。★D-122 の ESLint ルールが落とします★)

★★★「MySQL の ci(case-insensitive)照合順序なら中点も無視されるはず」は誤りです。実測済み★★★ 'ドン・キホーテ戸塚' LIKE '%ドンキホーテ%' は ★utf8mb4_unicode_ci / utf8mb4_general_ci / utf8mb4_0900_ai_ci の3種すべてで 0★ (中点入りの '%ドン・キホーテ%' なら 1)。 ★★この行を消さないこと★★ — ★★後任が「照合順序でできるはずだ」と戻ってくるのを防ぐための実測記録です★★ (指示書AG 段階5 / 罠5)。

★★だから「名前で絞る」のではなく「IDで絞る」★★:

  1. ★★施設名の照合はアプリ側で施設マスタに対して行う★★(★includesFoldName を使う。★★照合規則を SQL 側に写さない★★ = C-5 の「正は純粋関数1箇所」と同じ理由★)
  2. ★★当たった施設IDの配列を where: { facility_id: { in: [...] } } に渡す★★

★★清掃ログ本体(本番想定で clean_timetables 約26万行)は SQL で絞ったまま★★なので、 ★本書の目的(読み込み行数を減らす)は損なわれません★。★★むしろ日付だけで絞るより縮みます★★。

★★施設マスタが小さいから成立する解き方です★★ — ★fixtures / seed / ローカルDB すべて ★★実測12件★★★。 ★件数が増えたときの注意は §E-5★。

★★機種・シリアルは据え置き★★(★メモリ絞り込みのまま★)。★施設マスタではなくロボット側の属性で、 かつ includesFoldPlain(大文字小文字のみ)なので、無理に降ろす利得がありません★。

★★Prisma の contains を使う案に戻らないこと★★ — ★★contains は % と _ をエスケープしません★★ (★実測: contains: "%" → 施設12件が全部ヒット。JS の .includes("%") なら0件★)。 ★どうしても使う場面が出たら % _ \ を明示エスケープすること★。 ★★本書の案なら施設名を SQL に渡さないので、この問題自体が発生しません★★。

C-2. ★★最大の地雷 — 連続日数が壊れる★★

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 の解き方を守ってください★★。

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 が作る設定値を読む★★)。

C-4. ★契約の変え方★

  1. ★★DataSource のメソッドに引数を足す★★。★inspectionDetail(id) という前例が既にあります★ (lib/db/index.ts:131)
  2. ★★引数の型は lib/types/index.ts に置き、YAML のクエリパラメータと名前を揃える★★ (CLAUDE.md §9.2「型は lib/types/ に集約し、YAML のスキーマ名と揃える」)
  3. ★★fixtures 版も同じ引数を受け取り、同じ結果を返すこと★★ — ★★USE_FIXTURES の true/false で件数が変わってはいけない★★
  4. ★引数は ★★任意(args?)にせず必須にする★★★ — ★任意にすると「渡し忘れても動く(が全件読む)」 という状態が残り、直したつもりで直っていないことに誰も気づけません★。 ★全件が要る呼び出し側は、明示的に「範囲なし」を表す値を渡すこと★

C-5. ★★filterXxx() を外すか残すか — 本書の決定★★

★★決定: filterXxx() は残す。SQL に降ろした条件も API ルート側でそのまま絞り続ける★★

★★理由★★:

  1. ★★絞り込み規則の「正」を純粋関数1箇所に保つため★★(D-103 / D-109 が禁じた「規則が2箇所に散る」 状態を作らない)。★SQL 側は ★★その写し★★ であり、正ではありません★
  2. ★★fixtures 版が引数の扱いを間違えても、画面の見え方が変わらない保険になるため★★
  3. ★コストは「既に1ページぶんに絞られた配列をもう一度走査する」だけ★

★★ただし「二重に絞っているから安全」ではありません★★ — ★★SQL が絞りすぎた場合は filterXxx() では取り戻せません★★(C-6)。 ★★だから各段階で「SQL 版 = 全件版 + filterXxx()」の等価テストを必ず書いてください★★。

C-6. ★★★日付境界の補正 — ここを間違えると静かに行が消えます★★★

★★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 で更新してください★★。

★★★ローカルDBではこの間違いを検出できません(重大)★★★

★本書が実測しました★ — ローカルの 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との突き合わせだけで済ませないこと★★ — ★通ってしまいます★。


D. ★★段階(★1メソッドずつ。各段階でテストが通ること★)★★

★★一度に全メソッドを変えないこと★★。★★段階ごとに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() を最初にやる理由★:

  • ★★いちばん行数が多い★★(1,631行 / 1リクエスト)
  • ★★連続日数の問題がここにある★★(C-2)
  • ★★/dashboard /clean-logs /clean-logs/export /clean-logs/{id}/areas /clean-logs/{id}/advice の5つのルートが依存している★★
  • ★★ここで確定した作法を、段階4・5 がそのまま真似する★★

段階0 — 実測(★コミットなし★)

  1. 「0-1 基準値」を自分で測る
  2. 「0-2 決定事項の行番号」を実測する
  3. ★★各メソッドが1リクエストで何行読んでいるかを測る★★ — ★これが改善の証拠になります★

★測り方★(★スクラッチのスクリプトで測ること。★★リポジトリにファイルを増やさない★★):

  • prisma.$on("query") のイベントか、各 findMany() の戻り値の .length を数える
  • ★★スクリプトには成功時にも必ず process.exit(0) を書くこと★★ — ★Prisma の接続が残ってハングした事故があります★
  • ★スクリプトは ★★DB を SELECT するだけ★★。INSERT / UPDATE / DELETE を書かないこと★

★★段階1 以降、各段階の終わりに同じ測り方でもう一度測り、下の表を作業メモに埋めること★★:

段階 cleanLogs() が読む行数 prechecks() レポート4API 合計 エリアモーダル
段階0(現状) ★1,631★ ★1,631★ ★約6,000★ ★約3,200★
段階1 後
段階2 後
段階3 後
段階4 後
段階5 後

★★ローカルは538件なので速度差は出ません★★。★★測るのは「読み込む行数」です★★。 ★速度の測定は実データ接続後★(申し送り)。


段階1 — ★連続日数を専用のスリムなクエリに切り出す★(コミット1)

★★挙動を1ミリも変えないリファクタです★★。★★ここで画面の値が変わったら失敗です★★。

やること

  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 / precheck
      • clean_status_histories: clean_timetable_id / clean_ratio / clean_error / ended_at
    • ★★中でやることは現状と1文字同じにすること★★:
      • precheck === CLEAN_TIMETABLE_PRECHECK.main で本清掃だけに絞る
      • ★★isFutureSchedule(fromDbJst(row.start_at), now) で未来を外す★★ (★★外すと未来7日ぶんが全部「出発失敗」になり全施設が常時L3になります★★。D-106 / D-107 / D-110)
      • ★now は1回だけ作って全行で使い回す★
      • completionRateFromHistory() / latestHistoryByTimetableId() を使う(★別の書き方をしない★)
  2. ★★cleanLogs() / facilityPerformance() / continuousProblems() の3箇所が、 この1本を呼ぶように置き換える★★

    • ★現状 facilityPerformance()(lib/db/index.ts:1047-1057)と continuousProblems()(:1257-1269)は ★★ほぼ同じコードを2回書いています★★★ — それを1本にする
    • ★cleanLogs() は自分が作った drafts から ConsecutiveInput を作っています(:372)★ — ★★段階2 で drafts が期間で絞られるので、ここも loadConsecutiveInputs() に切り替えること★★
  3. ★★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 の準備ができた状態です★。


段階2 — ★cleanLogs() に引数を入れて日付・施設を SQL に降ろす★(コミット2)

★★本書の中心です。ここで作法を確定させ、段階4・5 はそれを真似します★★。

D-1. 引数の型

★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 と同じ理由)

D-2. 境界を作る純粋関数(★C-6 の対策★)

★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)。

D-3. ★★ページング(take / skip)は降ろさない★★

★★決定: cleanLogs() では take / skip を SQL に降ろさない★★

★★理由★★:

  • ★result=problem / result=critical(判定フィルタ)は ★★SQL で絞れません★★(C-1)
  • ★SQL でページングすると、★★meta.total が「判定フィルタ後の総件数」と食い違います★★★ (★1ページ目に10件しか出ないのに total が200、のような壊れ方をする★)
  • ★日付範囲は MAX_DATE_RANGE_DAYS = 31 で上限が決まっているので、★★絞ったあとは最大でも 約11,000行★★。★メモリ上のページングで足ります★

★★これは「あとでやる」ではなく「P1ではやらない」という決定です★★。 ★★申し送りに書いてください★★。

D-4. Prisma 側の書き方

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 配列 } } として引くこと★★
  • ★★1回のクエリで precheck 混在のまま引いて、あとでメモリで分けるやり方に戻さないこと★★ (★それだと本清掃の絞り込みが効かない★)

★★clean_status_histories / robot_error_reports の引き方★★:

  • ★★where: { clean_timetable_id: { in: [...] } } で引くこと★★
  • ★★in の要素数に上限を設けること★★ — ★日付範囲が31日でも約11,000件になりうる★。 ★★in が巨大になるとクエリ自体が重くなるので、facility_id + 日付範囲での絞り込みを 併用するか、in を分割すること★★。★どちらにしたかをコメントに書くこと★
  • ★★この2テーブルには clean_timetable_id の索引がありません★★(B-5)。 ★★facility_id の索引はあるので、facility_id の絞り込みも一緒に付けると効きます★★

D-5. fixtures 側

async cleanLogs(query: CleanLogDbQuery) {
  return filterFixtureCleanLogs(cleanLogs, query);  // ★同じ条件で絞って返す★
}
  • ★★USE_FIXTURES=true と false で件数が変わってはいけません★★
  • ★★fixtures の既存の値(lib/fixtures/clean-logs.ts の各行)は変更しないこと★★
  • ★★facility_ids の null と [] を、Prisma 版と同じ意味で扱うこと★★(罠9):
    • ★null → 施設で絞らない(全件)★
    • ★★[] → 0件を返す★★(★★[] を「条件なし」と読んで全件返す実装にしないこと★★ — ★USE_FIXTURES の true/false で件数が変わり、F 禁止事項6 に違反します★)
    • ★fixtures 版にも「[] → 0件」のテストを書くこと★

D-6. API ルート側

  • ★★施設名 → 施設IDの解決を1ステップ挟む★★(★C-1「施設名だけは照合そのものをSQLに降ろせない」★):
    // ★施設名の照合はアプリ側。当たった施設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 で絞らない」理由を コメントに残すこと★★
    • ★★解決は1クエリで済ませること★★(★施設名ごとにループでクエリを撃たない★)。 ★施設マスタは12件なので、全件引いてメモリで照合してよい★
  • ★★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 出力が救われた状態です★★。


段階3 — ★単一IDの取得経路を作る★(コミット3)

★★1行ぶんの表示のために約3,200行を読むのをやめます★★(B-3)。

やること

  1. ★CleanLogDbQuery に id: number | null を足す★(★または cleanLog(id) を別メソッドにする。 ★★どちらにしたかをコメントに書くこと★★)
  2. ★/clean-logs/{id}/advice★ — ★db.cleanLogs({ id }) で1件だけ引く★
    • ★★連続日数は loadConsecutiveInputs() 経由なので正しいまま★★(段階1 の効果)
  3. ★/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)
  4. ★areaCompletions() の引数型を lib/types/ に置く★(AreaCompletionDbQuery 等)
  5. ★★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節 / 申し送り⑬)

★★ここで止めてもよい★★ — ★★いちばん比率の悪かった無駄が消えた状態です★★。


段階4 — ★prechecks() に日付引数を入れる★(コミット4)

★段階2 の作法をそのまま真似します★。★新しい書き方を発明しないこと★。

やること

  • ★PrecheckDbQuery(date_from / date_to / facility_id)を lib/types/ に置く★
  • ★prechecks() は ★★2種類の行★★ を返します(lib/db/index.ts:473-501)— ★★両方を同じ期間で絞ること★★:
    1. severity: "precheck" … ★エラー行★。★期間の基準は ★★親の本清掃行の start_at★★★ (★errored_at ではありません★ — 現状 scheduled_at に親の start_at を入れているため。 :552-561)
    2. 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 ★同じ件数★

★★ここで止めてもよい★★。


段階5 — ★レポート4メソッドに期間引数を入れる★(コミット5)

★★レポート画面の4API 合計 約6,000行 を狙います★★。

やること

  • ★ReportDbQuery(period / year / month / week)を lib/types/ に置く★ — ★★ReportQuery(lib/reports/query.ts:56)と名前を揃えるか、そこから作ること★★
  • ★4メソッド(reportKpis / facilityPerformance / continuousProblems / errorBreakdown)に渡す★
  • ★★確定期間(サマリテーブル由来)★★:
    • ★customer_summaries / facility_summaries / facility_error_summaries を ★★where: { year, month, week } で引く★★★
    • ★★「前期比」のために1つ前の期間の行も要ります★★(shiftPeriod())— ★★2行ぶんだけ引くこと。全件読みに戻さないこと★★
    • ★★サマリの粒度は week が 0 かどうかで判別する★★(CLAUDE.md §4.7。 ★年次 month=0 AND week=0 は P1 では読まない。D-072項4★)
    • ★★確定期間の値を履歴から再計算しないこと★★(D-102 / CLAUDE.md 禁止事項)
  • ★★当期(進行中の週・月)★★:
    • ★現状は詳細テーブルを全件読んでその場集計しています★(:873-879 ほか)
    • ★★periodRange() で出した範囲を where に降ろすこと★★(★★dbDateRange() を通す。C-6★★)
  • ★★continuousProblems() は期間キーをデータの日付から列挙しています★★(:1272)— ★★引数で期間が決まるなら、その1期間だけを組み立てること★★
  • ★★連続日数は段階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画面がすべて期間で絞れた状態です★★。


段階6 — ★連続日数のクエリも有界にする★(コミット6)★★【実施する】★★

★★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日ぶん★★ を余分に遡って読む★★★
    • ★★なぜ「ぶん」ではなく「+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★)。★★「ぶん」に戻さないこと★★
    • ★★遡る範囲は「カレンダー日」ではなく「スケジュール存在日」で数える必要があります★★(D-026)— ★★カレンダー日で遡ると、清掃予定のない日を挟んだ連続を取りこぼします★★。 ★安全側に倒すなら、カレンダー日の遡り幅を上限より広めに取ること。★★どちらにしたかをコメントに書くこと★★★
  • ★★上限に達した連続日数の表示を決めること★★ — ★★数字を切り詰めるだけにしないこと★★。 ★「30日以上」のように「これ以上は数えていない」と分かる表示にすること★
  • ★★/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 はどれも落ちません★

段階7 — ★記録★(コミット7)

  1. ★★docs/decisions.md に決定事項を1件追記する★★
    • ★★追記直前に grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3 で 最終番号を実測してから採番すること★★(★★^### D-0 だけでは3桁台を拾えない★★)
    • ★書くこと★: ★C-5 の決定(filterXxx() を残す)★ / ★C-3 の決定(連続日数はB案)★ / ★D-3 の決定(take/skip は降ろさない)★ / ★C-6 の決定(toDbJst() 必須。 ローカルでは検出できないこと)★ / ★段階ごとの読み込み行数の実測表★ / ★申し送り★
  2. ★docs/handoff.md に追記する★(作成時点の最新は ★追記40(docs/handoff.md:3363)★)
  3. ★本書の冒頭の表と文末の「実行状態」表を更新する★

E. ★★セキュリティ(SQLを書くときの規律)★★

★ユーザー指示(2026-09-13)「SQLインジェクションなどのセキュリティ面も強化」★ ★★詳しいセキュリティの棚卸しは別の指示書(指示書AE)が扱います★★。 ★★本書は「SQL を書くときの規律」だけに絞ります★★。

E-1. ★★生SQLを使わない★★

  • ★★$queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe を使わないこと★★
  • ★★現状アプリコードに生SQLは1件もありません★★(CLAUDE.md §3 の注記)。★★その状態を保つこと★★
  • ★★Prisma のクエリビルダだけで書くこと★★
  • ★どうしても生SQLが要る場合★:
    • ★★$queryRaw のタグ付きテンプレート($queryRaw`...${x}...` )だけを使うこと★★
    • ★★$queryRawUnsafe / $executeRawUnsafe と文字列連結は禁止★★
    • ★★本番は MariaDB 10.11 なので、その箇所を TeamWorks に知らせること★★(CLAUDE.md §3 / D-100)。 ★★ローカル検証は MySQL 8.4 なので「ローカルで通っても本番で通らない」ことがあります★★
    • ★★使うなら、その理由と申告の有無を docs/decisions.md に書くこと★★

E-2. ★★入力は必ず parseXxxQuery() を通す★★

  • ★★生の searchParams の値を where に直接入れないこと★★
  • ★parseCleanLogQuery() / parsePrecheckQuery() / parseReportQuery() / parseFacilityQuery() / parseScheduleQuery() / parseDsAlertQuery() / parseDsDeviceQuery() が入口です★
  • ★★新しいパラメータを足すなら、先に d-watch-api.yaml を直すこと★★(CLAUDE.md 作業ルール1)

E-3. ★★orderBy のカラム名をユーザー入力から組み立てない★★

  • ★★許可リストで受けること★★(Record<許可された値, Prisma.SortOrder> のような形)
  • ★★searchParams.get("sort") をそのまま orderBy のキーにしないこと★★
  • ★現状ソートUIはどの画面にもありません★(D-120 申し送り⑥)。★★足すときにこの規律を守ること★★

E-4. ★★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取得や、 将来降ろすときのための規律として守ること★

E-5. ★★in に入れる配列の長さに上限を設ける★★

  • ★段階2 の 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' を実測する」と書くこと★★。

E-6. ★★書き込みは一切しない★★

  • ★★本作業は読み取りだけです★★
  • ★★メインDBへの書き込みが許されるのは 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★)

F. ★★禁止事項★★

  1. ★★git add -A 禁止★★(CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故あり★)— ★★必ずファイルパスを明示して git add すること★★
  2. ★★メインDBのスキーマ変更・マイグレーション作成 禁止★★(CLAUDE.md §7 / §2)
  3. ★★書き込み許可4カラム以外への DB 書き込み 禁止★★(D-042)。★★本作業では4カラムにも書かない★★
  4. ★★生SQL 禁止★★(§E-1)
  5. ★★lib/fixtures/ の既存の値を変更しないこと★★ — ★引数を受け取るための関数追加は可★
  6. ★★USE_FIXTURES の true/false で件数が変わってはいけない★★
  7. ★★連続日数を2箇所で別々に計算しないこと★★(★★D-109 の再発防止。C-3★★)
  8. ★★isFutureSchedule() を外さないこと★★(★★外すと未来7日ぶんが全部「出発失敗」になり 全施設が常時L3になります★★。D-106 / D-107 / D-110)
  9. ★★notExecutedFromHistory() の2項(履歴なし OR clean_error === 1)を呼び出し側に 書き下さないこと★★(D-103 項1 / D-107)
  10. ★★確定期間(サマリテーブル由来)の値を履歴から再計算しないこと★★(D-102)
  11. ★★表示用に丸めた値で判定しないこと★★(CLAUDE.md §4.3)
  12. ★★閾値のハードコード禁止★★(CLAUDE.md §7)— ★段階6 の CONSECUTIVE_LOOKBACK_DAYS、 段階2 の in の上限も lib/config/ に置くこと★
  13. ★★エラー種別を7分類に変換しないこと★★(D-077 / CLAUDE.md §4.8)
  14. ★★API 契約を変えるなら先に d-watch-api.yaml を直すこと★★(CLAUDE.md 作業ルール1)— ★★本書は原則としてレスポンスを1バイトも変えません★★
  15. ★★次のファイルに触らないこと★★:
    • ★README.md★
    • ★docs/code-map.md★
    • ★docs/handoff/管理画面_設計検討_20260910.md★
    • ★★.claude/worktrees/**★★
    • ★★docs/handoff/ 配下の他の指示書★★(★別のエージェントが 指示書AE を並行作成中★)
  16. ★★スクラッチのスクリプトをリポジトリに置かないこと★★。 ★★スクリプトには成功時にも必ず process.exit(0) を書くこと★★ (★Prisma の接続が残ってハングした事故あり★)

G. ★★測定について★★

G-1. ★ローカルでは速度差が出ません★

  • ★ローカルは ★★538件(clean_timetables は 1,044行)★★★ — ★本番想定より2桁少ない★
  • ★★cleanLogs() は現状でも 102ms で返ります★★(本書の実測)。★★段階2 のあとも「速くなった」ようには 見えません★★ — ★★それで正常です★★
  • ★★速度の測定は実データ接続後★★(申し送り)

G-2. ★★「読み込む行数」は今すぐ測れます★★

  • ★★段階ごとに、1リクエストで findMany() が返す行数を記録すること★★
  • ★★これが改善の証拠になります★★
  • ★段階0 の表(D の段階0)を埋めること★

G-3. ★★等価テストを必ず書くこと★★

★各段階で「SQL で絞った結果」と「全件読み + filterXxx()」が ★★完全一致★★ すること★。 ★★件数だけでなく、id の集合と、consecutive_problem_days の値まで比べること★★。


H. ★★申し送り★★

# 申し送り 状態
★①★ ★★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 の申し送り⑮

I. ★★実行状態★★

段階 内容 状態 コミット
段階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行)★★:

  1. ★★上限は loadConsecutiveDaysMax()(保存値)から毎回読む★★(★lib/config/ の既定値を直接読まない★)
  2. ★★「上限 +1日ぶん」余分に読む★★(★capConsecutiveDays() は「超えた」ときだけ capped: true にするため★)
  3. ★★遡り幅はカレンダー日で、安全側に広く取る★★ — ★1スケジュール存在日 = 最大7カレンダー日(CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay)★。 ★★スケジュール存在日ベースで SQL から絞る案は採っていません★★(施設ごとのスケジュール本数を 数えるクエリが要り、段階を小さく保てないため)。★この前提が崩れると連続日数が実際より短く出ます★