Newer
Older
client_dwatch / docs / handoff / 指示書AT_アラートの対応状況を画面から更新する_20261002.md

指示書AT — アラートの対応状況を画面から更新する(robot_error_reports の4カラム UPDATE)

日付: 2026-10-02 / 担当: Cursor / 前提: D-042 / D-104 / D-105 / D-152 / D-153 項3 / D-155 / CLAUDE.md §3・§6-1・§7・§9.3 / TeamWorks 第10回回答 N-5・N-6・N-14(docs/handoff/TeamWorks回答_第10回_20261002.md)

★渡す順番: 指示書AR → 指示書AS → 本書AT を一括で渡す★(D-153 項3 / D-155 項6)。 ★分割 PR の作成とマージは、AR と本書の実装が終わってから daichi がレビューして行う★(D-154 項4)。

★★監督の確認は済み(2026-10-02。D-156)★★ — §10 の8項目はすべて推奨どおりで承認。本文のまま実施すること。 (以下は作成時の注記)本書の §3 で ★【要確認】★ と付けた決定は、 推奨案を本文に書き込んである。★承認されればそのまま渡せる★。却下された項目だけ本文を差し替えること。 一覧は §10。★決まったら docs/decisions.md に D 番号で記録する(本書の作業者は書かない)★。


0. 何をするか(1行)

/alerts の★事前チェック由来のアラート★について、★対応状況(未解消→対応中→解消)と備考★を画面から更新できるようにする。 書き込み先は ★robot_error_reports の status / comment / responded_at / resolved_at の4カラムだけ★。


1. 前提 — いまどうなっているか(2026-10-02 実測。出所はすべて「構造(リポジトリのコード)」)

# 事実 場所
1 ★API 契約はある★。PATCH /prechecks/{id}/status(id は string、status は unresolved/in_progress/resolved、memo は最大400文字)。200 は更新後の PrecheckItem、ほかは 404 / 422 d-watch-api.yaml:659-690
2 ★実装は無い★。app/api/prechecks/ には route.ts(GET)しか無く、その冒頭に「PATCH は今回作らない」と書いてある app/api/prechecks/route.ts:7
3 PrecheckItem.id は ★事前チェック由来=robot_error_reports.error_id★、★出発失敗=本清掃行の clean_timetables.id の文字列★ lib/db/index.ts:1306 / :1371、d-watch-api.yaml:1771-1773
4 ★出発失敗(launch_failed)には robot_error_reports の行が無い★ → ★書き込み先が無い★。resolution_status は unresolved 固定・日時と備考は null 固定 D-104 / D-105 申し送り、lib/db/index.ts:1386-1395
5 robot_error_reports に ★主キーが無い★。一意なのは ★error_id(varchar(50))だけ★ prisma/schema.prisma:307-312
6 ★罠★: RobotErrorReport.updated_at に ★@updatedAt★ が付いている prisma/schema.prisma:336
7 status の 0/1/2 ↔ enum の変換は両方向とも既にある(逆変換は seed 用として作ったもの) lib/db/precheck-mapping.ts:32(DB→API)/ :48(API→DB)
8 ★日時を書くときは toDbJst() を通す★(アダプタは getUTC* で文字列を作るため)。docstring に「4カラムを実装するときは必ず通せ」とある lib/format/date.ts:70-83
9 書き込み監査ログの型 DbWriteLogEvent(event: "db.write")は ★定義だけあって一度も出していない★。★record_id: number★ lib/logger/events.ts:205-222、設計は docs/logging-design.md:311-339
10 ★生SQL は ESLint で禁止★。どうしても要るときはタグ付きテンプレートだけを個別に disable し、理由を decisions に書く、というルール eslint.config.mjs:41-70(D-122)
11 画面は ★バッジを表示するだけ★(クリックできない) components/features/alerts/AlertsScreen.tsx:295-312
12 モックでは ★D-Sec アラートだけ★ がクリックで状態を回す(未対応→対応中→対応済み→未対応)。★事前チェックのアラートには操作が無い★ d-watch-mock.jsx:191-201 / :961(D-Sec)、:640-645(事前チェック。バッジのみ)
13 書き込みの見本(PATCH ルート・画面の備考編集)は既にある。★保存先はメインDBではなく DFA の JSON ストア★ app/api/ds-devices/[serial_number]/note/route.ts、components/features/ds-logs/DsLogScreen.tsx:245-293
14 DB 接続は1アカウント分の環境変数しか無い(DATABASE_URL または DATABASE_HOST/PORT/USER/PASSWORD/NAME) lib/db/prisma.ts:38-70、.env.example
15 ★ステージングへの4カラム UPDATE はユーザーが許可済み★(実データの対応状況が書き換わることを承知のうえ)。★それ以外の列への書き込みは禁止★ CLAUDE.md §3 の接続先注記 / D-152
16 ★ステージングのデータは TeamWorks が入れ替えることがある(時期未定)★。★0行のテーブル(clean_timetables など)には 10/5 中に何らかのデータが入る★ 第10回 N-6 / D-155 項5
17 ★robot_error_reports はエラー全般の表になる予定★(robot_id / serial_number を NULL 可にする DDL 変更待ち。両方 NULL=セキュリティ、robot_id だけ NULL=D-S制御盤) 第10回 N-5 / D-155 項5
18 DB の既定タイムゾーンは 'Asia/Tokyo' に変更済み(先方申告。★当方の再実測は未了★) 第10回 N-14 / D-155 項4

2. 範囲

