use chrono::{Datelike, NaiveDate};
use datasynth_config::schema::{ManufacturingCostingConfig, ProductionOrderConfig, RoutingConfig};
use datasynth_core::models::{
CostBreakdown, OperationStatus, ProductionOrder, ProductionOrderStatus, ProductionOrderType,
RoutingOperation,
};
use datasynth_core::utils::seeded_rng;
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
use tracing::debug;
const WORK_CENTERS: &[&str] = &["WC-100", "WC-200", "WC-300", "WC-400", "WC-500"];
const OPERATION_DESCRIPTIONS: &[&str] = &[
"Material Preparation",
"Cutting",
"Machining",
"Assembly",
"Welding",
"Heat Treatment",
"Surface Finishing",
"Quality Check",
"Packaging",
"Final Inspection",
];
pub struct ProductionOrderGenerator {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl ProductionOrderGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ProductionOrder),
}
}
pub fn generate(
&mut self,
company_code: &str,
material_ids: &[(String, String)],
period_start: NaiveDate,
period_end: NaiveDate,
config: &ProductionOrderConfig,
costing: &ManufacturingCostingConfig,
routing: &RoutingConfig,
) -> Vec<ProductionOrder> {
debug!(company_code, material_count = material_ids.len(), %period_start, %period_end, orders_per_month = config.orders_per_month, "Generating production orders");
if material_ids.is_empty() {
return Vec::new();
}
let mut orders = Vec::new();
let mut current = period_start;
while current <= period_end {
let month_end = end_of_month(current).min(period_end);
for _ in 0..config.orders_per_month {
let order = self.generate_one(
company_code,
material_ids,
current,
month_end,
config,
costing,
routing,
);
orders.push(order);
}
current = next_month_start(current);
}
orders
}
fn generate_one(
&mut self,
company_code: &str,
material_ids: &[(String, String)],
month_start: NaiveDate,
month_end: NaiveDate,
config: &ProductionOrderConfig,
costing: &ManufacturingCostingConfig,
routing: &RoutingConfig,
) -> ProductionOrder {
let order_id = self.uuid_factory.next().to_string();
let (material_id, material_description) = material_ids
.choose(&mut self.rng)
.map(|(id, desc)| (id.clone(), desc.clone()))
.unwrap_or_else(|| ("MAT-UNKNOWN".to_string(), "Unknown Material".to_string()));
let order_type = self.pick_order_type(config);
let status = self.pick_status();
let batch_factor: f64 = self.rng.random_range(0.5..=1.5);
let planned_qty_f64 = config.avg_batch_size as f64 * batch_factor;
let planned_quantity = Decimal::from_f64_retain(planned_qty_f64.round())
.unwrap_or(Decimal::from(config.avg_batch_size));
let yield_factor: f64 = self.rng.random_range(0.95..=1.05);
let effective_yield = config.yield_rate * yield_factor;
let actual_qty_f64 = planned_qty_f64 * effective_yield;
let actual_quantity =
Decimal::from_f64_retain(actual_qty_f64.round()).unwrap_or(planned_quantity);
let scrap_quantity = (planned_quantity - actual_quantity).max(Decimal::ZERO);
let days_in_month = (month_end - month_start).num_days().max(1);
let start_offset = self.rng.random_range(0..days_in_month);
let planned_start = month_start + chrono::Duration::days(start_offset);
let production_days = self.rng.random_range(3..=14);
let planned_end = planned_start + chrono::Duration::days(production_days);
let (actual_start, actual_end) = match status {
ProductionOrderStatus::Planned => (None, None),
ProductionOrderStatus::Released => (None, None),
ProductionOrderStatus::InProcess => {
let offset = self.rng.random_range(0..=2);
(Some(planned_start + chrono::Duration::days(offset)), None)
}
ProductionOrderStatus::Completed | ProductionOrderStatus::Closed => {
let start_offset = self.rng.random_range(0..=2);
let end_offset = self.rng.random_range(-1..=3);
(
Some(planned_start + chrono::Duration::days(start_offset)),
Some(planned_end + chrono::Duration::days(end_offset)),
)
}
ProductionOrderStatus::Cancelled => (None, None),
};
let work_center = WORK_CENTERS
.choose(&mut self.rng)
.unwrap_or(&"WC-100")
.to_string();
let variation: i32 = self.rng.random_range(-1..=1);
let num_operations = (routing.avg_operations as i32 + variation).max(1) as u32;
let operations = self.generate_operations(
num_operations,
planned_quantity,
actual_quantity,
routing,
&status,
planned_start,
planned_end,
);
let raw_labor_hours: f64 = operations
.iter()
.map(|op| op.setup_time_hours + op.run_time_hours)
.sum();
let labor_variation: f64 = self
.rng
.random_range(-routing.run_time_variation..=routing.run_time_variation);
let labor_hours = raw_labor_hours * (1.0 + labor_variation);
let standard_labor_cost_f64 = labor_hours * costing.labor_rate_per_hour;
let standard_overhead_f64 = standard_labor_cost_f64 * costing.overhead_rate;
let standard_material_cost_f64 = planned_qty_f64 * self.rng.random_range(5.0..50.0);
let standard_unit_cost_f64 = if planned_qty_f64 > 0.0 {
(standard_material_cost_f64 + standard_labor_cost_f64 + standard_overhead_f64)
/ planned_qty_f64
} else {
0.0
};
let material_price_factor: f64 = self.rng.random_range(0.92..=1.10);
let material_usage_factor: f64 = self.rng.random_range(0.95..=1.08);
let actual_material_cost_f64 =
standard_material_cost_f64 * material_price_factor * material_usage_factor;
let labor_rate_factor: f64 = self.rng.random_range(0.95..=1.12);
let labor_efficiency_factor: f64 = self.rng.random_range(0.90..=1.10);
let actual_labor_cost_f64 =
standard_labor_cost_f64 * labor_rate_factor * labor_efficiency_factor;
let actual_overhead_f64 = actual_labor_cost_f64 * costing.overhead_rate;
let to_dec = |v: f64| {
Decimal::from_f64_retain(v)
.unwrap_or(Decimal::ZERO)
.round_dp(2)
};
let cost_breakdown = CostBreakdown {
material_cost: to_dec(actual_material_cost_f64),
labor_cost: to_dec(actual_labor_cost_f64),
overhead_cost: to_dec(actual_overhead_f64),
standard_material_cost: to_dec(standard_material_cost_f64),
standard_labor_cost: to_dec(standard_labor_cost_f64),
standard_overhead_cost: to_dec(standard_overhead_f64),
standard_unit_cost: to_dec(standard_unit_cost_f64),
};
let actual_cost = cost_breakdown.total_actual();
let planned_cost = cost_breakdown.total_standard();
let labor_hours_actual = labor_hours * labor_efficiency_factor;
let machine_hours = labor_hours_actual * 0.7;
let routing_id = Some(format!("RT-{}", order_id.get(..8).unwrap_or("00000000")));
let batch_number = Some(format!(
"BATCH-{}-{:04}",
planned_start.format("%Y%m%d"),
self.rng.random_range(1..=9999)
));
ProductionOrder {
order_id,
company_code: company_code.to_string(),
material_id,
material_description,
order_type,
status,
planned_quantity,
actual_quantity,
scrap_quantity,
planned_start,
planned_end,
actual_start,
actual_end,
work_center,
routing_id,
planned_cost,
actual_cost,
cost_breakdown: Some(cost_breakdown),
labor_hours: labor_hours_actual,
machine_hours,
yield_rate: effective_yield,
batch_number,
operations,
}
}
fn pick_order_type(&mut self, config: &ProductionOrderConfig) -> ProductionOrderType {
let roll: f64 = self.rng.random();
if roll < config.make_to_order_rate {
ProductionOrderType::MakeToOrder
} else if roll < config.make_to_order_rate + config.rework_rate {
ProductionOrderType::Rework
} else if self.rng.random_bool(0.5) {
ProductionOrderType::Standard
} else {
ProductionOrderType::MakeToStock
}
}
fn pick_status(&mut self) -> ProductionOrderStatus {
let roll: f64 = self.rng.random();
if roll < 0.50 {
ProductionOrderStatus::Completed
} else if roll < 0.70 {
ProductionOrderStatus::InProcess
} else if roll < 0.85 {
ProductionOrderStatus::Released
} else if roll < 0.95 {
ProductionOrderStatus::Closed
} else {
ProductionOrderStatus::Planned
}
}
fn generate_operations(
&mut self,
count: u32,
planned_quantity: Decimal,
actual_quantity: Decimal,
routing: &RoutingConfig,
order_status: &ProductionOrderStatus,
planned_start: NaiveDate,
planned_end: NaiveDate,
) -> Vec<RoutingOperation> {
let total_days = (planned_end - planned_start).num_days().max(1);
let days_per_op = total_days / count as i64;
(0..count)
.map(|i| {
let op_number = (i + 1) * 10;
let desc_idx = (i as usize) % OPERATION_DESCRIPTIONS.len();
let description = OPERATION_DESCRIPTIONS[desc_idx].to_string();
let wc = WORK_CENTERS
.choose(&mut self.rng)
.unwrap_or(&"WC-100")
.to_string();
let setup_variation: f64 = self.rng.random_range(0.8..=1.2);
let setup_time_hours = routing.setup_time_hours * setup_variation;
let base_run_hours: f64 = planned_quantity.to_f64().unwrap_or(100.0) * 0.05; let run_variation: f64 = self.rng.random_range(
1.0 - routing.run_time_variation..=1.0 + routing.run_time_variation,
);
let run_time_hours = base_run_hours * run_variation;
let op_status = match order_status {
ProductionOrderStatus::Planned | ProductionOrderStatus::Released => {
OperationStatus::Pending
}
ProductionOrderStatus::InProcess => {
if i < count / 2 {
OperationStatus::Completed
} else if i == count / 2 {
OperationStatus::InProcess
} else {
OperationStatus::Pending
}
}
ProductionOrderStatus::Completed | ProductionOrderStatus::Closed => {
OperationStatus::Completed
}
ProductionOrderStatus::Cancelled => OperationStatus::Cancelled,
};
let op_start_offset = i as i64 * days_per_op;
let started_at = match op_status {
OperationStatus::InProcess | OperationStatus::Completed => {
Some(planned_start + chrono::Duration::days(op_start_offset))
}
_ => None,
};
let completed_at = match op_status {
OperationStatus::Completed => {
Some(planned_start + chrono::Duration::days(op_start_offset + days_per_op))
}
_ => None,
};
RoutingOperation {
operation_number: op_number,
operation_description: description,
work_center: wc,
setup_time_hours,
run_time_hours,
planned_quantity,
actual_quantity,
status: op_status,
started_at,
completed_at,
}
})
.collect()
}
}
fn end_of_month(date: NaiveDate) -> NaiveDate {
let (y, m) = if date.month() == 12 {
(date.year() + 1, 1)
} else {
(date.year(), date.month() + 1)
};
NaiveDate::from_ymd_opt(y, m, 1)
.unwrap_or(date)
.pred_opt()
.unwrap_or(date)
}
fn next_month_start(date: NaiveDate) -> NaiveDate {
let (y, m) = if date.month() == 12 {
(date.year() + 1, 1)
} else {
(date.year(), date.month() + 1)
};
NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(date)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_materials() -> Vec<(String, String)> {
vec![
("MAT-001".to_string(), "Widget Alpha".to_string()),
("MAT-002".to_string(), "Widget Beta".to_string()),
("MAT-003".to_string(), "Widget Gamma".to_string()),
]
}
#[test]
fn test_basic_generation() {
let mut gen = ProductionOrderGenerator::new(42);
let materials = sample_materials();
let config = ProductionOrderConfig::default();
let costing = ManufacturingCostingConfig::default();
let routing = RoutingConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let orders = gen.generate("C001", &materials, start, end, &config, &costing, &routing);
assert_eq!(orders.len(), config.orders_per_month as usize);
for order in &orders {
assert_eq!(order.company_code, "C001");
assert!(!order.order_id.is_empty());
assert!(!order.material_id.is_empty());
assert!(order.planned_quantity > Decimal::ZERO);
assert!(order.planned_cost > Decimal::ZERO);
assert!(!order.operations.is_empty());
assert!(order.labor_hours > 0.0);
assert!(order.machine_hours > 0.0);
}
}
#[test]
fn test_deterministic() {
let materials = sample_materials();
let config = ProductionOrderConfig::default();
let costing = ManufacturingCostingConfig::default();
let routing = RoutingConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let mut gen1 = ProductionOrderGenerator::new(12345);
let orders1 = gen1.generate("C001", &materials, start, end, &config, &costing, &routing);
let mut gen2 = ProductionOrderGenerator::new(12345);
let orders2 = gen2.generate("C001", &materials, start, end, &config, &costing, &routing);
assert_eq!(orders1.len(), orders2.len());
for (o1, o2) in orders1.iter().zip(orders2.iter()) {
assert_eq!(o1.order_id, o2.order_id);
assert_eq!(o1.planned_quantity, o2.planned_quantity);
assert_eq!(o1.actual_quantity, o2.actual_quantity);
assert_eq!(o1.planned_cost, o2.planned_cost);
assert_eq!(o1.actual_cost, o2.actual_cost);
}
}
#[test]
fn test_yield_and_scrap() {
let mut gen = ProductionOrderGenerator::new(99);
let materials = sample_materials();
let config = ProductionOrderConfig {
orders_per_month: 200,
yield_rate: 0.90,
..Default::default()
};
let costing = ManufacturingCostingConfig::default();
let routing = RoutingConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let orders = gen.generate("C001", &materials, start, end, &config, &costing, &routing);
let total_planned: Decimal = orders.iter().map(|o| o.planned_quantity).sum();
let total_actual: Decimal = orders.iter().map(|o| o.actual_quantity).sum();
assert!(
total_actual < total_planned,
"With 90% yield, total actual ({}) should be less than planned ({})",
total_actual,
total_planned,
);
for order in &orders {
assert!(
order.scrap_quantity >= Decimal::ZERO,
"Scrap quantity should be non-negative, got {}",
order.scrap_quantity,
);
}
}
#[test]
fn test_multi_month_period() {
let mut gen = ProductionOrderGenerator::new(77);
let materials = sample_materials();
let config = ProductionOrderConfig {
orders_per_month: 10,
..Default::default()
};
let costing = ManufacturingCostingConfig::default();
let routing = RoutingConfig::default();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let orders = gen.generate("C001", &materials, start, end, &config, &costing, &routing);
assert_eq!(orders.len(), 30);
}
}