tenantId(); // 모델 존재 확인 $model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first(); if (!$model) { throw new NotFoundHttpException(__('error.not_found')); } $query = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $modelId); if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('rule_name', 'like', "%{$q}%") ->orWhere('condition_expression', 'like', "%{$q}%") ->orWhere('description', 'like', "%{$q}%"); }); } return $query->orderBy('priority')->orderBy('id')->paginate($size, ['*'], 'page', $page); } /** * 조건 규칙 조회 */ public function show(int $ruleId): BomConditionRule { $tenantId = $this->tenantId(); $rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first(); if (!$rule) { throw new NotFoundHttpException(__('error.not_found')); } // 연관된 타겟 정보도 함께 조회 $rule->load(['designModel']); return $rule; } /** * 조건 규칙 생성 */ public function create(array $data): BomConditionRule { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 모델 존재 확인 $model = DesignModel::where('tenant_id', $tenantId)->where('id', $data['model_id'])->first(); if (!$model) { throw new NotFoundHttpException(__('error.not_found')); } // 타겟 존재 확인 $this->validateTarget($data['target_type'], $data['target_id']); // 같은 모델 내에서 규칙명 중복 체크 $exists = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $data['model_id']) ->where('rule_name', $data['rule_name']) ->exists(); if ($exists) { throw ValidationException::withMessages(['rule_name' => __('error.duplicate')]); } // 조건식 문법 검증 $this->validateConditionExpression($data['condition_expression']); return DB::transaction(function () use ($tenantId, $userId, $data) { // priority가 없으면 자동 설정 if (!isset($data['priority'])) { $maxPriority = BomConditionRule::where('tenant_id', $tenantId) ->where('model_id', $data['model_id']) ->max('priority') ?? 0; $data['priority'] = $maxPriority + 1; } $payload = array_merge($data, [ 'tenant_id' => $tenantId, 'created_by' => $userId, 'updated_by' => $userId, ]); return BomConditionRule::create($payload); }); } /** * 조건 규칙 수정 */ public function update(int $ruleId, array $data): BomConditionRule { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first(); if (!$rule) { throw new NotFoundHttpException(__('error.not_found')); } // 규칙명 변경 시 중복 체크 if (isset($data['rule_name']) && $data['rule_name'] !== $rule->rule_name) { $exists = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $rule->model_id) ->where('rule_name', $data['rule_name']) ->where('id', '!=', $ruleId) ->exists(); if ($exists) { throw ValidationException::withMessages(['rule_name' => __('error.duplicate')]); } } // 타겟 변경 시 존재 확인 if (isset($data['target_type']) || isset($data['target_id'])) { $targetType = $data['target_type'] ?? $rule->target_type; $targetId = $data['target_id'] ?? $rule->target_id; $this->validateTarget($targetType, $targetId); } // 조건식 변경 시 문법 검증 if (isset($data['condition_expression'])) { $this->validateConditionExpression($data['condition_expression']); } return DB::transaction(function () use ($rule, $userId, $data) { $payload = array_merge($data, ['updated_by' => $userId]); $rule->update($payload); return $rule->fresh(); }); } /** * 조건 규칙 삭제 */ public function delete(int $ruleId): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first(); if (!$rule) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($rule, $userId) { $rule->update(['deleted_by' => $userId]); return $rule->delete(); }); } /** * 조건 규칙 활성화/비활성화 */ public function toggle(int $ruleId): BomConditionRule { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first(); if (!$rule) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($rule, $userId) { $rule->update([ 'is_active' => !$rule->is_active, 'updated_by' => $userId, ]); return $rule->fresh(); }); } /** * 조건 규칙 우선순위 변경 */ public function reorder(int $modelId, array $ruleIds): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 모델 존재 확인 $model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first(); if (!$model) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($tenantId, $userId, $modelId, $ruleIds) { $priority = 1; $updated = []; foreach ($ruleIds as $ruleId) { $rule = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $modelId) ->where('id', $ruleId) ->first(); if ($rule) { $rule->update([ 'priority' => $priority, 'updated_by' => $userId, ]); $updated[] = $rule->fresh(); $priority++; } } return $updated; }); } /** * 조건 규칙 대량 저장 (upsert) */ public function bulkUpsert(int $modelId, array $rules): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 모델 존재 확인 $model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first(); if (!$model) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($tenantId, $userId, $modelId, $rules) { $result = []; foreach ($rules as $index => $ruleData) { $ruleData['model_id'] = $modelId; // 타겟 및 조건식 검증 if (isset($ruleData['target_type']) && isset($ruleData['target_id'])) { $this->validateTarget($ruleData['target_type'], $ruleData['target_id']); } if (isset($ruleData['condition_expression'])) { $this->validateConditionExpression($ruleData['condition_expression']); } // ID가 있으면 업데이트, 없으면 생성 if (isset($ruleData['id']) && $ruleData['id']) { $rule = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $modelId) ->where('id', $ruleData['id']) ->first(); if ($rule) { $rule->update(array_merge($ruleData, ['updated_by' => $userId])); $result[] = $rule->fresh(); } } else { // 새로운 규칙 생성 $exists = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $modelId) ->where('rule_name', $ruleData['rule_name']) ->exists(); if (!$exists) { if (!isset($ruleData['priority'])) { $ruleData['priority'] = $index + 1; } $payload = array_merge($ruleData, [ 'tenant_id' => $tenantId, 'created_by' => $userId, 'updated_by' => $userId, ]); $result[] = BomConditionRule::create($payload); } } } return $result; }); } /** * 조건 규칙 평가 실행 */ public function evaluateRules(int $modelId, array $parameters): array { $tenantId = $this->tenantId(); // 활성 조건 규칙들을 우선순위 순으로 조회 $rules = BomConditionRule::query() ->where('tenant_id', $tenantId) ->where('model_id', $modelId) ->where('is_active', true) ->orderBy('priority') ->get(); $matchedRules = []; $bomActions = []; foreach ($rules as $rule) { try { if ($rule->evaluateCondition($parameters)) { $matchedRules[] = [ 'rule_id' => $rule->id, 'rule_name' => $rule->rule_name, 'action_type' => $rule->action_type, 'target_type' => $rule->target_type, 'target_id' => $rule->target_id, 'quantity_multiplier' => $rule->quantity_multiplier, ]; $bomActions[] = [ 'action_type' => $rule->action_type, 'target_type' => $rule->target_type, 'target_id' => $rule->target_id, 'quantity_multiplier' => $rule->quantity_multiplier, 'rule_name' => $rule->rule_name, ]; } } catch (\Exception $e) { // 조건 평가 실패 시 로그 남기고 건너뜀 \Log::warning("Rule evaluation failed: {$rule->rule_name}", [ 'error' => $e->getMessage(), 'parameters' => $parameters, ]); } } return [ 'matched_rules' => $matchedRules, 'bom_actions' => $bomActions, ]; } /** * 조건식 테스트 */ public function testCondition(int $ruleId, array $parameters): array { $tenantId = $this->tenantId(); $rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first(); if (!$rule) { throw new NotFoundHttpException(__('error.not_found')); } try { $result = $rule->evaluateCondition($parameters); return [ 'rule_name' => $rule->rule_name, 'condition_expression' => $rule->condition_expression, 'parameters' => $parameters, 'result' => $result, 'action_type' => $rule->action_type, 'target_type' => $rule->target_type, 'target_id' => $rule->target_id, ]; } catch (\Exception $e) { throw ValidationException::withMessages([ 'condition_expression' => __('error.condition_evaluation_failed', ['error' => $e->getMessage()]) ]); } } /** * 타겟 유효성 검증 */ private function validateTarget(string $targetType, int $targetId): void { $tenantId = $this->tenantId(); switch ($targetType) { case 'MATERIAL': $exists = Material::where('tenant_id', $tenantId)->where('id', $targetId)->exists(); if (!$exists) { throw ValidationException::withMessages(['target_id' => __('error.material_not_found')]); } break; case 'PRODUCT': $exists = Product::where('tenant_id', $tenantId)->where('id', $targetId)->exists(); if (!$exists) { throw ValidationException::withMessages(['target_id' => __('error.product_not_found')]); } break; default: throw ValidationException::withMessages(['target_type' => __('error.invalid_target_type')]); } } /** * 조건식 문법 검증 */ private function validateConditionExpression(string $expression): void { // 기본적인 문법 검증 $expression = trim($expression); if (empty($expression)) { throw ValidationException::withMessages(['condition_expression' => __('error.condition_expression_required')]); } // 허용된 패턴들 검증 $allowedPatterns = [ '/^.+\s*(==|!=|>=|<=|>|<)\s*.+$/', // 비교 연산자 '/^.+\s+IN\s+\(.+\)$/i', // IN 연산자 '/^.+\s+NOT\s+IN\s+\(.+\)$/i', // NOT IN 연산자 '/^(true|false|1|0)$/i', // 불린 값 ]; $isValid = false; foreach ($allowedPatterns as $pattern) { if (preg_match($pattern, $expression)) { $isValid = true; break; } } if (!$isValid) { throw ValidationException::withMessages([ 'condition_expression' => __('error.invalid_condition_expression') ]); } } /** * 모델의 규칙 템플릿 조회 (자주 사용되는 패턴들) */ public function getRuleTemplates(): array { return [ [ 'name' => '크기별 브라켓 개수', 'description' => '폭/높이에 따른 브라켓 개수 결정', 'condition_example' => 'W1 > 1000', 'action_type' => 'INCLUDE', 'target_type' => 'MATERIAL', ], [ 'name' => '스크린 타입별 자재', 'description' => '스크린 종류에 따른 자재 선택', 'condition_example' => "screen_type == 'STEEL'", 'action_type' => 'INCLUDE', 'target_type' => 'MATERIAL', ], [ 'name' => '설치 방식별 부품', 'description' => '설치 타입에 따른 추가 부품', 'condition_example' => "install_type IN ('CEILING', 'WALL')", 'action_type' => 'INCLUDE', 'target_type' => 'PRODUCT', ], [ 'name' => '면적별 수량 배수', 'description' => '면적에 비례하는 자재 수량', 'condition_example' => 'area > 10', 'action_type' => 'MODIFY_QUANTITY', 'target_type' => 'MATERIAL', ], ]; } }