Skip to main content

backbone_inventory/application/service/
inventory_receipt.rs

1//! The Purchase Receipt 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-in voucher. `create_purchase_receipt` opens a draft; `submit_purchase_receipt` mints
5//! ONE stock move per line through the converged engine (supplier location → the warehouse's
6//! stock location) — move application runs the **inflow** half of the moving-average valuation
7//! engine (`value += qty·rate; qty += qty; rate = value/qty`): the quant materializes, the Bin
8//! reblends, the SLE row mints — then the voucher's ONE balanced `AccountingPost` (`Dr Inventory ·
9//! Cr GR/IR`) is emitted and eventually reconciled; `repost_purchase_receipt` is the exit from a
10//! stuck `failed` GL post. The moves post no GL of their own: the voucher owns the single
11//! envelope its posting_state/repost/reversal machinery keys on.
12//!
13//! Per the module's 4-layer rule this file holds no SQL — the statements live on
14//! `PurchaseReceiptRepository` / `PurchaseReceiptItemRepository` 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, NewReceiptItemRow, NewReceiptRow};
25
26use super::inventory_events::{InventoryEvent, StockReceived};
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, NewReceipt, SubmitOutcome,
32};
33
34impl InventoryWriteService {
35    pub async fn create_purchase_receipt(&self, r: NewReceipt) -> Result<Uuid, InventoryError> {
36        if r.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
37        for l in &r.lines {
38            if l.quantity < Decimal::ZERO || l.rate < Decimal::ZERO { return Err(InventoryError::NegativeQuantity); }
39        }
40        // The document total values STOCK: a landed-cost service line (`is_landed_costs_line`)
41        // carries cost into a LandedCost document, not inventory — it mints no move and adds
42        // nothing to the door's Dr Inventory envelope (the landed cost posts its own GL with
43        // its own accounts; counting it here would debit inventory for stock never minted).
44        let total: Decimal = r.lines.iter()
45            .filter(|l| !l.is_landed_costs_line)
46            .map(|l| money(l.quantity * l.rate))
47            .sum();
48        let id = Uuid::new_v4();
49        let mut tx = self.rpool().begin().await?;
50        relay_ambient_scope(&mut tx).await?;
51        let ins = self.receipts.insert_draft(&mut tx, &NewReceiptRow {
52            id,
53            receipt_number: &r.receipt_number,
54            branch_id: r.branch_id,
55            supplier_id: r.supplier_id,
56            source_po_id: r.source_po_id,
57            warehouse_id: r.warehouse_id,
58            posting_date: r.posting_date,
59            currency: &r.currency,
60            total_value: total,
61            inventory_account_id: r.inventory_account_id,
62            grir_account_id: r.grir_account_id,
63        }).await;
64        if let Err(e) = ins {
65            return Err(if is_dup(&e) { InventoryError::DuplicateNumber(r.receipt_number) } else { e.into() });
66        }
67        for l in &r.lines {
68            self.receipt_items.insert_item(&mut tx, &NewReceiptItemRow {
69                id: Uuid::new_v4(),
70                receipt_id: id,
71                item_id: l.item_id,
72                quantity: l.quantity,
73                rate: l.rate,
74                amount: money(l.quantity * l.rate),
75                is_landed_costs_line: l.is_landed_costs_line,
76            }).await?;
77        }
78        tx.commit().await?;
79        Ok(id)
80    }
81
82    // ---- submit: Purchase Receipt (asset post) -----------------------------
83
84    /// Submit the goods-in voucher. Every line mints ONE stock move through the converged
85    /// engine (supplier location → the warehouse's stock location): confirm → assign (an
86    /// inbound move's supply is unconditionally available, so the execution line covers the
87    /// full line) → done, which materializes the destination quant, reblends the Bin
88    /// (`value += qty·rate; rate = value/qty`) and mints the SLE row. The voucher keeps its
89    /// identity verbatim — header status, the ONE `Dr Inventory · Cr GR/IR` envelope, the
90    /// posting_state/repost machinery, the `StockReceived` event; the moves post no GL (empty
91    /// directive + the door-owned-GL tripwire sink).
92    ///
93    /// Not cross-move atomic (each engine verb commits its own transaction): a crash mid-way
94    /// leaves the receipt `draft` with some line moves landed — the deterministic move names
95    /// (`{receipt_number}/{seq}`, `origin` = the receipt number) make a re-submit RESUME
96    /// instead of double-receiving.
97    pub async fn submit_purchase_receipt(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
98        self.submit_purchase_receipt_into(id, None, sink).await
99    }
100
101    /// Submit a receipt into a destination other than the warehouse's stock location.
102    ///
103    /// The ordinary receipt lands on the shelf, which is what a goods receipt means. A
104    /// subcontract receipt does not: the supplier's output belongs to an open manufacturing
105    /// order, and landing it on the shelf makes the estate believe in it twice, once here and
106    /// once when that order is received. The caller passes the location the goods are really
107    /// standing in — the production counterpart — and the manufacturing receive moves them onto
108    /// the shelf at their finished value.
109    ///
110    /// Inventory does not know what subcontracting is and does not learn it here: it takes a
111    /// location. Deciding WHICH location belongs to whoever knows why the goods arrived.
112    pub async fn submit_purchase_receipt_into(
113        &self,
114        id: Uuid,
115        destination_location_id: Option<Uuid>,
116        sink: &dyn GlPostSink,
117    ) -> Result<SubmitOutcome, InventoryError> {
118        // RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
119        let hdr = self.receipts.fetch_submit_header(&self.rpool(), id).await?
120            .ok_or(InventoryError::NotFound(id))?;
121        if hdr.status != "draft" {
122            return Err(InventoryError::NotDraft(id.to_string()));
123        }
124        let branch = hdr.branch_id;
125        let warehouse = hdr.warehouse_id;
126        let posting_date = hdr.posting_date;
127        let voucher_no = hdr.receipt_number;
128        let inv_acct = hdr.inventory_account_id;
129        let grir_acct = hdr.grir_account_id;
130        let source_po = hdr.source_po_id;
131
132        let items = self.receipt_items.fetch_items(&self.rpool(), id).await?;
133
134        // The door's move endpoints: the supplier location (virtual source) and the
135        // warehouse's stock location (internal destination) — resolve-or-bootstrap each.
136        let (supplier_loc, default_stock_loc) = self.door_move_endpoints(warehouse, "supplier").await?;
137        // An override must still be a location this tenant can see; the fenced read refuses a
138        // stranger's id rather than moving goods somewhere nobody can account for.
139        let stock_loc = match destination_location_id {
140            None => default_stock_loc,
141            Some(requested) => backbone_orm::company_scope::fetch_optional_scalar_scoped(
142                &self.rpool(),
143                sqlx::query_scalar::<_, Uuid>(
144                    "SELECT id FROM inventory.locations WHERE id = $1 AND active",
145                )
146                .bind(requested),
147            )
148            .await?
149            .ok_or(InventoryError::NotFound(requested))?,
150        };
151
152        // The posting posture (the valuation settings row; absent row = today's shapes).
153        // The receipt side is unchanged in BOTH postures — the header `grir_account_id` IS the
154        // interim-received leg (Assumption A1 of the valuation overlay); only the inventory leg
155        // consults the location valuation-account override. `periodic` suppresses the
156        // real-time post below (the voucher retires to `not_applicable`).
157        let posture = self.posting_posture().await?;
158        let inv_acct = self.inventory_leg_account(stock_loc, inv_acct).await?;
159
160        // ---- physical movement: one minted move per line, engine-driven ----
161        // The enumerate index is the deterministic name grain (`{voucher}/{idx+1}`): a skipped
162        // line (zero qty, or a landed-cost service line that mints no stock) still CONSUMES its
163        // index, so the names stay aligned with the line rows across submit, cancel and resume.
164        for (idx, it) in items.iter().enumerate() {
165            if it.quantity.is_zero() { continue; } // a zero line moves nothing and mints nothing
166            // The landed-cost seam: a flagged line carries cost into a LandedCost document, not
167            // stock — no move, no quant, no bin, no SLE (the landed cost revalues the receipt's
168            // DONE moves; a service line has none).
169            if it.is_landed_costs_line { continue; }
170            let name = format!("{}/{}", voucher_no, idx + 1);
171            let mid = match self.mint_line_move(NewStockMove {
172                name: name.clone(),
173                item_id: it.item_id,
174                demand_qty: it.quantity,
175                price_unit: it.rate, // the IN leg values the inflow at the line's rate
176                procure_method: "make_to_stock".into(),
177                picking_id: None,
178                origin: Some(voucher_no.clone()),
179                location_id: supplier_loc,
180                location_dest_id: stock_loc,
181                partner_id: None,
182                warehouse_id: Some(warehouse),
183                orderpoint_id: None,
184                move_orig_ids: vec![],
185                move_dest_ids: vec![],
186                is_inventory: false,
187                scrapped: false,
188                forced_value: None,
189            }).await? {
190                Some(mid) => mid,
191                None => continue, // the line already landed in a prior (crashed) attempt
192            };
193            self.advance_move_to_assigned(mid).await?;
194            self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &DoorOwnedGlSink).await?;
195        }
196        // The GL amount is the voucher's own arithmetic (Σ money(qty·rate) over the STOCK
197        // lines) — identical to the Σ of the moves' IN-leg carries by construction. A landed
198        // cost service line is excluded on both sides: it minted nothing for the envelope to
199        // mirror.
200        let total_debit: Decimal = items.iter()
201            .filter(|l| !l.is_landed_costs_line)
202            .map(|l| money(l.quantity * l.rate))
203            .sum();
204        {
205            let mut tx = self.rpool().begin().await?;
206            relay_ambient_scope(&mut tx).await?;
207            self.receipts.mark_submitted(&mut tx, id).await?;
208            tx.commit().await?;
209        }
210
211        // ---- GL post (eventually consistent) ----
212        // The explicit account-move gate: a voucher that carries neither value nor quantity
213        // posts nothing (stays `not_applicable`); a `periodic` valuation policy suppresses
214        // the real-time post the same way (the closing flow — a later increment — owns those legs).
215        let total_qty: Decimal = items.iter()
216            .filter(|l| !l.is_landed_costs_line)
217            .map(|l| l.quantity)
218            .sum();
219        if posture.periodic
220            || !Self::should_create_account_move(total_debit, total_qty, true)
221        {
222            self.gl.mark_not_applicable(&self.rpool(), GlVoucher::PurchaseReceipt, id).await?;
223            self.sink.publish(InventoryEvent::StockReceived(StockReceived {
224                receipt_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_po_id: source_po,
225                total_value: total_debit,
226            }));
227            return Ok(SubmitOutcome {
228                voucher_id: id, posted: false, journal_id: None, post_id: None,
229                gl_amount: Decimal::ZERO,
230            });
231        }
232        let env = AccountingPostEnvelope {
233            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
234            // consumers that still read a tenant off the wire. No module statement keys on it.
235            idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: branch,
236            source_type: "inventory".into(), source_id: id, source_reference: Some(voucher_no.clone()),
237            posting_date, currency: hdr.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
238            description: Some("Goods receipt".into()),
239            lines: vec![
240                GlPostLine::debit(inv_acct, total_debit).with_description("Inventory"),
241                GlPostLine::credit(grir_acct, total_debit).with_description("GR/IR clearing"),
242            ],
243        };
244        let outcome = self.emit_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, total_debit).await?;
245        self.sink.publish(InventoryEvent::StockReceived(StockReceived {
246            receipt_id: id, company_id: legacy_company_echo(), warehouse_id: warehouse, source_po_id: source_po,
247            total_value: total_debit,
248        }));
249        Ok(outcome)
250    }
251
252    // ---- repost: re-drive a stuck GL post (pending/failed) ------------------
253
254    /// Re-emit the GL post for a voucher whose physical movement committed but whose post is
255    /// `pending` or `failed` (a transient accounting outage, or a crash between commit and the
256    /// status update). This is the exit from a stuck `failed` voucher (council 2026-07-04) — without
257    /// it, `(submitted, failed)` is terminal and the subledger silently stops tying to the GL.
258    ///
259    /// **Idempotent** against the "physically-posted-but-status-not-updated" crash window: it rebuilds
260    /// the SAME envelope (`source_id = voucher_id`), and accounting dedupes on
261    /// `(company, source_type, source_id, posting_type)` — so a re-emit of an already-posted voucher
262    /// returns the original journal, never a second one. An already-`posted` voucher short-circuits
263    /// (returns the recorded ids); `not_applicable` is a no-op. Rebuilds the envelope from the stored
264    /// header (never re-touches the SLE/Bin — the physical movement already happened).
265    pub async fn repost_purchase_receipt(&self, id: Uuid, sink: &dyn GlPostSink) -> Result<SubmitOutcome, InventoryError> {
266        // RLS scope (ADR-0008), ID-only: fenced by the request/inherited scope.
267        let h = self.receipts.fetch_repost_header(&self.rpool(), id).await?
268            .ok_or(InventoryError::NotFound(id))?;
269        if let Some(o) = Self::already_settled(&h.gl, id) { return Ok(o); }
270        // The SAME posture the submit ran under (absent row = defaults): a `periodic`
271        // valuation policy suppresses the real-time post — the unsettled leg retires to
272        // `not_applicable` — and the inventory leg resolves the same location
273        // valuation-account override the submit used, so the rebuilt envelope is identical.
274        let posture = self.posting_posture().await?;
275        if posture.periodic {
276            self.gl.mark_not_applicable(&self.rpool(), GlVoucher::PurchaseReceipt, id).await?;
277            return Ok(SubmitOutcome {
278                voucher_id: id, posted: false, journal_id: None, post_id: None,
279                gl_amount: Decimal::ZERO,
280            });
281        }
282        let (_, stock_loc) = self.door_move_endpoints(h.warehouse_id, "supplier").await?;
283        let inv_acct = self.inventory_leg_account(stock_loc, h.inventory_account_id).await?;
284        let amt = h.total_value;
285        let env = AccountingPostEnvelope {
286            // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
287            // consumers that still read a tenant off the wire. No module statement keys on it.
288            idempotency_key: id.to_string(), company_id: legacy_company_echo(), branch_id: h.branch_id,
289            source_type: "inventory".into(), source_id: id, source_reference: Some(h.receipt_number),
290            posting_date: h.posting_date, currency: h.currency.clone(), posting_type: "original".into(), reverses_post_id: None,
291            description: Some("Goods receipt (repost)".into()),
292            lines: vec![
293                GlPostLine::debit(inv_acct, amt).with_description("Inventory"),
294                GlPostLine::credit(h.grir_account_id, amt).with_description("GR/IR clearing"),
295            ],
296        };
297        self.emit_and_reconcile(GlVoucher::PurchaseReceipt, id, &env, sink, amt).await
298    }
299}