Skip to main content

backbone_inventory/infrastructure/persistence/
stock_picking_repository.rs

1//! Picking-as-projection SQL (hand-authored, user-owned).
2//!
3//! Holds every statement for the converged transfer/picking surface that is NOT already owned by
4//! the move engine's repositories:
5//!   - the transfer (picking) header mint — the picking groups draft moves; it is NEVER a state
6//!     machine of its own (spec stock-business-logic.md §2 / §12 T1, ADR-0016 `projection`),
7//!   - the projection PROBE reads (header + member move states): the projected
8//!     `transfers.state` is READ here, never asserted or written — the only writer in the
9//!     module is the move engine's recompute, fired on every move-state change
10//!     (`StockMoveRepository::reproject_picking`),
11//!   - location resolution helpers the adjustment door shares (warehouse stock location,
12//!     inventory-loss location),
13//!   - the quant-surface backfill (a warehouse whose stock was seeded through the legacy
14//!     voucher paths has Bin rows but no quant rows; converged moves draw from quants, so the
15//!     first converged touch of a grain heals the quant from the Bin — a loud, one-time-per-
16//!     grain catch-up, never an ongoing second writer),
17//!   - adjustment-move detection: the ONE adjustment door's mint-once gate (an existing live
18//!     `is_inventory` move for the grain is resumed, a done one newer than the count means the
19//!     count already applied).
20//!
21//! State values are snake_case; enum parameters are bound as text with explicit casts and read
22//! back with `::text` (the module-wide enum lesson). Per the module's 4-layer rule the
23//! statements live here and take the caller's connection; services orchestrate.
24//!
25//! Tenancy is composition-installed (ADR-0029): the module is tenant-agnostic. Every statement
26//! here is ID- or grain-keyed only; the composing service's decorator owns org scoping (the
27//! fence bounds reads, the fill trigger stamps writes), so no statement keys on tenancy.
28
29use rust_decimal::Decimal;
30use sqlx::{PgConnection, Row};
31use uuid::Uuid;
32
33/// State of one member move, as the projection probe reads it.
34pub struct MoveStateRow {
35    pub id: Uuid,
36    pub item_id: Uuid,
37    pub state: String,
38    pub demand_qty: Decimal,
39    pub quantity: Decimal,
40    pub is_inventory: bool,
41}
42
43/// Header fields of one transfer (the projection probe surface).
44pub struct TransferHeaderRow {
45    pub id: Uuid,
46    pub name: String,
47    pub origin: Option<String>,
48    pub picking_type_id: Uuid,
49    pub location_id: Uuid,
50    pub location_dest_id: Uuid,
51    pub state: String,
52    pub date_done: Option<chrono::DateTime<chrono::Utc>>,
53}
54
55/// Facts of an operation type the picking mint/validate needs: the picking code (which GL leg
56/// shape its moves post), the reservation posture, the partial-validate backorder policy, the
57/// reference prefix its transfer names mint from, and its default shipping policy.
58pub struct OperationTypeFacts {
59    pub id: Uuid,
60    /// incoming / outgoing / internal (as text).
61    pub code: String,
62    /// at_confirm / manual / by_date (as text).
63    pub reservation_method: String,
64    /// ask / always / never (as text).
65    pub create_backorder: String,
66    /// Reference prefix for transfer names (e.g. `IN/`, `WH/OUT/`) — the vocabulary a minted
67    /// picking's name starts from.
68    pub sequence_code: String,
69    /// direct (ship as available) / one (ship all at once) — the shipping policy a minted
70    /// picking inherits from its operation type.
71    pub move_type: String,
72}
73
74/// An existing `is_inventory` move addressing a quant grain, as the adjustment door's
75/// mint-once gate reads it.
76pub struct AdjustmentMoveRow {
77    pub id: Uuid,
78    /// draft / waiting / confirmed / partially_available / assigned / done / cancel (as text).
79    pub state: String,
80    /// Effective date — stamped to the processing instant when the move hit `done`.
81    pub date: Option<chrono::DateTime<chrono::Utc>>,
82}
83
84/// A transfer header insert. `state` starts at `draft`; member moves are minted through the
85/// engine right after, and every mint/transition reprojects — so the stored value is always
86/// the projection of the member move states.
87pub struct NewPickingRow<'a> {
88    pub id: Uuid,
89    pub name: &'a str,
90    pub origin: Option<&'a str>,
91    pub picking_type_id: Uuid,
92    pub location_id: Uuid,
93    pub location_dest_id: Uuid,
94    pub partner_id: Option<Uuid>,
95    pub move_type: &'a str,
96}
97
98/// The picking mint/probe/heal SQL. Stateless — every method takes the caller's connection so
99/// the header, its moves, and the projection commit as one unit (moves themselves are minted
100/// through the engine's own guarded transactions).
101#[derive(Default)]
102pub struct StockPickingRepository;
103
104impl StockPickingRepository {
105    pub fn new() -> Self {
106        Self
107    }
108
109    /// Insert the transfer header. Leaks the raw `sqlx::Error` deliberately so the service can
110    /// turn a unique violation on the name — the composing decorator's org-scoped arbiter —
111    /// into a typed duplicate-name error.
112    pub async fn insert_transfer(
113        &self,
114        conn: &mut PgConnection,
115        t: &NewPickingRow<'_>,
116    ) -> Result<(), sqlx::Error> {
117        sqlx::query(
118            r#"INSERT INTO inventory.transfers
119                 (id, name, origin, priority, picking_type_id, location_id, location_dest_id,
120                  partner_id, move_type, scheduled_date, state)
121               VALUES ($1, $2, $3, 'normal', $4, $5, $6, $7, $8::move_type, NOW(),
122                       'draft'::transfer_state)"#,
123        )
124        .bind(t.id)
125        .bind(t.name)
126        .bind(t.origin)
127        .bind(t.picking_type_id)
128        .bind(t.location_id)
129        .bind(t.location_dest_id)
130        .bind(t.partner_id)
131        .bind(t.move_type)
132        .execute(conn)
133        .await?;
134        Ok(())
135    }
136
137    /// Read one transfer header (probe surface — the projected state is READ here, never
138    /// asserted by callers).
139    pub async fn fetch_transfer(
140        &self,
141        conn: &mut PgConnection,
142        transfer_id: Uuid,
143    ) -> Result<Option<TransferHeaderRow>, sqlx::Error> {
144        let row = sqlx::query(
145            r#"SELECT id, name, origin, picking_type_id, location_id, location_dest_id,
146                      state::text AS state, date_done
147               FROM inventory.transfers
148               WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
149        )
150        .bind(transfer_id)
151        .fetch_optional(conn)
152        .await?;
153        Ok(row.map(|r| TransferHeaderRow {
154            id: r.get("id"),
155            name: r.get("name"),
156            origin: r.get("origin"),
157            picking_type_id: r.get("picking_type_id"),
158            location_id: r.get("location_id"),
159            location_dest_id: r.get("location_dest_id"),
160            state: r.get("state"),
161            date_done: r.get("date_done"),
162        }))
163    }
164
165    /// Read a transfer's member moves (the projection inputs, for the probe surface).
166    pub async fn fetch_moves_of_transfer(
167        &self,
168        conn: &mut PgConnection,
169        transfer_id: Uuid,
170    ) -> Result<Vec<MoveStateRow>, sqlx::Error> {
171        let rows = sqlx::query(
172            r#"SELECT id, item_id, state::text AS state, demand_qty, quantity, is_inventory
173               FROM inventory.stock_moves
174               WHERE picking_id = $1 AND (metadata->>'deleted_at') IS NULL
175               ORDER BY create_date, id"#,
176        )
177        .bind(transfer_id)
178        .fetch_all(conn)
179        .await?;
180        Ok(rows
181            .iter()
182            .map(|r| MoveStateRow {
183                id: r.get("id"),
184                item_id: r.get("item_id"),
185                state: r.get("state"),
186                demand_qty: r.get("demand_qty"),
187                quantity: r.get("quantity"),
188                is_inventory: r.get("is_inventory"),
189            })
190            .collect())
191    }
192
193    /// The ids of a transfer's member moves (the validate surface iterates these).
194    pub async fn move_ids_of_transfer(
195        &self,
196        conn: &mut PgConnection,
197        transfer_id: Uuid,
198    ) -> Result<Vec<Uuid>, sqlx::Error> {
199        let rows = sqlx::query(
200            r#"SELECT id FROM inventory.stock_moves
201               WHERE picking_id = $1 AND (metadata->>'deleted_at') IS NULL"#,
202        )
203        .bind(transfer_id)
204        .fetch_all(conn)
205        .await?;
206        Ok(rows.iter().map(|r| r.get("id")).collect())
207    }
208
209    /// The operation type's facts. `None` when the type does not exist or is archived.
210    pub async fn fetch_operation_type(
211        &self,
212        conn: &mut PgConnection,
213        operation_type_id: Uuid,
214    ) -> Result<Option<OperationTypeFacts>, sqlx::Error> {
215        let row = sqlx::query(
216            r#"SELECT id, code::text AS code, reservation_method::text AS reservation_method,
217                      create_backorder::text AS create_backorder, sequence_code,
218                      move_type::text AS move_type
219               FROM inventory.operation_types
220               WHERE id = $1 AND active AND (metadata->>'deleted_at') IS NULL"#,
221        )
222        .bind(operation_type_id)
223        .fetch_optional(conn)
224        .await?;
225        Ok(row.map(|r| OperationTypeFacts {
226            id: r.get("id"),
227            code: r.get("code"),
228            reservation_method: r.get("reservation_method"),
229            create_backorder: r.get("create_backorder"),
230            sequence_code: r.get("sequence_code"),
231            move_type: r.get("move_type"),
232        }))
233    }
234
235    /// The OPEN transfer a move would join when it is grouped for fulfillment: the most
236    /// recently scheduled transfer whose operation type, source, destination, partner, and
237    /// origin (the procurement-group stand-in — moves launched for one source document share
238    /// it) all match, and whose projection has not reached a terminal state (`done` picks
239    /// nothing more up; a `cancel` never reopens). `None` when no open transfer matches — the
240    /// caller mints one.
241    pub async fn find_open_group_picking(
242        &self,
243        conn: &mut PgConnection,
244        picking_type_id: Uuid,
245        location_id: Uuid,
246        location_dest_id: Uuid,
247        partner_id: Option<Uuid>,
248        origin: Option<&str>,
249    ) -> Result<Option<Uuid>, sqlx::Error> {
250        let row = sqlx::query_scalar::<_, Uuid>(
251            r#"SELECT id FROM inventory.transfers
252               WHERE picking_type_id = $1
253                 AND location_id = $2
254                 AND location_dest_id = $3
255                 AND partner_id IS NOT DISTINCT FROM $4
256                 AND origin IS NOT DISTINCT FROM $5
257                 AND state NOT IN ('done'::transfer_state, 'cancel'::transfer_state)
258                 AND (metadata->>'deleted_at') IS NULL
259               ORDER BY scheduled_date DESC, id
260               LIMIT 1"#,
261        )
262        .bind(picking_type_id)
263        .bind(location_id)
264        .bind(location_dest_id)
265        .bind(partner_id)
266        .bind(origin)
267        .fetch_optional(conn)
268        .await?;
269        Ok(row)
270    }
271
272    /// The warehouse's stock location: the internal location the warehouse tree resolves to
273    /// (deterministic: shallowest in the tree, then name). `None` when the warehouse has no
274    /// internal location yet — the caller decides whether to bootstrap one.
275    pub async fn resolve_internal_location(
276        &self,
277        conn: &mut PgConnection,
278        warehouse_id: Uuid,
279    ) -> Result<Option<Uuid>, sqlx::Error> {
280        let row = sqlx::query(
281            r#"SELECT id FROM inventory.locations
282               WHERE warehouse_id = $1 AND usage = 'internal'::location_usage AND active
283                 AND (metadata->>'deleted_at') IS NULL
284               ORDER BY parent_path ASC NULLS FIRST, name ASC LIMIT 1"#,
285        )
286        .bind(warehouse_id)
287        .fetch_optional(conn)
288        .await?;
289        Ok(row.map(|r| r.get("id")))
290    }
291
292    /// The inventory-loss location (the far end of every adjustment move). `None` when none
293    /// exists — the caller decides whether to bootstrap one.
294    pub async fn resolve_inventory_loss_location(
295        &self,
296        conn: &mut PgConnection,
297    ) -> Result<Option<Uuid>, sqlx::Error> {
298        let row = sqlx::query(
299            r#"SELECT id FROM inventory.locations
300               WHERE usage = 'inventory'::location_usage AND active
301                 AND (metadata->>'deleted_at') IS NULL
302               ORDER BY name ASC LIMIT 1"#,
303        )
304        .fetch_optional(conn)
305        .await?;
306        Ok(row.map(|r| r.get("id")))
307    }
308
309    /// Bootstrap a warehouse's stock location (internal usage). The
310    /// adjustment door and the picking mint use this the first time a warehouse without a
311    /// location tree stages a count or mints a picking — a physical grain needs a location,
312    /// and minting the warehouse's own stock location is the least-surprise resolution.
313    pub async fn ensure_internal_location(
314        &self,
315        conn: &mut PgConnection,
316        warehouse_id: Uuid,
317    ) -> Result<Uuid, sqlx::Error> {
318        if let Some(id) = self.resolve_internal_location(conn, warehouse_id).await? {
319            return Ok(id);
320        }
321        let id = Uuid::new_v4();
322        sqlx::query(
323            r#"INSERT INTO inventory.locations
324                 (id, name, complete_name, usage, active, warehouse_id, parent_path)
325               VALUES ($1, 'Stock', 'Stock', 'internal'::location_usage, TRUE, $2, $1::text)"#,
326        )
327        .bind(id)
328        .bind(warehouse_id)
329        .execute(conn)
330        .await?;
331        Ok(id)
332    }
333
334    /// Bootstrap the inventory-loss location (usage `inventory` — the scrap /
335    /// adjustment sink). Shared root trees would normally seed this; the module ships no
336    /// location seed yet, so the door mints it on first use.
337    pub async fn ensure_inventory_loss_location(
338        &self,
339        conn: &mut PgConnection,
340    ) -> Result<Uuid, sqlx::Error> {
341        if let Some(id) = self.resolve_inventory_loss_location(conn).await? {
342            return Ok(id);
343        }
344        let id = Uuid::new_v4();
345        sqlx::query(
346            r#"INSERT INTO inventory.locations
347                 (id, name, complete_name, usage, active, parent_path)
348               VALUES ($1, 'Inventory adjustment', 'Inventory adjustment',
349                       'inventory'::location_usage, TRUE, $1::text)"#,
350        )
351        .bind(id)
352        .execute(conn)
353        .await?;
354        Ok(id)
355    }
356
357    /// Resolve the location of a partner usage (`supplier` / `customer`). `None` when none
358    /// exists — the caller decides whether to bootstrap one.
359    pub async fn resolve_partner_location(
360        &self,
361        conn: &mut PgConnection,
362        usage: &str,
363    ) -> Result<Option<Uuid>, sqlx::Error> {
364        let row = sqlx::query(
365            r#"SELECT id FROM inventory.locations
366               WHERE usage = $1::location_usage AND active
367                 AND (metadata->>'deleted_at') IS NULL
368               ORDER BY name ASC LIMIT 1"#,
369        )
370        .bind(usage)
371        .fetch_optional(conn)
372        .await?;
373        Ok(row.map(|r| r.get("id")))
374    }
375
376    /// Bootstrap the counterpart location for a partner usage (`supplier` — the
377    /// far end of every goods receipt; `customer` — the far end of every delivery). Voucher
378    /// doors are warehouse-grain, moves are location-grain: this is the resolve-or-mint that
379    /// gives the door's moves their virtual counterpart endpoint.
380    pub async fn ensure_partner_location(
381        &self,
382        conn: &mut PgConnection,
383        usage: &str,
384    ) -> Result<Uuid, sqlx::Error> {
385        if let Some(id) = self.resolve_partner_location(conn, usage).await? {
386            return Ok(id);
387        }
388        let id = Uuid::new_v4();
389        let name = if usage == "supplier" { "Suppliers" } else { "Customers" };
390        sqlx::query(
391            r#"INSERT INTO inventory.locations
392                 (id, name, complete_name, usage, active, parent_path)
393               VALUES ($1, $2, $2, $3::location_usage, TRUE, $1::text)"#,
394        )
395        .bind(id)
396        .bind(name)
397        .bind(usage)
398        .execute(conn)
399        .await?;
400        Ok(id)
401    }
402
403    /// The quant-surface backfill: guarantee a quant row exists at the bin grain
404    /// (item, location, all tracking dims NULL), initialized from the Bin balance of the
405    /// location's warehouse. Stock seeded through the legacy voucher paths wrote Bins without
406    /// quants; converged moves draw from quants, so the first converged touch of a grain
407    /// heals it. Idempotent by construction (NOT EXISTS guard + the same advisory-lock
408    /// pattern `QuantRepository::lock_or_init` uses — the unique index does not dedup NULL
409    /// dimensions, so creation must serialize). A second call finds the row and does nothing:
410    /// this is a one-time catch-up, never an ongoing second writer of `quantity` (the move
411    /// pipeline is the only ongoing writer).
412    pub async fn ensure_quant_surface(
413        &self,
414        conn: &mut PgConnection,
415        item_id: Uuid,
416        location_id: Uuid,
417        warehouse_id: Option<Uuid>,
418    ) -> Result<(), sqlx::Error> {
419        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
420            .bind(format!("quant:{item_id}:{location_id}:NULL:NULL:NULL"))
421            .execute(&mut *conn)
422            .await?;
423        // Location with a warehouse: seed from that warehouse's Bin balance (the legacy
424        // voucher paths wrote Bins only — the heal makes the quant surface catch up to the
425        // balance of record exactly once).
426        sqlx::query(
427            r#"INSERT INTO inventory.stock_quants
428                 (id, item_id, location_id, quantity, reserved_quantity, available_quantity,
429                  in_date)
430               SELECT $1, $2, $3,
431                      COALESCE((SELECT b.actual_qty FROM inventory.bins b
432                                WHERE b.item_id = $2
433                                  AND b.warehouse_id = $4::uuid
434                                  AND (b.metadata->>'deleted_at') IS NULL), 0),
435                      0,
436                      COALESCE((SELECT b.actual_qty FROM inventory.bins b
437                                WHERE b.item_id = $2
438                                  AND b.warehouse_id = $4::uuid
439                                  AND (b.metadata->>'deleted_at') IS NULL), 0),
440                      NOW()
441               WHERE $4::uuid IS NOT NULL
442                 AND NOT EXISTS (
443                   SELECT 1 FROM inventory.stock_quants q
444                   WHERE q.item_id = $2 AND q.location_id = $3
445                     AND q.lot_id IS NULL AND q.package_id IS NULL AND q.owner_id IS NULL
446                     AND (q.metadata->>'deleted_at') IS NULL
447                 )"#,
448        )
449        .bind(Uuid::new_v4())
450        .bind(item_id)
451        .bind(location_id)
452        .bind(warehouse_id)
453        .execute(&mut *conn)
454        .await?;
455        // Location without a warehouse (shared / virtual): a zeroed quant row is enough —
456        // there is no Bin behind it, and the moves that touch it flip it from zero.
457        sqlx::query(
458            r#"INSERT INTO inventory.stock_quants
459                 (id, item_id, location_id, quantity, reserved_quantity, available_quantity,
460                  in_date)
461               SELECT $1, $2, $3, 0, 0, 0, NOW()
462               WHERE $4::uuid IS NULL
463                 AND NOT EXISTS (
464                   SELECT 1 FROM inventory.stock_quants q
465                   WHERE q.item_id = $2 AND q.location_id = $3
466                     AND q.lot_id IS NULL AND q.package_id IS NULL AND q.owner_id IS NULL
467                     AND (q.metadata->>'deleted_at') IS NULL
468                 )"#,
469        )
470        .bind(Uuid::new_v4())
471        .bind(item_id)
472        .bind(location_id)
473        .bind(warehouse_id)
474        .execute(&mut *conn)
475        .await?;
476        Ok(())
477    }
478
479    /// The adjustment door's mint-once gate: an existing `is_inventory` move addressing this
480    /// item between exactly these two locations (either direction — the count direction only
481    /// flips the pair). Live moves first (resume candidates), then done ones (the
482    /// already-applied evidence), newest first.
483    pub async fn find_adjustment_moves(
484        &self,
485        conn: &mut PgConnection,
486        item_id: Uuid,
487        location_a: Uuid,
488        location_b: Uuid,
489    ) -> Result<Vec<AdjustmentMoveRow>, sqlx::Error> {
490        let rows = sqlx::query(
491            r#"SELECT id, state::text AS state, date
492               FROM inventory.stock_moves
493               WHERE item_id = $1 AND is_inventory
494                 AND (metadata->>'deleted_at') IS NULL
495                 AND ((location_id = $2 AND location_dest_id = $3)
496                   OR (location_id = $3 AND location_dest_id = $2))
497               ORDER BY (state NOT IN ('done'::move_state, 'cancel'::move_state)) DESC,
498                        date DESC NULLS LAST, create_date DESC"#,
499        )
500        .bind(item_id)
501        .bind(location_a)
502        .bind(location_b)
503        .fetch_all(conn)
504        .await?;
505        Ok(rows
506            .iter()
507            .map(|r| AdjustmentMoveRow { id: r.get("id"), state: r.get("state"), date: r.get("date") })
508            .collect())
509    }
510}