datasynth-generators 5.34.0

50+ data generators covering GL, P2P, O2C, S2C, HR, manufacturing, audit, tax, treasury, and ESG
Documentation
//! Integration tests for the Manufacturing (MFG) pipeline.
//!
//! Verifies cross-generator coherence: materials generated by
//! `MaterialGenerator` flow into production orders, quality inspections,
//! BOM components, and inventory movements with consistent material IDs
//! and valid cross-references.

#![allow(clippy::unwrap_used)]

use std::collections::HashSet;

use chrono::NaiveDate;
use datasynth_config::schema::{ManufacturingCostingConfig, ProductionOrderConfig, RoutingConfig};
use datasynth_generators::{
    BomGenerator, InventoryMovementGenerator, MaterialGenerator, ProductionOrderGenerator,
    QualityInspectionGenerator,
};
use rust_decimal::Decimal;

// =============================================================================
// Helpers
// =============================================================================

fn date(y: i32, m: u32, d: u32) -> NaiveDate {
    NaiveDate::from_ymd_opt(y, m, d).unwrap()
}

// =============================================================================
// Full pipeline coherence
// =============================================================================

/// Generate materials and feed them through production orders, quality
/// inspections, BOM generation, and inventory movements, then verify
/// all cross-references are valid.
#[test]
fn test_manufacturing_pipeline_coherence() {
    let seed = 42u64;
    let company_code = "C001";
    let start_date = date(2025, 1, 1);
    let end_date = date(2025, 3, 31);
    let currency = "USD";

    // ── Phase 1: Generate materials ──────────────────────────────────────
    let mut mat_gen = MaterialGenerator::new(seed);
    let material_pool = mat_gen.generate_material_pool(10, company_code, start_date);

    assert_eq!(
        material_pool.materials.len(),
        10,
        "Should generate 10 materials"
    );

    let material_id_set: HashSet<&str> = material_pool
        .materials
        .iter()
        .map(|m| m.material_id.as_str())
        .collect();

    // Material tuples consumed by downstream generators
    let material_tuples: Vec<(String, String)> = material_pool
        .materials
        .iter()
        .map(|m| (m.material_id.clone(), m.description.clone()))
        .collect();

    // ── Phase 2: Production orders ───────────────────────────────────────
    let mut prod_gen = ProductionOrderGenerator::new(seed + 1);
    let prod_config = ProductionOrderConfig {
        orders_per_month: 5,
        ..Default::default()
    };
    let costing = ManufacturingCostingConfig::default();
    let routing = RoutingConfig::default();

    let orders = prod_gen.generate(
        company_code,
        &material_tuples,
        start_date,
        end_date,
        &prod_config,
        &costing,
        &routing,
    );

    // 3 months * 5 orders/month = 15 orders
    assert_eq!(orders.len(), 15, "Should generate 15 production orders");

    let order_id_set: HashSet<&str> = orders.iter().map(|o| o.order_id.as_str()).collect();

    for order in &orders {
        assert_eq!(order.company_code, company_code);
        assert!(
            material_id_set.contains(order.material_id.as_str()),
            "Production order references unknown material: {}",
            order.material_id
        );
        assert!(
            order.planned_quantity > Decimal::ZERO,
            "Planned quantity should be positive"
        );
        assert!(
            order.planned_cost > Decimal::ZERO,
            "Planned cost should be positive"
        );
        assert!(
            !order.operations.is_empty(),
            "Production order should have routing operations"
        );
        assert!(order.labor_hours > 0.0, "Labor hours should be positive");
        assert!(
            order.machine_hours > 0.0,
            "Machine hours should be positive"
        );
    }

    // ── Phase 3: Quality inspections ─────────────────────────────────────
    let mut qi_gen = QualityInspectionGenerator::new(seed + 2);

    let order_tuples: Vec<(String, String, String)> = orders
        .iter()
        .map(|o| {
            (
                o.order_id.clone(),
                o.material_id.clone(),
                o.material_description.clone(),
            )
        })
        .collect();

    let inspections = qi_gen.generate(company_code, &order_tuples, date(2025, 3, 31));

    assert_eq!(
        inspections.len(),
        orders.len(),
        "Should generate one inspection per production order"
    );

    for insp in &inspections {
        assert_eq!(insp.company_code, company_code);
        assert!(
            order_id_set.contains(insp.reference_id.as_str()),
            "Quality inspection references unknown production order: {}",
            insp.reference_id
        );
        assert_eq!(
            insp.reference_type, "production_order",
            "Reference type should be production_order"
        );
        assert!(
            material_id_set.contains(insp.material_id.as_str()),
            "Quality inspection references unknown material: {}",
            insp.material_id
        );
        assert!(insp.lot_size > Decimal::ZERO, "Lot size should be positive");
        assert!(
            insp.sample_size > Decimal::ZERO,
            "Sample size should be positive"
        );
        assert!(
            insp.sample_size <= insp.lot_size,
            "Sample size ({}) should not exceed lot size ({})",
            insp.sample_size,
            insp.lot_size
        );
        assert!(
            !insp.characteristics.is_empty(),
            "Inspection should have characteristics"
        );
        assert!(
            insp.characteristics.len() >= 2 && insp.characteristics.len() <= 5,
            "Should have 2-5 characteristics, got {}",
            insp.characteristics.len()
        );

        // Verify characteristic consistency
        for c in &insp.characteristics {
            assert!(
                c.lower_limit < c.target_value,
                "Lower limit should be below target"
            );
            assert!(
                c.upper_limit > c.target_value,
                "Upper limit should be above target"
            );
            let within = c.actual_value >= c.lower_limit && c.actual_value <= c.upper_limit;
            assert_eq!(
                c.passed, within,
                "Passed flag inconsistent with limits for characteristic '{}'",
                c.name
            );
        }
    }

    // ── Phase 4: BOM components ──────────────────────────────────────────
    let mut bom_gen = BomGenerator::new(seed + 3);
    let bom_components = bom_gen.generate(company_code, &material_tuples);

    assert!(
        !bom_components.is_empty(),
        "Should generate BOM components for 10 materials"
    );

    for comp in &bom_components {
        assert!(
            material_id_set.contains(comp.component_material_id.as_str()),
            "BOM component references unknown material: {}",
            comp.component_material_id
        );
        assert!(
            comp.quantity > Decimal::ZERO,
            "BOM component quantity should be positive"
        );
        assert!(comp.id.is_some(), "BOM component should have an ID");
        assert!(
            comp.parent_material.is_some(),
            "BOM component should have a parent material"
        );
        // Verify parent is also a known material
        let parent = comp.parent_material.as_ref().unwrap();
        assert!(
            material_id_set.contains(parent.as_str()),
            "BOM parent material is unknown: {}",
            parent
        );
        // Component should not be its own parent
        assert_ne!(
            comp.component_material_id, *parent,
            "BOM component should not be its own parent"
        );
    }

    // ── Phase 5: Inventory movements ─────────────────────────────────────
    let mut inv_gen = InventoryMovementGenerator::new(seed + 4);
    let movements = inv_gen.generate(
        company_code,
        &material_tuples,
        start_date,
        end_date,
        3, // movements per material
        currency,
    );

    assert!(!movements.is_empty(), "Should generate inventory movements");

    for mv in &movements {
        assert_eq!(mv.entity_code, company_code);
        assert_eq!(mv.currency, currency);
        assert!(
            material_id_set.contains(mv.material_code.as_str()),
            "Inventory movement references unknown material: {}",
            mv.material_code
        );
        assert!(
            mv.quantity > Decimal::ZERO,
            "Movement quantity should be positive"
        );
        assert!(
            mv.value > Decimal::ZERO,
            "Movement value should be positive"
        );
        assert!(
            mv.movement_date >= start_date && mv.movement_date <= end_date,
            "Movement date {} outside expected range [{}, {}]",
            mv.movement_date,
            start_date,
            end_date
        );
        assert!(
            !mv.reference_doc.is_empty(),
            "Movement should have a reference document"
        );
    }

    // ── Summary ──────────────────────────────────────────────────────────
    println!(
        "MFG pipeline coherence OK: {} materials -> {} orders, {} inspections, {} BOM components, {} movements",
        material_pool.materials.len(),
        orders.len(),
        inspections.len(),
        bom_components.len(),
        movements.len()
    );
}

