Skip to main content

stateset_core/models/
cycle_count.rs

1//! Cycle count domain models
2//!
3//! A cycle count is a scheduled physical inventory count of a warehouse (or a
4//! single location within it). Counts move through a small state machine
5//! (`draft` → `in_progress` → `completed` / `cancelled`); completing a count
6//! computes per-line variances and applies matching inventory adjustments.
7
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use strum::{Display, EnumString};
12use uuid::Uuid;
13
14use crate::{CommerceError, Result};
15
16// ============================================================================
17// Status
18// ============================================================================
19
20/// Status of a cycle count
21#[derive(
22    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
23)]
24#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum CycleCountStatus {
28    /// Created but counting has not started
29    #[default]
30    Draft,
31    /// Counting is underway
32    InProgress,
33    /// Count finished; variances applied
34    Completed,
35    /// Count abandoned; no adjustments applied
36    Cancelled,
37}
38
39impl CycleCountStatus {
40    /// Returns true if a transition from `self` to `next` is allowed.
41    #[must_use]
42    pub const fn can_transition_to(self, next: Self) -> bool {
43        matches!(
44            (self, next),
45            (Self::Draft, Self::InProgress | Self::Cancelled)
46                | (Self::InProgress, Self::Completed | Self::Cancelled)
47        )
48    }
49
50    /// Returns true if this status is terminal (no further transitions).
51    #[must_use]
52    pub const fn is_terminal(self) -> bool {
53        matches!(self, Self::Completed | Self::Cancelled)
54    }
55}
56
57// ============================================================================
58// Cycle count + lines
59// ============================================================================
60
61/// A cycle count header
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub struct CycleCount {
65    pub id: Uuid,
66    pub warehouse_id: i32,
67    /// Optional single location scope; `None` counts across the warehouse.
68    pub location_id: Option<i32>,
69    pub status: CycleCountStatus,
70    pub scheduled_date: Option<DateTime<Utc>>,
71    pub counted_by: Option<String>,
72    pub lines: Vec<CycleCountLine>,
73    pub created_at: DateTime<Utc>,
74    pub updated_at: DateTime<Utc>,
75    pub completed_at: Option<DateTime<Utc>>,
76}
77
78/// A single SKU line on a cycle count
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub struct CycleCountLine {
82    pub id: Uuid,
83    pub cycle_count_id: Uuid,
84    pub sku: String,
85    pub lot_id: Option<Uuid>,
86    /// System quantity expected at count time.
87    pub expected_quantity: Decimal,
88    /// Physically counted quantity (recorded while in progress).
89    pub counted_quantity: Option<Decimal>,
90    /// `counted_quantity - expected_quantity`, set once counted.
91    pub variance: Option<Decimal>,
92}
93
94// ============================================================================
95// DTOs
96// ============================================================================
97
98/// Input line for creating a cycle count
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100#[serde(rename_all = "snake_case")]
101pub struct CreateCycleCountLine {
102    pub sku: String,
103    pub lot_id: Option<Uuid>,
104    pub expected_quantity: Decimal,
105}
106
107/// Input for creating a cycle count
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109#[serde(rename_all = "snake_case")]
110pub struct CreateCycleCount {
111    pub warehouse_id: i32,
112    pub location_id: Option<i32>,
113    pub scheduled_date: Option<DateTime<Utc>>,
114    pub counted_by: Option<String>,
115    pub lines: Vec<CreateCycleCountLine>,
116}
117
118impl CreateCycleCount {
119    /// Validate the input before persisting.
120    ///
121    /// # Errors
122    /// Returns [`CommerceError::ValidationError`] when the warehouse id is not
123    /// positive, no lines are supplied, a line has an empty SKU, or a line has
124    /// a negative expected quantity.
125    pub fn validate(&self) -> Result<()> {
126        if self.warehouse_id <= 0 {
127            return Err(CommerceError::ValidationError(
128                "cycle count warehouse_id must be positive".into(),
129            ));
130        }
131        if self.lines.is_empty() {
132            return Err(CommerceError::ValidationError(
133                "cycle count requires at least one line".into(),
134            ));
135        }
136        for (i, line) in self.lines.iter().enumerate() {
137            if line.sku.trim().is_empty() {
138                return Err(CommerceError::ValidationError(format!(
139                    "cycle count line {i} has an empty sku"
140                )));
141            }
142            if line.expected_quantity < Decimal::ZERO {
143                return Err(CommerceError::ValidationError(format!(
144                    "cycle count line {i} has a negative expected_quantity"
145                )));
146            }
147        }
148        Ok(())
149    }
150}
151
152/// Input for updating a draft cycle count
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154#[serde(rename_all = "snake_case")]
155pub struct UpdateCycleCount {
156    pub scheduled_date: Option<DateTime<Utc>>,
157    pub counted_by: Option<String>,
158}
159
160/// A recorded physical count for one line
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub struct RecordCycleCountLine {
164    pub sku: String,
165    pub lot_id: Option<Uuid>,
166    pub counted_quantity: Decimal,
167}
168
169impl RecordCycleCountLine {
170    /// Validate a recorded count.
171    ///
172    /// # Errors
173    /// Returns [`CommerceError::ValidationError`] on an empty SKU or negative
174    /// counted quantity.
175    pub fn validate(&self) -> Result<()> {
176        if self.sku.trim().is_empty() {
177            return Err(CommerceError::ValidationError("recorded count has an empty sku".into()));
178        }
179        if self.counted_quantity < Decimal::ZERO {
180            return Err(CommerceError::ValidationError(
181                "counted_quantity cannot be negative".into(),
182            ));
183        }
184        Ok(())
185    }
186}
187
188/// Filter for listing cycle counts
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub struct CycleCountFilter {
192    pub warehouse_id: Option<i32>,
193    pub location_id: Option<i32>,
194    pub status: Option<CycleCountStatus>,
195    pub limit: Option<u32>,
196    pub offset: Option<u32>,
197    /// Keyset cursor: return records after this `(sort_key, id)` pair.
198    /// Sort key is `created_at` (DESC ordering).
199    pub after_cursor: Option<(String, String)>,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use rust_decimal_macros::dec;
206    use std::str::FromStr;
207
208    #[test]
209    fn status_transitions() {
210        use CycleCountStatus as S;
211        assert!(S::Draft.can_transition_to(S::InProgress));
212        assert!(S::Draft.can_transition_to(S::Cancelled));
213        assert!(S::InProgress.can_transition_to(S::Completed));
214        assert!(S::InProgress.can_transition_to(S::Cancelled));
215        // Invalid transitions
216        assert!(!S::Draft.can_transition_to(S::Completed));
217        assert!(!S::Draft.can_transition_to(S::Draft));
218        assert!(!S::InProgress.can_transition_to(S::Draft));
219        assert!(!S::Completed.can_transition_to(S::Cancelled));
220        assert!(!S::Completed.can_transition_to(S::InProgress));
221        assert!(!S::Cancelled.can_transition_to(S::InProgress));
222        assert!(S::Completed.is_terminal());
223        assert!(S::Cancelled.is_terminal());
224        assert!(!S::Draft.is_terminal());
225        assert!(!S::InProgress.is_terminal());
226    }
227
228    #[test]
229    fn status_display_from_str_round_trip() {
230        for s in [
231            CycleCountStatus::Draft,
232            CycleCountStatus::InProgress,
233            CycleCountStatus::Completed,
234            CycleCountStatus::Cancelled,
235        ] {
236            assert_eq!(CycleCountStatus::from_str(&s.to_string()), Ok(s));
237        }
238        assert_eq!(CycleCountStatus::InProgress.to_string(), "in_progress");
239        assert_eq!(CycleCountStatus::from_str("IN_PROGRESS"), Ok(CycleCountStatus::InProgress));
240        assert!(CycleCountStatus::from_str("counting").is_err());
241    }
242
243    #[test]
244    fn create_validate_rejects_bad_input() {
245        let ok = CreateCycleCount {
246            warehouse_id: 1,
247            lines: vec![CreateCycleCountLine {
248                sku: "SKU-1".into(),
249                lot_id: None,
250                expected_quantity: dec!(5),
251            }],
252            ..Default::default()
253        };
254        assert!(ok.validate().is_ok());
255
256        let mut bad = ok.clone();
257        bad.warehouse_id = 0;
258        assert!(bad.validate().is_err());
259
260        let mut bad = ok.clone();
261        bad.lines.clear();
262        assert!(bad.validate().is_err());
263
264        let mut bad = ok.clone();
265        bad.lines[0].sku = "  ".into();
266        assert!(bad.validate().is_err());
267
268        let mut bad = ok;
269        bad.lines[0].expected_quantity = dec!(-1);
270        assert!(bad.validate().is_err());
271    }
272
273    #[test]
274    fn record_line_validate() {
275        let ok =
276            RecordCycleCountLine { sku: "SKU-1".into(), lot_id: None, counted_quantity: dec!(3) };
277        assert!(ok.validate().is_ok());
278        let mut bad = ok.clone();
279        bad.sku = String::new();
280        assert!(bad.validate().is_err());
281        let mut bad = ok;
282        bad.counted_quantity = dec!(-0.5);
283        assert!(bad.validate().is_err());
284    }
285}