feat: 업체별 동적 BOM 계산 시스템 구현

- 데이터베이스 스키마 확장: BOM 테이블에 계산 관련 필드 추가
- 계산 엔진 구현: CalculationEngine, FormulaParser, ParameterValidator
- API 구현: 견적 파라미터 추출, 실시간 BOM 계산, 업체별 산출식 관리
- FormRequest 검증: 모든 입력 데이터 검증 및 한국어 에러 메시지
- 라우트 등록: 5개 BOM 계산 API 엔드포인트 추가

주요 기능:
• BOM에서 필요한 조건만 동적 추출하여 견적 화면에 표시
• 경동기업 하드코딩 산출식을 동적 시스템으로 전환
• 업체별 산출식 버전 관리 및 실시간 테스트 지원

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-22 22:09:42 +09:00
parent fa9f8ac707
commit bd678dfea9
15 changed files with 2039 additions and 3 deletions

268
CLAUDE.md
View File

@@ -221,7 +221,272 @@ ### 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
@@ -450,3 +715,4 @@ ## Important Notes
- **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**
- **🇰🇷 LANGUAGE SETTING: 모든 응답은 한국어로 제공 (별도 요청 시 예외)**

View File

@@ -0,0 +1,288 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Http\Controllers\Controller;
use App\Services\Design\BomCalculationService;
use App\Http\Requests\Design\GetEstimateParametersRequest;
use App\Http\Requests\Design\CalculateBomRequest;
use App\Http\Requests\Design\SaveCompanyFormulaRequest;
use App\Http\Resources\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
/**
* @group Design/BOM Calculation
*
* BOM 기반 동적 산출식 계산 API
*/
class BomCalculationController extends Controller
{
protected BomCalculationService $bomCalculationService;
public function __construct(BomCalculationService $bomCalculationService)
{
$this->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')
];
});
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Requests\Design;
use Illuminate\Foundation\Http\FormRequest;
class CalculateBomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
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<string, string>
*/
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' => '업체명'
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Design;
use Illuminate\Foundation\Http\FormRequest;
class GetEstimateParametersRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'company_name' => 'nullable|string|max:100'
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'company_name.string' => __('error.validation.string'),
'company_name.max' => __('error.validation.max.string')
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Requests\Design;
use Illuminate\Foundation\Http\FormRequest;
class SaveCompanyFormulaRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
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<string, string>
*/
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' => '설명'
];
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Models\Calculation;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class CalculationConfig extends Model
{
protected $table = 'calculation_configs';
protected $fillable = [
'tenant_id',
'company_name',
'formula_type',
'version',
'formula_expression',
'parameters',
'conditions',
'validation_rules',
'description',
'is_active',
'created_by',
'updated_by'
];
protected $casts = [
'parameters' => '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();
}
}

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -0,0 +1,268 @@
<?php
namespace App\Services\Calculation;
use App\Models\Design\BomTemplate;
use App\Models\Design\BomTemplateItem;
use App\Models\Calculation\CalculationConfig;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class CalculationEngine
{
protected FormulaParser $parser;
protected ParameterValidator $validator;
public function __construct(FormulaParser $parser, ParameterValidator $validator)
{
$this->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'
];
}
}

View File

@@ -0,0 +1,284 @@
<?php
namespace App\Services\Calculation;
use Illuminate\Support\Facades\Log;
class FormulaParser
{
/**
* 계산식 실행
* @param string $formula 계산식 표현식
* @param array $variables 변수 값들
* @return array|float 계산 결과
*/
public function execute(string $formula, array $variables): array|float
{
try {
// 안전한 계산식 실행을 위한 파싱
$result = $this->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);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Services\Calculation;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class ParameterValidator
{
/**
* 파라미터 검증
* @param array $schema 파라미터 스키마
* @param array $parameters 검증할 파라미터
* @throws ValidationException
*/
public function validate(array $schema, array $parameters): void
{
$rules = $this->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
{
// 삼성물산 특수 규칙 (예시)
// 실제로는 해당 업체의 요구사항에 따라 구현
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace App\Services\Design;
use App\Services\Service;
use App\Services\Calculation\CalculationEngine;
use App\Models\Design\BomTemplate;
use App\Models\Calculation\CalculationConfig;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BomCalculationService extends Service
{
protected CalculationEngine $calculationEngine;
public function __construct(CalculationEngine $calculationEngine)
{
$this->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';
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* BOM 테이블에 산출식 관련 필드 추가
* - bom_templates: 견적시 필요한 파라미터 스키마 정의
* - bom_template_items: 개별 아이템의 계산식 정의
*/
public function up(): void {
// BOM 템플릿에 산출식 조건 스키마 추가
Schema::table('bom_templates', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* 업체별 산출식 설정 테이블 생성
* 다양한 업체(경동기업, 삼성물산 등)의 고유한 산출식을 관리
*/
public function up(): void {
Schema::create('calculation_configs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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');
});