feat(quotes): V2 데이터 변환 함수 구현

- LocationItem, QuoteFormDataV2 타입 정의 추가
- transformV2ToApi: V2 폼 데이터 → API 요청 형식 변환
- transformApiToV2: API 응답 → V2 폼 데이터 변환
- BOM 결과 포함 시 자재 상세 items 생성 지원

refs: quote-management-url-migration-plan Step 1.1
This commit is contained in:
2026-01-26 09:46:07 +09:00
parent cd060ec562
commit b35da7b8a5

View File

@@ -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<string, unknown> {
// 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<string, unknown> {
// calculationResults가 있으면 BOM 자재 기반으로 items 생성