- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정 - HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1282 lines
44 KiB
Markdown
1282 lines
44 KiB
Markdown
# 견적관리 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 3 코드 작업 완료 - 목록 페이지 링크 V2 적용 |
|
||
| **다음 작업** | Step 3.2: 통합 테스트 (사용자 수동 테스트) |
|
||
| **진행률** | 11/12 (92%) - Phase 1 ✅, Phase 2 ✅, Phase 3 (테스트 제외) ✅ |
|
||
| **마지막 업데이트** | 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<string, number | string>;
|
||
}
|
||
```
|
||
|
||
#### 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, DevToolbar 완료 |
|
||
| 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.3, 3.4 | 목록 페이지 V2 URL 적용, 문서 업데이트 | page.tsx, QuoteManagementClient.tsx, DevToolbar.tsx | ✅ |
|
||
| 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<string, unknown> {
|
||
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<string, unknown> {
|
||
|
||
// 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<Record<string, unknown>> = [];
|
||
|
||
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 보완)* |