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)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn test_delivery_creation() {
568 let delivery = Delivery::new(
569 "DLV-1000-0000000001",
570 "1000",
571 "C-000001",
572 "SP01",
573 2024,
574 1,
575 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
576 "JSMITH",
577 );
578
579 assert_eq!(delivery.customer_id, "C-000001");
580 assert_eq!(delivery.shipping_point, "SP01");
581 assert_eq!(delivery.delivery_status, DeliveryStatus::Created);
582 }
583
584 #[test]
585 fn test_delivery_from_sales_order() {
586 let delivery = Delivery::from_sales_order(
587 "DLV-1000-0000000001",
588 "1000",
589 "SO-1000-0000000001",
590 "C-000001",
591 "SP01",
592 2024,
593 1,
594 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
595 "JSMITH",
596 );
597
598 assert_eq!(
599 delivery.sales_order_id,
600 Some("SO-1000-0000000001".to_string())
601 );
602 assert_eq!(delivery.header.document_references.len(), 1);
603 }
604
605 #[test]
606 fn test_delivery_items() {
607 let mut delivery = Delivery::new(
608 "DLV-1000-0000000001",
609 "1000",
610 "C-000001",
611 "SP01",
612 2024,
613 1,
614 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
615 "JSMITH",
616 );
617
618 let item = DeliveryItem::from_sales_order(
619 1,
620 "Product A",
621 Decimal::from(100),
622 Decimal::from(50),
623 "SO-1000-0000000001",
624 1,
625 )
626 .with_material("MAT-001")
627 .with_cogs(Decimal::from(3000)); delivery.add_item(item);
630
631 assert_eq!(delivery.total_quantity, Decimal::from(100));
632 assert_eq!(delivery.total_cogs, Decimal::from(3000));
633 }
634
635 #[test]
636 fn test_pick_process() {
637 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50));
638
639 assert_eq!(item.open_quantity_pick(), Decimal::from(100));
640
641 item.record_pick(Decimal::from(60));
642 assert_eq!(item.open_quantity_pick(), Decimal::from(40));
643 assert!(!item.is_fully_picked);
644
645 item.record_pick(Decimal::from(40));
646 assert!(item.is_fully_picked);
647 }
648
649 #[test]
650 fn test_goods_issue_process() {
651 let mut delivery = Delivery::new(
652 "DLV-1000-0000000001",
653 "1000",
654 "C-000001",
655 "SP01",
656 2024,
657 1,
658 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
659 "JSMITH",
660 );
661
662 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
663 .with_cogs(Decimal::from(3000));
664
665 item.record_pick(Decimal::from(100));
666 delivery.add_item(item);
667
668 delivery.release_for_picking("PICKER");
669 delivery.confirm_pick();
670 delivery.confirm_pack(5);
671 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
672
673 assert!(delivery.is_goods_issued);
674 assert_eq!(delivery.delivery_status, DeliveryStatus::GoodsIssued);
675 assert!(delivery.is_fully_issued());
676 }
677
678 #[test]
679 fn test_gl_entry_generation() {
680 let mut delivery = Delivery::new(
681 "DLV-1000-0000000001",
682 "1000",
683 "C-000001",
684 "SP01",
685 2024,
686 1,
687 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
688 "JSMITH",
689 );
690
691 let mut item = DeliveryItem::new(1, "Product A", Decimal::from(100), Decimal::from(50))
692 .with_cogs(Decimal::from(3000));
693
694 item.record_pick(Decimal::from(100));
695 delivery.add_item(item);
696 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
697
698 let entries = delivery.generate_gl_entries();
699 assert_eq!(entries.len(), 2);
700 assert_eq!(entries[0].1, Decimal::from(3000));
702 assert_eq!(entries[1].2, Decimal::from(3000));
704 }
705
706 #[test]
707 fn test_delivery_complete() {
708 let mut delivery = Delivery::new(
709 "DLV-1000-0000000001",
710 "1000",
711 "C-000001",
712 "SP01",
713 2024,
714 1,
715 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
716 "JSMITH",
717 );
718
719 delivery.post_goods_issue("SHIPPER", NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
720 delivery.confirm_delivery(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap());
721 delivery.record_pod(NaiveDate::from_ymd_opt(2024, 1, 18).unwrap(), "John Doe");
722
723 assert!(delivery.is_complete);
724 assert_eq!(delivery.delivery_status, DeliveryStatus::Delivered);
725 assert_eq!(delivery.pod_signed_by, Some("John Doe".to_string()));
726 }
727}