> For the complete documentation index, see [llms.txt](https://lmile.gitbook.io/lmile-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://lmile.gitbook.io/lmile-docs/reference/idempotency.md).

# Idempotency

全公開 API の冪等性の一覧です。リトライ・二重送信時の挙動はここを正とします。

## Ingestion API

### Products — `/api/ingest/products`

`shop + barcode` で UPSERT。同じ barcode を何回送っても安全です。

* 値に変更がなければ `unchanged`
* 値に変更があれば `updated` + `product_update_logs` に `changed_fields` を記録
* `GET /api/ingest/accessory-services`（read）は書き込みが無いため冪等性の対象外です。何回呼んでも安全です。

### Inventory Snapshot — `/api/ingest/inventory-snapshot`

店舗 × 商品 × 拠点ごとの 1 行を絶対値で丸ごと置き換えます。同じ値の再送は在庫・日次とも重複せず、変更ログも**値が変わった行のみ**追記されます（再送に冪等）。読み値が反映済みより古い行は適用されず `stale_skipped[]` で返ります（同時刻は適用 = 再送冪等を維持。→ [古い読み値の上書き防止](https://lmile.gitbook.io/lmile-docs/reference/pages/KhWYLKd2K53zTuTiQY2T#古い読み値の上書き防止)）。

### Inventory Available — `/api/ingest/inventory-available`

* **`mode=absolute`**: `available_qty = N` の上書きのため再送に冪等。
* **`mode=delta`**: 積算のため本質的に非冪等。`idempotency_key` を付与すると、同一キー × 同一 `(product, location)` の `available_delta` ログが既存なら適用を skip（`lines_deduped`）。**順次リトライを吸収する best-effort** であり、完全な並行重複保証ではないため、**イベント単位で一意な `idempotency_key`** を付与してください。
* **`committed_qty` / `on_hand_qty`（任意）**: 送られた行だけ **absolute で上書き**するため再送に冪等。省略した行は据え置き（`mode` に依らず常に absolute）。ルーティングは `available_qty + committed_qty` で引き当てを判定します。
* **`as_of`（任意）**: 行の読み値を在庫システム側で読み取った時刻。反映済みより古い行は適用されず `stale_skipped[]` で返ります（同時刻は適用 = 再送冪等を維持。→ [古い読み値の上書き防止](https://lmile.gitbook.io/lmile-docs/reference/pages/KhWYLKd2K53zTuTiQY2T#古い読み値の上書き防止)）。

### Channel Available — `/api/ingest/channel-available`

* `shop + location_id + barcode` の行を、送った `channel_available_qty` で**上書き**するため再送に冪等です。
* 行の `as_of`（省略時は `inventory_as_of` → 受信時刻）が、反映済みの値の時刻より古い行は適用されず `stale_skipped[]` で返ります。順序が入れ替わって届いても新しい値が勝ちます。
* 倉庫から届いた在庫数（`on_hand_qty`）は変わりません。販売可能在庫は、チャネルの値が 0 以下なら 0、それ以外は通常の計算で決まります。

### MFC Inventory — `/api/ingest/mfc-inventory`

`shop + fulfillment_source_location_id + shelf_code + barcode` で棚別 qty を反映し、同一トランザクションで barcode を全棚合算し `sot.inventory_levels.available_qty` を更新します。

* **`mode=absolute`（既定 / 省略時）**: 絶対値 set のため同一ボディの再送に冪等です（棚在庫・合算とも重複しません）。
* **`mode=adjust`**: 符号付き差分の加減算のため**本質的に非冪等**です。冪等キーは持たないため、リトライ時は適用済みかを呼び出し側で確認してください（既存 routing write の secure / oversell と同方針）。

`barcode` 未登録（`product_not_found`）の行や `adjust` で在庫が負になる行は、silent skip せず**バッチ全体を reject** します（部分適用なし）。

### Stocktake — `/api/operator/stocktake/*`

* `open`: 拠点ごとに進行中のセッションは 1 つ（部分 UNIQUE）。進行中があれば `409 session_already_open` でその id を返す（新しく作らない）。
* `shelf-start`: 数えている途中の棚への再 start は何もしない。締めた棚への start は再計数（前の計数は `superseded` として残す）。
* `shelf-submit`: 棚単位の**置き換え**。同じ内容の再送は同じ結果（二重にならない）。帳簿数（`expected`）は締めた時点で固定。
* `transition`: 状態機械。許されない遷移は `409 invalid_state`。`location`（店舗）の `submit` は商品ごとの帳簿数の行を作り直す（取り下げ → 再提出で置き換わる）。
* `commit`（`location`）: 商品単位で `applied_at` を付け、外部システムへの調整は `UNIQUE (session_id, product_id)` で 1 行だけ作る。再 commit で二重に作らない。
* `commit`（`shelf`）: **商品（barcode）単位の部分反映で冪等**。反映した行には `applied_at` が付き、再 commit は未反映の行だけを処理する。差分（`counted − expected`）を当てるので、締めたあとの出荷・入庫と二重にならない。保留（`counted_below_active` / `negative_after_delta`）は再 commit で再評価する。`committed` への再 commit は `alreadyCommitted: true` の no-op。商品ごとに `(shop, location, barcode)` の advisory lock を取り、引き当て・再計算・数量修正と直列化する。

### Inventory Holds — `/api/inventory/hold` / `/api/inventory/release`

`hold` は `shop + reference_id + location + barcode` の取り置き数を**絶対値で設定**します（`quantity: 0` は削除）。同じ内容の再送は同じ結果に収束し、履歴（`inventory_ledger`）も数が変わったときだけ追記します。`reference_id` を省略すると毎回新しい参照 ID が生成されるため、**省略した再送は二重の取り置きになります**。1 リクエスト内の全行は 1 トランザクションで、1 行でも失敗すれば全行を取り消します。`release` は `shop + reference_id` の取り置きを削除し、該当なしの再送も `ok: true`（空配列）です。

### Order Line Master — `/api/order-line-master/bulk-upsert`

`shop + source_system + transaction_id + transaction_line_id + line_instance_no` で UPSERT。同一行の再送は差分があれば更新して `order_line_master_update_logs` に記録し、差分が無ければ何もしません（記録もしない・`unchanged_rows` に数える）。

### Order Line Master — `/api/order-line-master/customers`

顧客は `shop + メールアドレス` で 1 人。いなければ作り、いれば何も上書きしません。取引（`shop + source_system + transaction_id`）の受注明細の行に顧客を結び付けます。同じ内容の再送は同じ結果になります。

### Customers — `/api/ingest/customers` / `/api/ingest/customers/delete`

顧客は `shop（ストア）+ Shopify の顧客 ID` で識別し、同じ ID の再送は同じ顧客を更新します。初めての ID は、同じメールアドレスの顧客がいればその顧客に結び付けます。`updated_at` が前回当てた値より古い行は `stale_skipped` で当てません（同じ値は当てる）。`delete` は取り込んでいない ID なら何もしません。

### Share Analytics — `/api/ingest/share-analytics`

`source + source_share_id`（shares）/ `source + source_item_id`（items）/ `source + source_event_id`（events）の UNIQUE への upsert。同一行の再送・backfill 重複に冪等です。行単位の不備（必須欠落・許可外 `event_type`・不正/未来 `occurred_at`）は `skipped[]`（reason 付き）で返し、他行の処理は継続します。

## Routing API

### Orders — `/api/routing/orders`

* **入口冪等**: `shop + sourceSystem + externalOrderRef` で routing order 1 行に収束。`status = dispatched` の注文を再 POST すると再ルーティングせず、既存の出荷指示を `idempotent: true` で返します（外部への再送なし）。
* **出口冪等**: 出荷指示は `shop + idempotencyKey`（省略時 `{sourceSystem}:{externalOrderRef}`）で UPSERT し、外部 fulfillment への二重送信を防ぎます。
* まだ dispatch されていない注文の再 POST は、前回の decision を破棄して再計算します。

### Orders (shipped) — `/api/routing/orders/shipped`

* `shop + sourceSystem + externalOrderRef` の注文に、同じ拠点の出荷が既にあれば何も変えずに `idempotent: true`。そのまま再送してよいです。
* 同じ注文で別の拠点を送ると、その拠点の出荷が追加されます。取消済みの注文は `409 order_cancelled`。
* `/api/routing/orders/shipped/cancel`: 既に取消済みの注文は何も変えずに `idempotent: true`。登録されていない注文は `404 not_found`。

### Store Pickup — `/api/routing/store-pickup/*`

* `ready`: 既に受取待ちの荷物は時刻を変えずに `idempotent: true`。引渡し済みの荷物は `409 already_handed_over`。
* `handover`: 既に引渡し済みの荷物は何も変えずに `idempotent: true`。そのまま再送してよいです。

### Cancel / Update — `/api/routing/cancel` / `/api/routing/update`

* **cancel**: 既に `cancelled` の注文は再 dispatch せず `idempotent: true`。取消は SoT 側で常に確定（WMS への cancel 通知は best-effort）。
* **update**: 全量再ルーティング。出荷指示は元注文の `idempotencyKey` を維持するため、`shop + idempotencyKey` の同一行を更新（create→update で二重作成しない）。

### Inventory Adjustments — `/api/routing/inventory-adjustments/*`

* `claim`: 取った行を同じ呼び出しの中で `claimed` にする（`FOR UPDATE SKIP LOCKED`）。同時に呼んでも同じ行は 1 つにしか渡らない。`claimed` のまま `reclaimAfterMinutes`（既定 30 分）を過ぎた行は取り直す（`attempts` が増える）。
* `result`: `claimed` / `unknown` の行だけ確定する。`applied` / `failed` への再報告は `recorded: false` の no-op（結果を上書きしない）。
* 差分は**相対値**なので、外部システムへ 2 回当てると二重になる。結果が分からないときは `unknown` で返し、自動では再送しない。

### WMS Shipping Request — `/api/routing/wms-shipping-request`

冪等キーはありません。**1 回の呼び出し = 1 件の送信記録 +（`live` 時）1 回の送信**です。

**★同じ伝票番号での送り直しは内容が反映されません。** WMS は伝票番号ごとに 1 伝票を持ち、2 回目以降を無視します（上書きではありません）。重複送信は無害ですが、**初回に送った内容がそのまま出荷される**ため、送る前に内容を確定させてください。あとから変えたい場合は取り下げてから別の伝票として作り直します。1 注文を複数の荷物に分けるときは荷物ごとに別の伝票番号（2 本目以降は連番）で送ります。

送信が失敗したときは WMS に届いていないので、そのまま再送して構いません。`shadow` は記録のみで送信しません。リトライはこの API 自身は行わず、送信側のループが担います。

## Fulfillment API

### Secure — `/api/routing/secure`

* 単位は `shop + routing_order_id + line_instance_no + barcode` で一意。確保対象は `allocated` の単位のみで、既に `secured` の単位は再確保せず `alreadySecured` に計上（再 POST は no-op）。
* 出荷元ヘッダの全単位が確保済になった**遷移時のみ**ラベル生成。既に確保済ヘッダの再 secure はラベルを再生成しません。

### Picking List — `/api/routing/picking-list`

* **非冪等（再発行前提）**: 生成のたびに新しい PDF をタイムスタンプ付きファイル名で Storage 保存し、`sot.lm_picking_lists` に 1 行追記します（再印刷履歴の append-only ログ）。
* 出力は常に最新の routing データ（`sot.lm_routing_decisions` の `decision_status = applied`）から組み立てるため、冪等なのは入力側の routing です。売越し・再引き当て後の再生成は最新の引き当て先を反映します。
* **バッチ（`/api/routing/picking-list/batch`）** も同様に非冪等（生成のたびに Storage 上書き保存）。ただし複数注文にまたがるため `sot.lm_picking_lists` への追記は行いません（生成物は公開 URL が成果物）。

### Address Recovery — `/api/routing/shipment/address` / `/api/routing/shipment/retry-label`

* **住所修正**: 注文 1 件（`shop + sourceSystem + externalOrderRef` または `routingOrderId`）の `sot.lm_routing_orders.ship_to_*` を部分上書き。同じ住所での再 POST は同値で収束（冪等）。住所はラベル生成の単一ソースのため修正が即反映されます。
* **変更履歴**: 住所の更新と変更履歴（`sot.lm_routing_order_ship_to_changes`）の記録は同一トランザクション。**実際に値が変わったときだけ** 1 行 append するため、同値の再 POST（冪等）では履歴を増やしません（before/after を残す append-only ログ）。
* **dry-run**: `validateOnly: true` は読み取りのみ（保存・履歴・webhook なし）。何度呼んでも状態を変えません。
* **ラベル再発行**: 出荷元（shipment）単位。既に `tracking_number` がある出荷元は `already_issued` で skip し、`force: true` のときだけ再発行します（**二重発行ガード**）。確保前（`unallocated` / `allocated`）は `not_secured` で skip。
* 再発行成功で `last_label_error` をクリア、失敗で更新。`action=label` 設定が無い出荷元は `no_label_config` で skip（silent skip しません）。
* **自動再発行（opt-in）**: 住所修正の `autoRetryLabel: true` は、ラベルエラー中の出荷元に加え、ラベル面に影響する項目が**実際に変わった**（`changedFields`）ときだけ発行済み出荷元（`secured` / `oversell_secured`・未破棄）を再発行します。同値の再 POST では発行済み出荷元を再発行しません（冪等）。
* **再発行成功時の自動印刷**: 印刷イベント（`fulfillment_shipment_status_changed`）を印刷アプリへ自動配信します。イベントの冪等キーは `routingOrderId:shipmentId:status:transitionedAt`（`transitionedAt` = 再発行時刻）のため、初回確保イベントと重複起票しません。`label_printed_at` / `last_print_error` は再発行成功時にリセットされます（新ラベルは未印刷）。

### Print Label — `/api/routing/shipment/print-label`

* **非冪等（再印刷前提）**: 呼ぶたびに保存済みラベル PDF を出荷元 print server へ送信します（複数回 POST = 複数枚出力）。意図的な再印刷操作としてのみ使用してください。
* ラベル生成時の**自動印刷**（[Fulfillment](/lmile-docs/fulfillment-api/fulfillment.md)）はラベル生成と同じトリガーに従うため、確保済への遷移時のみ 1 回実行されます（リトライなし。失敗は `last_print_error` に記録し本 API で回収）。

### Shipment Memo — `/api/routing/shipment/memo`

* **追記のみ（非冪等）**: 呼ぶたびに `sot.lm_shipment_memos` へ 1 行 append します（同じ `body` の再 POST も別行）。編集・削除の API はありません（完全な記録 / 監査ログ）。タイムラインは `created_at` 降順で表示します。

### Shipment Delivery — `/api/routing/shipment/delivery`

* **冪等（部分更新）**: 解決キーは `routingOrderId`。`sot.lm_routing_orders` の `desired_delivery_date` / `desired_delivery_time` の 2 列だけを最新値で上書きします（同じ値の再 POST は副作用なし・`updated_at` のみ前進）。他のヘッダ列は触らず、再ルーティング・再 dispatch も行いません。`null` で指定解除。値はラベル生成時に Ship\&Co `setup.date` / `setup.time` へ載るため、既発行の送り状へ反映するには `retry-label`（`force`）で再発行します。
* **遅延のお知らせ**: `notifyDelay: true` で遅延になった変更 1 回につき 1 通です。同じ値の再 POST は遅延ではないので送りません。遅延の変更を繰り返すと、そのたびに 1 通送ります。

### External Fulfillment — `/api/routing/shipment/fulfill-job` / `/fulfill-result` / `/fulfill-pending` / `/api/ingest/sku-mappings`

* **fulfill-job / fulfill-pending / sku-mappings（read）**: SoT への書き込みが無いため冪等性の対象外です。何回呼んでも安全（同じ入力には同じ時点のスナップショットを返します）。
* **fulfill-result**: 解決キーは `shipmentId`。`accepted` は `external_fulfillment_id` / 受理時刻の**列上書き**のため再送に冪等です（`externalFulfillmentId` は同一 shipment で常に同じ決定的な値を推奨。例 `LM-{shipmentId}`）。`failed` はエラー記録の上書きのみ（受理マーカーは既存値を保持）。
* **二重発注ガード**: 受理後の `fulfill-job` は `alreadyAccepted: true` を返します。イベント再配信で同じ shipment のジョブを再取得しても、呼び出し側はこれを見て外部への再発注を skip できます。

### Shipment Ship — `/api/routing/shipment/ship`

* **冪等**: 既に出荷済みの shipment への再 POST は `200` + `noop: true`（更新も webhook の再発火もしない = 二重 fulfill 防止）。
* 出荷済みへの遷移は 1 回だけ副作用（配下単位への cascade / 棚紐付け解除 / 出荷ステータス webhook）を実行します。`trackingNumber` / `carrierCode` 指定時は shipped と**同一の書き込み**で保存されます（未指定は保存済みの値を使用）。
* MFC 集約グループの一員を指定した場合はグループ全体を一括で shipped にします（既 shipped グループは `noop: true`）。

### Shipment Collect — `/api/routing/shipment/collect`

* **冪等（残りだけ前進）**: 解決キーは `shipmentId`。集荷済みの単位はそのまま維持し、未集荷の単位だけを集荷済みにします。全単位が集荷済みなら `alreadyCollected: true` を返します（集荷の更新なし）。
* `issueLabel: true` は発行済みラベル（追跡番号あり）を再発行しません（`already_issued`）。ラベル発行だけが失敗した出荷は、同じリクエストの再送がそのまま再試行になります。
* 棚の減算処理が途中で失敗した場合、処理済みの単位は集荷済みのまま残ります。再送すれば残りの単位だけが前進します。

### Shipment Unsecure — `/api/routing/shipment/unsecure`

* **冪等**: 解決キーは `shipmentId`（+ `unitId`）。既に確保前の単位はそのまま維持し、`unsecured: 0` を返します。そのまま再送して構いません。
* 対象に解除できない単位が 1 つでもあれば、何も変えずに `409` を返します（一部だけ解除されることはありません）。

### Carrier Pickup — `/api/routing/pickup` / `/api/routing/pickup/cancel`

* **冪等ではありません（重複依頼は拒否）**: 解決キーは出荷の `pickup_id` 列。既に生きた集荷依頼に入っている出荷を含むリクエストは `409 pickup_already_requested` で拒否し、既存の受付番号を返します。同じ荷物に受付番号が 2 つ立つことを構造的に防ぐためで、再送しても新しい依頼にはなりません。
* **キャリアへ問い合わせる前に対象の出荷を予約します**。同時に 2 か所から依頼しても、あとから来た方はキャリアに触れずに `409` で止まります。キャリアへの依頼が失敗した場合は予約が自動で解除され、対象の出荷は未依頼に戻ります（そのまま再送できます）。
* **取消はキャリア側が成功してからこちらを解除します**。キャリアの取消が失敗すると `502` を返し、出荷は依頼済みのまま残ります（未依頼に戻して二重に呼ばないため）。取消済みの依頼の再取消は `409`。
* 時間帯や対象の変更は「取消 → 依頼し直し」で行います（依頼の書き換えはできません）。

## Oversell API

### Reallocate — `/api/routing/reallocate`

* 売越し（`oversold`）decision を**同一出荷元の別メンバー**へ再引き当て。旧 decision は `superseded` にし後継へリンク（重複適用なし）。
* 出荷指示は `shop + idempotencyKey` で UPSERT されるため、再 dispatch は同一行を更新（外部への二重送信を防止）。

### Oversell Cases — `/api/routing/oversell-case`

* 1 単位につきケース 1 件（`shop + fulfillment_unit_id` で一意）。初回呼び出しで起票、以降は前進。
* 同一 status への再呼び出しは `changed: false` の no-op（timestamp 再設定なし）。不正な遷移は `409 invalid_transition`。

### Source Change — `/api/routing/change-source`

* 1 単位につき変更 1 件（`shop + fulfillment_unit_id` で一意）。初回呼び出しで起票（`change_requested`、対象 unit は **`secured` のみ**＝LM-913）、以降は前進。前回が `cancelled`（終端で前進不可）の単位は**同一行を再オープン**して再起票します（`reopened: true`。LM-956。前回の進行・バルク・チェック・返送ラベル列は初期化）。
* `secured` からの起票は確保を解除（`secured` / `secured_at` クリア）し、旧出荷元ヘッダが空になった場合のみヘッダ `cancelled`・ラベル運用破棄（`label_voided_at`）・棚紐付け解放（`released_reason="source_changed"`）を行います（残アクティブ unit があるヘッダは触りません = 誤破棄なし）。
* 起票時に旧 decision を `superseded` にし後継（出荷元=B）へリンク（重複適用なし）。出荷指示の dispatch（A への cancel / B への create）は WMS 設定が無ければ `skipped`。
* 同一 status への再呼び出しは `changed: false` の no-op。不正な遷移は `409 invalid_transition`。`cancelled` は SoT を起票前の出荷元へ巻き戻します（WMS 再連絡は best-effort）。在庫は状態のみで `inventory_levels` は不変。
* **`mode=switch`（即時切替）**: ケースを起票せず同一レグの出荷元だけを更新します。切替完了後に同じボディを再送すると新旧出荷元が同一のため `422 same_source` で弾かれ、重複適用されません（自己在庫 available の再計算は派生量のため冪等）。

## Transfer API

### Transfer Batches — `/api/operator/transfer/batches` (+ `add-unit` / `remove-unit`)

* バルク作成は呼び出しごとに新規（同一経路の複数 `open` バルク = 複数箱は許容）。一覧は read 専用。
* `add-unit`: 同一バルクへの再スキャンは `200` の no-op（`added: false, alreadyInBatch: true`）。別バルク所属は `409 already_in_batch`。同 barcode の複数単位は未所属の先頭（`lineInstanceNo` 昇順）から 1 件ずつ。
* `remove-unit`: 既に外れていれば `200` の no-op（`alreadyRemoved: true`）。いずれも `open` 中のみ（それ以外は `409 batch_not_open`）。

### Transfer Dispatch — `/api/operator/transfer/batches/dispatch`

* `dispatched` / `received` 済みへの再実行は `200` + 既存 `pdfUrl` の no-op（`alreadyDispatched: true`）。
* 処理順は PDF → 単位遷移 → バルク確定。途中失敗時はバルクが `open` のまま残り、再実行が `change_requested` の残りだけ遷移して回復（遷移済みは skip・PDF は同一パスへ上書き）。
* 旧拠点の棚 −1 はピック済み（`picked_at` あり）単位を skip（ピック時に減算済み = 二重減算なし）。

### Transfer Receive — `/api/operator/transfer/batches/receive`

* 1 スキャン = 1 単位。受領済み単位の再スキャンは `200` の no-op（`alreadyReceived: true`）。
* 受領遷移は [Source Change](#source-change--apiroutingchange-source) の `return_received` と同一実装（重複適用なし）。全所属単位の終端でバルクが `received` に締まる（再判定は冪等）。

### Transfer Inbound — `/api/routing/transfer/inbound`

* 解決キーは `shipmentId`。その出荷の有効な単位に**生きた入荷転送がすでにあれば作り直さず**、`created: false` と既存の `batchId` を返す（イベント再配信で移動が二重に増えない）。そのまま再送してよい。
* 中止済みの移動しか残っていない場合は、新しく登録し直せる。
* 受け取りは配送状況の配達完了検知で自動的に確定する（同じ配達完了を再検知しても遷移は 1 回だけ）。倉庫アプリのスキャン受領はこの移動には使えない（`409 inbound_batch_not_manually_receivable`）。

## Stock Transfer API

拠点間在庫移動（[Stock Transfer](/lmile-docs/stock-transfer-api/stock-transfer.md)）。返送バルク（上記 Transfer API）とは別機能です。

### Create / Create Request — `/api/routing/stock-transfer/create` / `/create-request`

* **作成は冪等**: `idempotencyKey` を付けて同じ値で再送すると、新しい移動を作らず既存の移動を `idempotent: true` で返します（`shop + idempotency_key` で一意）。イベント単位で一意なキーを付与してください。
* `idempotencyKey` を省略すると毎回新しい移動になります。
* 再送で既存の移動が返ったとき、最初に決めた送り状の出し方（`labelMethod`）は変わりません。

### Set Lines / Remove Lines / Update — `/api/routing/stock-transfer/set-lines` / `/remove-lines` / `/update`

* **set-lines**: 同一 `barcode` は数量を置換（行を増やしません）。同じボディの再送は同値で収束（冪等）。出庫後は `not_editable`。
* **remove-lines**: 既に無い `barcode` の削除は数に含まれないだけで安全（`removed` に計上しない）。
* **update**: 指定したキー（`identificationNo` / `note`）だけを上書き。同値の再送は副作用なし。

### Ship — `/api/routing/stock-transfer/ship`

* **冪等**: 既に出庫済み（`shipped` 以降）への再 `ship` は `noop: true`（棚を二重に減らしません）。自社在庫の棚 − は明細ごとの適用済みマーカーで一度きりに保たれます。
* 移動可能量（棚合計 − 引き当て済み）を超える `shippedQty` は `insufficient_transferable_qty` で拒否（売越防止）。承認前は `approval_required`。
* **print-label は何回でも呼べます**: 発行済みのラベルを印刷し直すだけで、発行し直しません。
* **配送ラベルは 1 回だけ発行**: ラベルを発行する移動で、発行後に出庫が失敗して呼び直しても、前回のラベルを使います（二重に発行しません）。

### Receive — `/api/routing/stock-transfer/receive`

* **絶対値で冪等**: `acceptedQty` は「受け入れた総数」として上書きします。同じ値の再送は棚を二重に増やしません（適用済みを超えた差分だけ棚を増やします）。既に `received` への再送は `noop: true`。
* 自社在庫の受領は `destinationShelfCode` 必須（`destination_shelf_required`）。`acceptedQty + rejectedQty` は出庫数を超えられません（`invalid_quantity`）。修正依頼中は `modification_requested_locked`。

### Approve / Reject / Cancel / Delete — `/api/routing/stock-transfer/approve` / `/reject` / `/cancel` / `/delete`

* **approve**: 既に `approved` は `noop: true`。既定（`autoShip: true`）は承認後に `ship` まで実行し、出庫が失敗しても承認は確定済み（`ship` を呼び直して回復）。
* **reject**: 既に `rejected` は `noop: true`。承認待ち以外は `illegal_transition`。
* **cancel**: 既に `canceled` は `noop: true`。出庫済みは出庫元の棚を復元してから取消（復元失敗は `restore_failed` で取消を確定しない）。`receiving` / `received` は `not_cancelable`。
* **delete**: 既に存在しない移動の削除は `noop: true`（`200`）。削除できるのは出庫前のみ（`not_deletable`）。

### Modification Request — `/api/routing/stock-transfer/modification-request`（+ `/complete` / `/delete`）

* 1 つの移動に開ける依頼は 1 件（active は partial unique）。既に開いていれば `modification_request_already_open`。開いた依頼が無い状態での `complete` / `delete` は `no_open_modification_request`。
* 起票は入庫検品中（`receiving`）のみ（`not_receiving`）。棚・在庫は動かしません（実数量は receive の受領数で確定）。

### List / Get / Lines / Modification Requests（read）— `/api/routing/stock-transfer/list` / `/get` / `/lines` / `/modification-request(s)`

* **read 専用**: SoT への書き込みが無いため冪等性の対象外です。何回呼んでも安全（同じ入力には同じ時点のスナップショットを返します）。

## Customer Orders API

### Customer Orders — `/api/customer-orders/list` / `/api/customer-orders/detail`

* **read 専用**: SoT への書き込みが無いため冪等性の対象外です。何回呼んでも安全（同じ入力には同じ時点のスナップショットを返します）。

## Operator API

### Operator Login — `/api/operator/login` / `/api/operator/login/google` / `/api/operator/refresh`

* **非冪等（トークン発行）だが安全**: 呼ぶたびに新しい JWT / refresh token を発行します（毎回同じトークンは返しません）。SoT の業務状態は変えないため、再試行に害はありません（古いトークンも失効まで有効です）。
* `/api/operator/login/otp/request` は呼ぶたびに確認コードのメールを送ります。再送は短い間隔だと `429 rate_limited` になるので、少し待ってから送り直してください。
* 試行回数の上限超過は `429 rate_limited`、認証基盤に到達できない場合は `503 auth_unavailable`（フォールバックしません）。

### Visit Check-in — `/api/operator/visit/check-in`

* **解決キー**: `reservationId`。
* 「確定」かつ「未到着」の予約だけを「来店済み」にします。すでに来店済みの予約に再送すると何も変えず、`alreadyArrived: true` を返します（履歴も増えません）。
* `GET /api/operator/visit/reservations` は read 専用です。

### Operator Me / Lookup / Queues / Locations（read）— `/api/operator/me` / `/api/operator/shipments` / `/api/operator/shipments/by-tracking` / `/api/operator/products/by-barcode` / `/api/operator/oversell-cases` / `/api/operator/unallocated-orders` / `/api/operator/locations`

* **read 専用**: SoT への書き込みが無いため冪等性の対象外です。何回呼んでも安全（同じ入力には同じ時点のスナップショットを返します）。

### Account Delete — `/api/operator/account/delete`

* **解決キー**: JWT の `sub`（本人の `auth.users.id`）。削除対象は常に本人で、外部から指定するキーはありません。
* **冪等**: 既に削除済みのアカウント（有効だが失効済みの JWT を保持したケース等）への再実行は `200` + `deleted: true` / `alreadyDeleted: true` を返します（エラーにしません）。
* **監査**: 実行のたびに `sot.lm_operator_account_deletions` に監査行を 1 件記録します（削除完了時刻を含む）。削除自体は冪等ですが、監査行は試行ごとに残します（トレイル目的）。
* **デモ保護**: デモアカウントは状態を変えず `403 demo_account_protected` を返します。

### Secure Scan — `/api/operator/secure-scan`

* **順次確保**: ユニットQR（`<barcode>|<shipmentId>`）は同一 barcode の複数 unit を区別しないため、スキャンごとに「`allocated` かつ 未 picked の `line_instance_no` 昇順先頭 1 件」を確保します（数量 2 なら 2 回のスキャンで全確保）。
* **冪等**: 確保対象が無く既確保 unit が居る再スキャンは `200` + `secured: 0` / `alreadySecured: N`（二重確保しません）。ヘッダ遷移・ラベル生成は [`/api/routing/secure`](#secure--apiroutingsecure) と同一で、既に確保済ヘッダはラベルを再生成しません。
* 確保可能でも既確保でもない unit（取消済 / 売越し / picked 済のみ等）は `409 unit_not_securable` で表面化します（silent skip しません）。

### Secure by Tracking — `/api/operator/secure-by-tracking`

* **解決キー**: 配送ラベルの `trackingNumber`（`sot.lm_fulfillment_shipments.tracking_number`・shipment 単位）。同一追跡番号が複数 shipment に当たりうるため該当 shipment を全件処理し、各 shipment 配下の `status=allocated` ユニットを一括確保します。案 A（pick→secure 順）のため **picked 済も含めて確保**します（Secure Scan の「未 picked のみ」と異なります）。
* **冪等**: 確保対象（`allocated`）が無く既確保ユニットが居る再スキャンは `200` + 当該 shipment の `secured: 0` / `alreadySecured: N`（二重確保しません）。全ユニット確保でヘッダ `secured` に遷移しますが、先行生成済みラベルは再生成しません。
* 追跡番号に該当 shipment が無ければ `404 shipment_not_found`。確保可能でも既確保でもないユニットしか無い shipment は `409 unit_not_securable` で表面化します（silent skip しません）。
* **operator 記録**: operator JWT 経由の確保は `secured_by_staff_user_id` に操作者（`auth.users.id`）を記録します。

## Shelf API

### Shelf Link — `/api/shelf/link`

* **再送冪等**: shipment（`shop + trackingNumber` で 1 件に解決）に既にアクティブ紐付けがあり、棚コード・ロケーションが同一なら `200` + `idempotent: true`（同値で収束）。別棚への紐付けは自動で付け替えず `409 shipment_already_linked`。
* **付け替え（relink）**: `relink: true` 指定時のみ、別棚への既存紐付けを `manual_release` で解除して新棚へ付け替える（`200` + `relinked: true` + `previousShelfCode`）。解除行は履歴として残る。
* **アクティブ一意性**: 同一棚に 1 shipment / 同一 shipment に 1 棚（`released_at IS NULL` の partial unique index）。並行 INSERT 競合は `409`。
* **自動解除**: 出荷完了（`shipped` 遷移 → `released_reason="shipped"`）/ 取消（注文 cancel → `released_reason="cancelled"`）/ 出荷元変更（`secured` からの起票で旧ヘッダが空 → `released_reason="source_changed"`）で解除。解除は DELETE せず行を履歴として残します。

### Shelf Inventory — `/api/shelf/inventory/receive` / `pick` / `move` / `adjust`

* **非冪等（積算）**: 入庫・ピックは qty を増減する操作のため自然冪等ではありません。冪等キーは持たないため、リトライ時はクライアント側で適用済みかを確認してください（既存 routing write の secure / oversell と同方針）。
* qty 更新と増減ログ追記は write RPC が同一トランザクションで実行（部分適用なし）。`pick` の在庫不足は `409 insufficient_shelf_qty`、同一棚の別 barcode 占有は `409 shelf_occupied_by_different_barcode`。
* **`move` は非冪等（差分）**: 再送すると二重に動く。移動元の不足は `409 insufficient_shelf_qty`。
* **`adjust` は冪等（絶対値）**: 同じ値の再送は `delta: 0` で何もしない（ログも書かない）。
* `adjust` と `move`（barcode の付け替え）で、減らした結果その商品の棚の合計が注文に引き当て済みの数を下回る操作は `409 shelf_qty_below_active` で何も変えない。

### Shelf Condition Move — `/api/shelf/inventory/condition-move`

* **非冪等（差分）**: 商品状態の間で数を動かす操作のため、再送すると二重に動く。元の数の不足は `409 insufficient_shelf_qty` / `409 insufficient_condition_qty`。
* 良品を減らした結果が注文に引き当て済みの数を下回る移動は `409 shelf_qty_below_active` で何も変えない。
* 2 表の更新・ログ 2 行・販売可能数の再計算は write RPC が同一トランザクションで実行（部分適用なし）。

### Shelves — `/api/shelf/master/upsert`

* **冪等（絶対値）**: `(shop, locationId, shelfCode)` で upsert。同じ内容の再送は同じ結果。

### Shelf Pick — `/api/operator/shelf/pick`

* **順次 pick**: ユニットQR（`<barcode>|<shipmentId>`）は同一 barcode の複数 unit を区別しないため、`pick` はスキャンごとに「未 picked の `line_instance_no` 昇順先頭 1 件」を picked にします。`unpick` は「picked の降順先頭 1 件」（直近の pick から LIFO）を戻します。
* **冪等**: barcode 一致 unit が全て picked の状態での再 `pick` は `200` + `alreadyPicked: true`（上書きしません）。全て未 picked での `unpick` も同様（`alreadyUnpicked: true`）。状態の記録は `picked_at` / `picked_by` のみ。
* **棚 qty 減算の冪等（LM-769）**: 自己在庫 MFC ユニットの `pick`（−1 `reason=pick`）/ `unpick`（+1 `reason=receive`）と続く `available_qty` 再計算は **`picked_at` の遷移時のみ** 実行します。既に picked の再 `pick` / 既に未 picked の再 `unpick` は遷移しないため棚 qty を増減しません（二重減算なし）。再計算は派生量（`max(0, 棚合計 − 引き当て済み未出荷数)`）なので冪等です。棚操作はベストエフォートで、失敗しても `pick` 本体は成功し後続の再計算が整合させます。

### Shelf Lookup（read）— `/api/operator/shelf/lookup` / `/api/operator/shelf/by-code`

* **read 専用**: SoT への書き込みが無いため冪等性の対象外です。何回呼んでも安全（同じ入力には同じ時点のスナップショットを返します）。

## Configuration

### Star Printer — `/api/routing/star-printer`

* **設定 upsert**: `(shop, locationId)` で 1 件に収束（1 ロケーション 1 プリンター）。同じ `deviceId` の再送は同値で収束（冪等）。
* **detach**: 物理削除。該当無しの再 POST は `deleted: 0` の no-op（冪等）。

## Webhooks

### Source Inventory Webhook — `/api/routing/source-webhook-config`

* **設定 upsert**: `(shop, fulfillment_source_location_id)` で1件に収束。`authSecret` 省略時は既存の暗号化値を維持。
* **変動の冪等**: 出荷元の `barcode`（product）単位の合算 `available_qty` を `sot.lm_fulfillment_source_inventory_state` に「前回送信値」として保持し、**異なるときだけ**送信します。**同値の在庫再取込では送信しません**。送信ペイロードは合算の**絶対値**（差分ではない）のため、受け手は冪等に上書きできます。
* **再送**: 送信前にプロセスが落ちても event は outbox（`sot.lm_fulfillment_source_inventory_events`）に `pending` / `failed` で残り、回収・再送できます（合算の絶対値を再送するため二重適用の害なし）。

### Routing Allocation Webhook — `/api/routing/allocation-webhook-config`

* **設定 upsert**: `shop` で1件に収束。`authSecret` 省略時は既存の暗号化値を維持。
* **発火の冪等**: [`/api/routing/orders`](/lmile-docs/routing-api/orders.md) の入口冪等（`dispatched` 済み注文の同一キー再 POST）では発火しません。未 dispatch の同一注文を再 POST した場合は再ルーティングされ、最新の引き当て結果を改めて送信します（引き当て結果の**絶対状態**を送るため、受け手は最新イベントで冪等に上書きできます）。受け手の冪等キーは `shipmentId`（`unallocated` イベントは `routingOrderId` + `allocationStatus`）。
* **再送**: 送信前にプロセスが落ちても event は outbox（`sot.lm_routing_allocation_events`）に `pending` / `failed` で残り、回収・再送できます（明細は起票時 snapshot を再送するため、後続の再引き当てに影響されません）。

### Fulfillment Status Webhook — `/api/routing/status-webhook-config`

* **設定 upsert**: `shop` ×取込元（`sourceSystem`。省略時はその shop の既定宛先）で1件に収束。`authSecret` 省略時は既存の暗号化値を維持。
* **遷移の冪等**: [`/api/routing/fulfillment`](/lmile-docs/fulfillment-api/fulfillment.md) が各 shipment（`shop + routing_order_id + from_location_id`）の `status` を更新する際、**直前の値と異なる shipment だけ** event を起票・送信します。**同じ status の再報告では送信しません**。ペイロードには遷移後の絶対状態（`status` / `previousStatus` / `trackingNumber`）と shipment の出荷明細（`shipmentId` / `lines`: barcode 単位集約）を含むため、受け手は `shipmentId` を冪等キーに同一 shipment の再送を弾きつつ、該当明細だけを冪等に反映できます。
* **再送**: 送信前にプロセスが落ちても event は outbox（`sot.lm_fulfillment_status_events`）に `pending` / `failed` で残り、回収・再送できます。

### Oversell Status Webhook — `/api/routing/oversell-status-webhook-config`

* **設定 upsert**: `shop` で1件に収束。`authSecret` 省略時は既存の暗号化値を維持。
* **発行の冪等**: [`/api/routing/oversell-case`](/lmile-docs/oversell-api/cases.md) でケースが `warehouse_received` に遷移した時に 1 通発行します。outbox は `UNIQUE (oversell_case_id)` のため、同一ケースの event は 1 件しか起票されません（二重起票は無視して no-op）。`warehouse_received` は終端ステップで再遷移できないため、通常 1 ケース = 1 通です。受け手の冪等キーは `oversellCaseId`。
* **再送**: 送信前にプロセスが落ちても event は outbox（`sot.lm_oversell_status_events`）に `pending` / `failed` で残り、回収・再送できます（明細は起票時 snapshot を再送します）。

### Order Update Webhook — `/api/routing/order-update-webhook-config`

* **設定 upsert**: `shop` で1件に収束。`authSecret` 省略時は既存の暗号化値を維持。
* **発行の冪等**: [`/api/routing/shipment/address`](/lmile-docs/fulfillment-api/address-recovery.md) で配送先住所の手修正が成功するたびに 1 通発行します。1 注文に複数回の修正が起こり得るため outbox は `UNIQUE` 制約を持たず **1 修正 = 1 event** を起票します。ペイロードは修正後の `shipTo` 全体（差分ではなく絶対状態）を含むため、受け手は `routingOrderId` を冪等キーに、`correctedAt` で順序判断して最新の配送先で冪等に上書きできます。
* **再送**: 送信前にプロセスが落ちても event は outbox（`sot.lm_order_update_events`）に `pending` / `failed` で残り、回収・再送できます（修正後 `shipTo` は起票時 snapshot を再送します）。

## Apps

### イベント配信 — [LMile ホスト型ミニアプリ](/lmile-docs/apps/overview.md)

* **起票の冪等**: 同一ルーティング実行内の二重発火は配信キー（`routingOrderId` × 出荷先 × `decidedAt`）で 1 配信に収束します。再ルーティング（`decidedAt` が変わる）は新しいイベントとして配信されます。
* **handler の冪等契約**: 配信は **at-least-once / 順序保証なし**。同じイベントが複数回届くことがあるため、handler は冪等・順序非依存で書きます。`ctx.idempotencyKey` は同一イベントの再配信で同じ値のため、action 呼び出しの重複実行抑止に流用できます。
* **再送**: 配信失敗（実行環境の未起動 / handler 例外 / timeout）は outbox（`sot.lm_app_deliveries`）に残り、LMile が自動で再送します（既定 6 回・約 7.5 時間。印刷系は少ない回数で打ち切り）。打ち切った配信は LMile 運用に通知されます。送信元が自分で再送するイベント（倉庫への出荷指示など）は、LMile は再送しません。
* **再送でも同じキー**: 配信の `webhook-id` ヘッダーと `ctx.idempotencyKey` は同じ値で、何度送り直しても変わりません。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://lmile.gitbook.io/lmile-docs/reference/idempotency.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
