コンテンツにスキップ

配車・二次搬出

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

Adhoc Secondary-Transport Dispatch(不変条件ラチェット・mig110)

前日にしか決まらない二次搬出(排出元→搬出先の点対点・固定運搬費)を配車ボードの手動 stop で料金計上する機能。二次搬出+P&L(mig103/105/107)の拡張。実体: supabase/migrations/00000000000110_dispatch_stops_source_site_and_pnl_coalesce.sql。既存の回収 stop・course 由来 stop は非破壊。

  • データモデル(mig110): dispatch_stops.source_site_id uuid null references public.collection_sites(id) on delete set null(排出元回収先・二次搬出 stop のみ設定・回収 stop は null)+ 索引 dispatch_stops_source_site_idxdispatch_stops_unique を drop→recreate して unique nulls not distinct (tenant_id, dispatch_date, driver_id, collection_site_id, source_site_id)(Postgres 15 NULLS NOT DISTINCT・同搬出先でも排出元が異なる二次搬出2件を別ルートで共存・通常回収 stop は source=null で従来 dedup 維持)。※現行の dispatch_stops_unique は mig135 でさらに trip_seq を含む 6 列 (tenant_id, dispatch_date, driver_id, collection_site_id, source_site_id, trip_seq) へ drop→recreate 済み(本行の 5 列式は mig110 時点のスナップショット)。client 側の衝突判定・応援 eligibility は 6 列タプルに整合させること。
  • pnl_daily は raw 権威(snapshot): 収入引き当ての排出元 source を str.source_site_id = s.source_site_id(stop の raw source のみ) で解決する。旧 coalesce 方式(coalesce(s.source_site_id, c.source_site_id)・course fallback)は撤去し、effective == raw を構造的に常成立させる。これにより dispatch_stops_unique(NULLS NOT DISTINCT)が effective の二重計上排除キーそのものになる。course.source を後から変えても dispatch 済みの既存 stop は生成時の raw source を保持([[settlement-finalize-snapshot-not-source-freeze]] と同思想)。pnl_daily は DEFINER・admin 限定 is distinct from 'admin'(fail-closed)・search_path=''・全 join current_tenant_id() 明示・NaN 無害化・unpriced_count・revoke/grant は mig107 と一字一句同じ(source 式と不要になった dispatch_courses join の撤去のみ)。
  • pnl_daily の scope 不変条件(mig124): インターフェースは pnl_daily(p_from date, p_to date, p_scope text default 'all')、有効 scope は all / secondary のみ。scope が変えるのは費用だけで、all は全 vehicle_cost_entriessecondary は紐づく車両の vehicles.is_secondary_dedicated=true(車両の有効/無効を問わない)の vehicle_cost_entries だけを集計する。収入・未引当ロジックは scope 間で同一かつ既存のまま(完了した二次搬出 stop に引き当てる固定二次搬出運搬費のみ)であり、all は「全収入」ではなく収入対象を広げない。セキュリティ契約は SECURITY DEFINER / search_path=''current_user_role() IS DISTINCT FROM 'admin' なら raise する admin fail-closed、current_tenant_id() から得た tenant を全 join/抽出条件に明示、費用 NaN の 0 無害化、public から revoke・authenticated への execute grant を維持する。
  • course 由来 stop の raw 補完(過小計上防止): raw 権威 pnl では course 紐付き stop が raw=NULL だと unpriced に落ちるため、source_site_id を course.source から埋める 3 経路: (1) generate_dispatch_stops_from_courses(新規生成時・dedup キーも (driver_id, collection_site_id, source_site_id) に拡張)、(2) dispatch_stops_fill_source_site(DEFINER・BEFORE INSERT OR UPDATE OF course_id, source_site_id=列スコープで driver の status のみ UPDATE では非発火・course 紐付き raw=NULL を course.source に補完・同テナント明示フィルタ)、(3) dispatch_courses_propagate_source_site(DEFINER・AFTER UPDATE OF source_site_id ON dispatch_courses・course が生成後に source を得た瞬間に紐付き raw=NULL stop を補完・非 NULL 追随はしない=snapshot・同一ルート衝突は補完前に説明的 unique_violation で拒否)。§1b で既存 course 生成 stop を backfill。
  • 越境/driver guard(mig110): staff-manage RLS "admin manages dispatch stops" の WITH CHECK に source_site_id(null or 自テナント collection_site exists)を並置(外側列 dispatch_stops.source_site_id 完全修飾・tenant は current_tenant_id() 比較=[[rls-with-check-tenant-id-shadowing]])。dispatch_stops_guard_driver_update の保護列に source_site_id を追加(driver=料金源変更不可・status/completed_at/note のみ)。reassign_dispatch_stops の UNIQUE 先回り判定を (collection_site_id, source_site_id) タプル(NULL 安全)に拡張。dispatch_stops に fill_tenant トリガは無い(tenant は client 供給・dispatch_stops_check_collector が collector 種別のみ検証)ため越境整合は RLS WITH CHECK が唯一の担保。
  • 回帰: docker proof scripts/proofs/mig110-adhoc-secondary-dispatch.sql(8000-series fixture・[[docker-proof-uuid-collision-shared-db]]・全流し)。dev 実機は scripts/verify-secondary-transport-rls.mjs
  • 最終処分場フィルタ(mig136): 二次搬出の排出元/排出先ピッカーは partners.is_final_disposal=true の回収先に絞る。通常回収の回収先選択は非対象。
  • 二次搬出追加ダイアログ: source×dest×日付で有効 route を解決し固定運搬費 or 未登録警告を表示する(未登録=売上計上されない旨・resolveSecondaryFeeState)。実体は SecondaryDayEntryDialog(v1.30.0 の動線統合で旧 AddSecondaryStopDialog は削除済み)。統合時にこの表示が移植されず解決関数が dead code 化して売上欠落の口が開いていたため、運搬費表示のガードは SecondaryDayEntryDialogSecondaryPairFee レンダーテストで固定する(別 PR で復活・純関数テスト secondary-route-fee.test.ts だけではダイアログの dead-code 化を検出できない)。ダイアログを差し替えるときはこのテストごと移すこと。
  • 運搬一般化後の料金状態: route 解決は cargo_kind を含め、resolveSecondaryFeeState は kind ごとに priced(fixed)/priced_per_unit(量あり)/quantity_missing(per_unit・量未入力)/unpriced/incomplete を返す。既存の waste・fixed は priced のまま維持する。

