Newer
Older
client_dwatch / docs / handoff / 指示書BI_予定なしの行にもアドバイスを出す_20261104.md

指示書BI — 予定なしの行にもアドバイスを出す(2026-11-04)

担当: Cursor。監督(Claude Code)がレビューする。 前提: develop の 4bb639e 以降(指示書BH=並べ替え・予定あり/予定なしの絞り込み・文言「予定なし」が入った版)。 ★作業の前に必ず読むこと★: app/api/clean-logs/[id]/advice/route.ts(今のアドバイス API)、lib/db/unscheduled-clean-log-source.ts・lib/db/clean-log-mapping.ts(予定なしの行の作り方)、lib/api/path-params.ts の parsePathErrorId()(文字列 ID の検証の手本)。 ★行番号はすべて 4bb639e 時点の実物で確かめた値★(監督側の書き直し役が開いて確認。誤記5件・logApiError の参照0件は監督が数え直した)(作業中にずれたら、書いてある文言で探す)。


0. ユーザーの指示(原文)と回答

  • 「清掃詳細ログにあった、アドバイスを出す仕組みが消えています」
    • 監督の調査: 消えていない。予定のある △× の行は今も ▼ で開く。★予定なしの行(予定外タスク。CleanLogItem.unscheduled = true)は ▼ を出さない★(指示書BG §2-1 の決定)。 ステージングの △× 335件のうち 295件が予定なしで、既定の画面の1ページ目では 50行中5行しか ▼ が出ないため、消えたように見えた
  • 「予定外のものはアドバイスを出せないですか」
    • 監督の問い「予定なしの行にもアドバイスの ▼ を出しますか」への回答: ★「出す」★。実装は ★Cursor★

1. 何を変えるか(全体像)

項目 今 ★変更後★
予定なしの △× の行の ▼ 出さない(開けない) ★出す(開ける)★
開いたときのアドバイス — ★予定ありの行と同じ形(CleanLogAdviceResponse)★。新しい API から取る
開いたときの「エリア別詳細を見る →」 — ★予定なしの行では出さない★(エリア別詳細は予定の id が要る。今の方針のまま)
予定なしの ◎○ の行 開けない 開けない(予定ありと同じく △× だけ)
対象の画面 — ★清掃詳細ログとダッシュボードの小さい表の両方★(同じ表の部品 CleanLogTable を使っている)

予定なしの行に出るアドバイス(matchAdviceRules() の式からの帰結。変えない): 予定なしの行は必ず出発済み(departed = true)で、事前チェックの4列は null。そのため ★R1(出発失敗)・B2・B3(事前チェック)は出ない★。 出うるのは ★B1(△)・R2(完了率10%未満)・R3/R4(施設×日付の連続日数)★と L1〜L3。 △ なら B1、× なら R2 が必ず当たるので、予定なしの △× を開いて「該当するアドバイスはありません」になることは無い。 R3・R4 は ★その施設のその日の結果★(予定ありの清掃も合わせた連続日数。CLAUDE.md §4.5「予定外のタスクも、その施設のその日の結果に入れる」)で出る — 仕様どおり。

2. API

2-1. 新しい入口

GET /clean-logs/unscheduled/{report_id}/advice

  • {report_id} = CleanLogItem.report_id(clean_task_reports.report_id。★varchar(20) の文字列★。値は19桁の数字の文字列で、安全な整数を超える)
  • 返すもの: ★GET /clean-logs/{id}/advice と同じ CleanLogAdviceResponse★(200)
  • 404(not_found・「指定された清掃ログが見つかりません」。既存と同じ文言):
    • その report_id の行が無い
    • ★予定のあるレポート★(clean_timetable_id が 0 でも NULL でもない)— 予定のある行は /clean-logs/{id}/advice で引く
    • ★削除済みの施設・清掃エリアの行★(FACILITY_SECTION_VISIBLE_WHERE。一覧に出ない行。D-182)
  • 422(validation_failed): report_id が空・20文字を超える(下の 2-2)
  • 500: 取得失敗・アドバイスの設定が読めない(既存と同じ。表示上限の設定が読めないときは既定値で続ける — 既存と同じ非対称を崩さない)
  • ★なぜこの形か★:
    • /clean-logs/{id}/advice の {id} は整数の契約(PathId・parsePathId()。安全な整数まで)で、19桁の report_id を通せない
    • パス名の unscheduled は CleanLogItem.unscheduled・?schedule=unscheduled とそろう
    • Next の app ルートは同じ階層の静的なフォルダを動的な [id] より先に当てる(app/api/clean-logs/export/ と [id]/ が並んでいる前例がある)。 ★app/api/clean-logs/[report_id]/ のように clean-logs 直下に名前の違う動的フォルダを作ると、Next がビルドで止まる★(同じ階層で名前の違う動的セグメント)。必ず静的な unscheduled/ を挟む
    • 認証・IP 制限(proxy.ts)は /api/ 配下すべてにかかるので、設定の追加は要らない

2-2. パスの検証(lib/api/path-params.ts・lib/config/index.ts)

  • lib/config/index.ts に PATH_REPORT_ID_MAX_LENGTH = 20(clean_task_reports.report_id が varchar(20))。消費者は parsePathReportId() だけ
  • parsePathReportId(value: string): QueryParseResult<string> を ★parsePathErrorId()(lib/api/path-params.ts:89-97)と同じ作法★で足す: 空文字と20文字超だけを 422(message「report_idの形式が不正です」、details { report_id: ["20文字以内で指定してください"] })。★文字種は絞らない★。★Number() / BigInt() を通さない★。★入力値をエコーしない★

