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}