use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SalesOrderType {
#[default]
Standard,
Rush,
CashSale,
Return,
FreeOfCharge,
Consignment,
Service,
CreditMemoRequest,
DebitMemoRequest,
}
impl SalesOrderType {
pub fn requires_delivery(&self) -> bool {
!matches!(
self,
Self::Service | Self::CreditMemoRequest | Self::DebitMemoRequest
)
}
pub fn creates_revenue(&self) -> bool {
!matches!(
self,
Self::FreeOfCharge | Self::Return | Self::CreditMemoRequest
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SalesOrderItem {
#[serde(flatten)]
pub base: DocumentLineItem,
pub item_category: String,
pub schedule_lines: Vec<ScheduleLine>,
pub quantity_confirmed: Decimal,
pub quantity_delivered: Decimal,
pub quantity_invoiced: Decimal,
pub is_fully_delivered: bool,
pub is_fully_invoiced: bool,
pub rejection_reason: Option<String>,
pub is_rejected: bool,
pub route: Option<String>,
pub shipping_point: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleLine {
pub schedule_number: u16,
pub requested_date: NaiveDate,
pub confirmed_date: Option<NaiveDate>,
pub quantity: Decimal,
pub delivered_quantity: Decimal,
}
impl SalesOrderItem {
#[allow(clippy::too_many_arguments)]
pub fn new(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
) -> Self {
Self {
base: DocumentLineItem::new(line_number, description, quantity, unit_price),
item_category: "TAN".to_string(), schedule_lines: Vec::new(),
quantity_confirmed: Decimal::ZERO,
quantity_delivered: Decimal::ZERO,
quantity_invoiced: Decimal::ZERO,
is_fully_delivered: false,
is_fully_invoiced: false,
rejection_reason: None,
is_rejected: false,
route: None,
shipping_point: None,
}
}
pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
self.base = self.base.with_material(material_id);
self
}
pub fn with_plant(mut self, plant: impl Into<String>) -> Self {
self.base.plant = Some(plant.into());
self
}
pub fn add_schedule_line(&mut self, requested_date: NaiveDate, quantity: Decimal) {
let schedule_number = (self.schedule_lines.len() + 1) as u16;
self.schedule_lines.push(ScheduleLine {
schedule_number,
requested_date,
confirmed_date: None,
quantity,
delivered_quantity: Decimal::ZERO,
});
}
pub fn confirm_schedule(&mut self, schedule_number: u16, confirmed_date: NaiveDate) {
if let Some(line) = self
.schedule_lines
.iter_mut()
.find(|l| l.schedule_number == schedule_number)
{
line.confirmed_date = Some(confirmed_date);
self.quantity_confirmed += line.quantity;
}
}
pub fn record_delivery(&mut self, quantity: Decimal) {
self.quantity_delivered += quantity;
if self.quantity_delivered >= self.base.quantity {
self.is_fully_delivered = true;
}
}
pub fn record_invoice(&mut self, quantity: Decimal) {
self.quantity_invoiced += quantity;
if self.quantity_invoiced >= self.base.quantity {
self.is_fully_invoiced = true;
}
}
pub fn open_quantity_delivery(&self) -> Decimal {
(self.base.quantity - self.quantity_delivered).max(Decimal::ZERO)
}
pub fn open_quantity_billing(&self) -> Decimal {
(self.quantity_delivered - self.quantity_invoiced).max(Decimal::ZERO)
}
pub fn reject(&mut self, reason: impl Into<String>) {
self.is_rejected = true;
self.rejection_reason = Some(reason.into());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SalesOrder {
pub header: DocumentHeader,
pub so_type: SalesOrderType,
pub customer_id: String,
pub sold_to: Option<String>,
pub ship_to: Option<String>,
pub bill_to: Option<String>,
pub payer: Option<String>,
pub sales_org: String,
pub distribution_channel: String,
pub division: String,
pub sales_office: Option<String>,
pub sales_group: Option<String>,
pub items: Vec<SalesOrderItem>,
pub total_net_amount: Decimal,
pub total_tax_amount: Decimal,
pub total_gross_amount: Decimal,
pub payment_terms: String,
pub incoterms: Option<String>,
pub shipping_condition: Option<String>,
pub requested_delivery_date: Option<NaiveDate>,
pub customer_po_number: Option<String>,
pub is_complete: bool,
pub credit_status: CreditStatus,
pub credit_block_reason: Option<String>,
pub is_delivery_released: bool,
pub is_billing_released: bool,
pub quote_id: Option<String>,
pub contract_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CreditStatus {
#[default]
NotChecked,
Passed,
Failed,
Released,
}
impl SalesOrder {
pub fn new(
so_id: impl Into<String>,
company_code: impl Into<String>,
customer_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let header = DocumentHeader::new(
so_id,
DocumentType::SalesOrder,
company_code,
fiscal_year,
fiscal_period,
document_date,
created_by,
);
Self {
header,
so_type: SalesOrderType::Standard,
customer_id: customer_id.into(),
sold_to: None,
ship_to: None,
bill_to: None,
payer: None,
sales_org: "1000".to_string(),
distribution_channel: "10".to_string(),
division: "00".to_string(),
sales_office: None,
sales_group: None,
items: Vec::new(),
total_net_amount: Decimal::ZERO,
total_tax_amount: Decimal::ZERO,
total_gross_amount: Decimal::ZERO,
payment_terms: "NET30".to_string(),
incoterms: None,
shipping_condition: None,
requested_delivery_date: None,
customer_po_number: None,
is_complete: false,
credit_status: CreditStatus::NotChecked,
credit_block_reason: None,
is_delivery_released: false,
is_billing_released: false,
quote_id: None,
contract_id: None,
customer_name: None,
}
}
pub fn with_so_type(mut self, so_type: SalesOrderType) -> Self {
self.so_type = so_type;
self
}
pub fn with_sales_org(
mut self,
sales_org: impl Into<String>,
dist_channel: impl Into<String>,
division: impl Into<String>,
) -> Self {
self.sales_org = sales_org.into();
self.distribution_channel = dist_channel.into();
self.division = division.into();
self
}
pub fn with_partners(
mut self,
sold_to: impl Into<String>,
ship_to: impl Into<String>,
bill_to: impl Into<String>,
) -> Self {
self.sold_to = Some(sold_to.into());
self.ship_to = Some(ship_to.into());
self.bill_to = Some(bill_to.into());
self
}
pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
self.customer_po_number = Some(po_number.into());
self
}
pub fn with_requested_delivery_date(mut self, date: NaiveDate) -> Self {
self.requested_delivery_date = Some(date);
self
}
pub fn add_item(&mut self, item: SalesOrderItem) {
self.items.push(item);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.total_net_amount = self
.items
.iter()
.filter(|i| !i.is_rejected)
.map(|i| i.base.net_amount)
.sum();
self.total_tax_amount = self
.items
.iter()
.filter(|i| !i.is_rejected)
.map(|i| i.base.tax_amount)
.sum();
self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
}
pub fn check_credit(&mut self, passed: bool, block_reason: Option<String>) {
if passed {
self.credit_status = CreditStatus::Passed;
self.credit_block_reason = None;
} else {
self.credit_status = CreditStatus::Failed;
self.credit_block_reason = block_reason;
}
}
pub fn release_credit_block(&mut self, user: impl Into<String>) {
self.credit_status = CreditStatus::Released;
self.credit_block_reason = None;
self.header.update_status(DocumentStatus::Released, user);
}
pub fn release_for_delivery(&mut self) {
self.is_delivery_released = true;
}
pub fn release_for_billing(&mut self) {
self.is_billing_released = true;
}
pub fn check_complete(&mut self) {
self.is_complete = self
.items
.iter()
.all(|i| i.is_rejected || i.is_fully_invoiced);
}
pub fn open_delivery_value(&self) -> Decimal {
self.items
.iter()
.filter(|i| !i.is_rejected)
.map(|i| i.open_quantity_delivery() * i.base.unit_price)
.sum()
}
pub fn open_billing_value(&self) -> Decimal {
self.items
.iter()
.filter(|i| !i.is_rejected)
.map(|i| i.open_quantity_billing() * i.base.unit_price)
.sum()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_sales_order_creation() {
let so = SalesOrder::new(
"SO-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
assert_eq!(so.customer_id, "C-000001");
assert_eq!(so.header.status, DocumentStatus::Draft);
}
#[test]
fn test_sales_order_items() {
let mut so = SalesOrder::new(
"SO-1000-0000000001",
"1000",
"C-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
item.add_schedule_line(
NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
Decimal::from(10),
);
so.add_item(item);
assert_eq!(so.total_net_amount, Decimal::from(1000));
assert_eq!(so.items[0].schedule_lines.len(), 1);
}
#[test]
fn test_delivery_tracking() {
let mut item = SalesOrderItem::new(1, "Product A", Decimal::from(100), Decimal::from(10));
assert_eq!(item.open_quantity_delivery(), Decimal::from(100));
item.record_delivery(Decimal::from(60));
assert_eq!(item.open_quantity_delivery(), Decimal::from(40));
assert!(!item.is_fully_delivered);
item.record_delivery(Decimal::from(40));
assert!(item.is_fully_delivered);
}
}