Skip to main content

backbone_inventory/infrastructure/persistence/
scrap_door_repository.rs

1//! Scrap-door SQL (hand-authored, user-owned) — the stock.scrap satellite.
2//!
3//! Distinct from the generated `scrap_repository.rs` (the GenericCrud newtype): this file
4//! holds the scrap DOOR's own SQL — the header mint (a draft scrap order), the probe
5//! read, and the done-stamp that closes the door's document state. The document state
6//! (`draft` → `done`) is genuinely HAND-SET (unlike the picking-batch projection): a
7//! scrap is an operator document, and `done` is stamped exactly once, after the ONE
8//! stock-move engine validated the door's move (the physical/valuation/SLE legs all ride
9//! that single move — `scrapped = true` — there is no second estate here).
10//!
11//! Statements live here and take the caller's connection; services orchestrate. State
12//! values bind as text with explicit casts and read back with `::text`.
13
14use rust_decimal::Decimal;
15use sqlx::{PgConnection, Row};
16use uuid::Uuid;
17
18/// A scrap order insert. `state` starts at `draft`; the done-stamp is the door's
19/// terminal close after the engine validated the move.
20pub struct NewScrapRow<'a> {
21    pub id: Uuid,
22    pub name: &'a str,
23    pub origin: Option<&'a str>,
24    pub item_id: Uuid,
25    pub scrap_qty: Decimal,
26    pub location_id: Uuid,
27    pub scrap_location_id: Uuid,
28    pub lot_id: Option<Uuid>,
29    pub package_id: Option<Uuid>,
30    pub owner_id: Option<Uuid>,
31    pub picking_id: Option<Uuid>,
32    pub scrap_reason_tag_ids: Vec<Uuid>,
33}
34
35/// The scrap probe surface: the header as the door reads it back.
36#[derive(Debug, Clone)]
37pub struct ScrapRow {
38    pub id: Uuid,
39    pub name: String,
40    /// draft / done (as text).
41    pub state: String,
42    pub origin: Option<String>,
43    pub item_id: Uuid,
44    pub scrap_qty: Decimal,
45    pub location_id: Uuid,
46    pub scrap_location_id: Uuid,
47    pub lot_id: Option<Uuid>,
48    pub package_id: Option<Uuid>,
49    pub owner_id: Option<Uuid>,
50    pub picking_id: Option<Uuid>,
51    /// The engine move this scrap landed through (set by the done-stamp).
52    pub move_id: Option<Uuid>,
53    pub scrap_reason_tag_ids: Vec<Uuid>,
54}
55
56/// The scrap door's mint/probe/close SQL. Stateless — every method takes the caller's
57/// connection so the header insert and the later done-stamp run under the caller's scope.
58#[derive(Default)]
59pub struct ScrapDoorRepository;
60
61impl ScrapDoorRepository {
62    pub fn new() -> Self {
63        Self
64    }
65
66    /// Insert the scrap header (draft). Leaks the raw `sqlx::Error` deliberately so the
67    /// service can turn a unique violation on the name — the composing decorator's org-scoped
68    /// arbiter — into the typed duplicate error.
69    pub async fn insert_scrap(
70        &self,
71        conn: &mut PgConnection,
72        s: &NewScrapRow<'_>,
73    ) -> Result<(), sqlx::Error> {
74        sqlx::query(
75            r#"INSERT INTO inventory.scraps
76                 (id, name, state, origin, item_id, scrap_qty, location_id,
77                  scrap_location_id, lot_id, package_id, owner_id, picking_id,
78                  scrap_reason_tag_ids)
79               VALUES ($1, $2, 'draft'::scrap_state, $3, $4, $5, $6,
80                       $7, $8, $9, $10, $11, $12)"#,
81        )
82        .bind(s.id)
83        .bind(s.name)
84        .bind(s.origin)
85        .bind(s.item_id)
86        .bind(s.scrap_qty)
87        .bind(s.location_id)
88        .bind(s.scrap_location_id)
89        .bind(s.lot_id)
90        .bind(s.package_id)
91        .bind(s.owner_id)
92        .bind(s.picking_id)
93        .bind(&s.scrap_reason_tag_ids)
94        .execute(&mut *conn)
95        .await?;
96        Ok(())
97    }
98
99    /// Read one scrap header (probe surface).
100    pub async fn fetch_scrap(
101        &self,
102        conn: &mut PgConnection,
103        scrap_id: Uuid,
104    ) -> Result<Option<ScrapRow>, sqlx::Error> {
105        let row = sqlx::query(
106            r#"SELECT id, name, state::text AS state, origin, item_id, scrap_qty,
107                      location_id, scrap_location_id, lot_id, package_id, owner_id, picking_id,
108                      move_id, scrap_reason_tag_ids
109               FROM inventory.scraps
110               WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
111        )
112        .bind(scrap_id)
113        .fetch_optional(&mut *conn)
114        .await?;
115        Ok(row.map(|r| ScrapRow {
116            id: r.get("id"),
117            name: r.get("name"),
118            state: r.get("state"),
119            origin: r.get("origin"),
120            item_id: r.get("item_id"),
121            scrap_qty: r.get("scrap_qty"),
122            location_id: r.get("location_id"),
123            scrap_location_id: r.get("scrap_location_id"),
124            lot_id: r.get("lot_id"),
125            package_id: r.get("package_id"),
126            owner_id: r.get("owner_id"),
127            picking_id: r.get("picking_id"),
128            move_id: r.get("move_id"),
129            scrap_reason_tag_ids: r.get("scrap_reason_tag_ids"),
130        }))
131    }
132
133    /// The terminal close: stamp the scrap `done` and bind the engine move it landed
134    /// through. The `AND state = 'draft'` guard is the concurrency backstop — exactly one
135    /// racer transitions; the other sees `false` and re-reads the header as `done`.
136    pub async fn mark_scrap_done(
137        &self,
138        conn: &mut PgConnection,
139        scrap_id: Uuid,
140        move_id: Uuid,
141    ) -> Result<bool, sqlx::Error> {
142        let res = sqlx::query(
143            r#"UPDATE inventory.scraps
144               SET state = 'done'::scrap_state, move_id = $2
145               WHERE id = $1 AND state = 'draft'::scrap_state"#,
146        )
147        .bind(scrap_id)
148        .bind(move_id)
149        .execute(&mut *conn)
150        .await?;
151        Ok(res.rows_affected() == 1)
152    }
153}