やる やらない
★robot_error_reports の4カラムの UPDATE★(/alerts の事前チェック由来の行だけ) ★control_error_histories(D-Sec アラート)への書き込み★ — N-5 で D-Sec アラートの出どころが robot_error_reports に移る可能性があるため、★いまは作らない★(PATCH /ds-alerts/{id}/status は契約のまま残す)
PATCH /prechecks/{id}/status の実装(fixtures / Prisma の両方) 出発失敗(launch_failed)の対応状況の記録(書き込み先が無い。D-105 申し送りのまま)
/alerts の画面に「状態の切り替え」と「備考の編集」を足す 完了率・清掃結果・予定外タスクのコード(★指示書AS の範囲★)
書き込み監査ログ(db.write)を初めて出す 4カラム以外の列への書き込み(★権限も無い★。CLAUDE.md §7)
書き込み用の接続を別アカウントにできる器 スキーマの変更(★§3-1 の @updatedAt 1箇所だけ例外★)

3. 決定

3-1. ★@updatedAt の罠 — RobotErrorReport.updated_at から @updatedAt を外す★【要確認】

何が起きるか(★実測★。出所: 構造 — 当方が Prisma 7.10.0 の生成クライアントに★偽のドライバアダプタ★をつないで、送られる SQL を記録した。★DB には一切接続していない★)

いまのスキーマのまま updateMany で status / comment / responded_at だけを渡すと、Prisma は次の SQL を送る:

UPDATE `robot_error_reports` SET `status` = ?, `comment` = ?, `responded_at` = ?, `updated_at` = ? WHERE `robot_error_reports`.`error_id` = ?
-- updated_at の引数は "2026-10-02T11:35:11.700Z"(=アプリの時計の UTC)

update(1件更新)でも同じく updated_at = ? が入る(さらに前後に SELECT とトランザクションが付く)。

問題 中身
★5列目の書き込み★ CLAUDE.md §7 違反。★更新用アカウントは列単位の GRANT★(D-042)なので ★権限エラーで落ちる★
★値の時刻もずれる★ アダプタは getUTC* で文字列を作る(node_modules/@prisma/adapter-mariadb/dist/index.js の formatDateTime())ので、★JST の DB に UTC の壁時計値(9時間前)が入る★
★そもそも不要★ DDL に ON UPDATE current_timestamp() がある(第10回 N-14 c の回答でも確認)ので、★DB が自分で更新する★

検討した案

案 内容 判断
★(a)★ ★schema.prisma:336 から @updatedAt だけを外す★ ★採用★
(b) $executeRaw のタグ付きテンプレートで4列だけの UPDATE を書く 不採用。ESLint のルール上は個別 disable が可能(eslint.config.mjs:41-50)で、SQL も MySQL 互換の素の UPDATE なので TeamWorks への通知も要らない。だが ★「アプリに生SQLは1件も無い」状態(CLAUDE.md §3)が崩れる★、★列名・型の検査が効かなくなる★、★日時の書式を自前で扱う★ことになる。(a) で足りるので選ばない
(c) updateMany を使えば @updatedAt は付かないのでは ★不採用(実測で否定)★。上のとおり updateMany でも updated_at = ? が入る
(d) 書き込み専用に別のスキーマ/生成クライアントを持つ 不採用。生成が2系統になり CI の prisma generate も2本要る。スキーマが2つに割れて食い違う入口が増える
(e) $extends のクエリ拡張で updated_at を引数から抜く 不採用。updated_at は呼び出し側の引数ではなく、★クエリコンパイラ(wasm)が後から足している★ので抜けない

(a) を採る理由

  1. ★外した版で同じ実験をして、SET に渡した列しか出ないことを確かめた★(出所: 構造 — スキーマの複製から生成したクライアント+偽アダプタ。リポジトリのファイルは変更していない):
    UPDATE `robot_error_reports` SET `status` = ?, `comment` = ?, `responded_at` = ? WHERE `robot_error_reports`.`error_id` = ?
    (★control_error_histories は updated_at 列そのものが無い★ので、もともと SET status = ? だけになる。将来 D-Sec 側を作るときにこの罠は無い)
  2. ★D-056 に反しない★ — @updatedAt は ★DB 由来の事実ではなく Prisma 固有の属性★で、★db pull は出力しない★。 ★このプロジェクトは db pull のたびに @updatedAt を手で付け外ししている★(D-059 運用 docs/decisions.md:399、指示書Q 2-3 — 実測で HEAD 24箇所 → pull 後 20箇所)。 つまり ★「db pull 後に手で戻す」作業は既に毎回ある★。今回はそこに ★「robot_error_reports.updated_at だけは付けない」という例外を1つ足す★だけ
  3. ★壊れたら必ずテストが落ちるようにする★(段階2 のガードテスト)。誰かが「DDL に ON UPDATE があるから」と機械的に付け直しても、★npx prisma generate → npm test で赤くなる★

弱点(承知のうえで採る): db pull の手順書・D-059 の運用ルールに「例外1件」が増える。★記録は監督が D 番号で書く★(本書の作業者は docs/decisions.md に触らない)。

