Skip to main content

stateset_core/models/
quality.rs

1//! Quality Control domain models
2//!
3//! Models for inspections, non-conformance reports (NCRs), and quality holds.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use strum::{Display, EnumString};
9use uuid::Uuid;
10
11// ============================================================================
12// Inspection Types
13// ============================================================================
14
15/// Quality inspection for goods
16#[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/// Type of inspection
35#[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 goods inspection (alias for Receiving)
41    Incoming,
42    /// Inspection of received goods
43    Receiving,
44    /// In-process quality check during manufacturing
45    InProcess,
46    /// Final inspection before shipping
47    Final,
48    /// Random quality audit
49    Random,
50    /// Customer return inspection
51    Return,
52}
53
54impl Default for InspectionType {
55    fn default() -> Self {
56        Self::Incoming
57    }
58}
59
60/// Status of an inspection
61#[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    /// Inspection has been created but not yet scheduled.
67    Pending,
68    /// Inspection is scheduled for a future time.
69    Scheduled,
70    /// Inspector is actively performing the inspection.
71    InProgress,
72    /// All items passed the inspection criteria.
73    Passed,
74    /// One or more items failed the inspection criteria.
75    Failed,
76    /// Some items passed and some failed.
77    PartialPass,
78    /// Inspection is temporarily paused pending additional information.
79    OnHold,
80    /// Inspection was cancelled before completion.
81    Cancelled,
82}
83
84impl Default for InspectionStatus {
85    fn default() -> Self {
86        Self::Pending
87    }
88}
89
90/// Line item in an inspection
91#[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/// Result of inspecting an item
109#[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    /// Result has not yet been recorded.
115    Pending,
116    /// Item fully meets the quality criteria.
117    Pass,
118    /// Item does not meet the quality criteria.
119    Fail,
120    /// Item meets criteria only under specific conditions or with minor rework.
121    ConditionalPass,
122}
123
124impl Default for InspectionResult {
125    fn default() -> Self {
126        Self::Pending
127    }
128}
129
130// ============================================================================
131// Non-Conformance Report (NCR) Types
132// ============================================================================
133
134/// Non-Conformance Report for quality issues
135#[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/// Source of the non-conformance
160#[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    /// Defect discovered during a formal quality inspection.
166    Inspection,
167    /// Non-conformance reported by a customer.
168    CustomerComplaint,
169    /// Defect identified during an internal quality audit.
170    InternalAudit,
171    /// Problem attributed to a supplier's material or process.
172    SupplierIssue,
173    /// Defect introduced during the manufacturing process.
174    ProductionDefect,
175    /// Goods damaged in transit or during shipment.
176    ShippingDamage,
177}
178
179impl Default for NonConformanceSource {
180    fn default() -> Self {
181        Self::Inspection
182    }
183}
184
185/// Severity level
186#[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    /// Defect poses an immediate safety or compliance risk; requires urgent action.
192    Critical,
193    /// Significant defect likely to affect product function or customer satisfaction.
194    Major,
195    /// Small defect with limited impact on product use or appearance.
196    Minor,
197    /// Noteworthy finding that does not rise to the level of a defect.
198    Observation,
199}
200
201impl Default for Severity {
202    fn default() -> Self {
203        Self::Minor
204    }
205}
206
207/// Status of an NCR
208#[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    /// NCR has been created and is awaiting assignment.
214    Open,
215    /// NCR is being assessed by the quality team.
216    UnderReview,
217    /// Investigation is complete; awaiting a disposition decision.
218    PendingDisposition,
219    /// Corrective actions are being implemented to address the root cause.
220    CorrectiveAction,
221    /// Preventive actions are being implemented to avoid recurrence.
222    PreventiveAction,
223    /// Actions have been taken; effectiveness is being verified.
224    Verification,
225    /// All actions verified effective; NCR is closed.
226    Closed,
227    /// NCR was opened in error or deemed not applicable.
228    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/// Disposition decision for non-conforming material
256#[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    /// Material is accepted in its current state without modification.
262    UseAsIs,
263    /// Material will be re-processed to meet the original specification.
264    Rework,
265    /// Material will be fixed to an acceptable but possibly different specification.
266    Repair,
267    /// Material is disposed of; cannot be used or sold.
268    Scrap,
269    /// Material is returned to the supplier for credit or replacement.
270    ReturnToVendor,
271    /// Material is reclassified to a lower-grade specification.
272    Downgrade,
273    /// Each unit is individually inspected to separate conforming from non-conforming.
274    SortAndScreen,
275}
276
277// ============================================================================
278// Quality Hold Types
279// ============================================================================
280
281/// Quality hold to prevent inventory movement
282#[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/// Type of quality hold
303#[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    /// Held pending a quality inspection decision.
309    QualityInspection,
310    /// Returned by a customer; awaiting disposition.
311    CustomerReturn,
312    /// Subject to a product recall.
313    Recall,
314    /// Goods were damaged and cannot be sold as-is.
315    Damaged,
316    /// Goods have passed or are approaching their expiry date.
317    Expired,
318    /// Isolated to prevent potential contamination or spread.
319    Quarantine,
320    /// Held due to a regulatory agency requirement or investigation.
321    RegulatoryHold,
322    /// Held while an internal investigation is in progress.
323    InvestigationHold,
324}
325
326impl Default for HoldType {
327    fn default() -> Self {
328        Self::QualityInspection
329    }
330}
331
332// ============================================================================
333// Defect Code Types
334// ============================================================================
335
336/// Defect code definition
337#[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// ============================================================================
350// Input/Output Types
351// ============================================================================
352
353/// Input for creating an inspection
354#[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/// Input for creating an inspection item
366#[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/// Input for updating an inspection
386#[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/// Input for recording inspection results
395#[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/// Filter for listing inspections
407#[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    /// Keyset cursor: return records after this `(sort_key, id)` pair.
419    /// Sort key is `created_at` (DESC ordering).
420    pub after_cursor: Option<(String, String)>,
421}
422
423/// Input for creating an NCR
424#[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/// Input for updating an NCR
454#[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/// Filter for listing NCRs
467#[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    /// Keyset cursor: return records after this `(sort_key, id)` pair.
480    /// Sort key is `created_at` (DESC ordering).
481    pub after_cursor: Option<(String, String)>,
482}
483
484/// Input for creating a quality hold
485#[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/// Input for releasing a quality hold
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct ReleaseQualityHold {
521    pub released_by: String,
522    pub release_notes: Option<String>,
523}
524
525/// Filter for listing quality holds
526#[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/// Input for creating a defect code
538#[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
547// ============================================================================
548// Type Aliases for API compatibility
549// ============================================================================
550
551/// Alias for `CreateNonConformance` for API convenience
552pub type CreateNcr = CreateNonConformance;
553
554/// Input for completing an inspection
555#[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
574// ============================================================================
575// Business Logic
576// ============================================================================
577
578impl Inspection {
579    /// Check if inspection can be started
580    #[must_use]
581    pub const fn can_start(&self) -> bool {
582        matches!(self.status, InspectionStatus::Pending | InspectionStatus::Scheduled)
583    }
584
585    /// Check if inspection can be completed
586    #[must_use]
587    pub const fn can_complete(&self) -> bool {
588        matches!(self.status, InspectionStatus::InProgress)
589    }
590
591    /// Check if all items have been inspected
592    #[must_use]
593    pub fn all_items_inspected(&self) -> bool {
594        self.items.iter().all(|item| item.result != InspectionResult::Pending)
595    }
596
597    /// Get overall pass rate
598    #[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    /// Calculate overall result based on items
610    #[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    /// Check if NCR can be closed
634    #[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    /// Check if NCR requires immediate action based on severity
643    #[must_use]
644    pub const fn requires_immediate_action(&self) -> bool {
645        matches!(self.severity, Severity::Critical)
646    }
647
648    /// Check if disposition has been set
649    #[must_use]
650    pub const fn has_disposition(&self) -> bool {
651        self.disposition.is_some()
652    }
653}
654
655impl QualityHold {
656    /// Check if hold is active
657    #[must_use]
658    pub const fn is_active(&self) -> bool {
659        self.released_at.is_none()
660    }
661
662    /// Check if hold has expired
663    #[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    // ============================================================================
680    // Test Helpers
681    // ============================================================================
682
683    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    // ============================================================================
773    // Inspection lifecycle guards
774    // ============================================================================
775
776    #[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    // ============================================================================
819    // pass_rate
820    // ============================================================================
821
822    #[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    // ============================================================================
847    // calculate_overall_result
848    // ============================================================================
849
850    #[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    // ============================================================================
890    // NonConformance
891    // ============================================================================
892
893    #[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    // ============================================================================
933    // QualityHold
934    // ============================================================================
935
936    #[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()); // no expiry
948        hold.expires_at = Some(Utc::now() + chrono::Duration::hours(1));
949        assert!(!hold.is_expired()); // future expiry
950        hold.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
951        assert!(hold.is_expired()); // past expiry, not released
952        hold.released_at = Some(Utc::now());
953        assert!(!hold.is_expired()); // released holds are not "expired"
954    }
955
956    // ============================================================================
957    // Enum Display / FromStr round-trips and defaults
958    // ============================================================================
959
960    #[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}