計量・按分
本書は AGENTS.md の分冊(正典・AGENTS.md と同格)。優先順位は「実コード → AGENTS.md(本書を含む)→ DESIGN.md → PRODUCT.md」。 節は AGENTS.md から逐語で移設した(PR #nnn・2026-09-16)。対象パス:
apps/web/src/lib/db/weighings.ts` `apps/web/src/lib/weighing/**` `apps/web/src/components/weighing/**` `supabase/migrations/*weigh*` `supabase/migrations/*item_unit*。この領域に触るワーカー / レビュアは本書を読む。
目方入力の確定保存(不変条件ラチェット・mig222)¶
- 確定は原本凍結ではなく、ドライバーが現時点の目方入力を完了したという run 単位の宣言である。
- 入力ページ表示・save の allowedSiteIds・帰社計量警告は
mekataInputStopsという同一純関数を共有する。 - 抑制条件は最終確定時刻より前(同時刻を含む)に完了した stop だけで、確定後に完了した stop は再表示・再警告する。
complete_weighing_event・finalize_run_weighing_confirmed・mig143 ゲート・mig102 一意性は変更しない。- 最終処分場除外、claim 済み event 行、按分許容の既存契約も変更しない。
- 確定 RPC は本人 driver・tenant・run・collecting・未削除をすべて fail-closed に検証し、staff 代行を許可しない。
mekata_confirmed_atは上書きされる最終宣言時刻であり、新しい完了 stop の有無だけを比較して導線を導出する。weighings.mekata_confirmed_atは driver の直接 UPDATE 経路を持たない(driver はweighingsに書込不可=RLS で 0 行)。導線はconfirm_run_mekata(DEFINER)だけを通す。
品目別の按分丸め(不変条件ラチェット・mig223)¶
item_unit_settings.rounding_kgは提案値だけを丸める(例 10kg 単位)。NULL は丸めなし=従来挙動で、既存行を backfill しない。- 丸めは client の提案生成にのみ適用し、finalize RPC の Σ 検証・保存値の契約は変更しない(
finalize_run_weighing_confirmedのΣ(round(weight_kg,2)) = round(net_kg,2)厳密一致は不変)。端数は 1kg 品目が吸収する。 - CHECK は
rounding_kg is null or (> 0 and <> 'NaN' and < 'Infinity')。精度なしnumericなので Infinity ガードを外してはならない(型が弾かない)。 - テナント別設定なので RLS/列 guard は mig131 の
item_unit_settings契約(SELECT=自テナント全 authenticated / 書込=staff・WITH CHECK 完全修飾・service_role policy なし)をそのまま継承する。
run 按分の総重量/空車系列(不変条件ラチェット・mig224)¶
apportioned_gross_kg/apportioned_tare_kgは finalize RPC が導出する server-derived snapshot である。- 系列順の権威は client が供給する
p_itemsの配列順で、同一回収先の全品目行へ同じ gross/tare 対を保存する。 - 導出は event/run の gross/tare と site 合計 weight から行い、client の系列値は受け取らない。
- 先頭 gross・隣接 tare/gross・site 差分・最終 tare を検証し、不整合は fail-closed に扱う。
- zero-net の空 items は従来どおり成功し、系列を保存しない。再按分では既存 snapshot を再導出して上書きする。
- driver の直書きは mig225 の明示条項で遮断する。
weighing_items_guard_driver_writeは whitelist ではなく denylist(拒否列を名前で列挙する実装)なので、「新列は列挙外だから既定で守られる」は成立しない。mig224 時点の本節はそう書いていたが誤りで、実測(docker・全 migration 適用後)では role=driver が自分の collecting run の行へupdate weighing_items set apportioned_gross_kg = 99999を 1 行成功・commit でき、事務が計量履歴の詳細(WeighingDetailDialogはkind='run'で常に「総重量 / 空車」を表示する)でその値を読めた。mig225 が INSERT / UPDATE 双方へ 2 列の拒否条項を追加した。staff の直書きは従来どおり遮断しない(mig129 思想)。 - weighing_items へサーバ所有列を足すときは必ず mig225 の denylist へ拒否条項を追加する(UPDATE だけでなく INSERT 側も。UPDATE のみだと新規行に値を仕込む迂回が残る)。mig131 の目方 snapshot 3 列は driver 入力なので意図的に列挙外である。回帰は
scripts/proofs/mig225-apportioned-driver-guard.sql(G1/G2 UPDATE deny の文言 + 値不変・G3/G4 INSERT deny・G5 許可列の非退行・G6 finalize RPC の DEFINER carve-out 非破壊・G7 staff 非発火)を全流しで固定する。
Resource Weighing Run Optimization(不変条件ラチェット)¶
資源物(有価物)7品目を運行(1コース)単位で計量する機能(mig100/101/102)の不変条件。現物と一致させること(実体: supabase/migrations/00000000000100_resource_run_weighing.sql・..101_complete_run_weighing_rpc.sql・..102_run_weighing_single_collecting_per_driver_day.sql・apps/web/src/lib/db/weighings.ts・run-apportionment.ts・resource-aggregation.ts)。既存の単一計量(weighing_kind='single')は完全非破壊で温存する。
- データモデル(mig100):
weighings.weighing_kind text not null default 'single' check in ('single','run')・weighings.dispatch_stop_id uuid null・weighings.partner_idは nullable(run は取引先非紐付け/single 必須は INSERT/UPDATE ポリシーで担保)・statusにcollecting追加(full:collecting/open/weighed/completed、collectingは run 専用=weighings_collecting_requires_run_check)・gross_kgは collecting run のみ null 可(weighings_gross_required_unless_collecting_run_check)。weighing_itemsにcollection_site_id/partner_id/estimated_kg numeric(10,2)(ドライバー目方=原本)/is_remainder boolean not null default false・weight_kg(確定重量・default 0)・非負 CHECKweighing_items_estimated_nonneg_check。unique はweighing_items_unique_per_site_item (weighing_id, collection_site_id, item_type_id) NULLS NOT DISTINCT(single=site null でも(weighing_id, item_type_id)一意を保持しつつ run は回収先別に同一品目を許す)。item_typesは資源7品目 active(cardboard/magazine/newspaper/pet_bottle/waste_cloth/shredded_paper/aluminum(mig100 追加)・mig16 の code が正典)・styrofoam/mixed_paperはactive=false(履歴・FK 温存で行削除しない)。 - partner_id は単一情報源(mig100 fill トリガ):
weighing_items_aaa_fill_partner_from_site(BEFORE INSERT/UPDATE・DEFINER・aaa_接頭辞で他 guard より先に発火)がcollection_site_idからcollection_sites.partner_idを派生し クライアント供給のpartner_idを上書き(site null → partner null)。クライアント(weighings.ts)はcollection_site_idのみ送出する。月次集計fetchMonthlyAggregation/aggregateBySiteAndItem/aggregateByPartnerAndItemは item-level の partner_id/collection_site_id を集計軸に優先(run は親 weighings 側が null のため)・single は親値へフォールバック・weight_kg>0の確定行のみ集計。proofmig101-*.sqlの CR14 でピン留め。 - collecting run の一意性(mig102): 部分ユニーク索引
weighings_one_collecting_run_per_driver_day=(tenant_id, attribution_driver_id, weighed_on) WHERE weighing_kind='run' AND status='collecting' AND deleted_at IS NULL AND attribution_driver_id IS NOT NULL。理由: モバイルの run 解決が(driver, date, weighing_kind='run', collecting)をlimit(1)で引くため、1 driver+date に collecting run が2つあると誤 run に重量が混ざる。DB 索引(機械強制・23505)+ client 先回りブロック(fetchCollectingRunsForDriverDate/blocksDuplicateRun→RunAlreadyCollectingError)の二層防御。恒久解は run→course 紐付け(follow-up)。 - ライフサイクル/対称ガード:
collecting→open(gross)→weighed(tare)→completed。run の gross はrecordRunGross(条件付き UPDATEweighing_kind='run' AND status='collecting' AND deleted_at IS NULL・不一致はRunGrossNotCollectingError)、tare はrecordRunTare(同status='open'・不一致はRunTareNotOpenError)で対称にガード(stale パネルが soft-deleted/別状態の法定記録を巻き戻さない)。run の新規作成 UI と web のcreateRunWeighingは #533/v1.71.0 QA で撤去済み(新規はコース由来・get_or_create_today_runRPC のみ。staff INSERT policy と driver INSERT 不可の DB 契約は不変で、既存 run の閲覧・確定経路も不変)。single は既存useWeighIn/weighOut/useCompleteWeighingを非破壊で温存。 - 按分(
run-apportionment.ts): net=gross−tare。固定品目weightKg=estimatedKg・残り品目(is_remainder・運行あたり1つ)weightKg=net−Σ(固定 estimatedKg)をapplyResidualToItemsで充当しΣ(weightKg)=netを厳密成立。許容 =apportionmentToleranceKg(net)=max(1, net*0.005)(正側の丸め誤差吸収専用)。negative_net(net<0)/remainder_negative(Σ固定>net・許容でマスクしない)/multiple_remainders/no_positive_items(net>0 で正 item 皆無)/no_remainder_excess(残り無しで正側許容超過)はseverity='error'=確定不可。UI 確定ゲートは RPC 契約(net>0 のときのみ空/全ゼロ item 拒否・zero-net は空完了可)と一致。error パスでは固定品目のweightKgを書き換えない(原本保持)。 - RLS(mig100): driver は「driver inserts/updates own run weighing_items」で 自運行(
attribution_driver_id=自分)かつweighing_kind='run'かつstatus='collecting'かつ未削除のweighing_itemsのみ書ける。列 guardweighing_items_guard_driver_write(SECURITY INVOKER・current_user in ('authenticated','anon')かつcurrent_user_role()='driver'のときだけ発火・weight_kg/id/weighing_id/item_type_id/created_at/deleted_at/deleted_by/weighing_event_id/apportioned_gross_kg/apportioned_tare_kgの変更と INSERT 時weight_kg≠0/deleted_at/deleted_by/weighing_event_id/apportioned_*を raise)でweight_kg直書きを遮断。これは拒否列を名前で列挙する denylist であって whitelist ではない——列挙しなかった列は driver が直接 PATCH できるので、サーバ所有列を足すときは必ず拒否条項を追加する(実装と食い違う「未知列も既定で保護される」というコメント/記述が mig224 の列挙漏れを生み、mig225 で是正した)。越境 FK 注入はweighing_items_guard_fk_tenant(DEFINER・親 weighing の tenant とcollection_site/partnerの一致を全経路で検証)で遮断。single は driver 不可(mig99 の driver-own SELECT 二段は温存・再著述しない)。 - RPC(mig101):
complete_run_weighing(p_weighing_id uuid, p_items jsonb)は SECURITY INVOKER・search_path=''・全参照public.完全修飾・revoke from public+grant to authenticated。テナントガード(自テナント+weighing_kind='run'+未削除)→ net 未確定/負値weight_kg/net>0 空完了を raise → Σ検証(max(1,net*0.005))→ 原子(delete weighing_items→insert(weight_kg>0のみ・fill トリガが partner 派生)→update status='completed')→{weighing, items}を返す。旧complete_weighingは先頭で run を raise 拒否(single の挙動/署名/INVOKER/search_pathは mig75 完全不変)。sentinelmig100_run_weighing_applied()(immutable・true)が dev verifier の preflight。 - 回帰: docker proof(
scripts/proofs/mig100-*.sql/mig101-*.sql(CR14 partner 派生・CR13 空完了拒否・SENT sentinel 含む)/mig102-*.sql)をscripts/proofs/*.sql全流し(shim.sql+ 全 migration に適用・[OK]検出/[NG]=0)。dev 実機はscripts/verify-run-weighing-rls.mjs(sentinel preflight・infra/seed 起因は softSkip=exit0・漏洩のみ FAIL=exit1)。
Internal-Course Multi-Weighing(不変条件ラチェット・mig108)¶
社内コース1運行に複数の帰社計量イベントを持たせ、各イベントが「回収完了かつ計量未完了の回収先」を自動 claim して按分する機能(weighing_kind='run' の拡張・mig100/101/102 上に加法)。実体: supabase/migrations/00000000000108_run_weighing_events.sql。既存の single/run は非破壊。
- データモデル(mig108): 新表
run_weighing_events(帰社計量イベント・run 配下 N 件・gross_kg/tare_kg・net_kg numeric generated always as (gross_kg - tare_kg) stored・status text check in ('open','weighed','completed')・seq int=運行内連番・unique (run_weighing_id, seq)・tenant_idは run から fill トリガ補完)。numeric CHECK はnet/gross/tare is null or (col >= 0 and col <> 'NaN'::numeric)(NaN 封鎖・[[numeric-money-check-nan-gap]])。weighing_itemsに加法列:collected boolean not null default false(回収完了・driver 設定)/weighing_event_id uuid(null=計量未完了・非 null=計量完了)。weighing_event_idの FK はon delete restrict(set nullだと計量済み行が再 claim され二重計上する)。 - 列 guard(mig108):
run_weighing_events_guard_protected_cols(SECURITY INVOKER・current_user in ('authenticated','anon')かつcoalesce(current_setting('app.bypass_rwe_guard', true), '') <> 'on'のときだけ発火)が client 直 PATCH/INSERT を遮断(UPDATE でstatus/gross_kg/tare_kg/weighed_at/seqの distinct・INSERT でstatus<>'open'/weighed_at/gross_kg/tare_kg非 null を raise=空捏造完了・net 遡及改ざん封鎖)。weighing_items_guard_driver_write(mig100)は許可列にcollectedを追加・weighing_event_idは driver 直書き禁止・計量済み行(old.weighing_event_id is not null)の driver UPDATE を全面禁止。越 run 紐付けはweighing_items_guard_fk_tenant(DEFINER)がweighing_event_idは同一weighing_id(run) の event のみ許可で遮断。 - fill/整合トリガ(mig108):
run_weighing_events_fill_tenant(DEFINER・親 run から tenant 補完=越境封鎖・run_weighing_idは immutable・親がweighing_kind='run'かつ未削除を検証)。weighings_sync_weighed_event(DEFINER・AFTER UPDATE OF status,gross_kg,tare_kg・run がweighedへ遷移 or 既 weighed の gross/tare 訂正時に canonical event(seq 最小)を weighed で create-or-sync+active items をcollected=trueにマーク・completed event を持つ run は不触=確定保護)。 - RPC(mig108):
get_or_create_today_run(p_weighed_on date) returns uuid(SECURITY DEFINER・search_path=''・fail-closed role guard=driver/admin/normal 以外 raise・帰属はrole='driver'orcan_drive=true限定=[[epd-attribution-canactasdriver-and-live-partner]]・mig102 部分ユニークで 23505→再 SELECT で冪等)。complete_weighing_event(p_event_id uuid, p_items jsonb) returns jsonb(SECURITY INVOKER・search_path=''・写経complete_run_weighing): event.statusin ('weighed','completed')を許可(再按分)・claim 排他=collected=true AND weighing_event_id IS NULL(初回)orweighing_event_id = p_event_id(再按分)・親 run→event→items の順に FOR UPDATE ロック(TOCTOU/並行 claim 直列化)・p_items と claim 集合の全単射検証・Σ(weight_kg)=event.net_kg 許容greatest(1, net*0.005)・保護列書込はapp.bypass_rwe_guardlocal GUC で通す。 - 回帰: docker proof
scripts/proofs/mig108-*.sql(scripts/proofs/*.sql全流し・[[local-docker-proof-must-run-all-proofs]])。dev 実機はscripts/verify-run-weighing-events-rls.mjs(sentinel preflight・infra/seed softSkip=exit0・漏洩のみ exit1)。
Walk-in Weighing(不変条件ラチェット・mig111)¶
顧客持ち込み(walk-in)計量。weighing_kind='walk_in'(案A)。伝票(session)×品目行の逐次差分/単価/金額を扱う。実体: supabase/migrations/00000000000111_walk_in_weighing.sql。DB 層 + Web UI とも配線済み(mig111 Wave3): apps/web/src/lib/db/walk-in-weighings.ts・WalkInWeighingPage/WalkInWeighingsListPage・walkInReceiptData(計量証明書PDF)・App.tsx ルート /collector/walk-in-weighing(一覧/検索/詳細/再発行)。既存の single/run/walk_in 判定は非破壊。綴り: contract_type='walk-in'(ハイフン)/ weighing_kind='walk_in'(アンダースコア)。
- データモデル(mig111):
weighings加法列weighing_method(sequential/single/individual・walk_in なら NOT NULL)/payment_method(cash/transfer/card/invoice)/settlement_mode(spot/monthly)/total_amount numeric/receipt_no text/settled_at timestamptz。weighing_kindCHECK を('single','run','walk_in')に拡張・gross-required CHECK にor weighing_kind = 'walk_in'追加(walk_in は weighing.gross_kg NULL 許容)。新表walk_in_sessions(伝票セッション・seq/weighed_at/slip_no/vehicle_no・tenant は fill 補完)。weighing_itemswalk_in 列walk_in_session_id/line_seq/gross_kg/tare_kg/line_net_kg/kago_biki/dust_mizu_biki/direction(buyback/charge)/unit_price/amount。既存weight_kg= 課金対象重量(line_net_kg − kago_biki − dust_mizu_biki・控除後)。unique は walk_in 除外の部分索引 + walk_in 専用(weighing_id, walk_in_session_id, line_seq)+weighings (tenant_id, receipt_no) where receipt_no is not null。 - 符号規約 / NaN 封鎖:
total_amount正=客が支払う / 負=客が受け取る(= Σ(charge amount) − Σ(buyback amount))。total_amountは負値が正規(買取超過)のため>=0を付けず NaN のみ封鎖(total_amount is null or total_amount <> 'NaN'::numeric・[[numeric-money-check-nan-gap]])。walk_in 品目の numeric 列(gross/tare/line_net/kago/dust/unit_price/amount)は>= 0 and <> 'NaN'::numericの null 許容 CHECK。 - 列 guard(mig111・walk_in 行のみ):
weighings_guard_walk_in_cols(INVOKER・app.bypass_walkin_guardlocal GUC carve-out)が client の walk_in weighing 直接 INSERT を丸ごと raise(必ずfinalize_walk_in_weighing経由)+ UPDATE でweighing_kind(immutable)/total_amount/receipt_no/settled_at/payment_method/settlement_mode/weighing_method/attribution_driver_id(walk_in は driver 帰属なし=driver 不可視担保)/deleted_by(監査列)の distinct を raise。weighing_items_guard_walk_in_cols(INVOKER・同 GUC)が親 walk_in 配下への client 直接 item INSERT を raise + walk_in item の権威列(walk_in_session_id/line_seq/gross_kg/tare_kg/line_net_kg/kago_biki/dust_mizu_biki/direction/unit_price/amount/weight_kg/deleted_by)の client 変更を raise。越境 session link はweighing_items_guard_fk_tenant(mig108 に walk_in_session の同一 weighing 検証を追記)で遮断。 - RPC(mig111・いずれも SECURITY INVOKER・
search_path=''・staff 限定 fail-closed):finalize_walk_in_weighing(p_partner_id, p_weighing_method, p_weighed_on default null, p_payment_method default null, p_settlement_mode default null, p_sessions default '[]')=1 Tx で walk_in 伝票確定(bypass GUC 下で weighing/session/items INSERT・内部整合Σチェックは永続化後に storage からΣ weight_kg(=v_sum_actual) とΣ round(line_net_kg−kago−dust, 2)(=v_sum_rederive) を独立再導出しループ内v_sum_expectedとis distinct fromの厳密一致で照合=許容greatest(1, expected*0.005)は不採用(設計書より厳格)・不一致は raise で確定中止)。price_walk_in_weighing(p_weighing_id, p_prices)=後付価格(unit_price 更新・amount=round(weight_kg*unit_price)・total=Σ(charge)−Σ(buyback)再算出)。採番はnext_walk_in_receipt_seq(p_tenant uuid)(DEFINER・per-tenant/day 行ロック直列採番・p_tenant is distinct from current_tenant_id()で越境採番遮断=fail-closed)。receipt_no=to_char(current_date,'YYYYMMDD')||'-'||lpad(seq,3,'0')(日付部は採番日 current_date 基準・weighed_on ではない)。 - legacy 拒否(mig111):
complete_weighingは先頭で run 拒否に加え walk_in 拒否(walk_in は session/逐次差分/単価を持ち legacy の delete→plain reinsert で破壊されるためfinalize_walk_in_weighing経由に限定)。single の挙動/署名/INVOKER/search_path は不変。 - 回帰: docker proof
scripts/proofs/mig111-*.sql(1806/2300/1a04 系 fixture・全流し)。dev 実機はscripts/verify-walk-in-rls.mjs(sentinelmig111_walk_in_weighing_applied()preflight)。
Walk-in 未収(不変条件ラチェット・mig199)¶
顧客持ち込みの掛売を、確定時の「精算済み」表示から未収として正直に扱う Stage 1。請求書発行・入金消込の本体は別ステージ。
settled_atの意味論:settlement_mode='spot'と未指定(NULL)は確定時にnow()(従来挙動)。settlement_mode='monthly'を明示した行だけ確定時NULLのまま=未収。判定はapps/web/src/pages/collector/walkInPageLogic.tsのwalkInSettlementStatusに一元化する。- 入金記録 / 取消:
settle_walk_in_weighing(uuid)/unsettle_walk_in_weighing(uuid)(mig199)を経由する。いずれも SECURITY INVOKER・staff(admin/normal)・monthly walk-in・未削除・自テナント限定、role 欠落を含む fail-closed guard、app.bypass_walkin_guardtransaction-local GUC、対象行ロック + ROW_COUNT 検査を持つ。client のsettled_at直 PATCH は従来どおり列 guard で拒否する。 - 過去行の是正: mig199 以前に確定した monthly 行は、実際に入金済みかをデータだけで判別できないため NULL へ backfill しない。事務が実態を確認し、入金済み表示が誤りなら
unsettle_walk_in_weighingで人手是正する。 - 回帰:
scripts/proofs/mig199-walk-in-receivables.sqlの S1〜S7(monthly/spot/NULL、settle/unsettle、deny 文言・越境・role 欠落、直 PATCH guard)をscripts/proofs/*.sql全流しで検証する。
社内コース帰社計量 確定保存(不変条件ラチェット・mig118/143)¶
社内コース(weighing_kind='run')の帰社計量で、この run に目方がある「回収完了かつ確定計量済」回収先×品目のみを対象に、帰社スケール net を確定目方比で按分して確定保存する機能(mig100/101/108 上に加法、②a の目方ゲートは mig143)。実体: supabase/migrations/00000000000118_run_weighing_confirmed_finalize.sql・supabase/migrations/00000000000143_run_apportion_mekata_gate.sql・apps/web/src/lib/db/run-apportionment.ts(apportionRunByRatio)・apps/web/src/lib/weighing/confirmed-apportion-view.ts・apps/web/src/components/weighing/ApportionPanelRun.tsx。既存 single/run 完了経路(complete_run_weighing/complete_weighing_event)/ walk_in は完全非破壊。
- 確定保存 RPC(mig118):
finalize_run_weighing_confirmed(p_weighing_id, p_items)は SECURITY INVOKER・search_path=''・完全修飾・revoke public+grant authenticated(mig115 作法・DEFINER 化禁止)。status='weighed'の初回確定のみ親 run を FOR UPDATE(weighing_kind='run'・未削除・tenant_id=current_tenant_id())し、collecting/open は拒否、completed の再按分は既存complete_run_weighingに限定する。event link 済み行と未 claim collected 行が混在する partial event run は whole-run delete→reinsert を禁止する S1/S2 positive-invariant guard に従い、complete_weighing_eventで現在 weighed の event を確定する。通常経路はdelete→insert(保存丸め後 weight_kg>0 のみ)→status='completed'→canonical event create-or-complete→reinsert 行へ collected=true + weighing_event_idを原子的に行う。bypass GUC は既存のapp.bypass_legal_delete/app.bypass_rwe_guardのみ使用(新規 GUC を作らない)。 - 許可品目のサーバ強制(mig118・新セキュリティ契約):
collection_site_item_types(mig104)をサーバ側で強制する唯一の点。p_items の各(collection_site_id, item_type_id)は、その site に csit 行があるなら item がその集合に含まれること(未設定 site=0 行は全許可=mig104/driver UI と同 graceful fallback)。違反は raise(fail-closed)。mig104 以前は driver UI のみが whitelist を絞りサーバ強制が皆無だった穴を塞ぐ。client(ApportionPanelRun/confirmed-apportion-view.ts)は表示面でもresolveSiteItemTypesで絞るが、権威はこの RPC。 - 確定後不変性は event link で担保(staff freeze を作らない): finalize は items を
weighing_event_idlink + run completed にし、mig108 driver guard(old.weighing_event_id is not nullの driver UPDATE 全面禁止)+ FKon delete restrictで driver 側の不変性を担保する。staff の完了後編集は封じない: [[settlement-finalize-snapshot-not-source-freeze]](方針B)で原資料(weighings/weighing_items)は finalize 後も編集可・不変性は精算スナップショットで担保、が採用済み。status='completed'で weight_kg を一律凍結する guard は mig94(settlement source 編集 ALLOW)/mig101(再按分)/mig111(walk-in item 編集)と矛盾するため作らない(この判断は敵対的な全 proof 全流しで検出された回帰から確定・[[local-docker-proof-must-run-all-proofs]])。 - 確定比按分(client):
apportionRunByRatio(run-apportionment.ts)は walk-incomputeSingleApportionの整数「銭」(cents) 最大剰余法を移植し net を確定目方比で配分・Σ(weightKg)=net 厳密・全 share≥0・負残差取りこぼし無し。ApportionPanelRunは siteKeys 内の state のみを比率入力・表示合計・canSubmit・payload に使い、検証集合と送信集合を一致させる。既存apportionRun(固定+残りモデル)は非破壊で温存。サーバ RPC はΣ(round(weight_kg,2)) = round(net_kg,2)の厳密一致を検証し、同じ丸め済み値だけを INSERT する(許容greatest(1,net*0.005)は使わない)。 -
回帰: docker proof
scripts/proofs/mig118-confirmed-finalize.sql(1807/1a05/1d00-e 系 fixture・F1 許可外品目拒否/F2 fallback/F3 confirmed+Σ=net+event link/F4 driver 改変不可/F5 越境拒否/F6 非 weighed 拒否/F7 sentinel/F9 strict Σ/F10 inactive/F11 sub-cent 保存和乖離拒否)をscripts/proofs/*.sql全流しし[NG]=0を確認する。sentinelmig118_confirmed_finalize_applied()。新 server-owned 契約(許可品目強制)は prod-critical → codex 敵対的セキュリティ Workflow 必須([[adversarial-workflow-and-new-table-protected-cols]])。 -
計量ストップの表示範囲(2026-07-20 目方一本化): 計量ボードの運行(計量ストップ)のタイトル(
CollectorWeighingPage.runLabelOf→run-board-labels.runSiteLabel)と帰社計量詳細の初回確定対象(confirmed-apportion-view.confirmedSiteIds)は、配車予定(dispatch_stops)そのものではなく run のweighing_itemsでestimated_kg > 0またはis_remainder=trueのサイト(意味のある目方入力)に scope する。signal はsitesWithEstimate(items)(estimated_kg=0/null 非残りは対象外、残り行は estimated null でも対象)。タイトルはボードの run 群に対しuseWeighingItemsForRuns(tenantId, runIds)で weighing_items を 1 クエリバッチ取得して N+1 を避ける。再按分パス(reapportionSiteIds=既存内訳)・最終処分場除外・許可品目(mig104)・single は非破壊。通常回収 site(二次搬出の排出元を含む)は最終処分場でなければ対象に残す。回帰はconfirmed-apportion-view.test.ts/ApportionPanelRun.test.tsx/run-board-labels.test.ts/weighings.freshness.test.tsで固定。 - 帰社計量 finalize の目方ゲート(不変条件ラチェット・mig143/mig144):
finalize_run_weighing_confirmedの ②a・complete_weighing_eventの claim・clientconfirmedSiteIds/event round の母集合は、この run にestimated_kg > 0またはis_remainder=trueのweighing_itemsがあるcollection_siteに限る(event round は collected 未計量 claim を 0kg 補完する)。dispatch_stopsの有無・日付・帰属 driver・completed 状態を適格性判定へ戻してはならない。collection_sites.partner_id → partners.is_final_disposal=trueの最終処分場は、driver 目方行の生成時と両 finalize RPC で fail-closed に除外する。目方無し site、estimated_kg=0のみの site、null site は対象外。通常回収 site(二次搬出の排出元を含む)は最終処分場でなければ対象に残す。恒久回帰はscripts/proofs/mig143-run-apportion-mekata-gate.sql+scripts/proofs/mig144-final-disposal-mekata-gate.sql+confirmed-apportion-view.test.ts+ApportionPanelRun.test.tsxで固定する。
Driver Mekata Units + 保存後遷移 + PWA鮮度(不変条件ラチェット・mig131)¶
ドライバー運行目方入力に「品目別 単位/kg換算(テナント別・袋数入力)」「保存後の本日ルート遷移」を加え、PWA のデータ鮮度を改善する加法機能。実体: supabase/migrations/00000000000131_item_unit_settings_and_estimated_count.sql・packages/shared/src/lib/convert-count-to-kg.ts・apps/web/src/lib/db/item-unit-settings.ts・apps/web/src/components/weighing/RunWeighingKeypadSheet.tsx・apps/web/src/pages/driver/DriverRunWeighingPage.tsx・apps/web/src/pages/collector/ItemUnitSettingsPage.tsx・apps/web/src/lib/pwa-focus-refetch.ts。既存の single/run/walk_in・按分(run-apportionment/confirmed-finalize mig118/complete_run_weighing mig101/complete_weighing_event mig108)は完全非破壊。
- テナント別
item_unit_settings(mig131):(tenant_id, item_type_id, unit, kg_per_unit)・unique (tenant_id, item_type_id)(通常 unique=PostgREST upsert 可・部分索引 42P10 罠に非該当 [[run-weighing-upsert-42p10-partial-index]])。tenant_idはbefore insert or update(全列発火)の INVOKER fill トリガがcurrent_tenant_id()で上書き(越境封鎖・null で fail-closed [[fill-tenant-trigger-scoped-update-gap]])。RLS = SELECT は自テナント全 authenticated(driver 含む・換算読取)/ INSERT/UPDATE/DELETE は staff(admin|normal)自テナントのみ・WITH CHECK は完全修飾item_unit_settings.tenant_id = current_tenant_id()([[rls-with-check-tenant-id-shadowing]])。service_role policy は作らない(最小権限)。未設定品目はitem_types.default_unit='kg'・換算1 に fallback。 - numeric 契約(mig131):
kg_per_unit(> 0)・weighing_items.estimated_count(>= 0)・estimated_kg_per_unit(> 0)の CHECK は非負/正だけでなく<> 'NaN'::numericかつ< 'Infinity'::numericをセットで含める(NaN + ±Infinity の全非有限値を封鎖。PG14+ は numeric Infinity を持ち> 0 and <> 'NaN'だけでは Infinity を通すため。[[numeric-money-check-nan-gap]] の Infinity 拡張)。 - weighing_items スナップショット3列(mig131・additive):
estimated_count/estimated_unit/estimated_kg_per_unit。estimated_kgは按分原本のまま非改修(=round(count × kg_per_unit, 2)を client 計算で入れる)。weight_kgは driver 書込禁止のまま。mig100 のweighing_items_guard_driver_write(ブロックリスト実装)は再著述しない——新3列は列挙外で driver 書込可、weight_kg は既存 raise で遮断。kg 品目は3列 null(完全後方互換)。この不変性は proof IUS5(driver が3列を書ける)/IUS6(driver の weight_kg 直書きは依然 raise)で固定。 保存 RPCupsert_run_weighing_itemsは mig261 で3列を INSERT/UPDATE とも永続化する(mig115 は列を持たず送信値を捨てていた)。キー欠落は NULL。回帰はmig261-mekata-snapshot-persist.sqlと S112-mekata-units.scenario.test.tsで固定する。dev verifier の sentinel preflight はclassifySentinelResponse(scripts/lib/postgrest-sentinel.mjs)で分類し、未デプロイ(404/PGRST202)と既知の接続障害(5xx・PGRST000〜003・429)は softSkip、200/false・403等の退行だけ FAIL にする。全 verifier の sentinel preflight はprobeSentinel経由(ラチェットsentinel-preflight-ratchet)。 - 保存後遷移(web・Part A):
DriverRunWeighingPage.handleSaveはsave()の戻り{ saved, fetchError, whitelistDropped, routeDropped }を使い、完全成功(saved===true && !fetchError && whitelistDropped===0 && routeDropped===0)のときだけnavigate('/driver')。部分未保存/各種エラー時は遷移せず入力保持(stale クロージャ回避のため form state でなく save() 戻り値で判定)。 - PWA 鮮度(web・Part C):
queryClient.tsのグローバル既定は不変(refetchOnWindowFocus:false/staleTime:60s)。本日ルート/計量ボード等の閲覧系クエリのみ opt-in でrefetchOnWindowFocus:true+refetchOnReconnect:true+staleTime短縮+refetchInterval(背景停止refetchIntervalInBackground:false)。目方入力ページはポーリングしない(編集上書き回避・手動更新+save 前 fresh 取得を維持)。iOS standalone 復帰対策にpwa-focus-refetchがvisibilitychange/pageshow(persisted)でfocusManager.setFocused(true)。Supabase Realtime は本スライス対象外(follow-up)。 - 回帰: docker proof
scripts/proofs/mig131-item-unit-settings.sqlをscripts/proofs/*.sql全流し([[local-docker-proof-must-run-all-proofs]])。sentinelmig131_item_units_applied()。prod-critical(migration + 新テーブル)は codex 敵対的セキュリティレビュー済([[adversarial-workflow-and-new-table-protected-cols]])。
Scale Serial Auto-Probe(不変条件ラチェット・2026-07-15)¶
- 既存の AD-4410(2400/7E1)/AD-4347(9600/7E1)の固定選択は単一 open のまま非破壊で温存し、file モードは対象外とする。
- auto は
SCALE_PROBE_COMBOSを確度順(9600/7E1→2400/7E1→9600/8N1→4800/7E1)に試し、全候補でparseAdScaleLineを使い、isProbeHit(status !== 'unknown')の候補で確定・記憶する。無受信は error とする。 - 候補ごとの open↔close を厳密に対にし、プローブ中も lifecycleRef の直列化、各 await 後の stale ガード、DTR/RTS assert、XON(
shouldSendXon)を維持する。候補切替で二重 open/ポートリークを起こさない。 - 候補順序・hit 判定・auto 写像・成功候補記憶は純関数/小さな I/O helper として vitest で固定し、hook の I/O 実装へ判断ロジックを戻さない。
計量記録メモの側表(不変条件ラチェット・mig268/269)¶
weighings.notes は事務が計量記録に付ける現場メモで、書込・閲覧とも staff 限定の設計意図。
PostgreSQL の RLS は列マスクを持たないため、driver と同じ authenticated ロールから
分離できず、側表 weighing_notes(1:1・staff 限定 RLS)へ移した。設計正典は
docs/superpowers/specs/2026-09-10-weighing-notes-driver-secrecy-design.md。
- 対象は
weighings.notesだけ: driver 所有・driver 宛のメモ列(dispatch_stops.note/vehicle_daily_logs.note/ skip 理由類)は秘匿対象にしない(秘匿すると機能が壊れる)。 他 driver の stop の note が読める残存は wl-0t93(P3・別スライス)。 - 行はメモが空でない計量にだけ存在し(空/NULL は行削除)、
notesはbtrim <> ''の 名前付き CHECK。tenant_idは DEFINER fill が親weighingsから毎回再導出 (BEFORE INSERT OR UPDATE全列・UPDATE OFに限定しない)、weighing_idは immutable (fill と INVOKER guard の二重)。updated_by/updated_atは fill がサーバー値で 上書きする(client 直書きを 42501 で拒否するのではなく上書きする。INVOKER guard が 42501 で拒否するのはweighing_idの変更だけ)。 - RLS: staff(admin|normal)の SELECT・INSERT・UPDATE・DELETE 4 本(個別・ALL ではない)。
いずれも自テナント + 親 weighing が未削除であることを
existsで要求する。 driver policy なし・service_role policy なし・anon なし。 trigger 専用の DEFINER 関数(fill・同期)はrevoke all ... from public, anon, authenticated, service_roleで直接 CALL させない(mig253/196 の作法)。 sentinelmig268_weighing_notes_applied()はrevoke from public+grant to authenticatedだが、関数の default ACL により anon にも EXECUTE が残る (preflight が anon で成立する理由・revoke しない)。 - expand(mig268・日中可): backfill(冪等
on conflict do nothing)+ 同期トリガ (AFTER INSERT OR UPDATE OF notes・DEFINER・再入防止)で旧列と整合する。 段階 1 の web は旧列(weighings.notes)だけに書き、側表は同期トリガの派生として 読む(fetchWeighingNotes+attachWeighingNotes・未確定時は合流しない)。 親 UPDATE と側表 upsert の dual-write は採らない(別トランザクションの interleave で 新旧不一致になる・PR #772 レビュー F2。保存・CSV はメモ確定まで開始させない)。 driver 経路のweighings直 select はWEIGHING_COLUMNS(notes抜き 31 列)の明示列挙にする。rowToWeighing/rowToWalkInWeighingはrow.notesを読まない。 - contract(後続 migration・採番は着手時に実測・夜間推奨): 同期トリガと関数を drop →
weighings.notesの列を 落とす。weighingsに staff 限定のメモ列を戻してはならない(RLS は列マスクを持たない)。 - 回帰:
scripts/proofs/mig268-weighing-notes.sql(N1〜N11・変異 3 系統の歯)・ S138-weighing-notes・scripts/verify-weighing-notes-rls.mjs・no-unbounded-query-ratchetの weighings 投影検査(weighing-notes-okmarker)で固定する。