1use crate::sqlite::{
6 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
7 parse_decimal_row, parse_decimal_strict, parse_enum_row, parse_json_row, parse_uuid_opt_row,
8 parse_uuid_row, with_immediate_transaction,
9};
10use chrono::Utc;
11use r2d2::Pool;
12use r2d2_sqlite::SqliteConnectionManager;
13use rusqlite::params;
14use rust_decimal::Decimal;
15use uuid::Uuid;
16
17use stateset_core::{
18 AdjustLocationInventory, BatchResult, CommerceError, CreateCycleCount, CreateLocation,
19 CreateWarehouse, CreateZone, CycleCount, CycleCountFilter, CycleCountLine, CycleCountStatus,
20 Location, LocationFilter, LocationInventory, LocationInventoryFilter, LocationMovement,
21 MoveInventory, MovementFilter, MovementType, RecordCycleCountLine, Result, UpdateLocation,
22 UpdateWarehouse, UpdateZone, Warehouse, WarehouseFilter, WarehouseRepository, Zone,
23};
24
25#[derive(Debug)]
27pub struct SqliteWarehouseRepository {
28 pool: Pool<SqliteConnectionManager>,
29}
30
31impl SqliteWarehouseRepository {
32 #[must_use]
33 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
34 Self { pool }
35 }
36
37 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
38 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
39 }
40
41 fn row_to_warehouse(row: &rusqlite::Row<'_>) -> rusqlite::Result<Warehouse> {
43 Ok(Warehouse {
44 id: row.get("id")?,
45 code: row.get("code")?,
46 name: row.get("name")?,
47 warehouse_type: parse_enum_row(
48 &row.get::<_, String>("warehouse_type")?,
49 "warehouse",
50 "warehouse_type",
51 )?,
52 address: parse_json_row(
53 &row.get::<_, String>("address_json")?,
54 "warehouse",
55 "address_json",
56 )?,
57 timezone: row.get("timezone")?,
58 is_active: row.get::<_, i32>("is_active")? == 1,
59 created_at: parse_datetime_row(
60 &row.get::<_, String>("created_at")?,
61 "warehouse",
62 "created_at",
63 )?,
64 updated_at: parse_datetime_row(
65 &row.get::<_, String>("updated_at")?,
66 "warehouse",
67 "updated_at",
68 )?,
69 })
70 }
71
72 fn row_to_location(row: &rusqlite::Row<'_>) -> rusqlite::Result<Location> {
74 Ok(Location {
75 id: row.get("id")?,
76 warehouse_id: row.get("warehouse_id")?,
77 code: row.get("code")?,
78 location_type: parse_enum_row(
79 &row.get::<_, String>("location_type")?,
80 "location",
81 "location_type",
82 )?,
83 zone: row.get("zone")?,
84 aisle: row.get("aisle")?,
85 rack: row.get("rack")?,
86 level: row.get("level")?,
87 bin: row.get("bin")?,
88 max_weight_kg: parse_decimal_opt_row(
89 row.get::<_, Option<String>>("max_weight_kg")?,
90 "location",
91 "max_weight_kg",
92 )?,
93 max_volume_m3: parse_decimal_opt_row(
94 row.get::<_, Option<String>>("max_volume_m3")?,
95 "location",
96 "max_volume_m3",
97 )?,
98 current_weight_kg: parse_decimal_opt_row(
99 row.get::<_, Option<String>>("current_weight_kg")?,
100 "location",
101 "current_weight_kg",
102 )?,
103 current_volume_m3: parse_decimal_opt_row(
104 row.get::<_, Option<String>>("current_volume_m3")?,
105 "location",
106 "current_volume_m3",
107 )?,
108 is_pickable: row.get::<_, i32>("is_pickable")? == 1,
109 is_receivable: row.get::<_, i32>("is_receivable")? == 1,
110 is_active: row.get::<_, i32>("is_active")? == 1,
111 created_at: parse_datetime_row(
112 &row.get::<_, String>("created_at")?,
113 "location",
114 "created_at",
115 )?,
116 updated_at: parse_datetime_row(
117 &row.get::<_, String>("updated_at")?,
118 "location",
119 "updated_at",
120 )?,
121 })
122 }
123
124 fn row_to_location_inventory(row: &rusqlite::Row<'_>) -> rusqlite::Result<LocationInventory> {
126 let on_hand = parse_decimal_row(
127 &row.get::<_, String>("quantity_on_hand")?,
128 "location_inventory",
129 "quantity_on_hand",
130 )?;
131 let reserved = parse_decimal_row(
132 &row.get::<_, String>("quantity_reserved")?,
133 "location_inventory",
134 "quantity_reserved",
135 )?;
136
137 Ok(LocationInventory {
138 location_id: row.get("location_id")?,
139 sku: row.get("sku")?,
140 lot_id: parse_uuid_opt_row(
141 row.get::<_, Option<String>>("lot_id")?,
142 "location_inventory",
143 "lot_id",
144 )?,
145 quantity_on_hand: on_hand,
146 quantity_reserved: reserved,
147 quantity_available: on_hand - reserved,
148 updated_at: parse_datetime_row(
149 &row.get::<_, String>("updated_at")?,
150 "location_inventory",
151 "updated_at",
152 )?,
153 })
154 }
155
156 fn row_to_movement(row: &rusqlite::Row<'_>) -> rusqlite::Result<LocationMovement> {
158 Ok(LocationMovement {
159 id: parse_uuid_row(&row.get::<_, String>("id")?, "inventory_movement", "id")?,
160 movement_type: parse_enum_row(
161 &row.get::<_, String>("movement_type")?,
162 "inventory_movement",
163 "movement_type",
164 )?,
165 from_location_id: row.get("from_location_id")?,
166 to_location_id: row.get("to_location_id")?,
167 sku: row.get("sku")?,
168 lot_id: parse_uuid_opt_row(
169 row.get::<_, Option<String>>("lot_id")?,
170 "inventory_movement",
171 "lot_id",
172 )?,
173 quantity: parse_decimal_row(
174 &row.get::<_, String>("quantity")?,
175 "inventory_movement",
176 "quantity",
177 )?,
178 reference_type: row.get("reference_type")?,
179 reference_id: parse_uuid_opt_row(
180 row.get::<_, Option<String>>("reference_id")?,
181 "inventory_movement",
182 "reference_id",
183 )?,
184 reason: row.get("reason")?,
185 performed_by: row.get("performed_by")?,
186 created_at: parse_datetime_row(
187 &row.get::<_, String>("created_at")?,
188 "inventory_movement",
189 "created_at",
190 )?,
191 })
192 }
193
194 fn row_to_zone(row: &rusqlite::Row<'_>) -> rusqlite::Result<Zone> {
196 Ok(Zone {
197 id: row.get("id")?,
198 warehouse_id: row.get("warehouse_id")?,
199 code: row.get("code")?,
200 name: row.get("name")?,
201 description: row.get("description")?,
202 is_active: row.get::<_, i32>("is_active")? == 1,
203 created_at: parse_datetime_row(
204 &row.get::<_, String>("created_at")?,
205 "warehouse_zone",
206 "created_at",
207 )?,
208 })
209 }
210
211 fn row_to_cycle_count_header(row: &rusqlite::Row<'_>) -> rusqlite::Result<CycleCount> {
213 Ok(CycleCount {
214 id: parse_uuid_row(&row.get::<_, String>("id")?, "cycle_count", "id")?,
215 warehouse_id: row.get("warehouse_id")?,
216 location_id: row.get("location_id")?,
217 status: parse_enum_row(&row.get::<_, String>("status")?, "cycle_count", "status")?,
218 scheduled_date: parse_datetime_opt_row(
219 row.get::<_, Option<String>>("scheduled_date")?,
220 "cycle_count",
221 "scheduled_date",
222 )?,
223 counted_by: row.get("counted_by")?,
224 lines: Vec::new(),
225 created_at: parse_datetime_row(
226 &row.get::<_, String>("created_at")?,
227 "cycle_count",
228 "created_at",
229 )?,
230 updated_at: parse_datetime_row(
231 &row.get::<_, String>("updated_at")?,
232 "cycle_count",
233 "updated_at",
234 )?,
235 completed_at: parse_datetime_opt_row(
236 row.get::<_, Option<String>>("completed_at")?,
237 "cycle_count",
238 "completed_at",
239 )?,
240 })
241 }
242
243 fn row_to_cycle_count_line(row: &rusqlite::Row<'_>) -> rusqlite::Result<CycleCountLine> {
246 let lot_raw: String = row.get("lot_id")?;
247 let lot_id = if lot_raw.is_empty() {
248 None
249 } else {
250 Some(parse_uuid_row(&lot_raw, "cycle_count_line", "lot_id")?)
251 };
252 Ok(CycleCountLine {
253 id: parse_uuid_row(&row.get::<_, String>("id")?, "cycle_count_line", "id")?,
254 cycle_count_id: parse_uuid_row(
255 &row.get::<_, String>("cycle_count_id")?,
256 "cycle_count_line",
257 "cycle_count_id",
258 )?,
259 sku: row.get("sku")?,
260 lot_id,
261 expected_quantity: parse_decimal_row(
262 &row.get::<_, String>("expected_quantity")?,
263 "cycle_count_line",
264 "expected_quantity",
265 )?,
266 counted_quantity: parse_decimal_opt_row(
267 row.get::<_, Option<String>>("counted_quantity")?,
268 "cycle_count_line",
269 "counted_quantity",
270 )?,
271 variance: parse_decimal_opt_row(
272 row.get::<_, Option<String>>("variance")?,
273 "cycle_count_line",
274 "variance",
275 )?,
276 })
277 }
278
279 fn load_cycle_count_lines(
280 conn: &rusqlite::Connection,
281 id: Uuid,
282 ) -> Result<Vec<CycleCountLine>> {
283 let mut stmt = conn
284 .prepare(
285 "SELECT * FROM cycle_count_lines WHERE cycle_count_id = ?1 ORDER BY sku, lot_id",
286 )
287 .map_err(map_db_error)?;
288 let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
289 let mut lines = Vec::new();
290 while let Some(row) = rows.next().map_err(map_db_error)? {
291 lines.push(Self::row_to_cycle_count_line(row).map_err(map_db_error)?);
292 }
293 Ok(lines)
294 }
295
296 fn load_cycle_count_lines_batch(
299 conn: &rusqlite::Connection,
300 ids: &[Uuid],
301 ) -> Result<std::collections::HashMap<Uuid, Vec<CycleCountLine>>> {
302 let mut map: std::collections::HashMap<Uuid, Vec<CycleCountLine>> =
303 std::collections::HashMap::with_capacity(ids.len());
304 let id_strings: Vec<String> = ids.iter().map(Uuid::to_string).collect();
305 for chunk in id_strings.chunks(500) {
306 let placeholders = crate::sqlite::build_in_clause(chunk.len());
307 let sql = format!(
308 "SELECT * FROM cycle_count_lines WHERE cycle_count_id IN ({placeholders})
309 ORDER BY sku, lot_id"
310 );
311 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
312 let param_refs: Vec<&dyn rusqlite::ToSql> =
313 chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
314 let rows = stmt
315 .query_map(param_refs.as_slice(), |row| {
316 let line = Self::row_to_cycle_count_line(row)?;
317 Ok((line.cycle_count_id, line))
318 })
319 .map_err(map_db_error)?;
320 for row in rows {
321 let (parent, line) = row.map_err(map_db_error)?;
322 map.entry(parent).or_default().push(line);
323 }
324 }
325 Ok(map)
326 }
327
328 fn get_cycle_count_on(conn: &rusqlite::Connection, id: Uuid) -> Result<Option<CycleCount>> {
329 let mut stmt =
330 conn.prepare("SELECT * FROM cycle_counts WHERE id = ?1").map_err(map_db_error)?;
331 let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
332 let Some(row) = rows.next().map_err(map_db_error)? else {
333 return Ok(None);
334 };
335 let mut cc = Self::row_to_cycle_count_header(row).map_err(map_db_error)?;
336 drop(rows);
337 drop(stmt);
338 cc.lines = Self::load_cycle_count_lines(conn, id)?;
339 Ok(Some(cc))
340 }
341
342 fn transition_cycle_count(
344 &self,
345 id: Uuid,
346 next: CycleCountStatus,
347 set_completed_at: bool,
348 ) -> Result<CycleCount> {
349 let conn = self.conn()?;
350 let current = Self::get_cycle_count_on(&conn, id)?.ok_or(CommerceError::NotFound)?;
351 if !current.status.can_transition_to(next) {
352 return Err(CommerceError::ValidationError(format!(
353 "cannot transition cycle count from {} to {next}",
354 current.status
355 )));
356 }
357 let now = Utc::now().to_rfc3339();
358 conn.execute(
359 "UPDATE cycle_counts SET status = ?1, updated_at = ?2,
360 completed_at = CASE WHEN ?3 THEN ?2 ELSE completed_at END
361 WHERE id = ?4",
362 params![next.to_string(), now, set_completed_at, id.to_string()],
363 )
364 .map_err(map_db_error)?;
365 Self::get_cycle_count_on(&conn, id)?.ok_or_else(|| {
366 CommerceError::DatabaseError("Failed to retrieve updated cycle count".into())
367 })
368 }
369
370 fn generate_location_code(input: &CreateLocation) -> String {
372 let parts: Vec<&str> = [
373 input.zone.as_deref(),
374 input.aisle.as_deref(),
375 input.rack.as_deref(),
376 input.level.as_deref(),
377 input.bin.as_deref(),
378 ]
379 .iter()
380 .filter_map(|p| *p)
381 .collect();
382
383 if parts.is_empty() {
384 format!("LOC-{}", &Uuid::new_v4().to_string()[..8].to_uppercase())
385 } else {
386 parts.join("-")
387 }
388 }
389}
390
391impl WarehouseRepository for SqliteWarehouseRepository {
392 fn create_warehouse(&self, input: CreateWarehouse) -> Result<Warehouse> {
397 let conn = self.conn()?;
398 let now = Utc::now();
399 let now_str = now.to_rfc3339();
400 let address = input.address.clone();
401 let address_json = serde_json::to_string(&address).unwrap_or_else(|_| "{}".to_string());
402
403 conn.execute(
404 "INSERT INTO warehouses (code, name, warehouse_type, address_json, timezone, is_active, created_at, updated_at)
405 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?6)",
406 params![
407 input.code,
408 input.name,
409 input.warehouse_type.to_string(),
410 address_json,
411 input.timezone,
412 now_str,
413 ],
414 )
415 .map_err(map_db_error)?;
416
417 let id = conn.last_insert_rowid() as i32;
418
419 Ok(Warehouse {
421 id,
422 code: input.code,
423 name: input.name,
424 warehouse_type: input.warehouse_type,
425 address,
426 timezone: input.timezone,
427 is_active: true,
428 created_at: now,
429 updated_at: now,
430 })
431 }
432
433 fn get_warehouse(&self, id: i32) -> Result<Option<Warehouse>> {
434 let conn = self.conn()?;
435 let mut stmt =
436 conn.prepare("SELECT * FROM warehouses WHERE id = ?1").map_err(map_db_error)?;
437
438 let mut rows = stmt.query(params![id]).map_err(map_db_error)?;
439
440 if let Some(row) = rows.next().map_err(map_db_error)? {
441 Ok(Some(Self::row_to_warehouse(row).map_err(map_db_error)?))
442 } else {
443 Ok(None)
444 }
445 }
446
447 fn get_warehouse_by_code(&self, code: &str) -> Result<Option<Warehouse>> {
448 let conn = self.conn()?;
449 let mut stmt =
450 conn.prepare("SELECT * FROM warehouses WHERE code = ?1").map_err(map_db_error)?;
451
452 let mut rows = stmt.query(params![code]).map_err(map_db_error)?;
453
454 if let Some(row) = rows.next().map_err(map_db_error)? {
455 Ok(Some(Self::row_to_warehouse(row).map_err(map_db_error)?))
456 } else {
457 Ok(None)
458 }
459 }
460
461 fn update_warehouse(&self, id: i32, input: UpdateWarehouse) -> Result<Warehouse> {
462 let conn = self.conn()?;
463
464 let mut stmt =
466 conn.prepare("SELECT * FROM warehouses WHERE id = ?1").map_err(map_db_error)?;
467 let mut rows = stmt.query(params![id]).map_err(map_db_error)?;
468 let existing = if let Some(row) = rows.next().map_err(map_db_error)? {
469 Self::row_to_warehouse(row).map_err(map_db_error)?
470 } else {
471 return Err(CommerceError::NotFound);
472 };
473 drop(rows);
474 drop(stmt);
475
476 let code = existing.code;
477 let name = input.name.unwrap_or(existing.name);
478 let warehouse_type = input.warehouse_type.unwrap_or(existing.warehouse_type);
479 let address = input.address.unwrap_or(existing.address);
480 let timezone = input.timezone.or(existing.timezone);
481 let is_active = input.is_active.unwrap_or(existing.is_active);
482 let address_json = serde_json::to_string(&address).unwrap_or_else(|_| "{}".to_string());
483 let now = Utc::now();
484
485 conn.execute(
486 "UPDATE warehouses SET name = ?1, warehouse_type = ?2, address_json = ?3, timezone = ?4, is_active = ?5, updated_at = ?6 WHERE id = ?7",
487 params![
488 name,
489 warehouse_type.to_string(),
490 address_json,
491 timezone,
492 i32::from(is_active),
493 now.to_rfc3339(),
494 id,
495 ],
496 )
497 .map_err(map_db_error)?;
498
499 Ok(Warehouse {
501 id,
502 code,
503 name,
504 warehouse_type,
505 address,
506 timezone,
507 is_active,
508 created_at: existing.created_at,
509 updated_at: now,
510 })
511 }
512
513 fn list_warehouses(&self, filter: WarehouseFilter) -> Result<Vec<Warehouse>> {
514 let conn = self.conn()?;
515 let mut sql = "SELECT * FROM warehouses WHERE 1=1".to_string();
516 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
517
518 if let Some(wh_type) = filter.warehouse_type {
519 sql.push_str(" AND warehouse_type = ?");
520 params_vec.push(Box::new(wh_type.to_string()));
521 }
522
523 if let Some(active) = filter.is_active {
524 sql.push_str(" AND is_active = ?");
525 params_vec.push(Box::new(i32::from(active)));
526 }
527
528 sql.push_str(" ORDER BY name");
529
530 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
531
532 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
533 let params_refs: Vec<&dyn rusqlite::ToSql> =
534 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
535
536 let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
537
538 let mut warehouses = Vec::new();
539 while let Some(row) = rows.next().map_err(map_db_error)? {
540 warehouses.push(Self::row_to_warehouse(row).map_err(map_db_error)?);
541 }
542
543 Ok(warehouses)
544 }
545
546 fn delete_warehouse(&self, id: i32) -> Result<()> {
547 let conn = self.conn()?;
548
549 let loc_count: i32 = conn
551 .query_row(
552 "SELECT COUNT(*) FROM locations WHERE warehouse_id = ?1",
553 params![id],
554 |row| row.get(0),
555 )
556 .map_err(map_db_error)?;
557
558 if loc_count > 0 {
559 return Err(CommerceError::ValidationError(
560 "Cannot delete warehouse with existing locations".into(),
561 ));
562 }
563
564 conn.execute("DELETE FROM warehouses WHERE id = ?1", params![id]).map_err(map_db_error)?;
565
566 Ok(())
567 }
568
569 fn count_warehouses(&self, filter: WarehouseFilter) -> Result<u64> {
570 let conn = self.conn()?;
571 let mut sql = "SELECT COUNT(*) FROM warehouses WHERE 1=1".to_string();
572 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
573
574 if let Some(wh_type) = filter.warehouse_type {
575 sql.push_str(" AND warehouse_type = ?");
576 params_vec.push(Box::new(wh_type.to_string()));
577 }
578
579 if let Some(active) = filter.is_active {
580 sql.push_str(" AND is_active = ?");
581 params_vec.push(Box::new(i32::from(active)));
582 }
583
584 let params_refs: Vec<&dyn rusqlite::ToSql> =
585 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
586
587 let count: i64 =
588 conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
589
590 Ok(count as u64)
591 }
592
593 fn create_zone(&self, input: CreateZone) -> Result<Zone> {
598 let now = Utc::now().to_rfc3339();
599 let id = {
600 let conn = self.conn()?;
601 conn.execute(
602 "INSERT INTO warehouse_zones (warehouse_id, code, name, description, is_active, created_at)
603 VALUES (?1, ?2, ?3, ?4, 1, ?5)",
604 params![
605 input.warehouse_id,
606 input.code,
607 input.name,
608 input.description,
609 now,
610 ],
611 )
612 .map_err(map_db_error)?;
613
614 conn.last_insert_rowid() as i32
615 };
616 self.get_zone(id)?
617 .ok_or_else(|| CommerceError::DatabaseError("Failed to retrieve created zone".into()))
618 }
619
620 fn get_zone(&self, id: i32) -> Result<Option<Zone>> {
621 let conn = self.conn()?;
622 let mut stmt =
623 conn.prepare("SELECT * FROM warehouse_zones WHERE id = ?1").map_err(map_db_error)?;
624
625 let mut rows = stmt.query(params![id]).map_err(map_db_error)?;
626
627 if let Some(row) = rows.next().map_err(map_db_error)? {
628 Ok(Some(Self::row_to_zone(row).map_err(map_db_error)?))
629 } else {
630 Ok(None)
631 }
632 }
633
634 fn get_zones(&self, warehouse_id: i32) -> Result<Vec<Zone>> {
635 let conn = self.conn()?;
636 let mut stmt = conn
637 .prepare("SELECT * FROM warehouse_zones WHERE warehouse_id = ?1 ORDER BY code")
638 .map_err(map_db_error)?;
639
640 let mut rows = stmt.query(params![warehouse_id]).map_err(map_db_error)?;
641
642 let mut zones = Vec::new();
643 while let Some(row) = rows.next().map_err(map_db_error)? {
644 zones.push(Self::row_to_zone(row).map_err(map_db_error)?);
645 }
646
647 Ok(zones)
648 }
649
650 fn update_zone(&self, id: i32, input: UpdateZone) -> Result<Zone> {
651 let existing = self.get_zone(id)?.ok_or(CommerceError::NotFound)?;
652
653 let name = input.name.unwrap_or(existing.name);
654 let description = input.description.or(existing.description);
655 let is_active = input.is_active.unwrap_or(existing.is_active);
656
657 {
658 let conn = self.conn()?;
659 conn.execute(
660 "UPDATE warehouse_zones SET name = ?1, description = ?2, is_active = ?3 WHERE id = ?4",
661 params![name, description, i32::from(is_active), id],
662 )
663 .map_err(map_db_error)?;
664 }
665
666 self.get_zone(id)?
667 .ok_or_else(|| CommerceError::DatabaseError("Failed to retrieve updated zone".into()))
668 }
669
670 fn delete_zone(&self, id: i32) -> Result<()> {
671 let conn = self.conn()?;
672 conn.execute("DELETE FROM warehouse_zones WHERE id = ?1", params![id])
673 .map_err(map_db_error)?;
674 Ok(())
675 }
676
677 fn create_location(&self, input: CreateLocation) -> Result<Location> {
682 let conn = self.conn()?;
683 let now = Utc::now().to_rfc3339();
684 let code = input.code.clone().unwrap_or_else(|| Self::generate_location_code(&input));
685
686 conn.execute(
687 "INSERT INTO locations (warehouse_id, code, location_type, zone, aisle, rack, level, bin,
688 max_weight_kg, max_volume_m3, is_pickable, is_receivable, is_active, created_at, updated_at)
689 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, ?13, ?13)",
690 params![
691 input.warehouse_id,
692 code,
693 input.location_type.to_string(),
694 input.zone,
695 input.aisle,
696 input.rack,
697 input.level,
698 input.bin,
699 input.max_weight_kg.map(|d| d.to_string()),
700 input.max_volume_m3.map(|d| d.to_string()),
701 i32::from(input.is_pickable.unwrap_or(true)),
702 i32::from(input.is_receivable.unwrap_or(true)),
703 now,
704 ],
705 )
706 .map_err(map_db_error)?;
707
708 let id = conn.last_insert_rowid() as i32;
709 drop(conn);
710 self.get_location(id)?.ok_or_else(|| {
711 CommerceError::DatabaseError("Failed to retrieve created location".into())
712 })
713 }
714
715 fn get_location(&self, id: i32) -> Result<Option<Location>> {
716 let conn = self.conn()?;
717 let mut stmt =
718 conn.prepare("SELECT * FROM locations WHERE id = ?1").map_err(map_db_error)?;
719
720 let mut rows = stmt.query(params![id]).map_err(map_db_error)?;
721
722 if let Some(row) = rows.next().map_err(map_db_error)? {
723 Ok(Some(Self::row_to_location(row).map_err(map_db_error)?))
724 } else {
725 Ok(None)
726 }
727 }
728
729 fn get_location_by_code(&self, warehouse_id: i32, code: &str) -> Result<Option<Location>> {
730 let conn = self.conn()?;
731 let mut stmt = conn
732 .prepare("SELECT * FROM locations WHERE warehouse_id = ?1 AND code = ?2")
733 .map_err(map_db_error)?;
734
735 let mut rows = stmt.query(params![warehouse_id, code]).map_err(map_db_error)?;
736
737 if let Some(row) = rows.next().map_err(map_db_error)? {
738 Ok(Some(Self::row_to_location(row).map_err(map_db_error)?))
739 } else {
740 Ok(None)
741 }
742 }
743
744 fn update_location(&self, id: i32, input: UpdateLocation) -> Result<Location> {
745 let conn = self.conn()?;
746 let existing = self.get_location(id)?.ok_or(CommerceError::NotFound)?;
747
748 let location_type = input.location_type.unwrap_or(existing.location_type);
749 let zone = input.zone.or(existing.zone);
750 let aisle = input.aisle.or(existing.aisle);
751 let rack = input.rack.or(existing.rack);
752 let level = input.level.or(existing.level);
753 let bin = input.bin.or(existing.bin);
754 let max_weight = input.max_weight_kg.or(existing.max_weight_kg);
755 let max_volume = input.max_volume_m3.or(existing.max_volume_m3);
756 let is_pickable = input.is_pickable.unwrap_or(existing.is_pickable);
757 let is_receivable = input.is_receivable.unwrap_or(existing.is_receivable);
758 let is_active = input.is_active.unwrap_or(existing.is_active);
759
760 conn.execute(
761 "UPDATE locations SET location_type = ?1, zone = ?2, aisle = ?3, rack = ?4, level = ?5,
762 bin = ?6, max_weight_kg = ?7, max_volume_m3 = ?8, is_pickable = ?9, is_receivable = ?10,
763 is_active = ?11 WHERE id = ?12",
764 params![
765 location_type.to_string(),
766 zone,
767 aisle,
768 rack,
769 level,
770 bin,
771 max_weight.map(|d| d.to_string()),
772 max_volume.map(|d| d.to_string()),
773 i32::from(is_pickable),
774 i32::from(is_receivable),
775 i32::from(is_active),
776 id,
777 ],
778 )
779 .map_err(map_db_error)?;
780
781 self.get_location(id)?.ok_or_else(|| {
782 CommerceError::DatabaseError("Failed to retrieve updated location".into())
783 })
784 }
785
786 fn list_locations(&self, filter: LocationFilter) -> Result<Vec<Location>> {
787 let conn = self.conn()?;
788 let mut sql = "SELECT * FROM locations WHERE 1=1".to_string();
789 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
790
791 if let Some(warehouse_id) = filter.warehouse_id {
792 sql.push_str(" AND warehouse_id = ?");
793 params_vec.push(Box::new(warehouse_id));
794 }
795
796 if let Some(loc_type) = filter.location_type {
797 sql.push_str(" AND location_type = ?");
798 params_vec.push(Box::new(loc_type.to_string()));
799 }
800
801 if let Some(zone) = filter.zone {
802 sql.push_str(" AND zone = ?");
803 params_vec.push(Box::new(zone));
804 }
805
806 if let Some(aisle) = filter.aisle {
807 sql.push_str(" AND aisle = ?");
808 params_vec.push(Box::new(aisle));
809 }
810
811 if let Some(pickable) = filter.is_pickable {
812 sql.push_str(" AND is_pickable = ?");
813 params_vec.push(Box::new(i32::from(pickable)));
814 }
815
816 if let Some(receivable) = filter.is_receivable {
817 sql.push_str(" AND is_receivable = ?");
818 params_vec.push(Box::new(i32::from(receivable)));
819 }
820
821 if let Some(active) = filter.is_active {
822 sql.push_str(" AND is_active = ?");
823 params_vec.push(Box::new(i32::from(active)));
824 }
825
826 sql.push_str(" ORDER BY code");
827
828 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
829
830 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
831 let params_refs: Vec<&dyn rusqlite::ToSql> =
832 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
833
834 let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
835
836 let mut locations = Vec::new();
837 while let Some(row) = rows.next().map_err(map_db_error)? {
838 locations.push(Self::row_to_location(row).map_err(map_db_error)?);
839 }
840
841 Ok(locations)
842 }
843
844 fn delete_location(&self, id: i32) -> Result<()> {
845 let conn = self.conn()?;
846
847 let quantities: Vec<String> = {
851 let mut stmt = conn
852 .prepare("SELECT quantity_on_hand FROM location_inventory WHERE location_id = ?1")
853 .map_err(map_db_error)?;
854 let rows = stmt.query_map(params![id], |row| row.get(0)).map_err(map_db_error)?;
855 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)?
856 };
857 for qty in &quantities {
858 if parse_decimal_strict(qty, "location_inventory", "quantity_on_hand")? > Decimal::ZERO
859 {
860 return Err(CommerceError::ValidationError(
861 "Cannot delete location with inventory".into(),
862 ));
863 }
864 }
865
866 conn.execute("DELETE FROM locations WHERE id = ?1", params![id]).map_err(map_db_error)?;
867
868 Ok(())
869 }
870
871 fn count_locations(&self, filter: LocationFilter) -> Result<u64> {
872 let conn = self.conn()?;
873 let mut sql = "SELECT COUNT(*) FROM locations WHERE 1=1".to_string();
874 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
875
876 if let Some(warehouse_id) = filter.warehouse_id {
877 sql.push_str(" AND warehouse_id = ?");
878 params_vec.push(Box::new(warehouse_id));
879 }
880
881 if let Some(loc_type) = filter.location_type {
882 sql.push_str(" AND location_type = ?");
883 params_vec.push(Box::new(loc_type.to_string()));
884 }
885
886 if let Some(zone) = filter.zone {
889 sql.push_str(" AND zone = ?");
890 params_vec.push(Box::new(zone));
891 }
892
893 if let Some(aisle) = filter.aisle {
894 sql.push_str(" AND aisle = ?");
895 params_vec.push(Box::new(aisle));
896 }
897
898 if let Some(pickable) = filter.is_pickable {
899 sql.push_str(" AND is_pickable = ?");
900 params_vec.push(Box::new(i32::from(pickable)));
901 }
902
903 if let Some(receivable) = filter.is_receivable {
904 sql.push_str(" AND is_receivable = ?");
905 params_vec.push(Box::new(i32::from(receivable)));
906 }
907
908 if let Some(active) = filter.is_active {
909 sql.push_str(" AND is_active = ?");
910 params_vec.push(Box::new(i32::from(active)));
911 }
912
913 let params_refs: Vec<&dyn rusqlite::ToSql> =
914 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
915
916 let count: i64 =
917 conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
918
919 Ok(count as u64)
920 }
921
922 fn get_locations_for_warehouse(&self, warehouse_id: i32) -> Result<Vec<Location>> {
923 self.list_locations(LocationFilter {
927 warehouse_id: Some(warehouse_id),
928 ..Default::default()
929 })
930 }
931
932 fn get_pickable_locations(&self, warehouse_id: i32, sku: &str) -> Result<Vec<Location>> {
933 let conn = self.conn()?;
934 let mut stmt = conn
939 .prepare(
940 "SELECT l.*, li.quantity_on_hand AS li_on_hand,
941 li.quantity_reserved AS li_reserved
942 FROM locations l
943 JOIN location_inventory li ON l.id = li.location_id
944 WHERE l.warehouse_id = ?1 AND l.is_pickable = 1 AND l.is_active = 1
945 AND li.sku = ?2
946 ORDER BY l.code",
947 )
948 .map_err(map_db_error)?;
949
950 let mut rows = stmt.query(params![warehouse_id, sku]).map_err(map_db_error)?;
951
952 let mut locations = Vec::new();
953 while let Some(row) = rows.next().map_err(map_db_error)? {
954 let on_hand: String = row.get("li_on_hand").map_err(map_db_error)?;
955 let reserved: String = row.get("li_reserved").map_err(map_db_error)?;
956 let on_hand = parse_decimal_strict(&on_hand, "location_inventory", "quantity_on_hand")?;
957 let reserved =
958 parse_decimal_strict(&reserved, "location_inventory", "quantity_reserved")?;
959 if on_hand > reserved {
960 locations.push(Self::row_to_location(row).map_err(map_db_error)?);
961 }
962 }
963
964 Ok(locations)
965 }
966
967 fn get_receivable_locations(&self, warehouse_id: i32) -> Result<Vec<Location>> {
968 self.list_locations(LocationFilter {
969 warehouse_id: Some(warehouse_id),
970 is_receivable: Some(true),
971 is_active: Some(true),
972 ..Default::default()
973 })
974 }
975
976 fn get_location_inventory(&self, location_id: i32) -> Result<Vec<LocationInventory>> {
981 let conn = self.conn()?;
982 let mut stmt = conn
986 .prepare("SELECT * FROM location_inventory WHERE location_id = ?1")
987 .map_err(map_db_error)?;
988
989 let mut rows = stmt.query(params![location_id]).map_err(map_db_error)?;
990
991 let mut inventory = Vec::new();
992 while let Some(row) = rows.next().map_err(map_db_error)? {
993 let entry = Self::row_to_location_inventory(row).map_err(map_db_error)?;
994 if entry.quantity_on_hand > Decimal::ZERO {
995 inventory.push(entry);
996 }
997 }
998
999 Ok(inventory)
1000 }
1001
1002 fn get_inventory_for_sku(
1003 &self,
1004 warehouse_id: i32,
1005 sku: &str,
1006 ) -> Result<Vec<LocationInventory>> {
1007 let conn = self.conn()?;
1008 let mut stmt = conn
1012 .prepare(
1013 "SELECT li.* FROM location_inventory li
1014 JOIN locations l ON li.location_id = l.id
1015 WHERE l.warehouse_id = ?1 AND li.sku = ?2
1016 ORDER BY l.code",
1017 )
1018 .map_err(map_db_error)?;
1019
1020 let mut rows = stmt.query(params![warehouse_id, sku]).map_err(map_db_error)?;
1021
1022 let mut inventory = Vec::new();
1023 while let Some(row) = rows.next().map_err(map_db_error)? {
1024 let entry = Self::row_to_location_inventory(row).map_err(map_db_error)?;
1025 if entry.quantity_on_hand > Decimal::ZERO {
1026 inventory.push(entry);
1027 }
1028 }
1029
1030 Ok(inventory)
1031 }
1032
1033 fn adjust_inventory(&self, input: AdjustLocationInventory) -> Result<LocationInventory> {
1034 let conn = self.conn()?;
1035 let now = Utc::now();
1036 let now_str = now.to_rfc3339();
1037 let lot_id_str = input.lot_id.map(|id| id.to_string());
1038 let lot_key = lot_id_str.unwrap_or_default();
1039
1040 let existing: Option<(String, String)> = conn
1042 .query_row(
1043 "SELECT quantity_on_hand, quantity_reserved FROM location_inventory
1044 WHERE location_id = ?1 AND sku = ?2 AND COALESCE(lot_id, '') = ?3",
1045 params![input.location_id, input.sku, lot_key],
1046 |row| Ok((row.get(0)?, row.get(1)?)),
1047 )
1048 .ok();
1049
1050 let (new_on_hand, reserved) = if let Some((oh_str, res_str)) = existing {
1051 let on_hand = parse_decimal_strict(&oh_str, "location_inventory", "quantity_on_hand")?;
1052 let reserved =
1053 parse_decimal_strict(&res_str, "location_inventory", "quantity_reserved")?;
1054 let new_qty = on_hand + input.quantity;
1055
1056 if new_qty < Decimal::ZERO {
1057 return Err(CommerceError::ValidationError(
1058 "Adjustment would result in negative inventory".into(),
1059 ));
1060 }
1061
1062 conn.execute(
1063 "UPDATE location_inventory SET quantity_on_hand = ?1, updated_at = ?2
1064 WHERE location_id = ?3 AND sku = ?4 AND COALESCE(lot_id, '') = ?5",
1065 params![new_qty.to_string(), now_str, input.location_id, input.sku, lot_key],
1066 )
1067 .map_err(map_db_error)?;
1068
1069 (new_qty, reserved)
1070 } else {
1071 if input.quantity < Decimal::ZERO {
1072 return Err(CommerceError::ValidationError(
1073 "Cannot create negative inventory".into(),
1074 ));
1075 }
1076
1077 conn.execute(
1078 "INSERT INTO location_inventory (location_id, sku, lot_id, quantity_on_hand, quantity_reserved, updated_at)
1079 VALUES (?1, ?2, ?3, ?4, '0', ?5)",
1080 params![
1081 input.location_id,
1082 input.sku,
1083 lot_key,
1084 input.quantity.to_string(),
1085 now_str,
1086 ],
1087 )
1088 .map_err(map_db_error)?;
1089
1090 (input.quantity, Decimal::ZERO)
1091 };
1092
1093 let movement_id = Uuid::new_v4();
1095
1096 conn.execute(
1097 "INSERT INTO inventory_movements (id, movement_type, to_location_id, sku, lot_id, quantity, reference_type, reference_id, reason, performed_by, created_at)
1098 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1099 params![
1100 movement_id.to_string(),
1101 MovementType::Adjustment.to_string(),
1102 input.location_id,
1103 input.sku,
1104 input.lot_id.map(|id| id.to_string()),
1105 input.quantity.to_string(),
1106 input.reference_type,
1107 input.reference_id.map(|id| id.to_string()),
1108 input.reason,
1109 input.performed_by,
1110 now_str,
1111 ],
1112 )
1113 .map_err(map_db_error)?;
1114
1115 Ok(LocationInventory {
1116 location_id: input.location_id,
1117 sku: input.sku,
1118 lot_id: input.lot_id,
1119 quantity_on_hand: new_on_hand,
1120 quantity_reserved: reserved,
1121 quantity_available: new_on_hand - reserved,
1122 updated_at: now,
1123 })
1124 }
1125
1126 fn move_inventory(&self, input: MoveInventory) -> Result<LocationMovement> {
1127 let now = Utc::now();
1128 let now_str = now.to_rfc3339();
1129 let lot_id_str = input.lot_id.map(|id| id.to_string());
1130 let lot_key = lot_id_str.clone().unwrap_or_default();
1131 let movement_id = Uuid::new_v4();
1132
1133 let smuggle = |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
1140
1141 with_immediate_transaction(&self.pool, |tx| {
1142 let (source_on_hand, source_reserved): (String, String) = match tx.query_row(
1144 "SELECT quantity_on_hand, quantity_reserved FROM location_inventory
1145 WHERE location_id = ?1 AND sku = ?2 AND COALESCE(lot_id, '') = ?3",
1146 params![input.from_location_id, input.sku, lot_key],
1147 |row| Ok((row.get(0)?, row.get(1)?)),
1148 ) {
1149 Ok(v) => v,
1150 Err(rusqlite::Error::QueryReturnedNoRows) => {
1151 return Err(smuggle(CommerceError::NotFound));
1152 }
1153 Err(e) => return Err(e),
1154 };
1155
1156 let on_hand =
1157 parse_decimal_strict(&source_on_hand, "location_inventory", "quantity_on_hand")
1158 .map_err(smuggle)?;
1159 let reserved =
1160 parse_decimal_strict(&source_reserved, "location_inventory", "quantity_reserved")
1161 .map_err(smuggle)?;
1162 let available = on_hand - reserved;
1163
1164 if input.quantity > available {
1165 return Err(smuggle(CommerceError::ValidationError(format!(
1166 "Insufficient available quantity. Requested: {}, Available: {}",
1167 input.quantity, available
1168 ))));
1169 }
1170
1171 let new_source_qty = on_hand - input.quantity;
1173 tx.execute(
1174 "UPDATE location_inventory SET quantity_on_hand = ?1, updated_at = ?2
1175 WHERE location_id = ?3 AND sku = ?4 AND COALESCE(lot_id, '') = ?5",
1176 params![
1177 new_source_qty.to_string(),
1178 now_str,
1179 input.from_location_id,
1180 input.sku,
1181 lot_key,
1182 ],
1183 )?;
1184
1185 let dest_exists: bool = tx
1187 .query_row(
1188 "SELECT 1 FROM location_inventory WHERE location_id = ?1 AND sku = ?2 AND COALESCE(lot_id, '') = ?3",
1189 params![input.to_location_id, input.sku, lot_key],
1190 |_| Ok(true),
1191 )
1192 .unwrap_or(false);
1193
1194 if dest_exists {
1195 let dest_on_hand: String = tx.query_row(
1202 "SELECT quantity_on_hand FROM location_inventory
1203 WHERE location_id = ?1 AND sku = ?2 AND COALESCE(lot_id, '') = ?3",
1204 params![input.to_location_id, input.sku, lot_key],
1205 |row| row.get(0),
1206 )?;
1207 let new_dest_qty =
1208 parse_decimal_strict(&dest_on_hand, "location_inventory", "quantity_on_hand")
1209 .map_err(smuggle)?
1210 + input.quantity;
1211 tx.execute(
1212 "UPDATE location_inventory SET quantity_on_hand = ?1, updated_at = ?2
1213 WHERE location_id = ?3 AND sku = ?4 AND COALESCE(lot_id, '') = ?5",
1214 params![
1215 new_dest_qty.to_string(),
1216 now_str,
1217 input.to_location_id,
1218 input.sku,
1219 lot_key,
1220 ],
1221 )?;
1222 } else {
1223 tx.execute(
1224 "INSERT INTO location_inventory (location_id, sku, lot_id, quantity_on_hand, quantity_reserved, updated_at)
1225 VALUES (?1, ?2, ?3, ?4, '0', ?5)",
1226 params![
1227 input.to_location_id,
1228 input.sku,
1229 lot_key,
1230 input.quantity.to_string(),
1231 now_str,
1232 ],
1233 )?;
1234 }
1235
1236 tx.execute(
1238 "INSERT INTO inventory_movements (id, movement_type, from_location_id, to_location_id, sku, lot_id, quantity, reason, performed_by, created_at)
1239 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1240 params![
1241 movement_id.to_string(),
1242 MovementType::Transfer.to_string(),
1243 input.from_location_id,
1244 input.to_location_id,
1245 input.sku,
1246 lot_id_str,
1247 input.quantity.to_string(),
1248 input.reason,
1249 input.performed_by,
1250 now_str,
1251 ],
1252 )?;
1253
1254 Ok(LocationMovement {
1255 id: movement_id,
1256 movement_type: MovementType::Transfer,
1257 from_location_id: Some(input.from_location_id),
1258 to_location_id: Some(input.to_location_id),
1259 sku: input.sku.clone(),
1260 lot_id: input.lot_id,
1261 quantity: input.quantity,
1262 reference_type: None,
1263 reference_id: None,
1264 reason: input.reason.clone(),
1265 performed_by: input.performed_by.clone(),
1266 created_at: now,
1267 })
1268 })
1269 }
1270
1271 fn list_location_inventory(
1272 &self,
1273 filter: LocationInventoryFilter,
1274 ) -> Result<Vec<LocationInventory>> {
1275 let conn = self.conn()?;
1276 let mut sql = "SELECT li.* FROM location_inventory li".to_string();
1277 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1278
1279 if filter.warehouse_id.is_some() {
1280 sql.push_str(" JOIN locations l ON li.location_id = l.id");
1281 }
1282
1283 sql.push_str(" WHERE 1=1");
1284
1285 if let Some(location_id) = filter.location_id {
1286 sql.push_str(" AND li.location_id = ?");
1287 params_vec.push(Box::new(location_id));
1288 }
1289
1290 if let Some(warehouse_id) = filter.warehouse_id {
1291 sql.push_str(" AND l.warehouse_id = ?");
1292 params_vec.push(Box::new(warehouse_id));
1293 }
1294
1295 if let Some(sku) = filter.sku {
1296 sql.push_str(" AND li.sku = ?");
1297 params_vec.push(Box::new(sku));
1298 }
1299
1300 if let Some(lot_id) = filter.lot_id {
1301 sql.push_str(" AND li.lot_id = ?");
1302 params_vec.push(Box::new(lot_id.to_string()));
1303 }
1304
1305 let has_quantity = filter.has_quantity == Some(true);
1310
1311 if !has_quantity {
1312 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
1313 }
1314
1315 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1316 let params_refs: Vec<&dyn rusqlite::ToSql> =
1317 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1318
1319 let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
1320
1321 let mut inventory = Vec::new();
1322 while let Some(row) = rows.next().map_err(map_db_error)? {
1323 inventory.push(Self::row_to_location_inventory(row).map_err(map_db_error)?);
1324 }
1325
1326 if has_quantity {
1327 inventory.retain(|entry| entry.quantity_on_hand > Decimal::ZERO);
1328 if let Some(offset) = filter.offset {
1329 let offset = (offset as usize).min(inventory.len());
1330 inventory.drain(..offset);
1331 }
1332 if let Some(limit) = filter.limit {
1333 inventory.truncate(limit as usize);
1334 }
1335 }
1336
1337 Ok(inventory)
1338 }
1339
1340 fn get_movements(&self, filter: MovementFilter) -> Result<Vec<LocationMovement>> {
1345 let conn = self.conn()?;
1346 let mut sql = "SELECT m.* FROM inventory_movements m".to_string();
1347 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1348
1349 if filter.warehouse_id.is_some() {
1350 sql.push_str(
1351 " LEFT JOIN locations l_from ON m.from_location_id = l_from.id
1352 LEFT JOIN locations l_to ON m.to_location_id = l_to.id",
1353 );
1354 }
1355
1356 sql.push_str(" WHERE 1=1");
1357
1358 if let Some(warehouse_id) = filter.warehouse_id {
1359 sql.push_str(" AND (l_from.warehouse_id = ? OR l_to.warehouse_id = ?)");
1360 params_vec.push(Box::new(warehouse_id));
1361 params_vec.push(Box::new(warehouse_id));
1362 }
1363
1364 if let Some(location_id) = filter.location_id {
1365 sql.push_str(" AND (m.from_location_id = ? OR m.to_location_id = ?)");
1366 params_vec.push(Box::new(location_id));
1367 params_vec.push(Box::new(location_id));
1368 }
1369
1370 if let Some(sku) = filter.sku {
1371 sql.push_str(" AND m.sku = ?");
1372 params_vec.push(Box::new(sku));
1373 }
1374
1375 if let Some(lot_id) = filter.lot_id {
1376 sql.push_str(" AND m.lot_id = ?");
1377 params_vec.push(Box::new(lot_id.to_string()));
1378 }
1379
1380 if let Some(movement_type) = filter.movement_type {
1381 sql.push_str(" AND m.movement_type = ?");
1382 params_vec.push(Box::new(movement_type.to_string()));
1383 }
1384
1385 if let Some(from_date) = filter.from_date {
1386 sql.push_str(" AND m.created_at >= ?");
1387 params_vec.push(Box::new(from_date.to_rfc3339()));
1388 }
1389
1390 if let Some(to_date) = filter.to_date {
1391 sql.push_str(" AND m.created_at <= ?");
1392 params_vec.push(Box::new(to_date.to_rfc3339()));
1393 }
1394
1395 sql.push_str(" ORDER BY m.created_at DESC");
1396
1397 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
1398
1399 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1400 let params_refs: Vec<&dyn rusqlite::ToSql> =
1401 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1402
1403 let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
1404
1405 let mut movements = Vec::new();
1406 while let Some(row) = rows.next().map_err(map_db_error)? {
1407 movements.push(Self::row_to_movement(row).map_err(map_db_error)?);
1408 }
1409
1410 Ok(movements)
1411 }
1412
1413 fn count_movements(&self, filter: MovementFilter) -> Result<u64> {
1414 let conn = self.conn()?;
1415 let mut sql = "SELECT COUNT(*) FROM inventory_movements m".to_string();
1416 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1417
1418 if filter.warehouse_id.is_some() {
1419 sql.push_str(
1420 " LEFT JOIN locations l_from ON m.from_location_id = l_from.id
1421 LEFT JOIN locations l_to ON m.to_location_id = l_to.id",
1422 );
1423 }
1424
1425 sql.push_str(" WHERE 1=1");
1426
1427 if let Some(warehouse_id) = filter.warehouse_id {
1428 sql.push_str(" AND (l_from.warehouse_id = ? OR l_to.warehouse_id = ?)");
1429 params_vec.push(Box::new(warehouse_id));
1430 params_vec.push(Box::new(warehouse_id));
1431 }
1432
1433 if let Some(movement_type) = filter.movement_type {
1434 sql.push_str(" AND m.movement_type = ?");
1435 params_vec.push(Box::new(movement_type.to_string()));
1436 }
1437
1438 let params_refs: Vec<&dyn rusqlite::ToSql> =
1439 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1440
1441 let count: i64 =
1442 conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
1443
1444 Ok(count as u64)
1445 }
1446
1447 fn create_locations_batch(&self, inputs: Vec<CreateLocation>) -> Result<BatchResult<Location>> {
1452 let mut result = BatchResult::new();
1453
1454 for (index, input) in inputs.into_iter().enumerate() {
1455 match self.create_location(input) {
1456 Ok(location) => result.record_success(location),
1457 Err(e) => result.record_failure(index, None, &e),
1458 }
1459 }
1460
1461 Ok(result)
1462 }
1463
1464 fn get_locations_batch(&self, ids: Vec<i32>) -> Result<Vec<Location>> {
1465 let mut locations = Vec::new();
1466 for id in ids {
1467 if let Some(location) = self.get_location(id)? {
1468 locations.push(location);
1469 }
1470 }
1471 Ok(locations)
1472 }
1473
1474 fn create_cycle_count(&self, input: CreateCycleCount) -> Result<CycleCount> {
1479 input.validate()?;
1480 let conn = self.conn()?;
1481 let id = Uuid::new_v4();
1482 let now = Utc::now().to_rfc3339();
1483
1484 conn.execute(
1485 "INSERT INTO cycle_counts (id, warehouse_id, location_id, status, scheduled_date, counted_by, created_at, updated_at)
1486 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
1487 params![
1488 id.to_string(),
1489 input.warehouse_id,
1490 input.location_id,
1491 CycleCountStatus::Draft.to_string(),
1492 input.scheduled_date.map(|d| d.to_rfc3339()),
1493 input.counted_by,
1494 now,
1495 ],
1496 )
1497 .map_err(map_db_error)?;
1498
1499 for line in &input.lines {
1500 conn.execute(
1501 "INSERT INTO cycle_count_lines (id, cycle_count_id, sku, lot_id, expected_quantity)
1502 VALUES (?1, ?2, ?3, ?4, ?5)",
1503 params![
1504 Uuid::new_v4().to_string(),
1505 id.to_string(),
1506 line.sku,
1507 line.lot_id.map(|l| l.to_string()).unwrap_or_default(),
1508 line.expected_quantity.to_string(),
1509 ],
1510 )
1511 .map_err(map_db_error)?;
1512 }
1513
1514 Self::get_cycle_count_on(&conn, id)?.ok_or_else(|| {
1515 CommerceError::DatabaseError("Failed to retrieve created cycle count".into())
1516 })
1517 }
1518
1519 fn get_cycle_count(&self, id: Uuid) -> Result<Option<CycleCount>> {
1520 let conn = self.conn()?;
1521 Self::get_cycle_count_on(&conn, id)
1522 }
1523
1524 fn list_cycle_counts(&self, filter: CycleCountFilter) -> Result<Vec<CycleCount>> {
1525 let conn = self.conn()?;
1526 let mut sql = "SELECT * FROM cycle_counts WHERE 1=1".to_string();
1527 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1528
1529 if let Some(warehouse_id) = filter.warehouse_id {
1530 sql.push_str(" AND warehouse_id = ?");
1531 params_vec.push(Box::new(warehouse_id));
1532 }
1533 if let Some(location_id) = filter.location_id {
1534 sql.push_str(" AND location_id = ?");
1535 params_vec.push(Box::new(location_id));
1536 }
1537 if let Some(status) = filter.status {
1538 sql.push_str(" AND status = ?");
1539 params_vec.push(Box::new(status.to_string()));
1540 }
1541
1542 if let Some((cursor_created, cursor_id)) = &filter.after_cursor {
1544 sql.push_str(" AND (created_at < ? OR (created_at = ? AND id < ?))");
1545 params_vec.push(Box::new(cursor_created.clone()));
1546 params_vec.push(Box::new(cursor_created.clone()));
1547 params_vec.push(Box::new(cursor_id.clone()));
1548 }
1549 sql.push_str(" ORDER BY created_at DESC, id DESC");
1550 let offset = if filter.after_cursor.is_none() { filter.offset } else { None };
1552 crate::sqlite::append_limit_offset(&mut sql, filter.limit, offset);
1553
1554 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1555 let params_refs: Vec<&dyn rusqlite::ToSql> =
1556 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1557 let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
1558
1559 let mut counts = Vec::new();
1560 while let Some(row) = rows.next().map_err(map_db_error)? {
1561 counts.push(Self::row_to_cycle_count_header(row).map_err(map_db_error)?);
1562 }
1563 drop(rows);
1564 drop(stmt);
1565
1566 let ids: Vec<Uuid> = counts.iter().map(|c| c.id).collect();
1568 let mut lines_by_id = Self::load_cycle_count_lines_batch(&conn, &ids)?;
1569 for cc in &mut counts {
1570 cc.lines = lines_by_id.remove(&cc.id).unwrap_or_default();
1571 }
1572 Ok(counts)
1573 }
1574
1575 fn start_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
1576 self.transition_cycle_count(id, CycleCountStatus::InProgress, false)
1577 }
1578
1579 fn record_cycle_counts(
1580 &self,
1581 id: Uuid,
1582 counts: Vec<RecordCycleCountLine>,
1583 ) -> Result<CycleCount> {
1584 for count in &counts {
1585 count.validate()?;
1586 }
1587 let conn = self.conn()?;
1588 let existing = Self::get_cycle_count_on(&conn, id)?.ok_or(CommerceError::NotFound)?;
1589 if existing.status != CycleCountStatus::InProgress {
1590 return Err(CommerceError::ValidationError(format!(
1591 "counts can only be recorded on an in_progress cycle count (status: {})",
1592 existing.status
1593 )));
1594 }
1595
1596 let now = Utc::now().to_rfc3339();
1597 for count in &counts {
1598 let lot_key = count.lot_id.map(|l| l.to_string()).unwrap_or_default();
1599 let line = existing
1600 .lines
1601 .iter()
1602 .find(|l| l.sku == count.sku && l.lot_id == count.lot_id)
1603 .ok_or_else(|| {
1604 CommerceError::ValidationError(format!(
1605 "cycle count has no line for sku {} (lot: {lot_key:?})",
1606 count.sku
1607 ))
1608 })?;
1609 let variance = count.counted_quantity - line.expected_quantity;
1610 conn.execute(
1611 "UPDATE cycle_count_lines SET counted_quantity = ?1, variance = ?2
1612 WHERE cycle_count_id = ?3 AND sku = ?4 AND lot_id = ?5",
1613 params![
1614 count.counted_quantity.to_string(),
1615 variance.to_string(),
1616 id.to_string(),
1617 count.sku,
1618 lot_key,
1619 ],
1620 )
1621 .map_err(map_db_error)?;
1622 }
1623 conn.execute(
1624 "UPDATE cycle_counts SET updated_at = ?1 WHERE id = ?2",
1625 params![now, id.to_string()],
1626 )
1627 .map_err(map_db_error)?;
1628
1629 Self::get_cycle_count_on(&conn, id)?.ok_or_else(|| {
1630 CommerceError::DatabaseError("Failed to retrieve updated cycle count".into())
1631 })
1632 }
1633
1634 fn complete_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
1635 let smuggle = |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
1636
1637 with_immediate_transaction(&self.pool, |tx| {
1640 let existing = Self::get_cycle_count_on(tx, id)
1641 .map_err(smuggle)?
1642 .ok_or_else(|| smuggle(CommerceError::NotFound))?;
1643 if !existing.status.can_transition_to(CycleCountStatus::Completed) {
1644 return Err(smuggle(CommerceError::ValidationError(format!(
1645 "cannot complete cycle count in status {}",
1646 existing.status
1647 ))));
1648 }
1649
1650 let now = Utc::now();
1651 let now_str = now.to_rfc3339();
1652
1653 for line in &existing.lines {
1654 let counted = line.counted_quantity.ok_or_else(|| {
1655 smuggle(CommerceError::ValidationError(format!(
1656 "cycle count line for sku {} has no recorded count",
1657 line.sku
1658 )))
1659 })?;
1660 let variance = counted - line.expected_quantity;
1661 tx.execute(
1663 "UPDATE cycle_count_lines SET variance = ?1 WHERE id = ?2",
1664 params![variance.to_string(), line.id.to_string()],
1665 )?;
1666 if variance == Decimal::ZERO {
1667 continue;
1668 }
1669 let location_id = existing.location_id.ok_or_else(|| {
1670 smuggle(CommerceError::ValidationError(
1671 "cycle count has no location_id; cannot apply variance adjustments".into(),
1672 ))
1673 })?;
1674 let lot_key = line.lot_id.map(|l| l.to_string()).unwrap_or_default();
1675
1676 let current: Option<String> = tx
1678 .query_row(
1679 "SELECT quantity_on_hand FROM location_inventory
1680 WHERE location_id = ?1 AND sku = ?2 AND COALESCE(lot_id, '') = ?3",
1681 params![location_id, line.sku, lot_key],
1682 |row| row.get(0),
1683 )
1684 .ok();
1685 if let Some(oh_str) = current {
1686 let on_hand =
1687 parse_decimal_strict(&oh_str, "location_inventory", "quantity_on_hand")
1688 .map_err(smuggle)?;
1689 let new_qty = on_hand + variance;
1690 if new_qty < Decimal::ZERO {
1691 return Err(smuggle(CommerceError::ValidationError(format!(
1692 "cycle count adjustment for sku {} would result in negative inventory",
1693 line.sku
1694 ))));
1695 }
1696 tx.execute(
1697 "UPDATE location_inventory SET quantity_on_hand = ?1, updated_at = ?2
1698 WHERE location_id = ?3 AND sku = ?4 AND COALESCE(lot_id, '') = ?5",
1699 params![new_qty.to_string(), now_str, location_id, line.sku, lot_key],
1700 )?;
1701 } else {
1702 if variance < Decimal::ZERO {
1703 return Err(smuggle(CommerceError::ValidationError(format!(
1704 "cycle count adjustment for sku {} would result in negative inventory",
1705 line.sku
1706 ))));
1707 }
1708 tx.execute(
1709 "INSERT INTO location_inventory (location_id, sku, lot_id, quantity_on_hand, quantity_reserved, updated_at)
1710 VALUES (?1, ?2, ?3, ?4, '0', ?5)",
1711 params![location_id, line.sku, lot_key, variance.to_string(), now_str],
1712 )?;
1713 }
1714
1715 tx.execute(
1717 "INSERT INTO inventory_movements (id, movement_type, to_location_id, sku, lot_id, quantity, reference_type, reference_id, reason, performed_by, created_at)
1718 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1719 params![
1720 Uuid::new_v4().to_string(),
1721 MovementType::CycleCount.to_string(),
1722 location_id,
1723 line.sku,
1724 line.lot_id.map(|l| l.to_string()),
1725 variance.to_string(),
1726 "cycle_count",
1727 existing.id.to_string(),
1728 "Cycle count variance",
1729 existing.counted_by.as_deref(),
1730 now_str,
1731 ],
1732 )?;
1733 }
1734
1735 tx.execute(
1736 "UPDATE cycle_counts SET status = ?1, updated_at = ?2, completed_at = ?2 WHERE id = ?3",
1737 params![CycleCountStatus::Completed.to_string(), now_str, id.to_string()],
1738 )?;
1739
1740 Self::get_cycle_count_on(tx, id).map_err(smuggle)?.ok_or_else(|| {
1741 smuggle(CommerceError::DatabaseError(
1742 "Failed to retrieve completed cycle count".into(),
1743 ))
1744 })
1745 })
1746 }
1747
1748 fn cancel_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
1749 self.transition_cycle_count(id, CycleCountStatus::Cancelled, false)
1750 }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755 use super::*;
1756 use crate::SqliteDatabase;
1757 use rust_decimal_macros::dec;
1758 use stateset_core::{
1759 AdjustLocationInventory, CreateLocation, CreateWarehouse, CreateZone, LocationFilter,
1760 LocationType, MoveInventory, UpdateLocation, UpdateWarehouse, WarehouseAddress,
1761 WarehouseFilter, WarehouseRepository, WarehouseType,
1762 };
1763
1764 fn fresh_repo() -> SqliteWarehouseRepository {
1765 SqliteDatabase::in_memory().expect("in-memory").warehouse()
1766 }
1767
1768 fn addr() -> WarehouseAddress {
1769 WarehouseAddress {
1770 street1: "1 Test St".into(),
1771 street2: None,
1772 city: "Test City".into(),
1773 state: "TC".into(),
1774 postal_code: "00000".into(),
1775 country: "US".into(),
1776 phone: None,
1777 }
1778 }
1779
1780 fn make_wh(repo: &SqliteWarehouseRepository, code: &str) -> Warehouse {
1781 repo.create_warehouse(CreateWarehouse {
1782 code: code.into(),
1783 name: format!("WH {code}"),
1784 warehouse_type: WarehouseType::Distribution,
1785 address: addr(),
1786 timezone: Some("America/Los_Angeles".into()),
1787 })
1788 .expect("create warehouse")
1789 }
1790
1791 fn make_loc(repo: &SqliteWarehouseRepository, wh_id: i32, code: &str) -> Location {
1792 repo.create_location(CreateLocation {
1793 warehouse_id: wh_id,
1794 code: Some(code.into()),
1795 location_type: LocationType::Bulk,
1796 zone: Some("A".into()),
1797 aisle: Some("01".into()),
1798 rack: None,
1799 level: None,
1800 bin: None,
1801 max_weight_kg: Some(dec!(500)),
1802 max_volume_m3: Some(dec!(2.5)),
1803 is_pickable: Some(true),
1804 is_receivable: Some(true),
1805 })
1806 .expect("create location")
1807 }
1808
1809 fn seed_inventory(repo: &SqliteWarehouseRepository, location_id: i32, sku: &str, qty: &str) {
1811 let conn = repo.conn().expect("conn");
1812 conn.execute(
1813 "INSERT INTO location_inventory
1814 (location_id, sku, lot_id, quantity_on_hand, quantity_reserved, updated_at)
1815 VALUES (?1, ?2, '', ?3, '0', datetime('now'))",
1816 params![location_id, sku, qty],
1817 )
1818 .expect("seed inventory");
1819 }
1820
1821 #[test]
1822 fn cycle_count_flow_applies_variance_adjustment_exactly() {
1823 use stateset_core::{
1824 CreateCycleCount, CreateCycleCountLine, CycleCountStatus, MovementFilter, MovementType,
1825 RecordCycleCountLine,
1826 };
1827
1828 let repo = fresh_repo();
1829 let wh = make_wh(&repo, "WH-CC");
1830 let loc = make_loc(&repo, wh.id, "L-CC");
1831 seed_inventory(&repo, loc.id, "CC-SKU", "10");
1832
1833 let cc = repo
1834 .create_cycle_count(CreateCycleCount {
1835 warehouse_id: wh.id,
1836 location_id: Some(loc.id),
1837 scheduled_date: None,
1838 counted_by: Some("counter".into()),
1839 lines: vec![CreateCycleCountLine {
1840 sku: "CC-SKU".into(),
1841 lot_id: None,
1842 expected_quantity: dec!(10),
1843 }],
1844 })
1845 .expect("create cycle count");
1846 assert_eq!(cc.status, CycleCountStatus::Draft);
1847 assert_eq!(cc.lines.len(), 1);
1848
1849 assert!(repo.complete_cycle_count(cc.id).is_err());
1851
1852 let cc = repo.start_cycle_count(cc.id).expect("start");
1853 assert_eq!(cc.status, CycleCountStatus::InProgress);
1854
1855 let cc = repo
1857 .record_cycle_counts(
1858 cc.id,
1859 vec![RecordCycleCountLine {
1860 sku: "CC-SKU".into(),
1861 lot_id: None,
1862 counted_quantity: dec!(7.5),
1863 }],
1864 )
1865 .expect("record counts");
1866 assert_eq!(cc.lines[0].counted_quantity, Some(dec!(7.5)));
1867 assert_eq!(cc.lines[0].variance, Some(dec!(-2.5)));
1868
1869 let cc = repo.complete_cycle_count(cc.id).expect("complete");
1870 assert_eq!(cc.status, CycleCountStatus::Completed);
1871 assert!(cc.completed_at.is_some());
1872
1873 let inv = repo.get_location_inventory(loc.id).expect("inventory");
1875 let row = inv.iter().find(|i| i.sku == "CC-SKU").expect("row");
1876 assert_eq!(row.quantity_on_hand, dec!(7.5));
1877
1878 let movements = repo
1880 .get_movements(MovementFilter {
1881 movement_type: Some(MovementType::CycleCount),
1882 sku: Some("CC-SKU".into()),
1883 ..Default::default()
1884 })
1885 .expect("movements");
1886 assert_eq!(movements.len(), 1);
1887 assert_eq!(movements[0].quantity, dec!(-2.5));
1888 assert_eq!(movements[0].reference_id, Some(cc.id));
1889 assert_eq!(movements[0].reference_type.as_deref(), Some("cycle_count"));
1890
1891 assert!(repo.cancel_cycle_count(cc.id).is_err());
1893 assert!(repo.start_cycle_count(cc.id).is_err());
1894 }
1895
1896 #[test]
1897 fn cycle_count_cancel_applies_no_adjustments() {
1898 use stateset_core::{
1899 CreateCycleCount, CreateCycleCountLine, CycleCountFilter, CycleCountStatus,
1900 RecordCycleCountLine,
1901 };
1902
1903 let repo = fresh_repo();
1904 let wh = make_wh(&repo, "WH-CCX");
1905 let loc = make_loc(&repo, wh.id, "L-CCX");
1906 seed_inventory(&repo, loc.id, "CCX-SKU", "5");
1907
1908 let cc = repo
1909 .create_cycle_count(CreateCycleCount {
1910 warehouse_id: wh.id,
1911 location_id: Some(loc.id),
1912 scheduled_date: None,
1913 counted_by: None,
1914 lines: vec![CreateCycleCountLine {
1915 sku: "CCX-SKU".into(),
1916 lot_id: None,
1917 expected_quantity: dec!(5),
1918 }],
1919 })
1920 .expect("create");
1921 let cc = repo.start_cycle_count(cc.id).expect("start");
1922 repo.record_cycle_counts(
1923 cc.id,
1924 vec![RecordCycleCountLine {
1925 sku: "CCX-SKU".into(),
1926 lot_id: None,
1927 counted_quantity: dec!(2),
1928 }],
1929 )
1930 .expect("record");
1931
1932 let cc = repo.cancel_cycle_count(cc.id).expect("cancel");
1933 assert_eq!(cc.status, CycleCountStatus::Cancelled);
1934
1935 let inv = repo.get_location_inventory(loc.id).expect("inventory");
1937 assert_eq!(inv[0].quantity_on_hand, dec!(5));
1938
1939 let listed = repo
1941 .list_cycle_counts(CycleCountFilter {
1942 warehouse_id: Some(wh.id),
1943 status: Some(CycleCountStatus::Cancelled),
1944 ..Default::default()
1945 })
1946 .expect("list");
1947 assert_eq!(listed.len(), 1);
1948 assert_eq!(listed[0].id, cc.id);
1949 }
1950
1951 #[test]
1952 fn move_inventory_is_atomic_when_destination_write_fails() {
1953 let repo = fresh_repo();
1954 let wh = make_wh(&repo, "WH-ATOMIC");
1955 let src = make_loc(&repo, wh.id, "SRC");
1956 seed_inventory(&repo, src.id, "SKU-M", "10");
1957
1958 let result = repo.move_inventory(MoveInventory {
1962 from_location_id: src.id,
1963 to_location_id: 999_999,
1964 sku: "SKU-M".into(),
1965 lot_id: None,
1966 quantity: dec!(3),
1967 reason: None,
1968 performed_by: None,
1969 });
1970 assert!(result.is_err(), "move to a non-existent destination must fail");
1971
1972 let src_inv = repo.get_location_inventory(src.id).expect("inventory");
1973 let row = src_inv.iter().find(|i| i.sku == "SKU-M").expect("source inventory still exists");
1974 assert_eq!(
1975 row.quantity_on_hand,
1976 dec!(10),
1977 "source must not lose stock when a move fails partway"
1978 );
1979 }
1980
1981 #[test]
1982 fn pickable_locations_compare_on_hand_and_reserved_exactly() {
1983 let repo = fresh_repo();
1984 let wh = make_wh(&repo, "WH-EXACT");
1985 let loc = make_loc(&repo, wh.id, "L-EXACT");
1986 seed_inventory(&repo, loc.id, "PICK-X", "1.000000000000000001");
1989 {
1990 let conn = repo.conn().expect("conn");
1991 conn.execute(
1992 "UPDATE location_inventory SET quantity_reserved = '1'
1993 WHERE location_id = ?1 AND sku = 'PICK-X'",
1994 params![loc.id],
1995 )
1996 .expect("reserve");
1997 }
1998
1999 let pickable = repo.get_pickable_locations(wh.id, "PICK-X").expect("ok");
2000 assert_eq!(pickable.len(), 1, "1.000000000000000001 > 1 exactly");
2001 assert_eq!(pickable[0].id, loc.id);
2002
2003 {
2005 let conn = repo.conn().expect("conn");
2006 conn.execute(
2007 "UPDATE location_inventory SET quantity_reserved = quantity_on_hand
2008 WHERE location_id = ?1 AND sku = 'PICK-X'",
2009 params![loc.id],
2010 )
2011 .expect("reserve all");
2012 }
2013 let none = repo.get_pickable_locations(wh.id, "PICK-X").expect("ok");
2014 assert!(none.is_empty(), "fully reserved stock is not pickable");
2015 }
2016
2017 #[test]
2018 fn location_inventory_filters_zero_quantities_exactly() {
2019 let repo = fresh_repo();
2020 let wh = make_wh(&repo, "WH-ZQ");
2021 let loc = make_loc(&repo, wh.id, "L-ZQ");
2022 seed_inventory(&repo, loc.id, "ZQ-POS", "0.005");
2023 seed_inventory(&repo, loc.id, "ZQ-ZERO", "0.00");
2025
2026 let inv = repo.get_location_inventory(loc.id).expect("ok");
2027 assert_eq!(inv.len(), 1);
2028 assert_eq!(inv[0].sku, "ZQ-POS");
2029
2030 let for_sku = repo.get_inventory_for_sku(wh.id, "ZQ-POS").expect("ok");
2031 assert_eq!(for_sku.len(), 1);
2032
2033 let listed = repo
2034 .list_location_inventory(LocationInventoryFilter {
2035 location_id: Some(loc.id),
2036 has_quantity: Some(true),
2037 limit: Some(1),
2038 ..Default::default()
2039 })
2040 .expect("ok");
2041 assert_eq!(listed.len(), 1, "limit must apply after the quantity filter");
2042 assert_eq!(listed[0].sku, "ZQ-POS");
2043
2044 assert!(repo.delete_location(loc.id).is_err());
2046 {
2048 let conn = repo.conn().expect("conn");
2049 conn.execute(
2050 "UPDATE location_inventory SET quantity_on_hand = '0.000'
2051 WHERE location_id = ?1 AND sku = 'ZQ-POS'",
2052 params![loc.id],
2053 )
2054 .expect("zero out");
2055 }
2056 repo.delete_location(loc.id).expect("deletable once empty");
2057 }
2058
2059 #[test]
2060 fn create_warehouse_round_trips() {
2061 let repo = fresh_repo();
2062 let wh = make_wh(&repo, "WH-A");
2063 assert_eq!(wh.code, "WH-A");
2064 assert_eq!(wh.warehouse_type, WarehouseType::Distribution);
2065 assert!(wh.is_active);
2066
2067 let by_id = repo.get_warehouse(wh.id).expect("ok").expect("found");
2068 assert_eq!(by_id.id, wh.id);
2069 let by_code = repo.get_warehouse_by_code("WH-A").expect("ok").expect("found");
2070 assert_eq!(by_code.id, wh.id);
2071 assert!(repo.get_warehouse_by_code("missing").expect("ok").is_none());
2072 }
2073
2074 #[test]
2075 fn update_warehouse_changes_fields() {
2076 let repo = fresh_repo();
2077 let wh = make_wh(&repo, "WH-UP");
2078 let updated = repo
2079 .update_warehouse(
2080 wh.id,
2081 UpdateWarehouse {
2082 name: Some("Renamed Warehouse".into()),
2083 is_active: Some(false),
2084 ..Default::default()
2085 },
2086 )
2087 .expect("update");
2088 assert_eq!(updated.name, "Renamed Warehouse");
2089 assert!(!updated.is_active);
2090 }
2091
2092 #[test]
2093 fn list_warehouses_filters_by_active() {
2094 let repo = fresh_repo();
2095 let wh_active = make_wh(&repo, "WH-AC");
2096 let wh_inactive = make_wh(&repo, "WH-IN");
2097 repo.update_warehouse(
2098 wh_inactive.id,
2099 UpdateWarehouse { is_active: Some(false), ..Default::default() },
2100 )
2101 .expect("inactive");
2102
2103 let active = repo
2104 .list_warehouses(WarehouseFilter { is_active: Some(true), ..Default::default() })
2105 .expect("active");
2106 let inactive = repo
2107 .list_warehouses(WarehouseFilter { is_active: Some(false), ..Default::default() })
2108 .expect("inactive");
2109 assert!(active.iter().any(|w| w.id == wh_active.id));
2110 assert!(!active.iter().any(|w| w.id == wh_inactive.id));
2111 assert!(inactive.iter().any(|w| w.id == wh_inactive.id));
2112 }
2113
2114 #[test]
2115 fn delete_warehouse_soft_or_hard() {
2116 let repo = fresh_repo();
2117 let wh = make_wh(&repo, "WH-DEL");
2118 repo.delete_warehouse(wh.id).expect("delete");
2119 if let Some(found) = repo.get_warehouse(wh.id).expect("ok") {
2121 assert!(!found.is_active, "deleted warehouse should be inactive");
2122 }
2123 }
2124
2125 #[test]
2126 fn create_zone_round_trips() {
2127 let repo = fresh_repo();
2128 let wh = make_wh(&repo, "WH-Z");
2129 let zone = repo
2130 .create_zone(CreateZone {
2131 warehouse_id: wh.id,
2132 code: "PICK-A".into(),
2133 name: "Pick Aisle A".into(),
2134 description: Some("Fast-moving SKUs".into()),
2135 })
2136 .expect("create zone");
2137 assert_eq!(zone.code, "PICK-A");
2138
2139 let by_id = repo.get_zone(zone.id).expect("ok").expect("found");
2140 assert_eq!(by_id.id, zone.id);
2141
2142 let zones = repo.get_zones(wh.id).expect("zones");
2143 assert_eq!(zones.len(), 1);
2144 }
2145
2146 #[test]
2147 fn create_location_with_explicit_code_round_trips() {
2148 let repo = fresh_repo();
2149 let wh = make_wh(&repo, "WH-L");
2150 let loc = make_loc(&repo, wh.id, "A-01-01-001");
2151 assert_eq!(loc.warehouse_id, wh.id);
2152 assert_eq!(loc.code, "A-01-01-001");
2153 assert!(loc.is_pickable);
2154
2155 let by_id = repo.get_location(loc.id).expect("ok").expect("found");
2156 assert_eq!(by_id.id, loc.id);
2157 let by_code = repo.get_location_by_code(wh.id, "A-01-01-001").expect("ok").expect("found");
2158 assert_eq!(by_code.id, loc.id);
2159 assert!(repo.get_location_by_code(wh.id, "missing").expect("ok").is_none());
2160 }
2161
2162 #[test]
2163 fn update_location_changes_pickable_flag() {
2164 let repo = fresh_repo();
2165 let wh = make_wh(&repo, "WH-UPD");
2166 let loc = make_loc(&repo, wh.id, "B-02");
2167 let updated = repo
2168 .update_location(
2169 loc.id,
2170 UpdateLocation { is_pickable: Some(false), ..Default::default() },
2171 )
2172 .expect("update");
2173 assert!(!updated.is_pickable);
2174 }
2175
2176 #[test]
2177 fn list_locations_filters_by_warehouse_and_pickable() {
2178 let repo = fresh_repo();
2179 let wh = make_wh(&repo, "WH-LL");
2180 let pickable = make_loc(&repo, wh.id, "P-1");
2181 let non_pickable = repo
2182 .create_location(CreateLocation {
2183 warehouse_id: wh.id,
2184 code: Some("NP-1".into()),
2185 location_type: LocationType::Quarantine,
2186 is_pickable: Some(false),
2187 is_receivable: Some(false),
2188 ..Default::default()
2189 })
2190 .expect("create np");
2191
2192 let pickable_filter = repo
2193 .list_locations(LocationFilter {
2194 warehouse_id: Some(wh.id),
2195 is_pickable: Some(true),
2196 ..Default::default()
2197 })
2198 .expect("pickable");
2199 assert!(pickable_filter.iter().any(|l| l.id == pickable.id));
2200 assert!(!pickable_filter.iter().any(|l| l.id == non_pickable.id));
2201 }
2202
2203 #[test]
2204 fn count_locations_honors_same_filters_as_list_locations() {
2205 let repo = fresh_repo();
2206 let wh = make_wh(&repo, "WH-CNT");
2207 repo.create_location(CreateLocation {
2209 warehouse_id: wh.id,
2210 code: Some("A-PK".into()),
2211 location_type: LocationType::Bulk,
2212 zone: Some("A".into()),
2213 aisle: Some("01".into()),
2214 is_pickable: Some(true),
2215 is_receivable: Some(true),
2216 ..Default::default()
2217 })
2218 .expect("loc a");
2219 repo.create_location(CreateLocation {
2221 warehouse_id: wh.id,
2222 code: Some("B-NPK".into()),
2223 location_type: LocationType::Quarantine,
2224 zone: Some("B".into()),
2225 aisle: Some("02".into()),
2226 is_pickable: Some(false),
2227 is_receivable: Some(false),
2228 ..Default::default()
2229 })
2230 .expect("loc b");
2231
2232 for filter in [
2235 LocationFilter {
2236 warehouse_id: Some(wh.id),
2237 is_pickable: Some(true),
2238 ..Default::default()
2239 },
2240 LocationFilter {
2241 warehouse_id: Some(wh.id),
2242 is_receivable: Some(true),
2243 ..Default::default()
2244 },
2245 LocationFilter {
2246 warehouse_id: Some(wh.id),
2247 zone: Some("A".into()),
2248 ..Default::default()
2249 },
2250 LocationFilter {
2251 warehouse_id: Some(wh.id),
2252 aisle: Some("01".into()),
2253 ..Default::default()
2254 },
2255 ] {
2256 let listed = repo.list_locations(filter.clone()).expect("list").len() as u64;
2257 let counted = repo.count_locations(filter).expect("count");
2258 assert_eq!(counted, listed, "count_locations must match list_locations for {counted}");
2259 assert_eq!(counted, 1, "exactly one location should match");
2260 }
2261 }
2262
2263 #[test]
2264 fn get_locations_for_warehouse_returns_all() {
2265 let repo = fresh_repo();
2266 let wh = make_wh(&repo, "WH-ALL");
2267 make_loc(&repo, wh.id, "L1");
2268 make_loc(&repo, wh.id, "L2");
2269 let locs = repo.get_locations_for_warehouse(wh.id).expect("ok");
2270 assert_eq!(locs.len(), 2);
2271 }
2272
2273 #[test]
2274 fn get_locations_for_warehouse_includes_inactive() {
2275 let repo = fresh_repo();
2276 let wh = make_wh(&repo, "WH-INACT");
2277 let active = make_loc(&repo, wh.id, "ACT");
2278 let inactive = make_loc(&repo, wh.id, "INACT");
2279 repo.update_location(
2280 inactive.id,
2281 UpdateLocation { is_active: Some(false), ..Default::default() },
2282 )
2283 .expect("deactivate");
2284
2285 let locs = repo.get_locations_for_warehouse(wh.id).expect("ok");
2289 let ids: Vec<i32> = locs.iter().map(|l| l.id).collect();
2290 assert_eq!(locs.len(), 2, "must return active AND inactive locations: {ids:?}");
2291 assert!(ids.contains(&active.id));
2292 assert!(ids.contains(&inactive.id));
2293 }
2294
2295 #[test]
2296 fn get_pickable_locations_excludes_non_pickable() {
2297 let repo = fresh_repo();
2298 let wh = make_wh(&repo, "WH-PK");
2299 make_loc(&repo, wh.id, "PK-1");
2300 repo.create_location(CreateLocation {
2301 warehouse_id: wh.id,
2302 code: Some("NPK-1".into()),
2303 location_type: LocationType::Quarantine,
2304 is_pickable: Some(false),
2305 is_receivable: Some(false),
2306 ..Default::default()
2307 })
2308 .expect("npk");
2309
2310 let pickable = repo.get_pickable_locations(wh.id, "ANY-SKU").expect("ok");
2311 assert!(pickable.iter().all(|l| l.is_pickable));
2312 }
2313
2314 #[test]
2315 fn get_receivable_locations_only_returns_receivable() {
2316 let repo = fresh_repo();
2317 let wh = make_wh(&repo, "WH-RC");
2318 let recv = make_loc(&repo, wh.id, "RCV-1");
2319 let _non_recv = repo
2320 .create_location(CreateLocation {
2321 warehouse_id: wh.id,
2322 code: Some("NRCV-1".into()),
2323 location_type: LocationType::Quarantine,
2324 is_pickable: Some(false),
2325 is_receivable: Some(false),
2326 ..Default::default()
2327 })
2328 .expect("nrcv");
2329 let receivable = repo.get_receivable_locations(wh.id).expect("ok");
2330 assert!(receivable.iter().any(|l| l.id == recv.id));
2331 assert!(receivable.iter().all(|l| l.is_receivable));
2332 }
2333
2334 #[test]
2335 fn get_warehouse_unknown_returns_none() {
2336 let repo = fresh_repo();
2337 assert!(repo.get_warehouse(99_999).expect("ok").is_none());
2338 }
2339
2340 #[test]
2341 fn get_location_unknown_returns_none() {
2342 let repo = fresh_repo();
2343 assert!(repo.get_location(99_999).expect("ok").is_none());
2344 }
2345
2346 #[test]
2347 fn move_into_existing_dest_keeps_quantity_exact() {
2348 let repo = fresh_repo();
2353 let wh = make_wh(&repo, "WH-MV");
2354 let src = make_loc(&repo, wh.id, "SRC-1");
2355 let dst = make_loc(&repo, wh.id, "DST-1");
2356 let lot = Uuid::new_v4();
2357
2358 repo.adjust_inventory(AdjustLocationInventory {
2360 location_id: src.id,
2361 sku: "SKU-1".into(),
2362 lot_id: Some(lot),
2363 quantity: dec!(10),
2364 reason: "seed source".into(),
2365 reference_type: None,
2366 reference_id: None,
2367 performed_by: None,
2368 })
2369 .expect("seed source");
2370
2371 repo.move_inventory(MoveInventory {
2373 from_location_id: src.id,
2374 to_location_id: dst.id,
2375 sku: "SKU-1".into(),
2376 lot_id: Some(lot),
2377 quantity: dec!(0.1),
2378 reason: None,
2379 performed_by: None,
2380 })
2381 .expect("move 1");
2382
2383 repo.move_inventory(MoveInventory {
2385 from_location_id: src.id,
2386 to_location_id: dst.id,
2387 sku: "SKU-1".into(),
2388 lot_id: Some(lot),
2389 quantity: dec!(0.2),
2390 reason: None,
2391 performed_by: None,
2392 })
2393 .expect("move 2");
2394
2395 let dst_inv = repo.get_location_inventory(dst.id).expect("dest inventory");
2396 let on_hand = dst_inv
2397 .iter()
2398 .find(|i| i.sku == "SKU-1")
2399 .map(|i| i.quantity_on_hand)
2400 .expect("dest row present");
2401 assert_eq!(on_hand, dec!(0.3));
2402 }
2403
2404 #[test]
2405 fn list_cycle_counts_batched_line_loading_preserves_per_count_lines() {
2406 use stateset_core::{CreateCycleCount, CreateCycleCountLine, CycleCountFilter};
2407
2408 let repo = fresh_repo();
2409 let wh = make_wh(&repo, "WH-BATCH");
2410 let line = |sku: &str| CreateCycleCountLine {
2411 sku: sku.into(),
2412 lot_id: None,
2413 expected_quantity: dec!(1),
2414 };
2415 for lines in [
2417 vec![line("BAT-A1"), line("BAT-A2")],
2418 vec![line("BAT-B1")],
2419 vec![line("BAT-C1"), line("BAT-C2"), line("BAT-C3")],
2420 ] {
2421 repo.create_cycle_count(CreateCycleCount {
2422 warehouse_id: wh.id,
2423 location_id: None,
2424 scheduled_date: None,
2425 counted_by: None,
2426 lines,
2427 })
2428 .expect("create cycle count");
2429 }
2430
2431 let listed = repo.list_cycle_counts(CycleCountFilter::default()).expect("list");
2432 assert_eq!(listed.len(), 3);
2433 for cc in &listed {
2434 let direct = repo.get_cycle_count(cc.id).expect("get").expect("present").lines;
2435 let listed_skus: Vec<_> = cc.lines.iter().map(|l| l.sku.clone()).collect();
2436 let direct_skus: Vec<_> = direct.iter().map(|l| l.sku.clone()).collect();
2437 assert_eq!(listed_skus, direct_skus, "lines must match for cycle count {}", cc.id);
2438 assert!(
2439 cc.lines.iter().all(|l| l.cycle_count_id == cc.id),
2440 "lines must belong to their own cycle count"
2441 );
2442 }
2443 }
2444
2445 #[test]
2446 fn list_cycle_counts_after_cursor_paginates_without_overlap() {
2447 use stateset_core::{CreateCycleCount, CreateCycleCountLine, CycleCountFilter};
2448
2449 let repo = fresh_repo();
2450 let wh = make_wh(&repo, "WH-CURSOR");
2451 for i in 0..3 {
2452 repo.create_cycle_count(CreateCycleCount {
2453 warehouse_id: wh.id,
2454 location_id: None,
2455 scheduled_date: None,
2456 counted_by: None,
2457 lines: vec![CreateCycleCountLine {
2458 sku: format!("CUR-SKU-{i}"),
2459 lot_id: None,
2460 expected_quantity: dec!(1),
2461 }],
2462 })
2463 .expect("create cycle count");
2464 }
2465
2466 let all = repo.list_cycle_counts(CycleCountFilter::default()).expect("list all");
2467 assert_eq!(all.len(), 3);
2468
2469 let first_page = repo
2470 .list_cycle_counts(CycleCountFilter { limit: Some(2), ..Default::default() })
2471 .expect("page 1");
2472 assert_eq!(first_page.len(), 2);
2473 assert_eq!(first_page[0].id, all[0].id);
2474
2475 let last = &first_page[1];
2476 let second_page = repo
2477 .list_cycle_counts(CycleCountFilter {
2478 after_cursor: Some((last.created_at.to_rfc3339(), last.id.to_string())),
2479 ..Default::default()
2480 })
2481 .expect("page 2");
2482 assert_eq!(second_page.len(), 1);
2483 assert_eq!(second_page[0].id, all[2].id);
2484 }
2485}