backbone-catalog 0.7.0

Canonical product/service identity: Item, Item Group, UOM (Indonesia-first)
Documentation
//! Repository for Uom 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 UOM 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 — the write verbs pass their org-scoped
//! transaction, so the composing host's fence applies; on a plain pool (standalone
//! deployment, tests) they see the whole table.
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<Uom, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

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

use crate::domain::entity::Uom;
use crate::domain::services::uom_tree::UomChainNode;

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

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

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

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

/// The exact row a validated UOM insert writes.
pub struct NewUomRow<'a> {
    pub id: Uuid,
    pub code: &'a str,
    pub name: &'a str,
    pub uom_type: &'a str,
    pub decimal_places: i32,
    /// Parent (reference) unit; `None` creates a tree root.
    pub relative_uom_id: Option<Uuid>,
    /// Ratio to the parent; must be `Some(> 0)` exactly when `relative_uom_id` is set.
    pub relative_factor: Option<Decimal>,
    /// Stored effective factor to the tree root (`1` for a root;
    /// `parent.factor * relative_factor` for a child — the service computes it).
    pub factor: Decimal,
}

/// Catalog UOM SQL. Lives here (not in the service) per the module's 4-layer rule.
impl UomRepository {
    /// `EXISTS` probe for a live unit (replaces the prior string-built `exists_in` helper
    /// in the write service). Used for default_uom_id FK validation on create-item and
    /// parent-unit validation on tree writes.
    pub async fn exists_id(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        id: Uuid,
    ) -> Result<bool, sqlx::Error> {
        let found: Option<Uuid> = sqlx::query_scalar(
            "SELECT id FROM catalog.uoms \
             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(id)
        .fetch_optional(executor)
        .await?;
        Ok(found.is_some())
    }

    /// Stored effective factor of one live unit (`None` if the unit does not exist).
    /// Used to compute a child unit's stored factor at insert time.
    pub async fn find_factor(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        id: Uuid,
    ) -> Result<Option<Decimal>, sqlx::Error> {
        let factor: Option<Decimal> = sqlx::query_scalar(
            "SELECT factor FROM catalog.uoms \
             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(id)
        .fetch_optional(executor)
        .await?;
        Ok(factor)
    }

    /// Insert a validated UOM row. Unique-constraint errors propagate as `sqlx::Error` so
    /// the service can disambiguate code duplicates.
    pub async fn insert_uom(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        r: &NewUomRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO catalog.uoms
                   (id, code, name, uom_type, decimal_places,
                    relative_uom_id, relative_factor, factor, status)
               VALUES ($1,$2,$3,$4::uom_type,$5,$6,$7,$8,'active'::catalog_status)"#,
        )
        .bind(r.id)
        .bind(r.code)
        .bind(r.name)
        .bind(r.uom_type)
        .bind(r.decimal_places)
        .bind(r.relative_uom_id)
        .bind(r.relative_factor)
        .bind(r.factor)
        .execute(executor)
        .await?;
        Ok(())
    }

    /// Load `uom` together with its full ancestor chain up to (and including) its tree root.
    ///
    /// The leaf must be a live row (`None` otherwise); ancestors are loaded
    /// regardless of their soft-delete state, so archiving a unit never makes its subtree
    /// unconvertible. Rows come back in arbitrary SQL order — the caller assembles them into
    /// an ordered [`UomChain`] with [`UomChain::from_rows`], which fails loudly on cycles
    /// and dangling links. `UNION` (not `UNION ALL`) bounds the recursion if stored links
    /// are ever corrupt.
    pub async fn load_tree_chain(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        uom: Uuid,
    ) -> Result<Option<Vec<UomChainNode>>, sqlx::Error> {
        let rows: Vec<UomChainNode> = sqlx::query_as(
            r#"WITH RECURSIVE chain AS (
                   SELECT id, code, relative_uom_id, relative_factor, factor
                   FROM catalog.uoms
                   WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
                   UNION
                   SELECT p.id, p.code, p.relative_uom_id, p.relative_factor, p.factor
                   FROM catalog.uoms p
                   JOIN chain ON p.id = chain.relative_uom_id
               )
               SELECT id, code, relative_uom_id, relative_factor, factor FROM chain"#,
        )
        .bind(uom)
        .fetch_all(executor)
        .await?;
        Ok(if rows.is_empty() { None } else { Some(rows) })
    }

