1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Supplier SKU domain models
//!
//! A supplier SKU is a per-supplier identifier and unit-cost override for an
//! internal product. The same product may carry different SKUs and costs across
//! suppliers; `(product_id, supplier_id, sku)` is unique.
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, ProductId, SupplierSkuId};
use uuid::Uuid;
/// A supplier-specific SKU and optional unit-cost override for a product.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupplierSku {
/// Unique record ID.
pub id: SupplierSkuId,
/// Internal product this maps to.
pub product_id: ProductId,
/// Supplier this SKU belongs to.
pub supplier_id: Uuid,
/// The SKU as used by the supplier.
pub sku: String,
/// Optional unit cost from this supplier.
pub unit_cost: Option<Decimal>,
/// Currency for `unit_cost`.
pub currency: CurrencyCode,
/// Minimum order quantity, if the supplier enforces one.
pub min_order_qty: Option<Decimal>,
/// Lead time in days.
pub lead_time_days: Option<i32>,
/// Whether this is the preferred supplier SKU for the product.
pub is_preferred: bool,
/// When the record was created.
pub created_at: DateTime<Utc>,
/// When the record was last updated.
pub updated_at: DateTime<Utc>,
}
/// Input for creating a supplier SKU.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSupplierSku {
/// Internal product.
pub product_id: ProductId,
/// Supplier.
pub supplier_id: Uuid,
/// Supplier's SKU.
pub sku: String,
/// Optional unit cost.
pub unit_cost: Option<Decimal>,
/// Currency (defaults to account base currency when omitted).
pub currency: Option<CurrencyCode>,
/// Minimum order quantity.
pub min_order_qty: Option<Decimal>,
/// Lead time in days.
pub lead_time_days: Option<i32>,
}
/// Input for updating a supplier SKU. All fields optional (partial update).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateSupplierSku {
/// Updated supplier SKU.
pub sku: Option<String>,
/// Updated unit cost.
pub unit_cost: Option<Decimal>,
/// Updated currency.
pub currency: Option<CurrencyCode>,
/// Updated minimum order quantity.
pub min_order_qty: Option<Decimal>,
/// Updated lead time.
pub lead_time_days: Option<i32>,
/// Updated preferred flag.
pub is_preferred: Option<bool>,
}
/// A single item in a bulk supplier-SKU upsert, keyed by internal product.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkSupplierSkuItem {
/// Internal product.
pub product_id: ProductId,
/// Supplier's SKU.
pub sku: String,
/// Optional unit cost.
pub unit_cost: Option<Decimal>,
}
/// Filter for listing supplier SKUs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SupplierSkuFilter {
/// Filter by supplier.
pub supplier_id: Option<Uuid>,
/// Filter by product.
pub product_id: Option<ProductId>,
/// Maximum results.
pub limit: Option<u32>,
/// Offset for pagination.
pub offset: Option<u32>,
}
impl SupplierSku {
/// Effective unit cost, or zero when none is recorded.
#[must_use]
pub fn effective_cost(&self) -> Decimal {
self.unit_cost.unwrap_or(Decimal::ZERO)
}
/// Whether the supplier enforces a minimum order quantity above the given qty.
#[must_use]
pub fn below_min_order(&self, qty: Decimal) -> bool {
self.min_order_qty.is_some_and(|min| qty < min)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make(unit_cost: Option<Decimal>, min: Option<Decimal>) -> SupplierSku {
SupplierSku {
id: SupplierSkuId::new(),
product_id: ProductId::new(),
supplier_id: Uuid::nil(),
sku: "ACME-001".into(),
unit_cost,
currency: CurrencyCode::USD,
min_order_qty: min,
lead_time_days: Some(14),
is_preferred: false,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn effective_cost_defaults_zero() {
assert_eq!(make(None, None).effective_cost(), Decimal::ZERO);
assert_eq!(make(Some(dec!(12.50)), None).effective_cost(), dec!(12.50));
}
#[test]
fn below_min_order_respects_threshold() {
let s = make(None, Some(dec!(100)));
assert!(s.below_min_order(dec!(50)));
assert!(!s.below_min_order(dec!(100)));
assert!(!s.below_min_order(dec!(150)));
}
#[test]
fn below_min_order_false_without_minimum() {
assert!(!make(None, None).below_min_order(dec!(1)));
}
}