1use chrono::Utc;
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::{Row, params};
7use stateset_core::CommerceError;
8use stateset_core::{
9 BatchResult, ChangeSerialStatus, CreateSerialNumber, CreateSerialNumbersBulk, LotRepository,
10 MoveSerial, ReserveSerialNumber, SerialEventType, SerialFilter, SerialHistory,
11 SerialHistoryFilter, SerialLookupResult, SerialNumber, SerialRepository, SerialReservation,
12 SerialStatus, SerialValidation, TransferSerialOwnership, UpdateSerialNumber, WarrantyId,
13 WarrantyLookupStatus, WarrantyRepository,
14};
15use uuid::Uuid;
16
17use super::{
18 SqliteLotRepository, SqliteWarrantyRepository, build_in_clause, map_db_error, params_refs,
19 parse_datetime_opt_row, parse_datetime_row, parse_enum_row, parse_json_opt_row,
20 parse_uuid_opt_row, parse_uuid_row, string_params, uuid_params,
21};
22
23#[derive(Debug)]
25pub struct SqliteSerialRepository {
26 pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqliteSerialRepository {
30 #[must_use]
31 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
32 Self { pool }
33 }
34
35 fn serial_filter_conditions(
39 filter: &SerialFilter,
40 ) -> (Vec<String>, Vec<Box<dyn rusqlite::ToSql>>) {
41 let mut conditions = Vec::new();
42 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
43
44 if let Some(serial) = &filter.serial {
45 conditions.push("serial = ?".to_string());
46 params.push(Box::new(serial.clone()));
47 }
48 if let Some(prefix) = &filter.serial_prefix {
49 conditions.push("serial LIKE ?".to_string());
50 params.push(Box::new(format!("{prefix}%")));
51 }
52 if let Some(sku) = &filter.sku {
53 conditions.push("sku = ?".to_string());
54 params.push(Box::new(sku.clone()));
55 }
56 if let Some(status) = &filter.status {
57 conditions.push("status = ?".to_string());
58 params.push(Box::new(status.to_string()));
59 }
60 if let Some(statuses) = &filter.statuses {
61 let placeholders = build_in_clause(statuses.len());
62 conditions.push(format!("status IN ({placeholders})"));
63 for s in statuses {
64 params.push(Box::new(s.to_string()));
65 }
66 }
67 if let Some(lot_id) = &filter.lot_id {
68 conditions.push("lot_id = ?".to_string());
69 params.push(Box::new(lot_id.to_string()));
70 }
71 if let Some(lot_number) = &filter.lot_number {
72 conditions.push("lot_number = ?".to_string());
73 params.push(Box::new(lot_number.clone()));
74 }
75 if let Some(loc_id) = filter.location_id {
76 conditions.push("current_location_id = ?".to_string());
77 params.push(Box::new(loc_id));
78 }
79 if let Some(owner_id) = &filter.owner_id {
80 conditions.push("current_owner_id = ?".to_string());
81 params.push(Box::new(owner_id.to_string()));
82 }
83 if let Some(owner_type) = &filter.owner_type {
84 conditions.push("current_owner_type = ?".to_string());
85 params.push(Box::new(owner_type.clone()));
86 }
87 if let Some(warranty_id) = &filter.warranty_id {
88 conditions.push("warranty_id = ?".to_string());
89 params.push(Box::new(warranty_id.to_string()));
90 }
91 if let Some(has_warranty) = filter.has_warranty {
92 if has_warranty {
93 conditions.push("warranty_id IS NOT NULL".to_string());
94 } else {
95 conditions.push("warranty_id IS NULL".to_string());
96 }
97 }
98 if let Some(after) = &filter.manufactured_after {
99 conditions.push("manufactured_at >= ?".to_string());
100 params.push(Box::new(after.to_rfc3339()));
101 }
102 if let Some(before) = &filter.manufactured_before {
103 conditions.push("manufactured_at <= ?".to_string());
104 params.push(Box::new(before.to_rfc3339()));
105 }
106 if let Some(after) = &filter.sold_after {
107 conditions.push("sold_at >= ?".to_string());
108 params.push(Box::new(after.to_rfc3339()));
109 }
110 if let Some(before) = &filter.sold_before {
111 conditions.push("sold_at <= ?".to_string());
112 params.push(Box::new(before.to_rfc3339()));
113 }
114
115 (conditions, params)
116 }
117
118 fn map_serial_row(row: &Row<'_>) -> Result<SerialNumber, rusqlite::Error> {
119 let status_str: String = row.get("status")?;
120 let attributes_str: Option<String> = row.get("attributes")?;
121
122 Ok(SerialNumber {
123 id: parse_uuid_row(&row.get::<_, String>("id")?, "serial", "id")?,
124 serial: row.get("serial")?,
125 sku: row.get("sku")?,
126 status: parse_enum_row(&status_str, "serial", "status")?,
127 lot_id: parse_uuid_opt_row(
128 row.get::<_, Option<String>>("lot_id")?,
129 "serial",
130 "lot_id",
131 )?,
132 lot_number: row.get("lot_number")?,
133 current_location_id: row.get("current_location_id")?,
134 current_owner_id: parse_uuid_opt_row(
135 row.get::<_, Option<String>>("current_owner_id")?,
136 "serial",
137 "current_owner_id",
138 )?,
139 current_owner_type: row.get("current_owner_type")?,
140 warranty_id: parse_uuid_opt_row(
141 row.get::<_, Option<String>>("warranty_id")?,
142 "serial",
143 "warranty_id",
144 )?,
145 manufactured_at: parse_datetime_opt_row(
146 row.get::<_, Option<String>>("manufactured_at")?,
147 "serial",
148 "manufactured_at",
149 )?,
150 received_at: parse_datetime_opt_row(
151 row.get::<_, Option<String>>("received_at")?,
152 "serial",
153 "received_at",
154 )?,
155 sold_at: parse_datetime_opt_row(
156 row.get::<_, Option<String>>("sold_at")?,
157 "serial",
158 "sold_at",
159 )?,
160 activated_at: parse_datetime_opt_row(
161 row.get::<_, Option<String>>("activated_at")?,
162 "serial",
163 "activated_at",
164 )?,
165 last_service_at: parse_datetime_opt_row(
166 row.get::<_, Option<String>>("last_service_at")?,
167 "serial",
168 "last_service_at",
169 )?,
170 notes: row.get("notes")?,
171 attributes: parse_json_opt_row(attributes_str, "serial", "attributes")?
172 .unwrap_or(serde_json::Value::Null),
173 created_at: parse_datetime_row(
174 &row.get::<_, String>("created_at")?,
175 "serial",
176 "created_at",
177 )?,
178 updated_at: parse_datetime_row(
179 &row.get::<_, String>("updated_at")?,
180 "serial",
181 "updated_at",
182 )?,
183 })
184 }
185
186 fn map_history_row(row: &Row<'_>) -> Result<SerialHistory, rusqlite::Error> {
187 let event_type_str: String = row.get("event_type")?;
188 let from_status_str: String = row.get("from_status")?;
189 let to_status_str: String = row.get("to_status")?;
190
191 Ok(SerialHistory {
192 id: parse_uuid_row(&row.get::<_, String>("id")?, "serial_history", "id")?,
193 serial_id: parse_uuid_row(
194 &row.get::<_, String>("serial_id")?,
195 "serial_history",
196 "serial_id",
197 )?,
198 event_type: parse_enum_row(&event_type_str, "serial_history", "event_type")?,
199 reference_type: row.get("reference_type")?,
200 reference_id: parse_uuid_opt_row(
201 row.get::<_, Option<String>>("reference_id")?,
202 "serial_history",
203 "reference_id",
204 )?,
205 from_status: parse_enum_row(&from_status_str, "serial_history", "from_status")?,
206 to_status: parse_enum_row(&to_status_str, "serial_history", "to_status")?,
207 from_location_id: row.get("from_location_id")?,
208 to_location_id: row.get("to_location_id")?,
209 from_owner_id: parse_uuid_opt_row(
210 row.get::<_, Option<String>>("from_owner_id")?,
211 "serial_history",
212 "from_owner_id",
213 )?,
214 to_owner_id: parse_uuid_opt_row(
215 row.get::<_, Option<String>>("to_owner_id")?,
216 "serial_history",
217 "to_owner_id",
218 )?,
219 performed_by: row.get("performed_by")?,
220 notes: row.get("notes")?,
221 created_at: parse_datetime_row(
222 &row.get::<_, String>("created_at")?,
223 "serial_history",
224 "created_at",
225 )?,
226 })
227 }
228
229 fn map_reservation_row(row: &Row<'_>) -> Result<SerialReservation, rusqlite::Error> {
230 Ok(SerialReservation {
231 id: parse_uuid_row(&row.get::<_, String>("id")?, "serial_reservation", "id")?,
232 serial_id: parse_uuid_row(
233 &row.get::<_, String>("serial_id")?,
234 "serial_reservation",
235 "serial_id",
236 )?,
237 reference_type: row.get("reference_type")?,
238 reference_id: parse_uuid_row(
239 &row.get::<_, String>("reference_id")?,
240 "serial_reservation",
241 "reference_id",
242 )?,
243 reserved_by: row.get("reserved_by")?,
244 reserved_at: parse_datetime_row(
245 &row.get::<_, String>("reserved_at")?,
246 "serial_reservation",
247 "reserved_at",
248 )?,
249 expires_at: parse_datetime_opt_row(
250 row.get::<_, Option<String>>("expires_at")?,
251 "serial_reservation",
252 "expires_at",
253 )?,
254 confirmed_at: parse_datetime_opt_row(
255 row.get::<_, Option<String>>("confirmed_at")?,
256 "serial_reservation",
257 "confirmed_at",
258 )?,
259 released_at: parse_datetime_opt_row(
260 row.get::<_, Option<String>>("released_at")?,
261 "serial_reservation",
262 "released_at",
263 )?,
264 })
265 }
266
267 #[allow(clippy::too_many_arguments, dead_code)]
268 fn record_history(
269 &self,
270 conn: &rusqlite::Connection,
271 serial: &SerialNumber,
272 event_type: SerialEventType,
273 from_status: SerialStatus,
274 to_status: SerialStatus,
275 reference_type: Option<&str>,
276 reference_id: Option<Uuid>,
277 from_location_id: Option<i32>,
278 to_location_id: Option<i32>,
279 from_owner_id: Option<Uuid>,
280 to_owner_id: Option<Uuid>,
281 performed_by: Option<&str>,
282 notes: Option<&str>,
283 ) -> Result<(), rusqlite::Error> {
284 let id = Uuid::new_v4();
285 let now = Utc::now().to_rfc3339();
286
287 conn.execute(
288 "INSERT INTO serial_history (
289 id, serial_id, event_type, reference_type, reference_id,
290 from_status, to_status, from_location_id, to_location_id,
291 from_owner_id, to_owner_id, performed_by, notes, created_at
292 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
293 params![
294 id.to_string(),
295 serial.id.to_string(),
296 event_type.to_string(),
297 reference_type,
298 reference_id.map(|id| id.to_string()),
299 from_status.to_string(),
300 to_status.to_string(),
301 from_location_id,
302 to_location_id,
303 from_owner_id.map(|id| id.to_string()),
304 to_owner_id.map(|id| id.to_string()),
305 performed_by,
306 notes,
307 now,
308 ],
309 )?;
310
311 Ok(())
312 }
313
314 fn generate_serial(&self, prefix: Option<&str>) -> String {
315 let unique_part = Uuid::new_v4().to_string().replace('-', "").to_uppercase();
316 let short = &unique_part[..12];
317 match prefix {
318 Some(p) => format!("{p}-{short}"),
319 None => format!("SN-{short}"),
320 }
321 }
322}
323
324impl SerialRepository for SqliteSerialRepository {
325 fn create(&self, input: CreateSerialNumber) -> stateset_core::Result<SerialNumber> {
326 let id = Uuid::new_v4();
327 let now = Utc::now().to_rfc3339();
328 let serial = input.serial.unwrap_or_else(|| self.generate_serial(None));
329 let attributes = input.attributes.unwrap_or(serde_json::Value::Null);
330
331 {
332 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
333 conn.execute(
334 "INSERT INTO serial_numbers (
335 id, serial, sku, status, lot_id, lot_number, current_location_id,
336 manufactured_at, notes, attributes, created_at, updated_at
337 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
338 params![
339 id.to_string(),
340 serial,
341 input.sku,
342 SerialStatus::Available.to_string(),
343 input.lot_id.map(|id| id.to_string()),
344 input.lot_number,
345 input.location_id,
346 input.manufactured_at.map(|dt| dt.to_rfc3339()),
347 input.notes,
348 serde_json::to_string(&attributes).ok(),
349 now,
350 now,
351 ],
352 )
353 .map_err(map_db_error)?;
354 }
355
356 self.get(id)?.ok_or(CommerceError::NotFound)
357 }
358
359 fn create_bulk(
360 &self,
361 input: CreateSerialNumbersBulk,
362 ) -> stateset_core::Result<Vec<SerialNumber>> {
363 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
364 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
365
366 let mut serials = Vec::with_capacity(input.quantity as usize);
367 let now = Utc::now().to_rfc3339();
368
369 for i in 0..input.quantity {
370 let id = Uuid::new_v4();
371 let serial_number = match &input.prefix {
372 Some(prefix) => format!("{}-{:06}", prefix, i + 1),
373 None => self.generate_serial(None),
374 };
375
376 tx.execute(
377 "INSERT INTO serial_numbers (
378 id, serial, sku, status, lot_id, lot_number, current_location_id,
379 manufactured_at, created_at, updated_at
380 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
381 params![
382 id.to_string(),
383 serial_number,
384 input.sku,
385 SerialStatus::Available.to_string(),
386 input.lot_id.map(|id| id.to_string()),
387 input.lot_number,
388 input.location_id,
389 input.manufactured_at.map(|dt| dt.to_rfc3339()),
390 now,
391 now,
392 ],
393 )
394 .map_err(map_db_error)?;
395
396 let history_id = Uuid::new_v4();
398 tx.execute(
399 "INSERT INTO serial_history (
400 id, serial_id, event_type, from_status, to_status, created_at
401 ) VALUES (?, ?, ?, ?, ?, ?)",
402 params![
403 history_id.to_string(),
404 id.to_string(),
405 SerialEventType::Created.to_string(),
406 SerialStatus::Available.to_string(),
407 SerialStatus::Available.to_string(),
408 now,
409 ],
410 )
411 .map_err(map_db_error)?;
412
413 serials.push(SerialNumber {
414 id,
415 serial: serial_number,
416 sku: input.sku.clone(),
417 status: SerialStatus::Available,
418 lot_id: input.lot_id,
419 lot_number: input.lot_number.clone(),
420 current_location_id: input.location_id,
421 current_owner_id: None,
422 current_owner_type: None,
423 warranty_id: None,
424 manufactured_at: input.manufactured_at,
425 received_at: None,
426 sold_at: None,
427 activated_at: None,
428 last_service_at: None,
429 notes: None,
430 attributes: serde_json::Value::Null,
431 created_at: Utc::now(),
432 updated_at: Utc::now(),
433 });
434 }
435
436 tx.commit().map_err(map_db_error)?;
437 Ok(serials)
438 }
439
440 fn get(&self, id: Uuid) -> stateset_core::Result<Option<SerialNumber>> {
441 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
442
443 let result = conn.query_row(
444 "SELECT * FROM serial_numbers WHERE id = ?",
445 params![id.to_string()],
446 Self::map_serial_row,
447 );
448
449 match result {
450 Ok(serial) => Ok(Some(serial)),
451 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
452 Err(e) => Err(map_db_error(e)),
453 }
454 }
455
456 fn get_by_serial(&self, serial: &str) -> stateset_core::Result<Option<SerialNumber>> {
457 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
458
459 let result = conn.query_row(
460 "SELECT * FROM serial_numbers WHERE serial = ?",
461 params![serial],
462 Self::map_serial_row,
463 );
464
465 match result {
466 Ok(serial) => Ok(Some(serial)),
467 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
468 Err(e) => Err(map_db_error(e)),
469 }
470 }
471
472 fn update(&self, id: Uuid, input: UpdateSerialNumber) -> stateset_core::Result<SerialNumber> {
473 let now = Utc::now().to_rfc3339();
474
475 let mut updates = vec!["updated_at = ?".to_string()];
476 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
477
478 if let Some(status) = &input.status {
479 updates.push("status = ?".to_string());
480 params.push(Box::new(status.to_string()));
481 }
482 if let Some(loc) = input.location_id {
483 updates.push("current_location_id = ?".to_string());
484 params.push(Box::new(loc));
485 }
486 if let Some(lot_id) = &input.lot_id {
487 updates.push("lot_id = ?".to_string());
488 params.push(Box::new(lot_id.to_string()));
489 }
490 if let Some(warranty_id) = &input.warranty_id {
491 updates.push("warranty_id = ?".to_string());
492 params.push(Box::new(warranty_id.to_string()));
493 }
494 if let Some(notes) = &input.notes {
495 updates.push("notes = ?".to_string());
496 params.push(Box::new(notes.clone()));
497 }
498 if let Some(attrs) = &input.attributes {
499 updates.push("attributes = ?".to_string());
500 params.push(Box::new(serde_json::to_string(attrs).unwrap_or_default()));
501 }
502
503 params.push(Box::new(id.to_string()));
504
505 let sql = format!("UPDATE serial_numbers SET {} WHERE id = ?", updates.join(", "));
506
507 {
508 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
509 conn.execute(
510 &sql,
511 rusqlite::params_from_iter(params.iter().map(std::convert::AsRef::as_ref)),
512 )
513 .map_err(map_db_error)?;
514 }
515
516 self.get(id)?.ok_or(CommerceError::NotFound)
517 }
518
519 fn list(&self, filter: SerialFilter) -> stateset_core::Result<Vec<SerialNumber>> {
520 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
521
522 let (conditions, mut params) = Self::serial_filter_conditions(&filter);
523
524 let where_clause = if conditions.is_empty() {
525 String::new()
526 } else {
527 format!("WHERE {}", conditions.join(" AND "))
528 };
529
530 let limit = filter.limit.unwrap_or(100);
531 let offset = filter.offset.unwrap_or(0);
532
533 let sql = format!(
534 "SELECT * FROM serial_numbers {where_clause} ORDER BY created_at DESC LIMIT ? OFFSET ?"
535 );
536
537 params.push(Box::new(i64::from(limit)));
538 params.push(Box::new(i64::from(offset)));
539
540 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
541 let rows = stmt
542 .query_map(
543 rusqlite::params_from_iter(params.iter().map(std::convert::AsRef::as_ref)),
544 Self::map_serial_row,
545 )
546 .map_err(map_db_error)?;
547
548 let mut serials = Vec::new();
549 for row in rows {
550 serials.push(row.map_err(map_db_error)?);
551 }
552
553 Ok(serials)
554 }
555
556 fn delete(&self, id: Uuid) -> stateset_core::Result<()> {
557 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
558
559 let serial = self.get(id)?.ok_or(CommerceError::NotFound)?;
561 if serial.status != SerialStatus::Available {
562 return Err(CommerceError::ValidationError(
563 "Can only delete serials with 'available' status".to_string(),
564 ));
565 }
566
567 let history_count: i64 = conn
569 .query_row(
570 "SELECT COUNT(*) FROM serial_history WHERE serial_id = ? AND event_type != ?",
571 params![id.to_string(), SerialEventType::Created.to_string()],
572 |row| row.get(0),
573 )
574 .map_err(map_db_error)?;
575
576 if history_count > 0 {
577 return Err(CommerceError::ValidationError(
578 "Cannot delete serial with transaction history".to_string(),
579 ));
580 }
581
582 conn.execute("DELETE FROM serial_history WHERE serial_id = ?", params![id.to_string()])
584 .map_err(map_db_error)?;
585
586 conn.execute(
588 "DELETE FROM serial_reservations WHERE serial_id = ?",
589 params![id.to_string()],
590 )
591 .map_err(map_db_error)?;
592
593 conn.execute("DELETE FROM serial_numbers WHERE id = ?", params![id.to_string()])
595 .map_err(map_db_error)?;
596
597 Ok(())
598 }
599
600 fn change_status(&self, input: ChangeSerialStatus) -> stateset_core::Result<SerialNumber> {
601 {
602 let mut conn =
603 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
604 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
605 let now = Utc::now().to_rfc3339();
606
607 let serial: SerialNumber = tx
609 .query_row(
610 "SELECT * FROM serial_numbers WHERE id = ?",
611 params![input.serial_id.to_string()],
612 Self::map_serial_row,
613 )
614 .map_err(map_db_error)?;
615
616 let old_status = serial.status;
617
618 tx.execute(
620 "UPDATE serial_numbers SET
621 status = ?,
622 current_location_id = COALESCE(?, current_location_id),
623 current_owner_id = COALESCE(?, current_owner_id),
624 current_owner_type = COALESCE(?, current_owner_type),
625 updated_at = ?
626 WHERE id = ?",
627 params![
628 input.new_status.to_string(),
629 input.location_id,
630 input.owner_id.map(|id| id.to_string()),
631 input.owner_type,
632 now,
633 input.serial_id.to_string(),
634 ],
635 )
636 .map_err(map_db_error)?;
637
638 let history_id = Uuid::new_v4();
640 tx.execute(
641 "INSERT INTO serial_history (
642 id, serial_id, event_type, reference_type, reference_id,
643 from_status, to_status, from_location_id, to_location_id,
644 from_owner_id, to_owner_id, performed_by, notes, created_at
645 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
646 params![
647 history_id.to_string(),
648 input.serial_id.to_string(),
649 SerialEventType::StatusChanged.to_string(),
650 input.reference_type,
651 input.reference_id.map(|id| id.to_string()),
652 old_status.to_string(),
653 input.new_status.to_string(),
654 serial.current_location_id,
655 input.location_id,
656 serial.current_owner_id.map(|id| id.to_string()),
657 input.owner_id.map(|id| id.to_string()),
658 input.performed_by,
659 input.notes,
660 now,
661 ],
662 )
663 .map_err(map_db_error)?;
664
665 tx.commit().map_err(map_db_error)?;
666 }
667 self.get(input.serial_id)?.ok_or(CommerceError::NotFound)
668 }
669
670 fn reserve(&self, input: ReserveSerialNumber) -> stateset_core::Result<SerialReservation> {
671 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
672 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
673 let now = Utc::now();
674 let now_str = now.to_rfc3339();
675
676 let serial: SerialNumber = tx
678 .query_row(
679 "SELECT * FROM serial_numbers WHERE id = ?",
680 params![input.serial_id.to_string()],
681 Self::map_serial_row,
682 )
683 .map_err(map_db_error)?;
684
685 if serial.status != SerialStatus::Available {
686 return Err(CommerceError::ValidationError(format!(
687 "Serial is not available for reservation, current status: {}",
688 serial.status
689 )));
690 }
691
692 let existing: i64 = tx
694 .query_row(
695 "SELECT COUNT(*) FROM serial_reservations
696 WHERE serial_id = ? AND released_at IS NULL AND confirmed_at IS NULL
697 AND (expires_at IS NULL OR expires_at > ?)",
698 params![input.serial_id.to_string(), now_str],
699 |row| row.get(0),
700 )
701 .map_err(map_db_error)?;
702
703 if existing > 0 {
704 return Err(CommerceError::ValidationError(
705 "Serial already has an active reservation".to_string(),
706 ));
707 }
708
709 let id = Uuid::new_v4();
710 let expires_at = input.expires_in_seconds.map(|secs| now + chrono::Duration::seconds(secs));
711
712 tx.execute(
714 "INSERT INTO serial_reservations (
715 id, serial_id, reference_type, reference_id, reserved_by,
716 reserved_at, expires_at
717 ) VALUES (?, ?, ?, ?, ?, ?, ?)",
718 params![
719 id.to_string(),
720 input.serial_id.to_string(),
721 input.reference_type,
722 input.reference_id.to_string(),
723 input.reserved_by,
724 now_str,
725 expires_at.map(|dt| dt.to_rfc3339()),
726 ],
727 )
728 .map_err(map_db_error)?;
729
730 tx.execute(
732 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
733 params![SerialStatus::Reserved.to_string(), now_str, input.serial_id.to_string(),],
734 )
735 .map_err(map_db_error)?;
736
737 let history_id = Uuid::new_v4();
739 tx.execute(
740 "INSERT INTO serial_history (
741 id, serial_id, event_type, reference_type, reference_id,
742 from_status, to_status, performed_by, created_at
743 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
744 params![
745 history_id.to_string(),
746 input.serial_id.to_string(),
747 SerialEventType::Reserved.to_string(),
748 input.reference_type,
749 input.reference_id.to_string(),
750 SerialStatus::Available.to_string(),
751 SerialStatus::Reserved.to_string(),
752 input.reserved_by,
753 now_str,
754 ],
755 )
756 .map_err(map_db_error)?;
757
758 tx.commit().map_err(map_db_error)?;
759
760 Ok(SerialReservation {
761 id,
762 serial_id: input.serial_id,
763 reference_type: input.reference_type,
764 reference_id: input.reference_id,
765 reserved_by: input.reserved_by,
766 reserved_at: now,
767 expires_at,
768 confirmed_at: None,
769 released_at: None,
770 })
771 }
772
773 fn release_reservation(&self, reservation_id: Uuid) -> stateset_core::Result<()> {
774 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
775 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
776 let now = Utc::now().to_rfc3339();
777
778 let reservation: SerialReservation = tx
780 .query_row(
781 "SELECT * FROM serial_reservations WHERE id = ?",
782 params![reservation_id.to_string()],
783 Self::map_reservation_row,
784 )
785 .map_err(map_db_error)?;
786
787 if reservation.released_at.is_some() || reservation.confirmed_at.is_some() {
788 return Err(CommerceError::ValidationError(
789 "Reservation is already released or confirmed".to_string(),
790 ));
791 }
792
793 tx.execute(
795 "UPDATE serial_reservations SET released_at = ? WHERE id = ?",
796 params![now, reservation_id.to_string()],
797 )
798 .map_err(map_db_error)?;
799
800 tx.execute(
802 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
803 params![SerialStatus::Available.to_string(), now, reservation.serial_id.to_string(),],
804 )
805 .map_err(map_db_error)?;
806
807 let history_id = Uuid::new_v4();
809 tx.execute(
810 "INSERT INTO serial_history (
811 id, serial_id, event_type, reference_type, reference_id,
812 from_status, to_status, created_at
813 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
814 params![
815 history_id.to_string(),
816 reservation.serial_id.to_string(),
817 SerialEventType::Released.to_string(),
818 reservation.reference_type,
819 reservation.reference_id.to_string(),
820 SerialStatus::Reserved.to_string(),
821 SerialStatus::Available.to_string(),
822 now,
823 ],
824 )
825 .map_err(map_db_error)?;
826
827 tx.commit().map_err(map_db_error)?;
828 Ok(())
829 }
830
831 fn confirm_reservation(&self, reservation_id: Uuid) -> stateset_core::Result<()> {
832 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
833 let now = Utc::now().to_rfc3339();
834
835 let reservation: SerialReservation = conn
837 .query_row(
838 "SELECT * FROM serial_reservations WHERE id = ?",
839 params![reservation_id.to_string()],
840 Self::map_reservation_row,
841 )
842 .map_err(map_db_error)?;
843
844 if reservation.released_at.is_some() {
845 return Err(CommerceError::ValidationError(
846 "Reservation has been released".to_string(),
847 ));
848 }
849
850 if reservation.confirmed_at.is_some() {
851 return Ok(()); }
853
854 conn.execute(
855 "UPDATE serial_reservations SET confirmed_at = ? WHERE id = ?",
856 params![now, reservation_id.to_string()],
857 )
858 .map_err(map_db_error)?;
859
860 Ok(())
861 }
862
863 fn move_serial(&self, input: MoveSerial) -> stateset_core::Result<SerialNumber> {
864 {
865 let mut conn =
866 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
867 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
868 let now = Utc::now().to_rfc3339();
869
870 let serial: SerialNumber = tx
872 .query_row(
873 "SELECT * FROM serial_numbers WHERE id = ?",
874 params![input.serial_id.to_string()],
875 Self::map_serial_row,
876 )
877 .map_err(map_db_error)?;
878
879 let from_location = serial.current_location_id;
880
881 tx.execute(
883 "UPDATE serial_numbers SET current_location_id = ?, updated_at = ? WHERE id = ?",
884 params![input.to_location_id, now, input.serial_id.to_string()],
885 )
886 .map_err(map_db_error)?;
887
888 let history_id = Uuid::new_v4();
890 tx.execute(
891 "INSERT INTO serial_history (
892 id, serial_id, event_type, from_status, to_status,
893 from_location_id, to_location_id, performed_by, notes, created_at
894 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
895 params![
896 history_id.to_string(),
897 input.serial_id.to_string(),
898 SerialEventType::LocationChanged.to_string(),
899 serial.status.to_string(),
900 serial.status.to_string(),
901 from_location,
902 input.to_location_id,
903 input.performed_by,
904 input.notes,
905 now,
906 ],
907 )
908 .map_err(map_db_error)?;
909
910 tx.commit().map_err(map_db_error)?;
911 }
912 self.get(input.serial_id)?.ok_or(CommerceError::NotFound)
913 }
914
915 fn transfer_ownership(
916 &self,
917 input: TransferSerialOwnership,
918 ) -> stateset_core::Result<SerialNumber> {
919 {
920 let mut conn =
921 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
922 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
923 let now = Utc::now().to_rfc3339();
924
925 let serial: SerialNumber = tx
927 .query_row(
928 "SELECT * FROM serial_numbers WHERE id = ?",
929 params![input.serial_id.to_string()],
930 Self::map_serial_row,
931 )
932 .map_err(map_db_error)?;
933
934 tx.execute(
936 "UPDATE serial_numbers SET
937 current_owner_id = ?,
938 current_owner_type = ?,
939 status = ?,
940 updated_at = ?
941 WHERE id = ?",
942 params![
943 input.new_owner_id.to_string(),
944 input.new_owner_type,
945 SerialStatus::Transferred.to_string(),
946 now,
947 input.serial_id.to_string(),
948 ],
949 )
950 .map_err(map_db_error)?;
951
952 let history_id = Uuid::new_v4();
954 tx.execute(
955 "INSERT INTO serial_history (
956 id, serial_id, event_type, reference_type, reference_id,
957 from_status, to_status, from_owner_id, to_owner_id, notes, created_at
958 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
959 params![
960 history_id.to_string(),
961 input.serial_id.to_string(),
962 SerialEventType::Transferred.to_string(),
963 input.reference_type,
964 input.reference_id.map(|id| id.to_string()),
965 serial.status.to_string(),
966 SerialStatus::Transferred.to_string(),
967 serial.current_owner_id.map(|id| id.to_string()),
968 input.new_owner_id.to_string(),
969 input.notes,
970 now,
971 ],
972 )
973 .map_err(map_db_error)?;
974
975 tx.commit().map_err(map_db_error)?;
976 }
977 self.get(input.serial_id)?.ok_or(CommerceError::NotFound)
978 }
979
980 fn mark_sold(
981 &self,
982 id: Uuid,
983 customer_id: Uuid,
984 order_id: Option<Uuid>,
985 ) -> stateset_core::Result<SerialNumber> {
986 {
987 let mut conn =
988 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
989 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
990 let now = Utc::now();
991 let now_str = now.to_rfc3339();
992
993 let serial: SerialNumber = tx
995 .query_row(
996 "SELECT * FROM serial_numbers WHERE id = ?",
997 params![id.to_string()],
998 Self::map_serial_row,
999 )
1000 .map_err(map_db_error)?;
1001
1002 tx.execute(
1004 "UPDATE serial_numbers SET
1005 status = ?,
1006 current_owner_id = ?,
1007 current_owner_type = 'customer',
1008 sold_at = ?,
1009 updated_at = ?
1010 WHERE id = ?",
1011 params![
1012 SerialStatus::Sold.to_string(),
1013 customer_id.to_string(),
1014 now_str,
1015 now_str,
1016 id.to_string(),
1017 ],
1018 )
1019 .map_err(map_db_error)?;
1020
1021 let history_id = Uuid::new_v4();
1023 tx.execute(
1024 "INSERT INTO serial_history (
1025 id, serial_id, event_type, reference_type, reference_id,
1026 from_status, to_status, to_owner_id, created_at
1027 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1028 params![
1029 history_id.to_string(),
1030 id.to_string(),
1031 SerialEventType::Sold.to_string(),
1032 order_id.map(|_| "order"),
1033 order_id.map(|id| id.to_string()),
1034 serial.status.to_string(),
1035 SerialStatus::Sold.to_string(),
1036 customer_id.to_string(),
1037 now_str,
1038 ],
1039 )
1040 .map_err(map_db_error)?;
1041
1042 tx.commit().map_err(map_db_error)?;
1043 }
1044 self.get(id)?.ok_or(CommerceError::NotFound)
1045 }
1046
1047 fn mark_shipped(&self, id: Uuid, shipment_id: Uuid) -> stateset_core::Result<SerialNumber> {
1048 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1049 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1050 let now = Utc::now().to_rfc3339();
1051
1052 let serial: SerialNumber = tx
1054 .query_row(
1055 "SELECT * FROM serial_numbers WHERE id = ?",
1056 params![id.to_string()],
1057 Self::map_serial_row,
1058 )
1059 .map_err(map_db_error)?;
1060
1061 tx.execute(
1063 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
1064 params![SerialStatus::Shipped.to_string(), now, id.to_string()],
1065 )
1066 .map_err(map_db_error)?;
1067
1068 let history_id = Uuid::new_v4();
1070 tx.execute(
1071 "INSERT INTO serial_history (
1072 id, serial_id, event_type, reference_type, reference_id,
1073 from_status, to_status, created_at
1074 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1075 params![
1076 history_id.to_string(),
1077 id.to_string(),
1078 SerialEventType::Shipped.to_string(),
1079 "shipment",
1080 shipment_id.to_string(),
1081 serial.status.to_string(),
1082 SerialStatus::Shipped.to_string(),
1083 now,
1084 ],
1085 )
1086 .map_err(map_db_error)?;
1087
1088 tx.commit().map_err(map_db_error)?;
1089 self.get(id)?.ok_or(CommerceError::NotFound)
1090 }
1091
1092 fn mark_returned(&self, id: Uuid, return_id: Uuid) -> stateset_core::Result<SerialNumber> {
1093 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1094 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1095 let now = Utc::now().to_rfc3339();
1096
1097 let serial: SerialNumber = tx
1099 .query_row(
1100 "SELECT * FROM serial_numbers WHERE id = ?",
1101 params![id.to_string()],
1102 Self::map_serial_row,
1103 )
1104 .map_err(map_db_error)?;
1105
1106 tx.execute(
1108 "UPDATE serial_numbers SET
1109 status = ?,
1110 current_owner_id = NULL,
1111 current_owner_type = NULL,
1112 updated_at = ?
1113 WHERE id = ?",
1114 params![SerialStatus::Returned.to_string(), now, id.to_string()],
1115 )
1116 .map_err(map_db_error)?;
1117
1118 let history_id = Uuid::new_v4();
1120 tx.execute(
1121 "INSERT INTO serial_history (
1122 id, serial_id, event_type, reference_type, reference_id,
1123 from_status, to_status, from_owner_id, created_at
1124 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1125 params![
1126 history_id.to_string(),
1127 id.to_string(),
1128 SerialEventType::Returned.to_string(),
1129 "return",
1130 return_id.to_string(),
1131 serial.status.to_string(),
1132 SerialStatus::Returned.to_string(),
1133 serial.current_owner_id.map(|id| id.to_string()),
1134 now,
1135 ],
1136 )
1137 .map_err(map_db_error)?;
1138
1139 tx.commit().map_err(map_db_error)?;
1140 self.get(id)?.ok_or(CommerceError::NotFound)
1141 }
1142
1143 fn activate(&self, id: Uuid) -> stateset_core::Result<SerialNumber> {
1144 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1145 let now = Utc::now().to_rfc3339();
1146
1147 conn.execute(
1148 "UPDATE serial_numbers SET activated_at = ?, updated_at = ? WHERE id = ? AND activated_at IS NULL",
1149 params![now, now, id.to_string()],
1150 ).map_err(map_db_error)?;
1151
1152 if let Some(serial) = self.get(id)? {
1154 let history_id = Uuid::new_v4();
1155 conn.execute(
1156 "INSERT INTO serial_history (
1157 id, serial_id, event_type, from_status, to_status, created_at
1158 ) VALUES (?, ?, ?, ?, ?, ?)",
1159 params![
1160 history_id.to_string(),
1161 id.to_string(),
1162 SerialEventType::Activated.to_string(),
1163 serial.status.to_string(),
1164 serial.status.to_string(),
1165 now,
1166 ],
1167 )
1168 .map_err(map_db_error)?;
1169 }
1170
1171 self.get(id)?.ok_or(CommerceError::NotFound)
1172 }
1173
1174 fn quarantine(&self, id: Uuid, reason: &str) -> stateset_core::Result<SerialNumber> {
1175 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1176 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1177 let now = Utc::now().to_rfc3339();
1178
1179 let serial: SerialNumber = tx
1181 .query_row(
1182 "SELECT * FROM serial_numbers WHERE id = ?",
1183 params![id.to_string()],
1184 Self::map_serial_row,
1185 )
1186 .map_err(map_db_error)?;
1187
1188 tx.execute(
1190 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
1191 params![SerialStatus::Quarantined.to_string(), now, id.to_string()],
1192 )
1193 .map_err(map_db_error)?;
1194
1195 let history_id = Uuid::new_v4();
1197 tx.execute(
1198 "INSERT INTO serial_history (
1199 id, serial_id, event_type, from_status, to_status, notes, created_at
1200 ) VALUES (?, ?, ?, ?, ?, ?, ?)",
1201 params![
1202 history_id.to_string(),
1203 id.to_string(),
1204 SerialEventType::Quarantined.to_string(),
1205 serial.status.to_string(),
1206 SerialStatus::Quarantined.to_string(),
1207 reason,
1208 now,
1209 ],
1210 )
1211 .map_err(map_db_error)?;
1212
1213 tx.commit().map_err(map_db_error)?;
1214 self.get(id)?.ok_or(CommerceError::NotFound)
1215 }
1216
1217 fn release_quarantine(&self, id: Uuid) -> stateset_core::Result<SerialNumber> {
1218 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1219 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1220 let now = Utc::now().to_rfc3339();
1221
1222 let serial: SerialNumber = tx
1224 .query_row(
1225 "SELECT * FROM serial_numbers WHERE id = ?",
1226 params![id.to_string()],
1227 Self::map_serial_row,
1228 )
1229 .map_err(map_db_error)?;
1230
1231 if serial.status != SerialStatus::Quarantined {
1232 return Err(CommerceError::ValidationError("Serial is not quarantined".to_string()));
1233 }
1234
1235 tx.execute(
1237 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
1238 params![SerialStatus::Available.to_string(), now, id.to_string()],
1239 )
1240 .map_err(map_db_error)?;
1241
1242 let history_id = Uuid::new_v4();
1244 tx.execute(
1245 "INSERT INTO serial_history (
1246 id, serial_id, event_type, from_status, to_status, created_at
1247 ) VALUES (?, ?, ?, ?, ?, ?)",
1248 params![
1249 history_id.to_string(),
1250 id.to_string(),
1251 SerialEventType::QuarantineReleased.to_string(),
1252 SerialStatus::Quarantined.to_string(),
1253 SerialStatus::Available.to_string(),
1254 now,
1255 ],
1256 )
1257 .map_err(map_db_error)?;
1258
1259 tx.commit().map_err(map_db_error)?;
1260 self.get(id)?.ok_or(CommerceError::NotFound)
1261 }
1262
1263 fn scrap(&self, id: Uuid, reason: &str) -> stateset_core::Result<SerialNumber> {
1264 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1265 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1266 let now = Utc::now().to_rfc3339();
1267
1268 let serial: SerialNumber = tx
1270 .query_row(
1271 "SELECT * FROM serial_numbers WHERE id = ?",
1272 params![id.to_string()],
1273 Self::map_serial_row,
1274 )
1275 .map_err(map_db_error)?;
1276
1277 if !serial.can_scrap() {
1278 return Err(CommerceError::ValidationError(
1279 "Serial cannot be scrapped in current state".to_string(),
1280 ));
1281 }
1282
1283 tx.execute(
1285 "UPDATE serial_numbers SET status = ?, updated_at = ? WHERE id = ?",
1286 params![SerialStatus::Scrapped.to_string(), now, id.to_string()],
1287 )
1288 .map_err(map_db_error)?;
1289
1290 let history_id = Uuid::new_v4();
1292 tx.execute(
1293 "INSERT INTO serial_history (
1294 id, serial_id, event_type, from_status, to_status, notes, created_at
1295 ) VALUES (?, ?, ?, ?, ?, ?, ?)",
1296 params![
1297 history_id.to_string(),
1298 id.to_string(),
1299 SerialEventType::Scrapped.to_string(),
1300 serial.status.to_string(),
1301 SerialStatus::Scrapped.to_string(),
1302 reason,
1303 now,
1304 ],
1305 )
1306 .map_err(map_db_error)?;
1307
1308 tx.commit().map_err(map_db_error)?;
1309 self.get(id)?.ok_or(CommerceError::NotFound)
1310 }
1311
1312 fn get_history(
1313 &self,
1314 serial_id: Uuid,
1315 filter: SerialHistoryFilter,
1316 ) -> stateset_core::Result<Vec<SerialHistory>> {
1317 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1318
1319 let mut conditions = vec!["serial_id = ?".to_string()];
1320 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(serial_id.to_string())];
1321
1322 if let Some(event_type) = &filter.event_type {
1323 conditions.push("event_type = ?".to_string());
1324 params.push(Box::new(event_type.to_string()));
1325 }
1326 if let Some(ref_type) = &filter.reference_type {
1327 conditions.push("reference_type = ?".to_string());
1328 params.push(Box::new(ref_type.clone()));
1329 }
1330 if let Some(from_date) = &filter.from_date {
1331 conditions.push("created_at >= ?".to_string());
1332 params.push(Box::new(from_date.to_rfc3339()));
1333 }
1334 if let Some(to_date) = &filter.to_date {
1335 conditions.push("created_at <= ?".to_string());
1336 params.push(Box::new(to_date.to_rfc3339()));
1337 }
1338
1339 let limit = filter.limit.unwrap_or(100);
1340 let offset = filter.offset.unwrap_or(0);
1341
1342 let sql = format!(
1343 "SELECT * FROM serial_history WHERE {} ORDER BY created_at DESC LIMIT ? OFFSET ?",
1344 conditions.join(" AND ")
1345 );
1346
1347 params.push(Box::new(i64::from(limit)));
1348 params.push(Box::new(i64::from(offset)));
1349
1350 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1351 let rows = stmt
1352 .query_map(
1353 rusqlite::params_from_iter(params.iter().map(std::convert::AsRef::as_ref)),
1354 Self::map_history_row,
1355 )
1356 .map_err(map_db_error)?;
1357
1358 let mut history = Vec::new();
1359 for row in rows {
1360 history.push(row.map_err(map_db_error)?);
1361 }
1362
1363 Ok(history)
1364 }
1365
1366 fn lookup(&self, serial: &str) -> stateset_core::Result<Option<SerialLookupResult>> {
1367 let serial_number = match self.get_by_serial(serial)? {
1368 Some(s) => s,
1369 None => return Ok(None),
1370 };
1371
1372 let recent_history = self.get_history(
1374 serial_number.id,
1375 SerialHistoryFilter { limit: Some(10), ..Default::default() },
1376 )?;
1377
1378 let product_name = {
1379 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1380 let result: rusqlite::Result<String> = conn.query_row(
1381 "SELECT COALESCE(p.name, v.name)
1382 FROM product_variants v
1383 LEFT JOIN products p ON p.id = v.product_id
1384 WHERE v.sku = ?",
1385 [serial_number.sku.as_str()],
1386 |row| row.get(0),
1387 );
1388 match result {
1389 Ok(name) => Some(name),
1390 Err(rusqlite::Error::QueryReturnedNoRows) => None,
1391 Err(e) => return Err(map_db_error(e)),
1392 }
1393 };
1394
1395 let lot = {
1396 let lot_repo = SqliteLotRepository::new(self.pool.clone());
1397 match (serial_number.lot_id, serial_number.lot_number.as_deref()) {
1398 (Some(lot_id), lot_number) => match lot_repo.get(lot_id)? {
1399 Some(lot) => Some(lot),
1400 None => match lot_number {
1401 Some(number) => lot_repo.get_by_number(number)?,
1402 None => None,
1403 },
1404 },
1405 (None, Some(lot_number)) => lot_repo.get_by_number(lot_number)?,
1406 (None, None) => None,
1407 }
1408 };
1409
1410 let warranty_status = if let Some(warranty_id) = serial_number.warranty_id {
1411 let warranty_repo = SqliteWarrantyRepository::new(self.pool.clone());
1412 match warranty_repo.get(WarrantyId::from(warranty_id))? {
1413 Some(warranty) => Some(WarrantyLookupStatus {
1414 warranty_id,
1415 is_active: warranty.is_valid(),
1416 expires_at: warranty.end_date,
1417 coverage_type: Some(warranty.warranty_type.to_string()),
1418 }),
1419 None => None,
1420 }
1421 } else {
1422 None
1423 };
1424
1425 Ok(Some(SerialLookupResult {
1426 serial: serial_number,
1427 product_name,
1428 lot,
1429 warranty_status,
1430 recent_history,
1431 }))
1432 }
1433
1434 fn validate(&self, serial: &str) -> stateset_core::Result<SerialValidation> {
1435 match self.get_by_serial(serial)? {
1436 Some(s) => Ok(SerialValidation {
1437 is_valid: true,
1438 serial_id: Some(s.id),
1439 status: Some(s.status),
1440 sku: Some(s.sku),
1441 error_message: None,
1442 }),
1443 None => Ok(SerialValidation {
1444 is_valid: false,
1445 serial_id: None,
1446 status: None,
1447 sku: None,
1448 error_message: Some("Serial number not found".to_string()),
1449 }),
1450 }
1451 }
1452
1453 fn get_available_for_sku(
1454 &self,
1455 sku: &str,
1456 limit: u32,
1457 ) -> stateset_core::Result<Vec<SerialNumber>> {
1458 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1459
1460 let mut stmt = conn
1463 .prepare(
1464 "SELECT * FROM serial_numbers WHERE sku = ? AND status = ? \
1465 ORDER BY created_at ASC LIMIT ?",
1466 )
1467 .map_err(map_db_error)?;
1468 let rows = stmt
1469 .query_map(
1470 params![sku, SerialStatus::Available.to_string(), i64::from(limit)],
1471 Self::map_serial_row,
1472 )
1473 .map_err(map_db_error)?;
1474
1475 let mut serials = Vec::new();
1476 for row in rows {
1477 serials.push(row.map_err(map_db_error)?);
1478 }
1479 Ok(serials)
1480 }
1481
1482 fn get_for_lot(&self, lot_id: Uuid) -> stateset_core::Result<Vec<SerialNumber>> {
1483 self.list(SerialFilter { lot_id: Some(lot_id), ..Default::default() })
1484 }
1485
1486 fn get_for_customer(&self, customer_id: Uuid) -> stateset_core::Result<Vec<SerialNumber>> {
1487 self.list(SerialFilter {
1488 owner_id: Some(customer_id),
1489 owner_type: Some("customer".to_string()),
1490 ..Default::default()
1491 })
1492 }
1493
1494 fn count(&self, filter: SerialFilter) -> stateset_core::Result<u64> {
1495 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1496
1497 let (conditions, params) = Self::serial_filter_conditions(&filter);
1499
1500 let where_clause = if conditions.is_empty() {
1501 String::new()
1502 } else {
1503 format!("WHERE {}", conditions.join(" AND "))
1504 };
1505
1506 let sql = format!("SELECT COUNT(*) FROM serial_numbers {where_clause}");
1507
1508 let count: i64 = conn
1509 .query_row(
1510 &sql,
1511 rusqlite::params_from_iter(params.iter().map(std::convert::AsRef::as_ref)),
1512 |row| row.get(0),
1513 )
1514 .map_err(map_db_error)?;
1515
1516 Ok(count as u64)
1517 }
1518
1519 fn create_batch(
1520 &self,
1521 inputs: Vec<CreateSerialNumber>,
1522 ) -> stateset_core::Result<BatchResult<SerialNumber>> {
1523 let mut result = BatchResult::with_capacity(inputs.len());
1524
1525 for (idx, input) in inputs.into_iter().enumerate() {
1526 match self.create(input) {
1527 Ok(serial) => result.record_success(serial),
1528 Err(e) => result.record_failure(idx, None, &e),
1529 }
1530 }
1531
1532 Ok(result)
1533 }
1534
1535 fn get_batch(&self, ids: Vec<Uuid>) -> stateset_core::Result<Vec<SerialNumber>> {
1536 if ids.is_empty() {
1537 return Ok(Vec::new());
1538 }
1539
1540 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1541 let placeholders = build_in_clause(ids.len());
1542 let params = uuid_params(&ids);
1543 let params_ref = params_refs(¶ms);
1544
1545 let sql = format!("SELECT * FROM serial_numbers WHERE id IN ({placeholders})");
1546
1547 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1548 let rows = stmt
1549 .query_map(rusqlite::params_from_iter(params_ref), Self::map_serial_row)
1550 .map_err(map_db_error)?;
1551
1552 let mut serials = Vec::new();
1553 for row in rows {
1554 serials.push(row.map_err(map_db_error)?);
1555 }
1556
1557 Ok(serials)
1558 }
1559
1560 fn get_batch_by_serial(
1561 &self,
1562 serials: Vec<String>,
1563 ) -> stateset_core::Result<Vec<SerialNumber>> {
1564 if serials.is_empty() {
1565 return Ok(Vec::new());
1566 }
1567
1568 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1569 let placeholders = build_in_clause(serials.len());
1570 let params = string_params(&serials);
1571 let params_ref = params_refs(¶ms);
1572
1573 let sql = format!("SELECT * FROM serial_numbers WHERE serial IN ({placeholders})");
1574
1575 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1576 let rows = stmt
1577 .query_map(rusqlite::params_from_iter(params_ref), Self::map_serial_row)
1578 .map_err(map_db_error)?;
1579
1580 let mut result = Vec::new();
1581 for row in rows {
1582 result.push(row.map_err(map_db_error)?);
1583 }
1584
1585 Ok(result)
1586 }
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592 use crate::SqliteDatabase;
1593 use stateset_core::{
1594 ChangeSerialStatus, CreateSerialNumber, CreateSerialNumbersBulk, ReserveSerialNumber,
1595 SerialFilter, SerialRepository, SerialStatus, UpdateSerialNumber,
1596 };
1597
1598 fn fresh_repo() -> SqliteSerialRepository {
1599 SqliteDatabase::in_memory().expect("in-memory").serials()
1600 }
1601
1602 fn make_serial(repo: &SqliteSerialRepository, sku: &str, serial: &str) -> SerialNumber {
1603 repo.create(CreateSerialNumber {
1604 serial: Some(serial.into()),
1605 sku: sku.into(),
1606 lot_id: None,
1607 lot_number: Some("LOT-1".into()),
1608 location_id: Some(1),
1609 manufactured_at: None,
1610 notes: None,
1611 attributes: None,
1612 })
1613 .expect("create")
1614 }
1615
1616 #[test]
1617 fn create_with_explicit_serial_starts_available() {
1618 let repo = fresh_repo();
1619 let s = make_serial(&repo, "SKU-A", "S-001");
1620 assert_eq!(s.serial, "S-001");
1621 assert_eq!(s.sku, "SKU-A");
1622 assert_eq!(s.status, SerialStatus::Available);
1623 }
1624
1625 #[test]
1626 fn delete_rejects_missing_and_non_available_serials() {
1627 let repo = fresh_repo();
1628
1629 let err = repo.delete(Uuid::new_v4()).expect_err("missing serial must error");
1631 assert!(matches!(err, CommerceError::NotFound), "got {err:?}");
1632
1633 let s = make_serial(&repo, "SKU-DEL", "S-DEL-1");
1635 repo.update(
1636 s.id,
1637 UpdateSerialNumber { status: Some(SerialStatus::Sold), ..Default::default() },
1638 )
1639 .expect("update to sold");
1640 let err = repo.delete(s.id).expect_err("a sold serial must not be deletable");
1641 assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
1642 assert!(
1643 repo.get(s.id).expect("get").is_some(),
1644 "a rejected delete must not remove the serial"
1645 );
1646 }
1647
1648 #[test]
1649 fn count_matches_list_for_all_filters() {
1650 let repo = fresh_repo();
1651 make_serial(&repo, "SKU", "S-1");
1652 make_serial(&repo, "SKU", "S-2");
1653
1654 let filters = [
1658 SerialFilter { serial: Some("S-1".into()), ..Default::default() },
1659 SerialFilter { serial_prefix: Some("S-1".into()), ..Default::default() },
1660 SerialFilter { statuses: Some(vec![SerialStatus::Sold]), ..Default::default() },
1661 SerialFilter { sku: Some("SKU".into()), ..Default::default() },
1662 ];
1663 for filter in filters {
1664 let listed = repo.list(filter.clone()).expect("list").len() as u64;
1665 let counted = repo.count(filter).expect("count");
1666 assert_eq!(counted, listed, "count must match list for the same filter");
1667 }
1668
1669 assert_eq!(
1671 repo.count(SerialFilter { serial: Some("S-1".into()), ..Default::default() }).unwrap(),
1672 1
1673 );
1674 assert_eq!(
1675 repo.count(SerialFilter {
1676 statuses: Some(vec![SerialStatus::Sold]),
1677 ..Default::default()
1678 })
1679 .unwrap(),
1680 0
1681 );
1682 assert_eq!(
1683 repo.count(SerialFilter { sku: Some("SKU".into()), ..Default::default() }).unwrap(),
1684 2
1685 );
1686 }
1687
1688 #[test]
1689 fn get_available_for_sku_allocates_oldest_first_fifo() {
1690 let repo = fresh_repo();
1691 let oldest = make_serial(&repo, "SKU-F", "S-OLD");
1692 let newest = make_serial(&repo, "SKU-F", "S-NEW");
1693
1694 let one = repo.get_available_for_sku("SKU-F", 1).expect("available");
1698 assert_eq!(one.len(), 1);
1699 assert_eq!(one[0].id, oldest.id, "must allocate the oldest serial first (FIFO)");
1700
1701 let all = repo.get_available_for_sku("SKU-F", 10).expect("all");
1703 assert_eq!(
1704 all.iter().map(|s| s.id).collect::<Vec<_>>(),
1705 vec![oldest.id, newest.id],
1706 "available serials must be FIFO-ordered"
1707 );
1708 }
1709
1710 #[test]
1711 fn create_with_no_serial_generates_unique_one() {
1712 let repo = fresh_repo();
1713 let s1 = repo
1714 .create(CreateSerialNumber {
1715 serial: None,
1716 sku: "SKU-X".into(),
1717 lot_id: None,
1718 lot_number: None,
1719 location_id: None,
1720 manufactured_at: None,
1721 notes: None,
1722 attributes: None,
1723 })
1724 .expect("c1");
1725 let s2 = repo
1726 .create(CreateSerialNumber {
1727 serial: None,
1728 sku: "SKU-X".into(),
1729 lot_id: None,
1730 lot_number: None,
1731 location_id: None,
1732 manufactured_at: None,
1733 notes: None,
1734 attributes: None,
1735 })
1736 .expect("c2");
1737 assert_ne!(s1.serial, s2.serial);
1738 }
1739
1740 #[test]
1741 fn get_and_get_by_serial_round_trip() {
1742 let repo = fresh_repo();
1743 let s = make_serial(&repo, "SKU-RT", "S-RT-1");
1744 let by_id = repo.get(s.id).expect("ok").expect("found");
1745 assert_eq!(by_id.id, s.id);
1746 let by_serial = repo.get_by_serial("S-RT-1").expect("ok").expect("found");
1747 assert_eq!(by_serial.id, s.id);
1748 assert!(repo.get_by_serial("missing").expect("ok").is_none());
1749 }
1750
1751 #[test]
1752 fn create_bulk_creates_n_serials_with_prefix() {
1753 let repo = fresh_repo();
1754 let serials = repo
1755 .create_bulk(CreateSerialNumbersBulk {
1756 sku: "SKU-BULK".into(),
1757 quantity: 5,
1758 prefix: Some("BLK".into()),
1759 lot_id: None,
1760 lot_number: None,
1761 location_id: Some(1),
1762 manufactured_at: None,
1763 })
1764 .expect("bulk");
1765 assert_eq!(serials.len(), 5);
1766 for (i, s) in serials.iter().enumerate() {
1767 assert_eq!(s.serial, format!("BLK-{:06}", i + 1));
1768 assert_eq!(s.status, SerialStatus::Available);
1769 }
1770 }
1771
1772 #[test]
1773 fn list_filters_by_sku() {
1774 let repo = fresh_repo();
1775 make_serial(&repo, "SKU-L1", "S-L1-1");
1776 make_serial(&repo, "SKU-L1", "S-L1-2");
1777 make_serial(&repo, "SKU-L2", "S-L2-1");
1778
1779 let l1 = repo
1780 .list(SerialFilter { sku: Some("SKU-L1".into()), ..Default::default() })
1781 .expect("list");
1782 assert_eq!(l1.len(), 2);
1783 }
1784
1785 #[test]
1786 fn list_filters_by_status() {
1787 let repo = fresh_repo();
1788 let s_available = make_serial(&repo, "SKU-S", "S-AV");
1789 let s_to_reserve = make_serial(&repo, "SKU-S", "S-TR");
1790 repo.change_status(ChangeSerialStatus {
1791 serial_id: s_to_reserve.id,
1792 new_status: SerialStatus::Reserved,
1793 ..Default::default()
1794 })
1795 .expect("change status");
1796
1797 let available = repo
1798 .list(SerialFilter { status: Some(SerialStatus::Available), ..Default::default() })
1799 .expect("list available");
1800 let reserved = repo
1801 .list(SerialFilter { status: Some(SerialStatus::Reserved), ..Default::default() })
1802 .expect("list reserved");
1803 let av_ids: Vec<_> = available.iter().map(|s| s.id).collect();
1804 let res_ids: Vec<_> = reserved.iter().map(|s| s.id).collect();
1805 assert!(av_ids.contains(&s_available.id));
1806 assert!(res_ids.contains(&s_to_reserve.id));
1807 }
1808
1809 #[test]
1810 fn change_status_transitions() {
1811 let repo = fresh_repo();
1812 let s = make_serial(&repo, "SKU-T", "S-T-1");
1813 let updated = repo
1814 .change_status(ChangeSerialStatus {
1815 serial_id: s.id,
1816 new_status: SerialStatus::Sold,
1817 performed_by: Some("alice".into()),
1818 ..Default::default()
1819 })
1820 .expect("change");
1821 assert_eq!(updated.status, SerialStatus::Sold);
1822 }
1823
1824 #[test]
1825 fn reserve_serial_creates_reservation_and_changes_status() {
1826 let repo = fresh_repo();
1827 let s = make_serial(&repo, "SKU-R", "S-R-1");
1828 let order_id = Uuid::new_v4();
1829 let res = repo
1830 .reserve(ReserveSerialNumber {
1831 serial_id: s.id,
1832 reference_type: "order".into(),
1833 reference_id: order_id,
1834 reserved_by: Some("alice".into()),
1835 expires_in_seconds: Some(60),
1836 })
1837 .expect("reserve");
1838 assert_eq!(res.serial_id, s.id);
1839 let after = repo.get(s.id).expect("ok").expect("found");
1840 assert_eq!(after.status, SerialStatus::Reserved);
1841
1842 repo.release_reservation(res.id).expect("release");
1843 let after_release = repo.get(s.id).expect("ok").expect("found");
1844 assert_eq!(after_release.status, SerialStatus::Available);
1845 }
1846
1847 #[test]
1848 fn quarantine_marks_quarantined_then_release() {
1849 let repo = fresh_repo();
1850 let s = make_serial(&repo, "SKU-Q", "S-Q-1");
1851 let q = repo.quarantine(s.id, "qc fail").expect("quarantine");
1852 assert_eq!(q.status, SerialStatus::Quarantined);
1853 let r = repo.release_quarantine(s.id).expect("release");
1854 assert_eq!(r.status, SerialStatus::Available);
1855 }
1856
1857 #[test]
1858 fn get_for_customer_filters_by_owner() {
1859 let repo = fresh_repo();
1860 let s = make_serial(&repo, "SKU-O", "S-O-1");
1861 let cust = Uuid::new_v4();
1862 repo.change_status(ChangeSerialStatus {
1863 serial_id: s.id,
1864 new_status: SerialStatus::Sold,
1865 owner_id: Some(cust),
1866 owner_type: Some("customer".into()),
1867 ..Default::default()
1868 })
1869 .expect("change");
1870
1871 let owned = repo.get_for_customer(cust).expect("ok");
1872 let ids: Vec<_> = owned.iter().map(|s| s.id).collect();
1873 assert!(ids.contains(&s.id));
1874
1875 let none = repo.get_for_customer(Uuid::new_v4()).expect("ok");
1876 assert!(none.is_empty());
1877 }
1878
1879 #[test]
1880 fn get_available_for_sku_returns_only_available() {
1881 let repo = fresh_repo();
1882 make_serial(&repo, "SKU-AV", "S-AV-1");
1883 let s2 = make_serial(&repo, "SKU-AV", "S-AV-2");
1884 repo.change_status(ChangeSerialStatus {
1885 serial_id: s2.id,
1886 new_status: SerialStatus::Sold,
1887 ..Default::default()
1888 })
1889 .expect("change");
1890
1891 let available = repo.get_available_for_sku("SKU-AV", 10).expect("ok");
1892 assert_eq!(available.len(), 1);
1893 assert_eq!(available[0].status, SerialStatus::Available);
1894 }
1895
1896 #[test]
1897 fn get_for_unknown_id_returns_none() {
1898 let repo = fresh_repo();
1899 assert!(repo.get(Uuid::new_v4()).expect("ok").is_none());
1900 }
1901
1902 #[test]
1903 fn get_batch_returns_only_existing() {
1904 let repo = fresh_repo();
1905 let s1 = make_serial(&repo, "SKU-B", "S-B-1");
1906 let s2 = make_serial(&repo, "SKU-B", "S-B-2");
1907 let stranger = Uuid::new_v4();
1908
1909 let batch = repo.get_batch(vec![s1.id, s2.id, stranger]).expect("batch");
1910 assert_eq!(batch.len(), 2);
1911 }
1912
1913 #[test]
1914 fn create_batch_returns_per_input_results() {
1915 let repo = fresh_repo();
1916 let result = repo
1917 .create_batch(vec![
1918 CreateSerialNumber {
1919 serial: Some("S-CB-1".into()),
1920 sku: "SKU-CB".into(),
1921 lot_id: None,
1922 lot_number: None,
1923 location_id: None,
1924 manufactured_at: None,
1925 notes: None,
1926 attributes: None,
1927 },
1928 CreateSerialNumber {
1929 serial: Some("S-CB-2".into()),
1930 sku: "SKU-CB".into(),
1931 lot_id: None,
1932 lot_number: None,
1933 location_id: None,
1934 manufactured_at: None,
1935 notes: None,
1936 attributes: None,
1937 },
1938 ])
1939 .expect("batch");
1940 assert_eq!(result.success_count, 2);
1941 assert_eq!(result.failure_count, 0);
1942 }
1943}