Skip to main content

backbone_inventory/infrastructure/persistence/
delivery_note_repository.rs

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