1use crate::sqlite::{
4 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
5 parse_decimal_row, parse_decimal_strict, parse_enum_row, parse_json_row, parse_uuid,
6 parse_uuid_opt_row, parse_uuid_row,
7};
8use chrono::Utc;
9use r2d2::Pool;
10use r2d2_sqlite::SqliteConnectionManager;
11use rust_decimal::Decimal;
12use stateset_core::errors::BatchResult;
13use stateset_core::traits::LotRepository;
14use stateset_core::{
15 AddLotCertificate, AdjustLot, CommerceError, ConsumeLot, CreateLot, Lot, LotCertificate,
16 LotFilter, LotLocation, LotStatus, LotTransaction, LotTransactionType, MergeLots, ReserveLot,
17 Result, SplitLot, TraceNode, TraceNodeType, TraceabilityResult, TransferLot, UpdateLot,
18};
19use uuid::Uuid;
20
21#[derive(Debug)]
22pub struct SqliteLotRepository {
23 pool: Pool<SqliteConnectionManager>,
24}
25
26impl SqliteLotRepository {
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_lot_number(sku: &str) -> String {
37 let timestamp_ms = Utc::now().timestamp_millis();
41 let random_suffix = (Uuid::new_v4().as_u128() & 0xFFFF_FFFF) as u32;
42 format!(
43 "LOT-{}-{}-{:08x}",
44 sku.chars().take(6).collect::<String>().to_uppercase(),
45 timestamp_ms,
46 random_suffix
47 )
48 }
49
50 fn row_to_lot(row: &rusqlite::Row<'_>) -> rusqlite::Result<Lot> {
51 let attributes_str: String = row.get("attributes")?;
52 let attributes: serde_json::Value = parse_json_row(&attributes_str, "lot", "attributes")?;
53
54 Ok(Lot {
55 id: parse_uuid_row(&row.get::<_, String>("id")?, "lot", "id")?,
56 lot_number: row.get("lot_number")?,
57 sku: row.get("sku")?,
58 status: parse_enum_row(&row.get::<_, String>("status")?, "lot", "status")?,
59 quantity_produced: parse_decimal_row(
60 &row.get::<_, String>("quantity_produced")?,
61 "lot",
62 "quantity_produced",
63 )?,
64 quantity_remaining: parse_decimal_row(
65 &row.get::<_, String>("quantity_remaining")?,
66 "lot",
67 "quantity_remaining",
68 )?,
69 quantity_reserved: parse_decimal_row(
70 &row.get::<_, String>("quantity_reserved")?,
71 "lot",
72 "quantity_reserved",
73 )?,
74 quantity_quarantined: parse_decimal_row(
75 &row.get::<_, String>("quantity_quarantined")?,
76 "lot",
77 "quantity_quarantined",
78 )?,
79 production_date: parse_datetime_row(
80 &row.get::<_, String>("production_date")?,
81 "lot",
82 "production_date",
83 )?,
84 expiration_date: parse_datetime_opt_row(
85 row.get::<_, Option<String>>("expiration_date")?,
86 "lot",
87 "expiration_date",
88 )?,
89 best_before_date: parse_datetime_opt_row(
90 row.get::<_, Option<String>>("best_before_date")?,
91 "lot",
92 "best_before_date",
93 )?,
94 supplier_lot: row.get("supplier_lot")?,
95 supplier_id: parse_uuid_opt_row(
96 row.get::<_, Option<String>>("supplier_id")?,
97 "lot",
98 "supplier_id",
99 )?,
100 work_order_id: parse_uuid_opt_row(
101 row.get::<_, Option<String>>("work_order_id")?,
102 "lot",
103 "work_order_id",
104 )?,
105 purchase_order_id: parse_uuid_opt_row(
106 row.get::<_, Option<String>>("purchase_order_id")?,
107 "lot",
108 "purchase_order_id",
109 )?,
110 cost_per_unit: parse_decimal_opt_row(
111 row.get::<_, Option<String>>("cost_per_unit")?,
112 "lot",
113 "cost_per_unit",
114 )?,
115 attributes,
116 notes: row.get("notes")?,
117 created_at: parse_datetime_row(
118 &row.get::<_, String>("created_at")?,
119 "lot",
120 "created_at",
121 )?,
122 updated_at: parse_datetime_row(
123 &row.get::<_, String>("updated_at")?,
124 "lot",
125 "updated_at",
126 )?,
127 })
128 }
129
130 fn row_to_transaction(row: &rusqlite::Row<'_>) -> rusqlite::Result<LotTransaction> {
131 Ok(LotTransaction {
132 id: parse_uuid_row(&row.get::<_, String>("id")?, "lot_transaction", "id")?,
133 lot_id: parse_uuid_row(&row.get::<_, String>("lot_id")?, "lot_transaction", "lot_id")?,
134 transaction_type: parse_enum_row(
135 &row.get::<_, String>("transaction_type")?,
136 "lot_transaction",
137 "transaction_type",
138 )?,
139 quantity: parse_decimal_row(
140 &row.get::<_, String>("quantity")?,
141 "lot_transaction",
142 "quantity",
143 )?,
144 reference_type: row.get("reference_type")?,
145 reference_id: parse_uuid_row(
146 &row.get::<_, String>("reference_id")?,
147 "lot_transaction",
148 "reference_id",
149 )?,
150 from_location_id: row.get("from_location_id")?,
151 to_location_id: row.get("to_location_id")?,
152 reason: row.get("reason")?,
153 performed_by: row.get("performed_by")?,
154 created_at: parse_datetime_row(
155 &row.get::<_, String>("created_at")?,
156 "lot_transaction",
157 "created_at",
158 )?,
159 })
160 }
161
162 fn row_to_certificate(row: &rusqlite::Row<'_>) -> rusqlite::Result<LotCertificate> {
163 Ok(LotCertificate {
164 id: parse_uuid_row(&row.get::<_, String>("id")?, "lot_certificate", "id")?,
165 lot_id: parse_uuid_row(&row.get::<_, String>("lot_id")?, "lot_certificate", "lot_id")?,
166 certificate_type: parse_enum_row(
167 &row.get::<_, String>("certificate_type")?,
168 "lot_certificate",
169 "certificate_type",
170 )?,
171 certificate_number: row.get("certificate_number")?,
172 document_url: row.get("document_url")?,
173 issued_by: row.get("issued_by")?,
174 issued_at: parse_datetime_opt_row(
175 row.get::<_, Option<String>>("issued_at")?,
176 "lot_certificate",
177 "issued_at",
178 )?,
179 expires_at: parse_datetime_opt_row(
180 row.get::<_, Option<String>>("expires_at")?,
181 "lot_certificate",
182 "expires_at",
183 )?,
184 notes: row.get("notes")?,
185 created_at: parse_datetime_row(
186 &row.get::<_, String>("created_at")?,
187 "lot_certificate",
188 "created_at",
189 )?,
190 })
191 }
192
193 #[allow(clippy::too_many_arguments)]
194 fn record_transaction(
195 &self,
196 conn: &rusqlite::Connection,
197 lot_id: Uuid,
198 transaction_type: LotTransactionType,
199 quantity: Decimal,
200 reference_type: &str,
201 reference_id: Uuid,
202 from_location_id: Option<i32>,
203 to_location_id: Option<i32>,
204 reason: Option<&str>,
205 performed_by: Option<&str>,
206 ) -> Result<LotTransaction> {
207 let id = Uuid::new_v4();
208 let now = Utc::now();
209
210 conn.execute(
211 "INSERT INTO lot_transactions (id, lot_id, transaction_type, quantity, reference_type,
212 reference_id, from_location_id, to_location_id, reason,
213 performed_by, created_at)
214 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
215 rusqlite::params![
216 id.to_string(),
217 lot_id.to_string(),
218 transaction_type.to_string(),
219 quantity.to_string(),
220 reference_type,
221 reference_id.to_string(),
222 from_location_id,
223 to_location_id,
224 reason,
225 performed_by,
226 now.to_rfc3339(),
227 ],
228 )
229 .map_err(map_db_error)?;
230
231 Ok(LotTransaction {
232 id,
233 lot_id,
234 transaction_type,
235 quantity,
236 reference_type: reference_type.to_string(),
237 reference_id,
238 from_location_id,
239 to_location_id,
240 reason: reason.map(std::string::ToString::to_string),
241 performed_by: performed_by.map(std::string::ToString::to_string),
242 created_at: now,
243 })
244 }
245}
246
247impl LotRepository for SqliteLotRepository {
248 fn create(&self, input: CreateLot) -> Result<Lot> {
249 let mut conn = self.conn()?;
250 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
251
252 let id = Uuid::new_v4();
253 let lot_number = input.lot_number.unwrap_or_else(|| Self::generate_lot_number(&input.sku));
254 let now = Utc::now();
255 let production_date = input.production_date.unwrap_or(now);
256 let attributes_json = input
257 .attributes
258 .as_ref()
259 .map(serde_json::to_string)
260 .transpose()
261 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?
262 .unwrap_or_else(|| "{}".to_string());
263
264 tx.execute(
265 "INSERT INTO lots (id, lot_number, sku, status, quantity_produced, quantity_remaining,
266 quantity_reserved, quantity_quarantined, production_date,
267 expiration_date, best_before_date, supplier_lot, supplier_id,
268 work_order_id, purchase_order_id, cost_per_unit, attributes, notes,
269 created_at, updated_at)
270 VALUES (?, ?, ?, ?, ?, ?, '0', '0', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
271 rusqlite::params![
272 id.to_string(),
273 &lot_number,
274 &input.sku,
275 LotStatus::Active.to_string(),
276 input.quantity.to_string(),
277 input.quantity.to_string(),
278 production_date.to_rfc3339(),
279 input.expiration_date.map(|d| d.to_rfc3339()),
280 input.best_before_date.map(|d| d.to_rfc3339()),
281 &input.supplier_lot,
282 input.supplier_id.map(|i| i.to_string()),
283 input.work_order_id.map(|i| i.to_string()),
284 input.purchase_order_id.map(|i| i.to_string()),
285 input.cost_per_unit.map(|c| c.to_string()),
286 &attributes_json,
287 &input.notes,
288 now.to_rfc3339(),
289 now.to_rfc3339(),
290 ],
291 )
292 .map_err(map_db_error)?;
293
294 let tx_id = Uuid::new_v4();
296 let reference_id = input.work_order_id.or(input.purchase_order_id).unwrap_or(id);
297 let reference_type = if input.work_order_id.is_some() {
298 "work_order"
299 } else if input.purchase_order_id.is_some() {
300 "purchase_order"
301 } else {
302 "lot_creation"
303 };
304
305 tx.execute(
306 "INSERT INTO lot_transactions (id, lot_id, transaction_type, quantity, reference_type,
307 reference_id, to_location_id, created_at)
308 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
309 rusqlite::params![
310 tx_id.to_string(),
311 id.to_string(),
312 LotTransactionType::Received.to_string(),
313 input.quantity.to_string(),
314 reference_type,
315 reference_id.to_string(),
316 input.initial_location_id,
317 now.to_rfc3339(),
318 ],
319 )
320 .map_err(map_db_error)?;
321
322 if let Some(location_id) = input.initial_location_id {
324 tx.execute(
325 "INSERT INTO lot_locations (lot_id, location_id, quantity, updated_at)
326 VALUES (?, ?, ?, ?)",
327 rusqlite::params![
328 id.to_string(),
329 location_id,
330 input.quantity.to_string(),
331 now.to_rfc3339(),
332 ],
333 )
334 .map_err(map_db_error)?;
335 }
336
337 tx.commit().map_err(map_db_error)?;
338
339 Ok(Lot {
340 id,
341 lot_number,
342 sku: input.sku,
343 status: LotStatus::Active,
344 quantity_produced: input.quantity,
345 quantity_remaining: input.quantity,
346 quantity_reserved: Decimal::ZERO,
347 quantity_quarantined: Decimal::ZERO,
348 production_date,
349 expiration_date: input.expiration_date,
350 best_before_date: input.best_before_date,
351 supplier_lot: input.supplier_lot,
352 supplier_id: input.supplier_id,
353 work_order_id: input.work_order_id,
354 purchase_order_id: input.purchase_order_id,
355 cost_per_unit: input.cost_per_unit,
356 attributes: input.attributes.unwrap_or(serde_json::json!({})),
357 notes: input.notes,
358 created_at: now,
359 updated_at: now,
360 })
361 }
362
363 fn get(&self, id: Uuid) -> Result<Option<Lot>> {
364 let conn = self.conn()?;
365 let result =
366 conn.query_row("SELECT * FROM lots WHERE id = ?", [id.to_string()], Self::row_to_lot);
367
368 match result {
369 Ok(lot) => Ok(Some(lot)),
370 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
371 Err(e) => Err(map_db_error(e)),
372 }
373 }
374
375 fn get_by_number(&self, lot_number: &str) -> Result<Option<Lot>> {
376 let conn = self.conn()?;
377 let result = conn.query_row(
378 "SELECT * FROM lots WHERE lot_number = ?",
379 [lot_number],
380 Self::row_to_lot,
381 );
382
383 match result {
384 Ok(lot) => Ok(Some(lot)),
385 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
386 Err(e) => Err(map_db_error(e)),
387 }
388 }
389
390 fn update(&self, id: Uuid, input: UpdateLot) -> Result<Lot> {
391 let conn = self.conn()?;
392 let now = Utc::now();
393
394 let mut updates = vec!["updated_at = ?"];
395 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now.to_rfc3339())];
396
397 if let Some(status) = &input.status {
398 updates.push("status = ?");
399 params.push(Box::new(status.to_string()));
400 }
401 if let Some(expiration_date) = &input.expiration_date {
402 updates.push("expiration_date = ?");
403 params.push(Box::new(expiration_date.to_rfc3339()));
404 }
405 if let Some(best_before_date) = &input.best_before_date {
406 updates.push("best_before_date = ?");
407 params.push(Box::new(best_before_date.to_rfc3339()));
408 }
409 if let Some(cost_per_unit) = &input.cost_per_unit {
410 updates.push("cost_per_unit = ?");
411 params.push(Box::new(cost_per_unit.to_string()));
412 }
413 if let Some(attributes) = &input.attributes {
414 updates.push("attributes = ?");
415 let attributes_json = serde_json::to_string(attributes)
416 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
417 params.push(Box::new(attributes_json));
418 }
419 if let Some(notes) = &input.notes {
420 updates.push("notes = ?");
421 params.push(Box::new(notes.clone()));
422 }
423
424 params.push(Box::new(id.to_string()));
425
426 let sql = format!("UPDATE lots SET {} WHERE id = ?", updates.join(", "));
427 let params_refs: Vec<&dyn rusqlite::ToSql> =
428 params.iter().map(std::convert::AsRef::as_ref).collect();
429 conn.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
430
431 self.get(id)?.ok_or(CommerceError::NotFound)
432 }
433
434 fn list(&self, filter: LotFilter) -> Result<Vec<Lot>> {
435 let conn = self.conn()?;
436
437 let mut conditions = vec!["1=1"];
438 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
439
440 if let Some(sku) = &filter.sku {
441 conditions.push("sku = ?");
442 params.push(Box::new(sku.clone()));
443 }
444 if let Some(lot_number) = &filter.lot_number {
445 conditions.push("lot_number = ?");
446 params.push(Box::new(lot_number.clone()));
447 }
448 if let Some(status) = &filter.status {
449 conditions.push("status = ?");
450 params.push(Box::new(status.to_string()));
451 }
452 if let Some(supplier_id) = &filter.supplier_id {
453 conditions.push("supplier_id = ?");
454 params.push(Box::new(supplier_id.to_string()));
455 }
456 if filter.has_quantity == Some(true) {
457 conditions.push("quantity_remaining > 0");
458 }
459
460 let limit = filter.limit.unwrap_or(100);
461 let offset = filter.offset.unwrap_or(0);
462
463 let sql = format!(
464 "SELECT * FROM lots WHERE {} ORDER BY created_at DESC LIMIT ? OFFSET ?",
465 conditions.join(" AND ")
466 );
467
468 params.push(Box::new(i64::from(limit)));
469 params.push(Box::new(i64::from(offset)));
470
471 let params_refs: Vec<&dyn rusqlite::ToSql> =
472 params.iter().map(std::convert::AsRef::as_ref).collect();
473
474 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
475 let lots = stmt
476 .query_map(params_refs.as_slice(), Self::row_to_lot)
477 .map_err(map_db_error)?
478 .collect::<rusqlite::Result<Vec<_>>>()
479 .map_err(map_db_error)?;
480
481 Ok(lots)
482 }
483
484 fn delete(&self, id: Uuid) -> Result<()> {
485 let conn = self.conn()?;
486 let tx_count: i64 = conn
488 .query_row(
489 "SELECT COUNT(*) FROM lot_transactions WHERE lot_id = ?",
490 [id.to_string()],
491 |row| row.get(0),
492 )
493 .map_err(map_db_error)?;
494
495 if tx_count > 1 {
496 return Err(CommerceError::ValidationError(
497 "Cannot delete lot with transaction history".to_string(),
498 ));
499 }
500
501 conn.execute("DELETE FROM lots WHERE id = ?", [id.to_string()]).map_err(map_db_error)?;
502 Ok(())
503 }
504
505 fn adjust(&self, input: AdjustLot) -> Result<LotTransaction> {
506 let mut conn = self.conn()?;
507 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
508
509 let lot: Lot = tx
511 .query_row(
512 "SELECT * FROM lots WHERE id = ?",
513 [input.lot_id.to_string()],
514 Self::row_to_lot,
515 )
516 .map_err(|e| match e {
517 rusqlite::Error::QueryReturnedNoRows => {
518 CommerceError::ValidationError("Lot not found".to_string())
519 }
520 e => map_db_error(e),
521 })?;
522
523 let new_remaining = lot.quantity_remaining + input.quantity_change;
524 if new_remaining < Decimal::ZERO {
525 return Err(CommerceError::ValidationError(
526 "Cannot reduce quantity below zero".to_string(),
527 ));
528 }
529
530 tx.execute(
532 "UPDATE lots SET quantity_remaining = ?, updated_at = ? WHERE id = ?",
533 rusqlite::params![
534 new_remaining.to_string(),
535 Utc::now().to_rfc3339(),
536 input.lot_id.to_string(),
537 ],
538 )
539 .map_err(map_db_error)?;
540
541 let transaction = self.record_transaction(
543 &tx,
544 input.lot_id,
545 LotTransactionType::Adjusted,
546 input.quantity_change,
547 input.reference_type.as_deref().unwrap_or("manual_adjustment"),
548 input.reference_id.unwrap_or(input.lot_id),
549 None,
550 input.location_id,
551 Some(&input.reason),
552 input.performed_by.as_deref(),
553 )?;
554
555 tx.commit().map_err(map_db_error)?;
556
557 Ok(transaction)
558 }
559
560 fn consume(&self, input: ConsumeLot) -> Result<LotTransaction> {
561 let mut conn = self.conn()?;
562 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
563
564 let lot: Lot = tx
566 .query_row(
567 "SELECT * FROM lots WHERE id = ?",
568 [input.lot_id.to_string()],
569 Self::row_to_lot,
570 )
571 .map_err(|e| match e {
572 rusqlite::Error::QueryReturnedNoRows => {
573 CommerceError::ValidationError("Lot not found".to_string())
574 }
575 e => map_db_error(e),
576 })?;
577
578 if !lot.can_consume(input.quantity) {
579 return Err(CommerceError::InsufficientStock {
580 sku: lot.sku.clone(),
581 requested: input.quantity.to_string(),
582 available: lot.quantity_available().to_string(),
583 });
584 }
585
586 let new_remaining = lot.quantity_remaining - input.quantity;
587
588 let new_status =
590 if new_remaining <= Decimal::ZERO { LotStatus::Consumed } else { lot.status };
591
592 tx.execute(
594 "UPDATE lots SET quantity_remaining = ?, status = ?, updated_at = ? WHERE id = ?",
595 rusqlite::params![
596 new_remaining.to_string(),
597 new_status.to_string(),
598 Utc::now().to_rfc3339(),
599 input.lot_id.to_string(),
600 ],
601 )
602 .map_err(map_db_error)?;
603
604 let transaction = self.record_transaction(
606 &tx,
607 input.lot_id,
608 LotTransactionType::Consumed,
609 -input.quantity,
610 &input.reference_type,
611 input.reference_id,
612 input.location_id,
613 None,
614 None,
615 input.performed_by.as_deref(),
616 )?;
617
618 tx.commit().map_err(map_db_error)?;
619
620 Ok(transaction)
621 }
622
623 fn reserve(&self, input: ReserveLot) -> Result<Uuid> {
624 let mut conn = self.conn()?;
625 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
626
627 let lot: Lot = tx
629 .query_row(
630 "SELECT * FROM lots WHERE id = ?",
631 [input.lot_id.to_string()],
632 Self::row_to_lot,
633 )
634 .map_err(|e| match e {
635 rusqlite::Error::QueryReturnedNoRows => {
636 CommerceError::ValidationError("Lot not found".to_string())
637 }
638 e => map_db_error(e),
639 })?;
640
641 if !lot.can_reserve(input.quantity) {
642 return Err(CommerceError::InsufficientStock {
643 sku: lot.sku.clone(),
644 requested: input.quantity.to_string(),
645 available: lot.quantity_available().to_string(),
646 });
647 }
648
649 let reservation_id = Uuid::new_v4();
650 let now = Utc::now();
651 let expires_at = input.expires_in_seconds.map(|s| now + chrono::Duration::seconds(s));
652
653 tx.execute(
655 "INSERT INTO lot_reservations (id, lot_id, quantity, reference_type, reference_id,
656 reserved_at, expires_at)
657 VALUES (?, ?, ?, ?, ?, ?, ?)",
658 rusqlite::params![
659 reservation_id.to_string(),
660 input.lot_id.to_string(),
661 input.quantity.to_string(),
662 &input.reference_type,
663 input.reference_id.to_string(),
664 now.to_rfc3339(),
665 expires_at.map(|d| d.to_rfc3339()),
666 ],
667 )
668 .map_err(map_db_error)?;
669
670 let new_reserved = lot.quantity_reserved + input.quantity;
672 tx.execute(
673 "UPDATE lots SET quantity_reserved = ?, updated_at = ? WHERE id = ?",
674 rusqlite::params![
675 new_reserved.to_string(),
676 now.to_rfc3339(),
677 input.lot_id.to_string(),
678 ],
679 )
680 .map_err(map_db_error)?;
681
682 self.record_transaction(
684 &tx,
685 input.lot_id,
686 LotTransactionType::Reserved,
687 input.quantity,
688 &input.reference_type,
689 input.reference_id,
690 None,
691 None,
692 None,
693 None,
694 )?;
695
696 tx.commit().map_err(map_db_error)?;
697
698 Ok(reservation_id)
699 }
700
701 fn release_reservation(&self, reservation_id: Uuid) -> Result<()> {
702 let mut conn = self.conn()?;
703 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
704
705 let (lot_id, quantity, reference_type, reference_id): (String, String, String, String) = tx
707 .query_row(
708 "SELECT lot_id, quantity, reference_type, reference_id FROM lot_reservations WHERE id = ? AND released_at IS NULL AND confirmed_at IS NULL",
709 [reservation_id.to_string()],
710 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
711 )
712 .map_err(map_db_error)?;
713
714 let lot_id = parse_uuid(&lot_id, "lot_reservation", "lot_id")?;
715 let quantity = parse_decimal_strict(&quantity, "lot_reservation", "quantity")?;
716 let reference_id = parse_uuid(&reference_id, "lot_reservation", "reference_id")?;
717 let now = Utc::now();
718
719 tx.execute(
721 "UPDATE lot_reservations SET released_at = ? WHERE id = ?",
722 rusqlite::params![now.to_rfc3339(), reservation_id.to_string()],
723 )
724 .map_err(map_db_error)?;
725
726 let lot: Lot = tx
729 .query_row("SELECT * FROM lots WHERE id = ?", [lot_id.to_string()], Self::row_to_lot)
730 .map_err(map_db_error)?;
731 let new_reserved = (lot.quantity_reserved - quantity).max(Decimal::ZERO);
732 tx.execute(
733 "UPDATE lots SET quantity_reserved = ?, updated_at = ? WHERE id = ?",
734 rusqlite::params![new_reserved.to_string(), now.to_rfc3339(), lot_id.to_string()],
735 )
736 .map_err(map_db_error)?;
737
738 self.record_transaction(
740 &tx,
741 lot_id,
742 LotTransactionType::Released,
743 -quantity,
744 &reference_type,
745 reference_id,
746 None,
747 None,
748 Some("Reservation released"),
749 None,
750 )?;
751
752 tx.commit().map_err(map_db_error)?;
753
754 Ok(())
755 }
756
757 fn confirm_reservation(&self, reservation_id: Uuid) -> Result<LotTransaction> {
758 let mut conn = self.conn()?;
759 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
760
761 let (lot_id, quantity, reference_type, reference_id): (String, String, String, String) = tx
763 .query_row(
764 "SELECT lot_id, quantity, reference_type, reference_id FROM lot_reservations WHERE id = ? AND released_at IS NULL AND confirmed_at IS NULL",
765 [reservation_id.to_string()],
766 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
767 )
768 .map_err(map_db_error)?;
769
770 let lot_id = parse_uuid(&lot_id, "lot_reservation", "lot_id")?;
771 let quantity = parse_decimal_strict(&quantity, "lot_reservation", "quantity")?;
772 let reference_id = parse_uuid(&reference_id, "lot_reservation", "reference_id")?;
773 let now = Utc::now();
774
775 tx.execute(
777 "UPDATE lot_reservations SET confirmed_at = ? WHERE id = ?",
778 rusqlite::params![now.to_rfc3339(), reservation_id.to_string()],
779 )
780 .map_err(map_db_error)?;
781
782 let lot: Lot = tx
785 .query_row("SELECT * FROM lots WHERE id = ?", [lot_id.to_string()], Self::row_to_lot)
786 .map_err(map_db_error)?;
787 if lot.quantity_remaining < quantity {
788 return Err(CommerceError::InsufficientStock {
789 sku: lot.sku.clone(),
790 requested: quantity.to_string(),
791 available: lot.quantity_remaining.to_string(),
792 });
793 }
794 let new_reserved = (lot.quantity_reserved - quantity).max(Decimal::ZERO);
795 let new_remaining = lot.quantity_remaining - quantity;
796 tx.execute(
797 "UPDATE lots SET quantity_reserved = ?, quantity_remaining = ?, updated_at = ? WHERE id = ?",
798 rusqlite::params![new_reserved.to_string(), new_remaining.to_string(), now.to_rfc3339(), lot_id.to_string()],
799 )
800 .map_err(map_db_error)?;
801
802 let transaction = self.record_transaction(
804 &tx,
805 lot_id,
806 LotTransactionType::Consumed,
807 -quantity,
808 &reference_type,
809 reference_id,
810 None,
811 None,
812 Some("Reservation confirmed"),
813 None,
814 )?;
815
816 tx.commit().map_err(map_db_error)?;
817
818 Ok(transaction)
819 }
820
821 fn transfer(&self, input: TransferLot) -> Result<LotTransaction> {
822 if input.quantity <= Decimal::ZERO {
823 return Err(CommerceError::ValidationError(
824 "Transfer quantity must be positive".to_string(),
825 ));
826 }
827
828 let mut conn = self.conn()?;
829 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
830 let now = Utc::now();
831
832 let from_qty_str: String = tx
836 .query_row(
837 "SELECT quantity FROM lot_locations WHERE lot_id = ? AND location_id = ?",
838 rusqlite::params![input.lot_id.to_string(), input.from_location_id],
839 |row| row.get(0),
840 )
841 .map_err(|e| match e {
842 rusqlite::Error::QueryReturnedNoRows => CommerceError::ValidationError(format!(
843 "Lot {} has no quantity at source location {}",
844 input.lot_id, input.from_location_id
845 )),
846 e => map_db_error(e),
847 })?;
848 let from_qty = parse_decimal_strict(&from_qty_str, "lot_location", "quantity")?;
849 if from_qty < input.quantity {
850 return Err(CommerceError::ValidationError(format!(
851 "Insufficient quantity at source location {}: requested {}, available {}",
852 input.from_location_id, input.quantity, from_qty
853 )));
854 }
855
856 let new_from_qty = from_qty - input.quantity;
859 tx.execute(
860 "UPDATE lot_locations SET quantity = ?, updated_at = ? WHERE lot_id = ? AND location_id = ?",
861 rusqlite::params![
862 new_from_qty.to_string(),
863 now.to_rfc3339(),
864 input.lot_id.to_string(),
865 input.from_location_id,
866 ],
867 )
868 .map_err(map_db_error)?;
869
870 let existing_dest: Option<String> = tx
871 .query_row(
872 "SELECT quantity FROM lot_locations WHERE lot_id = ? AND location_id = ?",
873 rusqlite::params![input.lot_id.to_string(), input.to_location_id],
874 |row| row.get(0),
875 )
876 .map(Some)
877 .or_else(|e| match e {
878 rusqlite::Error::QueryReturnedNoRows => Ok(None),
879 e => Err(map_db_error(e)),
880 })?;
881 let new_dest_qty = match existing_dest {
882 Some(q) => parse_decimal_strict(&q, "lot_location", "quantity")? + input.quantity,
883 None => input.quantity,
884 };
885 tx.execute(
886 "INSERT INTO lot_locations (lot_id, location_id, quantity, updated_at)
887 VALUES (?, ?, ?, ?)
888 ON CONFLICT(lot_id, location_id) DO UPDATE SET
889 quantity = excluded.quantity, updated_at = excluded.updated_at",
890 rusqlite::params![
891 input.lot_id.to_string(),
892 input.to_location_id,
893 new_dest_qty.to_string(),
894 now.to_rfc3339(),
895 ],
896 )
897 .map_err(map_db_error)?;
898
899 let transaction = self.record_transaction(
901 &tx,
902 input.lot_id,
903 LotTransactionType::Transferred,
904 input.quantity,
905 "transfer",
906 input.lot_id,
907 Some(input.from_location_id),
908 Some(input.to_location_id),
909 input.reason.as_deref(),
910 input.performed_by.as_deref(),
911 )?;
912
913 tx.commit().map_err(map_db_error)?;
914
915 Ok(transaction)
916 }
917
918 fn split(&self, input: SplitLot) -> Result<Lot> {
919 let mut conn = self.conn()?;
920 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
921
922 let original: Lot = tx
924 .query_row(
925 "SELECT * FROM lots WHERE id = ?",
926 [input.lot_id.to_string()],
927 Self::row_to_lot,
928 )
929 .map_err(|e| match e {
930 rusqlite::Error::QueryReturnedNoRows => {
931 CommerceError::ValidationError("Lot not found".to_string())
932 }
933 e => map_db_error(e),
934 })?;
935
936 if original.quantity_remaining < input.quantity {
937 return Err(CommerceError::ValidationError(
938 "Insufficient quantity to split".to_string(),
939 ));
940 }
941
942 let new_lot_id = Uuid::new_v4();
943 let new_lot_number =
944 input.new_lot_number.unwrap_or_else(|| format!("{}-SPLIT", original.lot_number));
945 let now = Utc::now();
946
947 tx.execute(
949 "INSERT INTO lots (id, lot_number, sku, status, quantity_produced, quantity_remaining,
950 quantity_reserved, quantity_quarantined, production_date,
951 expiration_date, best_before_date, supplier_lot, supplier_id,
952 work_order_id, purchase_order_id, cost_per_unit, attributes, notes,
953 created_at, updated_at)
954 SELECT ?, ?, sku, status, ?, ?, '0', '0', production_date, expiration_date,
955 best_before_date, supplier_lot, supplier_id, work_order_id, purchase_order_id,
956 cost_per_unit, attributes, ?, ?, ?
957 FROM lots WHERE id = ?",
958 rusqlite::params![
959 new_lot_id.to_string(),
960 &new_lot_number,
961 input.quantity.to_string(),
962 input.quantity.to_string(),
963 input.reason.as_ref().map(|r| format!("Split from {}: {}", original.lot_number, r)),
964 now.to_rfc3339(),
965 now.to_rfc3339(),
966 input.lot_id.to_string(),
967 ],
968 )
969 .map_err(map_db_error)?;
970
971 let new_remaining = original.quantity_remaining - input.quantity;
973 tx.execute(
974 "UPDATE lots SET quantity_remaining = ?, updated_at = ? WHERE id = ?",
975 rusqlite::params![
976 new_remaining.to_string(),
977 now.to_rfc3339(),
978 input.lot_id.to_string(),
979 ],
980 )
981 .map_err(map_db_error)?;
982
983 self.record_transaction(
985 &tx,
986 input.lot_id,
987 LotTransactionType::Split,
988 -input.quantity,
989 "lot_split",
990 new_lot_id,
991 None,
992 None,
993 input.reason.as_deref(),
994 None,
995 )?;
996
997 self.record_transaction(
998 &tx,
999 new_lot_id,
1000 LotTransactionType::Received,
1001 input.quantity,
1002 "lot_split",
1003 input.lot_id,
1004 None,
1005 None,
1006 Some(&format!("Split from lot {}", original.lot_number)),
1007 None,
1008 )?;
1009
1010 tx.commit().map_err(map_db_error)?;
1011
1012 self.get(new_lot_id)?.ok_or(CommerceError::NotFound)
1013 }
1014
1015 fn merge(&self, input: MergeLots) -> Result<Lot> {
1016 if input.source_lot_ids.len() < 2 {
1017 return Err(CommerceError::ValidationError(
1018 "Need at least 2 lots to merge".to_string(),
1019 ));
1020 }
1021
1022 let mut conn = self.conn()?;
1023 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1024 let now = Utc::now();
1025
1026 let mut total_quantity = Decimal::ZERO;
1028 let mut sku: Option<String> = None;
1029 let mut lots_to_consume: Vec<(Uuid, String, Decimal)> = Vec::new();
1030
1031 for lot_id in &input.source_lot_ids {
1032 let lot: Lot = tx
1033 .query_row(
1034 "SELECT * FROM lots WHERE id = ?",
1035 [lot_id.to_string()],
1036 Self::row_to_lot,
1037 )
1038 .map_err(|e| match e {
1039 rusqlite::Error::QueryReturnedNoRows => {
1040 CommerceError::ValidationError(format!("Lot {lot_id} not found"))
1041 }
1042 e => map_db_error(e),
1043 })?;
1044
1045 if let Some(ref s) = sku {
1047 if s != &lot.sku {
1048 return Err(CommerceError::ValidationError(
1049 "Cannot merge lots with different SKUs".to_string(),
1050 ));
1051 }
1052 } else {
1053 sku = Some(lot.sku.clone());
1054 }
1055
1056 total_quantity += lot.quantity_remaining;
1057 lots_to_consume.push((lot.id, lot.lot_number, lot.quantity_remaining));
1058 }
1059
1060 let sku = sku.ok_or(CommerceError::ValidationError("No lots to merge".to_string()))?;
1061
1062 let new_lot_id = Uuid::new_v4();
1064 let new_lot_number = input
1065 .target_lot_number
1066 .unwrap_or_else(|| format!("MERGED-{}", Utc::now().format("%Y%m%d%H%M%S")));
1067
1068 let template: Lot = tx
1070 .query_row(
1071 "SELECT * FROM lots WHERE id = ?",
1072 [input.source_lot_ids[0].to_string()],
1073 Self::row_to_lot,
1074 )
1075 .map_err(map_db_error)?;
1076
1077 tx.execute(
1078 "INSERT INTO lots (id, lot_number, sku, status, quantity_produced, quantity_remaining,
1079 quantity_reserved, quantity_quarantined, production_date,
1080 expiration_date, best_before_date, cost_per_unit, attributes, notes,
1081 created_at, updated_at)
1082 VALUES (?, ?, ?, 'active', ?, ?, '0', '0', ?, ?, ?, ?, '{}', ?, ?, ?)",
1083 rusqlite::params![
1084 new_lot_id.to_string(),
1085 &new_lot_number,
1086 &sku,
1087 total_quantity.to_string(),
1088 total_quantity.to_string(),
1089 template.production_date.to_rfc3339(),
1090 template.expiration_date.map(|d| d.to_rfc3339()),
1091 template.best_before_date.map(|d| d.to_rfc3339()),
1092 template.cost_per_unit.map(|c| c.to_string()),
1093 input.reason.as_ref().map(|r| format!("Merged lots: {r}")),
1094 now.to_rfc3339(),
1095 now.to_rfc3339(),
1096 ],
1097 )
1098 .map_err(map_db_error)?;
1099
1100 for (lot_id, _lot_number, quantity) in lots_to_consume {
1102 tx.execute(
1103 "UPDATE lots SET status = 'consumed', quantity_remaining = '0', updated_at = ? WHERE id = ?",
1104 rusqlite::params![now.to_rfc3339(), lot_id.to_string()],
1105 )
1106 .map_err(map_db_error)?;
1107
1108 self.record_transaction(
1109 &tx,
1110 lot_id,
1111 LotTransactionType::Merged,
1112 -quantity,
1113 "lot_merge",
1114 new_lot_id,
1115 None,
1116 None,
1117 Some(&format!("Merged into lot {new_lot_number}")),
1118 None,
1119 )?;
1120 }
1121
1122 self.record_transaction(
1124 &tx,
1125 new_lot_id,
1126 LotTransactionType::Received,
1127 total_quantity,
1128 "lot_merge",
1129 input.source_lot_ids[0],
1130 None,
1131 None,
1132 Some("Created from merge"),
1133 None,
1134 )?;
1135
1136 tx.commit().map_err(map_db_error)?;
1137
1138 self.get(new_lot_id)?.ok_or(CommerceError::NotFound)
1139 }
1140
1141 fn quarantine(&self, id: Uuid, reason: &str) -> Result<Lot> {
1142 let mut conn = self.conn()?;
1143 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1144 let now = Utc::now();
1145
1146 let lot: Lot = tx
1148 .query_row("SELECT * FROM lots WHERE id = ?", [id.to_string()], Self::row_to_lot)
1149 .map_err(|e| match e {
1150 rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
1151 e => map_db_error(e),
1152 })?;
1153
1154 let available = lot.quantity_available();
1155
1156 tx.execute(
1158 "UPDATE lots SET status = ?, quantity_quarantined = ?, updated_at = ? WHERE id = ?",
1159 rusqlite::params![
1160 LotStatus::Quarantine.to_string(),
1161 available.to_string(),
1162 now.to_rfc3339(),
1163 id.to_string(),
1164 ],
1165 )
1166 .map_err(map_db_error)?;
1167
1168 self.record_transaction(
1170 &tx,
1171 id,
1172 LotTransactionType::Quarantined,
1173 available,
1174 "quarantine",
1175 id,
1176 None,
1177 None,
1178 Some(reason),
1179 None,
1180 )?;
1181
1182 tx.commit().map_err(map_db_error)?;
1183
1184 self.get(id)?.ok_or(CommerceError::NotFound)
1185 }
1186
1187 fn release_quarantine(&self, id: Uuid) -> Result<Lot> {
1188 let mut conn = self.conn()?;
1189 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1190 let now = Utc::now();
1191
1192 let quarantined: String = tx
1194 .query_row(
1195 "SELECT quantity_quarantined FROM lots WHERE id = ?",
1196 [id.to_string()],
1197 |row| row.get(0),
1198 )
1199 .map_err(map_db_error)?;
1200
1201 let quarantined = parse_decimal_strict(&quarantined, "lot", "quantity_quarantined")?;
1202
1203 tx.execute(
1205 "UPDATE lots SET status = 'active', quantity_quarantined = '0', updated_at = ? WHERE id = ?",
1206 rusqlite::params![now.to_rfc3339(), id.to_string()],
1207 )
1208 .map_err(map_db_error)?;
1209
1210 self.record_transaction(
1212 &tx,
1213 id,
1214 LotTransactionType::QuarantineReleased,
1215 quarantined,
1216 "quarantine_release",
1217 id,
1218 None,
1219 None,
1220 Some("Released from quarantine"),
1221 None,
1222 )?;
1223
1224 tx.commit().map_err(map_db_error)?;
1225
1226 self.get(id)?.ok_or(CommerceError::NotFound)
1227 }
1228
1229 fn get_transactions(&self, lot_id: Uuid, limit: u32) -> Result<Vec<LotTransaction>> {
1230 let conn = self.conn()?;
1231
1232 let mut stmt = conn
1233 .prepare(
1234 "SELECT * FROM lot_transactions WHERE lot_id = ? ORDER BY created_at DESC LIMIT ?",
1235 )
1236 .map_err(map_db_error)?;
1237
1238 let transactions = stmt
1239 .query_map(
1240 rusqlite::params![lot_id.to_string(), i64::from(limit)],
1241 Self::row_to_transaction,
1242 )
1243 .map_err(map_db_error)?
1244 .collect::<rusqlite::Result<Vec<_>>>()
1245 .map_err(map_db_error)?;
1246
1247 Ok(transactions)
1248 }
1249
1250 fn get_quantity_at_location(&self, lot_id: Uuid, location_id: i32) -> Result<Option<Decimal>> {
1251 let conn = self.conn()?;
1252
1253 let result = conn.query_row(
1254 "SELECT quantity FROM lot_locations WHERE lot_id = ? AND location_id = ?",
1255 rusqlite::params![lot_id.to_string(), location_id],
1256 |row| row.get::<_, String>(0),
1257 );
1258
1259 match result {
1260 Ok(qty) => Ok(Some(parse_decimal_strict(&qty, "lot_location", "quantity")?)),
1261 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1262 Err(e) => Err(map_db_error(e)),
1263 }
1264 }
1265
1266 fn get_lot_locations(&self, lot_id: Uuid) -> Result<Vec<LotLocation>> {
1267 let conn = self.conn()?;
1268
1269 let mut stmt = conn
1270 .prepare("SELECT lot_id, location_id, quantity, updated_at FROM lot_locations WHERE lot_id = ?")
1271 .map_err(map_db_error)?;
1272
1273 let locations = stmt
1274 .query_map([lot_id.to_string()], |row| {
1275 Ok(LotLocation {
1276 lot_id: parse_uuid_row(&row.get::<_, String>(0)?, "lot_location", "lot_id")?,
1277 location_id: row.get(1)?,
1278 quantity: parse_decimal_row(
1279 &row.get::<_, String>(2)?,
1280 "lot_location",
1281 "quantity",
1282 )?,
1283 updated_at: parse_datetime_row(
1284 &row.get::<_, String>(3)?,
1285 "lot_location",
1286 "updated_at",
1287 )?,
1288 })
1289 })
1290 .map_err(map_db_error)?
1291 .collect::<rusqlite::Result<Vec<_>>>()
1292 .map_err(map_db_error)?;
1293
1294 Ok(locations)
1295 }
1296
1297 fn add_certificate(&self, input: AddLotCertificate) -> Result<LotCertificate> {
1298 let conn = self.conn()?;
1299 let id = Uuid::new_v4();
1300 let now = Utc::now();
1301
1302 conn.execute(
1303 "INSERT INTO lot_certificates (id, lot_id, certificate_type, certificate_number,
1304 document_url, issued_by, issued_at, expires_at, notes,
1305 created_at)
1306 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1307 rusqlite::params![
1308 id.to_string(),
1309 input.lot_id.to_string(),
1310 input.certificate_type.to_string(),
1311 &input.certificate_number,
1312 &input.document_url,
1313 &input.issued_by,
1314 input.issued_at.map(|d| d.to_rfc3339()),
1315 input.expires_at.map(|d| d.to_rfc3339()),
1316 &input.notes,
1317 now.to_rfc3339(),
1318 ],
1319 )
1320 .map_err(map_db_error)?;
1321
1322 Ok(LotCertificate {
1323 id,
1324 lot_id: input.lot_id,
1325 certificate_type: input.certificate_type,
1326 certificate_number: input.certificate_number,
1327 document_url: input.document_url,
1328 issued_by: input.issued_by,
1329 issued_at: input.issued_at,
1330 expires_at: input.expires_at,
1331 notes: input.notes,
1332 created_at: now,
1333 })
1334 }
1335
1336 fn get_certificates(&self, lot_id: Uuid) -> Result<Vec<LotCertificate>> {
1337 let conn = self.conn()?;
1338
1339 let mut stmt = conn
1340 .prepare("SELECT * FROM lot_certificates WHERE lot_id = ? ORDER BY created_at DESC")
1341 .map_err(map_db_error)?;
1342
1343 let certs = stmt
1344 .query_map([lot_id.to_string()], Self::row_to_certificate)
1345 .map_err(map_db_error)?
1346 .collect::<rusqlite::Result<Vec<_>>>()
1347 .map_err(map_db_error)?;
1348
1349 Ok(certs)
1350 }
1351
1352 fn delete_certificate(&self, certificate_id: Uuid) -> Result<()> {
1353 let conn = self.conn()?;
1354 conn.execute("DELETE FROM lot_certificates WHERE id = ?", [certificate_id.to_string()])
1355 .map_err(map_db_error)?;
1356 Ok(())
1357 }
1358
1359 fn get_expiring_lots(&self, days: i32) -> Result<Vec<Lot>> {
1360 let conn = self.conn()?;
1361 let threshold = Utc::now() + chrono::Duration::days(i64::from(days));
1362
1363 let mut stmt = conn
1364 .prepare(
1365 "SELECT * FROM lots WHERE status = 'active' AND expiration_date IS NOT NULL
1366 AND expiration_date <= ? AND expiration_date > datetime('now')
1367 ORDER BY expiration_date ASC",
1368 )
1369 .map_err(map_db_error)?;
1370
1371 let lots = stmt
1372 .query_map([threshold.to_rfc3339()], Self::row_to_lot)
1373 .map_err(map_db_error)?
1374 .collect::<rusqlite::Result<Vec<_>>>()
1375 .map_err(map_db_error)?;
1376
1377 Ok(lots)
1378 }
1379
1380 fn get_expired_lots(&self) -> Result<Vec<Lot>> {
1381 let conn = self.conn()?;
1382
1383 let mut stmt = conn
1384 .prepare(
1385 "SELECT * FROM lots WHERE status = 'active' AND expiration_date IS NOT NULL
1386 AND expiration_date <= datetime('now') ORDER BY expiration_date ASC",
1387 )
1388 .map_err(map_db_error)?;
1389
1390 let lots = stmt
1391 .query_map([], Self::row_to_lot)
1392 .map_err(map_db_error)?
1393 .collect::<rusqlite::Result<Vec<_>>>()
1394 .map_err(map_db_error)?;
1395
1396 Ok(lots)
1397 }
1398
1399 fn get_available_lots_for_sku(&self, sku: &str) -> Result<Vec<Lot>> {
1400 self.list(LotFilter {
1401 sku: Some(sku.to_string()),
1402 status: Some(LotStatus::Active),
1403 has_quantity: Some(true),
1404 ..Default::default()
1405 })
1406 }
1407
1408 fn trace(&self, lot_id: Uuid) -> Result<TraceabilityResult> {
1409 let lot = self.get(lot_id)?.ok_or(CommerceError::NotFound)?;
1410 let conn = self.conn()?;
1411
1412 let mut upstream = Vec::new();
1414 if let Some(po_id) = lot.purchase_order_id {
1415 upstream.push(TraceNode {
1416 node_type: TraceNodeType::PurchaseOrder,
1417 node_id: po_id,
1418 reference_number: None,
1419 lot_number: Some(lot.lot_number.clone()),
1420 serial_number: None,
1421 quantity: lot.quantity_produced,
1422 timestamp: lot.created_at,
1423 entity_name: None,
1424 });
1425 }
1426 if let Some(wo_id) = lot.work_order_id {
1427 upstream.push(TraceNode {
1428 node_type: TraceNodeType::WorkOrder,
1429 node_id: wo_id,
1430 reference_number: None,
1431 lot_number: Some(lot.lot_number.clone()),
1432 serial_number: None,
1433 quantity: lot.quantity_produced,
1434 timestamp: lot.created_at,
1435 entity_name: None,
1436 });
1437 }
1438
1439 let mut stmt = conn
1441 .prepare(
1442 "SELECT transaction_type, reference_type, reference_id, quantity, created_at
1443 FROM lot_transactions WHERE lot_id = ? AND transaction_type IN ('consumed', 'shipped')
1444 ORDER BY created_at ASC",
1445 )
1446 .map_err(map_db_error)?;
1447
1448 let downstream = stmt
1449 .query_map([lot_id.to_string()], |row| {
1450 let ref_type: String = row.get(1)?;
1451 let node_type = match ref_type.as_str() {
1452 "order" => TraceNodeType::Order,
1453 "shipment" => TraceNodeType::Shipment,
1454 "work_order" => TraceNodeType::WorkOrder,
1455 _ => TraceNodeType::Adjustment,
1456 };
1457
1458 Ok(TraceNode {
1459 node_type,
1460 node_id: parse_uuid_row(
1461 &row.get::<_, String>(2)?,
1462 "lot_transaction",
1463 "reference_id",
1464 )?,
1465 reference_number: None,
1466 lot_number: Some(lot.lot_number.clone()),
1467 serial_number: None,
1468 quantity: parse_decimal_row(
1469 &row.get::<_, String>(3)?,
1470 "lot_transaction",
1471 "quantity",
1472 )?,
1473 timestamp: parse_datetime_row(
1474 &row.get::<_, String>(4)?,
1475 "lot_transaction",
1476 "created_at",
1477 )?,
1478 entity_name: None,
1479 })
1480 })
1481 .map_err(map_db_error)?
1482 .collect::<rusqlite::Result<Vec<_>>>()
1483 .map_err(map_db_error)?;
1484
1485 Ok(TraceabilityResult { lot, upstream, downstream })
1486 }
1487
1488 fn count(&self, filter: LotFilter) -> Result<u64> {
1489 let conn = self.conn()?;
1490
1491 let mut conditions = vec!["1=1"];
1492 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
1493
1494 if let Some(sku) = &filter.sku {
1495 conditions.push("sku = ?");
1496 params.push(Box::new(sku.clone()));
1497 }
1498 if let Some(status) = &filter.status {
1499 conditions.push("status = ?");
1500 params.push(Box::new(status.to_string()));
1501 }
1502
1503 let sql = format!("SELECT COUNT(*) FROM lots WHERE {}", conditions.join(" AND "));
1504
1505 let params_refs: Vec<&dyn rusqlite::ToSql> =
1506 params.iter().map(std::convert::AsRef::as_ref).collect();
1507
1508 conn.query_row(&sql, params_refs.as_slice(), |row| row.get::<_, i64>(0))
1509 .map(|c| c as u64)
1510 .map_err(map_db_error)
1511 }
1512
1513 fn create_batch(&self, inputs: Vec<CreateLot>) -> Result<BatchResult<Lot>> {
1514 let mut result = BatchResult::with_capacity(inputs.len());
1515
1516 for (index, input) in inputs.into_iter().enumerate() {
1517 match self.create(input) {
1518 Ok(lot) => result.record_success(lot),
1519 Err(e) => result.record_failure(index, None, &e),
1520 }
1521 }
1522
1523 Ok(result)
1524 }
1525
1526 fn get_batch(&self, ids: Vec<Uuid>) -> Result<Vec<Lot>> {
1527 let mut lots = Vec::with_capacity(ids.len());
1528 for id in ids {
1529 if let Some(lot) = self.get(id)? {
1530 lots.push(lot);
1531 }
1532 }
1533 Ok(lots)
1534 }
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539 use super::*;
1540 use crate::SqliteDatabase;
1541 use chrono::Duration;
1542 use rust_decimal_macros::dec;
1543 use stateset_core::{
1544 CreateLot, LotFilter, LotRepository, LotStatus, MergeLots, ReserveLot, SplitLot, UpdateLot,
1545 };
1546
1547 fn fresh_repo() -> SqliteLotRepository {
1548 SqliteDatabase::in_memory().expect("in-memory").lots()
1549 }
1550
1551 fn make_lot(repo: &SqliteLotRepository, sku: &str, qty: Decimal) -> Lot {
1552 repo.create(CreateLot {
1553 sku: sku.into(),
1554 quantity: qty,
1555 initial_location_id: Some(1),
1556 ..Default::default()
1557 })
1558 .expect("create lot")
1559 }
1560
1561 #[test]
1562 fn transfer_rejects_missing_source_location() {
1563 let repo = fresh_repo();
1564 let lot = make_lot(&repo, "SKU-XFER-MISS", dec!(100));
1565
1566 let err = repo
1569 .transfer(TransferLot {
1570 lot_id: lot.id,
1571 from_location_id: 99,
1572 to_location_id: 2,
1573 quantity: dec!(10),
1574 reason: None,
1575 performed_by: None,
1576 })
1577 .expect_err("transfer from empty location must fail");
1578 assert!(
1579 matches!(
1580 err,
1581 CommerceError::ValidationError(_) | CommerceError::InsufficientStock { .. }
1582 ),
1583 "got {err:?}"
1584 );
1585
1586 let locations = repo.get_lot_locations(lot.id).expect("locations");
1587 assert!(
1588 locations.iter().all(|l| l.location_id != 2),
1589 "destination must not gain quantity: {locations:?}"
1590 );
1591 }
1592
1593 #[test]
1594 fn transfer_rejects_insufficient_source_quantity() {
1595 let repo = fresh_repo();
1596 let lot = make_lot(&repo, "SKU-XFER-SHORT", dec!(5));
1597
1598 let err = repo
1599 .transfer(TransferLot {
1600 lot_id: lot.id,
1601 from_location_id: 1,
1602 to_location_id: 2,
1603 quantity: dec!(10),
1604 reason: None,
1605 performed_by: None,
1606 })
1607 .expect_err("transfer exceeding source quantity must fail");
1608 assert!(
1609 matches!(
1610 err,
1611 CommerceError::ValidationError(_) | CommerceError::InsufficientStock { .. }
1612 ),
1613 "got {err:?}"
1614 );
1615
1616 let locations = repo.get_lot_locations(lot.id).expect("locations");
1618 let source = locations.iter().find(|l| l.location_id == 1).expect("source");
1619 assert_eq!(source.quantity, dec!(5));
1620 assert!(locations.iter().all(|l| l.location_id != 2));
1621 }
1622
1623 #[test]
1624 fn release_reservation_keeps_reserved_exact() {
1625 let repo = fresh_repo();
1626 let lot = make_lot(&repo, "SKU-EXACT", dec!(1));
1627
1628 let first = repo
1632 .reserve(ReserveLot {
1633 lot_id: lot.id,
1634 quantity: dec!(0.3),
1635 reference_type: "order".into(),
1636 reference_id: Uuid::new_v4(),
1637 expires_in_seconds: None,
1638 })
1639 .expect("reserve 0.3");
1640 repo.reserve(ReserveLot {
1641 lot_id: lot.id,
1642 quantity: dec!(0.2),
1643 reference_type: "order".into(),
1644 reference_id: Uuid::new_v4(),
1645 expires_in_seconds: None,
1646 })
1647 .expect("reserve 0.2");
1648
1649 repo.release_reservation(first).expect("release");
1650
1651 let fetched = repo.get(lot.id).expect("get").expect("found");
1652 assert_eq!(fetched.quantity_reserved, dec!(0.2), "reserved drifted");
1653 }
1654
1655 #[test]
1656 fn confirm_reservation_consumes_exact() {
1657 let repo = fresh_repo();
1658 let lot = make_lot(&repo, "SKU-CONFIRM", dec!(1));
1659
1660 let reservation = repo
1661 .reserve(ReserveLot {
1662 lot_id: lot.id,
1663 quantity: dec!(0.3),
1664 reference_type: "order".into(),
1665 reference_id: Uuid::new_v4(),
1666 expires_in_seconds: None,
1667 })
1668 .expect("reserve 0.3");
1669
1670 repo.confirm_reservation(reservation).expect("confirm");
1671
1672 let fetched = repo.get(lot.id).expect("get").expect("found");
1673 assert_eq!(fetched.quantity_reserved, dec!(0));
1674 assert_eq!(fetched.quantity_remaining, dec!(0.7), "remaining drifted");
1675 }
1676
1677 #[test]
1678 fn create_lot_starts_active_with_full_remaining() {
1679 let repo = fresh_repo();
1680 let lot = make_lot(&repo, "SKU-A", dec!(100));
1681 assert_eq!(lot.sku, "SKU-A");
1682 assert_eq!(lot.quantity_produced, dec!(100));
1683 assert_eq!(lot.quantity_remaining, dec!(100));
1684 assert_eq!(lot.quantity_reserved, dec!(0));
1685 assert_eq!(lot.status, LotStatus::Active);
1686 assert!(!lot.lot_number.is_empty());
1687 }
1688
1689 #[test]
1690 fn get_and_get_by_number_round_trip() {
1691 let repo = fresh_repo();
1692 let lot = make_lot(&repo, "SKU-RT", dec!(50));
1693 let by_id = repo.get(lot.id).expect("ok").expect("found");
1694 assert_eq!(by_id.id, lot.id);
1695 let by_num = repo.get_by_number(&lot.lot_number).expect("ok").expect("found");
1696 assert_eq!(by_num.id, lot.id);
1697 assert!(repo.get_by_number("missing").expect("ok").is_none());
1698 }
1699
1700 #[test]
1701 fn update_lot_status_persists() {
1702 let repo = fresh_repo();
1703 let lot = make_lot(&repo, "SKU-UP", dec!(20));
1704 let updated = repo
1705 .update(
1706 lot.id,
1707 UpdateLot {
1708 status: Some(LotStatus::OnHold),
1709 notes: Some("on hold for QA".into()),
1710 ..Default::default()
1711 },
1712 )
1713 .expect("update");
1714 assert_eq!(updated.status, LotStatus::OnHold);
1715 }
1716
1717 #[test]
1718 fn list_filters_by_sku() {
1719 let repo = fresh_repo();
1720 make_lot(&repo, "SKU-L1", dec!(10));
1721 make_lot(&repo, "SKU-L1", dec!(20));
1722 make_lot(&repo, "SKU-L2", dec!(30));
1723
1724 let filtered = repo
1725 .list(LotFilter { sku: Some("SKU-L1".into()), ..Default::default() })
1726 .expect("list");
1727 assert_eq!(filtered.len(), 2);
1728 }
1729
1730 #[test]
1731 fn list_filters_by_status() {
1732 let repo = fresh_repo();
1733 let active = make_lot(&repo, "SKU-S", dec!(10));
1734 let to_hold = make_lot(&repo, "SKU-S", dec!(10));
1735 repo.update(
1736 to_hold.id,
1737 UpdateLot { status: Some(LotStatus::OnHold), ..Default::default() },
1738 )
1739 .expect("hold");
1740
1741 let actives = repo
1742 .list(LotFilter { status: Some(LotStatus::Active), ..Default::default() })
1743 .expect("active");
1744 let on_hold = repo
1745 .list(LotFilter { status: Some(LotStatus::OnHold), ..Default::default() })
1746 .expect("hold");
1747 assert!(actives.iter().any(|l| l.id == active.id));
1748 assert!(on_hold.iter().any(|l| l.id == to_hold.id));
1749 }
1750
1751 #[test]
1752 fn reserve_decrements_remaining_and_release_restores() {
1753 let repo = fresh_repo();
1754 let lot = make_lot(&repo, "SKU-R", dec!(50));
1755 let order_id = Uuid::new_v4();
1756 let res_id = repo
1757 .reserve(ReserveLot {
1758 lot_id: lot.id,
1759 quantity: dec!(15),
1760 reference_type: "order".into(),
1761 reference_id: order_id,
1762 expires_in_seconds: Some(60),
1763 })
1764 .expect("reserve");
1765
1766 let after = repo.get(lot.id).expect("ok").expect("found");
1767 assert_eq!(after.quantity_reserved, dec!(15));
1768 repo.release_reservation(res_id).expect("release");
1770 let restored = repo.get(lot.id).expect("ok").expect("found");
1771 assert_eq!(restored.quantity_reserved, dec!(0));
1772 }
1773
1774 #[test]
1775 fn quarantine_then_release_changes_status() {
1776 let repo = fresh_repo();
1777 let lot = make_lot(&repo, "SKU-Q", dec!(10));
1778 let q = repo.quarantine(lot.id, "qc fail").expect("quarantine");
1779 assert_eq!(q.status, LotStatus::Quarantine);
1780 let r = repo.release_quarantine(lot.id).expect("release");
1781 assert_eq!(r.status, LotStatus::Active);
1782 }
1783
1784 #[test]
1785 fn split_creates_new_lot_with_split_quantity() {
1786 let repo = fresh_repo();
1787 let lot = make_lot(&repo, "SKU-SP", dec!(100));
1788 let new_lot = repo
1789 .split(SplitLot {
1790 lot_id: lot.id,
1791 quantity: dec!(40),
1792 new_lot_number: Some("SP-001".into()),
1793 reason: Some("split for transfer".into()),
1794 })
1795 .expect("split");
1796 assert_eq!(new_lot.lot_number, "SP-001");
1797 assert_eq!(new_lot.sku, "SKU-SP");
1798 let original = repo.get(lot.id).expect("ok").expect("found");
1800 assert!(
1801 original.quantity_remaining < dec!(100),
1802 "source lot remaining should decrement after split"
1803 );
1804 }
1805
1806 #[test]
1807 fn merge_creates_target_from_sources() {
1808 let repo = fresh_repo();
1809 let l1 = make_lot(&repo, "SKU-M", dec!(30));
1810 let l2 = make_lot(&repo, "SKU-M", dec!(20));
1811 let merged = repo
1812 .merge(MergeLots {
1813 source_lot_ids: vec![l1.id, l2.id],
1814 target_lot_number: Some("MERGED-001".into()),
1815 reason: Some("consolidate".into()),
1816 })
1817 .expect("merge");
1818 assert_eq!(merged.lot_number, "MERGED-001");
1819 }
1820
1821 #[test]
1822 fn get_expiring_lots_returns_within_window() {
1823 let repo = fresh_repo();
1824 let soon = repo
1825 .create(CreateLot {
1826 sku: "SKU-EXP".into(),
1827 quantity: dec!(10),
1828 expiration_date: Some(Utc::now() + Duration::days(3)),
1829 initial_location_id: Some(1),
1830 ..Default::default()
1831 })
1832 .expect("soon");
1833 let later = repo
1834 .create(CreateLot {
1835 sku: "SKU-EXP".into(),
1836 quantity: dec!(10),
1837 expiration_date: Some(Utc::now() + Duration::days(60)),
1838 initial_location_id: Some(1),
1839 ..Default::default()
1840 })
1841 .expect("later");
1842 let no_exp = make_lot(&repo, "SKU-NOEXP", dec!(10));
1843
1844 let expiring = repo.get_expiring_lots(7).expect("ok");
1845 let ids: Vec<_> = expiring.iter().map(|l| l.id).collect();
1846 assert!(ids.contains(&soon.id));
1847 assert!(!ids.contains(&later.id));
1848 assert!(!ids.contains(&no_exp.id));
1849 }
1850
1851 #[test]
1852 fn get_available_lots_for_sku_filters_status() {
1853 let repo = fresh_repo();
1854 let active = make_lot(&repo, "SKU-AV", dec!(10));
1855 let scrapped = make_lot(&repo, "SKU-AV", dec!(5));
1856 repo.update(
1857 scrapped.id,
1858 UpdateLot { status: Some(LotStatus::Scrapped), ..Default::default() },
1859 )
1860 .expect("scrap");
1861
1862 let available = repo.get_available_lots_for_sku("SKU-AV").expect("ok");
1863 let ids: Vec<_> = available.iter().map(|l| l.id).collect();
1864 assert!(ids.contains(&active.id));
1865 assert!(!ids.contains(&scrapped.id));
1866 }
1867
1868 #[test]
1869 fn get_transactions_records_creation() {
1870 let repo = fresh_repo();
1871 let lot = make_lot(&repo, "SKU-TX", dec!(25));
1872 let txns = repo.get_transactions(lot.id, 10).expect("txns");
1873 assert!(!txns.is_empty(), "creation should record at least one transaction");
1874 }
1875
1876 #[test]
1877 fn create_batch_returns_per_input_results() {
1878 let repo = fresh_repo();
1879 let result = repo
1880 .create_batch(vec![
1881 CreateLot {
1882 sku: "SKU-CB".into(),
1883 quantity: dec!(10),
1884 initial_location_id: Some(1),
1885 ..Default::default()
1886 },
1887 CreateLot {
1888 sku: "SKU-CB".into(),
1889 quantity: dec!(20),
1890 initial_location_id: Some(1),
1891 ..Default::default()
1892 },
1893 ])
1894 .expect("batch");
1895 assert_eq!(result.success_count, 2);
1896 assert_eq!(result.failure_count, 0);
1897 }
1898
1899 #[test]
1900 fn get_batch_returns_only_existing() {
1901 let repo = fresh_repo();
1902 let l1 = make_lot(&repo, "SKU-GB", dec!(5));
1903 let l2 = make_lot(&repo, "SKU-GB", dec!(5));
1904 let stranger = Uuid::new_v4();
1905 let fetched = repo.get_batch(vec![l1.id, l2.id, stranger]).expect("ok");
1906 assert_eq!(fetched.len(), 2);
1907 }
1908
1909 #[test]
1910 fn get_unknown_id_returns_none() {
1911 let repo = fresh_repo();
1912 assert!(repo.get(Uuid::new_v4()).expect("ok").is_none());
1913 }
1914}