use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::ProductId;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BillOfMaterials {
pub id: Uuid,
pub bom_number: String,
pub product_id: ProductId,
pub name: String,
pub description: Option<String>,
pub revision: String,
pub status: BomStatus,
pub components: Vec<BomComponent>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl BillOfMaterials {
pub fn generate_bom_number() -> String {
let now = Utc::now();
format!("BOM-{}", now.format("%Y%m%d%H%M%S"))
}
pub fn total_component_count(&self) -> usize {
self.components.len()
}
pub fn total_quantity(&self) -> Decimal {
self.components.iter().map(|c| c.quantity).sum()
}
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BomStatus {
#[default]
Draft,
Active,
Obsolete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BomComponent {
pub id: Uuid,
pub bom_id: Uuid,
pub component_product_id: Option<ProductId>,
pub component_sku: Option<String>,
pub name: String,
pub quantity: Decimal,
pub unit_of_measure: String,
pub position: Option<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateBom {
pub product_id: ProductId,
pub name: String,
pub description: Option<String>,
pub revision: Option<String>,
pub components: Option<Vec<CreateBomComponent>>,
pub created_by: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateBomComponent {
pub component_product_id: Option<ProductId>,
pub component_sku: Option<String>,
pub name: String,
pub quantity: Decimal,
pub unit_of_measure: Option<String>,
pub position: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateBom {
pub name: Option<String>,
pub description: Option<String>,
pub revision: Option<String>,
pub status: Option<BomStatus>,
pub updated_by: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BomFilter {
pub product_id: Option<ProductId>,
pub status: Option<BomStatus>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkOrder {
pub id: Uuid,
pub work_order_number: String,
pub product_id: ProductId,
pub bom_id: Option<Uuid>,
pub work_center_id: Option<String>,
pub assigned_to: Option<Uuid>,
pub status: WorkOrderStatus,
pub priority: WorkOrderPriority,
pub quantity_to_build: Decimal,
pub quantity_completed: Decimal,
pub scheduled_start: Option<DateTime<Utc>>,
pub scheduled_end: Option<DateTime<Utc>>,
pub actual_start: Option<DateTime<Utc>>,
pub actual_end: Option<DateTime<Utc>>,
pub tasks: Vec<WorkOrderTask>,
pub materials: Vec<WorkOrderMaterial>,
pub notes: Option<String>,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl WorkOrder {
pub fn generate_work_order_number() -> String {
let now = Utc::now();
let suffix = Uuid::new_v4().simple().to_string();
format!("WO-{}-{}", now.format("%Y%m%d%H%M%S%3f"), &suffix[..8])
}
pub const fn can_start(&self) -> bool {
matches!(self.status, WorkOrderStatus::Planned | WorkOrderStatus::OnHold)
}
pub fn can_complete(&self) -> bool {
matches!(self.status, WorkOrderStatus::InProgress)
&& self.quantity_to_build > Decimal::ZERO
&& self.quantity_completed >= Decimal::ZERO
&& self.quantity_completed <= self.quantity_to_build
}
pub fn completion_percentage(&self) -> Decimal {
if self.quantity_to_build.is_zero() {
Decimal::ZERO
} else {
let value = (self.quantity_completed / self.quantity_to_build) * Decimal::from(100);
value.clamp(Decimal::ZERO, Decimal::from(100))
}
}
pub fn is_overdue(&self) -> bool {
if let Some(scheduled_end) = self.scheduled_end {
if !matches!(self.status, WorkOrderStatus::Completed | WorkOrderStatus::Cancelled) {
return Utc::now() > scheduled_end;
}
}
false
}
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkOrderStatus {
#[default]
Planned,
#[strum(serialize = "in_progress", serialize = "inprogress")]
InProgress,
Completed,
#[strum(serialize = "partially_completed", serialize = "partiallycompleted")]
PartiallyCompleted,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
#[strum(serialize = "on_hold", serialize = "onhold")]
OnHold,
}
impl WorkOrderStatus {
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Planned => matches!(next, Self::InProgress | Self::Cancelled),
Self::InProgress => matches!(
next,
Self::Completed | Self::PartiallyCompleted | Self::OnHold | Self::Cancelled
),
Self::OnHold => matches!(next, Self::InProgress | Self::Cancelled),
Self::PartiallyCompleted => matches!(next, Self::InProgress | Self::Completed),
Self::Completed | Self::Cancelled => false,
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Cancelled)
}
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkOrderPriority {
Low,
#[default]
Normal,
High,
Urgent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkOrder {
pub product_id: ProductId,
pub bom_id: Option<Uuid>,
pub work_center_id: Option<String>,
pub assigned_to: Option<Uuid>,
pub priority: Option<WorkOrderPriority>,
pub quantity_to_build: Decimal,
pub scheduled_start: Option<DateTime<Utc>>,
pub scheduled_end: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub tasks: Option<Vec<CreateWorkOrderTask>>,
}
impl Default for CreateWorkOrder {
fn default() -> Self {
Self {
product_id: ProductId::nil(),
bom_id: None,
work_center_id: None,
assigned_to: None,
priority: None,
quantity_to_build: Decimal::ONE,
scheduled_start: None,
scheduled_end: None,
notes: None,
tasks: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateWorkOrder {
pub work_center_id: Option<String>,
pub assigned_to: Option<Uuid>,
pub status: Option<WorkOrderStatus>,
pub priority: Option<WorkOrderPriority>,
pub quantity_to_build: Option<Decimal>,
pub quantity_completed: Option<Decimal>,
pub scheduled_start: Option<DateTime<Utc>>,
pub scheduled_end: Option<DateTime<Utc>>,
pub actual_start: Option<DateTime<Utc>>,
pub actual_end: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkOrderFilter {
pub product_id: Option<ProductId>,
pub bom_id: Option<Uuid>,
pub status: Option<WorkOrderStatus>,
pub priority: Option<WorkOrderPriority>,
pub assigned_to: Option<Uuid>,
pub work_center_id: Option<String>,
pub overdue_only: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkOrderTask {
pub id: Uuid,
pub work_order_id: Uuid,
pub sequence: i32,
pub task_name: String,
pub status: TaskStatus,
pub estimated_hours: Option<Decimal>,
pub actual_hours: Option<Decimal>,
pub assigned_to: Option<Uuid>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl WorkOrderTask {
pub const fn can_start(&self) -> bool {
matches!(self.status, TaskStatus::Pending)
}
pub const fn can_complete(&self) -> bool {
matches!(self.status, TaskStatus::InProgress)
}
pub fn efficiency(&self) -> Option<Decimal> {
match (self.estimated_hours, self.actual_hours) {
(Some(est), Some(act)) if !act.is_zero() => Some((est / act) * Decimal::from(100)),
_ => None,
}
}
}
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TaskStatus {
#[default]
Pending,
#[strum(serialize = "in_progress", serialize = "inprogress")]
InProgress,
Completed,
Skipped,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateWorkOrderTask {
pub sequence: Option<i32>,
pub task_name: String,
pub estimated_hours: Option<Decimal>,
pub assigned_to: Option<Uuid>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateWorkOrderTask {
pub sequence: Option<i32>,
pub task_name: Option<String>,
pub status: Option<TaskStatus>,
pub estimated_hours: Option<Decimal>,
pub actual_hours: Option<Decimal>,
pub assigned_to: Option<Uuid>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkOrderMaterial {
pub id: Uuid,
pub work_order_id: Uuid,
pub component_id: Option<Uuid>,
pub component_sku: String,
pub component_name: String,
pub reserved_quantity: Decimal,
pub consumed_quantity: Decimal,
pub inventory_reservation_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl WorkOrderMaterial {
pub fn remaining_reserved(&self) -> Decimal {
(self.reserved_quantity - self.consumed_quantity).max(Decimal::ZERO)
}
pub fn is_fully_consumed(&self) -> bool {
self.consumed_quantity == self.reserved_quantity
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AddWorkOrderMaterial {
pub component_id: Option<Uuid>,
pub component_sku: String,
pub component_name: String,
pub quantity: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumeMaterial {
pub material_id: Uuid,
pub quantity: Decimal,
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn test_work_order_completion_percentage() {
let wo = WorkOrder {
id: Uuid::new_v4(),
work_order_number: "WO-001".into(),
product_id: ProductId::new(),
bom_id: None,
work_center_id: None,
assigned_to: None,
status: WorkOrderStatus::InProgress,
priority: WorkOrderPriority::Normal,
quantity_to_build: Decimal::from(100),
quantity_completed: Decimal::from(25),
scheduled_start: None,
scheduled_end: None,
actual_start: None,
actual_end: None,
tasks: vec![],
materials: vec![],
notes: None,
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(wo.completion_percentage(), Decimal::from(25));
}
#[test]
fn test_bom_status_display() {
assert_eq!(BomStatus::Draft.to_string(), "draft");
assert_eq!(BomStatus::Active.to_string(), "active");
assert_eq!(BomStatus::Obsolete.to_string(), "obsolete");
}
#[test]
fn test_bom_status_from_str() {
assert_eq!(BomStatus::from_str("draft").unwrap(), BomStatus::Draft);
assert!(BomStatus::from_str("unknown").is_err());
}
#[test]
fn test_work_order_status_display() {
assert_eq!(WorkOrderStatus::Planned.to_string(), "planned");
assert_eq!(WorkOrderStatus::InProgress.to_string(), "in_progress");
assert_eq!(WorkOrderStatus::Completed.to_string(), "completed");
}
#[test]
fn test_work_order_status_from_str() {
assert_eq!(
WorkOrderStatus::from_str("partially_completed").unwrap(),
WorkOrderStatus::PartiallyCompleted
);
assert!(WorkOrderStatus::from_str("unknown").is_err());
}
#[test]
fn test_work_order_priority_from_str() {
assert_eq!(WorkOrderPriority::from_str("urgent").unwrap(), WorkOrderPriority::Urgent);
assert!(WorkOrderPriority::from_str("unknown").is_err());
}
#[test]
fn test_task_status_from_str() {
assert_eq!(TaskStatus::from_str("pending").unwrap(), TaskStatus::Pending);
assert!(TaskStatus::from_str("unknown").is_err());
}
#[test]
fn work_order_status_valid_transitions() {
use WorkOrderStatus::*;
assert!(Planned.can_transition_to(InProgress));
assert!(Planned.can_transition_to(Cancelled));
assert!(InProgress.can_transition_to(Completed));
assert!(InProgress.can_transition_to(PartiallyCompleted));
assert!(InProgress.can_transition_to(OnHold));
assert!(InProgress.can_transition_to(Cancelled));
assert!(OnHold.can_transition_to(InProgress));
assert!(OnHold.can_transition_to(Cancelled));
assert!(PartiallyCompleted.can_transition_to(InProgress));
assert!(PartiallyCompleted.can_transition_to(Completed));
}
#[test]
fn work_order_status_invalid_transitions() {
use WorkOrderStatus::*;
assert!(!Planned.can_transition_to(Completed));
assert!(!Planned.can_transition_to(OnHold));
assert!(!OnHold.can_transition_to(Completed));
assert!(!Completed.can_transition_to(InProgress));
assert!(!Cancelled.can_transition_to(Planned));
}
#[test]
fn work_order_status_terminal_states() {
use WorkOrderStatus::*;
assert!(Completed.is_terminal());
assert!(Cancelled.is_terminal());
assert!(!Planned.is_terminal());
assert!(!InProgress.is_terminal());
assert!(!OnHold.is_terminal());
assert!(!PartiallyCompleted.is_terminal());
}
}