backbone_inventory/application/service/inventory_transfer.rs
1//! The transfer/picking surface (hand-authored, user-owned).
2//!
3//! Two write paths live here, both converged onto the ONE stock-move engine
4//! ([`super::inventory_move_engine`]):
5//!
6//! **Picking-as-projection (spec stock-business-logic.md §2 / §12 T1, ADR-0016).** The
7//! transfer document is a PROJECTION: `transfers.state` is re-derived from its member move
8//! states by the engine on EVERY move change — there is NO per-picking state mutation code in
9//! this file (or anywhere in the lane): no hand-set transfer state machine, no transfer-state
10//! column writes. `create_picking` mints the header + DRAFT moves through the engine and
11//! confirms them; `validate_picking` is Odoo's `button_validate` — `_action_done` over the
12//! transfer's moves; the probes READ the projected state. Guards: R3 name unique (the
13//! composing decorator's org-scoped arbiter), R24 done needs lines (engine-enforced at move
14//! grain).
15//!
16//! **The stock-entry voucher (warehouse-to-warehouse move), re-wired onto move minting.**
17//! The voucher surface keeps its identity verbatim — header + item rows, the `StockMoved`
18//! event, the value-neutral no-GL contract — but the physical/valuation legs are now minted
19//! by the engine pipeline: one move per line, confirmed, reserved against the source quants,
20//! and validated (`_action_done`), which flips the quants (two-step sync) and mints the V7
21//!-ordered SLE pair through the moving-average Bin reblende (OUT valued before, IN after).
22//!
23//! Per the module's 4-layer rule this file holds no SQL — statements live on
24//! [`super::super::super::infrastructure::persistence::StockPickingRepository`] (header mint,
25//! probes, location resolution, the quant-surface heal) and the engine's repositories; every
26//! engine verb runs its own guarded transaction, so a move's quant flips, SLE rows, and GL leg
27//! commit as one unit.
28
29use rust_decimal::Decimal;
30use uuid::Uuid;
31
32use crate::infrastructure::persistence::{
33 NewMoveLineRow, NewPickingRow, NewStockEntryItemRow, NewTransferRow, ProcurementRepository,
34};
35
36use super::inventory_events::{InventoryEvent, StockMoved};
37use super::inventory_gl::{AccountingPostEnvelope, GlPostAck, GlPostRejected, GlPostSink};
38use super::inventory_move_engine::{BackorderPolicy, MoveGlDirective, NewStockMove};
39use super::inventory_write_service::{
40 is_dup, legacy_company_echo, relay_ambient_scope, InventoryError, InventoryWriteService,
41 NewTransfer,
42};
43
44/// The GL sink of the value-neutral transfer door. A warehouse-to-warehouse move posts NO GL
45/// (the W1 contract), so the engine's directive carried into `_action_done` never produces an
46/// envelope and this sink is never invoked. It exists so the door cannot SILENTLY post — if the
47/// valuation shape ever changes to emit one, the loud rejection surfaces it instead of a phantom
48/// journal entry nobody asked for.
49struct ValueNeutralDoorSink;
50#[async_trait::async_trait]
51impl GlPostSink for ValueNeutralDoorSink {
52 async fn post(&self, _e: &AccountingPostEnvelope) -> Result<GlPostAck, GlPostRejected> {
53 Err(GlPostRejected {
54 code: "no_gl_on_transfer_door".into(),
55 message: "warehouse-to-warehouse moves are value-neutral and post no GL".into(),
56 })
57 }
58}
59
60/// One line of a picking (the member move's demand).
61#[derive(Debug, Clone)]
62pub struct PickingLine {
63 pub item_id: Uuid,
64 pub demand_qty: Decimal,
65 /// Unit valuation price for external IN legs (0 = carry the current average).
66 pub price_unit: Decimal,
67}
68
69/// The picking-mint input. `name` is the transfer reference (unique within the composing
70/// tenant's scope — R3).
71#[derive(Debug, Clone)]
72pub struct NewPicking {
73 pub name: String,
74 pub picking_type_id: Uuid,
75 pub location_id: Uuid,
76 pub location_dest_id: Uuid,
77 pub partner_id: Option<Uuid>,
78 /// direct (ship as available) / one (ship all at once).
79 pub move_type: String,
80 pub origin: Option<String>,
81 pub lines: Vec<PickingLine>,
82}
83
84/// The picking mint outcome: the header id, the member move ids, and the PROJECTED state
85/// after every move minted + confirmed (a READ of the projection, never an assertion).
86#[derive(Debug, Clone)]
87pub struct PickingCreated {
88 pub transfer_id: Uuid,
89 pub move_ids: Vec<Uuid>,
90 pub projected_state: String,
91}
92
93/// The `button_validate` outcome: per-move results plus the transfer's final projected state
94/// (READ — the projection is what the moves made it, never what this method wanted).
95#[derive(Debug, Clone)]
96pub struct PickingValidated {
97 pub transfer_id: Uuid,
98 pub projected_state: String,
99 pub validated_moves: Vec<super::inventory_move_engine::MoveDoneOutcome>,
100}
101
102/// The picking-assignment outcome: the transfer the move now belongs to, and whether this
103/// call MINTED that transfer or JOINED an already-open one.
104#[derive(Debug, Clone)]
105pub struct PickingAssignment {
106 pub transfer_id: Uuid,
107 pub minted: bool,
108}
109
110impl InventoryWriteService {
111 // ---- picking-as-projection: mint / probe / validate ------------------------
112
113 /// Mint a picking: insert the transfer header, then mint one DRAFT move per line through
114 /// the engine and confirm each (the operation type's reservation posture fires with
115 /// confirm — `at_confirm` reserves immediately). Every mint/confirm REPROJECTS the
116 /// transfer (the engine does it on every move change — this method never touches
117 /// `transfers.state`). Guards: R3 (name unique — the typed duplicate error), R9 (source
118 /// != destination), non-empty, non-negative demand.
119 ///
120 /// Not cross-move atomic: each engine verb commits its own transaction. A failure
121 /// mid-way leaves the transfer projected at whatever its minted moves say (typically
122 /// `draft`/`confirmed`) — a probe-able, retryable state, never a half-flipped stock
123 /// position (stock only moves on `validate_picking`).
124 pub async fn create_picking(&self, p: NewPicking) -> Result<PickingCreated, InventoryError> {
125 if p.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
126 if p.location_id == p.location_dest_id {
127 return Err(InventoryError::SameLocation { move_id: Uuid::new_v4(), location_id: p.location_id });
128 }
129 for l in &p.lines {
130 if l.demand_qty < Decimal::ZERO || l.price_unit < Decimal::ZERO {
131 return Err(InventoryError::NegativeQuantity);
132 }
133 }
134 let id = Uuid::new_v4();
135 let mut tx = self.rpool().begin().await?;
136 // Re-bind the caller's ambient org scope before any read (ADR-0029) — the scope is
137 // task-local and a fresh pool transaction carries none of it; undecorated (module
138 // tests, jobs) the transaction stays plain.
139 relay_ambient_scope(&mut tx).await?;
140 let op = self.pickings.fetch_operation_type(&mut tx, p.picking_type_id).await?
141 .ok_or(InventoryError::NotFound(p.picking_type_id))?;
142 // The same structural pre-check the engine's move mint applies (R13: view locations
143 // hold no stock) — run it BEFORE the header insert so a bad location pair cannot
144 // leave an orphaned transfer.
145 let locs = self.moves.fetch_move_locations(&mut tx, p.location_id, p.location_dest_id).await?;
146 let (src, dst) = match locs {
147 (Some(s), Some(d)) => (s, d),
148 (None, _) => return Err(InventoryError::LocationNotFound(p.location_id)),
149 (_, None) => return Err(InventoryError::LocationNotFound(p.location_dest_id)),
150 };
151 for loc in [&src, &dst] {
152 if loc.usage == "view" {
153 return Err(InventoryError::ViewLocationHoldsNoStock { location_id: loc.id });
154 }
155 }
156 let ins = self.pickings.insert_transfer(&mut tx, &NewPickingRow {
157 id,
158 name: &p.name,
159 origin: p.origin.as_deref(),
160 picking_type_id: p.picking_type_id,
161 location_id: p.location_id,
162 location_dest_id: p.location_dest_id,
163 partner_id: p.partner_id,
164 move_type: &p.move_type,
165 }).await;
166 if let Err(e) = ins {
167 return Err(if is_dup(&e) { InventoryError::DuplicateNumber(p.name) } else { e.into() });
168 }
169 tx.commit().await?;
170
171 let mut move_ids = Vec::with_capacity(p.lines.len());
172 for (idx, l) in p.lines.iter().enumerate() {
173 let mid = self.create_move(NewStockMove {
174 name: format!("{}/{}", p.name, idx + 1),
175 item_id: l.item_id,
176 demand_qty: l.demand_qty,
177 price_unit: l.price_unit,
178 procure_method: "make_to_stock".into(),
179 picking_id: Some(id),
180 origin: p.origin.clone(),
181 location_id: p.location_id,
182 location_dest_id: p.location_dest_id,
183 partner_id: p.partner_id,
184 warehouse_id: src.warehouse_id,
185 orderpoint_id: None,
186 move_orig_ids: vec![],
187 move_dest_ids: vec![],
188 is_inventory: false,
189 scrapped: false,
190 forced_value: None, // ordinary demand: the valuation core derives the carry
191 }).await?;
192 self.action_confirm(mid).await?;
193 // The operation type's reservation posture: `at_confirm` (the generated default)
194 // reserves what is available right away — the picking projects to
195 // assigned / partially_available / confirmed accordingly. `manual` / `by_date`
196 // leave reservation to the scheduler or the operator.
197 if op.reservation_method == "at_confirm" {
198 let _ = self.action_assign(mid).await?;
199 }
200 move_ids.push(mid);
201 }
202 let projected = self.fetch_picking(id).await?;
203 Ok(PickingCreated { transfer_id: id, move_ids, projected_state: projected.0.state })
204 }
205
206 /// The projection PROBE: read the transfer header (with its projected state) and the
207 /// member move states. The state is a stored compute the engine re-derived — callers read
208 /// it, they never assert it. The read rides the caller's ambient org scope (ADR-0029):
209 /// under the composed shape the decorator's fence bounds it; undecorated (module tests,
210 /// jobs) it is plain.
211 pub async fn fetch_picking(
212 &self,
213 transfer_id: Uuid,
214 ) -> Result<(crate::infrastructure::persistence::TransferHeaderRow,
215 Vec<crate::infrastructure::persistence::MoveStateRow>), InventoryError> {
216 let mut tx = self.rpool().begin().await?;
217 relay_ambient_scope(&mut tx).await?;
218 let header = self.pickings.fetch_transfer(&mut tx, transfer_id).await?
219 .ok_or(InventoryError::NotFound(transfer_id))?;
220 let moves = self.pickings.fetch_moves_of_transfer(&mut tx, transfer_id).await?;
221 tx.commit().await?;
222 Ok((header, moves))
223 }
224
225 /// `_assign_picking`: attach a rule-launched move to its grouping transfer — the hop that
226 /// carries a demand launched through the procurement engine (`run_procurement` / `_run_pull`
227 /// mint moves with no transfer) onto the picking surface an operator validates. The move's
228 /// RULE names the operation type; the group key is (operation type, source, destination,
229 /// partner, origin) with `origin` standing in for the procurement group — every move
230 /// launched for one source document joins ONE transfer, and a transfer stays open for
231 /// later lines of the same document. An open transfer that matches is joined; otherwise a
232 /// header is minted (name from the operation type's reference prefix, unique within the
233 /// composing tenant's scope; shipping policy inherited from the operation type). The
234 /// projection then re-derives from the member move states, exactly as it does on every
235 /// move change.
236 ///
237 /// Idempotent: a move that already belongs to a transfer returns it (`minted: false`).
238 /// Fail-closed on a move with no procurement rule (voucher-door moves keep their voucher
239 /// identity — their door mints the moves and owns the GL, and no picking is derived for
240 /// them here) and on terminal states (`done` / `cancel` never re-group).
241 pub async fn assign_picking(
242 &self,
243 move_id: Uuid,
244 ) -> Result<PickingAssignment, InventoryError> {
245 let mut tx = self.rpool().begin().await?;
246 // The read rides the caller's ambient org scope (ADR-0029): under the composed shape
247 // the decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
248 relay_ambient_scope(&mut tx).await?;
249 let mv = self.moves.fetch_move(&mut tx, move_id).await?
250 .ok_or(InventoryError::NotFound(move_id))?;
251 match mv.state.as_str() {
252 "draft" | "waiting" | "confirmed" | "partially_available" | "assigned" => {}
253 other => return Err(InventoryError::WrongMoveState {
254 move_id, action: "assign_picking", current: other.into(),
255 }),
256 }
257 if let Some(existing) = mv.picking_id {
258 tx.commit().await?;
259 return Ok(PickingAssignment { transfer_id: existing, minted: false });
260 }
261 // The rule names the operation type; without one there is nothing to derive a
262 // grouping transfer from (refuse rather than guess a type).
263 let rule_id = mv.rule_id.ok_or(InventoryError::MoveHasNoRule { move_id })?;
264 let picking_type_id = ProcurementRepository::rule_picking_type(&mut *tx, rule_id).await?
265 .ok_or(InventoryError::NotFound(rule_id))?;
266 let op = self.pickings.fetch_operation_type(&mut tx, picking_type_id).await?
267 .ok_or(InventoryError::NotFound(picking_type_id))?;
268
269 let (transfer_id, minted) = match self.pickings.find_open_group_picking(
270 &mut tx, picking_type_id,
271 mv.location_id, mv.location_dest_id, mv.partner_id, mv.origin.as_deref(),
272 ).await? {
273 Some(open) => (open, false),
274 None => {
275 let id = Uuid::new_v4();
276 // The operation type's reference prefix (e.g. `IN/`, `WH/OUT/`) plus a short
277 // unique suffix — the name is unique (the composed decorator's org-scoped
278 // arbiter); a collision is the typed duplicate error the caller can retry.
279 let prefix = op.sequence_code.trim_end_matches('/');
280 let prefix = if prefix.is_empty() { "PICK" } else { prefix };
281 let name = format!("{}/{}", prefix, &Uuid::new_v4().simple().to_string()[..8].to_uppercase());
282 let ins = self.pickings.insert_transfer(&mut tx, &NewPickingRow {
283 id,
284 name: &name,
285 origin: mv.origin.as_deref(),
286 picking_type_id,
287 location_id: mv.location_id,
288 location_dest_id: mv.location_dest_id,
289 partner_id: mv.partner_id,
290 move_type: &op.move_type,
291 }).await;
292 if let Err(e) = ins {
293 return Err(if is_dup(&e) { InventoryError::DuplicateNumber(name) } else { e.into() });
294 }
295 (id, true)
296 }
297 };
298 self.moves.set_picking(&mut tx, move_id, transfer_id).await?;
299 self.move_lines.retarget_picking(&mut tx, move_id, transfer_id).await?;
300 self.moves.reproject_picking(&mut tx, transfer_id).await?;
301 tx.commit().await?;
302 Ok(PickingAssignment { transfer_id, minted })
303 }
304
305 /// `button_validate` (spec §2): `_action_done` over the transfer's moves — nothing else.
306 /// Done/cancelled member moves are skipped (idempotent re-validate). For each live move:
307 /// the quant surface at its source grain is healed if the stock predates the converged
308 /// model (Bins without quants); unreserved internal moves get an `assign` pass first (it
309 /// mints the reservation mirror lines), and a move still without lines — nothing
310 /// reservable, or an inbound move whose source is virtual — gets its demand line minted
311 /// as the physical detail row so `done` has lines (R24); `_action_done` then flips the
312 /// quants, mints the V7-ordered SLE pair, and posts the GL leg the move's shape calls
313 /// for. The partial-validate backorder policy comes from the operation type (`ask` maps
314 /// to creating the backorder — the non-interactive default of Odoo's wizard; `delayed`
315 /// creates it too but leaves its reservation to the scheduler's assign sweep).
316 ///
317 /// Posting a GL leg needs the composing service's `GlPostSink`; a leg whose accounts the
318 /// directive lacks is simply not posted (the physical movement is unaffected).
319 pub async fn validate_picking(
320 &self,
321 transfer_id: Uuid,
322 gl: &MoveGlDirective,
323 sink: &dyn GlPostSink,
324 ) -> Result<PickingValidated, InventoryError> {
325 let move_ids = {
326 let mut tx = self.rpool().begin().await?;
327 relay_ambient_scope(&mut tx).await?;
328 self.pickings.fetch_transfer(&mut tx, transfer_id).await?
329 .ok_or(InventoryError::NotFound(transfer_id))?;
330 let ids = self.pickings.move_ids_of_transfer(&mut tx, transfer_id).await?;
331 tx.commit().await?;
332 ids
333 };
334
335 let op_backorder = self.operation_backorder_policy(transfer_id).await?;
336 let mut outcomes = Vec::new();
337 for mid in move_ids {
338 let mv = {
339 let mut tx = self.rpool().begin().await?;
340 relay_ambient_scope(&mut tx).await?;
341 let mv = self.moves.fetch_move(&mut tx, mid).await?.ok_or(InventoryError::NotFound(mid))?;
342 tx.commit().await?;
343 mv
344 };
345 if matches!(mv.state.as_str(), "done" | "cancel") { continue; }
346 if mv.state == "draft" {
347 return Err(InventoryError::WrongMoveState { move_id: mid, action: "validate", current: mv.state.clone() });
348 }
349 self.prepare_move_for_validate(&mv).await?;
350 outcomes.push(self.action_done(mid, op_backorder, gl, sink).await?);
351 }
352 let header = self.fetch_picking(transfer_id).await?;
353 Ok(PickingValidated {
354 transfer_id,
355 projected_state: header.0.state,
356 validated_moves: outcomes,
357 })
358 }
359
360 /// The partial-validate backorder policy of the transfer's operation type (`ask` maps to
361 /// `Always` — the non-interactive default; a wizard is not available on this surface;
362 /// `delayed` mints the backorder but defers its reservation to the scheduler's assign
363 /// sweep).
364 async fn operation_backorder_policy(
365 &self,
366 transfer_id: Uuid,
367 ) -> Result<BackorderPolicy, InventoryError> {
368 let picking_type_id = {
369 let mut tx = self.rpool().begin().await?;
370 relay_ambient_scope(&mut tx).await?;
371 let header = self.pickings.fetch_transfer(&mut tx, transfer_id).await?
372 .ok_or(InventoryError::NotFound(transfer_id))?;
373 tx.commit().await?;
374 header.picking_type_id
375 };
376 let facts = {
377 let mut tx = self.rpool().begin().await?;
378 relay_ambient_scope(&mut tx).await?;
379 let f = self.pickings.fetch_operation_type(&mut tx, picking_type_id).await
380 .map_err(InventoryError::from)?
381 .ok_or(InventoryError::NotFound(picking_type_id))?;
382 tx.commit().await?;
383 f
384 };
385 Ok(match facts.create_backorder.as_str() {
386 "never" => BackorderPolicy::Never,
387 "delayed" => BackorderPolicy::Delayed,
388 _ => BackorderPolicy::Always,
389 })
390 }
391
392 /// Get a live move ready for `_action_done`: heal the source quant surface when the
393 /// stock predates the converged model, then make sure the move HAS lines (R24) — an
394 /// `assign` pass for internal sources (the reservation mirror), a minted demand line for
395 /// everything else. `pub(super)`: the scrap door drives the same preparation for its
396 /// move (a scrap's source is always an internal stock location).
397 pub(super) async fn prepare_move_for_validate(
398 &self,
399 mv: &crate::infrastructure::persistence::MoveRow,
400 ) -> Result<(), InventoryError> {
401 let mut tx = self.rpool().begin().await?;
402 relay_ambient_scope(&mut tx).await?;
403 let locs = self.moves.fetch_move_locations(&mut tx, mv.location_id, mv.location_dest_id).await?;
404 let src = locs.0.ok_or(InventoryError::LocationNotFound(mv.location_id))?;
405 // The quant-surface heal: legacy voucher paths wrote Bins without quants; the first
406 // converged touch of the grain seeds the quant from the Bin (one time, idempotent).
407 if src.usage == "internal" {
408 self.pickings.ensure_quant_surface(&mut tx, mv.item_id, src.id, src.warehouse_id).await?;
409 }
410 tx.commit().await?;
411
412 let lines = {
413 let mut tx = self.rpool().begin().await?;
414 relay_ambient_scope(&mut tx).await?;
415 let l = self.move_lines.fetch_lines_for_move(&mut tx, mv.id).await?;
416 tx.commit().await?;
417 l
418 };
419 if !lines.is_empty() { return Ok(()); }
420 if src.usage == "internal" {
421 // Reserve what is available — this mints the reservation mirror lines. Whatever
422 // the reservation could NOT cover is the BACKORDER's demand, never a phantom
423 // line here: the done verb's R22 draw guard refuses a draw the source quant
424 // cannot cover (you cannot ship stock that is not there), and its backorder
425 // split mints the unreserved remainder as its own draft move on the same
426 // picking. A move with nothing reservable stays lineless and the done verb
427 // refuses it loudly (R24) — the transfer stays open below `done`.
428 self.action_assign(mv.id).await?;
429 } else {
430 self.mint_demand_line(mv, mv.demand_qty).await?;
431 }
432 Ok(())
433 }
434
435 /// Mint the physical detail line for a move's demand (the R24 floor: a done move carries
436 /// at least one line). Used for untracked inbound moves (virtual source — nothing to
437 /// reserve) and for the unreservable remainder of an internal move; `_action_done`
438 /// enforces the draw guards (R22/R23) on whatever the lines claim.
439 async fn mint_demand_line(
440 &self,
441 mv: &crate::infrastructure::persistence::MoveRow,
442 qty: Decimal,
443 ) -> Result<(), InventoryError> {
444 if qty <= Decimal::ZERO { return Ok(()); }
445 let mut tx = self.rpool().begin().await?;
446 relay_ambient_scope(&mut tx).await?;
447 self.move_lines.insert_line(&mut tx, &NewMoveLineRow {
448 id: Uuid::new_v4(),
449 quantity: qty,
450 lot_id: None,
451 package_id: None,
452 result_package_id: None,
453 owner_id: None,
454 move_id: mv.id,
455 picking_id: mv.picking_id,
456 location_id: mv.location_id,
457 location_dest_id: mv.location_dest_id,
458 item_id: mv.item_id,
459 state: mv.state.as_str(),
460 }).await?;
461 tx.commit().await?;
462 Ok(())
463 }
464
465 // ---- submit: Stock Entry (transfer — value-neutral, no GL), engine-routed --------------
466
467 /// The warehouse-to-warehouse stock entry, converged: the voucher rows keep their exact
468 /// identity (header, item lines, the `StockMoved` event, value-neutral no-GL posture),
469 /// while each line's physical/valuation legs are minted by the move pipeline — one move
470 /// per line, confirmed, reserved against the source quants, validated. The engine's
471 /// valuation core produces the W1-proven arithmetic: the OUT leg valued at the source
472 /// Bin's pre-move average BEFORE the destination reblende (V7 ordering), the residual
473 /// flush when a Bin drains to zero, value conserved across the pair.
474 pub async fn submit_transfer(&self, t: NewTransfer) -> Result<Uuid, InventoryError> {
475 if t.lines.is_empty() { return Err(InventoryError::EmptyDocument); }
476 if t.from_warehouse_id == t.to_warehouse_id { return Err(InventoryError::SameWarehouse); }
477 for l in &t.lines { if l.quantity < Decimal::ZERO { return Err(InventoryError::NegativeQuantity); } }
478
479 let id = Uuid::new_v4();
480 let mut tx = self.rpool().begin().await?;
481 // Re-bind the caller's ambient org scope before any read (ADR-0029) — under the
482 // composed shape the decorator's fence bounds the bin read below; undecorated
483 // (module tests, jobs) the transaction stays plain.
484 relay_ambient_scope(&mut tx).await?;
485 // The warehouses' stock locations (bootstrapped per warehouse on first use — the
486 // picking/move grain is the location, the voucher grain was the warehouse).
487 let from_loc = self.pickings.ensure_internal_location(&mut tx, t.from_warehouse_id).await?;
488 let to_loc = self.pickings.ensure_internal_location(&mut tx, t.to_warehouse_id).await?;
489 let ins = self.entries.insert_transfer(&mut tx, &NewTransferRow {
490 id,
491 entry_number: &t.entry_number,
492 from_warehouse_id: t.from_warehouse_id,
493 to_warehouse_id: t.to_warehouse_id,
494 posting_date: t.posting_date,
495 }).await;
496 if let Err(e) = ins {
497 return Err(if is_dup(&e) { InventoryError::DuplicateNumber(t.entry_number) } else { e.into() });
498 }
499 for l in &t.lines {
500 self.entry_items.insert_item(&mut tx, &NewStockEntryItemRow {
501 id: Uuid::new_v4(),
502 entry_id: id,
503 item_id: l.item_id,
504 quantity: l.quantity,
505 }).await?;
506 // The all-or-nothing pre-check of the voucher path, kept verbatim: the source Bin
507 // must cover the line before anything mints (the engine's draw guard re-checks at
508 // the quant grain).
509 let from = self.bins.lock_or_init(&mut tx, l.item_id, t.from_warehouse_id).await?;
510 if from.actual_qty < l.quantity {
511 return Err(InventoryError::InsufficientStock {
512 item_id: l.item_id, warehouse_id: t.from_warehouse_id,
513 available: from.actual_qty, requested: l.quantity,
514 });
515 }
516 }
517 tx.commit().await?;
518
519 for l in &t.lines {
520 let mid = self.create_move(NewStockMove {
521 name: format!("{}/{}", t.entry_number, l.item_id.simple()),
522 item_id: l.item_id,
523 demand_qty: l.quantity,
524 price_unit: Decimal::ZERO, // internal move: value carried, not priced
525 procure_method: "make_to_stock".into(),
526 picking_id: None,
527 origin: Some(t.entry_number.clone()),
528 location_id: from_loc,
529 location_dest_id: to_loc,
530 partner_id: None,
531 warehouse_id: Some(t.from_warehouse_id),
532 orderpoint_id: None,
533 move_orig_ids: vec![],
534 move_dest_ids: vec![],
535 is_inventory: false,
536 scrapped: false,
537 forced_value: None, // ordinary demand: the valuation core derives the carry
538 }).await?;
539 self.action_confirm(mid).await?;
540 // Reserve from the (healed) source quant — mints the execution line — then
541 // validate. The voucher door stays all-or-nothing per line: no backorder.
542 self.prepare_move_for_validate(&(self.fetch_move_row(mid).await?)).await?;
543 self.action_done(mid, BackorderPolicy::Never, &MoveGlDirective::default(), &ValueNeutralDoorSink).await?;
544 }
545 self.sink.publish(InventoryEvent::StockMoved(StockMoved {
546 entry_id: id,
547 // Legacy twin (ADR-0029): filled from the ambient org scope's company echo for
548 // consumers that still read a tenant off the wire. No module statement keys on it.
549 company_id: legacy_company_echo(),
550 from_warehouse_id: Some(t.from_warehouse_id), to_warehouse_id: Some(t.to_warehouse_id),
551 }));
552 Ok(id)
553 }
554
555 /// Fetch one move row — a thin read for the voucher rewire's prepare step. The read
556 /// rides the caller's ambient org scope (ADR-0029): under the composed shape the
557 /// decorator's fence bounds it; undecorated (module tests, jobs) it is plain.
558 async fn fetch_move_row(
559 &self,
560 move_id: Uuid,
561 ) -> Result<crate::infrastructure::persistence::MoveRow, InventoryError> {
562 let mut tx = self.rpool().begin().await?;
563 relay_ambient_scope(&mut tx).await?;
564 let mv = self.moves.fetch_move(&mut tx, move_id).await?
565 .ok_or(InventoryError::NotFound(move_id))?;
566 tx.commit().await?;
567 Ok(mv)
568 }
569}