Skip to main content

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}