2-3. データ層(lib/db/)— ★予定なしの1行だけを取る読み口を新しく作る★

  • DataSource(lib/db/index.ts)に ★unscheduledCleanLog(reportId: string): Promise<CleanLogItem | null>★ を足す(前例: errorReportById() / precheckItemById()。条件外は null → ルートが 404)
  • ★CleanLogDbQuery に report_id を足さない★(必須フィールドの作法なので、本番5ルート・十数か所のテストに波及し、mainTimetableWhere()・cleanLogRunMeta にも条件が要る)
  • ★db.cleanLogs({ id: null, date_from: null, ... }) で全件を読んでから探さない★(予定のある行の全件読みに戻る。指示書AC で潰した問題)
  • Prisma 版(この順で):
    1. clean_task_reports を ★where: { ...UNSCHEDULED_TIMETABLE_ID_WHERE, ...FACILITY_SECTION_VISIBLE_WHERE, report_id: reportId }★ で読む(findMany)。 ★展開して並べる。AND で包まない★ — 同じ階層の OR は UNSCHEDULED_TIMETABLE_ID_WHERE の1つだけ(FACILITY_SECTION_VISIBLE_WHERE はリレーション条件、report_id はスカラー)。 既存の unscheduledCleanLogWhere()(lib/db/unscheduled-clean-log-source.ts:39-49)・loadConsecutiveInputs() の3本目(lib/db/consecutive-source.ts:590-598)と同じ展開の形にする。 ★unscheduledCleanLogWhere() に CleanLogDbQuery を作って渡す形にもしない★(必須フィールドが多い)。 ★include(施設名・清掃エリアのロボット)と並びは loadUnscheduledCleanLogRows()(同ファイル :120-142)と同じものを使う★ — where を引数で受ける関数に切り出すなどして、include の定義を2か所に書き写さない
    2. unscheduledCleanLogRowsFromReports() を通す(★予定のあるレポートはここで落ちて [] → null → 404★。unscheduledCleanLogItem() に直接渡すと例外になり 500 になる)
    3. ★手順2の結果から r.report.report_id === reportId(JS の ===)の行を find する★(UnscheduledCleanLogRow では report_id は report の中)。★先頭の1行だけを見ない★。無ければ null。 DB の照合順序が大小文字・末尾の空白を同一視しうるので、SQL で当たった行をここで弾く(errorReportById() の D-4 と同じ)
    4. 連続日数: ★loadConsecutiveInputs(prisma, now, { date_from: d, date_to: d, facility_ids: [その行の facility_id] })★(d は ★その行の JST の日付=started_at_iso の先頭10文字★。Date や UTC の日付を使わない)→ consecutiveProblemDaysByFacilityDate() → .get(facilityDateKey(facility_id, started_at_iso)) ?? 0。 連続日数は「その日まで」で数え、施設ごとに独立しているので、★表示上限以下なら一覧と同じ生値になる★(上限を超えると生値は経路で違いうるが、表示「{上限}日以上」と L3 は一致する。既存の3経路と同じ許容範囲。lib/db/consecutive-source.ts:491-495)。 ★指示書BB の U4(単一ID の連続日数の入力は全期間・全施設のまま)は、既存の /clean-logs/{id}/advice・/{id}/areas についての決定。新しい入口は行の日付と施設が先に分かるので絞る(U4 と食い違わない)★
    5. unscheduledCleanLogItem(report, context, consecutive) で1行を作る(一覧と同じ関数)。 ★置き場所★: 手順1〜5は prismaSource.unscheduledCleanLog()(lib/db/index.ts の中)に書き、★手順1の読み込みだけ★を lib/db/unscheduled-clean-log-source.ts の関数に置く。 ログは一覧と同じく reportSectionRobotCount(row.section_id, row.robot_count, new Set()) で出す(lib/db/index.ts:946 の★非公開関数★なので、unscheduled-clean-log-source.ts 側からは呼べない。1リクエストに1つの seen)
  • fixtures 版: unscheduledCleanLogs.find((r) => r.report_id === reportId) ?? null(lib/fixtures/clean-logs.ts:383。Prisma 版の手順3と同じく === で比べる)。 ★可視性(削除済み)の絞り込みは書かない。架空のフィルタを作らない★ — fixtures の清掃ログには削除済みの施設・清掃エリアが無く(lib/db/visibility.ts:8「fixtures に削除の概念が無い」)、一覧の filterFixtureCleanLogs() も可視性を見ない。 ★isVisibleFacilitySection() を呼んだり、施設の fixtures から deleted_at を引いたりしない★。★削除済みの 404 は Prisma のスタブ(表A-db の A-9・表A-route の A-9′)だけで確かめる★
  • ★1行の値は、データ層の一覧(db.cleanLogs())の同じ row_key の行と完全に一致すること★(連続日数・escalation_level まで。ルートの capCleanLogs() が付ける consecutive_problem_days_capped は比べる対象に含めない。表A-db の A-5・A-6 で縛る)

