Files
sam-api/app/Services/Design/BomResolverService.php
kent bf8036a64b 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>
2025-09-30 23:31:14 +09:00

471 lines
17 KiB
PHP

<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
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\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BomResolverService extends Service
{
protected ModelParameterService $parameterService;
protected ModelFormulaService $formulaService;
protected BomConditionRuleService $ruleService;
public function __construct(
ModelParameterService $parameterService,
ModelFormulaService $formulaService,
BomConditionRuleService $ruleService
) {
$this->parameterService = $parameterService;
$this->formulaService = $formulaService;
$this->ruleService = $ruleService;
}
/**
* 매개변수 기반 BOM 해석 (전체 프로세스)
*/
public function resolveBom(int $modelId, array $inputParameters, ?int $templateId = null): array
{
$tenantId = $this->tenantId();
// 1. 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.model_not_found'));
}
// 2. 매개변수 검증 및 기본값 적용
$validatedParameters = $this->parameterService->validateParameters($modelId, $inputParameters);
// 3. 공식 계산 실행
$calculatedValues = $this->formulaService->calculateFormulas($modelId, $validatedParameters);
// 4. 조건 규칙 평가
$ruleResults = $this->ruleService->evaluateRules($modelId, $calculatedValues);
// 5. 기본 BOM 템플릿 조회 (지정된 템플릿 또는 최신 버전)
$baseBom = $this->getBaseBomTemplate($modelId, $templateId);
// 6. 조건 규칙 적용으로 BOM 변환
$resolvedBom = $this->applyRulesToBom($baseBom, $ruleResults['bom_actions'], $calculatedValues);
// 7. BOM 아이템 정보 보강 (재료/제품 세부정보)
$enrichedBom = $this->enrichBomItems($resolvedBom);
return [
'model' => [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
],
'input_parameters' => $validatedParameters,
'calculated_values' => $calculatedValues,
'matched_rules' => $ruleResults['matched_rules'],
'base_bom_template_id' => $baseBom['template_id'] ?? null,
'resolved_bom' => $enrichedBom,
'summary' => $this->generateBomSummary($enrichedBom),
];
}
/**
* BOM 해석 미리보기 (저장하지 않음)
*/
public function previewBom(int $modelId, array $inputParameters, ?int $templateId = null): array
{
return $this->resolveBom($modelId, $inputParameters, $templateId);
}
/**
* 매개변수 변경에 따른 BOM 차이 분석
*/
public function compareBomByParameters(int $modelId, array $parameters1, array $parameters2, ?int $templateId = null): array
{
// 두 매개변수 세트로 각각 BOM 해석
$bom1 = $this->resolveBom($modelId, $parameters1, $templateId);
$bom2 = $this->resolveBom($modelId, $parameters2, $templateId);
return [
'parameters_diff' => [
'set1' => $parameters1,
'set2' => $parameters2,
'changed' => array_diff_assoc($parameters2, $parameters1),
],
'calculated_values_diff' => [
'set1' => $bom1['calculated_values'],
'set2' => $bom2['calculated_values'],
'changed' => array_diff_assoc($bom2['calculated_values'], $bom1['calculated_values']),
],
'bom_diff' => $this->compareBomItems($bom1['resolved_bom'], $bom2['resolved_bom']),
'summary_diff' => [
'set1' => $bom1['summary'],
'set2' => $bom2['summary'],
],
];
}
/**
* 기본 BOM 템플릿 조회
*/
private function getBaseBomTemplate(int $modelId, ?int $templateId = null): array
{
$tenantId = $this->tenantId();
if ($templateId) {
// 지정된 템플릿 사용
$template = BomTemplate::where('tenant_id', $tenantId)
->where('id', $templateId)
->first();
} else {
// 해당 모델의 최신 버전에서 BOM 템플릿 찾기
$template = BomTemplate::query()
->where('tenant_id', $tenantId)
->whereHas('modelVersion', function ($q) use ($modelId) {
$q->where('model_id', $modelId)
->where('status', 'RELEASED');
})
->orderByDesc('created_at')
->first();
}
if (!$template) {
// 기본 템플릿이 없으면 빈 BOM 반환
return [
'template_id' => null,
'items' => [],
];
}
$items = BomTemplateItem::query()
->where('bom_template_id', $template->id)
->orderBy('order')
->get()
->map(function ($item) {
return [
'target_type' => $item->ref_type,
'target_id' => $item->ref_id,
'quantity' => $item->quantity,
'waste_rate' => $item->waste_rate,
'reason' => 'base_template',
];
})
->toArray();
return [
'template_id' => $template->id,
'items' => $items,
];
}
/**
* 조건 규칙을 BOM에 적용
*/
private function applyRulesToBom(array $baseBom, array $bomActions, array $calculatedValues): array
{
$currentBom = $baseBom['items'];
foreach ($bomActions as $action) {
switch ($action['action_type']) {
case 'INCLUDE':
// 새 아이템 추가 (중복 체크)
$exists = collect($currentBom)->contains(function ($item) use ($action) {
return $item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id'];
});
if (!$exists) {
$currentBom[] = [
'target_type' => $action['target_type'],
'target_id' => $action['target_id'],
'quantity' => $action['quantity_multiplier'] ?? 1,
'waste_rate' => 0,
'reason' => $action['rule_name'],
];
}
break;
case 'EXCLUDE':
// 아이템 제외
$currentBom = array_filter($currentBom, function ($item) use ($action) {
return !($item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id']);
});
break;
case 'MODIFY_QUANTITY':
// 수량 변경
foreach ($currentBom as &$item) {
if ($item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id']) {
$multiplier = $action['quantity_multiplier'] ?? 1;
// 공식으로 계산된 값이 있으면 그것을 사용
if (isset($calculatedValues['quantity_' . $action['target_id']])) {
$item['quantity'] = $calculatedValues['quantity_' . $action['target_id']];
} else {
$item['quantity'] = $item['quantity'] * $multiplier;
}
$item['reason'] = $action['rule_name'];
}
}
break;
}
}
return array_values($currentBom); // 인덱스 재정렬
}
/**
* BOM 아이템 정보 보강
*/
private function enrichBomItems(array $bomItems): array
{
$tenantId = $this->tenantId();
$enriched = [];
foreach ($bomItems as $item) {
$enrichedItem = $item;
if ($item['target_type'] === 'MATERIAL') {
$material = Material::where('tenant_id', $tenantId)
->where('id', $item['target_id'])
->first();
if ($material) {
$enrichedItem['target_info'] = [
'id' => $material->id,
'code' => $material->code,
'name' => $material->name,
'unit' => $material->unit,
'type' => 'material',
];
}
} elseif ($item['target_type'] === 'PRODUCT') {
$product = Product::where('tenant_id', $tenantId)
->where('id', $item['target_id'])
->first();
if ($product) {
$enrichedItem['target_info'] = [
'id' => $product->id,
'code' => $product->code,
'name' => $product->name,
'unit' => $product->unit,
'type' => 'product',
];
}
}
// 실제 필요 수량 계산 (폐기율 적용)
$baseQuantity = $enrichedItem['quantity'];
$wasteRate = $enrichedItem['waste_rate'] ?? 0;
$enrichedItem['actual_quantity'] = $baseQuantity * (1 + $wasteRate / 100);
$enriched[] = $enrichedItem;
}
return $enriched;
}
/**
* BOM 요약 정보 생성
*/
private function generateBomSummary(array $bomItems): array
{
$totalItems = count($bomItems);
$materialCount = 0;
$productCount = 0;
$totalValue = 0; // 나중에 가격 정보 추가 시 사용
foreach ($bomItems as $item) {
if ($item['target_type'] === 'MATERIAL') {
$materialCount++;
} elseif ($item['target_type'] === 'PRODUCT') {
$productCount++;
}
}
return [
'total_items' => $totalItems,
'material_count' => $materialCount,
'product_count' => $productCount,
'total_estimated_value' => $totalValue,
'generated_at' => now()->toISOString(),
];
}
/**
* BOM 아이템 비교
*/
private function compareBomItems(array $bom1, array $bom2): array
{
$added = [];
$removed = [];
$modified = [];
// BOM1에 있던 아이템들 체크
foreach ($bom1 as $item1) {
$key = $item1['target_type'] . '_' . $item1['target_id'];
$found = false;
foreach ($bom2 as $item2) {
if ($item2['target_type'] === $item1['target_type'] &&
$item2['target_id'] === $item1['target_id']) {
$found = true;
// 수량이 변경되었는지 체크
if ($item1['quantity'] != $item2['quantity']) {
$modified[] = [
'target_type' => $item1['target_type'],
'target_id' => $item1['target_id'],
'target_info' => $item1['target_info'] ?? null,
'old_quantity' => $item1['quantity'],
'new_quantity' => $item2['quantity'],
'change' => $item2['quantity'] - $item1['quantity'],
];
}
break;
}
}
if (!$found) {
$removed[] = $item1;
}
}
// BOM2에 새로 추가된 아이템들
foreach ($bom2 as $item2) {
$found = false;
foreach ($bom1 as $item1) {
if ($item1['target_type'] === $item2['target_type'] &&
$item1['target_id'] === $item2['target_id']) {
$found = true;
break;
}
}
if (!$found) {
$added[] = $item2;
}
}
return [
'added' => $added,
'removed' => $removed,
'modified' => $modified,
'summary' => [
'added_count' => count($added),
'removed_count' => count($removed),
'modified_count' => count($modified),
],
];
}
/**
* BOM 해석 결과 저장 (향후 주문/견적 연계용)
*/
public function saveBomResolution(int $modelId, array $inputParameters, array $bomResolution, string $purpose = 'ESTIMATION'): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($tenantId, $userId, $modelId, $inputParameters, $bomResolution, $purpose) {
// BOM 해석 결과를 데이터베이스에 저장
// 향후 order_items, quotation_items 등과 연계할 수 있도록 구조 준비
$resolutionRecord = [
'tenant_id' => $tenantId,
'model_id' => $modelId,
'input_parameters' => json_encode($inputParameters),
'calculated_values' => json_encode($bomResolution['calculated_values']),
'resolved_bom' => json_encode($bomResolution['resolved_bom']),
'matched_rules' => json_encode($bomResolution['matched_rules']),
'summary' => json_encode($bomResolution['summary']),
'purpose' => $purpose,
'created_by' => $userId,
'created_at' => now(),
];
// 실제 테이블이 있다면 저장, 없으면 파일이나 캐시에 임시 저장
$resolutionId = md5(json_encode($resolutionRecord));
// 임시로 캐시에 저장 (1시간)
cache()->put("bom_resolution_{$resolutionId}", $resolutionRecord, 3600);
return [
'resolution_id' => $resolutionId,
'saved_at' => now()->toISOString(),
'purpose' => $purpose,
];
});
}
/**
* 저장된 BOM 해석 결과 조회
*/
public function getBomResolution(string $resolutionId): ?array
{
return cache()->get("bom_resolution_{$resolutionId}");
}
/**
* KSS01 시나리오 테스트용 빠른 실행
*/
public function resolveKSS01(array $parameters): array
{
// KSS01 모델이 있다고 가정하고 하드코딩된 로직
$defaults = [
'W0' => 800,
'H0' => 600,
'screen_type' => 'FABRIC',
'install_type' => 'WALL',
];
$params = array_merge($defaults, $parameters);
// 공식 계산 시뮬레이션
$calculated = [
'W1' => $params['W0'] + 100,
'H1' => $params['H0'] + 100,
];
$calculated['area'] = ($calculated['W1'] * $calculated['H1']) / 1000000;
// 조건 규칙 시뮬레이션
$bom = [];
// 스크린 타입에 따른 자재
if ($params['screen_type'] === 'FABRIC') {
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 1, 'quantity' => $calculated['area'], 'target_info' => ['name' => '패브릭 스크린']];
} else {
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 2, 'quantity' => $calculated['area'], 'target_info' => ['name' => '스틸 스크린']];
}
// 브라켓 개수 (폭에 따라)
$bracketCount = $calculated['W1'] > 1000 ? 3 : 2;
$bom[] = ['target_type' => 'PRODUCT', 'target_id' => 10, 'quantity' => $bracketCount, 'target_info' => ['name' => '브라켓']];
// 가이드레일
$railLength = ($calculated['W1'] + $calculated['H1']) * 2 / 1000; // m 단위
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 3, 'quantity' => $railLength, 'target_info' => ['name' => '가이드레일']];
return [
'model' => ['code' => 'KSS01', 'name' => '기본 스크린 시스템'],
'input_parameters' => $params,
'calculated_values' => $calculated,
'resolved_bom' => $bom,
'summary' => ['total_items' => count($bom)],
];
}
}