1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12 ReferenceType,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum DeliveryType {
19 #[default]
21 Outbound,
22 Return,
24 StockTransfer,
26 Replenishment,
28 ConsignmentIssue,
30 ConsignmentReturn,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum DeliveryStatus {
38 #[default]
40 Created,
41 PickReleased,
43 Picking,
45 Picked,
47 Packed,
49 GoodsIssued,
51 InTransit,
53 Delivered,
55 PartiallyDelivered,
57 Cancelled,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct DeliveryItem {
64 #[serde(flatten)]
66 pub base: DocumentLineItem,
67
68 pub sales_order_id: Option<String>,
70
71 pub so_item: Option<u16>,
73
74 pub quantity_picked: Decimal,
76
77 pub quantity_packed: Decimal,
79
80 pub quantity_issued: Decimal,
82
83 pub is_fully_picked: bool,
85
86 pub is_fully_issued: bool,
88
89 pub batch: Option<String>,
91
92 pub serial_numbers: Vec<String>,
94
95 pub pick_location: Option<String>,
97
98 pub handling_unit: Option<String>,
100
101 pub weight: Option<Decimal>,
103
104 pub volume: Option<Decimal>,
106
107 pub cogs_amount: Decimal,
109}
110
111impl DeliveryItem {
112 #[allow(clippy::too_many_arguments)]
114 pub fn new(
115 line_number: u16,
116 description: impl Into<String>,
117 quantity: Decimal,
118 unit_price: Decimal,
119 ) -> Self {
120 Self {
121 base: DocumentLineItem::new(line_number, description, quantity, unit_price),
122 sales_order_id: None,
123 so_item: None,
124 quantity_picked: Decimal::ZERO,
125 quantity_packed: Decimal::ZERO,
126 quantity_issued: Decimal::ZERO,
127 is_fully_picked: false,
128 is_fully_issued: false,
129 batch: None,
130 serial_numbers: Vec::new(),
131 pick_location: None,
132 handling_unit: None,
133 weight: None,
134 volume: None,
135 cogs_amount: Decimal::ZERO,
136 }
137 }
138
139 #[allow(clippy::too_many_arguments)]
141 pub fn from_sales_order(
142 line_number: u16,
143 description: impl Into<String>,
144 quantity: Decimal,
145 unit_price: Decimal,
146 sales_order_id: impl Into<String>,
147 so_item: u16,
148 ) -> Self {
149 let mut item = Self::new(line_number, description, quantity, unit_price);
150 item.sales_order_id = Some(sales_order_id.into());
151 item.so_item = Some(so_item);
152 item
153 }
154
155 pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
157 self.base = self.base.with_material(material_id);
158 self
159 }
160
161 pub fn with_batch(mut self, batch: impl Into<String>) -> Self {
163 self.batch = Some(batch.into());
164 self
165 }
166
167 pub fn with_cogs(mut self, cogs: Decimal) -> Self {
169 self.cogs_amount = cogs;
170 self
171 }
172
173 pub fn with_location(
175 mut self,
176 plant: impl Into<String>,
177 storage_location: impl Into<String>,
178 ) -> Self {
179 self.base.plant = Some(plant.into());
180 self.base.storage_location = Some(storage_location.into());
181 self
182 }
183
184 pub fn with_dimensions(mut self, weight: Decimal, volume: Decimal) -> Self {
186 self.weight = Some(weight);
187 self.volume = Some(volume);
188 self
189 }
190
191 pub fn add_serial_number(&mut self, serial: impl Into<String>) {
193 self.serial_numbers.push(serial.into());
194 }
195
196 pub fn record_pick(&mut self, quantity: Decimal) {
198 self.quantity_picked += quantity;
199 if self.quantity_picked >= self.base.quantity {
200 self.is_fully_picked = true;
201 }
202 }
203
204 pub fn record_pack(&mut self, quantity: Decimal) {
206 self.quantity_packed += quantity;
207 }
208
209 pub fn record_goods_issue(&mut self, quantity: Decimal) {
211 self.quantity_issued += quantity;
212 if self.quantity_issued >= self.base.quantity {
213 self.is_fully_issued = true;
214 }
215 }
216
217 pub fn open_quantity_pick(&self) -> Decimal {
219 (self.base.quantity - self.quantity_picked).max(Decimal::ZERO)
220 }
221
222 pub fn open_quantity_gi(&self) -> Decimal {
224 (self.quantity_picked - self.quantity_issued).max(Decimal::ZERO)
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Delivery {
231 pub header: DocumentHeader,
233
234 pub delivery_type: DeliveryType,
236
237 pub delivery_status: DeliveryStatus,
239
240 pub items: Vec<DeliveryItem>,
242
243 pub total_quantity: Decimal,
245
246 pub total_weight: Decimal,
248
249 pub total_volume: Decimal,
251
252 pub customer_id: String,
254
255 pub ship_to: Option<String>,
257
258 pub sales_order_id: Option<String>,
260
261 pub shipping_point: String,
263
264 pub route: Option<String>,
266
267 pub carrier: Option<String>,
269
270 pub shipping_condition: Option<String>,
272
273 pub incoterms: Option<String>,
275
276 pub planned_gi_date: NaiveDate,
278
279 pub actual_gi_date: Option<NaiveDate>,
281
282 pub delivery_date: Option<NaiveDate>,
284
285 pub pod_date: Option<NaiveDate>,
287
288 pub pod_signed_by: Option<String>,
290
291 pub bill_of_lading: Option<String>,
293
294 pub tracking_number: Option<String>,
296
297 pub number_of_packages: u32,
299
300 pub is_goods_issued: bool,
302
303 pub is_complete: bool,
305
306 pub is_cancelled: bool,
308
309 pub cancellation_doc: Option<String>,
311
312 pub total_cogs: Decimal,
314}
315
316impl Delivery {
317 #[allow(clippy::too_many_arguments)]
319 pub fn new(
320 delivery_id: impl Into<String>,
321 company_code: impl Into<String>,
322 customer_id: impl Into<String>,
323 shipping_point: impl Into<String>,
324 fiscal_year: u16,
325 fiscal_period: u8,
326 document_date: NaiveDate,
327 created_by: impl Into<String>,
328 ) -> Self {
329 let header = DocumentHeader::new(
330 delivery_id,
331 DocumentType::Delivery,
332 company_code,
333 fiscal_year,
334 fiscal_period,
335 document_date,
336 created_by,
337 );
338
339 Self {
340 header,
341 delivery_type: DeliveryType::Outbound,
342 delivery_status: DeliveryStatus::Created,
343 items: Vec::new(),
344 total_quantity: Decimal::ZERO,
345 total_weight: Decimal::ZERO,
346 total_volume: Decimal::ZERO,
347 customer_id: customer_id.into(),
348 ship_to: None,
349 sales_order_id: None,
350 shipping_point: shipping_point.into(),
351 route: None,
352 carrier: None,
353 shipping_condition: None,
354 incoterms: None,
355 planned_gi_date: document_date,
356 actual_gi_date: None,
357 delivery_date: None,
358 pod_date: None,
359 pod_signed_by: None,
360 bill_of_lading: None,
361 tracking_number: None,
362 number_of_packages: 0,
363 is_goods_issued: false,
364 is_complete: false,
365 is_cancelled: false,
366 cancellation_doc: None,
367 total_cogs: Decimal::ZERO,
368 }
369 }
370
371 #[allow(clippy::too_many_arguments)]
373 pub fn from_sales_order(
374 delivery_id: impl Into<String>,
375 company_code: impl Into<String>,
376 sales_order_id: impl Into<String>,
377 customer_id: impl Into<String>,
378 shipping_point: impl Into<String>,
379 fiscal_year: u16,
380 fiscal_period: u8,
381 document_date: NaiveDate,
382 created_by: impl Into<String>,
383 ) -> Self {
384 let so_id = sales_order_id.into();
385 let mut delivery = Self::new(
386 delivery_id,
387 company_code,
388 customer_id,
389 shipping_point,
390 fiscal_year,
391 fiscal_period,
392 document_date,
393 created_by,
394 );
395 delivery.sales_order_id = Some(so_id.clone());
396
397 delivery.header.add_reference(DocumentReference::new(
399 DocumentType::SalesOrder,
400 so_id,
401 DocumentType::Delivery,
402 delivery.header.document_id.clone(),
403 ReferenceType::FollowOn,
404 delivery.header.company_code.clone(),
405 document_date,
406 ));
407
408 delivery
409 }
410
411 pub fn with_delivery_type(mut self, delivery_type: DeliveryType) -> Self {
413 self.delivery_type = delivery_type;
414 self
415 }
416
417 pub fn with_ship_to(mut self, ship_to: impl Into<String>) -> Self {
419 self.ship_to = Some(ship_to.into());
420 self
421 }
422
423 pub fn with_carrier(mut self, carrier: impl Into<String>) -> Self {
425 self.carrier = Some(carrier.into());
426 self
427 }
428
429 pub fn with_route(mut self, route: impl Into<String>) -> Self {
431 self.route = Some(route.into());
432 self
433 }
434
435 pub fn with_planned_gi_date(mut self, date: NaiveDate) -> Self {
437 self.planned_gi_date = date;
438 self
439 }
440
441 pub fn add_item(&mut self, item: DeliveryItem) {
443 self.items.push(item);
444 self.recalculate_totals();
445 }
446
447 pub fn recalculate_totals(&mut self) {
449 self.total_quantity = self.items.iter().map(|i| i.base.quantity).sum();
450 self.total_weight = self.items.iter().filter_map(|i| i.weight).sum();
451 self.total_volume = self.items.iter().filter_map(|i| i.volume).sum();
452 self.total_cogs = self.items.iter().map(|i| i.cogs_amount).sum();
453 }
454
455 pub fn release_for_picking(&mut self, user: impl Into<String>) {
457 self.delivery_status = DeliveryStatus::PickReleased;
458 self.header.update_status(DocumentStatus::Released, user);
459 }
460
461 pub fn start_picking(&mut self) {
463 self.delivery_status = DeliveryStatus::Picking;
464 }
465
466 pub fn confirm_pick(&mut self) {
468 if self.items.iter().all(|i| i.is_fully_picked) {
469 self.delivery_status = DeliveryStatus::Picked;
470 }
471 }
472
473 pub fn confirm_pack(&mut self, num_packages: u32) {
475 self.delivery_status = DeliveryStatus::Packed;
476 self.number_of_packages = num_packages;
477 }
478
479 pub fn post_goods_issue(&mut self, user: impl Into<String>, gi_date: NaiveDate) {
481 self.actual_gi_date = Some(gi_date);
482 self.is_goods_issued = true;
483 self.delivery_status = DeliveryStatus::GoodsIssued;
484 self.header.posting_date = Some(gi_date);
485 self.header.update_status(DocumentStatus::Posted, user);
486
487 for item in &mut self.items {
489 item.quantity_issued = item.quantity_picked;
490 item.is_fully_issued = true;
491 }
492 }
493
494 pub fn confirm_delivery(&mut self, delivery_date: NaiveDate) {
496 self.delivery_date = Some(delivery_date);
497 self.delivery_status = DeliveryStatus::Delivered;
498 }
499
500 pub fn record_pod(&mut self, pod_date: NaiveDate, signed_by: impl Into<String>) {
502 self.pod_date = Some(pod_date);
503 self.pod_signed_by = Some(signed_by.into());
504 self.is_complete = true;
505 self.header
506 .update_status(DocumentStatus::Completed, "SYSTEM");
507 }
508
509 pub fn cancel(&mut self, user: impl Into<String>, reason: impl Into<String>) {
511 self.is_cancelled = true;
512 self.delivery_status = DeliveryStatus::Cancelled;
513 self.header.header_text = Some(reason.into());
514 self.header.update_status(DocumentStatus::Cancelled, user);
515 }
516
517 pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
520 let mut entries = Vec::new();
521
522 if !self.is_goods_issued {
523 return entries;
524 }
525
526 for item in &self.items {
527 if item.cogs_amount > Decimal::ZERO {
528 let cogs_account = item
530 .base
531 .gl_account
532 .clone()
533 .unwrap_or_else(|| "500000".to_string());
534
535 let inventory_account = "140000".to_string();
537
538 entries.push((cogs_account, item.cogs_amount, Decimal::ZERO));
539 entries.push((inventory_account, Decimal::ZERO, item.cogs_amount));
540 }
541 }
542
543 entries
544 }
545
546 pub fn total_value(&self) -> Decimal {
548 self.items.iter().map(|i| i.base.net_amount).sum()
549 }
550
551 pub fn is_fully_picked(&self) -> bool {
553 self.items.iter().all(|i| i.is_fully_picked)
554 }
555
556 pub fn is_fully_issued(&self) -> bool {
558 self.items.iter().all(|i| i.is_fully_issued)
559 }
560}
561
562#[cfg(test)]
563#[allow(clippy::unwrap_used)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn test_delivery_creation() {
569 let delivery = Delivery::new(
570 "DLV-1000-0000000001",
571 "1000",
572 "C-000001",
573 "SP01",
574 2024,
575 1,
576 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
577 "JSMITH",
578 );
579
580 assert_eq!(delivery.customer_id, "C-000001");
581 assert_eq!(delivery.shipping_point, "SP01");
582 assert_eq!(delivery.delivery_status, DeliveryStatus::Created);
583 }
584
585 #[test]
586 fn test_delivery_from_sales_order() {
587 let delivery = Delivery::from_sales_order(
588 "DLV-1000-0000000001",
589 "1000",
590 "SO-1000-0000000001",
591 "C-000001",
592 "SP01",
593 2024,
594 1,
595 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
596 "JSMITH",
597 );
598
599 assert_eq!(
600 delivery.sales_order_id,
601 Some("SO-1000-0000000001".to_string())
602 );
603 assert_eq!(delivery.header.document_references.len(), 1);
604 }
605
606 #[test]
607 fn test_delivery_items() {
608 let mut delivery = Delivery::new(
609 "DLV-1000-0000000001",
610 "1000",
611 "C-000001",
612 "SP01",
613 2024,
614 1,
615 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
616 "JSMITH",
617 );
618
619 let item = DeliveryItem::from_sales_order(
620 1,
621 "Product A",
622 Decimal::from(100),
623 Decimal::from(50),
624 "SO-1000-0000000001",
625 1,
626 )
627 .with_material("MAT-001")
628 .with_cogs(Decimal::from(3000)); delivery.add_item(item);
631
632 assert_eq!(delivery.total_quantity, Decimal::from(100));
633 assert_eq!(delivery.total_cogs, Decimal::from(3000));
634 }
635
636 #[test]
637 fn test_pick_process() {
638 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50));
639
640 assert_eq!(item.open_quantity_pick(), Decimal::from(100));
641
642 item.record_pick(Decimal::from(60));
643 assert_eq!(item.open_quantity_pick(), Decimal::from(40));
644 assert!(!item.is_fully_picked);
645
646 item.record_pick(Decimal::from(40));
647 assert!(item.is_fully_picked);
648 }
649
650 #[test]
651 fn test_goods_issue_process() {
652 let mut delivery = Delivery::new(
653 "DLV-1000-0000000001",
654 "1000",
655 "C-000001",
656 "SP01",
657 2024,
658 1,
659 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
660 "JSMITH",
661 );
662
663 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
664 .with_cogs(Decimal::from(3000));
665
666 item.record_pick(Decimal::from(100));
667 delivery.add_item(item);
668
669 delivery.release_for_picking("PICKER");
670 delivery.confirm_pick();
671 delivery.confirm_pack(5);
672 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
673
674 assert!(delivery.is_goods_issued);
675 assert_eq!(delivery.delivery_status, DeliveryStatus::GoodsIssued);
676 assert!(delivery.is_fully_issued());
677 }
678
679 #[test]
680 fn test_gl_entry_generation() {
681 let mut delivery = Delivery::new(
682 "DLV-1000-0000000001",
683 "1000",
684 "C-000001",
685 "SP01",
686 2024,
687 1,
688 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
689 "JSMITH",
690 );
691
692 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
693 .with_cogs(Decimal::from(3000));
694
695 item.record_pick(Decimal::from(100));
696 delivery.add_item(item);
697 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
698
699 let entries = delivery.generate_gl_entries();
700 assert_eq!(entries.len(), 2);
701 assert_eq!(entries[0].1, Decimal::from(3000));
703 assert_eq!(entries[1].2, Decimal::from(3000));
705 }
706
707 #[test]
708 fn test_delivery_complete() {
709 let mut delivery = Delivery::new(
710 "DLV-1000-0000000001",
711 "1000",
712 "C-000001",
713 "SP01",
714 2024,
715 1,
716 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
717 "JSMITH",
718 );
719
720 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
721 delivery.confirm_delivery(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap());
722 delivery.record_pod(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap(), "John Doe");
723
724 assert!(delivery.is_complete);
725 assert_eq!(delivery.delivery_status, DeliveryStatus::Delivered);
726 assert_eq!(delivery.pod_signed_by, Some("John Doe".to_string()));
727 }
728}