Skip to main content

datasynth_core/models/subledger/inventory/
movement.rs

1//! Inventory movement model.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::models::subledger::GLReference;
8
9/// Inventory movement (goods receipt, issue, transfer).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InventoryMovement {
12    /// Movement document number.
13    pub document_number: String,
14    /// Movement item number.
15    pub item_number: u32,
16    /// Company code.
17    pub company_code: String,
18    /// Movement date.
19    pub movement_date: NaiveDate,
20    /// Posting date.
21    pub posting_date: NaiveDate,
22    /// Movement type.
23    pub movement_type: MovementType,
24    /// Material ID.
25    pub material_id: String,
26    /// Material description.
27    pub description: String,
28    /// Plant.
29    pub plant: String,
30    /// Storage location.
31    pub storage_location: String,
32    /// Quantity.
33    pub quantity: Decimal,
34    /// Unit of measure.
35    pub unit: String,
36    /// Movement value.
37    pub value: Decimal,
38    /// Currency.
39    pub currency: String,
40    /// Unit cost.
41    pub unit_cost: Decimal,
42    /// Batch number.
43    pub batch_number: Option<String>,
44    /// Serial numbers.
45    pub serial_numbers: Vec<String>,
46    /// Reference document type.
47    pub reference_doc_type: Option<ReferenceDocType>,
48    /// Reference document number.
49    pub reference_doc_number: Option<String>,
50    /// Reference item.
51    pub reference_item: Option<u32>,
52    /// Vendor (for receipts).
53    pub vendor_id: Option<String>,
54    /// Customer (for issues).
55    pub customer_id: Option<String>,
56    /// Cost center.
57    pub cost_center: Option<String>,
58    /// GL account.
59    pub gl_account: String,
60    /// Offset account.
61    pub offset_account: String,
62    /// GL reference.
63    pub gl_reference: Option<GLReference>,
64    /// Special stock indicator.
65    pub special_stock: Option<SpecialStockType>,
66    /// Reason code.
67    pub reason_code: Option<String>,
68    /// Created by.
69    pub created_by: String,
70    /// Created at.
71    pub created_at: DateTime<Utc>,
72    /// Reversed.
73    pub is_reversed: bool,
74    /// Reversal document.
75    pub reversal_doc: Option<String>,
76    /// Notes.
77    pub notes: Option<String>,
78}
79
80impl InventoryMovement {
81    /// Creates a new inventory movement.
82    #[allow(clippy::too_many_arguments)]
83    pub fn new(
84        document_number: String,
85        item_number: u32,
86        company_code: String,
87        movement_date: NaiveDate,
88        movement_type: MovementType,
89        material_id: String,
90        description: String,
91        plant: String,
92        storage_location: String,
93        quantity: Decimal,
94        unit: String,
95        unit_cost: Decimal,
96        currency: String,
97        created_by: String,
98    ) -> Self {
99        let value = quantity * unit_cost;
100        let (gl_account, offset_account) = movement_type.default_accounts();
101
102        Self {
103            document_number,
104            item_number,
105            company_code,
106            movement_date,
107            posting_date: movement_date,
108            movement_type,
109            material_id,
110            description,
111            plant,
112            storage_location,
113            quantity,
114            unit,
115            value,
116            currency,
117            unit_cost,
118            batch_number: None,
119            serial_numbers: Vec::new(),
120            reference_doc_type: None,
121            reference_doc_number: None,
122            reference_item: None,
123            vendor_id: None,
124            customer_id: None,
125            cost_center: None,
126            gl_account,
127            offset_account,
128            gl_reference: None,
129            special_stock: None,
130            reason_code: None,
131            created_by,
132            created_at: Utc::now(),
133            is_reversed: false,
134            reversal_doc: None,
135            notes: None,
136        }
137    }
138
139    /// Creates a goods receipt from purchase order.
140    #[allow(clippy::too_many_arguments)]
141    pub fn goods_receipt_po(
142        document_number: String,
143        item_number: u32,
144        company_code: String,
145        movement_date: NaiveDate,
146        material_id: String,
147        description: String,
148        plant: String,
149        storage_location: String,
150        quantity: Decimal,
151        unit: String,
152        unit_cost: Decimal,
153        currency: String,
154        po_number: String,
155        po_item: u32,
156        vendor_id: String,
157        created_by: String,
158    ) -> Self {
159        let mut movement = Self::new(
160            document_number,
161            item_number,
162            company_code,
163            movement_date,
164            MovementType::GoodsReceiptPO,
165            material_id,
166            description,
167            plant,
168            storage_location,
169            quantity,
170            unit,
171            unit_cost,
172            currency,
173            created_by,
174        );
175
176        movement.reference_doc_type = Some(ReferenceDocType::PurchaseOrder);
177        movement.reference_doc_number = Some(po_number);
178        movement.reference_item = Some(po_item);
179        movement.vendor_id = Some(vendor_id);
180        movement
181    }
182
183    /// Creates a goods issue to sales order.
184    #[allow(clippy::too_many_arguments)]
185    pub fn goods_issue_sales(
186        document_number: String,
187        item_number: u32,
188        company_code: String,
189        movement_date: NaiveDate,
190        material_id: String,
191        description: String,
192        plant: String,
193        storage_location: String,
194        quantity: Decimal,
195        unit: String,
196        unit_cost: Decimal,
197        currency: String,
198        sales_order: String,
199        sales_item: u32,
200        customer_id: String,
201        created_by: String,
202    ) -> Self {
203        let mut movement = Self::new(
204            document_number,
205            item_number,
206            company_code,
207            movement_date,
208            MovementType::GoodsIssueSales,
209            material_id,
210            description,
211            plant,
212            storage_location,
213            quantity,
214            unit,
215            unit_cost,
216            currency,
217            created_by,
218        );
219
220        movement.reference_doc_type = Some(ReferenceDocType::SalesOrder);
221        movement.reference_doc_number = Some(sales_order);
222        movement.reference_item = Some(sales_item);
223        movement.customer_id = Some(customer_id);
224        movement
225    }
226
227    /// Sets batch number.
228    pub fn with_batch(mut self, batch_number: String) -> Self {
229        self.batch_number = Some(batch_number);
230        self
231    }
232
233    /// Sets serial numbers.
234    pub fn with_serials(mut self, serial_numbers: Vec<String>) -> Self {
235        self.serial_numbers = serial_numbers;
236        self
237    }
238
239    /// Sets cost center.
240    pub fn with_cost_center(mut self, cost_center: String) -> Self {
241        self.cost_center = Some(cost_center);
242        self
243    }
244
245    /// Sets reason code.
246    pub fn with_reason(mut self, reason_code: String) -> Self {
247        self.reason_code = Some(reason_code);
248        self
249    }
250
251    /// Sets GL reference.
252    pub fn with_gl_reference(mut self, reference: GLReference) -> Self {
253        self.gl_reference = Some(reference);
254        self
255    }
256
257    /// Marks as reversed.
258    pub fn reverse(&mut self, reversal_doc: String) {
259        self.is_reversed = true;
260        self.reversal_doc = Some(reversal_doc);
261    }
262
263    /// Creates a reversal movement.
264    pub fn create_reversal(&self, reversal_doc_number: String, created_by: String) -> Self {
265        let mut reversal = Self::new(
266            reversal_doc_number,
267            self.item_number,
268            self.company_code.clone(),
269            chrono::Local::now().date_naive(),
270            self.movement_type.reversal_type(),
271            self.material_id.clone(),
272            self.description.clone(),
273            self.plant.clone(),
274            self.storage_location.clone(),
275            self.quantity,
276            self.unit.clone(),
277            self.unit_cost,
278            self.currency.clone(),
279            created_by,
280        );
281
282        reversal.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
283        reversal.reference_doc_number = Some(self.document_number.clone());
284        reversal.reference_item = Some(self.item_number);
285        reversal.batch_number = self.batch_number.clone();
286        reversal.notes = Some(format!(
287            "Reversal of {}/{}",
288            self.document_number, self.item_number
289        ));
290        reversal
291    }
292
293    /// Gets sign for quantity (positive or negative).
294    pub fn quantity_sign(&self) -> i8 {
295        self.movement_type.quantity_sign()
296    }
297
298    /// Gets signed quantity.
299    pub fn signed_quantity(&self) -> Decimal {
300        self.quantity * Decimal::from(self.quantity_sign())
301    }
302}
303
304/// Movement type.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306pub enum MovementType {
307    /// Goods receipt from purchase order.
308    GoodsReceiptPO,
309    /// Goods receipt from production.
310    GoodsReceiptProduction,
311    /// Goods receipt without reference.
312    GoodsReceiptOther,
313    /// Goods receipt (generic).
314    GoodsReceipt,
315    /// Return to vendor.
316    ReturnToVendor,
317    /// Goods issue for sales order.
318    GoodsIssueSales,
319    /// Goods issue for production.
320    GoodsIssueProduction,
321    /// Goods issue for cost center.
322    GoodsIssueCostCenter,
323    /// Goods issue scrapping.
324    GoodsIssueScrapping,
325    /// Goods issue (generic).
326    GoodsIssue,
327    /// Scrap (alias for GoodsIssueScrapping).
328    Scrap,
329    /// Transfer posting between plants.
330    TransferPlant,
331    /// Transfer posting between storage locations.
332    TransferStorageLocation,
333    /// Transfer in.
334    TransferIn,
335    /// Transfer out.
336    TransferOut,
337    /// Transfer to quality inspection.
338    TransferToInspection,
339    /// Transfer from quality inspection.
340    TransferFromInspection,
341    /// Physical inventory difference.
342    PhysicalInventory,
343    /// Inventory adjustment in.
344    InventoryAdjustmentIn,
345    /// Inventory adjustment out.
346    InventoryAdjustmentOut,
347    /// Initial stock entry.
348    InitialStock,
349    /// Reversal of goods receipt.
350    ReversalGoodsReceipt,
351    /// Reversal of goods issue.
352    ReversalGoodsIssue,
353}
354
355impl MovementType {
356    /// Gets quantity sign (1 for receipts, -1 for issues).
357    pub fn quantity_sign(&self) -> i8 {
358        match self {
359            MovementType::GoodsReceiptPO
360            | MovementType::GoodsReceiptProduction
361            | MovementType::GoodsReceiptOther
362            | MovementType::GoodsReceipt
363            | MovementType::TransferFromInspection
364            | MovementType::TransferIn
365            | MovementType::InventoryAdjustmentIn
366            | MovementType::InitialStock
367            | MovementType::ReversalGoodsIssue => 1,
368
369            MovementType::ReturnToVendor
370            | MovementType::GoodsIssueSales
371            | MovementType::GoodsIssueProduction
372            | MovementType::GoodsIssueCostCenter
373            | MovementType::GoodsIssueScrapping
374            | MovementType::GoodsIssue
375            | MovementType::Scrap
376            | MovementType::TransferOut
377            | MovementType::InventoryAdjustmentOut
378            | MovementType::TransferToInspection
379            | MovementType::ReversalGoodsReceipt => -1,
380
381            MovementType::TransferPlant
382            | MovementType::TransferStorageLocation
383            | MovementType::PhysicalInventory => 0, // Neutral or depends on context
384        }
385    }
386
387    /// Gets default GL accounts.
388    pub fn default_accounts(&self) -> (String, String) {
389        match self {
390            MovementType::GoodsReceiptPO => ("1200".to_string(), "2100".to_string()), // Inventory, GR/IR
391            MovementType::GoodsReceiptProduction => ("1200".to_string(), "1300".to_string()), // Inventory, WIP
392            MovementType::GoodsReceiptOther => ("1200".to_string(), "1299".to_string()), // Inventory, Clearing
393            MovementType::GoodsReceipt => ("1200".to_string(), "1299".to_string()), // Generic receipt
394            MovementType::ReturnToVendor => ("2100".to_string(), "1200".to_string()), // GR/IR, Inventory
395            MovementType::GoodsIssueSales => ("5000".to_string(), "1200".to_string()), // COGS, Inventory
396            MovementType::GoodsIssueProduction => ("1300".to_string(), "1200".to_string()), // WIP, Inventory
397            MovementType::GoodsIssueCostCenter => ("7000".to_string(), "1200".to_string()), // Expense, Inventory
398            MovementType::GoodsIssueScrapping => ("7900".to_string(), "1200".to_string()), // Loss, Inventory
399            MovementType::GoodsIssue => ("7000".to_string(), "1200".to_string()), // Generic issue
400            MovementType::Scrap => ("7900".to_string(), "1200".to_string()), // Loss, Inventory (alias)
401            MovementType::TransferPlant => ("1200".to_string(), "1200".to_string()), // Inventory to Inventory
402            MovementType::TransferStorageLocation => ("1200".to_string(), "1200".to_string()),
403            MovementType::TransferIn => ("1200".to_string(), "1299".to_string()), // Inventory in
404            MovementType::TransferOut => ("1299".to_string(), "1200".to_string()), // Inventory out
405            MovementType::TransferToInspection => ("1210".to_string(), "1200".to_string()),
406            MovementType::TransferFromInspection => ("1200".to_string(), "1210".to_string()),
407            MovementType::PhysicalInventory => ("7910".to_string(), "1200".to_string()), // Gain/Loss, Inventory
408            MovementType::InventoryAdjustmentIn => ("1200".to_string(), "7910".to_string()), // Inventory, Gain
409            MovementType::InventoryAdjustmentOut => ("7910".to_string(), "1200".to_string()), // Loss, Inventory
410            MovementType::InitialStock => ("1200".to_string(), "3000".to_string()), // Inventory, Equity
411            MovementType::ReversalGoodsReceipt => ("2100".to_string(), "1200".to_string()),
412            MovementType::ReversalGoodsIssue => ("1200".to_string(), "5000".to_string()),
413        }
414    }
415
416    /// Gets the reversal movement type.
417    pub fn reversal_type(&self) -> MovementType {
418        match self {
419            MovementType::GoodsReceiptPO
420            | MovementType::GoodsReceiptProduction
421            | MovementType::GoodsReceiptOther
422            | MovementType::GoodsReceipt
423            | MovementType::TransferIn
424            | MovementType::InventoryAdjustmentIn => MovementType::ReversalGoodsReceipt,
425
426            MovementType::GoodsIssueSales
427            | MovementType::GoodsIssueProduction
428            | MovementType::GoodsIssueCostCenter
429            | MovementType::GoodsIssue
430            | MovementType::Scrap
431            | MovementType::TransferOut
432            | MovementType::InventoryAdjustmentOut
433            | MovementType::GoodsIssueScrapping => MovementType::ReversalGoodsIssue,
434
435            _ => *self, // Others reverse themselves
436        }
437    }
438}
439
440/// Reference document type.
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442pub enum ReferenceDocType {
443    /// Purchase order.
444    PurchaseOrder,
445    /// Sales order.
446    SalesOrder,
447    /// Production order.
448    ProductionOrder,
449    /// Delivery.
450    Delivery,
451    /// Material document.
452    MaterialDocument,
453    /// Reservation.
454    Reservation,
455    /// Physical inventory document.
456    PhysicalInventoryDoc,
457}
458
459/// Special stock type.
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
461pub enum SpecialStockType {
462    /// Consignment from vendor.
463    VendorConsignment,
464    /// Consignment at customer.
465    CustomerConsignment,
466    /// Project stock.
467    ProjectStock,
468    /// Sales order stock.
469    SalesOrderStock,
470    /// Subcontracting stock.
471    Subcontracting,
472}
473
474/// Stock transfer between locations.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct StockTransfer {
477    /// Transfer document number.
478    pub document_number: String,
479    /// Company code.
480    pub company_code: String,
481    /// Transfer date.
482    pub transfer_date: NaiveDate,
483    /// From plant.
484    pub from_plant: String,
485    /// From storage location.
486    pub from_storage_location: String,
487    /// To plant.
488    pub to_plant: String,
489    /// To storage location.
490    pub to_storage_location: String,
491    /// Items.
492    pub items: Vec<TransferItem>,
493    /// Status.
494    pub status: TransferStatus,
495    /// Created by.
496    pub created_by: String,
497    /// Created at.
498    pub created_at: DateTime<Utc>,
499}
500
501/// Transfer item.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct TransferItem {
504    /// Item number.
505    pub item_number: u32,
506    /// Material ID.
507    pub material_id: String,
508    /// Description.
509    pub description: String,
510    /// Quantity.
511    pub quantity: Decimal,
512    /// Unit.
513    pub unit: String,
514    /// Batch number.
515    pub batch_number: Option<String>,
516}
517
518/// Transfer status.
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520pub enum TransferStatus {
521    /// Draft.
522    Draft,
523    /// In transit.
524    InTransit,
525    /// Partially received.
526    PartiallyReceived,
527    /// Completed.
528    Completed,
529    /// Cancelled.
530    Cancelled,
531}
532
533/// Physical inventory count document.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct PhysicalInventoryDoc {
536    /// Document number.
537    pub document_number: String,
538    /// Company code.
539    pub company_code: String,
540    /// Plant.
541    pub plant: String,
542    /// Storage location.
543    pub storage_location: String,
544    /// Planned count date.
545    pub planned_date: NaiveDate,
546    /// Actual count date.
547    pub count_date: Option<NaiveDate>,
548    /// Status.
549    pub status: PIStatus,
550    /// Items.
551    pub items: Vec<PIItem>,
552    /// Created by.
553    pub created_by: String,
554    /// Created at.
555    pub created_at: DateTime<Utc>,
556    /// Posted.
557    pub posted: bool,
558    /// Posted at.
559    pub posted_at: Option<DateTime<Utc>>,
560}
561
562/// Physical inventory status.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564pub enum PIStatus {
565    /// Created.
566    Created,
567    /// Active (counting in progress).
568    Active,
569    /// Counted.
570    Counted,
571    /// Posted.
572    Posted,
573    /// Cancelled.
574    Cancelled,
575}
576
577/// Physical inventory item.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct PIItem {
580    /// Item number.
581    pub item_number: u32,
582    /// Material ID.
583    pub material_id: String,
584    /// Description.
585    pub description: String,
586    /// Book quantity.
587    pub book_quantity: Decimal,
588    /// Counted quantity.
589    pub counted_quantity: Option<Decimal>,
590    /// Difference.
591    pub difference: Option<Decimal>,
592    /// Unit.
593    pub unit: String,
594    /// Batch number.
595    pub batch_number: Option<String>,
596    /// Is zero count.
597    pub zero_count: bool,
598    /// Difference reason.
599    pub difference_reason: Option<String>,
600}
601
602impl PIItem {
603    /// Calculates difference.
604    pub fn calculate_difference(&mut self) {
605        if let Some(counted) = self.counted_quantity {
606            self.difference = Some(counted - self.book_quantity);
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use rust_decimal_macros::dec;
615
616    #[test]
617    fn test_goods_receipt_po() {
618        let movement = InventoryMovement::goods_receipt_po(
619            "MBLNR001".to_string(),
620            1,
621            "1000".to_string(),
622            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
623            "MAT001".to_string(),
624            "Test Material".to_string(),
625            "PLANT01".to_string(),
626            "SLOC01".to_string(),
627            dec!(100),
628            "EA".to_string(),
629            dec!(10),
630            "USD".to_string(),
631            "PO001".to_string(),
632            10,
633            "VEND001".to_string(),
634            "USER1".to_string(),
635        );
636
637        assert_eq!(movement.movement_type, MovementType::GoodsReceiptPO);
638        assert_eq!(movement.quantity, dec!(100));
639        assert_eq!(movement.value, dec!(1000));
640        assert_eq!(movement.quantity_sign(), 1);
641    }
642
643    #[test]
644    fn test_goods_issue_sales() {
645        let movement = InventoryMovement::goods_issue_sales(
646            "MBLNR002".to_string(),
647            1,
648            "1000".to_string(),
649            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
650            "MAT001".to_string(),
651            "Test Material".to_string(),
652            "PLANT01".to_string(),
653            "SLOC01".to_string(),
654            dec!(50),
655            "EA".to_string(),
656            dec!(10),
657            "USD".to_string(),
658            "SO001".to_string(),
659            10,
660            "CUST001".to_string(),
661            "USER1".to_string(),
662        );
663
664        assert_eq!(movement.movement_type, MovementType::GoodsIssueSales);
665        assert_eq!(movement.quantity_sign(), -1);
666        assert_eq!(movement.signed_quantity(), dec!(-50));
667    }
668
669    #[test]
670    fn test_create_reversal() {
671        let original = InventoryMovement::goods_receipt_po(
672            "MBLNR001".to_string(),
673            1,
674            "1000".to_string(),
675            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
676            "MAT001".to_string(),
677            "Test Material".to_string(),
678            "PLANT01".to_string(),
679            "SLOC01".to_string(),
680            dec!(100),
681            "EA".to_string(),
682            dec!(10),
683            "USD".to_string(),
684            "PO001".to_string(),
685            10,
686            "VEND001".to_string(),
687            "USER1".to_string(),
688        );
689
690        let reversal = original.create_reversal("MBLNR002".to_string(), "USER2".to_string());
691
692        assert_eq!(reversal.movement_type, MovementType::ReversalGoodsReceipt);
693        assert_eq!(reversal.reference_doc_number, Some("MBLNR001".to_string()));
694    }
695}