Skip to main content

backbone_catalog/application/service/
catalog_write_service.rs

1//! Validated write path for Item, ItemGroup, and the UoM tree — hand-authored (user-owned).
2//!
3//! Closes the CRUD-bypass: the generated 12-endpoint CRUD writes rows through `GenericCrudService`
4//! with NO domain validation, so a well-formed request could create an Item pointing at a
5//! non-existent item group or UOM, an Item that is neither sellable/purchasable/stocked, or an
6//! item-group whose parent is missing.
7//!
8//! Units of measure form parent-store reference trees (ADR-0023): a unit either is a tree root
9//! or points at a reference unit with a positive ratio, and every unit carries a recursive stored
10//! factor to its tree's root that this service re-derives on every tree write. Conversion between
11//! units is application-side math over those stored factors (`convert_quantity`) and fails loudly
12//! with a typed error when the two units live in different trees. The pre-v19 pairwise
13//! conversion-table write path is retired by that rewrite: the `uom_conversions` table and its
14//! read surface remain for legacy data and future item-specific layering, but it is no longer an
15//! input to conversion.
16//!
17//! Tenant-agnostic (ADR-0029): the service carries no tenancy of its own. Statements run plain
18//! on the module pool; when the composing host mounts a request-scoped org fence
19//! (`backbone_orm::org_scope::with_org_request_scope`), plain pool reads and writes ride the
20//! request-dedicated scoped connection and only in-scope rows are visible. Transactions this
21//! service opens itself re-bind the caller's ambient org scope when one is present
22//! ([`CatalogWriteService::relay_ambient_scope`]) and stay plain otherwise, so the module
23//! functions undecorated (standalone deployment, background jobs).
24//!
25//! `CatalogModule` mounts these validated writers via `create_guarded_catalog_routes`.
26//!
27//! All SQL lives in the repository newtypes (`item_repository.rs`, `item_group_repository.rs`,
28//! `item_variant_repository.rs`, `uom_repository.rs`, `attribute_repository.rs`,
29//! `attribute_value_repository.rs`, `brand_repository.rs` — each declared `user_owned` in
30//! `metaphor.codegen.yaml`). This service orchestrates the validated writes: usage-flag checks,
31//! FK existence probes, unique-constraint disambiguation, the in-tx variant lifecycle
32//! (`has_variants` flag flips + soft-delete), and the UoM tree link + factor re-derivation.
33
34use backbone_orm::org_scope;
35use rust_decimal::Decimal;
36use sqlx::PgPool;
37use uuid::Uuid;
38
39use crate::domain::entity::CatalogStatus;
40use crate::domain::services::uom_tree::{
41    convert_quantity, ConversionRounding, UomChain, UomConversionError,
42};
43
44// Re-export `ItemHit` so the service's public API surface (`application::service::ItemHit`) stays
45// stable now that the type itself lives next to the SQL that produces it.
46pub use crate::infrastructure::persistence::ItemHit;
47use crate::infrastructure::persistence::{
48    AttributeRepository, AttributeValueRepository, BrandRepository, ItemGroupRepository,
49    ItemRepository, ItemVariantRepository, NewAttributeRow, NewAttributeValueRow, NewBrandRow,
50    NewItemGroupRow, NewItemRow, NewItemVariantRow, NewUomRow, UomRepository,
51};
52
53#[derive(Debug)]
54pub enum CatalogWriteError {
55    ItemGroupNotFound(Uuid),
56    UomNotFound(Uuid),
57    ParentNotFound(Uuid),
58    NoUsageFlag,
59    DuplicateItemCode(String),
60    DuplicateBarcode(String),
61    // Attributes & variants
62    AttributeNotFound(Uuid),
63    BrandNotFound(Uuid),
64    ItemNotFound(Uuid),
65    ItemVariantNotFound(Uuid),
66    /// A status transition the CatalogStatus state machine does not permit (e.g.
67    /// `discontinued → active` — `discontinued` is terminal). See schema/hooks/catalog.hook.yaml.
68    InvalidStatusTransition { from: CatalogStatus, to: CatalogStatus },
69    DuplicateUomCode(String),
70    DuplicateBrandCode(String),
71    DuplicateAttributeCode(String),
72    DuplicateValueCode(String),
73    DuplicateSku(String),
74    NoOptions,
75    UnknownAttribute(String),
76    UnknownAttributeValue(String),
77    /// `relative_factor` was supplied without `relative_uom_id` (or vice versa): the tree
78    /// link shape is exactly "both set" or "both unset" (ADR-0023).
79    RelativeShapeMismatch,
80    /// A tree link ratio of zero or less (the stored factor chain must stay positive).
81    NonPositiveRelativeFactor,
82    /// Re-parenting a unit onto itself or one of its own descendants — that link would
83    /// form a cycle, so no root (and no derivable factor) exists anymore.
84    UomCycle { parent: Uuid },
85    /// A conversion between units of different trees (or over a corrupt tree) failed
86    /// loudly — the typed ADR-0023 failure, never a silent numeric result.
87    Conversion(UomConversionError),
88    /// The unit is module-seeded reference data (`is_protected`) and cannot be deleted;
89    /// retire it through the status lifecycle instead (UM-4).
90    ProtectedUom { code: String },
91    /// The unit is still the live parent of live units — re-link or detach the children
92    /// before retiring it, or the tree would lean on an archived reference.
93    UomHasChildren { code: String },
94    /// A live item still uses the unit as its default unit of measure (the orphaning
95    /// hazard that keeps generic delete off the guarded surface — ADR-005).
96    UomInUse { code: String },
97    Db(sqlx::Error),
98}
99
100impl CatalogWriteError {
101    pub fn code(&self) -> &'static str {
102        match self {
103            CatalogWriteError::ItemGroupNotFound(_) => "item_group_not_found",
104            CatalogWriteError::UomNotFound(_) => "uom_not_found",
105            CatalogWriteError::ParentNotFound(_) => "parent_not_found",
106            CatalogWriteError::NoUsageFlag => "no_usage_flag",
107            CatalogWriteError::DuplicateItemCode(_) => "duplicate_item_code",
108            CatalogWriteError::DuplicateBarcode(_) => "duplicate_barcode",
109            CatalogWriteError::AttributeNotFound(_) => "attribute_not_found",
110            CatalogWriteError::BrandNotFound(_) => "brand_not_found",
111            CatalogWriteError::ItemNotFound(_) => "item_not_found",
112            CatalogWriteError::ItemVariantNotFound(_) => "item_variant_not_found",
113            CatalogWriteError::InvalidStatusTransition { .. } => "invalid_status_transition",
114            CatalogWriteError::DuplicateUomCode(_) => "duplicate_uom_code",
115            CatalogWriteError::DuplicateBrandCode(_) => "duplicate_brand_code",
116            CatalogWriteError::DuplicateAttributeCode(_) => "duplicate_attribute_code",
117            CatalogWriteError::DuplicateValueCode(_) => "duplicate_value_code",
118            CatalogWriteError::DuplicateSku(_) => "duplicate_sku",
119            CatalogWriteError::NoOptions => "no_options",
120            CatalogWriteError::UnknownAttribute(_) => "unknown_attribute",
121            CatalogWriteError::UnknownAttributeValue(_) => "unknown_attribute_value",
122            CatalogWriteError::RelativeShapeMismatch => "relative_shape_mismatch",
123            CatalogWriteError::NonPositiveRelativeFactor => "non_positive_relative_factor",
124            CatalogWriteError::UomCycle { .. } => "uom_cycle",
125            CatalogWriteError::Conversion(e) => e.code(),
126            CatalogWriteError::ProtectedUom { .. } => "protected_uom",
127            CatalogWriteError::UomHasChildren { .. } => "uom_has_children",
128            CatalogWriteError::UomInUse { .. } => "uom_in_use",
129            CatalogWriteError::Db(_) => "internal_error",
130        }
131    }
132    pub fn http_status(&self) -> u16 {
133        match self {
134            CatalogWriteError::Db(_) => 500,
135            _ => 422,
136        }
137    }
138}
139impl std::fmt::Display for CatalogWriteError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{}", self.code())?;
142        match self {
143            CatalogWriteError::ItemGroupNotFound(id)
144            | CatalogWriteError::UomNotFound(id)
145            | CatalogWriteError::ParentNotFound(id) => write!(f, ": {id}"),
146            CatalogWriteError::DuplicateItemCode(v)
147            | CatalogWriteError::DuplicateBarcode(v)
148            | CatalogWriteError::DuplicateAttributeCode(v)
149            | CatalogWriteError::DuplicateValueCode(v)
150            | CatalogWriteError::DuplicateSku(v)
151            | CatalogWriteError::DuplicateUomCode(v)
152            | CatalogWriteError::DuplicateBrandCode(v)
153            | CatalogWriteError::UnknownAttribute(v)
154            | CatalogWriteError::UnknownAttributeValue(v) => write!(f, ": {v}"),
155            CatalogWriteError::AttributeNotFound(id)
156            | CatalogWriteError::BrandNotFound(id)
157            | CatalogWriteError::ItemNotFound(id)
158            | CatalogWriteError::ItemVariantNotFound(id) => write!(f, ": {id}"),
159            CatalogWriteError::InvalidStatusTransition { from, to } => write!(f, ": {from:?} -> {to:?}"),
160            CatalogWriteError::UomCycle { parent } => write!(f, ": {parent}"),
161            CatalogWriteError::Conversion(e) => write!(f, ": {e}"),
162            CatalogWriteError::ProtectedUom { code }
163            | CatalogWriteError::UomHasChildren { code }
164            | CatalogWriteError::UomInUse { code } => write!(f, ": {code}"),
165            _ => Ok(()),
166        }
167    }
168}
169impl std::error::Error for CatalogWriteError {}
170impl From<sqlx::Error> for CatalogWriteError {
171    fn from(e: sqlx::Error) -> Self {
172        CatalogWriteError::Db(e)
173    }
174}
175
176#[derive(Debug, Clone)]
177pub struct NewItemGroup {
178    pub code: String,
179    pub name: String,
180    pub parent_id: Option<Uuid>,
181    pub is_group: bool,
182}
183
184#[derive(Debug, Clone)]
185pub struct NewItem {
186    pub item_code: String,
187    pub name: String,
188    pub description: Option<String>,
189    pub barcode: Option<String>,
190    pub brand_id: Option<Uuid>,
191    pub item_group_id: Uuid,
192    pub default_uom_id: Uuid,
193    pub item_type: Option<String>,
194    pub is_sales_item: bool,
195    pub is_purchase_item: bool,
196    pub is_stock_item: bool,
197    pub hsn_code: Option<String>,
198    pub is_taxable: bool,
199    pub weight_per_unit: Option<Decimal>,
200    pub standard_cost: Option<Decimal>,
201    pub tags: Option<serde_json::Value>,
202    pub data: Option<serde_json::Value>,
203}
204
205/// Physical (stockable-capable) item types. Non-physical types are never stockable.
206pub fn is_physical_item_type(item_type: &str) -> bool {
207    matches!(item_type, "physical_good" | "bundle" | "rental")
208}
209
210#[derive(Debug, Clone)]
211pub struct NewAttribute {
212    pub code: String,
213    pub name: String,
214    pub attribute_type: Option<String>,
215}
216
217#[derive(Debug, Clone)]
218pub struct NewAttributeValue {
219    pub attribute_id: Uuid,
220    pub code: String,
221    pub label: String,
222    pub label_en: Option<String>,
223    pub swatch_hex: Option<String>,
224    pub sort_order: i32,
225}
226
227/// A new unit of measure. Leaving `relative_uom_id`/`relative_factor` unset creates a
228/// tree ROOT; setting exactly one of the two is a shape error (`RelativeShapeMismatch`).
229/// The stored `factor` is derived by the service (parent's factor × relative_factor),
230/// never supplied here.
231#[derive(Debug, Clone)]
232pub struct NewUom {
233    pub code: String,
234    pub name: String,
235    pub uom_type: Option<String>,
236    pub decimal_places: i32,
237    /// Parent (reference) unit — `None` makes this unit a tree root.
238    pub relative_uom_id: Option<Uuid>,
239    /// Ratio to the parent: 1 of the new unit = `relative_factor` of the parent.
240    pub relative_factor: Option<Decimal>,
241}
242
243#[derive(Debug, Clone)]
244pub struct NewBrand {
245    pub code: String,
246    pub name: String,
247    pub short_description: Option<String>,
248    pub description: Option<String>,
249    pub logo_url: Option<String>,
250    pub sort_order: i32,
251}
252
253#[derive(Debug, Clone)]
254pub struct NewItemVariant {
255    pub item_id: Uuid,
256    pub sku: String,
257    pub variant_label: Option<String>,
258    /// `{attribute_code: value_code}` — validated against the Attribute registry.
259    pub options: std::collections::BTreeMap<String, String>,
260    pub barcode: Option<String>,
261    pub is_default: bool,
262    pub weight_per_unit: Option<Decimal>,
263}
264
265#[derive(Clone)]
266pub struct CatalogWriteService {
267    db_pool: PgPool,
268}
269
270impl CatalogWriteService {
271    pub fn new(db_pool: PgPool) -> Self {
272        Self { db_pool }
273    }
274
275    /// Re-bind the caller's ambient org scope onto a transaction this service opened itself.
276    ///
277    /// Plain pool operations never inherit the composing host's request scope — sqlx
278    /// acquires a fresh, unfenced connection for them, so under a decorated host an insert
279    /// would lose the acting-unit DEFAULT and a read would see nothing at all. Every write
280    /// verb therefore opens its own transaction and re-binds the ambient scope (if any)
281    /// transaction-locally. With no ambient scope (standalone deployment, background jobs)
282    /// the transaction stays plain — the module functions undecorated.
283    async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
284        if let Some(scope) = org_scope::current_org_scope() {
285            org_scope::bind_org_scope_on(conn, &scope).await?;
286        }
287        Ok(())
288    }
289
290    /// Resolve a scanned code (barcode OR SKU/item_code) to a sellable identity. Matches the base item
291    /// first (by `barcode` or `item_code`), then a variant (by `barcode` or `sku`). `None` = unknown
292    /// code. Tenant-agnostic: the lookups ride the request-dedicated scoped connection when the
293    /// composed host mounts one, so out-of-scope rows are simply not found.
294    pub async fn lookup_item(&self, code: &str) -> Result<Option<ItemHit>, CatalogWriteError> {
295        let items = ItemRepository::new(self.db_pool.clone());
296        if let Some(hit) = items.find_by_scan_code_scoped(&self.db_pool, code).await? {
297            return Ok(Some(hit));
298        }
299        let variants = ItemVariantRepository::new(self.db_pool.clone());
300        let hit = variants
301            .find_variant_by_scan_code_scoped(&self.db_pool, code)
302            .await?;
303        Ok(hit)
304    }
305
306    fn is_dup(e: &sqlx::Error, needle: &str) -> bool {
307        e.as_database_error()
308            .map(|d| d.is_unique_violation() && d.constraint().unwrap_or("").contains(needle))
309            .unwrap_or(false)
310    }
311
312    pub async fn create_item_group(&self, g: NewItemGroup) -> Result<Uuid, CatalogWriteError> {
313        let item_groups = ItemGroupRepository::new(self.db_pool.clone());
314        let mut tx = self.db_pool.begin().await?;
315        Self::relay_ambient_scope(&mut tx).await?;
316        if let Some(pid) = g.parent_id {
317            if !item_groups.exists_id(&mut *tx, pid).await? {
318                return Err(CatalogWriteError::ParentNotFound(pid));
319            }
320        }
321        let id = Uuid::new_v4();
322        let r = item_groups
323            .insert_item_group(
324                &mut *tx,
325                &NewItemGroupRow {
326                    id,
327                    code: &g.code,
328                    name: &g.name,
329                    parent_id: g.parent_id,
330                    is_group: g.is_group,
331                },
332            )
333            .await;
334        match r {
335            Ok(_) => {
336                tx.commit().await?;
337                Ok(id)
338            }
339            Err(e) if Self::is_dup(&e, "code") => Err(CatalogWriteError::DuplicateItemCode(g.code)),
340            Err(e) => Err(e.into()),
341        }
342    }
343
344    pub async fn create_item(&self, i: NewItem) -> Result<Uuid, CatalogWriteError> {
345        let item_type = i.item_type.clone().unwrap_or_else(|| "physical_good".to_string());
346        // Non-physical types (digital/service/subscription/gift_card) are never stockable —
347        // derive it from the type rather than trusting the caller's flag.
348        let is_stock_item = i.is_stock_item && is_physical_item_type(&item_type);
349        if !(i.is_sales_item || i.is_purchase_item || is_stock_item) {
350            return Err(CatalogWriteError::NoUsageFlag);
351        }
352        let item_groups = ItemGroupRepository::new(self.db_pool.clone());
353        let uoms = UomRepository::new(self.db_pool.clone());
354        let mut tx = self.db_pool.begin().await?;
355        Self::relay_ambient_scope(&mut tx).await?;
356        if !item_groups.exists_id(&mut *tx, i.item_group_id).await? {
357            return Err(CatalogWriteError::ItemGroupNotFound(i.item_group_id));
358        }
359        if !uoms.exists_id(&mut *tx, i.default_uom_id).await? {
360            return Err(CatalogWriteError::UomNotFound(i.default_uom_id));
361        }
362        if let Some(bid) = i.brand_id {
363            let brands = BrandRepository::new(self.db_pool.clone());
364            if !brands.exists_id(&mut *tx, bid).await? {
365                return Err(CatalogWriteError::BrandNotFound(bid));
366            }
367        }
368        let id = Uuid::new_v4();
369        let tags = i.tags.clone().unwrap_or_else(|| serde_json::json!([]));
370        let data = i.data.clone().unwrap_or_else(|| serde_json::json!({}));
371        let items = ItemRepository::new(self.db_pool.clone());
372        let r = items
373            .insert_item(
374                &mut *tx,
375                &NewItemRow {
376                    id,
377                    item_code: &i.item_code,
378                    name: &i.name,
379                    description: i.description.as_deref(),
380                    barcode: i.barcode.as_deref(),
381                    brand_id: i.brand_id,
382                    item_group_id: i.item_group_id,
383                    default_uom_id: i.default_uom_id,
384                    item_type: &item_type,
385                    is_sales_item: i.is_sales_item,
386                    is_purchase_item: i.is_purchase_item,
387                    is_stock_item,
388                    hsn_code: i.hsn_code.as_deref(),
389                    is_taxable: i.is_taxable,
390                    weight_per_unit: i.weight_per_unit,
391                    standard_cost: i.standard_cost,
392                    tags: &tags,
393                    data: &data,
394                },
395            )
396            .await;
397        match r {
398            Ok(_) => {
399                tx.commit().await?;
400                Ok(id)
401            }
402            Err(e) if Self::is_dup(&e, "barcode") => Err(CatalogWriteError::DuplicateBarcode(
403                i.barcode.unwrap_or_default(),
404            )),
405            Err(e) if Self::is_dup(&e, "item_code") || Self::is_dup(&e, "items") => {
406                Err(CatalogWriteError::DuplicateItemCode(i.item_code))
407            }
408            Err(e) => Err(e.into()),
409        }
410    }
411
412    /// Create a Uom (leaf master) on the parent-store tree (ADR-0023). Validated create so
413    /// the guarded surface can mount Uom read-only (generic delete/patch would orphan items
414    /// that FK-point at it — council 2026-07-01). With `relative_uom_id` set the unit becomes
415    /// a child of that reference unit and its stored factor is derived as
416    /// `parent.factor * relative_factor`; without it the unit is a new tree root (factor 1).
417    pub async fn create_uom(&self, u: NewUom) -> Result<Uuid, CatalogWriteError> {
418        let repo = UomRepository::new(self.db_pool.clone());
419        let mut tx = self.db_pool.begin().await?;
420        Self::relay_ambient_scope(&mut tx).await?;
421        let (relative_uom_id, relative_factor, factor) = match (u.relative_uom_id, u.relative_factor) {
422            (None, None) => (None, None, Decimal::ONE),
423            (Some(parent), Some(rf)) => {
424                if rf <= Decimal::ZERO {
425                    return Err(CatalogWriteError::NonPositiveRelativeFactor);
426                }
427                if !repo.exists_id(&mut *tx, parent).await? {
428                    return Err(CatalogWriteError::ParentNotFound(parent));
429                }
430                // A new unit has no descendants yet, so its stored factor is exactly
431                // the parent's stored factor scaled by the link ratio.
432                let parent_factor = repo
433                    .find_factor(&mut *tx, parent)
434                    .await?
435                    .ok_or(CatalogWriteError::ParentNotFound(parent))?;
436                (Some(parent), Some(rf), parent_factor * rf)
437            }
438            (Some(_), None) | (None, Some(_)) => {
439                return Err(CatalogWriteError::RelativeShapeMismatch);
440            }
441        };
442        let id = Uuid::new_v4();
443        let ut = u.uom_type.clone().unwrap_or_else(|| "count".to_string());
444        let r = repo
445            .insert_uom(
446                &mut *tx,
447                &NewUomRow {
448                    id,
449                    code: &u.code,
450                    name: &u.name,
451                    uom_type: &ut,
452                    decimal_places: u.decimal_places,
453                    relative_uom_id,
454                    relative_factor,
455                    factor,
456                },
457            )
458            .await;
459        match r {
460            Ok(_) => {
461                // A tree write ends with the declared derive: re-derive every stored
462                // factor from the roots (heals any drift the new row would otherwise
463                // inherit from a tampered parent, and raises loudly if any chain is
464                // unreachable from a root). The insert and the derive share one
465                // transaction, so a failing derive rolls the new row back instead of
466                // committing drift.
467                repo.recompute_factors(&mut *tx).await?;
468                tx.commit().await?;
469                Ok(id)
470            }
471            Err(e) if Self::is_dup(&e, "code") => Err(CatalogWriteError::DuplicateUomCode(u.code)),
472            Err(e) => Err(e.into()),
473        }
474    }
475
476    /// Re-parent a live unit onto a new reference unit (or detach it to become a tree root
477    /// by passing `None`). After the link changes, every stored factor in the affected tree
478    /// is re-derived (the unit's own and all its descendants'). Guards:
479    /// shape (both fields together), positivity, parent existence, and the
480    /// cycle rule — a unit may never point at itself or its own descendant.
481    pub async fn set_uom_relative(
482        &self,
483        uom_id: Uuid,
484        relative: Option<(Uuid, Decimal)>,
485    ) -> Result<(), CatalogWriteError> {
486        let (relative_uom_id, relative_factor) = match relative {
487            None => (None, None),
488            Some((parent, rf)) => {
489                if rf <= Decimal::ZERO {
490                    return Err(CatalogWriteError::NonPositiveRelativeFactor);
491                }
492                (Some(parent), Some(rf))
493            }
494        };
495        let uoms = UomRepository::new(self.db_pool.clone());
496        let mut tx = self.db_pool.begin().await?;
497        Self::relay_ambient_scope(&mut tx).await?;
498        // Pre-validation reads run on the scoped transaction connection so the fence sees
499        // the caller's scope; the mutation and the factor re-derivation below share the
500        // same committed unit of work.
501        if !uoms.exists_id(&mut *tx, uom_id).await? {
502            return Err(CatalogWriteError::UomNotFound(uom_id));
503        }
504        if let Some(parent) = relative_uom_id {
505            if !uoms.exists_id(&mut *tx, parent).await? {
506                return Err(CatalogWriteError::ParentNotFound(parent));
507            }
508            // The cycle rule: linking at yourself or any descendant would leave the
509            // subtree with no root — fail loudly before touching any row.
510            if uoms.is_self_or_descendant(&mut *tx, uom_id, parent).await? {
511                return Err(CatalogWriteError::UomCycle { parent });
512            }
513        }
514        uoms.set_relative(&mut *tx, uom_id, relative_uom_id, relative_factor)
515            .await?;
516        // Re-derive the stored factors for this unit and its subtree. The function also
517        // raises on unreachable rows (cycle/dangling) as the storage-side backstop.
518        uoms.recompute_factors(&mut *tx).await?;
519        tx.commit().await?;
520        Ok(())
521    }
522
523    /// Convert `qty` from one unit to another over the parent-store tree (ADR-0023).
524    ///
525    /// Application-side math over the stored factors: `qty * factor_from / factor_to`,
526    /// with the rounding policy declared by the caller. Units in different trees fail
527    /// LOUDLY with a typed error naming both trees — never a silent numeric result.
528    pub async fn convert_quantity(
529        &self,
530        qty: Decimal,
531        from_uom: Uuid,
532        to_uom: Uuid,
533        rounding: ConversionRounding,
534    ) -> Result<Decimal, CatalogWriteError> {
535        let uoms = UomRepository::new(self.db_pool.clone());
536        let from_rows = uoms
537            .load_tree_chain(&self.db_pool, from_uom)
538            .await?
539            .ok_or(CatalogWriteError::UomNotFound(from_uom))?;
540        let to_rows = uoms
541            .load_tree_chain(&self.db_pool, to_uom)
542            .await?
543            .ok_or(CatalogWriteError::UomNotFound(to_uom))?;
544        let from = UomChain::from_rows(from_uom, from_rows).map_err(CatalogWriteError::Conversion)?;
545        let to = UomChain::from_rows(to_uom, to_rows).map_err(CatalogWriteError::Conversion)?;
546        convert_quantity(qty, &from, &to, rounding).map_err(CatalogWriteError::Conversion)
547    }
548
549    /// Retire (soft-delete) a unit of measure through the validated path (UM-4).
550    ///
551    /// User-created units are deletable once nothing leans on them; module-seeded
552    /// reference units (`is_protected`) refuse deletion with a typed error — retire
553    /// those through the status lifecycle (`active -> inactive`) instead. The guards,
554    /// in order: the unit must exist and be live, must not be protected, must not be
555    /// the live parent of live units, and must not be the default unit of any live
556    /// item (the orphaning hazard that keeps generic delete off the guarded surface,
557    /// ADR-005). The database-level protected-unit triggers are the backstop if a
558    /// protected row ever slips past the service check.
559    pub async fn delete_uom(&self, uom_id: Uuid) -> Result<(), CatalogWriteError> {
560        let uoms = UomRepository::new(self.db_pool.clone());
561        let mut tx = self.db_pool.begin().await?;
562        Self::relay_ambient_scope(&mut tx).await?;
563
564        // All probes run on the transaction connection so the protection state cannot
565        // change between the checks and the archive write.
566        let code: Option<String> = sqlx::query_scalar(
567            "SELECT code FROM catalog.uoms \
568             WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
569        )
570        .bind(uom_id)
571        .fetch_optional(&mut *tx)
572        .await?;
573        let code = code.ok_or(CatalogWriteError::UomNotFound(uom_id))?;
574
575        if uoms.find_protection(&mut *tx, uom_id).await? != Some(false) {
576            return Err(CatalogWriteError::ProtectedUom { code });
577        }
578        if uoms.count_live_children(&mut *tx, uom_id).await? > 0 {
579            return Err(CatalogWriteError::UomHasChildren { code });
580        }
581        if uoms.exists_live_item_using(&mut *tx, uom_id).await? {
582            return Err(CatalogWriteError::UomInUse { code });
583        }
584
585        uoms.soft_delete_uom(&mut *tx, uom_id).await?;
586        tx.commit().await?;
587        Ok(())
588    }
589
590    pub async fn create_attribute(&self, a: NewAttribute) -> Result<Uuid, CatalogWriteError> {
591        let id = Uuid::new_v4();
592        let at = a.attribute_type.clone().unwrap_or_else(|| "other".to_string());
593        let repo = AttributeRepository::new(self.db_pool.clone());
594        let mut tx = self.db_pool.begin().await?;
595        Self::relay_ambient_scope(&mut tx).await?;
596        let r = repo
597            .insert_attribute(
598                &mut *tx,
599                &NewAttributeRow {
600                    id,
601                    code: &a.code,
602                    name: &a.name,
603                    attribute_type: &at,
604                },
605            )
606            .await;
607        match r {
608            Ok(_) => {
609                tx.commit().await?;
610                Ok(id)
611            }
612            Err(e) if Self::is_dup(&e, "code") => Err(CatalogWriteError::DuplicateAttributeCode(a.code)),
613            Err(e) => Err(e.into()),
614        }
615    }
616
617    pub async fn create_attribute_value(&self, v: NewAttributeValue) -> Result<Uuid, CatalogWriteError> {
618        let attrs = AttributeRepository::new(self.db_pool.clone());
619        let mut tx = self.db_pool.begin().await?;
620        Self::relay_ambient_scope(&mut tx).await?;
621        if !attrs.exists_id(&mut *tx, v.attribute_id).await? {
622            return Err(CatalogWriteError::AttributeNotFound(v.attribute_id));
623        }
624        let id = Uuid::new_v4();
625        let repo = AttributeValueRepository::new(self.db_pool.clone());
626        let r = repo
627            .insert_attribute_value(
628                &mut *tx,
629                &NewAttributeValueRow {
630                    id,
631                    attribute_id: v.attribute_id,
632                    code: &v.code,
633                    label: &v.label,
634                    label_en: v.label_en.as_deref(),
635                    swatch_hex: v.swatch_hex.as_deref(),
636                    sort_order: v.sort_order,
637                },
638            )
639            .await;
640        match r {
641            Ok(_) => {
642                tx.commit().await?;
643                Ok(id)
644            }
645            Err(e) if Self::is_dup(&e, "code") => Err(CatalogWriteError::DuplicateValueCode(v.code)),
646            Err(e) => Err(e.into()),
647        }
648    }
649
650    /// Create a variant SKU. Validates the item exists, every option maps to a known
651    /// attribute+value in the registry, then persists the variant and flips the item's
652    /// `has_variants` flag. `variant_label` defaults to the option value labels joined " / ".
653    pub async fn create_item_variant(&self, v: NewItemVariant) -> Result<Uuid, CatalogWriteError> {
654        let items = ItemRepository::new(self.db_pool.clone());
655        if v.options.is_empty() {
656            return Err(CatalogWriteError::NoOptions);
657        }
658
659        // Validate options against the registry and collect display labels for the label
660        // default. The validation reads ride the scoped transaction connection so the
661        // fence sees the caller's scope.
662        let attr_values = AttributeValueRepository::new(self.db_pool.clone());
663        let attrs = AttributeRepository::new(self.db_pool.clone());
664        let mut tx = self.db_pool.begin().await?;
665        Self::relay_ambient_scope(&mut tx).await?;
666        if !items.exists_id(&mut *tx, v.item_id).await? {
667            return Err(CatalogWriteError::ItemNotFound(v.item_id));
668        }
669        let mut labels: Vec<String> = Vec::with_capacity(v.options.len());
670        for (attr_code, val_code) in &v.options {
671            let row = attr_values
672                .find_value_with_attribute(&mut *tx, attr_code, val_code)
673                .await?;
674            match row {
675                Some(r) => labels.push(r.label),
676                None => {
677                    // Distinguish unknown axis vs unknown value for a clearer error.
678                    let attr_ok = attrs.find_id_by_code(&mut *tx, attr_code).await?;
679                    return if attr_ok.is_some() {
680                        Err(CatalogWriteError::UnknownAttributeValue(format!("{attr_code}={val_code}")))
681                    } else {
682                        Err(CatalogWriteError::UnknownAttribute(attr_code.clone()))
683                    };
684                }
685            }
686        }
687
688        let label = v.variant_label.clone().unwrap_or_else(|| labels.join(" / "));
689        let options_json = serde_json::to_value(&v.options).unwrap_or(serde_json::json!({}));
690
691        let id = Uuid::new_v4();
692        let variants = ItemVariantRepository::new(self.db_pool.clone());
693        let r = variants
694            .insert_variant(
695                &mut *tx,
696                &NewItemVariantRow {
697                    id,
698                    item_id: v.item_id,
699                    sku: &v.sku,
700                    variant_label: &label,
701                    options: &options_json,
702                    barcode: v.barcode.as_deref(),
703                    is_default: v.is_default,
704                    weight_per_unit: v.weight_per_unit,
705                },
706            )
707            .await;
708        if let Err(e) = r {
709            drop(tx);
710            return if Self::is_dup(&e, "barcode") {
711                Err(CatalogWriteError::DuplicateBarcode(v.barcode.unwrap_or_default()))
712            } else if e.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false) {
713                Err(CatalogWriteError::DuplicateSku(v.sku))
714            } else {
715                Err(e.into())
716            };
717        }
718        items.set_has_variants_true(&mut *tx, v.item_id).await?;
719        tx.commit().await?;
720        Ok(id)
721    }
722
723    /// Soft-delete a variant and keep `Item.has_variants` honest: if the item has no live variants
724    /// left, flip the flag back to false so the storefront picker never lies.
725    pub async fn delete_item_variant(&self, variant_id: Uuid) -> Result<(), CatalogWriteError> {
726        let variants = ItemVariantRepository::new(self.db_pool.clone());
727        let mut tx = self.db_pool.begin().await?;
728        Self::relay_ambient_scope(&mut tx).await?;
729        let item_id = variants
730            .find_item_id_for_live(&mut *tx, variant_id)
731            .await?
732            .ok_or(CatalogWriteError::ItemVariantNotFound(variant_id))?;
733
734        variants.soft_delete_variant(&mut *tx, variant_id).await?;
735        let remaining = variants.count_live_variants(&mut *tx, item_id).await?;
736        if remaining == 0 {
737            let items = ItemRepository::new(self.db_pool.clone());
738            items.set_has_variants_false(&mut *tx, item_id).await?;
739        }
740        tx.commit().await?;
741        Ok(())
742    }
743
744    /// Transition an Item's lifecycle status, enforcing the CatalogStatus state machine declared
745    /// in schema/hooks/catalog.hook.yaml (`active ↔ inactive`, `active|inactive → discontinued`;
746    /// `discontinued` is terminal).
747    pub async fn transition_item_status(
748        &self,
749        item_id: Uuid,
750        target: CatalogStatus,
751    ) -> Result<(), CatalogWriteError> {
752        let items = ItemRepository::new(self.db_pool.clone());
753        let mut tx = self.db_pool.begin().await?;
754        Self::relay_ambient_scope(&mut tx).await?;
755        let current = items
756            .find_status(&mut *tx, item_id)
757            .await?
758            .ok_or(CatalogWriteError::ItemNotFound(item_id))?;
759        if !Self::transition_allowed(current, target) {
760            return Err(CatalogWriteError::InvalidStatusTransition { from: current, to: target });
761        }
762        items.set_status(&mut *tx, item_id, target).await?;
763        tx.commit().await?;
764        Ok(())
765    }
766
767    /// The CatalogStatus state machine (schema/hooks/catalog.hook.yaml): `discontinued` is terminal.
768    fn transition_allowed(from: CatalogStatus, to: CatalogStatus) -> bool {
769        use CatalogStatus::*;
770        matches!(
771            (from, to),
772            (Active, Inactive) | (Inactive, Active) | (Active, Discontinued) | (Inactive, Discontinued)
773        )
774    }
775
776    /// Create a Brand (leaf master). Validated create — same rationale as `create_uom`.
777    pub async fn create_brand(&self, b: NewBrand) -> Result<Uuid, CatalogWriteError> {
778        let id = Uuid::new_v4();
779        let repo = BrandRepository::new(self.db_pool.clone());
780        let mut tx = self.db_pool.begin().await?;
781        Self::relay_ambient_scope(&mut tx).await?;
782        let r = repo
783            .insert_brand(
784                &mut *tx,
785                &NewBrandRow {
786                    id,
787                    code: &b.code,
788                    name: &b.name,
789                    short_description: b.short_description.as_deref(),
790                    description: b.description.as_deref(),
791                    logo_url: b.logo_url.as_deref(),
792                    sort_order: b.sort_order,
793                },
794            )
795            .await;
796        match r {
797            Ok(_) => {
798                tx.commit().await?;
799                Ok(id)
800            }
801            Err(e) if Self::is_dup(&e, "code") => Err(CatalogWriteError::DuplicateBrandCode(b.code)),
802            Err(e) => Err(e.into()),
803        }
804    }
805}