2-4. ルートと共通化

  • 新規 app/api/clean-logs/unscheduled/[report_id]/advice/route.ts: parsePathReportId() → db.unscheduledCleanLog()(例外は 500)→ null なら 404 → 下の共通関数
  • ★[id]/advice/route.ts の「1行が決まった後」(find() の後:ルール判定・ログ・設定の読み込み・表示上限・応答。今の :78-174)を共通関数に切り出し、両方のルートから呼ぶ★(新規 lib/clean-logs/advice-response.ts。名前は案)。 lib/judge/ には置かない(設定の読み込みとログを含むので純粋関数ではない。CLAUDE.md §9.4)。 ★[id]/advice の挙動・ログ・文言は1文字も変えない★(既存のテストが変えずに通ること)。 ★判定(matchAdviceRules()・judgeEscalationLevel())とログには生値、capConsecutiveDays() は応答の値だけ★(今の順序を崩さない)
  • ★共通関数の形★: adviceResponse(log: CleanLogItem, ctx: { route: string; logIds: { clean_log_id: number } | { report_id: string } }): Promise<NextResponse>(名前は案)。
    • ★共通関数のファイルに経路名の定数を持たない★。logApiError(loadAdviceRules() の失敗)・judge.advice・judge.escalation の3か所すべてで ctx.route を使う
    • judge.advice には ...ctx.logIds を広げる(予定ありは clean_log_id だけ、予定なしは report_id だけを出す)。judge.escalation には今どおり ID を足さない
    • ★[id] ルート★: 今のファイル内の ROUTE("/api/clean-logs/[id]/advice"。:25)と、パスを解析した logId をそのまま渡す({ route: ROUTE, logIds: { clean_log_id: logId } })。log.id から作らない(:99-100 のコメントの作法)
    • ★新しいルート★: ファイル内に const ROUTE = "/api/clean-logs/unscheduled/[report_id]/advice" を置く。全体を withRequestId(request, …) で包む。 db.unscheduledCleanLog() の例外は、その ROUTE で logApiError({ route: ROUTE, method: "GET", body, status: 500, err }) して標準の形の 500(「データの取得に失敗しました」)にする。 共通関数には { route: ROUTE, logIds: { report_id: <パスの report_id> } } を渡す(log.report_id は型の上で null を含む。null になるのは不変条件違反なので、渡すのは parsePathReportId() を通したパスの値。手順3の === で一致を確かめた値と同じ)
    • [id] 側の既存テストは経路名(route)を見ていない(app/api/clean-logs/[id]/__tests__/route.test.ts:925-946 は method・status だけ)。[id] の経路名が変わっていないことは、ROUTE を渡していることをコードレビューで確かめる
  • ログ: JudgeAdviceLogEvent(lib/logger/events.ts:139-)に ★report_id?: string★ を足し、予定なしの行では clean_log_id を出さず report_id を出す(clean_log_id は number 型)。 docs/logging-design.md を直す:
    • 「記録可(数値・enum・ID)」(:204)に report_id を足す
    • :253(matchAdviceRules / judgeEscalationLevel の呼び出し元)・:289(judge.advice / judge.escalation を出すルート)の表: [id]/advice だけを挙げている行に、新しいルート app/api/clean-logs/unscheduled/[report_id]/advice/route.ts(共通関数 lib/clean-logs/advice-response.ts 経由)を足す
    • :487 のルート一覧に新しい route.ts を足す
  • lib/logger/events.ts の SettingsStoreCorruptLogEvent の説明(:377-379)の「/api/settings/advice と /api/clean-logs/[id]/advice の2ルートから共有」を、 「/api/settings/advice と、アドバイスの共通関数を通る /api/clean-logs/[id]/advice・/api/clean-logs/unscheduled/[report_id]/advice の3ルートから共有」に直す

2-5. d-watch-api.yaml(★先に直す。CLAUDE.md §6-1★)

  • 新しい path /clean-logs/unscheduled/{report_id}/advice を /clean-logs/{id}/advice(:703)の直後に足す。パラメータはインラインの string・maxLength: 20(/errors/{error_id}/status(:1074)と同じ書き方)。200・404(NotFound)・422(ValidationError)・500
  • ★200 の形を components/schemas/CleanLogAdviceResponse に切り出し、既存の /clean-logs/{id}/advice と新しい入口の両方から $ref★(写すと片方だけ直す事故が起きる。型名は lib/types/index.ts の CleanLogAdviceResponse とそろう。CLAUDE.md §9.2)。中身は今のインラインの定義をそのまま移す
  • /clean-logs/{id}/advice の説明(:709)の「予定外タスクの行(id: null)には無い」→「予定なしの行は /clean-logs/unscheduled/{report_id}/advice」
  • CleanLogItem の id・report_id・unscheduled の説明: 「予定外の行のアドバイス・エリア別詳細は取得できない(画面は行を開かない)」(:2332)→「アドバイスは /clean-logs/unscheduled/{report_id}/advice で取れる(画面は △× の行を開く)。エリア別詳細は取れない」
  • ★ついでに誤記を直す★(clean_task_reports に id 列は無い。主キーは report_id。prisma/schema.prisma:551)。★次の5か所すべて★:
    • d-watch-api.yaml の CleanLogItem:
      • id の説明(:2331)の「clean_task_reports.id は19桁」→「clean_task_reports.report_id は19桁」
      • row_key の説明(:2338)の「"rp-<clean_task_reports.id>"」→「"rp-<clean_task_reports.report_id>"」
      • report_id の説明(:2344)の「clean_task_reports.id」→「clean_task_reports.report_id」
    • lib/types/index.ts の CleanLogItem:
      • id の docstring(:237)の「clean_task_reports.id」→「clean_task_reports.report_id」
      • report_id の docstring(:248)の「clean_task_reports.id」→「clean_task_reports.report_id」
    • 終わったら grep -rn "clean_task_reports\.id" d-watch-api.yaml lib app components が★0件★になることを確かめて、その出力を報告する(直す前は上の5件だけ。監督が 2026-11-04 に確認)
  • /clean-logs/{id}/areas の「予定外タスクの行からは開かない」は★変えない★

