diff --git a/app/Console/Commands/SeedQuoteFormulasCommand.php b/app/Console/Commands/SeedQuoteFormulasCommand.php index 0ffb625a..e2f76185 100644 --- a/app/Console/Commands/SeedQuoteFormulasCommand.php +++ b/app/Console/Commands/SeedQuoteFormulasCommand.php @@ -447,6 +447,28 @@ private function getFormulaData(): array 'sort_order' => 4, ], + // 제작사이즈 통합 변수 (제품 카테고리 기반) + [ + 'category_code' => 'MAKE_SIZE', + 'variable' => 'W1', + 'name' => '제작가로 통합', + 'type' => 'calculation', + 'formula' => 'W1_SCREEN', // TODO: PC 기반 분기 로직 필요 + 'description' => '제품 카테고리에 따른 제작 가로 (기본: 스크린)', + 'metadata' => ['unit' => 'mm'], + 'sort_order' => 5, + ], + [ + 'category_code' => 'MAKE_SIZE', + 'variable' => 'H1', + 'name' => '제작세로 통합', + 'type' => 'calculation', + 'formula' => 'H1_SCREEN', // TODO: PC 기반 분기 로직 필요 + 'description' => '제품 카테고리에 따른 제작 세로 (기본: 스크린)', + 'metadata' => ['unit' => 'mm'], + 'sort_order' => 6, + ], + // ============================== // 3. 면적 (AREA) - 1개 // ============================== @@ -462,7 +484,7 @@ private function getFormulaData(): array ], // ============================== - // 4. 중량 (WEIGHT) - 2개 + // 4. 중량 (WEIGHT) - 3개 // ============================== [ 'category_code' => 'WEIGHT', @@ -484,6 +506,17 @@ private function getFormulaData(): array 'metadata' => ['unit' => 'kg', 'product_type' => 'steel'], 'sort_order' => 2, ], + // 중량 통합 변수 (모터 선택용) + [ + 'category_code' => 'WEIGHT', + 'variable' => 'K', + 'name' => '중량 통합', + 'type' => 'calculation', + 'formula' => 'K_SCREEN', // TODO: PC 기반 분기 로직 필요 + 'description' => '제품 카테고리에 따른 중량 (기본: 스크린)', + 'metadata' => ['unit' => 'kg'], + 'sort_order' => 3, + ], // ============================== // 5. 가이드레일 (GUIDE_RAIL) - 2개 (활성) @@ -516,7 +549,7 @@ private function getFormulaData(): array ], // ============================== - // 6. 케이스 (CASE) - 3개 + // 6. 케이스 (CASE) - 4개 // ============================== [ 'category_code' => 'CASE', @@ -538,6 +571,17 @@ private function getFormulaData(): array 'metadata' => ['unit' => 'mm', 'product_type' => 'steel'], 'sort_order' => 2, ], + // 케이스 사이즈 통합 변수 + [ + 'category_code' => 'CASE', + 'variable' => 'S', + 'name' => '케이스 사이즈 통합', + 'type' => 'calculation', + 'formula' => 'S_SCREEN', // TODO: PC 기반 분기 로직 필요 + 'description' => '제품 카테고리에 따른 케이스 사이즈 (기본: 스크린)', + 'metadata' => ['unit' => 'mm'], + 'sort_order' => 3, + ], [ 'category_code' => 'CASE', 'variable' => 'CASE_AUTO_SELECT', @@ -718,41 +762,12 @@ private function seedItems(int $tenantId): int /** * 품목 출력용 수식 데이터 + * Note: 가이드레일, 케이스, 모터는 range 수식에서 자동 선택되므로 별도 품목 수식 불필요 */ private function getItemFormulaData(): array { return [ - // 가이드레일 품목 출력 - [ - 'category_code' => 'GUIDE_RAIL', - 'variable' => 'ITEM_GUIDE_RAIL', - 'name' => '가이드레일 품목', - 'type' => 'calculation', - 'formula' => '1', // 항상 출력 - 'description' => '가이드레일 품목 출력용', - 'sort_order' => 10, - ], - // 케이스 품목 출력 - [ - 'category_code' => 'CASE', - 'variable' => 'ITEM_CASE', - 'name' => '케이스 품목', - 'type' => 'calculation', - 'formula' => '1', - 'description' => '케이스 품목 출력용', - 'sort_order' => 10, - ], - // 모터 품목 출력 - [ - 'category_code' => 'MOTOR', - 'variable' => 'ITEM_MOTOR', - 'name' => '모터 품목', - 'type' => 'calculation', - 'formula' => '1', - 'description' => '모터 품목 출력용', - 'sort_order' => 10, - ], - // 검사비 품목 출력 + // 검사비 품목 출력 (range가 아닌 고정 품목) [ 'category_code' => 'INSPECTION', 'variable' => 'ITEM_INSPECTION', @@ -767,46 +782,11 @@ private function getItemFormulaData(): array /** * 품목 데이터 정의 + * Note: 가이드레일, 케이스, 모터는 range 수식의 result_value에서 자동 추출됨 */ private function getItemData(): array { return [ - // ========== 가이드레일 품목 ========== - [ - 'formula_variable' => 'ITEM_GUIDE_RAIL', - 'item_code' => 'PT-GR-3000', - 'item_name' => '가이드레일 3000', - 'specification' => '3000mm', - 'unit' => 'EA', - 'quantity_formula' => '2', // 기본 2개 - 'unit_price_formula' => null, // DB에서 조회 - 'sort_order' => 1, - ], - - // ========== 케이스 품목 ========== - [ - 'formula_variable' => 'ITEM_CASE', - 'item_code' => 'PT-CASE-3600', - 'item_name' => '케이스 3600', - 'specification' => '3600mm', - 'unit' => 'EA', - 'quantity_formula' => '1', - 'unit_price_formula' => null, - 'sort_order' => 1, - ], - - // ========== 모터 품목 ========== - [ - 'formula_variable' => 'ITEM_MOTOR', - 'item_code' => 'PT-MOTOR-150', - 'item_name' => '모터 150K', - 'specification' => '150kg', - 'unit' => 'EA', - 'quantity_formula' => '1', - 'unit_price_formula' => null, - 'sort_order' => 1, - ], - // ========== 검사비 품목 ========== [ 'formula_variable' => 'ITEM_INSPECTION', diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php index 39204a56..2ce6fc4f 100644 --- a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php @@ -289,6 +289,11 @@ public function simulate(Request $request): JsonResponse $validated['input_variables'] ); + // 품목 상세 정보 및 BOM 트리 추가 + if (! empty($result['items'])) { + $result['items'] = $this->evaluatorService->enrichItemsWithDetails($result['items']); + } + // 오류가 있어도 계산된 결과는 반환 (부분 성공) // 오류는 data.errors에 포함되어 UI에서 별도 표시 return response()->json([ @@ -311,4 +316,69 @@ public function duplicate(int $id): JsonResponse 'data' => $formula, ]); } + + /** + * 전체 품목 목록 (시뮬레이터용) + */ + public function items(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + ], 400); + } + + $query = \DB::table('items') + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->where('is_active', true); + + // 검색 + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + // 품목 유형 필터 + if ($itemType = $request->get('item_type')) { + $query->where('item_type', $itemType); + } + + $items = $query->orderBy('item_type') + ->orderBy('code') + ->get(['id', 'code', 'name', 'item_type', 'unit', 'bom']); + + // BOM 정보 가공 + $items = $items->map(function ($item) { + $bomData = json_decode($item->bom ?? '[]', true); + + return [ + 'id' => $item->id, + 'code' => $item->code, + 'name' => $item->name, + 'item_type' => $item->item_type, + 'item_type_label' => $this->evaluatorService->getItemTypeLabel($item->item_type), + 'unit' => $item->unit, + 'has_bom' => ! empty($bomData), + 'bom_count' => count($bomData), + ]; + }); + + // 품목 유형별 통계 + $stats = $items->groupBy('item_type')->map(fn ($group) => $group->count()); + + return response()->json([ + 'success' => true, + 'data' => [ + 'items' => $items->values(), + 'stats' => $stats, + 'total' => $items->count(), + ], + ]); + } } diff --git a/app/Models/PushDeviceToken.php b/app/Models/PushDeviceToken.php index 1e8e5202..a298d6c1 100644 --- a/app/Models/PushDeviceToken.php +++ b/app/Models/PushDeviceToken.php @@ -32,6 +32,89 @@ class PushDeviceToken extends Model 'last_error_at' => 'datetime', ]; + /** + * User-Agent에서 파싱된 기기명 + */ + public function getParsedDeviceNameAttribute(): string + { + if (empty($this->device_name)) { + return '-'; + } + + // User-Agent 문자열인 경우 파싱 + if (str_contains($this->device_name, 'Mozilla/') || str_contains($this->device_name, 'AppleWebKit')) { + return $this->parseUserAgent($this->device_name); + } + + // 이미 간단한 기기명인 경우 그대로 반환 + return $this->device_name; + } + + /** + * User-Agent에서 파싱된 OS 버전 + */ + public function getParsedOsVersionAttribute(): ?string + { + if (empty($this->device_name)) { + return null; + } + + // Android 버전 추출 + if (preg_match('/Android\s+([\d.]+)/', $this->device_name, $matches)) { + return 'Android ' . $matches[1]; + } + + // iOS 버전 추출 + if (preg_match('/iPhone\s+OS\s+([\d_]+)/', $this->device_name, $matches)) { + return 'iOS ' . str_replace('_', '.', $matches[1]); + } + + // iPad 버전 추출 + if (preg_match('/CPU\s+OS\s+([\d_]+)/', $this->device_name, $matches)) { + return 'iOS ' . str_replace('_', '.', $matches[1]); + } + + return null; + } + + /** + * User-Agent 문자열에서 기기명 추출 + */ + private function parseUserAgent(string $userAgent): string + { + // Android 기기명 추출: (Linux; Android 10; SM-N960N Build/...) + if (preg_match('/;\s*([A-Za-z0-9\-_]+(?:\s+[A-Za-z0-9\-_]+)*)\s+Build\//', $userAgent, $matches)) { + return trim($matches[1]); + } + + // Android 기기명 대체 패턴: Android X; MODEL) + if (preg_match('/Android\s+[\d.]+;\s*([^)]+)\)/', $userAgent, $matches)) { + $model = trim($matches[1]); + // Build/ 이전까지만 추출 + if (($pos = strpos($model, ' Build')) !== false) { + $model = substr($model, 0, $pos); + } + return $model ?: 'Android Device'; + } + + // iPhone 추출 + if (str_contains($userAgent, 'iPhone')) { + return 'iPhone'; + } + + // iPad 추출 + if (str_contains($userAgent, 'iPad')) { + return 'iPad'; + } + + // 기타 - 너무 긴 경우 축약 + if (strlen($userAgent) > 30) { + return 'Unknown Device'; + } + + return $userAgent; + } + /** * 플랫폼 상수 */ diff --git a/app/Services/FlowTester/ConditionEvaluator.php b/app/Services/FlowTester/ConditionEvaluator.php new file mode 100644 index 00000000..a4f3ba3d --- /dev/null +++ b/app/Services/FlowTester/ConditionEvaluator.php @@ -0,0 +1,508 @@ +", "right": 100000} + * - 복합 조건: {"and": [{"stepResult": "login", "is": "success"}, "{{login.role}} == 'admin'"]} + */ +class ConditionEvaluator +{ + private VariableBinder $binder; + + /** + * 스텝 실행 결과 맵 (stepId => ['success' => bool, 'status' => int, 'skipped' => bool]) + */ + private array $stepResults = []; + + /** + * 평가 로그 + */ + private array $evaluationLog = []; + + public function __construct(?VariableBinder $binder = null) + { + $this->binder = $binder ?? new VariableBinder; + } + + /** + * VariableBinder 설정 + */ + public function setBinder(VariableBinder $binder): void + { + $this->binder = $binder; + } + + /** + * 스텝 결과 등록 + * + * @param string $stepId 스텝 ID + * @param array $result 스텝 실행 결과 + */ + public function setStepResult(string $stepId, array $result): void + { + $this->stepResults[$stepId] = [ + 'success' => $result['success'] ?? false, + 'status' => $result['response']['status'] ?? 0, + 'skipped' => $result['skipped'] ?? false, + 'executed' => ! ($result['skipped'] ?? false), + 'response' => $result['response']['body'] ?? null, + 'extracted' => $result['extracted'] ?? [], + ]; + } + + /** + * 조건 평가 + * + * @param mixed $condition 조건 (문자열 또는 배열) + * @return array ['passed' => bool, 'reason' => string, 'evaluated' => string] + */ + public function evaluate(mixed $condition): array + { + $this->evaluationLog = []; + + if ($condition === null || $condition === true) { + return $this->result(true, 'No condition (always run)'); + } + + if ($condition === false) { + return $this->result(false, 'Condition is explicitly false'); + } + + try { + if (is_string($condition)) { + return $this->evaluateStringExpression($condition); + } + + if (is_array($condition)) { + return $this->evaluateArrayCondition($condition); + } + + return $this->result(false, 'Invalid condition type: '.gettype($condition)); + } catch (\Exception $e) { + return $this->result(false, 'Evaluation error: '.$e->getMessage()); + } + } + + /** + * 문자열 표현식 평가 + * + * 형식: "{{step.field}} op value" + * 예: "{{login.success}} == true" + * "{{order.total}} > 100000" + * "{{user.role}} != 'guest'" + */ + private function evaluateStringExpression(string $expression): array + { + // 변수 바인딩 적용 + $boundExpression = $this->binder->bind($expression); + $this->log("Expression: {$expression} → {$boundExpression}"); + + // 연산자 패턴 매칭 + $operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<', ' contains ', ' in ', ' matches ']; + + foreach ($operators as $op) { + $trimmedOp = trim($op); + if (str_contains($boundExpression, $op)) { + $parts = explode($op, $boundExpression, 2); + if (count($parts) === 2) { + $left = $this->parseValue(trim($parts[0])); + $right = $this->parseValue(trim($parts[1])); + + $result = $this->compare($left, $trimmedOp, $right); + $this->log("Compare: {$left} {$trimmedOp} {$right} = ".($result ? 'true' : 'false')); + + return $this->result($result, "{$expression} → ".($result ? 'true' : 'false')); + } + } + } + + // 연산자가 없으면 truthy 체크 + $value = $this->parseValue($boundExpression); + $result = $this->isTruthy($value); + + return $this->result($result, "Truthy check: {$boundExpression} → ".($result ? 'true' : 'false')); + } + + /** + * 배열 조건 평가 + */ + private function evaluateArrayCondition(array $condition): array + { + // 1. 논리 연산자: and, or, not + if (isset($condition['and'])) { + return $this->evaluateAnd($condition['and']); + } + + if (isset($condition['or'])) { + return $this->evaluateOr($condition['or']); + } + + if (isset($condition['not'])) { + $inner = $this->evaluate($condition['not']); + + return $this->result(! $inner['passed'], "NOT ({$inner['reason']})"); + } + + // 2. 스텝 결과 조건 + if (isset($condition['stepResult'])) { + return $this->evaluateStepResult($condition); + } + + // 3. 존재 확인 + if (isset($condition['exists'])) { + return $this->evaluateExists($condition['exists'], true); + } + + if (isset($condition['notExists'])) { + return $this->evaluateExists($condition['notExists'], false); + } + + // 4. 빈 값 확인 + if (isset($condition['isEmpty'])) { + return $this->evaluateEmpty($condition['isEmpty'], true); + } + + if (isset($condition['isNotEmpty'])) { + return $this->evaluateEmpty($condition['isNotEmpty'], false); + } + + // 5. 비교 연산 객체: {left, op, right} + if (isset($condition['left']) && isset($condition['op'])) { + return $this->evaluateComparisonObject($condition); + } + + // 6. 타입 체크 + if (isset($condition['isType'])) { + return $this->evaluateType($condition['value'] ?? '', $condition['isType']); + } + + // 7. 모든 조건 (all - and의 별칭) + if (isset($condition['all'])) { + return $this->evaluateAnd($condition['all']); + } + + // 8. 하나라도 (any - or의 별칭) + if (isset($condition['any'])) { + return $this->evaluateOr($condition['any']); + } + + return $this->result(false, 'Unknown condition structure: '.json_encode($condition)); + } + + /** + * AND 조건 평가 + */ + private function evaluateAnd(array $conditions): array + { + $reasons = []; + + foreach ($conditions as $i => $cond) { + $result = $this->evaluate($cond); + $reasons[] = "[{$i}] ".($result['passed'] ? '✓' : '✗').' '.$result['reason']; + + if (! $result['passed']) { + return $this->result(false, 'AND failed: '.implode(', ', $reasons)); + } + } + + return $this->result(true, 'AND passed: '.implode(', ', $reasons)); + } + + /** + * OR 조건 평가 + */ + private function evaluateOr(array $conditions): array + { + $reasons = []; + + foreach ($conditions as $i => $cond) { + $result = $this->evaluate($cond); + $reasons[] = "[{$i}] ".($result['passed'] ? '✓' : '✗').' '.$result['reason']; + + if ($result['passed']) { + return $this->result(true, 'OR passed: '.implode(', ', $reasons)); + } + } + + return $this->result(false, 'OR failed: '.implode(', ', $reasons)); + } + + /** + * 스텝 결과 조건 평가 + * + * {"stepResult": "login", "is": "success|failure|skipped|executed"} + */ + private function evaluateStepResult(array $condition): array + { + $stepId = $condition['stepResult']; + $expected = $condition['is'] ?? 'success'; + + if (! isset($this->stepResults[$stepId])) { + return $this->result(false, "Step '{$stepId}' has not been executed yet"); + } + + $stepResult = $this->stepResults[$stepId]; + + $passed = match ($expected) { + 'success' => $stepResult['success'] === true, + 'failure', 'failed' => $stepResult['success'] === false && ! $stepResult['skipped'], + 'skipped' => $stepResult['skipped'] === true, + 'executed' => $stepResult['executed'] === true, + default => false, + }; + + $actual = $stepResult['skipped'] ? 'skipped' : ($stepResult['success'] ? 'success' : 'failure'); + + return $this->result( + $passed, + "Step '{$stepId}' is {$actual}, expected: {$expected}" + ); + } + + /** + * 존재 확인 평가 + */ + private function evaluateExists(string $path, bool $shouldExist): array + { + $value = $this->binder->bind($path); + + // 바인딩 후에도 변수 패턴이 남아있으면 존재하지 않는 것 + $exists = ! str_contains($value, '{{') && $value !== '' && $value !== null; + + $passed = $shouldExist ? $exists : ! $exists; + $status = $shouldExist ? 'exists' : 'notExists'; + + return $this->result($passed, "{$path} {$status}: ".($exists ? 'yes' : 'no')); + } + + /** + * 빈 값 확인 평가 + */ + private function evaluateEmpty(string $path, bool $shouldBeEmpty): array + { + $value = $this->binder->bind($path); + $parsedValue = $this->parseValue($value); + + $isEmpty = empty($parsedValue) || $parsedValue === '' || $parsedValue === [] || $parsedValue === null; + + $passed = $shouldBeEmpty ? $isEmpty : ! $isEmpty; + + return $this->result($passed, "{$path} isEmpty: ".($isEmpty ? 'yes' : 'no')); + } + + /** + * 비교 연산 객체 평가 + * + * {"left": "{{step.field}}", "op": "==", "right": "value"} + */ + private function evaluateComparisonObject(array $condition): array + { + $left = $this->binder->bind($condition['left'] ?? ''); + $op = $condition['op'] ?? '=='; + $right = $condition['right'] ?? null; + + // right도 변수일 수 있음 + if (is_string($right) && str_contains($right, '{{')) { + $right = $this->binder->bind($right); + } + + $leftValue = $this->parseValue($left); + $rightValue = is_string($right) ? $this->parseValue($right) : $right; + + $result = $this->compare($leftValue, $op, $rightValue); + + $leftDisplay = is_array($leftValue) ? json_encode($leftValue) : $leftValue; + $rightDisplay = is_array($rightValue) ? json_encode($rightValue) : $rightValue; + + return $this->result($result, "{$leftDisplay} {$op} {$rightDisplay} → ".($result ? 'true' : 'false')); + } + + /** + * 타입 체크 평가 + */ + private function evaluateType(string $path, string $expectedType): array + { + $value = $this->binder->bind($path); + $parsedValue = $this->parseValue($value); + + $actualType = gettype($parsedValue); + + $passed = match ($expectedType) { + 'number', 'numeric' => is_numeric($parsedValue), + 'integer', 'int' => is_int($parsedValue) || (is_string($parsedValue) && ctype_digit($parsedValue)), + 'string' => is_string($parsedValue), + 'array' => is_array($parsedValue), + 'object' => is_object($parsedValue) || (is_array($parsedValue) && ! array_is_list($parsedValue)), + 'boolean', 'bool' => is_bool($parsedValue) || in_array(strtolower((string) $parsedValue), ['true', 'false']), + 'null' => $parsedValue === null || strtolower((string) $parsedValue) === 'null', + default => $actualType === $expectedType, + }; + + return $this->result($passed, "{$path} isType {$expectedType}: actual={$actualType}"); + } + + /** + * 값 비교 + */ + private function compare(mixed $left, string $op, mixed $right): bool + { + return match ($op) { + '==', '=' => $left == $right, + '===' => $left === $right, + '!=', '<>' => $left != $right, + '!==' => $left !== $right, + '>' => is_numeric($left) && is_numeric($right) && $left > $right, + '>=' => is_numeric($left) && is_numeric($right) && $left >= $right, + '<' => is_numeric($left) && is_numeric($right) && $left < $right, + '<=' => is_numeric($left) && is_numeric($right) && $left <= $right, + 'contains' => $this->contains($left, $right), + 'notContains', 'not_contains' => ! $this->contains($left, $right), + 'in' => $this->in($left, $right), + 'notIn', 'not_in' => ! $this->in($left, $right), + 'startsWith', 'starts_with' => is_string($left) && is_string($right) && str_starts_with($left, $right), + 'endsWith', 'ends_with' => is_string($left) && is_string($right) && str_ends_with($left, $right), + 'matches', 'regex' => is_string($left) && is_string($right) && preg_match($right, $left), + default => false, + }; + } + + /** + * 포함 여부 확인 + */ + private function contains(mixed $haystack, mixed $needle): bool + { + if (is_string($haystack)) { + return str_contains($haystack, (string) $needle); + } + + if (is_array($haystack)) { + return in_array($needle, $haystack); + } + + return false; + } + + /** + * 배열 내 포함 여부 확인 + */ + private function in(mixed $needle, mixed $haystack): bool + { + if (! is_array($haystack)) { + return false; + } + + return in_array($needle, $haystack); + } + + /** + * 문자열 값 파싱 + */ + private function parseValue(string $value): mixed + { + $trimmed = trim($value); + + // 따옴표로 감싸진 문자열 + if ((str_starts_with($trimmed, "'") && str_ends_with($trimmed, "'")) || + (str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"'))) { + return substr($trimmed, 1, -1); + } + + // boolean + if (strtolower($trimmed) === 'true') { + return true; + } + if (strtolower($trimmed) === 'false') { + return false; + } + + // null + if (strtolower($trimmed) === 'null') { + return null; + } + + // 숫자 + if (is_numeric($trimmed)) { + return str_contains($trimmed, '.') ? (float) $trimmed : (int) $trimmed; + } + + // JSON 배열/객체 + if ((str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) || + (str_starts_with($trimmed, '{') && str_ends_with($trimmed, '}'))) { + $decoded = json_decode($trimmed, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + + return $trimmed; + } + + /** + * Truthy 체크 + */ + private function isTruthy(mixed $value): bool + { + if ($value === null || $value === false || $value === '' || $value === 0 || $value === '0') { + return false; + } + + if (is_array($value) && empty($value)) { + return false; + } + + return true; + } + + /** + * 결과 생성 + */ + private function result(bool $passed, string $reason): array + { + return [ + 'passed' => $passed, + 'reason' => $reason, + 'log' => $this->evaluationLog, + ]; + } + + /** + * 평가 로그 추가 + */ + private function log(string $message): void + { + $this->evaluationLog[] = $message; + } + + /** + * 상태 초기화 + */ + public function reset(): void + { + $this->stepResults = []; + $this->evaluationLog = []; + } + + /** + * 스텝 결과 조회 + */ + public function getStepResults(): array + { + return $this->stepResults; + } +} \ No newline at end of file diff --git a/app/Services/FlowTester/DependencyResolver.php b/app/Services/FlowTester/DependencyResolver.php index a20c499c..019d7cab 100644 --- a/app/Services/FlowTester/DependencyResolver.php +++ b/app/Services/FlowTester/DependencyResolver.php @@ -9,6 +9,15 @@ * * 플로우 스텝의 dependsOn 속성을 분석하여 * 올바른 실행 순서를 결정합니다. + * + * 지원하는 의존성 형식: + * 1. 단순 문자열: "step_id" - 해당 스텝이 성공해야 실행 + * 2. 조건부 객체: {"step": "step_id", "onlyIf": "success|failure|executed|skipped"} + * - success: 해당 스텝이 성공했을 때만 실행 (기본값) + * - failure: 해당 스텝이 실패했을 때만 실행 + * - executed: 해당 스텝이 실행되었을 때만 실행 (성공/실패 무관, 스킵 제외) + * - skipped: 해당 스텝이 스킵되었을 때만 실행 + * - any: 해당 스텝의 결과와 무관하게 순서만 보장 */ class DependencyResolver { @@ -40,10 +49,13 @@ public function resolve(array $steps): array $deps = $step['dependsOn'] ?? []; foreach ($deps as $dep) { - if (! isset($graph[$dep])) { - throw new Exception("Unknown dependency: '{$dep}' in step '{$id}'"); + // 조건부 의존성 또는 단순 문자열 지원 + $depId = $this->extractDependencyId($dep); + + if (! isset($graph[$depId])) { + throw new Exception("Unknown dependency: '{$depId}' in step '{$id}'"); } - $graph[$dep][] = $id; + $graph[$depId][] = $id; $inDegree[$id]++; } } @@ -78,6 +90,53 @@ public function resolve(array $steps): array return $sorted; } + /** + * 의존성에서 스텝 ID 추출 + * + * @param mixed $dependency 의존성 (문자열 또는 조건부 객체) + * @return string 스텝 ID + */ + private function extractDependencyId(mixed $dependency): string + { + if (is_string($dependency)) { + return $dependency; + } + + if (is_array($dependency) && isset($dependency['step'])) { + return $dependency['step']; + } + + throw new Exception('Invalid dependency format: '.json_encode($dependency)); + } + + /** + * 의존성 조건 파싱 + * + * @param mixed $dependency 의존성 (문자열 또는 조건부 객체) + * @return array ['stepId' => string, 'condition' => string] + */ + public function parseDependency(mixed $dependency): array + { + if (is_string($dependency)) { + return [ + 'stepId' => $dependency, + 'condition' => 'success', // 기본값: 성공 시에만 + ]; + } + + if (is_array($dependency)) { + return [ + 'stepId' => $dependency['step'] ?? '', + 'condition' => $dependency['onlyIf'] ?? 'success', + ]; + } + + return [ + 'stepId' => '', + 'condition' => 'success', + ]; + } + /** * 의존성 그래프 시각화 (디버깅용) * @@ -137,14 +196,24 @@ public function validate(array $steps): array $deps = $step['dependsOn'] ?? []; foreach ($deps as $dep) { - if (! in_array($dep, $stepIds)) { - $errors[] = "Step '{$id}' depends on unknown step '{$dep}'"; + // 조건부 의존성 지원 + $parsed = $this->parseDependency($dep); + $depId = $parsed['stepId']; + + if (! in_array($depId, $stepIds)) { + $errors[] = "Step '{$id}' depends on unknown step '{$depId}'"; } // 자기 참조 체크 - if ($dep === $id) { + if ($depId === $id) { $errors[] = "Step '{$id}' cannot depend on itself"; } + + // 조건부 의존성 조건값 검증 + $validConditions = ['success', 'failure', 'executed', 'skipped', 'any']; + if (! in_array($parsed['condition'], $validConditions)) { + $errors[] = "Step '{$id}': Invalid dependency condition '{$parsed['condition']}'. Valid values: ".implode(', ', $validConditions); + } } } diff --git a/app/Services/FlowTester/FlowExecutor.php b/app/Services/FlowTester/FlowExecutor.php index e8e7418d..4b70177f 100644 --- a/app/Services/FlowTester/FlowExecutor.php +++ b/app/Services/FlowTester/FlowExecutor.php @@ -28,6 +28,8 @@ class FlowExecutor private ApiLogCapturer $logCapturer; + private ConditionEvaluator $conditionEvaluator; + /** * 실행 로그 */ @@ -55,18 +57,25 @@ class FlowExecutor */ private array $apiLogs = []; + /** + * 플로우 설정 (bearerToken 동적 업데이트용) + */ + private array $flowConfig = []; + public function __construct( ?VariableBinder $binder = null, ?DependencyResolver $resolver = null, ?ResponseValidator $validator = null, ?HttpClient $httpClient = null, - ?ApiLogCapturer $logCapturer = null + ?ApiLogCapturer $logCapturer = null, + ?ConditionEvaluator $conditionEvaluator = null ) { $this->binder = $binder ?? new VariableBinder; $this->resolver = $resolver ?? new DependencyResolver; $this->validator = $validator ?? new ResponseValidator; $this->httpClient = $httpClient ?? new HttpClient; $this->logCapturer = $logCapturer ?? new ApiLogCapturer; + $this->conditionEvaluator = $conditionEvaluator ?? new ConditionEvaluator($this->binder); } /** @@ -118,26 +127,56 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra foreach ($orderedStepIds as $stepId) { $step = $stepMap[$stepId]; + $stepName = $step['name'] ?? $stepId; - // 의존성 스텝 성공 여부 확인 + // 1. 의존성 스텝 성공 여부 확인 $dependencyCheck = $this->checkDependencies($step); if (! $dependencyCheck['canRun']) { // 의존성 실패로 스킵 - $skipResult = $this->buildStepResult($stepId, $step['name'] ?? $stepId, microtime(true), false, [ + $skipResult = $this->buildStepResult($stepId, $stepName, microtime(true), false, [ 'skipped' => true, 'skipReason' => $dependencyCheck['reason'], + 'skipType' => 'dependency', 'failedDependencies' => $dependencyCheck['failedDeps'], ]); $this->executionLog[] = $skipResult; $this->stepSuccessMap[$stepId] = false; + $this->registerStepResult($stepId, $skipResult); continue; } + // 2. 조건(condition) 평가 - 분기 처리 + if (isset($step['condition'])) { + $conditionResult = $this->conditionEvaluator->evaluate($step['condition']); + + if (! $conditionResult['passed']) { + // 조건 불만족으로 스킵 (실패가 아님, 정상 스킵) + $skipResult = $this->buildStepResult($stepId, $stepName, microtime(true), true, [ + 'skipped' => true, + 'skipReason' => 'Condition not met: '.$conditionResult['reason'], + 'skipType' => 'condition', + 'conditionEvaluation' => $conditionResult, + ]); + $this->executionLog[] = $skipResult; + // 조건 스킵은 성공으로 처리 (분기에서 선택되지 않은 것일 뿐) + $this->stepSuccessMap[$stepId] = true; + $this->completedSteps++; + $this->registerStepResult($stepId, $skipResult); + + continue; + } + } + + // 3. Bearer 토큰 동적 업데이트 (login 스텝 이후) + $this->updateBearerToken(); + + // 4. 스텝 실행 $stepResult = $this->executeStep($step); $this->executionLog[] = $stepResult; $this->stepSuccessMap[$stepId] = $stepResult['success']; + $this->registerStepResult($stepId, $stepResult); if ($stepResult['success']) { $this->completedSteps++; @@ -170,6 +209,10 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra /** * 의존성 스텝 성공 여부 확인 * + * 조건부 의존성 지원: + * - 단순 문자열: "step_id" - 해당 스텝이 성공해야 실행 + * - 조건부 객체: {"step": "step_id", "onlyIf": "success|failure|executed|skipped|any"} + * * @param array $step 실행할 스텝 * @return array ['canRun' => bool, 'reason' => string|null, 'failedDeps' => array] */ @@ -183,19 +226,34 @@ private function checkDependencies(array $step): array $failedDeps = []; - foreach ($dependencies as $depId) { - // 의존성 스텝이 실행되지 않았거나 실패한 경우 - if (! isset($this->stepSuccessMap[$depId])) { - $failedDeps[] = $depId.' (not executed)'; - } elseif (! $this->stepSuccessMap[$depId]) { - $failedDeps[] = $depId.' (failed)'; + foreach ($dependencies as $dep) { + // 조건부 의존성 파싱 + $parsed = $this->resolver->parseDependency($dep); + $depId = $parsed['stepId']; + $condition = $parsed['condition']; + + // 의존 스텝 결과 조회 + $stepResults = $this->conditionEvaluator->getStepResults(); + $depResult = $stepResults[$depId] ?? null; + + // 의존 스텝이 아직 실행되지 않은 경우 (순서 문제) + if ($depResult === null && ! isset($this->stepSuccessMap[$depId])) { + $failedDeps[] = "{$depId} (not executed yet)"; + continue; + } + + // 조건에 따른 의존성 체크 + $conditionMet = $this->checkDependencyCondition($depId, $condition, $depResult); + + if (! $conditionMet) { + $failedDeps[] = "{$depId} (condition '{$condition}' not met)"; } } if (! empty($failedDeps)) { return [ 'canRun' => false, - 'reason' => 'Dependency failed: '.implode(', ', $failedDeps), + 'reason' => 'Dependency condition failed: '.implode(', ', $failedDeps), 'failedDeps' => $failedDeps, ]; } @@ -203,6 +261,31 @@ private function checkDependencies(array $step): array return ['canRun' => true, 'reason' => null, 'failedDeps' => []]; } + /** + * 의존성 조건 체크 + * + * @param string $depId 의존 스텝 ID + * @param string $condition 조건 (success, failure, executed, skipped, any) + * @param array|null $depResult 의존 스텝 실행 결과 + * @return bool 조건 충족 여부 + */ + private function checkDependencyCondition(string $depId, string $condition, ?array $depResult): bool + { + // stepSuccessMap에서 기본 정보 조회 + $isSuccess = $this->stepSuccessMap[$depId] ?? false; + $isSkipped = $depResult['skipped'] ?? false; + $isExecuted = isset($this->stepSuccessMap[$depId]) && ! $isSkipped; + + return match ($condition) { + 'success' => $isSuccess && ! $isSkipped, + 'failure', 'failed' => ! $isSuccess && ! $isSkipped && $isExecuted, + 'executed' => $isExecuted, + 'skipped' => $isSkipped, + 'any' => true, // 순서만 보장, 결과 무관 + default => $isSuccess, // 기본값은 success + }; + } + /** * 단일 스텝 실행 */ @@ -498,6 +581,8 @@ private function validateFlowDefinition(array $definition): void */ private function applyConfig(array $config): void { + // config 저장 (bearerToken 동적 업데이트용) + $this->flowConfig = $config; // Base URL 결정 - JSON에 있으면 사용, 없으면 .env에서 $baseUrl = $this->resolveBaseUrl($config['baseUrl'] ?? null); @@ -574,6 +659,27 @@ private function getDefaultBearerToken(): ?string return session('api_explorer_token') ?: null; } + /** + * Bearer 토큰 동적 업데이트 + * + * login 스텝에서 추출된 token을 후속 요청에서 사용할 수 있도록 + * 각 스텝 실행 전에 config.bearerToken을 다시 바인딩합니다. + */ + private function updateBearerToken(): void + { + $bearerToken = $this->flowConfig['bearerToken'] ?? null; + + if (! empty($bearerToken)) { + // 플레이스홀더 치환 ({{login.token}} 등) + $resolvedToken = $this->binder->bind($bearerToken); + + if (! empty($resolvedToken) && $resolvedToken !== $bearerToken) { + // 바인딩된 토큰이 있으면 httpClient에 설정 + $this->httpClient->setBearerToken($resolvedToken); + } + } + } + /** * Base URL 결정 * @@ -697,6 +803,20 @@ private function reset(): void $this->stepSuccessMap = []; $this->apiLogs = []; $this->binder->reset(); + $this->conditionEvaluator->reset(); + } + + /** + * 스텝 결과를 ConditionEvaluator에 등록 + * + * 다음 스텝의 조건 평가에서 이전 스텝 결과를 참조할 수 있도록 합니다. + * + * @param string $stepId 스텝 ID + * @param array $result 스텝 실행 결과 + */ + private function registerStepResult(string $stepId, array $result): void + { + $this->conditionEvaluator->setStepResult($stepId, $result); } /** diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index fb63d881..2ad4e90d 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -4,6 +4,7 @@ use App\Models\Quote\QuoteFormula; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class FormulaEvaluatorService { @@ -137,6 +138,14 @@ public function executeAll(Collection $formulasByCategory, array $inputVariables 'category' => $formula->category->name, 'type' => $formula->type, ]; + + // range/mapping 결과에서 품목 자동 추출 + if (in_array($formula->type, [QuoteFormula::TYPE_RANGE, QuoteFormula::TYPE_MAPPING])) { + $extractedItem = $this->extractItemFromResult($result, $formula); + if ($extractedItem) { + $items[] = $extractedItem; + } + } } else { // 품목 출력 foreach ($formula->items as $item) { @@ -167,6 +176,46 @@ public function executeAll(Collection $formulasByCategory, array $inputVariables ]; } + /** + * range/mapping 결과에서 품목 정보 추출 + */ + private function extractItemFromResult(mixed $result, QuoteFormula $formula): ?array + { + // JSON 문자열이면 파싱 + if (is_string($result)) { + $decoded = json_decode($result, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $result = $decoded; + } + } + + // 배열이고 item_code가 있으면 품목으로 변환 + if (is_array($result) && isset($result['item_code'])) { + $quantity = $result['quantity'] ?? 1; + $itemCode = $result['item_code']; + $unitPrice = $this->getItemPrice($itemCode); + + // 수량이 수식이면 평가 + if (! is_numeric($quantity)) { + $quantity = $this->evaluate((string) $quantity); + } + + return [ + 'item_code' => $itemCode, + 'item_name' => $result['value'] ?? $itemCode, + 'specification' => $result['note'] ?? null, + 'unit' => 'EA', + 'quantity' => (float) $quantity, + 'unit_price' => $unitPrice, + 'total_price' => (float) $quantity * $unitPrice, + 'formula_variable' => $formula->variable, + 'auto_selected' => true, + ]; + } + + return null; + } + /** * 단일 수식 실행 */ @@ -326,7 +375,7 @@ private function getItemPrice(string $itemCode): float $tenantId = session('selected_tenant_id'); if (! $tenantId) { - $this->errors[] = "테넌트 ID가 설정되지 않았습니다."; + $this->errors[] = '테넌트 ID가 설정되지 않았습니다.'; return 0; } @@ -358,4 +407,168 @@ public function resetVariables(): void $this->variables = []; $this->errors = []; } + + /** + * 품목 상세 정보 조회 (BOM 트리 포함) + */ + public function getItemDetails(string $itemCode): ?array + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return null; + } + + $item = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if (! $item) { + return null; + } + + return [ + 'id' => $item->id, + 'code' => $item->code, + 'name' => $item->name, + 'item_type' => $item->item_type, + 'item_type_label' => $this->getItemTypeLabel($item->item_type), + 'unit' => $item->unit, + 'description' => $item->description, + 'attributes' => json_decode($item->attributes ?? '{}', true), + 'bom' => $this->getBomTree($tenantId, $item->id, json_decode($item->bom ?? '[]', true)), + 'has_bom' => ! empty($item->bom) && $item->bom !== '[]', + ]; + } + + /** + * BOM 트리 재귀적으로 조회 + */ + private function getBomTree(int $tenantId, int $parentItemId, array $bomData, int $depth = 0): array + { + // 무한 루프 방지 + if ($depth > 10 || empty($bomData)) { + return []; + } + + $children = []; + $childIds = array_column($bomData, 'child_item_id'); + + if (empty($childIds)) { + return []; + } + + // 자식 품목들 일괄 조회 + $childItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('id', $childIds) + ->whereNull('deleted_at') + ->get() + ->keyBy('id'); + + foreach ($bomData as $bomItem) { + $childItemId = $bomItem['child_item_id'] ?? null; + $quantity = $bomItem['quantity'] ?? 1; + + if (! $childItemId) { + continue; + } + + $childItem = $childItems->get($childItemId); + + if (! $childItem) { + continue; + } + + $childBomData = json_decode($childItem->bom ?? '[]', true); + + $children[] = [ + 'id' => $childItem->id, + 'code' => $childItem->code, + 'name' => $childItem->name, + 'item_type' => $childItem->item_type, + 'item_type_label' => $this->getItemTypeLabel($childItem->item_type), + 'unit' => $childItem->unit, + 'quantity' => (float) $quantity, + 'description' => $childItem->description, + 'has_bom' => ! empty($childBomData), + 'children' => $this->getBomTree($tenantId, $childItem->id, $childBomData, $depth + 1), + ]; + } + + return $children; + } + + /** + * 품목 유형 라벨 + */ + public function getItemTypeLabel(string $itemType): string + { + return match ($itemType) { + 'FG' => '완제품', + 'PT' => '부품', + 'SM' => '부자재', + 'RM' => '원자재', + 'CS' => '소모품', + default => $itemType, + }; + } + + /** + * 품목 목록에 상세 정보 추가 + */ + public function enrichItemsWithDetails(array $items): array + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return $items; + } + + // 품목 코드 수집 + $itemCodes = array_unique(array_column($items, 'item_code')); + + if (empty($itemCodes)) { + return $items; + } + + // 품목 정보 일괄 조회 + $itemsData = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('code', $itemCodes) + ->whereNull('deleted_at') + ->get() + ->keyBy('code'); + + // 품목별 상세 정보 추가 + foreach ($items as &$item) { + $itemData = $itemsData->get($item['item_code']); + + if ($itemData) { + $bomData = json_decode($itemData->bom ?? '[]', true); + + $item['item_id'] = $itemData->id; + $item['item_type'] = $itemData->item_type; + $item['item_type_label'] = $this->getItemTypeLabel($itemData->item_type); + $item['item_name'] = $itemData->name; // DB에서 정확한 이름으로 갱신 + $item['unit'] = $itemData->unit ?? $item['unit']; + $item['description'] = $itemData->description; + $item['attributes'] = json_decode($itemData->attributes ?? '{}', true); + $item['has_bom'] = ! empty($bomData); + $item['bom_children'] = $this->getBomTree($tenantId, $itemData->id, $bomData); + } else { + $item['item_id'] = null; + $item['item_type'] = null; + $item['item_type_label'] = '미등록'; + $item['description'] = null; + $item['attributes'] = []; + $item['has_bom'] = false; + $item['bom_children'] = []; + } + } + + return $items; + } } diff --git a/public/sounds/default.wav b/public/sounds/default.wav new file mode 100644 index 00000000..59238b1b Binary files /dev/null and b/public/sounds/default.wav differ diff --git a/resources/views/dev-tools/flow-tester/edit.blade.php b/resources/views/dev-tools/flow-tester/edit.blade.php index 0bfb2474..94bdb93e 100644 --- a/resources/views/dev-tools/flow-tester/edit.blade.php +++ b/resources/views/dev-tools/flow-tester/edit.blade.php @@ -15,6 +15,13 @@ class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">