Skip to main content

stateset_db/sqlite/
quality.rs

1//! SQLite implementation of Quality Control repository
2
3use crate::sqlite::{
4    map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
5    parse_decimal_row, parse_enum_row, parse_json_opt_row, parse_json_row, parse_uuid_opt_row,
6    parse_uuid_row,
7};
8use chrono::Utc;
9use r2d2::Pool;
10use r2d2_sqlite::SqliteConnectionManager;
11use rust_decimal::Decimal;
12use stateset_core::traits::QualityRepository;
13use stateset_core::{
14    CommerceError, CreateDefectCode, CreateInspection, CreateNonConformance, CreateQualityHold,
15    DefectCode, Inspection, InspectionFilter, InspectionItem, InspectionResult, InspectionStatus,
16    NcrStatus, NonConformance, NonConformanceFilter, QualityHold, QualityHoldFilter,
17    RecordInspectionResult, ReleaseQualityHold, Result, UpdateInspection, UpdateNonConformance,
18};
19use uuid::Uuid;
20
21#[derive(Debug)]
22pub struct SqliteQualityRepository {
23    pool: Pool<SqliteConnectionManager>,
24}
25
26impl SqliteQualityRepository {
27    #[must_use]
28    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
29        Self { pool }
30    }
31
32    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
33        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
34    }
35
36    fn generate_inspection_number() -> String {
37        // Millisecond timestamp + UUID suffix so concurrent inspection creation
38        // (or rapid-fire batches in tests) cannot collide on the UNIQUE constraint.
39        let suffix = &Uuid::new_v4().simple().to_string()[..8];
40        format!("INS-{}-{suffix}", Utc::now().format("%Y%m%d%H%M%S%3f"))
41    }
42
43    fn generate_ncr_number() -> String {
44        // Same fix as inspection_number above: include ms + UUID suffix.
45        let suffix = &Uuid::new_v4().simple().to_string()[..8];
46        format!("NCR-{}-{suffix}", Utc::now().format("%Y%m%d%H%M%S%3f"))
47    }
48
49    fn row_to_inspection(row: &rusqlite::Row<'_>) -> rusqlite::Result<Inspection> {
50        Ok(Inspection {
51            id: parse_uuid_row(&row.get::<_, String>("id")?, "inspection", "id")?,
52            inspection_number: row.get("inspection_number")?,
53            inspection_type: parse_enum_row(
54                &row.get::<_, String>("inspection_type")?,
55                "inspection",
56                "inspection_type",
57            )?,
58            reference_type: row.get("reference_type")?,
59            reference_id: parse_uuid_row(
60                &row.get::<_, String>("reference_id")?,
61                "inspection",
62                "reference_id",
63            )?,
64            status: parse_enum_row(&row.get::<_, String>("status")?, "inspection", "status")?,
65            inspector_id: row.get("inspector_id")?,
66            scheduled_at: parse_datetime_opt_row(
67                row.get::<_, Option<String>>("scheduled_at")?,
68                "inspection",
69                "scheduled_at",
70            )?,
71            started_at: parse_datetime_opt_row(
72                row.get::<_, Option<String>>("started_at")?,
73                "inspection",
74                "started_at",
75            )?,
76            completed_at: parse_datetime_opt_row(
77                row.get::<_, Option<String>>("completed_at")?,
78                "inspection",
79                "completed_at",
80            )?,
81            notes: row.get("notes")?,
82            items: vec![],
83            created_at: parse_datetime_row(
84                &row.get::<_, String>("created_at")?,
85                "inspection",
86                "created_at",
87            )?,
88            updated_at: parse_datetime_row(
89                &row.get::<_, String>("updated_at")?,
90                "inspection",
91                "updated_at",
92            )?,
93        })
94    }
95
96    fn row_to_inspection_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<InspectionItem> {
97        Ok(InspectionItem {
98            id: parse_uuid_row(&row.get::<_, String>("id")?, "inspection_item", "id")?,
99            inspection_id: parse_uuid_row(
100                &row.get::<_, String>("inspection_id")?,
101                "inspection_item",
102                "inspection_id",
103            )?,
104            sku: row.get("sku")?,
105            lot_number: row.get("lot_number")?,
106            serial_number: row.get("serial_number")?,
107            quantity_inspected: parse_decimal_row(
108                &row.get::<_, String>("quantity_inspected")?,
109                "inspection_item",
110                "quantity_inspected",
111            )?,
112            quantity_passed: parse_decimal_row(
113                &row.get::<_, String>("quantity_passed")?,
114                "inspection_item",
115                "quantity_passed",
116            )?,
117            quantity_failed: parse_decimal_row(
118                &row.get::<_, String>("quantity_failed")?,
119                "inspection_item",
120                "quantity_failed",
121            )?,
122            defect_codes: parse_json_row(
123                &row.get::<_, String>("defect_codes")?,
124                "inspection_item",
125                "defect_codes",
126            )?,
127            measurements: parse_json_opt_row(
128                row.get::<_, Option<String>>("measurements")?,
129                "inspection_item",
130                "measurements",
131            )?,
132            result: parse_enum_row(&row.get::<_, String>("result")?, "inspection_item", "result")?,
133            notes: row.get("notes")?,
134            created_at: parse_datetime_row(
135                &row.get::<_, String>("created_at")?,
136                "inspection_item",
137                "created_at",
138            )?,
139        })
140    }
141
142    fn row_to_ncr(row: &rusqlite::Row<'_>) -> rusqlite::Result<NonConformance> {
143        let disposition = match row.get::<_, Option<String>>("disposition")? {
144            Some(value) => Some(parse_enum_row(&value, "non_conformance", "disposition")?),
145            None => None,
146        };
147
148        Ok(NonConformance {
149            id: parse_uuid_row(&row.get::<_, String>("id")?, "non_conformance", "id")?,
150            ncr_number: row.get("ncr_number")?,
151            inspection_id: parse_uuid_opt_row(
152                row.get::<_, Option<String>>("inspection_id")?,
153                "non_conformance",
154                "inspection_id",
155            )?,
156            source: parse_enum_row(&row.get::<_, String>("source")?, "non_conformance", "source")?,
157            severity: parse_enum_row(
158                &row.get::<_, String>("severity")?,
159                "non_conformance",
160                "severity",
161            )?,
162            status: parse_enum_row(&row.get::<_, String>("status")?, "non_conformance", "status")?,
163            sku: row.get("sku")?,
164            lot_number: row.get("lot_number")?,
165            serial_number: row.get("serial_number")?,
166            quantity_affected: parse_decimal_row(
167                &row.get::<_, String>("quantity_affected")?,
168                "non_conformance",
169                "quantity_affected",
170            )?,
171            description: row.get("description")?,
172            root_cause: row.get("root_cause")?,
173            corrective_action: row.get("corrective_action")?,
174            preventive_action: row.get("preventive_action")?,
175            disposition,
176            disposition_quantity: parse_decimal_opt_row(
177                row.get::<_, Option<String>>("disposition_quantity")?,
178                "non_conformance",
179                "disposition_quantity",
180            )?,
181            assigned_to: row.get("assigned_to")?,
182            created_at: parse_datetime_row(
183                &row.get::<_, String>("created_at")?,
184                "non_conformance",
185                "created_at",
186            )?,
187            updated_at: parse_datetime_row(
188                &row.get::<_, String>("updated_at")?,
189                "non_conformance",
190                "updated_at",
191            )?,
192            closed_at: parse_datetime_opt_row(
193                row.get::<_, Option<String>>("closed_at")?,
194                "non_conformance",
195                "closed_at",
196            )?,
197        })
198    }
199
200    fn row_to_hold(row: &rusqlite::Row<'_>) -> rusqlite::Result<QualityHold> {
201        Ok(QualityHold {
202            id: parse_uuid_row(&row.get::<_, String>("id")?, "quality_hold", "id")?,
203            sku: row.get("sku")?,
204            lot_number: row.get("lot_number")?,
205            serial_number: row.get("serial_number")?,
206            location_id: row.get("location_id")?,
207            quantity_held: parse_decimal_row(
208                &row.get::<_, String>("quantity_held")?,
209                "quality_hold",
210                "quantity_held",
211            )?,
212            reason: row.get("reason")?,
213            hold_type: parse_enum_row(
214                &row.get::<_, String>("hold_type")?,
215                "quality_hold",
216                "hold_type",
217            )?,
218            ncr_id: parse_uuid_opt_row(
219                row.get::<_, Option<String>>("ncr_id")?,
220                "quality_hold",
221                "ncr_id",
222            )?,
223            inspection_id: parse_uuid_opt_row(
224                row.get::<_, Option<String>>("inspection_id")?,
225                "quality_hold",
226                "inspection_id",
227            )?,
228            placed_by: row.get("placed_by")?,
229            released_by: row.get("released_by")?,
230            release_notes: row.get("release_notes")?,
231            placed_at: parse_datetime_row(
232                &row.get::<_, String>("placed_at")?,
233                "quality_hold",
234                "placed_at",
235            )?,
236            released_at: parse_datetime_opt_row(
237                row.get::<_, Option<String>>("released_at")?,
238                "quality_hold",
239                "released_at",
240            )?,
241            expires_at: parse_datetime_opt_row(
242                row.get::<_, Option<String>>("expires_at")?,
243                "quality_hold",
244                "expires_at",
245            )?,
246        })
247    }
248
249    fn row_to_defect_code(row: &rusqlite::Row<'_>) -> rusqlite::Result<DefectCode> {
250        Ok(DefectCode {
251            id: parse_uuid_row(&row.get::<_, String>("id")?, "defect_code", "id")?,
252            code: row.get("code")?,
253            name: row.get("name")?,
254            description: row.get("description")?,
255            category: row.get("category")?,
256            severity: parse_enum_row(
257                &row.get::<_, String>("severity")?,
258                "defect_code",
259                "severity",
260            )?,
261            is_active: row.get::<_, i32>("is_active")? != 0,
262            created_at: parse_datetime_row(
263                &row.get::<_, String>("created_at")?,
264                "defect_code",
265                "created_at",
266            )?,
267        })
268    }
269
270    fn load_inspection_items(
271        &self,
272        conn: &rusqlite::Connection,
273        inspection_id: Uuid,
274    ) -> Result<Vec<InspectionItem>> {
275        let mut stmt = conn
276            .prepare("SELECT * FROM inspection_items WHERE inspection_id = ?")
277            .map_err(map_db_error)?;
278
279        let items = stmt
280            .query_map([inspection_id.to_string()], Self::row_to_inspection_item)
281            .map_err(map_db_error)?
282            .collect::<rusqlite::Result<Vec<_>>>()
283            .map_err(map_db_error)?;
284
285        Ok(items)
286    }
287
288    fn load_inspection_items_batch(
289        conn: &rusqlite::Connection,
290        ids: &[Uuid],
291    ) -> Result<std::collections::HashMap<String, Vec<InspectionItem>>> {
292        let mut map: std::collections::HashMap<String, Vec<InspectionItem>> =
293            std::collections::HashMap::with_capacity(ids.len());
294        let id_strs: Vec<String> = ids.iter().map(ToString::to_string).collect();
295        for chunk in id_strs.chunks(500) {
296            let placeholders = super::build_in_clause(chunk.len());
297            let sql =
298                format!("SELECT * FROM inspection_items WHERE inspection_id IN ({placeholders})");
299            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
300                chunk.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
301            let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
302            let rows = stmt
303                .query_map(param_refs.as_slice(), |row| {
304                    let parent: String = row.get("inspection_id")?;
305                    Ok((parent, Self::row_to_inspection_item(row)?))
306                })
307                .map_err(map_db_error)?;
308            for row in rows {
309                let (parent, item) = row.map_err(map_db_error)?;
310                map.entry(parent).or_default().push(item);
311            }
312        }
313        Ok(map)
314    }
315}
316
317impl QualityRepository for SqliteQualityRepository {
318    fn create_inspection(&self, input: CreateInspection) -> Result<Inspection> {
319        let mut conn = self.conn()?;
320        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
321
322        let id = Uuid::new_v4();
323        let inspection_number = Self::generate_inspection_number();
324        let now = Utc::now();
325
326        tx.execute(
327            "INSERT INTO inspections (id, inspection_number, inspection_type, reference_type, reference_id,
328                                      status, inspector_id, scheduled_at, notes, created_at, updated_at)
329             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
330            rusqlite::params![
331                id.to_string(),
332                &inspection_number,
333                input.inspection_type.to_string(),
334                &input.reference_type,
335                input.reference_id.to_string(),
336                InspectionStatus::Pending.to_string(),
337                &input.inspector_id,
338                input.scheduled_at.map(|d| d.to_rfc3339()),
339                &input.notes,
340                now.to_rfc3339(),
341                now.to_rfc3339(),
342            ],
343        )
344        .map_err(map_db_error)?;
345
346        // Insert items
347        let mut items = Vec::with_capacity(input.items.len());
348        for item_input in &input.items {
349            let item_id = Uuid::new_v4();
350            tx.execute(
351                "INSERT INTO inspection_items (id, inspection_id, sku, lot_number, serial_number,
352                                               quantity_inspected, quantity_passed, quantity_failed,
353                                               defect_codes, result, created_at)
354                 VALUES (?, ?, ?, ?, ?, ?, '0', '0', '[]', ?, ?)",
355                rusqlite::params![
356                    item_id.to_string(),
357                    id.to_string(),
358                    &item_input.sku,
359                    &item_input.lot_number,
360                    &item_input.serial_number,
361                    item_input.quantity_to_inspect.to_string(),
362                    InspectionResult::Pending.to_string(),
363                    now.to_rfc3339(),
364                ],
365            )
366            .map_err(map_db_error)?;
367
368            items.push(InspectionItem {
369                id: item_id,
370                inspection_id: id,
371                sku: item_input.sku.clone(),
372                lot_number: item_input.lot_number.clone(),
373                serial_number: item_input.serial_number.clone(),
374                quantity_inspected: item_input.quantity_to_inspect,
375                quantity_passed: Decimal::ZERO,
376                quantity_failed: Decimal::ZERO,
377                defect_codes: vec![],
378                measurements: None,
379                result: InspectionResult::Pending,
380                notes: None,
381                created_at: now,
382            });
383        }
384
385        tx.commit().map_err(map_db_error)?;
386
387        Ok(Inspection {
388            id,
389            inspection_number,
390            inspection_type: input.inspection_type,
391            reference_type: input.reference_type,
392            reference_id: input.reference_id,
393            status: InspectionStatus::Pending,
394            inspector_id: input.inspector_id,
395            scheduled_at: input.scheduled_at,
396            started_at: None,
397            completed_at: None,
398            notes: input.notes,
399            items,
400            created_at: now,
401            updated_at: now,
402        })
403    }
404
405    fn get_inspection(&self, id: Uuid) -> Result<Option<Inspection>> {
406        let conn = self.conn()?;
407        let result = conn.query_row(
408            "SELECT * FROM inspections WHERE id = ?",
409            [id.to_string()],
410            Self::row_to_inspection,
411        );
412
413        match result {
414            Ok(mut inspection) => {
415                inspection.items = self.load_inspection_items(&conn, id)?;
416                Ok(Some(inspection))
417            }
418            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
419            Err(e) => Err(map_db_error(e)),
420        }
421    }
422
423    fn get_inspection_by_number(&self, number: &str) -> Result<Option<Inspection>> {
424        let conn = self.conn()?;
425        let result = conn.query_row(
426            "SELECT * FROM inspections WHERE inspection_number = ?",
427            [number],
428            Self::row_to_inspection,
429        );
430
431        match result {
432            Ok(mut inspection) => {
433                inspection.items = self.load_inspection_items(&conn, inspection.id)?;
434                Ok(Some(inspection))
435            }
436            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
437            Err(e) => Err(map_db_error(e)),
438        }
439    }
440
441    fn update_inspection(&self, id: Uuid, input: UpdateInspection) -> Result<Inspection> {
442        let conn = self.conn()?;
443        let now = Utc::now();
444
445        let mut updates = vec!["updated_at = ?"];
446        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now.to_rfc3339())];
447
448        if let Some(status) = &input.status {
449            updates.push("status = ?");
450            params.push(Box::new(status.to_string()));
451        }
452        if let Some(inspector_id) = &input.inspector_id {
453            updates.push("inspector_id = ?");
454            params.push(Box::new(inspector_id.clone()));
455        }
456        if let Some(scheduled_at) = &input.scheduled_at {
457            updates.push("scheduled_at = ?");
458            params.push(Box::new(scheduled_at.to_rfc3339()));
459        }
460        if let Some(notes) = &input.notes {
461            updates.push("notes = ?");
462            params.push(Box::new(notes.clone()));
463        }
464
465        params.push(Box::new(id.to_string()));
466
467        let sql = format!("UPDATE inspections SET {} WHERE id = ?", updates.join(", "));
468
469        let params_refs: Vec<&dyn rusqlite::ToSql> =
470            params.iter().map(std::convert::AsRef::as_ref).collect();
471        conn.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
472
473        self.get_inspection(id)?.ok_or(CommerceError::NotFound)
474    }
475
476    fn list_inspections(&self, filter: InspectionFilter) -> Result<Vec<Inspection>> {
477        let conn = self.conn()?;
478
479        let mut conditions = vec!["1=1"];
480        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
481
482        if let Some(inspection_type) = &filter.inspection_type {
483            conditions.push("inspection_type = ?");
484            params.push(Box::new(inspection_type.to_string()));
485        }
486        if let Some(status) = &filter.status {
487            conditions.push("status = ?");
488            params.push(Box::new(status.to_string()));
489        }
490        if let Some(reference_type) = &filter.reference_type {
491            conditions.push("reference_type = ?");
492            params.push(Box::new(reference_type.clone()));
493        }
494        if let Some(reference_id) = &filter.reference_id {
495            conditions.push("reference_id = ?");
496            params.push(Box::new(reference_id.to_string()));
497        }
498        if let Some(inspector_id) = &filter.inspector_id {
499            conditions.push("inspector_id = ?");
500            params.push(Box::new(inspector_id.clone()));
501        }
502        if let Some(from_date) = &filter.from_date {
503            conditions.push("created_at >= ?");
504            params.push(Box::new(from_date.to_rfc3339()));
505        }
506        if let Some(to_date) = &filter.to_date {
507            conditions.push("created_at <= ?");
508            params.push(Box::new(to_date.to_rfc3339()));
509        }
510
511        // Keyset cursor: (created_at, id) for stable DESC ordering
512        if let Some((cursor_created, cursor_id)) = &filter.after_cursor {
513            conditions.push("(created_at < ? OR (created_at = ? AND id < ?))");
514            params.push(Box::new(cursor_created.clone()));
515            params.push(Box::new(cursor_created.clone()));
516            params.push(Box::new(cursor_id.clone()));
517        }
518
519        let limit = super::effective_limit(filter.limit);
520        // Offset pagination applies only in non-cursor mode.
521        let offset = if filter.after_cursor.is_none() { filter.offset.unwrap_or(0) } else { 0 };
522
523        let sql = format!(
524            "SELECT * FROM inspections WHERE {} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
525            conditions.join(" AND ")
526        );
527
528        params.push(Box::new(i64::from(limit)));
529        params.push(Box::new(i64::from(offset)));
530
531        let params_refs: Vec<&dyn rusqlite::ToSql> =
532            params.iter().map(std::convert::AsRef::as_ref).collect();
533
534        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
535        let inspections = stmt
536            .query_map(params_refs.as_slice(), Self::row_to_inspection)
537            .map_err(map_db_error)?
538            .collect::<rusqlite::Result<Vec<_>>>()
539            .map_err(map_db_error)?;
540
541        // Load items for all inspections in one batched query
542        let ids: Vec<Uuid> = inspections.iter().map(|i| i.id).collect();
543        let mut items_by_id = Self::load_inspection_items_batch(&conn, &ids)?;
544        let mut result = Vec::with_capacity(inspections.len());
545        for mut inspection in inspections {
546            inspection.items = items_by_id.remove(&inspection.id.to_string()).unwrap_or_default();
547            result.push(inspection);
548        }
549
550        Ok(result)
551    }
552
553    fn delete_inspection(&self, id: Uuid) -> Result<()> {
554        let conn = self.conn()?;
555        conn.execute("DELETE FROM inspections WHERE id = ?", [id.to_string()])
556            .map_err(map_db_error)?;
557        Ok(())
558    }
559
560    fn start_inspection(&self, id: Uuid) -> Result<Inspection> {
561        let conn = self.conn()?;
562        let now = Utc::now();
563
564        conn.execute(
565            "UPDATE inspections SET status = ?, started_at = ?, updated_at = ? WHERE id = ?",
566            rusqlite::params![
567                InspectionStatus::InProgress.to_string(),
568                now.to_rfc3339(),
569                now.to_rfc3339(),
570                id.to_string(),
571            ],
572        )
573        .map_err(map_db_error)?;
574
575        self.get_inspection(id)?.ok_or(CommerceError::NotFound)
576    }
577
578    fn complete_inspection(&self, id: Uuid) -> Result<Inspection> {
579        let conn = self.conn()?;
580        let now = Utc::now();
581
582        // Get inspection and calculate overall status
583        let inspection = self.get_inspection(id)?.ok_or(CommerceError::NotFound)?;
584
585        let overall_status = inspection.calculate_overall_result();
586
587        conn.execute(
588            "UPDATE inspections SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?",
589            rusqlite::params![
590                overall_status.to_string(),
591                now.to_rfc3339(),
592                now.to_rfc3339(),
593                id.to_string(),
594            ],
595        )
596        .map_err(map_db_error)?;
597
598        self.get_inspection(id)?.ok_or(CommerceError::NotFound)
599    }
600
601    fn record_inspection_result(&self, input: RecordInspectionResult) -> Result<InspectionItem> {
602        if input.quantity_passed < Decimal::ZERO || input.quantity_failed < Decimal::ZERO {
603            return Err(CommerceError::ValidationError(
604                "Inspection passed/failed quantities must not be negative".to_string(),
605            ));
606        }
607
608        let conn = self.conn()?;
609        let now = Utc::now();
610
611        // The passed + failed counts cannot exceed the quantity inspected: you
612        // cannot pass or fail more units than the inspection item covers.
613        let inspected_raw: String = conn
614            .query_row(
615                "SELECT quantity_inspected FROM inspection_items WHERE id = ?",
616                [input.item_id.to_string()],
617                |row| row.get(0),
618            )
619            .map_err(|e| match e {
620                rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
621                other => map_db_error(other),
622            })?;
623        let quantity_inspected =
624            parse_decimal_row(&inspected_raw, "inspection_item", "quantity_inspected")
625                .map_err(map_db_error)?;
626        if input.quantity_passed + input.quantity_failed > quantity_inspected {
627            return Err(CommerceError::ValidationError(format!(
628                "Inspection result exceeds inspected quantity: {} passed + {} failed > {} inspected",
629                input.quantity_passed, input.quantity_failed, quantity_inspected
630            )));
631        }
632
633        let defect_codes_json = serde_json::to_string(&input.defect_codes).unwrap_or_default();
634        let measurements_json =
635            input.measurements.map(|m| serde_json::to_string(&m).unwrap_or_default());
636
637        conn.execute(
638            "UPDATE inspection_items SET quantity_passed = ?, quantity_failed = ?, result = ?,
639                     defect_codes = ?, measurements = ?, notes = ?
640             WHERE id = ?",
641            rusqlite::params![
642                input.quantity_passed.to_string(),
643                input.quantity_failed.to_string(),
644                input.result.to_string(),
645                &defect_codes_json,
646                &measurements_json,
647                &input.notes,
648                input.item_id.to_string(),
649            ],
650        )
651        .map_err(map_db_error)?;
652
653        // Also update the inspection's updated_at
654        conn.execute(
655            "UPDATE inspections SET updated_at = ? WHERE id = (SELECT inspection_id FROM inspection_items WHERE id = ?)",
656            rusqlite::params![now.to_rfc3339(), input.item_id.to_string()],
657        )
658        .map_err(map_db_error)?;
659
660        conn.query_row(
661            "SELECT * FROM inspection_items WHERE id = ?",
662            [input.item_id.to_string()],
663            Self::row_to_inspection_item,
664        )
665        .map_err(map_db_error)
666    }
667
668    fn get_inspection_items(&self, inspection_id: Uuid) -> Result<Vec<InspectionItem>> {
669        let conn = self.conn()?;
670        self.load_inspection_items(&conn, inspection_id)
671    }
672
673    fn count_inspections(&self, filter: InspectionFilter) -> Result<u64> {
674        let conn = self.conn()?;
675
676        let mut conditions = vec!["1=1"];
677        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
678
679        if let Some(inspection_type) = &filter.inspection_type {
680            conditions.push("inspection_type = ?");
681            params.push(Box::new(inspection_type.to_string()));
682        }
683        if let Some(status) = &filter.status {
684            conditions.push("status = ?");
685            params.push(Box::new(status.to_string()));
686        }
687
688        if let Some(reference_type) = &filter.reference_type {
689            conditions.push("reference_type = ?");
690            params.push(Box::new(reference_type.clone()));
691        }
692        if let Some(reference_id) = &filter.reference_id {
693            conditions.push("reference_id = ?");
694            params.push(Box::new(reference_id.to_string()));
695        }
696        if let Some(inspector_id) = &filter.inspector_id {
697            conditions.push("inspector_id = ?");
698            params.push(Box::new(inspector_id.clone()));
699        }
700        if let Some(from_date) = &filter.from_date {
701            conditions.push("created_at >= ?");
702            params.push(Box::new(from_date.to_rfc3339()));
703        }
704        if let Some(to_date) = &filter.to_date {
705            conditions.push("created_at <= ?");
706            params.push(Box::new(to_date.to_rfc3339()));
707        }
708
709        let sql = format!("SELECT COUNT(*) FROM inspections WHERE {}", conditions.join(" AND "));
710
711        let params_refs: Vec<&dyn rusqlite::ToSql> =
712            params.iter().map(std::convert::AsRef::as_ref).collect();
713
714        conn.query_row(&sql, params_refs.as_slice(), |row| row.get::<_, i64>(0))
715            .map(|c| c as u64)
716            .map_err(map_db_error)
717    }
718
719    fn create_ncr(&self, input: CreateNonConformance) -> Result<NonConformance> {
720        let conn = self.conn()?;
721        let id = Uuid::new_v4();
722        let ncr_number = Self::generate_ncr_number();
723        let now = Utc::now();
724
725        conn.execute(
726            "INSERT INTO non_conformances (id, ncr_number, inspection_id, source, severity, status,
727                                           sku, lot_number, serial_number, quantity_affected,
728                                           description, assigned_to, created_at, updated_at)
729             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
730            rusqlite::params![
731                id.to_string(),
732                &ncr_number,
733                input.inspection_id.map(|i| i.to_string()),
734                input.source.to_string(),
735                input.severity.to_string(),
736                NcrStatus::Open.to_string(),
737                &input.sku,
738                &input.lot_number,
739                &input.serial_number,
740                input.quantity_affected.to_string(),
741                &input.description,
742                &input.assigned_to,
743                now.to_rfc3339(),
744                now.to_rfc3339(),
745            ],
746        )
747        .map_err(map_db_error)?;
748
749        Ok(NonConformance {
750            id,
751            ncr_number,
752            inspection_id: input.inspection_id,
753            source: input.source,
754            severity: input.severity,
755            status: NcrStatus::Open,
756            sku: input.sku,
757            lot_number: input.lot_number,
758            serial_number: input.serial_number,
759            quantity_affected: input.quantity_affected,
760            description: input.description,
761            root_cause: None,
762            corrective_action: None,
763            preventive_action: None,
764            disposition: None,
765            disposition_quantity: None,
766            assigned_to: input.assigned_to,
767            created_at: now,
768            updated_at: now,
769            closed_at: None,
770        })
771    }
772
773    fn get_ncr(&self, id: Uuid) -> Result<Option<NonConformance>> {
774        let conn = self.conn()?;
775        let result = conn.query_row(
776            "SELECT * FROM non_conformances WHERE id = ?",
777            [id.to_string()],
778            Self::row_to_ncr,
779        );
780
781        match result {
782            Ok(ncr) => Ok(Some(ncr)),
783            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
784            Err(e) => Err(map_db_error(e)),
785        }
786    }
787
788    fn get_ncr_by_number(&self, number: &str) -> Result<Option<NonConformance>> {
789        let conn = self.conn()?;
790        let result = conn.query_row(
791            "SELECT * FROM non_conformances WHERE ncr_number = ?",
792            [number],
793            Self::row_to_ncr,
794        );
795
796        match result {
797            Ok(ncr) => Ok(Some(ncr)),
798            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
799            Err(e) => Err(map_db_error(e)),
800        }
801    }
802
803    fn update_ncr(&self, id: Uuid, input: UpdateNonConformance) -> Result<NonConformance> {
804        let conn = self.conn()?;
805        let now = Utc::now();
806
807        let mut updates = vec!["updated_at = ?"];
808        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now.to_rfc3339())];
809
810        if let Some(status) = &input.status {
811            updates.push("status = ?");
812            params.push(Box::new(status.to_string()));
813        }
814        if let Some(severity) = &input.severity {
815            updates.push("severity = ?");
816            params.push(Box::new(severity.to_string()));
817        }
818        if let Some(root_cause) = &input.root_cause {
819            updates.push("root_cause = ?");
820            params.push(Box::new(root_cause.clone()));
821        }
822        if let Some(corrective_action) = &input.corrective_action {
823            updates.push("corrective_action = ?");
824            params.push(Box::new(corrective_action.clone()));
825        }
826        if let Some(preventive_action) = &input.preventive_action {
827            updates.push("preventive_action = ?");
828            params.push(Box::new(preventive_action.clone()));
829        }
830        if let Some(disposition) = &input.disposition {
831            updates.push("disposition = ?");
832            params.push(Box::new(disposition.to_string()));
833        }
834        if let Some(disposition_quantity) = &input.disposition_quantity {
835            updates.push("disposition_quantity = ?");
836            params.push(Box::new(disposition_quantity.to_string()));
837        }
838        if let Some(assigned_to) = &input.assigned_to {
839            updates.push("assigned_to = ?");
840            params.push(Box::new(assigned_to.clone()));
841        }
842
843        params.push(Box::new(id.to_string()));
844
845        let sql = format!("UPDATE non_conformances SET {} WHERE id = ?", updates.join(", "));
846
847        let params_refs: Vec<&dyn rusqlite::ToSql> =
848            params.iter().map(std::convert::AsRef::as_ref).collect();
849        conn.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
850
851        self.get_ncr(id)?.ok_or(CommerceError::NotFound)
852    }
853
854    fn list_ncrs(&self, filter: NonConformanceFilter) -> Result<Vec<NonConformance>> {
855        let conn = self.conn()?;
856
857        let mut conditions = vec!["1=1"];
858        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
859
860        if let Some(source) = &filter.source {
861            conditions.push("source = ?");
862            params.push(Box::new(source.to_string()));
863        }
864        if let Some(severity) = &filter.severity {
865            conditions.push("severity = ?");
866            params.push(Box::new(severity.to_string()));
867        }
868        if let Some(status) = &filter.status {
869            conditions.push("status = ?");
870            params.push(Box::new(status.to_string()));
871        }
872        if let Some(sku) = &filter.sku {
873            conditions.push("sku = ?");
874            params.push(Box::new(sku.clone()));
875        }
876        if let Some(lot_number) = &filter.lot_number {
877            conditions.push("lot_number = ?");
878            params.push(Box::new(lot_number.clone()));
879        }
880        if let Some(assigned_to) = &filter.assigned_to {
881            conditions.push("assigned_to = ?");
882            params.push(Box::new(assigned_to.clone()));
883        }
884        if let Some(from_date) = &filter.from_date {
885            conditions.push("created_at >= ?");
886            params.push(Box::new(from_date.to_rfc3339()));
887        }
888        if let Some(to_date) = &filter.to_date {
889            conditions.push("created_at <= ?");
890            params.push(Box::new(to_date.to_rfc3339()));
891        }
892
893        // Keyset cursor: (created_at, id) for stable DESC ordering
894        if let Some((cursor_created, cursor_id)) = &filter.after_cursor {
895            conditions.push("(created_at < ? OR (created_at = ? AND id < ?))");
896            params.push(Box::new(cursor_created.clone()));
897            params.push(Box::new(cursor_created.clone()));
898            params.push(Box::new(cursor_id.clone()));
899        }
900
901        let limit = super::effective_limit(filter.limit);
902        // Offset pagination applies only in non-cursor mode.
903        let offset = if filter.after_cursor.is_none() { filter.offset.unwrap_or(0) } else { 0 };
904
905        let sql = format!(
906            "SELECT * FROM non_conformances WHERE {} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
907            conditions.join(" AND ")
908        );
909
910        params.push(Box::new(i64::from(limit)));
911        params.push(Box::new(i64::from(offset)));
912
913        let params_refs: Vec<&dyn rusqlite::ToSql> =
914            params.iter().map(std::convert::AsRef::as_ref).collect();
915
916        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
917        let ncrs = stmt
918            .query_map(params_refs.as_slice(), Self::row_to_ncr)
919            .map_err(map_db_error)?
920            .collect::<rusqlite::Result<Vec<_>>>()
921            .map_err(map_db_error)?;
922
923        Ok(ncrs)
924    }
925
926    fn close_ncr(&self, id: Uuid) -> Result<NonConformance> {
927        let conn = self.conn()?;
928        let now = Utc::now();
929
930        conn.execute(
931            "UPDATE non_conformances SET status = ?, closed_at = ?, updated_at = ? WHERE id = ?",
932            rusqlite::params![
933                NcrStatus::Closed.to_string(),
934                now.to_rfc3339(),
935                now.to_rfc3339(),
936                id.to_string(),
937            ],
938        )
939        .map_err(map_db_error)?;
940
941        self.get_ncr(id)?.ok_or(CommerceError::NotFound)
942    }
943
944    fn cancel_ncr(&self, id: Uuid) -> Result<NonConformance> {
945        let conn = self.conn()?;
946        let now = Utc::now();
947
948        conn.execute(
949            "UPDATE non_conformances SET status = ?, updated_at = ? WHERE id = ?",
950            rusqlite::params![NcrStatus::Cancelled.to_string(), now.to_rfc3339(), id.to_string(),],
951        )
952        .map_err(map_db_error)?;
953
954        self.get_ncr(id)?.ok_or(CommerceError::NotFound)
955    }
956
957    fn count_ncrs(&self, filter: NonConformanceFilter) -> Result<u64> {
958        let conn = self.conn()?;
959
960        let mut conditions = vec!["1=1"];
961        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
962
963        if let Some(status) = &filter.status {
964            conditions.push("status = ?");
965            params.push(Box::new(status.to_string()));
966        }
967        if let Some(severity) = &filter.severity {
968            conditions.push("severity = ?");
969            params.push(Box::new(severity.to_string()));
970        }
971
972        if let Some(source) = &filter.source {
973            conditions.push("source = ?");
974            params.push(Box::new(source.to_string()));
975        }
976        if let Some(sku) = &filter.sku {
977            conditions.push("sku = ?");
978            params.push(Box::new(sku.clone()));
979        }
980        if let Some(lot_number) = &filter.lot_number {
981            conditions.push("lot_number = ?");
982            params.push(Box::new(lot_number.clone()));
983        }
984        if let Some(assigned_to) = &filter.assigned_to {
985            conditions.push("assigned_to = ?");
986            params.push(Box::new(assigned_to.clone()));
987        }
988        if let Some(from_date) = &filter.from_date {
989            conditions.push("created_at >= ?");
990            params.push(Box::new(from_date.to_rfc3339()));
991        }
992        if let Some(to_date) = &filter.to_date {
993            conditions.push("created_at <= ?");
994            params.push(Box::new(to_date.to_rfc3339()));
995        }
996
997        let sql =
998            format!("SELECT COUNT(*) FROM non_conformances WHERE {}", conditions.join(" AND "));
999
1000        let params_refs: Vec<&dyn rusqlite::ToSql> =
1001            params.iter().map(std::convert::AsRef::as_ref).collect();
1002
1003        conn.query_row(&sql, params_refs.as_slice(), |row| row.get::<_, i64>(0))
1004            .map(|c| c as u64)
1005            .map_err(map_db_error)
1006    }
1007
1008    fn create_hold(&self, input: CreateQualityHold) -> Result<QualityHold> {
1009        let conn = self.conn()?;
1010        let id = Uuid::new_v4();
1011        let now = Utc::now();
1012
1013        conn.execute(
1014            "INSERT INTO quality_holds (id, sku, lot_number, serial_number, location_id,
1015                                        quantity_held, reason, hold_type, ncr_id, inspection_id,
1016                                        placed_by, placed_at, expires_at)
1017             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1018            rusqlite::params![
1019                id.to_string(),
1020                &input.sku,
1021                &input.lot_number,
1022                &input.serial_number,
1023                input.location_id,
1024                input.quantity.to_string(),
1025                &input.reason,
1026                input.hold_type.to_string(),
1027                input.ncr_id.map(|i| i.to_string()),
1028                input.inspection_id.map(|i| i.to_string()),
1029                &input.placed_by,
1030                now.to_rfc3339(),
1031                input.expires_at.map(|d| d.to_rfc3339()),
1032            ],
1033        )
1034        .map_err(map_db_error)?;
1035
1036        Ok(QualityHold {
1037            id,
1038            sku: input.sku,
1039            lot_number: input.lot_number,
1040            serial_number: input.serial_number,
1041            location_id: input.location_id,
1042            quantity_held: input.quantity,
1043            reason: input.reason,
1044            hold_type: input.hold_type,
1045            ncr_id: input.ncr_id,
1046            inspection_id: input.inspection_id,
1047            placed_by: input.placed_by,
1048            released_by: None,
1049            release_notes: None,
1050            placed_at: now,
1051            released_at: None,
1052            expires_at: input.expires_at,
1053        })
1054    }
1055
1056    fn get_hold(&self, id: Uuid) -> Result<Option<QualityHold>> {
1057        let conn = self.conn()?;
1058        let result = conn.query_row(
1059            "SELECT * FROM quality_holds WHERE id = ?",
1060            [id.to_string()],
1061            Self::row_to_hold,
1062        );
1063
1064        match result {
1065            Ok(hold) => Ok(Some(hold)),
1066            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1067            Err(e) => Err(map_db_error(e)),
1068        }
1069    }
1070
1071    fn list_holds(&self, filter: QualityHoldFilter) -> Result<Vec<QualityHold>> {
1072        let conn = self.conn()?;
1073
1074        let mut conditions = vec!["1=1"];
1075        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
1076
1077        if let Some(sku) = &filter.sku {
1078            conditions.push("sku = ?");
1079            params.push(Box::new(sku.clone()));
1080        }
1081        if let Some(lot_number) = &filter.lot_number {
1082            conditions.push("lot_number = ?");
1083            params.push(Box::new(lot_number.clone()));
1084        }
1085        if let Some(hold_type) = &filter.hold_type {
1086            conditions.push("hold_type = ?");
1087            params.push(Box::new(hold_type.to_string()));
1088        }
1089        if let Some(location_id) = filter.location_id {
1090            conditions.push("location_id = ?");
1091            params.push(Box::new(location_id));
1092        }
1093        if filter.active_only.unwrap_or(false) {
1094            conditions.push("released_at IS NULL");
1095        }
1096
1097        let limit = filter.limit.unwrap_or(100);
1098        let offset = filter.offset.unwrap_or(0);
1099
1100        let sql = format!(
1101            "SELECT * FROM quality_holds WHERE {} ORDER BY placed_at DESC LIMIT ? OFFSET ?",
1102            conditions.join(" AND ")
1103        );
1104
1105        params.push(Box::new(i64::from(limit)));
1106        params.push(Box::new(i64::from(offset)));
1107
1108        let params_refs: Vec<&dyn rusqlite::ToSql> =
1109            params.iter().map(std::convert::AsRef::as_ref).collect();
1110
1111        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1112        let holds = stmt
1113            .query_map(params_refs.as_slice(), Self::row_to_hold)
1114            .map_err(map_db_error)?
1115            .collect::<rusqlite::Result<Vec<_>>>()
1116            .map_err(map_db_error)?;
1117
1118        Ok(holds)
1119    }
1120
1121    fn release_hold(&self, id: Uuid, input: ReleaseQualityHold) -> Result<QualityHold> {
1122        let conn = self.conn()?;
1123        let now = Utc::now();
1124
1125        conn.execute(
1126            "UPDATE quality_holds SET released_by = ?, release_notes = ?, released_at = ? WHERE id = ?",
1127            rusqlite::params![
1128                &input.released_by,
1129                &input.release_notes,
1130                now.to_rfc3339(),
1131                id.to_string(),
1132            ],
1133        )
1134        .map_err(map_db_error)?;
1135
1136        self.get_hold(id)?.ok_or(CommerceError::NotFound)
1137    }
1138
1139    fn get_active_holds_for_sku(&self, sku: &str) -> Result<Vec<QualityHold>> {
1140        self.list_holds(QualityHoldFilter {
1141            sku: Some(sku.to_string()),
1142            active_only: Some(true),
1143            ..Default::default()
1144        })
1145    }
1146
1147    fn get_active_holds_for_lot(&self, lot_number: &str) -> Result<Vec<QualityHold>> {
1148        self.list_holds(QualityHoldFilter {
1149            lot_number: Some(lot_number.to_string()),
1150            active_only: Some(true),
1151            ..Default::default()
1152        })
1153    }
1154
1155    fn count_active_holds(&self) -> Result<u64> {
1156        let conn = self.conn()?;
1157        conn.query_row("SELECT COUNT(*) FROM quality_holds WHERE released_at IS NULL", [], |row| {
1158            row.get::<_, i64>(0)
1159        })
1160        .map(|c| c as u64)
1161        .map_err(map_db_error)
1162    }
1163
1164    fn create_defect_code(&self, input: CreateDefectCode) -> Result<DefectCode> {
1165        let conn = self.conn()?;
1166        let id = Uuid::new_v4();
1167        let now = Utc::now();
1168
1169        conn.execute(
1170            "INSERT INTO defect_codes (id, code, name, description, category, severity, is_active, created_at)
1171             VALUES (?, ?, ?, ?, ?, ?, 1, ?)",
1172            rusqlite::params![
1173                id.to_string(),
1174                &input.code,
1175                &input.name,
1176                &input.description,
1177                &input.category,
1178                input.severity.to_string(),
1179                now.to_rfc3339(),
1180            ],
1181        )
1182        .map_err(map_db_error)?;
1183
1184        Ok(DefectCode {
1185            id,
1186            code: input.code,
1187            name: input.name,
1188            description: input.description,
1189            category: input.category,
1190            severity: input.severity,
1191            is_active: true,
1192            created_at: now,
1193        })
1194    }
1195
1196    fn get_defect_code(&self, code: &str) -> Result<Option<DefectCode>> {
1197        let conn = self.conn()?;
1198        let result = conn.query_row(
1199            "SELECT * FROM defect_codes WHERE code = ?",
1200            [code],
1201            Self::row_to_defect_code,
1202        );
1203
1204        match result {
1205            Ok(dc) => Ok(Some(dc)),
1206            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1207            Err(e) => Err(map_db_error(e)),
1208        }
1209    }
1210
1211    fn list_defect_codes(&self, category: Option<&str>) -> Result<Vec<DefectCode>> {
1212        let conn = self.conn()?;
1213
1214        let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(cat) = category {
1215            (
1216                "SELECT * FROM defect_codes WHERE category = ? AND is_active = 1 ORDER BY code"
1217                    .to_string(),
1218                vec![Box::new(cat.to_string())],
1219            )
1220        } else {
1221            ("SELECT * FROM defect_codes WHERE is_active = 1 ORDER BY code".to_string(), vec![])
1222        };
1223
1224        let params_refs: Vec<&dyn rusqlite::ToSql> =
1225            params.iter().map(std::convert::AsRef::as_ref).collect();
1226
1227        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1228        let codes = stmt
1229            .query_map(params_refs.as_slice(), Self::row_to_defect_code)
1230            .map_err(map_db_error)?
1231            .collect::<rusqlite::Result<Vec<_>>>()
1232            .map_err(map_db_error)?;
1233
1234        Ok(codes)
1235    }
1236
1237    fn deactivate_defect_code(&self, id: Uuid) -> Result<()> {
1238        let conn = self.conn()?;
1239        conn.execute("UPDATE defect_codes SET is_active = 0 WHERE id = ?", [id.to_string()])
1240            .map_err(map_db_error)?;
1241        Ok(())
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use crate::SqliteDatabase;
1249    use rust_decimal_macros::dec;
1250    use stateset_core::{
1251        CreateDefectCode, CreateInspection, CreateInspectionItem, CreateNonConformance,
1252        CreateQualityHold, InspectionFilter, InspectionType, NonConformanceFilter,
1253        NonConformanceSource, QualityHoldFilter, QualityRepository, Severity,
1254    };
1255
1256    fn fresh_repo() -> SqliteQualityRepository {
1257        SqliteDatabase::in_memory().expect("in-memory").quality()
1258    }
1259
1260    fn make_inspection(repo: &SqliteQualityRepository) -> Inspection {
1261        repo.create_inspection(CreateInspection {
1262            inspection_type: InspectionType::Incoming,
1263            reference_type: "purchase_order".into(),
1264            reference_id: Uuid::new_v4(),
1265            inspector_id: Some("inspector-1".into()),
1266            scheduled_at: None,
1267            notes: Some("incoming check".into()),
1268            items: vec![CreateInspectionItem {
1269                sku: "WIDGET-1".into(),
1270                lot_number: None,
1271                serial_number: None,
1272                quantity_to_inspect: dec!(10),
1273            }],
1274        })
1275        .expect("create inspection")
1276    }
1277
1278    #[test]
1279    fn create_inspection_round_trips() {
1280        let repo = fresh_repo();
1281        let i = make_inspection(&repo);
1282        assert_eq!(i.inspection_type, InspectionType::Incoming);
1283        assert!(!i.inspection_number.is_empty());
1284
1285        let by_id = repo.get_inspection(i.id).expect("ok").expect("found");
1286        assert_eq!(by_id.id, i.id);
1287        let by_num =
1288            repo.get_inspection_by_number(&i.inspection_number).expect("ok").expect("found");
1289        assert_eq!(by_num.id, i.id);
1290        assert!(repo.get_inspection_by_number("missing").expect("ok").is_none());
1291
1292        let items = repo.get_inspection_items(i.id).expect("items");
1293        assert_eq!(items.len(), 1);
1294        assert_eq!(items[0].sku, "WIDGET-1");
1295    }
1296
1297    #[test]
1298    fn list_inspections_filters_by_type() {
1299        let repo = fresh_repo();
1300        make_inspection(&repo);
1301        repo.create_inspection(CreateInspection {
1302            inspection_type: InspectionType::Final,
1303            reference_type: "shipment".into(),
1304            reference_id: Uuid::new_v4(),
1305            inspector_id: None,
1306            scheduled_at: None,
1307            notes: None,
1308            items: vec![CreateInspectionItem {
1309                sku: "FINAL-1".into(),
1310                quantity_to_inspect: dec!(1),
1311                ..Default::default()
1312            }],
1313        })
1314        .expect("final");
1315
1316        let incoming = repo
1317            .list_inspections(InspectionFilter {
1318                inspection_type: Some(InspectionType::Incoming),
1319                ..Default::default()
1320            })
1321            .expect("incoming");
1322        assert!(incoming.iter().all(|i| i.inspection_type == InspectionType::Incoming));
1323        assert!(!incoming.is_empty());
1324    }
1325
1326    #[test]
1327    fn create_ncr_round_trips() {
1328        let repo = fresh_repo();
1329        let ncr = repo
1330            .create_ncr(CreateNonConformance {
1331                inspection_id: None,
1332                source: NonConformanceSource::InternalAudit,
1333                severity: Severity::Major,
1334                sku: "SKU-NCR".into(),
1335                lot_number: None,
1336                serial_number: None,
1337                quantity_affected: dec!(5),
1338                description: "scratched units".into(),
1339                assigned_to: Some("qa-1".into()),
1340            })
1341            .expect("create ncr");
1342        assert_eq!(ncr.sku, "SKU-NCR");
1343        assert!(!ncr.ncr_number.is_empty());
1344
1345        let by_id = repo.get_ncr(ncr.id).expect("ok").expect("found");
1346        assert_eq!(by_id.id, ncr.id);
1347        let by_num = repo.get_ncr_by_number(&ncr.ncr_number).expect("ok").expect("found");
1348        assert_eq!(by_num.id, ncr.id);
1349        assert!(repo.get_ncr_by_number("missing").expect("ok").is_none());
1350    }
1351
1352    #[test]
1353    fn list_ncrs_filters_by_severity() {
1354        let repo = fresh_repo();
1355        repo.create_ncr(CreateNonConformance {
1356            inspection_id: None,
1357            source: NonConformanceSource::InternalAudit,
1358            severity: Severity::Minor,
1359            sku: "SKU-1".into(),
1360            lot_number: None,
1361            serial_number: None,
1362            quantity_affected: dec!(1),
1363            description: "minor".into(),
1364            assigned_to: None,
1365        })
1366        .expect("minor");
1367        repo.create_ncr(CreateNonConformance {
1368            inspection_id: None,
1369            source: NonConformanceSource::InternalAudit,
1370            severity: Severity::Major,
1371            sku: "SKU-2".into(),
1372            lot_number: None,
1373            serial_number: None,
1374            quantity_affected: dec!(5),
1375            description: "major".into(),
1376            assigned_to: None,
1377        })
1378        .expect("major");
1379
1380        let majors = repo
1381            .list_ncrs(NonConformanceFilter {
1382                severity: Some(Severity::Major),
1383                ..Default::default()
1384            })
1385            .expect("majors");
1386        assert!(majors.iter().all(|n| n.severity == Severity::Major));
1387        assert!(!majors.is_empty());
1388    }
1389
1390    #[test]
1391    fn list_ncrs_filters_by_sku_and_source() {
1392        let repo = fresh_repo();
1393        repo.create_ncr(CreateNonConformance {
1394            inspection_id: None,
1395            source: NonConformanceSource::SupplierIssue,
1396            severity: Severity::Minor,
1397            sku: "WIDGET".into(),
1398            lot_number: None,
1399            serial_number: None,
1400            quantity_affected: dec!(1),
1401            description: "supplier".into(),
1402            assigned_to: None,
1403        })
1404        .expect("supplier ncr");
1405        repo.create_ncr(CreateNonConformance {
1406            inspection_id: None,
1407            source: NonConformanceSource::InternalAudit,
1408            severity: Severity::Minor,
1409            sku: "GADGET".into(),
1410            lot_number: None,
1411            serial_number: None,
1412            quantity_affected: dec!(1),
1413            description: "audit".into(),
1414            assigned_to: None,
1415        })
1416        .expect("audit ncr");
1417
1418        let by_sku = repo
1419            .list_ncrs(NonConformanceFilter { sku: Some("WIDGET".into()), ..Default::default() })
1420            .expect("by sku");
1421        assert_eq!(by_sku.len(), 1);
1422        assert!(by_sku.iter().all(|n| n.sku == "WIDGET"), "sku filter must exclude other SKUs");
1423
1424        let by_source = repo
1425            .list_ncrs(NonConformanceFilter {
1426                source: Some(NonConformanceSource::SupplierIssue),
1427                ..Default::default()
1428            })
1429            .expect("by source");
1430        assert!(by_source.iter().all(|n| n.source == NonConformanceSource::SupplierIssue));
1431        assert_eq!(by_source.len(), 1);
1432    }
1433
1434    #[test]
1435    fn list_ncrs_filters_by_date_range() {
1436        let repo = fresh_repo();
1437        let ncr = repo
1438            .create_ncr(CreateNonConformance {
1439                inspection_id: None,
1440                source: NonConformanceSource::InternalAudit,
1441                severity: Severity::Minor,
1442                sku: "DATED".into(),
1443                lot_number: None,
1444                serial_number: None,
1445                quantity_affected: dec!(1),
1446                description: "dated".into(),
1447                assigned_to: None,
1448            })
1449            .expect("ncr");
1450        let past = chrono::Utc::now() - chrono::Duration::days(1);
1451
1452        // to_date in the past must exclude the just-created NCR.
1453        let before = repo
1454            .list_ncrs(NonConformanceFilter { to_date: Some(past), ..Default::default() })
1455            .expect("before");
1456        assert!(!before.iter().any(|n| n.id == ncr.id), "to_date must exclude newer NCRs");
1457    }
1458
1459    #[test]
1460    fn list_inspections_filters_by_reference_and_date() {
1461        let repo = fresh_repo();
1462        let shipment_ref = Uuid::new_v4();
1463        repo.create_inspection(CreateInspection {
1464            inspection_type: InspectionType::Final,
1465            reference_type: "shipment".into(),
1466            reference_id: shipment_ref,
1467            inspector_id: Some("insp-1".into()),
1468            scheduled_at: None,
1469            notes: None,
1470            items: vec![CreateInspectionItem {
1471                sku: "REF-1".into(),
1472                quantity_to_inspect: dec!(1),
1473                ..Default::default()
1474            }],
1475        })
1476        .expect("shipment insp");
1477        make_inspection(&repo); // an unrelated PO-reference inspection
1478
1479        let by_ref = repo
1480            .list_inspections(InspectionFilter {
1481                reference_id: Some(shipment_ref),
1482                ..Default::default()
1483            })
1484            .expect("by ref");
1485        assert_eq!(by_ref.len(), 1);
1486        assert!(by_ref.iter().all(|i| i.reference_id == shipment_ref));
1487
1488        let past = chrono::Utc::now() - chrono::Duration::days(1);
1489        let before = repo
1490            .list_inspections(InspectionFilter { to_date: Some(past), ..Default::default() })
1491            .expect("before");
1492        assert!(before.is_empty(), "to_date in the past must exclude all inspections");
1493    }
1494
1495    #[test]
1496    fn create_hold_and_get_active_holds_for_sku() {
1497        let repo = fresh_repo();
1498        let hold = repo
1499            .create_hold(CreateQualityHold {
1500                sku: "HOLD-1".into(),
1501                lot_number: None,
1502                serial_number: None,
1503                location_id: None,
1504                quantity: dec!(20),
1505                reason: "audit".into(),
1506                hold_type: stateset_core::HoldType::default(),
1507                ncr_id: None,
1508                inspection_id: None,
1509                placed_by: "qa".into(),
1510                expires_at: None,
1511            })
1512            .expect("create hold");
1513        assert_eq!(hold.sku, "HOLD-1");
1514
1515        let active_for_sku = repo.get_active_holds_for_sku("HOLD-1").expect("ok");
1516        assert_eq!(active_for_sku.len(), 1);
1517        assert!(repo.get_active_holds_for_sku("MISSING").expect("ok").is_empty());
1518    }
1519
1520    #[test]
1521    fn list_holds_filters_by_active_only() {
1522        let repo = fresh_repo();
1523        repo.create_hold(CreateQualityHold {
1524            sku: "ACT-1".into(),
1525            quantity: dec!(1),
1526            reason: "r".into(),
1527            placed_by: "qa".into(),
1528            ..Default::default()
1529        })
1530        .expect("h1");
1531        let holds = repo
1532            .list_holds(QualityHoldFilter { active_only: Some(true), ..Default::default() })
1533            .expect("active");
1534        assert!(!holds.is_empty());
1535    }
1536
1537    #[test]
1538    fn create_defect_code_round_trips() {
1539        let repo = fresh_repo();
1540        let code = repo
1541            .create_defect_code(CreateDefectCode {
1542                code: "SCRATCH".into(),
1543                name: "Surface Scratch".into(),
1544                description: Some("Visible scratch on surface".into()),
1545                category: "cosmetic".into(),
1546                severity: Severity::Minor,
1547            })
1548            .expect("create code");
1549        assert_eq!(code.code, "SCRATCH");
1550        let by_code = repo.get_defect_code("SCRATCH").expect("ok").expect("found");
1551        assert_eq!(by_code.code, "SCRATCH");
1552        assert!(repo.get_defect_code("missing").expect("ok").is_none());
1553
1554        let listed = repo.list_defect_codes(None).expect("list");
1555        assert!(listed.iter().any(|c| c.code == "SCRATCH"));
1556
1557        let by_cat = repo.list_defect_codes(Some("cosmetic")).expect("by cat");
1558        assert!(by_cat.iter().any(|c| c.category == "cosmetic"));
1559    }
1560
1561    #[test]
1562    fn unknown_inspection_returns_none() {
1563        let repo = fresh_repo();
1564        assert!(repo.get_inspection(Uuid::new_v4()).expect("ok").is_none());
1565    }
1566
1567    #[test]
1568    fn unknown_ncr_returns_none() {
1569        let repo = fresh_repo();
1570        assert!(repo.get_ncr(Uuid::new_v4()).expect("ok").is_none());
1571    }
1572
1573    #[test]
1574    fn unknown_hold_returns_none() {
1575        let repo = fresh_repo();
1576        assert!(repo.get_hold(Uuid::new_v4()).expect("ok").is_none());
1577    }
1578
1579    #[test]
1580    fn list_inspections_after_cursor_paginates_without_overlap() {
1581        let repo = fresh_repo();
1582        for _ in 0..3 {
1583            make_inspection(&repo);
1584        }
1585        let all = repo.list_inspections(InspectionFilter::default()).expect("list all");
1586        assert_eq!(all.len(), 3);
1587
1588        let first_page = repo
1589            .list_inspections(InspectionFilter { limit: Some(2), ..Default::default() })
1590            .expect("page 1");
1591        assert_eq!(first_page.len(), 2);
1592        assert_eq!(first_page[0].id, all[0].id);
1593
1594        let last = &first_page[1];
1595        let second_page = repo
1596            .list_inspections(InspectionFilter {
1597                after_cursor: Some((last.created_at.to_rfc3339(), last.id.to_string())),
1598                ..Default::default()
1599            })
1600            .expect("page 2");
1601        assert_eq!(second_page.len(), 1);
1602        assert_eq!(second_page[0].id, all[2].id);
1603    }
1604}