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    /// Vendor display name (denormalized, DS-011)
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub vendor_name: Option<String>,
262}
263
264impl PurchaseOrder {
265    /// Create a new purchase order.
266    pub fn new(
267        po_id: impl Into<String>,
268        company_code: impl Into<String>,
269        vendor_id: impl Into<String>,
270        fiscal_year: u16,
271        fiscal_period: u8,
272        document_date: NaiveDate,
273        created_by: impl Into<String>,
274    ) -> Self {
275        let header = DocumentHeader::new(
276            po_id,
277            DocumentType::PurchaseOrder,
278            company_code,
279            fiscal_year,
280            fiscal_period,
281            document_date,
282            created_by,
283        );
284
285        Self {
286            header,
287            po_type: PurchaseOrderType::Standard,
288            vendor_id: vendor_id.into(),
289            purchasing_org: "1000".to_string(),
290            purchasing_group: "001".to_string(),
291            payment_terms: "NET30".to_string(),
292            incoterms: None,
293            incoterms_location: None,
294            items: Vec::new(),
295            total_net_amount: Decimal::ZERO,
296            total_tax_amount: Decimal::ZERO,
297            total_gross_amount: Decimal::ZERO,
298            is_complete: false,
299            is_closed: false,
300            requisition_id: None,
301            contract_id: None,
302            release_status: None,
303            output_complete: false,
304            vendor_name: None,
305        }
306    }
307
308    /// Set PO type.
309    pub fn with_po_type(mut self, po_type: PurchaseOrderType) -> Self {
310        self.po_type = po_type;
311        self
312    }
313
314    /// Set purchasing organization.
315    pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
316        self.purchasing_org = org.into();
317        self
318    }
319
320    /// Set purchasing group.
321    pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
322        self.purchasing_group = group.into();
323        self
324    }
325
326    /// Set payment terms.
327    pub fn with_payment_terms(mut self, terms: impl Into<String>) -> Self {
328        self.payment_terms = terms.into();
329        self
330    }
331
332    /// Set incoterms.
333    pub fn with_incoterms(
334        mut self,
335        incoterms: impl Into<String>,
336        location: impl Into<String>,
337    ) -> Self {
338        self.incoterms = Some(incoterms.into());
339        self.incoterms_location = Some(location.into());
340        self
341    }
342
343    /// Add a line item.
344    pub fn add_item(&mut self, item: PurchaseOrderItem) {
345        self.items.push(item);
346        self.recalculate_totals();
347    }
348
349    /// Recalculate totals from items.
350    pub fn recalculate_totals(&mut self) {
351        self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
352        self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
353        self.total_gross_amount = self.items.iter().map(|i| i.base.gross_amount).sum();
354    }
355
356    /// Release the PO for processing.
357    pub fn release(&mut self, user: impl Into<String>) {
358        self.header.update_status(DocumentStatus::Released, user);
359    }
360
361    /// Check if all items are fully received.
362    pub fn check_complete(&mut self) {
363        self.is_complete = self
364            .items
365            .iter()
366            .all(|i| !i.gr_indicator || i.is_fully_received)
367            && self
368                .items
369                .iter()
370                .all(|i| !i.ir_indicator || i.is_fully_invoiced);
371    }
372
373    /// Get total open amount for goods receipt.
374    pub fn open_gr_amount(&self) -> Decimal {
375        self.items
376            .iter()
377            .filter(|i| i.gr_indicator)
378            .map(|i| i.open_quantity_gr() * i.base.unit_price)
379            .sum()
380    }
381
382    /// Get total open amount for invoice.
383    pub fn open_iv_amount(&self) -> Decimal {
384        self.items
385            .iter()
386            .filter(|i| i.ir_indicator)
387            .map(|i| i.open_amount_iv())
388            .sum()
389    }
390
391    /// Close the PO.
392    pub fn close(&mut self, user: impl Into<String>) {
393        self.is_closed = true;
394        self.header.update_status(DocumentStatus::Completed, user);
395    }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_purchase_order_creation() {
405        let po = PurchaseOrder::new(
406            "PO-1000-0000000001",
407            "1000",
408            "V-000001",
409            2024,
410            1,
411            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
412            "JSMITH",
413        );
414
415        assert_eq!(po.vendor_id, "V-000001");
416        assert_eq!(po.header.status, DocumentStatus::Draft);
417    }
418
419    #[test]
420    fn test_purchase_order_items() {
421        let mut po = PurchaseOrder::new(
422            "PO-1000-0000000001",
423            "1000",
424            "V-000001",
425            2024,
426            1,
427            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
428            "JSMITH",
429        );
430
431        po.add_item(
432            PurchaseOrderItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
433                .with_cost_center("CC-1000"),
434        );
435
436        po.add_item(
437            PurchaseOrderItem::new(
438                2,
439                "Computer Equipment",
440                Decimal::from(5),
441                Decimal::from(500),
442            )
443            .with_cost_center("CC-1000"),
444        );
445
446        assert_eq!(po.items.len(), 2);
447        assert_eq!(po.total_net_amount, Decimal::from(2750)); // 250 + 2500
448    }
449
450    #[test]
451    fn test_goods_receipt_tracking() {
452        let mut item =
453            PurchaseOrderItem::new(1, "Test Item", Decimal::from(100), Decimal::from(10));
454
455        assert_eq!(item.open_quantity_gr(), Decimal::from(100));
456
457        item.record_goods_receipt(Decimal::from(60));
458        assert_eq!(item.open_quantity_gr(), Decimal::from(40));
459        assert!(!item.is_fully_received);
460
461        item.record_goods_receipt(Decimal::from(40));
462        assert_eq!(item.open_quantity_gr(), Decimal::ZERO);
463        assert!(item.is_fully_received);
464    }
465
466    #[test]
467    fn test_service_order() {
468        let item = PurchaseOrderItem::service(
469            1,
470            "Consulting Services",
471            Decimal::from(40),
472            Decimal::from(150),
473        );
474
475        assert_eq!(item.item_category, "SERVICE");
476        assert!(!item.gr_indicator);
477        assert_eq!(item.base.uom, "HR");
478    }
479}