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']); } }