Files
sam-api/tests/Feature/Design/BomResolverTest.php

709 lines
23 KiB
PHP
Raw Normal View History

<?php
namespace Tests\Feature\Design;
use Tests\TestCase;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelVersion;
use App\Models\Design\ModelParameter;
use App\Models\Design\ModelFormula;
use App\Models\Design\BomConditionRule;
use App\Models\Design\BomTemplate;
use App\Models\Design\BomTemplateItem;
use App\Models\Product;
use App\Models\Material;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\Sanctum;
class BomResolverTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Tenant $tenant;
private DesignModel $model;
private ModelVersion $modelVersion;
private BomTemplate $bomTemplate;
private Product $product1;
private Product $product2;
private Material $material1;
private Material $material2;
protected function setUp(): void
{
parent::setUp();
// Create test tenant and user
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create();
// Associate user with tenant
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
// Create test design model
$this->model = DesignModel::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'KSS01',
'name' => 'Screen Door System',
'is_active' => true
]);
// Create model version
$this->modelVersion = ModelVersion::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'version_no' => '1.0',
'status' => 'RELEASED'
]);
// Create products and materials
$this->product1 = Product::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'BRACKET001',
'name' => 'Wall Bracket',
'unit' => 'EA'
]);
$this->product2 = Product::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'MOTOR001',
'name' => 'DC Motor',
'unit' => 'EA'
]);
$this->material1 = Material::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'FABRIC001',
'name' => 'Screen Fabric',
'unit' => 'M2'
]);
$this->material2 = Material::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'RAIL001',
'name' => 'Guide Rail',
'unit' => 'M'
]);
// Create BOM template
$this->bomTemplate = BomTemplate::factory()->create([
'tenant_id' => $this->tenant->id,
'model_version_id' => $this->modelVersion->id,
'name' => 'Base BOM Template'
]);
// Create BOM template items
BomTemplateItem::factory()->create([
'bom_template_id' => $this->bomTemplate->id,
'ref_type' => 'PRODUCT',
'ref_id' => $this->product1->id,
'quantity' => 2,
'waste_rate' => 5,
'order' => 1
]);
BomTemplateItem::factory()->create([
'bom_template_id' => $this->bomTemplate->id,
'ref_type' => 'MATERIAL',
'ref_id' => $this->material1->id,
'quantity' => 1,
'waste_rate' => 10,
'order' => 2
]);
// Create test parameters
ModelParameter::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'parameter_name' => 'W0',
'parameter_type' => 'NUMBER',
'default_value' => '800',
'min_value' => 500,
'max_value' => 2000,
'unit' => 'mm'
]);
ModelParameter::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'parameter_name' => 'H0',
'parameter_type' => 'NUMBER',
'default_value' => '600',
'min_value' => 400,
'max_value' => 1500,
'unit' => 'mm'
]);
ModelParameter::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'parameter_name' => 'screen_type',
'parameter_type' => 'SELECT',
'options' => ['FABRIC', 'STEEL', 'PLASTIC'],
'default_value' => 'FABRIC'
]);
// Create formulas
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'W1',
'expression' => 'W0 + 100',
'sort_order' => 1
]);
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'H1',
'expression' => 'H0 + 100',
'sort_order' => 2
]);
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'area',
'expression' => '(W1 * H1) / 1000000',
'sort_order' => 3
]);
// Authenticate user
Sanctum::actingAs($this->user, ['*']);
Auth::login($this->user);
}
/** @test */
public function can_resolve_basic_bom_without_rules()
{
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'model' => ['id', 'code', 'name'],
'input_parameters',
'calculated_values',
'matched_rules',
'base_bom_template_id',
'resolved_bom',
'summary'
]
]);
$data = $response->json('data');
// Check calculated values
$this->assertEquals(1100, $data['calculated_values']['W1']); // 1000 + 100
$this->assertEquals(900, $data['calculated_values']['H1']); // 800 + 100
$this->assertEquals(0.99, $data['calculated_values']['area']); // (1100 * 900) / 1000000
// Check resolved BOM contains base template items
$this->assertCount(2, $data['resolved_bom']);
$this->assertEquals('PRODUCT', $data['resolved_bom'][0]['target_type']);
$this->assertEquals($this->product1->id, $data['resolved_bom'][0]['target_id']);
$this->assertEquals(2, $data['resolved_bom'][0]['quantity']);
}
/** @test */
public function can_resolve_bom_with_include_rules()
{
// Create rule to include motor for large widths
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Large Width Motor',
'condition_expression' => 'W0 > 1200',
'action_type' => 'INCLUDE',
'target_type' => 'PRODUCT',
'target_id' => $this->product2->id,
'quantity_multiplier' => 1,
'sort_order' => 1
]);
$inputParameters = [
'W0' => 1500, // Triggers the rule
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Should have base template items + motor
$this->assertCount(3, $data['resolved_bom']);
// Check if motor was added
$motorItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product2->id);
$this->assertNotNull($motorItem);
$this->assertEquals('PRODUCT', $motorItem['target_type']);
$this->assertEquals(1, $motorItem['quantity']);
$this->assertEquals('Large Width Motor', $motorItem['reason']);
}
/** @test */
public function can_resolve_bom_with_exclude_rules()
{
// Create rule to exclude fabric for steel type
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Steel Type Exclude Fabric',
'condition_expression' => 'screen_type == "STEEL"',
'action_type' => 'EXCLUDE',
'target_type' => 'MATERIAL',
'target_id' => $this->material1->id,
'sort_order' => 1
]);
$inputParameters = [
'W0' => 1000,
'H0' => 800,
'screen_type' => 'STEEL'
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Should only have bracket (fabric excluded)
$this->assertCount(1, $data['resolved_bom']);
$this->assertEquals($this->product1->id, $data['resolved_bom'][0]['target_id']);
// Verify fabric is not in BOM
$fabricItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material1->id);
$this->assertNull($fabricItem);
}
/** @test */
public function can_resolve_bom_with_modify_quantity_rules()
{
// Create rule to modify bracket quantity for large areas
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Large Area Extra Brackets',
'condition_expression' => 'area > 1.0',
'action_type' => 'MODIFY_QUANTITY',
'target_type' => 'PRODUCT',
'target_id' => $this->product1->id,
'quantity_multiplier' => 2.0,
'sort_order' => 1
]);
$inputParameters = [
'W0' => 1200, // area = (1300 * 900) / 1000000 = 1.17
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Find bracket item
$bracketItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product1->id);
$this->assertNotNull($bracketItem);
$this->assertEquals(4, $bracketItem['quantity']); // 2 * 2.0
$this->assertEquals('Large Area Extra Brackets', $bracketItem['reason']);
}
/** @test */
public function can_resolve_bom_with_multiple_rules()
{
// Create multiple rules that should all apply
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Add Motor',
'condition_expression' => 'W0 > 1000',
'action_type' => 'INCLUDE',
'target_type' => 'PRODUCT',
'target_id' => $this->product2->id,
'quantity_multiplier' => 1,
'sort_order' => 1
]);
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Add Rail',
'condition_expression' => 'screen_type == "FABRIC"',
'action_type' => 'INCLUDE',
'target_type' => 'MATERIAL',
'target_id' => $this->material2->id,
'quantity_multiplier' => 2.0,
'sort_order' => 2
]);
$inputParameters = [
'W0' => 1200, // Triggers first rule
'H0' => 800,
'screen_type' => 'FABRIC' // Triggers second rule
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Should have all items: bracket, fabric (base) + motor, rail (rules)
$this->assertCount(4, $data['resolved_bom']);
// Check all matched rules
$this->assertCount(2, $data['matched_rules']);
// Verify motor was added
$motorItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product2->id);
$this->assertNotNull($motorItem);
// Verify rail was added
$railItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material2->id);
$this->assertNotNull($railItem);
$this->assertEquals(2.0, $railItem['quantity']);
}
/** @test */
public function can_preview_bom_without_saving()
{
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/preview', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'model',
'input_parameters',
'calculated_values',
'resolved_bom',
'summary'
]
]);
// Verify it's the same as resolve but without persistence
$data = $response->json('data');
$this->assertIsArray($data['resolved_bom']);
$this->assertIsArray($data['summary']);
}
/** @test */
public function can_compare_bom_by_different_parameters()
{
$parameters1 = [
'W0' => 800,
'H0' => 600,
'screen_type' => 'FABRIC'
];
$parameters2 = [
'W0' => 1200,
'H0' => 800,
'screen_type' => 'STEEL'
];
$response = $this->postJson('/api/v1/design/bom-resolver/compare', [
'model_id' => $this->model->id,
'parameters1' => $parameters1,
'parameters2' => $parameters2
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'parameters_diff' => [
'set1',
'set2',
'changed'
],
'calculated_values_diff' => [
'set1',
'set2',
'changed'
],
'bom_diff' => [
'added',
'removed',
'modified',
'summary'
],
'summary_diff'
]
]);
$data = $response->json('data');
// Check parameter differences
$this->assertEquals(400, $data['parameters_diff']['changed']['W0']); // 1200 - 800
$this->assertEquals(200, $data['parameters_diff']['changed']['H0']); // 800 - 600
$this->assertEquals('STEEL', $data['parameters_diff']['changed']['screen_type']);
}
/** @test */
public function can_save_and_retrieve_bom_resolution()
{
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
// First resolve and save
$resolveResponse = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters,
'save_resolution' => true,
'purpose' => 'ESTIMATION'
]);
$resolveResponse->assertStatus(200);
$resolutionId = $resolveResponse->json('data.resolution_id');
$this->assertNotNull($resolutionId);
// Then retrieve saved resolution
$retrieveResponse = $this->getJson('/api/v1/design/bom-resolver/resolution/' . $resolutionId);
$retrieveResponse->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'resolution_id',
'model_id',
'input_parameters',
'calculated_values',
'resolved_bom',
'purpose',
'saved_at'
]
]);
}
/** @test */
public function handles_missing_bom_template_gracefully()
{
// Delete the BOM template
$this->bomTemplate->delete();
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Should return empty BOM with calculated values
$this->assertEmpty($data['resolved_bom']);
$this->assertNull($data['base_bom_template_id']);
$this->assertNotEmpty($data['calculated_values']);
}
/** @test */
public function validates_input_parameters()
{
// Test missing required parameter
$invalidParameters = [
'H0' => 800
// Missing W0
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $invalidParameters
]);
$response->assertStatus(422);
// Test parameter out of range
$outOfRangeParameters = [
'W0' => 3000, // Max is 2000
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $outOfRangeParameters
]);
$response->assertStatus(422);
}
/** @test */
public function enforces_tenant_isolation()
{
// Create model for different tenant
$otherTenant = Tenant::factory()->create();
$otherModel = DesignModel::factory()->create([
'tenant_id' => $otherTenant->id
]);
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $otherModel->id,
'parameters' => $inputParameters
]);
$response->assertStatus(404);
}
/** @test */
public function can_handle_bom_with_waste_rates()
{
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
// Check waste rate calculation
$bracketItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product1->id);
$this->assertNotNull($bracketItem);
$this->assertEquals(2, $bracketItem['quantity']); // Base quantity
$this->assertEquals(2.1, $bracketItem['actual_quantity']); // 2 * (1 + 5/100)
$fabricItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material1->id);
$this->assertNotNull($fabricItem);
$this->assertEquals(1, $fabricItem['quantity']); // Base quantity
$this->assertEquals(1.1, $fabricItem['actual_quantity']); // 1 * (1 + 10/100)
}
/** @test */
public function can_test_kss01_specific_scenario()
{
// Test KSS01 specific logic using the built-in test method
$kssParameters = [
'W0' => 1200,
'H0' => 800,
'screen_type' => 'FABRIC',
'install_type' => 'WALL'
];
$response = $this->postJson('/api/v1/design/bom-resolver/kss01-test', [
'parameters' => $kssParameters
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'model' => ['code', 'name'],
'input_parameters',
'calculated_values' => ['W1', 'H1', 'area'],
'resolved_bom',
'summary'
]
]);
$data = $response->json('data');
$this->assertEquals('KSS01', $data['model']['code']);
$this->assertEquals(1300, $data['calculated_values']['W1']); // 1200 + 100
$this->assertEquals(900, $data['calculated_values']['H1']); // 800 + 100
}
/** @test */
public function can_handle_formula_calculation_errors()
{
// Create formula with potential division by zero
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'ratio',
'expression' => 'W0 / (H0 - 800)', // Division by zero when H0 = 800
'sort_order' => 4
]);
$inputParameters = [
'W0' => 1000,
'H0' => 800 // This will cause division by zero
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
// Should handle the error gracefully
$response->assertStatus(422)
->assertJsonFragment([
'error' => 'Formula calculation error'
]);
}
/** @test */
public function generates_comprehensive_summary()
{
$inputParameters = [
'W0' => 1000,
'H0' => 800
];
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
'model_id' => $this->model->id,
'parameters' => $inputParameters
]);
$response->assertStatus(200);
$data = $response->json('data');
$summary = $data['summary'];
$this->assertArrayHasKey('total_items', $summary);
$this->assertArrayHasKey('material_count', $summary);
$this->assertArrayHasKey('product_count', $summary);
$this->assertArrayHasKey('total_estimated_value', $summary);
$this->assertArrayHasKey('generated_at', $summary);
$this->assertEquals(2, $summary['total_items']);
$this->assertEquals(1, $summary['material_count']);
$this->assertEquals(1, $summary['product_count']);
}
}