Skip to main content

stateset_core/models/
serial.rs

1//! Serial Number Management domain models
2//!
3//! Models for individual unit tracking via serial numbers.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumString};
8use uuid::Uuid;
9
10// ============================================================================
11// Serial Number Types
12// ============================================================================
13
14/// A uniquely serialized unit of inventory
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct SerialNumber {
17    pub id: Uuid,
18    pub serial: String,
19    pub sku: String,
20    pub status: SerialStatus,
21    pub lot_id: Option<Uuid>,
22    pub lot_number: Option<String>,
23    pub current_location_id: Option<i32>,
24    pub current_owner_id: Option<Uuid>,
25    pub current_owner_type: Option<String>,
26    pub warranty_id: Option<Uuid>,
27    pub manufactured_at: Option<DateTime<Utc>>,
28    pub received_at: Option<DateTime<Utc>>,
29    pub sold_at: Option<DateTime<Utc>>,
30    pub activated_at: Option<DateTime<Utc>>,
31    pub last_service_at: Option<DateTime<Utc>>,
32    pub notes: Option<String>,
33    pub attributes: serde_json::Value,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38/// Status of a serial number
39#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize)]
40#[strum(serialize_all = "snake_case")]
41#[serde(rename_all = "snake_case")]
42#[non_exhaustive]
43pub enum SerialStatus {
44    /// In production/assembly
45    InProduction,
46    /// Available in inventory
47    Available,
48    /// Reserved for an order
49    Reserved,
50    /// Shipped to customer
51    Shipped,
52    /// Sold to customer
53    Sold,
54    /// Returned by customer
55    Returned,
56    /// Under repair/service
57    InService,
58    /// Under warranty claim
59    InWarranty,
60    /// Quarantined for quality
61    Quarantined,
62    /// Scrapped/destroyed
63    Scrapped,
64    /// Recalled
65    Recalled,
66    /// Lost/missing
67    Lost,
68    /// Transferred to another entity
69    Transferred,
70}
71
72impl Default for SerialStatus {
73    fn default() -> Self {
74        Self::Available
75    }
76}
77
78impl std::str::FromStr for SerialStatus {
79    type Err = String;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        match s.to_lowercase().as_str() {
83            "in_production" => Ok(Self::InProduction),
84            "available" => Ok(Self::Available),
85            "reserved" => Ok(Self::Reserved),
86            "shipped" => Ok(Self::Shipped),
87            "sold" => Ok(Self::Sold),
88            "returned" => Ok(Self::Returned),
89            "in_service" => Ok(Self::InService),
90            "in_warranty" => Ok(Self::InWarranty),
91            "quarantined" => Ok(Self::Quarantined),
92            "scrapped" => Ok(Self::Scrapped),
93            "recalled" => Ok(Self::Recalled),
94            "lost" => Ok(Self::Lost),
95            "transferred" => Ok(Self::Transferred),
96            _ => Err(format!("Unknown serial status: {s}")),
97        }
98    }
99}
100
101// ============================================================================
102// Serial History Types
103// ============================================================================
104
105/// History event for a serial number
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct SerialHistory {
108    pub id: Uuid,
109    pub serial_id: Uuid,
110    pub event_type: SerialEventType,
111    pub reference_type: Option<String>,
112    pub reference_id: Option<Uuid>,
113    pub from_status: SerialStatus,
114    pub to_status: SerialStatus,
115    pub from_location_id: Option<i32>,
116    pub to_location_id: Option<i32>,
117    pub from_owner_id: Option<Uuid>,
118    pub to_owner_id: Option<Uuid>,
119    pub performed_by: Option<String>,
120    pub notes: Option<String>,
121    pub created_at: DateTime<Utc>,
122}
123
124/// Type of serial number event
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
126#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
127#[serde(rename_all = "snake_case")]
128#[non_exhaustive]
129pub enum SerialEventType {
130    Created,
131    Received,
132    LocationChanged,
133    Reserved,
134    Released,
135    Picked,
136    Packed,
137    Shipped,
138    Delivered,
139    Sold,
140    Activated,
141    Returned,
142    Repaired,
143    Serviced,
144    WarrantyClaimed,
145    Quarantined,
146    QuarantineReleased,
147    Scrapped,
148    Recalled,
149    Lost,
150    Found,
151    Transferred,
152    StatusChanged,
153    AttributeUpdated,
154}
155
156impl Default for SerialEventType {
157    fn default() -> Self {
158        Self::Created
159    }
160}
161
162// ============================================================================
163// Serial Reservation Types
164// ============================================================================
165
166/// Reservation of a specific serial number
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct SerialReservation {
169    pub id: Uuid,
170    pub serial_id: Uuid,
171    pub reference_type: String,
172    pub reference_id: Uuid,
173    pub reserved_by: Option<String>,
174    pub reserved_at: DateTime<Utc>,
175    pub expires_at: Option<DateTime<Utc>>,
176    pub confirmed_at: Option<DateTime<Utc>>,
177    pub released_at: Option<DateTime<Utc>>,
178}
179
180// ============================================================================
181// Input/Output Types
182// ============================================================================
183
184/// Input for creating a serial number
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186pub struct CreateSerialNumber {
187    pub serial: Option<String>,
188    pub sku: String,
189    pub lot_id: Option<Uuid>,
190    pub lot_number: Option<String>,
191    pub location_id: Option<i32>,
192    pub manufactured_at: Option<DateTime<Utc>>,
193    pub notes: Option<String>,
194    pub attributes: Option<serde_json::Value>,
195}
196
197/// Input for creating multiple serial numbers in bulk
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199pub struct CreateSerialNumbersBulk {
200    pub sku: String,
201    pub quantity: i32,
202    pub prefix: Option<String>,
203    pub lot_id: Option<Uuid>,
204    pub lot_number: Option<String>,
205    pub location_id: Option<i32>,
206    pub manufactured_at: Option<DateTime<Utc>>,
207}
208
209/// Input for updating a serial number
210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211pub struct UpdateSerialNumber {
212    pub status: Option<SerialStatus>,
213    pub location_id: Option<i32>,
214    pub lot_id: Option<Uuid>,
215    pub warranty_id: Option<Uuid>,
216    pub notes: Option<String>,
217    pub attributes: Option<serde_json::Value>,
218}
219
220/// Input for changing serial status with tracking
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ChangeSerialStatus {
223    pub serial_id: Uuid,
224    pub new_status: SerialStatus,
225    pub reference_type: Option<String>,
226    pub reference_id: Option<Uuid>,
227    pub location_id: Option<i32>,
228    pub owner_id: Option<Uuid>,
229    pub owner_type: Option<String>,
230    pub performed_by: Option<String>,
231    pub notes: Option<String>,
232}
233
234impl Default for ChangeSerialStatus {
235    fn default() -> Self {
236        Self {
237            serial_id: Uuid::nil(),
238            new_status: SerialStatus::default(),
239            reference_type: None,
240            reference_id: None,
241            location_id: None,
242            owner_id: None,
243            owner_type: None,
244            performed_by: None,
245            notes: None,
246        }
247    }
248}
249
250/// Input for reserving a serial number
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ReserveSerialNumber {
253    pub serial_id: Uuid,
254    pub reference_type: String,
255    pub reference_id: Uuid,
256    pub reserved_by: Option<String>,
257    pub expires_in_seconds: Option<i64>,
258}
259
260impl Default for ReserveSerialNumber {
261    fn default() -> Self {
262        Self {
263            serial_id: Uuid::nil(),
264            reference_type: String::new(),
265            reference_id: Uuid::nil(),
266            reserved_by: None,
267            expires_in_seconds: None,
268        }
269    }
270}
271
272/// Input for transferring ownership of a serial number
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct TransferSerialOwnership {
275    pub serial_id: Uuid,
276    pub new_owner_id: Uuid,
277    pub new_owner_type: String,
278    pub reference_type: Option<String>,
279    pub reference_id: Option<Uuid>,
280    pub notes: Option<String>,
281}
282
283impl Default for TransferSerialOwnership {
284    fn default() -> Self {
285        Self {
286            serial_id: Uuid::nil(),
287            new_owner_id: Uuid::nil(),
288            new_owner_type: String::new(),
289            reference_type: None,
290            reference_id: None,
291            notes: None,
292        }
293    }
294}
295
296/// Input for moving a serial to a new location
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct MoveSerial {
299    pub serial_id: Uuid,
300    pub to_location_id: i32,
301    pub performed_by: Option<String>,
302    pub notes: Option<String>,
303}
304
305impl Default for MoveSerial {
306    fn default() -> Self {
307        Self { serial_id: Uuid::nil(), to_location_id: 0, performed_by: None, notes: None }
308    }
309}
310
311/// Filter for listing serial numbers
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313pub struct SerialFilter {
314    pub serial: Option<String>,
315    pub serial_prefix: Option<String>,
316    pub sku: Option<String>,
317    pub status: Option<SerialStatus>,
318    pub statuses: Option<Vec<SerialStatus>>,
319    pub lot_id: Option<Uuid>,
320    pub lot_number: Option<String>,
321    pub location_id: Option<i32>,
322    pub owner_id: Option<Uuid>,
323    pub owner_type: Option<String>,
324    pub warranty_id: Option<Uuid>,
325    pub has_warranty: Option<bool>,
326    pub manufactured_after: Option<DateTime<Utc>>,
327    pub manufactured_before: Option<DateTime<Utc>>,
328    pub sold_after: Option<DateTime<Utc>>,
329    pub sold_before: Option<DateTime<Utc>>,
330    pub limit: Option<u32>,
331    pub offset: Option<u32>,
332}
333
334/// Filter for serial history
335#[derive(Debug, Clone, Default, Serialize, Deserialize)]
336pub struct SerialHistoryFilter {
337    pub serial_id: Option<Uuid>,
338    pub event_type: Option<SerialEventType>,
339    pub reference_type: Option<String>,
340    pub from_date: Option<DateTime<Utc>>,
341    pub to_date: Option<DateTime<Utc>>,
342    pub limit: Option<u32>,
343    pub offset: Option<u32>,
344}
345
346/// Result of scanning/looking up a serial number
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct SerialLookupResult {
349    pub serial: SerialNumber,
350    pub product_name: Option<String>,
351    pub lot: Option<super::lot::Lot>,
352    pub warranty_status: Option<WarrantyLookupStatus>,
353    pub recent_history: Vec<SerialHistory>,
354}
355
356/// Warranty status for lookup
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct WarrantyLookupStatus {
359    pub warranty_id: Uuid,
360    pub is_active: bool,
361    pub expires_at: Option<DateTime<Utc>>,
362    pub coverage_type: Option<String>,
363}
364
365/// Serial number validation result
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct SerialValidation {
368    pub is_valid: bool,
369    pub serial_id: Option<Uuid>,
370    pub status: Option<SerialStatus>,
371    pub sku: Option<String>,
372    pub error_message: Option<String>,
373}
374
375// ============================================================================
376// Type Aliases for API compatibility
377// ============================================================================
378
379/// Alias for `CreateSerialNumber` for API convenience
380pub type CreateSerial = CreateSerialNumber;
381
382// ============================================================================
383// Business Logic
384// ============================================================================
385
386impl SerialNumber {
387    /// Check if serial is available for sale
388    #[must_use]
389    pub fn is_available(&self) -> bool {
390        self.status == SerialStatus::Available
391    }
392
393    /// Check if serial is in customer's possession
394    #[must_use]
395    pub const fn is_with_customer(&self) -> bool {
396        matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped)
397    }
398
399    /// Check if serial can be reserved
400    #[must_use]
401    pub fn can_reserve(&self) -> bool {
402        self.status == SerialStatus::Available
403    }
404
405    /// Check if serial can be shipped
406    #[must_use]
407    pub const fn can_ship(&self) -> bool {
408        matches!(self.status, SerialStatus::Available | SerialStatus::Reserved)
409    }
410
411    /// Check if serial can be returned
412    #[must_use]
413    pub const fn can_return(&self) -> bool {
414        matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped)
415    }
416
417    /// Check if serial can be scrapped
418    #[must_use]
419    pub const fn can_scrap(&self) -> bool {
420        !matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped | SerialStatus::Scrapped)
421    }
422
423    /// Check if serial has been activated
424    #[must_use]
425    pub const fn is_activated(&self) -> bool {
426        self.activated_at.is_some()
427    }
428
429    /// Get age in days since manufacture
430    #[must_use]
431    pub fn age_days(&self) -> Option<i64> {
432        self.manufactured_at.map(|mfg| (Utc::now() - mfg).num_days())
433    }
434
435    /// Get days since sold
436    #[must_use]
437    pub fn days_since_sold(&self) -> Option<i64> {
438        self.sold_at.map(|sold| (Utc::now() - sold).num_days())
439    }
440}
441
442impl SerialReservation {
443    /// Check if reservation is active
444    #[must_use]
445    pub fn is_active(&self) -> bool {
446        self.released_at.is_none() && self.confirmed_at.is_none() && !self.is_expired()
447    }
448
449    /// Check if reservation has expired
450    #[must_use]
451    pub fn is_expired(&self) -> bool {
452        if let Some(expires) = self.expires_at {
453            Utc::now() > expires && self.confirmed_at.is_none()
454        } else {
455            false
456        }
457    }
458
459    /// Check if reservation has been confirmed
460    #[must_use]
461    pub const fn is_confirmed(&self) -> bool {
462        self.confirmed_at.is_some()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use std::str::FromStr;
470
471    // ============================================================================
472    // Test Helpers
473    // ============================================================================
474
475    const ALL_STATUSES: [SerialStatus; 13] = [
476        SerialStatus::InProduction,
477        SerialStatus::Available,
478        SerialStatus::Reserved,
479        SerialStatus::Shipped,
480        SerialStatus::Sold,
481        SerialStatus::Returned,
482        SerialStatus::InService,
483        SerialStatus::InWarranty,
484        SerialStatus::Quarantined,
485        SerialStatus::Scrapped,
486        SerialStatus::Recalled,
487        SerialStatus::Lost,
488        SerialStatus::Transferred,
489    ];
490
491    fn create_test_serial(status: SerialStatus) -> SerialNumber {
492        let now = Utc::now();
493        SerialNumber {
494            id: Uuid::new_v4(),
495            serial: "SN-0001".to_string(),
496            sku: "SKU-001".to_string(),
497            status,
498            lot_id: None,
499            lot_number: None,
500            current_location_id: None,
501            current_owner_id: None,
502            current_owner_type: None,
503            warranty_id: None,
504            manufactured_at: None,
505            received_at: None,
506            sold_at: None,
507            activated_at: None,
508            last_service_at: None,
509            notes: None,
510            attributes: serde_json::json!({}),
511            created_at: now,
512            updated_at: now,
513        }
514    }
515
516    fn create_test_reservation() -> SerialReservation {
517        SerialReservation {
518            id: Uuid::new_v4(),
519            serial_id: Uuid::new_v4(),
520            reference_type: "order".to_string(),
521            reference_id: Uuid::new_v4(),
522            reserved_by: None,
523            reserved_at: Utc::now(),
524            expires_at: None,
525            confirmed_at: None,
526            released_at: None,
527        }
528    }
529
530    // ============================================================================
531    // Status guards
532    // ============================================================================
533
534    #[test]
535    fn is_available_only_when_available() {
536        for status in ALL_STATUSES {
537            let serial = create_test_serial(status);
538            assert_eq!(serial.is_available(), status == SerialStatus::Available);
539        }
540    }
541
542    #[test]
543    fn can_reserve_only_from_available() {
544        for status in ALL_STATUSES {
545            let serial = create_test_serial(status);
546            assert_eq!(serial.can_reserve(), status == SerialStatus::Available, "status {status}");
547        }
548    }
549
550    #[test]
551    fn can_ship_from_available_or_reserved_only() {
552        for status in ALL_STATUSES {
553            let serial = create_test_serial(status);
554            let expected = matches!(status, SerialStatus::Available | SerialStatus::Reserved);
555            assert_eq!(serial.can_ship(), expected, "status {status}");
556        }
557    }
558
559    #[test]
560    fn can_return_only_from_sold_or_shipped() {
561        for status in ALL_STATUSES {
562            let serial = create_test_serial(status);
563            let expected = matches!(status, SerialStatus::Sold | SerialStatus::Shipped);
564            assert_eq!(serial.can_return(), expected, "status {status}");
565        }
566    }
567
568    #[test]
569    fn can_scrap_excludes_sold_shipped_scrapped() {
570        for status in ALL_STATUSES {
571            let serial = create_test_serial(status);
572            let expected = !matches!(
573                status,
574                SerialStatus::Sold | SerialStatus::Shipped | SerialStatus::Scrapped
575            );
576            assert_eq!(serial.can_scrap(), expected, "status {status}");
577        }
578    }
579
580    #[test]
581    fn is_with_customer_for_sold_and_shipped() {
582        assert!(create_test_serial(SerialStatus::Sold).is_with_customer());
583        assert!(create_test_serial(SerialStatus::Shipped).is_with_customer());
584        assert!(!create_test_serial(SerialStatus::Returned).is_with_customer());
585        assert!(!create_test_serial(SerialStatus::Available).is_with_customer());
586    }
587
588    // ============================================================================
589    // Activation and age
590    // ============================================================================
591
592    #[test]
593    fn is_activated_tracks_activated_at() {
594        let mut serial = create_test_serial(SerialStatus::Sold);
595        assert!(!serial.is_activated());
596        serial.activated_at = Some(Utc::now());
597        assert!(serial.is_activated());
598    }
599
600    #[test]
601    fn age_days_none_without_manufactured_at() {
602        let serial = create_test_serial(SerialStatus::Available);
603        assert_eq!(serial.age_days(), None);
604        assert_eq!(serial.days_since_sold(), None);
605    }
606
607    #[test]
608    fn age_days_and_days_since_sold_computed() {
609        let mut serial = create_test_serial(SerialStatus::Sold);
610        serial.manufactured_at = Some(Utc::now() - chrono::Duration::days(30));
611        serial.sold_at = Some(Utc::now() - chrono::Duration::days(10));
612        assert_eq!(serial.age_days(), Some(30));
613        assert_eq!(serial.days_since_sold(), Some(10));
614    }
615
616    // ============================================================================
617    // SerialReservation lifecycle
618    // ============================================================================
619
620    #[test]
621    fn fresh_reservation_is_active() {
622        let res = create_test_reservation();
623        assert!(res.is_active());
624        assert!(!res.is_expired());
625        assert!(!res.is_confirmed());
626    }
627
628    #[test]
629    fn released_reservation_is_not_active() {
630        let mut res = create_test_reservation();
631        res.released_at = Some(Utc::now());
632        assert!(!res.is_active());
633    }
634
635    #[test]
636    fn confirmed_reservation_is_not_active_but_confirmed() {
637        let mut res = create_test_reservation();
638        res.confirmed_at = Some(Utc::now());
639        assert!(!res.is_active());
640        assert!(res.is_confirmed());
641    }
642
643    #[test]
644    fn expired_reservation_is_expired_and_inactive() {
645        let mut res = create_test_reservation();
646        res.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
647        assert!(res.is_expired());
648        assert!(!res.is_active());
649    }
650
651    #[test]
652    fn confirmation_suppresses_expiry() {
653        let mut res = create_test_reservation();
654        res.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
655        res.confirmed_at = Some(Utc::now());
656        assert!(!res.is_expired());
657    }
658
659    #[test]
660    fn future_expiry_reservation_still_active() {
661        let mut res = create_test_reservation();
662        res.expires_at = Some(Utc::now() + chrono::Duration::hours(1));
663        assert!(!res.is_expired());
664        assert!(res.is_active());
665    }
666
667    // ============================================================================
668    // Enum Display / FromStr round-trips and defaults
669    // ============================================================================
670
671    #[test]
672    fn serial_status_display_from_str_round_trip() {
673        for status in ALL_STATUSES {
674            let parsed = SerialStatus::from_str(&status.to_string()).expect("round trip");
675            assert_eq!(parsed, status);
676        }
677    }
678
679    #[test]
680    fn serial_status_from_str_case_insensitive_and_unknown() {
681        assert_eq!(SerialStatus::from_str("IN_PRODUCTION"), Ok(SerialStatus::InProduction));
682        assert!(SerialStatus::from_str("nonsense").is_err());
683    }
684
685    #[test]
686    fn serial_event_type_round_trip() {
687        for t in [
688            SerialEventType::Created,
689            SerialEventType::LocationChanged,
690            SerialEventType::WarrantyClaimed,
691            SerialEventType::QuarantineReleased,
692            SerialEventType::AttributeUpdated,
693        ] {
694            assert_eq!(SerialEventType::from_str(&t.to_string()), Ok(t));
695        }
696        assert!(SerialEventType::from_str("nope").is_err());
697    }
698
699    #[test]
700    fn defaults_are_sane() {
701        assert_eq!(SerialStatus::default(), SerialStatus::Available);
702        assert_eq!(SerialEventType::default(), SerialEventType::Created);
703        let change = ChangeSerialStatus::default();
704        assert_eq!(change.serial_id, Uuid::nil());
705        assert_eq!(change.new_status, SerialStatus::Available);
706        let reserve = ReserveSerialNumber::default();
707        assert_eq!(reserve.serial_id, Uuid::nil());
708        assert!(reserve.reference_type.is_empty());
709    }
710}