diff --git a/CLAUDE.md b/CLAUDE.md index 0a11446..93827a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,10 +221,275 @@ ### Testing - PHPUnit configuration: `phpunit.xml` in each Laravel app - Test directories: `tests/` in Laravel applications -## Development Workflow +## SAM 프로젝트 권장 워크플로우 + +### 1. 기능 개발 워크플로우 (Feature Development) + +새로운 기능 개발 시 API-First 접근 방식을 따릅니다: + +``` +1. 기획 및 분석 + - CURRENT_WORKS.md에 작업 계획 작성 + - 영향받는 저장소 식별 (api/admin/front) + - 데이터베이스 스키마 변경 필요성 검토 + +2. 개발 환경 준비 + - git status로 모든 저장소 상태 확인 + - Docker 서비스 실행 확인 (composer dev) + - 마이그레이션 상태 확인 (php artisan migrate:status) + +3. API 우선 개발 (API-First) + - Service 클래스 구현 (비즈니스 로직) + - Controller 구현 (FormRequest + ApiResponse) + - Swagger 문서 작성 + - API 테스트 (Postman/Swagger UI) + +4. Frontend 연동 + - Admin 패널 (Filament) 또는 CodeIgniter 뷰 구현 + - 필요시 공통 모델을 shared/로 이동 + +5. 품질 검증 + - API 테스트 실행 + - Pint 코드 포맷팅 (./vendor/bin/pint) + - Swagger 문서 검토 + +6. 커밋 및 정리 + - CURRENT_WORKS.md 업데이트 + - 저장소별 순차 커밋 + - 임시 파일 정리 +``` + +**확인 포인트**: API 개발 완료 후 → Frontend 연동 진행 여부 확인 + +### 2. 버그 수정 워크플로우 (Bugfix) + +``` +1. 문제 식별 + - 에러 로그 확인 (Laravel 로그, 브라우저 콘솔) + - 영향 범위 파악 (어떤 저장소/컴포넌트) + - 재현 방법 문서화 + +2. 핫픽스 브랜치 생성 (긴급시) + - git checkout -b hotfix/issue-description + - 수정 범위를 최소화 + +3. 수정 구현 + - 근본 원인 해결 (임시방편 금지) + - 관련 테스트 케이스 추가 + - 감사 로그 영향 고려 + +4. 검증 및 배포 + - 로컬 테스트 완료 + - Swagger 문서 업데이트 (API 변경시) + - 프로덕션 배포 후 모니터링 +``` + +**확인 포인트**: 긴급도에 따른 핫픽스 브랜치 생성 여부 확인 + +### 3. 데이터베이스 변경 워크플로우 + +``` +1. 마이그레이션 설계 + - 기존 데이터 호환성 검토 + - 롤백 시나리오 준비 + - 멀티테넌트 영향도 분석 + +2. 개발 환경 테스트 + - php artisan migrate:status 확인 + - 테스트 데이터로 마이그레이션 검증 + - 롤백 테스트 수행 + +3. 모델 업데이트 + - Eloquent 모델 수정 + - BelongsToTenant 스코프 확인 + - 감사 로그 설정 점검 + +4. API 영향도 점검 + - 기존 API 호환성 확인 + - Swagger 스키마 업데이트 + - 버전 호환성 고려 +``` + +**확인 포인트**: 마이그레이션 실행 전 백업 완료 확인 + +### 4. 멀티 저장소 동기화 워크플로우 (상시 적용) + +``` +작업 순서: api (백엔드) → admin (관리 UI) → front (사용자 UI) → shared (공통) + +의존성 관리: +- API 변경사항 완료 후 Frontend 작업 +- Shared 모델 변경 시 모든 저장소 동기화 +- Docker 설정 변경 시 전체 재시작 + +통합 테스트: +- API → Admin 패널 → 사용자 화면 순서로 테스트 +- 권한 및 멀티테넌트 기능 검증 +``` + +### 5. 코드 리뷰 및 품질 관리 (상시 적용) + +**코드 품질 체크리스트:** +``` +□ SAM API Development Rules 준수 +□ Service-First 아키텍처 (Controller는 단순) +□ FormRequest 검증 사용 +□ i18n 메시지 키 사용 (__('message.xxx')) +□ Swagger 문서 완성도 +□ 멀티테넌트 스코프 적용 (BelongsToTenant) +□ 감사 로그 고려 +□ Soft Delete 적용 +□ 테스트 케이스 작성 +□ Pint 포맷팅 적용 +``` + +### 6. 배포 준비 워크플로우 + +``` +1. 코드 검증 + - 모든 테스트 통과 + - Swagger 문서 최신화 + - 환경변수 설정 확인 + +2. 데이터베이스 + - 마이그레이션 순서 확인 + - 백업 계획 수립 + - 롤백 시나리오 준비 + +3. 문서화 + - CURRENT_WORKS.md 최종 업데이트 + - API 변경사항 문서화 + - 배포 노트 작성 +``` + +**확인 포인트**: 배포 전 마지막 체크리스트 확인 + +### 워크플로우 진행 방식 + +- **1~3번**: 시작부터 완료까지 연속 진행 (중간 확인 포인트에서 사용자 확인) +- **4~5번**: 상시 적용 (작업할 때마다 자동 적용) +- **6번**: 배포 시에만 적용 + +### SuperClaude 활용 전략 + +각 개발 단계별로 SuperClaude 프레임워크를 전략적으로 활용합니다: + +#### 🎯 기획 단계 (SuperClaude 필수) +``` +/sc:business-panel @requirements.md +- 비즈니스 전문가 패널을 통한 요구사항 분석 +- Porter, Drucker, Collins 등 전문가 관점으로 기능 검증 +- 시장 적합성 및 비즈니스 가치 평가 +- 우선순위 및 리소스 배분 결정 +``` + +#### 🔧 백엔드 개발 (SuperClaude 적극 활용) +``` +/sc:architect --system --api-design +- 시스템 아키텍처 설계 및 검토 +- API 설계 패턴 최적화 +- 데이터베이스 스키마 설계 +- 성능 및 확장성 고려사항 + +/sc:security-engineer --api-security +- API 보안 취약점 분석 +- 멀티테넌트 보안 설계 +- 인증/인가 시스템 검증 +- 감사 로그 보안 검토 +``` + +#### 🎨 프론트엔드 개발 (SuperClaude 부분 활용) +``` +/sc:frontend-architect --ui-design --accessibility +- UI/UX 아키텍처 설계 +- 접근성 및 사용성 검증 +- 컴포넌트 재사용성 최적화 +- 반응형 디자인 전략 + +Magic MCP 활용: +- /ui 명령어로 21st.dev 패턴 기반 컴포넌트 생성 +- 현대적이고 접근성 높은 UI 구현 +``` + +#### ✅ 검증 단계 (SuperClaude 필수) +``` +/sc:quality-engineer --comprehensive-testing +- 테스트 전략 수립 및 검증 +- 코드 품질 메트릭 분석 +- 성능 테스트 설계 +- 엣지 케이스 식별 + +/sc:security-engineer --penetration-testing +- 보안 취약점 스캔 +- 멀티테넌트 격리 검증 +- API 보안 테스트 +- 데이터 보호 검증 + +/sc:business-panel --quality-validation +- 비즈니스 요구사항 충족도 검증 +- 사용자 관점 품질 평가 +- ROI 및 비즈니스 가치 측정 +``` + +#### 🚀 배포 준비 (SuperClaude 선택적 활용) +``` +/sc:devops-architect --deployment-strategy +- 배포 전략 최적화 +- 인프라 자동화 검토 +- 모니터링 및 로깅 설정 +- 롤백 시나리오 검증 +``` + +### SuperClaude 모드별 활용 가이드 + +#### 🔥 고강도 작업 (--ultrathink) +- 복잡한 시스템 설계 +- 대규모 리팩토링 +- 보안 아키텍처 설계 +- 성능 최적화 + +#### 🎯 중강도 작업 (--think-hard) +- API 설계 및 검토 +- 데이터베이스 스키마 변경 +- 비즈니스 로직 구현 +- 테스트 전략 수립 + +#### 💡 일반 작업 (--think) +- 기능 구현 +- 버그 수정 +- 코드 리뷰 +- 문서 작성 + +### 자동 SuperClaude 활성화 조건 + +다음 상황에서 자동으로 SuperClaude를 활성화합니다: + +**기획 단계:** +- 새로운 기능 요구사항 분석 시 +- 비즈니스 프로세스 변경 시 +- 시장 적합성 검토 시 + +**백엔드 개발:** +- 새로운 API 엔드포인트 설계 시 +- 데이터베이스 스키마 변경 시 +- 보안 관련 기능 구현 시 +- 성능 최적화 작업 시 + +**프론트엔드 개발:** +- 새로운 UI 컴포넌트 설계 시 +- 사용자 경험 개선 작업 시 +- 접근성 요구사항 구현 시 + +**검증 단계:** +- 품질 보증 체크리스트 실행 시 +- 보안 검증 작업 시 +- 성능 테스트 수행 시 +- 비즈니스 요구사항 검증 시 + +## Development Workflow (기본 환경 설정) 1. **Environment Setup**: Configure `.env` files for each application -2. **Database**: Run migrations in both admin and api applications +2. **Database**: Run migrations in both admin and api applications 3. **Dependencies**: `composer install` and `npm install` in relevant directories 4. **Development**: Use `composer dev` to run all services concurrently 5. **API Testing**: Access Swagger documentation for API endpoints @@ -449,4 +714,5 @@ ## Important Notes - Multi-tenant architecture requires proper tenant context in all operations - **Follow SAM API Development Rules strictly when working on API-related tasks** - **Always maintain CURRENT_WORKS.md for project continuity** -- **Sync CLAUDE.md files between sessions for consistency** \ No newline at end of file +- **Sync CLAUDE.md files between sessions for consistency** +- **🇰🇷 LANGUAGE SETTING: 모든 응답은 한국어로 제공 (별도 요청 시 예외)** \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/Design/BomCalculationController.php b/app/Http/Controllers/Api/V1/Design/BomCalculationController.php new file mode 100644 index 0000000..18949eb --- /dev/null +++ b/app/Http/Controllers/Api/V1/Design/BomCalculationController.php @@ -0,0 +1,288 @@ +bomCalculationService = $bomCalculationService; + } + + /** + * 견적 파라미터 조회 + * + * 특정 모델의 견적 시 필요한 입력 파라미터 스키마를 조회합니다. + * BOM에 정의된 조건만 동적으로 추출하여 반환합니다. + * + * @urlParam model_id int required 모델 ID Example: 1 + * @queryParam company_name string 업체명 (선택사항) Example: 경동기업 + * + * @response 200 { + * "success": true, + * "message": "견적 파라미터를 성공적으로 조회했습니다.", + * "data": { + * "model_info": { + * "id": 1, + * "name": "스크린셔터 표준형", + * "version": "v1.0", + * "bom_template_id": 1 + * }, + * "company_info": { + * "company_type": "경동기업", + * "formula_version": "v2.0", + * "requested_company": "경동기업" + * }, + * "parameters": { + * "required_parameters": [ + * { + * "key": "W0", + * "label": "오픈사이즈 가로(mm)", + * "type": "integer", + * "required": true, + * "min": 500, + * "max": 15000 + * } + * ] + * } + * } + * } + */ + public function getEstimateParameters(GetEstimateParametersRequest $request, int $modelId): JsonResponse + { + return ApiResponse::handle(function () use ($request, $modelId) { + $companyName = $request->query('company_name'); + + $result = $this->bomCalculationService->getEstimateParameters($modelId, $companyName); + + return [ + 'success' => true, + 'data' => $result, + 'message' => __('message.fetched') + ]; + }); + } + + /** + * BOM 계산 실행 + * + * 입력된 파라미터를 기반으로 BOM 수량을 동적으로 계산합니다. + * 업체별 산출식을 적용하여 실시간 견적을 생성합니다. + * + * @urlParam bom_template_id int required BOM 템플릿 ID Example: 1 + * + * @bodyParam parameters object required 계산 파라미터 + * @bodyParam parameters.W0 int required 오픈사이즈 가로(mm) Example: 3000 + * @bodyParam parameters.H0 int required 오픈사이즈 세로(mm) Example: 2500 + * @bodyParam parameters.product_type string required 제품타입 Example: screen + * @bodyParam company_name string 업체명 (선택사항) Example: 경동기업 + * + * @response 200 { + * "success": true, + * "message": "BOM 계산이 완료되었습니다.", + * "data": { + * "bom_template": { + * "id": 1, + * "name": "Main", + * "company_type": "경동기업", + * "formula_version": "v2.0" + * }, + * "input_parameters": { + * "W0": 3000, + * "H0": 2500, + * "product_type": "screen" + * }, + * "calculated_values": { + * "W1": 3160, + * "H1": 2850, + * "area": 9.006, + * "weight": 60.42 + * }, + * "bom_items": [ + * { + * "item_id": 1, + * "ref_type": "MATERIAL", + * "ref_id": 101, + * "original_qty": 1, + * "calculated_qty": 2, + * "is_calculated": true, + * "calculation_formula": "bracket_quantity" + * } + * ] + * } + * } + */ + public function calculateBom(CalculateBomRequest $request, int $bomTemplateId): JsonResponse + { + return ApiResponse::handle(function () use ($request, $bomTemplateId) { + $data = $request->validated(); + + $result = $this->bomCalculationService->calculateBomEstimate( + $bomTemplateId, + $data['parameters'], + $data['company_name'] ?? null + ); + + if (!$result['success']) { + return [ + 'success' => false, + 'message' => __('error.calculation_failed'), + 'error' => $result['error'] + ]; + } + + return [ + 'success' => true, + 'data' => $result['data'], + 'message' => __('message.calculation.completed') + ]; + }); + } + + /** + * 업체별 산출식 목록 조회 + * + * 특정 업체의 등록된 산출식 목록을 조회합니다. + * + * @urlParam company_name string required 업체명 Example: 경동기업 + * + * @response 200 { + * "success": true, + * "message": "업체 산출식 목록을 조회했습니다.", + * "data": { + * "company_name": "경동기업", + * "total_formulas": 5, + * "formulas": [ + * { + * "type": "manufacturing_size", + * "version": "v2.0", + * "description": "제작사이즈 계산식", + * "updated_at": "2025-09-22T15:30:00Z" + * } + * ] + * } + * } + */ + public function getCompanyFormulas(string $companyName): JsonResponse + { + return ApiResponse::handle(function () use ($companyName) { + $result = $this->bomCalculationService->getCompanyFormulas($companyName); + + return [ + 'success' => true, + 'data' => $result, + 'message' => __('message.fetched') + ]; + }); + } + + /** + * 업체별 산출식 등록/수정 + * + * 특정 업체의 산출식을 등록하거나 수정합니다. + * 기존 산출식이 있으면 새 버전으로 업데이트됩니다. + * + * @urlParam company_name string required 업체명 Example: 경동기업 + * @urlParam formula_type string required 산출식 타입 Example: manufacturing_size + * + * @bodyParam formula_expression string required 계산식 표현식 + * @bodyParam parameters array required 필요한 파라미터 정의 + * @bodyParam conditions array 적용 조건 (선택사항) + * @bodyParam validation_rules array 검증 규칙 (선택사항) + * @bodyParam description string 산출식 설명 (선택사항) + * + * @response 201 { + * "success": true, + * "message": "업체 산출식이 등록되었습니다.", + * "data": { + * "id": 1, + * "company_name": "경동기업", + * "formula_type": "manufacturing_size", + * "version": "v1.0", + * "is_active": true, + * "created_at": "2025-09-22T15:30:00Z" + * } + * } + */ + public function saveCompanyFormula(SaveCompanyFormulaRequest $request, string $companyName, string $formulaType): JsonResponse + { + return ApiResponse::handle(function () use ($request, $companyName, $formulaType) { + $data = $request->validated(); + + $result = $this->bomCalculationService->saveCompanyFormula($companyName, $formulaType, $data); + + return [ + 'success' => true, + 'data' => $result, + 'message' => __('message.company_formula.created') + ]; + }); + } + + /** + * 계산식 테스트 실행 + * + * 산출식을 실제 적용하기 전에 테스트해볼 수 있습니다. + * + * @bodyParam formula_expression string required 테스트할 계산식 + * @bodyParam test_parameters object required 테스트 파라미터 + * + * @response 200 { + * "success": true, + * "message": "계산식 테스트가 완료되었습니다.", + * "data": { + * "formula_expression": "bracket_quantity", + * "input_parameters": {"W1": 3000}, + * "result": {"result": 2}, + * "execution_time_ms": 1.5 + * } + * } + */ + public function testFormula(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $request->validate([ + 'formula_expression' => 'required|string', + 'test_parameters' => 'required|array' + ]); + + $startTime = microtime(true); + + // 직접 FormulaParser를 사용하여 테스트 + $parser = app(\App\Services\Calculation\FormulaParser::class); + $result = $parser->execute( + $request->input('formula_expression'), + $request->input('test_parameters') + ); + + $executionTime = (microtime(true) - $startTime) * 1000; // ms로 변환 + + return [ + 'success' => true, + 'data' => [ + 'formula_expression' => $request->input('formula_expression'), + 'input_parameters' => $request->input('test_parameters'), + 'result' => $result, + 'execution_time_ms' => round($executionTime, 2) + ], + 'message' => __('message.formula.test_completed') + ]; + }); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Design/CalculateBomRequest.php b/app/Http/Requests/Design/CalculateBomRequest.php new file mode 100644 index 0000000..87942a1 --- /dev/null +++ b/app/Http/Requests/Design/CalculateBomRequest.php @@ -0,0 +1,87 @@ +|string> + */ + public function rules(): array + { + return [ + 'parameters' => 'required|array', + 'parameters.W0' => 'nullable|integer|min:500|max:15000', + 'parameters.H0' => 'nullable|integer|min:500|max:15000', + 'parameters.product_type' => 'nullable|string|in:screen,steel,blind', + 'parameters.installation_type' => 'nullable|string|in:window,wall,ceiling', + 'parameters.power_source' => 'nullable|string|in:manual,electric,automatic', + 'parameters.motor_type' => 'nullable|string|in:standard,low_noise,high_torque', + 'parameters.material_grade' => 'nullable|string|in:standard,premium,luxury', + 'company_name' => 'nullable|string|max:100' + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'parameters.required' => __('error.validation.required'), + 'parameters.array' => __('error.validation.array'), + 'parameters.W0.integer' => __('error.validation.integer'), + 'parameters.W0.min' => __('error.validation.min.numeric'), + 'parameters.W0.max' => __('error.validation.max.numeric'), + 'parameters.H0.integer' => __('error.validation.integer'), + 'parameters.H0.min' => __('error.validation.min.numeric'), + 'parameters.H0.max' => __('error.validation.max.numeric'), + 'parameters.product_type.string' => __('error.validation.string'), + 'parameters.product_type.in' => __('error.validation.in'), + 'parameters.installation_type.string' => __('error.validation.string'), + 'parameters.installation_type.in' => __('error.validation.in'), + 'parameters.power_source.string' => __('error.validation.string'), + 'parameters.power_source.in' => __('error.validation.in'), + 'parameters.motor_type.string' => __('error.validation.string'), + 'parameters.motor_type.in' => __('error.validation.in'), + 'parameters.material_grade.string' => __('error.validation.string'), + 'parameters.material_grade.in' => __('error.validation.in'), + 'company_name.string' => __('error.validation.string'), + 'company_name.max' => __('error.validation.max.string') + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'parameters' => '계산 파라미터', + 'parameters.W0' => '오픈사이즈 가로(mm)', + 'parameters.H0' => '오픈사이즈 세로(mm)', + 'parameters.product_type' => '제품타입', + 'parameters.installation_type' => '설치타입', + 'parameters.power_source' => '동력원', + 'parameters.motor_type' => '모터타입', + 'parameters.material_grade' => '자재등급', + 'company_name' => '업체명' + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Design/GetEstimateParametersRequest.php b/app/Http/Requests/Design/GetEstimateParametersRequest.php new file mode 100644 index 0000000..30cbf86 --- /dev/null +++ b/app/Http/Requests/Design/GetEstimateParametersRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'company_name' => 'nullable|string|max:100' + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'company_name.string' => __('error.validation.string'), + 'company_name.max' => __('error.validation.max.string') + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Design/SaveCompanyFormulaRequest.php b/app/Http/Requests/Design/SaveCompanyFormulaRequest.php new file mode 100644 index 0000000..ea13813 --- /dev/null +++ b/app/Http/Requests/Design/SaveCompanyFormulaRequest.php @@ -0,0 +1,120 @@ +|string> + */ + public function rules(): array + { + return [ + 'formula_expression' => 'required|string|max:2000', + 'parameters' => 'required|array', + 'parameters.*.key' => 'required|string|max:50', + 'parameters.*.label' => 'required|string|max:100', + 'parameters.*.type' => 'required|string|in:integer,decimal,string,boolean,array', + 'parameters.*.required' => 'required|boolean', + 'parameters.*.min' => 'nullable|numeric', + 'parameters.*.max' => 'nullable|numeric', + 'parameters.*.default' => 'nullable', + 'parameters.*.options' => 'nullable|array', + 'conditions' => 'nullable|array', + 'conditions.*.field' => 'nullable|string|max:50', + 'conditions.*.operator' => 'nullable|string|in:=,!=,>,>=,<,<=,in,not_in', + 'conditions.*.value' => 'nullable', + 'validation_rules' => 'nullable|array', + 'validation_rules.*.field' => 'nullable|string|max:50', + 'validation_rules.*.rule' => 'nullable|string|max:200', + 'validation_rules.*.message' => 'nullable|string|max:200', + 'description' => 'nullable|string|max:500' + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'formula_expression.required' => __('error.validation.required'), + 'formula_expression.string' => __('error.validation.string'), + 'formula_expression.max' => __('error.validation.max.string'), + 'parameters.required' => __('error.validation.required'), + 'parameters.array' => __('error.validation.array'), + 'parameters.*.key.required' => __('error.validation.required'), + 'parameters.*.key.string' => __('error.validation.string'), + 'parameters.*.key.max' => __('error.validation.max.string'), + 'parameters.*.label.required' => __('error.validation.required'), + 'parameters.*.label.string' => __('error.validation.string'), + 'parameters.*.label.max' => __('error.validation.max.string'), + 'parameters.*.type.required' => __('error.validation.required'), + 'parameters.*.type.string' => __('error.validation.string'), + 'parameters.*.type.in' => __('error.validation.in'), + 'parameters.*.required.required' => __('error.validation.required'), + 'parameters.*.required.boolean' => __('error.validation.boolean'), + 'parameters.*.min.numeric' => __('error.validation.numeric'), + 'parameters.*.max.numeric' => __('error.validation.numeric'), + 'parameters.*.options.array' => __('error.validation.array'), + 'conditions.array' => __('error.validation.array'), + 'conditions.*.field.string' => __('error.validation.string'), + 'conditions.*.field.max' => __('error.validation.max.string'), + 'conditions.*.operator.string' => __('error.validation.string'), + 'conditions.*.operator.in' => __('error.validation.in'), + 'validation_rules.array' => __('error.validation.array'), + 'validation_rules.*.field.string' => __('error.validation.string'), + 'validation_rules.*.field.max' => __('error.validation.max.string'), + 'validation_rules.*.rule.string' => __('error.validation.string'), + 'validation_rules.*.rule.max' => __('error.validation.max.string'), + 'validation_rules.*.message.string' => __('error.validation.string'), + 'validation_rules.*.message.max' => __('error.validation.max.string'), + 'description.string' => __('error.validation.string'), + 'description.max' => __('error.validation.max.string') + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'formula_expression' => '계산식 표현식', + 'parameters' => '파라미터 정의', + 'parameters.*.key' => '파라미터 키', + 'parameters.*.label' => '파라미터 레이블', + 'parameters.*.type' => '파라미터 타입', + 'parameters.*.required' => '필수 여부', + 'parameters.*.min' => '최소값', + 'parameters.*.max' => '최대값', + 'parameters.*.default' => '기본값', + 'parameters.*.options' => '옵션 목록', + 'conditions' => '적용 조건', + 'conditions.*.field' => '조건 필드', + 'conditions.*.operator' => '조건 연산자', + 'conditions.*.value' => '조건 값', + 'validation_rules' => '검증 규칙', + 'validation_rules.*.field' => '검증 필드', + 'validation_rules.*.rule' => '검증 규칙', + 'validation_rules.*.message' => '검증 메시지', + 'description' => '설명' + ]; + } +} \ No newline at end of file diff --git a/app/Models/Calculation/CalculationConfig.php b/app/Models/Calculation/CalculationConfig.php new file mode 100644 index 0000000..2185dc9 --- /dev/null +++ b/app/Models/Calculation/CalculationConfig.php @@ -0,0 +1,102 @@ + 'array', + 'conditions' => 'array', + 'validation_rules' => 'array', + 'is_active' => 'boolean' + ]; + + /** + * 테넌트별 스코프 + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where('tenant_id', $tenantId); + } + + /** + * 활성화된 설정만 조회 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * 업체별 산출식 조회 + */ + public function scopeForCompany(Builder $query, string $companyName): Builder + { + return $query->where('company_name', $companyName); + } + + /** + * 산출식 타입별 조회 + */ + public function scopeForType(Builder $query, string $formulaType): Builder + { + return $query->where('formula_type', $formulaType); + } + + /** + * 최신 버전 조회 + */ + public function scopeLatestVersion(Builder $query): Builder + { + return $query->orderByDesc('version'); + } + + /** + * 업체별 활성 산출식 목록 조회 + */ + public static function getActiveFormulasForCompany(int $tenantId, string $companyName): array + { + return static::forTenant($tenantId) + ->forCompany($companyName) + ->active() + ->get() + ->groupBy('formula_type') + ->map(function ($formulas) { + return $formulas->sortByDesc('version')->first(); + }) + ->toArray(); + } + + /** + * 특정 산출식 조회 (최신 버전) + */ + public static function getLatestFormula(int $tenantId, string $companyName, string $formulaType): ?static + { + return static::forTenant($tenantId) + ->forCompany($companyName) + ->forType($formulaType) + ->active() + ->latestVersion() + ->first(); + } +} \ No newline at end of file diff --git a/app/Models/Design/BomTemplate.php b/app/Models/Design/BomTemplate.php index 45a944f..b1f6c5a 100644 --- a/app/Models/Design/BomTemplate.php +++ b/app/Models/Design/BomTemplate.php @@ -13,10 +13,12 @@ class BomTemplate extends Model protected $fillable = [ 'tenant_id','model_version_id','name','is_primary','notes', + 'calculation_schema','company_type','formula_version', ]; protected $casts = [ 'is_primary' => 'boolean', + 'calculation_schema' => 'array', ]; public function modelVersion() { diff --git a/app/Models/Design/BomTemplateItem.php b/app/Models/Design/BomTemplateItem.php index fbfba7f..2c9715e 100644 --- a/app/Models/Design/BomTemplateItem.php +++ b/app/Models/Design/BomTemplateItem.php @@ -10,11 +10,15 @@ class BomTemplateItem extends Model protected $fillable = [ 'tenant_id','bom_template_id','ref_type','ref_id','qty','waste_rate','uom_id','notes','sort_order', + 'is_calculated','calculation_formula','depends_on','calculation_config', ]; protected $casts = [ 'qty' => 'decimal:6', 'waste_rate' => 'decimal:6', + 'is_calculated' => 'boolean', + 'depends_on' => 'array', + 'calculation_config' => 'array', ]; public function template() { diff --git a/app/Services/Calculation/CalculationEngine.php b/app/Services/Calculation/CalculationEngine.php new file mode 100644 index 0000000..86aa5cf --- /dev/null +++ b/app/Services/Calculation/CalculationEngine.php @@ -0,0 +1,268 @@ +parser = $parser; + $this->validator = $validator; + } + + /** + * BOM 계산 실행 + * @param int $bomTemplateId BOM 템플릿 ID + * @param array $parameters 입력 파라미터 + * @param string|null $companyName 업체명 (null시 기본값 사용) + * @return array 계산 결과 + */ + public function calculateBOM(int $bomTemplateId, array $parameters, ?string $companyName = null): array + { + try { + // BOM 템플릿 조회 + $bomTemplate = BomTemplate::with(['items', 'modelVersion.model']) + ->findOrFail($bomTemplateId); + + // 파라미터 검증 + $this->validateParameters($bomTemplate, $parameters); + + // 중간 계산값 도출 (W1, H1, 면적, 중량 등) + $calculatedValues = $this->calculateIntermediateValues($bomTemplate, $parameters, $companyName); + + // BOM 아이템별 수량 계산 + $bomItems = $this->calculateBomItems($bomTemplate->items, $calculatedValues, $parameters, $companyName); + + return [ + 'success' => true, + 'bom_template' => [ + 'id' => $bomTemplate->id, + 'name' => $bomTemplate->name, + 'company_type' => $bomTemplate->company_type, + 'formula_version' => $bomTemplate->formula_version + ], + 'input_parameters' => $parameters, + 'calculated_values' => $calculatedValues, + 'bom_items' => $bomItems, + 'calculation_timestamp' => now() + ]; + + } catch (\Exception $e) { + Log::error('BOM 계산 실패', [ + 'bom_template_id' => $bomTemplateId, + 'parameters' => $parameters, + 'error' => $e->getMessage() + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'bom_template_id' => $bomTemplateId + ]; + } + } + + /** + * 견적시 필요한 파라미터 스키마 추출 + * @param int $bomTemplateId BOM 템플릿 ID + * @return array 파라미터 스키마 + */ + public function getRequiredParameters(int $bomTemplateId): array + { + $bomTemplate = BomTemplate::findOrFail($bomTemplateId); + + $schema = $bomTemplate->calculation_schema; + if (!$schema) { + return $this->getDefaultParameterSchema($bomTemplate); + } + + return $schema; + } + + /** + * 중간 계산값 도출 (W1, H1, 면적, 중량 등) + */ + protected function calculateIntermediateValues(BomTemplate $bomTemplate, array $parameters, ?string $companyName): array + { + $company = $companyName ?: $bomTemplate->company_type; + $calculated = []; + + // 기본 파라미터에서 추출 + $W0 = $parameters['W0'] ?? 0; + $H0 = $parameters['H0'] ?? 0; + $productType = $parameters['product_type'] ?? 'screen'; + + // 업체별 제작사이즈 계산 + $sizeFormula = $this->getCalculationConfig($company, 'manufacturing_size'); + if ($sizeFormula) { + $calculated = array_merge($calculated, $this->parser->execute($sizeFormula->formula_expression, [ + 'W0' => $W0, + 'H0' => $H0, + 'product_type' => $productType + ])); + } else { + // 기본 공식 (경동기업 기준) + if ($productType === 'screen') { + $calculated['W1'] = $W0 + 160; + $calculated['H1'] = $H0 + 350; + } else { + $calculated['W1'] = $W0 + 110; + $calculated['H1'] = $H0 + 350; + } + } + + // 면적 계산 + $calculated['area'] = ($calculated['W1'] * $calculated['H1']) / 1000000; + + // 중량 계산 + $weightFormula = $this->getCalculationConfig($company, 'weight_calculation'); + if ($weightFormula) { + $weightResult = $this->parser->execute($weightFormula->formula_expression, array_merge($parameters, $calculated)); + $calculated['weight'] = $weightResult['weight'] ?? 0; + } else { + // 기본 중량 공식 (스크린) + if ($productType === 'screen') { + $calculated['weight'] = ($calculated['area'] * 2) + ($W0 / 1000 * 14.17); + } else { + $calculated['weight'] = $calculated['area'] * 40; + } + } + + return $calculated; + } + + /** + * BOM 아이템별 수량 계산 + */ + protected function calculateBomItems(Collection $bomItems, array $calculatedValues, array $parameters, ?string $companyName): array + { + $results = []; + + foreach ($bomItems as $item) { + if (!$item->is_calculated) { + // 고정 수량 아이템 + $results[] = [ + 'item_id' => $item->id, + 'ref_type' => $item->ref_type, + 'ref_id' => $item->ref_id, + 'original_qty' => $item->qty, + 'calculated_qty' => $item->qty, + 'is_calculated' => false, + 'calculation_formula' => null + ]; + continue; + } + + // 계산식 적용 아이템 + try { + $allParameters = array_merge($parameters, $calculatedValues); + $calculatedQty = $this->parser->execute($item->calculation_formula, $allParameters); + + $results[] = [ + 'item_id' => $item->id, + 'ref_type' => $item->ref_type, + 'ref_id' => $item->ref_id, + 'original_qty' => $item->qty, + 'calculated_qty' => $calculatedQty['result'] ?? $item->qty, + 'is_calculated' => true, + 'calculation_formula' => $item->calculation_formula, + 'depends_on' => $item->depends_on + ]; + } catch (\Exception $e) { + Log::warning('BOM 아이템 계산 실패', [ + 'item_id' => $item->id, + 'formula' => $item->calculation_formula, + 'error' => $e->getMessage() + ]); + + // 계산 실패시 원래 수량 사용 + $results[] = [ + 'item_id' => $item->id, + 'ref_type' => $item->ref_type, + 'ref_id' => $item->ref_id, + 'original_qty' => $item->qty, + 'calculated_qty' => $item->qty, + 'is_calculated' => false, + 'calculation_error' => $e->getMessage() + ]; + } + } + + return $results; + } + + /** + * 파라미터 검증 + */ + protected function validateParameters(BomTemplate $bomTemplate, array $parameters): void + { + $schema = $bomTemplate->calculation_schema; + if (!$schema) return; + + $this->validator->validate($schema, $parameters); + } + + /** + * 업체별 산출식 설정 조회 + */ + protected function getCalculationConfig(string $companyName, string $formulaType): ?CalculationConfig + { + return CalculationConfig::where('company_name', $companyName) + ->where('formula_type', $formulaType) + ->where('is_active', true) + ->latest('version') + ->first(); + } + + /** + * 기본 파라미터 스키마 생성 + */ + protected function getDefaultParameterSchema(BomTemplate $bomTemplate): array + { + return [ + 'required_parameters' => [ + [ + 'key' => 'W0', + 'label' => '오픈사이즈 가로(mm)', + 'type' => 'integer', + 'required' => true, + 'min' => 500, + 'max' => 15000 + ], + [ + 'key' => 'H0', + 'label' => '오픈사이즈 세로(mm)', + 'type' => 'integer', + 'required' => true, + 'min' => 500, + 'max' => 5000 + ], + [ + 'key' => 'product_type', + 'label' => '제품타입', + 'type' => 'select', + 'options' => ['screen' => '스크린', 'steel' => '철재'], + 'required' => true + ], + [ + 'key' => 'installation_type', + 'label' => '설치방식', + 'type' => 'select', + 'options' => ['wall' => '벽면형', 'side' => '측면형', 'mixed' => '혼합형'], + 'required' => false + ] + ], + 'company_type' => $bomTemplate->company_type ?: 'default', + 'formula_version' => $bomTemplate->formula_version ?: 'v1.0' + ]; + } +} \ No newline at end of file diff --git a/app/Services/Calculation/FormulaParser.php b/app/Services/Calculation/FormulaParser.php new file mode 100644 index 0000000..ce19dac --- /dev/null +++ b/app/Services/Calculation/FormulaParser.php @@ -0,0 +1,284 @@ +parseAndExecute($formula, $variables); + + if (is_array($result)) { + return $result; + } + + return ['result' => $result]; + + } catch (\Exception $e) { + Log::error('계산식 실행 실패', [ + 'formula' => $formula, + 'variables' => $variables, + 'error' => $e->getMessage() + ]); + + throw new \RuntimeException("계산식 실행 실패: {$e->getMessage()}"); + } + } + + /** + * 계산식 파싱 및 실행 + */ + protected function parseAndExecute(string $formula, array $variables): array|float + { + // 미리 정의된 함수 패턴들 처리 + if ($this->isPreDefinedFunction($formula)) { + return $this->executePreDefinedFunction($formula, $variables); + } + + // 단순 수학 표현식 처리 + if ($this->isSimpleMathExpression($formula)) { + return $this->executeSimpleMath($formula, $variables); + } + + // 조건식 처리 (IF문 등) + if ($this->isConditionalExpression($formula)) { + return $this->executeConditionalExpression($formula, $variables); + } + + throw new \InvalidArgumentException("지원되지 않는 계산식 형태: {$formula}"); + } + + /** + * 미리 정의된 함수 실행 + */ + protected function executePreDefinedFunction(string $formula, array $variables): array + { + // 경동기업 스크린 제작사이즈 계산 + if ($formula === 'kyungdong_screen_size') { + return [ + 'W1' => ($variables['W0'] ?? 0) + 160, + 'H1' => ($variables['H0'] ?? 0) + 350 + ]; + } + + // 경동기업 철재 제작사이즈 계산 + if ($formula === 'kyungdong_steel_size') { + return [ + 'W1' => ($variables['W0'] ?? 0) + 110, + 'H1' => ($variables['H0'] ?? 0) + 350 + ]; + } + + // 스크린 중량 계산 + if ($formula === 'screen_weight_calculation') { + $W0 = $variables['W0'] ?? 0; + $W1 = $variables['W1'] ?? 0; + $H1 = $variables['H1'] ?? 0; + $area = ($W1 * $H1) / 1000000; + + return [ + 'area' => $area, + 'weight' => ($area * 2) + ($W0 / 1000 * 14.17) + ]; + } + + // 브라켓 수량 계산 + if ($formula === 'bracket_quantity') { + $W1 = $variables['W1'] ?? 0; + if ($W1 <= 3000) return ['result' => 2]; + if ($W1 <= 6000) return ['result' => 3]; + if ($W1 <= 9000) return ['result' => 4]; + if ($W1 <= 12000) return ['result' => 5]; + return ['result' => 5]; // 최대값 + } + + // 환봉 수량 계산 + if ($formula === 'round_bar_quantity') { + $W1 = $variables['W1'] ?? 0; + $qty = $variables['qty'] ?? 1; + + if ($W1 <= 3000) return ['result' => 1 * $qty]; + if ($W1 <= 6000) return ['result' => 2 * $qty]; + if ($W1 <= 9000) return ['result' => 3 * $qty]; + if ($W1 <= 12000) return ['result' => 4 * $qty]; + return ['result' => 4 * $qty]; + } + + // 샤프트 규격 결정 + if ($formula === 'shaft_size_determination') { + $W1 = $variables['W1'] ?? 0; + + if ($W1 <= 6000) return ['result' => 4]; // 4인치 + if ($W1 <= 8200) return ['result' => 5]; // 5인치 + return ['result' => 0]; // 미정의 + } + + // 모터 용량 결정 + if ($formula === 'motor_capacity_determination') { + $shaftSize = $variables['shaft_size'] ?? 4; + $weight = $variables['weight'] ?? 0; + + // 샤프트별 중량 매트릭스 + if ($shaftSize == 4) { + if ($weight <= 150) return ['result' => '150K']; + if ($weight <= 300) return ['result' => '300K']; + if ($weight <= 400) return ['result' => '400K']; + } elseif ($shaftSize == 5) { + if ($weight <= 123) return ['result' => '150K']; + if ($weight <= 246) return ['result' => '300K']; + if ($weight <= 327) return ['result' => '400K']; + if ($weight <= 500) return ['result' => '500K']; + if ($weight <= 600) return ['result' => '600K']; + } elseif ($shaftSize == 6) { + if ($weight <= 104) return ['result' => '150K']; + if ($weight <= 208) return ['result' => '300K']; + if ($weight <= 300) return ['result' => '400K']; + if ($weight <= 424) return ['result' => '500K']; + if ($weight <= 508) return ['result' => '600K']; + if ($weight <= 800) return ['result' => '800K']; + if ($weight <= 1000) return ['result' => '1000K']; + } + + return ['result' => '미정의']; + } + + throw new \InvalidArgumentException("알 수 없는 미리 정의된 함수: {$formula}"); + } + + /** + * 단순 수학 표현식 실행 + */ + protected function executeSimpleMath(string $formula, array $variables): float + { + // 변수 치환 + $expression = $formula; + foreach ($variables as $key => $value) { + $expression = str_replace($key, (string)$value, $expression); + } + + // 안전한 수학 표현식 검증 + if (!$this->isSafeMathExpression($expression)) { + throw new \InvalidArgumentException("안전하지 않은 수학 표현식: {$expression}"); + } + + // 계산 실행 + return eval("return {$expression};"); + } + + /** + * 조건식 실행 + */ + protected function executeConditionalExpression(string $formula, array $variables): float + { + // 간단한 IF 조건식 파싱 + // 예: "IF(W1 <= 3000, 2, IF(W1 <= 6000, 3, 4))" + + $pattern = '/IF\s*\(\s*([^,]+),\s*([^,]+),\s*(.+)\)/i'; + + if (preg_match($pattern, $formula, $matches)) { + $condition = trim($matches[1]); + $trueValue = trim($matches[2]); + $falseValue = trim($matches[3]); + + // 조건 평가 + if ($this->evaluateCondition($condition, $variables)) { + return is_numeric($trueValue) ? (float)$trueValue : $this->execute($trueValue, $variables)['result']; + } else { + return is_numeric($falseValue) ? (float)$falseValue : $this->execute($falseValue, $variables)['result']; + } + } + + throw new \InvalidArgumentException("지원되지 않는 조건식: {$formula}"); + } + + /** + * 조건 평가 + */ + protected function evaluateCondition(string $condition, array $variables): bool + { + // 변수 치환 + $expression = $condition; + foreach ($variables as $key => $value) { + $expression = str_replace($key, (string)$value, $expression); + } + + // 안전한 조건식 검증 + if (!$this->isSafeConditionExpression($expression)) { + throw new \InvalidArgumentException("안전하지 않은 조건식: {$expression}"); + } + + return eval("return {$expression};"); + } + + /** + * 미리 정의된 함수인지 확인 + */ + protected function isPreDefinedFunction(string $formula): bool + { + $predefinedFunctions = [ + 'kyungdong_screen_size', + 'kyungdong_steel_size', + 'screen_weight_calculation', + 'bracket_quantity', + 'round_bar_quantity', + 'shaft_size_determination', + 'motor_capacity_determination' + ]; + + return in_array($formula, $predefinedFunctions); + } + + /** + * 단순 수학 표현식인지 확인 + */ + protected function isSimpleMathExpression(string $formula): bool + { + return preg_match('/^[A-Za-z0-9_+\-*\/().\s]+$/', $formula); + } + + /** + * 조건식인지 확인 + */ + protected function isConditionalExpression(string $formula): bool + { + return preg_match('/IF\s*\(/i', $formula); + } + + /** + * 안전한 수학 표현식인지 검증 + */ + protected function isSafeMathExpression(string $expression): bool + { + // 위험한 함수나 키워드 차단 + $dangerous = ['exec', 'system', 'shell_exec', 'eval', 'file', 'fopen', 'include', 'require']; + + foreach ($dangerous as $func) { + if (stripos($expression, $func) !== false) { + return false; + } + } + + // 허용된 문자만 포함하는지 확인 + return preg_match('/^[0-9+\-*\/().\s]+$/', $expression); + } + + /** + * 안전한 조건식인지 검증 + */ + protected function isSafeConditionExpression(string $expression): bool + { + // 허용된 연산자: ==, !=, <, >, <=, >=, &&, || + $allowedPattern = '/^[0-9+\-*\/().\s<>=!&|]+$/'; + return preg_match($allowedPattern, $expression); + } +} \ No newline at end of file diff --git a/app/Services/Calculation/ParameterValidator.php b/app/Services/Calculation/ParameterValidator.php new file mode 100644 index 0000000..a7743fc --- /dev/null +++ b/app/Services/Calculation/ParameterValidator.php @@ -0,0 +1,218 @@ +buildValidationRules($schema); + $messages = $this->buildValidationMessages($schema); + + $validator = Validator::make($parameters, $rules, $messages); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + } + + /** + * 검증 규칙 생성 + * @param array $schema 파라미터 스키마 + * @return array 라라벨 검증 규칙 + */ + protected function buildValidationRules(array $schema): array + { + $rules = []; + + if (!isset($schema['required_parameters'])) { + return $rules; + } + + foreach ($schema['required_parameters'] as $param) { + $key = $param['key']; + $paramRules = []; + + // 필수 여부 + if ($param['required'] ?? false) { + $paramRules[] = 'required'; + } else { + $paramRules[] = 'nullable'; + } + + // 데이터 타입 + $type = $param['type'] ?? 'string'; + switch ($type) { + case 'integer': + $paramRules[] = 'integer'; + break; + case 'numeric': + case 'decimal': + $paramRules[] = 'numeric'; + break; + case 'boolean': + $paramRules[] = 'boolean'; + break; + case 'select': + if (isset($param['options'])) { + $validOptions = is_array($param['options']) ? array_keys($param['options']) : $param['options']; + $paramRules[] = 'in:' . implode(',', $validOptions); + } + break; + default: + $paramRules[] = 'string'; + } + + // 최소값/최대값 + if (isset($param['min'])) { + $paramRules[] = 'min:' . $param['min']; + } + if (isset($param['max'])) { + $paramRules[] = 'max:' . $param['max']; + } + + // 정규표현식 + if (isset($param['pattern'])) { + $paramRules[] = 'regex:' . $param['pattern']; + } + + $rules[$key] = implode('|', $paramRules); + } + + return $rules; + } + + /** + * 검증 메시지 생성 + * @param array $schema 파라미터 스키마 + * @return array 검증 메시지 + */ + protected function buildValidationMessages(array $schema): array + { + $messages = []; + + if (!isset($schema['required_parameters'])) { + return $messages; + } + + foreach ($schema['required_parameters'] as $param) { + $key = $param['key']; + $label = $param['label'] ?? $key; + + $messages["{$key}.required"] = "{$label}은(는) 필수 입력 항목입니다."; + $messages["{$key}.integer"] = "{$label}은(는) 정수여야 합니다."; + $messages["{$key}.numeric"] = "{$label}은(는) 숫자여야 합니다."; + $messages["{$key}.boolean"] = "{$label}은(는) 참/거짓 값이어야 합니다."; + $messages["{$key}.string"] = "{$label}은(는) 문자열이어야 합니다."; + + if (isset($param['min'])) { + $messages["{$key}.min"] = "{$label}은(는) 최소 {$param['min']} 이상이어야 합니다."; + } + if (isset($param['max'])) { + $messages["{$key}.max"] = "{$label}은(는) 최대 {$param['max']} 이하여야 합니다."; + } + + if (isset($param['options'])) { + $validOptions = is_array($param['options']) ? array_values($param['options']) : $param['options']; + $messages["{$key}.in"] = "{$label}은(는) 다음 중 하나여야 합니다: " . implode(', ', $validOptions); + } + + if (isset($param['pattern'])) { + $messages["{$key}.regex"] = "{$label}의 형식이 올바르지 않습니다."; + } + } + + return $messages; + } + + /** + * 범위 검증 + * @param array $parameters 파라미터 + * @param array $ranges 범위 정의 + */ + public function validateRanges(array $parameters, array $ranges): void + { + foreach ($ranges as $key => $range) { + if (!isset($parameters[$key])) continue; + + $value = $parameters[$key]; + + if (isset($range['min']) && $value < $range['min']) { + throw new ValidationException(validator([], []), [ + $key => ["{$key}는 최소 {$range['min']} 이상이어야 합니다."] + ]); + } + + if (isset($range['max']) && $value > $range['max']) { + throw new ValidationException(validator([], []), [ + $key => ["{$key}는 최대 {$range['max']} 이하여야 합니다."] + ]); + } + } + } + + /** + * 업체별 특수 검증 + * @param array $parameters 파라미터 + * @param string $companyType 업체 타입 + */ + public function validateCompanySpecific(array $parameters, string $companyType): void + { + switch ($companyType) { + case '경동기업': + $this->validateKyungdongRules($parameters); + break; + case '삼성물산': + $this->validateSamsungRules($parameters); + break; + default: + // 기본 검증만 수행 + break; + } + } + + /** + * 경동기업 특수 검증 규칙 + */ + protected function validateKyungdongRules(array $parameters): void + { + // 경동기업 특수 규칙 예시 + if (isset($parameters['W0']) && isset($parameters['H0'])) { + $area = ($parameters['W0'] * $parameters['H0']) / 1000000; + + // 면적이 너무 크면 경고 + if ($area > 50) { + throw new ValidationException(validator([], []), [ + 'area' => ['면적이 50㎡를 초과합니다. 특수 산출식이 필요할 수 있습니다.'] + ]); + } + } + + // 제품타입별 사이즈 제한 + if ($parameters['product_type'] === 'screen' && isset($parameters['W0'])) { + if ($parameters['W0'] > 12000) { + throw new ValidationException(validator([], []), [ + 'W0' => ['스크린 제품은 가로 12,000mm를 초과할 수 없습니다.'] + ]); + } + } + } + + /** + * 삼성물산 특수 검증 규칙 + */ + protected function validateSamsungRules(array $parameters): void + { + // 삼성물산 특수 규칙 (예시) + // 실제로는 해당 업체의 요구사항에 따라 구현 + } +} \ No newline at end of file diff --git a/app/Services/Design/BomCalculationService.php b/app/Services/Design/BomCalculationService.php new file mode 100644 index 0000000..f2001c4 --- /dev/null +++ b/app/Services/Design/BomCalculationService.php @@ -0,0 +1,265 @@ +calculationEngine = $calculationEngine; + } + + /** + * 견적 파라미터 조회 + * @param int $modelId 모델 ID + * @param string|null $companyName 업체명 + * @return array + */ + public function getEstimateParameters(int $modelId, ?string $companyName = null): array + { + try { + // 모델의 기본 BOM 템플릿 조회 + $bomTemplate = BomTemplate::with(['modelVersion.model']) + ->where('tenant_id', $this->tenantId()) + ->whereHas('modelVersion', function ($query) use ($modelId) { + $query->whereHas('model', function ($q) use ($modelId) { + $q->where('id', $modelId); + }); + }) + ->where('is_primary', true) + ->first(); + + if (!$bomTemplate) { + throw new \Exception("모델 ID {$modelId}에 대한 BOM 템플릿을 찾을 수 없습니다."); + } + + // 파라미터 스키마 추출 + $parameters = $this->calculationEngine->getRequiredParameters($bomTemplate->id); + + // 업체별 추가 파라미터가 있는지 확인 + if ($companyName) { + $companyParameters = $this->getCompanySpecificParameters($companyName); + if ($companyParameters) { + $parameters = array_merge_recursive($parameters, $companyParameters); + } + } + + return [ + 'model_info' => [ + 'id' => $bomTemplate->modelVersion->model->id, + 'name' => $bomTemplate->modelVersion->model->name ?? '모델명 없음', + 'version' => $bomTemplate->modelVersion->version_no ?? 'v1.0', + 'bom_template_id' => $bomTemplate->id + ], + 'company_info' => [ + 'company_type' => $bomTemplate->company_type, + 'formula_version' => $bomTemplate->formula_version, + 'requested_company' => $companyName + ], + 'parameters' => $parameters, + 'calculation_preview' => true + ]; + + } catch (\Exception $e) { + Log::error('견적 파라미터 조회 실패', [ + 'model_id' => $modelId, + 'company_name' => $companyName, + 'error' => $e->getMessage() + ]); + + throw $e; + } + } + + /** + * BOM 계산 실행 + * @param int $bomTemplateId BOM 템플릿 ID + * @param array $parameters 입력 파라미터 + * @param string|null $companyName 업체명 + * @return array + */ + public function calculateBomEstimate(int $bomTemplateId, array $parameters, ?string $companyName = null): array + { + try { + DB::beginTransaction(); + + // BOM 템플릿 조회 및 권한 확인 + $bomTemplate = BomTemplate::where('tenant_id', $this->tenantId()) + ->findOrFail($bomTemplateId); + + // 업체명이 지정된 경우 BOM 템플릿 업데이트 + if ($companyName && $bomTemplate->company_type !== $companyName) { + $bomTemplate->update(['company_type' => $companyName]); + } + + // 계산 엔진 실행 + $result = $this->calculationEngine->calculateBOM($bomTemplateId, $parameters, $companyName); + + // 계산 이력 로그 저장 (선택사항) + $this->logCalculationHistory($bomTemplateId, $parameters, $result, $companyName); + + DB::commit(); + + return [ + 'success' => $result['success'], + 'data' => $result['success'] ? $result : null, + 'error' => $result['success'] ? null : $result['error'] + ]; + + } catch (\Exception $e) { + DB::rollBack(); + Log::error('BOM 계산 실행 실패', [ + 'bom_template_id' => $bomTemplateId, + 'parameters' => $parameters, + 'company_name' => $companyName, + 'error' => $e->getMessage() + ]); + + return [ + 'success' => false, + 'data' => null, + 'error' => $e->getMessage() + ]; + } + } + + /** + * 업체별 산출식 목록 조회 + * @param string $companyName 업체명 + * @return array + */ + public function getCompanyFormulas(string $companyName): array + { + $formulas = CalculationConfig::getActiveFormulasForCompany($this->tenantId(), $companyName); + + return [ + 'company_name' => $companyName, + 'total_formulas' => count($formulas), + 'formulas' => array_map(function ($formula) { + return [ + 'type' => $formula['formula_type'], + 'version' => $formula['version'], + 'description' => $formula['description'], + 'parameters' => $formula['parameters'], + 'updated_at' => $formula['updated_at'] + ]; + }, $formulas) + ]; + } + + /** + * 업체별 산출식 등록/수정 + * @param string $companyName 업체명 + * @param string $formulaType 산출식 타입 + * @param array $formulaData 산출식 데이터 + * @return array + */ + public function saveCompanyFormula(string $companyName, string $formulaType, array $formulaData): array + { + try { + DB::beginTransaction(); + + // 기존 산출식이 있는지 확인 + $existingFormula = CalculationConfig::getLatestFormula($this->tenantId(), $companyName, $formulaType); + + if ($existingFormula) { + // 버전 업그레이드 + $newVersion = $this->incrementVersion($existingFormula->version); + $existingFormula->update(['is_active' => false]); // 기존 비활성화 + } else { + $newVersion = 'v1.0'; + } + + // 새 산출식 생성 + $formula = CalculationConfig::create([ + 'tenant_id' => $this->tenantId(), + 'company_name' => $companyName, + 'formula_type' => $formulaType, + 'version' => $newVersion, + 'formula_expression' => $formulaData['formula_expression'], + 'parameters' => $formulaData['parameters'], + 'conditions' => $formulaData['conditions'] ?? null, + 'validation_rules' => $formulaData['validation_rules'] ?? null, + 'description' => $formulaData['description'] ?? null, + 'is_active' => true, + 'created_by' => $this->apiUserId() + ]); + + DB::commit(); + + return [ + 'id' => $formula->id, + 'company_name' => $formula->company_name, + 'formula_type' => $formula->formula_type, + 'version' => $formula->version, + 'is_active' => $formula->is_active, + 'created_at' => $formula->created_at + ]; + + } catch (\Exception $e) { + DB::rollBack(); + Log::error('업체별 산출식 저장 실패', [ + 'company_name' => $companyName, + 'formula_type' => $formulaType, + 'error' => $e->getMessage() + ]); + + throw $e; + } + } + + /** + * 업체별 추가 파라미터 조회 + */ + protected function getCompanySpecificParameters(string $companyName): ?array + { + // 업체별 특수 파라미터가 있는지 확인 + $companyConfig = CalculationConfig::forTenant($this->tenantId()) + ->forCompany($companyName) + ->forType('additional_parameters') + ->active() + ->latest() + ->first(); + + return $companyConfig ? $companyConfig->parameters : null; + } + + /** + * 계산 이력 로그 저장 + */ + protected function logCalculationHistory(int $bomTemplateId, array $parameters, array $result, ?string $companyName): void + { + // 필요에 따라 계산 이력을 별도 테이블에 저장 + Log::info('BOM 계산 완료', [ + 'bom_template_id' => $bomTemplateId, + 'company_name' => $companyName, + 'parameters' => $parameters, + 'success' => $result['success'], + 'calculated_items_count' => $result['success'] ? count($result['bom_items']) : 0, + 'calculated_by' => $this->apiUserId() + ]); + } + + /** + * 버전 번호 증가 + */ + protected function incrementVersion(string $currentVersion): string + { + if (preg_match('/^v(\d+)\.(\d+)$/', $currentVersion, $matches)) { + $major = (int)$matches[1]; + $minor = (int)$matches[2]; + return "v{$major}." . ($minor + 1); + } + + return 'v1.0'; + } +} \ No newline at end of file diff --git a/database/migrations/2025_09_22_215127_add_calculation_fields_to_bom_tables.php b/database/migrations/2025_09_22_215127_add_calculation_fields_to_bom_tables.php new file mode 100644 index 0000000..b4c84f8 --- /dev/null +++ b/database/migrations/2025_09_22_215127_add_calculation_fields_to_bom_tables.php @@ -0,0 +1,42 @@ +json('calculation_schema')->nullable()->comment('견적 파라미터 스키마 (JSON)'); + $table->string('company_type', 50)->default('default')->comment('업체 타입 (경동기업, 삼성물산 등)'); + $table->string('formula_version', 10)->default('v1.0')->comment('산출식 버전'); + }); + + // BOM 아이템에 계산식 필드 추가 + Schema::table('bom_template_items', function (Blueprint $table) { + $table->boolean('is_calculated')->default(false)->comment('계산식 적용 여부'); + $table->text('calculation_formula')->nullable()->comment('계산식 표현식'); + $table->json('depends_on')->nullable()->comment('의존하는 파라미터 목록 (JSON)'); + $table->json('calculation_config')->nullable()->comment('계산 설정 (JSON)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::table('bom_template_items', function (Blueprint $table) { + $table->dropColumn(['is_calculated', 'calculation_formula', 'depends_on', 'calculation_config']); + }); + + Schema::table('bom_templates', function (Blueprint $table) { + $table->dropColumn(['calculation_schema', 'company_type', 'formula_version']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_22_215217_create_calculation_configs_table.php b/database/migrations/2025_09_22_215217_create_calculation_configs_table.php new file mode 100644 index 0000000..eaaa89f --- /dev/null +++ b/database/migrations/2025_09_22_215217_create_calculation_configs_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('company_name', 100)->comment('업체명 (경동기업, 삼성물산 등)'); + $table->string('formula_type', 100)->comment('산출식 타입 (screen_weight, motor_capacity 등)'); + $table->string('version', 10)->default('v1.0')->comment('산출식 버전'); + $table->text('formula_expression')->comment('실제 계산식 표현식'); + $table->json('parameters')->comment('필요한 입력 파라미터 정의'); + $table->json('conditions')->nullable()->comment('적용 조건 (제품타입, 사이즈 범위 등)'); + $table->json('validation_rules')->nullable()->comment('파라미터 검증 규칙'); + $table->text('description')->nullable()->comment('산출식 설명'); + $table->boolean('is_active')->default(true)->comment('활성 상태'); + $table->timestamps(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + + $table->unique(['tenant_id', 'company_name', 'formula_type', 'version'], 'uq_calc_config_unique'); + $table->index(['tenant_id', 'company_name', 'is_active'], 'idx_calc_config_company'); + $table->index(['tenant_id', 'formula_type', 'is_active'], 'idx_calc_config_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('calculation_configs'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index fd09d28..4f12290 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,7 @@ use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController; use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; +use App\Http\Controllers\Api\V1\Design\BomCalculationController; // error test Route::get('/test-error', function () { @@ -360,6 +361,13 @@ // 감사 로그 조회 Route::get ('/audit-logs', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); + + // BOM 계산 시스템 + Route::get ('/models/{modelId}/estimate-parameters', [BomCalculationController::class, 'getEstimateParameters'])->name('v1.design.models.estimate-parameters'); + Route::post ('/bom-templates/{bomTemplateId}/calculate-bom', [BomCalculationController::class, 'calculateBom'])->name('v1.design.bom-templates.calculate-bom'); + Route::get ('/companies/{companyName}/formulas', [BomCalculationController::class, 'getCompanyFormulas'])->name('v1.design.companies.formulas'); + Route::post ('/companies/{companyName}/formulas/{formulaType}', [BomCalculationController::class, 'saveCompanyFormula'])->name('v1.design.companies.formulas.save'); + Route::post ('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test'); });