service = new ProductFromModelService(); $this->service->setTenantId(1)->setApiUserId(1); $this->model = Model::factory()->screen()->create(['code' => 'KSS01']); $this->setupKSS01Model(); } private function setupKSS01Model(): void { // Create parameters ModelParameter::factory() ->screenParameters() ->create(['model_id' => $this->model->id]); // Create formulas ModelFormula::factory() ->screenFormulas() ->create(['model_id' => $this->model->id]); // Create condition rules BomConditionRule::factory() ->screenRules() ->create(['model_id' => $this->model->id]); } /** @test */ public function it_can_resolve_bom_for_small_screen() { // Arrange $inputParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act $result = $this->service->resolveBom($this->model->id, $inputParams); // Assert $this->assertArrayHasKey('parameters', $result); $this->assertArrayHasKey('formulas', $result); $this->assertArrayHasKey('bom_items', $result); // Check calculated formulas $formulas = $result['formulas']; $this->assertEquals(1120, $formulas['W1']); // W0 + 120 $this->assertEquals(900, $formulas['H1']); // H0 + 100 $this->assertEquals(1.008, $formulas['area']); // W1 * H1 / 1000000 // Check BOM items $bomItems = $result['bom_items']; $this->assertGreaterThan(0, count($bomItems)); // Check specific components $caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE')); $this->assertNotNull($caseItem); $this->assertEquals('CASE-SMALL', $caseItem['component_code']); // area <= 3 } /** @test */ public function it_can_resolve_bom_for_large_screen() { // Arrange $inputParams = [ 'W0' => 2500, 'H0' => 1500, 'screen_type' => 'SCREEN', 'install_type' => 'SIDE', 'power_source' => 'AC', ]; // Act $result = $this->service->resolveBom($this->model->id, $inputParams); // Assert $formulas = $result['formulas']; $this->assertEquals(2620, $formulas['W1']); // 2500 + 120 $this->assertEquals(1600, $formulas['H1']); // 1500 + 100 $this->assertEquals(4.192, $formulas['area']); // 2620 * 1600 / 1000000 // Check that large case is selected $bomItems = $result['bom_items']; $caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE')); $this->assertEquals('CASE-MEDIUM', $caseItem['component_code']); // 3 < area <= 6 // Check bracket quantity calculation $bracketItem = collect($bomItems)->first(fn($item) => $item['component_code'] === 'BOTTOM-001'); $this->assertNotNull($bracketItem); $this->assertEquals(3, $bracketItem['quantity']); // CEIL(2620 / 1000) } /** @test */ public function it_can_resolve_bom_for_maximum_size() { // Arrange $inputParams = [ 'W0' => 3000, 'H0' => 2000, 'screen_type' => 'SCREEN', 'install_type' => 'MIXED', 'power_source' => 'DC', ]; // Act $result = $this->service->resolveBom($this->model->id, $inputParams); // Assert $formulas = $result['formulas']; $this->assertEquals(3120, $formulas['W1']); $this->assertEquals(2100, $formulas['H1']); $this->assertEquals(6.552, $formulas['area']); // > 6 // Check that large case is selected $bomItems = $result['bom_items']; $caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE')); $this->assertEquals('CASE-LARGE', $caseItem['component_code']); // area > 6 // Check motor capacity $this->assertEquals('2HP', $formulas['motor']); // area > 6 } /** @test */ public function it_handles_slat_type_differences() { // Arrange $inputParams = [ 'W0' => 1500, 'H0' => 1000, 'screen_type' => 'SLAT', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act $result = $this->service->resolveBom($this->model->id, $inputParams); // Assert $bomItems = $result['bom_items']; // Check that SLAT pipe is used instead of SCREEN pipe $screenPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SCREEN'); $slatPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SLAT'); $this->assertNull($screenPipe); $this->assertNotNull($slatPipe); } /** @test */ public function it_validates_input_parameters() { // Test missing required parameter $incompleteParams = [ 'W0' => 1000, // Missing H0, screen_type, etc. ]; $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Missing required parameter'); $this->service->resolveBom($this->model->id, $incompleteParams); } /** @test */ public function it_validates_parameter_ranges() { // Test out-of-range parameter $invalidParams = [ 'W0' => 100, // Below minimum (500) 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Parameter W0 value 100 is outside valid range'); $this->service->resolveBom($this->model->id, $invalidParams); } /** @test */ public function it_validates_select_parameter_options() { // Test invalid select option $invalidParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'INVALID_TYPE', 'install_type' => 'WALL', 'power_source' => 'AC', ]; $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid value for parameter screen_type'); $this->service->resolveBom($this->model->id, $invalidParams); } /** @test */ public function it_can_preview_product_before_creation() { // Arrange $inputParams = [ 'W0' => 1200, 'H0' => 900, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act $preview = $this->service->previewProduct($this->model->id, $inputParams); // Assert $this->assertArrayHasKey('product_info', $preview); $this->assertArrayHasKey('bom_summary', $preview); $this->assertArrayHasKey('estimated_cost', $preview); $productInfo = $preview['product_info']; $this->assertStringContains('KSS01', $productInfo['suggested_code']); $this->assertStringContains('1200x900', $productInfo['suggested_name']); $bomSummary = $preview['bom_summary']; $this->assertArrayHasKey('total_components', $bomSummary); $this->assertArrayHasKey('component_categories', $bomSummary); } /** @test */ public function it_can_create_product_from_resolved_bom() { // Arrange $inputParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; $productData = [ 'code' => 'KSS01-001', 'name' => '스크린 블라인드 1000x800', 'description' => '매개변수 기반 생성 제품', ]; // Act $result = $this->service->createProductFromModel($this->model->id, $inputParams, $productData); // Assert $this->assertArrayHasKey('product', $result); $this->assertArrayHasKey('bom_items', $result); $product = $result['product']; $this->assertEquals('KSS01-001', $product['code']); $this->assertEquals('스크린 블라인드 1000x800', $product['name']); $bomItems = $result['bom_items']; $this->assertGreaterThan(0, count($bomItems)); // Verify BOM items are properly linked to the product foreach ($bomItems as $item) { $this->assertEquals($product['id'], $item['product_id']); $this->assertNotEmpty($item['component_code']); $this->assertGreaterThan(0, $item['quantity']); } } /** @test */ public function it_handles_formula_evaluation_errors_gracefully() { // Arrange - Create a formula with invalid expression ModelFormula::factory() ->create([ 'model_id' => $this->model->id, 'name' => 'invalid_formula', 'expression' => 'UNKNOWN_FUNCTION(W0)', 'sort_order' => 999, ]); $inputParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act & Assert $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Formula evaluation failed'); $this->service->resolveBom($this->model->id, $inputParams); } /** @test */ public function it_respects_tenant_isolation() { // Arrange $otherTenantModel = Model::factory()->create(['tenant_id' => 2]); $inputParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act & Assert $this->expectException(\ModelNotFoundException::class); $this->service->resolveBom($otherTenantModel->id, $inputParams); } /** @test */ public function it_caches_formula_results_for_performance() { // Arrange $inputParams = [ 'W0' => 1000, 'H0' => 800, 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; // Act - First call $start1 = microtime(true); $result1 = $this->service->resolveBom($this->model->id, $inputParams); $time1 = microtime(true) - $start1; // Act - Second call with same parameters $start2 = microtime(true); $result2 = $this->service->resolveBom($this->model->id, $inputParams); $time2 = microtime(true) - $start2; // Assert $this->assertEquals($result1['formulas'], $result2['formulas']); $this->assertEquals($result1['bom_items'], $result2['bom_items']); // Second call should be faster due to caching $this->assertLessThan($time1, $time2 * 2); // Allow some variance } /** @test */ public function it_handles_boundary_conditions_correctly() { // Test exactly at boundary values $boundaryTestCases = [ // Test area exactly at 3 (boundary between small and medium case) [ 'W0' => 1612, // Will result in W1=1732, need H1=1732 for area=3 'H0' => 1632, // Will result in H1=1732, area = 1732*1732/1000000 ≈ 3 'expected_case' => 'CASE-SMALL', // area <= 3 ], // Test area exactly at 6 (boundary between medium and large case) [ 'W0' => 2329, // Will result in area slightly above 6 'H0' => 2349, 'expected_case' => 'CASE-LARGE', // area > 6 ], ]; foreach ($boundaryTestCases as $testCase) { $inputParams = [ 'W0' => $testCase['W0'], 'H0' => $testCase['H0'], 'screen_type' => 'SCREEN', 'install_type' => 'WALL', 'power_source' => 'AC', ]; $result = $this->service->resolveBom($this->model->id, $inputParams); $bomItems = $result['bom_items']; $caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE')); $this->assertEquals($testCase['expected_case'], $caseItem['component_code'], "Failed boundary test for W0={$testCase['W0']}, H0={$testCase['H0']}, area={$result['formulas']['area']}"); } } }