- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env) - 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget) - 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget) - 리소스 한국어화: Product, Material 모델 레이블 추가 - 대시보드: 위젯 등록 및 캐시 최적화 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
496 lines
16 KiB
PHP
496 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Services\Service;
|
||
use Shared\Models\Products\ModelMaster;
|
||
use Shared\Models\Products\Product;
|
||
use Shared\Models\Products\ProductComponent;
|
||
use Shared\Models\Products\BomTemplate;
|
||
use Shared\Models\Products\BomTemplateItem;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Product From Model Service
|
||
* 모델 기반 제품 생성 서비스
|
||
*/
|
||
class ProductFromModelService extends Service
|
||
{
|
||
private BomResolverService $bomResolverService;
|
||
|
||
public function __construct()
|
||
{
|
||
parent::__construct();
|
||
$this->bomResolverService = new BomResolverService();
|
||
}
|
||
|
||
/**
|
||
* 모델에서 제품 생성
|
||
*/
|
||
public function createProductFromModel(int $modelId, array $inputParameters, array $productData = []): Product
|
||
{
|
||
$this->validateModelAccess($modelId);
|
||
|
||
return DB::transaction(function () use ($modelId, $inputParameters, $productData) {
|
||
// 1. BOM 해석
|
||
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
|
||
|
||
// 2. 제품 기본 정보 생성
|
||
$product = $this->createProduct($modelId, $inputParameters, $resolvedBom, $productData);
|
||
|
||
// 3. BOM 스냅샷 저장
|
||
$this->saveBomSnapshot($product->id, $resolvedBom);
|
||
|
||
// 4. 매개변수 및 계산값 저장
|
||
$this->saveParameterSnapshot($product->id, $resolvedBom);
|
||
|
||
return $product->fresh(['components']);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 제품 코드 생성 미리보기
|
||
*/
|
||
public function previewProductCode(int $modelId, array $inputParameters): string
|
||
{
|
||
$this->validateModelAccess($modelId);
|
||
|
||
$model = ModelMaster::where('tenant_id', $this->tenantId())
|
||
->findOrFail($modelId);
|
||
|
||
// BOM 해석하여 계산값 가져오기
|
||
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
|
||
|
||
return $this->generateProductCode($model, $resolvedBom['calculated_values']);
|
||
}
|
||
|
||
/**
|
||
* 제품 사양서 생성
|
||
*/
|
||
public function generateProductSpecification(int $modelId, array $inputParameters): array
|
||
{
|
||
$this->validateModelAccess($modelId);
|
||
|
||
$model = ModelMaster::where('tenant_id', $this->tenantId())
|
||
->findOrFail($modelId);
|
||
|
||
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
|
||
|
||
return [
|
||
'model' => [
|
||
'id' => $model->id,
|
||
'code' => $model->code,
|
||
'name' => $model->name,
|
||
'version' => $model->current_version,
|
||
],
|
||
'parameters' => $this->formatParameters($resolvedBom['input_parameters']),
|
||
'calculated_values' => $this->formatCalculatedValues($resolvedBom['calculated_values']),
|
||
'bom_summary' => $resolvedBom['summary'],
|
||
'specifications' => $this->generateSpecifications($model, $resolvedBom),
|
||
'generated_at' => now()->toISOString(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 대량 제품 생성
|
||
*/
|
||
public function createProductsBatch(int $modelId, array $parameterSets, array $baseProductData = []): array
|
||
{
|
||
$this->validateModelAccess($modelId);
|
||
|
||
$results = [];
|
||
|
||
DB::transaction(function () use ($modelId, $parameterSets, $baseProductData, &$results) {
|
||
foreach ($parameterSets as $index => $parameters) {
|
||
try {
|
||
$productData = array_merge($baseProductData, [
|
||
'name' => ($baseProductData['name'] ?? '') . ' #' . ($index + 1),
|
||
]);
|
||
|
||
$product = $this->createProductFromModel($modelId, $parameters, $productData);
|
||
|
||
$results[$index] = [
|
||
'success' => true,
|
||
'product_id' => $product->id,
|
||
'product_code' => $product->code,
|
||
'parameters' => $parameters,
|
||
];
|
||
} catch (\Throwable $e) {
|
||
$results[$index] = [
|
||
'success' => false,
|
||
'error' => $e->getMessage(),
|
||
'parameters' => $parameters,
|
||
];
|
||
}
|
||
}
|
||
});
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* 기존 제품의 BOM 업데이트
|
||
*/
|
||
public function updateProductBom(int $productId, array $newParameters): Product
|
||
{
|
||
$product = Product::where('tenant_id', $this->tenantId())
|
||
->findOrFail($productId);
|
||
|
||
if (!$product->source_model_id) {
|
||
throw new \InvalidArgumentException(__('error.product_not_from_model'));
|
||
}
|
||
|
||
return DB::transaction(function () use ($product, $newParameters) {
|
||
// 1. 새 BOM 해석
|
||
$resolvedBom = $this->bomResolverService->resolveBom($product->source_model_id, $newParameters);
|
||
|
||
// 2. 기존 BOM 컴포넌트 삭제
|
||
ProductComponent::where('product_id', $product->id)->delete();
|
||
|
||
// 3. 새 BOM 스냅샷 저장
|
||
$this->saveBomSnapshot($product->id, $resolvedBom);
|
||
|
||
// 4. 매개변수 업데이트
|
||
$this->saveParameterSnapshot($product->id, $resolvedBom, true);
|
||
|
||
// 5. 제품 정보 업데이트
|
||
$product->update([
|
||
'updated_by' => $this->apiUserId(),
|
||
]);
|
||
|
||
return $product->fresh(['components']);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 제품 버전 생성 (기존 제품의 파라미터 변경)
|
||
*/
|
||
public function createProductVersion(int $productId, array $newParameters, string $versionNote = ''): Product
|
||
{
|
||
$originalProduct = Product::where('tenant_id', $this->tenantId())
|
||
->findOrFail($productId);
|
||
|
||
if (!$originalProduct->source_model_id) {
|
||
throw new \InvalidArgumentException(__('error.product_not_from_model'));
|
||
}
|
||
|
||
return DB::transaction(function () use ($originalProduct, $newParameters, $versionNote) {
|
||
// 원본 제품 복제
|
||
$productData = $originalProduct->toArray();
|
||
unset($productData['id'], $productData['created_at'], $productData['updated_at'], $productData['deleted_at']);
|
||
|
||
// 버전 정보 추가
|
||
$productData['name'] = $originalProduct->name . ' (v' . now()->format('YmdHis') . ')';
|
||
$productData['parent_product_id'] = $originalProduct->id;
|
||
$productData['version_note'] = $versionNote;
|
||
|
||
$newProduct = $this->createProductFromModel(
|
||
$originalProduct->source_model_id,
|
||
$newParameters,
|
||
$productData
|
||
);
|
||
|
||
return $newProduct;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 제품 생성
|
||
*/
|
||
private function createProduct(int $modelId, array $inputParameters, array $resolvedBom, array $productData): Product
|
||
{
|
||
$model = ModelMaster::where('tenant_id', $this->tenantId())
|
||
->findOrFail($modelId);
|
||
|
||
// 기본 제품 데이터 설정
|
||
$defaultData = [
|
||
'tenant_id' => $this->tenantId(),
|
||
'code' => $this->generateProductCode($model, $resolvedBom['calculated_values']),
|
||
'name' => $productData['name'] ?? $this->generateProductName($model, $resolvedBom['calculated_values']),
|
||
'type' => 'PRODUCT',
|
||
'source_model_id' => $modelId,
|
||
'model_version' => $model->current_version,
|
||
'is_active' => true,
|
||
'created_by' => $this->apiUserId(),
|
||
];
|
||
|
||
$finalData = array_merge($defaultData, $productData);
|
||
|
||
return Product::create($finalData);
|
||
}
|
||
|
||
/**
|
||
* BOM 스냅샷 저장
|
||
*/
|
||
private function saveBomSnapshot(int $productId, array $resolvedBom): void
|
||
{
|
||
foreach ($resolvedBom['bom_items'] as $index => $item) {
|
||
ProductComponent::create([
|
||
'tenant_id' => $this->tenantId(),
|
||
'product_id' => $productId,
|
||
'ref_type' => $item['ref_type'] ?? ($item['material_id'] ? 'MATERIAL' : 'PRODUCT'),
|
||
'product_id_ref' => $item['product_id'] ?? null,
|
||
'material_id' => $item['material_id'] ?? null,
|
||
'quantity' => $item['total_quantity'],
|
||
'base_quantity' => $item['calculated_quantity'],
|
||
'waste_rate' => $item['waste_rate'] ?? 0,
|
||
'unit' => $item['unit'] ?? 'ea',
|
||
'memo' => $item['memo'] ?? null,
|
||
'order' => $item['order'] ?? $index + 1,
|
||
'created_by' => $this->apiUserId(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 매개변수 스냅샷 저장
|
||
*/
|
||
private function saveParameterSnapshot(int $productId, array $resolvedBom, bool $update = false): void
|
||
{
|
||
$parameterData = [
|
||
'input_parameters' => $resolvedBom['input_parameters'],
|
||
'calculated_values' => $resolvedBom['calculated_values'],
|
||
'bom_summary' => $resolvedBom['summary'],
|
||
'resolved_at' => $resolvedBom['resolved_at'],
|
||
];
|
||
|
||
$product = Product::findOrFail($productId);
|
||
|
||
if ($update) {
|
||
$existingSnapshot = $product->parameter_snapshot ?? [];
|
||
$parameterData = array_merge($existingSnapshot, $parameterData);
|
||
}
|
||
|
||
$product->update([
|
||
'parameter_snapshot' => $parameterData,
|
||
'updated_by' => $this->apiUserId(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 제품 코드 생성
|
||
*/
|
||
private function generateProductCode(ModelMaster $model, array $calculatedValues): string
|
||
{
|
||
// 모델 코드 + 주요 치수값으로 코드 생성
|
||
$code = $model->code;
|
||
|
||
// 주요 치수값 추가 (W1, H1 등)
|
||
$keyDimensions = ['W1', 'H1', 'W0', 'H0'];
|
||
$dimensionParts = [];
|
||
|
||
foreach ($keyDimensions as $dim) {
|
||
if (isset($calculatedValues[$dim]) && is_numeric($calculatedValues[$dim])) {
|
||
$dimensionParts[] = $dim . (int) $calculatedValues[$dim];
|
||
}
|
||
}
|
||
|
||
if (!empty($dimensionParts)) {
|
||
$code .= '_' . implode('_', $dimensionParts);
|
||
}
|
||
|
||
// 고유성 보장을 위한 접미사
|
||
$suffix = Str::upper(Str::random(4));
|
||
$code .= '_' . $suffix;
|
||
|
||
return $code;
|
||
}
|
||
|
||
/**
|
||
* 제품명 생성
|
||
*/
|
||
private function generateProductName(ModelMaster $model, array $calculatedValues): string
|
||
{
|
||
$name = $model->name;
|
||
|
||
// 주요 치수값 추가
|
||
$keyDimensions = ['W1', 'H1'];
|
||
$dimensionParts = [];
|
||
|
||
foreach ($keyDimensions as $dim) {
|
||
if (isset($calculatedValues[$dim]) && is_numeric($calculatedValues[$dim])) {
|
||
$dimensionParts[] = (int) $calculatedValues[$dim];
|
||
}
|
||
}
|
||
|
||
if (!empty($dimensionParts)) {
|
||
$name .= ' (' . implode('×', $dimensionParts) . ')';
|
||
}
|
||
|
||
return $name;
|
||
}
|
||
|
||
/**
|
||
* 매개변수 포맷팅
|
||
*/
|
||
private function formatParameters(array $parameters): array
|
||
{
|
||
$formatted = [];
|
||
|
||
foreach ($parameters as $name => $value) {
|
||
$formatted[] = [
|
||
'name' => $name,
|
||
'value' => $value,
|
||
'type' => is_numeric($value) ? 'number' : (is_bool($value) ? 'boolean' : 'text'),
|
||
];
|
||
}
|
||
|
||
return $formatted;
|
||
}
|
||
|
||
/**
|
||
* 계산값 포맷팅
|
||
*/
|
||
private function formatCalculatedValues(array $calculatedValues): array
|
||
{
|
||
$formatted = [];
|
||
|
||
foreach ($calculatedValues as $name => $value) {
|
||
if (is_numeric($value)) {
|
||
$formatted[] = [
|
||
'name' => $name,
|
||
'value' => round((float) $value, 3),
|
||
'type' => 'calculated',
|
||
];
|
||
}
|
||
}
|
||
|
||
return $formatted;
|
||
}
|
||
|
||
/**
|
||
* 사양서 생성
|
||
*/
|
||
private function generateSpecifications(ModelMaster $model, array $resolvedBom): array
|
||
{
|
||
$specs = [];
|
||
|
||
// 기본 치수 사양
|
||
$dimensionSpecs = $this->generateDimensionSpecs($resolvedBom['calculated_values']);
|
||
if (!empty($dimensionSpecs)) {
|
||
$specs['dimensions'] = $dimensionSpecs;
|
||
}
|
||
|
||
// 무게/면적 사양
|
||
$physicalSpecs = $this->generatePhysicalSpecs($resolvedBom['calculated_values']);
|
||
if (!empty($physicalSpecs)) {
|
||
$specs['physical'] = $physicalSpecs;
|
||
}
|
||
|
||
// BOM 요약
|
||
$bomSpecs = $this->generateBomSpecs($resolvedBom['bom_items']);
|
||
if (!empty($bomSpecs)) {
|
||
$specs['components'] = $bomSpecs;
|
||
}
|
||
|
||
return $specs;
|
||
}
|
||
|
||
/**
|
||
* 치수 사양 생성
|
||
*/
|
||
private function generateDimensionSpecs(array $calculatedValues): array
|
||
{
|
||
$specs = [];
|
||
$dimensionKeys = ['W0', 'H0', 'W1', 'H1', 'D1', 'L1'];
|
||
|
||
foreach ($dimensionKeys as $key) {
|
||
if (isset($calculatedValues[$key]) && is_numeric($calculatedValues[$key])) {
|
||
$specs[$key] = [
|
||
'value' => round((float) $calculatedValues[$key], 1),
|
||
'unit' => 'mm',
|
||
'label' => $this->getDimensionLabel($key),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $specs;
|
||
}
|
||
|
||
/**
|
||
* 물리적 사양 생성
|
||
*/
|
||
private function generatePhysicalSpecs(array $calculatedValues): array
|
||
{
|
||
$specs = [];
|
||
|
||
if (isset($calculatedValues['area']) && is_numeric($calculatedValues['area'])) {
|
||
$specs['area'] = [
|
||
'value' => round((float) $calculatedValues['area'], 3),
|
||
'unit' => 'm²',
|
||
'label' => '면적',
|
||
];
|
||
}
|
||
|
||
if (isset($calculatedValues['weight']) && is_numeric($calculatedValues['weight'])) {
|
||
$specs['weight'] = [
|
||
'value' => round((float) $calculatedValues['weight'], 2),
|
||
'unit' => 'kg',
|
||
'label' => '중량',
|
||
];
|
||
}
|
||
|
||
return $specs;
|
||
}
|
||
|
||
/**
|
||
* BOM 사양 생성
|
||
*/
|
||
private function generateBomSpecs(array $bomItems): array
|
||
{
|
||
$specs = [
|
||
'total_components' => count($bomItems),
|
||
'materials' => 0,
|
||
'products' => 0,
|
||
'major_components' => [],
|
||
];
|
||
|
||
foreach ($bomItems as $item) {
|
||
if (!empty($item['material_id'])) {
|
||
$specs['materials']++;
|
||
} else {
|
||
$specs['products']++;
|
||
}
|
||
|
||
// 주요 구성품 (수량이 많거나 중요한 것들)
|
||
if (($item['total_quantity'] ?? 0) >= 10 || !empty($item['memo'])) {
|
||
$specs['major_components'][] = [
|
||
'type' => !empty($item['material_id']) ? 'material' : 'product',
|
||
'id' => $item['material_id'] ?? $item['product_id'],
|
||
'quantity' => $item['total_quantity'] ?? 0,
|
||
'unit' => $item['unit'] ?? 'ea',
|
||
'memo' => $item['memo'] ?? '',
|
||
];
|
||
}
|
||
}
|
||
|
||
return $specs;
|
||
}
|
||
|
||
/**
|
||
* 치수 라벨 가져오기
|
||
*/
|
||
private function getDimensionLabel(string $key): string
|
||
{
|
||
$labels = [
|
||
'W0' => '입력 폭',
|
||
'H0' => '입력 높이',
|
||
'W1' => '실제 폭',
|
||
'H1' => '실제 높이',
|
||
'D1' => '깊이',
|
||
'L1' => '길이',
|
||
];
|
||
|
||
return $labels[$key] ?? $key;
|
||
}
|
||
|
||
/**
|
||
* 모델 접근 권한 검증
|
||
*/
|
||
private function validateModelAccess(int $modelId): void
|
||
{
|
||
$model = ModelMaster::where('tenant_id', $this->tenantId())
|
||
->findOrFail($modelId);
|
||
}
|
||
} |