3-2. ★書き込みは updateMany(1文の UPDATE)で、書き込み用の接続から行う★【要確認】

  • ★読み取り(存在確認・更新前の値・更新後の値)は既存の getPrisma()(参照用)で行う★
  • ★書き込みは getWritePrisma()(新設)で、robotErrorReport.updateMany({ where: { error_id }, data }) の1文だけ★
  • ★update(1件更新)を使わない理由★: Prisma の update は ★SELECT → UPDATE → 全列の SELECT をトランザクションで包む★(実測)。 ★更新用アカウントに SELECT 権限があるかは分かっていない★(CLAUDE.md §3 は「4カラムのみ UPDATE 可」とだけ書いている)。 ★updateMany なら必要な権限は「4列の UPDATE」+「WHERE で読む error_id の SELECT」だけ★(MySQL は WHERE で読む列に SELECT 権限を要求する)
  • ★updateMany の戻り値(件数)で存在を判定しない★ — 存在確認は書く前に参照用で行う。件数が 0 なら「確認と書き込みの間に行が消えた」(N-6 の入れ替え)とみなして 404 にする (mariadb ドライバの既定 foundRows: true で「一致した行数」が返る。値が変わらなくても 1。ただしこの既定に依存した分岐は書かない)

環境変数(★値は絶対に書かない・コミットしない★)

変数 意味
DATABASE_WRITE_USER 書き込み用アカウントのユーザー名
DATABASE_WRITE_PASSWORD 同パスワード
  • ★接続先(ホスト・ポート・DB名)は参照用と同じ DB なので DATABASE_HOST / DATABASE_PORT / DATABASE_NAME を使う★(DATABASE_WRITE_* を使うときはこの3つが必須。URL 形式は設けない — %40 が壊れる問題(lib/db/prisma.ts:29-30)を持ち込まないため)
  • ★2つとも未設定なら、参照用の接続(getPrisma() と同じクライアント)で書く★ — ★いまはこれで動く★(ユーザー: 今のアカウントに書き込み権限がある見込み)
  • ★片方だけ設定されていたら、最初の書き込みで例外★(黙って参照用に倒さない)。メッセージに値を出さない
  • lib/db/prisma.ts に getWritePrisma() を足す。★globalThis.__dwatchPrismaWrite にキャッシュ★(getPrisma() と同じ理由)。★lib/db/index.ts の先頭で静的 import しない★(lib/db/prisma.ts:3-8 の理由)
  • scripts/check-env.ts が使う lib/config/env-check.ts に検査を足す: ★未設定=warn「書き込みは参照用アカウントで行います」★ / ★片方だけ=error★。★値は出さない★
  • .env.example に2つのキーを★コメントアウト・値なし★で足す

3-3. ★書き込んでよい行 — 「/alerts に事前チェック異常として出る行」だけ。それ以外は 404★【要確認】

PATCH /prechecks/{id}/status の {id} を robot_error_reports.error_id として引き、★次をすべて満たすときだけ書く★。

  1. error_id = {id} の行がある
  2. その行の clean_timetable_id が null でない
  3. その clean_timetables 行があり、★precheckMinutesFromCode(precheck) が null でない(=事前チェック行)★
  4. その事前チェック行の parent_id が指す本清掃行がある(無ければ prechecks() と同じく★例外★=500。lib/db/index.ts:1290-1301)

★1〜3 のどれかを満たさなければ 404★。これは prismaSource.prechecks() が事前チェック行を作る条件(lib/db/index.ts:1265-1285)と同じ。★条件を自前で書き直さず、同じ関数(precheckMinutesFromCode())を使う★。

  • ★出発失敗の id(clean_timetables.id の文字列)は、error_id として引いても当たらない → 404★。★別の分岐を作らない★ (404 のメッセージは「対応状況を記録できるアラートが見つかりません(出発失敗には記録先がありません)」)
  • ★N-5 の DDL 変更後に入ってくる D-S制御盤・セキュリティのエラー行★(clean_timetable_id が入らない見込み)も ★2 で 404 になる★ — ★/prechecks から D-Sec 側の行を書き換えられない★。これは意図した性質
  • ★期間(date_from/date_to)は見ない★(一覧の絞り込みであって、行の性質ではない)
  • fixtures 版は「lib/fixtures/prechecks.ts の id が一致し、かつ severity === "precheck"」で同じ判定にする

3-4. ★状態遷移と日時の規則(期待値表)★【要確認】

★純粋関数★ planPrecheckStatusUpdate(current, request, now) に閉じ込める(新設 lib/prechecks/status-update.ts)。 入力は★API の値★(resolution_status / responded_at / resolved_at は「真の瞬間」の Date か null / note)、 出力は★変える列だけ★を持つパッチ(キーが無い列は書かない)。★toDbJst() は出力をDBに書く直前に1回だけ通す★(この関数の中では通さない)。

不変条件(★この規則で保たれる形★。d-watch-api.yaml:1795-1804 の「対応開始日時(対応中以降で設定)」に合わせた): 未解消 → 両方 null / 対応中 → responded_at のみ / 解消 → 両方あり

now = 2026-10-02 08:15 JST、t1 = 07:00、t2 = 07:30 とする。

