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 #[serde(with = "crate::serde_timestamp::utc")]
72 pub created_at: DateTime<Utc>,
73 pub is_reversed: bool,
75 pub reversal_doc: Option<String>,
77 pub notes: Option<String>,
79}
80
81impl InventoryMovement {
82 #[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 #[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 #[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 pub fn with_batch(mut self, batch_number: String) -> Self {
230 self.batch_number = Some(batch_number);
231 self
232 }
233
234 pub fn with_serials(mut self, serial_numbers: Vec<String>) -> Self {
236 self.serial_numbers = serial_numbers;
237 self
238 }
239
240 pub fn with_cost_center(mut self, cost_center: String) -> Self {
242 self.cost_center = Some(cost_center);
243 self
244 }
245
246 pub fn with_reason(mut self, reason_code: String) -> Self {
248 self.reason_code = Some(reason_code);
249 self
250 }
251
252 pub fn with_gl_reference(mut self, reference: GLReference) -> Self {
254 self.gl_reference = Some(reference);
255 self
256 }
257
258 pub fn reverse(&mut self, reversal_doc: String) {
260 self.is_reversed = true;
261 self.reversal_doc = Some(reversal_doc);
262 }
263
264 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 pub fn quantity_sign(&self) -> i8 {
296 self.movement_type.quantity_sign()
297 }
298
299 pub fn signed_quantity(&self) -> Decimal {
301 self.quantity * Decimal::from(self.quantity_sign())
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307pub enum MovementType {
308 GoodsReceiptPO,
310 GoodsReceiptProduction,
312 GoodsReceiptOther,
314 GoodsReceipt,
316 ReturnToVendor,
318 GoodsIssueSales,
320 GoodsIssueProduction,
322 GoodsIssueCostCenter,
324 GoodsIssueScrapping,
326 GoodsIssue,
328 Scrap,
330 TransferPlant,
332 TransferStorageLocation,
334 TransferIn,
336 TransferOut,
338 TransferToInspection,
340 TransferFromInspection,
342 PhysicalInventory,
344 InventoryAdjustmentIn,
346 InventoryAdjustmentOut,
348 InitialStock,
350 ReversalGoodsReceipt,
352 ReversalGoodsIssue,
354}
355
356impl MovementType {
357 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, }
386 }
387
388 pub fn default_accounts(&self) -> (String, String) {
390 match self {
391 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()),
404 MovementType::TransferIn => ("1200".to_string(), "1299".to_string()), MovementType::TransferOut => ("1299".to_string(), "1200".to_string()), 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()), 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()),
413 MovementType::ReversalGoodsIssue => ("1200".to_string(), "5000".to_string()),
414 }
415 }
416
417 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, }
438 }
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443pub enum ReferenceDocType {
444 PurchaseOrder,
446 SalesOrder,
448 ProductionOrder,
450 Delivery,
452 MaterialDocument,
454 Reservation,
456 PhysicalInventoryDoc,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462pub enum SpecialStockType {
463 VendorConsignment,
465 CustomerConsignment,
467 ProjectStock,
469 SalesOrderStock,
471 Subcontracting,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct StockTransfer {
478 pub document_number: String,
480 pub company_code: String,
482 pub transfer_date: NaiveDate,
484 pub from_plant: String,
486 pub from_storage_location: String,
488 pub to_plant: String,
490 pub to_storage_location: String,
492 pub items: Vec<TransferItem>,
494 pub status: TransferStatus,
496 pub created_by: String,
498 #[serde(with = "crate::serde_timestamp::utc")]
500 pub created_at: DateTime<Utc>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct TransferItem {
506 pub item_number: u32,
508 pub material_id: String,
510 pub description: String,
512 pub quantity: Decimal,
514 pub unit: String,
516 pub batch_number: Option<String>,
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
522pub enum TransferStatus {
523 Draft,
525 InTransit,
527 PartiallyReceived,
529 Completed,
531 Cancelled,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct PhysicalInventoryDoc {
538 pub document_number: String,
540 pub company_code: String,
542 pub plant: String,
544 pub storage_location: String,
546 pub planned_date: NaiveDate,
548 pub count_date: Option<NaiveDate>,
550 pub status: PIStatus,
552 pub items: Vec<PIItem>,
554 pub created_by: String,
556 #[serde(with = "crate::serde_timestamp::utc")]
558 pub created_at: DateTime<Utc>,
559 pub posted: bool,
561 #[serde(default, with = "crate::serde_timestamp::utc::option")]
563 pub posted_at: Option<DateTime<Utc>>,
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
568pub enum PIStatus {
569 Created,
571 Active,
573 Counted,
575 Posted,
577 Cancelled,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct PIItem {
584 pub item_number: u32,
586 pub material_id: String,
588 pub description: String,
590 pub book_quantity: Decimal,
592 pub counted_quantity: Option<Decimal>,
594 pub difference: Option<Decimal>,
596 pub unit: String,
598 pub batch_number: Option<String>,
600 pub zero_count: bool,
602 pub difference_reason: Option<String>,
604}
605
606impl PIItem {
607 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}