運搬の一般化(不変条件ラチェット・mig252)

  • 二次搬出(waste・fixed)の金額・計上日・請求・証跡・完了統制は一字も変えない
  • cargo_kindsource_site_id is not null と同値で存在する(CHECK)。通常回収に種別を持たせない。
  • 証跡は cargo_kind='waste' の stop だけが持てる(DB raise + UI 分岐)。
  • 有価物運搬の量(transported_quantity)は担当 driver 本人が未完了(pending)の間だけ書ける。 横乗り(mig177 の UPDATE 経路)と完了後の変更は 42501(訂正は取消→再完了)。種別・品目は staff だけが書ける。 driver 許可列を足すときは所有者と状態の条件を必ず付ける(横乗り policy と合成されて他人の値を書けるため)。
  • cargo_kind は INSERT 後 immutable(全ロール・DEFINER/service_role 含む・dispatch_stops_guard_cargo_kind_immutable)。 種別で INSERT をゲートしても種別列が後から変更可なら不変条件は行間で崩れる(有価物に法定証跡が残る)。 種別を誤った stop は削除して作り直す。NULL→値の初期設定(course propagate・backfill)だけ許可。
  • course 由来(course_id is not null)の stop は waste のみdispatch_stops_fill_source_site が明示 valuables を 23514 で拒否・NULL は waste へ補完)。dispatch_courses に貨物種別の軸が無い。大型運搬は手動 stop のみ
  • fixed の運搬費は丸めないpnl_daily / pnl_revenue_breakdown は mig141/186 と同値・小数は正規入力)。 round() は per_unit 分岐の内側だけに置く。分岐に丸めを足すとき CASE の外に置くと既存分岐の金額まで変わる。 JS の resolveSecondaryFeeStateroundDecimalProductToInteger(十進・half away from zero)で PostgreSQL と一致させる (Math.round は 1.005×100 → 100 で食い違う)。
  • 宵積みの権威は vehicle_transport_days(車両×日)secondary_transport_days へ新規書込しない。
  • 量の単位は断定表示しない(mig254): driver が実単位を読める DEFINER RPC get_dispatch_stop_quantity_units を唯一の読取経路にする。戻り列は (stop_id, quantity_unit) の 2 列に固定し、unit_price を返さない(金額秘匿)。 driver は担当本人の stop のみ・staff(admin|normal)はテナント内・role NULL / tenant NULL は fail-closed に raise する。secondary_transport_routes に driver の SELECT を足してはならない。 proof scripts/proofs/mig254-dispatch-stop-quantity-units.sql の Q8 が戻り列を pin する。 単位未取得の UI は「数量(単位未取得)」の中立表示 + 警告文にし、kg を既定値にしない。
  • valuables の完了経路は担当本人の量入力だけ(横乗り・管理者ボードは UI で fail-closed)。 横乗り行は完了ボタンを無効化して理由文を出し、canStaffComplete は valuables を false にする (取消はどちらも可=担当の再完了へ戻す)。完了の分岐判断は planSecondaryCompletion に一元化し、 hook に分岐を残さない。DB 上 staff が transported_quantity を書けること(guard は driver ロールのみ発火)は変えない。量の事務後入力 UI は作らない。
  • 運搬費単価と買取/請求の単価契約を結合しない(別レイヤ)。
  • 非スコープ: 大型車両フラグ・シフト/交代表・品目別単価(排他キー拡張)・secondary_transport_days の drop(contract)・計量(weighings)連携・量の事務後入力 UI(完了取消→再完了で代替)。

二次搬出の運搬品目(不変条件ラチェット・mig255)

  • secondary_cargo_types はテナント別の編集可能マスタ。全 authenticated が自テナントを SELECT、書込は staff(admin|normal)だけ。tenant_idBEFORE INSERT OR UPDATE 全列 fill で current_tenant_id() から毎回上書きし、NULL は拒否する。
  • active 名称一意は (tenant_id, md5(collection_site_name_key(name))) の部分・式 unique(空キー除外)。無効化済みの品目も既存 stop から読める。
  • dispatch_stops.secondary_cargo_type_id は nullable・ON DELETE RESTRICT。非 NULL は cargo_kind='waste' のみ許し、CHECK の UNKNOWN 通過を防ぐため通常回収の cargo_kind=NULL も明示拒否する。
  • staff WITH CHECK の品目 exists は外側列 dispatch_stops.secondary_cargo_type_id を完全修飾し、自テナントを検証する。driver guard は通常更新と reorder 経路の両方で新列の変更を拒否する。
  • 品目は表示情報であり、料金キー・請求・JWNET・マニフェストへ結合しない。回帰は scripts/proofs/mig255-secondary-cargo-types.sqlapps/web/src/scenarios/13-secondary-cargo-types.scenario.test.ts

Dispatch Vehicle Assignment(不変条件ラチェット・mig183/184/185/188/196/198)

