1use 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
44pub 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 AttributeNotFound(Uuid),
63 BrandNotFound(Uuid),
64 ItemNotFound(Uuid),
65 ItemVariantNotFound(Uuid),
66 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 RelativeShapeMismatch,
80 NonPositiveRelativeFactor,
82 UomCycle { parent: Uuid },
85 Conversion(UomConversionError),
88 ProtectedUom { code: String },
91 UomHasChildren { code: String },
94 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
205pub 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#[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 pub relative_uom_id: Option<Uuid>,
239 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 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 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 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 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 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 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 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 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 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 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 uoms.recompute_factors(&mut *tx).await?;
519 tx.commit().await?;
520 Ok(())
521 }
522
523 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 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 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 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 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 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 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 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 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 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}