feat: add quantity to Product aggregate #36

Merged
realaravinth merged 2 commits from include-quantity-in-product into master 2024-07-15 19:58:05 +05:30
12 changed files with 252 additions and 64 deletions

View file

@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS cqrs_inventory_product_query
price_major INTEGER NOT NULL, price_major INTEGER NOT NULL,
price_currency TEXT NOT NULL, price_currency TEXT NOT NULL,
quantity_number INTEGER NOT NULL,
quantity_unit TEXT NOT NULL,
category_id UUID NOT NULL, category_id UUID NOT NULL,

View file

@ -60,7 +60,7 @@ mod tests {
assert!(!db.category_id_exists(&category).await.unwrap()); assert!(!db.category_id_exists(&category).await.unwrap());
create_dummy_category_record(&category, &db).await; create_dummy_category_record(&category, &db).await;
// state exists // state exists
assert!(db.category_id_exists(&category).await.unwrap()); assert!(db.category_id_exists(&category).await.unwrap());

View file

@ -53,6 +53,7 @@ pub mod tests {
.image(cmd.image().as_ref().map(|s| s.to_string())) .image(cmd.image().as_ref().map(|s| s.to_string()))
.sku_able(cmd.sku_able().clone()) .sku_able(cmd.sku_able().clone())
.category_id(cmd.category_id().clone()) .category_id(cmd.category_id().clone())
.quantity(cmd.quantity().clone())
.product_id(UUID.clone()) .product_id(UUID.clone())
.price(cmd.price().clone()) .price(cmd.price().clone())
.build() .build()
@ -81,9 +82,11 @@ pub mod tests {
price_major, price_major,
price_minor, price_minor,
price_currency, price_currency,
sku_able sku_able,
quantity_unit,
quantity_number
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
);", );",
1, 1,
p.name(), p.name(),
@ -94,7 +97,9 @@ pub mod tests {
p.price().major().clone() as i32, p.price().major().clone() as i32,
p.price().minor().clone() as i32, p.price().minor().clone() as i32,
p.price().currency().to_string(), p.price().currency().to_string(),
p.sku_able().clone() p.sku_able().clone(),
p.quantity().unit().to_string(),
p.quantity().number().clone() as i32,
) )
.execute(&db.pool) .execute(&db.pool)
.await .await

View file

@ -62,6 +62,7 @@ mod tests {
.category_id(cmd.category_id().clone()) .category_id(cmd.category_id().clone())
.product_id(UUID.clone()) .product_id(UUID.clone())
.price(cmd.price().clone()) .price(cmd.price().clone())
.quantity(cmd.quantity().clone())
.build() .build()
.unwrap(); .unwrap();

View file

@ -14,7 +14,7 @@ use super::errors::*;
use super::InventoryDBPostgresAdapter; use super::InventoryDBPostgresAdapter;
use crate::inventory::domain::events::InventoryEvent; use crate::inventory::domain::events::InventoryEvent;
use crate::inventory::domain::product_aggregate::{ use crate::inventory::domain::product_aggregate::{
Currency, PriceBuilder, Product, ProductBuilder, Currency, PriceBuilder, Product, ProductBuilder, QuantityBuilder, QuantityUnit,
}; };
use crate::utils::parse_aggregate_id::parse_aggregate_id; use crate::utils::parse_aggregate_id::parse_aggregate_id;
@ -34,6 +34,9 @@ pub struct ProductView {
price_major: i32, price_major: i32,
price_currency: String, price_currency: String,
quantity_unit: String,
quantity_number: i32,
category_id: Uuid, category_id: Uuid,
} }
@ -46,6 +49,12 @@ impl From<ProductView> for Product {
.build() .build()
.unwrap(); .unwrap();
let quantity = QuantityBuilder::default()
.number(v.quantity_number as usize)
.unit(QuantityUnit::from_str(&v.quantity_unit).unwrap())
.build()
.unwrap();
ProductBuilder::default() ProductBuilder::default()
.name(v.name) .name(v.name)
.description(v.description) .description(v.description)
@ -53,6 +62,7 @@ impl From<ProductView> for Product {
.sku_able(v.sku_able) .sku_able(v.sku_able)
.price(price) .price(price)
.category_id(v.category_id) .category_id(v.category_id)
.quantity(quantity)
.product_id(v.product_id) .product_id(v.product_id)
.build() .build()
.unwrap() .unwrap()
@ -77,6 +87,9 @@ impl View<Product> for ProductView {
self.price_minor = val.price().minor().clone() as i32; self.price_minor = val.price().minor().clone() as i32;
self.price_major = val.price().major().clone() as i32; self.price_major = val.price().major().clone() as i32;
self.price_currency = val.price().currency().to_string(); self.price_currency = val.price().currency().to_string();
self.quantity_number = val.quantity().number().clone() as i32;
self.quantity_unit = val.quantity().unit().to_string();
} }
_ => (), _ => (),
} }
@ -102,7 +115,9 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
price_major, price_major,
price_minor, price_minor,
price_currency, price_currency,
sku_able sku_able,
quantity_unit,
quantity_number
FROM FROM
cqrs_inventory_product_query cqrs_inventory_product_query
WHERE WHERE
@ -135,7 +150,9 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
price_major, price_major,
price_minor, price_minor,
price_currency, price_currency,
sku_able sku_able,
quantity_unit,
quantity_number
FROM FROM
cqrs_inventory_product_query cqrs_inventory_product_query
WHERE WHERE
@ -188,9 +205,11 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
price_major, price_major,
price_minor, price_minor,
price_currency, price_currency,
sku_able sku_able,
quantity_unit,
quantity_number
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
);", );",
version, version,
view.name, view.name,
@ -201,7 +220,9 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
view.price_major, view.price_major,
view.price_minor, view.price_minor,
view.price_currency, view.price_currency,
view.sku_able view.sku_able,
view.quantity_unit,
view.quantity_number,
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@ -222,7 +243,9 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
price_major = $7, price_major = $7,
price_minor = $8, price_minor = $8,
price_currency = $9, price_currency = $9,
sku_able = $10;", sku_able = $10,
quantity_unit = $11,
quantity_number = $12;",
version, version,
view.name, view.name,
view.description, view.description,
@ -232,7 +255,9 @@ impl ViewRepository<ProductView, Product> for InventoryDBPostgresAdapter {
view.price_major, view.price_major,
view.price_minor, view.price_minor,
view.price_currency, view.price_currency,
view.sku_able view.sku_able,
view.quantity_unit,
view.quantity_number
) )
.execute(&self.pool) .execute(&self.pool)
.await .await

