コンテンツにスキップ

車両コスト・OCR

本書は AGENTS.md の分冊(正典・AGENTS.md と同格)。優先順位は「実コード → AGENTS.md(本書を含む)→ DESIGN.md → PRODUCT.md」。 節は AGENTS.md から逐語で移設した(PR #nnn・2026-09-16)。対象パス: apps/api/src/routes/vehicle-costs.ts` `apps/api/src/ocr/**` `apps/web/src/components/cost/**` `supabase/migrations/*vehicle_cost*` `supabase/migrations/*ocr*。この領域に触るワーカー / レビュアは本書を読む。

Bulk OCR Import(不変条件ラチェット・mig123)

マニフェスト画像の一括取込は、既存 POST /manifests/ocr をブラウザから逐次 fan-out し、永続レビューキューで作成権限者が確認して manifests へ昇格する機能である。実体は supabase/migrations/00000000000123_ocr_import_queue.sqlapps/web/src/lib/ocr-import/run-bulk-ocr.tsapps/web/src/pages/manifest/BulkManifestImportPage.tsxapps/web/src/pages/manifest/ManifestReviewQueuePage.tsx

  • テナント / 可視性: ocr_import_batches / ocr_import_itemstenant_id=current_tenant_id() で分離し、共有スコープは canCreateManifest と同じく discharger または collector の staff(admin/normal)。external_partner は対象外。driver は mig175 で「自分の batch」スコープの SELECT/INSERT/UPDATE を持つcreated_by = 自 profile の batch とその items のみ・列 whitelist ガード付き・二次搬出マニフェスト写真の経路)。他テナントの batch/item は全ロールで不可視。Storage manifest-import-images もパス先頭フォルダ <tenant_id>/... を RLS で強制する(driver は own-tenant folder への INSERT のみ・SELECT/DELETE なし)。
  • server-owned / 監査列: deleted_at / deleted_byocr_import_batches_guard_protected_cols / ocr_import_items_guard_protected_cols(SECURITY INVOKER)の client 直書き guard で遮断する。discard は物理 DELETE ではなく status=discarded の通常 UPDATE 経路で行う。tenant_id / created_by は fill trigger の権威であり、client payload から供給しない。
  • confidence: ocr_import_items.confidence numeric(4,3)null または >= 0 and <= 1 and <> 'NaN'::numeric。要レビュー判定は API apps/api/src/ocr/scoring.ts が算出した lowConfidenceFields と required fields 欠落を使い、Web は閾値(per-field < 0.7)を複製しない。
  • 処理モデル: サーバ背景処理・Queue/DO は作らず、ブラウザの runBulkOcr が concurrency=1 で downscale→既存 /manifests/ocr→item 永続化を行う。429 は当該 item を failed として残りを rate_limited で打ち切り、その他の失敗は failed 保存後に継続する。各 item 完了時に onProgress(done,total) を通知し、1バッチ上限は 50 枚(MAX_BULK_BATCH_SIZE)。完了時に ocr_import_batches.statusrunBulkOcr の結果(done / rate_limited)へ更新する(driver 経路は markManifestOcrBatchDonedone)。
  • 保存前画像圧縮(wl-k2z): マニフェスト一括取込の画像は Storage 保存前に optimizeImageForUploaddownscaleImageForOcr と同じ長辺 2048px / JPEG quality 85%、4.8MiB 超は quality 70% 再試行)を通し、圧縮済みの同じ blob を Storage と OCR の双方へ渡す。ブラウザの画像デコード/エンコードに失敗した場合は原本を保存する(写真を失わないことを容量より優先)。PDF 由来 JPEG も実測サイズが 4.8MiB を超える場合だけ同じ圧縮ヘルパーで再エンコードする。保存前ヘルパーは免許画像経路とも共有し、呼び出し側へ try/catch を複製しない。
  • 確定 / 画像: レビューは作成権限者共有キューで行い、低信頼項目を修正してから findManifestByNumber で重複確認する。非衝突は Web の authenticated Supabase RLS 直挿入、衝突は明示的な既存 manifest 上書きで確定し、image_url には private Storage のパス(署名 URL の対象)を保存する。確定後は item に manifest_id / reviewed_by / reviewed_at を記録する。service-role 経路・サーバ背景昇格経路は作らない。
  • 画像の保持: manifest-import-images に保存期限を設定せず、自動削除ジョブも追加しない。法定記録の裏付けであるため、容量対策は保存前圧縮で行う。既存の未圧縮画像は原本を後から書き換えないため遡って圧縮しない。
  • 回帰: scripts/proofs/mig123-ocr-import-queue.sql を含む scripts/proofs/*.sql 全流しで、作成権限者 allow・driver/越境 deny・監査列 guard・Storage tenant folder・confidence NaN 拒否・sentinel mig123_ocr_import_applied() を固定する。新しい bulk OCR 実装は Web unit/render tests と pnpm verify を通過してから完了とする。

Cost Receipt OCR(不変条件ラチェット・2026-07-13)

領収書のモバイル複数撮影 OCR は、既存の請求書 OCR と vehicle_cost_entries を再利用する加法機能であり、migration は持たない。OCR 明細(line items)は既存のコスト明細受け皿へ加法的に配線する。実体は apps/api/src/routes/vehicle-costs.tsapps/api/src/ocr/receipt-pipeline.tsapps/web/src/lib/receipt-ocr.tsapps/web/src/components/cost/UnifiedCostIntake.tsxapps/web/src/pages/collector/fleet/VehicleCostNewPage.tsx である。旧 ImportPage は削除済みで、旧 /collector/fleet/costs/import URL は App.tsx/collector/fleet/costs/new replace redirect だけを維持する。

  • 統合 batch 不変条件: コスト OCR の到達フローは /collector/fleet/costs/new の multi-card batch(原本画像+低信頼+一括保存・nav なし・失敗/429 surface)。単票 blind 反映は廃止。ImportPage は削除。

  • route / authz: endpoint は POST /vehicle-costs/ocr?docType=receipt に固定し、請求書と同じ requireCollectorStaff 境界を必ず通る。docType の dispatch は invoice / receipt の明示 whitelist のみ(動的 import・eval・文字列連結による実行経路を作らない)。未知の docType は rate-limit 予約より前に HTTP 400 とし、未指定および form の invoice は既存請求書経路へ後方互換に dispatch する。

  • rate limit / cost: receipt と invoice は同一の public.ocr_daily_usage (tenant_id, usage_date) 共有日次枠(200/日)を使い、receipt fan-out は concurrency=1、429 で残りを打ち切り、その他の失敗は 継続する。Vision→cheap→high の既存 tier escalation(Google Vision で文字抽出 → Claude OCR_MODEL_CHEAP=claude-sonnet-5OCR_MODEL_HIGH=claude-opus-4-8・既定 fallback claude-haiku-4-5。実体は apps/api/src/ocr/extractor.tsresolveModels + apps/api/wrangler.toml)の固定運用を変更しない。claude-sonnet-4-6 は実在しないモデル ID で 2026-07-21 に本番 OCR を全停止させた(#289 hotfix)ので戻してはならない。切替は staging 疎通確認を先行させる。
  • 一次 tier の hard failure: Vision が cheap を選んだ一次 tier の抽出が null(非200・ネットワーク・パース不能)でも、high tier へ1 回だけ自動 escalate する。2026-07-21 の本番停止と同型(cheap の不正モデル ID / upstream 障害で OCR 全体が 502)を落とし、壊れた cheap tier だけで全停止させないためである。日次 200 件の枠予約は service_role 限定の原子 DB UPDATE(reserve_ocr_daily_quota・mig266)で、条件付き UPDATE / ON CONFLICT … WHERE は行ロック + EvalPlanQual で並行リクエストでも上限を超えない(mig050 の招待ゲートと同型)。枠ストアが到達不能・未設定のときは fail-closed(503/429)し、旧 KV の best-effort 超過・fail-open には戻さない。201 並列の回帰テストは apps/api/src/ocr/rate-limit.test.ts。この経路が増やすコストは hard fail したリクエスト 1 件につき high tier 1 回だけで、リクエスト数そのものは増えず、per-request の tier 試行倍率は 2 倍が上限である。cheap が系統的に壊れた日に OCR が丸ごと 502 になって全停止するより、この増分を受け入れる。high も失敗したら null(502)のまま、Vision が最初から high を選んだ場合は再試行しない。低信頼による既存の escalate とは reason 別ログで区別する(apps/api/src/ocr/pipeline.ts)。
  • response numeric safety: ReceiptAmount(税込金額・税額)は nullable だが finite かつ nonnegative を要求し、NaN / Infinity / 負値を受理しない。Web の validateCostDraft / costDraftToInsert も finite・正値を再検証してから保存する。
  • line-item extraction / cost: 請求書・領収書の lineItems は既存の構造化 JSON の同一 OCR 呼び出しの出力拡張で抽出し、追加の Vision/Claude 呼び出し・課金を発生させない。請求書ブロック/領収書 1 枚あたりの明細行上限は 40 行MAX_LINE_ITEMS=40)で、空品名・非有限 amount は保存対象から除外する。
  • parent amount authority: vehicle_cost_entries.amount は親の税込合計として正(権威値)であり、明細は内訳である。Σ明細と親 amount の乖離は許容し、確定をブロックせず Web では非ブロックのヒントとして表示する(値引き行・税等を許容する mig109 契約)。
  • review / persistence: Web の請求書・領収書 DraftCard は lowConfidenceFields'lineItems' があるかに依存せず、明細行を常時表示・編集可能にする。保存は親 vehicle_cost_entries 作成後に同じ entry.idvehicle_cost_line_items を insert し、行 0 件なら子 insert を呼ばない。子 insert だけ失敗しても親を巻き戻さず、非ブロック警告を出す。
  • persistence / tenant: receipt は 1 写真=1 vehicle_cost_entries 行(親の税込 total)で、抽出・編集された明細があれば vehicle_cost_line_items の子行として保存する。vehicleId は receipt draft のレビューで必須、未選択なら確定不可。保存は既存 authenticated client の vehicle_cost_entries/vehicle_cost_line_items RLS を継承し、service-role・cross-tenant・車両なしの経路を作らない。
  • mobile review: Web は accept="image/*"capture="environment"multiple で最大 MAX_BULK_BATCH_SIZE 枚を逐次処理し、店名・日付・金額・費目・メモ・低信頼表示・必須車両セレクタをレビューカードで確認してから登録する。
  • 回帰: apps/api の route/authz/rate-limit/whitelist テスト(201 並列の原子予約テスト含む)、packages/shared の ReceiptAmount NaN 安全テスト、Web の fan-out/draft/render テストと旧 /collector/fleet/costs/import redirect 回帰を維持し、scripts/proofs/mig266-ocr-daily-quota.sql を含む docker proof と pnpm verify を通過してから完了とする。

OCR 日次枠の原子予約(不変条件ラチェット・mig266)

両 OCR ルート(POST /manifests/ocrPOST /vehicle-costs/ocr)が共有する 200/tenant/UTC 日 (= 09:00 JST リセット)の予約は reserve_ocr_daily_quotaSECURITY DEFINERsearch_path=''PUBLIC/anon/authenticated から revoke・service_role のみ EXECUTE)の単文の原子ゲートだけを通す。 API は authMeta.tenantId(client 供給の tenant を受けない)を渡し、DB 不通・鍵未設定・RPC error は fail-closed(503、上限到達の 429 と文言で区別)。払戻し(release_ocr_daily_quota)は課金対象呼出し前の 失敗(!billableCallStarted)にだけ使い、課金開始後の失敗は枠を消費したままにする。now は リクエスト先頭で 1 回だけ固定し(UTC 日跨ぎで予約と払戻しの行がずれない)、used_countinteger (NaN/Infinity CHECK・Q6-10 の義務なし)。実 pipeline 3 種は入口で markBillableCallStarted を 同期呼出しするため、実ルーター経路の upstream 失敗は課金開始後扱い=払戻しなしであり、払戻し分岐の 回帰は handle-ocr-request.test.ts(mark 前 throw → release 呼出し)が担う。旧 KV 経路 (checkOcrRateLimit / ocrRateLimitKey / refundOcrRateLimit)は削除済み。OCR_RATELIMIT binding は rollback 用に 1 リリース残す。

Cost Input Integrated OCR P1(不変条件ラチェット・mig133)

請求書/領収書 OCR をコスト入力の新規ページへ統合し、請求書 PDF の1枚目分類と車両なし共通費を扱う P1。P2 の訂正ログ・few-shot 学習は本スライスに含めない。実体は supabase/migrations/00000000000133_vehicle_cost_common_rows.sqlapps/api/src/ocr/invoice-extractor.tsapps/web/src/lib/invoice-ocr.tsapps/web/src/lib/cost-ocr/route-pdf-pages.tsapps/web/src/components/cost/UnifiedCostIntake.tsxapps/web/src/pages/collector/fleet/VehicleCostNewPage.tsx

  • 共通コスト行(mig133): vehicle_cost_entries.vehicle_id は NULL 可。vehicle_id IS NULL は請求書合計表の共通費 1 行だけに使い、単票/領収書の未照合車両を NULL のまま保存する経路を作らない。vehicle_cost_entries_fill_from_vehicleBEFORE INSERT OR UPDATE(全列)で、非 NULL の vehicle は車両から tenant を再導出し、NULL の vehicle は current_tenant_id() を再導出する。NULL tenant や認証コンテキスト不在は fail-closed で拒否し、client の tenant 供給値を権威にしない。SELECT/RLS と P&L も共通行を自テナント範囲で扱い、secondary scope は vehicle が NULL の行を含めない。
  • 請求書 API 契約: 既存の請求書 OCR route・block の必須項目と認証/rate-limit 契約は非破壊で、docStructuresingle_slip/aggregate_summary)と top-level lowConfidenceFields を加法する。分類不確実時は必ず single_sliplowConfidenceFieldsdocStructure へ倒す。aggregate 用の税額・登録番号・費目注記は任意の加法フィールドで、単票の既存保存契約を変更しない。領収書 OCR route と、領収書の車両必須・未選択保存不可は不変。
  • PDF 分類と上限: PDF は1枚目を先行 OCR する。確信できる aggregate_summary は1枚目だけを解析し、残ページを OCR せず、OCR block を重複計上しない共通(車両なし)コスト 1 行へ変換する。single_slip または低確信は安全側に全ページを順次 OCR し、実 PDF のページ番号を保持する。解析対象は先頭 30 ページ(超過は警告)で、既存の concurrency=1・429 打切り・失敗継続契約を維持する。
  • Web 導線: UnifiedCostIntake は NewPage に統合し、請求書 PDF/画像と領収書写真の選択・進捗・下書き選択を同一ページで提供する。合計表の draft は vehicle_id=NULL で保存可能、単票の未照合 draft と領収書 draft は車両選択を要求する。旧 /collector/fleet/costs/import/collector/fleet/costs/new へ replace redirect し、Dashboard は OCR 専用ボタンを残さず「コスト入力」1 本へ集約する。原本 Storage、明細子行、非ブロックのアップロード/明細警告は既存契約を維持する。
  • 実績費目提案: 未削除の自テナント vehicle_cost_entries の直近 20 件から、vendor を NFKC・trim・大小・空白正規化して同一 vendor の最頻 category を提案する。同数は最新行を採用し、該当なしは OCR の suggestedCategory へ fallback。NewPage では実績提案を OCR 推論より優先して初期値にし、「過去の登録から」を表示するが、ユーザーが費目を変更した後に上書きしない。
  • 車番照合: matchPlateToVehicle の fail-closed 完全一致(NFKC、かな、空白/ハイフン除去、曖昧衝突は未照合)を維持し、神戸830さ6980 / 神戸 830 さ 69-80 など実データ変形を同一車両へ照合する回帰を共有テストに固定する。
  • 回帰 / proof: route-pdf-pagesrunInvoiceOcrDocument・NewPage 取込ゾーン・旧 ImportPage 回帰・vendor suggestion・plate 正規化をテストする。mig133 proof を含む scripts/proofs/*.sql 全流しで [NG]=0 を確認し、完了主張前に pnpm verify(install/lint/arch/typecheck/test/build/semgrep/gitleaks)を実行する。

Vehicle Cost Production MVP(不変条件ラチェット・mig129/130)

車両コストを本番運用へ移行する最小スライス(soft-delete/監査、消費税額、インボイス番号手入力、CSV、原本画像)。実体は supabase/migrations/00000000000129_vehicle_cost_prod_hardening.sql00000000000130_vehicle_cost_images_bucket.sqlapps/web/src/lib/db/vehicle-cost-images.ts

  • 金銭 / 番号: vehicle_cost_entries.tax_amountnull または非負かつ <> 'NaN'::numericinvoice_registration_numbernull または ^T\d{13}$ の CHECK を維持する。税込 amount と税額は別列で、OCR/手入力/編集/CSV のすべてで税額を握りつぶさない。インボイス番号は OCR で自動抽出せず、レビュー・手入力で任意設定する。
  • server-owned 列 guard: vehicle_cost_entries_guard_protected_colsSECURITY INVOKERBEFORE INSERT OR UPDATE。client(authenticated/anon)の INSERT は deleted_at/deleted_by 非NULLを拒否するが、取込で必要な image_path と作成属性 updated_by は INSERT で許可する。UPDATE は deleted_at/deleted_by/updated_by/image_path の4列を IS DISTINCT FROM で遮断し、updated_by は client の業務更新時に profile から fill する。DEFINER/service-role/postgres と RPC の transaction-local bypass だけを carve-out とする。
  • soft-delete: soft_delete_vehicle_cost_entry / restore_vehicle_cost_entrySECURITY DEFINERsearch_path=''・呼出元/対象の tenant 明示比較・current_user_role() IS DISTINCT FROM による fail-closed role guard・authenticated 限定 grant を維持する。物理 DELETE は client に許可せず、SELECT policy と Web query の双方で deleted_at is null を維持する。Web の削除表示は「復元可能」とし、削除経路は RPC のみ。
  • 原本 Storage: vehicle-cost-images は private bucket。authenticated の collector staff(admin/normal)だけが SELECT/INSERT でき、(storage.foldername(name))[1] = current_tenant_id()::text で tenant-first path を強制する。Web は authenticated client からのみ <tenant_id>/vehicle-cost/... へ保存し、service-role Storage 経路・越境パス・公開 bucket を作らない。署名 URL は短期にし、アップロード失敗は警告してコスト本体の登録を妨げない。
  • 保存前画像圧縮(wl-k2z): 領収書を含むコスト原本は vehicle-cost-images へ保存する前に optimizeImageForUpload(長辺 2048px / JPEG quality 85%、4.8MiB 超は quality 70% 再試行)を通す。圧縮に失敗した場合は原本を保存して写真を失わない。既存の未圧縮画像は法定記録の原本を後から書き換えないため遡って圧縮しない。バケットに保存期限を設けず、自動削除ジョブも追加しない。
  • 恒久チェック / proof: scripts/hardening-sweep-ratchet.mjs / hardening-sweep-ratchet.test.mjs は mig129/130 の CHECK・guard・削除済み非表示・RPC・Storage tenant policy と後続 migration の保護削除を検査する。mig129/130 と scripts/proofs/mig129-vcost-prod-insert-guard.sql を含む scripts/proofs/*.sql 全流しで、全 [OK][NG]=0 を確認する。

Vehicle Cost Next Wave(T1-T4)

車両コスト次波は web 中心・原則 migration なしで、既存の soft-delete・税額・インボイス番号・原本画像・CSV 挙動を非破壊で拡張する。月次締め・レガシー取込は対象外。

  • 明細編集: vehicle_cost_line_items の編集は replace_cost_line_items RPC(useReplaceCostLineItems)経由に限定する。親 vehicle_cost_entries.amount は権威値として変更せず、明細合計との乖離は非ブロックの参考表示とし、0 行保存を許容する。
  • 重複検出: 同一 tenant・vehicle・entry date・amount の既存行(soft-delete 済みを除外)を保存前に検出してソフト警告だけを表示する。正当な同額反復登録を妨げる DB unique 制約・索引は作らず、ユーザーは警告を無視して保存できる。
  • 一覧検索 / ページング: vehicle cost entries の一覧は vendor/note の部分一致検索と .range(from,to) + exact count を使う server-side paging(既定 pageSize=50)とし、共通 Pagination の境界(先頭・末尾・0 件)を維持する。CSV は既存どおり現在の全フィルタ結果を出力する。

Prerelease Hardening(不変条件ラチェット・2026-07-15)

二次搬出と車両コスト集計を本番投入する前の hardening。対象 migration は develop の mig133 から連番で mig134→mig135 とし、既存の P&L/dispatch 契約を非破壊で維持する。

  • 無界クエリの全件取得: 一覧・集計・CSV の全件取得は共有 fetchAllPages.range(from, to) で短い最終ページまで取得する(Supabase の max_rows=既定 1000 や単発 select に依存しない)。ページ途中の error は throw し、range を実際に反映するモックで先頭/境界/1000行超の回帰を Web テストで固定する(range を無視して全件返すモックだと未配線の回帰を検出できない)。range は offset ページングなので id 等の tiebreaker で全順序を与える(無いとページ間で行の重複・欠落が起きる)。
  • marker 規約: 意図的な無界クエリは文の直上// unbounded-ok: <非空の理由> を置く(// 形式のみ・/* */ は不可)。marker 無し・理由空・orphan は違反とし、statement 内の候補数と marker 数を一致させる(例: apps/web/src/lib/db/vehicles.ts:33)。
  • 配線済み(2026-08-23 実測更新): fetchMonthlyAggregationresource-aggregation.ts・weighings と weighing_items の両方)/ useVehicleCostMonthly / コスト CSV export / pnl_daily / useCollectorManifests / useWeighingHistoryWeighingHistoryPage が walk_in を含む計量履歴の実体。旧 useWalkInWeighings は redirect のみで、一覧の実体ではない)。WeighingHistoryPage の表示は client-side の pageSize=50 + 共通 Pagination、CSV は全件取得結果を維持する。
  • 正典が関数名を一般化して実装と乖離した前例: 本節は当初 fetchMonthlyAggregation を対象と宣言していたが、実際に配線されたのは useVehicleCostMonthly だけで、月次集計は無界のまま残っていた(QA スイープ 2026-07-26 の docs 監査が git log -S で検出)。1000 件超で過少計上=月次の判断を誤らせる状態だった。この節に関数名を書くときは配線を実測してから書く
  • 非有限 numeric 封鎖(mig134): secondary_transport_routes.transport_fee_amountvehicle_cost_entries.amount/tax_amount、車両コスト明細の amount/quantity/unit_price、walk-in の total_amount と金額明細は、既存の符号・NULL・非負契約を保ったまま NaN と ±Infinity を CHECK で拒否する。既存の非有限値は制約追加前に無害化する。pnl_daily は mig132 の署名・戻り列・scope (all/secondary)・DEFINER/security 契約・raw source 引当を一字一句維持し、収入と費用の双方で NaN/±Infinity を 0 として無害化する。
  • 同日反復二次搬出(mig135): dispatch_stops.trip_seqnot null default 1・正整数。source_site_id is not null の二次搬出だけ (route, trip_seq) を unique 判定へ含め、同日同一 route の便を複数保持できる。source_site_id is null の通常回収 stop は trip_seq を無視し、従来の dedup を維持する。再配車 RPC の先回り判定も DB index と同じ NULL-safe なキーで照合する。pnl_daily の per-stop raw source/snapshot 計上は変更しない。
  • 明細キャッシュ鮮度: useCreateCostLineItems / useReplaceCostLineItems の mutation 完了時は、単一 byCostEntry と一覧の by-cost-entries をともに invalidate する。明細行の編集後にバッチ表示が stale のまま残る経路を作らない。
  • 恒久チェック / proof: migration 固有の Web 回帰テスト、scripts/proofs/mig134-infinity-sweep.sqlscripts/proofs/mig135-dispatch-stop-trip-seq.sql を追加し、Docker では scripts/proofs/*.sql を全 migration 適用後に全流しして [NG]=0 を確認する。以後の migration は上記4条件を再導入せず、例外は同じ変更でこの不変条件とテストを更新する。