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}