1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::models::subledger::GLReference;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InventoryMovement {
12 pub document_number: String,
14 pub item_number: u32,
16 pub company_code: String,
18 pub movement_date: NaiveDate,
20 pub posting_date: NaiveDate,
22 pub movement_type: MovementType,
24 pub material_id: String,
26 pub description: String,
28 pub plant: String,
30 pub storage_location: String,
32 pub quantity: Decimal,
34 pub unit: String,
36 pub value: Decimal,
38 pub currency: String,
40 pub unit_cost: Decimal,
42 pub batch_number: Option<String>,
44 pub serial_numbers: Vec<String>,
46 pub reference_doc_type: Option<ReferenceDocType>,
48 pub reference_doc_number: Option<String>,
50 pub reference_item: Option<u32>,
52 pub vendor_id: Option<String>,
54 pub customer_id: Option<String>,
56 pub cost_center: Option<String>,
58 pub gl_account: String,
60 pub offset_account: String,
62 pub gl_reference: Option<GLReference>,
64 pub special_stock: Option<SpecialStockType>,
66 pub reason_code: Option<String>,
68 pub created_by: String,
70 pub created_at: DateTime<Utc>,
72 pub is_reversed: bool,
74 pub reversal_doc: Option<String>,
76 pub notes: Option<String>,
78}
79
80impl InventoryMovement {
81 #[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 #[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 #[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 pub fn with_batch(mut self, batch_number: String) -> Self {
229 self.batch_number = Some(batch_number);
230 self
231 }
232
233 pub fn with_serials(mut self, serial_numbers: Vec<String>) -> Self {
235 self.serial_numbers = serial_numbers;
236 self
237 }
238
239 pub fn with_cost_center(mut self, cost_center: String) -> Self {
241 self.cost_center = Some(cost_center);
242 self
243 }
244
245 pub fn with_reason(mut self, reason_code: String) -> Self {
247 self.reason_code = Some(reason_code);
248 self
249 }
250
251 pub fn with_gl_reference(mut self, reference: GLReference) -> Self {
253 self.gl_reference = Some(reference);
254 self
255 }
256
257 pub fn reverse(&mut self, reversal_doc: String) {
259 self.is_reversed = true;
260 self.reversal_doc = Some(reversal_doc);
261 }
262
263 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 pub fn quantity_sign(&self) -> i8 {
295 self.movement_type.quantity_sign()
296 }
297
298 pub fn signed_quantity(&self) -> Decimal {
300 self.quantity * Decimal::from(self.quantity_sign())
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306pub enum MovementType {
307 GoodsReceiptPO,
309 GoodsReceiptProduction,
311 GoodsReceiptOther,
313 GoodsReceipt,
315 ReturnToVendor,
317 GoodsIssueSales,
319 GoodsIssueProduction,
321 GoodsIssueCostCenter,
323 GoodsIssueScrapping,
325 GoodsIssue,
327 Scrap,
329 TransferPlant,
331 TransferStorageLocation,
333 TransferIn,
335 TransferOut,
337 TransferToInspection,
339 TransferFromInspection,
341 PhysicalInventory,
343 InventoryAdjustmentIn,
345 InventoryAdjustmentOut,
347 InitialStock,
349 ReversalGoodsReceipt,
351 ReversalGoodsIssue,
353}
354
355impl MovementType {
356 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, }
385 }
386
387 pub fn default_accounts(&self) -> (String, String) {
389 match self {
390 MovementType::GoodsReceiptPO => ("1200".to_string(), "2100".to_string()), MovementType::GoodsReceiptProduction => ("1200".to_string(), "1300".to_string()), MovementType::GoodsReceiptOther => ("1200".to_string(), "1299".to_string()), MovementType::GoodsReceipt => ("1200".to_string(), "1299".to_string()), MovementType::ReturnToVendor => ("2100".to_string(), "1200".to_string()), MovementType::GoodsIssueSales => ("5000".to_string(), "1200".to_string()), MovementType::GoodsIssueProduction => ("1300".to_string(), "1200".to_string()), MovementType::GoodsIssueCostCenter => ("7000".to_string(), "1200".to_string()), MovementType::GoodsIssueScrapping => ("7900".to_string(), "1200".to_string()), MovementType::GoodsIssue => ("7000".to_string(), "1200".to_string()), MovementType::Scrap => ("7900".to_string(), "1200".to_string()), MovementType::TransferPlant => ("1200".to_string(), "1200".to_string()), MovementType::TransferStorageLocation => ("1200".to_string(), "1200".to_string()),
403 MovementType::TransferIn => ("1200".to_string(), "1299".to_string()), MovementType::TransferOut => ("1299".to_string(), "1200".to_string()), 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()), MovementType::InventoryAdjustmentIn => ("1200".to_string(), "7910".to_string()), MovementType::InventoryAdjustmentOut => ("7910".to_string(), "1200".to_string()), MovementType::InitialStock => ("1200".to_string(), "3000".to_string()), MovementType::ReversalGoodsReceipt => ("2100".to_string(), "1200".to_string()),
412 MovementType::ReversalGoodsIssue => ("1200".to_string(), "5000".to_string()),
413 }
414 }
415
416 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, }
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442pub enum ReferenceDocType {
443 PurchaseOrder,
445 SalesOrder,
447 ProductionOrder,
449 Delivery,
451 MaterialDocument,
453 Reservation,
455 PhysicalInventoryDoc,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
461pub enum SpecialStockType {
462 VendorConsignment,
464 CustomerConsignment,
466 ProjectStock,
468 SalesOrderStock,
470 Subcontracting,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct StockTransfer {
477 pub document_number: String,
479 pub company_code: String,
481 pub transfer_date: NaiveDate,
483 pub from_plant: String,
485 pub from_storage_location: String,
487 pub to_plant: String,
489 pub to_storage_location: String,
491 pub items: Vec<TransferItem>,
493 pub status: TransferStatus,
495 pub created_by: String,
497 pub created_at: DateTime<Utc>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct TransferItem {
504 pub item_number: u32,
506 pub material_id: String,
508 pub description: String,
510 pub quantity: Decimal,
512 pub unit: String,
514 pub batch_number: Option<String>,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520pub enum TransferStatus {
521 Draft,
523 InTransit,
525 PartiallyReceived,
527 Completed,
529 Cancelled,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct PhysicalInventoryDoc {
536 pub document_number: String,
538 pub company_code: String,
540 pub plant: String,
542 pub storage_location: String,
544 pub planned_date: NaiveDate,
546 pub count_date: Option<NaiveDate>,
548 pub status: PIStatus,
550 pub items: Vec<PIItem>,
552 pub created_by: String,
554 pub created_at: DateTime<Utc>,
556 pub posted: bool,
558 pub posted_at: Option<DateTime<Utc>>,
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564pub enum PIStatus {
565 Created,
567 Active,
569 Counted,
571 Posted,
573 Cancelled,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct PIItem {
580 pub item_number: u32,
582 pub material_id: String,
584 pub description: String,
586 pub book_quantity: Decimal,
588 pub counted_quantity: Option<Decimal>,
590 pub difference: Option<Decimal>,
592 pub unit: String,
594 pub batch_number: Option<String>,
596 pub zero_count: bool,
598 pub difference_reason: Option<String>,
600}
601
602impl PIItem {
603 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}