use chrono::NaiveDate;
use datasynth_core::models::{InventoryMovement, MovementType};
use datasynth_core::utils::seeded_rng;
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use tracing::debug;
const STORAGE_LOCATIONS: &[&str] = &[
"WH01-A01", "WH01-A02", "WH01-B01", "WH02-A01", "WH02-B01", "WH03-A01",
];
pub struct InventoryMovementGenerator {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl InventoryMovementGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::InventoryMovement),
}
}
pub fn generate(
&mut self,
company_code: &str,
material_ids: &[(String, String)],
period_start: NaiveDate,
period_end: NaiveDate,
movements_per_material: u32,
currency: &str,
) -> Vec<InventoryMovement> {
self.generate_with_production_orders(
company_code,
material_ids,
period_start,
period_end,
movements_per_material,
currency,
&[],
)
}
#[allow(clippy::too_many_arguments)]
pub fn generate_with_production_orders(
&mut self,
company_code: &str,
material_ids: &[(String, String)],
period_start: NaiveDate,
period_end: NaiveDate,
movements_per_material: u32,
currency: &str,
production_order_ids: &[String],
) -> Vec<InventoryMovement> {
debug!(
company_code,
material_count = material_ids.len(),
%period_start,
%period_end,
movements_per_material,
production_order_count = production_order_ids.len(),
"Generating inventory movements"
);
let mut movements = Vec::new();
let period_days = (period_end - period_start).num_days().max(1) as u64;
let period_str = format!(
"{}-{:02}",
period_start.format("%Y"),
period_start.format("%m")
);
let mut prd_idx: usize = 0;
for (material_id, material_desc) in material_ids {
let count = self.rng.random_range(1..=movements_per_material.max(1) * 2);
for _ in 0..count {
let mv_id = self.uuid_factory.next().to_string();
let day_offset = self.rng.random_range(0..period_days);
let movement_date = period_start + chrono::Duration::days(day_offset as i64);
let movement_type = self.pick_movement_type();
let quantity = Decimal::from(self.rng.random_range(1..=500));
let unit_cost: f64 = self.rng.random_range(5.0..=200.0);
let value = (quantity
* Decimal::from_f64_retain(unit_cost).unwrap_or(Decimal::from(10)))
.round_dp(2);
let storage_location = STORAGE_LOCATIONS
[self.rng.random_range(0..STORAGE_LOCATIONS.len())]
.to_string();
let reference_doc = match movement_type {
MovementType::GoodsReceipt => {
format!("PO-{:08}", self.rng.random_range(10000..99999))
}
MovementType::GoodsIssue => {
if !production_order_ids.is_empty() {
let id =
production_order_ids[prd_idx % production_order_ids.len()].clone();
prd_idx += 1;
id
} else {
format!("PRD-{:08}", self.rng.random_range(10000..99999))
}
}
MovementType::Transfer => {
format!("TR-{:08}", self.rng.random_range(10000..99999))
}
MovementType::Return => {
format!("RET-{:08}", self.rng.random_range(10000..99999))
}
MovementType::Scrap => {
format!("QI-{:08}", self.rng.random_range(10000..99999))
}
MovementType::Adjustment => {
format!("CC-{:08}", self.rng.random_range(10000..99999))
}
};
let mv = InventoryMovement::new(
mv_id,
company_code,
material_id,
material_desc,
movement_date,
&period_str,
movement_type,
quantity,
"EA",
value,
currency,
&storage_location,
&reference_doc,
);
movements.push(mv);
}
}
movements
}
fn pick_movement_type(&mut self) -> MovementType {
let roll: f64 = self.rng.random();
if roll < 0.35 {
MovementType::GoodsReceipt
} else if roll < 0.65 {
MovementType::GoodsIssue
} else if roll < 0.80 {
MovementType::Transfer
} else if roll < 0.88 {
MovementType::Return
} else if roll < 0.95 {
MovementType::Scrap
} else {
MovementType::Adjustment
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn test_materials() -> Vec<(String, String)> {
vec![
("MAT-001".to_string(), "Widget A".to_string()),
("MAT-002".to_string(), "Widget B".to_string()),
("MAT-003".to_string(), "Widget C".to_string()),
]
}
#[test]
fn test_movement_generation() {
let mut gen = InventoryMovementGenerator::new(42);
let materials = test_materials();
let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let movements = gen.generate("C001", &materials, start, end, 5, "USD");
assert!(!movements.is_empty());
for mv in &movements {
assert_eq!(mv.entity_code, "C001");
assert_eq!(mv.currency, "USD");
assert!(mv.quantity > Decimal::ZERO);
assert!(mv.value > Decimal::ZERO);
assert!(mv.movement_date >= start);
assert!(mv.movement_date <= end);
assert!(!mv.reference_doc.is_empty());
}
}
#[test]
fn test_movement_type_distribution() {
let mut gen = InventoryMovementGenerator::new(77);
let materials: Vec<(String, String)> = (0..20)
.map(|i| (format!("MAT-{:03}", i), format!("M-{}", i)))
.collect();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let movements = gen.generate("C001", &materials, start, end, 10, "USD");
let receipts = movements
.iter()
.filter(|m| matches!(m.movement_type, MovementType::GoodsReceipt))
.count();
let issues = movements
.iter()
.filter(|m| matches!(m.movement_type, MovementType::GoodsIssue))
.count();
assert!(receipts > 0, "Should have goods receipts");
assert!(issues > 0, "Should have goods issues");
}
#[test]
fn test_movement_deterministic() {
let materials = test_materials();
let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let mut gen1 = InventoryMovementGenerator::new(12345);
let mv1 = gen1.generate("C001", &materials, start, end, 3, "USD");
let mut gen2 = InventoryMovementGenerator::new(12345);
let mv2 = gen2.generate("C001", &materials, start, end, 3, "USD");
assert_eq!(mv1.len(), mv2.len());
for (a, b) in mv1.iter().zip(mv2.iter()) {
assert_eq!(a.id, b.id);
assert_eq!(a.material_code, b.material_code);
assert_eq!(a.quantity, b.quantity);
assert_eq!(a.value, b.value);
}
}
}