- INDEX.md 업데이트 - 견적관리 URL 마이그레이션 계획 수정 - API 분석 리포트, tenant-id 준수 계획 추가 - 견적관리 기능 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
44 KiB
44 KiB
견적관리 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 목표
- V2 페이지에 실제 API 연동 완료
- V2 URL 패턴을 정식 경로로 채택 (test 접두사 제거)
- V1 페이지 삭제 또는 V2로 리다이렉트 처리
- 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
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
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 테이블 - 그대로 사용
-- 핵심 필드
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 테이블 - 그대로 사용
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 (저장 시)
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 (조회 시)
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)
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)
// 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)
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)
// 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)
// 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)
// 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)
// 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 서비스 확인
# 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 직접 테스트
# 견적 목록 조회
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 디버깅
# React 콘솔 로그 확인 (브라우저 개발자 도구)
# [QuoteActions] 접두사로 API 요청/응답 확인
# API 디버그 로그 확인
docker compose exec api tail -f storage/logs/laravel.log
부록 E: V2 변환 함수 구현 가이드
Phase 1.1에서 구현할 함수 상세 가이드
E.1 transformV2ToApi 구현
// 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 구현
/**
* 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 보완)