1use 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 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 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 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 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 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 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 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 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 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 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 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 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); 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}