Skip to main content

backbone_inventory/infrastructure/persistence/
stock_reconciliation_repository.rs

1//! Repository for StockReconciliation 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 StockReconciliation SQL
6//! (4-layer rule: services orchestrate, repositories hold the SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use rust_decimal::Decimal;
13use sqlx::{PgPool, Row};
14use uuid::Uuid;
15
16use backbone_orm::org_scope;
17
18use super::gl_voucher_repository::GlSettlementState;
19
20use crate::domain::entity::StockReconciliation;
21
22/// Table name for StockReconciliation entities
23pub const TABLE_NAME: &str = "inventory.stock_reconciliations";
24
25/// Repository for StockReconciliation entities.
26///
27/// All standard CRUD, soft-delete, pagination, and bulk methods are
28/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
29pub struct StockReconciliationRepository(
30    backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>,
31);
32
33impl std::ops::Deref for StockReconciliationRepository {
34    type Target = backbone_orm::GenericCrudRepository<StockReconciliation, backbone_orm::SoftDelete>;
35    fn deref(&self) -> &Self::Target { &self.0 }
36}
37
38impl StockReconciliationRepository {
39    /// Create a new repository instance.
40    pub fn new(pool: PgPool) -> Self {
41        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
42    }
43}
44
45/// The exact row a Stock Reconciliation header insert writes.
46///
47/// Mirrors the raw column shape rather than the `StockReconciliation` entity: `net_difference`
48/// starts as a literal 0 (it is only known once every counted line is applied), and `status` /
49/// `posting_state` are literal casts.
50pub struct NewReconciliationRow<'a> {
51    pub id: Uuid,
52    pub recon_number: &'a str,
53    pub warehouse_id: Uuid,
54    pub posting_date: chrono::NaiveDate,
55    pub currency: &'a str,
56    pub inventory_account_id: Uuid,
57    pub adjustment_account_id: Uuid,
58}
59
60/// The repost path's header projection — rebuilds the SAME value-diff envelope from the stored
61/// header, never re-touching the SLE/Bin (the physical movement already happened). Mirrors the
62/// receipt/delivery repost headers (council 2026-07-29, parking-lot item: `repost_reconciliation`).
63pub struct ReconRepostHeaderRow {
64    pub recon_number: String,
65    pub posting_date: chrono::NaiveDate,
66    pub currency: String,
67    pub net_difference: Decimal,
68    pub inventory_account_id: Uuid,
69    pub adjustment_account_id: Uuid,
70    pub gl: GlSettlementState,
71}
72
73/// Hand-written StockReconciliation SQL. Lives here per the module's 4-layer rule.
74impl StockReconciliationRepository {
75    /// Insert the reconciliation header.
76    ///
77    /// Takes the CALLER'S connection so the header, its counted lines, and every bin/SLE write
78    /// commit as one unit. The caller has already relayed the ambient org scope onto it — don't
79    /// re-bind here.
80    ///
81    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to
82    /// turn a duplicate recon number into `DuplicateNumber`.
83    pub async fn insert_submitted(
84        &self,
85        conn: &mut sqlx::PgConnection,
86        r: &NewReconciliationRow<'_>,
87    ) -> Result<(), sqlx::Error> {
88        sqlx::query(
89            r#"INSERT INTO inventory.stock_reconciliations
90                (id, recon_number, warehouse_id, posting_date, currency, net_difference,
91                 inventory_account_id, adjustment_account_id, status, posting_state)
92               VALUES ($1,$2,$3,$4,$5,0,$6,$7,'submitted'::doc_status,'pending'::gl_posting_state)"#,
93        )
94        .bind(r.id).bind(r.recon_number).bind(r.warehouse_id).bind(r.posting_date)
95        .bind(r.currency)
96        .bind(r.inventory_account_id).bind(r.adjustment_account_id)
97        .execute(conn)
98        .await?;
99        Ok(())
100    }
101
102    /// Record the net value difference the count produced. Must ride the movement's transaction.
103    pub async fn update_net_difference(
104        &self,
105        conn: &mut sqlx::PgConnection,
106        id: Uuid,
107        net_difference: Decimal,
108    ) -> Result<(), sqlx::Error> {
109        sqlx::query("UPDATE inventory.stock_reconciliations SET net_difference=$2 WHERE id=$1")
110            .bind(id).bind(net_difference)
111            .execute(conn)
112            .await?;
113        Ok(())
114    }
115
116    /// Mark a zero-net reconciliation as needing no GL post at all (the count matched the books).
117    ///
118    /// Runs `org_scope::execute_scoped` on the pool: the write rides the ambient org scope's
119    /// request-dedicated connection so it passes the composing decorator's fence (ADR-0029).
120    /// Undecorated (module tests) the UPDATE runs plain.
121    pub async fn mark_not_applicable(
122        &self,
123        pool: &PgPool,
124        id: Uuid,
125    ) -> Result<(), sqlx::Error> {
126        org_scope::execute_scoped(
127            pool,
128            sqlx::query("UPDATE inventory.stock_reconciliations SET posting_state='not_applicable'::gl_posting_state WHERE id=$1")
129                .bind(id),
130        )
131        .await?;
132        Ok(())
133    }
134
135    /// Read the repost path's header. Same ID-only scope fence as the receipt/delivery repost
136    /// headers (`fetch_optional_row_scoped` rides the ambient org scope the composing service set
137    /// per request). Used by `repost_reconciliation` to re-drive a stuck `pending`/`failed` post
138    /// (or a `net==0` recon that crashed before `mark_not_applicable`).
139    pub async fn fetch_repost_header(
140        &self,
141        pool: &PgPool,
142        id: Uuid,
143    ) -> Result<Option<ReconRepostHeaderRow>, sqlx::Error> {
144        let row = org_scope::fetch_optional_row_scoped(
145            pool,
146            sqlx::query(
147                r#"SELECT recon_number, posting_date, currency, net_difference,
148                          inventory_account_id, adjustment_account_id,
149                          posting_state::text AS ps, journal_id, accounting_post_id
150                   FROM inventory.stock_reconciliations WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
151            )
152            .bind(id),
153        )
154        .await?;
155        Ok(row.map(|h| ReconRepostHeaderRow {
156            recon_number: h.get("recon_number"),
157            posting_date: h.get("posting_date"), currency: h.get("currency"),
158            net_difference: h.get("net_difference"),
159            inventory_account_id: h.get("inventory_account_id"),
160            adjustment_account_id: h.get("adjustment_account_id"),
161            gl: GlSettlementState {
162                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
163                accounting_post_id: h.get("accounting_post_id"),
164            },
165        }))
166    }
167}
168
169backbone_core::impl_crud_repository!(StockReconciliationRepository, StockReconciliation, soft_delete);