Skip to main content

stateset_db/sqlite/
inventory.rs

1//! SQLite inventory repository implementation
2
3use super::{
4    INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, MAX_RETRIES, build_in_clause, i64_params, map_db_error,
5    params_refs, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
6    parse_decimal_row, parse_decimal_strict, parse_enum_row, parse_uuid, parse_uuid_row,
7    string_params, with_immediate_transaction, with_retry,
8};
9use chrono::{DateTime, Utc};
10use r2d2::Pool;
11use r2d2_sqlite::SqliteConnectionManager;
12use rust_decimal::Decimal;
13use stateset_core::{
14    AdjustInventory, BatchResult, CommerceError, CreateInventoryItem, InventoryBalance,
15    InventoryFilter, InventoryItem, InventoryRepository, InventoryReservation,
16    InventoryTransaction, LocationStock, ReservationStatus, ReserveInventory, Result, StockLevel,
17    TransactionType, validate_batch_size, validate_quantity, validate_sku,
18};
19use std::cell::Cell;
20use uuid::Uuid;
21
22/// SQLite implementation of `InventoryRepository`
23#[derive(Debug)]
24pub struct SqliteInventoryRepository {
25    pool: Pool<SqliteConnectionManager>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum ReservationConfirmOutcome {
30    Confirmed,
31    Expired,
32}
33
34thread_local! {
35    static INVENTORY_RETRY_SEED: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
36}
37
38fn inventory_retry_delay_ms(backoff_ms: u64, retry: u32) -> u64 {
39    let jitter = INVENTORY_RETRY_SEED.with(|seed| {
40        let mut state = seed.get().wrapping_add(u64::from(retry) + 1);
41        state ^= state << 13;
42        state ^= state >> 7;
43        state ^= state << 17;
44        seed.set(state);
45        state % 50
46    });
47
48    backoff_ms.min(MAX_BACKOFF_MS) + jitter
49}
50
51fn should_retry_inventory_error(err: &CommerceError) -> bool {
52    match err {
53        CommerceError::VersionConflict { entity, .. } => entity == "inventory_balance",
54        CommerceError::DatabaseError(message) => {
55            message.contains("database is locked") || message.contains("database table is locked")
56        }
57        _ => false,
58    }
59}
60
61fn with_inventory_retry<T, F>(mut operation: F) -> Result<T>
62where
63    F: FnMut() -> Result<T>,
64{
65    let mut retries = 0;
66    let mut backoff_ms = INITIAL_BACKOFF_MS;
67
68    loop {
69        match operation() {
70            Ok(result) => return Ok(result),
71            Err(err) if should_retry_inventory_error(&err) && retries < MAX_RETRIES => {
72                retries += 1;
73                let delay_ms = inventory_retry_delay_ms(backoff_ms, retries);
74                std::thread::sleep(std::time::Duration::from_millis(delay_ms));
75                backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
76            }
77            Err(err) => return Err(err),
78        }
79    }
80}
81
82impl SqliteInventoryRepository {
83    #[must_use]
84    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
85        Self { pool }
86    }
87
88    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
89        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
90    }
91
92    fn expire_reservation_in_tx(
93        conn: &rusqlite::Connection,
94        reservation_id: Uuid,
95        item_id: i64,
96        location_id: i32,
97        quantity: Decimal,
98        now: DateTime<Utc>,
99    ) -> Result<()> {
100        let current_version: i32 = conn
101            .query_row(
102                "SELECT version FROM inventory_balances WHERE item_id = ? AND location_id = ?",
103                rusqlite::params![item_id, location_id],
104                |row| row.get(0),
105            )
106            .map_err(map_db_error)?;
107
108        let rows_affected = conn
109            .execute(
110                "UPDATE inventory_balances SET quantity_allocated = quantity_allocated - ?,
111                 quantity_available = quantity_available + ?, version = version + 1, updated_at = ?
112                 WHERE item_id = ? AND location_id = ? AND version = ?",
113                rusqlite::params![
114                    quantity.to_string(),
115                    quantity.to_string(),
116                    now.to_rfc3339(),
117                    item_id,
118                    location_id,
119                    current_version
120                ],
121            )
122            .map_err(map_db_error)?;
123
124        if rows_affected == 0 {
125            return Err(CommerceError::VersionConflict {
126                entity: "inventory_balance".to_string(),
127                id: format!("{item_id}:{location_id}"),
128                expected_version: current_version,
129            });
130        }
131
132        conn.execute(
133            "UPDATE inventory_reservations SET status = 'expired' WHERE id = ?",
134            [reservation_id.to_string()],
135        )
136        .map_err(map_db_error)?;
137
138        Ok(())
139    }
140
141    fn expire_reservations_for_item_in_tx(
142        conn: &rusqlite::Connection,
143        item_id: i64,
144        location_id: i32,
145        now: DateTime<Utc>,
146    ) -> Result<()> {
147        let mut stmt = conn
148            .prepare(
149                "SELECT id, quantity FROM inventory_reservations
150                 WHERE item_id = ? AND location_id = ?
151                   AND status IN ('pending', 'confirmed', 'allocated')
152                   AND expires_at IS NOT NULL AND expires_at < ?",
153            )
154            .map_err(map_db_error)?;
155        let mut rows = stmt
156            .query(rusqlite::params![item_id, location_id, now.to_rfc3339()])
157            .map_err(map_db_error)?;
158
159        while let Some(row) = rows.next().map_err(map_db_error)? {
160            let id_str: String = row.get(0).map_err(map_db_error)?;
161            let qty_str: String = row.get(1).map_err(map_db_error)?;
162            let reservation_id = parse_uuid(&id_str, "inventory_reservation", "id")?;
163            let quantity = parse_decimal_strict(&qty_str, "inventory_reservation", "quantity")?;
164
165            Self::expire_reservation_in_tx(
166                conn,
167                reservation_id,
168                item_id,
169                location_id,
170                quantity,
171                now,
172            )?;
173        }
174
175        Ok(())
176    }
177
178    pub(crate) fn reserve_in_tx(
179        tx: &rusqlite::Transaction<'_>,
180        input: &ReserveInventory,
181    ) -> std::result::Result<InventoryReservation, rusqlite::Error> {
182        validate_quantity(input.quantity)
183            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
184
185        let sku = input.sku.clone();
186        let quantity = input.quantity;
187        let location_id = input.location_id.unwrap_or(1);
188        let reference_type = input.reference_type.clone();
189        let reference_id = input.reference_id.clone();
190        let expires_in_seconds = input.expires_in_seconds;
191
192        let now = Utc::now();
193
194        let item = tx
195            .query_row("SELECT * FROM inventory_items WHERE sku = ?", [&sku], |row| {
196                Ok(InventoryItem {
197                    id: row.get("id")?,
198                    sku: row.get("sku")?,
199                    name: row.get("name")?,
200                    description: row.get("description")?,
201                    unit_of_measure: row.get("unit_of_measure")?,
202                    is_active: row.get::<_, i32>("is_active")? != 0,
203                    created_at: parse_datetime_row(
204                        &row.get::<_, String>("created_at")?,
205                        "inventory_item",
206                        "created_at",
207                    )?,
208                    updated_at: parse_datetime_row(
209                        &row.get::<_, String>("updated_at")?,
210                        "inventory_item",
211                        "updated_at",
212                    )?,
213                })
214            })
215            .map_err(|e| match e {
216                rusqlite::Error::QueryReturnedNoRows => rusqlite::Error::ToSqlConversionFailure(
217                    Box::new(CommerceError::InventoryItemNotFound(sku.clone())),
218                ),
219                other => other,
220            })?;
221
222        Self::expire_reservations_for_item_in_tx(tx, item.id, location_id, now)
223            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
224
225        let balance = tx.query_row(
226            "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
227            rusqlite::params![item.id, location_id],
228            |row| {
229                Ok(InventoryBalance {
230                    id: row.get("id")?,
231                    item_id: row.get("item_id")?,
232                    location_id: row.get("location_id")?,
233                    quantity_on_hand: parse_decimal_row(
234                        &row.get::<_, String>("quantity_on_hand")?,
235                        "inventory_balance",
236                        "quantity_on_hand",
237                    )?,
238                    quantity_allocated: parse_decimal_row(
239                        &row.get::<_, String>("quantity_allocated")?,
240                        "inventory_balance",
241                        "quantity_allocated",
242                    )?,
243                    quantity_available: parse_decimal_row(
244                        &row.get::<_, String>("quantity_available")?,
245                        "inventory_balance",
246                        "quantity_available",
247                    )?,
248                    reorder_point: parse_decimal_opt_row(
249                        row.get::<_, Option<String>>("reorder_point")?,
250                        "inventory_balance",
251                        "reorder_point",
252                    )?,
253                    safety_stock: parse_decimal_opt_row(
254                        row.get::<_, Option<String>>("safety_stock")?,
255                        "inventory_balance",
256                        "safety_stock",
257                    )?,
258                    version: row.get("version")?,
259                    last_counted_at: parse_datetime_opt_row(
260                        row.get::<_, Option<String>>("last_counted_at")?,
261                        "inventory_balance",
262                        "last_counted_at",
263                    )?,
264                    updated_at: parse_datetime_row(
265                        &row.get::<_, String>("updated_at")?,
266                        "inventory_balance",
267                        "updated_at",
268                    )?,
269                })
270            },
271        )?;
272
273        if balance.quantity_available < quantity {
274            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
275                CommerceError::InsufficientStock {
276                    sku,
277                    requested: quantity.to_string(),
278                    available: balance.quantity_available.to_string(),
279                },
280            )));
281        }
282
283        let reservation_id = Uuid::new_v4();
284        let expires_at = expires_in_seconds.map(|secs| now + chrono::Duration::seconds(secs));
285
286        tx.execute(
287            "INSERT INTO inventory_reservations (id, item_id, location_id, quantity, status, reference_type, reference_id, expires_at, created_at)
288             VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)",
289            rusqlite::params![
290                reservation_id.to_string(),
291                item.id,
292                location_id,
293                quantity.to_string(),
294                &reference_type,
295                &reference_id,
296                expires_at.map(|t| t.to_rfc3339()),
297                now.to_rfc3339(),
298            ],
299        )?;
300
301        let new_allocated = balance.quantity_allocated + quantity;
302        let new_available = balance.quantity_on_hand - new_allocated;
303        let current_version = balance.version;
304
305        // Guard the write with the optimistic-lock version check. The Rust-side
306        // `balance.quantity_available < quantity` check above already rejected
307        // insufficient stock using exact `Decimal` arithmetic; matching `version`
308        // here proves the row has not changed since that read, so the balance is
309        // still current and the reservation cannot over-allocate. (An earlier
310        // `AND CAST(quantity_available AS REAL) >= CAST(? AS REAL)` clause was
311        // dropped: comparing TEXT money columns as IEEE-754 floats could both
312        // spuriously reject valid reservations and allow sub-cent oversells at
313        // the boundary — and it was redundant given the version guard.)
314        let rows_affected = tx.execute(
315            "UPDATE inventory_balances SET quantity_allocated = ?, quantity_available = ?, version = version + 1, updated_at = ?
316             WHERE item_id = ? AND location_id = ? AND version = ?",
317            rusqlite::params![
318                new_allocated.to_string(),
319                new_available.to_string(),
320                now.to_rfc3339(),
321                item.id,
322                location_id,
323                current_version,
324            ],
325        )?;
326
327        if rows_affected == 0 {
328            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
329                CommerceError::VersionConflict {
330                    entity: "inventory_balance".to_string(),
331                    id: format!("{}:{}", item.id, location_id),
332                    expected_version: current_version,
333                },
334            )));
335        }
336
337        Ok(InventoryReservation {
338            id: reservation_id,
339            item_id: item.id,
340            location_id,
341            quantity,
342            status: ReservationStatus::Pending,
343            reference_type,
344            reference_id,
345            expires_at,
346            created_at: now,
347        })
348    }
349
350    pub(crate) fn list_reservation_ids_by_reference_in_tx(
351        tx: &rusqlite::Transaction<'_>,
352        reference_type: &str,
353        reference_id: &str,
354    ) -> std::result::Result<Vec<Uuid>, rusqlite::Error> {
355        let mut stmt = tx.prepare(
356            "SELECT id FROM inventory_reservations WHERE reference_type = ? AND reference_id = ? ORDER BY created_at",
357        )?;
358        let rows = stmt.query_map(rusqlite::params![reference_type, reference_id], |row| {
359            let id_str: String = row.get(0)?;
360            parse_uuid(&id_str, "inventory_reservation", "id")
361                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
362        })?;
363        let mut ids = Vec::new();
364        for row in rows {
365            ids.push(row?);
366        }
367        Ok(ids)
368    }
369
370    pub(crate) fn release_reservation_in_tx(
371        tx: &rusqlite::Transaction<'_>,
372        reservation_id: Uuid,
373    ) -> std::result::Result<(), rusqlite::Error> {
374        let now = Utc::now();
375
376        let res = tx.query_row(
377            "SELECT item_id, location_id, quantity, status, expires_at FROM inventory_reservations WHERE id = ?",
378            [reservation_id.to_string()],
379            |row| {
380                Ok((
381                    row.get::<_, i64>("item_id")?,
382                    row.get::<_, i32>("location_id")?,
383                    parse_decimal_row(&row.get::<_, String>("quantity")?, "inventory_reservation", "quantity")?,
384                    row.get::<_, String>("status")?,
385                    parse_datetime_opt_row(row.get::<_, Option<String>>("expires_at")?, "inventory_reservation", "expires_at")?,
386                ))
387            },
388        );
389
390        let (item_id, location_id, quantity, status, expires_at) = match res {
391            Ok(r) => r,
392            Err(rusqlite::Error::QueryReturnedNoRows) => {
393                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
394                    CommerceError::ReservationNotFound(reservation_id),
395                )));
396            }
397            Err(e) => return Err(e),
398        };
399
400        let parsed_status: ReservationStatus = status.parse().map_err(|e| {
401            rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
402                format!("Invalid inventory_reservation.status '{status}': {e}"),
403            )))
404        })?;
405
406        if parsed_status == ReservationStatus::Released
407            || parsed_status == ReservationStatus::Cancelled
408            || parsed_status == ReservationStatus::Expired
409        {
410            return Ok(());
411        }
412
413        if let Some(expires_at) = expires_at {
414            if expires_at < now {
415                Self::expire_reservation_in_tx(
416                    tx,
417                    reservation_id,
418                    item_id,
419                    location_id,
420                    quantity,
421                    now,
422                )
423                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
424                return Ok(());
425            }
426        }
427
428        tx.execute(
429            "UPDATE inventory_reservations SET status = 'released' WHERE id = ?",
430            [reservation_id.to_string()],
431        )?;
432
433        let current_version: i32 = tx.query_row(
434            "SELECT version FROM inventory_balances WHERE item_id = ? AND location_id = ?",
435            rusqlite::params![item_id, location_id],
436            |row| row.get(0),
437        )?;
438
439        let rows_affected = tx.execute(
440            "UPDATE inventory_balances SET quantity_allocated = quantity_allocated - ?,
441             quantity_available = quantity_available + ?, version = version + 1, updated_at = ?
442             WHERE item_id = ? AND location_id = ? AND version = ?",
443            rusqlite::params![
444                quantity.to_string(),
445                quantity.to_string(),
446                now.to_rfc3339(),
447                item_id,
448                location_id,
449                current_version
450            ],
451        )?;
452
453        if rows_affected == 0 {
454            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
455                CommerceError::VersionConflict {
456                    entity: "inventory_balance".to_string(),
457                    id: format!("{item_id}:{location_id}"),
458                    expected_version: current_version,
459                },
460            )));
461        }
462
463        Ok(())
464    }
465
466    pub(crate) fn confirm_reservation_in_tx(
467        tx: &rusqlite::Transaction<'_>,
468        reservation_id: Uuid,
469    ) -> std::result::Result<ReservationConfirmOutcome, rusqlite::Error> {
470        Self::confirm_reservation_in_tx_with_now(tx, reservation_id, Utc::now())
471    }
472
473    pub(crate) fn confirm_reservation_in_tx_with_now(
474        tx: &rusqlite::Transaction<'_>,
475        reservation_id: Uuid,
476        now: DateTime<Utc>,
477    ) -> std::result::Result<ReservationConfirmOutcome, rusqlite::Error> {
478        let res = tx.query_row(
479            "SELECT item_id, location_id, quantity, status, expires_at FROM inventory_reservations WHERE id = ?",
480            [reservation_id.to_string()],
481            |row| {
482                Ok((
483                    row.get::<_, i64>("item_id")?,
484                    row.get::<_, i32>("location_id")?,
485                    parse_decimal_row(&row.get::<_, String>("quantity")?, "inventory_reservation", "quantity")?,
486                    row.get::<_, String>("status")?,
487                    parse_datetime_opt_row(row.get::<_, Option<String>>("expires_at")?, "inventory_reservation", "expires_at")?,
488                ))
489            },
490        );
491
492        let (item_id, location_id, quantity, status, expires_at) = match res {
493            Ok(r) => r,
494            Err(rusqlite::Error::QueryReturnedNoRows) => {
495                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
496                    CommerceError::ReservationNotFound(reservation_id),
497                )));
498            }
499            Err(e) => return Err(e),
500        };
501
502        let parsed_status: ReservationStatus = status.parse().map_err(|e| {
503            rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
504                format!("Invalid inventory_reservation.status '{status}': {e}"),
505            )))
506        })?;
507
508        if parsed_status == ReservationStatus::Released
509            || parsed_status == ReservationStatus::Cancelled
510        {
511            return Ok(ReservationConfirmOutcome::Confirmed);
512        }
513
514        if parsed_status == ReservationStatus::Expired {
515            return Ok(ReservationConfirmOutcome::Expired);
516        }
517
518        if let Some(expires_at) = expires_at {
519            if expires_at < now {
520                Self::expire_reservation_in_tx(
521                    tx,
522                    reservation_id,
523                    item_id,
524                    location_id,
525                    quantity,
526                    now,
527                )
528                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
529                return Ok(ReservationConfirmOutcome::Expired);
530            }
531        }
532
533        tx.execute(
534            "UPDATE inventory_reservations SET status = 'confirmed' WHERE id = ?",
535            [reservation_id.to_string()],
536        )?;
537
538        Ok(ReservationConfirmOutcome::Confirmed)
539    }
540
541    pub(crate) fn expire_reservation_if_needed_in_tx(
542        tx: &rusqlite::Transaction<'_>,
543        reservation_id: Uuid,
544        now: DateTime<Utc>,
545    ) -> std::result::Result<bool, rusqlite::Error> {
546        let res = tx.query_row(
547            "SELECT item_id, location_id, quantity, status, expires_at FROM inventory_reservations WHERE id = ?",
548            [reservation_id.to_string()],
549            |row| {
550                Ok((
551                    row.get::<_, i64>("item_id")?,
552                    row.get::<_, i32>("location_id")?,
553                    parse_decimal_row(&row.get::<_, String>("quantity")?, "inventory_reservation", "quantity")?,
554                    row.get::<_, String>("status")?,
555                    parse_datetime_opt_row(row.get::<_, Option<String>>("expires_at")?, "inventory_reservation", "expires_at")?,
556                ))
557            },
558        );
559
560        let (item_id, location_id, quantity, status, expires_at) = match res {
561            Ok(r) => r,
562            Err(rusqlite::Error::QueryReturnedNoRows) => {
563                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
564                    CommerceError::ReservationNotFound(reservation_id),
565                )));
566            }
567            Err(e) => return Err(e),
568        };
569
570        let parsed_status: ReservationStatus = status.parse().map_err(|e| {
571            rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
572                format!("Invalid inventory_reservation.status '{status}': {e}"),
573            )))
574        })?;
575
576        if parsed_status == ReservationStatus::Released
577            || parsed_status == ReservationStatus::Cancelled
578        {
579            return Ok(false);
580        }
581
582        if parsed_status == ReservationStatus::Expired {
583            return Ok(true);
584        }
585
586        if let Some(expires_at) = expires_at {
587            if expires_at < now {
588                Self::expire_reservation_in_tx(
589                    tx,
590                    reservation_id,
591                    item_id,
592                    location_id,
593                    quantity,
594                    now,
595                )
596                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
597                return Ok(true);
598            }
599        }
600
601        Ok(false)
602    }
603}
604
605impl InventoryRepository for SqliteInventoryRepository {
606    fn create_item(&self, input: CreateInventoryItem) -> Result<InventoryItem> {
607        // Validate SKU format
608        validate_sku(&input.sku)?;
609
610        // Clone values needed in the closure
611        let sku = input.sku.clone();
612        let name = input.name.clone();
613        let description = input.description.clone();
614        let unit_of_measure = input.unit_of_measure.clone().unwrap_or_else(|| "EA".to_string());
615        let location_id = input.location_id.unwrap_or(1);
616        let initial_qty = input.initial_quantity.unwrap_or_default();
617        let reorder_point = input.reorder_point;
618        let safety_stock = input.safety_stock;
619
620        with_immediate_transaction(&self.pool, |tx| {
621            let now = Utc::now();
622
623            // Check SKU uniqueness
624            let exists: i32 = tx.query_row(
625                "SELECT COUNT(*) FROM inventory_items WHERE sku = ?",
626                [&sku],
627                |row| row.get(0),
628            )?;
629
630            if exists > 0 {
631                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
632                    CommerceError::DuplicateSku(sku.clone()),
633                )));
634            }
635
636            tx.execute(
637                "INSERT INTO inventory_items (sku, name, description, unit_of_measure, is_active, created_at, updated_at)
638                 VALUES (?, ?, ?, ?, 1, ?, ?)",
639                rusqlite::params![
640                    &sku,
641                    &name,
642                    &description,
643                    &unit_of_measure,
644                    now.to_rfc3339(),
645                    now.to_rfc3339(),
646                ],
647            )?;
648
649            let item_id = tx.last_insert_rowid();
650
651            tx.execute(
652                "INSERT INTO inventory_balances (item_id, location_id, quantity_on_hand, quantity_allocated, quantity_available, reorder_point, safety_stock, updated_at)
653                 VALUES (?, ?, ?, '0', ?, ?, ?, ?)",
654                rusqlite::params![
655                    item_id,
656                    location_id,
657                    initial_qty.to_string(),
658                    initial_qty.to_string(),
659                    reorder_point.map(|d| d.to_string()),
660                    safety_stock.map(|d| d.to_string()),
661                    now.to_rfc3339(),
662                ],
663            )?;
664
665            // Record initial transaction if quantity > 0
666            if initial_qty > Decimal::ZERO {
667                tx.execute(
668                    "INSERT INTO inventory_transactions (item_id, location_id, transaction_type, quantity, reason, created_at)
669                     VALUES (?, ?, 'receipt', ?, 'Initial stock', ?)",
670                    rusqlite::params![item_id, location_id, initial_qty.to_string(), now.to_rfc3339()],
671                )?;
672            }
673
674            // Clone values for the return since Fn closure may be called multiple times
675            Ok(InventoryItem {
676                id: item_id,
677                sku: sku.clone(),
678                name: name.clone(),
679                description: description.clone(),
680                unit_of_measure: unit_of_measure.clone(),
681                is_active: true,
682                created_at: now,
683                updated_at: now,
684            })
685        })
686    }
687
688    fn get_item(&self, id: i64) -> Result<Option<InventoryItem>> {
689        let conn = self.conn()?;
690        let result = conn.query_row("SELECT * FROM inventory_items WHERE id = ?", [id], |row| {
691            Ok(InventoryItem {
692                id: row.get("id")?,
693                sku: row.get("sku")?,
694                name: row.get("name")?,
695                description: row.get("description")?,
696                unit_of_measure: row.get("unit_of_measure")?,
697                is_active: row.get::<_, i32>("is_active")? != 0,
698                created_at: parse_datetime_row(
699                    &row.get::<_, String>("created_at")?,
700                    "inventory_item",
701                    "created_at",
702                )?,
703                updated_at: parse_datetime_row(
704                    &row.get::<_, String>("updated_at")?,
705                    "inventory_item",
706                    "updated_at",
707                )?,
708            })
709        });
710
711        match result {
712            Ok(item) => Ok(Some(item)),
713            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
714            Err(e) => Err(map_db_error(e)),
715        }
716    }
717
718    fn get_item_by_sku(&self, sku: &str) -> Result<Option<InventoryItem>> {
719        let conn = self.conn()?;
720        let result = conn.query_row("SELECT * FROM inventory_items WHERE sku = ?", [sku], |row| {
721            Ok(InventoryItem {
722                id: row.get("id")?,
723                sku: row.get("sku")?,
724                name: row.get("name")?,
725                description: row.get("description")?,
726                unit_of_measure: row.get("unit_of_measure")?,
727                is_active: row.get::<_, i32>("is_active")? != 0,
728                created_at: parse_datetime_row(
729                    &row.get::<_, String>("created_at")?,
730                    "inventory_item",
731                    "created_at",
732                )?,
733                updated_at: parse_datetime_row(
734                    &row.get::<_, String>("updated_at")?,
735                    "inventory_item",
736                    "updated_at",
737                )?,
738            })
739        });
740
741        match result {
742            Ok(item) => Ok(Some(item)),
743            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
744            Err(e) => Err(map_db_error(e)),
745        }
746    }
747
748    fn get_stock(&self, sku: &str) -> Result<Option<StockLevel>> {
749        with_retry(
750            || {
751                let conn = self.pool.get().map_err(|e| {
752                    rusqlite::Error::SqliteFailure(
753                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
754                        Some(e.to_string()),
755                    )
756                })?;
757
758                // Get item directly with this connection
759                let item_result =
760                    conn.query_row("SELECT * FROM inventory_items WHERE sku = ?", [sku], |row| {
761                        Ok(InventoryItem {
762                            id: row.get("id")?,
763                            sku: row.get("sku")?,
764                            name: row.get("name")?,
765                            description: row.get("description")?,
766                            unit_of_measure: row.get("unit_of_measure")?,
767                            is_active: row.get::<_, i32>("is_active")? != 0,
768                            created_at: parse_datetime_row(
769                                &row.get::<_, String>("created_at")?,
770                                "inventory_item",
771                                "created_at",
772                            )?,
773                            updated_at: parse_datetime_row(
774                                &row.get::<_, String>("updated_at")?,
775                                "inventory_item",
776                                "updated_at",
777                            )?,
778                        })
779                    });
780
781                let item = match item_result {
782                    Ok(item) => item,
783                    Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
784                    Err(e) => return Err(e),
785                };
786
787                // Get all balances for item
788                let mut stmt = conn.prepare(
789                    "SELECT b.*, l.name as location_name
790                 FROM inventory_balances b
791                 LEFT JOIN inventory_locations l ON b.location_id = l.id
792                 WHERE b.item_id = ?",
793                )?;
794
795                let locations: Vec<LocationStock> = stmt
796                    .query_map([item.id], |row| {
797                        Ok(LocationStock {
798                            location_id: row.get("location_id")?,
799                            location_name: row.get("location_name")?,
800                            on_hand: parse_decimal_row(
801                                &row.get::<_, String>("quantity_on_hand")?,
802                                "inventory_balance",
803                                "quantity_on_hand",
804                            )?,
805                            allocated: parse_decimal_row(
806                                &row.get::<_, String>("quantity_allocated")?,
807                                "inventory_balance",
808                                "quantity_allocated",
809                            )?,
810                            available: parse_decimal_row(
811                                &row.get::<_, String>("quantity_available")?,
812                                "inventory_balance",
813                                "quantity_available",
814                            )?,
815                        })
816                    })?
817                    .collect::<rusqlite::Result<Vec<_>>>()?;
818
819                let total_on_hand: Decimal = locations.iter().map(|l| l.on_hand).sum();
820                let total_allocated: Decimal = locations.iter().map(|l| l.allocated).sum();
821                let total_available: Decimal = locations.iter().map(|l| l.available).sum();
822
823                Ok(Some(StockLevel {
824                    sku: item.sku,
825                    name: item.name,
826                    total_on_hand,
827                    total_allocated,
828                    total_available,
829                    locations,
830                }))
831            },
832            MAX_RETRIES,
833        )
834        .map_err(map_db_error)
835    }
836
837    fn get_balance(&self, item_id: i64, location_id: i32) -> Result<Option<InventoryBalance>> {
838        let conn = self.conn()?;
839        let result = conn.query_row(
840            "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
841            rusqlite::params![item_id, location_id],
842            |row| {
843                Ok(InventoryBalance {
844                    id: row.get("id")?,
845                    item_id: row.get("item_id")?,
846                    location_id: row.get("location_id")?,
847                    quantity_on_hand: parse_decimal_row(
848                        &row.get::<_, String>("quantity_on_hand")?,
849                        "inventory_balance",
850                        "quantity_on_hand",
851                    )?,
852                    quantity_allocated: parse_decimal_row(
853                        &row.get::<_, String>("quantity_allocated")?,
854                        "inventory_balance",
855                        "quantity_allocated",
856                    )?,
857                    quantity_available: parse_decimal_row(
858                        &row.get::<_, String>("quantity_available")?,
859                        "inventory_balance",
860                        "quantity_available",
861                    )?,
862                    reorder_point: parse_decimal_opt_row(
863                        row.get::<_, Option<String>>("reorder_point")?,
864                        "inventory_balance",
865                        "reorder_point",
866                    )?,
867                    safety_stock: parse_decimal_opt_row(
868                        row.get::<_, Option<String>>("safety_stock")?,
869                        "inventory_balance",
870                        "safety_stock",
871                    )?,
872                    version: row.get("version")?,
873                    last_counted_at: parse_datetime_opt_row(
874                        row.get::<_, Option<String>>("last_counted_at")?,
875                        "inventory_balance",
876                        "last_counted_at",
877                    )?,
878                    updated_at: parse_datetime_row(
879                        &row.get::<_, String>("updated_at")?,
880                        "inventory_balance",
881                        "updated_at",
882                    )?,
883                })
884            },
885        );
886
887        match result {
888            Ok(balance) => Ok(Some(balance)),
889            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
890            Err(e) => Err(map_db_error(e)),
891        }
892    }
893
894    fn adjust(&self, input: AdjustInventory) -> Result<InventoryTransaction> {
895        // Validate SKU format
896        validate_sku(&input.sku)?;
897
898        // Validate that adjustment quantity is not zero
899        if input.quantity.is_zero() {
900            return Err(CommerceError::ValidationError(
901                "Adjustment quantity cannot be zero".into(),
902            ));
903        }
904
905        // Clone values needed in the closure
906        let sku = input.sku.clone();
907        let quantity = input.quantity;
908        let location_id = input.location_id.unwrap_or(1);
909        let reference_type = input.reference_type.clone();
910        let reference_id = input.reference_id.clone();
911        let reason = input.reason;
912
913        with_immediate_transaction(&self.pool, |tx| {
914            let now = Utc::now();
915
916            // Get item directly with this connection
917            let item = tx.query_row(
918                "SELECT * FROM inventory_items WHERE sku = ?",
919                [&sku],
920                |row| {
921                    Ok(InventoryItem {
922                        id: row.get("id")?,
923                        sku: row.get("sku")?,
924                        name: row.get("name")?,
925                        description: row.get("description")?,
926                        unit_of_measure: row.get("unit_of_measure")?,
927                        is_active: row.get::<_, i32>("is_active")? != 0,
928                        created_at: parse_datetime_row(&row.get::<_, String>("created_at")?, "inventory_item", "created_at")?,
929                        updated_at: parse_datetime_row(&row.get::<_, String>("updated_at")?, "inventory_item", "updated_at")?,
930                    })
931                },
932            )?;
933
934            // Get or create balance directly with this connection
935            let balance_result = tx.query_row(
936                "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
937                rusqlite::params![item.id, location_id],
938                |row| {
939                    Ok(InventoryBalance {
940                        id: row.get("id")?,
941                        item_id: row.get("item_id")?,
942                        location_id: row.get("location_id")?,
943                        quantity_on_hand: parse_decimal_row(&row.get::<_, String>("quantity_on_hand")?, "inventory_balance", "quantity_on_hand")?,
944                        quantity_allocated: parse_decimal_row(&row.get::<_, String>("quantity_allocated")?, "inventory_balance", "quantity_allocated")?,
945                        quantity_available: parse_decimal_row(&row.get::<_, String>("quantity_available")?, "inventory_balance", "quantity_available")?,
946                        reorder_point: parse_decimal_opt_row(row.get::<_, Option<String>>("reorder_point")?, "inventory_balance", "reorder_point")?,
947                        safety_stock: parse_decimal_opt_row(row.get::<_, Option<String>>("safety_stock")?, "inventory_balance", "safety_stock")?,
948                        version: row.get("version")?,
949                        last_counted_at: parse_datetime_opt_row(row.get::<_, Option<String>>("last_counted_at")?, "inventory_balance", "last_counted_at")?,
950                        updated_at: parse_datetime_row(&row.get::<_, String>("updated_at")?, "inventory_balance", "updated_at")?,
951                    })
952                },
953            );
954
955            let balance = match balance_result {
956                Ok(b) => b,
957                Err(rusqlite::Error::QueryReturnedNoRows) => {
958                    tx.execute(
959                        "INSERT INTO inventory_balances (item_id, location_id, quantity_on_hand, quantity_allocated, quantity_available, updated_at)
960                         VALUES (?, ?, '0', '0', '0', ?)",
961                        rusqlite::params![item.id, location_id, now.to_rfc3339()],
962                    )?;
963
964                    // Query the newly created balance
965                    tx.query_row(
966                        "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
967                        rusqlite::params![item.id, location_id],
968                        |row| {
969                            Ok(InventoryBalance {
970                                id: row.get("id")?,
971                                item_id: row.get("item_id")?,
972                                location_id: row.get("location_id")?,
973                                quantity_on_hand: parse_decimal_row(&row.get::<_, String>("quantity_on_hand")?, "inventory_balance", "quantity_on_hand")?,
974                                quantity_allocated: parse_decimal_row(&row.get::<_, String>("quantity_allocated")?, "inventory_balance", "quantity_allocated")?,
975                                quantity_available: parse_decimal_row(&row.get::<_, String>("quantity_available")?, "inventory_balance", "quantity_available")?,
976                                reorder_point: parse_decimal_opt_row(row.get::<_, Option<String>>("reorder_point")?, "inventory_balance", "reorder_point")?,
977                                safety_stock: parse_decimal_opt_row(row.get::<_, Option<String>>("safety_stock")?, "inventory_balance", "safety_stock")?,
978                                version: row.get("version")?,
979                                last_counted_at: parse_datetime_opt_row(row.get::<_, Option<String>>("last_counted_at")?, "inventory_balance", "last_counted_at")?,
980                                updated_at: parse_datetime_row(&row.get::<_, String>("updated_at")?, "inventory_balance", "updated_at")?,
981                            })
982                        },
983                    )?
984                }
985                Err(e) => return Err(e),
986            };
987
988            // Calculate new quantities
989            let new_on_hand = balance.quantity_on_hand + quantity;
990            let new_available = new_on_hand - balance.quantity_allocated;
991
992            if new_on_hand < Decimal::ZERO {
993                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
994                    CommerceError::InsufficientStock {
995                        sku: sku.clone(),
996                        requested: quantity.abs().to_string(),
997                        available: balance.quantity_on_hand.to_string(),
998                    },
999                )));
1000            }
1001            if new_available < Decimal::ZERO {
1002                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1003                    CommerceError::InsufficientStock {
1004                        sku: sku.clone(),
1005                        requested: quantity.abs().to_string(),
1006                        available: balance.quantity_available.to_string(),
1007                    },
1008                )));
1009            }
1010
1011            // Update balance with optimistic locking
1012            let current_version = balance.version;
1013            let rows_affected = tx.execute(
1014                "UPDATE inventory_balances SET quantity_on_hand = ?, quantity_available = ?, version = version + 1, updated_at = ?
1015                 WHERE item_id = ? AND location_id = ? AND version = ?",
1016                rusqlite::params![
1017                    new_on_hand.to_string(),
1018                    new_available.to_string(),
1019                    now.to_rfc3339(),
1020                    item.id,
1021                    location_id,
1022                    current_version
1023                ],
1024            )?;
1025
1026            if rows_affected == 0 {
1027                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1028                    CommerceError::VersionConflict {
1029                        entity: "inventory_balance".to_string(),
1030                        id: format!("{}:{}", item.id, location_id),
1031                        expected_version: current_version,
1032                    },
1033                )));
1034            }
1035
1036            // Record transaction
1037            let tx_type = if quantity >= Decimal::ZERO { "receipt" } else { "adjustment" };
1038            tx.execute(
1039                "INSERT INTO inventory_transactions (item_id, location_id, transaction_type, quantity, reference_type, reference_id, reason, created_at)
1040                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1041                rusqlite::params![
1042                    item.id,
1043                    location_id,
1044                    tx_type,
1045                    quantity.to_string(),
1046                    &reference_type,
1047                    &reference_id,
1048                    &reason,
1049                    now.to_rfc3339(),
1050                ],
1051            )?;
1052
1053            let tx_id = tx.last_insert_rowid();
1054            // Clone values for the return since Fn closure may be called multiple times
1055            Ok(InventoryTransaction {
1056                id: tx_id,
1057                item_id: item.id,
1058                location_id,
1059                transaction_type: if quantity >= Decimal::ZERO {
1060                    TransactionType::Receipt
1061                } else {
1062                    TransactionType::Adjustment
1063                },
1064                quantity,
1065                reference_type: reference_type.clone(),
1066                reference_id: reference_id.clone(),
1067                reason: Some(reason.clone()),
1068                created_by: None,
1069                created_at: now,
1070            })
1071        })
1072        .map_err(|e| {
1073            // Check if it's not found
1074            if e.is_not_found() {
1075                return CommerceError::InventoryItemNotFound(sku.clone());
1076            }
1077            e
1078        })
1079    }
1080
1081    fn reserve(&self, input: ReserveInventory) -> Result<InventoryReservation> {
1082        with_inventory_retry(|| {
1083            with_immediate_transaction(&self.pool, |tx| Self::reserve_in_tx(tx, &input)).map_err(
1084                |e| {
1085                    if e.is_not_found() {
1086                        return CommerceError::InventoryItemNotFound(input.sku.clone());
1087                    }
1088                    e
1089                },
1090            )
1091        })
1092    }
1093
1094    fn get_reservation(&self, reservation_id: Uuid) -> Result<Option<InventoryReservation>> {
1095        let conn = self.conn()?;
1096
1097        let result = conn.query_row(
1098            "SELECT id, item_id, location_id, quantity, status, reference_type, reference_id, expires_at, created_at
1099             FROM inventory_reservations WHERE id = ?",
1100            [reservation_id.to_string()],
1101            |row| {
1102                Ok(InventoryReservation {
1103                    id: parse_uuid_row(&row.get::<_, String>("id")?, "inventory_reservation", "id")?,
1104                    item_id: row.get("item_id")?,
1105                    location_id: row.get("location_id")?,
1106                    quantity: parse_decimal_row(
1107                        &row.get::<_, String>("quantity")?,
1108                        "inventory_reservation",
1109                        "quantity",
1110                    )?,
1111                    status: parse_enum_row(
1112                        &row.get::<_, String>("status")?,
1113                        "inventory_reservation",
1114                        "status",
1115                    )?,
1116                    reference_type: row.get("reference_type")?,
1117                    reference_id: row.get("reference_id")?,
1118                    expires_at: parse_datetime_opt_row(
1119                        row.get::<_, Option<String>>("expires_at")?,
1120                        "inventory_reservation",
1121                        "expires_at",
1122                    )?,
1123                    created_at: parse_datetime_row(
1124                        &row.get::<_, String>("created_at")?,
1125                        "inventory_reservation",
1126                        "created_at",
1127                    )?,
1128                })
1129            },
1130        );
1131
1132        match result {
1133            Ok(reservation) => Ok(Some(reservation)),
1134            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1135            Err(e) => Err(map_db_error(e)),
1136        }
1137    }
1138
1139    fn release_reservation(&self, reservation_id: Uuid) -> Result<()> {
1140        with_inventory_retry(|| {
1141            with_immediate_transaction(&self.pool, |tx| {
1142                Self::release_reservation_in_tx(tx, reservation_id)
1143            })
1144        })
1145    }
1146
1147    fn confirm_reservation(&self, reservation_id: Uuid) -> Result<()> {
1148        let outcome = with_inventory_retry(|| {
1149            with_immediate_transaction(&self.pool, |tx| {
1150                Self::confirm_reservation_in_tx(tx, reservation_id)
1151            })
1152        })?;
1153
1154        match outcome {
1155            ReservationConfirmOutcome::Confirmed => Ok(()),
1156            ReservationConfirmOutcome::Expired => {
1157                Err(CommerceError::ReservationExpired(reservation_id))
1158            }
1159        }
1160    }
1161
1162    fn list_reservations_by_reference(
1163        &self,
1164        reference_type: &str,
1165        reference_id: &str,
1166    ) -> Result<Vec<InventoryReservation>> {
1167        let conn = self.conn()?;
1168        let mut stmt = conn
1169            .prepare(
1170                "SELECT id, item_id, location_id, quantity, status, reference_type, reference_id, expires_at, created_at
1171                 FROM inventory_reservations
1172                 WHERE reference_type = ? AND reference_id = ?
1173                 ORDER BY created_at",
1174            )
1175            .map_err(map_db_error)?;
1176
1177        let reservations = stmt
1178            .query_map(rusqlite::params![reference_type, reference_id], |row| {
1179                Ok(InventoryReservation {
1180                    id: parse_uuid_row(
1181                        &row.get::<_, String>("id")?,
1182                        "inventory_reservation",
1183                        "id",
1184                    )?,
1185                    item_id: row.get("item_id")?,
1186                    location_id: row.get("location_id")?,
1187                    quantity: parse_decimal_row(
1188                        &row.get::<_, String>("quantity")?,
1189                        "inventory_reservation",
1190                        "quantity",
1191                    )?,
1192                    status: parse_enum_row(
1193                        &row.get::<_, String>("status")?,
1194                        "inventory_reservation",
1195                        "status",
1196                    )?,
1197                    reference_type: row.get("reference_type")?,
1198                    reference_id: row.get("reference_id")?,
1199                    expires_at: parse_datetime_opt_row(
1200                        row.get::<_, Option<String>>("expires_at")?,
1201                        "inventory_reservation",
1202                        "expires_at",
1203                    )?,
1204                    created_at: parse_datetime_row(
1205                        &row.get::<_, String>("created_at")?,
1206                        "inventory_reservation",
1207                        "created_at",
1208                    )?,
1209                })
1210            })
1211            .map_err(map_db_error)?
1212            .collect::<rusqlite::Result<Vec<_>>>()
1213            .map_err(map_db_error)?;
1214
1215        Ok(reservations)
1216    }
1217
1218    fn list(&self, filter: InventoryFilter) -> Result<Vec<InventoryItem>> {
1219        let conn = self.conn()?;
1220        let mut sql = "SELECT * FROM inventory_items WHERE 1=1".to_string();
1221        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
1222
1223        if let Some(sku) = &filter.sku {
1224            sql.push_str(" AND sku LIKE ?");
1225            params.push(Box::new(format!("%{sku}%")));
1226        }
1227        if let Some(is_active) = &filter.is_active {
1228            sql.push_str(" AND is_active = ?");
1229            params.push(Box::new(i32::from(*is_active)));
1230        }
1231
1232        sql.push_str(" ORDER BY sku");
1233
1234        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
1235
1236        let params_refs: Vec<&dyn rusqlite::ToSql> =
1237            params.iter().map(std::convert::AsRef::as_ref).collect();
1238        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1239
1240        let items = stmt
1241            .query_map(params_refs.as_slice(), |row| {
1242                Ok(InventoryItem {
1243                    id: row.get("id")?,
1244                    sku: row.get("sku")?,
1245                    name: row.get("name")?,
1246                    description: row.get("description")?,
1247                    unit_of_measure: row.get("unit_of_measure")?,
1248                    is_active: row.get::<_, i32>("is_active")? != 0,
1249                    created_at: parse_datetime_row(
1250                        &row.get::<_, String>("created_at")?,
1251                        "inventory_item",
1252                        "created_at",
1253                    )?,
1254                    updated_at: parse_datetime_row(
1255                        &row.get::<_, String>("updated_at")?,
1256                        "inventory_item",
1257                        "updated_at",
1258                    )?,
1259                })
1260            })
1261            .map_err(map_db_error)?
1262            .collect::<rusqlite::Result<Vec<_>>>()
1263            .map_err(map_db_error)?;
1264
1265        Ok(items)
1266    }
1267
1268    fn get_reorder_needed(&self) -> Result<Vec<StockLevel>> {
1269        // `quantity_available` and `reorder_point` are TEXT decimals; comparing
1270        // them in SQL with CAST(... AS REAL) coerces both operands to IEEE-754
1271        // floats and can misclassify balances right at the reorder boundary,
1272        // so the comparison happens on exact parsed `Decimal`s in Rust.
1273        let candidates = {
1274            let conn = self.conn()?;
1275            let mut stmt = conn
1276                .prepare(
1277                    "SELECT i.sku, b.quantity_available, b.reorder_point
1278                     FROM inventory_items i
1279                     JOIN inventory_balances b ON i.id = b.item_id
1280                     WHERE b.reorder_point IS NOT NULL
1281                     AND i.is_active = 1",
1282                )
1283                .map_err(map_db_error)?;
1284
1285            stmt.query_map([], |row| {
1286                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
1287            })
1288            .map_err(map_db_error)?
1289            .collect::<rusqlite::Result<Vec<_>>>()
1290            .map_err(map_db_error)?
1291        };
1292
1293        let mut skus: Vec<String> = Vec::new();
1294        for (sku, available, reorder_point) in candidates {
1295            let available =
1296                parse_decimal_strict(&available, "inventory_balance", "quantity_available")?;
1297            let reorder_point =
1298                parse_decimal_strict(&reorder_point, "inventory_balance", "reorder_point")?;
1299            if available < reorder_point && !skus.contains(&sku) {
1300                skus.push(sku);
1301            }
1302        }
1303
1304        let mut result = Vec::with_capacity(skus.len());
1305        for sku in skus {
1306            if let Some(stock) = self.get_stock(&sku)? {
1307                result.push(stock);
1308            }
1309        }
1310
1311        Ok(result)
1312    }
1313
1314    fn record_transaction(
1315        &self,
1316        transaction: InventoryTransaction,
1317    ) -> Result<InventoryTransaction> {
1318        let conn = self.conn()?;
1319
1320        conn.execute(
1321            "INSERT INTO inventory_transactions (item_id, location_id, transaction_type, quantity, reference_type, reference_id, reason, created_by, created_at)
1322             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1323            rusqlite::params![
1324                transaction.item_id,
1325                transaction.location_id,
1326                transaction.transaction_type.to_string(),
1327                transaction.quantity.to_string(),
1328                transaction.reference_type,
1329                transaction.reference_id,
1330                transaction.reason,
1331                transaction.created_by,
1332                transaction.created_at.to_rfc3339(),
1333            ],
1334        )
1335        .map_err(map_db_error)?;
1336
1337        let id = conn.last_insert_rowid();
1338
1339        Ok(InventoryTransaction { id, ..transaction })
1340    }
1341
1342    fn get_transactions(&self, item_id: i64, limit: u32) -> Result<Vec<InventoryTransaction>> {
1343        let conn = self.conn()?;
1344
1345        let mut stmt = conn
1346            .prepare(&format!(
1347                "SELECT * FROM inventory_transactions WHERE item_id = ? ORDER BY created_at DESC LIMIT {limit}"
1348            ))
1349            .map_err(map_db_error)?;
1350
1351        let transactions = stmt
1352            .query_map([item_id], |row| {
1353                Ok(InventoryTransaction {
1354                    id: row.get("id")?,
1355                    item_id: row.get("item_id")?,
1356                    location_id: row.get("location_id")?,
1357                    transaction_type: parse_enum_row(
1358                        &row.get::<_, String>("transaction_type")?,
1359                        "inventory_transaction",
1360                        "transaction_type",
1361                    )?,
1362                    quantity: parse_decimal_row(
1363                        &row.get::<_, String>("quantity")?,
1364                        "inventory_transaction",
1365                        "quantity",
1366                    )?,
1367                    reference_type: row.get("reference_type")?,
1368                    reference_id: row.get("reference_id")?,
1369                    reason: row.get("reason")?,
1370                    created_by: row.get("created_by")?,
1371                    created_at: parse_datetime_row(
1372                        &row.get::<_, String>("created_at")?,
1373                        "inventory_transaction",
1374                        "created_at",
1375                    )?,
1376                })
1377            })
1378            .map_err(map_db_error)?
1379            .collect::<rusqlite::Result<Vec<_>>>()
1380            .map_err(map_db_error)?;
1381
1382        Ok(transactions)
1383    }
1384
1385    // === Batch Operations ===
1386
1387    fn create_item_batch(
1388        &self,
1389        inputs: Vec<CreateInventoryItem>,
1390    ) -> Result<BatchResult<InventoryItem>> {
1391        validate_batch_size(&inputs)?;
1392        let mut result = BatchResult::with_capacity(inputs.len());
1393
1394        for (index, input) in inputs.into_iter().enumerate() {
1395            match self.create_item(input) {
1396                Ok(item) => result.record_success(item),
1397                Err(e) => result.record_failure(index, None, &e),
1398            }
1399        }
1400
1401        Ok(result)
1402    }
1403
1404    fn create_item_batch_atomic(
1405        &self,
1406        inputs: Vec<CreateInventoryItem>,
1407    ) -> Result<Vec<InventoryItem>> {
1408        validate_batch_size(&inputs)?;
1409        if inputs.is_empty() {
1410            return Ok(vec![]);
1411        }
1412
1413        let mut conn = self.conn()?;
1414        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1415        let mut results = Vec::with_capacity(inputs.len());
1416
1417        for input in inputs {
1418            let now = Utc::now();
1419            let sku = input.sku.clone();
1420            let name = input.name.clone();
1421            let description = input.description.clone();
1422            let unit_of_measure = input.unit_of_measure.clone().unwrap_or_else(|| "EA".to_string());
1423
1424            // Check SKU uniqueness
1425            let exists: i32 = tx
1426                .query_row("SELECT COUNT(*) FROM inventory_items WHERE sku = ?", [&sku], |row| {
1427                    row.get(0)
1428                })
1429                .map_err(map_db_error)?;
1430
1431            if exists > 0 {
1432                return Err(CommerceError::DuplicateSku(sku));
1433            }
1434
1435            tx.execute(
1436                "INSERT INTO inventory_items (sku, name, description, unit_of_measure, is_active, created_at, updated_at)
1437                 VALUES (?, ?, ?, ?, 1, ?, ?)",
1438                rusqlite::params![
1439                    &sku,
1440                    &name,
1441                    &description,
1442                    &unit_of_measure,
1443                    now.to_rfc3339(),
1444                    now.to_rfc3339(),
1445                ],
1446            )
1447            .map_err(map_db_error)?;
1448
1449            let item_id = tx.last_insert_rowid();
1450
1451            // Create initial balance if quantity provided
1452            let location_id = input.location_id.unwrap_or(1);
1453            let initial_qty = input.initial_quantity.unwrap_or_default();
1454
1455            tx.execute(
1456                "INSERT INTO inventory_balances (item_id, location_id, quantity_on_hand, quantity_allocated, quantity_available, reorder_point, safety_stock, updated_at)
1457                 VALUES (?, ?, ?, '0', ?, ?, ?, ?)",
1458                rusqlite::params![
1459                    item_id,
1460                    location_id,
1461                    initial_qty.to_string(),
1462                    initial_qty.to_string(),
1463                    input.reorder_point.map(|d| d.to_string()),
1464                    input.safety_stock.map(|d| d.to_string()),
1465                    now.to_rfc3339(),
1466                ],
1467            )
1468            .map_err(map_db_error)?;
1469
1470            // Record initial transaction if quantity > 0
1471            if initial_qty > Decimal::ZERO {
1472                tx.execute(
1473                    "INSERT INTO inventory_transactions (item_id, location_id, transaction_type, quantity, reason, created_at)
1474                     VALUES (?, ?, 'receipt', ?, 'Initial stock', ?)",
1475                    rusqlite::params![item_id, location_id, initial_qty.to_string(), now.to_rfc3339()],
1476                )
1477                .map_err(map_db_error)?;
1478            }
1479
1480            results.push(InventoryItem {
1481                id: item_id,
1482                sku,
1483                name,
1484                description,
1485                unit_of_measure,
1486                is_active: true,
1487                created_at: now,
1488                updated_at: now,
1489            });
1490        }
1491
1492        tx.commit().map_err(map_db_error)?;
1493        Ok(results)
1494    }
1495
1496    fn adjust_batch(
1497        &self,
1498        adjustments: Vec<AdjustInventory>,
1499    ) -> Result<BatchResult<InventoryTransaction>> {
1500        validate_batch_size(&adjustments)?;
1501        let mut result = BatchResult::with_capacity(adjustments.len());
1502
1503        for (index, input) in adjustments.into_iter().enumerate() {
1504            let sku = input.sku.clone();
1505            match self.adjust(input) {
1506                Ok(transaction) => result.record_success(transaction),
1507                Err(e) => result.record_failure(index, Some(sku), &e),
1508            }
1509        }
1510
1511        Ok(result)
1512    }
1513
1514    fn adjust_batch_atomic(
1515        &self,
1516        adjustments: Vec<AdjustInventory>,
1517    ) -> Result<Vec<InventoryTransaction>> {
1518        validate_batch_size(&adjustments)?;
1519        if adjustments.is_empty() {
1520            return Ok(vec![]);
1521        }
1522
1523        let mut conn = self.conn()?;
1524        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1525        let mut results = Vec::with_capacity(adjustments.len());
1526        let now = Utc::now();
1527
1528        for input in adjustments {
1529            validate_sku(&input.sku)?;
1530            if input.quantity.is_zero() {
1531                return Err(CommerceError::ValidationError(
1532                    "Adjustment quantity cannot be zero".into(),
1533                ));
1534            }
1535            // Get item directly with this connection
1536            let item = tx
1537                .query_row("SELECT * FROM inventory_items WHERE sku = ?", [&input.sku], |row| {
1538                    Ok(InventoryItem {
1539                        id: row.get("id")?,
1540                        sku: row.get("sku")?,
1541                        name: row.get("name")?,
1542                        description: row.get("description")?,
1543                        unit_of_measure: row.get("unit_of_measure")?,
1544                        is_active: row.get::<_, i32>("is_active")? != 0,
1545                        created_at: parse_datetime_row(
1546                            &row.get::<_, String>("created_at")?,
1547                            "inventory_item",
1548                            "created_at",
1549                        )?,
1550                        updated_at: parse_datetime_row(
1551                            &row.get::<_, String>("updated_at")?,
1552                            "inventory_item",
1553                            "updated_at",
1554                        )?,
1555                    })
1556                })
1557                .map_err(|e| match e {
1558                    rusqlite::Error::QueryReturnedNoRows => {
1559                        CommerceError::InventoryItemNotFound(input.sku.clone())
1560                    }
1561                    e => map_db_error(e),
1562                })?;
1563
1564            let location_id = input.location_id.unwrap_or(1);
1565
1566            // Get or create balance directly with this connection
1567            let balance_result = tx.query_row(
1568                "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
1569                rusqlite::params![item.id, location_id],
1570                |row| {
1571                    Ok(InventoryBalance {
1572                        id: row.get("id")?,
1573                        item_id: row.get("item_id")?,
1574                        location_id: row.get("location_id")?,
1575                        quantity_on_hand: parse_decimal_row(
1576                            &row.get::<_, String>("quantity_on_hand")?,
1577                            "inventory_balance",
1578                            "quantity_on_hand",
1579                        )?,
1580                        quantity_allocated: parse_decimal_row(
1581                            &row.get::<_, String>("quantity_allocated")?,
1582                            "inventory_balance",
1583                            "quantity_allocated",
1584                        )?,
1585                        quantity_available: parse_decimal_row(
1586                            &row.get::<_, String>("quantity_available")?,
1587                            "inventory_balance",
1588                            "quantity_available",
1589                        )?,
1590                        reorder_point: parse_decimal_opt_row(
1591                            row.get::<_, Option<String>>("reorder_point")?,
1592                            "inventory_balance",
1593                            "reorder_point",
1594                        )?,
1595                        safety_stock: parse_decimal_opt_row(
1596                            row.get::<_, Option<String>>("safety_stock")?,
1597                            "inventory_balance",
1598                            "safety_stock",
1599                        )?,
1600                        version: row.get("version")?,
1601                        last_counted_at: parse_datetime_opt_row(
1602                            row.get::<_, Option<String>>("last_counted_at")?,
1603                            "inventory_balance",
1604                            "last_counted_at",
1605                        )?,
1606                        updated_at: parse_datetime_row(
1607                            &row.get::<_, String>("updated_at")?,
1608                            "inventory_balance",
1609                            "updated_at",
1610                        )?,
1611                    })
1612                },
1613            );
1614
1615            let balance = match balance_result {
1616                Ok(b) => b,
1617                Err(rusqlite::Error::QueryReturnedNoRows) => {
1618                    tx.execute(
1619                        "INSERT INTO inventory_balances (item_id, location_id, quantity_on_hand, quantity_allocated, quantity_available, updated_at)
1620                         VALUES (?, ?, '0', '0', '0', ?)",
1621                        rusqlite::params![item.id, location_id, now.to_rfc3339()],
1622                    )
1623                    .map_err(map_db_error)?;
1624
1625                    // Query the newly created balance
1626                    tx.query_row(
1627                        "SELECT * FROM inventory_balances WHERE item_id = ? AND location_id = ?",
1628                        rusqlite::params![item.id, location_id],
1629                        |row| {
1630                            Ok(InventoryBalance {
1631                                id: row.get("id")?,
1632                                item_id: row.get("item_id")?,
1633                                location_id: row.get("location_id")?,
1634                                quantity_on_hand: parse_decimal_row(
1635                                    &row.get::<_, String>("quantity_on_hand")?,
1636                                    "inventory_balance",
1637                                    "quantity_on_hand",
1638                                )?,
1639                                quantity_allocated: parse_decimal_row(
1640                                    &row.get::<_, String>("quantity_allocated")?,
1641                                    "inventory_balance",
1642                                    "quantity_allocated",
1643                                )?,
1644                                quantity_available: parse_decimal_row(
1645                                    &row.get::<_, String>("quantity_available")?,
1646                                    "inventory_balance",
1647                                    "quantity_available",
1648                                )?,
1649                                reorder_point: parse_decimal_opt_row(
1650                                    row.get::<_, Option<String>>("reorder_point")?,
1651                                    "inventory_balance",
1652                                    "reorder_point",
1653                                )?,
1654                                safety_stock: parse_decimal_opt_row(
1655                                    row.get::<_, Option<String>>("safety_stock")?,
1656                                    "inventory_balance",
1657                                    "safety_stock",
1658                                )?,
1659                                version: row.get("version")?,
1660                                last_counted_at: parse_datetime_opt_row(
1661                                    row.get::<_, Option<String>>("last_counted_at")?,
1662                                    "inventory_balance",
1663                                    "last_counted_at",
1664                                )?,
1665                                updated_at: parse_datetime_row(
1666                                    &row.get::<_, String>("updated_at")?,
1667                                    "inventory_balance",
1668                                    "updated_at",
1669                                )?,
1670                            })
1671                        },
1672                    )
1673                    .map_err(map_db_error)?
1674                }
1675                Err(e) => return Err(map_db_error(e)),
1676            };
1677
1678            // Calculate new quantities
1679            let new_on_hand = balance.quantity_on_hand + input.quantity;
1680            let new_available = new_on_hand - balance.quantity_allocated;
1681
1682            if new_on_hand < Decimal::ZERO {
1683                return Err(CommerceError::InsufficientStock {
1684                    sku: input.sku.clone(),
1685                    requested: input.quantity.abs().to_string(),
1686                    available: balance.quantity_on_hand.to_string(),
1687                });
1688            }
1689            if new_available < Decimal::ZERO {
1690                return Err(CommerceError::InsufficientStock {
1691                    sku: input.sku.clone(),
1692                    requested: input.quantity.abs().to_string(),
1693                    available: balance.quantity_available.to_string(),
1694                });
1695            }
1696
1697            // Update balance with optimistic locking
1698            let current_version = balance.version;
1699            let rows_affected = tx.execute(
1700                "UPDATE inventory_balances SET quantity_on_hand = ?, quantity_available = ?, version = version + 1, updated_at = ?
1701                 WHERE item_id = ? AND location_id = ? AND version = ?",
1702                rusqlite::params![
1703                    new_on_hand.to_string(),
1704                    new_available.to_string(),
1705                    now.to_rfc3339(),
1706                    item.id,
1707                    location_id,
1708                    current_version
1709                ],
1710            )
1711            .map_err(map_db_error)?;
1712
1713            if rows_affected == 0 {
1714                return Err(CommerceError::VersionConflict {
1715                    entity: "inventory_balance".to_string(),
1716                    id: format!("{}:{}", item.id, location_id),
1717                    expected_version: current_version,
1718                });
1719            }
1720
1721            // Record transaction
1722            let tx_type = if input.quantity >= Decimal::ZERO { "receipt" } else { "adjustment" };
1723            tx.execute(
1724                "INSERT INTO inventory_transactions (item_id, location_id, transaction_type, quantity, reference_type, reference_id, reason, created_at)
1725                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1726                rusqlite::params![
1727                    item.id,
1728                    location_id,
1729                    tx_type,
1730                    input.quantity.to_string(),
1731                    input.reference_type,
1732                    input.reference_id,
1733                    input.reason,
1734                    now.to_rfc3339(),
1735                ],
1736            )
1737            .map_err(map_db_error)?;
1738
1739            let tx_id = tx.last_insert_rowid();
1740            results.push(InventoryTransaction {
1741                id: tx_id,
1742                item_id: item.id,
1743                location_id,
1744                transaction_type: if input.quantity >= Decimal::ZERO {
1745                    TransactionType::Receipt
1746                } else {
1747                    TransactionType::Adjustment
1748                },
1749                quantity: input.quantity,
1750                reference_type: input.reference_type,
1751                reference_id: input.reference_id,
1752                reason: Some(input.reason),
1753                created_by: None,
1754                created_at: now,
1755            });
1756        }
1757
1758        tx.commit().map_err(map_db_error)?;
1759        Ok(results)
1760    }
1761
1762    fn get_item_batch(&self, ids: Vec<i64>) -> Result<Vec<InventoryItem>> {
1763        validate_batch_size(&ids)?;
1764        if ids.is_empty() {
1765            return Ok(vec![]);
1766        }
1767
1768        let conn = self.conn()?;
1769        let placeholders = build_in_clause(ids.len());
1770        let sql = format!("SELECT * FROM inventory_items WHERE id IN ({placeholders})");
1771
1772        let params = i64_params(&ids);
1773        let params_refs = params_refs(&params);
1774
1775        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1776        let items = stmt
1777            .query_map(params_refs.as_slice(), |row| {
1778                Ok(InventoryItem {
1779                    id: row.get("id")?,
1780                    sku: row.get("sku")?,
1781                    name: row.get("name")?,
1782                    description: row.get("description")?,
1783                    unit_of_measure: row.get("unit_of_measure")?,
1784                    is_active: row.get::<_, i32>("is_active")? != 0,
1785                    created_at: parse_datetime_row(
1786                        &row.get::<_, String>("created_at")?,
1787                        "inventory_item",
1788                        "created_at",
1789                    )?,
1790                    updated_at: parse_datetime_row(
1791                        &row.get::<_, String>("updated_at")?,
1792                        "inventory_item",
1793                        "updated_at",
1794                    )?,
1795                })
1796            })
1797            .map_err(map_db_error)?
1798            .collect::<rusqlite::Result<Vec<_>>>()
1799            .map_err(map_db_error)?;
1800
1801        Ok(items)
1802    }
1803
1804    fn get_stock_batch(&self, skus: Vec<String>) -> Result<Vec<StockLevel>> {
1805        validate_batch_size(&skus)?;
1806        if skus.is_empty() {
1807            return Ok(vec![]);
1808        }
1809
1810        let conn = self.conn()?;
1811        let placeholders = build_in_clause(skus.len());
1812        let sql = format!("SELECT * FROM inventory_items WHERE sku IN ({placeholders})");
1813
1814        let params = string_params(&skus);
1815        let params_refs = params_refs(&params);
1816
1817        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1818        let items: Vec<InventoryItem> = stmt
1819            .query_map(params_refs.as_slice(), |row| {
1820                Ok(InventoryItem {
1821                    id: row.get("id")?,
1822                    sku: row.get("sku")?,
1823                    name: row.get("name")?,
1824                    description: row.get("description")?,
1825                    unit_of_measure: row.get("unit_of_measure")?,
1826                    is_active: row.get::<_, i32>("is_active")? != 0,
1827                    created_at: parse_datetime_row(
1828                        &row.get::<_, String>("created_at")?,
1829                        "inventory_item",
1830                        "created_at",
1831                    )?,
1832                    updated_at: parse_datetime_row(
1833                        &row.get::<_, String>("updated_at")?,
1834                        "inventory_item",
1835                        "updated_at",
1836                    )?,
1837                })
1838            })
1839            .map_err(map_db_error)?
1840            .collect::<rusqlite::Result<Vec<_>>>()
1841            .map_err(map_db_error)?;
1842
1843        // Build stock levels for each item
1844        let mut results = Vec::with_capacity(items.len());
1845        for item in items {
1846            let mut balance_stmt = conn
1847                .prepare(
1848                    "SELECT b.*, l.name as location_name
1849                     FROM inventory_balances b
1850                     LEFT JOIN inventory_locations l ON b.location_id = l.id
1851                     WHERE b.item_id = ?",
1852                )
1853                .map_err(map_db_error)?;
1854
1855            let locations: Vec<LocationStock> = balance_stmt
1856                .query_map([item.id], |row| {
1857                    Ok(LocationStock {
1858                        location_id: row.get("location_id")?,
1859                        location_name: row.get("location_name")?,
1860                        on_hand: parse_decimal_row(
1861                            &row.get::<_, String>("quantity_on_hand")?,
1862                            "inventory_balance",
1863                            "quantity_on_hand",
1864                        )?,
1865                        allocated: parse_decimal_row(
1866                            &row.get::<_, String>("quantity_allocated")?,
1867                            "inventory_balance",
1868                            "quantity_allocated",
1869                        )?,
1870                        available: parse_decimal_row(
1871                            &row.get::<_, String>("quantity_available")?,
1872                            "inventory_balance",
1873                            "quantity_available",
1874                        )?,
1875                    })
1876                })
1877                .map_err(map_db_error)?
1878                .collect::<rusqlite::Result<Vec<_>>>()
1879                .map_err(map_db_error)?;
1880
1881            let total_on_hand: Decimal = locations.iter().map(|l| l.on_hand).sum();
1882            let total_allocated: Decimal = locations.iter().map(|l| l.allocated).sum();
1883            let total_available: Decimal = locations.iter().map(|l| l.available).sum();
1884
1885            results.push(StockLevel {
1886                sku: item.sku,
1887                name: item.name,
1888                total_on_hand,
1889                total_allocated,
1890                total_available,
1891                locations,
1892            });
1893        }
1894
1895        Ok(results)
1896    }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use crate::SqliteDatabase;
1903    use rust_decimal_macros::dec;
1904    use stateset_core::{CommerceError, InventoryRepository};
1905
1906    fn fresh_repo() -> SqliteInventoryRepository {
1907        let db = SqliteDatabase::in_memory().expect("in-memory sqlite");
1908        db.inventory()
1909    }
1910
1911    fn item(sku: &str) -> CreateInventoryItem {
1912        CreateInventoryItem {
1913            sku: sku.into(),
1914            name: format!("Item {sku}"),
1915            description: Some("test".into()),
1916            unit_of_measure: Some("EA".into()),
1917            initial_quantity: Some(dec!(10)),
1918            location_id: None,
1919            reorder_point: Some(dec!(5)),
1920            safety_stock: Some(dec!(2)),
1921        }
1922    }
1923
1924    #[test]
1925    fn reorder_needed_compares_available_to_reorder_point_exactly() {
1926        let repo = fresh_repo();
1927        let below = repo.create_item(item("REORD-BELOW")).expect("create below");
1928        let above = repo.create_item(item("REORD-ABOVE")).expect("create above");
1929        {
1930            let conn = repo.conn().expect("conn");
1931            // Below the reorder point by 1e-18: both values round to the same
1932            // f64, so a CAST-AS-REAL comparison would wrongly skip the reorder.
1933            conn.execute(
1934                "UPDATE inventory_balances
1935                 SET quantity_available = '9.999999999999999999', reorder_point = '10'
1936                 WHERE item_id = ?1",
1937                rusqlite::params![below.id],
1938            )
1939            .expect("update below");
1940            conn.execute(
1941                "UPDATE inventory_balances
1942                 SET quantity_available = '10.000000000000000001', reorder_point = '10'
1943                 WHERE item_id = ?1",
1944                rusqlite::params![above.id],
1945            )
1946            .expect("update above");
1947        }
1948
1949        let needed = repo.get_reorder_needed().expect("ok");
1950        let skus: Vec<&str> = needed.iter().map(|s| s.sku.as_str()).collect();
1951        assert!(
1952            skus.contains(&"REORD-BELOW"),
1953            "9.999999999999999999 < 10 exactly, even though the f64s are equal"
1954        );
1955        assert!(
1956            !skus.contains(&"REORD-ABOVE"),
1957            "10.000000000000000001 is not below the reorder point"
1958        );
1959    }
1960
1961    #[test]
1962    fn create_item_persists_basic_fields() {
1963        let repo = fresh_repo();
1964        let created = repo.create_item(item("WIDGET-001")).expect("create");
1965        assert_eq!(created.sku, "WIDGET-001");
1966        assert_eq!(created.name, "Item WIDGET-001");
1967        assert_eq!(created.unit_of_measure, "EA");
1968        assert!(created.is_active);
1969    }
1970
1971    #[test]
1972    fn create_item_with_zero_initial_quantity_records_no_transaction() {
1973        let repo = fresh_repo();
1974        let mut input = item("ZERO-INIT");
1975        input.initial_quantity = Some(Decimal::ZERO);
1976        let created = repo.create_item(input).expect("create");
1977        let txns = repo.get_transactions(created.id, 10).expect("transactions");
1978        assert!(txns.is_empty(), "no transaction expected for zero initial qty");
1979    }
1980
1981    #[test]
1982    fn create_item_records_initial_receipt_transaction() {
1983        let repo = fresh_repo();
1984        let created = repo.create_item(item("INIT-001")).expect("create");
1985        let txns = repo.get_transactions(created.id, 10).expect("transactions");
1986        assert_eq!(txns.len(), 1, "initial receipt expected");
1987    }
1988
1989    #[test]
1990    fn create_item_rejects_duplicate_sku() {
1991        let repo = fresh_repo();
1992        repo.create_item(item("DUPE-001")).expect("first create");
1993        let err = repo.create_item(item("DUPE-001")).expect_err("dup err");
1994        assert!(matches!(err, CommerceError::DuplicateSku(s) if s == "DUPE-001"));
1995    }
1996
1997    #[test]
1998    fn create_item_rejects_invalid_sku() {
1999        let repo = fresh_repo();
2000        let mut input = item("");
2001        input.sku = "bad sku!!".into();
2002        let err = repo.create_item(input).expect_err("invalid");
2003        assert!(matches!(err, CommerceError::ValidationError(_)));
2004    }
2005
2006    #[test]
2007    fn get_item_by_sku_round_trips() {
2008        let repo = fresh_repo();
2009        let created = repo.create_item(item("ROUND-001")).expect("create");
2010        let by_sku = repo.get_item_by_sku("ROUND-001").expect("get by sku").expect("found");
2011        assert_eq!(by_sku.id, created.id);
2012        let by_id = repo.get_item(created.id).expect("get").expect("found");
2013        assert_eq!(by_id.sku, "ROUND-001");
2014        assert!(repo.get_item_by_sku("MISSING").expect("ok").is_none());
2015    }
2016
2017    #[test]
2018    fn get_stock_aggregates_by_location() {
2019        let repo = fresh_repo();
2020        repo.create_item(item("STOCK-001")).expect("create");
2021        let stock = repo.get_stock("STOCK-001").expect("get stock").expect("found");
2022        assert_eq!(stock.sku, "STOCK-001");
2023        assert_eq!(stock.total_on_hand, dec!(10));
2024        assert_eq!(stock.total_available, dec!(10));
2025        assert_eq!(stock.total_allocated, dec!(0));
2026        assert_eq!(stock.locations.len(), 1);
2027    }
2028
2029    #[test]
2030    fn adjust_increases_and_decreases_on_hand() {
2031        let repo = fresh_repo();
2032        repo.create_item(item("ADJ-001")).expect("create");
2033        repo.adjust(AdjustInventory {
2034            sku: "ADJ-001".into(),
2035            location_id: Some(1),
2036            quantity: dec!(5),
2037            reason: "receipt".into(),
2038            reference_type: None,
2039            reference_id: None,
2040        })
2041        .expect("receipt");
2042        let stock = repo.get_stock("ADJ-001").expect("ok").expect("found");
2043        assert_eq!(stock.total_on_hand, dec!(15));
2044
2045        repo.adjust(AdjustInventory {
2046            sku: "ADJ-001".into(),
2047            location_id: Some(1),
2048            quantity: dec!(-3),
2049            reason: "shrink".into(),
2050            reference_type: None,
2051            reference_id: None,
2052        })
2053        .expect("decrement");
2054        let stock = repo.get_stock("ADJ-001").expect("ok").expect("found");
2055        assert_eq!(stock.total_on_hand, dec!(12));
2056    }
2057
2058    #[test]
2059    fn reserve_then_release_round_trip() {
2060        let repo = fresh_repo();
2061        repo.create_item(item("RESERVE-001")).expect("create");
2062        let res = repo
2063            .reserve(ReserveInventory {
2064                sku: "RESERVE-001".into(),
2065                location_id: Some(1),
2066                quantity: dec!(4),
2067                reference_type: "order".into(),
2068                reference_id: "ord-1".into(),
2069                expires_in_seconds: Some(60),
2070            })
2071            .expect("reserve");
2072
2073        let after_reserve = repo.get_stock("RESERVE-001").expect("ok").expect("found");
2074        assert_eq!(after_reserve.total_allocated, dec!(4));
2075        assert_eq!(after_reserve.total_available, dec!(6));
2076
2077        let fetched = repo.get_reservation(res.id).expect("get res").expect("found");
2078        assert_eq!(fetched.id, res.id);
2079
2080        repo.release_reservation(res.id).expect("release");
2081        let after_release = repo.get_stock("RESERVE-001").expect("ok").expect("found");
2082        assert_eq!(after_release.total_allocated, dec!(0));
2083        assert_eq!(after_release.total_available, dec!(10));
2084    }
2085
2086    #[test]
2087    fn list_reservations_by_reference_filters_correctly() {
2088        let repo = fresh_repo();
2089        repo.create_item(item("MULTI-RES-001")).expect("create");
2090        repo.reserve(ReserveInventory {
2091            sku: "MULTI-RES-001".into(),
2092            location_id: Some(1),
2093            quantity: dec!(1),
2094            reference_type: "order".into(),
2095            reference_id: "ord-A".into(),
2096            expires_in_seconds: None,
2097        })
2098        .expect("res 1");
2099        repo.reserve(ReserveInventory {
2100            sku: "MULTI-RES-001".into(),
2101            location_id: Some(1),
2102            quantity: dec!(2),
2103            reference_type: "order".into(),
2104            reference_id: "ord-A".into(),
2105            expires_in_seconds: None,
2106        })
2107        .expect("res 2");
2108        repo.reserve(ReserveInventory {
2109            sku: "MULTI-RES-001".into(),
2110            location_id: Some(1),
2111            quantity: dec!(1),
2112            reference_type: "order".into(),
2113            reference_id: "ord-B".into(),
2114            expires_in_seconds: None,
2115        })
2116        .expect("res 3");
2117
2118        let by_a = repo.list_reservations_by_reference("order", "ord-A").expect("list");
2119        assert_eq!(by_a.len(), 2);
2120        let by_b = repo.list_reservations_by_reference("order", "ord-B").expect("list");
2121        assert_eq!(by_b.len(), 1);
2122    }
2123
2124    #[test]
2125    fn list_filters_by_sku() {
2126        let repo = fresh_repo();
2127        repo.create_item(item("LIST-001")).expect("c1");
2128        repo.create_item(item("LIST-002")).expect("c2");
2129        repo.create_item(item("OTHER-001")).expect("c3");
2130
2131        let listed = repo
2132            .list(InventoryFilter { sku: Some("LIST".into()), ..Default::default() })
2133            .expect("list");
2134        assert_eq!(listed.len(), 2);
2135        assert!(listed.iter().all(|i| i.sku.starts_with("LIST")));
2136    }
2137
2138    #[test]
2139    fn get_reorder_needed_returns_below_threshold() {
2140        let repo = fresh_repo();
2141
2142        let mut healthy = item("HEALTHY-001");
2143        healthy.initial_quantity = Some(dec!(100));
2144        healthy.reorder_point = Some(dec!(10));
2145        repo.create_item(healthy).expect("healthy");
2146
2147        let mut low = item("LOW-001");
2148        low.initial_quantity = Some(dec!(2));
2149        low.reorder_point = Some(dec!(10));
2150        repo.create_item(low).expect("low");
2151
2152        let needs = repo.get_reorder_needed().expect("reorder");
2153        let skus: Vec<&str> = needs.iter().map(|s| s.sku.as_str()).collect();
2154        assert!(skus.contains(&"LOW-001"));
2155        assert!(!skus.contains(&"HEALTHY-001"));
2156    }
2157
2158    #[test]
2159    fn get_transactions_returns_in_recent_first_order() {
2160        let repo = fresh_repo();
2161        let created = repo.create_item(item("TX-001")).expect("create");
2162        repo.adjust(AdjustInventory {
2163            sku: "TX-001".into(),
2164            location_id: Some(1),
2165            quantity: dec!(3),
2166            reason: "receipt".into(),
2167            reference_type: None,
2168            reference_id: None,
2169        })
2170        .expect("adjust");
2171
2172        let txns = repo.get_transactions(created.id, 10).expect("txns");
2173        assert_eq!(txns.len(), 2);
2174    }
2175
2176    #[test]
2177    fn create_item_batch_creates_all() {
2178        let repo = fresh_repo();
2179        let result = repo
2180            .create_item_batch(vec![item("BATCH-001"), item("BATCH-002"), item("BATCH-003")])
2181            .expect("batch");
2182        assert_eq!(result.success_count, 3);
2183        assert_eq!(result.failure_count, 0);
2184        assert_eq!(result.succeeded.len(), 3);
2185    }
2186}