担当: 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件は監督が数え直した)(作業中にずれたら、書いてある文言で探す)。
CleanLogItem.unscheduled = true)は ▼ を出さない★(指示書BG §2-1 の決定)。 ステージングの △× 335件のうち 295件が予定なしで、既定の画面の1ページ目では 50行中5行しか ▼ が出ないため、消えたように見えた| 項目 | 今 | ★変更後★ |
|---|---|---|
| 予定なしの △× の行の ▼ | 出さない(開けない) | ★出す(開ける)★ |
| 開いたときのアドバイス | — | ★予定ありの行と同じ形(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「予定外のタスクも、その施設のその日の結果に入れる」)で出る — 仕様どおり。
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)not_found・「指定された清掃ログが見つかりません」。既存と同じ文言):
report_id の行が無いclean_timetable_id が 0 でも NULL でもない)— 予定のある行は /clean-logs/{id}/advice で引くFACILITY_SECTION_VISIBLE_WHERE。一覧に出ない行。D-182)validation_failed): report_id が空・20文字を超える(下の 2-2)/clean-logs/{id}/advice の {id} は整数の契約(PathId・parsePathId()。安全な整数まで)で、19桁の report_id を通せないunscheduled は CleanLogItem.unscheduled・?schedule=unscheduled とそろう[id] より先に当てる(app/api/clean-logs/export/ と [id]/ が並んでいる前例がある)。 ★app/api/clean-logs/[report_id]/ のように clean-logs 直下に名前の違う動的フォルダを作ると、Next がビルドで止まる★(同じ階層で名前の違う動的セグメント)。必ず静的な unscheduled/ を挟むproxy.ts)は /api/ 配下すべてにかかるので、設定の追加は要らない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() を通さない★。★入力値をエコーしない★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 で潰した問題)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か所に書き写さないunscheduledCleanLogRowsFromReports() を通す(★予定のあるレポートはここで落ちて [] → null → 404★。unscheduledCleanLogItem() に直接渡すと例外になり 500 になる)r.report.report_id === reportId(JS の ===)の行を find する★(UnscheduledCleanLogRow では report_id は report の中)。★先頭の1行だけを見ない★。無ければ null。 DB の照合順序が大小文字・末尾の空白を同一視しうるので、SQL で当たった行をここで弾く(errorReportById() の D-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 と食い違わない)★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)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′)だけで確かめる★db.cleanLogs())の同じ row_key の行と完全に一致すること★(連続日数・escalation_level まで。ルートの capCleanLogs() が付ける consecutive_problem_days_capped は比べる対象に含めない。表A-db の A-5・A-6 で縛る)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 を直す:
report_id を足す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 経由)を足す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ルートから共有」に直すd-watch-api.yaml(★先に直す。CLAUDE.md §6-1★)/clean-logs/unscheduled/{report_id}/advice を /clean-logs/{id}/advice(:703)の直後に足す。パラメータはインラインの string・maxLength: 20(/errors/{error_id}/status(:1074)と同じ書き方)。200・404(NotFound)・422(ValidationError)・500components/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 の「予定外タスクの行からは開かない」は★変えない★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}/adviceunscheduled === 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 のコメントを直す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 の誤記の修正を参照)表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 |
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)を使わない★ — 予定外は未実施にならないので、予定なしで出発失敗の行は存在しない{ departed: "ok", resultLabel: "清掃正常終了", unknownStatus: null, completionRate: 95.2, gradeShown: true, expandable: false, areaDetail: false }(S の completion_rate は 95.2・task_status は 4。:147-154)departed "ok"・resultLabel「清掃中断」・unknownStatus null・completionRate 45・gradeShown true)と、:200-201 の CLEAN_LOG_UNSCHEDULED_LABEL・translate の2行は★そのまま★残すareaDetail の足し方:
areaDetail を足す(期待値の他の欄は変えない。C2-1 false / C2-2 true / C2-3 false / C2-4 true)」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:24lib/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 は偶然通る)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 テストの中身は変えない)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)は★変えずに★全部通ること(共通関数への切り出しで挙動が変わっていない証拠)
d-watch-api.yaml(2-5。誤記の5か所と grep の確認も)lib/config/index.ts・lib/api/path-params.ts(2-2)+表Plib/db/unscheduled-clean-log-source.ts・lib/db/index.ts(2-3)+★表A-db(A-5〜A-10)★。スタブの report_id の照合順序の再現もここで足す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か所)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か所も)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)。ここで漏れが無いか見直すexpandable だけを true にすると、2画面の toggleRow() の if (logId === null) return; で止まる★ — ▼ は出るのに押しても開かない。型エラーもテストの失敗も出ない(画面で押さないと分からない)toggleRow() は2画面にある★。片方だけ直すと、詳細ログでは開くのにダッシュボードでは開かない。 ★ダッシュボード側は fixtures の画面で予定なしの行を開けない(§7)。直したかどうかは grep でしか確かめられない★openAreas() が黙って return する)report_id を数値にしない★(19桁は安全な整数を超える。Number() で末尾が丸まる)。parsePathId() を流用すると全件 422 になるreport_id を unscheduledCleanLogItem() に直接渡すと例外 → 500★。unscheduledCleanLogRowsFromReports() を通して 404 にするfindUnique({ where: { report_id } }) だけで引くと、予定なしの条件と可視性(削除済み)が SQL から抜ける★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 を解釈せず条件が全部消えるstarted_at_iso の先頭10文字at(daysBack))。テストに日付を直書きしないapp/api/clean-logs/[report_id]/ は作らない★([id] と同じ階層に名前の違う動的フォルダ → ビルドで止まる)capConsecutiveDays() は応答だけ★。共通関数に切り出すときも順序を崩さない。loadAdviceRules() の失敗は 500、loadConsecutiveDaysMax() の失敗は既定値で続行 — この非対称をそろえてしまわないclean_task_reports.id は誤記★(YAML :2331・:2338・:2344、lib/types/index.ts :237・:248 の5か所。§2-5 で全部直す)。新しいパラメータの説明・関数コメントに写さないcapCleanLogs() が consecutive_problem_days_capped を付ける。データ層の1行には無い★。A-5 で API の応答と toEqual で比べると全行が失敗する(データ層の db.cleanLogs() と比べる)loadConsecutiveInputs をモックして引数を見るのではなく、スタブの calls に残った where で確かめる★(route.test.ts の #8(:479-499)が手本)=== の再確認)を落としても、照合順序を再現しないスタブでは緑のまま★。A-11 はスタブ側で report_id の照合順序を再現して初めて意味を持つUSE_FIXTURES=true npm run build → USE_FIXTURES=true npx next start -p 3100。★:3000 は触らない★。終わったら :3100 を止める)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)は ▼ が出ない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 のとおり)/api/clean-logs/unscheduled/<report_id>/advice、予定ありの行は /api/clean-logs/<id>/advice を呼んでいるgit add -A 禁止。★コミットはしない(監督がまとめてコミットする)★docs/decisions.md・docs/handoff/指示書AY_*・docs/handoff/設計_制御盤ログの取り出し_*・components/features/errors/ErrorContentPicker.tsx・lib/errors/error-content-label.ts とそのテスト・prisma/・CLAUDE.mddocs/decisions.md に後で書く。Cursor は書かない)GET /clean-logs/unscheduled/{report_id}/advice({id} は整数の契約なので別の入口。unscheduled は既存の名前とそろう)CleanLogDbQuery に足すと波及が大きい)。連続日数は行の日付と施設に絞って読む(表示上限以下なら一覧と同じ値)matchAdviceRules() を呼ぶ案(§9.6 違反)、row_key を受ける1本の入口(予定のある行の入口が2本になり、row_key が API の入力になる)、/clean-logs/{id}/advice?kind=report(整数の契約を壊す)、CleanLogDbQuery に report_id を足す案