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