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:
@@ -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 생성
|
||||
|
||||
Reference in New Issue
Block a user