Skip to main content

backbone_inventory/infrastructure/persistence/
purchase_receipt_repository.rs

1//! Repository for PurchaseReceipt 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 PurchaseReceipt SQL
6//! (4-layer rule: services orchestrate, repositories hold the SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<PurchaseReceipt, 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::PurchaseReceipt;
21
22/// Table name for PurchaseReceipt entities
23pub const TABLE_NAME: &str = "inventory.purchase_receipts";
24
25/// Repository for PurchaseReceipt entities.
26///
27/// All standard CRUD, soft-delete, pagination, and bulk methods are
28/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
29pub struct PurchaseReceiptRepository(
30    backbone_orm::GenericCrudRepository<PurchaseReceipt, backbone_orm::SoftDelete>,
31);
32
33impl std::ops::Deref for PurchaseReceiptRepository {
34    type Target = backbone_orm::GenericCrudRepository<PurchaseReceipt, backbone_orm::SoftDelete>;
35    fn deref(&self) -> &Self::Target { &self.0 }
36}
37
38impl PurchaseReceiptRepository {
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 draft Purchase Receipt header insert writes.
46///
47/// Mirrors the raw column shape rather than the `PurchaseReceipt` entity: `status` and
48/// `posting_state` are literal casts in the statement (`'draft'::doc_status`,
49/// `'pending'::gl_posting_state`), `currency` arrives from the caller (no longer a hardcoded
50/// 'IDR' — council 2026-07-29), and `total_value` arrives already rounded.
51pub struct NewReceiptRow<'a> {
52    pub id: Uuid,
53    pub receipt_number: &'a str,
54    pub branch_id: Option<Uuid>,
55    pub supplier_id: Uuid,
56    pub source_po_id: Option<Uuid>,
57    pub warehouse_id: Uuid,
58    pub posting_date: chrono::NaiveDate,
59    pub currency: &'a str,
60    pub total_value: Decimal,
61    pub inventory_account_id: Uuid,
62    pub grir_account_id: Uuid,
63}
64
65/// The submit path's header projection — everything needed to drive the movement + the asset post.
66pub struct ReceiptSubmitHeaderRow {
67    pub receipt_number: String,
68    pub branch_id: Option<Uuid>,
69    pub warehouse_id: Uuid,
70    pub posting_date: chrono::NaiveDate,
71    pub source_po_id: Option<Uuid>,
72    pub currency: String,
73    /// `status::text` — the submit path refuses anything but "draft".
74    pub status: String,
75    pub inventory_account_id: Uuid,
76    pub grir_account_id: Uuid,
77}
78
79/// The repost path's header projection — rebuilds the SAME envelope from the stored header, never
80/// re-touching the SLE/Bin (the physical movement already happened).
81pub struct ReceiptRepostHeaderRow {
82    pub branch_id: Option<Uuid>,
83    pub receipt_number: String,
84    pub posting_date: chrono::NaiveDate,
85    pub currency: String,
86    pub total_value: Decimal,
87    pub inventory_account_id: Uuid,
88    pub grir_account_id: Uuid,
89    /// The warehouse whose stock location resolves the location valuation-account override —
90    /// the repost must rebuild the SAME envelope the submit built, override included.
91    pub warehouse_id: Uuid,
92    pub gl: GlSettlementState,
93}
94
95/// The cancel path's header projection — everything needed to reverse the inflow and emit the
96/// `posting_type='reversal'` post. Carries the original `accounting_post_id` (to reverse) and any
97/// already-recorded `reversal_accounting_post_id` (the idempotency/recovery short-circuit).
98pub struct ReceiptCancelHeaderRow {
99    pub branch_id: Option<Uuid>,
100    pub warehouse_id: Uuid,
101    pub posting_date: chrono::NaiveDate,
102    pub currency: String,
103    pub receipt_number: String,
104    /// The original posted value — the reversal GL leg re-posts this amount (Dr GR/IR · Cr Inventory).
105    pub total_value: Decimal,
106    /// `status::text` — a fresh cancel requires "submitted"; "cancelled" is the idempotent/recovery
107    /// short-circuit.
108    pub status: String,
109    pub inventory_account_id: Uuid,
110    pub grir_account_id: Uuid,
111    pub gl: GlSettlementState,
112    pub reversal_journal_id: Option<Uuid>,
113    pub reversal_accounting_post_id: Option<Uuid>,
114}
115
116/// Hand-written PurchaseReceipt SQL. Lives here per the module's 4-layer rule.
117impl PurchaseReceiptRepository {
118    /// Insert the draft receipt header.
119    ///
120    /// Takes the CALLER'S connection so the header and its items commit as one unit. The caller has
121    /// already relayed the ambient org scope onto it — don't re-bind here.
122    ///
123    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to
124    /// turn a duplicate receipt number into `DuplicateNumber`.
125    pub async fn insert_draft(
126        &self,
127        conn: &mut sqlx::PgConnection,
128        r: &NewReceiptRow<'_>,
129    ) -> Result<(), sqlx::Error> {
130        sqlx::query(
131            r#"INSERT INTO inventory.purchase_receipts
132                (id, receipt_number, branch_id, supplier_id, source_po_id, warehouse_id,
133                 posting_date, currency, total_value, inventory_account_id, grir_account_id,
134                 status, posting_state)
135               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'draft'::doc_status,'pending'::gl_posting_state)"#,
136        )
137        .bind(r.id).bind(r.receipt_number).bind(r.branch_id).bind(r.supplier_id)
138        .bind(r.source_po_id).bind(r.warehouse_id).bind(r.posting_date).bind(r.currency).bind(r.total_value)
139        .bind(r.inventory_account_id).bind(r.grir_account_id)
140        .execute(conn)
141        .await?;
142        Ok(())
143    }
144
145    /// Read the submit path's header. `Ok(None)` = no such live receipt in scope.
146    ///
147    /// ID-only: `fetch_optional_row_scoped` means it rides the ambient org scope's request-dedicated
148    /// connection (ADR-0029), so the decorator's fence bounds what the read can see.
149    pub async fn fetch_submit_header(
150        &self,
151        pool: &PgPool,
152        id: Uuid,
153    ) -> Result<Option<ReceiptSubmitHeaderRow>, sqlx::Error> {
154        let row = org_scope::fetch_optional_row_scoped(
155            pool,
156            sqlx::query(
157                r#"SELECT receipt_number, branch_id, warehouse_id, posting_date, source_po_id,
158                          currency, status::text AS st, inventory_account_id, grir_account_id
159                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
160            )
161            .bind(id),
162        )
163        .await?;
164        Ok(row.map(|h| ReceiptSubmitHeaderRow {
165            receipt_number: h.get("receipt_number"),
166            branch_id: h.get("branch_id"), warehouse_id: h.get("warehouse_id"),
167            posting_date: h.get("posting_date"), source_po_id: h.get("source_po_id"),
168            currency: h.get("currency"),
169            status: h.get("st"), inventory_account_id: h.get("inventory_account_id"),
170            grir_account_id: h.get("grir_account_id"),
171        }))
172    }
173
174    /// Read the repost path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
175    pub async fn fetch_repost_header(
176        &self,
177        pool: &PgPool,
178        id: Uuid,
179    ) -> Result<Option<ReceiptRepostHeaderRow>, sqlx::Error> {
180        let row = org_scope::fetch_optional_row_scoped(
181            pool,
182            sqlx::query(
183                r#"SELECT branch_id, receipt_number, posting_date, currency, total_value,
184                          inventory_account_id, grir_account_id, posting_state::text AS ps, journal_id, accounting_post_id,
185                          warehouse_id
186                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
187            )
188            .bind(id),
189        )
190        .await?;
191        Ok(row.map(|h| ReceiptRepostHeaderRow {
192            branch_id: h.get("branch_id"),
193            receipt_number: h.get("receipt_number"), posting_date: h.get("posting_date"),
194            currency: h.get("currency"),
195            total_value: h.get("total_value"), inventory_account_id: h.get("inventory_account_id"),
196            grir_account_id: h.get("grir_account_id"),
197            warehouse_id: h.get("warehouse_id"),
198            gl: GlSettlementState {
199                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
200                accounting_post_id: h.get("accounting_post_id"),
201            },
202        }))
203    }
204
205    /// Flip the receipt draft→submitted. Must ride the movement's transaction, so the status change
206    /// commits with the SLE + Bin writes it describes.
207    pub async fn mark_submitted(
208        &self,
209        conn: &mut sqlx::PgConnection,
210        id: Uuid,
211    ) -> Result<(), sqlx::Error> {
212        sqlx::query("UPDATE inventory.purchase_receipts SET status='submitted'::doc_status WHERE id=$1")
213            .bind(id)
214            .execute(conn)
215            .await?;
216        Ok(())
217    }
218
219    /// Flip the receipt submitted→cancelled. Must ride the cancellation's transaction, so the status
220    /// change commits with the compensating SLE + Bin writes it describes. Guarded on `submitted` so a
221    /// draft or an already-cancelled receipt is not touched here (the service gates those cases).
222    pub async fn mark_cancelled(
223        &self,
224        conn: &mut sqlx::PgConnection,
225        id: Uuid,
226    ) -> Result<(), sqlx::Error> {
227        sqlx::query("UPDATE inventory.purchase_receipts SET status='cancelled'::doc_status WHERE id=$1 AND status='submitted'::doc_status")
228            .bind(id)
229            .execute(conn)
230            .await?;
231        Ok(())
232    }
233
234    /// Read the cancel path's header. Same ID-only scope fence as [`Self::fetch_submit_header`].
235    pub async fn fetch_cancel_header(
236        &self,
237        pool: &PgPool,
238        id: Uuid,
239    ) -> Result<Option<ReceiptCancelHeaderRow>, sqlx::Error> {
240        let row = org_scope::fetch_optional_row_scoped(
241            pool,
242            sqlx::query(
243                r#"SELECT branch_id, warehouse_id, posting_date, currency, receipt_number,
244                          total_value, status::text AS st, inventory_account_id, grir_account_id,
245                          posting_state::text AS ps, journal_id, accounting_post_id,
246                          reversal_journal_id, reversal_accounting_post_id
247                   FROM inventory.purchase_receipts WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
248            )
249            .bind(id),
250        )
251        .await?;
252        Ok(row.map(|h| ReceiptCancelHeaderRow {
253            branch_id: h.get("branch_id"),
254            warehouse_id: h.get("warehouse_id"), posting_date: h.get("posting_date"),
255            currency: h.get("currency"), receipt_number: h.get("receipt_number"),
256            total_value: h.get("total_value"),
257            status: h.get("st"), inventory_account_id: h.get("inventory_account_id"),
258            grir_account_id: h.get("grir_account_id"),
259            reversal_journal_id: h.get("reversal_journal_id"),
260            reversal_accounting_post_id: h.get("reversal_accounting_post_id"),
261            gl: GlSettlementState {
262                posting_state: h.get("ps"), journal_id: h.get("journal_id"),
263                accounting_post_id: h.get("accounting_post_id"),
264            },
265        }))
266    }
267}
268
269backbone_core::impl_crud_repository!(PurchaseReceiptRepository, PurchaseReceipt, soft_delete);