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 PurchaseOrderType {
#[default]
Standard,
Service,
Framework,
Consignment,
StockTransfer,
Subcontracting,
}
impl PurchaseOrderType {
pub fn requires_goods_receipt(&self) -> bool {
!matches!(self, Self::Service)
}
pub fn is_internal(&self) -> bool {
matches!(self, Self::StockTransfer)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseOrderItem {
#[serde(flatten)]
pub base: DocumentLineItem,
pub item_category: String,
pub purchasing_group: Option<String>,
pub gr_indicator: bool,
pub ir_indicator: bool,
pub gr_based_iv: bool,
pub quantity_received: Decimal,
pub quantity_invoiced: Decimal,
pub quantity_returned: Decimal,
pub is_fully_received: bool,
pub is_fully_invoiced: bool,
pub requested_date: Option<NaiveDate>,
pub confirmed_date: Option<NaiveDate>,
pub incoterms: Option<String>,
pub account_assignment_category: String,
}
impl PurchaseOrderItem {
#[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: "GOODS".to_string(),
purchasing_group: None,
gr_indicator: true,
ir_indicator: true,
gr_based_iv: true,
quantity_received: Decimal::ZERO,
quantity_invoiced: Decimal::ZERO,
quantity_returned: Decimal::ZERO,
is_fully_received: false,
is_fully_invoiced: false,
requested_date: None,
confirmed_date: None,
incoterms: None,
account_assignment_category: "K".to_string(), }
}
pub fn service(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
) -> Self {
let mut item = Self::new(line_number, description, quantity, unit_price);
item.item_category = "SERVICE".to_string();
item.gr_indicator = false;
item.gr_based_iv = false;
item.base.uom = "HR".to_string();
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_cost_center(mut self, cost_center: impl Into<String>) -> Self {
self.base = self.base.with_cost_center(cost_center);
self
}
pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
self.base = self.base.with_gl_account(account);
self
}
pub fn with_requested_date(mut self, date: NaiveDate) -> Self {
self.requested_date = Some(date);
self.base = self.base.with_delivery_date(date);
self
}
pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
self.purchasing_group = Some(group.into());
self
}
pub fn record_goods_receipt(&mut self, quantity: Decimal) {
self.quantity_received += quantity;
if self.quantity_received >= self.base.quantity {
self.is_fully_received = 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_gr(&self) -> Decimal {
(self.base.quantity - self.quantity_received - self.quantity_returned).max(Decimal::ZERO)
}
pub fn open_quantity_iv(&self) -> Decimal {
if self.gr_based_iv {
(self.quantity_received - self.quantity_invoiced).max(Decimal::ZERO)
} else {
(self.base.quantity - self.quantity_invoiced).max(Decimal::ZERO)
}
}
pub fn open_amount_iv(&self) -> Decimal {
self.open_quantity_iv() * self.base.unit_price
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseOrder {
pub header: DocumentHeader,
pub po_type: PurchaseOrderType,
pub vendor_id: String,
pub purchasing_org: String,
pub purchasing_group: String,
pub payment_terms: String,
pub incoterms: Option<String>,
pub incoterms_location: Option<String>,
pub items: Vec<PurchaseOrderItem>,
pub total_net_amount: Decimal,
pub total_tax_amount: Decimal,
pub total_gross_amount: Decimal,
pub is_complete: bool,
pub is_closed: bool,
pub requisition_id: Option<String>,
pub contract_id: Option<String>,
pub release_status: Option<String>,
pub output_complete: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor_name: Option<String>,
}
impl PurchaseOrder {
pub fn new(
po_id: impl Into<String>,
company_code: impl Into<String>,
vendor_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
document_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let header = DocumentHeader::new(
po_id,
DocumentType::PurchaseOrder,
company_code,
fiscal_year,
fiscal_period,
document_date,
created_by,
);
Self {
header,
po_type: PurchaseOrderType::Standard,
vendor_id: vendor_id.into(),
purchasing_org: "1000".to_string(),
purchasing_group: "001".to_string(),
payment_terms: "NET30".to_string(),
incoterms: None,
incoterms_location: None,
items: Vec::new(),
total_net_amount: Decimal::ZERO,
total_tax_amount: Decimal::ZERO,
total_gross_amount: Decimal::ZERO,
is_complete: false,
is_closed: false,
requisition_id: None,
contract_id: None,
release_status: None,
output_complete: false,
vendor_name: None,
}
}
pub fn with_po_type(mut self, po_type: PurchaseOrderType) -> Self {
self.po_type = po_type;
self
}
pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
self.purchasing_org = org.into();
self
}
pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
self.purchasing_group = group.into();
self
}
pub fn with_payment_terms(mut self, terms: impl Into<String>) -> Self {
self.payment_terms = terms.into();
self
}
pub fn with_incoterms(
mut self,
incoterms: impl Into<String>,
location: impl Into<String>,
) -> Self {
self.incoterms = Some(incoterms.into());
self.incoterms_location = Some(location.into());
self
}
pub fn add_item(&mut self, item: PurchaseOrderItem) {
self.items.push(item);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
self.total_gross_amount = self.items.iter().map(|i| i.base.gross_amount).sum();
}
pub fn release(&mut self, user: impl Into<String>) {
self.header.update_status(DocumentStatus::Released, user);
}
pub fn check_complete(&mut self) {
self.is_complete = self
.items
.iter()
.all(|i| !i.gr_indicator || i.is_fully_received)
&& self
.items
.iter()
.all(|i| !i.ir_indicator || i.is_fully_invoiced);
}
pub fn open_gr_amount(&self) -> Decimal {
self.items
.iter()
.filter(|i| i.gr_indicator)
.map(|i| i.open_quantity_gr() * i.base.unit_price)
.sum()
}
pub fn open_iv_amount(&self) -> Decimal {
self.items
.iter()
.filter(|i| i.ir_indicator)
.map(PurchaseOrderItem::open_amount_iv)
.sum()
}
pub fn close(&mut self, user: impl Into<String>) {
self.is_closed = true;
self.header.update_status(DocumentStatus::Completed, user);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_purchase_order_creation() {
let po = PurchaseOrder::new(
"PO-1000-0000000001",
"1000",
"V-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
assert_eq!(po.vendor_id, "V-000001");
assert_eq!(po.header.status, DocumentStatus::Draft);
}
#[test]
fn test_purchase_order_items() {
let mut po = PurchaseOrder::new(
"PO-1000-0000000001",
"1000",
"V-000001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
po.add_item(
PurchaseOrderItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
.with_cost_center("CC-1000"),
);
po.add_item(
PurchaseOrderItem::new(
2,
"Computer Equipment",
Decimal::from(5),
Decimal::from(500),
)
.with_cost_center("CC-1000"),
);
assert_eq!(po.items.len(), 2);
assert_eq!(po.total_net_amount, Decimal::from(2750)); }
#[test]
fn test_goods_receipt_tracking() {
let mut item =
PurchaseOrderItem::new(1, "Test Item", Decimal::from(100), Decimal::from(10));
assert_eq!(item.open_quantity_gr(), Decimal::from(100));
item.record_goods_receipt(Decimal::from(60));
assert_eq!(item.open_quantity_gr(), Decimal::from(40));
assert!(!item.is_fully_received);
item.record_goods_receipt(Decimal::from(40));
assert_eq!(item.open_quantity_gr(), Decimal::ZERO);
assert!(item.is_fully_received);
}
#[test]
fn test_service_order() {
let item = PurchaseOrderItem::service(
1,
"Consulting Services",
Decimal::from(40),
Decimal::from(150),
);
assert_eq!(item.item_category, "SERVICE");
assert!(!item.gr_indicator);
assert_eq!(item.base.uom, "HR");
}
}