3. 画面

  • lib/format/clean-result.ts の cleanLogRowView():
    • expandable: !row.unscheduled && (△|×)(:139)→ ★△|×★(結果待ちは今までどおり false)
    • ★戻り値に areaDetail: boolean を足す★ = expandable && !row.unscheduled(「エリア別詳細を見る →」を出すか。結果待ちの分岐(:121-130)も false を返す。1行の見え方はこの関数の1か所で決める作法。CleanLogTable.tsx:324)
    • 型 CleanLogRowView の説明(:99-103「予定外タスクの行も開けない」)を直す
  • 新規 ★lib/clean-logs/advice-url.ts★ の純粋関数 cleanLogAdviceUrl(row: Pick<CleanLogItem, "id" | "unscheduled" | "report_id">): string | null:
    • 予定あり(unscheduled === false)で id が数値 → /api/clean-logs/${id}/advice
    • 予定なし(unscheduled === true)で report_id が文字列 → /api/clean-logs/unscheduled/${encodeURIComponent(report_id)}/advice(★文字列のまま。Number() を通さない。row_key を分解しない★)
    • それ以外(予定ありなのに id が null、予定なしなのに report_id が null。API の不変条件では起きない)→ null(開かない)
  • DetailLogScreen.tsx と DashboardScreen.tsx の toggleRow()(★2画面に同じ処理がある。両方直す★。DetailLogScreen.tsx:265- / DashboardScreen.tsx:114-): 先頭の const logId = row.id; if (logId === null) return; を ★const url = cleanLogAdviceUrl(row); if (url === null) return;★ に替え、取得を apiFetch<CleanLogAdviceResponse>(url) にする。先頭のコメント(「予定外タスクの行(id: null)は開かない」。:266 / :115)を直す。 ★開いている行・キャッシュ・読み込み中・失敗は今までどおり row_key で持つ★(予定ありは tt-…、予定なしは rp-… で重ならない。キーを id に戻さない)
  • CleanLogTable.tsx: 開いた中身の「エリア別詳細を見る →」ボタン(:477-487)を ★view.areaDetail が true のときだけ★描く。予定なしの行では何も代わりに出さない。 expandedKey の説明(:23-26「予定外の行は開かないので…」)と :327 のコメントを直す
  • ★コメント3か所も直す★(ボタンを隠すと事実と合わなくなる):
    • CleanLogTable.tsx:477(areaBtn のボタンの上のコメント)「エリア別詳細はアドバイスの有無・取得の成否によらず出す」→「エリア別詳細は予定のある行だけ出す(view.areaDetail。予定の id が要る)。アドバイスの有無・取得の成否にはよらない(取得は別の API。指示書BC 2-1)」
    • DetailLogScreen.tsx:290 と DashboardScreen.tsx:139(toggleRow() の catch。2画面とも)の「行は開いたまま。エリア別詳細のボタンは出る」→「行は開いたまま。予定のある行ならエリア別詳細のボタンは出る」
    • openAreas() のコメント(DetailLogScreen.tsx:309・DashboardScreen.tsx:158「予定外タスクの行(id: null)からは開かない」)は正しいままなので★変えない★
  • ★openAreas() の if (row.id === null) return;(2画面。:310 / :159)は残す★(ボタンを隠したあとも保険。AreaTarget.logId は number なので外すと tsc が止まる)
  • ★画面で matchAdviceRules() を呼んで作らない★(判定は API から受け取る。CLAUDE.md §9.6。文言も運用設定でサーバー側にある)
  • lib/types/index.ts: CleanLogItem.unscheduled(:255「▼(アドバイス・エリア別詳細)を出さない」)・id(:235-240)の説明を「アドバイスは出す(△×)・エリア別詳細は出さない」に直す。 あわせて id(:237)・report_id(:248)の clean_task_reports.id → clean_task_reports.report_id(§2-5 の誤記の修正を参照)

4. 期待値表(★監督が確定。テストはこれをそのまま使う★)

表P — parsePathReportId()(lib/api/__tests__/path-params.test.ts か既存の置き場所に足す)

# 入力 結果
P-1 "" 422。details { report_id: ["20文字以内で指定してください"] }(toEqual で丸ごと)
P-2 "9200000000000000002"(19桁) ok・★文字列のまま★ "9200000000000000002"
P-3 20文字("1".repeat(20)) ok
P-4 21文字("1".repeat(21)) 422(P-1 と同じ details)。本文に入力値が出ない
P-5 "abc" ok(文字種は絞らない。ルートでは 404 になる)

表U — cleanLogAdviceUrl()(新規 lib/clean-logs/__tests__/advice-url.test.ts)

# 行 結果
U-1 { id: 54, unscheduled: false, report_id: null } "/api/clean-logs/54/advice"
U-2 { id: null, unscheduled: true, report_id: "9200000000000000002" } "/api/clean-logs/unscheduled/9200000000000000002/advice"
U-3 { id: null, unscheduled: true, report_id: "a/b c" } "/api/clean-logs/unscheduled/a%2Fb%20c/advice"(encodeURIComponent)
U-4 { id: null, unscheduled: false, report_id: null } null
U-5 { id: null, unscheduled: true, report_id: null } null

表V — cleanLogRowView()(lib/format/__tests__/clean-result.test.ts。入力の F・R・S・W は同ファイル :138-155 の定義)

# 行(入力) expandable areaDetail
V-1 予定あり・×(既存 C2-2 の F。:169) true true
V-2 予定あり・◎(既存 C2-3 の S。:180) false false
V-3 予定なし・×(★新しいテスト★。入力 { ...S, task_status: 6, completion_rate: 0, grade: "×", unscheduled: true }。fixtures の rp-9200000000000000002 と同じ形) ★true★ ★false★
V-4 予定なし・△(★:191-202 の既存テストを書き換える★。入力 { ...W, unscheduled: true } はそのまま) ★true(false から変える)★ false
V-5 予定なし・◎(★新しいテスト★。入力 { ...S, unscheduled: true }) false false
V-6 結果待ち(予定あり。既存 C2-1 の R。:157) false false
  • V-3 の期待値(戻り値全体を toEqual で丸ごと): { departed: "ok", resultLabel: "清掃キャンセル", unknownStatus: null, completionRate: 0, gradeShown: true, expandable: true, areaDetail: false } (task_status 6 は 3 と同じ「清掃キャンセル」。lib/format/clean-result.ts:14)。★入力に F(departed: false)を使わない★ — 予定外は未実施にならないので、予定なしで出発失敗の行は存在しない
  • V-5 の期待値(同じく丸ごと): { departed: "ok", resultLabel: "清掃正常終了", unknownStatus: null, completionRate: 95.2, gradeShown: true, expandable: false, areaDetail: false }(S の completion_rate は 95.2・task_status は 4。:147-154)
  • V-4: テスト名を「予定なしの行は △× なら開ける(アドバイスあり)。エリア別詳細は出さない」に直す。ほかの欄(departed "ok"・resultLabel「清掃中断」・unknownStatus null・completionRate 45・gradeShown true)と、:200-201 の CLEAN_LOG_UNSCHEDULED_LABEL・translate の2行は★そのまま★残す
  • 既存の toEqual への areaDetail の足し方:
    • 「C2-1・C2-2・C2-3・C2-4(:158・:170・:181・:205)には areaDetail を足す(期待値の他の欄は変えない。C2-1 false / C2-2 true / C2-3 false / C2-4 true)」
    • 「:192 の予定なしのテストは V-4 として expandable を true に変え、areaDetail: false を足す(ほかの欄は変えない)」

