diff --git a/plans/quote-management-url-migration-plan.md b/plans/quote-management-url-migration-plan.md new file mode 100644 index 0000000..03086d0 --- /dev/null +++ b/plans/quote-management-url-migration-plan.md @@ -0,0 +1,1281 @@ +ㅅ# 견적관리 URL 구조 마이그레이션 계획 + +> **작성일**: 2026-01-26 +> **목적**: 견적관리 페이지 URL 구조를 Query 기반(?mode=new)에서 RESTful 경로 기반(/test-new, /test/[id])으로 마이그레이션 +> **기준 문서**: docs/standards/api-rules.md, docs/specs/database-schema.md +> **상태**: 📋 계획 수립 완료 (Serena ID: quote-url-migration-state) + +--- + +## 📍 현재 진행 상태 + +| 항목 | 내용 | +|------|------| +| **마지막 완료 작업** | Phase 2 완료 - URL 경로 정식화 (test-new → new, test/[id] → [id]) | +| **다음 작업** | Step 3.2: 통합 테스트 (CRUD + 문서출력) | +| **진행률** | 7/12 (58%) - Phase 1 ✅, Phase 2 ✅ | +| **마지막 업데이트** | 2026-01-26 | + +--- + +## 1. 개요 + +### 1.1 배경 + +현재 견적관리 시스템에는 두 가지 URL 패턴이 공존합니다: + +**V1 (기존 - Query 기반):** +- 목록: `/sales/quote-management` +- 등록: `/sales/quote-management?mode=new` +- 상세: `/sales/quote-management/[id]` +- 수정: `/sales/quote-management/[id]?mode=edit` + +**V2 (신규 - RESTful 경로 기반):** +- 목록: `/sales/quote-management` (동일) +- 등록: `/sales/quote-management/test-new` +- 상세: `/sales/quote-management/test/[id]` +- 수정: `/sales/quote-management/test/[id]?mode=edit` + +V2는 `IntegratedDetailTemplate` + `QuoteRegistrationV2` 컴포넌트를 사용하며, 현재 테스트(Mock 데이터) 상태입니다. + +### 1.2 목표 + +1. V2 페이지에 실제 API 연동 완료 +2. V2 URL 패턴을 정식 경로로 채택 (test 접두사 제거) +3. V1 페이지 삭제 또는 V2로 리다이렉트 처리 +4. DB 스키마 변경 없이 기존 API 활용 + +### 1.3 기준 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 핵심 원칙 │ +├─────────────────────────────────────────────────────────────────┤ +│ - DB 스키마 변경 없음 (기존 quotes, quote_items 테이블 활용) │ +│ - 기존 API 엔드포인트 재사용 (POST/PUT /api/v1/quotes) │ +│ - V1 → V2 단계적 마이그레이션 (병행 기간 최소화) │ +│ - IntegratedDetailTemplate 표준 적용 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.4 변경 승인 정책 + +| 분류 | 예시 | 승인 | +|------|------|------| +| ✅ 즉시 가능 | 컴포넌트 수정, API 연동 코드, 타입 정의 | 불필요 | +| ⚠️ 컨펌 필요 | 라우트 경로 변경, 기존 페이지 삭제/리다이렉트 | **필수** | +| 🔴 금지 | DB 스키마 변경, 기존 API 엔드포인트 삭제 | 별도 협의 | + +### 1.5 준수 규칙 +- `docs/quickstart/quick-start.md` - 빠른 시작 가이드 +- `docs/standards/quality-checklist.md` - 품질 체크리스트 +- `docs/standards/api-rules.md` - API 개발 규칙 + +--- + +## 2. 현재 상태 분석 + +### 2.1 파일 구조 비교 + +#### V1 (기존) +``` +react/src/app/[locale]/(protected)/sales/quote-management/ +├── page.tsx # 목록 + mode=new 감지 → QuoteRegistration +├── new/page.tsx # 리다이렉트용 (거의 미사용) +├── [id]/page.tsx # 상세 + mode=edit 감지 → QuoteRegistration +└── [id]/edit/page.tsx # 리다이렉트용 (거의 미사용) +``` + +#### V2 (신규) +``` +react/src/app/[locale]/(protected)/sales/quote-management/ +├── test-new/page.tsx # 등록 (IntegratedDetailTemplate) +├── test/[id]/page.tsx # 상세/수정 (IntegratedDetailTemplate) +└── test/[id]/edit/page.tsx # 리다이렉트 → test/[id]?mode=edit +``` + +### 2.2 컴포넌트 비교 + +| 항목 | V1 (QuoteRegistration) | V2 (QuoteRegistrationV2) | +|------|------------------------|--------------------------| +| 파일 크기 | ~50KB | ~45KB | +| 레이아웃 | 단일 폼 | 좌우 분할 (개소 목록 \| 상세) | +| 템플릿 | 자체 레이아웃 | IntegratedDetailTemplate | +| 데이터 구조 | `QuoteFormData` | `QuoteFormDataV2` + `LocationItem` | +| API 연동 | ✅ 완료 | ❌ Mock 데이터 | +| 상태 관리 | `status: string` | `status: 'draft' \| 'temporary' \| 'final'` | + +### 2.3 데이터 구조 비교 + +#### V1: QuoteFormData +```typescript +interface QuoteFormData { + id?: string; + quoteNumber?: string; + registrationDate?: string; + clientId?: string | number; + clientName?: string; + siteName?: string; + manager?: string; + contact?: string; + dueDate?: string; + remarks?: string; + status?: string; + items?: QuoteItem[]; // 층별 항목 + bomMaterials?: BomMaterial[]; + calculationInputs?: Record; +} +``` + +#### V2: QuoteFormDataV2 +```typescript +interface QuoteFormDataV2 { + id?: string; + registrationDate: string; + writer: string; + clientId: string; + clientName: string; + siteName: string; + manager: string; + contact: string; + dueDate: string; + remarks: string; + status: 'draft' | 'temporary' | 'final'; + locations: LocationItem[]; // 개소별 항목 (더 상세한 구조) +} + +interface LocationItem { + id: string; + floor: string; + code: string; + openWidth: number; + openHeight: number; + productCode: string; + productName: string; + quantity: number; + guideRailType: string; + motorPower: string; + controller: string; + wingSize: number; + inspectionFee: number; + unitPrice?: number; + totalPrice?: number; + bomResult?: BomCalculationResult; +} +``` + +### 2.4 API 엔드포인트 (변경 없음) + +| HTTP | Endpoint | 설명 | V1 사용 | V2 사용 | +|------|----------|------|:-------:|:-------:| +| GET | `/api/v1/quotes` | 목록 조회 | ✅ | ✅ | +| GET | `/api/v1/quotes/{id}` | 단건 조회 | ✅ | 🔲 (TODO) | +| POST | `/api/v1/quotes` | 생성 | ✅ | 🔲 (TODO) | +| PUT | `/api/v1/quotes/{id}` | 수정 | ✅ | 🔲 (TODO) | +| POST | `/api/v1/quotes/calculate/bom/bulk` | BOM 자동산출 | ✅ | ✅ | + +### 2.5 DB 스키마 (변경 없음) + +**quotes 테이블** - 그대로 사용 +```sql +-- 핵심 필드 +id, tenant_id, quote_number +registration_date, author +client_id, client_name, manager, contact +site_name, site_code +product_category, product_id, product_code, product_name +open_size_width, open_size_height, quantity +material_cost, labor_cost, install_cost +subtotal, discount_rate, discount_amount, total_amount +status, is_final +calculation_inputs (JSON) +options (JSON) +``` + +**quote_items 테이블** - 그대로 사용 +```sql +id, quote_id, tenant_id +item_id, item_code, item_name, specification, unit +base_quantity, calculated_quantity +unit_price, total_price +formula, formula_result, formula_source, formula_category +sort_order +``` + +--- + +## 3. 대상 범위 + +### 3.1 Phase 1: V2 API 연동 (프론트엔드) + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1.1 | V2 데이터 변환 함수 구현 | ✅ | `transformV2ToApi`, `transformApiToV2` (2026-01-26) | +| 1.2 | test-new 페이지 API 연동 (createQuote) | ✅ | Mock → 실제 API (2026-01-26) | +| 1.3 | test/[id] 페이지 API 연동 (getQuoteById) | ✅ | Mock → 실제 API (2026-01-26) | +| 1.4 | test/[id] 수정 API 연동 (updateQuote) | ✅ | Mock → 실제 API (2026-01-26) | + +### 3.2 Phase 2: URL 경로 정식화 (라우팅) + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 2.1 | test-new → new 경로 변경 | ✅ | V2 버전으로 교체 완료 (2026-01-26) | +| 2.2 | test/[id] → [id] 경로 통합 | ✅ | V2 버전으로 교체 완료 (2026-01-26) | +| 2.3 | 기존 V1 페이지 처리 결정 | ✅ | V1 백업 보존, test 폴더 삭제 | + +### 3.3 Phase 3: 정리 및 테스트 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 3.1 | V1 컴포넌트/페이지 정리 | ✅ | test 폴더 삭제 완료, V1 백업 보존 | +| 3.2 | 통합 테스트 | ⏳ | CRUD + 문서출력 + 상태전환 | +| 3.3 | 목록 페이지 링크 업데이트 | ⏳ | QuoteManagementClient | +| 3.4 | 문서 업데이트 | 🔄 | 진행중 | + +--- + +## 4. 작업 절차 + +### 4.1 단계별 절차 + +``` +Phase 1: V2 API 연동 +├── Step 1.1: 데이터 변환 함수 +│ ├── transformV2ToApi() - V2 → API 요청 형식 +│ ├── transformApiToV2() - API 응답 → V2 형식 +│ └── actions.ts에 추가 +│ +├── Step 1.2: test-new 페이지 연동 +│ ├── handleSave에서 createQuote 호출 +│ ├── 성공 시 /sales/quote-management/test/{id}로 이동 +│ └── 에러 처리 +│ +├── Step 1.3: test/[id] 상세 페이지 연동 +│ ├── useEffect에서 getQuoteById 호출 +│ ├── transformApiToV2로 데이터 변환 +│ └── 로딩/에러 상태 처리 +│ +└── Step 1.4: test/[id] 수정 연동 + ├── handleSave에서 updateQuote 호출 + ├── 성공 시 view 모드로 복귀 + └── 에러 처리 + +Phase 2: URL 경로 정식화 +├── Step 2.1: 새 경로 생성 +│ ├── new/page.tsx → IntegratedDetailTemplate 버전 +│ └── 기존 new/page.tsx 백업 +│ +├── Step 2.2: 상세 경로 통합 +│ ├── [id]/page.tsx를 V2 버전으로 교체 +│ └── 기존 [id]/page.tsx 백업 +│ +└── Step 2.3: V1 처리 + ├── 옵션 A: V1 페이지 삭제 + └── 옵션 B: V1 → V2 리다이렉트 + +Phase 3: 정리 및 테스트 +├── Step 3.1: 파일 정리 +│ ├── test-new, test/[id] 폴더 삭제 +│ ├── V1 백업 파일 삭제 (확인 후) +│ └── 미사용 컴포넌트 정리 +│ +├── Step 3.2: 통합 테스트 +│ ├── 신규 등록 → 저장 → 상세 확인 +│ ├── 상세 → 수정 → 저장 → 상세 확인 +│ ├── 문서 출력 (견적서, 산출내역서, 발주서) +│ ├── 최종확정 → 수주전환 +│ └── 목록 링크 동작 확인 +│ +├── Step 3.3: 목록 페이지 링크 +│ └── QuoteManagementClient의 라우팅 경로 확인 +│ +└── Step 3.4: 문서 업데이트 + ├── 이 계획 문서 완료 처리 + └── 필요시 claudedocs에 작업 기록 +``` + +### 4.2 데이터 변환 상세 + +#### V2 → API (저장 시) +```typescript +function transformV2ToApi(data: QuoteFormDataV2) { + return { + registration_date: data.registrationDate, + author: data.writer, + client_id: data.clientId || null, + client_name: data.clientName, + site_name: data.siteName, + manager: data.manager, + contact: data.contact, + completion_date: data.dueDate, + remarks: data.remarks, + status: data.status === 'final' ? 'finalized' : data.status, + + // locations → items 변환 + items: data.locations.map((loc, index) => ({ + floor: loc.floor, + code: loc.code, + product_code: loc.productCode, + product_name: loc.productName, + open_width: loc.openWidth, + open_height: loc.openHeight, + quantity: loc.quantity, + guide_rail_type: loc.guideRailType, + motor_power: loc.motorPower, + controller: loc.controller, + wing_size: loc.wingSize, + inspection_fee: loc.inspectionFee, + unit_price: loc.unitPrice, + total_price: loc.totalPrice, + sort_order: index, + })), + + // calculation_inputs 생성 (첫 번째 location 기준) + calculation_inputs: data.locations.length > 0 ? { + W0: data.locations[0].openWidth, + H0: data.locations[0].openHeight, + QTY: data.locations[0].quantity, + GT: data.locations[0].guideRailType, + MP: data.locations[0].motorPower, + } : null, + }; +} +``` + +#### API → V2 (조회 시) +```typescript +function transformApiToV2(apiData: QuoteResponse): QuoteFormDataV2 { + return { + id: apiData.id, + registrationDate: apiData.registrationDate, + writer: apiData.author || '', + clientId: String(apiData.clientId || ''), + clientName: apiData.clientName || '', + siteName: apiData.siteName || '', + manager: apiData.manager || '', + contact: apiData.contact || '', + dueDate: apiData.completionDate || '', + remarks: apiData.remarks || '', + status: mapApiStatusToV2(apiData.status), + + // items → locations 변환 + locations: (apiData.items || []).map(item => ({ + id: String(item.id), + floor: item.floor || '', + code: item.code || '', + openWidth: item.openWidth || 0, + openHeight: item.openHeight || 0, + productCode: item.productCode || '', + productName: item.productName || '', + quantity: item.quantity || 1, + guideRailType: item.guideRailType || 'wall', + motorPower: item.motorPower || 'single', + controller: item.controller || 'basic', + wingSize: item.wingSize || 50, + inspectionFee: item.inspectionFee || 0, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + })), + }; +} + +function mapApiStatusToV2(apiStatus: string): 'draft' | 'temporary' | 'final' { + switch (apiStatus) { + case 'finalized': + case 'converted': + return 'final'; + case 'draft': + case 'sent': + case 'approved': + return 'draft'; + default: + return 'draft'; + } +} +``` + +--- + +## 5. 컨펌 대기 목록 + +> API 내부 로직 변경 등 승인 필요 항목 + +| # | 항목 | 변경 내용 | 영향 범위 | 상태 | +|---|------|----------|----------|------| +| C-1 | URL 경로 정식화 | test-new → new, test/[id] → [id] | 라우팅 전체 | ⏳ 대기 | +| C-2 | V1 페이지 처리 | 삭제 vs 리다이렉트 결정 | 기존 사용자 | ⏳ 대기 | +| C-3 | 컴포넌트 정리 | QuoteRegistration.tsx 삭제 여부 | 코드베이스 | ⏳ 대기 | + +--- + +## 6. 변경 이력 + +| 날짜 | 항목 | 변경 내용 | 파일 | 승인 | +|------|------|----------|------|------| +| 2026-01-26 | Step 3.1 | test 폴더 삭제, V1 백업 보존 | test-new/, test/ 삭제 | ✅ | +| 2026-01-26 | Step 2.1, 2.2 | URL 경로 정식화 (Phase 2 완료) | new/page.tsx, [id]/page.tsx | ✅ | +| 2026-01-26 | Step 1.3, 1.4 | test/[id] 상세/수정 API 연동 (Phase 1 완료) | test/[id]/page.tsx | ✅ | +| 2026-01-26 | Step 1.2 | test-new 페이지 createQuote API 연동 | test-new/page.tsx | ✅ | +| 2026-01-26 | Step 1.1 | V2 데이터 변환 함수 구현 완료 | types.ts | ✅ | +| 2026-01-26 | - | 계획 문서 초안 작성 | - | - | + +--- + +## 7. 참고 문서 + +- **빠른 시작**: `docs/quickstart/quick-start.md` +- **품질 체크리스트**: `docs/standards/quality-checklist.md` +- **API 규칙**: `docs/standards/api-rules.md` +- **DB 스키마**: `docs/specs/database-schema.md` + +### 7.1 핵심 파일 경로 + +#### 프론트엔드 (React) +``` +# V1 (기존) +react/src/app/[locale]/(protected)/sales/quote-management/page.tsx +react/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +react/src/components/quotes/QuoteRegistration.tsx (50KB) +react/src/components/quotes/actions.ts (28KB) +react/src/components/quotes/types.ts + +# V2 (신규) +react/src/app/[locale]/(protected)/sales/quote-management/test-new/page.tsx +react/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx +react/src/components/quotes/QuoteRegistrationV2.tsx +react/src/components/quotes/LocationListPanel.tsx +react/src/components/quotes/LocationDetailPanel.tsx +react/src/components/quotes/QuoteSummaryPanel.tsx +react/src/components/quotes/QuoteFooterBar.tsx +react/src/components/quotes/quoteConfig.ts +``` + +#### 백엔드 (Laravel API) - 변경 없음 +``` +api/app/Http/Controllers/Api/V1/QuoteController.php +api/app/Http/Requests/Quote/QuoteStoreRequest.php +api/app/Http/Requests/Quote/QuoteUpdateRequest.php +api/app/Models/Quote/Quote.php +api/app/Models/Quote/QuoteItem.php +api/app/Services/Quote/QuoteService.php +api/app/Services/Quote/QuoteCalculationService.php +``` + +--- + +## 8. 세션 및 메모리 관리 정책 (Serena Optimized) + +### 8.1 세션 시작 시 (Load Strategy) +```javascript +read_memory("quote-url-migration-state") // 1. 상태 파악 +read_memory("quote-url-migration-snapshot") // 2. 사고 흐름 복구 +``` + +### 8.2 작업 중 관리 (Context Defense) +| 컨텍스트 잔량 | Action | 내용 | +|--------------|--------|------| +| **30% 이하** | 🛠 **Snapshot** | 현재까지의 코드 변경점과 논의 핵심 요약 | +| **20% 이하** | 🧹 **Context Purge** | 수정 중인 핵심 파일 및 함수 목록 | +| **10% 이하** | 🛑 **Stop & Save** | 최종 상태 저장 후 세션 교체 권고 | + +### 8.3 Serena 메모리 구조 +- `quote-url-migration-state`: { phase, progress, next_step, last_decision } +- `quote-url-migration-snapshot`: 현재까지의 코드 변경 및 논의 요약 +- `quote-url-migration-active-files`: 수정 중인 파일 목록 + +--- + +## 9. 검증 결과 + +> 작업 완료 후 이 섹션에 검증 결과 추가 + +### 9.1 테스트 케이스 + +| 시나리오 | 입력 | 예상 결과 | 실제 결과 | 상태 | +|---------|------|----------|----------|------| +| 신규 등록 | 견적 정보 입력 후 저장 | DB 저장, 상세 페이지 이동 | - | ⏳ | +| 상세 조회 | /quote-management/[id] 접근 | 저장된 데이터 표시 | - | ⏳ | +| 수정 | mode=edit에서 수정 후 저장 | DB 업데이트, view 모드 복귀 | - | ⏳ | +| 문서 출력 | 견적서 버튼 클릭 | 견적서 모달 표시 | - | ⏳ | +| 최종확정 | 최종확정 버튼 클릭 | status → finalized | - | ⏳ | + +### 9.2 성공 기준 달성 현황 + +| 기준 | 달성 | 비고 | +|------|:----:|------| +| V2 API 연동 완료 | ✅ | Phase 1 완료 | +| URL 경로 정식화 | ✅ | Phase 2 완료 | +| V1 정리 완료 | ✅ | test 폴더 삭제, 백업 보존 | +| 통합 테스트 통과 | ⏳ | 사용자 테스트 필요 | + +--- + +## 10. 자기완결성 점검 결과 + +### 10.1 체크리스트 검증 + +| # | 검증 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1 | 작업 목적이 명확한가? | ✅ | 섹션 1.2 목표 참조 | +| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.2 참조 | +| 3 | 작업 범위가 구체적인가? | ✅ | 섹션 3 대상 범위 참조 | +| 4 | 의존성이 명시되어 있는가? | ✅ | DB/API 변경 없음 명시 | +| 5 | 참고 파일 경로가 정확한가? | ✅ | 섹션 7.1 검증 완료 | +| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 4 상세 절차 참조 | +| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 9.1 테스트 케이스 | +| 8 | 모호한 표현이 없는가? | ✅ | 구체적 함수명, 경로 명시 | + +### 10.2 새 세션 시뮬레이션 테스트 + +| 질문 | 답변 가능 | 참조 섹션 | +|------|:--------:|----------| +| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.2 목표 | +| Q2. 어디서부터 시작해야 하는가? | ✅ | 4.1 Step 1.1 | +| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 7.1 핵심 파일 경로 | +| Q4. 작업 완료 확인 방법은? | ✅ | 9.1 테스트 케이스 | +| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 | + +**결과**: 5/5 통과 → ✅ 자기완결성 확보 + +--- + +## 부록 A: API 스키마 상세 + +> V2 연동 시 참고할 실제 API 요청/응답 스키마 + +### A.1 API 응답 타입 (QuoteApiData) + +```typescript +// react/src/components/quotes/types.ts 에서 발췌 + +interface QuoteApiData { + id: number; + quote_number: string; + registration_date: string; + + // 발주처 정보 + client_id: number | null; + client_name: string; + client?: { id: number; name: string; }; // with('client') 로드 시 + + // 현장 정보 + site_name: string | null; + site_code: string | null; + + // 담당자 정보 (API 실제 필드명) + manager?: string | null; // 담당자명 + contact?: string | null; // 연락처 + manager_name?: string | null; // 레거시 호환 + manager_contact?: string | null; // 레거시 호환 + + // 제품 정보 + product_category: 'screen' | 'steel'; + quantity: number; + unit_symbol?: string | null; // 단위 (개소, set 등) + + // 금액 정보 + supply_amount: string | number; + tax_amount: string | number; + total_amount: string | number; + + // 상태 + status: 'draft' | 'sent' | 'approved' | 'rejected' | 'finalized' | 'converted'; + current_revision: number; + is_final: boolean; + + // 비고/납기 + remarks?: string | null; // API 실제 필드명 + completion_date?: string | null; // API 실제 필드명 + description?: string | null; // 레거시 호환 + delivery_date?: string | null; // 레거시 호환 + + // 자동산출 입력값 (JSON) + calculation_inputs?: { + items?: Array<{ + productCategory?: string; + productName?: string; + openWidth?: string; + openHeight?: string; + guideRailType?: string; + motorPower?: string; + controller?: string; + wingSize?: string; + inspectionFee?: number; + floor?: string; + code?: string; + quantity?: number; + }>; + } | null; + + // 품목 목록 + items?: QuoteItemApiData[]; + bom_materials?: BomMaterialApiData[]; + + // 감사 정보 + created_at: string; + updated_at: string; + created_by: number | null; + updated_by: number | null; + finalized_at: string | null; + finalized_by: number | null; + + // 관계 데이터 (with 로드 시) + creator?: { id: number; name: string; } | null; + updater?: { id: number; name: string; } | null; + finalizer?: { id: number; name: string; } | null; +} +``` + +### A.2 품목 API 타입 (QuoteItemApiData) + +```typescript +interface QuoteItemApiData { + id: number; + quote_id: number; + + // 품목 정보 + item_id?: number | null; + item_code?: string | null; + item_name: string; + product_id?: number | null; // 레거시 호환 + product_name?: string; // 레거시 호환 + specification: string | null; + unit: string | null; + + // 수량 (API는 calculated_quantity 사용) + base_quantity?: number; // 1개당 BOM 수량 + calculated_quantity?: number; // base × 주문 수량 + quantity?: number; // 레거시 호환 + + // 금액 + unit_price: string | number; + total_price?: string | number; // API 실제 필드 + supply_amount?: string | number; // 레거시 호환 + tax_amount?: string | number; + total_amount?: string | number; // 레거시 호환 + + sort_order: number; + note: string | null; +} +``` + +### A.3 API 요청 형식 (POST/PUT /api/v1/quotes) + +```typescript +// transformFormDataToApi() 출력 형식 + +interface QuoteApiRequest { + registration_date: string; // "2026-01-26" + author: string | null; // 작성자명 + client_id: number | null; + client_name: string; + site_name: string | null; + manager: string | null; // 담당자명 + contact: string | null; // 연락처 + completion_date: string | null; // 납기일 "2026-02-01" + remarks: string | null; + product_category: 'screen' | 'steel'; + quantity: number; // 총 수량 (items.quantity 합계) + unit_symbol: string; // "개소" | "SET" + total_amount: number; // 총액 (공급가 + 세액) + + // 자동산출 입력값 저장 (폼 복원용) + calculation_inputs: { + items: Array<{ + productCategory: string; + productName: string; + openWidth: string; + openHeight: string; + guideRailType: string; + motorPower: string; + controller: string; + wingSize: string; + inspectionFee: number; + floor: string; + code: string; + quantity: number; + }>; + }; + + // BOM 자재 기반 items + items: Array<{ + item_name: string; + item_code: string; + specification: string | null; + unit: string; + quantity: number; // 주문 수량 + base_quantity: number; // 1개당 BOM 수량 + calculated_quantity: number; // base × 주문 수량 + unit_price: number; + total_price: number; + sort_order: number; + note: string | null; + item_index?: number; // calculation_inputs.items 인덱스 + finished_goods_code?: string; // 완제품 코드 + formula_category?: string; // 공정 그룹 + }>; +} +``` + +--- + +## 부록 B: 기존 변환 함수 코드 + +> 새 세션에서 바로 사용할 수 있도록 V1 변환 함수 전체 코드 포함 + +### B.1 API → 프론트엔드 변환 (transformApiToFrontend) + +```typescript +// react/src/components/quotes/types.ts + +export function transformApiToFrontend(apiData: QuoteApiData): Quote { + return { + id: String(apiData.id), + quoteNumber: apiData.quote_number, + registrationDate: apiData.registration_date, + clientId: apiData.client_id ? String(apiData.client_id) : '', + clientName: apiData.client?.name || apiData.client_name || '', + siteName: apiData.site_name || undefined, + siteCode: apiData.site_code || undefined, + // API 실제 필드명 우선, 레거시 폴백 + managerName: apiData.manager || apiData.manager_name || undefined, + managerContact: apiData.contact || apiData.manager_contact || undefined, + productCategory: apiData.product_category, + quantity: apiData.quantity || 0, + unitSymbol: apiData.unit_symbol || undefined, + supplyAmount: parseFloat(String(apiData.supply_amount)) || 0, + taxAmount: parseFloat(String(apiData.tax_amount)) || 0, + totalAmount: parseFloat(String(apiData.total_amount)) || 0, + status: apiData.status, + currentRevision: apiData.current_revision || 0, + isFinal: apiData.is_final || false, + description: apiData.remarks || apiData.description || undefined, + validUntil: apiData.valid_until || undefined, + deliveryDate: apiData.completion_date || apiData.delivery_date || undefined, + deliveryLocation: apiData.delivery_location || undefined, + paymentTerms: apiData.payment_terms || undefined, + items: (apiData.items || []).map(transformItemApiToFrontend), + calculationInputs: apiData.calculation_inputs || undefined, + bomMaterials: (apiData.bom_materials || []).map(transformBomMaterialApiToFrontend), + createdAt: apiData.created_at, + updatedAt: apiData.updated_at, + createdBy: apiData.creator?.name || undefined, + updatedBy: apiData.updater?.name || undefined, + finalizedAt: apiData.finalized_at || undefined, + finalizedBy: apiData.finalizer?.name || undefined, + }; +} +``` + +### B.2 프론트엔드 → API 변환 (transformFormDataToApi) + +```typescript +// react/src/components/quotes/types.ts (핵심 부분) + +export function transformFormDataToApi(formData: QuoteFormData): Record { + let itemsData = []; + + // calculationResults가 있으면 BOM 자재 기반으로 items 생성 + if (formData.calculationResults && formData.calculationResults.items.length > 0) { + let sortOrder = 1; + formData.calculationResults.items.forEach((calcItem) => { + const formItem = formData.items[calcItem.index]; + const orderQuantity = formItem?.quantity || 1; + + calcItem.result.items.forEach((bomItem) => { + const baseQuantity = bomItem.quantity; + const calculatedQuantity = bomItem.unit === 'EA' + ? Math.round(baseQuantity * orderQuantity) + : parseFloat((baseQuantity * orderQuantity).toFixed(2)); + const totalPrice = bomItem.unit_price * calculatedQuantity; + + itemsData.push({ + item_name: bomItem.item_name, + item_code: bomItem.item_code, + specification: bomItem.specification || null, + unit: bomItem.unit || 'EA', + quantity: orderQuantity, + base_quantity: baseQuantity, + calculated_quantity: calculatedQuantity, + unit_price: bomItem.unit_price, + total_price: totalPrice, + sort_order: sortOrder++, + note: `${formItem?.floor || ''} ${formItem?.code || ''}`.trim() || null, + item_index: calcItem.index, + finished_goods_code: calcItem.result.finished_goods.code, + formula_category: bomItem.process_group || undefined, + }); + }); + }); + } else { + // 기존 로직: 완제품 기준 items 생성 + itemsData = formData.items.map((item, index) => ({ + item_name: item.productName, + item_code: item.productName, + specification: item.openWidth && item.openHeight + ? `${item.openWidth}x${item.openHeight}mm` : null, + unit: item.unit || '개소', + quantity: item.quantity, + base_quantity: 1, + calculated_quantity: item.quantity, + unit_price: item.unitPrice || item.inspectionFee || 0, + total_price: (item.unitPrice || item.inspectionFee || 0) * item.quantity, + sort_order: index + 1, + note: `${item.floor || ''} ${item.code || ''}`.trim() || null, + })); + } + + // 총액 계산 + const totalSupply = itemsData.reduce((sum, item) => sum + item.total_price, 0); + const totalTax = Math.round(totalSupply * 0.1); + const grandTotal = totalSupply + totalTax; + + // 자동산출 입력값 저장 + const calculationInputs = { + items: formData.items.map(item => ({ + productCategory: item.productCategory, + productName: item.productName, + openWidth: item.openWidth, + openHeight: item.openHeight, + guideRailType: item.guideRailType, + motorPower: item.motorPower, + controller: item.controller, + wingSize: item.wingSize, + inspectionFee: item.inspectionFee, + floor: item.floor, + code: item.code, + quantity: item.quantity, + })), + }; + + return { + registration_date: formData.registrationDate, + author: formData.writer || null, + client_id: formData.clientId ? parseInt(formData.clientId, 10) : null, + client_name: formData.clientName, + site_name: formData.siteName || null, + manager: formData.manager || null, + contact: formData.contact || null, + completion_date: formData.dueDate || null, + remarks: formData.remarks || null, + product_category: formData.items[0]?.productCategory?.toLowerCase() || 'screen', + quantity: formData.items.reduce((sum, item) => sum + item.quantity, 0), + unit_symbol: formData.unitSymbol || '개소', + total_amount: grandTotal, + calculation_inputs: calculationInputs, + items: itemsData, + }; +} +``` + +### B.3 Quote → QuoteFormData 변환 (transformQuoteToFormData) + +```typescript +// react/src/components/quotes/types.ts + +export function transformQuoteToFormData(quote: Quote): QuoteFormData { + const calcInputs = quote.calculationInputs?.items || []; + + // BOM 자재(quote.items)의 총 금액 계산 + const totalBomAmount = quote.items.reduce((sum, item) => sum + (item.totalAmount || 0), 0); + const itemCount = calcInputs.length || 1; + const amountPerItem = Math.round(totalBomAmount / itemCount); + + return { + id: quote.id, + registrationDate: formatDateForInput(quote.registrationDate), + writer: quote.createdBy || '', + clientId: quote.clientId, + clientName: quote.clientName, + siteName: quote.siteName || '', + manager: quote.managerName || '', + contact: quote.managerContact || '', + dueDate: formatDateForInput(quote.deliveryDate), + remarks: quote.description || '', + unitSymbol: quote.unitSymbol, + + // calculation_inputs.items가 있으면 그것으로 items 복원 + items: calcInputs.length > 0 + ? calcInputs.map((calcInput, index) => ({ + id: `temp-${index}`, + floor: calcInput.floor || '', + code: calcInput.code || '', + productCategory: calcInput.productCategory || '', + productName: calcInput.productName || '', + openWidth: calcInput.openWidth || '', + openHeight: calcInput.openHeight || '', + guideRailType: calcInput.guideRailType || '', + motorPower: calcInput.motorPower || '', + controller: calcInput.controller || '', + quantity: calcInput.quantity || 1, + unit: undefined, + wingSize: calcInput.wingSize || '50', + inspectionFee: calcInput.inspectionFee || 50000, + unitPrice: Math.round(amountPerItem / (calcInput.quantity || 1)), + totalAmount: amountPerItem, + })) + : quote.items.map((item) => ({ + id: item.id, + floor: '', + code: '', + productCategory: '', + productName: item.productName, + openWidth: '', + openHeight: '', + guideRailType: '', + motorPower: '', + controller: '', + quantity: item.quantity || 1, + unit: item.unit, + wingSize: '50', + inspectionFee: item.unitPrice || 50000, + unitPrice: item.unitPrice, + totalAmount: item.totalAmount, + })), + + bomMaterials: calcInputs.length > 0 + ? quote.items.map((item, index) => ({ + itemIndex: index, + finishedGoodsCode: '', + itemCode: item.itemCode || '', + itemName: item.productName, + itemType: '', + itemCategory: '', + specification: item.specification || '', + unit: item.unit || '', + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalAmount, + processType: '', + })) + : quote.bomMaterials, + }; +} + +// 날짜 형식 변환 헬퍼 +function formatDateForInput(dateStr: string | null | undefined): string { + if (!dateStr) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ''; + return date.toISOString().split('T')[0]; +} +``` + +--- + +## 부록 C: V2 ↔ API 필드 매핑표 + +> 새 변환 함수 작성 시 참고할 필드 매핑 + +### C.1 견적 마스터 필드 매핑 + +| V2 필드 (QuoteFormDataV2) | API 필드 (QuoteApiData) | DB 컬럼 (quotes) | 비고 | +|--------------------------|------------------------|-----------------|------| +| `id` | `id` | `id` | string ↔ number 변환 | +| `registrationDate` | `registration_date` | `registration_date` | | +| `writer` | `author` / `creator.name` | `author` | 저장: author, 조회: creator.name | +| `clientId` | `client_id` | `client_id` | string ↔ number 변환 | +| `clientName` | `client_name` / `client.name` | `client_name` | | +| `siteName` | `site_name` | `site_name` | | +| `manager` | `manager` | `manager` | | +| `contact` | `contact` | `contact` | | +| `dueDate` | `completion_date` | `completion_date` | | +| `remarks` | `remarks` | `remarks` | | +| `status` | `status` | `status` | V2: draft/temporary/final ↔ API: draft/sent/.../finalized | +| `locations` | `items` + `calculation_inputs.items` | - | 복합 변환 필요 | + +### C.2 개소 항목 필드 매핑 + +| V2 필드 (LocationItem) | API calculation_inputs.items | API items | 비고 | +|-----------------------|----------------------------|-----------|------| +| `id` | - | `id` | | +| `floor` | `floor` | `note` (일부) | | +| `code` | `code` | `note` (일부) | | +| `openWidth` | `openWidth` | `specification` (파싱) | "3000x2500mm" 형식 | +| `openHeight` | `openHeight` | `specification` (파싱) | | +| `productCode` | - | `finished_goods_code` | BOM 산출 시 사용 | +| `productName` | `productName` | `item_name` | | +| `quantity` | `quantity` | `quantity` | 주문 수량 | +| `guideRailType` | `guideRailType` | - | calculation_inputs에만 저장 | +| `motorPower` | `motorPower` | - | | +| `controller` | `controller` | - | | +| `wingSize` | `wingSize` | - | | +| `inspectionFee` | `inspectionFee` | - | | +| `unitPrice` | - | `unit_price` | | +| `totalPrice` | - | `total_price` | | + +### C.3 상태값 매핑 + +| V2 status | API status | 설명 | +|-----------|-----------|------| +| `draft` | `draft`, `sent`, `approved`, `rejected` | 작성중/진행중 | +| `temporary` | - | V2 전용 (임시저장) → API에는 `draft`로 저장 | +| `final` | `finalized`, `converted` | 최종확정/수주전환 | + +--- + +## 부록 D: 테스트 명령어 + +> Docker 환경에서 테스트하는 방법 + +### D.1 서비스 확인 + +```bash +# Docker 서비스 상태 확인 +cd /Users/kent/Works/@KD_SAM/SAM +docker compose ps + +# API 서버 로그 확인 +docker compose logs -f api + +# React 개발 서버 로그 확인 +docker compose logs -f react +``` + +### D.2 API 직접 테스트 + +```bash +# 견적 목록 조회 +curl -X GET "http://api.sam.kr/api/v1/quotes" \ + -H "Authorization: Bearer {TOKEN}" \ + -H "Accept: application/json" + +# 견적 상세 조회 +curl -X GET "http://api.sam.kr/api/v1/quotes/{ID}" \ + -H "Authorization: Bearer {TOKEN}" \ + -H "Accept: application/json" + +# 견적 생성 (예시) +curl -X POST "http://api.sam.kr/api/v1/quotes" \ + -H "Authorization: Bearer {TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "registration_date": "2026-01-26", + "client_name": "테스트 발주처", + "site_name": "테스트 현장", + "product_category": "screen", + "quantity": 1, + "total_amount": 1000000, + "items": [] + }' +``` + +### D.3 브라우저 테스트 URL + +``` +# V1 (기존) +http://dev.sam.kr/sales/quote-management # 목록 +http://dev.sam.kr/sales/quote-management?mode=new # 등록 +http://dev.sam.kr/sales/quote-management/1 # 상세 +http://dev.sam.kr/sales/quote-management/1?mode=edit # 수정 + +# V2 (신규 - 테스트) +http://dev.sam.kr/sales/quote-management/test-new # 등록 +http://dev.sam.kr/sales/quote-management/test/1 # 상세 +http://dev.sam.kr/sales/quote-management/test/1?mode=edit # 수정 +``` + +### D.4 디버깅 + +```bash +# React 콘솔 로그 확인 (브라우저 개발자 도구) +# [QuoteActions] 접두사로 API 요청/응답 확인 + +# API 디버그 로그 확인 +docker compose exec api tail -f storage/logs/laravel.log +``` + +--- + +## 부록 E: V2 변환 함수 구현 가이드 + +> Phase 1.1에서 구현할 함수 상세 가이드 + +### E.1 transformV2ToApi 구현 + +```typescript +// react/src/components/quotes/types.ts에 추가 + +import type { QuoteFormDataV2, LocationItem } from './QuoteRegistrationV2'; + +/** + * V2 폼 데이터 → API 요청 형식 변환 + * + * 핵심 차이점: + * - V2는 locations[] 배열, API는 items[] + calculation_inputs.items[] 구조 + * - V2 status는 3가지, API status는 6가지 + * - BOM 산출 결과가 있으면 items에 자재 상세 포함 + */ +export function transformV2ToApi( + data: QuoteFormDataV2, + bomResults?: BomCalculationResult[] +): Record { + + // 1. calculation_inputs 생성 (폼 복원용) + const calculationInputs = { + items: data.locations.map(loc => ({ + productCategory: 'screen', // TODO: 실제 카테고리 + productName: loc.productName, + openWidth: String(loc.openWidth), + openHeight: String(loc.openHeight), + guideRailType: loc.guideRailType, + motorPower: loc.motorPower, + controller: loc.controller, + wingSize: String(loc.wingSize), + inspectionFee: loc.inspectionFee, + floor: loc.floor, + code: loc.code, + quantity: loc.quantity, + })), + }; + + // 2. items 생성 (BOM 결과 있으면 자재 상세, 없으면 완제품 기준) + let items: Array> = []; + + if (bomResults && bomResults.length > 0) { + // BOM 자재 기반 + let sortOrder = 1; + bomResults.forEach((bomResult, locIndex) => { + const loc = data.locations[locIndex]; + const orderQty = loc?.quantity || 1; + + bomResult.items.forEach(bomItem => { + const baseQty = bomItem.quantity; + const calcQty = bomItem.unit === 'EA' + ? Math.round(baseQty * orderQty) + : parseFloat((baseQty * orderQty).toFixed(2)); + + items.push({ + item_name: bomItem.item_name, + item_code: bomItem.item_code, + specification: bomItem.specification || null, + unit: bomItem.unit || 'EA', + quantity: orderQty, + base_quantity: baseQty, + calculated_quantity: calcQty, + unit_price: bomItem.unit_price, + total_price: bomItem.unit_price * calcQty, + sort_order: sortOrder++, + note: `${loc?.floor || ''} ${loc?.code || ''}`.trim() || null, + item_index: locIndex, + finished_goods_code: bomResult.finished_goods.code, + formula_category: bomItem.process_group || undefined, + }); + }); + }); + } else { + // 완제품 기준 (BOM 산출 전) + items = data.locations.map((loc, index) => ({ + item_name: loc.productName, + item_code: loc.productCode, + specification: `${loc.openWidth}x${loc.openHeight}mm`, + unit: '개소', + quantity: loc.quantity, + base_quantity: 1, + calculated_quantity: loc.quantity, + unit_price: loc.unitPrice || loc.inspectionFee || 0, + total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity, + sort_order: index + 1, + note: `${loc.floor} ${loc.code}`.trim() || null, + })); + } + + // 3. 총액 계산 + const totalSupply = items.reduce((sum, item) => sum + (item.total_price as number), 0); + const totalTax = Math.round(totalSupply * 0.1); + const grandTotal = totalSupply + totalTax; + + // 4. API 요청 객체 반환 + return { + registration_date: data.registrationDate, + author: data.writer || null, + client_id: data.clientId ? parseInt(data.clientId, 10) : null, + client_name: data.clientName, + site_name: data.siteName || null, + manager: data.manager || null, + contact: data.contact || null, + completion_date: data.dueDate || null, + remarks: data.remarks || null, + product_category: 'screen', // TODO: 동적으로 결정 + quantity: data.locations.reduce((sum, loc) => sum + loc.quantity, 0), + unit_symbol: '개소', + total_amount: grandTotal, + status: data.status === 'final' ? 'finalized' : 'draft', + calculation_inputs: calculationInputs, + items: items, + }; +} +``` + +### E.2 transformApiToV2 구현 + +```typescript +/** + * API 응답 → V2 폼 데이터 변환 + * + * 핵심: + * - calculation_inputs.items가 있으면 그것으로 locations 복원 + * - 없으면 items에서 추출 시도 (레거시 호환) + */ +export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { + const calcInputs = apiData.calculation_inputs?.items || []; + + // calculation_inputs에서 locations 복원 + const locations: LocationItem[] = calcInputs.length > 0 + ? calcInputs.map((ci, index) => { + // 해당 인덱스의 BOM 자재에서 금액 계산 + const relatedItems = (apiData.items || []).filter( + item => item.item_index === index || item.note?.includes(ci.floor || '') + ); + const totalPrice = relatedItems.reduce( + (sum, item) => sum + parseFloat(String(item.total_price || 0)), 0 + ); + const qty = ci.quantity || 1; + + return { + id: `loc-${index}`, + floor: ci.floor || '', + code: ci.code || '', + openWidth: parseInt(ci.openWidth || '0', 10), + openHeight: parseInt(ci.openHeight || '0', 10), + productCode: '', // TODO: finished_goods_code에서 추출 + productName: ci.productName || '', + quantity: qty, + guideRailType: ci.guideRailType || 'wall', + motorPower: ci.motorPower || 'single', + controller: ci.controller || 'basic', + wingSize: parseInt(ci.wingSize || '50', 10), + inspectionFee: ci.inspectionFee || 50000, + unitPrice: Math.round(totalPrice / qty), + totalPrice: totalPrice, + }; + }) + : []; // TODO: items에서 복원 로직 추가 + + // 상태 매핑 + const mapStatus = (s: string): 'draft' | 'temporary' | 'final' => { + if (s === 'finalized' || s === 'converted') return 'final'; + return 'draft'; + }; + + return { + id: String(apiData.id), + registrationDate: formatDateForInput(apiData.registration_date), + writer: apiData.creator?.name || '', + clientId: apiData.client_id ? String(apiData.client_id) : '', + clientName: apiData.client?.name || apiData.client_name || '', + siteName: apiData.site_name || '', + manager: apiData.manager || apiData.manager_name || '', + contact: apiData.contact || apiData.manager_contact || '', + dueDate: formatDateForInput(apiData.completion_date || apiData.delivery_date), + remarks: apiData.remarks || apiData.description || '', + status: mapStatus(apiData.status), + locations: locations, + }; +} +``` + +--- + +*이 문서는 /sc:plan 스킬로 생성되었습니다. (2026-01-26 보완)* \ No newline at end of file