Skip to main content

stateset_db/sqlite/
warehouse.rs

1//! SQLite implementation for warehouse management
2//!
3//! Provides full warehouse, zone, location, and inventory movement functionality.
4
5use 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/// SQLite warehouse repository
26#[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    // Helper to parse warehouse from row
42    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    // Helper to parse location from row
73    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    // Helper to parse location inventory from row
125    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    // Helper to parse inventory movement from row
157    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    // Helper to parse zone from row
195    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    // Helper to parse a cycle count header from a row (lines loaded separately)
212    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    // Helper to parse a cycle count line from a row. Lot-less lines store
244    // lot_id = '' (NOT NULL), matching the location_inventory convention.
245    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    /// Batch-load cycle count lines for many cycle counts in chunked
297    /// `IN`-clause queries, grouped by parent cycle count id. Uses the caller's connection.
298    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    /// Update a cycle count's status after verifying the transition is legal.
343    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    // Generate location code from parts
371    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    // ========================================================================
393    // Warehouse Operations
394    // ========================================================================
395
396    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        // Construct warehouse directly to avoid nested conn() call
420        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        // Get existing warehouse using the same connection
465        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        // Construct result directly to avoid nested conn() call
500        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        // Check if warehouse has locations
550        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    // ========================================================================
594    // Zone Operations
595    // ========================================================================
596
597    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    // ========================================================================
678    // Location Operations
679    // ========================================================================
680
681    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        // Check if location has inventory. `quantity_on_hand` is a TEXT
848        // decimal, so compare the exact parsed `Decimal`s in Rust instead of a
849        // float-coercing CAST(... AS REAL) in SQL.
850        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        // Honor the same predicates as `list_locations`, so `count_locations(f)`
887        // always equals `list_locations(f).len()` (and matches Postgres).
888        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        // Return ALL locations for the warehouse (active and inactive), matching
924        // the Postgres backend and the trait contract; callers that want only
925        // active/pickable/receivable locations use the dedicated accessors.
926        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        // `quantity_on_hand` and `quantity_reserved` are TEXT decimals;
935        // comparing them in SQL with CAST(... AS REAL) coerces both operands to
936        // IEEE-754 floats and can misclassify locations right at the boundary,
937        // so the availability check happens on exact `Decimal`s in Rust.
938        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    // ========================================================================
977    // Location Inventory Operations
978    // ========================================================================
979
980    fn get_location_inventory(&self, location_id: i32) -> Result<Vec<LocationInventory>> {
981        let conn = self.conn()?;
982        // `quantity_on_hand` is a TEXT decimal; the positive-quantity filter
983        // happens on the exact parsed `Decimal` rather than a float-coercing
984        // CAST(... AS REAL) in SQL.
985        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        // `quantity_on_hand` is a TEXT decimal; the positive-quantity filter
1009        // happens on the exact parsed `Decimal` rather than a float-coercing
1010        // CAST(... AS REAL) in SQL.
1011        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        // Try to get existing inventory
1041        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        // Record the movement
1094        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        // A move is a source-decrement + destination-increment + movement-insert.
1134        // Run all three atomically inside one IMMEDIATE (retrying) transaction so a
1135        // failure partway (e.g. a bad destination) can never leave stock destroyed
1136        // at the source with nothing credited to the destination — the Postgres
1137        // backend already wraps the move in a transaction. Domain errors are
1138        // carried out via `ToSqlConversionFailure` so they are not retried.
1139        let smuggle = |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
1140
1141        with_immediate_transaction(&self.pool, |tx| {
1142            // Get source inventory
1143            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            // Reduce source location
1172            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            // Increase destination location
1186            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                // `quantity_on_hand` is a TEXT column (migration 016), so adding in
1196                // SQL ('CAST(quantity_on_hand AS REAL) + ?1') would coerce both
1197                // operands to IEEE-754 floats ('0.1' + '0.2' = 0.30000000000000004).
1198                // Read the destination's current quantity, add with
1199                // `rust_decimal::Decimal` in Rust, and write the exact precomputed
1200                // string back as a bound parameter.
1201                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            // Record the movement
1237            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        // `quantity_on_hand` is a TEXT decimal, so the has_quantity filter is
1306        // applied below on the exact parsed `Decimal` (a SQL CAST(... AS REAL)
1307        // comparison coerces to IEEE-754 floats). LIMIT/OFFSET then also move
1308        // to Rust so pagination is applied after the filter, as before.
1309        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    // ========================================================================
1341    // Movement Operations
1342    // ========================================================================
1343
1344    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    // ========================================================================
1448    // Batch Operations
1449    // ========================================================================
1450
1451    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    // ========================================================================
1475    // Cycle Count Operations
1476    // ========================================================================
1477
1478    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        // Keyset cursor: (created_at, id) for stable DESC ordering
1543        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        // Offset pagination applies only in non-cursor mode.
1551        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        // Batch-load lines for all listed cycle counts on the same connection.
1567        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        // Completion applies every non-zero variance as an inventory adjustment
1638        // plus a `cycle_count` movement, then flips the status — all atomically.
1639        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                // Persist the final variance even when zero.
1662                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                // Adjust location_inventory (same path as adjust_inventory).
1677                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                // Record the movement as a cycle_count adjustment.
1716                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    /// Seed a `location_inventory` row directly with an exact TEXT quantity.
1810    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        // Cannot complete from draft.
1850        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        // Record a short count: 7.5 counted vs 10 expected → variance -2.5.
1856        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        // Adjustment applied exactly: 10 - 2.5 = 7.5 on hand.
1874        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        // A cycle_count movement was recorded referencing the count.
1879        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        // Terminal: cannot cancel or restart a completed count.
1892        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        // Inventory untouched.
1936        let inv = repo.get_location_inventory(loc.id).expect("inventory");
1937        assert_eq!(inv[0].quantity_on_hand, dec!(5));
1938
1939        // Filter by status finds it.
1940        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        // Move to a destination location id that does not exist. The source is
1959        // decremented first, then the destination write fails — the whole move
1960        // must roll back, leaving the source stock intact (never destroyed).
1961        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        // On hand exceeds reserved by 1e-18: both round to the same f64, so a
1987        // CAST-AS-REAL comparison would wrongly report nothing pickable.
1988        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        // Fully reserved stock must not be pickable.
2004        {
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        // A non-canonical zero, to make sure filtering is numeric.
2024        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        // Location still holds stock, so it cannot be deleted...
2045        assert!(repo.delete_location(loc.id).is_err());
2046        // ...but once the last positive quantity is zeroed out it can be.
2047        {
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        // Either gone entirely or marked inactive — both behaviours are acceptable.
2120        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        // Zone A: pickable + receivable.
2208        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        // Zone B: neither pickable nor receivable.
2220        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        // Every predicate `list_locations` honors, `count_locations` must honor
2233        // too — each of these filters must select exactly one location.
2234        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        // `get_locations_for_warehouse` returns ALL locations (matching Postgres
2286        // and the trait contract) — active AND inactive. Filtered subsets have
2287        // their own accessors (get_pickable_locations / get_receivable_locations).
2288        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        // Regression: quantity_on_hand is a TEXT column and the destination
2349        // receipt was mutated via 'CAST(quantity_on_hand AS REAL) + ?1', so
2350        // 0.1 + 0.2 stored as 0.30000000000000004. With Decimal arithmetic the
2351        // destination must hold exactly 0.3.
2352        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        // Seed the source with enough stock to move twice.
2359        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        // First move creates the destination row (0.1).
2372        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        // Second move hits the dest_exists branch: 0.1 + 0.2 must be exactly 0.3.
2384        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        // Distinct line sets per parent so cross-wiring would be caught.
2416        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}