datasynth_core/models/documents/
purchase_requisition.rs

1//! Purchase Requisition document model.
2//!
3//! Represents purchase requisitions in the P2P (Procure-to-Pay) process flow.
4//! Purchase requisitions are the starting point for procurement and do not create GL entries.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
11
12/// Purchase Requisition type.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum PurchaseRequisitionType {
16    /// Standard PR for goods/services
17    #[default]
18    Standard,
19    /// Emergency/rush PR requiring expedited processing
20    Emergency,
21    /// Framework PR for blanket release orders
22    Framework,
23    /// Consignment PR for consignment inventory
24    Consignment,
25    /// Non-stock material PR (direct expense)
26    NonStock,
27    /// Service PR for external services
28    Service,
29}
30
31impl PurchaseRequisitionType {
32    /// Check if this PR type requires expedited processing.
33    pub fn is_expedited(&self) -> bool {
34        matches!(self, Self::Emergency)
35    }
36
37    /// Check if this PR type requires goods receipt.
38    pub fn requires_goods_receipt(&self) -> bool {
39        !matches!(self, Self::Service | Self::NonStock)
40    }
41
42    /// Check if this is a service requisition.
43    pub fn is_service(&self) -> bool {
44        matches!(self, Self::Service)
45    }
46}
47
48/// Purchase Requisition priority.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum RequisitionPriority {
52    /// Low priority - can wait
53    Low,
54    /// Normal priority - standard processing
55    #[default]
56    Normal,
57    /// High priority - expedite
58    High,
59    /// Urgent - immediate attention required
60    Urgent,
61}
62
63impl RequisitionPriority {
64    /// Get the numeric priority value (higher = more urgent).
65    pub fn value(&self) -> u8 {
66        match self {
67            Self::Low => 1,
68            Self::Normal => 2,
69            Self::High => 3,
70            Self::Urgent => 4,
71        }
72    }
73}
74
75/// Purchase Requisition line item with procurement-specific fields.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PurchaseRequisitionItem {
78    /// Base line item fields
79    #[serde(flatten)]
80    pub base: DocumentLineItem,
81
82    /// Requester user ID
83    pub requester: String,
84
85    /// Approver user ID (when approved)
86    pub approver: Option<String>,
87
88    /// Purchasing group to handle this item
89    pub purchasing_group: Option<String>,
90
91    /// Preferred vendor ID (if any)
92    pub preferred_vendor: Option<String>,
93
94    /// Fixed vendor (must use this vendor)
95    pub fixed_vendor: Option<String>,
96
97    /// Budget center for approval
98    pub budget_center: Option<String>,
99
100    /// Is this item approved?
101    pub is_approved: bool,
102
103    /// Is this item rejected?
104    pub is_rejected: bool,
105
106    /// Rejection reason
107    pub rejection_reason: Option<String>,
108
109    /// Business justification/reason for request
110    pub reason: Option<String>,
111
112    /// Requested delivery date
113    pub requested_date: Option<NaiveDate>,
114
115    /// Item category (goods, service, limit, etc.)
116    pub item_category: String,
117
118    /// Account assignment category (K=cost center, F=order, etc.)
119    pub account_assignment_category: String,
120
121    /// Related Purchase Order ID (once converted)
122    pub purchase_order_id: Option<String>,
123
124    /// Related PO item number
125    pub purchase_order_item: Option<u16>,
126
127    /// Is this item closed (cannot be converted)?
128    pub is_closed: bool,
129
130    /// Tracking number for status updates
131    pub tracking_number: Option<String>,
132}
133
134impl PurchaseRequisitionItem {
135    /// Create a new purchase requisition item.
136    pub fn new(
137        line_number: u16,
138        description: impl Into<String>,
139        quantity: Decimal,
140        unit_price: Decimal,
141        requester: impl Into<String>,
142    ) -> Self {
143        Self {
144            base: DocumentLineItem::new(line_number, description, quantity, unit_price),
145            requester: requester.into(),
146            approver: None,
147            purchasing_group: None,
148            preferred_vendor: None,
149            fixed_vendor: None,
150            budget_center: None,
151            is_approved: false,
152            is_rejected: false,
153            rejection_reason: None,
154            reason: None,
155            requested_date: None,
156            item_category: "GOODS".to_string(),
157            account_assignment_category: "K".to_string(),
158            purchase_order_id: None,
159            purchase_order_item: None,
160            is_closed: false,
161            tracking_number: None,
162        }
163    }
164
165    /// Create a service line item.
166    pub fn service(
167        line_number: u16,
168        description: impl Into<String>,
169        quantity: Decimal,
170        unit_price: Decimal,
171        requester: impl Into<String>,
172    ) -> Self {
173        let mut item = Self::new(line_number, description, quantity, unit_price, requester);
174        item.item_category = "SERVICE".to_string();
175        item.base.uom = "HR".to_string();
176        item
177    }
178
179    /// Set cost center.
180    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
181        self.base = self.base.with_cost_center(cost_center);
182        self
183    }
184
185    /// Set GL account.
186    pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
187        self.base = self.base.with_gl_account(account);
188        self
189    }
190
191    /// Set material.
192    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
193        self.base = self.base.with_material(material_id);
194        self
195    }
196
197    /// Set purchasing group.
198    pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
199        self.purchasing_group = Some(group.into());
200        self
201    }
202
203    /// Set preferred vendor.
204    pub fn with_preferred_vendor(mut self, vendor: impl Into<String>) -> Self {
205        self.preferred_vendor = Some(vendor.into());
206        self
207    }
208
209    /// Set fixed vendor (mandatory source).
210    pub fn with_fixed_vendor(mut self, vendor: impl Into<String>) -> Self {
211        self.fixed_vendor = Some(vendor.into());
212        self
213    }
214
215    /// Set requested delivery date.
216    pub fn with_requested_date(mut self, date: NaiveDate) -> Self {
217        self.requested_date = Some(date);
218        self.base = self.base.with_delivery_date(date);
219        self
220    }
221
222    /// Set business justification.
223    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
224        self.reason = Some(reason.into());
225        self
226    }
227
228    /// Set budget center.
229    pub fn with_budget_center(mut self, budget_center: impl Into<String>) -> Self {
230        self.budget_center = Some(budget_center.into());
231        self
232    }
233
234    /// Approve the line item.
235    pub fn approve(&mut self, approver: impl Into<String>) {
236        self.is_approved = true;
237        self.is_rejected = false;
238        self.approver = Some(approver.into());
239        self.rejection_reason = None;
240    }
241
242    /// Reject the line item.
243    pub fn reject(&mut self, approver: impl Into<String>, reason: impl Into<String>) {
244        self.is_rejected = true;
245        self.is_approved = false;
246        self.approver = Some(approver.into());
247        self.rejection_reason = Some(reason.into());
248    }
249
250    /// Convert to PO (mark as converted).
251    pub fn convert_to_po(&mut self, po_id: impl Into<String>, po_item: u16) {
252        self.purchase_order_id = Some(po_id.into());
253        self.purchase_order_item = Some(po_item);
254    }
255
256    /// Close the item (cannot be converted).
257    pub fn close(&mut self) {
258        self.is_closed = true;
259    }
260
261    /// Check if item can be converted to PO.
262    pub fn can_convert(&self) -> bool {
263        self.is_approved && !self.is_rejected && !self.is_closed && self.purchase_order_id.is_none()
264    }
265
266    /// Get open quantity (not yet converted to PO).
267    pub fn open_quantity(&self) -> Decimal {
268        if self.purchase_order_id.is_some() || self.is_closed {
269            Decimal::ZERO
270        } else {
271            self.base.quantity
272        }
273    }
274}
275
276/// Purchase Requisition document.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PurchaseRequisition {
279    /// Document header
280    pub header: DocumentHeader,
281
282    /// PR type
283    pub pr_type: PurchaseRequisitionType,
284
285    /// Priority
286    pub priority: RequisitionPriority,
287
288    /// Requester user ID (document-level)
289    pub requester_id: String,
290
291    /// Requester name
292    pub requester_name: Option<String>,
293
294    /// Requester email
295    pub requester_email: Option<String>,
296
297    /// Requester department
298    pub requester_department: Option<String>,
299
300    /// Approver user ID (document-level)
301    pub approver_id: Option<String>,
302
303    /// Purchasing organization
304    pub purchasing_org: String,
305
306    /// Default purchasing group
307    pub purchasing_group: Option<String>,
308
309    /// Line items
310    pub items: Vec<PurchaseRequisitionItem>,
311
312    /// Total net amount
313    pub total_net_amount: Decimal,
314
315    /// Total tax amount
316    pub total_tax_amount: Decimal,
317
318    /// Total gross amount
319    pub total_gross_amount: Decimal,
320
321    /// Is this PR fully approved?
322    pub is_approved: bool,
323
324    /// Is this PR fully converted to PO(s)?
325    pub is_converted: bool,
326
327    /// Is this PR closed?
328    pub is_closed: bool,
329
330    /// Related PO IDs (multiple POs can be created from one PR)
331    pub purchase_order_ids: Vec<String>,
332
333    /// Business justification (document-level)
334    pub justification: Option<String>,
335
336    /// Budget code for approval routing
337    pub budget_code: Option<String>,
338
339    /// Approval workflow ID
340    pub workflow_id: Option<String>,
341
342    /// Notes/comments
343    pub notes: Option<String>,
344
345    /// Desired vendor (document-level preference)
346    pub desired_vendor: Option<String>,
347}
348
349impl PurchaseRequisition {
350    /// Create a new purchase requisition.
351    pub fn new(
352        pr_id: impl Into<String>,
353        company_code: impl Into<String>,
354        requester_id: impl Into<String>,
355        fiscal_year: u16,
356        fiscal_period: u8,
357        document_date: NaiveDate,
358        created_by: impl Into<String>,
359    ) -> Self {
360        let header = DocumentHeader::new(
361            pr_id,
362            DocumentType::PurchaseRequisition,
363            company_code,
364            fiscal_year,
365            fiscal_period,
366            document_date,
367            created_by,
368        );
369
370        Self {
371            header,
372            pr_type: PurchaseRequisitionType::Standard,
373            priority: RequisitionPriority::Normal,
374            requester_id: requester_id.into(),
375            requester_name: None,
376            requester_email: None,
377            requester_department: None,
378            approver_id: None,
379            purchasing_org: "1000".to_string(),
380            purchasing_group: None,
381            items: Vec::new(),
382            total_net_amount: Decimal::ZERO,
383            total_tax_amount: Decimal::ZERO,
384            total_gross_amount: Decimal::ZERO,
385            is_approved: false,
386            is_converted: false,
387            is_closed: false,
388            purchase_order_ids: Vec::new(),
389            justification: None,
390            budget_code: None,
391            workflow_id: None,
392            notes: None,
393            desired_vendor: None,
394        }
395    }
396
397    /// Set PR type.
398    pub fn with_pr_type(mut self, pr_type: PurchaseRequisitionType) -> Self {
399        self.pr_type = pr_type;
400        self
401    }
402
403    /// Set priority.
404    pub fn with_priority(mut self, priority: RequisitionPriority) -> Self {
405        self.priority = priority;
406        self
407    }
408
409    /// Set purchasing organization.
410    pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
411        self.purchasing_org = org.into();
412        self
413    }
414
415    /// Set purchasing group.
416    pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
417        self.purchasing_group = Some(group.into());
418        self
419    }
420
421    /// Set requester details.
422    pub fn with_requester_details(
423        mut self,
424        name: impl Into<String>,
425        email: impl Into<String>,
426        department: impl Into<String>,
427    ) -> Self {
428        self.requester_name = Some(name.into());
429        self.requester_email = Some(email.into());
430        self.requester_department = Some(department.into());
431        self
432    }
433
434    /// Set justification.
435    pub fn with_justification(mut self, justification: impl Into<String>) -> Self {
436        self.justification = Some(justification.into());
437        self
438    }
439
440    /// Set budget code.
441    pub fn with_budget_code(mut self, code: impl Into<String>) -> Self {
442        self.budget_code = Some(code.into());
443        self
444    }
445
446    /// Set desired vendor.
447    pub fn with_desired_vendor(mut self, vendor: impl Into<String>) -> Self {
448        self.desired_vendor = Some(vendor.into());
449        self
450    }
451
452    /// Add a line item.
453    pub fn add_item(&mut self, item: PurchaseRequisitionItem) {
454        self.items.push(item);
455        self.recalculate_totals();
456    }
457
458    /// Recalculate totals from items.
459    pub fn recalculate_totals(&mut self) {
460        self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
461        self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
462        self.total_gross_amount = self.items.iter().map(|i| i.base.gross_amount).sum();
463    }
464
465    /// Submit the PR for approval.
466    pub fn submit(&mut self, user: impl Into<String>) {
467        self.header.update_status(DocumentStatus::Submitted, user);
468    }
469
470    /// Approve the entire PR.
471    pub fn approve(&mut self, approver: impl Into<String>) {
472        let approver_str: String = approver.into();
473        self.is_approved = true;
474        self.approver_id = Some(approver_str.clone());
475
476        // Approve all pending items
477        for item in &mut self.items {
478            if !item.is_rejected && !item.is_approved {
479                item.approve(approver_str.clone());
480            }
481        }
482
483        self.header
484            .update_status(DocumentStatus::Approved, approver_str);
485    }
486
487    /// Reject the entire PR.
488    pub fn reject(&mut self, approver: impl Into<String>, reason: impl Into<String>) {
489        let approver_str: String = approver.into();
490        let reason_str: String = reason.into();
491
492        self.approver_id = Some(approver_str.clone());
493
494        // Reject all non-approved items
495        for item in &mut self.items {
496            if !item.is_approved {
497                item.reject(approver_str.clone(), reason_str.clone());
498            }
499        }
500
501        self.header
502            .update_status(DocumentStatus::Rejected, approver_str);
503    }
504
505    /// Release the PR for conversion to PO.
506    pub fn release(&mut self, user: impl Into<String>) {
507        if self.is_approved {
508            self.header.update_status(DocumentStatus::Released, user);
509        }
510    }
511
512    /// Convert to PO (mark as converted).
513    pub fn convert_to_po(&mut self, po_id: impl Into<String>, user: impl Into<String>) {
514        let po_id_str = po_id.into();
515        self.purchase_order_ids.push(po_id_str.clone());
516
517        // Check if all items are converted
518        let all_converted = self
519            .items
520            .iter()
521            .all(|i| i.purchase_order_id.is_some() || i.is_closed || i.is_rejected);
522
523        if all_converted {
524            self.is_converted = true;
525            self.header.update_status(DocumentStatus::Completed, user);
526        } else {
527            self.header
528                .update_status(DocumentStatus::PartiallyProcessed, user);
529        }
530    }
531
532    /// Close the PR.
533    pub fn close(&mut self, user: impl Into<String>) {
534        self.is_closed = true;
535        for item in &mut self.items {
536            if item.purchase_order_id.is_none() {
537                item.close();
538            }
539        }
540        self.header.update_status(DocumentStatus::Completed, user);
541    }
542
543    /// Get total open amount (not yet converted to PO).
544    pub fn open_amount(&self) -> Decimal {
545        self.items
546            .iter()
547            .filter(|i| i.can_convert())
548            .map(|i| i.base.net_amount)
549            .sum()
550    }
551
552    /// Get count of approved items.
553    pub fn approved_item_count(&self) -> usize {
554        self.items.iter().filter(|i| i.is_approved).count()
555    }
556
557    /// Get count of rejected items.
558    pub fn rejected_item_count(&self) -> usize {
559        self.items.iter().filter(|i| i.is_rejected).count()
560    }
561
562    /// Get count of converted items.
563    pub fn converted_item_count(&self) -> usize {
564        self.items
565            .iter()
566            .filter(|i| i.purchase_order_id.is_some())
567            .count()
568    }
569
570    /// Check if all items are approved.
571    pub fn all_items_approved(&self) -> bool {
572        !self.items.is_empty()
573            && self
574                .items
575                .iter()
576                .all(|i| i.is_approved || i.is_rejected || i.is_closed)
577    }
578
579    /// Check if any items can be converted.
580    pub fn has_convertible_items(&self) -> bool {
581        self.items.iter().any(|i| i.can_convert())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn test_purchase_requisition_creation() {
591        let pr = PurchaseRequisition::new(
592            "PR-1000-0000000001",
593            "1000",
594            "EMP-001",
595            2024,
596            1,
597            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
598            "JSMITH",
599        );
600
601        assert_eq!(pr.requester_id, "EMP-001");
602        assert_eq!(pr.header.status, DocumentStatus::Draft);
603        assert!(!pr.is_approved);
604        assert_eq!(pr.pr_type, PurchaseRequisitionType::Standard);
605        assert_eq!(pr.priority, RequisitionPriority::Normal);
606    }
607
608    #[test]
609    fn test_purchase_requisition_items() {
610        let mut pr = PurchaseRequisition::new(
611            "PR-1000-0000000001",
612            "1000",
613            "EMP-001",
614            2024,
615            1,
616            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
617            "JSMITH",
618        );
619
620        pr.add_item(
621            PurchaseRequisitionItem::new(
622                1,
623                "Office Supplies",
624                Decimal::from(10),
625                Decimal::from(25),
626                "EMP-001",
627            )
628            .with_cost_center("CC-1000"),
629        );
630
631        pr.add_item(
632            PurchaseRequisitionItem::new(
633                2,
634                "Computer Equipment",
635                Decimal::from(5),
636                Decimal::from(500),
637                "EMP-001",
638            )
639            .with_cost_center("CC-1000"),
640        );
641
642        assert_eq!(pr.items.len(), 2);
643        assert_eq!(pr.total_net_amount, Decimal::from(2750)); // 250 + 2500
644    }
645
646    #[test]
647    fn test_pr_approval_workflow() {
648        let mut pr = PurchaseRequisition::new(
649            "PR-1000-0000000001",
650            "1000",
651            "EMP-001",
652            2024,
653            1,
654            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
655            "JSMITH",
656        );
657
658        pr.add_item(PurchaseRequisitionItem::new(
659            1,
660            "Test Item",
661            Decimal::from(10),
662            Decimal::from(100),
663            "EMP-001",
664        ));
665
666        // Submit
667        pr.submit("JSMITH");
668        assert_eq!(pr.header.status, DocumentStatus::Submitted);
669
670        // Approve
671        pr.approve("MANAGER");
672        assert!(pr.is_approved);
673        assert_eq!(pr.header.status, DocumentStatus::Approved);
674        assert!(pr.items[0].is_approved);
675        assert_eq!(pr.items[0].approver, Some("MANAGER".to_string()));
676    }
677
678    #[test]
679    fn test_pr_rejection() {
680        let mut pr = PurchaseRequisition::new(
681            "PR-1000-0000000001",
682            "1000",
683            "EMP-001",
684            2024,
685            1,
686            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
687            "JSMITH",
688        );
689
690        pr.add_item(PurchaseRequisitionItem::new(
691            1,
692            "Expensive Item",
693            Decimal::from(1),
694            Decimal::from(10000),
695            "EMP-001",
696        ));
697
698        pr.submit("JSMITH");
699        pr.reject("MANAGER", "Budget exceeded");
700
701        assert_eq!(pr.header.status, DocumentStatus::Rejected);
702        assert!(pr.items[0].is_rejected);
703        assert_eq!(
704            pr.items[0].rejection_reason,
705            Some("Budget exceeded".to_string())
706        );
707    }
708
709    #[test]
710    fn test_pr_conversion_to_po() {
711        let mut pr = PurchaseRequisition::new(
712            "PR-1000-0000000001",
713            "1000",
714            "EMP-001",
715            2024,
716            1,
717            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
718            "JSMITH",
719        );
720
721        pr.add_item(PurchaseRequisitionItem::new(
722            1,
723            "Test Item",
724            Decimal::from(10),
725            Decimal::from(100),
726            "EMP-001",
727        ));
728
729        pr.approve("MANAGER");
730        pr.release("BUYER");
731
732        // Convert item to PO
733        pr.items[0].convert_to_po("PO-1000-0000000001", 1);
734        pr.convert_to_po("PO-1000-0000000001", "BUYER");
735
736        assert!(pr.is_converted);
737        assert_eq!(pr.header.status, DocumentStatus::Completed);
738        assert_eq!(
739            pr.items[0].purchase_order_id,
740            Some("PO-1000-0000000001".to_string())
741        );
742    }
743
744    #[test]
745    fn test_pr_item_can_convert() {
746        let mut item = PurchaseRequisitionItem::new(
747            1,
748            "Test Item",
749            Decimal::from(10),
750            Decimal::from(100),
751            "EMP-001",
752        );
753
754        // Cannot convert if not approved
755        assert!(!item.can_convert());
756
757        // Can convert after approval
758        item.approve("MANAGER");
759        assert!(item.can_convert());
760
761        // Cannot convert after conversion
762        item.convert_to_po("PO-001", 1);
763        assert!(!item.can_convert());
764    }
765
766    #[test]
767    fn test_service_requisition() {
768        let item = PurchaseRequisitionItem::service(
769            1,
770            "Consulting Services",
771            Decimal::from(40),
772            Decimal::from(150),
773            "EMP-001",
774        );
775
776        assert_eq!(item.item_category, "SERVICE");
777        assert_eq!(item.base.uom, "HR");
778    }
779
780    #[test]
781    fn test_pr_type_properties() {
782        assert!(PurchaseRequisitionType::Emergency.is_expedited());
783        assert!(!PurchaseRequisitionType::Standard.is_expedited());
784        assert!(PurchaseRequisitionType::Service.is_service());
785        assert!(!PurchaseRequisitionType::Service.requires_goods_receipt());
786        assert!(!PurchaseRequisitionType::NonStock.requires_goods_receipt());
787        assert!(PurchaseRequisitionType::Standard.requires_goods_receipt());
788        assert!(PurchaseRequisitionType::Framework.requires_goods_receipt());
789    }
790
791    #[test]
792    fn test_priority_values() {
793        assert!(RequisitionPriority::Urgent.value() > RequisitionPriority::High.value());
794        assert!(RequisitionPriority::High.value() > RequisitionPriority::Normal.value());
795        assert!(RequisitionPriority::Normal.value() > RequisitionPriority::Low.value());
796    }
797}