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 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub vendor_name: Option<String>,
262}
263
264impl PurchaseOrder {
265 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 pub fn with_po_type(mut self, po_type: PurchaseOrderType) -> Self {
310 self.po_type = po_type;
311 self
312 }
313
314 pub fn with_purchasing_org(mut self, org: impl Into<String>) -> Self {
316 self.purchasing_org = org.into();
317 self
318 }
319
320 pub fn with_purchasing_group(mut self, group: impl Into<String>) -> Self {
322 self.purchasing_group = group.into();
323 self
324 }
325
326 pub fn with_payment_terms(mut self, terms: impl Into<String>) -> Self {
328 self.payment_terms = terms.into();
329 self
330 }
331
332 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 pub fn add_item(&mut self, item: PurchaseOrderItem) {
345 self.items.push(item);
346 self.recalculate_totals();
347 }
348
349 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 pub fn release(&mut self, user: impl Into<String>) {
358 self.header.update_status(DocumentStatus::Released, user);
359 }
360
361 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 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 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 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)); }
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}