diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx index c85ad2d3..5920adac 100644 --- a/src/components/quotes/LocationDetailPanel.tsx +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -164,15 +164,31 @@ export function LocationDetailPanel({ }; items.forEach((item) => { + // process_group_key (API 그룹 키) 또는 process_group (한글명) 사용 + const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toLowerCase() || ""; const processGroup = item.process_group?.toLowerCase() || ""; - if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫")) { + // API 그룹 키 기반 분류 (우선) + if (processGroupKey === "screen" || processGroupKey === "assembly") { result.body.push(item); - } else if (processGroup.includes("가이드") || processGroup.includes("레일")) { + } else if (processGroupKey === "bending") { result["guide-rail"].push(item); - } else if (processGroup.includes("케이스")) { + } else if (processGroupKey === "steel") { result.case.push(item); - } else if (processGroup.includes("하단") || processGroup.includes("마감")) { + } else if (processGroupKey === "electric") { + result.bottom.push(item); + } else if (processGroupKey) { + // 기타 그룹키는 본체에 포함 + result.body.push(item); + } + // 한글명 기반 분류 (fallback) + else if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) { + result.body.push(item); + } else if (processGroup.includes("가이드") || processGroup.includes("레일") || processGroup.includes("절곡")) { + result["guide-rail"].push(item); + } else if (processGroup.includes("케이스") || processGroup.includes("철재")) { + result.case.push(item); + } else if (processGroup.includes("하단") || processGroup.includes("마감") || processGroup.includes("전기")) { result.bottom.push(item); } else { // 기타 항목은 본체에 포함 @@ -187,7 +203,7 @@ export function LocationDetailPanel({ const tabSubtotals = useMemo(() => { const result: Record = {}; Object.entries(bomItemsByTab).forEach(([tab, items]) => { - result[tab] = items.reduce((sum, item) => sum + (item.total_price || 0), 0); + result[tab] = items.reduce((sum: number, item: { total_price?: number }) => sum + (item.total_price || 0), 0); }); return result; }, [bomItemsByTab]); diff --git a/src/components/quotes/QuoteRegistrationV2.tsx b/src/components/quotes/QuoteRegistrationV2.tsx index 1a341832..a81ea723 100644 --- a/src/components/quotes/QuoteRegistrationV2.tsx +++ b/src/components/quotes/QuoteRegistrationV2.tsx @@ -40,6 +40,7 @@ import { getSiteNames, type FinishedGoods, type BomCalculationResult, + type BomBulkResponse, } from "./actions"; import { getClients } from "../accounting/VendorManagement/actions"; import { isNextRedirectError } from "@/lib/utils/redirect-error"; @@ -179,10 +180,24 @@ export function QuoteRegistrationV2({ // handleCalculate 참조 (DevFill에서 사용) const calculateRef = useRef<(() => Promise) | null>(null); + // 디버그용: formData를 window에 노출 + useEffect(() => { + if (typeof window !== "undefined") { + (window as unknown as { __QUOTE_DEBUG__: { formData: QuoteFormDataV2; selectedLocationId: string | null } }).__QUOTE_DEBUG__ = { + formData, + selectedLocationId, + }; + } + }, [formData, selectedLocationId]); + // --------------------------------------------------------------------------- // DevFill (개발/테스트용 자동 채우기) // --------------------------------------------------------------------------- useDevFill("quoteV2", useCallback(() => { + // BOM이 있는 제품만 필터링 + const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0)); + console.log(`[DevFill] BOM 있는 제품: ${productsWithBom.length}개 / 전체: ${finishedGoods.length}개`); + // 랜덤 개소 생성 함수 const createRandomLocation = (index: number): LocationItem => { const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"]; @@ -195,7 +210,9 @@ export function QuoteRegistrationV2({ const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)]; const randomWidth = Math.floor(Math.random() * 4000) + 2000; // 2000~6000 const randomHeight = Math.floor(Math.random() * 3000) + 2000; // 2000~5000 - const randomProduct = finishedGoods[Math.floor(Math.random() * finishedGoods.length)]; + // BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택) + const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods; + const randomProduct = productPool[Math.floor(Math.random() * productPool.length)]; return { id: `loc-${Date.now()}-${index}`, @@ -455,19 +472,27 @@ export function QuoteRegistrationV2({ const result = await calculateBomBulk(bomItems); if (result.success && result.data) { - // API 응답: { summary: { grand_total }, items: [{ index, result: BomCalculationResult }] } - const apiData = result.data as { - summary?: { grand_total: number }; - items?: Array<{ index: number; result: BomCalculationResult }>; - }; + // API 응답: { success, summary: { grand_total, ... }, items: [{ index, result: BomCalculationResult }] } + const apiData = result.data as BomBulkResponse; + const bomResponseItems = apiData.items || []; - const bomItems = apiData.items || []; + console.log('[QuoteRegistrationV2] BOM 계산 결과:', { + success: apiData.success, + summary: apiData.summary, + itemsCount: bomResponseItems.length, + firstItem: bomResponseItems[0], + }); // 결과 반영 const updatedLocations = formData.locations.map((loc, index) => { - const bomItem = bomItems.find((item) => item.index === index); + const bomItem = bomResponseItems.find((item) => item.index === index); const bomResult = bomItem?.result; if (bomResult) { + console.log(`[QuoteRegistrationV2] Location ${index} bomResult:`, { + items: bomResult.items?.length, + subtotals: bomResult.subtotals, + grand_total: bomResult.grand_total, + }); return { ...loc, unitPrice: bomResult.grand_total, diff --git a/src/components/quotes/QuoteSummaryPanel.tsx b/src/components/quotes/QuoteSummaryPanel.tsx index b5efce16..0269b9b5 100644 --- a/src/components/quotes/QuoteSummaryPanel.tsx +++ b/src/components/quotes/QuoteSummaryPanel.tsx @@ -140,15 +140,31 @@ export function QuoteSummaryPanel({ } const subtotals = selectedLocation.bomResult.subtotals; + const groupedItems = selectedLocation.bomResult.grouped_items; const result: DetailCategory[] = []; Object.entries(subtotals).forEach(([key, value]) => { if (typeof value === "object" && value !== null) { + // grouped_items에서 items 가져오기 (subtotals에는 items가 없을 수 있음) + const groupItemsRaw = groupedItems?.[key]?.items || value.items || []; + // DetailItem 형식으로 변환 + const groupItems: DetailItem[] = (groupItemsRaw as Array<{ + item_name?: string; + name?: string; + quantity?: number; + unit_price?: number; + total_price?: number; + }>).map((item) => ({ + name: item.item_name || item.name || "", + quantity: item.quantity || 0, + unitPrice: item.unit_price || 0, + totalPrice: item.total_price || 0, + })); result.push({ label: value.name || key, count: value.count || 0, amount: value.subtotal || 0, - items: value.items || [], + items: groupItems, }); } else if (typeof value === "number") { result.push({ diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index 8c100eba..29e42bed 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -796,6 +796,8 @@ export interface FinishedGoods { item_category: string; specification?: string; unit?: string; + has_bom?: boolean; + bom?: unknown[]; } export async function getFinishedGoods(category?: string): Promise<{ @@ -868,6 +870,8 @@ export async function getFinishedGoods(category?: string): Promise<{ item_category: (item.item_category as string) || '', specification: item.specification as string | undefined, unit: item.unit as string | undefined, + has_bom: item.has_bom as boolean | undefined, + bom: item.bom as unknown[] | undefined, })), }; } catch (error) { @@ -896,28 +900,59 @@ export interface BomCalculateItem { } export interface BomCalculationResult { + success?: boolean; finished_goods: { code: string; name: string; item_category?: string; }; + variables?: Record; items: Array<{ item_code: string; item_name: string; + item_category?: string; specification?: string; unit?: string; quantity: number; + quantity_formula?: string; + base_price?: number; + multiplier?: number; unit_price: number; total_price: number; + calculation_note?: string; + category_group?: string; process_group?: string; + process_group_key?: string; }>; - subtotals: Record; + grouped_items?: Record; + subtotal: number; + }>; + subtotals: Record; grand_total: number; } +// API 서버 응답 구조 (QuoteCalculationService::calculateBomBulk) +export interface BomBulkResponse { + success: boolean; + summary: { + total_count: number; + success_count: number; + fail_count: number; + grand_total: number; + }; + items: Array<{ + index: number; + finished_goods_code: string; + inputs: Record; + result: BomCalculationResult; + }>; +} + export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{ success: boolean; - data: BomCalculationResult[]; + data: BomBulkResponse | null; error?: string; __authError?: boolean; }> { @@ -934,7 +969,7 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{ if (error) { return { success: false, - data: [], + data: null, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; @@ -943,7 +978,7 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{ if (!response) { return { success: false, - data: [], + data: null, error: 'BOM 계산에 실패했습니다.', }; } @@ -954,21 +989,21 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{ if (!response.ok || !result.success) { return { success: false, - data: [], + data: null, error: result.message || 'BOM 계산에 실패했습니다.', }; } return { success: true, - data: result.data || [], + data: result.data || null, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[QuoteActions] calculateBomBulk error:', error); return { success: false, - data: [], + data: null, error: '서버 오류가 발생했습니다.', }; }