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}