配車ストップへ車両を固定し、コース生成と手動配車の結果を同じ車両解決規則へ揃える機能。実体: supabase/migrations/00000000000183_dispatch_stop_vehicle.sqlsupabase/migrations/00000000000184_dispatch_generation_vehicle.sqlsupabase/migrations/00000000000185_dispatch_vehicle_hardening.sqlsupabase/migrations/00000000000188_dispatch_vehicle_optional.sqlsupabase/migrations/00000000000198_resolver_definer.sqlapps/web/src/lib/dispatch/assigned-vehicle.tsDispatchBoardPage.tsxsupabase/migrations/00000000000196_vehicle_multi_driver.sql

  • 専用車の権威(mig196): 専用車の割当は vehicle_assigned_drivers join 表だけが権威であり、 vehicles.assigned_driver_id は撤去済み。web は vehicle_assigned_drivers(profile_id) を平坦化して Vehicle.assignedDriverIds として扱う。割当の付け替えは join 行の差分 delete/insert で行い、 行自体は immutable(UPDATE policy なし)。
  • 割当 join 表の保護(mig196): vehicle_assigned_drivers の tenant fill は DEFINER の BEFORE INSERT OR UPDATE(全列発火)で、vehicle_id / profile_id は immutable(変更は 42501、delete→insert のみ)。profile が別 tenant なら 42501 で拒否し、tenant_id は 親 vehicle から再導出する。RLS は authenticated の自 tenant 全件 SELECT、staff(admin|normal) のみ INSERT/DELETE とし、UPDATE policy は作らない。service_role policy も作らない。
  • 自動適用の不変条件: driver から見て active・非構内機器・工場一致の専用車がちょうど1台の ときだけ自動適用する。0台・2台以上・工場不一致は NULL/未解決。DB resolve_assigned_vehicle と web resolveAssignedVehicleId は同じ判定表を保ち、2台持ち driver の専用車表示も行わない。
  • resolver の実行権限(mig198): resolve_assigned_vehicleSECURITY DEFINER。INVOKER だと driver 経路で vehicles の RLS が候補を目減りさせ、「2台以上は未解決」が破れるため、 mig185 の検証 helper と同じ理由で執行層を DEFINER 化する。呼出元 tenant の fail-closed guard を持ち、p_tenant_idcurrent_tenant_id() と不一致なら例外ではなく NULL を返す。
  • driver 経路の返り値(mig198): DEFINER 化により driver からも resolve_assigned_vehicle で同一テナント他工場の車両 UUID のみが引ける(属性なし・書込側は mig185 検証が遮断・受容済み)。
  • CSV: 車両の担当ドライバーはセミコロン区切り(山田 太郎;鈴木 花子)で入出力する。単一名は 後方互換として1人の割当になる。一部の名前が解決できない行は部分適用せず行エラーにする。

  • vehicle_id の必須境界(mig188): dispatch_stops.vehicle_id は既存行を残すため列自体は NULL 可。active・非構内機器・工場所属一致の専用車がちょうど1台に決まる driver の INSERT だけ必須で、既存車両を NULL へ戻す clear も同じ driver に限り拒否する。0台・複数台・工場不整合の driver は NULL の INSERT/保持/clear を許可する。web の DispatchStopvehicleId: string | null を必須プロパティとし、新規 insert の vehicleId は UUID または NULL を送る。

  • 車両の整合性(mig183/185): vehicle_id は同一 tenant の車両だけを許可し、driver/profile の工場と一致することを検証する。工場一致は profiles.factory_id profile_factories の両方を参照し、片方だけの所属を信頼しない。vehicles.is_yard_equipment=true は配車車両として拒否する。越境と構内機器は全 UPDATE で検証するが、工場整合だけは INSERT または vehicle_id / driver_id が変わった UPDATE に限定する。status・completed_at・note・stop_order の更新まで工場を再検証すると、工場異動後の履歴便完了を止めるためである。工場判定は dispatch_stops_vehicle_validation(SECURITY DEFINER、search_path=''、完全修飾、検証以外の副作用なし)へ切り出す。driver は自分の工場の車両しか SELECT できるため、trigger の INVOKER 参照だけでは判定できない。
  • 専用車の自動解決(mig184/185/188/196/web): vehicle_assigned_drivers で driver と結び付く active かつ構内機器でない車両がちょうど 1 台で、さらに driver が車両の工場へ profiles.factory_id または profile_factories で所属するときだけ専用車として自動適用する。0 台・2 台以上・工場不整合は未解決(NULL)。DB の resolve_assigned_vehicle と web の resolveAssignedVehicleId は同じ規則でなければならない。
  • NULL 車両過去便の再利用防止(mig185/188): vehicle_id is null完了済み 行では、dispatch_date / driver_id / collection_site_id / source_site_id / course_id / trip_seq の変更を全ロールで拒否する。P&L を壊す「完了済み過去便の新しい配車としての再利用」を service_role からも許さない。未完了の NULL 便は新しい正規状態なので identity 列を変更できる。完了済み cross-driver の拒否は車両の有無にかかわらず別条項で維持する。
  • コース生成(mig184/188): generate_dispatch_stops_from_courses_result(date, boolean) は専用車を解決できない driver の分も vehicle_id=NULL で作成し、created に含める。互換列 skipped_no_vehicle はシグネチャを保ったまま常に0。旧 generate_dispatch_stops と integer 版 generate_dispatch_stops_from_courses も未解決 driver を raise/skip せず NULL で作成する。
  • 応援移譲(mig184/188): reassign_dispatch_stops は移譲先 driver の専用車が未解決でも raise せず、vehicle_id=NULL で移譲する。専用車が一意なら従来どおりその車両を設定する。web の専用車なしエラー写像は持たず、一般エラー表示へ戻す。
  • 配車ボード D&D(mig185/188/web): cross-driver の stop 移動は buildCrossDriverStopMove で移動先 driver の専用車を一意解決し、driver_id / stop_ordervehicle_id を同じ更新集合へ載せる。専用車が未解決でも移動を止めず vehicle_id=NULL とする。同一 driver 内の並び替えは stop_order だけを更新し、車両を触らない。完了済み stop の cross-driver 移動は UI で draggable を無効化し、DB trigger でも拒否する。D&D は handoff 監査付きの reassign_dispatch_stops へ集約しない。車両未設定の便はボード・一覧で「車両未設定」と表示する。
  • 完了済み cross-driver の拒否(mig185): old.status='completed' and new.driver_id is distinct from old.driver_id は全ロールで 42501。完了時刻の帰属を別 driver へ移して壊さない。
  • 配車ストップ削除(mig260・#659): delete_dispatch_stop(uuid) は SECURITY INVOKER で SELECT 可視性と DELETE 権限を分け、deleted / not_found / denied を返す。既存 RLS・guard の例外を維持し、DEFINER 化や直接 DELETE に戻さない。 Web は3状態を別々に通知し、not_found でも一覧を再取得する。一括削除は既に削除済みでも継続し、権限拒否を成功・既に削除済みに数えない。未割当への移動は実削除のみ、完了済み件数の警告は維持する。 回帰は scripts/proofs/mig260-delete-dispatch-stop-outcome.sql(SELECT を省く変異で越境検査が赤)と削除 hook・ボード操作のテスト。
  • driver の書込境界: driver は vehicle_id を変更できない。dispatch_stops_guard_driver_update の許可列外に置き、reorder_my_dispatch_stops の bypass allowlist にも追加しない。事務の手動追加・車両変更だけが staff 経路で行われる。
  • 過去分 backfill: 運行ログから埋めるのは、tenant・driver・運行日ごとに車両が1 台だけのときに限る。2 台以上の日は推定せず NULL のままにし、既存の非 NULL 値を上書きしない(再実行しても非破壊)。
  • 回帰: web は専用車 1/0/2 台、工場不整合除外、無効車・構内機器除外、完了済み cross-driver 拒否、同一 driver 内並び替え、NULL での D&D/手動追加、車両未設定表示を固定する。DB proof scripts/proofs/mig183-dispatch-stop-vehicle.sql / mig184-dispatch-generation-vehicle.sql / mig185-dispatch-vehicle-hardening.sql / mig188-dispatch-vehicle-optional.sql は、専用車必須境界、未完了/完了済み identity、生成・応援の NULL 経路、工場/越境/構内機器拒否、完了済み移動拒否を検証し、全流しする。

日次 P&L 車番グルーピング(不変条件ラチェット・mig186)

日次 P&L の内訳を日付 → 車番 → 収入/費用の 3 段で表示する。pnl_daily の収入・費用の対象条件と金額定義(mig110/124/134/141)は変更せず、mig186 は内訳 RPC に車両情報を加えるだけである。

  • 合計一致: pnl_revenue_breakdown の fee 合計と pnl_cost_breakdown の amount 合計は、車両未設定(vehicle_id is null)の行を含めて同期間の pnl_daily 表示額と一致しなければならない。UI の breakdownMatchesTotal と有限値のみを合計する純関数の契約を維持する。
  • 車両キー / NULL 枠: Web のグループキーは表示ラベルではなく vehicle_idvehicle_id is null は「車両未設定」の独立バケットとして必ず表示し、並びの末尾に置く。同じラベルの別車両を統合してはならない。
  • ラベル機密: 表示ラベルは既存 pnl_cost_breakdown と逐語で同じ coalesce(v.plate_number, v.short_code, v.vehicle_name) のみを使い、vehicles.chassis_number を UI/API のラベルへ使わない。
  • RPC の非破壊契約: 収入側の dispatch_stops.vehicle_id は NULL を保持する left join、費用側の vehicle_cost_entries.vehicle_id は NULL の共通費を保持する。既存の order by(id を含む全順序)、SECURITY DEFINER / search_path='' / admin fail-closed / tenant 明示 / revoke-grant を崩さない。
  • 戻り列のラチェット: 内訳 RPC の戻り列を変更・追加するときは、scripts/proofs/mig186-pnl-breakdown-vehicle.sql の PV7(pg_get_function_result の固定照合)を同じ変更で更新する。migration は drop → create とし、pnl_daily の署名・戻り値・実装を書き換えない。
  • 回帰: mig186 proof を含む scripts/proofs/*.sql 全流しで [NG]=0 かつ psql exit 0、Web の車両グルーピング/render テスト、および pnpm verify を完了主張前に通す。

Driver Stop Reorder(不変条件ラチェット・mig139)

ドライバーの dispatch_stops.stop_order 変更は reorder_my_dispatch_stops(INVOKER・自 stop/同一テナント/同一日付/全網羅・app.bypass_driver_reorder local GUC 下でのみ stop_order のみ再採番)経由に限定する。guard は直接 client 書込を依然遮断し、driver_id/site/source/trip_seq/course/date/tenant は driver 変更不可とする。 - TOCTOU 直列化(terra 敵対レビュー反映): RPC は網羅検証の前にその日の自 stop 全体を FOR UPDATE NOWAIT でロックし、再採番 UPDATE には dispatch_date = v_date を明示、GET DIAGNOSTICS ROW_COUNT が期待 N と不一致なら raise する(並行 reassign_dispatch_stops/追加/再計量が検証〜更新間に集合を変えても 1,3,4 のような順序破綻を作らず fail-closed で中止)。set_config(..., true) は transaction-local で成功・失敗いずれも off に戻る(同一 Tx の後続直書きは再び guard で拒否)。 - NOWAIT でデッドロック回避(terra 敵対レビュー2巡目反映): reassign_dispatch_stops(DEFINER)は移譲後に source/dest 双方の当日ルートを再採番するため、reorder が行ロックを「待つ」設計だと両者で 40P01 デッドロックになりうる。reorder は NOWAIT で決して待たない側に固定し(cycle の待機辺を持てない=デッドロック不成立)、競合時は lock_not_available の retryable エラーで即諦める(staff reassign を優先・driver は再試行)。reassign 側は不変(advisory lock 追加はしない=DEFINER 関数の blast radius を避ける)。二セッション競合は単一接続の docker proof では決定的検証不能のため proof 化しない(非競合パスは R1/R8/R9 が担保)。 - 監査/識別列の driver 直書き遮断(terra 敵対レビュー反映): guard は両分岐(通常 / bypass)で id/created_by/created_atis distinct from 変更も raise する(mig135 は保護列のみで監査/識別列が素通りしていた穴を mig139 で塞ぐ)。staff(admin/normal)は guard 非発火のため対象外(信頼済み操作者・別レイヤ)。 - 既知の良性制約: phantom-insert race(terra 3巡目・follow-up 予定): 行ロック(FOR UPDATE)は既存 route 行しかロックできず (tenant_id, driver_id, dispatch_date)述語ギャップは守れない。driver の reorder 実行中に staff(admin/normal)が同一 route へ stop を直接 INSERT/move-in(RLS 直挿入・web 直接 insert 経路)すると、reorder は自分がロックした行のみ再採番し新規行は stop_order を保持するため、ROW_COUNT=N は成立したまま一時的に stop_order が重複しうる(例 1,1,2,3,4)。破損/消失/越境は無く、id 第二ソート(fetchDispatchStops)で表示は決定的、次回 reorder or staff 再採番で正規化される自己修復・良性のドリフト。これは dispatch_stops 順序モデルの既存性質であり、shipped の reassign_dispatch_stops 再採番 / generate_dispatch_stops_from_courses も同じ phantom 露出を持つ(stop_order は unique ではない)。完全修正は全 route 書込経路(reorder/reassign/generation/staff 直接 insert・delete・move・web 直接 insert)の直列化 or (tenant,date,driver,stop_order) deferrable unique = 横断的な構造改修であり、本機能スコープ外。follow-up 設計で dispatch_stops 順序の並行性を横断的に扱う(docs/superpowers/specs/2026-07-16-dispatch-stops-ordering-concurrency-followup.md)。二セッション phantom race は単一接続 docker proof で決定的検証不能のため proof 化しない。 - 回帰は scripts/proofs/mig139-driver-stop-reorder.sql(R1-R9 allow/deny・G1-G4 guard・bypass 保護列は 8 列全て・監査列 deny・GUC reset・date スコープ・status/note 回帰)を含む scripts/proofs/*.sql 全流しで [NG]=0 を確認する。

二次搬出 宵積み・入場時間・配車順(不変条件ラチェット・mig140/141/142)

mig253(2026-09-04)で宵積みの権威は vehicle_transport_days(車両×日)へ移った。 大型ドライバーは ローテーション(日替わり・車両固定)なので driver×日 では翌朝の引き継ぎができない。下記 mig140/169 の 契約は旧表 secondary_transport_days の出荷済み不変条件の記録として維持する(行・RLS・trigger は 温存・新規書込しない・web の呼出元は全部新表へ移し旧 lib/db/secondary-transport-days.ts は削除済み)。 新表の契約: - RLS: staff(admin|normal)は自テナントを manage。driver は exists (select 1 from dispatch_stops s where s.tenant_id = vtd.tenant_id and s.vehicle_id = vtd.vehicle_id and s.dispatch_date >= vtd.dispatch_date and s.driver_id = <自 profile>) で読む=翌営業日にその車両を 運転する driver が prior 日の宵積み(carryover)を読める。他車両・将来行・車両交代後の旧車両当日行は 0 行。 driver は INSERT/UPDATE/DELETE 不可。service_role policy は作らない。>= の向きを = に戻すと繰越が 翌日の driver から消える(proof V253-RLS-DRIVER が固定)。 - fill(DEFINER): tenant_id を vehicle から再導出(client 供給値を上書き)・他テナント vehicle・ 構内機器・vehicle_id 変更は raise。監査列 created_by/created_at は mig171 の fill でサーバー所有・ client 変更は INVOKER guard で 42501。 - carryover 検証: 同一 tenant・同一日・同一車両の二次搬出 stop のみ。status <> 'completed'load_timing は判定に使わない(mig169 の判断を継承)。 - 移行: 旧表の各行で (tenant, date, driver) の二次搬出 stop に vehicle_id IS NULL が 1 行でも混在する、 または旧行の carryover_stop_id が指す stop の車両が確定車両と一致しない場合は、対象を NOTICE で列挙して非移行 (migration を止めない)。それ以外で dispatch_stops.vehicle_id が非 NULL で 1 台に一意なら on conflict do nothing でコピー。0 台/複数台/判定不能は NOTICE で列挙して非移行(黙って捨てない)。 - 繰越の表示権威は computeSecondaryRun(車両軸)。複数車両を混ぜない(secondary-vehicle-runs.ts)。 取得失敗時は宵積みを推測せず通常配車へ fail-closed。 - 繰越完了 RPC: complete_vehicle_carryover_stop(p_stop_id uuid, p_transported_quantity numeric default null) は SECURITY DEFINER・search_path=''・全参照 public. 完全修飾、PUBLIC から revoke、authenticated のみ execute。 認可は全て fail-closed(不一致は 42501、NULL は is distinct from で拒否)で、次の 6 条件を満たす: 1. auth.uid() から profile を解決し、profile が存在し current_tenant_id() が NULL でなく profile tenant と一致。 2. stop を FOR UPDATE でロックし、current tenant・source_site_id is not null・pending(deleted_at がある環境では NULL)を検証。 3. 同一 tenant/date/vehicle の vehicle_transport_days 行で carryover_stop_id = p_stop_id を明示検証(stop_order 推測不可)。 4. 呼出元が同じ vehicle の prior 日より後の stop を担当することを検証。 5. 横乗り(mig177)は本 RPC の対象外(横乗りは完了/取消のみで数量は書けない・mig252 guard と整合)、profile role=driver または can_drive=true(staff 代行は既存 staff 経路)。 6. valuables は p_transported_quantity 必須(NULL は 42501、非負/NaN は既存 CHECK)、waste は quantity 不要。 所有権移管(driver_id)、pending→completed、completed_by、completed_at_source='driver'、quantity を同一 UPDATE し、 completed_at は server now。DEFINER では driver guard が非発火のため、RPC 内で note / stop_order / source / trip_seq / course / cargo / item を変更せず guard の統制を明示維持する。担当本人の通常 stop は既存直接 UPDATE、横乗りは対象外。 - proof: scripts/proofs/mig253-vehicle-transport-days.sql(NULL 混在/車両不整合の非移行、RPC allow/deny、所有権移管)。 設計正典: docs/superpowers/specs/2026-09-03-transport-generalization-design.md §3.3/§3.4。

二次搬出の積方(朝積み/宵積み)・入場時間・事務が固定する明示繰越・宵積み翌朝の繰越表示を、既存の通常回収・既存 dispatch・単発二次搬出追加・mig139 並べ替えと非破壊に共存させる機能。実体: supabase/migrations/00000000000140_secondary_transport_days.sqlsupabase/migrations/00000000000141_pnl_daily_completion_date.sqlsupabase/migrations/00000000000142_secondary_driver_controls.sqlsupabase/migrations/00000000000253_vehicle_transport_days.sqlapps/web/src/lib/dispatch/secondary-run.tsapps/web/src/lib/db/vehicle-transport-days.tsapps/web/src/pages/collector/dispatch/SecondaryDayEntryDialog.tsx

  • 日次設定/RLS: secondary_transport_days の一意キーは (tenant_id, dispatch_date, driver_id)load_timingmorning / eveningentry_time は nullable の timecarryover_stop_id は nullable FK。admin/normal は自テナントを manage し、driver は自分の行だけ SELECT 可・INSERT/UPDATE/DELETE 不可。tenant_iddriver_id の越境は RLS WITH CHECK で拒否し、carryover は SECURITY DEFINER 検証トリガで同一 tenant/driver/date の二次搬出 stop だけを許可する(mig169 で evening 限定を撤去。積方は繰越の可否を決めない)。created_by は INSERT fill で auth.uid()profiles.idcreated_at は server now() に強制し、secondary_transport_days_guard_protected_cols(SECURITY INVOKER・authenticated/anon)が client UPDATE の両列変更を拒否する。updated_at は既存 trigger の権威、service-role/postgres は trusted carve-out。client の upsert は既存の通常 unique arbiter を使う。
  • 繰越/表示順(mig169 で積方から分離): computeSecondaryRun が二次搬出運行の表示・順序の単一権威。caller が解決した直近 prior dispatch 日の priorSetting.carryoverStopId が指す stop が未完了なら、その 1 件だけを carryover として当日先頭へ仮想前置し、当日 stop は stop_order 昇順で続ける。完了済み/未設定/不正 ID は繰り越さない。stop_order の最大値から派生せず、後からの reorder でも明示 ID は変わらない。entry_time は算出された運行先頭にだけ表示する。
  • 繰越の権威は carryover_stop_id であり load_timing は繰越の可否を決めない。 「1 日目は朝積みだが C だけ宵積みになった(A,B は当日配送済み)」を表すため、mig169 で trigger と client の evening 限定を撤去した。load_timing は「その日の積込の段取り」= 表示とピッカー初期値のみ。
  • 二重配送を防ぐのは「事務の明示指定」と「未完了」の AND であり、status <> 'completed' を外してはならない。 timing ガードを撤去した後、担保はこの 2 条件だけである。これは timing ガードより「強い」わけではない — 朝積みの日は静的に不可能だったので朝積みに関しては弱くなる(機能追加の代償として意図的に受容・codex 敵対レビュー 2026-07-28 で当初の主張を撤回)。
  • trigger に d.status <> 'completed' を足してはならない。 全列発火の BEFORE trigger なので、繰越に指定した stop が完了した後にその日の設定(入場時間だけ等)を編集できなくなる。完了済みを指したまま残る行は client が表示から除外するため無害。DB で強制したいなら別スライスで RPC 化か AFTER trigger を設計する。
  • 既定値はピッカーの初期値として与え、保存時に再計算してはならないinitialSecondaryDaySettings)。再計算すると「宵積みだが繰越なし」を表現できない。追加フロー(resolveCarryoverStopId)は宵積み→最後の便の自動既定を温存する。
  • 既知の残存リスク(follow-up): (1) 完了→取消で配送済みの便が翌日へ再提示される(mig140 からの既存性質)、(2) pending 二次搬出を staff が再割当すると繰越設定が孤児化して両 driver から消える、(3) 繰越を届けなかった翌々日に黙って消える。いずれも収入の二重計上には至らない(収入は stop 1 行の completed_at で 1 回)。
  • 二次搬出 driver の完了/取消境界: dispatch_stops_guard_driver_update(mig141/152)は pending→completed の completed_at を server now() に統制し、driver は自分の二次搬出 stop の完了を取消できる(mig152・通常回収と同じ)。この統制は二次搬出条件の外側にあり driver の全 stop に効くので、通常回収でも driver に完了時刻の入力欄を出してはならない(UPDATE は 1 行成功するが値は破棄されるため「成功トーストが出て元に戻る」嘘 UI になる。QA スイープ 2026-07-26 で撤去)。訂正は「取消 → 再完了」(server 時刻で再採番)で行う。取消では completed_at=null となり、その JST 日の pnl_daily 収入から外れる。再完了では server now() が再採番され、再完了日の収入として計上される。note 編集は staff のみ(mig141 の監査保護を維持)。reorder/応援移譲は staff のみ(mig142 を維持)。mig142 はデプロイ済み mig139/mig110 を create or replace で再定義し、reorder_my_dispatch_stops の secondary 含有入力と reassign_dispatch_stops の非 staff 呼出を raise する。通常回収の driver 取消/note/reorder/reassign と staff の全経路は非破壊で維持する。
  • staff reassign の設定追随: staff の二次搬出 reassign は業務上許可されるが、secondary_transport_days は driver×日キーのため、移譲後に旧 driver 設定が孤児化しうる。これは security deny ではなく運用/設定追随の follow-up とし、staff は対象日の設定を再確認・再設定する。
  • 車両単位化(mig253): 宵積みの新規書込・読取の権威は vehicle_transport_days(tenant_id, dispatch_date, vehicle_id)、車両×日)であり、secondary_transport_days(driver×日)へ新規書込してはならない。旧表の行・RLS・guard・trigger は温存し、mig253 の初回移行は (tenant, dispatch_date, driver) に紐づく dispatch_stops.vehicle_id が非 NULL でちょうど 1 台の行だけを on conflict do nothing でコピーし、0 台/複数台は NOTICE で列挙して非移行とする。vehicle_transport_days_fill_tenant(DEFINER・BEFORE INSERT OR UPDATE 全列・search_path='')は vehicle 親から tenant_id を再導出し、越境 vehicle・構内機器を拒否し、vehicle_id は immutable。vehicle_transport_days_validate_carryover(DEFINER)は carryover_stop_id の同一 tenant・同一日・同一 vehicle・source_site_id is not null だけを検証し、status <> 'completed'load_timing を条件に足してはならない(mig169 の繰越権威を継承、表示側で明示 ID AND 未完了を判定)。
  • driver SELECT の >= 規則: driver はその行の日付以降(s.dispatch_date >= vehicle_transport_days.dispatch_date)に同じ vehicle の stop を持つ場合だけ読める。したがって当日車両=可、prior 日設定を翌日同車両 driver が読める=可、他車両=0 行、将来行=0 行、prior 日に乗った後に当日別車両へ移った driver の旧車両当日行=0 行。staff(admin/normal)は自テナント全件、driver の INSERT/UPDATE/DELETE は不可、WITH CHECK の外側列は完全修飾し、service_role policy は作らない。
  • 恒久チェック: scripts/proofs/mig253-vehicle-transport-days.sql は上記 driver SELECT 4 通り、fill/構内機器/vehicle immutable、同車両 completed carryover と別車両 deny、driver write deny、監査列 guard、1 台移行/0・複数台 NOTICE 非移行を検査する。scripts/proofs/*.sql 全流しで mig140/169 の旧表 proof も緑のまま確認する。
  • 既存経路の非破壊: dispatch_stops の前日 stop を当日へ複製・更新せず、carryover は client 表示上の read-only 行として扱う。通常回収(source_site_id is null)、既存 dispatch の D&D、現行の SecondaryDayEntryDialog(詳細は「二次搬出追加ダイアログ」節)、mig139 の通常回収 reorder は温存する。二次搬出 stop を含む運行だけ driver の reorder/応援ボタンを無効化し、DriverFactoryBoardPage の引き取りと CollectorDriverHome の「後続すべて」から二次搬出を除外する。
  • P&L: pnl_daily の収入日付は completed_at の JST 業務日 (completed_at at time zone 'Asia/Tokyo')::date。宵積み繰越の翌日計上以外の収入・cost・scope・admin fail-closed・NaN/unpriced 契約は mig134 のまま維持する。completed_at is not null を必ず含める。
  • 恒久チェック: scripts/proofs/mig140-secondary-transport-days.sqlscripts/proofs/mig141-pnl-completion-date.sqlscripts/proofs/mig139-driver-stop-reorder.sqlscripts/proofs/mig142-secondary-driver-controls.sql を含む scripts/proofs/*.sql 全流しで [NG]=0。mig140 の explicit carryover FK/trigger、mig141 の completed_at/note server guard と JST 単価期間、mig142 の driver secondary deny を含む。dev 実機は scripts/verify-secondary-evening-load-rls.mjs を使い、既存 sentinel の preflight で未デプロイ/未シード/接続不能を softSkip(exit0) し、適用済みなら staff manage・driver own read・driver write deny・driver 越境 write deny・mig142 driver secondary deny を Docker proof/実機契約で検証する。

横乗り可視化 + per-stop 横乗り(不変条件ラチェット・mig158)

ドライバー1人に同乗する「横乗り」を、既存の full-day 横乗り(dispatch_ride_alongs・mig57・ADR 0016)に加えて 回収先(stop)単位でも設定でき、横乗りする本人がそのドライバーの本日ルートを閲覧できるようにする加法機能。既存の full-day 横乗り・配車ストップ生成/並び替え/D&D・二次搬出・応援は非破壊。実体: supabase/migrations/00000000000158_dispatch_stop_ride_alongs.sqlapps/web/src/lib/dispatch-stop-ride-along.tsapps/web/src/lib/stop-labels.tsapps/web/src/pages/CollectorDriverHome.tsxapps/web/src/pages/collector/dispatch/DispatchBoardPage.tsx。当初 mig156 だったが mig157 先入りで mig158 へ採番修正(実測最大+1 規約)。

  • データモデル(mig158): 新表 dispatch_stop_ride_alongs (id, tenant_id, dispatch_stop_id → dispatch_stops on delete cascade, profile_id → profiles on delete cascade, created_by → profiles on delete set null, created_at)unique (tenant_id, dispatch_stop_id, profile_id)。full-day 横乗り(dispatch_ride_alongs・driver×date 単位)とは別表で、per-stop は「特定の回収先だけ同行」を表す(ADR 0016 が割り切った粒度を加法で補完)。
  • fill / 越境ガード(mig158): dispatch_stop_ride_alongs_fill(SECURITY INVOKER・before insert or update(全列発火)・search_path='')が tenant_id を親 stop から再導出(client 供給値を上書き・[[fill-tenant-trigger-scoped-update-gap]])・stop/profile の別テナント紐付けを raise担当ドライバー本人の自己登録を raiseprofile_id = stop.driver_id 拒否)。監査列 created_by(INSERT 時 auth.uid() の profile へ強制)/created_at は UPDATE で OLD 保持(client 偽造/書換不可)。
  • RLS(mig158): SELECT = 自テナント全 authenticated(driver 含む閲覧)。書込(ALL)= admin|normal 自テナントのみ。越境 WITH CHECK は stop・profile が自テナントに属することを exists 二重検証・外側列は完全修飾([[rls-with-check-tenant-id-shadowing]])。service_role policy は作らない(最小権限・service-role 経路なし)。
  • 可視化 scope(漏洩防止=表示面のみ): dispatch_stops の SELECT はテナント全開放(mig27)で RLS では止まらないため、横乗り者に見せるのは自分が実際に同乗するドライバーのルートだけを client で絞ることがすべて。driverIdsIRideToday/myRideAlongStopIdsdispatch-stop-ride-along.ts)は profileId === 自分 のみを集計し myProfileId=null は空集合(漏洩なし)。fail-closed: 横乗り関係クエリ(useDispatchRideAlongs/useDispatchStopRideAlongs)が isError/isRefetchError のときはその種別を空集合として扱い、失効(横乗り解除)済みドライバーのルートを stale 表示しない。mig177 で完了/取消だけ操作可へ緩和(並び替え/応援/目方/現場メモの affordance は依然出さない・境界は mig177 節参照)。自己同乗の残行(再割当で driverId === profileId になった行)は表示ヘルパーで除外し本人ルートを二重表示しない。横乗りルートは既取得の tenant-wide allStops(応援と共用)から driver 絞り込みで導出し、同乗ドライバー数ぶんの個別 fetch(N+1)を作らない。
  • 停車先は回収先名のみ(2026-09-06): 配車・ドライバー(本日/明日/横乗り)・工場ボードの stop 表示は、通常回収も二次搬出と同じく回収先名のみ(取引先名を出さない)。stop-labels.tsbuildSiteDisplayMap/combineStopLabel/stopNarrationLabel/destinationSiteDisplay)を単一権威とし、行本文・「次:」・aria-label・D&D toast・SR announcement・応援ダイアログ stopLabel の全経路で共有する。取引先の下段や名称解決用の partner props/map を復活させない。同名回収先の曖昧さはユーザー判断で受容する。取引先マスタ・マニフェスト・請求・計量記録の取引先表示は別の責務として維持する。
  • 横乗りは既定で折りたたむ(2026-09-06): 本日・明日とも同乗先ドライバーごとに件数と次の回収先を1行で表示し、Button size="touch"aria-expanded disclosure を開いたときだけ既存の stop 行を描画する。0件の日は横乗り節を出さない。取得中・失敗・成功0件をサマリでも区別し、失敗時の再試行は折りたたみ中も表示する。mig177 の完了/取消境界・明日の閲覧専用・入場時間非表示・関係クエリの失効安全・既存の純関数は不変。
  • 明日の配車にも横乗りを出す(2026-07-26 加法): 宵積みの前日準備では「明日は誰の横乗りに入るか」が当日ルートと同じくらい効くため、CollectorDriverHome明日の配車と同じ節に「横乗り: <ドライバー名>さんのルート」を閲覧専用で出し、明日の自分の stop も per-stop 登録があれば isRideAlongStop で強調する。当日分と同じ純関数driverIdsIRideToday/myRideAlongStopIds。日付は呼び側が渡す行集合で決まる)と同じ fail-closed / revocation 安全(取得・再取得の失敗はその種別を空集合)を使う。
  • 入場時間は明日の横乗りルートに載せない: mig140 の RLS で driver は自分の secondary_transport_days 行しか SELECT できず、同乗先ドライバーの入場時間は取得できない(自分の入場時間を他ドライバーのルートへ誤表示しない)。当日の横乗り節も同じ理由で entryTime を渡していない。
  • 空状態は自分の担当と横乗りが両方空のときだけshouldShowNoTomorrowDispatch)。横乗りだけ入っている日に「明日の配車はありません」と出すと前日準備を取り落とす。
  • 明日分のテナント全件 dispatch_stops は同乗先が居る日だけ取得するrideAlongRouteQueryDate)。当日分の全件は応援(sitesTaken)でも使うので常時取得のまま。
  • 判断ロジックは CollectorDriverHome の export 済み純関数として単体テストで固定する(ページ全体は react 2 コピー問題でレンダーできない)。
  • 恒久チェック: docker proof scripts/proofs/mig158-dispatch-stop-ride-alongs.sql(R1-R11: allow/deny + fill 越境 + 自己登録拒否 + 監査列 server-owned + WITH CHECK 単独越境拒否(R10 stop / R10b profile) + cross-profile UPDATE(R11) + created_by FK on-delete=set null)を scripts/proofs/*.sql 全流し([[local-docker-proof-must-run-all-proofs]])。web unit/render tests(dispatch-stop-ride-along.test.ts/stop-labels.test.ts/各ページ test。明日分は rideAlongRouteQueryDate / shouldShowNoTomorrowDispatch)。prod-critical 新表 → codex terra+luna 敵対レビュー済(P1=0・[[adversarial-workflow-and-new-table-protected-cols]])

横乗りの完了/取消(不変条件ラチェット・mig177)

横乗りメンバーが同乗先ドライバーの配車を完了/取消できるようにする(ユーザー要望 2026-07-31・ wl-tcq)。mig158「閲覧専用」の完了/取消だけを緩和し、他の操作境界は不変。実体: supabase/migrations/00000000000177_ride_along_completion.sqlapps/web/src/pages/CollectorDriverHome.tsxuseStopCompletionGate / RideAlongStopRow)。

  • 「横乗りか」の判定は is_ride_along_for_stop(p_stop_id) が単一権威(SECURITY INVOKER・ search_path=''・STABLE)。full-day(dispatch_ride_alongs=stop の driver×日)OR per-stop (dispatch_stop_ride_alongs=stop 個別)。RLS policy と guard の両方がこの関数を呼ぶ — 二重定義してはならない(片側だけ緩むドリフトの温床)。INVOKER なので越境 stop は 「見えない = false」で fail-closed。登録を staff が外した瞬間から書けなくなる(失効安全・ 判定は保存された登録行を毎回読む)。
  • スコープ: full-day = その日のそのドライバーの全便 / per-stop = 登録された便のみ。 対象は role='driver' の呼出元だけ(can_drive の staff 横乗りは既存の staff 経路 = mig172 の完了統制を通る)。RLS 追加 policy は "ride-along updates accompanied dispatch stops"(UPDATE のみ・SELECT はテナント全開放 mig27 で既に可視)。
  • 完了/取消の統制は担当本人と同等: completed_at はサーバー時刻(mig152 guard + mig172 z_fill が横乗りにもそのまま効く・client 供給値は捨てる)、completed_by は 横乗り本人の profile(誰が完了させたかの監査は mig172 の fill が満たす)。
  • note(現場メモ)は担当 driver 本人だけ: guard に mig177 で所有者条項を追加 (old.driver_id = 呼出元 profile でなければ raise)。横乗りは担当でない stop を更新できる 唯一の非 staff 経路なので、この条項が無いと現場メモを書き換えられる。二次搬出 note の staff 限定(mig141)は従来どおり先に評価される。並び替え(stop_order)は既存 guard の 許可列外で raise、応援 reassign は staff 限定(mig142)、目方は attribution_driver_id ベースの RLS で書けない — いずれも閲覧のまま
  • mig175 撮影ゲートも横乗りへ拡張: ocr_import_items_guard_driver_write の帰属検証 (INSERT / UPDATE 再照合)と evidence の driver INSERT/UPDATE policy を 「担当 or 横乗り」へ広げた(広げないと横乗りの二次搬出完了フローが 42501 で詰む)。 raise メッセージは mig175 proof が照合するため不変。提出後凍結・列 whitelist・ photo→skipped 後退禁止・理由編集禁止・Storage tenant folder は mig175 のまま。
  • web: 完了+撮影ゲート機構は useStopCompletionGate に一元化し、StopRow(担当本人)と RideAlongStopRow(横乗り)が同じ統制を通る(複製禁止)。横乗り行の操作は完了/取消 ボタンのみ。証跡クエリの対象は manifestEvidenceStopIds(担当 + 横乗り先の二次搬出便・ 重複除去)— 含め忘れると横乗りの再完了時に証跡があるのにカメラを再要求する。 明日の横乗り・工場ボードは ReadOnlyStopRow のまま(当日のみ操作可)。
  • 回帰: docker proof scripts/proofs/mig177-ride-along-completion.sql(RA-SENT/RA1-7/ RB1-5/RC1-4/RD1-3。RB5=横乗りでも提出後 OCR item は凍結 / RC4=role-less fail-closed / RD2=登録を外した瞬間から書けない(失効の即時性)/ RD3=bypass GUC でも note 不可 (allowlist の歯)。再実行に強い・ミューテーション 5 系統 = RLS policy drop / guard 退行 / items guard 退行 / evidence policy 退行 / bypass blocklist 退行、で歯を確認済み)+ mig139/141/142/152/172/175 の既存 proof を全流しで非破壊確認 ([[local-docker-proof-must-run-all-proofs]])。web は CollectorDriverHome.test.tsxmanifestEvidenceStopIds)。