Skip to main content

backbone_inventory/application/service/
inventory_write_service.rs

1//! Validated write path + valuation engine for inventory (hand-authored, user-owned).
2//!
3//! The core is the **moving-average valuation engine** over an append-only Stock Ledger (SLE) and a
4//! per-(item,warehouse) `Bin` running balance:
5//!   - **Receipt:** `value += qty·rate; qty += qty; rate = value/qty` — new cost blends in.
6//!   - **Delivery:** `cogs = qty · rate; value -= cogs; qty -= qty` — rate is unchanged by an
7//!     outflow; COGS consumes the current average.
8//!   - **Transfer:** paired out/in SLE at the source rate — value-neutral, no GL.
9//!   - **Reconciliation:** set qty/value to the counted figures; the delta is the posted difference.
10//! Every valuation-changing movement writes an immutable SLE and emits a balanced `AccountingPost`
11//! (Dr Inventory·Cr GR/IR on receipt; Dr COGS·Cr Inventory on delivery; the signed value diff on
12//! reconciliation). The physical movement (SLE+Bin) commits first; the GL post is eventually
13//! consistent (`posting_state` pending→posted|failed), per the GL-posting contract.
14//!
15//! Money: `stock_value`/GL amounts are 2dp (half-up); `valuation_rate` is 6dp.
16//!
17//! **Layering (the module's 4-layer rule).** This service ORCHESTRATES: it owns the valuation
18//! arithmetic, the unit of work (`begin`/`commit`), the ORDER the bin locks are taken in, the
19//! ambient org scope's seams (ADR-0029) and the seam events. It holds no SQL — every statement
20//! lives in `infrastructure::persistence`, and the repository methods that participate in a
21//! movement take THIS service's connection so the SLE + Bin writes commit together with their
22//! voucher.
23//!
24//! **This file is the hub:** it holds the module's vocabulary (input structs, outcomes, errors), the
25//! ctor, and the shared GL-emit/reconcile helper used by every submit/repost path. The write surface
26//! is chunked into focused siblings, each an `impl InventoryWriteService` block over these same
27//! types:
28//!
29//! - [`super::inventory_masters`] — warehouse + stock-item setup (`create_warehouse`,
30//!   `create_stock_item`).
31//! - [`super::inventory_receipt`] — Purchase Receipt: draft → submit → repost.
32//! - [`super::inventory_delivery`] — Delivery Note: draft → submit → repost.
33//! - [`super::inventory_transfer`] — Stock Entry (warehouse-to-warehouse move; value-neutral, no GL).
34//! - [`super::inventory_reconciliation`] — Stock Reconciliation (physical count → value diff).
35
36use backbone_orm::org_scope;
37use rust_decimal::{Decimal, RoundingStrategy};
38use sqlx::PgPool;
39use std::sync::Arc;
40use uuid::Uuid;
41
42use crate::infrastructure::persistence::{
43    BinRepository, DeliveryNoteItemRepository, DeliveryNoteRepository, GlSettlementState, GlVoucher,
44    GlVoucherRepository, PickingBatchProjectionRepository, PurchaseReceiptItemRepository,
45    ScrapDoorRepository,
46    PurchaseReceiptRepository, QuantRepository, StockAdjustmentRepository, StockEntryItemRepository,
47    StockEntryRepository, StockItemRepository, StockLedgerEntryRepository, StockMoveLineRepository,
48    StockMoveRepository, StockPickingRepository, StockReconciliationItemRepository,
49    StockReconciliationRepository, ValuationOverlayRepository, WarehouseRepository,
50};
51
52use super::inventory_events::{InventoryEventSink, LoggingSink};
53use super::inventory_gl::{AccountingPostEnvelope, GlPostSink};
54
55/// The ambient org scope's legacy company echo — the acting org unit, which the spine mirrored
56/// from the historical company id verbatim. Cross-module wire fields that still carry a tenant
57/// (the GL envelope, the outbox fence, the event payloads) are filled from it; nothing in this
58/// module keys a statement on it. `Uuid::nil()` when no scope is bound (undecorated module
59/// tests, jobs).
60pub(crate) fn legacy_company_echo() -> Uuid {
61    org_scope::current_org_scope()
62        .and_then(|s| s.legacy_company_id())
63        .unwrap_or(Uuid::nil())
64}
65
66/// Re-bind the caller's ambient org scope onto a transaction this service opened itself — the
67/// scope is task-local and a fresh pool transaction carries none of it. With no ambient scope
68/// (undecorated module tests, jobs) the transaction stays plain: the module is tenant-agnostic
69/// and the composed decorator owns isolation.
70pub(crate) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
71    if let Some(scope) = org_scope::current_org_scope() {
72        org_scope::bind_org_scope_on(conn, &scope).await?;
73    }
74    Ok(())
75}
76
77pub(super) fn money(v: Decimal) -> Decimal {
78    v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
79}
80pub(super) fn rate6(v: Decimal) -> Decimal {
81    v.round_dp_with_strategy(6, RoundingStrategy::MidpointAwayFromZero)
82}
83
84// --- input structs -----------------------------------------------------------
85
86#[derive(Debug, Clone)]
87pub struct NewWarehouse {
88    pub code: String,
89    pub name: String,
90    pub warehouse_type: Option<String>,
91    pub parent_warehouse_id: Option<Uuid>,
92    pub is_group: bool,
93}
94
95#[derive(Debug, Clone)]
96pub struct NewStockItem {
97    pub item_id: Uuid,
98    pub stock_uom: String,
99    pub valuation_method: Option<String>,
100    pub reorder_level: Decimal,
101}
102
103#[derive(Debug, Clone)]
104pub struct ReceiptLine {
105    pub item_id: Uuid,
106    pub quantity: Decimal,
107    pub rate: Decimal,
108    /// Landed-cost service line (the seam is owned by inventory): a flagged line carries cost
109    /// into a LandedCost document, NOT stock — the receipt door skips move-minting, the
110    /// document totals and the cancellation for it. Default `false` everywhere.
111    pub is_landed_costs_line: bool,
112}
113#[derive(Debug, Clone)]
114pub struct NewReceipt {
115    pub receipt_number: String,
116    pub branch_id: Option<Uuid>,
117    pub supplier_id: Uuid,
118    pub source_po_id: Option<Uuid>,
119    pub warehouse_id: Uuid,
120    pub posting_date: chrono::NaiveDate,
121    pub currency: String,
122    pub inventory_account_id: Uuid,
123    pub grir_account_id: Uuid,
124    pub lines: Vec<ReceiptLine>,
125}
126
127#[derive(Debug, Clone)]
128pub struct DeliveryLine {
129    pub item_id: Uuid,
130    pub quantity: Decimal,
131}
132#[derive(Debug, Clone)]
133pub struct NewDelivery {
134    pub delivery_number: String,
135    pub branch_id: Option<Uuid>,
136    pub customer_id: Uuid,
137    pub source_so_id: Option<Uuid>,
138    pub warehouse_id: Uuid,
139    pub posting_date: chrono::NaiveDate,
140    pub currency: String,
141    pub cogs_account_id: Uuid,
142    pub inventory_account_id: Uuid,
143    pub lines: Vec<DeliveryLine>,
144}
145
146#[derive(Debug, Clone)]
147pub struct NewTransfer {
148    pub entry_number: String,
149    pub from_warehouse_id: Uuid,
150    pub to_warehouse_id: Uuid,
151    pub posting_date: chrono::NaiveDate,
152    pub lines: Vec<DeliveryLine>, // item_id + quantity
153}
154
155#[derive(Debug, Clone)]
156pub struct ReconLine {
157    pub item_id: Uuid,
158    pub counted_qty: Decimal,
159    pub counted_rate: Decimal, // 0 = keep current rate
160}
161#[derive(Debug, Clone)]
162pub struct NewReconciliation {
163    pub recon_number: String,
164    pub warehouse_id: Uuid,
165    pub posting_date: chrono::NaiveDate,
166    pub currency: String,
167    pub inventory_account_id: Uuid,
168    pub adjustment_account_id: Uuid,
169    pub lines: Vec<ReconLine>,
170}
171
172/// Outcome of submitting a movement that posts to the GL.
173#[derive(Debug, Clone)]
174pub struct SubmitOutcome {
175    pub voucher_id: Uuid,
176    pub posted: bool,
177    pub journal_id: Option<Uuid>,
178    pub post_id: Option<Uuid>,
179    pub gl_amount: Decimal,
180}
181
182/// One landed-cost charge line of a draft landed cost. `account_id` is the credit side
183/// (required — a line without it is rejected); `split_method` is `quantity` | `value` |
184/// `weight`; `amount` may be negative — a negative landed cost is the REVERSAL pattern
185/// (a validated landed cost can never cancel, so corrections re-book with swapped legs).
186#[derive(Debug, Clone)]
187pub struct LcCostLine {
188    pub name: String,
189    pub account_id: Uuid,
190    pub split_method: String,
191    pub amount: Decimal,
192}
193
194/// A draft landed-cost document: one target purchase receipt + its cost lines. The document
195/// revalues the target receipt's DONE moves at validation — see
196/// [`super::landed_cost_service_custom`].
197#[derive(Debug, Clone)]
198pub struct NewLandedCost {
199    pub lc_number: String,
200    pub branch_id: Option<Uuid>,
201    pub target_receipt_id: Uuid,
202    pub posting_date: chrono::NaiveDate,
203    pub currency: String,
204    pub notes: Option<String>,
205    pub lines: Vec<LcCostLine>,
206}
207
208// --- errors ------------------------------------------------------------------
209
210#[derive(Debug)]
211pub enum InventoryError {
212    EmptyDocument,
213    NegativeQuantity,
214    InsufficientStock { item_id: Uuid, warehouse_id: Uuid, available: Decimal, requested: Decimal },
215    DuplicateNumber(String),
216    NotFound(Uuid),
217    NotDraft(String),
218    SameWarehouse,
219    /// A cancellation was attempted on a voucher whose status is not `submitted` (e.g. a draft —
220    /// delete it instead — or an already-cancelled one that has no GL post to recover).
221    NotSubmitted(Uuid),
222    /// A cancellation needs the original GL post to be `posted` (so it can be reversed); the original
223    /// is still `pending`/`failed`. Repost the voucher first.
224    GlNotPosted(Uuid),
225    /// A receipt cancellation would push a bin's quantity below zero (the received stock was already
226    /// issued). Reverse the issue first, or correct via a reconciliation.
227    InsufficientStockToReverse { item_id: Uuid, warehouse_id: Uuid, available: Decimal, requested: Decimal },
228    GlRejected { code: String, message: String },
229    // ---- stock-move pipeline (the converged lifecycle engine) -----------------------------
230    /// The move is not in the state this action requires (the guarded state machine rejected the
231    /// transition — e.g. `_action_assign` on a draft move, or `_action_done` on a cancelled one).
232    WrongMoveState { move_id: Uuid, action: &'static str, current: String },
233    /// Picking assignment is rule-driven: a move carries no procurement rule to derive its
234    /// operation type from, so no grouping transfer can be resolved for it (voucher-door moves
235    /// keep their voucher identity and mint through their own door instead).
236    MoveHasNoRule { move_id: Uuid },
237    /// A move whose source and destination location are the same row (a physical no-op — rejected,
238    /// stock.hook.yaml R9).
239    SameLocation { move_id: Uuid, location_id: Uuid },
240    /// `_action_done` requires at least one execution line (the picking's done-needs-lines guard,
241    /// R24 at move grain).
242    MoveLinesRequired { move_id: Uuid },
243    /// Every done line must carry a non-negative quantity and the move a positive total (R23)
244    /// — reuses [`InventoryError::NegativeQuantity`] for the plain negative case.
245    /// The source location of a quant write is a view location (view nodes hold no stock — R13).
246    ViewLocationHoldsNoStock { location_id: Uuid },
247    /// The location a move/quant references does not exist (or is soft-deleted).
248    LocationNotFound(Uuid),
249    // ---- quant-driven adjustment door (spec §5.2 — no stock.inventory model) ----------------
250    /// A count cannot be staged or applied while the quant holds reservations (stock.hook.yaml
251    /// R24 `no_count_while_reserved`) — release the reservations first.
252    CountReserved { quant_id: Uuid, reserved_qty: Decimal },
253    /// The staged count is stale: the quant's on-hand moved between the count and the apply
254    /// (spec §5.2 `is_outdated` — a move landed in the window; the user must re-count).
255    OutdatedCount { quant_id: Uuid, counted_qty: Decimal, on_hand_qty: Decimal },
256    /// The reconciliation write surface only carries counted QUANTITY; an explicit counted
257    /// RATE is a valuation-overlay concern (the quant-driven door values the diff at the
258    /// current moving average — never a silent rate revaluation).
259    CountedRateUnsupported,
260    // ---- valuation overlay (the ambient posting posture + landed costs) -------------------
261    /// The anglo-saxon delivery-debit posture is ON but no stock interim delivered account is
262    /// configured. Fail-closed: the delivery door refuses to operate (no envelope, no partial
263    /// post) until the account is set — a silent COGS fallback would post the interim leg to
264    /// the wrong account on every delivery.
265    AngloPostureUnconfigured,
266    /// A landed-cost validation found no DONE moves under its target receipt — nothing to
267    /// revalue (the receipt was never submitted, or its lines were all zero / landed-cost
268    /// service lines that mint no stock).
269    LandedCostNoValuedTargets { receipt_id: Uuid },
270    /// A landed-cost line carries no credit account (the split's credit side is undefined).
271    LandedCostLineNeedsAccount { line_id: Uuid },
272    /// The cost method is `standard`: landed costs refuse to validate loudly. Under standard
273    /// costing a receipt's value comes from the item's standard price and a landed cost would
274    /// introduce a variance the standard-recompute engine (a later increment) would have to
275    /// absorb — refusing beats silently diverging.
276    LandedCostRequiresCostMethod { cost_method: String },
277    /// The split basis sums to zero across every target line (e.g. a `weight` split where no
278    /// item carries a per-unit weight, or a `value` split over zero-valued lines). This is a
279    /// LOUD rejection by decision: the historical silent equal-split fallback masked
280    /// misconfigured weight data. No fallback, no partial worksheet rows — the transaction
281    /// rolls back.
282    LandedCostZeroSplitBasis { basis: String, target_receipt_id: Uuid },
283    /// The landed cost is not in `draft` (validate and cancel only ever start from `draft`;
284    /// a `done` landed cost can never cancel — the reversal pattern is a negative-amount
285    /// landed cost).
286    LandedCostNotDraft { lc_id: Uuid, state: String },
287    /// A draft landed cost with no cost lines has nothing to allocate.
288    LandedCostNoLines { lc_id: Uuid },
289    /// A target receipt line resolves no inventory valuation account for the landed-cost
290    /// debit leg (neither a per-location override nor the receipt's header inventory
291    /// account).
292    LandedCostNoValuationAccount { move_id: Uuid },
293    /// The worksheet's Σ allocations does not equal the Σ cost line amounts — an internal
294    /// arithmetic invariant broke (each cost line's shares, with the last target line eating
295    /// the rounding diff, must sum to exactly its amount).
296    LandedCostAllocationMismatch { lc_id: Uuid, allocated: Decimal, declared: Decimal },
297    // ---- picking batch (the stock.picking.batch satellite, SB-1) -------------------------
298    /// A picking cannot join a batch whose projection already reached a terminal state
299    /// (`done`/`cancel`) — Odoo's sanitize-instead posture is deliberately NOT ported: a
300    /// terminal batch silently cancelling a live picking it was handed is a data-loss
301    /// shaped surprise, so the membership verb refuses loudly instead (fail-closed).
302    BatchTerminal { batch_id: Uuid, state: String },
303    /// A terminal picking (`done`/`cancel`) cannot join a batch — it has nothing left to
304    /// pick. Removal from a batch is always allowed; joining is draft-to-assigned only.
305    PickingTerminalForBatch { picking_id: Uuid, state: String },
306    /// A picking already belongs to another batch (one batch per picking — the membership
307    /// column is single-valued). Move it explicitly: remove from the old batch, then add.
308    PickingAlreadyBatched { picking_id: Uuid, batch_id: Uuid },
309    /// The picking is not a member of this batch (the removal verb only clears what is
310    /// actually grouped).
311    NotABatchMember { batch_id: Uuid, picking_id: Uuid },
312    // ---- scrap door (spec stock-business-logic.md §8) -----------------------------------
313    /// A scrap record can only be processed from `draft` (a done scrap is terminal — its
314    /// move exists; corrections are reversal moves, never re-processing).
315    ScrapNotDraft { scrap_id: Uuid, state: String },
316    /// The scrap record's scrap location is absent and no inventory-loss location exists
317    /// (explicit destination or a seeded loss location is required).
318    ScrapLocationUnavailable,
319    Db(sqlx::Error),
320}
321
322impl InventoryError {
323    pub fn code(&self) -> String {
324        match self {
325            InventoryError::EmptyDocument => "empty_document".into(),
326            InventoryError::NegativeQuantity => "negative_quantity".into(),
327            InventoryError::InsufficientStock { .. } => "insufficient_stock".into(),
328            InventoryError::DuplicateNumber(_) => "duplicate_number".into(),
329            InventoryError::NotFound(_) => "not_found".into(),
330            InventoryError::NotDraft(_) => "not_draft".into(),
331            InventoryError::NotSubmitted(_) => "not_submitted".into(),
332            InventoryError::GlNotPosted(_) => "gl_not_posted".into(),
333            InventoryError::InsufficientStockToReverse { .. } => "insufficient_stock_to_reverse".into(),
334            InventoryError::SameWarehouse => "same_warehouse".into(),
335            InventoryError::GlRejected { code, .. } => code.clone(),
336            InventoryError::WrongMoveState { .. } => "wrong_move_state".into(),
337            InventoryError::MoveHasNoRule { .. } => "move_has_no_rule".into(),
338            InventoryError::SameLocation { .. } => "same_location".into(),
339            InventoryError::MoveLinesRequired { .. } => "move_lines_required".into(),
340            InventoryError::ViewLocationHoldsNoStock { .. } => "quant_on_view_location".into(),
341            InventoryError::LocationNotFound(_) => "location_not_found".into(),
342            InventoryError::CountReserved { .. } => "quant_reserved".into(),
343            InventoryError::OutdatedCount { .. } => "outdated_count".into(),
344            InventoryError::CountedRateUnsupported => "counted_rate_unsupported".into(),
345            InventoryError::AngloPostureUnconfigured => "anglo_posture_unconfigured".into(),
346            InventoryError::LandedCostNoValuedTargets { .. } => "landed_cost_no_valued_targets".into(),
347            InventoryError::LandedCostLineNeedsAccount { .. } => "landed_cost_line_needs_account".into(),
348            InventoryError::LandedCostRequiresCostMethod { .. } => "landed_cost_requires_cost_method".into(),
349            InventoryError::LandedCostZeroSplitBasis { .. } => "landed_cost_zero_split_basis".into(),
350            InventoryError::LandedCostNotDraft { .. } => "landed_cost_not_draft".into(),
351            InventoryError::LandedCostNoLines { .. } => "landed_cost_no_lines".into(),
352            InventoryError::LandedCostNoValuationAccount { .. } => "landed_cost_no_valuation_account".into(),
353            InventoryError::LandedCostAllocationMismatch { .. } => "landed_cost_allocation_mismatch".into(),
354            InventoryError::BatchTerminal { .. } => "batch_terminal".into(),
355            InventoryError::PickingTerminalForBatch { .. } => "picking_terminal_for_batch".into(),
356            InventoryError::PickingAlreadyBatched { .. } => "picking_already_batched".into(),
357            InventoryError::NotABatchMember { .. } => "not_a_batch_member".into(),
358            InventoryError::ScrapNotDraft { .. } => "scrap_not_draft".into(),
359            InventoryError::ScrapLocationUnavailable { .. } => "scrap_location_unavailable".into(),
360            InventoryError::Db(_) => "internal_error".into(),
361        }
362    }
363    pub fn http_status(&self) -> u16 {
364        match self {
365            InventoryError::NotFound(_) => 404,
366            InventoryError::Db(_) => 500,
367            _ => 422,
368        }
369    }
370}
371impl std::fmt::Display for InventoryError {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        match self {
374            InventoryError::GlRejected { code, message } => write!(f, "{code}: {message}"),
375            other => write!(f, "{}", other.code()),
376        }
377    }
378}
379impl std::error::Error for InventoryError {}
380impl From<sqlx::Error> for InventoryError {
381    fn from(e: sqlx::Error) -> Self { InventoryError::Db(e) }
382}
383
384/// Discriminate a unique violation out of a raw `sqlx::Error`.
385///
386/// This is why the repositories' write methods leak `sqlx::Error` rather than a typed repo error: the
387/// service turns a re-used receipt number into `DuplicateNumber`, and a typed error would have thrown
388/// that information away.
389pub(super) fn is_dup(e: &sqlx::Error) -> bool {
390    e.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false)
391}
392
393/// The GL-posting tripwire for voucher doors whose legs are minted through the move engine.
394/// A re-wired door (receipt / delivery / their cancels) owns ONE GL post per voucher — its
395/// envelope, `posting_state`, repost and reversal machinery key on the voucher id — so its
396/// moves are minted with an empty [`super::inventory_move_engine::MoveGlDirective`] (a leg
397/// whose accounts are absent posts nothing). This sink exists so a move can never post GL
398/// SILENTLY under such a door: if a directive ever grows accounts and the engine builds an
399/// envelope, the loud rejection surfaces it instead of a phantom second journal.
400pub(in crate::application::service) struct DoorOwnedGlSink;
401#[async_trait::async_trait]
402impl super::inventory_gl::GlPostSink for DoorOwnedGlSink {
403    async fn post(
404        &self,
405        _e: &super::inventory_gl::AccountingPostEnvelope,
406    ) -> Result<super::inventory_gl::GlPostAck, super::inventory_gl::GlPostRejected> {
407        Err(super::inventory_gl::GlPostRejected {
408            code: "gl_owned_by_voucher_door".into(),
409            message: "voucher doors post one door-owned GL envelope; their moves post none".into(),
410        })
411    }
412}
413
414#[derive(Clone)]
415pub struct InventoryWriteService {
416    pub(super) db_pool: PgPool,
417    pub(super) sink: Arc<dyn InventoryEventSink>,
418    pub(super) warehouses: Arc<WarehouseRepository>,
419    pub(super) stock_items: Arc<StockItemRepository>,
420    pub(super) bins: Arc<BinRepository>,
421    pub(super) sles: Arc<StockLedgerEntryRepository>,
422    pub(super) receipts: Arc<PurchaseReceiptRepository>,
423    pub(super) receipt_items: Arc<PurchaseReceiptItemRepository>,
424    pub(super) deliveries: Arc<DeliveryNoteRepository>,
425    pub(super) delivery_items: Arc<DeliveryNoteItemRepository>,
426    pub(super) entries: Arc<StockEntryRepository>,
427    pub(super) entry_items: Arc<StockEntryItemRepository>,
428    pub(super) recons: Arc<StockReconciliationRepository>,
429    pub(super) recon_items: Arc<StockReconciliationItemRepository>,
430    pub(super) gl: Arc<GlVoucherRepository>,
431    // The stock-convergence engine's repositories (quant reservation apex, move lifecycle,
432    // move-line mirror) — same construction pattern as the voucher doors above.
433    pub(super) quants: Arc<QuantRepository>,
434    pub(super) moves: Arc<StockMoveRepository>,
435    pub(super) move_lines: Arc<StockMoveLineRepository>,
436    // The picking-as-projection + quant-driven adjustment companions (transfer header mint,
437    // projection probes, location resolution, quant-surface heal; count staging/consume).
438    pub(super) pickings: Arc<StockPickingRepository>,
439    pub(super) adjustments: Arc<StockAdjustmentRepository>,
440    // The valuation overlay (the ambient posting posture + landed-cost document family).
441    // Stateless hand-owned SQL, same construction pattern as the GL voucher repository.
442    pub(super) valuation_overlay: Arc<ValuationOverlayRepository>,
443    // The picking-batch satellite's membership surface (state mint, member add/remove,
444    // the SB-1 projection recompute, probes). Stateless hand-owned SQL.
445    pub(super) batches: Arc<PickingBatchProjectionRepository>,
446    // The scrap satellite's door (header mint, probe, terminal done-stamp). Stateless
447    // hand-owned SQL.
448    pub(super) scraps: Arc<ScrapDoorRepository>,
449}
450
451impl InventoryWriteService {
452    pub fn new(db_pool: PgPool) -> Self {
453        Self::with_sink(db_pool, Arc::new(LoggingSink))
454    }
455    pub fn with_sink(db_pool: PgPool, sink: Arc<dyn InventoryEventSink>) -> Self {
456        Self {
457            warehouses: Arc::new(WarehouseRepository::new(db_pool.clone())),
458            stock_items: Arc::new(StockItemRepository::new(db_pool.clone())),
459            bins: Arc::new(BinRepository::new(db_pool.clone())),
460            sles: Arc::new(StockLedgerEntryRepository::new(db_pool.clone())),
461            receipts: Arc::new(PurchaseReceiptRepository::new(db_pool.clone())),
462            receipt_items: Arc::new(PurchaseReceiptItemRepository::new(db_pool.clone())),
463            deliveries: Arc::new(DeliveryNoteRepository::new(db_pool.clone())),
464            delivery_items: Arc::new(DeliveryNoteItemRepository::new(db_pool.clone())),
465            entries: Arc::new(StockEntryRepository::new(db_pool.clone())),
466            entry_items: Arc::new(StockEntryItemRepository::new(db_pool.clone())),
467            recons: Arc::new(StockReconciliationRepository::new(db_pool.clone())),
468            recon_items: Arc::new(StockReconciliationItemRepository::new(db_pool.clone())),
469            gl: Arc::new(GlVoucherRepository::new()),
470            quants: Arc::new(QuantRepository::new(db_pool.clone())),
471            moves: Arc::new(StockMoveRepository::new(db_pool.clone())),
472            move_lines: Arc::new(StockMoveLineRepository::new(db_pool.clone())),
473            pickings: Arc::new(StockPickingRepository::new()),
474            adjustments: Arc::new(StockAdjustmentRepository::new()),
475            valuation_overlay: Arc::new(ValuationOverlayRepository::new()),
476            batches: Arc::new(PickingBatchProjectionRepository::new()),
477            scraps: Arc::new(ScrapDoorRepository::new()),
478            db_pool,
479            sink,
480        }
481    }
482
483    /// The database this call runs on: the composer's request pool when one
484    /// is bound (a tenant mount, or a relay consumer wrapped by the host),
485    /// else the composed pool (ADR-0029 pool law).
486    pub(super) fn rpool(&self) -> PgPool {
487        crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
488    }
489
490    // ---- shared: repost short-circuit + GL emit/reconcile ------------------
491
492    /// Short-circuit a repost when the voucher is already settled: `posted` → return the recorded
493    /// ids; `not_applicable` → no-op. Returns None when a (re-)emit is actually needed.
494    pub(super) fn already_settled(gl: &GlSettlementState, id: Uuid) -> Option<SubmitOutcome> {
495        match gl.posting_state.as_str() {
496            "posted" => Some(SubmitOutcome {
497                voucher_id: id, posted: true,
498                journal_id: gl.journal_id, post_id: gl.accounting_post_id, gl_amount: Decimal::ZERO,
499            }),
500            "not_applicable" => Some(SubmitOutcome { voucher_id: id, posted: false, journal_id: None, post_id: None, gl_amount: Decimal::ZERO }),
501            _ => None,
502        }
503    }
504
505    /// Emit the envelope through `sink` and reconcile the voucher's posting_state. The physical
506    /// movement is already committed; on GL failure the voucher is `failed` — now re-drivable via
507    /// `repost_*` (never rolled back).
508    pub(super) async fn emit_and_reconcile(
509        &self, voucher: GlVoucher, voucher_id: Uuid, env: &AccountingPostEnvelope, sink: &dyn GlPostSink, gl_amount: Decimal,
510    ) -> Result<SubmitOutcome, InventoryError> {
511        debug_assert!(env.is_balanced());
512        match sink.post(env).await {
513            Ok(ack) => {
514                self.gl.mark_posted(&self.rpool(), voucher, voucher_id, ack.journal_id, ack.post_id).await?;
515                Ok(SubmitOutcome { voucher_id, posted: true, journal_id: Some(ack.journal_id), post_id: Some(ack.post_id), gl_amount })
516            }
517            Err(rej) => {
518                let _ = self.gl.mark_failed(&self.rpool(), voucher, voucher_id).await;
519                Err(InventoryError::GlRejected { code: rej.code, message: rej.message })
520            }
521        }
522    }
523
524    /// Emit a `posting_type='reversal'` post and record its ids in the voucher's
525    /// `reversal_*` columns (council 2026-07-29, #3). Distinct from [`Self::emit_and_reconcile`]:
526    /// the original post is already `posted` and its ids stay intact, so on success we call
527    /// `mark_reversal_posted` (no `posting_state` guard); on failure we do NOT `mark_failed` (that
528    /// would clobber the original's `posted` state) — the voucher stays `cancelled` with a NULL
529    /// `reversal_accounting_post_id`, and re-calling cancel re-emits only this GL leg.
530    pub(super) async fn emit_reversal_and_reconcile(
531        &self, voucher: GlVoucher, voucher_id: Uuid, env: &AccountingPostEnvelope, sink: &dyn GlPostSink, gl_amount: Decimal,
532    ) -> Result<SubmitOutcome, InventoryError> {
533        debug_assert!(env.is_balanced());
534        match sink.post(env).await {
535            Ok(ack) => {
536                self.gl.mark_reversal_posted(&self.rpool(), voucher, voucher_id, ack.journal_id, ack.post_id).await?;
537                Ok(SubmitOutcome { voucher_id, posted: true, journal_id: Some(ack.journal_id), post_id: Some(ack.post_id), gl_amount })
538            }
539            Err(rej) => Err(InventoryError::GlRejected { code: rej.code, message: rej.message }),
540        }
541    }
542
543    // ---- shared: voucher doors minting their legs through the move engine ------------------
544    //
545    // The receipt/delivery voucher doors (and their cancel verbs) no longer write Bins or SLEs
546    // directly: each line mints ONE stock move through the engine (create → confirm → assign →
547    // done), so the quant flips, the Bin reblende and the SLE rows come from move application —
548    // one stock estate, one writer. The voucher keeps its identity (header, item rows, events,
549    // its ONE GL envelope + posting_state/repost/reversal machinery); the moves post no GL
550    // (empty directive + the DoorOwnedGlSink tripwire above).
551
552    /// Mint (or resume) the move a voucher line maps to. The mapping is the deterministic move
553    /// name — `{voucher}/{seq}` for forward legs, `{voucher}/REV/{seq}` for reversal legs —
554    /// looked up among the moves stamped with the voucher number as `origin`:
555    ///
556    /// - no move under the name → mint a fresh DRAFT move through the engine;
557    /// - a DONE move → the line already landed in a prior (crashed) attempt — `None`, the
558    ///   caller skips the line (a door is not cross-move atomic; the name mapping is what makes
559    ///   a retry converge instead of double-moving stock);
560    /// - a LIVE move (draft/confirmed/…) → hand it back for the caller to keep driving;
561    /// - a CANCELLED move → refuse loudly: someone cancelled the voucher's leg by hand, and
562    ///   silently re-minting under a new name would double-count the line.
563    pub(in crate::application::service) async fn mint_line_move(
564        &self,
565        m: super::inventory_move_engine::NewStockMove,
566    ) -> Result<Option<Uuid>, InventoryError> {
567        let origin = m.origin.clone().unwrap_or_default();
568        let existing = {
569            let mut tx = self.rpool().begin().await?;
570            relay_ambient_scope(&mut tx).await?;
571            let rows = self.moves.fetch_moves_by_origin(&mut tx, &origin).await?;
572            tx.commit().await?;
573            rows
574        };
575        if let Some(prior) = existing.iter().find(|mv| mv.name == m.name) {
576            return match prior.state.as_str() {
577                "done" => Ok(None),
578                "cancel" => Err(InventoryError::WrongMoveState {
579                    move_id: prior.id, action: "mint", current: "cancel".into(),
580                }),
581                _ => Ok(Some(prior.id)),
582            };
583        }
584        Ok(Some(self.create_move(m).await?))
585    }
586
587    /// Advance a door-minted move to the most-reserved state it can reach: confirm (if still
588    /// draft), then an assign pass (if confirmed/partial). Returns the post-advance state —
589    /// `assigned` when the full demand is covered, less when not. The door decides what a
590    /// short advance means (the delivery refuses; the inbound receipt never is).
591    ///
592    /// Every state read and every engine verb below rides the ambient org scope (ADR-0029):
593    /// the composing decorator owns isolation, the module binds nothing itself.
594    pub(in crate::application::service) async fn advance_move_to_assigned(
595        &self,
596        move_id: Uuid,
597    ) -> Result<String, InventoryError> {
598        let mut state = self.move_state_of(move_id).await?;
599        if state == "draft" {
600            self.action_confirm(move_id).await?;
601            state = self.move_state_of(move_id).await?;
602        }
603        if state == "confirmed" || state == "partially_available" {
604            let outcome = self.action_assign(move_id).await?;
605            state = outcome.state;
606        }
607        Ok(state)
608    }
609
610    /// Read one move's current state. The door drives the engine's verbs; this re-read between
611    /// verbs is how it follows the state the ENGINE wrote (the door never derives or asserts
612    /// move state itself). The read rides the caller's ambient org scope (ADR-0029) — under the
613    /// composed shape the decorator's fence bounds it; undecorated (module tests) it is plain.
614    pub(in crate::application::service) async fn move_state_of(
615        &self,
616        move_id: Uuid,
617    ) -> Result<String, InventoryError> {
618        let mut tx = self.rpool().begin().await?;
619        relay_ambient_scope(&mut tx).await?;
620        let mv = self.moves.fetch_move(&mut tx, move_id).await?
621            .ok_or(InventoryError::NotFound(move_id))?;
622        tx.commit().await?;
623        Ok(mv.state)
624    }
625
626    /// Resolve the endpoints a voucher door's line moves run between: the warehouse's stock
627    /// location (internal, resolve-or-bootstrap) and the counterpart partner location
628    /// (supplier for receipts, customer for deliveries — resolve-or-bootstrap). Returns
629    /// `(partner_location_id, stock_location_id)`.
630    pub(in crate::application::service) async fn door_move_endpoints(
631        &self,
632        warehouse_id: Uuid,
633        partner_usage: &str,
634    ) -> Result<(Uuid, Uuid), InventoryError> {
635        let mut tx = self.rpool().begin().await?;
636        relay_ambient_scope(&mut tx).await?;
637        let partner = self.pickings.ensure_partner_location(&mut tx, partner_usage).await?;
638        let stock = self.pickings.ensure_internal_location(&mut tx, warehouse_id).await?;
639        tx.commit().await?;
640        Ok((partner, stock))
641    }
642}