表A — GET /clean-logs/unscheduled/{report_id}/advice(3つの表に分ける。番号は A-1〜A-4 が fixtures、A-5〜A-10 がデータ層、A-6′〜A-9′・A-11〜A-13 がルート)

表A-fixtures(A-1〜A-4。段階4) — USE_FIXTURES=true。予定なしの2行は lib/fixtures/clean-logs.ts:383-420 の unscheduledCleanLogs。連続日数は固定値なので日付が変わっても同じ。

★置き場所は新規 app/api/clean-logs/unscheduled/[report_id]/advice/__tests__/route.fixtures.test.ts★。

  • ファイルの冒頭で vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })) と明示的に固定する。前例は app/api/errors/__tests__/route.fixtures.test.ts:18 と app/api/errors/[error_id]/status/__tests__/route.fixtures.test.ts:24
  • モックを省いて環境変数に任せない — lib/db/use-fixtures.ts:17 は環境の USE_FIXTURES=false(.env.local)で false に変わる
  • lib/logger の logEvent は前例と同じく vi.fn() に差し替える。ルートの GET はファイル先頭で静的 import する
  • ★既存の app/api/clean-logs/__tests__/route.test.ts・app/api/clean-logs/[id]/__tests__/route.test.ts には A-1〜A-4 を足さない★ — どちらも :235 / :173 で USE_FIXTURES: false に固定しており、vi.mock はファイル単位、lib/db/index.ts:3197 の db は読み込み時に束縛されるので、同じファイルでは true にできない。そこに書くと Prisma 経路が走り、A-1・A-2 は 404 で落ちる(A-3・A-4 は偶然通る)
  • fixtures の行の日付は今日からの相対なので、vi.setSystemTime で今を固定するか、日付を直書きしない(§6 罠10)
# report_id 結果
A-1 9200000000000000002(施設11・×・完了率0・連続1) 200。escalation_level "L1"・consecutive_problem_days 1・advices の rule は ★["R2"] だけ★・max_level 1
A-2 9200000000000000001(施設1・◎・95.9・連続0) 200。escalation_level null・advices [](API は ◎ でも返す。画面が開かないだけ)
A-3 123(存在しない) 404(not_found・「指定された清掃ログが見つかりません」)
A-4 21文字 422(details のキーは report_id)

Prisma のスタブ(表A-db・表A-route 共通) — app/api/clean-logs/__tests__/route.test.ts の予定なしのスタブ・unscheduledReport() ヘルパー・OR に対応した matchesWhere(:144-180)を使う。★[id]/__tests__/route.test.ts のスタブ(:126-)は OR を無視するので、そちらに書くなら OR 対応を移すこと★。

  • ★スタブの matchesWhere は OR だけを解釈し、AND は黙って無視する★。buildData() の予定ありのレポート(report() の行)には section・report_id が無い。そのため where の条件が外れると、sortIncludedRobots()(:222-233)が TypeError を出す。 ★A-5〜A-10 が赤いときは、まず where が平らに展開されているか(§2-3 手順1)を確かめる。スタブ側を書き換えて通さない★(例外は下の report_id の照合順序の再現だけ)
  • ★置き場所★: 既存の describe("予定外タスクの行(指示書BG 表U1)")(:1116-1266)の中に it として足す。unscheduledReport()・RP1・RP2 はその describe の中のローカル定義(:1118-1147)なので、外の describe からは参照できない。外に新しい describe を作るなら、先にこの3つをファイルの最上位(get()(:384)の近く)へ移す(既存の U1 テストの中身は変えない)
  • ★ルートの import★: 新しいルートの GET は、ファイル先頭で静的 import する(ファイル冒頭 :8-10 の作法。it の中で await import() しない)。例: import { GET as GET_UNSCHEDULED_ADVICE } from "@/app/api/clean-logs/unscheduled/[report_id]/advice/route";
  • ★report_id の列だけ照合順序を再現する★(A-11 のため): matchesWhere で field === "report_id" かつ cond が文字列のときだけ、typeof value === "string" && value.replace(/ +$/, "").toLowerCase() === cond.replace(/ +$/, "").toLowerCase() で比べる(ci+末尾の空白を無視する PAD SPACE。report() の行のように report_id を持たない行は不一致)。ほかの列の比較(value !== cond)は変えない。 前例は errors / prechecks の status のテストの ciEqual(app/api/errors/[error_id]/status/__tests__/route.test.ts:93。こちらは大小文字だけ。D-4)。★手順3を消したら A-11 が 200 になって赤くなること★
  • ★logApiError を見る A-12 のため★、このファイルに vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })) を足す([id]/__tests__/route.test.ts:175 と同じ)。このファイルには今 logApiError を見るテストが無い(監督が 2026-11-04 に grep で0件を確認)ので、ファイル全体に効いても既存のテストは変わらない

表A-db(A-5〜A-10。db.unscheduledCleanLog()。段階3)

