From 0ddeec23745f16b94e5748a94834f57718281dbb Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Mon, 15 Jul 2024 19:49:36 +0530 Subject: [PATCH 1/2] feat: place holder stock aggregate --- src/inventory/domain/mod.rs | 2 +- src/inventory/domain/stock_aggregate.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/inventory/domain/stock_aggregate.rs diff --git a/src/inventory/domain/mod.rs b/src/inventory/domain/mod.rs index 89649c4..cb92726 100644 --- a/src/inventory/domain/mod.rs +++ b/src/inventory/domain/mod.rs @@ -5,7 +5,7 @@ // aggregates pub mod category_aggregate; pub mod product_aggregate; -//pub mod stock_aggregate; +pub mod stock_aggregate; pub mod store_aggregate; // commands diff --git a/src/inventory/domain/stock_aggregate.rs b/src/inventory/domain/stock_aggregate.rs new file mode 100644 index 0000000..cda309f --- /dev/null +++ b/src/inventory/domain/stock_aggregate.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// 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, + sold: bool, + quantity: Quantity, +} From 06e455ccb9e7bea25fd3175571c7364b03de5635 Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Mon, 15 Jul 2024 19:50:39 +0530 Subject: [PATCH 2/2] feat: add quantity to Product aggregate --- ...715113708_cqrs_inventory_product_query.sql | 2 + .../output/db/postgres/category_id_exists.rs | 2 +- .../output/db/postgres/product_id_exists.rs | 11 +- .../product_name_exists_for_category.rs | 1 + .../output/db/postgres/product_view.rs | 41 +++- .../services/add_product_service.rs | 6 +- src/inventory/application/services/errors.rs | 4 +- src/inventory/domain/add_product_command.rs | 36 +++- src/inventory/domain/product_added_event.rs | 4 +- src/inventory/domain/product_aggregate.rs | 183 +++++++++++++----- 10 files changed, 227 insertions(+), 63 deletions(-) diff --git a/migrations/20240715113708_cqrs_inventory_product_query.sql b/migrations/20240715113708_cqrs_inventory_product_query.sql index 0036ba6..b307cf5 100644 --- a/migrations/20240715113708_cqrs_inventory_product_query.sql +++ b/migrations/20240715113708_cqrs_inventory_product_query.sql @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS cqrs_inventory_product_query price_major INTEGER NOT NULL, price_currency TEXT NOT NULL, + quantity_number INTEGER NOT NULL, + quantity_unit TEXT NOT NULL, category_id UUID NOT NULL, diff --git a/src/inventory/adapters/output/db/postgres/category_id_exists.rs b/src/inventory/adapters/output/db/postgres/category_id_exists.rs index c61f9a5..9438657 100644 --- a/src/inventory/adapters/output/db/postgres/category_id_exists.rs +++ b/src/inventory/adapters/output/db/postgres/category_id_exists.rs @@ -60,7 +60,7 @@ mod tests { assert!(!db.category_id_exists(&category).await.unwrap()); create_dummy_category_record(&category, &db).await; - + // state exists assert!(db.category_id_exists(&category).await.unwrap()); diff --git a/src/inventory/adapters/output/db/postgres/product_id_exists.rs b/src/inventory/adapters/output/db/postgres/product_id_exists.rs index 9d8b076..b799bff 100644 --- a/src/inventory/adapters/output/db/postgres/product_id_exists.rs +++ b/src/inventory/adapters/output/db/postgres/product_id_exists.rs @@ -53,6 +53,7 @@ pub mod tests { .image(cmd.image().as_ref().map(|s| s.to_string())) .sku_able(cmd.sku_able().clone()) .category_id(cmd.category_id().clone()) + .quantity(cmd.quantity().clone()) .product_id(UUID.clone()) .price(cmd.price().clone()) .build() @@ -81,9 +82,11 @@ pub mod tests { price_major, price_minor, price_currency, - sku_able + sku_able, + quantity_unit, + quantity_number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 );", 1, p.name(), @@ -94,7 +97,9 @@ pub mod tests { p.price().major().clone() as i32, p.price().minor().clone() as i32, 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) .await diff --git a/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs index c2f1f45..4763f73 100644 --- a/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs +++ b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs @@ -62,6 +62,7 @@ mod tests { .category_id(cmd.category_id().clone()) .product_id(UUID.clone()) .price(cmd.price().clone()) + .quantity(cmd.quantity().clone()) .build() .unwrap(); diff --git a/src/inventory/adapters/output/db/postgres/product_view.rs b/src/inventory/adapters/output/db/postgres/product_view.rs index be06416..e4c0579 100644 --- a/src/inventory/adapters/output/db/postgres/product_view.rs +++ b/src/inventory/adapters/output/db/postgres/product_view.rs @@ -14,7 +14,7 @@ use super::errors::*; use super::InventoryDBPostgresAdapter; use crate::inventory::domain::events::InventoryEvent; 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; @@ -34,6 +34,9 @@ pub struct ProductView { price_major: i32, price_currency: String, + quantity_unit: String, + quantity_number: i32, + category_id: Uuid, } @@ -46,6 +49,12 @@ impl From for Product { .build() .unwrap(); + let quantity = QuantityBuilder::default() + .number(v.quantity_number as usize) + .unit(QuantityUnit::from_str(&v.quantity_unit).unwrap()) + .build() + .unwrap(); + ProductBuilder::default() .name(v.name) .description(v.description) @@ -53,6 +62,7 @@ impl From for Product { .sku_able(v.sku_able) .price(price) .category_id(v.category_id) + .quantity(quantity) .product_id(v.product_id) .build() .unwrap() @@ -77,6 +87,9 @@ impl View for ProductView { self.price_minor = val.price().minor().clone() as i32; self.price_major = val.price().major().clone() as i32; 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 for InventoryDBPostgresAdapter { price_major, price_minor, price_currency, - sku_able + sku_able, + quantity_unit, + quantity_number FROM cqrs_inventory_product_query WHERE @@ -135,7 +150,9 @@ impl ViewRepository for InventoryDBPostgresAdapter { price_major, price_minor, price_currency, - sku_able + sku_able, + quantity_unit, + quantity_number FROM cqrs_inventory_product_query WHERE @@ -188,9 +205,11 @@ impl ViewRepository for InventoryDBPostgresAdapter { price_major, price_minor, price_currency, - sku_able + sku_able, + quantity_unit, + quantity_number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 );", version, view.name, @@ -201,7 +220,9 @@ impl ViewRepository for InventoryDBPostgresAdapter { view.price_major, view.price_minor, view.price_currency, - view.sku_able + view.sku_able, + view.quantity_unit, + view.quantity_number, ) .execute(&self.pool) .await @@ -222,7 +243,9 @@ impl ViewRepository for InventoryDBPostgresAdapter { price_major = $7, price_minor = $8, price_currency = $9, - sku_able = $10;", + sku_able = $10, + quantity_unit = $11, + quantity_number = $12;", version, view.name, view.description, @@ -232,7 +255,9 @@ impl ViewRepository for InventoryDBPostgresAdapter { view.price_major, view.price_minor, view.price_currency, - view.sku_able + view.sku_able, + view.quantity_unit, + view.quantity_number ) .execute(&self.pool) .await diff --git a/src/inventory/application/services/add_product_service.rs b/src/inventory/application/services/add_product_service.rs index de5fe7f..65cf1bb 100644 --- a/src/inventory/application/services/add_product_service.rs +++ b/src/inventory/application/services/add_product_service.rs @@ -59,6 +59,7 @@ impl AddProductUseCase for AddProductService { .sku_able(cmd.sku_able().clone()) .price(cmd.price().clone()) .category_id(cmd.category_id().clone()) + .quantity(cmd.quantity().clone()) .product_id(product_id) .build() .unwrap(); @@ -80,6 +81,7 @@ impl AddProductUseCase for AddProductService { .price(product.price().clone()) .category_id(product.category_id().clone()) .product_id(product.product_id().clone()) + .quantity(product.quantity().clone()) .build() .unwrap()) } @@ -89,8 +91,6 @@ impl AddProductUseCase for AddProductService { pub mod tests { use super::*; - use uuid::Uuid; - use crate::inventory::domain::add_product_command::tests::get_command; use crate::utils::uuid::tests::UUID; use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; @@ -109,6 +109,7 @@ pub mod tests { .category_id(cmd.category_id().clone()) .product_id(UUID.clone()) .price(cmd.price().clone()) + .quantity(cmd.quantity().clone()) .added_by_user(cmd.adding_by().clone()) .build() .unwrap(); @@ -146,6 +147,7 @@ pub mod tests { assert_eq!(res.added_by_user(), cmd.adding_by()); assert_eq!(res.category_id(), cmd.category_id()); assert_eq!(res.product_id(), &UUID); + assert_eq!(res.quantity(), cmd.quantity()); } #[actix_rt::test] diff --git a/src/inventory/application/services/errors.rs b/src/inventory/application/services/errors.rs index e1a5a70..9d71a1f 100644 --- a/src/inventory/application/services/errors.rs +++ b/src/inventory/application/services/errors.rs @@ -27,11 +27,11 @@ impl From for InventoryError { InventoryDBError::DuplicateStoreID => { error!("DuplicateStoreID"); Self::InternalError - }, + } InventoryDBError::DuplicateProductID => { error!("DuplicateProductID"); Self::InternalError - }, + } InventoryDBError::DuplicateCategoryID => { error!("DuplicateCategoryID"); Self::InternalError diff --git a/src/inventory/domain/add_product_command.rs b/src/inventory/domain/add_product_command.rs index 045a808..4a08f23 100644 --- a/src/inventory/domain/add_product_command.rs +++ b/src/inventory/domain/add_product_command.rs @@ -8,7 +8,7 @@ use derive_more::{Display, Error}; use serde::{Deserialize, Serialize}; 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)] pub enum AddProductCommandError { @@ -24,6 +24,7 @@ pub struct UnvalidatedAddProductCommand { image: Option, category_id: Uuid, sku_able: bool, + quantity: Quantity, price: Price, adding_by: Uuid, } @@ -36,6 +37,7 @@ pub struct AddProductCommand { category_id: Uuid, sku_able: bool, price: Price, + quantity: Quantity, adding_by: Uuid, } @@ -75,6 +77,7 @@ impl UnvalidatedAddProductCommand { category_id: self.category_id, sku_able: self.sku_able, price: self.price, + quantity: self.quantity, adding_by: self.adding_by, }) } @@ -85,7 +88,9 @@ pub mod tests { use super::*; use crate::{ - inventory::domain::product_aggregate::{Currency, PriceBuilder}, + inventory::domain::product_aggregate::{ + Currency, PriceBuilder, QuantityBuilder, QuantityUnit, + }, utils::uuid::tests::UUID, }; @@ -104,12 +109,19 @@ pub mod tests { .build() .unwrap(); + let quantity = QuantityBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(); + let cmd = UnvalidatedAddProductCommandBuilder::default() .name(name.into()) .description(description.clone()) .image(image.clone()) .category_id(category_id.clone()) .adding_by(adding_by.clone()) + .quantity(quantity) .sku_able(sku_able) .price(price.clone()) .build() @@ -131,6 +143,11 @@ pub mod tests { .currency(Currency::INR) .build() .unwrap(); + let quantity = QuantityBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(); // description = None let cmd = UnvalidatedAddProductCommandBuilder::default() @@ -139,6 +156,7 @@ pub mod tests { .image(None) .category_id(category_id.clone()) .adding_by(adding_by.clone()) + .quantity(quantity.clone()) .sku_able(sku_able) .price(price.clone()) .build() @@ -153,6 +171,7 @@ pub mod tests { assert_eq!(cmd.image(), &None); assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.price(), &price); + assert_eq!(cmd.quantity(), &quantity); } #[test] fn test_description_some() { @@ -169,12 +188,18 @@ pub mod tests { .currency(Currency::INR) .build() .unwrap(); + let quantity = QuantityBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(); let cmd = UnvalidatedAddProductCommandBuilder::default() .name(name.into()) .description(description.clone()) .image(image.clone()) .category_id(category_id.clone()) + .quantity(quantity.clone()) .adding_by(adding_by.clone()) .sku_able(sku_able) .price(price.clone()) @@ -190,6 +215,7 @@ pub mod tests { assert_eq!(cmd.image(), &image); assert_eq!(cmd.sku_able(), &sku_able); assert_eq!(cmd.price(), &price); + assert_eq!(cmd.quantity(), &quantity); } #[test] @@ -206,6 +232,11 @@ pub mod tests { .currency(Currency::INR) .build() .unwrap(); + let quantity = QuantityBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(); let cmd = UnvalidatedAddProductCommandBuilder::default() .name("".into()) @@ -213,6 +244,7 @@ pub mod tests { .image(image.clone()) .category_id(category_id.clone()) .adding_by(adding_by.clone()) + .quantity(quantity) .sku_able(sku_able) .price(price.clone()) .build() diff --git a/src/inventory/domain/product_added_event.rs b/src/inventory/domain/product_added_event.rs index 48f7637..5c3ce09 100644 --- a/src/inventory/domain/product_added_event.rs +++ b/src/inventory/domain/product_added_event.rs @@ -7,7 +7,7 @@ use derive_getters::Getters; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::product_aggregate::Price; +use super::product_aggregate::{Price, Quantity}; #[derive( Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, @@ -19,6 +19,7 @@ pub struct ProductAddedEvent { description: Option, image: Option, // string = file_name price: Price, + quantity: Quantity, category_id: Uuid, sku_able: bool, product_id: Uuid, @@ -41,6 +42,7 @@ pub mod tests { .category_id(cmd.category_id().clone()) .product_id(UUID.clone()) .price(cmd.price().clone()) + .quantity(cmd.quantity().clone()) .added_by_user(cmd.adding_by().clone()) .build() .unwrap() diff --git a/src/inventory/domain/product_aggregate.rs b/src/inventory/domain/product_aggregate.rs index dff8b0b..d6b1a2e 100644 --- a/src/inventory/domain/product_aggregate.rs +++ b/src/inventory/domain/product_aggregate.rs @@ -15,17 +15,93 @@ use super::{commands::InventoryCommand, events::InventoryEvent}; use crate::inventory::application::services::errors::*; 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 { + 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( - 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 { - name: String, - description: Option, - image: Option, // string = file_name - price: Price, - category_id: Uuid, - sku_able: bool, - product_id: Uuid, +pub struct Quantity { + number: usize, + unit: QuantityUnit, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub enum Currency { + 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 { + let s = s.trim(); + match s { + INR => Ok(Self::INR), + _ => Err("Currency unsupported".into()), + } + } +} + +impl Default for Currency { + fn default() -> Self { + Self::INR + } } #[derive( @@ -37,35 +113,20 @@ pub struct Price { currency: Currency, } -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] -pub enum Currency { - 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 { - 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 - } +#[derive( + Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Product { + name: String, + description: Option, + image: Option, // string = file_name + price: Price, + // 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, + product_id: Uuid, } #[async_trait] @@ -107,6 +168,7 @@ impl Aggregate for Product { .category_id(e.category_id().clone()) .sku_able(e.sku_able().clone()) .product_id(e.product_id().clone()) + .quantity(e.quantity().clone()) .build() .unwrap(); } @@ -151,15 +213,48 @@ mod aggregate_tests { .then_expect_events(vec![expected]); } + fn test_helper(t: T, str_value: &str) -> bool + where + T: ToString + FromStr + std::fmt::Debug + PartialEq, + ::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] 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!( - Currency::from_str(Currency::INR.to_string().as_str()).unwrap(), - Currency::INR - ); + #[test] + fn quantity_unit_gram() { + 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)); } }