Files
sam-api/app/Services/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

505 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\ModelMaster;
use Shared\Models\Products\BomTemplate;
use Shared\Models\Products\BomTemplateItem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* BOM Resolver Service
* 매개변수 기반 BOM 해석 및 생성 엔진
*/
class BomResolverService extends Service
{
private ModelParameterService $parameterService;
private ModelFormulaService $formulaService;
private BomConditionRuleService $conditionRuleService;
public function __construct()
{
parent::__construct();
$this->parameterService = new ModelParameterService();
$this->formulaService = new ModelFormulaService();
$this->conditionRuleService = new BomConditionRuleService();
}
/**
* 매개변수 기반 BOM 해석 (미리보기)
*/
public function resolveBom(int $modelId, array $inputParameters): array
{
$this->validateModelAccess($modelId);
// 1. 매개변수 검증 및 타입 변환
$validatedParameters = $this->validateAndCastParameters($modelId, $inputParameters);
// 2. 공식 계산
$calculatedValues = $this->calculateFormulas($modelId, $validatedParameters);
// 3. 기본 BOM 템플릿 가져오기
$baseBomItems = $this->getBaseBomItems($modelId);
// 4. 조건 규칙 적용
$resolvedBomItems = $this->applyConditionRules($modelId, $baseBomItems, $calculatedValues);
// 5. 수량 및 공식 계산 적용
$finalBomItems = $this->calculateItemQuantities($resolvedBomItems, $calculatedValues);
return [
'model_id' => $modelId,
'input_parameters' => $validatedParameters,
'calculated_values' => $calculatedValues,
'bom_items' => $finalBomItems,
'summary' => $this->generateBomSummary($finalBomItems),
'resolved_at' => now()->toISOString(),
];
}
/**
* 실시간 미리보기 (캐시 활용)
*/
public function previewBom(int $modelId, array $inputParameters): array
{
// 캐시 키 생성
$cacheKey = $this->generateCacheKey($modelId, $inputParameters);
return Cache::remember($cacheKey, 300, function () use ($modelId, $inputParameters) {
return $this->resolveBom($modelId, $inputParameters);
});
}
/**
* BOM 검증 (오류 체크)
*/
public function validateBom(int $modelId, array $inputParameters): array
{
$errors = [];
$warnings = [];
try {
// 매개변수 검증
$parameterErrors = $this->parameterService->validateParameterValues($modelId, $inputParameters);
if (!empty($parameterErrors)) {
$errors['parameters'] = $parameterErrors;
}
// 기본 BOM 템플릿 존재 확인
if (!$this->hasBaseBomTemplate($modelId)) {
$warnings[] = 'No base BOM template found for this model';
}
// 공식 계산 가능 여부 확인
try {
$validatedParameters = $this->validateAndCastParameters($modelId, $inputParameters);
$this->calculateFormulas($modelId, $validatedParameters);
} catch (\Throwable $e) {
$errors['formulas'] = ['Formula calculation failed: ' . $e->getMessage()];
}
// 조건 규칙 평가 가능 여부 확인
try {
$calculatedValues = $this->calculateFormulas($modelId, $validatedParameters ?? []);
$this->conditionRuleService->getApplicableRules($modelId, $calculatedValues);
} catch (\Throwable $e) {
$errors['condition_rules'] = ['Condition rule evaluation failed: ' . $e->getMessage()];
}
} catch (\Throwable $e) {
$errors['general'] = [$e->getMessage()];
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* BOM 비교 (다른 매개변수값과 비교)
*/
public function compareBom(int $modelId, array $parameters1, array $parameters2): array
{
$bom1 = $this->resolveBom($modelId, $parameters1);
$bom2 = $this->resolveBom($modelId, $parameters2);
return [
'parameters_diff' => $this->compareParameters($bom1['calculated_values'], $bom2['calculated_values']),
'bom_items_diff' => $this->compareBomItems($bom1['bom_items'], $bom2['bom_items']),
'summary_diff' => $this->compareSummary($bom1['summary'], $bom2['summary']),
];
}
/**
* 대량 BOM 해석 (여러 매개변수 조합)
*/
public function resolveBomBatch(int $modelId, array $parameterSets): array
{
$results = [];
DB::transaction(function () use ($modelId, $parameterSets, &$results) {
foreach ($parameterSets as $index => $parameters) {
try {
$results[$index] = $this->resolveBom($modelId, $parameters);
} catch (\Throwable $e) {
$results[$index] = [
'error' => $e->getMessage(),
'parameters' => $parameters,
];
}
}
});
return $results;
}
/**
* BOM 성능 최적화 제안
*/
public function getOptimizationSuggestions(int $modelId, array $inputParameters): array
{
$resolvedBom = $this->resolveBom($modelId, $inputParameters);
$suggestions = [];
// 1. 불필요한 조건 규칙 탐지
$unusedRules = $this->findUnusedRules($modelId, $resolvedBom['calculated_values']);
if (!empty($unusedRules)) {
$suggestions[] = [
'type' => 'unused_rules',
'message' => 'Found unused condition rules that could be removed',
'details' => $unusedRules,
];
}
// 2. 복잡한 공식 탐지
$complexFormulas = $this->findComplexFormulas($modelId);
if (!empty($complexFormulas)) {
$suggestions[] = [
'type' => 'complex_formulas',
'message' => 'Found complex formulas that might impact performance',
'details' => $complexFormulas,
];
}
// 3. 중복 BOM 아이템 탐지
$duplicateItems = $this->findDuplicateBomItems($resolvedBom['bom_items']);
if (!empty($duplicateItems)) {
$suggestions[] = [
'type' => 'duplicate_items',
'message' => 'Found duplicate BOM items that could be consolidated',
'details' => $duplicateItems,
];
}
return $suggestions;
}
/**
* 매개변수 검증 및 타입 변환
*/
private function validateAndCastParameters(int $modelId, array $inputParameters): array
{
$errors = $this->parameterService->validateParameterValues($modelId, $inputParameters);
if (!empty($errors)) {
throw new \InvalidArgumentException(__('error.invalid_parameters') . ': ' . json_encode($errors));
}
return $this->parameterService->castParameterValues($modelId, $inputParameters);
}
/**
* 공식 계산
*/
private function calculateFormulas(int $modelId, array $parameters): array
{
return $this->formulaService->calculateFormulas($modelId, $parameters);
}
/**
* 기본 BOM 템플릿 아이템 가져오기
*/
private function getBaseBomItems(int $modelId): array
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
// 현재 활성 버전의 BOM 템플릿 가져오기
$bomTemplate = BomTemplate::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('is_active', true)
->orderBy('created_at', 'desc')
->first();
if (!$bomTemplate) {
return [];
}
$bomItems = BomTemplateItem::where('tenant_id', $this->tenantId())
->where('bom_template_id', $bomTemplate->id)
->orderBy('order')
->get()
->map(function ($item) {
return [
'id' => $item->id,
'product_id' => $item->product_id,
'material_id' => $item->material_id,
'ref_type' => $item->ref_type,
'quantity' => $item->quantity,
'quantity_formula' => $item->quantity_formula,
'waste_rate' => $item->waste_rate,
'unit' => $item->unit,
'memo' => $item->memo,
'order' => $item->order,
];
})
->toArray();
return $bomItems;
}
/**
* 조건 규칙 적용
*/
private function applyConditionRules(int $modelId, array $bomItems, array $calculatedValues): array
{
return $this->conditionRuleService->applyRulesToBomItems($modelId, $bomItems, $calculatedValues);
}
/**
* 아이템별 수량 계산
*/
private function calculateItemQuantities(array $bomItems, array $calculatedValues): array
{
foreach ($bomItems as &$item) {
// 수량 공식이 있는 경우 계산
if (!empty($item['quantity_formula'])) {
$calculatedQuantity = $this->evaluateQuantityFormula($item['quantity_formula'], $calculatedValues);
if ($calculatedQuantity !== null) {
$item['calculated_quantity'] = $calculatedQuantity;
}
} else {
$item['calculated_quantity'] = $item['quantity'] ?? 1;
}
// 낭비율 적용
if (isset($item['waste_rate']) && $item['waste_rate'] > 0) {
$item['total_quantity'] = $item['calculated_quantity'] * (1 + $item['waste_rate'] / 100);
} else {
$item['total_quantity'] = $item['calculated_quantity'];
}
// 반올림 (소수점 3자리)
$item['calculated_quantity'] = round($item['calculated_quantity'], 3);
$item['total_quantity'] = round($item['total_quantity'], 3);
}
return $bomItems;
}
/**
* 수량 공식 계산
*/
private function evaluateQuantityFormula(string $formula, array $variables): ?float
{
try {
$expression = $formula;
// 변수값 치환
foreach ($variables as $name => $value) {
if (is_numeric($value)) {
$expression = preg_replace('/\b' . preg_quote($name, '/') . '\b/', $value, $expression);
}
}
// 안전한 계산 실행
if (preg_match('/^[0-9+\-*\/().,\s]+$/', $expression)) {
return eval("return $expression;");
}
return null;
} catch (\Throwable $e) {
return null;
}
}
/**
* BOM 요약 정보 생성
*/
private function generateBomSummary(array $bomItems): array
{
$summary = [
'total_items' => count($bomItems),
'materials_count' => 0,
'products_count' => 0,
'total_cost' => 0, // 향후 가격 정보 추가 시
];
foreach ($bomItems as $item) {
if (!empty($item['material_id'])) {
$summary['materials_count']++;
} else {
$summary['products_count']++;
}
}
return $summary;
}
/**
* 캐시 키 생성
*/
private function generateCacheKey(int $modelId, array $parameters): string
{
ksort($parameters); // 매개변수 순서 정규화
$hash = md5(json_encode($parameters));
return "bom_preview_{$this->tenantId()}_{$modelId}_{$hash}";
}
/**
* 기본 BOM 템플릿 존재 여부 확인
*/
private function hasBaseBomTemplate(int $modelId): bool
{
return BomTemplate::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('is_active', true)
->exists();
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
/**
* 매개변수 비교
*/
private function compareParameters(array $params1, array $params2): array
{
$diff = [];
$allKeys = array_unique(array_merge(array_keys($params1), array_keys($params2)));
foreach ($allKeys as $key) {
$value1 = $params1[$key] ?? null;
$value2 = $params2[$key] ?? null;
if ($value1 !== $value2) {
$diff[$key] = [
'set1' => $value1,
'set2' => $value2,
];
}
}
return $diff;
}
/**
* BOM 아이템 비교
*/
private function compareBomItems(array $items1, array $items2): array
{
// 간단한 비교 로직 (실제로는 더 정교한 비교 필요)
return [
'count_diff' => count($items1) - count($items2),
'items_only_in_set1' => array_diff_key($items1, $items2),
'items_only_in_set2' => array_diff_key($items2, $items1),
];
}
/**
* 요약 정보 비교
*/
private function compareSummary(array $summary1, array $summary2): array
{
$diff = [];
foreach ($summary1 as $key => $value) {
if (isset($summary2[$key]) && $value !== $summary2[$key]) {
$diff[$key] = [
'set1' => $value,
'set2' => $summary2[$key],
'diff' => $value - $summary2[$key],
];
}
}
return $diff;
}
/**
* 사용되지 않는 규칙 찾기
*/
private function findUnusedRules(int $modelId, array $calculatedValues): array
{
$allRules = $this->conditionRuleService->getRulesByModel($modelId);
$applicableRules = $this->conditionRuleService->getApplicableRules($modelId, $calculatedValues);
$unusedRules = $allRules->diff($applicableRules);
return $unusedRules->map(function ($rule) {
return [
'id' => $rule->id,
'name' => $rule->name,
'condition' => $rule->condition_expression,
];
})->toArray();
}
/**
* 복잡한 공식 찾기
*/
private function findComplexFormulas(int $modelId): array
{
$formulas = $this->formulaService->getFormulasByModel($modelId);
return $formulas->filter(function ($formula) {
// 복잡성 기준 (의존성 수, 표현식 길이 등)
$dependencyCount = count($formula->dependencies ?? []);
$expressionLength = strlen($formula->expression);
return $dependencyCount > 5 || $expressionLength > 100;
})->map(function ($formula) {
return [
'id' => $formula->id,
'name' => $formula->name,
'complexity_score' => strlen($formula->expression) + count($formula->dependencies ?? []) * 10,
];
})->toArray();
}
/**
* 중복 BOM 아이템 찾기
*/
private function findDuplicateBomItems(array $bomItems): array
{
$seen = [];
$duplicates = [];
foreach ($bomItems as $item) {
$key = ($item['product_id'] ?? 'null') . '_' . ($item['material_id'] ?? 'null');
if (isset($seen[$key])) {
$duplicates[] = [
'product_id' => $item['product_id'],
'material_id' => $item['material_id'],
'occurrences' => ++$seen[$key],
];
} else {
$seen[$key] = 1;
}
}
return $duplicates;
}
}