use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::{
DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
ReferenceType,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeliveryType {
#[default]
Outbound,
Return,
StockTransfer,
Replenishment,
ConsignmentIssue,
ConsignmentReturn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeliveryStatus {
#[default]
Created,
PickReleased,
Picking,
Picked,
Packed,
GoodsIssued,
InTransit,
Delivered,
PartiallyDelivered,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveryItem {
#[serde(flatten)]
pub base: DocumentLineItem,
pub sales_order_id: Option<String>,
pub so_item: Option<u16>,
pub quantity_picked: Decimal,
pub quantity_packed: Decimal,
pub quantity_issued: Decimal,
pub is_fully_picked: bool,
pub is_fully_issued: bool,
pub batch: Option<String>,
pub serial_numbers: Vec<String>,
pub pick_location: Option<String>,
pub handling_unit: Option<String>,
pub weight: Option<Decimal>,
pub volume: Option<Decimal>,
pub cogs_amount: Decimal,
}
impl DeliveryItem {
#[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),
sales_order_id: None,
so_item: None,
quantity_picked: Decimal::ZERO,
quantity_packed: Decimal::ZERO,
quantity_issued: Decimal::ZERO,
is_fully_picked: false,
is_fully_issued: false,
batch: None,
serial_numbers: Vec::new(),
pick_location: None,
handling_unit: None,
weight: None,
volume: None,
cogs_amount: Decimal::ZERO,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_sales_order(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
sales_order_id: impl Into<String>,
so_item: u16,
) -> Self {
let mut item = Self::new(line_number, description, quantity, unit_price);
item.sales_order_id = Some(sales_order_id.into());
item.so_item = Some(so_item);
item
}
pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
self.base = self.base.with_material(material_id);
self
}
pub fn with_batch(mut self, batch: impl Into<String>) -> Self {
self.batch = Some(batch.into());
self
}
pub fn with_cogs(mut self, cogs: Decimal) -> Self {
self.cogs_amount = cogs;
self
}
pub fn with_location(
mut self,
plant: impl Into<String>,
storage_location: impl Into<String>,
) -> Self {
self.base.plant = Some(plant.into());
self.base.storage_location = Some(storage_location.into());
self
}
pub fn with_dimensions(mut self, weight: Decimal, volume: Decimal) -> Self {
self.weight = Some(weight);
self.volume = Some(volume);
self
}
pub fn add_serial_number(&mut self, serial: impl Into<String>) {
self.serial_numbers.push(serial.into());
}
pub fn record_pick(&mut self, quantity: Decimal) {
self.quantity_picked += quantity;
if self.quantity_picked >= self.base.quantity {
self.is_fully_picked = true;
}
}
pub fn record_pack(&mut self, quantity: Decimal) {
self.quantity_packed += quantity;
}
pub fn record_goods_issue(&mut self, quantity: Decimal) {
self.quantity_issued += quantity;
if self.quantity_issued >= self.base.quantity {
self.is_fully_issued = true;
}
}
pub fn open_quantity_pick(&self) -> Decimal {
(self.base.quantity - self.quantity_picked).max(Decimal::ZERO)
}
pub fn open_quantity_gi(&self) -> Decimal {
(self.quantity_picked - self.quantity_issued).max(Decimal::ZERO)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delivery {
pub header: DocumentHeader,
pub delivery_type: DeliveryType,
pub delivery_status: DeliveryStatus,
pub items: Vec<DeliveryItem>,
pub total_quantity: Decimal,
pub total_weight: Decimal,
pub total_volume: Decimal,
pub customer_id: String,
pub ship_to: Option<String>,
pub sales_order_id: Option<String>,
pub shipping_point: String,
pub route: Option<String>,
pub carrier: Option<String>,
pub shipping_condition: Option<String>,
pub incoterms: Option<String>,
pub planned_gi_date: NaiveDate,
pub actual_gi_date: Option<NaiveDate>,
pub delivery_date: Option<NaiveDate>,
pub pod_date: Option<NaiveDate>,
pub pod_signed_by: Option<String>,
pub bill_of_lading: Option<String>,
pub tracking_number: Option<String>,
pub number_of_packages: u32,
pub is_goods_issued: bool,
pub is_complete: bool,
pub is_cancelled: bool,
pub cancellation_doc: Option<String>,
pub total_cogs: Decimal,
}
impl Delivery {
#[allow(clippy::too_many_arguments)]
pub fn new(
delivery_id: impl Into<String>,
company_code: impl Into<String>,
customer_id: impl Into<String>,
shipping_point: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let header = DocumentHeader::new(
delivery_id,
DocumentType::Delivery,
company_code,
fiscal_year,
fiscal_period,
document_date,
created_by,
);
Self {
header,
delivery_type: DeliveryType::Outbound,
delivery_status: DeliveryStatus::Created,
items: Vec::new(),
total_quantity: Decimal::ZERO,
total_weight: Decimal::ZERO,
total_volume: Decimal::ZERO,
customer_id: customer_id.into(),
ship_to: None,
sales_order_id: None,
shipping_point: shipping_point.into(),
route: None,
carrier: None,
shipping_condition: None,
incoterms: None,
planned_gi_date: document_date,
actual_gi_date: None,
delivery_date: None,
pod_date: None,
pod_signed_by: None,
bill_of_lading: None,
tracking_number: None,
number_of_packages: 0,
is_goods_issued: false,
is_complete: false,
is_cancelled: false,
cancellation_doc: None,
total_cogs: Decimal::ZERO,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_sales_order(
delivery_id: impl Into<String>,
company_code: impl Into<String>,
sales_order_id: impl Into<String>,
customer_id: impl Into<String>,
shipping_point: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let so_id = sales_order_id.into();
let mut delivery = Self::new(
delivery_id,
company_code,
customer_id,
shipping_point,
fiscal_year,
fiscal_period,
document_date,
created_by,
);
delivery.sales_order_id = Some(so_id.clone());
delivery.header.add_reference(DocumentReference::new(
DocumentType::SalesOrder,
so_id,
DocumentType::Delivery,
delivery.header.document_id.clone(),
ReferenceType::FollowOn,
delivery.header.company_code.clone(),
document_date,
));
delivery
}
pub fn with_delivery_type(mut self, delivery_type: DeliveryType) -> Self {
self.delivery_type = delivery_type;
self
}
pub fn with_ship_to(mut self, ship_to: impl Into<String>) -> Self {
self.ship_to = Some(ship_to.into());
self
}
pub fn with_carrier(mut self, carrier: impl Into<String>) -> Self {
self.carrier = Some(carrier.into());
self
}
pub fn with_route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub fn with_planned_gi_date(mut self, date: NaiveDate) -> Self {
self.planned_gi_date = date;
self
}
pub fn add_item(&mut self, item: DeliveryItem) {
self.items.push(item);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.total_quantity = self.items.iter().map(|i| i.base.quantity).sum();
self.total_weight = self.items.iter().filter_map(|i| i.weight).sum();
self.total_volume = self.items.iter().filter_map(|i| i.volume).sum();
self.total_cogs = self.items.iter().map(|i| i.cogs_amount).sum();
}
pub fn release_for_picking(&mut self, user: impl Into<String>) {
self.delivery_status = DeliveryStatus::PickReleased;
self.header.update_status(DocumentStatus::Released, user);
}
pub fn start_picking(&mut self) {
self.delivery_status = DeliveryStatus::Picking;
}
pub fn confirm_pick(&mut self) {
if self.items.iter().all(|i| i.is_fully_picked) {
self.delivery_status = DeliveryStatus::Picked;
}
}
pub fn confirm_pack(&mut self, num_packages: u32) {
self.delivery_status = DeliveryStatus::Packed;
self.number_of_packages = num_packages;
}
pub fn post_goods_issue(&mut self, user: impl Into<String>, gi_date: NaiveDate) {
self.actual_gi_date = Some(gi_date);
self.is_goods_issued = true;
self.delivery_status = DeliveryStatus::GoodsIssued;
self.header.posting_date = Some(gi_date);
self.header.update_status(DocumentStatus::Posted, user);
for item in &mut self.items {
item.quantity_issued = item.quantity_picked;
item.is_fully_issued = true;
}
}
pub fn confirm_delivery(&mut self, delivery_date: NaiveDate) {
self.delivery_date = Some(delivery_date);
self.delivery_status = DeliveryStatus::Delivered;
}
pub fn record_pod(&mut self, pod_date: NaiveDate, signed_by: impl Into<String>) {
self.pod_date = Some(pod_date);
self.pod_signed_by = Some(signed_by.into());
self.is_complete = true;
self.header
.update_status(DocumentStatus::Completed, "SYSTEM");
}
pub fn cancel(&mut self, user: impl Into<String>, reason: impl Into<String>) {
self.is_cancelled = true;
self.delivery_status = DeliveryStatus::Cancelled;
self.header.header_text = Some(reason.into());
self.header.update_status(DocumentStatus::Cancelled, user);
}
pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
let mut entries = Vec::new();
if !self.is_goods_issued {
return entries;
}
for item in &self.items {
if item.cogs_amount > Decimal::ZERO {
let cogs_account = item
.base
.gl_account
.clone()
.unwrap_or_else(|| "500000".to_string());
let inventory_account = "140000".to_string();
entries.push((cogs_account, item.cogs_amount, Decimal::ZERO));
entries.push((inventory_account, Decimal::ZERO, item.cogs_amount));
}
}
entries
}
pub fn total_value(&self) -> Decimal {
self.items.iter().map(|i| i.base.net_amount).sum()
}
pub fn is_fully_picked(&self) -> bool {
self.items.iter().all(|i| i.is_fully_picked)
}
pub fn is_fully_issued(&self) -> bool {
self.items.iter().all(|i| i.is_fully_issued)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_delivery_creation() {
let delivery = Delivery::new(
"DLV-1000-0000000001",
"1000",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
assert_eq!(delivery.customer_id, "C-000001");
assert_eq!(delivery.shipping_point, "SP01");
assert_eq!(delivery.delivery_status, DeliveryStatus::Created);
}
#[test]
fn test_delivery_from_sales_order() {
let delivery = Delivery::from_sales_order(
"DLV-1000-0000000001",
"1000",
"SO-1000-0000000001",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
assert_eq!(
delivery.sales_order_id,
Some("SO-1000-0000000001".to_string())
);
assert_eq!(delivery.header.document_references.len(), 1);
}
#[test]
fn test_delivery_items() {
let mut delivery = Delivery::new(
"DLV-1000-0000000001",
"1000",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
let item = DeliveryItem::from_sales_order(
1,
"Product A",
Decimal::from(100),
Decimal::from(50),
"SO-1000-0000000001",
1,
)
.with_material("MAT-001")
.with_cogs(Decimal::from(3000));
delivery.add_item(item);
assert_eq!(delivery.total_quantity, Decimal::from(100));
assert_eq!(delivery.total_cogs, Decimal::from(3000));
}
#[test]
fn test_pick_process() {
let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50));
assert_eq!(item.open_quantity_pick(), Decimal::from(100));
item.record_pick(Decimal::from(60));
assert_eq!(item.open_quantity_pick(), Decimal::from(40));
assert!(!item.is_fully_picked);
item.record_pick(Decimal::from(40));
assert!(item.is_fully_picked);
}
#[test]
fn test_goods_issue_process() {
let mut delivery = Delivery::new(
"DLV-1000-0000000001",
"1000",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
.with_cogs(Decimal::from(3000));
item.record_pick(Decimal::from(100));
delivery.add_item(item);
delivery.release_for_picking("PICKER");
delivery.confirm_pick();
delivery.confirm_pack(5);
delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
assert!(delivery.is_goods_issued);
assert_eq!(delivery.delivery_status, DeliveryStatus::GoodsIssued);
assert!(delivery.is_fully_issued());
}
#[test]
fn test_gl_entry_generation() {
let mut delivery = Delivery::new(
"DLV-1000-0000000001",
"1000",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
.with_cogs(Decimal::from(3000));
item.record_pick(Decimal::from(100));
delivery.add_item(item);
delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
let entries = delivery.generate_gl_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].1, Decimal::from(3000));
assert_eq!(entries[1].2, Decimal::from(3000));
}
#[test]
fn test_delivery_complete() {
let mut delivery = Delivery::new(
"DLV-1000-0000000001",
"1000",
"C-000001",
"SP01",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
delivery.confirm_delivery(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap());
delivery.record_pod(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap(), "John Doe");
assert!(delivery.is_complete);
assert_eq!(delivery.delivery_status, DeliveryStatus::Delivered);
assert_eq!(delivery.pod_signed_by, Some("John Doe".to_string()));
}
}