1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum PurchaseRequisitionType {
16 #[default]
18 Standard,
19 Emergency,
21 Framework,
23 Consignment,
25 NonStock,
27 Service,
29}
30
31impl PurchaseRequisitionType {
32 pub fn is_expedited(&self) -> bool {
34 matches!(self, Self::Emergency)
35 }
36
37 pub fn requires_goods_receipt(&self) -> bool {
39 !matches!(self, Self::Service | Self::NonStock)
40 }
41
42 pub fn is_service(&self) -> bool {
44 matches!(self, Self::Service)
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum RequisitionPriority {
52 Low,
54 #[default]
56 Normal,
57 High,
59 Urgent,
61}
62
63impl RequisitionPriority {
64 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#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PurchaseRequisitionItem {
78 #[serde(flatten)]
80 pub base: DocumentLineItem,
81
82 pub requester: String,
84
85 pub approver: Option<String>,
87
88 pub purchasing_group: Option<String>,
90
91 pub preferred_vendor: Option<String>,
93
94 pub fixed_vendor: Option<String>,
96
97 pub budget_center: Option<String>,
99
100 pub is_approved: bool,
102
103 pub is_rejected: bool,
105
106 pub rejection_reason: Option<String>,
108
109 pub reason: Option<String>,
111
112 pub requested_date: Option<NaiveDate>,
114
115 pub item_category: String,
117
118 pub account_assignment_category: String,
120
121 pub purchase_order_id: Option<String>,
123
124 pub purchase_order_item: Option<u16>,
126
127 pub is_closed: bool,
129
130 pub tracking_number: Option<String>,
132}
133
134impl PurchaseRequisitionItem {
135 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 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 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 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 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 pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
199 self.purchasing_group = Some(group.into());
200 self
201 }
202
203 pub fn with_preferred_vendor(mut self, vendor: impl Into<String>) -> Self {
205 self.preferred_vendor = Some(vendor.into());
206 self
207 }
208
209 pub fn with_fixed_vendor(mut self, vendor: impl Into<String>) -> Self {
211 self.fixed_vendor = Some(vendor.into());
212 self
213 }
214
215 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 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
224 self.reason = Some(reason.into());
225 self
226 }
227
228 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 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 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 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 pub fn close(&mut self) {
258 self.is_closed = true;
259 }
260
261 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PurchaseRequisition {
279 pub header: DocumentHeader,
281
282 pub pr_type: PurchaseRequisitionType,
284
285 pub priority: RequisitionPriority,
287
288 pub requester_id: String,
290
291 pub requester_name: Option<String>,
293
294 pub requester_email: Option<String>,
296
297 pub requester_department: Option<String>,
299
300 pub approver_id: Option<String>,
302
303 pub purchasing_org: String,
305
306 pub purchasing_group: Option<String>,
308
309 pub items: Vec<PurchaseRequisitionItem>,
311
312 pub total_net_amount: Decimal,
314
315 pub total_tax_amount: Decimal,
317
318 pub total_gross_amount: Decimal,
320
321 pub is_approved: bool,
323
324 pub is_converted: bool,
326
327 pub is_closed: bool,
329
330 pub purchase_order_ids: Vec<String>,
332
333 pub justification: Option<String>,
335
336 pub budget_code: Option<String>,
338
339 pub workflow_id: Option<String>,
341
342 pub notes: Option<String>,
344
345 pub desired_vendor: Option<String>,
347}
348
349impl PurchaseRequisition {
350 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 pub fn with_pr_type(mut self, pr_type: PurchaseRequisitionType) -> Self {
399 self.pr_type = pr_type;
400 self
401 }
402
403 pub fn with_priority(mut self, priority: RequisitionPriority) -> Self {
405 self.priority = priority;
406 self
407 }
408
409 pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
411 self.purchasing_org = org.into();
412 self
413 }
414
415 pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
417 self.purchasing_group = Some(group.into());
418 self
419 }
420
421 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 pub fn with_justification(mut self, justification: impl Into<String>) -> Self {
436 self.justification = Some(justification.into());
437 self
438 }
439
440 pub fn with_budget_code(mut self, code: impl Into<String>) -> Self {
442 self.budget_code = Some(code.into());
443 self
444 }
445
446 pub fn with_desired_vendor(mut self, vendor: impl Into<String>) -> Self {
448 self.desired_vendor = Some(vendor.into());
449 self
450 }
451
452 pub fn add_item(&mut self, item: PurchaseRequisitionItem) {
454 self.items.push(item);
455 self.recalculate_totals();
456 }
457
458 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 pub fn submit(&mut self, user: impl Into<String>) {
467 self.header.update_status(DocumentStatus::Submitted, user);
468 }
469
470 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 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 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 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 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 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 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 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 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 pub fn approved_item_count(&self) -> usize {
554 self.items.iter().filter(|i| i.is_approved).count()
555 }
556
557 pub fn rejected_item_count(&self) -> usize {
559 self.items.iter().filter(|i| i.is_rejected).count()
560 }
561
562 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 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 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)); }
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 pr.submit("JSMITH");
668 assert_eq!(pr.header.status, DocumentStatus::Submitted);
669
670 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 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 assert!(!item.can_convert());
756
757 item.approve("MANAGER");
759 assert!(item.can_convert());
760
761 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}