Skip to main content

stateset_core/models/
lot.rs

1//! Lot/Batch Tracking domain models
2//!
3//! Models for lot tracking, traceability, and batch management.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use strum::{Display, EnumString};
9use uuid::Uuid;
10
11// ============================================================================
12// Lot Types
13// ============================================================================
14
15/// A lot/batch of inventory items
16#[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/// Status of a lot
41#[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    /// Lot is active and available
47    Active,
48    /// Lot is in quarantine pending inspection
49    Quarantine,
50    /// Lot has expired
51    Expired,
52    /// Lot is fully consumed
53    Consumed,
54    /// Lot is on hold (quality issue)
55    OnHold,
56    /// Lot has been recalled
57    Recalled,
58    /// Lot has been scrapped
59    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// ============================================================================
86// Lot Transaction Types
87// ============================================================================
88
89/// Transaction record for lot movements
90#[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/// Type of lot transaction
106#[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    /// Initial creation/receipt of lot
112    Received,
113    /// Consumed in production or sale
114    Consumed,
115    /// Manual adjustment
116    Adjusted,
117    /// Reserved for an order
118    Reserved,
119    /// Released from reservation
120    Released,
121    /// Moved to quarantine
122    Quarantined,
123    /// Released from quarantine
124    QuarantineReleased,
125    /// Transferred between locations
126    Transferred,
127    /// Scrapped
128    Scrapped,
129    /// Returned from customer
130    Returned,
131    /// Split from another lot
132    Split,
133    /// Merged with another lot
134    Merged,
135}
136
137impl Default for LotTransactionType {
138    fn default() -> Self {
139        Self::Received
140    }
141}
142
143// ============================================================================
144// Lot Certificate Types
145// ============================================================================
146
147/// Certificate/document associated with a lot
148#[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/// Type of certificate
163#[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    /// Certificate of Analysis
169    Coa,
170    /// Certificate of Conformance
171    Coc,
172    /// Material Safety Data Sheet
173    Msds,
174    /// Safety Data Sheet
175    Sds,
176    /// Test Report
177    TestReport,
178    /// Inspection Report
179    InspectionReport,
180    /// Country of Origin
181    CountryOfOrigin,
182    /// Other
183    Other,
184}
185
186impl Default for CertificateType {
187    fn default() -> Self {
188        Self::Coa
189    }
190}
191
192// ============================================================================
193// Lot Location Types
194// ============================================================================
195
196/// Inventory of a lot at a specific location
197#[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// ============================================================================
206// Traceability Types
207// ============================================================================
208
209/// Result of a traceability query
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct TraceabilityResult {
212    pub lot: Lot,
213    /// Upstream trace - where did this lot come from
214    pub upstream: Vec<TraceNode>,
215    /// Downstream trace - where did this lot go
216    pub downstream: Vec<TraceNode>,
217}
218
219/// A node in the traceability chain
220#[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/// Type of node in traceability chain
233#[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// ============================================================================
263// Input/Output Types
264// ============================================================================
265
266/// Input for creating a lot
267#[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/// Input for updating a lot
307#[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/// Input for adjusting lot quantity
318#[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/// Input for consuming from a lot
344#[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/// Input for reserving from a lot
368#[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/// Input for transferring lot between locations
390#[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/// Input for splitting a lot
414#[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/// Input for merging lots
429#[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/// Filter for listing lots
437#[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/// Input for adding a certificate to a lot
454#[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
481// ============================================================================
482// Business Logic
483// ============================================================================
484
485impl Lot {
486    /// Check if lot has available quantity
487    #[must_use]
488    pub fn has_available(&self) -> bool {
489        self.quantity_available() > Decimal::ZERO
490    }
491
492    /// Get available quantity (not reserved or quarantined)
493    #[must_use]
494    pub fn quantity_available(&self) -> Decimal {
495        self.quantity_remaining - self.quantity_reserved - self.quantity_quarantined
496    }
497
498    /// Check if lot is expired
499    #[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    /// Check if lot is expiring soon (within days)
505    #[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    /// Check if lot can be consumed
516    #[must_use]
517    pub fn can_consume(&self, quantity: Decimal) -> bool {
518        self.status == LotStatus::Active && self.quantity_available() >= quantity
519    }
520
521    /// Check if lot can be reserved
522    #[must_use]
523    pub fn can_reserve(&self, quantity: Decimal) -> bool {
524        self.status == LotStatus::Active && self.quantity_available() >= quantity
525    }
526
527    /// Get days until expiration
528    #[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    /// Get shelf life percentage remaining
534    #[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    // ============================================================================
560    // Test Helpers
561    // ============================================================================
562
563    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    // ============================================================================
590    // Quantity / Availability
591    // ============================================================================
592
593    #[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    // ============================================================================
624    // can_consume / can_reserve guards
625    // ============================================================================
626
627    #[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    // ============================================================================
672    // Expiration
673    // ============================================================================
674
675    #[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)); // truncated by sub-day remainder
709    }
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    // ============================================================================
742    // Enum Display / FromStr round-trips and defaults
743    // ============================================================================
744
745    #[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}