datasynth_core/models/documents/
goods_receipt.rs

1//! Goods Receipt document model.
2//!
3//! Represents goods receipts in the P2P (Procure-to-Pay) process flow.
4//! Goods receipts create accounting entries: DR Inventory, CR GR/IR Clearing.
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/// Goods Receipt type.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum GoodsReceiptType {
19    /// Standard goods receipt against PO
20    #[default]
21    PurchaseOrder,
22    /// Return to vendor
23    ReturnToVendor,
24    /// Stock transfer receipt
25    StockTransfer,
26    /// Production receipt
27    Production,
28    /// Initial stock entry
29    InitialStock,
30    /// Subcontracting receipt
31    Subcontracting,
32}
33
34/// Movement type for inventory.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum MovementType {
38    /// GR for PO (101)
39    #[default]
40    GrForPo,
41    /// Return to vendor (122)
42    ReturnToVendor,
43    /// GR for production order (131)
44    GrForProduction,
45    /// Transfer posting (301)
46    TransferPosting,
47    /// Initial stock entry (561)
48    InitialEntry,
49    /// Scrapping (551)
50    Scrapping,
51    /// Consumption (201)
52    Consumption,
53}
54
55impl MovementType {
56    /// Get the SAP movement type code.
57    pub fn code(&self) -> &'static str {
58        match self {
59            Self::GrForPo => "101",
60            Self::ReturnToVendor => "122",
61            Self::GrForProduction => "131",
62            Self::TransferPosting => "301",
63            Self::InitialEntry => "561",
64            Self::Scrapping => "551",
65            Self::Consumption => "201",
66        }
67    }
68
69    /// Check if this movement increases inventory.
70    pub fn is_receipt(&self) -> bool {
71        matches!(
72            self,
73            Self::GrForPo | Self::GrForProduction | Self::InitialEntry | Self::TransferPosting
74        )
75    }
76
77    /// Check if this movement decreases inventory.
78    pub fn is_issue(&self) -> bool {
79        matches!(
80            self,
81            Self::ReturnToVendor | Self::Scrapping | Self::Consumption
82        )
83    }
84}
85
86/// Goods Receipt line item.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct GoodsReceiptItem {
89    /// Base line item fields
90    #[serde(flatten)]
91    pub base: DocumentLineItem,
92
93    /// Movement type
94    pub movement_type: MovementType,
95
96    /// Reference PO number
97    pub po_number: Option<String>,
98
99    /// Reference PO item
100    pub po_item: Option<u16>,
101
102    /// Batch number (if batch managed)
103    pub batch: Option<String>,
104
105    /// Serial numbers (if serial managed)
106    pub serial_numbers: Vec<String>,
107
108    /// Vendor batch
109    pub vendor_batch: Option<String>,
110
111    /// Quantity in base UOM
112    pub quantity_base_uom: Decimal,
113
114    /// Valuation type
115    pub valuation_type: Option<String>,
116
117    /// Stock type (unrestricted, quality inspection, blocked)
118    pub stock_type: StockType,
119
120    /// Reason for movement (for returns, adjustments)
121    pub reason_for_movement: Option<String>,
122
123    /// Delivery note reference
124    pub delivery_note: Option<String>,
125
126    /// Bill of lading
127    pub bill_of_lading: Option<String>,
128}
129
130/// Stock type for goods receipt.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
132#[serde(rename_all = "snake_case")]
133pub enum StockType {
134    /// Unrestricted use stock
135    #[default]
136    Unrestricted,
137    /// Quality inspection stock
138    QualityInspection,
139    /// Blocked stock
140    Blocked,
141    /// Returns stock
142    Returns,
143}
144
145impl GoodsReceiptItem {
146    /// Create a new goods receipt item.
147    #[allow(clippy::too_many_arguments)]
148    pub fn new(
149        line_number: u16,
150        description: impl Into<String>,
151        quantity: Decimal,
152        unit_price: Decimal,
153    ) -> Self {
154        Self {
155            base: DocumentLineItem::new(line_number, description, quantity, unit_price),
156            movement_type: MovementType::GrForPo,
157            po_number: None,
158            po_item: None,
159            batch: None,
160            serial_numbers: Vec::new(),
161            vendor_batch: None,
162            quantity_base_uom: quantity,
163            valuation_type: None,
164            stock_type: StockType::Unrestricted,
165            reason_for_movement: None,
166            delivery_note: None,
167            bill_of_lading: None,
168        }
169    }
170
171    /// Create from PO reference.
172    pub fn from_po(
173        line_number: u16,
174        description: impl Into<String>,
175        quantity: Decimal,
176        unit_price: Decimal,
177        po_number: impl Into<String>,
178        po_item: u16,
179    ) -> Self {
180        let mut item = Self::new(line_number, description, quantity, unit_price);
181        item.po_number = Some(po_number.into());
182        item.po_item = Some(po_item);
183        item
184    }
185
186    /// Set material.
187    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
188        self.base = self.base.with_material(material_id);
189        self
190    }
191
192    /// Set batch.
193    pub fn with_batch(mut self, batch: impl Into<String>) -> Self {
194        self.batch = Some(batch.into());
195        self
196    }
197
198    /// Set stock type.
199    pub fn with_stock_type(mut self, stock_type: StockType) -> Self {
200        self.stock_type = stock_type;
201        self
202    }
203
204    /// Set movement type.
205    pub fn with_movement_type(mut self, movement_type: MovementType) -> Self {
206        self.movement_type = movement_type;
207        self
208    }
209
210    /// Set plant and storage location.
211    pub fn with_location(
212        mut self,
213        plant: impl Into<String>,
214        storage_location: impl Into<String>,
215    ) -> Self {
216        self.base.plant = Some(plant.into());
217        self.base.storage_location = Some(storage_location.into());
218        self
219    }
220
221    /// Set delivery note.
222    pub fn with_delivery_note(mut self, note: impl Into<String>) -> Self {
223        self.delivery_note = Some(note.into());
224        self
225    }
226
227    /// Add serial number.
228    pub fn add_serial_number(&mut self, serial: impl Into<String>) {
229        self.serial_numbers.push(serial.into());
230    }
231}
232
233/// Goods Receipt document.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct GoodsReceipt {
236    /// Document header
237    pub header: DocumentHeader,
238
239    /// GR type
240    pub gr_type: GoodsReceiptType,
241
242    /// Line items
243    pub items: Vec<GoodsReceiptItem>,
244
245    /// Total quantity received
246    pub total_quantity: Decimal,
247
248    /// Total value
249    pub total_value: Decimal,
250
251    /// Reference PO (primary)
252    pub purchase_order_id: Option<String>,
253
254    /// Vendor (for info)
255    pub vendor_id: Option<String>,
256
257    /// Bill of lading
258    pub bill_of_lading: Option<String>,
259
260    /// Delivery note from vendor
261    pub delivery_note: Option<String>,
262
263    /// Receiving plant
264    pub plant: String,
265
266    /// Receiving storage location
267    pub storage_location: String,
268
269    /// Material document year
270    pub material_doc_year: u16,
271
272    /// Is this GR posted?
273    pub is_posted: bool,
274
275    /// Is this GR cancelled/reversed?
276    pub is_cancelled: bool,
277
278    /// Cancellation GR reference
279    pub cancellation_doc: Option<String>,
280}
281
282impl GoodsReceipt {
283    /// Create a new goods receipt.
284    #[allow(clippy::too_many_arguments)]
285    pub fn new(
286        gr_id: impl Into<String>,
287        company_code: impl Into<String>,
288        plant: impl Into<String>,
289        storage_location: impl Into<String>,
290        fiscal_year: u16,
291        fiscal_period: u8,
292        document_date: NaiveDate,
293        created_by: impl Into<String>,
294    ) -> Self {
295        let header = DocumentHeader::new(
296            gr_id,
297            DocumentType::GoodsReceipt,
298            company_code,
299            fiscal_year,
300            fiscal_period,
301            document_date,
302            created_by,
303        );
304
305        Self {
306            header,
307            gr_type: GoodsReceiptType::PurchaseOrder,
308            items: Vec::new(),
309            total_quantity: Decimal::ZERO,
310            total_value: Decimal::ZERO,
311            purchase_order_id: None,
312            vendor_id: None,
313            bill_of_lading: None,
314            delivery_note: None,
315            plant: plant.into(),
316            storage_location: storage_location.into(),
317            material_doc_year: fiscal_year,
318            is_posted: false,
319            is_cancelled: false,
320            cancellation_doc: None,
321        }
322    }
323
324    /// Create from PO reference.
325    #[allow(clippy::too_many_arguments)]
326    pub fn from_purchase_order(
327        gr_id: impl Into<String>,
328        company_code: impl Into<String>,
329        purchase_order_id: impl Into<String>,
330        vendor_id: impl Into<String>,
331        plant: impl Into<String>,
332        storage_location: impl Into<String>,
333        fiscal_year: u16,
334        fiscal_period: u8,
335        document_date: NaiveDate,
336        created_by: impl Into<String>,
337    ) -> Self {
338        let po_id = purchase_order_id.into();
339        let mut gr = Self::new(
340            gr_id,
341            company_code,
342            plant,
343            storage_location,
344            fiscal_year,
345            fiscal_period,
346            document_date,
347            created_by,
348        );
349        gr.purchase_order_id = Some(po_id.clone());
350        gr.vendor_id = Some(vendor_id.into());
351
352        // Add reference to PO
353        gr.header.add_reference(DocumentReference::new(
354            DocumentType::PurchaseOrder,
355            po_id,
356            DocumentType::GoodsReceipt,
357            gr.header.document_id.clone(),
358            ReferenceType::FollowOn,
359            gr.header.company_code.clone(),
360            document_date,
361        ));
362
363        gr
364    }
365
366    /// Set GR type.
367    pub fn with_gr_type(mut self, gr_type: GoodsReceiptType) -> Self {
368        self.gr_type = gr_type;
369        self
370    }
371
372    /// Set delivery note.
373    pub fn with_delivery_note(mut self, note: impl Into<String>) -> Self {
374        self.delivery_note = Some(note.into());
375        self
376    }
377
378    /// Set bill of lading.
379    pub fn with_bill_of_lading(mut self, bol: impl Into<String>) -> Self {
380        self.bill_of_lading = Some(bol.into());
381        self
382    }
383
384    /// Add a line item.
385    pub fn add_item(&mut self, mut item: GoodsReceiptItem) {
386        item.base.plant = Some(self.plant.clone());
387        item.base.storage_location = Some(self.storage_location.clone());
388        self.items.push(item);
389        self.recalculate_totals();
390    }
391
392    /// Recalculate totals.
393    pub fn recalculate_totals(&mut self) {
394        self.total_quantity = self.items.iter().map(|i| i.base.quantity).sum();
395        self.total_value = self.items.iter().map(|i| i.base.net_amount).sum();
396    }
397
398    /// Post the GR and generate GL entry.
399    pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
400        self.header.posting_date = Some(posting_date);
401        self.header.update_status(DocumentStatus::Posted, user);
402        self.is_posted = true;
403    }
404
405    /// Cancel/reverse the GR.
406    pub fn cancel(&mut self, user: impl Into<String>, cancellation_doc: impl Into<String>) {
407        self.is_cancelled = true;
408        self.cancellation_doc = Some(cancellation_doc.into());
409        self.header.update_status(DocumentStatus::Cancelled, user);
410    }
411
412    /// Generate the GL journal entry for this GR.
413    /// DR Inventory (or GR/IR for services)
414    /// CR GR/IR Clearing
415    pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
416        let mut entries = Vec::new();
417
418        for item in &self.items {
419            if item.movement_type.is_receipt() {
420                // Debit: Inventory or Expense account
421                let debit_account = item
422                    .base
423                    .gl_account
424                    .clone()
425                    .unwrap_or_else(|| "140000".to_string()); // Default inventory
426
427                // Credit: GR/IR Clearing
428                let credit_account = "290000".to_string();
429
430                entries.push((debit_account, item.base.net_amount, Decimal::ZERO));
431                entries.push((credit_account, Decimal::ZERO, item.base.net_amount));
432            }
433        }
434
435        entries
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_goods_receipt_creation() {
445        let gr = GoodsReceipt::new(
446            "GR-1000-0000000001",
447            "1000",
448            "1000",
449            "0001",
450            2024,
451            1,
452            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
453            "JSMITH",
454        );
455
456        assert_eq!(gr.plant, "1000");
457        assert_eq!(gr.header.status, DocumentStatus::Draft);
458    }
459
460    #[test]
461    fn test_goods_receipt_from_po() {
462        let gr = GoodsReceipt::from_purchase_order(
463            "GR-1000-0000000001",
464            "1000",
465            "PO-1000-0000000001",
466            "V-000001",
467            "1000",
468            "0001",
469            2024,
470            1,
471            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
472            "JSMITH",
473        );
474
475        assert_eq!(gr.purchase_order_id, Some("PO-1000-0000000001".to_string()));
476        assert_eq!(gr.header.document_references.len(), 1);
477    }
478
479    #[test]
480    fn test_goods_receipt_items() {
481        let mut gr = GoodsReceipt::new(
482            "GR-1000-0000000001",
483            "1000",
484            "1000",
485            "0001",
486            2024,
487            1,
488            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
489            "JSMITH",
490        );
491
492        gr.add_item(
493            GoodsReceiptItem::from_po(
494                1,
495                "Raw Materials",
496                Decimal::from(100),
497                Decimal::from(10),
498                "PO-1000-0000000001",
499                1,
500            )
501            .with_material("MAT-001"),
502        );
503
504        assert_eq!(gr.total_quantity, Decimal::from(100));
505        assert_eq!(gr.total_value, Decimal::from(1000));
506    }
507
508    #[test]
509    fn test_movement_types() {
510        assert!(MovementType::GrForPo.is_receipt());
511        assert!(!MovementType::GrForPo.is_issue());
512        assert!(MovementType::ReturnToVendor.is_issue());
513        assert_eq!(MovementType::GrForPo.code(), "101");
514    }
515
516    #[test]
517    fn test_gl_entry_generation() {
518        let mut gr = GoodsReceipt::new(
519            "GR-1000-0000000001",
520            "1000",
521            "1000",
522            "0001",
523            2024,
524            1,
525            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
526            "JSMITH",
527        );
528
529        gr.add_item(GoodsReceiptItem::new(
530            1,
531            "Test Item",
532            Decimal::from(10),
533            Decimal::from(100),
534        ));
535
536        let entries = gr.generate_gl_entries();
537        assert_eq!(entries.len(), 2);
538        // First entry: DR Inventory
539        assert_eq!(entries[0].1, Decimal::from(1000));
540        // Second entry: CR GR/IR
541        assert_eq!(entries[1].2, Decimal::from(1000));
542    }
543}