diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index 450ec08e..57486250 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -187,6 +187,21 @@ export async function getQuoteById(id: string): Promise<{ }; } + // 디버깅: API 응답 구조 확인 + console.log('[QuoteActions] getQuoteById raw data:', JSON.stringify({ + client_name: result.data.client_name, + client: result.data.client, + calculation_inputs: result.data.calculation_inputs, + items: result.data.items?.map((item: Record) => ({ + id: item.id, + item_name: item.item_name, + calculated_quantity: item.calculated_quantity, + base_quantity: item.base_quantity, + unit_price: item.unit_price, + total_price: item.total_price, + })), + }, null, 2)); + return { success: true, data: transformApiToFrontend(result.data), @@ -201,11 +216,13 @@ export async function getQuoteById(id: string): Promise<{ } // ===== 견적 등록 ===== +// 주의: 호출자가 transformFormDataToApi 또는 transformFrontendToApi로 변환한 데이터를 전달해야 함 export async function createQuote( - data: Partial + data: Record ): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> { try { - const apiData = transformFrontendToApi(data); + // 이미 변환된 API 형식 데이터를 그대로 사용 + const apiData = data; console.log('[QuoteActions] POST quote request:', apiData); @@ -255,12 +272,14 @@ export async function createQuote( } // ===== 견적 수정 ===== +// 주의: 호출자가 transformFormDataToApi 또는 transformFrontendToApi로 변환한 데이터를 전달해야 함 export async function updateQuote( id: string, - data: Partial + data: Record ): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> { try { - const apiData = transformFrontendToApi(data); + // 이미 변환된 API 형식 데이터를 그대로 사용 + const apiData = data; console.log('[QuoteActions] PUT quote request:', apiData); @@ -754,6 +773,190 @@ export async function sendQuoteKakao( } } +// ===== 완제품(FG) 목록 조회 ===== +export interface FinishedGoods { + id: number; + item_code: string; + item_name: string; + item_category: string; + specification?: string; + unit?: string; +} + +export async function getFinishedGoods(category?: string): Promise<{ + success: boolean; + data: FinishedGoods[]; + error?: string; + __authError?: boolean; +}> { + try { + const searchParams = new URLSearchParams(); + searchParams.set('item_type', 'FG'); + if (category) { + searchParams.set('item_category', category); + } + searchParams.set('size', '1000'); // 전체 조회 + + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`; + + console.log('[QuoteActions] GET finished goods:', url); + + const { response, error } = await serverFetch(url, { + method: 'GET', + }); + + if (error) { + return { + success: false, + data: [], + error: error.message, + __authError: error.code === 'UNAUTHORIZED', + }; + } + + if (!response) { + return { + success: false, + data: [], + error: '완제품 목록 조회에 실패했습니다.', + }; + } + + if (!response.ok) { + console.warn('[QuoteActions] GET finished goods error:', response.status); + return { + success: false, + data: [], + error: `API 오류: ${response.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: [], + error: result.message || '완제품 목록 조회에 실패했습니다.', + }; + } + + // API 응답: { success, data: { data: [], ... } } 또는 { success, data: [] } + const items = result.data?.data || result.data || []; + + return { + success: true, + data: items.map((item: Record) => ({ + id: item.id, + item_code: item.code as string, // Item 모델은 'code' 필드 사용 + item_name: item.name as string, // Item 모델은 'name' 필드 사용 + item_category: (item.item_category as string) || '', + specification: item.specification as string | undefined, + unit: item.unit as string | undefined, + })), + }; + } catch (error) { + console.error('[QuoteActions] getFinishedGoods error:', error); + return { + success: false, + data: [], + error: '서버 오류가 발생했습니다.', + }; + } +} + +// ===== BOM 기반 자동 견적 산출 (다건) ===== +export interface BomCalculateItem { + finished_goods_code: string; + // React 필드명 (camelCase) - API가 내부에서 W0/H0 등으로 변환 + openWidth: number; + openHeight: number; + quantity?: number; + guideRailType?: string; + motorPower?: string; + controller?: string; + wingSize?: number; + inspectionFee?: number; +} + +export interface BomCalculationResult { + finished_goods: { + code: string; + name: string; + item_category?: string; + }; + items: Array<{ + item_code: string; + item_name: string; + specification?: string; + unit?: string; + quantity: number; + unit_price: number; + total_price: number; + process_group?: string; + }>; + subtotals: Record; + grand_total: number; +} + +export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{ + success: boolean; + data: BomCalculationResult[]; + error?: string; + __authError?: boolean; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/calculate/bom/bulk`; + + console.log('[QuoteActions] POST calculate BOM bulk:', { items }); + + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify({ items }), + }); + + if (error) { + return { + success: false, + data: [], + error: error.message, + __authError: error.code === 'UNAUTHORIZED', + }; + } + + if (!response) { + return { + success: false, + data: [], + error: 'BOM 계산에 실패했습니다.', + }; + } + + const result = await response.json(); + console.log('[QuoteActions] POST calculate BOM bulk response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + data: [], + error: result.message || 'BOM 계산에 실패했습니다.', + }; + } + + return { + success: true, + data: result.data || [], + }; + } catch (error) { + console.error('[QuoteActions] calculateBomBulk error:', error); + return { + success: false, + data: [], + error: '서버 오류가 발생했습니다.', + }; + } +} + // ===== 견적 요약 통계 ===== export async function getQuotesSummary(params?: { dateFrom?: string; @@ -826,4 +1029,49 @@ export async function getQuotesSummary(params?: { error: '서버 오류가 발생했습니다.', }; } +} + +// ===== 현장명 목록 조회 (자동완성용) ===== +export async function getSiteNames(): Promise<{ + success: boolean; + data: string[]; + error?: string; + __authError?: boolean; +}> { + try { + // 기존 견적에서 현장명 수집 (중복 제거) + const listResult = await getQuotes({ + perPage: 500, + }); + + if (!listResult.success) { + return { + success: false, + data: [], + error: listResult.error, + __authError: listResult.__authError, + }; + } + + // 현장명 추출 및 중복 제거 + const siteNames = Array.from( + new Set( + listResult.data + .map(q => q.siteName) + .filter((name): name is string => !!name && name.trim() !== '') + ) + ).sort(); + + return { + success: true, + data: siteNames, + }; + } catch (error) { + console.error('[QuoteActions] getSiteNames error:', error); + return { + success: false, + data: [], + error: '서버 오류가 발생했습니다.', + }; + } } \ No newline at end of file diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 392f9c96..3de2efeb 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -33,6 +33,29 @@ export const QUOTE_STATUS_COLORS: Record = { converted: 'bg-indigo-100 text-indigo-800', }; +// ===== 날짜 형식 변환 헬퍼 ===== +/** + * API 날짜 문자열을 HTML date input용 YYYY-MM-DD 형식으로 변환 + * 지원 형식: ISO 8601, datetime string, date only + */ +function formatDateForInput(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 형식 (2025-01-06T00:00:00.000Z, 2025-01-06 00:00:00) + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return ''; // 유효하지 않은 날짜 + } + + // YYYY-MM-DD 형식으로 변환 + return date.toISOString().split('T')[0]; +} + // ===== 제품 카테고리 ===== export type ProductCategory = 'screen' | 'steel'; @@ -71,6 +94,7 @@ export interface Quote { managerContact?: string; productCategory: ProductCategory; quantity: number; + unitSymbol?: string; // 단위 (개소, set 등) supplyAmount: number; taxAmount: number; totalAmount: number; @@ -83,6 +107,8 @@ export interface Quote { deliveryLocation?: string; paymentTerms?: string; items: QuoteItem[]; + calculationInputs?: CalculationInputs; // 자동산출 입력값 (폼 복원용) + bomMaterials?: BomMaterial[]; // BOM 자재 목록 createdAt: string; updatedAt: string; createdBy?: string; @@ -92,6 +118,42 @@ export interface Quote { } // ===== API 응답 타입 ===== +// 자동산출 입력값 타입 +export interface CalculationInputItem { + productCategory?: string; + productName?: string; + openWidth?: string; + openHeight?: string; + guideRailType?: string; + motorPower?: string; + controller?: string; + wingSize?: string; + inspectionFee?: number; + floor?: string; + code?: string; + quantity?: number; // 수량 +} + +export interface CalculationInputs { + items?: CalculationInputItem[]; +} + +// BOM 자재 항목 타입 (API 응답) +export interface BomMaterialApiData { + item_index: number; + finished_goods_code: string; + item_code: string; + item_name: string; + item_type: string; // 품목 유형 (RM: 원자재, SM: 부자재, CS: 소모품) + item_category: string; // 품목 카테고리 + specification: string; + unit: string; + quantity: number; + unit_price: number; + total_price: number; + process_type: string; // 공정 유형 +} + export interface QuoteApiData { id: number; quote_number: string; @@ -104,42 +166,72 @@ export interface QuoteApiData { }; site_name: string | null; site_code: string | null; - manager_name: string | null; - manager_contact: string | null; + // 담당자/연락처 - API 실제 필드명과 레거시 호환 + manager?: string | null; // API 실제 필드명 + contact?: string | null; // API 실제 필드명 + 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: QuoteStatus; current_revision: number; is_final: boolean; - description: string | null; - valid_until: string | null; - delivery_date: string | null; - delivery_location: string | null; - payment_terms: string | null; + // 비고/납기일 - API 실제 필드명과 레거시 호환 + remarks?: string | null; // API 실제 필드명 + completion_date?: string | null; // API 실제 필드명 + description?: string | null; // 레거시 호환 + valid_until?: string | null; + delivery_date?: string | null; // 레거시 호환 + delivery_location?: string | null; + payment_terms?: string | null; + calculation_inputs?: CalculationInputs | null; items?: QuoteItemApiData[]; + bom_materials?: BomMaterialApiData[]; // BOM 자재 목록 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; } export interface QuoteItemApiData { id: number; quote_id: number; - product_id: number | null; - product_name: string; + // 품목 정보 (API는 item_name/item_code 사용) + item_id?: number | null; + item_code?: string | null; + item_name: string; + product_id?: number | null; // 레거시 호환 + product_name?: string; // 레거시 호환 specification: string | null; unit: string | null; - quantity: number; + // 수량/금액 (API는 calculated_quantity, total_price 사용) + base_quantity?: number; + calculated_quantity?: number; + quantity?: number; // 레거시 호환 unit_price: string | number; - supply_amount: string | number; - tax_amount: string | number; - total_amount: string | number; + total_price?: string | number; + supply_amount?: string | number; // 레거시 호환 + tax_amount?: string | number; // 레거시 호환 + total_amount?: string | number; // 레거시 호환 sort_order: number; note: string | null; } @@ -163,44 +255,57 @@ export function transformApiToFrontend(apiData: QuoteApiData): Quote { clientName: apiData.client?.name || apiData.client_name || '', siteName: apiData.site_name || undefined, siteCode: apiData.site_code || undefined, - managerName: apiData.manager_name || undefined, - managerContact: apiData.manager_contact || undefined, + // API 실제 필드명(manager, contact) 우선, 레거시 필드명(manager_name, manager_contact) 폴백 + 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.description || undefined, + // API 실제 필드명(remarks, completion_date) 우선, 레거시 필드명(description, delivery_date) 폴백 + description: apiData.remarks || apiData.description || undefined, validUntil: apiData.valid_until || undefined, - deliveryDate: apiData.delivery_date || 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), // BOM 자재 목록 createdAt: apiData.created_at, updatedAt: apiData.updated_at, - createdBy: apiData.created_by ? String(apiData.created_by) : undefined, - updatedBy: apiData.updated_by ? String(apiData.updated_by) : undefined, + createdBy: apiData.creator?.name || undefined, + updatedBy: apiData.updater?.name || undefined, finalizedAt: apiData.finalized_at || undefined, - finalizedBy: apiData.finalized_by ? String(apiData.finalized_by) : undefined, + finalizedBy: apiData.finalizer?.name || undefined, }; } export function transformItemApiToFrontend(apiData: QuoteItemApiData): QuoteItem { + // API 필드 우선순위: item_name > product_name + const productName = apiData.item_name || apiData.product_name || ''; + // API 필드 우선순위: calculated_quantity > quantity > base_quantity (정수로 변환) + const rawQuantity = apiData.calculated_quantity ?? apiData.quantity ?? apiData.base_quantity ?? 0; + const quantity = Math.round(rawQuantity); + // API 필드 우선순위: total_price > total_amount > supply_amount + const totalAmount = parseFloat(String(apiData.total_price ?? apiData.total_amount ?? apiData.supply_amount ?? 0)) || 0; + return { id: String(apiData.id), quoteId: String(apiData.quote_id), - productId: apiData.product_id ? String(apiData.product_id) : undefined, - productName: apiData.product_name, + productId: apiData.item_id ? String(apiData.item_id) : (apiData.product_id ? String(apiData.product_id) : undefined), + productName, specification: apiData.specification || undefined, unit: apiData.unit || undefined, - quantity: apiData.quantity || 0, + quantity, unitPrice: parseFloat(String(apiData.unit_price)) || 0, - supplyAmount: parseFloat(String(apiData.supply_amount)) || 0, + supplyAmount: totalAmount, // total_price를 supplyAmount로 사용 taxAmount: parseFloat(String(apiData.tax_amount)) || 0, - totalAmount: parseFloat(String(apiData.total_amount)) || 0, + totalAmount, sortOrder: apiData.sort_order || 0, note: apiData.note || undefined, }; @@ -286,6 +391,7 @@ export interface QuoteFormItem { motorPower: string; controller: string; quantity: number; + unit?: string; // 품목 단위 wingSize: string; inspectionFee: number; unitPrice?: number; @@ -293,6 +399,55 @@ export interface QuoteFormItem { installType?: string; } +// BOM 자재 항목 (프론트엔드용) +export interface BomMaterial { + itemIndex: number; + finishedGoodsCode: string; + itemCode: string; + itemName: string; + itemType: string; // 품목 유형 (RM: 원자재, SM: 부자재, CS: 소모품) + itemCategory: string; // 품목 카테고리 + specification: string; + unit: string; + quantity: number; + unitPrice: number; + totalPrice: number; + processType: string; // 공정 유형 +} + +// BOM 계산 결과 아이템 타입 +export interface BomCalculationResultItem { + item_code: string; + item_name: string; + specification?: string; + unit?: string; + quantity: number; // 1개당 BOM 수량 (base_quantity) + unit_price: number; + total_price: number; + process_group?: string; +} + +// BOM 계산 결과 타입 +export interface BomCalculationResult { + finished_goods: { + code: string; + name: string; + item_category?: string; + }; + items: BomCalculationResultItem[]; + subtotals: Record; + grand_total: number; +} + +// 견적 산출 결과 타입 +export interface CalculationResults { + summary: { grand_total: number }; + items: Array<{ + index: number; + result: BomCalculationResult; + }>; +} + export interface QuoteFormData { id?: string; registrationDate: string; @@ -304,65 +459,343 @@ export interface QuoteFormData { contact: string; dueDate: string; remarks: string; + unitSymbol?: string; // 선택한 제품(모델)의 단위 (개소, set 등) items: QuoteFormItem[]; + bomMaterials?: BomMaterial[]; // BOM 자재 목록 + calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용) } // ===== Quote → QuoteFormData 변환 ===== 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); + + // 디버깅 로그 + console.log('[transformQuoteToFormData] quote.calculationInputs:', JSON.stringify(quote.calculationInputs, null, 2)); + console.log('[transformQuoteToFormData] calcInputs:', JSON.stringify(calcInputs, null, 2)); + console.log('[transformQuoteToFormData] quote.items.length:', quote.items.length); + console.log('[transformQuoteToFormData] totalBomAmount:', totalBomAmount, 'amountPerItem:', amountPerItem); + return { id: quote.id, - registrationDate: quote.registrationDate, + registrationDate: formatDateForInput(quote.registrationDate), writer: quote.createdBy || '', clientId: quote.clientId, clientName: quote.clientName, siteName: quote.siteName || '', manager: quote.managerName || '', contact: quote.managerContact || '', - dueDate: quote.deliveryDate || '', + dueDate: formatDateForInput(quote.deliveryDate), remarks: quote.description || '', - items: quote.items.map((item) => ({ - id: item.id, - floor: '', - code: '', - productCategory: '', - productName: item.productName, - openWidth: '', - openHeight: '', - guideRailType: '', - motorPower: '', - controller: '', - quantity: item.quantity, - wingSize: '', - inspectionFee: item.unitPrice || 0, - unitPrice: item.unitPrice, - totalAmount: item.totalAmount, - })), + unitSymbol: quote.unitSymbol, // 단위 (EA, 개소 등) + // calculation_inputs.items가 있으면 그것으로 items 복원 (견적 탭 = 사용자가 입력한 수) + // 없으면 quote.items 사용 (레거시 호환) + items: calcInputs.length > 0 + ? calcInputs.map((calcInput, index) => ({ + id: `temp-${index}`, // 임시 ID (새로 저장 시 갱신됨) + 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, // BOM 자재에서 가져올 수 있지만 입력 시점에는 없음 + wingSize: calcInput.wingSize || '50', + inspectionFee: calcInput.inspectionFee || 50000, + // 금액은 BOM 자재 총합을 탭 수로 나눠서 배분 + unitPrice: Math.round(amountPerItem / (calcInput.quantity || 1)), + totalAmount: amountPerItem, + })) + : quote.items.map((item, index) => { + const spec = item.specification || ''; + // specification에서 width x height 추출 (예: "3000x2500mm") + const sizeMatch = spec.match(/(\d+)\s*x\s*(\d+)/i); + + return { + id: item.id, + floor: '', + code: '', + productCategory: '', + productName: item.productName, + openWidth: sizeMatch ? sizeMatch[1] : '', + openHeight: sizeMatch ? sizeMatch[2] : '', + guideRailType: '', + motorPower: '', + controller: '', + quantity: item.quantity || 1, + unit: item.unit, + wingSize: '50', + inspectionFee: item.unitPrice || 50000, + unitPrice: item.unitPrice, + totalAmount: item.totalAmount, + }; + }), + // BOM 자재 목록: + // - calcInputs가 있으면: quote.items에 BOM 자재가 저장되어 있음 (quote_items 테이블) + // - calcInputs가 없으면: quote.bomMaterials 사용 (별도 bom_materials 필드가 있는 경우) + bomMaterials: calcInputs.length > 0 + ? quote.items.map((item, index) => ({ + itemIndex: index, + finishedGoodsCode: '', + itemCode: item.productId || item.id || '', + itemName: item.productName, + itemType: '', + itemCategory: '', + specification: item.specification || '', + unit: item.unit || '', + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalAmount, + processType: '', + })) + : quote.bomMaterials, + }; +} + +// ===== BOM 자재 변환 함수 ===== +export function transformBomMaterialApiToFrontend(apiData: BomMaterialApiData): BomMaterial { + return { + itemIndex: apiData.item_index, + finishedGoodsCode: apiData.finished_goods_code, + itemCode: apiData.item_code, + itemName: apiData.item_name, + itemType: apiData.item_type, // 품목 유형 (RM, SM, CS) + itemCategory: apiData.item_category, // 품목 카테고리 + specification: apiData.specification, + unit: apiData.unit, + quantity: apiData.quantity, + unitPrice: apiData.unit_price, + totalPrice: apiData.total_price, + processType: apiData.process_type, // 공정 유형 + }; +} + +// ===== QuoteApiData → QuoteFormData 변환 (calculation_inputs + bom_materials 포함) ===== +export function transformApiDataToFormData(apiData: QuoteApiData): QuoteFormData { + const calcInputs = apiData.calculation_inputs?.items || []; + + // BOM 자재(apiData.items)의 총 금액 계산 + const totalBomAmount = (apiData.items || []).reduce((sum, item) => { + const itemTotal = parseFloat(String(item.total_price ?? item.total_amount ?? 0)) || 0; + return sum + itemTotal; + }, 0); + const itemCount = calcInputs.length || 1; + const amountPerItem = Math.round(totalBomAmount / itemCount); + + console.log('[transformApiDataToFormData] totalBomAmount:', totalBomAmount, 'itemCount:', itemCount, 'amountPerItem:', amountPerItem); + + 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 || '', + // API 실제 필드명(manager, contact) 우선, 레거시 필드명(manager_name, manager_contact) 폴백 + manager: apiData.manager || apiData.manager_name || '', + contact: apiData.contact || apiData.manager_contact || '', + // API 실제 필드명(completion_date, remarks) 우선, 레거시 필드명(delivery_date, description) 폴백 + // 날짜는 YYYY-MM-DD 형식으로 변환 + dueDate: formatDateForInput(apiData.completion_date || apiData.delivery_date), + remarks: apiData.remarks || apiData.description || '', + unitSymbol: apiData.unit_symbol || undefined, // 단위 (EA, 개소 등) + // calculation_inputs.items가 있으면 그것으로 items 복원 (견적 탭 = 사용자가 입력한 수) + // 없으면 apiData.items 사용 (레거시 호환) + items: calcInputs.length > 0 + ? calcInputs.map((calcInput, index) => ({ + id: `temp-${index}`, // 임시 ID (새로 저장 시 갱신됨) + 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, // BOM 자재에서 가져올 수 있지만 입력 시점에는 없음 + wingSize: calcInput.wingSize || '50', + inspectionFee: calcInput.inspectionFee || 50000, + // 금액은 BOM 자재 총합을 탭 수로 나눠서 배분 + unitPrice: Math.round(amountPerItem / (calcInput.quantity || 1)), + totalAmount: amountPerItem, + })) + : (apiData.items || []).map((item, index) => { + const spec = item.specification || ''; + // specification에서 width x height 추출 (예: "3000x2500mm") + const sizeMatch = spec.match(/(\d+)\s*x\s*(\d+)/i); + // 수량: calculated_quantity > base_quantity > quantity 순으로 확인 + const itemQuantity = item.calculated_quantity ?? item.base_quantity ?? item.quantity ?? 1; + + return { + id: String(item.id), + floor: '', + code: '', + productCategory: '', + productName: item.product_name || '', + openWidth: sizeMatch ? sizeMatch[1] : '', + openHeight: sizeMatch ? sizeMatch[2] : '', + guideRailType: '', + motorPower: '', + controller: '', + quantity: Math.round(itemQuantity), + unit: item.unit || undefined, + wingSize: '50', + inspectionFee: 50000, + unitPrice: parseFloat(String(item.unit_price)) || 0, + totalAmount: parseFloat(String(item.total_amount)) || 0, + }; + }), + // BOM 자재 목록 변환 + bomMaterials: (apiData.bom_materials || []).map(transformBomMaterialApiToFrontend), }; } // ===== QuoteFormData → API 요청 데이터 변환 ===== export function transformFormDataToApi(formData: QuoteFormData): Record { - return { + // calculationResults가 있으면 BOM 자재 기반으로 items 생성 + // 없으면 완제품 기준으로 items 생성 (기존 로직) + let itemsData: 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 (formData.calculationResults && formData.calculationResults.items.length > 0) { + // BOM 자재 기반 items 생성 + 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; // 1개당 BOM 수량 + 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, // 1개당 BOM 수량 + calculated_quantity: calculatedQuantity, // base × 주문 수량 + 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) => { + const unitPrice = item.unitPrice || item.inspectionFee || 0; + const supplyAmount = unitPrice * item.quantity; + + return { + 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, // 완제품은 1개당 1개 + calculated_quantity: item.quantity, + unit_price: unitPrice, + total_price: supplyAmount, + 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: 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, // 수량도 저장 + })), + }; + + const result = { registration_date: formData.registrationDate, + author: formData.writer || null, // writer → author 필드 매핑 client_id: formData.clientId ? parseInt(formData.clientId, 10) : null, client_name: formData.clientName, site_name: formData.siteName || null, - manager_name: formData.manager || null, - manager_contact: formData.contact || null, - delivery_date: formData.dueDate || null, - description: formData.remarks || null, - product_category: 'screen', // 기본값 + 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), - items: formData.items.map((item, index) => ({ - product_name: item.productName, - specification: `${item.openWidth}x${item.openHeight}mm`, - quantity: item.quantity, - unit_price: item.inspectionFee || item.unitPrice || 0, - supply_amount: (item.inspectionFee || item.unitPrice || 0) * item.quantity, - tax_amount: Math.round((item.inspectionFee || item.unitPrice || 0) * item.quantity * 0.1), - total_amount: Math.round((item.inspectionFee || item.unitPrice || 0) * item.quantity * 1.1), - sort_order: index + 1, - note: `${item.floor} ${item.code}`.trim() || null, - })), + unit_symbol: formData.unitSymbol || '개소', // 선택한 제품의 단위 또는 기본값 + total_amount: grandTotal, + calculation_inputs: calculationInputs, + items: itemsData, }; + + // 디버그: 전송되는 데이터 확인 + console.log('[transformFormDataToApi] 전송 데이터:', JSON.stringify({ + author: result.author, + manager: result.manager, + contact: result.contact, + site_name: result.site_name, + completion_date: result.completion_date, + remarks: result.remarks, + quantity: result.quantity, + items_count: result.items?.length, + items_sample: result.items?.slice(0, 3).map(i => ({ + item_name: i.item_name, + quantity: i.quantity, + base_quantity: i.base_quantity, + calculated_quantity: i.calculated_quantity, + })), + }, null, 2)); + + return result; } \ No newline at end of file diff --git a/src/lib/api/quote.ts b/src/lib/api/quote.ts index 88b64b46..a7b11331 100644 --- a/src/lib/api/quote.ts +++ b/src/lib/api/quote.ts @@ -85,6 +85,64 @@ interface InputSchemaField { description?: string; } +/** + * BOM 계산 요청 (단건) + */ +export interface CalculateBomRequest { + finished_goods_code: string; // 완제품 코드 + input_variables: { + W0: number; // 오픈사이즈 가로 (mm) + H0: number; // 오픈사이즈 세로 (mm) + QTY?: number; // 수량 + GT?: string; // 가이드레일 설치 유형 (벽면형/측면형) + MP?: string; // 모터 전원 (220V/380V) + CT?: string; // 연동제어기 (단독/연동) + WS?: number; // 마구리 날개치수 + INSP?: number; // 검사비 + }; +} + +/** + * BOM 계산 요청 (다건) + */ +export interface CalculateBomBulkRequest { + items: CalculateBomRequest[]; +} + +/** + * BOM 계산 결과 항목 + */ +interface BomResultItem { + item_code: string; + item_name: string; + specification?: string; + unit?: string; + quantity: number; + unit_price: number; + total_price: number; + process_group?: string; +} + +/** + * BOM 계산 결과 + */ +export interface BomCalculationResult { + finished_goods: { + code: string; + name: string; + item_category?: string; + }; + items: BomResultItem[]; + subtotals: Record; + grand_total: number; + debug_steps?: Array<{ + step: number; + label: string; + description: string; + data?: Record; + }>; +} + /** * API 응답 공통 형식 */ @@ -159,6 +217,26 @@ class QuoteApiClient extends ApiClient { const query = productCategory ? `?product_category=${productCategory}` : ''; return this.get>>(`/api/v1/quotes/calculation-schema${query}`); } + + /** + * BOM 기반 자동 견적 산출 (단건) + * + * @param request - BOM 산출 요청 파라미터 + * @returns BOM 계산 결과 + */ + async calculateBom(request: CalculateBomRequest): Promise> { + return this.post>('/api/v1/quotes/calculate/bom', request); + } + + /** + * BOM 기반 자동 견적 산출 (다건) + * + * @param request - 다건 BOM 산출 요청 파라미터 + * @returns BOM 계산 결과 배열 + */ + async calculateBomBulk(request: CalculateBomBulkRequest): Promise> { + return this.post>('/api/v1/quotes/calculate/bom/bulk', request); + } } // 싱글톤 인스턴스 내보내기