# 現在 responded_at resolved_at 要求 出力パッチ
1 未解消 null null 対応中 status=対応中, responded_at=now
2 未解消 null null 解消 status=解消, responded_at=now, resolved_at=now
3 未解消 null null 未解消 ★空(書かない)★
4 対応中 t1 null 解消 status=解消, resolved_at=now(★responded_at は変えない★)
5 対応中 ★null★(不正データ) null 解消 status=解消, responded_at=now, resolved_at=now
6 対応中 t1 null 対応中 ★空★
7 対応中 t1 null 未解消 status=未解消, responded_at=null
8 解消 t1 t2 未解消 status=未解消, responded_at=null, resolved_at=null(★戻すと両方消す★)
9 解消 t1 t2 対応中 status=対応中, resolved_at=null(responded_at は変えない)
10 解消 t1 t2 解消 ★空★(★resolved_at を now で上書きしない★)
11 未解消 ★t1★(先方が入れた等) null 対応中 status=対応中(★既にある responded_at は上書きしない★)
12 解消 t1 ★null★(不正データ) 解消 resolved_at=now
  • ★「戻すと両方消す」(#7・#8)を採る理由★: 上の不変条件を保てる。★ステージング検証で「元の4列に完全に戻せる」★(段階9)。 却下した案: 戻しても日時を残す — 「未解消なのに対応開始日時がある」行ができ、画面の意味が説明できなくなる
  • ★同時に2人が操作したら後勝ち★(行ロックはしない。P1 は社内の数名で、Prisma で SELECT … FOR UPDATE を書くには生SQLが要る)

3-5. ★リクエストの備考のキーは note に揃える(memo をやめる)★【要確認】

  • いまの契約は★リクエストが memo、レスポンス(PrecheckItem)が note★(d-watch-api.yaml:682 / :1805)。★同じ DB 列(comment)に2つの名前★がある
  • ★note に揃える★。CLAUDE.md §6-1 のとおり ★先に YAML を直す★。理由: 型・fixtures・画面・D-S制御盤の備考 API(/ds-devices/{serial_number}/note)がすべて note。★まだ誰も memo を送っていないので互換の問題は無い★
  • ★/ds-alerts/{id}/status の memo は変えない★(あちらはレスポンスの DsAlert.memo とも揃っていて食い違いが無い。範囲外)

3-6. 備考の規則(/ds-devices/{serial_number}/note の作法に揃える)

入力 扱い
note が無い ★comment は変えない★(状態だけの変更)
文字列以外(null・数値など) 422
★401文字以上(トリム前で数える)★ 422(DEVICE_NOTE_MAX_LENGTH と同じ理由。app/api/ds-devices/[serial_number]/note/route.ts:76-87)
空文字・空白だけ ★comment = null★(削除)
それ以外 ★前後の空白を除いて保存★
トリム後が現在値と同じ ★comment は書かない★
  • 上限 400 は ★lib/config/index.ts に PRECHECK_NOTE_MAX_LENGTH = 400 として置く★(ハードコード禁止。YAML の maxLength: 400 と一致させる)
  • status は必須(YAML どおり)。★備考だけ変えるときも画面は現在の status を送る★(#3・#6・#10 の「空」になり、日時は動かない)
  • ★本文に4つ以外のキー(updated_at・error_type など)が来ても無視する★。★パッチを作るのは planPrecheckStatusUpdate() だけ★なので、本文のキーが DB に届く経路を作らない(テストで固定する)

3-7. 書き込み監査ログ(db.write)

docs/logging-design.md §6(:311-339)の設計どおり、★成功・失敗の両方で1件ずつ出す★。

  • ★DbWriteLogEvent.record_id を number → string に変える★(lib/logger/events.ts:210。error_id は varchar。★Number() を通さない★ — D-105 項2 と同じ理由)。★このイベントはまだ一度も出していないので、壊れる利用者はいない★
  • 載せるもの: table: "robot_error_reports" / record_id(=error_id)/ columns(★実際に SET した列だけ★)/ status_old・status_new(API の enum 文字列)/ responded_at_old/new・resolved_at_old/new(JST の ISO 文字列か null)/ ★comment_changed: boolean★ / success / 失敗時の db_error_code
  • ★備考の中身は絶対に載せない★(comment / note / memo は禁止キー。docs/logging-design.md:159-172)。★監督の依頼文は「4列の旧値・新値」だが、備考は boolean だけにする★
  • db_error_code は ★Prisma のエラーコードとドライバのエラー番号(例: 列の権限エラー 1143)だけ★。★エラーメッセージは載せない★(DB のユーザー名が入る)
  • ★空パッチ(何も書かない)のときはログを出さない★(書き込みが起きていない)
  • docs/logging-design.md §6 の表に「record_id は文字列(robot_error_reports は error_id)」と1行足す

3-8. 画面(/alerts)— 最小限

モックの事前チェック画面には操作が無い(§1 #12)。★D-Sec アラートのモックの「クリックで状態を回すボタン」★と、★D-Secログの「備考のインライン編集」★(既に実装済み)を★そのまま借りる★。新しい部品は作らない。【要確認: モックに無い操作を足すこと】

  • ★severity === "precheck" の行だけ★:
    • 右上のバッジ(AlertsScreen.tsx:295-312)を★ボタンにする★。クリックで ★未解消 → 対応中 → 解消 → 未解消★(モック :191-201 と同じ順)。表示は今のバッジと同じ(解消は「解消 (日時)」)
    • 「予定: HH:MM — 備考」の★備考の部分をクリックで編集★(備考が無ければ t("クリックして備考を編集") を薄く出す)。★DsLogScreen.tsx:245-293 の作法をそのまま★: textarea・onBlur で確定・変えていなければ送らない・失敗時は編集状態のまま残す・maxLength は PRECHECK_NOTE_MAX_LENGTH
  • ★launch_failed の行は今のまま(バッジのみ・編集不可)★
  • 保存中はその行のボタンを無効にする(二度押しで2回進まない)
  • ★成功したら、レスポンスの PrecheckItem でその1行だけ差し替える★(一覧を取り直さない。DsLogScreen.tsx:252-256 の理由と同じ)。 ★絞り込み(未解消のみ)に合わなくなっても、次に取り直すまで行を消さない★(押し間違いをその場で戻せるように)
  • サマリ(未解消/解消/合計)は ★純粋関数 applyResolutionChange(summary, before, after)★(lib/prechecks/query.ts に追加)で差分だけ反映する
  • i18n(lib/i18n.ts): ★"未解消"・"対応中"・"解消" の対訳を足す★(D-105 で「キーが無い」と申し送られていたもの。足せば既存のバッジも英語になる)。 新規は "対応状況の更新に失敗しました": "Failed to update the status" だけ。備考まわりは既存キー(クリックして備考を編集 / 備考を入力 / 保存中… / 保存に失敗しました)を使う。★それ以外の未訳キーには手を出さない★

4. 段階

★各段階で npx tsc --noEmit -p . / npx eslint . / npm test が通ってからコミットする★(D-050)。

段階0 — 着手前の確認

  1. ★develop で作業する★(.claude/worktrees/ の中ではない。ワークツリーは古い main 基点)。git pull で最新にする
  2. ★AR と AS が先に入っていること★を git log で確かめる(本書は AR → AS → AT の順で渡している)
  3. npx prisma generate のあと npm test の件数を記録する(以後の増分の基準)
  4. ★作業ツリーに自分の作業でない変更(例: components/features/detail-log/CleanLogTable.tsx)があっても触らない・コミットに含めない★
  5. ★Next.js は版が新しい★。ルートを書く前に node_modules/next/dist/docs/ の Route Handler の項を読む(CLAUDE.md 末尾)。★既存の app/api/ds-devices/[serial_number]/note/route.ts の形(params は Promise)に合わせる★

段階1 — API 契約と型・設定値

  1. ★d-watch-api.yaml を先に直す★(:659-690):
    • memo → ★note★(§3-5)
    • description を足す: 書き込むのは robot_error_reports の4列だけ / §3-4 の日時の規則(不変条件と「戻すと両方消す」)/ §3-6 の備考の規則 / ★出発失敗と事前チェック以外の行は 404★ / 何も変わらない要求は書かずに現在の値を返す
    • 404 の説明に「launch_failed の id もここ」を書く
  2. lib/types/index.ts に ★PrecheckStatusUpdateRequest★({ status: PrecheckStatus; note?: string })を足す
  3. lib/config/index.ts に ★PRECHECK_NOTE_MAX_LENGTH = 400★ と、パスの error_id の長さ上限 ★PATH_ERROR_ID_MAX_LENGTH = 50★(varchar(50)。PATH_SERIAL_NUMBER_MAX_LENGTH と同じ作法)を足す
  4. lib/api/path-params.ts に ★parsePathErrorId()★ を足す(★空文字と51文字以上だけ 422、文字種は絞らない★。parsePathSerialNumber() :66 と同じ作法。テストも同じ形で)

段階2 — @updatedAt を外し、ガードテストで固定する(§3-1)

  1. prisma/schema.prisma:336 の ★@updatedAt だけ★を消す。@default(now()) と @db.Timestamp(0) は残す。★他のモデルの @updatedAt は1つも触らない★
  2. 直上にコメントを書く(例):
    // ★@updatedAt を付けないこと(DDL に ON UPDATE current_timestamp() はある)★
    //   アプリがこの表の4カラム(status/comment/responded_at/resolved_at)を UPDATE するため。
    //   付けると Prisma が updated_at = <アプリの時刻(UTC)> を SET に足し、
    //   ①列単位 GRANT の更新用アカウントでは権限エラー ②JST の DB に UTC の値が入る。
    //   DB が ON UPDATE で自分で更新する。db pull 後に @updatedAt を付け直す作業の★唯一の例外★。
    //   lib/db/__tests__/robot-error-write-sql.test.ts が固定している(指示書AT §3-1)
  3. ★ファイル冒頭のコメント(:29-33 付近の「db pull 後に移植し直すこと」)にも1行足す★: 「RobotErrorReport.updated_at には @updatedAt を付けない(指示書AT)」
  4. npx prisma generate
  5. ★ガードテストを書く★(新設 lib/db/__tests__/robot-error-write-sql.test.ts):
    • ★本物の生成クライアント★(@/lib/generated/prisma/client)を、★SQL を記録するだけの偽アダプタ★でつなぐ(型は import type { SqlDriverAdapterFactory } from "@prisma/client/runtime/client"。any は使わない)。★DB には接続しない★
    • ★段階4 で作る書き込み関数そのもの★を、この偽クライアントを渡して呼ぶ(テスト用に別の書き方をしない)
    • 期待: 記録された UPDATE 文の SET の列が ★渡したパッチの列と完全に一致し、updated_at を含まない★。4列すべてを渡したときも同じ
    • 期待: 日時の引数が ★壁時計値★ になっている(now = 08:15 JST を渡すと引数は "2026-10-02T08:15:00.000Z"。toDbJst() の付け忘れを検出する)
    • ★参考★: 本書の作成時に同じ形の実験を tsx で行い、動くことを確認済み(§3-1)。vitest は environment: "node"(vitest.config :13)なので wasm のクエリコンパイラも動くはず。★動かなければ止まって報告★
    • ★CI は npx prisma generate のあとに npm test を走らせる★(.github/workflows/ci.yml)のでこのテストは CI でも動く
  6. ★このテストが @updatedAt を戻すと落ちることを一度だけ手元で確かめる★(戻して generate → 赤 → 外して generate → 緑)。★戻した状態はコミットしない★

段階3 — 純粋関数(期待値表 → テスト → 実装。CLAUDE.md §6-2)

  1. lib/prechecks/__tests__/status-update.test.ts に ★§3-4 の12行と §3-6 の備考の表★をそのまま書く。★先にテストを書いて赤を確かめる★
  2. lib/prechecks/status-update.ts に planPrecheckStatusUpdate() を実装する。
    • 戻り値の型は ★4列だけのキーを持つ型★(例: { status?: PrecheckStatus; note?: string | null; responded_at?: Date | null; resolved_at?: Date | null })
    • ★now は引数で受け取る★(関数の中で new Date() しない)
  3. lib/prechecks/query.ts に applyResolutionChange() とテスト(未解消↔対応中は件数が動かない / 解消に入る・出るで unresolved と resolved が1ずつ動く / total は不変)

段階4 — データ層

  1. ★prechecks() の行の組み立て(lib/db/index.ts:1303-1336)を純粋関数に切り出す★ — lib/db/precheck-mapping.ts に precheckItemFromErrorRow(report, precheckMinutes, mainRow)。 ★prechecks() もこの関数を使うように置き換える★(★同じ行の作り方を2箇所に書かない★)。 ★既存の app/api/prechecks/__tests__/route.test.ts が無修正で通ること★=挙動が変わっていない証拠
  2. DataSource(lib/db/index.ts:186)に2つ足す:
    • precheckItemById(id: string): Promise<PrecheckItem | null> — §3-3 の1〜4。★事前チェックでなければ null★
    • updatePrecheckErrorColumns(errorId: string, patch): Promise<{ matched: number }> — ★パッチを DB の列に写して updateMany を1回★
      • status → robotErrorStatusFromResolutionStatus()(lib/db/precheck-mapping.ts:48。★docstring の「seed 用」を「seed とアプリの書き込み」に直す★)
      • note → comment
      • responded_at / resolved_at → ★toDbJst()★(null はそのまま null)
      • ★写した結果のキーが4列以外を含んだら例外★(実行時の歯止め。型で防げていても残す)
      • ★書き込みの中身は lib/db/robot-error-write.ts(新設)の関数1つに置き、クライアントを引数で受ける★(段階2 のガードテストが同じ関数を呼べるように)
  3. ★fixtures 版★: lib/fixtures/prechecks.ts は書き換えず、★モジュール内の上書き表(id → 4項目)★を持つ。fixtureSource.prechecks() と precheckItemById() は上書きを反映して返す。テスト用に resetFixturePrecheckOverrides() を出す。★プロセスを再起動すると消える★(それで良い。fixtures は開発・デモ用。D-045)
  4. lib/db/prisma.ts に getWritePrisma()(§3-2)
  5. 古くなったコメントを直す:
    • app/api/prechecks/route.ts:7「PATCH は今回作らない」
    • lib/format/date.ts:76-80「アプリからの DB 書き込みはまだ無い」
    • lib/db/index.ts:1392「将来…書き込み先が無い」は ★事実なので残し★、「PATCH は 404 を返す(指示書AT §3-3)」を足す

段階5 — ルート PATCH /prechecks/{id}/status

新設 app/api/prechecks/[id]/status/route.ts。順序は ★形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し★(app/api/ds-devices/[serial_number]/note/route.ts と同じ考え方)。

  1. parsePathErrorId() → 失敗は 422
  2. 本文: JSON でない → 422 / status が enum 外 → 422 / note の型・長さ(§3-6)→ 422
  3. const now = new Date() を★1回だけ★作る
  4. db.precheckItemById(id) → null なら 404。例外は 500(logApiError)
  5. planPrecheckStatusUpdate(before, request, now) → ★空なら書かずに 200 で before を返す★
  6. db.updatePrecheckErrorColumns(id, patch):
    • 例外 → ★db.write(success: false)★ と api.error を出して 500
    • matched === 0 → db.write(success: false, db_error_code: "no_matching_row")を出して 404
  7. db.precheckItemById(id) で読み直す → ★db.write(success: true)★ → 200 で返す(読み直しで null なら 404。ログは success のまま)
  8. ★withRequestId() で包む★(既存ルートと同じ)

段階6 — 画面(§3-8)

components/features/alerts/AlertsScreen.tsx(と .module.css)、lib/i18n.ts。★launch_failed の行の見た目が変わっていないこと★を目で確かめる。

段階7 — 環境変数の検査

lib/config/env-check.ts と lib/config/__tests__/env-check.test.ts(§3-2)。.env.example。★テストに「出力に値が含まれない」ことを足す★(既存の :210-222 と同じ形)。

段階8 — ローカルDB(dwatch_local)で確かめる

  1. .env.local を dwatch_local に向け、USE_FIXTURES=false。npm run seed:local(★lib/db/local-guard.ts がローカル以外を拒否する★)
  2. ★列単位の権限を再現したローカル専用ユーザーを作る★(★本番・ステージングの権限を模すため。パスワードは手元で決め、文書にもコミットにも書かない★):
    CREATE USER 'dwatch_write_local'@'localhost' IDENTIFIED BY '<手元で決めた値>';
    GRANT SELECT (error_id) ON dwatch_local.robot_error_reports TO 'dwatch_write_local'@'localhost';
    GRANT UPDATE (status, comment, responded_at, resolved_at) ON dwatch_local.robot_error_reports TO 'dwatch_write_local'@'localhost';
    .env.local に DATABASE_WRITE_USER / DATABASE_WRITE_PASSWORD(ローカル専用の値)を入れる。 ★認証方式の違いで接続できなければ、root と同じ方式で作り直す(SELECT user, plugin FROM mysql.user)。それでも駄目なら 8-2 は飛ばして報告★
  3. npm run dev → /alerts で事前チェックの行を1件選び、★未解消 → 対応中 → 解消 → 未解消★、★備考を入れる → 空にする★を画面から行う。各回で:
    • 画面の表示・サマリの件数が §3-4 / §3-8 どおり
    • サーバーのログに db.write が success: true で出て、columns が実際に変えた列だけ・備考の中身が無い
    • ★権限エラー(1143)が出ない★ = ★5列目を書いていない★ことの実地の確認
  4. ★@updatedAt を戻した状態でも一度だけ試し、1143(updated_at への権限エラー)で落ちて db.write が success: false で出ることを確かめる★(§3-1 の実地の裏付け。★戻した状態はコミットしない★)
  5. mysql で ★その1行の updated_at を読むだけ★ — 操作の時刻(JST)に更新されていること(★DB 側の ON UPDATE が動いている★)
  6. 出発失敗の行・存在しない id への PATCH が 404 になること(curl 等で。認証 cookie が要る)
  7. 結果(件数・時刻・ログの抜粋。★パスワードやユーザー名は貼らない★)を作業報告に書く

段階9 — ステージングで確かめる(★条件付き。満たさなければ止まって報告★)

着手の条件(すべて):

  • ステージングに接続できる(★2026-10-02 は認証エラーで接続できていない★。D-155 項4。★別の認証情報を探したり試したりしない★)
  • ★ステージングに事前チェック行がある★ — clean_timetables は 10-01 時点で0行。第10回 N-6 で「10/5 中に何らかのデータを入れる」。★/alerts に事前チェックの行が1件も出なければ検証できない★ので、そこで止める

手順:

  1. ★書き込むのは1行だけ★。/alerts(全件)から ★事前チェックで、未解消・responded_at/resolved_at/備考がすべて空の行★を1件選ぶ。★無ければ止まって報告(勝手に別の状態の行を選ばない)★
  2. ★操作前の値を記録する★: error_id と4列(画面・API の値)。★updated_at は読むだけ★(mysql で SELECT error_id, status, comment, responded_at, resolved_at, updated_at FROM robot_error_reports WHERE error_id = ?。★UPDATE / DELETE / INSERT 文を手で打たない★)
  3. 段階8-3 と同じ操作(未解消 → 対応中 → 解消 → 未解消、備考を入れる → 空にする)
  4. ★最後に4列が操作前と完全に一致すること★を確かめる(§3-4 #8 の「戻すと両方消す」と §3-6 の「空で null」で元に戻る)。★updated_at だけは DB が更新するので戻らない★ — 値を記録して報告する
  5. ★updated_at が操作の時刻の JST になっているか★を記録する(第10回 N-14「DB の既定を Asia/Tokyo にした」の再実測を兼ねる。★9時間ずれていても補正しない・報告だけ★)
  6. ★途中でステージングのデータが入れ替わった(行が消えた・別物になった)ら止めて報告★(N-6)。★他の行に書き込んで代わりにしない★
  7. ★npm run seed:local をステージングに向けない★(テーブルを消して入れ直すので実データが消える)

段階10 — 確認とコミット

  1. npx tsc --noEmit -p . — 0件
  2. npx eslint . — 0件(★eslint-disable を1つも足していないこと★)
  3. npm test — 全件通過(段階0 の件数から増えていること)
  4. npm run build(USE_FIXTURES=true)が通る
  5. ★git diff --stat に CLAUDE.md / docs/decisions.md / control_error_histories を書く変更が無い★こと
  6. ★grep -rn "\$executeRaw\|\$queryRaw" app lib components★ が0件のまま

5. テスト一覧

ファイル 中身
lib/prechecks/__tests__/status-update.test.ts(新) §3-4 の12行 / §3-6 の備考の表 / ★出力のキーが4つ以外を含まない★ / now 以外の時刻を使わない
lib/prechecks/__tests__/query.test.ts applyResolutionChange()
lib/db/__tests__/robot-error-write-sql.test.ts(新) ★本物の生成クライアント+偽アダプタ★で、SET が渡した列だけ・updated_at を含まない / 日時が壁時計値 / ★4列以外のキーを渡すと例外★
lib/db/__tests__/precheck-mapping.test.ts precheckItemFromErrorRow()(既存の prechecks() の期待値と同じ行ができる)
lib/api/__tests__/path-params.test.ts parsePathErrorId()(空 / 50文字 / 51文字 / 記号を含む)
app/api/prechecks/[id]/status/__tests__/route.test.ts(新。getPrisma / getWritePrisma をスタブ。app/api/prechecks/__tests__/route.test.ts:153-170 の作法) 200(各遷移)/ ★空パッチなら updateMany を呼ばない★ / ★updateMany の data のキーが4列のうちの該当分だけ★ / ★本文に updated_at・error_type を混ぜても data に出ない★ / 422(id・JSON・status・note の型・401文字)/ ★404: 行が無い・clean_timetable_id null・本清掃行を指すエラー・出発失敗の id★ / matched === 0 で 404 / 書き込み例外で 500 / ★db.write が成功・失敗で1件ずつ出て、record_id が文字列・備考の中身を含まない★ / ★responded_at に toDbJst() が通っている★
app/api/prechecks/[id]/status/__tests__/route.fixtures.test.ts(新) fixtures で PATCH → GET /prechecks に反映される / ★launch_failed の id("4"・"8")は 404★
lib/config/__tests__/env-check.test.ts 書き込み用の変数: 未設定=warn / 片方だけ=error / 値が出力に出ない
app/api/prechecks/__tests__/route.test.ts ★無修正で通ること★(段階4-1 の切り出しで挙動が変わっていない)

6. 完了条件

  1. /alerts で事前チェックの行の状態と備考を変えられ、★再読み込みしても残っている★(ローカルDB)
  2. ★DB に送られる UPDATE が4列のうちの必要な分だけ★であることが、★テスト(偽アダプタ)と実地(列単位 GRANT のユーザーで 1143 が出ない)の両方★で確かめられている
  3. ★@updatedAt を戻すとガードテストが落ちる★ことを確かめてある
  4. 出発失敗・事前チェック以外の行は 404
  5. db.write が成功・失敗とも出て、備考の中身・認証情報を含まない
  6. d-watch-api.yaml が実装と一致している(note、404 の説明、日時の規則)
  7. 段階9 は ★実施して元に戻したか、条件を満たさず止めたか★のどちらかが報告されている
  8. tsc / eslint / test / build がすべて通る

7. 触ってはいけないもの・禁止事項

  • ★CLAUDE.md・docs/decisions.md★(記録は監督が書く)
  • ★control_error_histories への書き込み★・PATCH /ds-alerts/{id}/status の実装・D-Sec アラート画面(§2)
  • ★完了率・清掃結果・予定外タスクのコード★(★指示書AS の範囲★。lib/judge/kpi.ts など)
  • ★スキーマの変更★ — ★§3-1 の @updatedAt 1箇所とそのコメントだけ例外★。db pull は実行しない
  • ★4カラム以外への書き込み★・★生SQL($executeRaw / $queryRaw)と eslint-disable の追加★
  • ★ステージングへの npm run seed:local★・★ステージングで1行より多く書き込むこと★・★mysql からの手動の UPDATE / INSERT / DELETE★
  • ★認証情報(パスワード・接続文字列・DB のユーザー名)を文書・コード・テスト・コミット・作業報告に書くこと★(D-154 項3 の事故。例に実物を使わない)
  • ★lib/fixtures/prechecks.ts の中身★(§9 の食い違いは申し送り)
  • ★git add -A★ — ファイルパスを明示する

8. コミット

段階ごとに分けてよい(例: 段階1 / 段階2 / 段階3 / 段階4〜5 / 段階6〜7 / 段階8〜9 の記録)。 ★段階2 のコミットメッセージには「なぜ @updatedAt を外すか(5列目の書き込み・権限・タイムゾーン)」と「ガードテストの場所」を書く★。


9. 申し送り(本書では直さない)

# 内容 次に動く契機
1 ★N-5 の DDL 変更(robot_id / serial_number を NULL 可)★のあと db pull すると、precheckItemFromErrorRow() で serial_number が `string \ nullになり tsc が止まる。★その時に扱いを決める★(事前チェックの行はロボットのエラーなので NULL は来ない見込み)。★db pull のあとRobotErrorReport.updated_atに@updatedAt` が付いていないこと★も確かめる(ガードテストが見張っている) TeamWorks の DDL 変更
2 ★D-Sec アラートの対応状況(control_error_histories)★は、N-5 で D-Sec のエラーが robot_error_reports に入るなら書き込み先が変わる。★control_error_histories には updated_at 列が無いので §3-1 の罠は無い★(実測) N-5 の食い違いの確認(第11回)
3 ★更新用アカウントに SELECT 権限(少なくとも error_id)があるか★ — 無いと updateMany の WHERE で権限エラーになる。★別アカウントを使い始める前に TeamWorks に確認★ 第11回 / 書き込み用アカウントの発行時
4 lib/fixtures/prechecks.ts の launch_failed(id "4" / "8")が ★対応中・解消で備考あり★になっている。★実DB では常に未解消・備考なし(D-104)★と食い違う fixtures の見直し時
5 ★出発失敗の対応状況の記録★(D-105 の2案: DFA 独自ストア / TeamWorks にエラー行を作ってもらう)。N-5 で「エラー全般の表」になるなら後者が通りやすい 監督の判断
6 同時操作は後勝ち(§3-4)。問題が出たら WHERE status = <旧値> を足す楽観ロックを検討 運用で問題が出たとき

10. 監督の確認が要る項目(一覧)

# 項目 推奨(本文に書き込み済み)
1 §3-1 RobotErrorReport.updated_at から @updatedAt を外す(D-059 の運用ルールに例外を1つ足す) 外す+ガードテスト
2 §3-2 書き込みは updateMany / 書き込み用の環境変数 DATABASE_WRITE_USER・DATABASE_WRITE_PASSWORD(未設定なら参照用で書く・片方だけなら例外) そのとおり
3 §3-3 /prechecks から書けるのは事前チェック異常の行だけ(出発失敗・本清掃行のエラー・予定 ID が NULL の行は 404) そのとおり
4 §3-4 日時の規則。特に ★戻すと responded_at / resolved_at を消す★・★解消済みを再度「解消」にしても日時を上書きしない★ そのとおり
5 §3-5 リクエストの memo を note に改名(YAML を先に直す) 改名する
6 §3-8 モックに無い操作(状態ボタン・備考編集)を事前チェックの行に足す D-Sec のモックと D-Secログの作法を借りて足す
7 段階9 ステージング検証 — 今は接続できず事前チェック行も無い。★ローカル検証だけで AT を完了扱いにし、ステージングは条件が揃ってから★でよいか よい(完了条件 7)
8 段階9 で updated_at だけは元に戻らない(DB が更新する)ことを受け入れるか 受け入れる(ユーザーは実データの書き換えを承知済み。D-152)