# 場面 結果
A-5 db.cleanLogs({ id: null, date_from: null, date_to: null, facility_ids: null }) が返す予定なしの行(unscheduled === true。ルートの capCleanLogs() を通す前のデータ層の値)それぞれについて db.unscheduledCleanLog(row.report_id!) ★その行と toEqual で一致★(consecutive_problem_days・escalation_level まで。数値を手で書き写さない)。★一覧の API(GET /clean-logs)の応答とは比べない★ — ルートの capCleanLogs()(app/api/clean-logs/route.ts:107)が各行に consecutive_problem_days_capped: false を付けるが、データ層の1行(unscheduledCleanLogItem())にはこのキーが無く、toEqual が必ず失敗する(toEqual が無視するのは値が undefined のキーだけ)。どうしても API の応答と比べるなら capCleanLogs([db の1行], maxDays)[0] と比べる。この例には、連続日数が表示上限を超える行を入れない(上限を超えると生値は経路で違いうる。§2-3 の4)
A-6 施設1で 9/8〜9/10 が3日連続 × の例(既存 :1243-1249 と同じく unscheduledReport(RP1, 1, FACILITY_1, "2026-09-10T21:30:00+09:00", 959, 1000)。◎) 行を返す。grade "◎"・consecutive_problem_days 3・escalation_level "L3"
A-7 予定のあるレポート(unscheduledReport() の第7引数に本清掃の id(例 103n)を渡した行)の report_id null を返す(★例外にならない★)
A-8 clean_timetable_id が 0n の行と null の行 どちらも行を返す(null でない。どちらも予定なし)
A-9 削除済みの施設の予定なしの行(U1-5(:1208-1223)と同じ作り方) null を返す。+★SQL の where に可視性と予定なしの条件が載っている★: calls.cleanTaskReport.find((c) => "include" in c) の where が toMatchObject(FACILITY_SECTION_VISIBLE_WHERE)(U1-5 の :1221-1222 と同じ形)、かつ toMatchObject(UNSCHEDULED_TIMETABLE_ID_WHERE)(@/lib/db/timetable-link から import)。AND で包むとこの toMatchObject が落ちるので、展開の形が担保される
A-10 連続日数の読み込みの範囲 ★スタブに発行された where で確かめる(既存 #8「連続日数用のクエリは表示期間+上限ぶんの遡りで絞る」:479-499 と同じ作法)★。行の JST の日付を d とする。
(a) 連続日数用の予定のクエリ = calls.cleanTimetable のうち select を持ち select.facility_id === true のもの。その where.start_at.lt.toISOString() = `${d の翌日}T00:00:00.000Z` 。where.start_at.gte の日付部分(toISOString().slice(0, 10))= d から consecutiveLookbackCalendarDays(await loadConsecutiveDaysMax()) 日前。where.facility_id = { in: [BigInt(その行の facility_id)] }。
(b) 予定外の3本目 = calls.cleanTaskReport のうち select を持ち select.facility_id === true のもの(その行を引く1本目は include 付き、予定の IN で引く2本目は select に facility_id が無いので区別できる。lib/db/consecutive-source.ts:566- の2本目)。その where.started_at と where.facility_id が (a) と同じ。
★vi.mock("@/lib/db/consecutive-source") や vi.spyOn で loadConsecutiveInputs を差し替えない★(lib/db/index.ts はモジュールの中で直接呼んでいるので spyOn では差し替えられない。vi.mock はファイル全体に効き、既存の #8 と一覧の連続日数のテストが壊れる)。★遡りの日数を数値で直書きしない★(保存された表示上限から導く)

表A-route(A-6′〜A-9′・A-11〜A-13。GET /clean-logs/unscheduled/{report_id}/advice をルート経由で。段階4)

# 場面 結果
A-6′ A-6 と同じデータ 200。escalation_level "L3"・consecutive_problem_days 3・consecutive_problem_days_capped false・advices の rule は ★ちょうど ["R3", "R4"]★(toEqual。R3 も同時に出る — lib/judge/advice.ts:82-90。R4 だけではない。◎ なので R2・B1 は当たらず、出発済みなので R1、事前チェックが null なので B2・B3 も当たらない)・max_level 3。★この行は ◎(95.9)なので画面では ▼ が出ず開かない(表V の V-5)が、API は返す(A-2 と同じ)★
A-7′ A-7 と同じデータ 404(not_found・「指定された清掃ログが見つかりません」)。★500 にならない★
A-8′ A-8 と同じデータ(0 の行と NULL の行) どちらも 200
A-9′ A-9 と同じデータ(削除済みの施設の行) 404(A-7′ と同じ本文)
A-11 パスの report_id が "9200000000000000002 "(末尾に空白1つ・20文字。表P の P-3 と同じく 422 にならない長さ)。DB に入っている行は "9200000000000000002"(予定なし・生きている施設) 404(not_found)。★さらに、スタブの clean_task_reports の findMany(include 付きの1本目)がその行を返していた(SQL では当たった)ことも確かめる★(スタブの calls は引数しか残さないので、calls.cleanTaskReport.find((c) => "include" in c) の where に対して、同ファイルの matchesWhere(その行, where) が true になることで確かめる)。これで 404 の理由が「SQL が空を返した」ではなく「§2-3 手順3の JS の === が弾いた」ことの証拠になる(上の照合順序の再現が前提)
A-12 新しい入口で loadAdviceRules() が例外(__setAdviceStoreForTesting() で壊れたストアを入れる。[id]/__tests__/route.test.ts:925-946 の T3 が手本。finally で null に戻す) 500・{ error: { code: "internal_error", message: "データの取得に失敗しました", details: null } }。logApiError が1回呼ばれ、第1引数が { route: "/api/clean-logs/unscheduled/[report_id]/advice", method: "GET", status: 500 } に toMatchObject
A-13 A-8′ の NULL の行(予定なし・生きている施設)で 200 のとき、logEvent の呼び出し judge.advice の第2引数が { route: "/api/clean-logs/unscheduled/[report_id]/advice", report_id: <その行の report_id> } を含み、★clean_log_id のキーを持たない★("clean_log_id" in payload が false)。judge.escalation の route も同じ値

既存(回帰): GET /clean-logs/{id}/advice のテスト(app/api/clean-logs/[id]/__tests__/route.test.ts)は★変えずに★全部通ること(共通関数への切り出しで挙動が変わっていない証拠)

5. 段階(この順で)

  1. d-watch-api.yaml(2-5。誤記の5か所と grep の確認も)
  2. lib/config/index.ts・lib/api/path-params.ts(2-2)+表P
  3. lib/db/unscheduled-clean-log-source.ts・lib/db/index.ts(2-3)+★表A-db(A-5〜A-10)★。スタブの report_id の照合順序の再現もここで足す
  4. lib/clean-logs/advice-response.ts(共通関数)・[id]/advice/route.ts の置き換え → ★既存の [id]/advice のテストが変えずに通ることを先に確かめる★ → 新しいルート+★表A-fixtures(A-1〜A-4。新規 app/api/clean-logs/unscheduled/[report_id]/advice/__tests__/route.fixtures.test.ts)と表A-route(A-6′〜A-9′・A-11〜A-13。app/api/clean-logs/__tests__/route.test.ts)★。 lib/logger/events.ts(JudgeAdviceLogEvent の report_id・SettingsStoreCorruptLogEvent の説明。§2-4)・docs/logging-design.md(§2-4 の3か所)
  5. lib/format/clean-result.ts+表V、lib/clean-logs/advice-url.ts+表U、CleanLogTable.tsx・DetailLogScreen.tsx・DashboardScreen.tsx(コメント3か所も。§3)、lib/types/index.ts の説明(誤記の2か所も)
  6. docs を新しい入口・新しい動きに合わせる:
    • docs/code-map.md: API 一覧(:932 の /api/clean-logs/[id]/advice の行の後。画面の呼び出し列つき)とファイル一覧(:4118 の後)に新しい入口を足す。 :596 の「★『エリア別詳細を見る →』はアドバイスの有無・成否によらず出します★」を「★予定ありの行では、アドバイスの有無・成否によらず出します。予定なしの行には出しません★(指示書BI)」に直す
    • docs/fixtures.md:123: 「▼ を出さない(行を開かない)」を「△× の行(施設11 の ×)は ▼ でアドバイスが開く(/api/clean-logs/unscheduled/{report_id}/advice)。エリア別詳細を見る → は出ない。◎ の行(施設1)は ▼ なし」に替える。 同じ行の「予定外(EN "Unscheduled")」「CSV の『予定時刻』列も『予定外』」は、指示書BH §13 に合わせて「予定なし(EN "No schedule")」に直す
    • docs/spec.md:66(F-02 の API): /clean-logs/unscheduled/{report_id}/advice を足す
    • docs/architecture.md:81 の後: | \app/api/clean-logs/unscheduled/[report_id]/advice/route.ts` | 予定なしの行のアドバイス取得 | 実装済み |` の行を足す
    • docs/logging-design.md は段階4で直した(:204・:253・:289・:487)。ここで漏れが無いか見直す
  7. tsc・lint(触ったファイル)・vitest 全件の件数と、段階ごとの変更ファイルを報告(★報告は日本語★)

6. 既知の罠

  1. ★expandable だけを true にすると、2画面の toggleRow() の if (logId === null) return; で止まる★ — ▼ は出るのに押しても開かない。型エラーもテストの失敗も出ない(画面で押さないと分からない)
  2. ★toggleRow() は2画面にある★。片方だけ直すと、詳細ログでは開くのにダッシュボードでは開かない。 ★ダッシュボード側は fixtures の画面で予定なしの行を開けない(§7)。直したかどうかは grep でしか確かめられない★
  3. ★「エリア別詳細を見る →」は今は無条件で出る★ — 隠さないと、予定なしの行で「押しても何も起きないボタン」になる(openAreas() が黙って return する)
  4. ★report_id を数値にしない★(19桁は安全な整数を超える。Number() で末尾が丸まる)。parsePathId() を流用すると全件 422 になる
  5. ★予定のある report_id を unscheduledCleanLogItem() に直接渡すと例外 → 500★。unscheduledCleanLogRowsFromReports() を通して 404 にする
  6. ★findUnique({ where: { report_id } }) だけで引くと、予定なしの条件と可視性(削除済み)が SQL から抜ける★
  7. ★UNSCHEDULED_TIMETABLE_ID_WHERE はトップレベルの OR★。同じ階層に別の OR が来るときだけ AND: [...] で包む(前例: findUnscheduledErrors()。lib/db/unscheduled-report-source.ts:95-96)。 ★今回は OR が1つなので包まない★。包むと、テストのスタブ(app/api/clean-logs/__tests__/route.test.ts の matchesWhere)が AND を解釈せず条件が全部消える
  8. ★連続日数の範囲を null(全期間・全施設)で渡さない★(重い。行の日付と施設が先に分かるので絞る)。日付は JST の started_at_iso の先頭10文字
  9. fixtures の予定なしの行の連続日数は固定値(0 と 1)。fixtures で一覧と一致しても Prisma 版が一致する保証にはならない → 表A-db の A-5 は Prisma のスタブで確かめる
  10. fixtures の予定なしの行の日付は今日からの相対(at(daysBack))。テストに日付を直書きしない
  11. ★app/api/clean-logs/[report_id]/ は作らない★([id] と同じ階層に名前の違う動的フォルダ → ビルドで止まる)
  12. ★判定とログには生値、capConsecutiveDays() は応答だけ★。共通関数に切り出すときも順序を崩さない。loadAdviceRules() の失敗は 500、loadConsecutiveDaysMax() の失敗は既定値で続行 — この非対称をそろえてしまわない
  13. 一覧を出した時点と ▼ を開いた時点で「今」が違うので、同じ施設・同じ日の予定のある行が結果待ちから未実施に変わると、連続日数が一覧とずれうる(予定のある行のアドバイスにも同じ性質がある。今回直さない)
  14. ★clean_task_reports.id は誤記★(YAML :2331・:2338・:2344、lib/types/index.ts :237・:248 の5か所。§2-5 で全部直す)。新しいパラメータの説明・関数コメントに写さない
  15. ★一覧の API の応答には capCleanLogs() が consecutive_problem_days_capped を付ける。データ層の1行には無い★。A-5 で API の応答と toEqual で比べると全行が失敗する(データ層の db.cleanLogs() と比べる)
  16. ★A-10 は loadConsecutiveInputs をモックして引数を見るのではなく、スタブの calls に残った where で確かめる★(route.test.ts の #8(:479-499)が手本)
  17. ★手順3(=== の再確認)を落としても、照合順序を再現しないスタブでは緑のまま★。A-11 はスタブ側で report_id の照合順序を再現して初めて意味を持つ

7. 画面で確かめること(fixtures。USE_FIXTURES=true npm run build → USE_FIXTURES=true npx next start -p 3100。★:3000 は触らない★。終わったら :3100 を止める)

  • 清掃詳細ログ: 「全件」+「予定なし」で2行。★× の行(施設11)に ▼ が出る★。 押すと、見出し「⚠ レベル1 — 現場確認が必要」(max_level 1。CleanLogTable.tsx の adviceHead()、:58-78)と、カード「⚠ 完了率が極端に低い(R2)」の★1枚だけ★が出る(R1・B1 のカードは出ない)。 ★画面には escalation_level("L1")を出す箇所が無いので、「L1」という文字は探さない。表示も足さない★。L1 であることは API の応答(表A-fixtures の A-1: GET /api/clean-logs/unscheduled/9200000000000000002/advice が escalation_level: "L1"・max_level: 1・advices の rule が ["R2"] だけ)で確かめる。 カードの題と見出しは運用設定・既定のルール(lib/config/advice-rules.ts:49-62)の文言で決まる。fixtures の既定値と違って見えるときは、文言の一致にこだわらず「見出しがレベル1」と「R2 のカード1枚」の2点を確かめる。 「エリア別詳細を見る →」は★出ない★。◎ の行(施設1)は ▼ が出ない
  • 予定ありの △× の行: 今までどおり ▼ で開き、アドバイスと「エリア別詳細を見る →」が出て、エリア別詳細も開く
  • ダッシュボードの小さい表: fixtures の直近モードでは、通常の時間帯に予定なしの行は problem_logs に出ない(監督側の点検役が 2026-11-04 16:34 に確認。監督は再現していない。施設11 は今日 03:30 の結果待ちの行(lib/fixtures/clean-logs.ts:367)が直近に選ばれ、結果待ちは problem_logs に出ないため。予定なしの × が出るのは ?date= を指定したときだけで、ダッシュボードの画面には日付を渡す手段が無い — DashboardScreen.tsx:88)。 ★画面での確認はしない★。★fixtures に行を足さない、時刻もずらさない★(表A-fixtures の A-1・A-2 と、件数を縛る既存のテストが崩れる)。 代わりに、§6 罠2 の確認として次のコマンドを実行し、出力を報告に貼る。 grep -n "cleanLogAdviceUrl\|logId\|/advice" components/features/dashboard/DashboardScreen.tsx components/features/detail-log/DetailLogScreen.tsx 期待する結果は次の3点。 (a) 両ファイルとも toggleRow() の中に cleanLogAdviceUrl(row) と apiFetch<CleanLogAdviceResponse>(url) がある。 (b) toggleRow() の中に logId と、/api/clean-logs/${...}/advice の直書きが残っていない(AreaTarget.logId・openAreas() の logId: row.id・areaLogId の行は toggleRow() の外なので残ってよい)。 (c) openAreas() の if (row.id === null) return; は残っている(§3 のとおり)
  • 開いた行を閉じて別の行を開く・予定ありと予定なしを交互に開く・同じ行を開き直す(2回目は取り直さない)が今までどおり動く
  • ブラウザのネットワークで、予定なしの行は /api/clean-logs/unscheduled/<report_id>/advice、予定ありの行は /api/clean-logs/<id>/advice を呼んでいる
  • JP/EN の切り替えで、開いた中身の文言とはみ出しが今までどおり

8. 共通ルール

  • 期待値表(§4)を先にテストにする。数字・順番を変えない。合わないと思ったら実装を変えずに監督に報告
  • git add -A 禁止。★コミットはしない(監督がまとめてコミットする)★
  • 触らない: docs/decisions.md・docs/handoff/指示書AY_*・docs/handoff/設計_制御盤ログの取り出し_*・components/features/errors/ErrorContentPicker.tsx・lib/errors/error-content-label.ts とそのテスト・prisma/・CLAUDE.md
  • DB に書き込まない。サーバー(:3000)を起動・停止しない
  • 完了したら tsc・lint(触ったファイル)・vitest 全件の件数と、段階ごとの変更ファイルを報告する

9. 決定の記録(監督が docs/decisions.md に後で書く。Cursor は書かない)

  • 予定なしの △× の行にもアドバイスの ▼ を出す(ユーザー回答「出す」。2026-11-04)。指示書BG §2-1 の「予定外の行は開かない」のうち、アドバイスの部分を取り消す。エリア別詳細は出さないまま
  • 入口は GET /clean-logs/unscheduled/{report_id}/advice({id} は整数の契約なので別の入口。unscheduled は既存の名前とそろう)
  • データ層は予定なしの1行専用の読み口(CleanLogDbQuery に足すと波及が大きい)。連続日数は行の日付と施設に絞って読む(表示上限以下なら一覧と同じ値)
  • 却下: 画面で matchAdviceRules() を呼ぶ案(§9.6 違反)、row_key を受ける1本の入口(予定のある行の入口が2本になり、row_key が API の入力になる)、/clean-logs/{id}/advice?kind=report(整数の契約を壊す)、CleanLogDbQuery に report_id を足す案
  • 点検(2026-11-04): 4観点で29件の指摘、反証で26件が本物(重複をまとめて約15項目)。棄却3件: U4 との食い違い(新しい入口には及ばない)、A-10 の観測方法についての2件(スタブの where で確かめる形に決めた)