1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12 ReferenceType,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum GoodsReceiptType {
19 #[default]
21 PurchaseOrder,
22 ReturnToVendor,
24 StockTransfer,
26 Production,
28 InitialStock,
30 Subcontracting,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum MovementType {
38 #[default]
40 GrForPo,
41 ReturnToVendor,
43 GrForProduction,
45 TransferPosting,
47 InitialEntry,
49 Scrapping,
51 Consumption,
53}
54
55impl MovementType {
56 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 pub fn is_receipt(&self) -> bool {
71 matches!(
72 self,
73 Self::GrForPo | Self::GrForProduction | Self::InitialEntry | Self::TransferPosting
74 )
75 }
76
77 pub fn is_issue(&self) -> bool {
79 matches!(
80 self,
81 Self::ReturnToVendor | Self::Scrapping | Self::Consumption
82 )
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct GoodsReceiptItem {
89 #[serde(flatten)]
91 pub base: DocumentLineItem,
92
93 pub movement_type: MovementType,
95
96 pub po_number: Option<String>,
98
99 pub po_item: Option<u16>,
101
102 pub batch: Option<String>,
104
105 pub serial_numbers: Vec<String>,
107
108 pub vendor_batch: Option<String>,
110
111 pub quantity_base_uom: Decimal,
113
114 pub valuation_type: Option<String>,
116
117 pub stock_type: StockType,
119
120 pub reason_for_movement: Option<String>,
122
123 pub delivery_note: Option<String>,
125
126 pub bill_of_lading: Option<String>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
132#[serde(rename_all = "snake_case")]
133pub enum StockType {
134 #[default]
136 Unrestricted,
137 QualityInspection,
139 Blocked,
141 Returns,
143}
144
145impl GoodsReceiptItem {
146 #[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 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 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 pub fn with_batch(mut self, batch: impl Into<String>) -> Self {
194 self.batch = Some(batch.into());
195 self
196 }
197
198 pub fn with_stock_type(mut self, stock_type: StockType) -> Self {
200 self.stock_type = stock_type;
201 self
202 }
203
204 pub fn with_movement_type(mut self, movement_type: MovementType) -> Self {
206 self.movement_type = movement_type;
207 self
208 }
209
210 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 pub fn with_delivery_note(mut self, note: impl Into<String>) -> Self {
223 self.delivery_note = Some(note.into());
224 self
225 }
226
227 pub fn add_serial_number(&mut self, serial: impl Into<String>) {
229 self.serial_numbers.push(serial.into());
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct GoodsReceipt {
236 pub header: DocumentHeader,
238
239 pub gr_type: GoodsReceiptType,
241
242 pub items: Vec<GoodsReceiptItem>,
244
245 pub total_quantity: Decimal,
247
248 pub total_value: Decimal,
250
251 pub purchase_order_id: Option<String>,
253
254 pub vendor_id: Option<String>,
256
257 pub bill_of_lading: Option<String>,
259
260 pub delivery_note: Option<String>,
262
263 pub plant: String,
265
266 pub storage_location: String,
268
269 pub material_doc_year: u16,
271
272 pub is_posted: bool,
274
275 pub is_cancelled: bool,
277
278 pub cancellation_doc: Option<String>,
280}
281
282impl GoodsReceipt {
283 #[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 #[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 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 pub fn with_gr_type(mut self, gr_type: GoodsReceiptType) -> Self {
368 self.gr_type = gr_type;
369 self
370 }
371
372 pub fn with_delivery_note(mut self, note: impl Into<String>) -> Self {
374 self.delivery_note = Some(note.into());
375 self
376 }
377
378 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 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 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 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 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 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 let debit_account = item
422 .base
423 .gl_account
424 .clone()
425 .unwrap_or_else(|| "140000".to_string()); 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 assert_eq!(entries[0].1, Decimal::from(1000));
540 assert_eq!(entries[1].2, Decimal::from(1000));
542 }
543}