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

492 lines
16 KiB
PHP

<?php
namespace App\Services\Design;
use App\Models\Design\BomConditionRule;
use App\Models\Design\DesignModel;
use App\Models\Product;
use App\Models\Material;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BomConditionRuleService extends Service
{
/**
* 모델의 조건 규칙 목록 조회
*/
public function listByModel(int $modelId, string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->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',
],
];
}
}