// =============================================================================
// Determinism
// =============================================================================

/// Running the pipeline twice with the same seed should produce identical output.
#[test]
fn test_manufacturing_pipeline_deterministic() {
    let seed = 55u64;
    let company_code = "C001";
    let start = date(2025, 1, 1);
    let end = date(2025, 1, 31);

    let generate = |s: u64| {
        let mut mat_gen = MaterialGenerator::new(s);
        let pool = mat_gen.generate_material_pool(5, company_code, start);
        let material_tuples: Vec<(String, String)> = pool
            .materials
            .iter()
            .map(|m| (m.material_id.clone(), m.description.clone()))
            .collect();

        let mut prod_gen = ProductionOrderGenerator::new(s + 1);
        let config = ProductionOrderConfig {
            orders_per_month: 3,
            ..Default::default()
        };
        let costing = ManufacturingCostingConfig::default();
        let routing = RoutingConfig::default();
        let orders = prod_gen.generate(
            company_code,
            &material_tuples,
            start,
            end,
            &config,
            &costing,
            &routing,
        );

        let mut bom_gen = BomGenerator::new(s + 2);
        let bom = bom_gen.generate(company_code, &material_tuples);

        let mut inv_gen = InventoryMovementGenerator::new(s + 3);
        let movements = inv_gen.generate(company_code, &material_tuples, start, end, 2, "USD");

        (
            pool.materials.len(),
            orders.len(),
            orders.first().map(|o| o.order_id.clone()),
            bom.len(),
            movements.len(),
        )
    };

    let run1 = generate(seed);
    let run2 = generate(seed);

    assert_eq!(
        run1, run2,
        "Deterministic runs should produce identical results"
    );
}

// =============================================================================
// Edge case: empty material pool
// =============================================================================

/// Production order generator should handle an empty material list gracefully.
#[test]
fn test_production_orders_with_empty_materials() {
    let mut gen = ProductionOrderGenerator::new(42);
    let empty: Vec<(String, String)> = vec![];
    let config = ProductionOrderConfig::default();
    let costing = ManufacturingCostingConfig::default();
    let routing = RoutingConfig::default();

    let orders = gen.generate(
        "C001",
        &empty,
        date(2025, 1, 1),
        date(2025, 1, 31),
        &config,
        &costing,
        &routing,
    );

    assert!(
        orders.is_empty(),
        "Should return empty orders for empty material list"
    );
}

/// BOM generator should handle fewer than 3 materials gracefully.
#[test]
fn test_bom_with_few_materials() {
    let mut gen = BomGenerator::new(42);
    let materials = vec![
        ("MAT-001".to_string(), "Widget".to_string()),
        ("MAT-002".to_string(), "Gadget".to_string()),
    ];
    let bom = gen.generate("C001", &materials);

    assert!(
        bom.is_empty(),
        "BOM generator should return empty for < 3 materials"
    );
}