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}