Skip to main content

stateset_core/models/
manufacturing.rs

1//! Manufacturing domain models
2//!
3//! This module contains models for manufacturing operations including:
4//! - Bill of Materials (BOM) - defines product composition
5//! - Work Orders - tracks manufacturing jobs
6//! - Work Order Tasks - individual steps in manufacturing
7//! - Work Order Materials - material consumption tracking
8
9use chrono::{DateTime, Utc};
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use stateset_primitives::ProductId;
13use uuid::Uuid;
14
15// =============================================================================
16// Bill of Materials (BOM)
17// =============================================================================
18
19/// Bill of Materials - defines what components make up a product
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct BillOfMaterials {
22    pub id: Uuid,
23    /// Unique BOM number (e.g., "BOM-2024-001")
24    pub bom_number: String,
25    /// Product this BOM is for
26    pub product_id: ProductId,
27    /// Human-readable name
28    pub name: String,
29    /// Description of the BOM
30    pub description: Option<String>,
31    /// Revision identifier (e.g., "A", "B", "1.0")
32    pub revision: String,
33    /// Lifecycle status
34    pub status: BomStatus,
35    /// Components in this BOM
36    pub components: Vec<BomComponent>,
37    /// Who created this BOM
38    pub created_by: Option<Uuid>,
39    /// Who last updated this BOM
40    pub updated_by: Option<Uuid>,
41    pub created_at: DateTime<Utc>,
42    pub updated_at: DateTime<Utc>,
43}
44
45impl BillOfMaterials {
46    /// Generate a BOM number based on timestamp.
47    ///
48    /// Includes a millisecond timestamp plus a UUID suffix (matching
49    /// `generate_work_order_number`) so two BOMs created within the same second
50    /// cannot collide on the UNIQUE `bom_number` constraint — a second-granularity
51    /// number made rapid/concurrent `create` calls fail on both backends.
52    #[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    /// Calculate total component count
60    #[must_use]
61    pub fn total_component_count(&self) -> usize {
62        self.components.len()
63    }
64
65    /// Calculate total material cost (sum of component quantities * unit costs if available)
66    #[must_use]
67    pub fn total_quantity(&self) -> Decimal {
68        self.components.iter().map(|c| c.quantity).sum()
69    }
70}
71
72/// BOM lifecycle status
73#[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    /// Draft - being edited, not ready for production
90    #[default]
91    Draft,
92    /// Active - approved for use in production
93    Active,
94    /// Obsolete - no longer used
95    Obsolete,
96}
97
98/// A component in a Bill of Materials
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct BomComponent {
101    pub id: Uuid,
102    /// The BOM this component belongs to
103    pub bom_id: Uuid,
104    /// The component product (for sub-assemblies)
105    pub component_product_id: Option<ProductId>,
106    /// Component SKU
107    pub component_sku: Option<String>,
108    /// Component name
109    pub name: String,
110    /// Quantity required per unit of finished product
111    pub quantity: Decimal,
112    /// Unit of measure (e.g., "each", "kg", "m")
113    pub unit_of_measure: String,
114    /// Position/reference designator (e.g., "R1", "C5")
115    pub position: Option<String>,
116    /// Notes about this component
117    pub notes: Option<String>,
118    pub created_at: DateTime<Utc>,
119    pub updated_at: DateTime<Utc>,
120}
121
122/// Input for creating a new BOM
123#[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/// Input for creating a BOM component
134#[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/// Input for updating a BOM
146#[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/// Filter for listing BOMs
156#[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// =============================================================================
166// Work Orders
167// =============================================================================
168
169/// Work Order - a manufacturing job to build products
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct WorkOrder {
172    pub id: Uuid,
173    /// Unique work order number (e.g., "WO-2024-001")
174    pub work_order_number: String,
175    /// Product being manufactured
176    pub product_id: ProductId,
177    /// BOM to use (optional - can manufacture without BOM)
178    pub bom_id: Option<Uuid>,
179    /// Work center or production line
180    pub work_center_id: Option<String>,
181    /// User assigned to this work order
182    pub assigned_to: Option<Uuid>,
183    /// Current status
184    pub status: WorkOrderStatus,
185    /// Priority level
186    pub priority: WorkOrderPriority,
187    /// Quantity to build
188    pub quantity_to_build: Decimal,
189    /// Quantity completed so far
190    pub quantity_completed: Decimal,
191    /// Scheduled start date/time
192    pub scheduled_start: Option<DateTime<Utc>>,
193    /// Scheduled end date/time
194    pub scheduled_end: Option<DateTime<Utc>>,
195    /// Actual start date/time
196    pub actual_start: Option<DateTime<Utc>>,
197    /// Actual end date/time
198    pub actual_end: Option<DateTime<Utc>>,
199    /// Tasks within this work order
200    pub tasks: Vec<WorkOrderTask>,
201    /// Materials reserved/consumed
202    pub materials: Vec<WorkOrderMaterial>,
203    /// Notes
204    pub notes: Option<String>,
205    /// Version for optimistic locking
206    pub version: i32,
207    pub created_at: DateTime<Utc>,
208    pub updated_at: DateTime<Utc>,
209}
210
211impl WorkOrder {
212    /// Generate a work order number based on timestamp
213    #[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    /// Check if work order can be started
221    #[must_use]
222    pub const fn can_start(&self) -> bool {
223        matches!(self.status, WorkOrderStatus::Planned | WorkOrderStatus::OnHold)
224    }
225
226    /// Check if work order can be completed
227    #[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    /// Calculate completion percentage
236    #[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    /// Check if work order is overdue
247    #[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/// Work order status
259#[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    /// Planned but not started
276    #[default]
277    Planned,
278    /// Currently in progress
279    #[strum(serialize = "in_progress", serialize = "inprogress")]
280    InProgress,
281    /// Completed successfully
282    Completed,
283    /// Partially completed (some quantity done)
284    #[strum(serialize = "partially_completed", serialize = "partiallycompleted")]
285    PartiallyCompleted,
286    /// Cancelled
287    #[strum(serialize = "cancelled", serialize = "canceled")]
288    Cancelled,
289    /// On hold (temporarily stopped)
290    #[strum(serialize = "on_hold", serialize = "onhold")]
291    OnHold,
292}
293
294impl WorkOrderStatus {
295    /// Check if a status transition is allowed.
296    #[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    /// Returns true if this status is a terminal state.
314    #[must_use]
315    pub const fn is_terminal(self) -> bool {
316        matches!(self, Self::Completed | Self::Cancelled)
317    }
318}
319
320/// Work order priority
321#[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 priority
338    Low,
339    /// Normal priority
340    #[default]
341    Normal,
342    /// High priority
343    High,
344    /// Urgent - needs immediate attention
345    Urgent,
346}
347
348/// Input for creating a work order
349#[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    /// Optionally create tasks at the same time
361    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/// Input for updating a work order
382#[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/// Filter for listing work orders
398#[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    /// Keyset cursor: return records after this `(sort_key, id)` pair.
410    /// Sort key is `created_at` (DESC ordering).
411    pub after_cursor: Option<(String, String)>,
412}
413
414// =============================================================================
415// Work Order Tasks
416// =============================================================================
417
418/// A task within a work order (a step in manufacturing)
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
420pub struct WorkOrderTask {
421    pub id: Uuid,
422    /// Parent work order
423    pub work_order_id: Uuid,
424    /// Sequence number (order of execution)
425    pub sequence: i32,
426    /// Task name/description
427    pub task_name: String,
428    /// Task status
429    pub status: TaskStatus,
430    /// Estimated hours to complete
431    pub estimated_hours: Option<Decimal>,
432    /// Actual hours spent
433    pub actual_hours: Option<Decimal>,
434    /// User assigned to this task
435    pub assigned_to: Option<Uuid>,
436    /// When task was started
437    pub started_at: Option<DateTime<Utc>>,
438    /// When task was completed
439    pub completed_at: Option<DateTime<Utc>>,
440    /// Notes
441    pub notes: Option<String>,
442    pub created_at: DateTime<Utc>,
443    pub updated_at: DateTime<Utc>,
444}
445
446impl WorkOrderTask {
447    /// Check if task can be started
448    #[must_use]
449    pub const fn can_start(&self) -> bool {
450        matches!(self.status, TaskStatus::Pending)
451    }
452
453    /// Check if task can be completed
454    #[must_use]
455    pub const fn can_complete(&self) -> bool {
456        matches!(self.status, TaskStatus::InProgress)
457    }
458
459    /// Calculate efficiency (estimated vs actual hours)
460    #[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/// Task status
470#[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    /// Not started
487    #[default]
488    Pending,
489    /// In progress
490    #[strum(serialize = "in_progress", serialize = "inprogress")]
491    InProgress,
492    /// Completed
493    Completed,
494    /// Skipped
495    Skipped,
496    /// Cancelled
497    #[strum(serialize = "cancelled", serialize = "canceled")]
498    Cancelled,
499}
500
501/// Input for creating a work order task
502#[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/// Input for updating a work order task
512#[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// =============================================================================
526// Work Order Materials
527// =============================================================================
528
529/// Material tracking for a work order
530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
531pub struct WorkOrderMaterial {
532    pub id: Uuid,
533    /// Parent work order
534    pub work_order_id: Uuid,
535    /// Reference to BOM component (if applicable)
536    pub component_id: Option<Uuid>,
537    /// Component SKU
538    pub component_sku: String,
539    /// Component name
540    pub component_name: String,
541    /// Quantity reserved from inventory
542    pub reserved_quantity: Decimal,
543    /// Quantity actually consumed
544    pub consumed_quantity: Decimal,
545    /// Link to inventory reservation
546    pub inventory_reservation_id: Option<Uuid>,
547    pub created_at: DateTime<Utc>,
548    pub updated_at: DateTime<Utc>,
549}
550
551impl WorkOrderMaterial {
552    /// Get remaining reserved quantity (reserved but not consumed)
553    #[must_use]
554    pub fn remaining_reserved(&self) -> Decimal {
555        (self.reserved_quantity - self.consumed_quantity).max(Decimal::ZERO)
556    }
557
558    /// Check if fully consumed
559    #[must_use]
560    pub fn is_fully_consumed(&self) -> bool {
561        self.consumed_quantity == self.reserved_quantity
562    }
563}
564
565/// Input for adding material to a work order
566#[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/// Input for consuming material from a work order
575#[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}