backbone-catalog 0.7.0

Canonical product/service identity: Item, Item Group, UOM (Indonesia-first)
Documentation
//! Repository for ItemVariant entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the catalog write service's variant SQL (4-layer rule: services orchestrate, repos
//! hold SQL).
//!
//! Tenant-agnostic (ADR-0029): no statement here names a tenancy column. Statements ride
//! whatever executor the caller passes — write verbs pass their org-scoped transaction;
//! request-path reads use the `*_scoped` helpers, which ride the composing host's
//! request-dedicated connection when one is bound and the plain pool otherwise.
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<ItemVariant, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use rust_decimal::Decimal;
use sqlx::{PgConnection, PgPool, Row};
use uuid::Uuid;

use crate::domain::entity::ItemVariant;
use crate::infrastructure::persistence::item_repository::ItemHit;

/// Table name for ItemVariant entities
pub const TABLE_NAME: &str = "catalog.item_variants";

/// Repository for ItemVariant entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct ItemVariantRepository(
    backbone_orm::GenericCrudRepository<ItemVariant, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for ItemVariantRepository {
    type Target = backbone_orm::GenericCrudRepository<ItemVariant, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl ItemVariantRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a validated item-variant insert writes. `options` is the JSON-encoded
/// `{attribute_code: value_code}` map validated against the attribute registry by the caller.
pub struct NewItemVariantRow<'a> {
    pub id: Uuid,
    pub item_id: Uuid,
    pub sku: &'a str,
    pub variant_label: &'a str,
    pub options: &'a serde_json::Value,
    pub barcode: Option<&'a str>,
    pub is_default: bool,
    pub weight_per_unit: Option<Decimal>,
}

/// Catalog variant SQL. Lives here (not in the service) per the module's 4-layer rule.
impl ItemVariantRepository {
    /// Resolve a scanned code (barcode OR SKU) to a variant, joined with its parent item for the
    /// sellable identity. Tenant-agnostic: the statement runs plain on the caller's pool —
    /// under a composed host's org request scope it rides the request-dedicated scoped
    /// connection, so out-of-scope variants simply are not found.
    pub async fn find_variant_by_scan_code(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        code: &str,
    ) -> Result<Option<ItemHit>, sqlx::Error> {
        let hit = sqlx::query_as::<_, ItemHit>(
            r#"SELECT v.item_id, v.id AS variant_id, i.item_code, i.name, v.barcode, v.sku
               FROM catalog.item_variants v JOIN catalog.items i ON i.id = v.item_id
               WHERE (v.barcode = $1 OR v.sku = $1)
                 AND (v.metadata->>'deleted_at') IS NULL
               LIMIT 1"#,
        )
        .bind(code)
        .fetch_optional(executor)
        .await?;
        Ok(hit)
    }

    /// Scan-code variant lookup for the request path: rides the composing host's
    /// request-dedicated connection when one is bound (so the org fence applies), plain
    /// pool otherwise. Tenant-agnostic — the database fence owns isolation, this picks the lane.
    pub async fn find_variant_by_scan_code_scoped(
        &self,
        pool: &PgPool,
        code: &str,
    ) -> Result<Option<ItemHit>, sqlx::Error> {
        let row = backbone_orm::org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT v.item_id, v.id AS variant_id, i.item_code, i.name, v.barcode, v.sku
                   FROM catalog.item_variants v JOIN catalog.items i ON i.id = v.item_id
                   WHERE (v.barcode = $1 OR v.sku = $1)
                     AND (v.metadata->>'deleted_at') IS NULL
                   LIMIT 1"#,
            )
            .bind(code),
        )
        .await?;
        Ok(row.map(|r| ItemHit {
            item_id: r.get("item_id"),
            variant_id: r.get("variant_id"),
            item_code: r.get("item_code"),
            name: r.get("name"),
            barcode: r.get("barcode"),
            sku: r.get("sku"),
        }))
    }

    /// Resolve `item_id` for a live variant (the soft-delete lookup step in
    /// `CatalogWriteService::delete_item_variant`).
    pub async fn find_item_id_for_live(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        variant_id: Uuid,
    ) -> Result<Option<Uuid>, sqlx::Error> {
        let item_id: Option<Uuid> = sqlx::query_scalar(
            "SELECT item_id FROM catalog.item_variants \
             WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(variant_id)
        .fetch_optional(executor)
        .await?;
        Ok(item_id)
    }

    /// Insert a variant on the caller's tx. Unique-constraint errors propagate as
    /// `sqlx::Error` so the service can disambiguate barcode vs SKU duplicates.
    pub async fn insert_variant(
        &self,
        conn: &mut PgConnection,
        r: &NewItemVariantRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO catalog.item_variants
                (id, item_id, sku, variant_label, options, barcode, is_default, weight_per_unit, status)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active'::catalog_status)"#,
        )
        .bind(r.id)
        .bind(r.item_id)
        .bind(r.sku)
        .bind(r.variant_label)
        .bind(r.options)
        .bind(r.barcode)
        .bind(r.is_default)
        .bind(r.weight_per_unit)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Soft-delete a variant on the caller's tx.
    pub async fn soft_delete_variant(
        &self,
        conn: &mut PgConnection,
        variant_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            "UPDATE catalog.item_variants \
             SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb(now())) \
             WHERE id=$1",
        )
        .bind(variant_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Count live variants for an item on the caller's tx (used by `delete_item_variant` to decide
    /// whether to flip `Item.has_variants` back to FALSE). Returns the COUNT.
    pub async fn count_live_variants(
        &self,
        conn: &mut PgConnection,
        item_id: Uuid,
    ) -> Result<i64, sqlx::Error> {
        let remaining: i64 = sqlx::query_scalar(
            "SELECT COUNT(*) FROM catalog.item_variants \
             WHERE item_id=$1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(item_id)
        .fetch_one(conn)
        .await?;
        Ok(remaining)
    }
}

backbone_core::impl_crud_repository!(ItemVariantRepository, ItemVariant, soft_delete);