1use chrono::{DateTime, Utc};
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use stateset_primitives::ProductId;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct BillOfMaterials {
22 pub id: Uuid,
23 pub bom_number: String,
25 pub product_id: ProductId,
27 pub name: String,
29 pub description: Option<String>,
31 pub revision: String,
33 pub status: BomStatus,
35 pub components: Vec<BomComponent>,
37 pub created_by: Option<Uuid>,
39 pub updated_by: Option<Uuid>,
41 pub created_at: DateTime<Utc>,
42 pub updated_at: DateTime<Utc>,
43}
44
45impl BillOfMaterials {
46 #[must_use]
53 pub fn generate_bom_number() -> String {
54 let now = Utc::now();
55 let suffix = Uuid::new_v4().simple().to_string();
56 format!("BOM-{}-{}", now.format("%Y%m%d%H%M%S%3f"), &suffix[..8])
57 }
58
59 #[must_use]
61 pub fn total_component_count(&self) -> usize {
62 self.components.len()
63 }
64
65 #[must_use]
67 pub fn total_quantity(&self) -> Decimal {
68 self.components.iter().map(|c| c.quantity).sum()
69 }
70}
71
72#[derive(
74 Debug,
75 Clone,
76 Copy,
77 Serialize,
78 Deserialize,
79 PartialEq,
80 Eq,
81 strum::Display,
82 strum::EnumString,
83 Default,
84)]
85#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum BomStatus {
89 #[default]
91 Draft,
92 Active,
94 Obsolete,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct BomComponent {
101 pub id: Uuid,
102 pub bom_id: Uuid,
104 pub component_product_id: Option<ProductId>,
106 pub component_sku: Option<String>,
108 pub name: String,
110 pub quantity: Decimal,
112 pub unit_of_measure: String,
114 pub position: Option<String>,
116 pub notes: Option<String>,
118 pub created_at: DateTime<Utc>,
119 pub updated_at: DateTime<Utc>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct CreateBom {
125 pub product_id: ProductId,
126 pub name: String,
127 pub description: Option<String>,
128 pub revision: Option<String>,
129 pub components: Option<Vec<CreateBomComponent>>,
130 pub created_by: Option<Uuid>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct CreateBomComponent {
136 pub component_product_id: Option<ProductId>,
137 pub component_sku: Option<String>,
138 pub name: String,
139 pub quantity: Decimal,
140 pub unit_of_measure: Option<String>,
141 pub position: Option<String>,
142 pub notes: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct UpdateBom {
148 pub name: Option<String>,
149 pub description: Option<String>,
150 pub revision: Option<String>,
151 pub status: Option<BomStatus>,
152 pub updated_by: Option<Uuid>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
157pub struct BomFilter {
158 pub product_id: Option<ProductId>,
159 pub status: Option<BomStatus>,
160 pub search: Option<String>,
161 pub limit: Option<u32>,
162 pub offset: Option<u32>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct WorkOrder {
172 pub id: Uuid,
173 pub work_order_number: String,
175 pub product_id: ProductId,
177 pub bom_id: Option<Uuid>,
179 pub work_center_id: Option<String>,
181 pub assigned_to: Option<Uuid>,
183 pub status: WorkOrderStatus,
185 pub priority: WorkOrderPriority,
187 pub quantity_to_build: Decimal,
189 pub quantity_completed: Decimal,
191 pub scheduled_start: Option<DateTime<Utc>>,
193 pub scheduled_end: Option<DateTime<Utc>>,
195 pub actual_start: Option<DateTime<Utc>>,
197 pub actual_end: Option<DateTime<Utc>>,
199 pub tasks: Vec<WorkOrderTask>,
201 pub materials: Vec<WorkOrderMaterial>,
203 pub notes: Option<String>,
205 pub version: i32,
207 pub created_at: DateTime<Utc>,
208 pub updated_at: DateTime<Utc>,
209}
210
211impl WorkOrder {
212 #[must_use]
214 pub fn generate_work_order_number() -> String {
215 let now = Utc::now();
216 let suffix = Uuid::new_v4().simple().to_string();
217 format!("WO-{}-{}", now.format("%Y%m%d%H%M%S%3f"), &suffix[..8])
218 }
219
220 #[must_use]
222 pub const fn can_start(&self) -> bool {
223 matches!(self.status, WorkOrderStatus::Planned | WorkOrderStatus::OnHold)
224 }
225
226 #[must_use]
228 pub fn can_complete(&self) -> bool {
229 matches!(self.status, WorkOrderStatus::InProgress)
230 && self.quantity_to_build > Decimal::ZERO
231 && self.quantity_completed >= Decimal::ZERO
232 && self.quantity_completed <= self.quantity_to_build
233 }
234
235 #[must_use]
237 pub fn completion_percentage(&self) -> Decimal {
238 if self.quantity_to_build.is_zero() {
239 Decimal::ZERO
240 } else {
241 let value = (self.quantity_completed / self.quantity_to_build) * Decimal::from(100);
242 value.clamp(Decimal::ZERO, Decimal::from(100))
243 }
244 }
245
246 #[must_use]
248 pub fn is_overdue(&self) -> bool {
249 if let Some(scheduled_end) = self.scheduled_end {
250 if !matches!(self.status, WorkOrderStatus::Completed | WorkOrderStatus::Cancelled) {
251 return Utc::now() > scheduled_end;
252 }
253 }
254 false
255 }
256}
257
258#[derive(
260 Debug,
261 Clone,
262 Copy,
263 Serialize,
264 Deserialize,
265 PartialEq,
266 Eq,
267 strum::Display,
268 strum::EnumString,
269 Default,
270)]
271#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
272#[serde(rename_all = "snake_case")]
273#[non_exhaustive]
274pub enum WorkOrderStatus {
275 #[default]
277 Planned,
278 #[strum(serialize = "in_progress", serialize = "inprogress")]
280 InProgress,
281 Completed,
283 #[strum(serialize = "partially_completed", serialize = "partiallycompleted")]
285 PartiallyCompleted,
286 #[strum(serialize = "cancelled", serialize = "canceled")]
288 Cancelled,
289 #[strum(serialize = "on_hold", serialize = "onhold")]
291 OnHold,
292}
293
294impl WorkOrderStatus {
295 #[must_use]
297 pub fn can_transition_to(self, next: Self) -> bool {
298 if self == next {
299 return true;
300 }
301 match self {
302 Self::Planned => matches!(next, Self::InProgress | Self::Cancelled),
303 Self::InProgress => matches!(
304 next,
305 Self::Completed | Self::PartiallyCompleted | Self::OnHold | Self::Cancelled
306 ),
307 Self::OnHold => matches!(next, Self::InProgress | Self::Cancelled),
308 Self::PartiallyCompleted => matches!(next, Self::InProgress | Self::Completed),
309 Self::Completed | Self::Cancelled => false,
310 }
311 }
312
313 #[must_use]
315 pub const fn is_terminal(self) -> bool {
316 matches!(self, Self::Completed | Self::Cancelled)
317 }
318}
319
320#[derive(
322 Debug,
323 Clone,
324 Copy,
325 Serialize,
326 Deserialize,
327 PartialEq,
328 Eq,
329 strum::Display,
330 strum::EnumString,
331 Default,
332)]
333#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
334#[serde(rename_all = "snake_case")]
335#[non_exhaustive]
336pub enum WorkOrderPriority {
337 Low,
339 #[default]
341 Normal,
342 High,
344 Urgent,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct CreateWorkOrder {
351 pub product_id: ProductId,
352 pub bom_id: Option<Uuid>,
353 pub work_center_id: Option<String>,
354 pub assigned_to: Option<Uuid>,
355 pub priority: Option<WorkOrderPriority>,
356 pub quantity_to_build: Decimal,
357 pub scheduled_start: Option<DateTime<Utc>>,
358 pub scheduled_end: Option<DateTime<Utc>>,
359 pub notes: Option<String>,
360 pub tasks: Option<Vec<CreateWorkOrderTask>>,
362}
363
364impl Default for CreateWorkOrder {
365 fn default() -> Self {
366 Self {
367 product_id: ProductId::nil(),
368 bom_id: None,
369 work_center_id: None,
370 assigned_to: None,
371 priority: None,
372 quantity_to_build: Decimal::ONE,
373 scheduled_start: None,
374 scheduled_end: None,
375 notes: None,
376 tasks: None,
377 }
378 }
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
383pub struct UpdateWorkOrder {
384 pub work_center_id: Option<String>,
385 pub assigned_to: Option<Uuid>,
386 pub status: Option<WorkOrderStatus>,
387 pub priority: Option<WorkOrderPriority>,
388 pub quantity_to_build: Option<Decimal>,
389 pub quantity_completed: Option<Decimal>,
390 pub scheduled_start: Option<DateTime<Utc>>,
391 pub scheduled_end: Option<DateTime<Utc>>,
392 pub actual_start: Option<DateTime<Utc>>,
393 pub actual_end: Option<DateTime<Utc>>,
394 pub notes: Option<String>,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, Default)]
399pub struct WorkOrderFilter {
400 pub product_id: Option<ProductId>,
401 pub bom_id: Option<Uuid>,
402 pub status: Option<WorkOrderStatus>,
403 pub priority: Option<WorkOrderPriority>,
404 pub assigned_to: Option<Uuid>,
405 pub work_center_id: Option<String>,
406 pub overdue_only: Option<bool>,
407 pub limit: Option<u32>,
408 pub offset: Option<u32>,
409 pub after_cursor: Option<(String, String)>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
420pub struct WorkOrderTask {
421 pub id: Uuid,
422 pub work_order_id: Uuid,
424 pub sequence: i32,
426 pub task_name: String,
428 pub status: TaskStatus,
430 pub estimated_hours: Option<Decimal>,
432 pub actual_hours: Option<Decimal>,
434 pub assigned_to: Option<Uuid>,
436 pub started_at: Option<DateTime<Utc>>,
438 pub completed_at: Option<DateTime<Utc>>,
440 pub notes: Option<String>,
442 pub created_at: DateTime<Utc>,
443 pub updated_at: DateTime<Utc>,
444}
445
446impl WorkOrderTask {
447 #[must_use]
449 pub const fn can_start(&self) -> bool {
450 matches!(self.status, TaskStatus::Pending)
451 }
452
453 #[must_use]
455 pub const fn can_complete(&self) -> bool {
456 matches!(self.status, TaskStatus::InProgress)
457 }
458
459 #[must_use]
461 pub fn efficiency(&self) -> Option<Decimal> {
462 match (self.estimated_hours, self.actual_hours) {
463 (Some(est), Some(act)) if !act.is_zero() => Some((est / act) * Decimal::from(100)),
464 _ => None,
465 }
466 }
467}
468
469#[derive(
471 Debug,
472 Clone,
473 Copy,
474 Serialize,
475 Deserialize,
476 PartialEq,
477 Eq,
478 strum::Display,
479 strum::EnumString,
480 Default,
481)]
482#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
483#[serde(rename_all = "snake_case")]
484#[non_exhaustive]
485pub enum TaskStatus {
486 #[default]
488 Pending,
489 #[strum(serialize = "in_progress", serialize = "inprogress")]
491 InProgress,
492 Completed,
494 Skipped,
496 #[strum(serialize = "cancelled", serialize = "canceled")]
498 Cancelled,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, Default)]
503pub struct CreateWorkOrderTask {
504 pub sequence: Option<i32>,
505 pub task_name: String,
506 pub estimated_hours: Option<Decimal>,
507 pub assigned_to: Option<Uuid>,
508 pub notes: Option<String>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, Default)]
513pub struct UpdateWorkOrderTask {
514 pub sequence: Option<i32>,
515 pub task_name: Option<String>,
516 pub status: Option<TaskStatus>,
517 pub estimated_hours: Option<Decimal>,
518 pub actual_hours: Option<Decimal>,
519 pub assigned_to: Option<Uuid>,
520 pub started_at: Option<DateTime<Utc>>,
521 pub completed_at: Option<DateTime<Utc>>,
522 pub notes: Option<String>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
531pub struct WorkOrderMaterial {
532 pub id: Uuid,
533 pub work_order_id: Uuid,
535 pub component_id: Option<Uuid>,
537 pub component_sku: String,
539 pub component_name: String,
541 pub reserved_quantity: Decimal,
543 pub consumed_quantity: Decimal,
545 pub inventory_reservation_id: Option<Uuid>,
547 pub created_at: DateTime<Utc>,
548 pub updated_at: DateTime<Utc>,
549}
550
551impl WorkOrderMaterial {
552 #[must_use]
554 pub fn remaining_reserved(&self) -> Decimal {
555 (self.reserved_quantity - self.consumed_quantity).max(Decimal::ZERO)
556 }
557
558 #[must_use]
560 pub fn is_fully_consumed(&self) -> bool {
561 self.consumed_quantity == self.reserved_quantity
562 }
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, Default)]
567pub struct AddWorkOrderMaterial {
568 pub component_id: Option<Uuid>,
569 pub component_sku: String,
570 pub component_name: String,
571 pub quantity: Decimal,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct ConsumeMaterial {
577 pub material_id: Uuid,
578 pub quantity: Decimal,
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use std::str::FromStr;
585
586 #[test]
587 fn test_work_order_completion_percentage() {
588 let wo = WorkOrder {
589 id: Uuid::new_v4(),
590 work_order_number: "WO-001".into(),
591 product_id: ProductId::new(),
592 bom_id: None,
593 work_center_id: None,
594 assigned_to: None,
595 status: WorkOrderStatus::InProgress,
596 priority: WorkOrderPriority::Normal,
597 quantity_to_build: Decimal::from(100),
598 quantity_completed: Decimal::from(25),
599 scheduled_start: None,
600 scheduled_end: None,
601 actual_start: None,
602 actual_end: None,
603 tasks: vec![],
604 materials: vec![],
605 notes: None,
606 version: 1,
607 created_at: Utc::now(),
608 updated_at: Utc::now(),
609 };
610
611 assert_eq!(wo.completion_percentage(), Decimal::from(25));
612 }
613
614 #[test]
615 fn test_bom_status_display() {
616 assert_eq!(BomStatus::Draft.to_string(), "draft");
617 assert_eq!(BomStatus::Active.to_string(), "active");
618 assert_eq!(BomStatus::Obsolete.to_string(), "obsolete");
619 }
620
621 #[test]
622 fn test_bom_status_from_str() {
623 assert_eq!(BomStatus::from_str("draft").unwrap(), BomStatus::Draft);
624 assert!(BomStatus::from_str("unknown").is_err());
625 }
626
627 #[test]
628 fn test_work_order_status_display() {
629 assert_eq!(WorkOrderStatus::Planned.to_string(), "planned");
630 assert_eq!(WorkOrderStatus::InProgress.to_string(), "in_progress");
631 assert_eq!(WorkOrderStatus::Completed.to_string(), "completed");
632 }
633
634 #[test]
635 fn test_work_order_status_from_str() {
636 assert_eq!(
637 WorkOrderStatus::from_str("partially_completed").unwrap(),
638 WorkOrderStatus::PartiallyCompleted
639 );
640 assert!(WorkOrderStatus::from_str("unknown").is_err());
641 }
642
643 #[test]
644 fn test_work_order_priority_from_str() {
645 assert_eq!(WorkOrderPriority::from_str("urgent").unwrap(), WorkOrderPriority::Urgent);
646 assert!(WorkOrderPriority::from_str("unknown").is_err());
647 }
648
649 #[test]
650 fn test_task_status_from_str() {
651 assert_eq!(TaskStatus::from_str("pending").unwrap(), TaskStatus::Pending);
652 assert!(TaskStatus::from_str("unknown").is_err());
653 }
654
655 #[test]
656 fn work_order_status_valid_transitions() {
657 use WorkOrderStatus::*;
658 assert!(Planned.can_transition_to(InProgress));
659 assert!(Planned.can_transition_to(Cancelled));
660 assert!(InProgress.can_transition_to(Completed));
661 assert!(InProgress.can_transition_to(PartiallyCompleted));
662 assert!(InProgress.can_transition_to(OnHold));
663 assert!(InProgress.can_transition_to(Cancelled));
664 assert!(OnHold.can_transition_to(InProgress));
665 assert!(OnHold.can_transition_to(Cancelled));
666 assert!(PartiallyCompleted.can_transition_to(InProgress));
667 assert!(PartiallyCompleted.can_transition_to(Completed));
668 }
669
670 #[test]
671 fn work_order_status_invalid_transitions() {
672 use WorkOrderStatus::*;
673 assert!(!Planned.can_transition_to(Completed));
674 assert!(!Planned.can_transition_to(OnHold));
675 assert!(!OnHold.can_transition_to(Completed));
676 assert!(!Completed.can_transition_to(InProgress));
677 assert!(!Cancelled.can_transition_to(Planned));
678 }
679
680 #[test]
681 fn work_order_status_terminal_states() {
682 use WorkOrderStatus::*;
683 assert!(Completed.is_terminal());
684 assert!(Cancelled.is_terminal());
685 assert!(!Planned.is_terminal());
686 assert!(!InProgress.is_terminal());
687 assert!(!OnHold.is_terminal());
688 assert!(!PartiallyCompleted.is_terminal());
689 }
690}