# 견적 자동산출 개발 계획 > **작성일**: 2025-12-22 > **상태**: ✅ 구현 완료 > **목표**: MNG 견적수식 데이터 셋팅 + React 견적관리 자동산출 기능 구현 > **완료일**: 2025-12-22 > **실제 소요 시간**: 약 2시간 --- ## 0. 빠른 시작 가이드 ### 폴더 구조 이해 (중요!) | 폴더 | 포트 | 역할 | 비고 | |------|------|------|------| | `design/` | localhost:3002 | 디자인 프로토타입 | UI 참고용 | | `react/` | localhost:3000 | **실제 프론트엔드** | 구현 대상 ✅ | | `mng/` | mng.sam.kr | 관리자 패널 | 수식 데이터 관리 | | `api/` | api.sam.kr | REST API | 견적 산출 엔진 | ### 이 문서만으로 작업을 시작하려면: ```bash # 1. Docker 서비스 시작 cd /Users/hskwon/Works/@KD_SAM/SAM docker-compose up -d # 2. MNG 시더 실행 (Phase 1 완료 후) cd mng php artisan quote:seed-formulas --tenant=1 # 3. React 개발 서버 (실제 구현 대상) cd react npm run dev # http://localhost:3000 접속 ``` ### 핵심 파일 위치 | 구분 | 파일 경로 | 역할 | |------|----------|------| | **MNG 시더** | `mng/app/Console/Commands/SeedQuoteFormulasCommand.php` | 🆕 생성 필요 | | **React 자동산출** | `react/src/components/quotes/QuoteRegistration.tsx` | ⚡ 수정 필요 (line 332) | | **API 클라이언트** | `react/src/lib/api/client.ts` | 참조 | | **API 엔드포인트** | `api/app/Http/Controllers/Api/V1/QuoteController.php` | ✅ 구현됨 | | **수식 엔진** | `api/app/Services/Quote/QuoteCalculationService.php` | ✅ 구현됨 | --- ## 1. 현황 분석 ### 1.1 시스템 구조 ``` ┌───────────────────────────────────────────────────────────────────────────────┐ │ SAM 시스템 │ ├───────────────────────────────────────────────────────────────────────────────┤ │ MNG (mng.sam.kr) │ React (react/ 폴더) │ Design │ │ ├── 기준정보관리 │ ├── 판매관리 │ (참고용) │ │ │ └── 견적수식관리 ✅ │ │ └── 견적관리 │ │ │ │ - 카테고리 CRUD │ │ └── 자동견적산출 │ design/ │ │ │ - 수식 CRUD │ │ UI 있음 ✅ │ :3002 │ │ │ - 범위/매핑/품목 탭 │ │ API 연동 ❌ │ │ │ │ │ │ │ │ │ └── DB: quote_formulas 테이블 │ └── API 호출: │ │ │ (데이터 없음! ❌) │ POST /v1/quotes/calculate │ │ └───────────────────────────────────────────────────────────────────────────────┘ ※ design/ 폴더 (localhost:3002)는 UI 프로토타입용이며, 실제 구현은 react/ 폴더에서 진행 ``` ### 1.2 React 견적등록 컴포넌트 현황 **파일**: `react/src/components/quotes/QuoteRegistration.tsx` ```typescript // 현재 상태 (line 332-335) const handleAutoCalculate = () => { toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`); }; // 입력 필드 (이미 구현됨): interface QuoteItem { openWidth: string; // W0 (오픈사이즈 가로) openHeight: string; // H0 (오픈사이즈 세로) productCategory: string; // screen | steel quantity: number; // ... 기타 필드 } ``` ### 1.3 API 엔드포인트 현황 **파일**: `api/app/Http/Controllers/Api/V1/QuoteController.php` ```php // 이미 구현됨 (line 135-145) public function calculate(QuoteCalculateRequest $request) { return ApiResponse::handle(function () use ($request) { $validated = $request->validated(); return $this->calculationService->calculate( $validated['inputs'] ?? $validated, $validated['product_category'] ?? null ); }, __('message.quote.calculated')); } ``` ### 1.4 수식 시더 데이터 (API) **파일**: `api/database/seeders/QuoteFormulaSeeder.php` | 카테고리 | 수식 수 | 설명 | |---------|--------|------| | OPEN_SIZE | 2 | W0, H0 입력값 | | MAKE_SIZE | 4 | 제작사이즈 계산 | | AREA | 1 | 면적 = W1 * H1 / 1000000 | | WEIGHT | 2 | 중량 계산 (스크린/철재) | | GUIDE_RAIL | 5 | 가이드레일 자동 선택 | | CASE | 3 | 케이스 자동 선택 | | MOTOR | 1 | 모터 자동 선택 (범위 9개) | | CONTROLLER | 2 | 제어기 매핑 | | EDGE_WING | 1 | 마구리 수량 | | INSPECTION | 1 | 검사비 | | PRICE_FORMULA | 8 | 단가 수식 | | **합계** | **30개** | + 범위 18개 | --- ## 2. 개발 상세 계획 ### Phase 1: MNG 시더 데이터 생성 (1일) #### 2.1 Artisan 명령어 생성 **생성할 파일**: `mng/app/Console/Commands/SeedQuoteFormulasCommand.php` ```php option('tenant'); $only = $this->option('only'); $fresh = $this->option('fresh'); if ($fresh) { $this->warn('기존 데이터를 삭제합니다...'); $this->truncateTables($tenantId); } if (!$only || $only === 'categories') { $this->seedCategories($tenantId); } if (!$only || $only === 'formulas') { $this->seedFormulas($tenantId); } if (!$only || $only === 'ranges') { $this->seedRanges($tenantId); } $this->info('✅ 견적수식 시드 완료!'); return Command::SUCCESS; } private function seedCategories(int $tenantId): void { $categories = [ ['code' => 'OPEN_SIZE', 'name' => '오픈사이즈', 'sort_order' => 1], ['code' => 'MAKE_SIZE', 'name' => '제작사이즈', 'sort_order' => 2], ['code' => 'AREA', 'name' => '면적', 'sort_order' => 3], ['code' => 'WEIGHT', 'name' => '중량', 'sort_order' => 4], ['code' => 'GUIDE_RAIL', 'name' => '가이드레일', 'sort_order' => 5], ['code' => 'CASE', 'name' => '케이스', 'sort_order' => 6], ['code' => 'MOTOR', 'name' => '모터', 'sort_order' => 7], ['code' => 'CONTROLLER', 'name' => '제어기', 'sort_order' => 8], ['code' => 'EDGE_WING', 'name' => '마구리', 'sort_order' => 9], ['code' => 'INSPECTION', 'name' => '검사', 'sort_order' => 10], ['code' => 'PRICE_FORMULA', 'name' => '단가수식', 'sort_order' => 11], ]; foreach ($categories as $cat) { DB::table('quote_formula_categories')->updateOrInsert( ['tenant_id' => $tenantId, 'code' => $cat['code']], array_merge($cat, [ 'tenant_id' => $tenantId, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]) ); } $this->info("카테고리 " . count($categories) . "개 생성됨"); } private function seedFormulas(int $tenantId): void { // API 시더와 동일한 데이터 (api/database/seeders/QuoteFormulaSeeder.php 참조) $formulas = $this->getFormulaData(); $categoryMap = DB::table('quote_formula_categories') ->where('tenant_id', $tenantId) ->pluck('id', 'code') ->toArray(); $count = 0; foreach ($formulas as $formula) { $categoryId = $categoryMap[$formula['category_code']] ?? null; if (!$categoryId) continue; DB::table('quote_formulas')->updateOrInsert( ['tenant_id' => $tenantId, 'variable' => $formula['variable']], [ 'tenant_id' => $tenantId, 'category_id' => $categoryId, 'variable' => $formula['variable'], 'name' => $formula['name'], 'type' => $formula['type'], 'formula' => $formula['formula'] ?? null, 'output_type' => 'variable', 'description' => $formula['description'] ?? null, 'sort_order' => $formula['sort_order'] ?? 0, 'is_active' => $formula['is_active'] ?? true, 'created_at' => now(), 'updated_at' => now(), ] ); $count++; } $this->info("수식 {$count}개 생성됨"); } private function getFormulaData(): array { return [ // 오픈사이즈 ['category_code' => 'OPEN_SIZE', 'variable' => 'W0', 'name' => '오픈사이즈 W0 (가로)', 'type' => 'input', 'formula' => null, 'sort_order' => 1], ['category_code' => 'OPEN_SIZE', 'variable' => 'H0', 'name' => '오픈사이즈 H0 (세로)', 'type' => 'input', 'formula' => null, 'sort_order' => 2], // 제작사이즈 ['category_code' => 'MAKE_SIZE', 'variable' => 'W1_SCREEN', 'name' => '제작사이즈 W1 (스크린)', 'type' => 'calculation', 'formula' => 'W0 + 140', 'sort_order' => 1], ['category_code' => 'MAKE_SIZE', 'variable' => 'H1_SCREEN', 'name' => '제작사이즈 H1 (스크린)', 'type' => 'calculation', 'formula' => 'H0 + 350', 'sort_order' => 2], ['category_code' => 'MAKE_SIZE', 'variable' => 'W1_STEEL', 'name' => '제작사이즈 W1 (철재)', 'type' => 'calculation', 'formula' => 'W0 + 110', 'sort_order' => 3], ['category_code' => 'MAKE_SIZE', 'variable' => 'H1_STEEL', 'name' => '제작사이즈 H1 (철재)', 'type' => 'calculation', 'formula' => 'H0 + 350', 'sort_order' => 4], // 면적 ['category_code' => 'AREA', 'variable' => 'M', 'name' => '면적 계산', 'type' => 'calculation', 'formula' => 'W1 * H1 / 1000000', 'sort_order' => 1], // 중량 ['category_code' => 'WEIGHT', 'variable' => 'K_SCREEN', 'name' => '중량 계산 (스크린)', 'type' => 'calculation', 'formula' => 'M * 2 + W0 / 1000 * 14.17', 'sort_order' => 1], ['category_code' => 'WEIGHT', 'variable' => 'K_STEEL', 'name' => '중량 계산 (철재)', 'type' => 'calculation', 'formula' => 'M * 25', 'sort_order' => 2], // 가이드레일 ['category_code' => 'GUIDE_RAIL', 'variable' => 'G', 'name' => '가이드레일 제작길이', 'type' => 'calculation', 'formula' => 'H0 + 250', 'sort_order' => 1], ['category_code' => 'GUIDE_RAIL', 'variable' => 'GR_AUTO_SELECT', 'name' => '가이드레일 자재 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 2], // 케이스 ['category_code' => 'CASE', 'variable' => 'S_SCREEN', 'name' => '케이스 사이즈 (스크린)', 'type' => 'calculation', 'formula' => 'W0 + 220', 'sort_order' => 1], ['category_code' => 'CASE', 'variable' => 'S_STEEL', 'name' => '케이스 사이즈 (철재)', 'type' => 'calculation', 'formula' => 'W0 + 240', 'sort_order' => 2], ['category_code' => 'CASE', 'variable' => 'CASE_AUTO_SELECT', 'name' => '케이스 자재 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 3], // 모터 ['category_code' => 'MOTOR', 'variable' => 'MOTOR_AUTO_SELECT', 'name' => '모터 자동 선택', 'type' => 'range', 'formula' => null, 'sort_order' => 1], // 제어기 ['category_code' => 'CONTROLLER', 'variable' => 'CONTROLLER_TYPE', 'name' => '제어기 유형', 'type' => 'input', 'formula' => null, 'sort_order' => 0], ['category_code' => 'CONTROLLER', 'variable' => 'CTRL_AUTO_SELECT', 'name' => '제어기 자동 선택', 'type' => 'mapping', 'formula' => null, 'sort_order' => 1], // 검사 ['category_code' => 'INSPECTION', 'variable' => 'INSP_FEE', 'name' => '검사비', 'type' => 'calculation', 'formula' => '1', 'sort_order' => 1], ]; } // ... 나머지 메서드 (seedRanges, truncateTables 등) } ``` #### 2.2 작업 순서 ```bash # 1. 명령어 파일 생성 # mng/app/Console/Commands/SeedQuoteFormulasCommand.php # 2. 실행 cd mng php artisan quote:seed-formulas --tenant=1 # 3. 확인 php artisan tinker >>> \App\Models\Quote\QuoteFormula::count() # 예상: 30 # 4. 시뮬레이터 테스트 # mng.sam.kr/quote-formulas/simulator # 입력: W0=3000, H0=2500 ``` --- ### Phase 2: React 자동산출 기능 구현 (2-3일) #### 2.1 API 클라이언트 추가 **수정할 파일**: `react/src/lib/api/quote.ts` (신규) ```typescript // react/src/lib/api/quote.ts import { ApiClient } from './client'; import { AUTH_CONFIG } from './auth/auth-config'; // API 응답 타입 interface CalculationResult { inputs: Record; outputs: Record; items: Array<{ item_code: string; item_name: string; specification?: string; unit?: string; quantity: number; unit_price: number; total_price: number; formula_variable: string; }>; costs: { material_cost: number; labor_cost: number; install_cost: number; subtotal: number; }; errors: string[]; } interface CalculateRequest { inputs: { W0: number; H0: number; QTY?: number; INSTALL_TYPE?: string; CONTROL_TYPE?: string; }; product_category: 'screen' | 'steel'; } // Quote API 클라이언트 class QuoteApiClient extends ApiClient { constructor() { super({ mode: 'bearer', apiKey: AUTH_CONFIG.apiKey, getToken: () => { if (typeof window !== 'undefined') { return localStorage.getItem('auth_token'); } return null; }, }); } /** * 자동 견적 산출 */ async calculate(request: CalculateRequest): Promise<{ success: boolean; data: CalculationResult; message: string }> { return this.post('/api/v1/quotes/calculate', request); } /** * 입력 스키마 조회 */ async getCalculationSchema(productCategory?: string): Promise<{ success: boolean; data: Record }> { const query = productCategory ? `?product_category=${productCategory}` : ''; return this.get(`/api/v1/quotes/calculation-schema${query}`); } } export const quoteApi = new QuoteApiClient(); ``` #### 2.2 QuoteRegistration.tsx 수정 **수정할 파일**: `react/src/components/quotes/QuoteRegistration.tsx` ```typescript // 추가할 import import { quoteApi } from '@/lib/api/quote'; import { useState } from 'react'; // 상태 추가 (컴포넌트 내부) const [calculationResult, setCalculationResult] = useState(null); const [isCalculating, setIsCalculating] = useState(false); // handleAutoCalculate 수정 (line 332-335) const handleAutoCalculate = async () => { const item = formData.items[activeItemIndex]; if (!item.openWidth || !item.openHeight) { toast.error('오픈사이즈(W0, H0)를 입력해주세요.'); return; } setIsCalculating(true); try { const response = await quoteApi.calculate({ inputs: { W0: parseFloat(item.openWidth), H0: parseFloat(item.openHeight), QTY: item.quantity, INSTALL_TYPE: item.guideRailType, CONTROL_TYPE: item.controller, }, product_category: item.productCategory as 'screen' | 'steel' || 'screen', }); if (response.success) { setCalculationResult(response.data); toast.success('자동 산출이 완료되었습니다.'); } else { toast.error(response.message || '산출 중 오류가 발생했습니다.'); } } catch (error) { console.error('자동 산출 오류:', error); toast.error('서버 연결에 실패했습니다.'); } finally { setIsCalculating(false); } }; // 산출 결과 반영 함수 추가 const handleApplyCalculation = () => { if (!calculationResult) return; // 산출된 품목을 견적 항목에 반영 const newItems = calculationResult.items.map((item, index) => ({ id: `calc-${Date.now()}-${index}`, floor: formData.items[activeItemIndex].floor, code: item.item_code, productCategory: formData.items[activeItemIndex].productCategory, productName: item.item_name, openWidth: formData.items[activeItemIndex].openWidth, openHeight: formData.items[activeItemIndex].openHeight, guideRailType: formData.items[activeItemIndex].guideRailType, motorPower: formData.items[activeItemIndex].motorPower, controller: formData.items[activeItemIndex].controller, quantity: item.quantity, wingSize: formData.items[activeItemIndex].wingSize, inspectionFee: item.unit_price, unitPrice: item.unit_price, totalAmount: item.total_price, })); setFormData({ ...formData, items: [...formData.items.slice(0, activeItemIndex), ...newItems, ...formData.items.slice(activeItemIndex + 1)], }); setCalculationResult(null); toast.success(`${newItems.length}개 품목이 반영되었습니다.`); }; ``` #### 2.3 산출 결과 표시 UI 추가 ```tsx {/* 자동 견적 산출 버튼 아래에 추가 */} {calculationResult && ( 산출 결과 {/* 계산 변수 */}
{Object.entries(calculationResult.outputs).map(([key, val]) => (
{val.name}
{typeof val.value === 'number' ? val.value.toFixed(2) : val.value}
))}
{/* 산출 품목 */} {calculationResult.items.map((item, i) => ( ))}
품목코드 품목명 수량 단가 금액
{item.item_code} {item.item_name} {item.quantity} {item.unit_price.toLocaleString()} {item.total_price.toLocaleString()}
합계 {calculationResult.costs.subtotal.toLocaleString()}원
{/* 반영 버튼 */}
)} ``` --- ### Phase 3: 통합 테스트 (1일) #### 3.1 테스트 시나리오 | 번호 | 테스트 케이스 | 입력값 | 예상 결과 | |-----|-------------|-------|----------| | 1 | 기본 스크린 산출 | W0=3000, H0=2500 | 가이드레일 PT-GR-3000, 모터 PT-MOTOR-150 | | 2 | 대형 스크린 산출 | W0=5000, H0=4000 | 모터 규격 상향 (300K 이상) | | 3 | 철재 산출 | W0=2000, H0=2000, steel | 중량 M*25 적용 | | 4 | 품목 반영 | 산출 후 반영 클릭 | 견적 항목에 추가됨 | | 5 | 에러 처리 | W0/H0 미입력 | "오픈사이즈를 입력해주세요" | #### 3.2 검증 체크리스트 ``` □ MNG 시뮬레이터에서 수식 계산 정확도 확인 □ React 자동산출 버튼 클릭 → API 호출 확인 □ 산출 결과 테이블 정상 표시 □ "품목에 반영하기" 클릭 → 견적 항목 추가 확인 □ 견적 저장 시 calculation_inputs 필드 저장 확인 □ 에러 시 적절한 메시지 표시 ``` --- ## 3. SAM 개발 규칙 요약 ### 3.1 API 개발 규칙 (CLAUDE.md 참조) ```php // Controller: FormRequest + ApiResponse 패턴 public function calculate(QuoteCalculateRequest $request) { return ApiResponse::handle(function () use ($request) { return $this->calculationService->calculate($request->validated()); }, __('message.quote.calculated')); } // Service: 비즈니스 로직 분리 class QuoteCalculationService extends Service { public function calculate(array $inputs, ?string $productCategory = null): array { $tenantId = $this->tenantId(); // 필수 // ... } } // 응답 형식 { "success": true, "message": "견적이 산출되었습니다.", "data": { ... } } ``` ### 3.2 React 개발 패턴 ```typescript // API 클라이언트 패턴 (react/src/lib/api/client.ts) class ApiClient { async post(endpoint: string, data?: unknown): Promise async get(endpoint: string): Promise } // 컴포넌트 패턴 // - shadcn/ui 컴포넌트 사용 // - toast (sonner) 알림 // - FormField, Card, Button 등 ``` ### 3.3 MNG 개발 패턴 ```php // Artisan 명령어 패턴 protected $signature = 'quote:seed-formulas {--tenant=1}'; // 모델 사용 use App\Models\Quote\QuoteFormula; use App\Models\Quote\QuoteFormulaCategory; // 서비스 패턴 class QuoteFormulaService { public function __construct( private FormulaEvaluatorService $evaluator ) {} } ``` --- ## 4. 파일 구조 ``` SAM/ ├── mng/ │ ├── app/Console/Commands/ │ │ └── SeedQuoteFormulasCommand.php # 🆕 Phase 1 │ ├── app/Models/Quote/ │ │ ├── QuoteFormula.php # ✅ 있음 │ │ ├── QuoteFormulaCategory.php # ✅ 있음 │ │ └── QuoteFormulaRange.php # ✅ 있음 │ └── app/Services/Quote/ │ └── FormulaEvaluatorService.php # ✅ 있음 │ ├── api/ │ ├── app/Http/Controllers/Api/V1/ │ │ └── QuoteController.php # ✅ calculate() 있음 │ ├── app/Services/Quote/ │ │ ├── QuoteCalculationService.php # ✅ 있음 │ │ └── FormulaEvaluatorService.php # ✅ 있음 │ └── database/seeders/ │ └── QuoteFormulaSeeder.php # 참조용 데이터 │ ├── react/ │ ├── src/lib/api/ │ │ ├── client.ts # ✅ ApiClient 클래스 │ │ └── quote.ts # 🆕 Phase 2 │ └── src/components/quotes/ │ └── QuoteRegistration.tsx # ⚡ Phase 2 수정 │ └── docs/plans/ └── quote-auto-calculation-development-plan.md # 이 문서 ``` --- ## 5. 수식 계산 예시 ``` 입력: W0=3000mm, H0=2500mm, product_category=screen 계산 순서: 1. W1 = W0 + 140 = 3140mm (스크린 제작 가로) 2. H1 = H0 + 350 = 2850mm (스크린 제작 세로) 3. M = W1 * H1 / 1000000 = 8.949㎡ (면적) 4. K = M * 2 + W0 / 1000 * 14.17 = 60.41kg (중량) 5. G = H0 + 250 = 2750mm (가이드레일 길이) 6. S = W0 + 220 = 3220mm (케이스 사이즈) 범위 자동 선택: - 가이드레일: G=2750 → 2438 < G ≤ 3000 → PT-GR-3000 × 2개 - 케이스: S=3220 → 3000 < S ≤ 3600 → PT-CASE-3600 × 1개 - 모터: K=60.41 → 0 < K ≤ 150 → PT-MOTOR-150 × 1개 ``` --- ## 6. 일정 요약 | Phase | 작업 | 예상 기간 | 상태 | |-------|------|----------|------| | 1 | MNG 시더 명령어 생성 | 1일 | ✅ 완료 | | 2 | React Quote API 클라이언트 생성 | 0.5일 | ✅ 완료 | | 3 | React handleAutoCalculate API 연동 | 0.5일 | ✅ 완료 | | 4 | 산출 결과 UI 추가 | 0.5일 | ✅ 완료 | | 5 | 문서 업데이트 | 0.5시간 | ✅ 완료 | | **합계** | | **약 2시간** | ✅ | --- ## 7. 완료된 구현 내역 ### 생성된 파일 | 파일 경로 | 역할 | |----------|------| | `mng/app/Console/Commands/SeedQuoteFormulasCommand.php` | MNG 견적수식 시더 명령어 | | `react/src/lib/api/quote.ts` | React Quote API 클라이언트 | ### 수정된 파일 | 파일 경로 | 변경 내용 | |----------|----------| | `react/src/components/quotes/QuoteRegistration.tsx` | handleAutoCalculate API 연동, 산출 결과 UI 추가 | ### MNG 시더 실행 결과 ``` ✅ 견적수식 시드 완료! 카테고리: 11개 수식: 18개 범위: 18개 ``` ### React 기능 구현 - `handleAutoCalculate`: API 호출 및 로딩 상태 관리 - `handleApplyCalculation`: 산출 결과를 견적 항목에 반영 - 산출 결과 UI: 변수, 품목 테이블, 비용 합계 표시 - 에러 처리: 입력값 검증, API 에러 토스트 --- *문서 버전*: 3.0 (구현 완료) *작성자*: Claude Code *최종 업데이트*: 2025-12-22