Skip to main content

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)]
394#[allow(clippy::unwrap_used)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_purchase_order_creation() {
400        let po = PurchaseOrder::new(
401            "PO-1000-0000000001",
402            "1000",
403            "V-000001",
404            2024,
405            1,
406            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
407            "JSMITH",
408        );
409
410        assert_eq!(po.vendor_id, "V-000001");
411        assert_eq!(po.header.status, DocumentStatus::Draft);
412    }
413
414    #[test]
415    fn test_purchase_order_items() {
416        let mut po = PurchaseOrder::new(
417            "PO-1000-0000000001",
418            "1000",
419            "V-000001",
420            2024,
421            1,
422            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
423            "JSMITH",
424        );
425
426        po.add_item(
427            PurchaseOrderItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
428                .with_cost_center("CC-1000"),
429        );
430
431        po.add_item(
432            PurchaseOrderItem::new(
433                2,
434                "Computer Equipment",
435                Decimal::from(5),
436                Decimal::from(500),
437            )
438            .with_cost_center("CC-1000"),
439        );
440
441        assert_eq!(po.items.len(), 2);
442        assert_eq!(po.total_net_amount, Decimal::from(2750)); // 250 + 2500
443    }
444
445    #[test]
446    fn test_goods_receipt_tracking() {
447        let mut item =
448            PurchaseOrderItem::new(1, "Test Item", Decimal::from(100), Decimal::from(10));
449
450        assert_eq!(item.open_quantity_gr(), Decimal::from(100));
451
452        item.record_goods_receipt(Decimal::from(60));
453        assert_eq!(item.open_quantity_gr(), Decimal::from(40));
454        assert!(!item.is_fully_received);
455
456        item.record_goods_receipt(Decimal::from(40));
457        assert_eq!(item.open_quantity_gr(), Decimal::ZERO);
458        assert!(item.is_fully_received);
459    }
460
461    #[test]
462    fn test_service_order() {
463        let item = PurchaseOrderItem::service(
464            1,
465            "Consulting Services",
466            Decimal::from(40),
467            Decimal::from(150),
468        );
469
470        assert_eq!(item.item_category, "SERVICE");
471        assert!(!item.gr_indicator);
472        assert_eq!(item.base.uom, "HR");
473    }
474}