From b35da7b8a5db802d692e052fc249b807ea594606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 26 Jan 2026 09:46:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(quotes):=20V2=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B3=80=ED=99=98=20=ED=95=A8=EC=88=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationItem, QuoteFormDataV2 타입 정의 추가 - transformV2ToApi: V2 폼 데이터 → API 요청 형식 변환 - transformApiToV2: API 응답 → V2 폼 데이터 변환 - BOM 결과 포함 시 자재 상세 items 생성 지원 refs: quote-management-url-migration-plan Step 1.1 --- src/components/quotes/types.ts | 300 +++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index c0c11f51..e47d71e6 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -662,6 +662,306 @@ export function transformApiDataToFormData(apiData: QuoteApiData): QuoteFormData }; } +// ============================================================================= +// V2 타입 정의 (QuoteRegistrationV2 컴포넌트용) +// ============================================================================= + +/** + * 발주 개소 항목 (V2) + * - 좌우 분할 레이아웃에서 사용되는 개소별 데이터 구조 + */ +export interface LocationItem { + id: string; + floor: string; // 층 + code: string; // 부호 + openWidth: number; // 가로 (오픈사이즈 W) + openHeight: number; // 세로 (오픈사이즈 H) + productCode: string; // 제품코드 + productName: string; // 제품명 + quantity: number; // 수량 + guideRailType: string; // 가이드레일 설치 유형 + motorPower: string; // 모터 전원 + controller: string; // 연동제어기 + wingSize: number; // 마구리 날개치수 + inspectionFee: number; // 검사비 + // 계산 결과 + manufactureWidth?: number; // 제작사이즈 W + manufactureHeight?: number; // 제작사이즈 H + weight?: number; // 산출중량 (kg) + area?: number; // 산출면적 (m²) + unitPrice?: number; // 단가 + totalPrice?: number; // 합계 + bomResult?: BomCalculationResult; // BOM 계산 결과 +} + +/** + * 견적 폼 데이터 V2 + * - QuoteRegistrationV2 컴포넌트에서 사용하는 폼 데이터 구조 + * - V1의 QuoteFormData와 달리 locations[] 배열로 개소 관리 + */ +export 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[]; +} + +// ============================================================================= +// V2 변환 함수 +// ============================================================================= + +/** + * V2 폼 데이터 → API 요청 형식 변환 + * + * 핵심 차이점: + * - V2는 locations[] 배열, API는 items[] + calculation_inputs.items[] 구조 + * - V2 status는 3가지 (draft/temporary/final), API status는 6가지 + * - BOM 산출 결과가 있으면 items에 자재 상세 포함 + */ +export function transformV2ToApi( + data: QuoteFormDataV2, + bomResults?: BomCalculationResult[] +): Record { + + // 1. calculation_inputs 생성 (폼 복원용) + const calculationInputs: 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<{ + item_name: string; + item_code: string; + specification: string | null; + unit: string; + quantity: number; + base_quantity: number; + calculated_quantity: number; + unit_price: number; + total_price: number; + sort_order: number; + note: string | null; + item_index?: number; + finished_goods_code?: string; + formula_category?: string; + }> = []; + + 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 if (data.locations.some(loc => loc.bomResult)) { + // locations에 저장된 bomResult 사용 + let sortOrder = 1; + data.locations.forEach((loc, locIndex) => { + if (loc.bomResult) { + const orderQty = loc.quantity || 1; + loc.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: loc.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 && loc.openHeight + ? `${loc.openWidth}x${loc.openHeight}mm` + : null, + 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, 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, + }; +} + +/** + * API 응답 → V2 폼 데이터 변환 + * + * 핵심: + * - calculation_inputs.items가 있으면 그것으로 locations 복원 + * - 없으면 items에서 추출 시도 (레거시 호환) + */ +export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { + const calcInputs = apiData.calculation_inputs?.items || []; + + // calculation_inputs에서 locations 복원 + let locations: LocationItem[] = []; + + if (calcInputs.length > 0) { + locations = calcInputs.map((ci, index) => { + // 해당 인덱스의 BOM 자재에서 금액 계산 + const relatedItems = (apiData.items || []).filter( + item => (item as QuoteItemApiData & { item_index?: number }).item_index === index || + (item.note && ci.floor && item.note.includes(ci.floor)) + ); + const totalPrice = relatedItems.reduce( + (sum, item) => sum + parseFloat(String(item.total_price ?? item.total_amount ?? 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: '', // calculation_inputs에 없음, 필요시 items에서 추출 + 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: totalPrice > 0 ? Math.round(totalPrice / qty) : undefined, + totalPrice: totalPrice > 0 ? totalPrice : undefined, + }; + }); + } + + // 상태 매핑 + 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, + }; +} + +/** + * 날짜 형식 변환 헬퍼 (V2용 - formatDateForInput과 동일) + * API 날짜 문자열을 HTML date input용 YYYY-MM-DD 형식으로 변환 + */ +function formatDateForInputV2(dateStr: string | null | undefined): string { + if (!dateStr) return ''; + + // 이미 YYYY-MM-DD 형식인 경우 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return dateStr; + } + + // ISO 8601 또는 datetime 형식 + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return ''; + } + + return date.toISOString().split('T')[0]; +} + // ===== QuoteFormData → API 요청 데이터 변환 ===== export function transformFormDataToApi(formData: QuoteFormData): Record { // calculationResults가 있으면 BOM 자재 기반으로 items 생성