Skip to main content

backbone_inventory/application/service/
inventory_delivery.rs

1//! The Delivery Note path: draft → submit → repost (hand-authored, user-owned).
2//!
3//! An `impl InventoryWriteService` chunk over the vocabulary in [`super::inventory_write_service`]:
4//! the goods-out voucher. `create_delivery_note` opens a draft; `submit_delivery_note` mints ONE
5//! stock move per line through the converged engine (the warehouse's stock location → the
6//! customer location) — move application runs the **outflow** half of the moving-average
7//! valuation engine (`cogs = qty·rate; value -= cogs; qty -= qty; rate unchanged by an
8//! outflow`, with the residual-flush rule when the bin drains to 0): the source quant draws,
9//! the Bin reblends, the SLE row mints; then the voucher's ONE balanced `AccountingPost`
10//! (`Dr COGS · Cr Inventory`) is emitted and eventually reconciled. `repost_delivery_note` is
11//! the exit from a stuck `failed` GL post.
12//!
13//! Per the module's 4-layer rule this file holds no SQL — the statements live on
14//! `DeliveryNoteRepository` / `DeliveryNoteItemRepository` and the engine's repositories.
15//!
16//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file opens
17//! re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing decorator owns
18//! isolation. Cross-module wire fields that still carry a company id are legacy twins filled
19//! from the ambient scope's company echo.
20
21use rust_decimal::Decimal;
22use uuid::Uuid;
23
24use crate::infrastructure::persistence::{GlVoucher, NewDeliveryItemRow, NewDeliveryRow};
25
26use super::inventory_events::{InventoryEvent, StockDelivered};
27use super::inventory_gl::{AccountingPostEnvelope, GlPostLine, GlPostSink};
28use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
29use super::inventory_write_service::{
30    is_dup, legacy_company_echo, money, relay_ambient_scope, DoorOwnedGlSink, InventoryError,
31    InventoryWriteService, NewDelivery, SubmitOutcome,
32};
33
34impl InventoryWriteService {
35    pub async fn create_delivery_note(&self, d: NewDelivery) -> Result<Uuid, InventoryError> {
36        if d.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
37        for l in &d.lines {
38            if l.quantity < Decimal::ZERO { return Err(InventoryError::NegativeQuantity); }
39        }
40        let id = Uuid::new_v4();
41        let mut tx = self.rpool().begin().await?;
42        relay_ambient_scope(&mut tx).await?;
43        let ins = self.deliveries.insert_draft(&mut tx, &NewDeliveryRow {
44            id,
45            delivery_number: &d.delivery_number,
46            branch_id: d.branch_id,
47            customer_id: d.customer_id,
48            source_so_id: d.source_so_id,
49            warehouse_id: d.warehouse_id,
50            posting_date: d.posting_date,
51            currency: &d.currency,
52            cogs_account_id: d.cogs_account_id,
53            inventory_account_id: d.inventory_account_id,
54        }).await;
55        if let Err(e) = ins {
56            return Err(if is_dup(&e) { InventoryError::DuplicateNumber(d.delivery_number) } else { e.into() });
57        }
58        for l in &d.lines {
59            self.delivery_items.insert_item(&mut tx, &NewDeliveryItemRow {
60                id: Uuid::new_v4(),
61                delivery_id: id,
62                item_id: l.item_id,
63                quantity: l.quantity,
64            }).await?;
65        }
66        tx.commit().await?;
67        Ok(id)
68    }
69
70    // ---- submit: Delivery Note (COGS post) ---------------------------------
71
72    /// Submit the goods-out voucher. Every line mints ONE stock move through the converged
73    /// engine (the warehouse's stock location → the customer location): confirm → assign
74    /// (reserves against the source quants — the door refuses a line the reservation cannot
75    /// cover WHOLE, preserving the voucher's all-or-nothing availability posture) → done,
76    /// which releases the reservation, draws the source quant, reblends the Bin (`cogs =
77    /// qty·rate; rate unchanged`; residual flush when the bin drains to 0) and mints the SLE
78    /// row. The voucher keeps its identity verbatim — header status + per-line COGS snapshot,
79    /// the ONE `Dr COGS · Cr Inventory` envelope, the posting_state/repost machinery, the
80    /// `StockDelivered` event; the moves post no GL (empty directive + the door-owned-GL
81    /// tripwire sink).
82    ///
83    /// Not cross-move atomic (each engine verb commits its own transaction): a crash mid-way
84    /// leaves the delivery `draft` with some line moves landed — the deterministic move names
85    /// (`{delivery_number}/{seq}`, `origin` = the delivery number) make a re-submit RESUME
86    /// instead of double-shipping. A refusal mid-way (a line the reservation cannot cover)
87    /// unreserves that line's move and leaves the voucher `draft`: no stock moved, no
88    /// reservation held.
89    pub async fn submit_delivery_note(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
90        // RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
91        let hdr = self.deliveries.fetch_submit_header(&self.rpool(), id).await?
92            .ok_or(InventoryError::NotFound(id))?;
93        if hdr.status != "draft" {
94            return Err(InventoryError::NotDraft(id.to_string()));
95        }
96        let branch = hdr.branch_id;
97        let warehouse = hdr.warehouse_id;
98        let posting_date = hdr.posting_date;
99        let voucher_no = hdr.delivery_number;
100        let cogs_acct = hdr.cogs_account_id;
101        let inv_acct = hdr.inventory_account_id;
102        let source_so = hdr.source_so_id;
103
104        // The posting posture (the valuation settings row; absent row = today's shapes).
105        // Resolved BEFORE anything mints so the anglo-saxon posture's fail-closed account
106        // check refuses the delivery with NOTHING moved and nothing posted: under the
107        // posture the debit leg is the interim-delivered account, and an unconfigured one
108        // must not fall back to COGS silently.
109        let posture = self.posting_posture().await?;
110        let debit_acct = self.delivery_debit_account(&posture, cogs_acct)?;
111
112        let items = self.delivery_items.fetch_items(&self.rpool(), id).await?;
113
114        // The door's move endpoints: the warehouse's stock location (internal source) and the
115        // customer location (virtual destination) — resolve-or-bootstrap each.
116        let (customer_loc, stock_loc) = self.door_move_endpoints(warehouse, "customer").await?;
117        // The inventory credit leg resolves the same location valuation-account override the
118        // receipt path uses (the chain: location override → header account).
119        let inv_acct = self.inventory_leg_account(stock_loc, inv_acct).await?;
120
121        // ---- all-or-nothing availability pre-check, under the Bin locks -----------------------
122        // Same posture the voucher path always had: EVERY line's demand must be coverable
123        // before anything mints (the engine's draw guard re-checks at the quant grain). Also
124        // captures each line's valuation-rate snapshot — the per-line COGS copy the voucher
125        // rows carry.
126        let mut line_rates: std::collections::HashMap<Uuid, Decimal> = std::collections::HashMap::new();
127        {
128            let mut tx = self.rpool().begin().await?;
129            // Re-bind the caller's ambient org scope before any bin read (ADR-0029) — the
130            // scope is task-local and a fresh pool transaction carries none of it;
131            // undecorated (module tests, jobs) the transaction stays plain.
132            relay_ambient_scope(&mut tx).await?;
133            for it in &items {
134                if it.quantity.is_zero() { continue; }
135                let bin = self.bins.lock_or_init(&mut tx, it.item_id, warehouse).await?;
136                line_rates.insert(it.item_id, bin.valuation_rate);
137                if bin.actual_qty < it.quantity {
138                    return Err(InventoryError::InsufficientStock {
139                        item_id: it.item_id, warehouse_id: warehouse,
140                        available: bin.actual_qty, requested: it.quantity,
141                    });
142                }
143                // The quant-surface heal: stock seeded through the legacy direct-write paths
144                // wrote Bins without quants — seed the quant from the (locked) Bin once, so the
145                // line move's reservation has a surface to reserve against.
146                self.pickings.ensure_quant_surface(&mut tx, it.item_id, stock_loc, Some(warehouse)).await?;
147            }
148            tx.commit().await?;
149        }
150
151        // ---- physical movement: one minted move per line, engine-driven ----
152        let mut total_cogs = Decimal::ZERO;
153        for (idx, it) in items.iter().enumerate() {
154            if it.quantity.is_zero() { continue; } // a zero line moves nothing and mints nothing
155            let name = format!("{}/{}", voucher_no, idx + 1);
156            let mid = match self.mint_line_move(NewStockMove {
157                name: name.clone(),
158                item_id: it.item_id,
159                demand_qty: it.quantity,
160                price_unit: Decimal::ZERO, // an outflow is valued at the source average, never priced
161                procure_method: "make_to_stock".into(),
162                picking_id: None,
163                origin: Some(voucher_no.clone()),
164                location_id: stock_loc,
165                location_dest_id: customer_loc,
166                partner_id: None,
167                warehouse_id: Some(warehouse),
168                orderpoint_id: None,
169                move_orig_ids: vec![],
170                move_dest_ids: vec![],
171                is_inventory: false,
172                scrapped: false,
173                forced_value: None,
174            }).await? {
175                Some(mid) => mid,
176                None => {
177                    // The line already landed in a prior (crashed) attempt: its COGS is the
178                    // value its move carried — recover it from the move's SLE leg.
179                    let prior_cogs = self.sles.sum_move_value(&self.rpool(), &name).await?;
180                    total_cogs += prior_cogs;
181                    continue;
182                }
183            };
184            let state = self.advance_move_to_assigned(mid).await?;
185            if state != "assigned" {
186                // The all-or-nothing posture: a line the reservation cannot cover WHOLE
187                // refuses the delivery. Release whatever the partial assign took; no stock
188                // moved, no reservation held, the voucher stays draft (retryable).
189                self.unreserve_move(mid).await?;
190                let on_hand = self.quants.fetch_on_hand(&self.rpool(), it.item_id, stock_loc).await?;
191                return Err(InventoryError::InsufficientStock {
192                    item_id: it.item_id, warehouse_id: warehouse,
193                    available: on_hand.on_hand_qty, requested: it.quantity,
194                });
195            }
196            let outcome = self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &DoorOwnedGlSink).await?;
197            // The engine's OUT leg IS the line's COGS (average or residual-flush — the same
198            // arithmetic the voucher path always used); snapshot it on the voucher row.
199            let cogs = outcome.out_value;
200            let rate = line_rates.get(&it.item_id).copied().unwrap_or(Decimal::ZERO);
201            {
202                let mut tx = self.rpool().begin().await?;
203                relay_ambient_scope(&mut tx).await?;
204                self.delivery_items.update_valuation(&mut tx, it.id, rate, cogs).await?;
205                tx.commit().await?;
206            }
207            total_cogs += cogs;
208        }
209        {
210            let mut tx = self.rpool().begin().await?;
211            relay_ambient_scope(&mut tx).await?;
212            self.deliveries.mark_submitted_with_cogs(&mut tx, id, total_cogs).await?;
213            tx.commit().await?;
214        }
215
216        // The explicit account-move gate: a voucher that carries neither value nor quantity
217        // posts nothing (stays `not_applicable`); a `periodic` valuation policy suppresses the
218        // real-time post the same way (the closing flow — a later increment — owns those legs).
219        let total_qty: Decimal = items.iter().map(|l| l.quantity).sum();
220        if posture.periodic
221            || !Self::should_create_account_move(total_cogs, total_qty, true)
222        {
223            self.gl.mark_not_applicable(&self.rpool(), GlVoucher::DeliveryNote, id).await?;
224            self.sink.publish(InventoryEvent::StockDelivered(StockDelivered {
225                delivery_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_so_id: source_so,
226                total_cogs,
227            }));
228            return Ok(SubmitOutcome {
229                voucher_id: id, posted: false, journal_id: None, post_id: None,
230                gl_amount: Decimal::ZERO,
231            });
232        }
233        let env = AccountingPostEnvelope {
234            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
235            // consumers that still read a tenant off the wire. No module statement keys on it.
236            idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: branch,
237            source_type: "inventory".into(), source_id: id, source_reference: Some(voucher_no.clone()),
238            posting_date, currency: hdr.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
239            description: Some("Delivery COGS".into()),
240            lines: vec![
241                // Under the anglo-saxon posture `debit_acct` is the interim-delivered leg
242                // (COGS recognition deferred to the invoice side); else today's COGS debit.
243                GlPostLine::debit(debit_acct, total_cogs).with_description("COGS"),
244                GlPostLine::credit(inv_acct, total_cogs).with_description("Inventory"),
245            ],
246        };
247        let outcome = self.emit_and_reconcile(GlVoucher::DeliveryNote, id, &env, sink, total_cogs).await?;
248        self.sink.publish(InventoryEvent::StockDelivered(StockDelivered {
249            delivery_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_so_id: source_so,
250            total_cogs,
251        }));
252        Ok(outcome)
253    }
254
255    // ---- repost: re-drive a stuck GL post (pending/failed) ------------------
256
257    pub async fn repost_delivery_note(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
258        // RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
259        let h = self.deliveries.fetch_repost_header(&self.rpool(), id).await?
260            .ok_or(InventoryError::NotFound(id))?;
261        if let Some(o) = Self::already_settled(&h.gl, id) { return Ok(o); }
262        // The SAME posture the submit ran under (absent row = defaults): the anglo-saxon
263        // debit swap applies to the rebuilt envelope identically (fail-closed on an
264        // unconfigured interim account — a repost must not silently diverge from its
265        // original), a `periodic` valuation policy retires the unsettled leg to
266        // `not_applicable`, and the inventory leg resolves the same location override the
267        // submit used.
268        let posture = self.posting_posture().await?;
269        if posture.periodic {
270            self.gl.mark_not_applicable(&self.rpool(), GlVoucher::DeliveryNote, id).await?;
271            return Ok(SubmitOutcome {
272                voucher_id: id, posted: false, journal_id: None, post_id: None,
273                gl_amount: Decimal::ZERO,
274            });
275        }
276        let debit_acct = self.delivery_debit_account(&posture, h.cogs_account_id)?;
277        let (_, stock_loc) = self.door_move_endpoints(h.warehouse_id, "customer").await?;
278        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
279        let amt = h.total_cogs;
280        let env = AccountingPostEnvelope {
281            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
282            // consumers that still read a tenant off the wire. No module statement keys on it.
283            idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: h.branch_id,
284            source_type: "inventory".into(), source_id: id, source_reference: Some(h.delivery_number),
285            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
286            description: Some("Delivery COGS (repost)".into()),
287            lines: vec![
288                GlPostLine::debit(debit_acct, amt).with_description("COGS"),
289                GlPostLine::credit(inv_acct, amt).with_description("Inventory"),
290            ],
291        };
292        self.emit_and_reconcile(GlVoucher::DeliveryNote, id, &env, sink, amt).await
293    }
294}