    /// Is `candidate` the unit itself or one of its descendants in the tree?
    /// The cycle guard for re-parenting: a unit must never point at its own subtree.
    /// Runs on the caller's transaction connection.
    pub async fn is_self_or_descendant(
        &self,
        conn: &mut PgConnection,
        uom: Uuid,
        candidate: Uuid,
    ) -> Result<bool, sqlx::Error> {
        let hit: bool = sqlx::query_scalar(
            r#"WITH RECURSIVE subtree AS (
                   SELECT id FROM catalog.uoms WHERE id = $1
                   UNION
                   SELECT c.id FROM catalog.uoms c JOIN subtree s ON c.relative_uom_id = s.id
               )
               SELECT EXISTS (SELECT 1 FROM subtree WHERE id = $2)"#,
        )
        .bind(uom)
        .bind(candidate)
        .fetch_one(conn)
        .await?;
        Ok(hit)
    }

    /// Point a live unit at a new parent (or detach it to become a root). The caller has
    /// validated shape/positivity and run the descendant cycle guard; the stored
    /// factor re-derivation happens in [`UomRepository::recompute_factors`].
    pub async fn set_relative(
        &self,
        conn: &mut PgConnection,
        uom: Uuid,
        relative_uom_id: Option<Uuid>,
        relative_factor: Option<Decimal>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            "UPDATE catalog.uoms SET relative_uom_id = $2, relative_factor = $3 \
             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(uom)
        .bind(relative_uom_id)
        .bind(relative_factor)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Re-derive every stored tree factor on the caller's connection by calling the SQL
    /// function installed by the parent-store-tree migration. Idempotent (rows whose
    /// derived factor equals the stored one are not rewritten) and loud: a cycle or
    /// dangling link raises a database error instead of pinning a stale factor.
    pub async fn recompute_factors(
        &self,
        executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("SELECT catalog.uom_recompute_factors()")
            .execute(executor)
            .await?;
        Ok(())
    }

    // ── Protected-unit retire path (UM-4) ─────────────────────────────────────────

    /// Protection flag of one live unit (`None` if the unit does not exist). Protected
    /// rows are module-seeded reference data and refuse deletion.
    pub async fn find_protection(
        &self,
        conn: &mut PgConnection,
        id: Uuid,
    ) -> Result<Option<bool>, sqlx::Error> {
        let protected: Option<bool> = sqlx::query_scalar(
            "SELECT is_protected FROM catalog.uoms \
             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(id)
        .fetch_optional(conn)
        .await?;
        Ok(protected)
    }

    /// Count the unit's live children in the tree — units whose `relative_uom_id`
    /// points here. A unit that is still the live parent of live units must be
    /// re-linked before it can be retired.
    pub async fn count_live_children(
        &self,
        conn: &mut PgConnection,
        uom: Uuid,
    ) -> Result<i64, sqlx::Error> {
        let n: i64 = sqlx::query_scalar(
            "SELECT count(*) FROM catalog.uoms \
             WHERE relative_uom_id = $1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(uom)
        .fetch_one(conn)
        .await?;
        Ok(n)
    }

    /// Does any live item still use this unit as its default? Retiring a referenced
    /// unit would orphan the item's unit resolution (the reason generic delete is not
    /// mounted for Uom — ADR-005).
    pub async fn exists_live_item_using(
        &self,
        conn: &mut PgConnection,
        uom: Uuid,
    ) -> Result<bool, sqlx::Error> {
        let hit: bool = sqlx::query_scalar(
            "SELECT EXISTS ( \
                 SELECT 1 FROM catalog.items \
                 WHERE default_uom_id = $1 \
                   AND (metadata->>'deleted_at') IS NULL )",
        )
        .bind(uom)
        .fetch_one(conn)
        .await?;
        Ok(hit)
    }

    /// Archive (soft-delete) a unit the validated retire path has cleared. The
    /// storage-level protected-unit guard is the backstop if a protected row ever
    /// reaches this UPDATE.
    pub async fn soft_delete_uom(
        &self,
        conn: &mut PgConnection,
        uom: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            "UPDATE catalog.uoms \
             SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb(now())) \
             WHERE id = $1",
        )
        .bind(uom)
        .execute(conn)
        .await?;
        Ok(())
    }
}

backbone_core::impl_crud_repository!(UomRepository, Uom, soft_delete);