View file

@ -59,6 +59,7 @@ impl AddProductUseCase for AddProductService {
.sku_able(cmd.sku_able().clone()) .sku_able(cmd.sku_able().clone())
.price(cmd.price().clone()) .price(cmd.price().clone())
.category_id(cmd.category_id().clone()) .category_id(cmd.category_id().clone())
.quantity(cmd.quantity().clone())
.product_id(product_id) .product_id(product_id)
.build() .build()
.unwrap(); .unwrap();
@ -80,6 +81,7 @@ impl AddProductUseCase for AddProductService {
.price(product.price().clone()) .price(product.price().clone())
.category_id(product.category_id().clone()) .category_id(product.category_id().clone())
.product_id(product.product_id().clone()) .product_id(product.product_id().clone())
.quantity(product.quantity().clone())
.build() .build()
.unwrap()) .unwrap())
} }
@ -89,8 +91,6 @@ impl AddProductUseCase for AddProductService {
pub mod tests { pub mod tests {
use super::*; use super::*;
use uuid::Uuid;
use crate::inventory::domain::add_product_command::tests::get_command; use crate::inventory::domain::add_product_command::tests::get_command;
use crate::utils::uuid::tests::UUID; use crate::utils::uuid::tests::UUID;
use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid};
@ -109,6 +109,7 @@ pub mod tests {
.category_id(cmd.category_id().clone()) .category_id(cmd.category_id().clone())
.product_id(UUID.clone()) .product_id(UUID.clone())
.price(cmd.price().clone()) .price(cmd.price().clone())
.quantity(cmd.quantity().clone())
.added_by_user(cmd.adding_by().clone()) .added_by_user(cmd.adding_by().clone())
.build() .build()
.unwrap(); .unwrap();
@ -146,6 +147,7 @@ pub mod tests {
assert_eq!(res.added_by_user(), cmd.adding_by()); assert_eq!(res.added_by_user(), cmd.adding_by());
assert_eq!(res.category_id(), cmd.category_id()); assert_eq!(res.category_id(), cmd.category_id());
assert_eq!(res.product_id(), &UUID); assert_eq!(res.product_id(), &UUID);
assert_eq!(res.quantity(), cmd.quantity());
} }
#[actix_rt::test] #[actix_rt::test]

View file

@ -27,11 +27,11 @@ impl From<InventoryDBError> for InventoryError {
InventoryDBError::DuplicateStoreID => { InventoryDBError::DuplicateStoreID => {
error!("DuplicateStoreID"); error!("DuplicateStoreID");
Self::InternalError Self::InternalError
}, }
InventoryDBError::DuplicateProductID => { InventoryDBError::DuplicateProductID => {
error!("DuplicateProductID"); error!("DuplicateProductID");
Self::InternalError Self::InternalError
}, }
InventoryDBError::DuplicateCategoryID => { InventoryDBError::DuplicateCategoryID => {
error!("DuplicateCategoryID"); error!("DuplicateCategoryID");
Self::InternalError Self::InternalError

View file

@ -8,7 +8,7 @@ use derive_more::{Display, Error};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use super::product_aggregate::Price; use super::product_aggregate::{Price, Quantity};
#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AddProductCommandError { pub enum AddProductCommandError {
@ -24,6 +24,7 @@ pub struct UnvalidatedAddProductCommand {
image: Option<String>, image: Option<String>,
category_id: Uuid, category_id: Uuid,
sku_able: bool, sku_able: bool,
quantity: Quantity,
price: Price, price: Price,
adding_by: Uuid, adding_by: Uuid,
} }
@ -36,6 +37,7 @@ pub struct AddProductCommand {
category_id: Uuid, category_id: Uuid,
sku_able: bool, sku_able: bool,
price: Price, price: Price,
quantity: Quantity,
adding_by: Uuid, adding_by: Uuid,
} }
@ -75,6 +77,7 @@ impl UnvalidatedAddProductCommand {
category_id: self.category_id, category_id: self.category_id,
sku_able: self.sku_able, sku_able: self.sku_able,
price: self.price, price: self.price,
quantity: self.quantity,
adding_by: self.adding_by, adding_by: self.adding_by,
}) })
} }
@ -85,7 +88,9 @@ pub mod tests {
use super::*; use super::*;
use crate::{ use crate::{
inventory::domain::product_aggregate::{Currency, PriceBuilder}, inventory::domain::product_aggregate::{
Currency, PriceBuilder, QuantityBuilder, QuantityUnit,
},
utils::uuid::tests::UUID, utils::uuid::tests::UUID,
}; };
@ -104,12 +109,19 @@ pub mod tests {
.build() .build()
.unwrap(); .unwrap();
let quantity = QuantityBuilder::default()
.number(1)
.unit(QuantityUnit::DiscreteNumber)
.build()
.unwrap();
let cmd = UnvalidatedAddProductCommandBuilder::default() let cmd = UnvalidatedAddProductCommandBuilder::default()
.name(name.into()) .name(name.into())
.description(description.clone()) .description(description.clone())
.image(image.clone()) .image(image.clone())
.category_id(category_id.clone()) .category_id(category_id.clone())
.adding_by(adding_by.clone()) .adding_by(adding_by.clone())
.quantity(quantity)
.sku_able(sku_able) .sku_able(sku_able)
.price(price.clone()) .price(price.clone())
.build() .build()
@ -131,6 +143,11 @@ pub mod tests {
.currency(Currency::INR) .currency(Currency::INR)
.build() .build()
.unwrap(); .unwrap();
let quantity = QuantityBuilder::default()
.number(1)
.unit(QuantityUnit::DiscreteNumber)
.build()
.unwrap();
// description = None // description = None
let cmd = UnvalidatedAddProductCommandBuilder::default() let cmd = UnvalidatedAddProductCommandBuilder::default()
@ -139,6 +156,7 @@ pub mod tests {
.image(None) .image(None)
.category_id(category_id.clone()) .category_id(category_id.clone())
.adding_by(adding_by.clone()) .adding_by(adding_by.clone())
.quantity(quantity.clone())
.sku_able(sku_able) .sku_able(sku_able)
.price(price.clone()) .price(price.clone())
.build() .build()
@ -153,6 +171,7 @@ pub mod tests {
assert_eq!(cmd.image(), &None); assert_eq!(cmd.image(), &None);
assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.sku_able(), &sku_able);
assert_eq!(cmd.price(), &price); assert_eq!(cmd.price(), &price);
assert_eq!(cmd.quantity(), &quantity);
} }
#[test] #[test]
fn test_description_some() { fn test_description_some() {
@ -169,12 +188,18 @@ pub mod tests {
.currency(Currency::INR) .currency(Currency::INR)
.build() .build()
.unwrap(); .unwrap();
let quantity = QuantityBuilder::default()
.number(1)
.unit(QuantityUnit::DiscreteNumber)
.build()
.unwrap();
let cmd = UnvalidatedAddProductCommandBuilder::default() let cmd = UnvalidatedAddProductCommandBuilder::default()
.name(name.into()) .name(name.into())
.description(description.clone()) .description(description.clone())
.image(image.clone()) .image(image.clone())
.category_id(category_id.clone()) .category_id(category_id.clone())
.quantity(quantity.clone())
.adding_by(adding_by.clone()) .adding_by(adding_by.clone())
.sku_able(sku_able) .sku_able(sku_able)
.price(price.clone()) .price(price.clone())
@ -190,6 +215,7 @@ pub mod tests {
assert_eq!(cmd.image(), &image); assert_eq!(cmd.image(), &image);
assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.sku_able(), &sku_able);
assert_eq!(cmd.price(), &price); assert_eq!(cmd.price(), &price);
assert_eq!(cmd.quantity(), &quantity);
} }
#[test] #[test]
@ -206,6 +232,11 @@ pub mod tests {
.currency(Currency::INR) .currency(Currency::INR)
.build() .build()
.unwrap(); .unwrap();
let quantity = QuantityBuilder::default()
.number(1)
.unit(QuantityUnit::DiscreteNumber)
.build()
.unwrap();
let cmd = UnvalidatedAddProductCommandBuilder::default() let cmd = UnvalidatedAddProductCommandBuilder::default()
.name("".into()) .name("".into())
@ -213,6 +244,7 @@ pub mod tests {
.image(image.clone()) .image(image.clone())
.category_id(category_id.clone()) .category_id(category_id.clone())
.adding_by(adding_by.clone()) .adding_by(adding_by.clone())
.quantity(quantity)
.sku_able(sku_able) .sku_able(sku_able)
.price(price.clone()) .price(price.clone())
.build() .build()

View file

@ -5,7 +5,7 @@
// aggregates // aggregates
pub mod category_aggregate; pub mod category_aggregate;
pub mod product_aggregate; pub mod product_aggregate;
//pub mod stock_aggregate; pub mod stock_aggregate;
pub mod store_aggregate; pub mod store_aggregate;
// commands // commands

View file

@ -7,7 +7,7 @@ use derive_getters::Getters;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use super::product_aggregate::Price; use super::product_aggregate::{Price, Quantity};
#[derive( #[derive(
Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd,
@ -19,6 +19,7 @@ pub struct ProductAddedEvent {
description: Option<String>, description: Option<String>,
image: Option<String>, // string = file_name image: Option<String>, // string = file_name
price: Price, price: Price,
quantity: Quantity,
category_id: Uuid, category_id: Uuid,
sku_able: bool, sku_able: bool,
product_id: Uuid, product_id: Uuid,
@ -41,6 +42,7 @@ pub mod tests {
.category_id(cmd.category_id().clone()) .category_id(cmd.category_id().clone())
.product_id(UUID.clone()) .product_id(UUID.clone())
.price(cmd.price().clone()) .price(cmd.price().clone())
.quantity(cmd.quantity().clone())
.added_by_user(cmd.adding_by().clone()) .added_by_user(cmd.adding_by().clone())
.build() .build()
.unwrap() .unwrap()

View file

@ -15,17 +15,93 @@ use super::{commands::InventoryCommand, events::InventoryEvent};
use crate::inventory::application::services::errors::*; use crate::inventory::application::services::errors::*;
use crate::inventory::application::services::InventoryServicesInterface; use crate::inventory::application::services::InventoryServicesInterface;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
pub enum QuantityUnit {
Kilogram,
Gram,
DiscreteNumber, // example: 1 sofa, 2 bed, etc.
MilliLiter,
Liter,
}
impl Default for QuantityUnit {
fn default() -> Self {
Self::DiscreteNumber
}
}
const KILO_GRAM: &str = "kg";
const GRAM: &str = "g";
const DISCRETE_NUMBER: &str = "discrete_number";
const MILLI_LITER: &str = "ml";
const LITER: &str = "l";
impl ToString for QuantityUnit {
fn to_string(&self) -> String {
match self {
Self::Kilogram => KILO_GRAM,
Self::Gram => GRAM,
Self::DiscreteNumber => DISCRETE_NUMBER,
Self::MilliLiter => MILLI_LITER,
Self::Liter => LITER,
}
.into()
}
}
impl FromStr for QuantityUnit {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
KILO_GRAM => Ok(Self::Kilogram),
GRAM => Ok(Self::Gram),
DISCRETE_NUMBER => Ok(Self::DiscreteNumber),
MILLI_LITER => Ok(Self::MilliLiter),
LITER => Ok(Self::Liter),
_ => Err("Currency unsupported".into()),
}
}
}
#[derive( #[derive(
Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, Clone, Debug, Serialize, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters,
)] )]
pub struct Product { pub struct Quantity {
name: String, number: usize,
description: Option<String>, unit: QuantityUnit,
image: Option<String>, // string = file_name }
price: Price,
category_id: Uuid, #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
sku_able: bool, pub enum Currency {
product_id: Uuid, INR,
}
const INR: &str = "INR";
impl ToString for Currency {
fn to_string(&self) -> String {
match self {
Self::INR => INR,
}
.into()
}
}
impl FromStr for Currency {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
match s {
INR => Ok(Self::INR),
_ => Err("Currency unsupported".into()),
}
}
}
impl Default for Currency {
fn default() -> Self {
Self::INR
}
} }
#[derive( #[derive(
@ -37,35 +113,20 @@ pub struct Price {
currency: Currency, currency: Currency,
} }
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] #[derive(
pub enum Currency { Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder,
INR, )]
} pub struct Product {
name: String,
impl ToString for Currency { description: Option<String>,
fn to_string(&self) -> String { image: Option<String>, // string = file_name
match self { price: Price,
Self::INR => "INR".into(), // stock = Σ (not sold SKU), if SKU is relevant. Where irrelevant; it exists independent of SKU.
} // relevancy is determined Product.sku_able
} quantity: Quantity,
} category_id: Uuid,
sku_able: bool,
impl FromStr for Currency { product_id: Uuid,
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let inr = Self::INR.to_string();
match s {
inr => Ok(Self::INR),
_ => Err("Currency unsupported".into()),
}
}
}
impl Default for Currency {
fn default() -> Self {
Self::INR
}
} }
#[async_trait] #[async_trait]
@ -107,6 +168,7 @@ impl Aggregate for Product {
.category_id(e.category_id().clone()) .category_id(e.category_id().clone())
.sku_able(e.sku_able().clone()) .sku_able(e.sku_able().clone())
.product_id(e.product_id().clone()) .product_id(e.product_id().clone())
.quantity(e.quantity().clone())
.build() .build()
.unwrap(); .unwrap();
} }
@ -151,15 +213,48 @@ mod aggregate_tests {
.then_expect_events(vec![expected]); .then_expect_events(vec![expected]);
} }
fn test_helper<T>(t: T, str_value: &str) -> bool
where
T: ToString + FromStr + std::fmt::Debug + PartialEq,
<T as FromStr>::Err: std::fmt::Debug,
{
println!("Testing type: {:?} against value {str_value}", t);
assert_eq!(t.to_string(), str_value.to_string());
assert_eq!(T::from_str(str_value).unwrap(), t);
assert_eq!(T::from_str(t.to_string().as_str()).unwrap(), t,);
true
}
#[test] #[test]
fn currency_to_string_from_str() { fn currency_to_string_from_str() {
assert_eq!(Currency::INR.to_string(), "INR".to_string()); assert!(test_helper(Currency::INR, INR));
}
assert_eq!(Currency::from_str("INR").unwrap(), Currency::INR); #[test]
fn quantity_unit_kilogram() {
assert!(test_helper(QuantityUnit::Kilogram, KILO_GRAM));
}
assert_eq!( #[test]
Currency::from_str(Currency::INR.to_string().as_str()).unwrap(), fn quantity_unit_gram() {
Currency::INR assert!(test_helper(QuantityUnit::Gram, GRAM));
); }
#[test]
fn quantity_unit_discrete_number() {
assert!(test_helper(QuantityUnit::DiscreteNumber, DISCRETE_NUMBER));
}
#[test]
fn quantity_unit_milli_liter() {
assert!(test_helper(QuantityUnit::MilliLiter, MILLI_LITER));
}
#[test]
fn quantity_unit_liter() {
assert!(test_helper(QuantityUnit::Liter, LITER));
} }
} }

View file

@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use derive_builder::Builder;
use derive_getters::Getters;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use super::product_aggregate::Quantity;
// stock keeping unit
// TODO: will implement later, have to figure out how to print SKU label and during billing.
#[derive(
Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters,
)]
pub struct SKU {
id: String,
product_id: Uuid,
expiry: Option<OffsetDateTime>,
sold: bool,
quantity: Quantity,
}