Skip to main content

backbone_catalog/infrastructure/persistence/
item_variant_repository.rs

1//! Repository for ItemVariant 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 variant SQL (4-layer rule: services orchestrate, repos
6//! hold 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<ItemVariant, 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::ItemVariant;
21use crate::infrastructure::persistence::item_repository::ItemHit;
22
23/// Table name for ItemVariant entities
24pub const TABLE_NAME: &str = "catalog.item_variants";
25
26/// Repository for ItemVariant entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct ItemVariantRepository(
31    backbone_orm::GenericCrudRepository<ItemVariant, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for ItemVariantRepository {
35    type Target = backbone_orm::GenericCrudRepository<ItemVariant, backbone_orm::SoftDelete>;
36    fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl ItemVariantRepository {
40    /// Create a new repository instance.
41    pub fn new(pool: PgPool) -> Self {
42        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
43    }
44}
45
46/// The exact row a validated item-variant insert writes. `options` is the JSON-encoded
47/// `{attribute_code: value_code}` map validated against the attribute registry by the caller.
48pub struct NewItemVariantRow<'a> {
49    pub id: Uuid,
50    pub item_id: Uuid,
51    pub sku: &'a str,
52    pub variant_label: &'a str,
53    pub options: &'a serde_json::Value,
54    pub barcode: Option<&'a str>,
55    pub is_default: bool,
56    pub weight_per_unit: Option<Decimal>,
57}
58
59/// Catalog variant SQL. Lives here (not in the service) per the module's 4-layer rule.
60impl ItemVariantRepository {
61    /// Resolve a scanned code (barcode OR SKU) to a variant, joined with its parent item for the
62    /// sellable identity. Tenant-agnostic: the statement runs plain on the caller's pool —
63    /// under a composed host's org request scope it rides the request-dedicated scoped
64    /// connection, so out-of-scope variants simply are not found.
65    pub async fn find_variant_by_scan_code(
66        &self,
67        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
68        code: &str,
69    ) -> Result<Option<ItemHit>, sqlx::Error> {
70        let hit = sqlx::query_as::<_, ItemHit>(
71            r#"SELECT v.item_id, v.id AS variant_id, i.item_code, i.name, v.barcode, v.sku
72               FROM catalog.item_variants v JOIN catalog.items i ON i.id = v.item_id
73               WHERE (v.barcode = $1 OR v.sku = $1)
74                 AND (v.metadata->>'deleted_at') IS NULL
75               LIMIT 1"#,
76        )
77        .bind(code)
78        .fetch_optional(executor)
79        .await?;
80        Ok(hit)
81    }
82
83    /// Scan-code variant lookup for the request path: rides the composing host's
84    /// request-dedicated connection when one is bound (so the org fence applies), plain
85    /// pool otherwise. Tenant-agnostic — the database fence owns isolation, this picks the lane.
86    pub async fn find_variant_by_scan_code_scoped(
87        &self,
88        pool: &PgPool,
89        code: &str,
90    ) -> Result<Option<ItemHit>, sqlx::Error> {
91        let row = backbone_orm::org_scope::fetch_optional_row_scoped(
92            pool,
93            sqlx::query(
94                r#"SELECT v.item_id, v.id AS variant_id, i.item_code, i.name, v.barcode, v.sku
95                   FROM catalog.item_variants v JOIN catalog.items i ON i.id = v.item_id
96                   WHERE (v.barcode = $1 OR v.sku = $1)
97                     AND (v.metadata->>'deleted_at') IS NULL
98                   LIMIT 1"#,
99            )
100            .bind(code),
101        )
102        .await?;
103        Ok(row.map(|r| ItemHit {
104            item_id: r.get("item_id"),
105            variant_id: r.get("variant_id"),
106            item_code: r.get("item_code"),
107            name: r.get("name"),
108            barcode: r.get("barcode"),
109            sku: r.get("sku"),
110        }))
111    }
112
113    /// Resolve `item_id` for a live variant (the soft-delete lookup step in
114    /// `CatalogWriteService::delete_item_variant`).
115    pub async fn find_item_id_for_live(
116        &self,
117        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
118        variant_id: Uuid,
119    ) -> Result<Option<Uuid>, sqlx::Error> {
120        let item_id: Option<Uuid> = sqlx::query_scalar(
121            "SELECT item_id FROM catalog.item_variants \
122             WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
123        )
124        .bind(variant_id)
125        .fetch_optional(executor)
126        .await?;
127        Ok(item_id)
128    }
129
130    /// Insert a variant on the caller's tx. Unique-constraint errors propagate as
131    /// `sqlx::Error` so the service can disambiguate barcode vs SKU duplicates.
132    pub async fn insert_variant(
133        &self,
134        conn: &mut PgConnection,
135        r: &NewItemVariantRow<'_>,
136    ) -> Result<(), sqlx::Error> {
137        sqlx::query(
138            r#"INSERT INTO catalog.item_variants
139                (id, item_id, sku, variant_label, options, barcode, is_default, weight_per_unit, status)
140               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active'::catalog_status)"#,
141        )
142        .bind(r.id)
143        .bind(r.item_id)
144        .bind(r.sku)
145        .bind(r.variant_label)
146        .bind(r.options)
147        .bind(r.barcode)
148        .bind(r.is_default)
149        .bind(r.weight_per_unit)
150        .execute(conn)
151        .await?;
152        Ok(())
153    }
154
155    /// Soft-delete a variant on the caller's tx.
156    pub async fn soft_delete_variant(
157        &self,
158        conn: &mut PgConnection,
159        variant_id: Uuid,
160    ) -> Result<(), sqlx::Error> {
161        sqlx::query(
162            "UPDATE catalog.item_variants \
163             SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb(now())) \
164             WHERE id=$1",
165        )
166        .bind(variant_id)
167        .execute(conn)
168        .await?;
169        Ok(())
170    }
171
172    /// Count live variants for an item on the caller's tx (used by `delete_item_variant` to decide
173    /// whether to flip `Item.has_variants` back to FALSE). Returns the COUNT.
174    pub async fn count_live_variants(
175        &self,
176        conn: &mut PgConnection,
177        item_id: Uuid,
178    ) -> Result<i64, sqlx::Error> {
179        let remaining: i64 = sqlx::query_scalar(
180            "SELECT COUNT(*) FROM catalog.item_variants \
181             WHERE item_id=$1 AND (metadata->>'deleted_at') IS NULL",
182        )
183        .bind(item_id)
184        .fetch_one(conn)
185        .await?;
186        Ok(remaining)
187    }
188}
189
190backbone_core::impl_crud_repository!(ItemVariantRepository, ItemVariant, soft_delete);