1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use strum::{Display, EnumString};
9use uuid::Uuid;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Lot {
18 pub id: Uuid,
19 pub lot_number: String,
20 pub sku: String,
21 pub status: LotStatus,
22 pub quantity_produced: Decimal,
23 pub quantity_remaining: Decimal,
24 pub quantity_reserved: Decimal,
25 pub quantity_quarantined: Decimal,
26 pub production_date: DateTime<Utc>,
27 pub expiration_date: Option<DateTime<Utc>>,
28 pub best_before_date: Option<DateTime<Utc>>,
29 pub supplier_lot: Option<String>,
30 pub supplier_id: Option<Uuid>,
31 pub work_order_id: Option<Uuid>,
32 pub purchase_order_id: Option<Uuid>,
33 pub cost_per_unit: Option<Decimal>,
34 pub attributes: serde_json::Value,
35 pub notes: Option<String>,
36 pub created_at: DateTime<Utc>,
37 pub updated_at: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize)]
42#[strum(serialize_all = "snake_case")]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum LotStatus {
46 Active,
48 Quarantine,
50 Expired,
52 Consumed,
54 OnHold,
56 Recalled,
58 Scrapped,
60}
61
62impl Default for LotStatus {
63 fn default() -> Self {
64 Self::Active
65 }
66}
67
68impl std::str::FromStr for LotStatus {
69 type Err = String;
70
71 fn from_str(s: &str) -> Result<Self, Self::Err> {
72 match s.to_lowercase().as_str() {
73 "active" => Ok(Self::Active),
74 "quarantine" => Ok(Self::Quarantine),
75 "expired" => Ok(Self::Expired),
76 "consumed" => Ok(Self::Consumed),
77 "on_hold" => Ok(Self::OnHold),
78 "recalled" => Ok(Self::Recalled),
79 "scrapped" => Ok(Self::Scrapped),
80 _ => Err(format!("Unknown lot status: {s}")),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct LotTransaction {
92 pub id: Uuid,
93 pub lot_id: Uuid,
94 pub transaction_type: LotTransactionType,
95 pub quantity: Decimal,
96 pub reference_type: String,
97 pub reference_id: Uuid,
98 pub from_location_id: Option<i32>,
99 pub to_location_id: Option<i32>,
100 pub reason: Option<String>,
101 pub performed_by: Option<String>,
102 pub created_at: DateTime<Utc>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
107#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
108#[serde(rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum LotTransactionType {
111 Received,
113 Consumed,
115 Adjusted,
117 Reserved,
119 Released,
121 Quarantined,
123 QuarantineReleased,
125 Transferred,
127 Scrapped,
129 Returned,
131 Split,
133 Merged,
135}
136
137impl Default for LotTransactionType {
138 fn default() -> Self {
139 Self::Received
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct LotCertificate {
150 pub id: Uuid,
151 pub lot_id: Uuid,
152 pub certificate_type: CertificateType,
153 pub certificate_number: Option<String>,
154 pub document_url: Option<String>,
155 pub issued_by: Option<String>,
156 pub issued_at: Option<DateTime<Utc>>,
157 pub expires_at: Option<DateTime<Utc>>,
158 pub notes: Option<String>,
159 pub created_at: DateTime<Utc>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
164#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
165#[serde(rename_all = "snake_case")]
166#[non_exhaustive]
167pub enum CertificateType {
168 Coa,
170 Coc,
172 Msds,
174 Sds,
176 TestReport,
178 InspectionReport,
180 CountryOfOrigin,
182 Other,
184}
185
186impl Default for CertificateType {
187 fn default() -> Self {
188 Self::Coa
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct LotLocation {
199 pub lot_id: Uuid,
200 pub location_id: i32,
201 pub quantity: Decimal,
202 pub updated_at: DateTime<Utc>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct TraceabilityResult {
212 pub lot: Lot,
213 pub upstream: Vec<TraceNode>,
215 pub downstream: Vec<TraceNode>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct TraceNode {
222 pub node_type: TraceNodeType,
223 pub node_id: Uuid,
224 pub reference_number: Option<String>,
225 pub lot_number: Option<String>,
226 pub serial_number: Option<String>,
227 pub quantity: Decimal,
228 pub timestamp: DateTime<Utc>,
229 pub entity_name: Option<String>,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235#[non_exhaustive]
236pub enum TraceNodeType {
237 PurchaseOrder,
238 Receipt,
239 WorkOrder,
240 Order,
241 Shipment,
242 Return,
243 Transfer,
244 Adjustment,
245}
246
247impl std::fmt::Display for TraceNodeType {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 match self {
250 Self::PurchaseOrder => write!(f, "purchase_order"),
251 Self::Receipt => write!(f, "receipt"),
252 Self::WorkOrder => write!(f, "work_order"),
253 Self::Order => write!(f, "order"),
254 Self::Shipment => write!(f, "shipment"),
255 Self::Return => write!(f, "return"),
256 Self::Transfer => write!(f, "transfer"),
257 Self::Adjustment => write!(f, "adjustment"),
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct CreateLot {
269 pub lot_number: Option<String>,
270 pub sku: String,
271 pub quantity: Decimal,
272 pub production_date: Option<DateTime<Utc>>,
273 pub expiration_date: Option<DateTime<Utc>>,
274 pub best_before_date: Option<DateTime<Utc>>,
275 pub supplier_lot: Option<String>,
276 pub supplier_id: Option<Uuid>,
277 pub work_order_id: Option<Uuid>,
278 pub purchase_order_id: Option<Uuid>,
279 pub cost_per_unit: Option<Decimal>,
280 pub attributes: Option<serde_json::Value>,
281 pub notes: Option<String>,
282 pub initial_location_id: Option<i32>,
283}
284
285impl Default for CreateLot {
286 fn default() -> Self {
287 Self {
288 lot_number: None,
289 sku: String::new(),
290 quantity: Decimal::ZERO,
291 production_date: None,
292 expiration_date: None,
293 best_before_date: None,
294 supplier_lot: None,
295 supplier_id: None,
296 work_order_id: None,
297 purchase_order_id: None,
298 cost_per_unit: None,
299 attributes: None,
300 notes: None,
301 initial_location_id: None,
302 }
303 }
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct UpdateLot {
309 pub status: Option<LotStatus>,
310 pub expiration_date: Option<DateTime<Utc>>,
311 pub best_before_date: Option<DateTime<Utc>>,
312 pub cost_per_unit: Option<Decimal>,
313 pub attributes: Option<serde_json::Value>,
314 pub notes: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct AdjustLot {
320 pub lot_id: Uuid,
321 pub quantity_change: Decimal,
322 pub reason: String,
323 pub reference_type: Option<String>,
324 pub reference_id: Option<Uuid>,
325 pub location_id: Option<i32>,
326 pub performed_by: Option<String>,
327}
328
329impl Default for AdjustLot {
330 fn default() -> Self {
331 Self {
332 lot_id: Uuid::nil(),
333 quantity_change: Decimal::ZERO,
334 reason: String::new(),
335 reference_type: None,
336 reference_id: None,
337 location_id: None,
338 performed_by: None,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct ConsumeLot {
346 pub lot_id: Uuid,
347 pub quantity: Decimal,
348 pub reference_type: String,
349 pub reference_id: Uuid,
350 pub location_id: Option<i32>,
351 pub performed_by: Option<String>,
352}
353
354impl Default for ConsumeLot {
355 fn default() -> Self {
356 Self {
357 lot_id: Uuid::nil(),
358 quantity: Decimal::ZERO,
359 reference_type: String::new(),
360 reference_id: Uuid::nil(),
361 location_id: None,
362 performed_by: None,
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ReserveLot {
370 pub lot_id: Uuid,
371 pub quantity: Decimal,
372 pub reference_type: String,
373 pub reference_id: Uuid,
374 pub expires_in_seconds: Option<i64>,
375}
376
377impl Default for ReserveLot {
378 fn default() -> Self {
379 Self {
380 lot_id: Uuid::nil(),
381 quantity: Decimal::ZERO,
382 reference_type: String::new(),
383 reference_id: Uuid::nil(),
384 expires_in_seconds: None,
385 }
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct TransferLot {
392 pub lot_id: Uuid,
393 pub quantity: Decimal,
394 pub from_location_id: i32,
395 pub to_location_id: i32,
396 pub reason: Option<String>,
397 pub performed_by: Option<String>,
398}
399
400impl Default for TransferLot {
401 fn default() -> Self {
402 Self {
403 lot_id: Uuid::nil(),
404 quantity: Decimal::ZERO,
405 from_location_id: 0,
406 to_location_id: 0,
407 reason: None,
408 performed_by: None,
409 }
410 }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct SplitLot {
416 pub lot_id: Uuid,
417 pub quantity: Decimal,
418 pub new_lot_number: Option<String>,
419 pub reason: Option<String>,
420}
421
422impl Default for SplitLot {
423 fn default() -> Self {
424 Self { lot_id: Uuid::nil(), quantity: Decimal::ZERO, new_lot_number: None, reason: None }
425 }
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, Default)]
430pub struct MergeLots {
431 pub source_lot_ids: Vec<Uuid>,
432 pub target_lot_number: Option<String>,
433 pub reason: Option<String>,
434}
435
436#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct LotFilter {
439 pub sku: Option<String>,
440 pub lot_number: Option<String>,
441 pub status: Option<LotStatus>,
442 pub supplier_id: Option<Uuid>,
443 pub work_order_id: Option<Uuid>,
444 pub purchase_order_id: Option<Uuid>,
445 pub expiring_before: Option<DateTime<Utc>>,
446 pub expiring_after: Option<DateTime<Utc>>,
447 pub has_quantity: Option<bool>,
448 pub location_id: Option<i32>,
449 pub limit: Option<u32>,
450 pub offset: Option<u32>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct AddLotCertificate {
456 pub lot_id: Uuid,
457 pub certificate_type: CertificateType,
458 pub certificate_number: Option<String>,
459 pub document_url: Option<String>,
460 pub issued_by: Option<String>,
461 pub issued_at: Option<DateTime<Utc>>,
462 pub expires_at: Option<DateTime<Utc>>,
463 pub notes: Option<String>,
464}
465
466impl Default for AddLotCertificate {
467 fn default() -> Self {
468 Self {
469 lot_id: Uuid::nil(),
470 certificate_type: CertificateType::default(),
471 certificate_number: None,
472 document_url: None,
473 issued_by: None,
474 issued_at: None,
475 expires_at: None,
476 notes: None,
477 }
478 }
479}
480
481impl Lot {
486 #[must_use]
488 pub fn has_available(&self) -> bool {
489 self.quantity_available() > Decimal::ZERO
490 }
491
492 #[must_use]
494 pub fn quantity_available(&self) -> Decimal {
495 self.quantity_remaining - self.quantity_reserved - self.quantity_quarantined
496 }
497
498 #[must_use]
500 pub fn is_expired(&self) -> bool {
501 if let Some(exp) = self.expiration_date { Utc::now() > exp } else { false }
502 }
503
504 #[must_use]
506 pub fn is_expiring_soon(&self, days: i64) -> bool {
507 if let Some(exp) = self.expiration_date {
508 let threshold = Utc::now() + chrono::Duration::days(days);
509 exp <= threshold && !self.is_expired()
510 } else {
511 false
512 }
513 }
514
515 #[must_use]
517 pub fn can_consume(&self, quantity: Decimal) -> bool {
518 self.status == LotStatus::Active && self.quantity_available() >= quantity
519 }
520
521 #[must_use]
523 pub fn can_reserve(&self, quantity: Decimal) -> bool {
524 self.status == LotStatus::Active && self.quantity_available() >= quantity
525 }
526
527 #[must_use]
529 pub fn days_until_expiration(&self) -> Option<i64> {
530 self.expiration_date.map(|exp| (exp - Utc::now()).num_days())
531 }
532
533 #[must_use]
535 pub fn shelf_life_remaining(&self) -> Option<Decimal> {
536 if let Some(exp) = self.expiration_date {
537 let total_days = (exp - self.production_date).num_days();
538 if total_days > 0 {
539 let remaining_days = (exp - Utc::now()).num_days();
540 Some(
541 Decimal::from(remaining_days.max(0)) / Decimal::from(total_days)
542 * Decimal::from(100),
543 )
544 } else {
545 None
546 }
547 } else {
548 None
549 }
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use rust_decimal_macros::dec;
557 use std::str::FromStr;
558
559 fn create_test_lot() -> Lot {
564 let now = Utc::now();
565 Lot {
566 id: Uuid::new_v4(),
567 lot_number: "LOT-001".to_string(),
568 sku: "SKU-001".to_string(),
569 status: LotStatus::Active,
570 quantity_produced: dec!(100),
571 quantity_remaining: dec!(100),
572 quantity_reserved: Decimal::ZERO,
573 quantity_quarantined: Decimal::ZERO,
574 production_date: now,
575 expiration_date: None,
576 best_before_date: None,
577 supplier_lot: None,
578 supplier_id: None,
579 work_order_id: None,
580 purchase_order_id: None,
581 cost_per_unit: Some(dec!(2.50)),
582 attributes: serde_json::json!({}),
583 notes: None,
584 created_at: now,
585 updated_at: now,
586 }
587 }
588
589 #[test]
594 fn quantity_available_subtracts_reserved_and_quarantined() {
595 let mut lot = create_test_lot();
596 lot.quantity_reserved = dec!(30);
597 lot.quantity_quarantined = dec!(20);
598 assert_eq!(lot.quantity_available(), dec!(50));
599 }
600
601 #[test]
602 fn has_available_true_when_positive() {
603 let lot = create_test_lot();
604 assert!(lot.has_available());
605 }
606
607 #[test]
608 fn has_available_false_when_zero() {
609 let mut lot = create_test_lot();
610 lot.quantity_reserved = dec!(100);
611 assert_eq!(lot.quantity_available(), Decimal::ZERO);
612 assert!(!lot.has_available());
613 }
614
615 #[test]
616 fn quantity_available_can_go_negative_when_over_reserved() {
617 let mut lot = create_test_lot();
618 lot.quantity_reserved = dec!(120);
619 assert_eq!(lot.quantity_available(), dec!(-20));
620 assert!(!lot.has_available());
621 }
622
623 #[test]
628 fn can_consume_exact_boundary() {
629 let lot = create_test_lot();
630 assert!(lot.can_consume(dec!(100)));
631 assert!(!lot.can_consume(dec!(100.0001)));
632 }
633
634 #[test]
635 fn can_consume_zero_quantity() {
636 let lot = create_test_lot();
637 assert!(lot.can_consume(Decimal::ZERO));
638 }
639
640 #[test]
641 fn can_consume_rejected_for_non_active_statuses() {
642 for status in [
643 LotStatus::Quarantine,
644 LotStatus::Expired,
645 LotStatus::Consumed,
646 LotStatus::OnHold,
647 LotStatus::Recalled,
648 LotStatus::Scrapped,
649 ] {
650 let mut lot = create_test_lot();
651 lot.status = status;
652 assert!(!lot.can_consume(dec!(1)), "should not consume from {status}");
653 }
654 }
655
656 #[test]
657 fn can_reserve_exact_boundary_and_over() {
658 let mut lot = create_test_lot();
659 lot.quantity_reserved = dec!(40);
660 assert!(lot.can_reserve(dec!(60)));
661 assert!(!lot.can_reserve(dec!(61)));
662 }
663
664 #[test]
665 fn can_reserve_rejected_when_on_hold() {
666 let mut lot = create_test_lot();
667 lot.status = LotStatus::OnHold;
668 assert!(!lot.can_reserve(dec!(1)));
669 }
670
671 #[test]
676 fn is_expired_false_without_expiration_date() {
677 let lot = create_test_lot();
678 assert!(!lot.is_expired());
679 assert_eq!(lot.days_until_expiration(), None);
680 }
681
682 #[test]
683 fn is_expired_true_for_past_date() {
684 let mut lot = create_test_lot();
685 lot.expiration_date = Some(Utc::now() - chrono::Duration::days(1));
686 assert!(lot.is_expired());
687 }
688
689 #[test]
690 fn is_expiring_soon_within_window() {
691 let mut lot = create_test_lot();
692 lot.expiration_date = Some(Utc::now() + chrono::Duration::days(5));
693 assert!(lot.is_expiring_soon(7));
694 assert!(!lot.is_expiring_soon(2));
695 }
696
697 #[test]
698 fn is_expiring_soon_false_when_already_expired() {
699 let mut lot = create_test_lot();
700 lot.expiration_date = Some(Utc::now() - chrono::Duration::days(1));
701 assert!(!lot.is_expiring_soon(30));
702 }
703
704 #[test]
705 fn days_until_expiration_positive_for_future() {
706 let mut lot = create_test_lot();
707 lot.expiration_date = Some(Utc::now() + chrono::Duration::days(10));
708 assert_eq!(lot.days_until_expiration(), Some(9)); }
710
711 #[test]
712 fn shelf_life_remaining_none_without_expiration() {
713 let lot = create_test_lot();
714 assert_eq!(lot.shelf_life_remaining(), None);
715 }
716
717 #[test]
718 fn shelf_life_remaining_none_when_total_days_zero() {
719 let mut lot = create_test_lot();
720 lot.expiration_date = Some(lot.production_date);
721 assert_eq!(lot.shelf_life_remaining(), None);
722 }
723
724 #[test]
725 fn shelf_life_remaining_clamps_expired_to_zero() {
726 let mut lot = create_test_lot();
727 lot.production_date = Utc::now() - chrono::Duration::days(100);
728 lot.expiration_date = Some(Utc::now() - chrono::Duration::days(10));
729 assert_eq!(lot.shelf_life_remaining(), Some(Decimal::ZERO));
730 }
731
732 #[test]
733 fn shelf_life_remaining_roughly_half_midway() {
734 let mut lot = create_test_lot();
735 lot.production_date = Utc::now() - chrono::Duration::days(50);
736 lot.expiration_date = Some(Utc::now() + chrono::Duration::days(50));
737 let pct = lot.shelf_life_remaining().expect("should have shelf life");
738 assert!(pct >= dec!(48) && pct <= dec!(52), "expected ~50, got {pct}");
739 }
740
741 #[test]
746 fn lot_status_display_from_str_round_trip() {
747 for status in [
748 LotStatus::Active,
749 LotStatus::Quarantine,
750 LotStatus::Expired,
751 LotStatus::Consumed,
752 LotStatus::OnHold,
753 LotStatus::Recalled,
754 LotStatus::Scrapped,
755 ] {
756 let parsed = LotStatus::from_str(&status.to_string()).expect("round trip");
757 assert_eq!(parsed, status);
758 }
759 }
760
761 #[test]
762 fn lot_status_from_str_case_insensitive_and_unknown() {
763 assert_eq!(LotStatus::from_str("ON_HOLD"), Ok(LotStatus::OnHold));
764 assert!(LotStatus::from_str("bogus").is_err());
765 }
766
767 #[test]
768 fn lot_transaction_type_round_trip() {
769 for t in [
770 LotTransactionType::Received,
771 LotTransactionType::Consumed,
772 LotTransactionType::QuarantineReleased,
773 LotTransactionType::Split,
774 LotTransactionType::Merged,
775 ] {
776 assert_eq!(LotTransactionType::from_str(&t.to_string()), Ok(t));
777 }
778 assert!(LotTransactionType::from_str("nope").is_err());
779 }
780
781 #[test]
782 fn certificate_type_round_trip_and_default() {
783 assert_eq!(CertificateType::default(), CertificateType::Coa);
784 for t in [CertificateType::Coa, CertificateType::Msds, CertificateType::CountryOfOrigin] {
785 assert_eq!(CertificateType::from_str(&t.to_string()), Ok(t));
786 }
787 }
788
789 #[test]
790 fn trace_node_type_display() {
791 assert_eq!(TraceNodeType::PurchaseOrder.to_string(), "purchase_order");
792 assert_eq!(TraceNodeType::WorkOrder.to_string(), "work_order");
793 assert_eq!(TraceNodeType::Adjustment.to_string(), "adjustment");
794 }
795
796 #[test]
797 fn defaults_are_sane() {
798 assert_eq!(LotStatus::default(), LotStatus::Active);
799 assert_eq!(LotTransactionType::default(), LotTransactionType::Received);
800 let create = CreateLot::default();
801 assert_eq!(create.quantity, Decimal::ZERO);
802 assert!(create.sku.is_empty());
803 let consume = ConsumeLot::default();
804 assert_eq!(consume.lot_id, Uuid::nil());
805 assert_eq!(consume.quantity, Decimal::ZERO);
806 }
807}