Skip to main content

backbone_inventory/application/service/
inventory_batch.rs

1//! The picking-batch surface (hand-authored, user-owned) — the stock.picking.batch
2//! satellite (spec misc-features.md SB).
3//!
4//! A batch GROUPS pickings (a wave-picking work list). Its `state` is a PROJECTION
5//! derived from its member pickings, exactly the way a transfer's state is derived from
6//! its member moves (the T1 discipline): there is NO per-batch state mutation code in
7//! this file — no hand-set batch state machine, no batch-state column writes. `create_batch`
8//! mints a draft header; the membership verbs move the `transfers.batch_id` pointer and
9//! then RE-DERIVE the projection; the engine's picking projection cascades the same
10//! recompute on every member-state change (the batch never lags its members). The probes
11//! READ the projected state.
12//!
13//! Membership is one-batch-per-picking (`transfers.batch_id`), the effective Odoo shape:
14//! a picking leaves its batch by clearing the pointer — which re-derives both sides.
15//!
16//! Deliberate deviations from Odoo's `_sanitize` behavior, both documented here and in
17//! the schema description: a picking may only JOIN while non-terminal (`done`/`cancel`
18//! refuse with the typed error rather than being silently dragged in), and a batch in a
19//! terminal state refuses membership CHANGES (`BatchTerminal`) rather than cancelling
20//! its members out from under the operator. Removal is always allowed — detaching a done
21//! or cancelled picking from its batch is how a list gets cleaned up.
22//!
23//! Per the module's 4-layer rule this file holds no SQL — statements live on
24//! [`super::super::super::infrastructure::persistence::PickingBatchProjectionRepository`].
25//!
26//! Tenancy (ADR-0029): the module is tenant-agnostic. Every write transaction this file
27//! opens re-binds the caller's ambient org scope (`relay_ambient_scope`); the composing
28//! decorator owns isolation.
29
30use uuid::Uuid;
31
32use crate::infrastructure::persistence::{BatchHeaderRow, BatchMemberRow, NewBatchRow};
33
34use super::inventory_write_service::{is_dup, relay_ambient_scope, InventoryError, InventoryWriteService};
35
36impl InventoryWriteService {
37    // ---- picking-batch: mint / membership / probe --------------------------------
38
39    /// Mint a batch: insert the header (a draft grouping point). `state` starts at
40    /// `draft` and is from here on ONLY ever written by the projection recompute — this
41    /// method never touches it. Guards: R3-shaped org-scoped name unique (the typed
42    /// duplicate error on collision).
43    pub async fn create_batch(
44        &self,
45        name: String,
46        is_wave: bool,
47        user_id: Option<Uuid>,
48    ) -> Result<BatchHeaderRow, InventoryError> {
49        let id = Uuid::new_v4();
50        let mut tx = self.rpool().begin().await?;
51        // Re-bind the caller's ambient org scope before the write (ADR-0029) — the
52        // composing decorator's fence arbiter turns a collision into the typed duplicate.
53        relay_ambient_scope(&mut tx).await?;
54        let ins = self.batches.insert_batch(&mut tx, &NewBatchRow {
55            id,
56            name: &name,
57            is_wave,
58            user_id,
59        }).await;
60        if let Err(e) = ins {
61            return Err(if is_dup(&e) { InventoryError::DuplicateNumber(name) } else { e.into() });
62        }
63        tx.commit().await?;
64        let (header, _) = self.fetch_batch(id).await?;
65        Ok(header)
66    }
67
68    /// The projection PROBE: read the batch header (with its projected state) and its
69    /// member pickings' states, riding the caller's ambient org scope (ADR-0029) — an
70    /// out-of-scope batch reads as absent under the composition's fence.
71    pub async fn fetch_batch(
72        &self,
73        batch_id: Uuid,
74    ) -> Result<(BatchHeaderRow, Vec<BatchMemberRow>), InventoryError> {
75        let mut tx = self.rpool().begin().await?;
76        relay_ambient_scope(&mut tx).await?;
77        let header = self.batches.fetch_batch(&mut tx, batch_id).await?
78            .ok_or(InventoryError::NotFound(batch_id))?;
79        let members = self.batches.fetch_members(&mut tx, batch_id).await?;
80        tx.commit().await?;
81        Ok((header, members))
82    }
83
84    /// Attach a picking to its batch. Guards (all typed, all service-side with the
85    /// DB trigger as backstop — `enforcement: both`, ADR-0015):
86    ///   - the batch exists (absent under the ambient scope reads as `NotFound`),
87    ///   - the batch is non-terminal (`done`/`cancel` refuse — `BatchTerminal`),
88    ///   - the picking exists and is non-terminal (`PickingTerminalForBatch`
89    ///     — a done or cancelled picking cannot join a work list),
90    ///   - the picking belongs to no other batch (`PickingAlreadyBatched` — membership is
91    ///     one batch per picking).
92    /// The write moves only the membership pointer and stamps the `had_members`
93    /// discriminator; the projection recompute that follows derives the state. Returns
94    /// the re-derived header (a READ of the projection).
95    pub async fn add_picking_to_batch(
96        &self,
97        batch_id: Uuid,
98        picking_id: Uuid,
99    ) -> Result<BatchHeaderRow, InventoryError> {
100        let mut tx = self.rpool().begin().await?;
101        relay_ambient_scope(&mut tx).await?;
102        let batch = self.batches.fetch_batch(&mut tx, batch_id).await?
103            .ok_or(InventoryError::NotFound(batch_id))?;
104        match batch.state.as_str() {
105            "draft" | "waiting" | "ready" => {}
106            other => return Err(InventoryError::BatchTerminal { batch_id, state: other.into() }),
107        }
108        let picking = self.pickings.fetch_transfer(&mut tx, picking_id).await?
109            .ok_or(InventoryError::NotFound(picking_id))?;
110        match picking.state.as_str() {
111            "draft" | "waiting" | "confirmed" | "partially_available" | "assigned" => {}
112            other => return Err(InventoryError::PickingTerminalForBatch {
113                picking_id, state: other.into(),
114            }),
115        }
116        if let Some(other) = self.batches.picking_membership(&mut tx, picking_id).await?
117            .flatten()
118        {
119            return Err(InventoryError::PickingAlreadyBatched { picking_id, batch_id: other });
120        }
121        self.batches.add_member(&mut tx, batch_id, picking_id).await?;
122        self.batches.reproject_batch(&mut tx, batch_id).await?;
123        tx.commit().await?;
124        let (header, _) = self.fetch_batch(batch_id).await?;
125        Ok(header)
126    }
127
128    /// Detach a picking from its batch. Removal is always allowed — including a done or
129    /// cancelled picking (that is how a finished list gets cleaned up). Detaching the
130    /// last member leaves an EMPTY batch, which the projection then auto-cancels (the
131    /// SB-1 discriminator: a batch that has grouped work must not linger as a live work
132    /// item once emptied). Returns the re-derived header after the removal.
133    pub async fn remove_picking_from_batch(
134        &self,
135        batch_id: Uuid,
136        picking_id: Uuid,
137    ) -> Result<BatchHeaderRow, InventoryError> {
138        let mut tx = self.rpool().begin().await?;
139        relay_ambient_scope(&mut tx).await?;
140        self.batches.fetch_batch(&mut tx, batch_id).await?
141            .ok_or(InventoryError::NotFound(batch_id))?;
142        let removed = self.batches.remove_member(&mut tx, batch_id, picking_id).await?;
143        if !removed {
144            return Err(InventoryError::NotABatchMember { batch_id, picking_id });
145        }
146        self.batches.reproject_batch(&mut tx, batch_id).await?;
147        tx.commit().await?;
148        let (header, _) = self.fetch_batch(batch_id).await?;
149        Ok(header)
150    }
151}