feat: DB 연결 오버라이딩 및 대시보드 통계 위젯 추가

- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env)
- 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget)
- 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget)
- 리소스 한국어화: Product, Material 모델 레이블 추가
- 대시보드: 위젯 등록 및 캐시 최적화

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 23:31:14 +09:00
parent d94ab59fd1
commit bf8036a64b
81 changed files with 22632 additions and 102 deletions

View File

@@ -0,0 +1,679 @@
<?php
namespace Tests\Feature\Design;
use Tests\TestCase;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelParameter;
use App\Models\Design\ModelFormula;
use App\Models\Design\BomConditionRule;
use App\Models\Product;
use App\Models\Material;
use App\Models\Category;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\Sanctum;
class ProductFromModelTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Tenant $tenant;
private DesignModel $model;
private Category $category;
private Product $baseMaterial;
private Product $baseProduct;
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 category
$this->category = Category::factory()->create([
'tenant_id' => $this->tenant->id,
'name' => 'Screen Systems',
'code' => 'SCREENS'
]);
// Create test design model
$this->model = DesignModel::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'KSS01',
'name' => 'Screen Door System',
'category_id' => $this->category->id,
'is_active' => true
]);
// Create base materials and products for BOM
$this->baseMaterial = Material::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'FABRIC001',
'name' => 'Screen Fabric'
]);
$this->baseProduct = Product::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'BRACKET001',
'name' => 'Wall Bracket'
]);
// Create test parameters
ModelParameter::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'parameter_name' => 'width',
'parameter_type' => 'NUMBER',
'unit' => 'mm'
]);
ModelParameter::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'parameter_name' => 'height',
'parameter_type' => 'NUMBER',
'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']
]);
// Create test formulas
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'outer_width',
'expression' => 'width + 100'
]);
ModelFormula::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'formula_name' => 'outer_height',
'expression' => 'height + 100'
]);
// Authenticate user
Sanctum::actingAs($this->user, ['*']);
Auth::login($this->user);
}
/** @test */
public function can_create_product_from_model_with_parameters()
{
$productData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1200,
'height' => 800,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'KSS01-1200x800-FABRIC',
'name' => 'Screen Door 1200x800 Fabric',
'category_id' => $this->category->id,
'description' => 'Custom screen door based on KSS01 model',
'unit' => 'EA'
],
'generate_bom' => true
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'message.created'
])
->assertJsonStructure([
'data' => [
'product' => [
'id',
'code',
'name',
'category_id',
'description'
],
'model_reference' => [
'model_id',
'input_parameters',
'calculated_values'
],
'bom_created',
'bom_items_count'
]
]);
// Verify product was created
$this->assertDatabaseHas('products', [
'tenant_id' => $this->tenant->id,
'code' => 'KSS01-1200x800-FABRIC',
'name' => 'Screen Door 1200x800 Fabric'
]);
$data = $response->json('data');
$this->assertEquals($this->model->id, $data['model_reference']['model_id']);
$this->assertEquals(1200, $data['model_reference']['input_parameters']['width']);
$this->assertEquals(1300, $data['model_reference']['calculated_values']['outer_width']); // width + 100
}
/** @test */
public function can_create_product_with_auto_generated_code()
{
$productData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1000,
'height' => 600,
'screen_type' => 'STEEL'
],
'product_data' => [
'name' => 'Custom Steel Screen',
'category_id' => $this->category->id,
'auto_generate_code' => true
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201);
$data = $response->json('data');
// Code should be auto-generated based on model and parameters
$expectedCode = 'KSS01-1000x600-STEEL';
$this->assertEquals($expectedCode, $data['product']['code']);
$this->assertDatabaseHas('products', [
'tenant_id' => $this->tenant->id,
'code' => $expectedCode
]);
}
/** @test */
public function can_create_product_with_bom_generation()
{
// Create rule for BOM generation
BomConditionRule::factory()->create([
'tenant_id' => $this->tenant->id,
'model_id' => $this->model->id,
'rule_name' => 'Add Fabric Material',
'condition_expression' => 'screen_type == "FABRIC"',
'action_type' => 'INCLUDE',
'target_type' => 'MATERIAL',
'target_id' => $this->baseMaterial->id,
'quantity_multiplier' => 1.2
]);
$productData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1200,
'height' => 800,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'TEST-PRODUCT-001',
'name' => 'Test Product with BOM',
'category_id' => $this->category->id
],
'generate_bom' => true
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201);
$data = $response->json('data');
$this->assertTrue($data['bom_created']);
$this->assertGreaterThan(0, $data['bom_items_count']);
// Verify BOM was created in product_components table
$product = Product::where('code', 'TEST-PRODUCT-001')->first();
$this->assertDatabaseHas('product_components', [
'tenant_id' => $this->tenant->id,
'product_id' => $product->id,
'ref_type' => 'MATERIAL',
'ref_id' => $this->baseMaterial->id
]);
}
/** @test */
public function can_create_product_without_bom_generation()
{
$productData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1000,
'height' => 600,
'screen_type' => 'STEEL'
],
'product_data' => [
'code' => 'TEST-NO-BOM',
'name' => 'Test Product without BOM',
'category_id' => $this->category->id
],
'generate_bom' => false
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201);
$data = $response->json('data');
$this->assertFalse($data['bom_created']);
$this->assertEquals(0, $data['bom_items_count']);
// Verify no BOM components were created
$product = Product::where('code', 'TEST-NO-BOM')->first();
$this->assertDatabaseMissing('product_components', [
'product_id' => $product->id
]);
}
/** @test */
public function can_preview_product_before_creation()
{
$previewData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1500,
'height' => 1000,
'screen_type' => 'FABRIC'
],
'product_data' => [
'name' => 'Preview Product',
'category_id' => $this->category->id,
'auto_generate_code' => true
],
'generate_bom' => true
];
$response = $this->postJson('/api/v1/design/product-from-model/preview', $previewData);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'preview_product' => [
'code',
'name',
'category_id'
],
'model_reference' => [
'model_id',
'input_parameters',
'calculated_values'
],
'preview_bom' => [
'items',
'summary'
]
]
]);
// Verify no actual product was created
$data = $response->json('data');
$this->assertDatabaseMissing('products', [
'code' => $data['preview_product']['code']
]);
}
/** @test */
public function validates_required_parameters()
{
// Missing required width parameter
$invalidData = [
'model_id' => $this->model->id,
'parameters' => [
'height' => 800, // Missing width
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'INVALID-PRODUCT',
'name' => 'Invalid Product'
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $invalidData);
$response->assertStatus(422);
// Parameter out of valid range
$outOfRangeData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => -100, // Invalid negative value
'height' => 800,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'OUT-OF-RANGE',
'name' => 'Out of Range Product'
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $outOfRangeData);
$response->assertStatus(422);
}
/** @test */
public function validates_product_code_uniqueness()
{
// Create first product
$productData1 = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1000,
'height' => 600,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'DUPLICATE-CODE',
'name' => 'First Product',
'category_id' => $this->category->id
]
];
$response1 = $this->postJson('/api/v1/design/product-from-model', $productData1);
$response1->assertStatus(201);
// Try to create second product with same code
$productData2 = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1200,
'height' => 800,
'screen_type' => 'STEEL'
],
'product_data' => [
'code' => 'DUPLICATE-CODE', // Same code
'name' => 'Second Product',
'category_id' => $this->category->id
]
];
$response2 = $this->postJson('/api/v1/design/product-from-model', $productData2);
$response2->assertStatus(422);
}
/** @test */
public function can_create_product_with_custom_attributes()
{
$productData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1200,
'height' => 800,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'CUSTOM-ATTRS',
'name' => 'Product with Custom Attributes',
'category_id' => $this->category->id,
'description' => 'Product with extended attributes',
'unit' => 'SET',
'weight' => 15.5,
'color' => 'WHITE',
'material_grade' => 'A-GRADE'
],
'custom_attributes' => [
'installation_difficulty' => 'MEDIUM',
'warranty_period' => '2_YEARS',
'fire_rating' => 'B1'
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201);
$this->assertDatabaseHas('products', [
'tenant_id' => $this->tenant->id,
'code' => 'CUSTOM-ATTRS',
'weight' => 15.5,
'color' => 'WHITE'
]);
}
/** @test */
public function can_create_multiple_products_from_same_model()
{
$baseData = [
'model_id' => $this->model->id,
'generate_bom' => false
];
$products = [
[
'parameters' => ['width' => 800, 'height' => 600, 'screen_type' => 'FABRIC'],
'code' => 'KSS01-800x600-FABRIC'
],
[
'parameters' => ['width' => 1000, 'height' => 800, 'screen_type' => 'STEEL'],
'code' => 'KSS01-1000x800-STEEL'
],
[
'parameters' => ['width' => 1200, 'height' => 1000, 'screen_type' => 'FABRIC'],
'code' => 'KSS01-1200x1000-FABRIC'
]
];
foreach ($products as $index => $productSpec) {
$productData = array_merge($baseData, [
'parameters' => $productSpec['parameters'],
'product_data' => [
'code' => $productSpec['code'],
'name' => 'Product ' . ($index + 1),
'category_id' => $this->category->id
]
]);
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(201);
}
// Verify all products were created
foreach ($products as $productSpec) {
$this->assertDatabaseHas('products', [
'tenant_id' => $this->tenant->id,
'code' => $productSpec['code']
]);
}
}
/** @test */
public function can_list_products_created_from_model()
{
// Create some products from the model
$this->createTestProductFromModel('PROD-1', ['width' => 800, 'height' => 600]);
$this->createTestProductFromModel('PROD-2', ['width' => 1000, 'height' => 800]);
// Create a product not from model
Product::factory()->create([
'tenant_id' => $this->tenant->id,
'code' => 'NON-MODEL-PROD'
]);
$response = $this->getJson('/api/v1/design/product-from-model/list?model_id=' . $this->model->id);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'data' => [
'*' => [
'id',
'code',
'name',
'model_reference' => [
'model_id',
'input_parameters',
'calculated_values'
],
'created_at'
]
]
]
]);
$data = $response->json('data.data');
$this->assertCount(2, $data); // Only products created from model
}
/** @test */
public function enforces_tenant_isolation()
{
// Create model for different tenant
$otherTenant = Tenant::factory()->create();
$otherModel = DesignModel::factory()->create([
'tenant_id' => $otherTenant->id
]);
$productData = [
'model_id' => $otherModel->id,
'parameters' => [
'width' => 1000,
'height' => 800
],
'product_data' => [
'code' => 'TENANT-TEST',
'name' => 'Tenant Test Product'
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
$response->assertStatus(404);
}
/** @test */
public function can_update_existing_product_from_model()
{
// First create a product
$createData = [
'model_id' => $this->model->id,
'parameters' => [
'width' => 1000,
'height' => 600,
'screen_type' => 'FABRIC'
],
'product_data' => [
'code' => 'UPDATE-TEST',
'name' => 'Original Product',
'category_id' => $this->category->id
]
];
$createResponse = $this->postJson('/api/v1/design/product-from-model', $createData);
$createResponse->assertStatus(201);
$productId = $createResponse->json('data.product.id');
// Then update it with new parameters
$updateData = [
'parameters' => [
'width' => 1200, // Changed
'height' => 800, // Changed
'screen_type' => 'STEEL' // Changed
],
'product_data' => [
'name' => 'Updated Product', // Changed
'description' => 'Updated description'
],
'regenerate_bom' => true
];
$updateResponse = $this->putJson('/api/v1/design/product-from-model/' . $productId, $updateData);
$updateResponse->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'message.updated'
]);
// Verify product was updated
$this->assertDatabaseHas('products', [
'id' => $productId,
'name' => 'Updated Product'
]);
}
/** @test */
public function can_clone_product_with_modified_parameters()
{
// Create original product
$originalProductId = $this->createTestProductFromModel('ORIGINAL', [
'width' => 1000,
'height' => 600,
'screen_type' => 'FABRIC'
]);
// Clone with modified parameters
$cloneData = [
'source_product_id' => $originalProductId,
'parameters' => [
'width' => 1200, // Modified
'height' => 600, // Same
'screen_type' => 'STEEL' // Modified
],
'product_data' => [
'code' => 'CLONED-PRODUCT',
'name' => 'Cloned Product',
'category_id' => $this->category->id
]
];
$response = $this->postJson('/api/v1/design/product-from-model/clone', $cloneData);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'message.cloned'
]);
// Verify clone was created
$this->assertDatabaseHas('products', [
'tenant_id' => $this->tenant->id,
'code' => 'CLONED-PRODUCT'
]);
// Verify original product still exists
$this->assertDatabaseHas('products', [
'id' => $originalProductId
]);
}
/**
* Helper method to create a test product from model
*/
private function createTestProductFromModel(string $code, array $parameters): int
{
$productData = [
'model_id' => $this->model->id,
'parameters' => array_merge([
'width' => 1000,
'height' => 600,
'screen_type' => 'FABRIC'
], $parameters),
'product_data' => [
'code' => $code,
'name' => 'Test Product ' . $code,
'category_id' => $this->category->id
]
];
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
return $response->json('data.product.id');
}
}