Files
sam-docs/plans/quote-management-url-migration-plan.md
권혁성 2bbf220dc8 docs:견적관리 분석 문서 및 INDEX 업데이트
- INDEX.md 업데이트
- 견적관리 URL 마이그레이션 계획 수정
- API 분석 리포트, tenant-id 준수 계획 추가
- 견적관리 기능 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 20:37:04 +09:00

44 KiB
Raw Blame History

견적관리 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

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 보완)