1use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8
9use super::{DocumentHeader, DocumentLineItem, DocumentStatus, DocumentType};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum PurchaseOrderType {
15 #[default]
17 Standard,
18 Service,
20 Framework,
22 Consignment,
24 StockTransfer,
26 Subcontracting,
28}
29
30impl PurchaseOrderType {
31 pub fn requires_goods_receipt(&self) -> bool {
33 !matches!(self, Self::Service)
34 }
35
36 pub fn is_internal(&self) -> bool {
38 matches!(self, Self::StockTransfer)
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PurchaseOrderItem {
45 #[serde(flatten)]
47 pub base: DocumentLineItem,
48
49 pub item_category: String,
51
52 pub purchasing_group: Option<String>,
54
55 pub gr_indicator: bool,
57
58 pub ir_indicator: bool,
60
61 pub gr_based_iv: bool,
63
64 pub quantity_received: Decimal,
66
67 pub quantity_invoiced: Decimal,
69
70 pub quantity_returned: Decimal,
72
73 pub is_fully_received: bool,
75
76 pub is_fully_invoiced: bool,
78
79 pub requested_date: Option<NaiveDate>,
81
82 pub confirmed_date: Option<NaiveDate>,
84
85 pub incoterms: Option<String>,
87
88 pub account_assignment_category: String,
90}
91
92impl PurchaseOrderItem {
93 #[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(), }
118 }
119
120 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 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 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 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 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 pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
162 self.purchasing_group = Some(group.into());
163 self
164 }
165
166 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 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 pub fn open_quantity_gr(&self) -> Decimal {
184 (self.base.quantity - self.quantity_received - self.quantity_returned).max(Decimal::ZERO)
185 }
186
187 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 pub fn open_amount_iv(&self) -> Decimal {
198 self.open_quantity_iv() * self.base.unit_price
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct PurchaseOrder {
205 pub header: DocumentHeader,
207
208 pub po_type: PurchaseOrderType,
210
211 pub vendor_id: String,
213
214 pub purchasing_org: String,
216
217 pub purchasing_group: String,
219
220 pub payment_terms: String,
222
223 pub incoterms: Option<String>,
225
226 pub incoterms_location: Option<String>,
228
229 pub items: Vec<PurchaseOrderItem>,
231
232 pub total_net_amount: Decimal,
234
235 pub total_tax_amount: Decimal,
237
238 pub total_gross_amount: Decimal,
240
241 pub is_complete: bool,
243
244 pub is_closed: bool,
246
247 pub requisition_id: Option<String>,
249
250 pub contract_id: Option<String>,
252
253 pub release_status: Option<String>,
255
256 pub output_complete: bool,
258}
259
260impl PurchaseOrder {
261 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 pub fn with_po_type(mut self, po_type: PurchaseOrderType) -> Self {
305 self.po_type = po_type;
306 self
307 }
308
309 pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
311 self.purchasing_org = org.into();
312 self
313 }
314
315 pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
317 self.purchasing_group = group.into();
318 self
319 }
320
321 pub fn with_payment_terms(mut self, terms: impl Into<String>) -> Self {
323 self.payment_terms = terms.into();
324 self
325 }
326
327 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 pub fn add_item(&mut self, item: PurchaseOrderItem) {
340 self.items.push(item);
341 self.recalculate_totals();
342 }
343
344 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 pub fn release(&mut self, user: impl Into<String>) {
353 self.header.update_status(DocumentStatus::Released, user);
354 }
355
356 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 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 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 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)); }
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}