1use 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#[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 #[default]
30 Draft,
31 InProgress,
33 Completed,
35 Cancelled,
37}
38
39impl CycleCountStatus {
40 #[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 #[must_use]
52 pub const fn is_terminal(self) -> bool {
53 matches!(self, Self::Completed | Self::Cancelled)
54 }
55}
56
57#[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 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#[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 pub expected_quantity: Decimal,
88 pub counted_quantity: Option<Decimal>,
90 pub variance: Option<Decimal>,
92}
93
94#[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#[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 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#[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#[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 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#[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 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 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}