From 537cf58b6f16ef701a7aa0d29ad772141573a795 Mon Sep 17 00:00:00 2001 From: despiegk Date: Mon, 21 Apr 2025 10:51:04 +0200 Subject: [PATCH] ... --- herodb/src/models/biz/README.md | 68 ++++++++++-------- herodb/src/models/biz/contract.rs | 44 +++++++++++- herodb/src/models/biz/currency.rs | 28 +++++++- herodb/src/models/biz/invoice.rs | 65 ++++++++++++++++- herodb/src/models/biz/product.rs | 68 +++++++++++++++++- herodb/src/models/biz/sale.rs | 115 +++++++++++++++--------------- herodb/src/models/biz/service.rs | 69 ++++++------------ 7 files changed, 315 insertions(+), 142 deletions(-) diff --git a/herodb/src/models/biz/README.md b/herodb/src/models/biz/README.md index b6a9859..fc16b98 100644 --- a/herodb/src/models/biz/README.md +++ b/herodb/src/models/biz/README.md @@ -9,26 +9,26 @@ The business models are implemented as Rust structs and enums with serialization ## Model Relationships ``` - ┌─────────────┐ - │ Customer │ - └──────┬──────┘ - │ - ▼ + ┌─────────────┐ + │ Customer │ + └──────┬──────┘ + │ + ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Currency │◄────┤ Product │◄────┤ SaleItem │◄────┤ Sale │ -└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ - ▲ │ - │ │ - ┌─────┴──────────┐ │ - │ProductComponent│ │ - └────────────────┘ │ - │ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ Currency │◄────┤ Service │◄────┤ ServiceItem │◄───────────┘ -└─────────────┘ └─────────────┘ └─────────────┘ - │ - │ - ▼ +│ Currency │◄────┤ Product │◄────┤ │ │ │ +└─────────────┘ └─────────────┘ │ │ │ │ + ▲ │ SaleItem │◄────┤ Sale │ + │ │ │ │ │ + ┌─────┴──────────┐ │ │ │ │ + │ProductComponent│ └─────────────┘ └──────┬──────┘ + └────────────────┘ ▲ │ + / │ +┌─────────────┐ ┌─────────────┐ / │ +│ Currency │◄────┤ Service │◄────────/ │ +└─────────────┘ └─────────────┘ │ + │ + │ + ▼ ┌─────────────┐ ┌─────────────┐ │ InvoiceItem │◄────┤ Invoice │ └─────────────┘ └─────────────┘ @@ -40,7 +40,9 @@ The business models are implemented as Rust structs and enums with serialization - **Product/Service**: Defines what is being sold, including its base price - Can be marked as a template (`is_template=true`) to create copies for actual sales - **Sale**: Represents the transaction of selling products/services to customers, including tax calculations - - Can be linked to a Service when the sale creates an ongoing service + - Contains SaleItems that can be linked to either Products or Services +- **SaleItem**: Represents an item within a sale + - Can be linked to either a Product or a Service (via product_id or service_id) - **Service**: Represents an ongoing service provided to a customer - Created from a Product template when the product type is Service - **Invoice**: Represents the billing document for a sale, with payment tracking @@ -217,8 +219,9 @@ Represents an item within a sale. **Properties:** - `id`: u32 - Unique identifier - `sale_id`: u32 - Parent sale ID -- `product_id`: u32 - ID of the product sold -- `name`: String - Product name at time of sale +- `product_id`: Option - ID of the product sold (if this is a product sale) +- `service_id`: Option - ID of the service sold (if this is a service sale) +- `name`: String - Product/service name at time of sale - `description`: String - Detailed description of the item - `comments`: String - Additional notes or comments about the item - `quantity`: i32 - Number of items purchased @@ -481,15 +484,22 @@ Products and Services can be marked as templates (`is_template=true`). When a cu #### Sale to Service Relationship -When a product of type `Service` is sold, a Service instance can be created from the Sale: +A SaleItem can be directly linked to a Service via the `service_id` field. This allows for selling existing services or creating new services as part of a sale: ```rust -// Create a service from a sale -let service = sale.create_service( - service_id, - ServiceStatus::Active, - BillingFrequency::Monthly -); +// Create a SaleItem linked to a service +let sale_item = SaleItemBuilder::new() + .id(1) + .sale_id(1) + .service_id(Some(42)) // Link to service with ID 42 + .product_id(None) // No product link since this is a service + .name("Premium Support") + .quantity(1) + .unit_price(unit_price) + .tax_rate(20.0) + .active_till(now + Duration::days(30)) + .build() + .expect("Failed to build sale item"); ``` #### Sale to Invoice Relationship diff --git a/herodb/src/models/biz/contract.rs b/herodb/src/models/biz/contract.rs index ffc6dcc..6b30952 100644 --- a/herodb/src/models/biz/contract.rs +++ b/herodb/src/models/biz/contract.rs @@ -1,4 +1,4 @@ -use crate::db::{Model, Storable}; // Import Model trait from db module +use crate::db::{Model, Storable, IndexKey}; // Import Model trait and IndexKey from db module use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -248,4 +248,46 @@ impl Model for Contract { fn db_prefix() -> &'static str { "contract" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for customer_id + keys.push(IndexKey { + name: "customer_id", + value: self.customer_id.to_string(), + }); + + // Add an index for service_id if present + if let Some(service_id) = self.service_id { + keys.push(IndexKey { + name: "service_id", + value: service_id.to_string(), + }); + } + + // Add an index for sale_id if present + if let Some(sale_id) = self.sale_id { + keys.push(IndexKey { + name: "sale_id", + value: sale_id.to_string(), + }); + } + + // Add an index for status + keys.push(IndexKey { + name: "status", + value: format!("{:?}", self.status), + }); + + // Add an index for active contracts + if self.is_active() { + keys.push(IndexKey { + name: "active", + value: "true".to_string(), + }); + } + + keys + } } \ No newline at end of file diff --git a/herodb/src/models/biz/currency.rs b/herodb/src/models/biz/currency.rs index 933c6f9..728cc5c 100644 --- a/herodb/src/models/biz/currency.rs +++ b/herodb/src/models/biz/currency.rs @@ -1,4 +1,4 @@ -use crate::db::model::Model; +use crate::db::model::{Model, IndexKey}; use crate::db::{Storable, DbError, DbResult}; use chrono::{DateTime, Duration, Utc}; use rhai::{CustomType, EvalAltResult, TypeBuilder}; @@ -85,4 +85,30 @@ impl Model for Currency { fn db_prefix() -> &'static str { "currency" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for currency_code + keys.push(IndexKey { + name: "currency_code", + value: self.currency_code.clone(), + }); + + // Add an index for amount range + // This allows finding currencies within specific ranges + let amount_range = match self.amount { + a if a < 100.0 => "0-100", + a if a < 1000.0 => "100-1000", + a if a < 10000.0 => "1000-10000", + _ => "10000+", + }; + + keys.push(IndexKey { + name: "amount_range", + value: amount_range.to_string(), + }); + + keys + } } diff --git a/herodb/src/models/biz/invoice.rs b/herodb/src/models/biz/invoice.rs index 8dd3b8a..45dd798 100644 --- a/herodb/src/models/biz/invoice.rs +++ b/herodb/src/models/biz/invoice.rs @@ -1,5 +1,5 @@ use crate::models::biz::Currency; // Use crate:: for importing from the module -use crate::db::{Model, Storable}; // Import Model trait from db module +use crate::db::{Model, Storable, IndexKey}; // Import Model trait and IndexKey from db module use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -511,4 +511,67 @@ impl Model for Invoice { fn db_prefix() -> &'static str { "invoice" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for customer_id + keys.push(IndexKey { + name: "customer_id", + value: self.customer_id.to_string(), + }); + + // Add an index for status + keys.push(IndexKey { + name: "status", + value: format!("{:?}", self.status), + }); + + // Add an index for payment_status + keys.push(IndexKey { + name: "payment_status", + value: format!("{:?}", self.payment_status), + }); + + // Add an index for currency code + keys.push(IndexKey { + name: "currency", + value: self.total_amount.currency_code.clone(), + }); + + // Add an index for amount range + let amount_range = match self.total_amount.amount { + a if a < 100.0 => "0-100", + a if a < 1000.0 => "100-1000", + a if a < 10000.0 => "1000-10000", + _ => "10000+", + }; + + keys.push(IndexKey { + name: "amount_range", + value: amount_range.to_string(), + }); + + // Add an index for issue date (year-month) + keys.push(IndexKey { + name: "issue_date", + value: format!("{}-{:02}", self.issue_date.year(), self.issue_date.month()), + }); + + // Add an index for due date (year-month) + keys.push(IndexKey { + name: "due_date", + value: format!("{}-{:02}", self.due_date.year(), self.due_date.month()), + }); + + // Add an index for overdue invoices + if self.is_overdue() { + keys.push(IndexKey { + name: "overdue", + value: "true".to_string(), + }); + } + + keys + } } \ No newline at end of file diff --git a/herodb/src/models/biz/product.rs b/herodb/src/models/biz/product.rs index 498627e..2a2b9ab 100644 --- a/herodb/src/models/biz/product.rs +++ b/herodb/src/models/biz/product.rs @@ -1,8 +1,8 @@ -use crate::db::model::Model; +use crate::db::model::{Model, IndexKey}; use crate::db::Storable; use chrono::{DateTime, Duration, Utc}; use rhai::{CustomType, EvalAltResult, TypeBuilder, export_module}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; /// ProductType represents the type of a product #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -355,6 +355,70 @@ impl Model for Product { fn db_prefix() -> &'static str { "product" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for name + keys.push(IndexKey { + name: "name", + value: self.name.clone(), + }); + + // Add an index for category + keys.push(IndexKey { + name: "category", + value: self.category.clone(), + }); + + // Add an index for product type + keys.push(IndexKey { + name: "type", + value: format!("{:?}", self.type_), + }); + + // Add an index for status + keys.push(IndexKey { + name: "status", + value: format!("{:?}", self.status), + }); + + // Add an index for price range + let price_range = match self.price.amount { + a if a < 100.0 => "0-100", + a if a < 1000.0 => "100-1000", + a if a < 10000.0 => "1000-10000", + _ => "10000+", + }; + + keys.push(IndexKey { + name: "price_range", + value: price_range.to_string(), + }); + + // Add an index for currency code + keys.push(IndexKey { + name: "currency", + value: self.price.currency_code.clone(), + }); + + // Add indexes for purchasable and active products + if self.is_purchasable() { + keys.push(IndexKey { + name: "purchasable", + value: "true".to_string(), + }); + } + + if self.is_active() { + keys.push(IndexKey { + name: "active", + value: "true".to_string(), + }); + } + + keys + } } // Import Currency from the currency module diff --git a/herodb/src/models/biz/sale.rs b/herodb/src/models/biz/sale.rs index ba279ee..673630a 100644 --- a/herodb/src/models/biz/sale.rs +++ b/herodb/src/models/biz/sale.rs @@ -1,4 +1,4 @@ -use crate::db::{Model, Storable, DbError, DbResult}; +use crate::db::{Model, Storable, DbError, DbResult, IndexKey}; use crate::models::biz::Currency; // Use crate:: for importing from the module // use super::db::Model; // Removed old Model trait import use chrono::{DateTime, Utc}; @@ -19,7 +19,8 @@ pub enum SaleStatus { pub struct SaleItem { pub id: u32, pub sale_id: u32, - pub product_id: u32, + pub product_id: Option, // ID of the product sold (if this is a product sale) + pub service_id: Option, // ID of the service sold (if this is a service sale) pub name: String, pub description: String, // Description of the item pub comments: String, // Additional comments about the item @@ -36,7 +37,8 @@ impl SaleItem { pub fn new( id: u32, sale_id: u32, - product_id: u32, + product_id: Option, + service_id: Option, name: String, description: String, comments: String, @@ -45,6 +47,12 @@ impl SaleItem { tax_rate: f64, active_till: DateTime, ) -> Self { + // Validate that either product_id or service_id is provided, but not both + assert!( + (product_id.is_some() && service_id.is_none()) || + (product_id.is_none() && service_id.is_some()), + "Either product_id or service_id must be provided, but not both" + ); // Calculate subtotal (before tax) let amount = unit_price.amount * quantity as f64; let subtotal = Currency::new( @@ -65,6 +73,7 @@ impl SaleItem { id, sale_id, product_id, + service_id, name, description, comments, @@ -93,6 +102,7 @@ pub struct SaleItemBuilder { id: Option, sale_id: Option, product_id: Option, + service_id: Option, name: Option, description: Option, comments: Option, @@ -111,6 +121,7 @@ impl SaleItemBuilder { id: None, sale_id: None, product_id: None, + service_id: None, name: None, description: None, comments: None, @@ -136,8 +147,22 @@ impl SaleItemBuilder { } /// Set the product_id - pub fn product_id(mut self, product_id: u32) -> Self { - self.product_id = Some(product_id); + pub fn product_id(mut self, product_id: Option) -> Self { + // If setting product_id, ensure service_id is None + if product_id.is_some() { + self.service_id = None; + } + self.product_id = product_id; + self + } + + /// Set the service_id + pub fn service_id(mut self, service_id: Option) -> Self { + // If setting service_id, ensure product_id is None + if service_id.is_some() { + self.product_id = None; + } + self.service_id = service_id; self } @@ -189,6 +214,14 @@ impl SaleItemBuilder { let quantity = self.quantity.ok_or("quantity is required")?; let tax_rate = self.tax_rate.unwrap_or(0.0); // Default to 0% tax if not specified + // Validate that either product_id or service_id is provided, but not both + if self.product_id.is_none() && self.service_id.is_none() { + return Err("Either product_id or service_id must be provided"); + } + if self.product_id.is_some() && self.service_id.is_some() { + return Err("Only one of product_id or service_id can be provided"); + } + // Calculate subtotal let amount = unit_price.amount * quantity as f64; let subtotal = Currency::new( @@ -208,7 +241,8 @@ impl SaleItemBuilder { Ok(SaleItem { id: self.id.ok_or("id is required")?, sale_id: self.sale_id.ok_or("sale_id is required")?, - product_id: self.product_id.ok_or("product_id is required")?, + product_id: self.product_id, + service_id: self.service_id, name: self.name.ok_or("name is required")?, description: self.description.unwrap_or_default(), comments: self.comments.unwrap_or_default(), @@ -226,10 +260,7 @@ impl SaleItemBuilder { #[derive(Debug, Clone, Serialize, Deserialize, CustomType)] pub struct Sale { pub id: u32, - pub company_id: u32, pub customer_id: u32, // ID of the customer making the purchase - pub buyer_name: String, - pub buyer_email: String, pub subtotal_amount: Currency, // Total before tax pub tax_amount: Currency, // Total tax pub total_amount: Currency, // Total including tax @@ -247,10 +278,7 @@ impl Sale { /// Create a new sale with default timestamps pub fn new( id: u32, - company_id: u32, customer_id: u32, - buyer_name: String, - buyer_email: String, currency_code: String, status: SaleStatus, ) -> Self { @@ -263,10 +291,7 @@ impl Sale { Self { id, - company_id, customer_id, - buyer_name, - buyer_email, subtotal_amount: zero_currency.clone(), tax_amount: zero_currency.clone(), total_amount: zero_currency, @@ -362,49 +387,10 @@ impl Sale { self.updated_at = Utc::now(); } - /// Create a service from this sale - /// This method should be called when a product of type Service is sold - pub fn create_service(&mut self, service_id: u32, status: crate::models::biz::ServiceStatus, billing_frequency: crate::models::biz::BillingFrequency) -> Result { - use crate::models::biz::{Service, ServiceItem, ServiceStatus, BillingFrequency}; - - // Create a new service - let mut service = Service::new( - service_id, - self.customer_id, - self.total_amount.currency_code.clone(), - status, - billing_frequency, - ); - - // Convert sale items to service items - for sale_item in &self.items { - // Check if the product is a service type - // In a real implementation, you would check the product type from the database - - // Create a service item from the sale item - let service_item = ServiceItem::new( - sale_item.id, - service_id, - sale_item.product_id, - sale_item.name.clone(), - sale_item.description.clone(), // Copy description from sale item - sale_item.comments.clone(), // Copy comments from sale item - sale_item.quantity, - sale_item.unit_price.clone(), - sale_item.tax_rate, - true, // is_taxable - sale_item.active_till, - ); - - // Add the service item to the service - service.add_item(service_item); - } - - // Link this sale to the service + /// Link this sale to an existing service + pub fn link_to_service(&mut self, service_id: u32) { self.service_id = Some(service_id); self.updated_at = Utc::now(); - - Ok(service) } } @@ -549,10 +535,7 @@ impl SaleBuilder { Ok(Sale { id, - company_id: self.company_id.ok_or("company_id is required")?, customer_id: self.customer_id.ok_or("customer_id is required")?, - buyer_name: self.buyer_name.ok_or("buyer_name is required")?, - buyer_email: self.buyer_email.ok_or("buyer_email is required")?, subtotal_amount: self.subtotal_amount.unwrap_or(subtotal_amount), tax_amount: self.tax_amount.unwrap_or(tax_amount), total_amount: self.total_amount.unwrap_or(total_amount), @@ -578,5 +561,19 @@ impl Model for Sale { fn db_prefix() -> &'static str { "sale" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for customer_id + keys.push(IndexKey { + name: "customer_id", + value: self.customer_id.to_string(), + }); + + + + keys + } } diff --git a/herodb/src/models/biz/service.rs b/herodb/src/models/biz/service.rs index ddac8cc..cf83c9d 100644 --- a/herodb/src/models/biz/service.rs +++ b/herodb/src/models/biz/service.rs @@ -1,5 +1,5 @@ use crate::models::biz::Currency; // Use crate:: for importing from the module -use crate::db::{Model, Storable, DbError, DbResult}; // Import Model trait from db module +use crate::db::{Model, Storable, DbError, DbResult, IndexKey}; // Import Model trait and IndexKey from db module use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -34,9 +34,6 @@ pub struct ServiceItem { pub quantity: i32, pub unit_price: Currency, pub subtotal: Currency, - pub tax_rate: f64, - pub tax_amount: Currency, - pub is_taxable: bool, pub active_till: DateTime, } @@ -51,8 +48,6 @@ impl ServiceItem { comments: String, quantity: i32, unit_price: Currency, - tax_rate: f64, - is_taxable: bool, active_till: DateTime, ) -> Self { // Calculate subtotal @@ -62,22 +57,7 @@ impl ServiceItem { amount, unit_price.currency_code.clone() ); - - // Calculate tax amount if taxable - let tax_amount = if is_taxable { - Currency::new( - 0, // Use 0 as a temporary ID - subtotal.amount * tax_rate, - unit_price.currency_code.clone() - ) - } else { - Currency::new( - 0, // Use 0 as a temporary ID - 0.0, - unit_price.currency_code.clone() - ) - }; - + Self { id, service_id, @@ -88,9 +68,6 @@ impl ServiceItem { quantity, unit_price, subtotal, - tax_rate, - tax_amount, - is_taxable, active_till, } } @@ -105,22 +82,6 @@ impl ServiceItem { ); } - /// Calculate the tax amount based on subtotal and tax rate - pub fn calculate_tax(&mut self) { - if self.is_taxable { - self.tax_amount = Currency::new( - 0, // Use 0 as a temporary ID - self.subtotal.amount * self.tax_rate, - self.subtotal.currency_code.clone() - ); - } else { - self.tax_amount = Currency::new( - 0, // Use 0 as a temporary ID - 0.0, - self.subtotal.currency_code.clone() - ); - } - } } /// Builder for ServiceItem @@ -266,9 +227,6 @@ impl ServiceItemBuilder { quantity, unit_price, subtotal, - tax_rate, - tax_amount, - is_taxable, active_till: self.active_till.ok_or("active_till is required")?, }) } @@ -321,13 +279,13 @@ impl Service { // First item, initialize the total amount with the same currency self.total_amount = Currency::new( 0, // Use 0 as a temporary ID - item.subtotal.amount + item.tax_amount.amount, + item.subtotal.amount , item.subtotal.currency_code.clone() ); } else { // Add to the existing total // (Assumes all items have the same currency) - self.total_amount.amount += item.subtotal.amount + item.tax_amount.amount; + self.total_amount.amount += item.subtotal.amount; } // Add the item to the list @@ -349,7 +307,7 @@ impl Service { // Calculate the total amount let mut total = 0.0; for item in &self.items { - total += item.subtotal.amount + item.tax_amount.amount; + total += item.subtotal.amount; } // Update the total amount @@ -467,13 +425,13 @@ impl ServiceBuilder { // First item, initialize the total amount with the same currency total_amount = Currency::new( 0, // Use 0 as a temporary ID - item.subtotal.amount + item.tax_amount.amount, + item.subtotal.amount, item.subtotal.currency_code.clone() ); } else { // Add to the existing total // (Assumes all items have the same currency) - total_amount.amount += item.subtotal.amount + item.tax_amount.amount; + total_amount.amount += item.subtotal.amount ; } } @@ -504,4 +462,17 @@ impl Model for Service { fn db_prefix() -> &'static str { "service" } + + fn db_keys(&self) -> Vec { + let mut keys = Vec::new(); + + // Add an index for customer_id + keys.push(IndexKey { + name: "customer_id", + value: self.customer_id.to_string(), + }); + + + keys + } } \ No newline at end of file