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)]
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)); // 100 units * $30 cost
628
629        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        // DR COGS
701        assert_eq!(entries[0].1, Decimal::from(3000));
702        // CR Inventory
703        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}