1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumString};
8use uuid::Uuid;
9
10#[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#[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 InProduction,
46 Available,
48 Reserved,
50 Shipped,
52 Sold,
54 Returned,
56 InService,
58 InWarranty,
60 Quarantined,
62 Scrapped,
64 Recalled,
66 Lost,
68 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
375pub type CreateSerial = CreateSerialNumber;
381
382impl SerialNumber {
387 #[must_use]
389 pub fn is_available(&self) -> bool {
390 self.status == SerialStatus::Available
391 }
392
393 #[must_use]
395 pub const fn is_with_customer(&self) -> bool {
396 matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped)
397 }
398
399 #[must_use]
401 pub fn can_reserve(&self) -> bool {
402 self.status == SerialStatus::Available
403 }
404
405 #[must_use]
407 pub const fn can_ship(&self) -> bool {
408 matches!(self.status, SerialStatus::Available | SerialStatus::Reserved)
409 }
410
411 #[must_use]
413 pub const fn can_return(&self) -> bool {
414 matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped)
415 }
416
417 #[must_use]
419 pub const fn can_scrap(&self) -> bool {
420 !matches!(self.status, SerialStatus::Sold | SerialStatus::Shipped | SerialStatus::Scrapped)
421 }
422
423 #[must_use]
425 pub const fn is_activated(&self) -> bool {
426 self.activated_at.is_some()
427 }
428
429 #[must_use]
431 pub fn age_days(&self) -> Option<i64> {
432 self.manufactured_at.map(|mfg| (Utc::now() - mfg).num_days())
433 }
434
435 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}