Skip to main content

backbone_catalog/infrastructure/persistence/
item_repository.rs

1//! Repository for Item 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 catalog write service's item SQL (4-layer rule: services orchestrate, repos hold
6//! SQL).
7//!
8//! Tenant-agnostic (ADR-0029): no statement here names a tenancy column. Statements ride
9//! whatever executor the caller passes — write verbs pass their org-scoped transaction;
10//! request-path reads use the `*_scoped` helpers, which ride the composing host's
11//! request-dedicated connection when one is bound and the plain pool otherwise.
12//!
13//! Thin newtype over `backbone_orm::GenericCrudRepository<Item, backbone_orm::SoftDelete>`.
14//! All standard CRUD methods are available via `Deref`.
15
16use rust_decimal::Decimal;
17use sqlx::{PgConnection, PgPool, Row};
18use uuid::Uuid;
19
20use crate::domain::entity::Item;
21
22/// Table name for Item entities
23pub const TABLE_NAME: &str = "catalog.items";
24
25/// Repository for Item entities.
26///
27/// All standard CRUD, soft-delete, pagination, and bulk methods are
28/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
29pub struct ItemRepository(
30    backbone_orm::GenericCrudRepository<Item, backbone_orm::SoftDelete>,
31);
32
33impl std::ops::Deref for ItemRepository {
34    type Target = backbone_orm::GenericCrudRepository<Item, backbone_orm::SoftDelete>;
35    fn deref(&self) -> &Self::Target { &self.0 }
36}
37
38impl ItemRepository {
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/// A scan resolved to a sellable identity: the item (always) plus the variant if the scanned code
46/// matched a variant SKU/barcode rather than the base item. POS rings against `item_id`.
47///
48/// Returned by [`ItemRepository::find_by_scan_code`] and
49/// [`crate::infrastructure::persistence::ItemVariantRepository::find_variant_by_scan_code`].
50#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
51pub struct ItemHit {
52    pub item_id: Uuid,
53    pub variant_id: Option<Uuid>,
54    pub item_code: String,
55    pub name: String,
56    pub barcode: Option<String>,
57    pub sku: Option<String>,
58}
59
60/// The exact row a validated item insert writes.
61pub struct NewItemRow<'a> {
62    pub id: Uuid,
63    pub item_code: &'a str,
64    pub name: &'a str,
65    pub description: Option<&'a str>,
66    pub barcode: Option<&'a str>,
67    pub brand_id: Option<Uuid>,
68    pub item_group_id: Uuid,
69    pub default_uom_id: Uuid,
70    pub item_type: &'a str,
71    pub is_sales_item: bool,
72    pub is_purchase_item: bool,
73    pub is_stock_item: bool,
74    pub hsn_code: Option<&'a str>,
75    pub is_taxable: bool,
76    pub weight_per_unit: Option<Decimal>,
77    pub standard_cost: Option<Decimal>,
78    pub tags: &'a serde_json::Value,
79    pub data: &'a serde_json::Value,
80}
81
82/// Catalog item SQL. Lives here (not in the service) per the module's 4-layer rule.
83impl ItemRepository {
84    /// Resolve a scanned code (barcode OR item_code) to the base item. Tenant-agnostic: the
85    /// statement runs plain on the caller's pool — under a composed host's org request scope
86    /// it rides the request-dedicated scoped connection, so out-of-scope items simply are
87    /// not found.
88    pub async fn find_by_scan_code(
89        &self,
90        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
91        code: &str,
92    ) -> Result<Option<ItemHit>, sqlx::Error> {
93        let hit = sqlx::query_as::<_, ItemHit>(
94            r#"SELECT id AS item_id, NULL::uuid AS variant_id, item_code, name, barcode, NULL::text AS sku
95               FROM catalog.items
96               WHERE (barcode = $1 OR item_code = $1)
97                 AND (metadata->>'deleted_at') IS NULL
98               LIMIT 1"#,
99        )
100        .bind(code)
101        .fetch_optional(executor)
102        .await?;
103        Ok(hit)
104    }
105
106    /// Scan-code lookup for the request path: rides the composing host's request-dedicated
107    /// connection when one is bound (so the org fence applies), plain pool otherwise.
108    /// Tenant-agnostic — the database fence owns isolation, this picks the lane.
109    pub async fn find_by_scan_code_scoped(
110        &self,
111        pool: &PgPool,
112        code: &str,
113    ) -> Result<Option<ItemHit>, sqlx::Error> {
114        let row = backbone_orm::org_scope::fetch_optional_row_scoped(
115            pool,
116            sqlx::query(
117                r#"SELECT id AS item_id, NULL::uuid AS variant_id, item_code, name, barcode, NULL::text AS sku
118                   FROM catalog.items
119                   WHERE (barcode = $1 OR item_code = $1)
120                     AND (metadata->>'deleted_at') IS NULL
121                   LIMIT 1"#,
122            )
123            .bind(code),
124        )
125        .await?;
126        Ok(row.map(|r| ItemHit {
127            item_id: r.get("item_id"),
128            variant_id: r.get("variant_id"),
129            item_code: r.get("item_code"),
130            name: r.get("name"),
131            barcode: r.get("barcode"),
132            sku: r.get("sku"),
133        }))
134    }
135
136    /// `EXISTS` probe for a live row (replaces the prior string-built `exists_in` helper in
137    /// the write service). Tenant-agnostic: scoped by the caller's connection, never by a
138    /// column here.
139    pub async fn exists_id(
140        &self,
141        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
142        id: Uuid,
143    ) -> Result<bool, sqlx::Error> {
144        let found: Option<Uuid> = sqlx::query_scalar(
145            "SELECT id FROM catalog.items \
146             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
147        )
148        .bind(id)
149        .fetch_optional(executor)
150        .await?;
151        Ok(found.is_some())
152    }
153
154    /// Insert a validated item row. Unique-constraint errors propagate as `sqlx::Error` so
155    /// the service can disambiguate barcode vs item_code duplicates.
156    pub async fn insert_item(
157        &self,
158        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
159        r: &NewItemRow<'_>,
160    ) -> Result<(), sqlx::Error> {
161        sqlx::query(
162            r#"INSERT INTO catalog.items
163                (id, item_code, name, description, barcode, brand_id, item_group_id,
164                 default_uom_id, item_type, is_sales_item, is_purchase_item, is_stock_item,
165                 hsn_code, is_taxable, weight_per_unit, standard_cost, tags, data, status)
166               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::item_type,$10,$11,$12,$13,$14,$15,$16,$17,$18,'active'::catalog_status)"#,
167        )
168        .bind(r.id)
169        .bind(r.item_code)
170        .bind(r.name)
171        .bind(r.description)
172        .bind(r.barcode)
173        .bind(r.brand_id)
174        .bind(r.item_group_id)
175        .bind(r.default_uom_id)
176        .bind(r.item_type)
177        .bind(r.is_sales_item)
178        .bind(r.is_purchase_item)
179        .bind(r.is_stock_item)
180        .bind(r.hsn_code)
181        .bind(r.is_taxable)
182        .bind(r.weight_per_unit)
183        .bind(r.standard_cost)
184        .bind(r.tags)
185        .bind(r.data)
186        .execute(executor)
187        .await?;
188        Ok(())
189    }
190
191    /// Flip `has_variants = TRUE` on the item (in-tx; called from the variant-create tx after the
192    /// variant row is inserted).
193    pub async fn set_has_variants_true(
194        &self,
195        conn: &mut PgConnection,
196        item_id: Uuid,
197    ) -> Result<(), sqlx::Error> {
198        sqlx::query("UPDATE catalog.items SET has_variants = TRUE WHERE id = $1")
199            .bind(item_id)
200            .execute(conn)
201            .await?;
202        Ok(())
203    }
204
205    /// Read an item's current lifecycle status (in-tx). Used by the validated
206    /// status-transition path to enforce the CatalogStatus state machine.
207    pub async fn find_status(
208        &self,
209        conn: &mut PgConnection,
210        item_id: Uuid,
211    ) -> Result<Option<crate::domain::entity::CatalogStatus>, sqlx::Error> {
212        Ok(sqlx::query_scalar("SELECT status FROM catalog.items WHERE id = $1")
213            .bind(item_id)
214            .fetch_optional(conn)
215            .await?)
216    }
217
218    /// Set an item's lifecycle status (in-tx).
219    pub async fn set_status(
220        &self,
221        conn: &mut PgConnection,
222        item_id: Uuid,
223        status: crate::domain::entity::CatalogStatus,
224    ) -> Result<(), sqlx::Error> {
225        sqlx::query("UPDATE catalog.items SET status = $1 WHERE id = $2")
226            .bind(status)
227            .bind(item_id)
228            .execute(conn)
229            .await?;
230        Ok(())
231    }
232
233    /// Flip `has_variants = FALSE` on the item (in-tx; called from the variant-delete tx when no
234    /// live variants remain).
235    pub async fn set_has_variants_false(
236        &self,
237        conn: &mut PgConnection,
238        item_id: Uuid,
239    ) -> Result<(), sqlx::Error> {
240        sqlx::query("UPDATE catalog.items SET has_variants = FALSE WHERE id=$1")
241            .bind(item_id)
242            .execute(conn)
243            .await?;
244        Ok(())
245    }
246}
247
248backbone_core::impl_crud_repository!(ItemRepository, Item, soft_delete);