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 Inspection {
18 pub id: Uuid,
19 pub inspection_number: String,
20 pub inspection_type: InspectionType,
21 pub reference_type: String,
22 pub reference_id: Uuid,
23 pub status: InspectionStatus,
24 pub inspector_id: Option<String>,
25 pub scheduled_at: Option<DateTime<Utc>>,
26 pub started_at: Option<DateTime<Utc>>,
27 pub completed_at: Option<DateTime<Utc>>,
28 pub notes: Option<String>,
29 pub items: Vec<InspectionItem>,
30 pub created_at: DateTime<Utc>,
31 pub updated_at: DateTime<Utc>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
36#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum InspectionType {
40 Incoming,
42 Receiving,
44 InProcess,
46 Final,
48 Random,
50 Return,
52}
53
54impl Default for InspectionType {
55 fn default() -> Self {
56 Self::Incoming
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
62#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
63#[serde(rename_all = "snake_case")]
64#[non_exhaustive]
65pub enum InspectionStatus {
66 Pending,
68 Scheduled,
70 InProgress,
72 Passed,
74 Failed,
76 PartialPass,
78 OnHold,
80 Cancelled,
82}
83
84impl Default for InspectionStatus {
85 fn default() -> Self {
86 Self::Pending
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct InspectionItem {
93 pub id: Uuid,
94 pub inspection_id: Uuid,
95 pub sku: String,
96 pub lot_number: Option<String>,
97 pub serial_number: Option<String>,
98 pub quantity_inspected: Decimal,
99 pub quantity_passed: Decimal,
100 pub quantity_failed: Decimal,
101 pub defect_codes: Vec<String>,
102 pub measurements: Option<serde_json::Value>,
103 pub result: InspectionResult,
104 pub notes: Option<String>,
105 pub created_at: DateTime<Utc>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
110#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
111#[serde(rename_all = "snake_case")]
112#[non_exhaustive]
113pub enum InspectionResult {
114 Pending,
116 Pass,
118 Fail,
120 ConditionalPass,
122}
123
124impl Default for InspectionResult {
125 fn default() -> Self {
126 Self::Pending
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct NonConformance {
137 pub id: Uuid,
138 pub ncr_number: String,
139 pub inspection_id: Option<Uuid>,
140 pub source: NonConformanceSource,
141 pub severity: Severity,
142 pub status: NcrStatus,
143 pub sku: String,
144 pub lot_number: Option<String>,
145 pub serial_number: Option<String>,
146 pub quantity_affected: Decimal,
147 pub description: String,
148 pub root_cause: Option<String>,
149 pub corrective_action: Option<String>,
150 pub preventive_action: Option<String>,
151 pub disposition: Option<Disposition>,
152 pub disposition_quantity: Option<Decimal>,
153 pub assigned_to: Option<String>,
154 pub created_at: DateTime<Utc>,
155 pub updated_at: DateTime<Utc>,
156 pub closed_at: Option<DateTime<Utc>>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
161#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
162#[serde(rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum NonConformanceSource {
165 Inspection,
167 CustomerComplaint,
169 InternalAudit,
171 SupplierIssue,
173 ProductionDefect,
175 ShippingDamage,
177}
178
179impl Default for NonConformanceSource {
180 fn default() -> Self {
181 Self::Inspection
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
187#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
188#[serde(rename_all = "snake_case")]
189#[non_exhaustive]
190pub enum Severity {
191 Critical,
193 Major,
195 Minor,
197 Observation,
199}
200
201impl Default for Severity {
202 fn default() -> Self {
203 Self::Minor
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize)]
209#[strum(serialize_all = "snake_case")]
210#[serde(rename_all = "snake_case")]
211#[non_exhaustive]
212pub enum NcrStatus {
213 Open,
215 UnderReview,
217 PendingDisposition,
219 CorrectiveAction,
221 PreventiveAction,
223 Verification,
225 Closed,
227 Cancelled,
229}
230
231impl Default for NcrStatus {
232 fn default() -> Self {
233 Self::Open
234 }
235}
236
237impl std::str::FromStr for NcrStatus {
238 type Err = String;
239
240 fn from_str(s: &str) -> Result<Self, Self::Err> {
241 match s.to_lowercase().as_str() {
242 "open" => Ok(Self::Open),
243 "under_review" => Ok(Self::UnderReview),
244 "pending_disposition" => Ok(Self::PendingDisposition),
245 "corrective_action" => Ok(Self::CorrectiveAction),
246 "preventive_action" => Ok(Self::PreventiveAction),
247 "verification" => Ok(Self::Verification),
248 "closed" => Ok(Self::Closed),
249 "cancelled" => Ok(Self::Cancelled),
250 _ => Err(format!("Unknown NCR status: {s}")),
251 }
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
257#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
258#[serde(rename_all = "snake_case")]
259#[non_exhaustive]
260pub enum Disposition {
261 UseAsIs,
263 Rework,
265 Repair,
267 Scrap,
269 ReturnToVendor,
271 Downgrade,
273 SortAndScreen,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct QualityHold {
284 pub id: Uuid,
285 pub sku: String,
286 pub lot_number: Option<String>,
287 pub serial_number: Option<String>,
288 pub location_id: Option<i32>,
289 pub quantity_held: Decimal,
290 pub reason: String,
291 pub hold_type: HoldType,
292 pub ncr_id: Option<Uuid>,
293 pub inspection_id: Option<Uuid>,
294 pub placed_by: String,
295 pub released_by: Option<String>,
296 pub release_notes: Option<String>,
297 pub placed_at: DateTime<Utc>,
298 pub released_at: Option<DateTime<Utc>>,
299 pub expires_at: Option<DateTime<Utc>>,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
304#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
305#[serde(rename_all = "snake_case")]
306#[non_exhaustive]
307pub enum HoldType {
308 QualityInspection,
310 CustomerReturn,
312 Recall,
314 Damaged,
316 Expired,
318 Quarantine,
320 RegulatoryHold,
322 InvestigationHold,
324}
325
326impl Default for HoldType {
327 fn default() -> Self {
328 Self::QualityInspection
329 }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct DefectCode {
339 pub id: Uuid,
340 pub code: String,
341 pub name: String,
342 pub description: Option<String>,
343 pub category: String,
344 pub severity: Severity,
345 pub is_active: bool,
346 pub created_at: DateTime<Utc>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
355pub struct CreateInspection {
356 pub inspection_type: InspectionType,
357 pub reference_type: String,
358 pub reference_id: Uuid,
359 pub inspector_id: Option<String>,
360 pub scheduled_at: Option<DateTime<Utc>>,
361 pub notes: Option<String>,
362 pub items: Vec<CreateInspectionItem>,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct CreateInspectionItem {
368 pub sku: String,
369 pub lot_number: Option<String>,
370 pub serial_number: Option<String>,
371 pub quantity_to_inspect: Decimal,
372}
373
374impl Default for CreateInspectionItem {
375 fn default() -> Self {
376 Self {
377 sku: String::new(),
378 lot_number: None,
379 serial_number: None,
380 quantity_to_inspect: Decimal::ZERO,
381 }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
387pub struct UpdateInspection {
388 pub status: Option<InspectionStatus>,
389 pub inspector_id: Option<String>,
390 pub scheduled_at: Option<DateTime<Utc>>,
391 pub notes: Option<String>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct RecordInspectionResult {
397 pub item_id: Uuid,
398 pub quantity_passed: Decimal,
399 pub quantity_failed: Decimal,
400 pub result: InspectionResult,
401 pub defect_codes: Vec<String>,
402 pub measurements: Option<serde_json::Value>,
403 pub notes: Option<String>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
408pub struct InspectionFilter {
409 pub inspection_type: Option<InspectionType>,
410 pub status: Option<InspectionStatus>,
411 pub reference_type: Option<String>,
412 pub reference_id: Option<Uuid>,
413 pub inspector_id: Option<String>,
414 pub from_date: Option<DateTime<Utc>>,
415 pub to_date: Option<DateTime<Utc>>,
416 pub limit: Option<u32>,
417 pub offset: Option<u32>,
418 pub after_cursor: Option<(String, String)>,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct CreateNonConformance {
426 pub inspection_id: Option<Uuid>,
427 pub source: NonConformanceSource,
428 pub severity: Severity,
429 pub sku: String,
430 pub lot_number: Option<String>,
431 pub serial_number: Option<String>,
432 pub quantity_affected: Decimal,
433 pub description: String,
434 pub assigned_to: Option<String>,
435}
436
437impl Default for CreateNonConformance {
438 fn default() -> Self {
439 Self {
440 inspection_id: None,
441 source: NonConformanceSource::default(),
442 severity: Severity::default(),
443 sku: String::new(),
444 lot_number: None,
445 serial_number: None,
446 quantity_affected: Decimal::ZERO,
447 description: String::new(),
448 assigned_to: None,
449 }
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
455pub struct UpdateNonConformance {
456 pub status: Option<NcrStatus>,
457 pub severity: Option<Severity>,
458 pub root_cause: Option<String>,
459 pub corrective_action: Option<String>,
460 pub preventive_action: Option<String>,
461 pub disposition: Option<Disposition>,
462 pub disposition_quantity: Option<Decimal>,
463 pub assigned_to: Option<String>,
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
468pub struct NonConformanceFilter {
469 pub source: Option<NonConformanceSource>,
470 pub severity: Option<Severity>,
471 pub status: Option<NcrStatus>,
472 pub sku: Option<String>,
473 pub lot_number: Option<String>,
474 pub assigned_to: Option<String>,
475 pub from_date: Option<DateTime<Utc>>,
476 pub to_date: Option<DateTime<Utc>>,
477 pub limit: Option<u32>,
478 pub offset: Option<u32>,
479 pub after_cursor: Option<(String, String)>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct CreateQualityHold {
487 pub sku: String,
488 pub lot_number: Option<String>,
489 pub serial_number: Option<String>,
490 pub location_id: Option<i32>,
491 pub quantity: Decimal,
492 pub reason: String,
493 pub hold_type: HoldType,
494 pub ncr_id: Option<Uuid>,
495 pub inspection_id: Option<Uuid>,
496 pub placed_by: String,
497 pub expires_at: Option<DateTime<Utc>>,
498}
499
500impl Default for CreateQualityHold {
501 fn default() -> Self {
502 Self {
503 sku: String::new(),
504 lot_number: None,
505 serial_number: None,
506 location_id: None,
507 quantity: Decimal::ZERO,
508 reason: String::new(),
509 hold_type: HoldType::default(),
510 ncr_id: None,
511 inspection_id: None,
512 placed_by: String::new(),
513 expires_at: None,
514 }
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct ReleaseQualityHold {
521 pub released_by: String,
522 pub release_notes: Option<String>,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
527pub struct QualityHoldFilter {
528 pub sku: Option<String>,
529 pub lot_number: Option<String>,
530 pub hold_type: Option<HoldType>,
531 pub location_id: Option<i32>,
532 pub active_only: Option<bool>,
533 pub limit: Option<u32>,
534 pub offset: Option<u32>,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
539pub struct CreateDefectCode {
540 pub code: String,
541 pub name: String,
542 pub description: Option<String>,
543 pub category: String,
544 pub severity: Severity,
545}
546
547pub type CreateNcr = CreateNonConformance;
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct CompleteInspection {
557 pub quantity_passed: Decimal,
558 pub quantity_failed: Decimal,
559 pub inspector_id: Option<String>,
560 pub notes: Option<String>,
561}
562
563impl Default for CompleteInspection {
564 fn default() -> Self {
565 Self {
566 quantity_passed: Decimal::ZERO,
567 quantity_failed: Decimal::ZERO,
568 inspector_id: None,
569 notes: None,
570 }
571 }
572}
573
574impl Inspection {
579 #[must_use]
581 pub const fn can_start(&self) -> bool {
582 matches!(self.status, InspectionStatus::Pending | InspectionStatus::Scheduled)
583 }
584
585 #[must_use]
587 pub const fn can_complete(&self) -> bool {
588 matches!(self.status, InspectionStatus::InProgress)
589 }
590
591 #[must_use]
593 pub fn all_items_inspected(&self) -> bool {
594 self.items.iter().all(|item| item.result != InspectionResult::Pending)
595 }
596
597 #[must_use]
599 pub fn pass_rate(&self) -> Option<Decimal> {
600 let total_inspected: Decimal = self.items.iter().map(|i| i.quantity_inspected).sum();
601 if total_inspected > Decimal::ZERO {
602 let total_passed: Decimal = self.items.iter().map(|i| i.quantity_passed).sum();
603 Some((total_passed / total_inspected) * Decimal::from(100))
604 } else {
605 None
606 }
607 }
608
609 #[must_use]
611 pub fn calculate_overall_result(&self) -> InspectionStatus {
612 if self.items.is_empty() || self.items.iter().any(|i| i.result == InspectionResult::Pending)
613 {
614 return InspectionStatus::InProgress;
615 }
616
617 let all_passed = self.items.iter().all(|i| i.result == InspectionResult::Pass);
618 let any_passed = self.items.iter().any(|i| {
619 i.result == InspectionResult::Pass || i.result == InspectionResult::ConditionalPass
620 });
621
622 if all_passed {
623 InspectionStatus::Passed
624 } else if any_passed {
625 InspectionStatus::PartialPass
626 } else {
627 InspectionStatus::Failed
628 }
629 }
630}
631
632impl NonConformance {
633 #[must_use]
635 pub const fn can_close(&self) -> bool {
636 matches!(
637 self.status,
638 NcrStatus::Verification | NcrStatus::CorrectiveAction | NcrStatus::PreventiveAction
639 ) && self.disposition.is_some()
640 }
641
642 #[must_use]
644 pub const fn requires_immediate_action(&self) -> bool {
645 matches!(self.severity, Severity::Critical)
646 }
647
648 #[must_use]
650 pub const fn has_disposition(&self) -> bool {
651 self.disposition.is_some()
652 }
653}
654
655impl QualityHold {
656 #[must_use]
658 pub const fn is_active(&self) -> bool {
659 self.released_at.is_none()
660 }
661
662 #[must_use]
664 pub fn is_expired(&self) -> bool {
665 if let Some(expires_at) = self.expires_at {
666 Utc::now() > expires_at && self.released_at.is_none()
667 } else {
668 false
669 }
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use rust_decimal_macros::dec;
677 use std::str::FromStr;
678
679 fn create_test_inspection(status: InspectionStatus) -> Inspection {
684 let now = Utc::now();
685 Inspection {
686 id: Uuid::new_v4(),
687 inspection_number: "INSP-001".to_string(),
688 inspection_type: InspectionType::Receiving,
689 reference_type: "receipt".to_string(),
690 reference_id: Uuid::new_v4(),
691 status,
692 inspector_id: None,
693 scheduled_at: None,
694 started_at: None,
695 completed_at: None,
696 notes: None,
697 items: Vec::new(),
698 created_at: now,
699 updated_at: now,
700 }
701 }
702
703 fn create_test_item(
704 result: InspectionResult,
705 inspected: Decimal,
706 passed: Decimal,
707 ) -> InspectionItem {
708 InspectionItem {
709 id: Uuid::new_v4(),
710 inspection_id: Uuid::new_v4(),
711 sku: "SKU-001".to_string(),
712 lot_number: None,
713 serial_number: None,
714 quantity_inspected: inspected,
715 quantity_passed: passed,
716 quantity_failed: inspected - passed,
717 defect_codes: Vec::new(),
718 measurements: None,
719 result,
720 notes: None,
721 created_at: Utc::now(),
722 }
723 }
724
725 fn create_test_ncr(status: NcrStatus, disposition: Option<Disposition>) -> NonConformance {
726 let now = Utc::now();
727 NonConformance {
728 id: Uuid::new_v4(),
729 ncr_number: "NCR-001".to_string(),
730 inspection_id: None,
731 source: NonConformanceSource::Inspection,
732 severity: Severity::Minor,
733 status,
734 sku: "SKU-001".to_string(),
735 lot_number: None,
736 serial_number: None,
737 quantity_affected: dec!(5),
738 description: "Defect".to_string(),
739 root_cause: None,
740 corrective_action: None,
741 preventive_action: None,
742 disposition,
743 disposition_quantity: None,
744 assigned_to: None,
745 created_at: now,
746 updated_at: now,
747 closed_at: None,
748 }
749 }
750
751 fn create_test_hold() -> QualityHold {
752 QualityHold {
753 id: Uuid::new_v4(),
754 sku: "SKU-001".to_string(),
755 lot_number: None,
756 serial_number: None,
757 location_id: None,
758 quantity_held: dec!(10),
759 reason: "inspection pending".to_string(),
760 hold_type: HoldType::QualityInspection,
761 ncr_id: None,
762 inspection_id: None,
763 placed_by: "qa".to_string(),
764 released_by: None,
765 release_notes: None,
766 placed_at: Utc::now(),
767 released_at: None,
768 expires_at: None,
769 }
770 }
771
772 #[test]
777 fn can_start_from_pending_and_scheduled_only() {
778 for status in [
779 InspectionStatus::Pending,
780 InspectionStatus::Scheduled,
781 InspectionStatus::InProgress,
782 InspectionStatus::Passed,
783 InspectionStatus::Failed,
784 InspectionStatus::PartialPass,
785 InspectionStatus::OnHold,
786 InspectionStatus::Cancelled,
787 ] {
788 let insp = create_test_inspection(status);
789 let expected =
790 matches!(status, InspectionStatus::Pending | InspectionStatus::Scheduled);
791 assert_eq!(insp.can_start(), expected, "status {status}");
792 }
793 }
794
795 #[test]
796 fn can_complete_only_from_in_progress() {
797 assert!(create_test_inspection(InspectionStatus::InProgress).can_complete());
798 assert!(!create_test_inspection(InspectionStatus::Pending).can_complete());
799 assert!(!create_test_inspection(InspectionStatus::Passed).can_complete());
800 assert!(!create_test_inspection(InspectionStatus::Cancelled).can_complete());
801 }
802
803 #[test]
804 fn all_items_inspected_vacuously_true_when_empty() {
805 assert!(create_test_inspection(InspectionStatus::InProgress).all_items_inspected());
806 }
807
808 #[test]
809 fn all_items_inspected_false_with_pending_item() {
810 let mut insp = create_test_inspection(InspectionStatus::InProgress);
811 insp.items.push(create_test_item(InspectionResult::Pass, dec!(10), dec!(10)));
812 insp.items.push(create_test_item(InspectionResult::Pending, dec!(5), dec!(0)));
813 assert!(!insp.all_items_inspected());
814 insp.items.pop();
815 assert!(insp.all_items_inspected());
816 }
817
818 #[test]
823 fn pass_rate_none_when_nothing_inspected() {
824 let insp = create_test_inspection(InspectionStatus::InProgress);
825 assert_eq!(insp.pass_rate(), None);
826 let mut zero = create_test_inspection(InspectionStatus::InProgress);
827 zero.items.push(create_test_item(InspectionResult::Pending, Decimal::ZERO, Decimal::ZERO));
828 assert_eq!(zero.pass_rate(), None);
829 }
830
831 #[test]
832 fn pass_rate_computes_percentage() {
833 let mut insp = create_test_inspection(InspectionStatus::InProgress);
834 insp.items.push(create_test_item(InspectionResult::Pass, dec!(60), dec!(60)));
835 insp.items.push(create_test_item(InspectionResult::Fail, dec!(40), dec!(15)));
836 assert_eq!(insp.pass_rate(), Some(dec!(75)));
837 }
838
839 #[test]
840 fn pass_rate_zero_when_all_failed() {
841 let mut insp = create_test_inspection(InspectionStatus::InProgress);
842 insp.items.push(create_test_item(InspectionResult::Fail, dec!(10), Decimal::ZERO));
843 assert_eq!(insp.pass_rate(), Some(Decimal::ZERO));
844 }
845
846 #[test]
851 fn overall_result_in_progress_when_empty_or_pending() {
852 let insp = create_test_inspection(InspectionStatus::InProgress);
853 assert_eq!(insp.calculate_overall_result(), InspectionStatus::InProgress);
854 let mut pending = create_test_inspection(InspectionStatus::InProgress);
855 pending.items.push(create_test_item(InspectionResult::Pending, dec!(1), Decimal::ZERO));
856 assert_eq!(pending.calculate_overall_result(), InspectionStatus::InProgress);
857 }
858
859 #[test]
860 fn overall_result_passed_when_all_pass() {
861 let mut insp = create_test_inspection(InspectionStatus::InProgress);
862 insp.items.push(create_test_item(InspectionResult::Pass, dec!(5), dec!(5)));
863 insp.items.push(create_test_item(InspectionResult::Pass, dec!(3), dec!(3)));
864 assert_eq!(insp.calculate_overall_result(), InspectionStatus::Passed);
865 }
866
867 #[test]
868 fn overall_result_partial_pass_when_mixed() {
869 let mut insp = create_test_inspection(InspectionStatus::InProgress);
870 insp.items.push(create_test_item(InspectionResult::Pass, dec!(5), dec!(5)));
871 insp.items.push(create_test_item(InspectionResult::Fail, dec!(5), Decimal::ZERO));
872 assert_eq!(insp.calculate_overall_result(), InspectionStatus::PartialPass);
873 }
874
875 #[test]
876 fn overall_result_conditional_pass_counts_as_partial() {
877 let mut insp = create_test_inspection(InspectionStatus::InProgress);
878 insp.items.push(create_test_item(InspectionResult::ConditionalPass, dec!(5), dec!(5)));
879 assert_eq!(insp.calculate_overall_result(), InspectionStatus::PartialPass);
880 }
881
882 #[test]
883 fn overall_result_failed_when_all_fail() {
884 let mut insp = create_test_inspection(InspectionStatus::InProgress);
885 insp.items.push(create_test_item(InspectionResult::Fail, dec!(5), Decimal::ZERO));
886 assert_eq!(insp.calculate_overall_result(), InspectionStatus::Failed);
887 }
888
889 #[test]
894 fn ncr_can_close_requires_late_status_and_disposition() {
895 for status in
896 [NcrStatus::Verification, NcrStatus::CorrectiveAction, NcrStatus::PreventiveAction]
897 {
898 assert!(create_test_ncr(status, Some(Disposition::Rework)).can_close());
899 assert!(!create_test_ncr(status, None).can_close(), "no disposition, {status}");
900 }
901 }
902
903 #[test]
904 fn ncr_cannot_close_from_early_or_terminal_statuses() {
905 for status in [
906 NcrStatus::Open,
907 NcrStatus::UnderReview,
908 NcrStatus::PendingDisposition,
909 NcrStatus::Closed,
910 NcrStatus::Cancelled,
911 ] {
912 assert!(!create_test_ncr(status, Some(Disposition::Scrap)).can_close(), "{status}");
913 }
914 }
915
916 #[test]
917 fn ncr_requires_immediate_action_only_for_critical() {
918 let mut ncr = create_test_ncr(NcrStatus::Open, None);
919 assert!(!ncr.requires_immediate_action());
920 ncr.severity = Severity::Critical;
921 assert!(ncr.requires_immediate_action());
922 ncr.severity = Severity::Major;
923 assert!(!ncr.requires_immediate_action());
924 }
925
926 #[test]
927 fn ncr_has_disposition() {
928 assert!(!create_test_ncr(NcrStatus::Open, None).has_disposition());
929 assert!(create_test_ncr(NcrStatus::Open, Some(Disposition::UseAsIs)).has_disposition());
930 }
931
932 #[test]
937 fn hold_is_active_until_released() {
938 let mut hold = create_test_hold();
939 assert!(hold.is_active());
940 hold.released_at = Some(Utc::now());
941 assert!(!hold.is_active());
942 }
943
944 #[test]
945 fn hold_is_expired_only_past_expiry_and_unreleased() {
946 let mut hold = create_test_hold();
947 assert!(!hold.is_expired()); hold.expires_at = Some(Utc::now() + chrono::Duration::hours(1));
949 assert!(!hold.is_expired()); hold.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
951 assert!(hold.is_expired()); hold.released_at = Some(Utc::now());
953 assert!(!hold.is_expired()); }
955
956 #[test]
961 fn ncr_status_display_from_str_round_trip() {
962 for status in [
963 NcrStatus::Open,
964 NcrStatus::UnderReview,
965 NcrStatus::PendingDisposition,
966 NcrStatus::CorrectiveAction,
967 NcrStatus::PreventiveAction,
968 NcrStatus::Verification,
969 NcrStatus::Closed,
970 NcrStatus::Cancelled,
971 ] {
972 assert_eq!(NcrStatus::from_str(&status.to_string()), Ok(status));
973 }
974 assert_eq!(NcrStatus::from_str("UNDER_REVIEW"), Ok(NcrStatus::UnderReview));
975 assert!(NcrStatus::from_str("bogus").is_err());
976 }
977
978 #[test]
979 fn inspection_status_round_trip() {
980 for status in [
981 InspectionStatus::Pending,
982 InspectionStatus::InProgress,
983 InspectionStatus::PartialPass,
984 InspectionStatus::OnHold,
985 ] {
986 assert_eq!(InspectionStatus::from_str(&status.to_string()), Ok(status));
987 }
988 assert!(InspectionStatus::from_str("nope").is_err());
989 }
990
991 #[test]
992 fn inspection_type_and_result_round_trip() {
993 for t in [InspectionType::Incoming, InspectionType::InProcess, InspectionType::Return] {
994 assert_eq!(InspectionType::from_str(&t.to_string()), Ok(t));
995 }
996 for r in [InspectionResult::Pass, InspectionResult::ConditionalPass] {
997 assert_eq!(InspectionResult::from_str(&r.to_string()), Ok(r));
998 }
999 }
1000
1001 #[test]
1002 fn disposition_and_hold_type_round_trip() {
1003 for d in [Disposition::UseAsIs, Disposition::ReturnToVendor, Disposition::SortAndScreen] {
1004 assert_eq!(Disposition::from_str(&d.to_string()), Ok(d));
1005 }
1006 for h in [HoldType::QualityInspection, HoldType::RegulatoryHold, HoldType::Quarantine] {
1007 assert_eq!(HoldType::from_str(&h.to_string()), Ok(h));
1008 }
1009 }
1010
1011 #[test]
1012 fn defaults_are_sane() {
1013 assert_eq!(InspectionType::default(), InspectionType::Incoming);
1014 assert_eq!(InspectionStatus::default(), InspectionStatus::Pending);
1015 assert_eq!(InspectionResult::default(), InspectionResult::Pending);
1016 assert_eq!(NcrStatus::default(), NcrStatus::Open);
1017 assert_eq!(Severity::default(), Severity::Minor);
1018 assert_eq!(HoldType::default(), HoldType::QualityInspection);
1019 let create = CreateNonConformance::default();
1020 assert_eq!(create.quantity_affected, Decimal::ZERO);
1021 assert!(create.sku.is_empty());
1022 let hold = CreateQualityHold::default();
1023 assert_eq!(hold.quantity, Decimal::ZERO);
1024 assert!(hold.placed_by.is_empty());
1025 }
1026}