Skip to main content

backbone_inventory/infrastructure/persistence/
stock_move_line_repository.rs

1//! Repository for StockMoveLine entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written move-line SQL — the MIRROR arm of the reservation triangle (spec
6//! stock-business-logic §3): each line records the grain that touched a quant
7//! (item x src x dest x lot x package x owner x quantity); `quantity` mirrors the quant's
8//! authoritative `reserved_quantity`, never the reverse (T2). `state` mirrors the parent move's
9//! state (projection shape, ADR-0016) — written only by [`Self::mirror_state`].
10//!
11//! 4-layer rule: services orchestrate, repositories hold SQL. Write methods take the CALLER'S
12//! connection; the caller has already relayed the ambient org scope onto it.
13
14use rust_decimal::Decimal;
15use sqlx::{PgPool, Row};
16use uuid::Uuid;
17
18use crate::domain::entity::StockMoveLine;
19
20/// Table name for StockMoveLine entities
21pub const TABLE_NAME: &str = "inventory.stock_move_lines";
22
23/// Repository for StockMoveLine entities.
24///
25/// All standard CRUD, soft-delete, pagination, and bulk methods are
26/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
27pub struct StockMoveLineRepository(
28    backbone_orm::GenericCrudRepository<StockMoveLine, backbone_orm::SoftDelete>,
29);
30
31impl std::ops::Deref for StockMoveLineRepository {
32    type Target = backbone_orm::GenericCrudRepository<StockMoveLine, backbone_orm::SoftDelete>;
33    fn deref(&self) -> &Self::Target { &self.0 }
34}
35
36impl StockMoveLineRepository {
37    /// Create a new repository instance.
38    pub fn new(pool: PgPool) -> Self {
39        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
40    }
41}
42
43/// The exact row a move-line insert writes (the reservation mirror minted by `_action_assign`).
44pub struct NewMoveLineRow<'a> {
45    pub id: Uuid,
46    pub quantity: Decimal,
47    pub lot_id: Option<Uuid>,
48    pub package_id: Option<Uuid>,
49    pub result_package_id: Option<Uuid>,
50    pub owner_id: Option<Uuid>,
51    pub move_id: Uuid,
52    pub picking_id: Option<Uuid>,
53    pub location_id: Uuid,
54    pub location_dest_id: Uuid,
55    pub item_id: Uuid,
56    /// The mirrored state at mint time (matches the parent move's post-assign state).
57    pub state: &'a str,
58}
59
60/// A move-line row as the engine reads it. `state` decodes as text.
61pub struct MoveLineRow {
62    pub id: Uuid,
63    pub quantity: Decimal,
64    pub lot_id: Option<Uuid>,
65    pub package_id: Option<Uuid>,
66    pub result_package_id: Option<Uuid>,
67    pub owner_id: Option<Uuid>,
68    pub state: String,
69    pub move_id: Uuid,
70    pub location_id: Uuid,
71    pub location_dest_id: Uuid,
72    pub item_id: Uuid,
73}
74
75impl StockMoveLineRepository {
76    /// Insert one move line (the per-quant reservation mirror).
77    pub async fn insert_line(
78        &self,
79        conn: &mut sqlx::PgConnection,
80        l: &NewMoveLineRow<'_>,
81    ) -> Result<(), sqlx::Error> {
82        sqlx::query(
83            r#"INSERT INTO inventory.stock_move_lines
84                 (id, quantity, lot_id, package_id, result_package_id, owner_id, state, move_id,
85                  picking_id, location_id, location_dest_id, item_id)
86               VALUES ($1,$2,$3,$4,$5,$6,$7::move_state,$8,$9,$10,$11,$12)"#,
87        )
88        .bind(l.id).bind(l.quantity).bind(l.lot_id).bind(l.package_id).bind(l.result_package_id)
89        .bind(l.owner_id).bind(l.state).bind(l.move_id).bind(l.picking_id)
90        .bind(l.location_id).bind(l.location_dest_id).bind(l.item_id)
91        .execute(conn)
92        .await?;
93        Ok(())
94    }
95
96    /// Retarget every line of one move onto its grouping transfer (the picking-assignment
97    /// write's line half — lines minted before the move joined a picking carry a NULL
98    /// `picking_id`, and the transfer's line gather reads this column). Runs inside the
99    /// caller's transaction; no-op when the move has no lines yet.
100    pub async fn retarget_picking(
101        &self,
102        conn: &mut sqlx::PgConnection,
103        move_id: Uuid,
104        picking_id: Uuid,
105    ) -> Result<(), sqlx::Error> {
106        sqlx::query(
107            r#"UPDATE inventory.stock_move_lines
108               SET picking_id = $2
109               WHERE move_id = $1 AND (metadata->>'deleted_at') IS NULL"#,
110        )
111        .bind(move_id)
112        .bind(picking_id)
113        .execute(&mut *conn)
114        .await?;
115        Ok(())
116    }
117
118    /// All live lines of one move (execution order: mint order).
119    pub async fn fetch_lines_for_move(
120        &self,
121        conn: &mut sqlx::PgConnection,
122        move_id: Uuid,
123    ) -> Result<Vec<MoveLineRow>, sqlx::Error> {
124        let rows = sqlx::query(
125            r#"SELECT id, quantity, lot_id, package_id, result_package_id, owner_id,
126                      state::text AS state, move_id, location_id, location_dest_id, item_id
127               FROM inventory.stock_move_lines
128               WHERE move_id=$1 AND (metadata->>'deleted_at') IS NULL
129               ORDER BY id"#,
130        )
131        .bind(move_id)
132        .fetch_all(&mut *conn)
133        .await?;
134        Ok(rows.into_iter().map(line_row_of).collect())
135    }
136
137    /// One move line by id, as a landed-cost repost reads it (the target-line facts: its frozen
138    /// done quantity, its move, and the destination location the debit leg's account chain
139    /// resolves from). `None` when the line does not exist or is soft-deleted.
140    pub async fn fetch_line(
141        &self,
142        conn: &mut sqlx::PgConnection,
143        move_line_id: Uuid,
144    ) -> Result<Option<MoveLineRow>, sqlx::Error> {
145        let row = sqlx::query(
146            r#"SELECT id, quantity, lot_id, package_id, result_package_id, owner_id,
147                      state::text AS state, move_id, location_id, location_dest_id, item_id
148               FROM inventory.stock_move_lines
149               WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
150        )
151        .bind(move_line_id)
152        .fetch_optional(&mut *conn)
153        .await?;
154        Ok(row.map(line_row_of))
155    }
156
157    /// Sum of the mirror quantities of one move — the aggregate the move state derives from
158    /// (`assigned` when it meets demand, `partially_available` when positive but short).
159    pub async fn sum_mirror_qty(
160        &self,
161        conn: &mut sqlx::PgConnection,
162        move_id: Uuid,
163    ) -> Result<Decimal, sqlx::Error> {
164        let qty: Option<Decimal> = sqlx::query_scalar(
165            r#"SELECT SUM(quantity) FROM inventory.stock_move_lines
166               WHERE move_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
167        )
168        .bind(move_id)
169        .fetch_one(&mut *conn)
170        .await?;
171        Ok(qty.unwrap_or(Decimal::ZERO))
172    }
173
174    /// Rewrite one line's mirror quantity (reserve/unreserve delta landing on the mirror arm).
175    pub async fn set_quantity(
176        &self,
177        conn: &mut sqlx::PgConnection,
178        line_id: Uuid,
179        quantity: Decimal,
180    ) -> Result<(), sqlx::Error> {
181        sqlx::query(
182            r#"UPDATE inventory.stock_move_lines SET quantity=$2
183               WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
184        )
185        .bind(line_id).bind(quantity)
186        .execute(conn)
187        .await?;
188        Ok(())
189    }
190
191    /// Mirror the parent move's state onto every line of the move (the projection arm — lines
192    /// never carry a state of their own; the `_free_reservation_index` partial rides this column).
193    pub async fn mirror_state(
194        &self,
195        conn: &mut sqlx::PgConnection,
196        move_id: Uuid,
197        state: &str,
198    ) -> Result<(), sqlx::Error> {
199        sqlx::query(
200            r#"UPDATE inventory.stock_move_lines SET state=$2::move_state
201               WHERE move_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
202        )
203        .bind(move_id).bind(state)
204        .execute(conn)
205        .await?;
206        Ok(())
207    }
208}
209
210fn line_row_of(r: sqlx::postgres::PgRow) -> MoveLineRow {
211    MoveLineRow {
212        id: r.get("id"),
213        quantity: r.get("quantity"),
214        lot_id: r.get("lot_id"),
215        package_id: r.get("package_id"),
216        result_package_id: r.get("result_package_id"),
217        owner_id: r.get("owner_id"),
218        state: r.get("state"),
219        move_id: r.get("move_id"),
220        location_id: r.get("location_id"),
221        location_dest_id: r.get("location_dest_id"),
222        item_id: r.get("item_id"),
223    }
224}
225
226backbone_core::impl_crud_repository!(StockMoveLineRepository, StockMoveLine, soft_delete);