Skip to main content

datasynth_core/models/documents/
delivery.rs

1//! Delivery document model.
2//!
3//! Represents outbound deliveries in the O2C (Order-to-Cash) process flow.
4//! Deliveries create accounting entries: DR COGS, CR Inventory.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11    DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12    ReferenceType,
13};
14
15/// Delivery type.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum DeliveryType {
19    /// Standard outbound delivery
20    #[default]
21    Outbound,
22    /// Return delivery (from customer)
23    Return,
24    /// Stock transfer delivery
25    StockTransfer,
26    /// Replenishment delivery
27    Replenishment,
28    /// Consignment issue
29    ConsignmentIssue,
30    /// Consignment return
31    ConsignmentReturn,
32}
33
34/// Delivery status.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum DeliveryStatus {
38    /// Created - not yet picked
39    #[default]
40    Created,
41    /// Pick released
42    PickReleased,
43    /// Picking in progress
44    Picking,
45    /// Picked and ready for packing
46    Picked,
47    /// Packed
48    Packed,
49    /// Goods issued
50    GoodsIssued,
51    /// In transit
52    InTransit,
53    /// Delivered
54    Delivered,
55    /// Partially delivered
56    PartiallyDelivered,
57    /// Cancelled
58    Cancelled,
59}
60
61/// Delivery item.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct DeliveryItem {
64    /// Base line item fields
65    #[serde(flatten)]
66    pub base: DocumentLineItem,
67
68    /// Reference SO number
69    pub sales_order_id: Option<String>,
70
71    /// Reference SO item
72    pub so_item: Option<u16>,
73
74    /// Picked quantity
75    pub quantity_picked: Decimal,
76
77    /// Packed quantity
78    pub quantity_packed: Decimal,
79
80    /// Goods issued quantity
81    pub quantity_issued: Decimal,
82
83    /// Is this line fully picked?
84    pub is_fully_picked: bool,
85
86    /// Is this line fully issued?
87    pub is_fully_issued: bool,
88
89    /// Batch number (if batch managed)
90    pub batch: Option<String>,
91
92    /// Serial numbers (if serial managed)
93    pub serial_numbers: Vec<String>,
94
95    /// Pick location (bin)
96    pub pick_location: Option<String>,
97
98    /// Handling unit
99    pub handling_unit: Option<String>,
100
101    /// Weight in kg
102    pub weight: Option<Decimal>,
103
104    /// Volume in m³
105    pub volume: Option<Decimal>,
106
107    /// COGS amount (cost of goods sold)
108    pub cogs_amount: Decimal,
109}
110
111impl DeliveryItem {
112    /// Create a new delivery item.
113    #[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    /// Create from SO reference.
140    #[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    /// Set material.
156    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    /// Set batch.
162    pub fn with_batch(mut self, batch: impl Into<String>) -> Self {
163        self.batch = Some(batch.into());
164        self
165    }
166
167    /// Set COGS amount (cost).
168    pub fn with_cogs(mut self, cogs: Decimal) -> Self {
169        self.cogs_amount = cogs;
170        self
171    }
172
173    /// Set location.
174    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    /// Set weight and volume.
185    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    /// Add serial number.
192    pub fn add_serial_number(&mut self, serial: impl Into<String>) {
193        self.serial_numbers.push(serial.into());
194    }
195
196    /// Record pick.
197    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    /// Record pack.
205    pub fn record_pack(&mut self, quantity: Decimal) {
206        self.quantity_packed += quantity;
207    }
208
209    /// Record goods issue.
210    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    /// Get open quantity for picking.
218    pub fn open_quantity_pick(&self) -> Decimal {
219        (self.base.quantity - self.quantity_picked).max(Decimal::ZERO)
220    }
221
222    /// Get open quantity for goods issue.
223    pub fn open_quantity_gi(&self) -> Decimal {
224        (self.quantity_picked - self.quantity_issued).max(Decimal::ZERO)
225    }
226}
227
228/// Delivery document.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Delivery {
231    /// Document header
232    pub header: DocumentHeader,
233
234    /// Delivery type
235    pub delivery_type: DeliveryType,
236
237    /// Delivery status
238    pub delivery_status: DeliveryStatus,
239
240    /// Line items
241    pub items: Vec<DeliveryItem>,
242
243    /// Total quantity
244    pub total_quantity: Decimal,
245
246    /// Total weight (kg)
247    pub total_weight: Decimal,
248
249    /// Total volume (m³)
250    pub total_volume: Decimal,
251
252    /// Customer ID
253    pub customer_id: String,
254
255    /// Ship-to party (if different)
256    pub ship_to: Option<String>,
257
258    /// Reference sales order (primary)
259    pub sales_order_id: Option<String>,
260
261    /// Shipping point
262    pub shipping_point: String,
263
264    /// Route
265    pub route: Option<String>,
266
267    /// Carrier/forwarder
268    pub carrier: Option<String>,
269
270    /// Shipping condition
271    pub shipping_condition: Option<String>,
272
273    /// Incoterms
274    pub incoterms: Option<String>,
275
276    /// Planned goods issue date
277    pub planned_gi_date: NaiveDate,
278
279    /// Actual goods issue date
280    pub actual_gi_date: Option<NaiveDate>,
281
282    /// Delivery date
283    pub delivery_date: Option<NaiveDate>,
284
285    /// Proof of delivery
286    pub pod_date: Option<NaiveDate>,
287
288    /// POD signed by
289    pub pod_signed_by: Option<String>,
290
291    /// Bill of lading
292    pub bill_of_lading: Option<String>,
293
294    /// Tracking number
295    pub tracking_number: Option<String>,
296
297    /// Number of packages
298    pub number_of_packages: u32,
299
300    /// Is goods issued?
301    pub is_goods_issued: bool,
302
303    /// Is delivery complete?
304    pub is_complete: bool,
305
306    /// Is this delivery cancelled/reversed?
307    pub is_cancelled: bool,
308
309    /// Cancellation document reference
310    pub cancellation_doc: Option<String>,
311
312    /// Total COGS (for GL posting)
313    pub total_cogs: Decimal,
314}
315
316impl Delivery {
317    /// Create a new delivery.
318    #[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    /// Create from sales order reference.
372    #[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        // Add reference to SO
398        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    /// Set delivery type.
412    pub fn with_delivery_type(mut self, delivery_type: DeliveryType) -> Self {
413        self.delivery_type = delivery_type;
414        self
415    }
416
417    /// Set ship-to party.
418    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    /// Set carrier.
424    pub fn with_carrier(mut self, carrier: impl Into<String>) -> Self {
425        self.carrier = Some(carrier.into());
426        self
427    }
428
429    /// Set route.
430    pub fn with_route(mut self, route: impl Into<String>) -> Self {
431        self.route = Some(route.into());
432        self
433    }
434
435    /// Set planned GI date.
436    pub fn with_planned_gi_date(mut self, date: NaiveDate) -> Self {
437        self.planned_gi_date = date;
438        self
439    }
440
441    /// Add a line item.
442    pub fn add_item(&mut self, item: DeliveryItem) {
443        self.items.push(item);
444        self.recalculate_totals();
445    }
446
447    /// Recalculate totals.
448    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    /// Release for picking.
456    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    /// Start picking.
462    pub fn start_picking(&mut self) {
463        self.delivery_status = DeliveryStatus::Picking;
464    }
465
466    /// Confirm pick complete.
467    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    /// Confirm packing.
474    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    /// Post goods issue.
480    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        // Mark all items as fully issued
488        for item in &mut self.items {
489            item.quantity_issued = item.quantity_picked;
490            item.is_fully_issued = true;
491        }
492    }
493
494    /// Confirm delivery.
495    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    /// Record proof of delivery.
501    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    /// Cancel the delivery.
510    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    /// Generate GL entries for goods issue.
518    /// DR COGS (or expense), CR Inventory
519    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                // Debit: COGS
529                let cogs_account = item
530                    .base
531                    .gl_account
532                    .clone()
533                    .unwrap_or_else(|| "500000".to_string());
534
535                // Credit: Inventory
536                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    /// Get total value (sales value).
547    pub fn total_value(&self) -> Decimal {
548        self.items.iter().map(|i| i.base.net_amount).sum()
549    }
550
551    /// Check if all items are picked.
552    pub fn is_fully_picked(&self) -> bool {
553        self.items.iter().all(|i| i.is_fully_picked)
554    }
555
556    /// Check if all items are issued.
557    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)); // 100 units * $30 cost
629
630        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        // DR COGS
702        assert_eq!(entries[0].1, Decimal::from(3000));
703        // CR Inventory
704        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}