datasynth_core/models/documents/
purchase_order.rs

1//! Purchase Order document model.
2//!
3//! Represents purchase orders in the P2P (Procure-to-Pay) process flow.
4
5use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8
9use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
10
11/// Purchase Order type.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum PurchaseOrderType {
15    /// Standard purchase order for goods
16    #[default]
17    Standard,
18    /// Service purchase order
19    Service,
20    /// Framework/blanket order
21    Framework,
22    /// Consignment order
23    Consignment,
24    /// Stock transfer order
25    StockTransfer,
26    /// Subcontracting order
27    Subcontracting,
28}
29
30impl PurchaseOrderType {
31    /// Check if this PO type requires goods receipt.
32    pub fn requires_goods_receipt(&self) -> bool {
33        !matches!(self, Self::Service)
34    }
35
36    /// Check if this is an internal order (stock transfer).
37    pub fn is_internal(&self) -> bool {
38        matches!(self, Self::StockTransfer)
39    }
40}
41
42/// Purchase Order line item with P2P specific fields.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PurchaseOrderItem {
45    /// Base line item fields
46    #[serde(flatten)]
47    pub base: DocumentLineItem,
48
49    /// Item category (goods, service, etc.)
50    pub item_category: String,
51
52    /// Purchasing group
53    pub purchasing_group: Option<String>,
54
55    /// Goods receipt indicator
56    pub gr_indicator: bool,
57
58    /// Invoice receipt indicator
59    pub ir_indicator: bool,
60
61    /// GR-based invoice verification
62    pub gr_based_iv: bool,
63
64    /// Quantity received so far
65    pub quantity_received: Decimal,
66
67    /// Quantity invoiced so far
68    pub quantity_invoiced: Decimal,
69
70    /// Quantity returned
71    pub quantity_returned: Decimal,
72
73    /// Is this line fully received?
74    pub is_fully_received: bool,
75
76    /// Is this line fully invoiced?
77    pub is_fully_invoiced: bool,
78
79    /// Requested delivery date
80    pub requested_date: Option<NaiveDate>,
81
82    /// Confirmed delivery date
83    pub confirmed_date: Option<NaiveDate>,
84
85    /// Incoterms
86    pub incoterms: Option<String>,
87
88    /// Account assignment category (cost center, asset, etc.)
89    pub account_assignment_category: String,
90}
91
92impl PurchaseOrderItem {
93    /// Create a new purchase order item.
94    #[allow(clippy::too_many_arguments)]
95    pub fn new(
96        line_number: u16,
97        description: impl Into<String>,
98        quantity: Decimal,
99        unit_price: Decimal,
100    ) -> Self {
101        Self {
102            base: DocumentLineItem::new(line_number, description, quantity, unit_price),
103            item_category: "GOODS".to_string(),
104            purchasing_group: None,
105            gr_indicator: true,
106            ir_indicator: true,
107            gr_based_iv: true,
108            quantity_received: Decimal::ZERO,
109            quantity_invoiced: Decimal::ZERO,
110            quantity_returned: Decimal::ZERO,
111            is_fully_received: false,
112            is_fully_invoiced: false,
113            requested_date: None,
114            confirmed_date: None,
115            incoterms: None,
116            account_assignment_category: "K".to_string(), // Cost center
117        }
118    }
119
120    /// Create a service line item.
121    pub fn service(
122        line_number: u16,
123        description: impl Into<String>,
124        quantity: Decimal,
125        unit_price: Decimal,
126    ) -> Self {
127        let mut item = Self::new(line_number, description, quantity, unit_price);
128        item.item_category = "SERVICE".to_string();
129        item.gr_indicator = false;
130        item.gr_based_iv = false;
131        item.base.uom = "HR".to_string();
132        item
133    }
134
135    /// Set material.
136    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
137        self.base = self.base.with_material(material_id);
138        self
139    }
140
141    /// Set cost center.
142    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
143        self.base = self.base.with_cost_center(cost_center);
144        self
145    }
146
147    /// Set GL account.
148    pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
149        self.base = self.base.with_gl_account(account);
150        self
151    }
152
153    /// Set requested delivery date.
154    pub fn with_requested_date(mut self, date: NaiveDate) -> Self {
155        self.requested_date = Some(date);
156        self.base = self.base.with_delivery_date(date);
157        self
158    }
159
160    /// Set purchasing group.
161    pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
162        self.purchasing_group = Some(group.into());
163        self
164    }
165
166    /// Record goods receipt.
167    pub fn record_goods_receipt(&mut self, quantity: Decimal) {
168        self.quantity_received += quantity;
169        if self.quantity_received >= self.base.quantity {
170            self.is_fully_received = true;
171        }
172    }
173
174    /// Record invoice receipt.
175    pub fn record_invoice(&mut self, quantity: Decimal) {
176        self.quantity_invoiced += quantity;
177        if self.quantity_invoiced >= self.base.quantity {
178            self.is_fully_invoiced = true;
179        }
180    }
181
182    /// Get open quantity for receipt.
183    pub fn open_quantity_gr(&self) -> Decimal {
184        (self.base.quantity - self.quantity_received - self.quantity_returned).max(Decimal::ZERO)
185    }
186
187    /// Get open quantity for invoice.
188    pub fn open_quantity_iv(&self) -> Decimal {
189        if self.gr_based_iv {
190            (self.quantity_received - self.quantity_invoiced).max(Decimal::ZERO)
191        } else {
192            (self.base.quantity - self.quantity_invoiced).max(Decimal::ZERO)
193        }
194    }
195
196    /// Get open amount for invoice.
197    pub fn open_amount_iv(&self) -> Decimal {
198        self.open_quantity_iv() * self.base.unit_price
199    }
200}
201
202/// Purchase Order document.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct PurchaseOrder {
205    /// Document header
206    pub header: DocumentHeader,
207
208    /// PO type
209    pub po_type: PurchaseOrderType,
210
211    /// Vendor ID
212    pub vendor_id: String,
213
214    /// Purchasing organization
215    pub purchasing_org: String,
216
217    /// Purchasing group
218    pub purchasing_group: String,
219
220    /// Payment terms
221    pub payment_terms: String,
222
223    /// Incoterms
224    pub incoterms: Option<String>,
225
226    /// Incoterms location
227    pub incoterms_location: Option<String>,
228
229    /// Line items
230    pub items: Vec<PurchaseOrderItem>,
231
232    /// Total net amount
233    pub total_net_amount: Decimal,
234
235    /// Total tax amount
236    pub total_tax_amount: Decimal,
237
238    /// Total gross amount
239    pub total_gross_amount: Decimal,
240
241    /// Is this PO completely delivered?
242    pub is_complete: bool,
243
244    /// Is this PO closed?
245    pub is_closed: bool,
246
247    /// Related purchase requisition
248    pub requisition_id: Option<String>,
249
250    /// Contract reference
251    pub contract_id: Option<String>,
252
253    /// Release status (for framework orders)
254    pub release_status: Option<String>,
255
256    /// Output control - PO printed/sent
257    pub output_complete: bool,
258}
259
260impl PurchaseOrder {
261    /// Create a new purchase order.
262    pub fn new(
263        po_id: impl Into<String>,
264        company_code: impl Into<String>,
265        vendor_id: impl Into<String>,
266        fiscal_year: u16,
267        fiscal_period: u8,
268        document_date: NaiveDate,
269        created_by: impl Into<String>,
270    ) -> Self {
271        let header = DocumentHeader::new(
272            po_id,
273            DocumentType::PurchaseOrder,
274            company_code,
275            fiscal_year,
276            fiscal_period,
277            document_date,
278            created_by,
279        );
280
281        Self {
282            header,
283            po_type: PurchaseOrderType::Standard,
284            vendor_id: vendor_id.into(),
285            purchasing_org: "1000".to_string(),
286            purchasing_group: "001".to_string(),
287            payment_terms: "NET30".to_string(),
288            incoterms: None,
289            incoterms_location: None,
290            items: Vec::new(),
291            total_net_amount: Decimal::ZERO,
292            total_tax_amount: Decimal::ZERO,
293            total_gross_amount: Decimal::ZERO,
294            is_complete: false,
295            is_closed: false,
296            requisition_id: None,
297            contract_id: None,
298            release_status: None,
299            output_complete: false,
300        }
301    }
302
303    /// Set PO type.
304    pub fn with_po_type(mut self, po_type: PurchaseOrderType) -> Self {
305        self.po_type = po_type;
306        self
307    }
308
309    /// Set purchasing organization.
310    pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
311        self.purchasing_org = org.into();
312        self
313    }
314
315    /// Set purchasing group.
316    pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
317        self.purchasing_group = group.into();
318        self
319    }
320
321    /// Set payment terms.
322    pub fn with_payment_terms(mut self, terms: impl Into<String>) -> Self {
323        self.payment_terms = terms.into();
324        self
325    }
326
327    /// Set incoterms.
328    pub fn with_incoterms(
329        mut self,
330        incoterms: impl Into<String>,
331        location: impl Into<String>,
332    ) -> Self {
333        self.incoterms = Some(incoterms.into());
334        self.incoterms_location = Some(location.into());
335        self
336    }
337
338    /// Add a line item.
339    pub fn add_item(&mut self, item: PurchaseOrderItem) {
340        self.items.push(item);
341        self.recalculate_totals();
342    }
343
344    /// Recalculate totals from items.
345    pub fn recalculate_totals(&mut self) {
346        self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
347        self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
348        self.total_gross_amount = self.items.iter().map(|i| i.base.gross_amount).sum();
349    }
350
351    /// Release the PO for processing.
352    pub fn release(&mut self, user: impl Into<String>) {
353        self.header.update_status(DocumentStatus::Released, user);
354    }
355
356    /// Check if all items are fully received.
357    pub fn check_complete(&mut self) {
358        self.is_complete = self
359            .items
360            .iter()
361            .all(|i| !i.gr_indicator || i.is_fully_received)
362            && self
363                .items
364                .iter()
365                .all(|i| !i.ir_indicator || i.is_fully_invoiced);
366    }
367
368    /// Get total open amount for goods receipt.
369    pub fn open_gr_amount(&self) -> Decimal {
370        self.items
371            .iter()
372            .filter(|i| i.gr_indicator)
373            .map(|i| i.open_quantity_gr() * i.base.unit_price)
374            .sum()
375    }
376
377    /// Get total open amount for invoice.
378    pub fn open_iv_amount(&self) -> Decimal {
379        self.items
380            .iter()
381            .filter(|i| i.ir_indicator)
382            .map(|i| i.open_amount_iv())
383            .sum()
384    }
385
386    /// Close the PO.
387    pub fn close(&mut self, user: impl Into<String>) {
388        self.is_closed = true;
389        self.header.update_status(DocumentStatus::Completed, user);
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_purchase_order_creation() {
399        let po = PurchaseOrder::new(
400            "PO-1000-0000000001",
401            "1000",
402            "V-000001",
403            2024,
404            1,
405            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
406            "JSMITH",
407        );
408
409        assert_eq!(po.vendor_id, "V-000001");
410        assert_eq!(po.header.status, DocumentStatus::Draft);
411    }
412
413    #[test]
414    fn test_purchase_order_items() {
415        let mut po = PurchaseOrder::new(
416            "PO-1000-0000000001",
417            "1000",
418            "V-000001",
419            2024,
420            1,
421            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
422            "JSMITH",
423        );
424
425        po.add_item(
426            PurchaseOrderItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
427                .with_cost_center("CC-1000"),
428        );
429
430        po.add_item(
431            PurchaseOrderItem::new(
432                2,
433                "Computer Equipment",
434                Decimal::from(5),
435                Decimal::from(500),
436            )
437            .with_cost_center("CC-1000"),
438        );
439
440        assert_eq!(po.items.len(), 2);
441        assert_eq!(po.total_net_amount, Decimal::from(2750)); // 250 + 2500
442    }
443
444    #[test]
445    fn test_goods_receipt_tracking() {
446        let mut item =
447            PurchaseOrderItem::new(1, "Test Item", Decimal::from(100), Decimal::from(10));
448
449        assert_eq!(item.open_quantity_gr(), Decimal::from(100));
450
451        item.record_goods_receipt(Decimal::from(60));
452        assert_eq!(item.open_quantity_gr(), Decimal::from(40));
453        assert!(!item.is_fully_received);
454
455        item.record_goods_receipt(Decimal::from(40));
456        assert_eq!(item.open_quantity_gr(), Decimal::ZERO);
457        assert!(item.is_fully_received);
458    }
459
460    #[test]
461    fn test_service_order() {
462        let item = PurchaseOrderItem::service(
463            1,
464            "Consulting Services",
465            Decimal::from(40),
466            Decimal::from(150),
467        );
468
469        assert_eq!(item.item_category, "SERVICE");
470        assert!(!item.gr_indicator);
471        assert_eq!(item.base.uom, "HR");
472    }
473}