Skip to main content

stateset_db/sqlite/
lots.rs

1//! SQLite implementation of Lot repository
2
3use crate::sqlite::{
4    map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
5    parse_decimal_row, parse_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        // Include millisecond timestamp + random uuid suffix so concurrent lot creation
38        // (or multiple in the same second, common in tests/batch flows) cannot collide
39        // on the UNIQUE constraint.
40        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        // Record initial transaction
295        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 initial location specified, create lot_location entry
323        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        // Check if lot has transactions
487        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        // Get current lot
510        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        // Update lot
531        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        // Record transaction
542        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        // Get current lot
565        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        // Check if consumed completely
589        let new_status =
590            if new_remaining <= Decimal::ZERO { LotStatus::Consumed } else { lot.status };
591
592        // Update lot
593        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        // Record transaction
605        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        // Get current lot
628        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        // Create reservation
654        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        // Update lot reserved quantity
671        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        // Record transaction
683        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        // Get reservation
706        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        // Mark reservation as released
720        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        // Update lot reserved quantity, computed in Decimal and floored at
727        // zero (the aggregate must never go negative even if it has drifted).
728        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        // Record transaction
739        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        // Get reservation
762        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        // Mark reservation as confirmed
776        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        // Update lot: reduce both reserved and remaining, computed in Decimal.
783        // Confirming consumes stock, so the remaining quantity must cover it.
784        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        // Record transaction
803        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        // The source location must exist and cover the transfer — a blind
833        // decrement would silently mint quantity at the destination when the
834        // source row is missing, or drive it negative when short.
835        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        // Compute both sides in Decimal (TEXT-column SQL arithmetic coerces
857        // through IEEE-754 floats).
858        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        // Record transaction
900        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        // Get original lot
923        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        // Create new lot
948        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        // Reduce original lot
972        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        // Record transactions
984        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        // Get all source lots
1027        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            // Verify same SKU
1046            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        // Create new merged lot
1063        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        // Get first lot as template
1069        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        // Mark source lots as consumed and record transactions
1101        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        // Record received transaction for new lot
1123        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        // Get lot
1147        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        // Update lot
1157        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        // Record transaction
1169        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        // Get current quarantined quantity
1193        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        // Update lot
1204        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        // Record transaction
1211        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        // Get upstream (where did this lot come from)
1413        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        // Get downstream (where did this lot go)
1440        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        // Location 99 holds nothing for this lot; the transfer must not
1567        // silently mint quantity at the destination.
1568        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        // Source keeps its full quantity; destination gains nothing.
1617        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        // Two fractional reservations; releasing the first must leave the
1629        // aggregate exactly 0.2 (TEXT-column SQL arithmetic would drift
1630        // through IEEE-754: 0.5 - 0.3 = 0.19999999999999998).
1631        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        // remaining is on-hand minus reserved depending on impl; assert reserved is right
1769        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        // Source lot should have less remaining
1799        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}