- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env) - 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget) - 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget) - 리소스 한국어화: Product, Material 모델 레이블 추가 - 대시보드: 위젯 등록 및 캐시 최적화 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
471 lines
17 KiB
PHP
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)],
|
|
];
|
|
}
|
|
} |