1use 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#[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 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(&input.sku)?;
609
610 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 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 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 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 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 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(&input.sku)?;
897
898 if input.quantity.is_zero() {
900 return Err(CommerceError::ValidationError(
901 "Adjustment quantity cannot be zero".into(),
902 ));
903 }
904
905 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¶ms);
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(¶ms);
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 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 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}