fix: 견적 V2 자동 견적 산출 UI 오류 수정
- actions.ts: BomBulkResponse 타입, FinishedGoods에 has_bom/bom 필드 추가 - QuoteRegistrationV2.tsx: handleCalculate 응답 처리, DevFill BOM 필터링 - LocationDetailPanel.tsx: bomItemsByTab process_group 기반 매핑 - QuoteSummaryPanel.tsx: detailTotals grouped_items 기반 계산 해결된 문제: 1. 오른쪽 패널 제품 리스트 미표시 2. 개소별 합계(상세소계) 미표시 3. 상세별 합계(그룹) 미표시 4. 예상 견적금액 0원 표시
This commit is contained in:
@@ -164,15 +164,31 @@ export function LocationDetailPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
items.forEach((item) => {
|
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() || "";
|
const processGroup = item.process_group?.toLowerCase() || "";
|
||||||
|
|
||||||
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫")) {
|
// API 그룹 키 기반 분류 (우선)
|
||||||
|
if (processGroupKey === "screen" || processGroupKey === "assembly") {
|
||||||
result.body.push(item);
|
result.body.push(item);
|
||||||
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
|
} else if (processGroupKey === "bending") {
|
||||||
result["guide-rail"].push(item);
|
result["guide-rail"].push(item);
|
||||||
} else if (processGroup.includes("케이스")) {
|
} else if (processGroupKey === "steel") {
|
||||||
result.case.push(item);
|
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);
|
result.bottom.push(item);
|
||||||
} else {
|
} else {
|
||||||
// 기타 항목은 본체에 포함
|
// 기타 항목은 본체에 포함
|
||||||
@@ -187,7 +203,7 @@ export function LocationDetailPanel({
|
|||||||
const tabSubtotals = useMemo(() => {
|
const tabSubtotals = useMemo(() => {
|
||||||
const result: Record<string, number> = {};
|
const result: Record<string, number> = {};
|
||||||
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
|
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;
|
return result;
|
||||||
}, [bomItemsByTab]);
|
}, [bomItemsByTab]);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
getSiteNames,
|
getSiteNames,
|
||||||
type FinishedGoods,
|
type FinishedGoods,
|
||||||
type BomCalculationResult,
|
type BomCalculationResult,
|
||||||
|
type BomBulkResponse,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
import { getClients } from "../accounting/VendorManagement/actions";
|
import { getClients } from "../accounting/VendorManagement/actions";
|
||||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||||
@@ -179,10 +180,24 @@ export function QuoteRegistrationV2({
|
|||||||
// handleCalculate 참조 (DevFill에서 사용)
|
// handleCalculate 참조 (DevFill에서 사용)
|
||||||
const calculateRef = useRef<(() => Promise<void>) | null>(null);
|
const calculateRef = useRef<(() => Promise<void>) | 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 (개발/테스트용 자동 채우기)
|
// DevFill (개발/테스트용 자동 채우기)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
useDevFill("quoteV2", useCallback(() => {
|
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 createRandomLocation = (index: number): LocationItem => {
|
||||||
const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
|
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 randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)];
|
||||||
const randomWidth = Math.floor(Math.random() * 4000) + 2000; // 2000~6000
|
const randomWidth = Math.floor(Math.random() * 4000) + 2000; // 2000~6000
|
||||||
const randomHeight = Math.floor(Math.random() * 3000) + 2000; // 2000~5000
|
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 {
|
return {
|
||||||
id: `loc-${Date.now()}-${index}`,
|
id: `loc-${Date.now()}-${index}`,
|
||||||
@@ -455,19 +472,27 @@ export function QuoteRegistrationV2({
|
|||||||
const result = await calculateBomBulk(bomItems);
|
const result = await calculateBomBulk(bomItems);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// API 응답: { summary: { grand_total }, items: [{ index, result: BomCalculationResult }] }
|
// API 응답: { success, summary: { grand_total, ... }, items: [{ index, result: BomCalculationResult }] }
|
||||||
const apiData = result.data as {
|
const apiData = result.data as BomBulkResponse;
|
||||||
summary?: { grand_total: number };
|
const bomResponseItems = apiData.items || [];
|
||||||
items?: Array<{ index: number; result: BomCalculationResult }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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;
|
const bomResult = bomItem?.result;
|
||||||
if (bomResult) {
|
if (bomResult) {
|
||||||
|
console.log(`[QuoteRegistrationV2] Location ${index} bomResult:`, {
|
||||||
|
items: bomResult.items?.length,
|
||||||
|
subtotals: bomResult.subtotals,
|
||||||
|
grand_total: bomResult.grand_total,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...loc,
|
...loc,
|
||||||
unitPrice: bomResult.grand_total,
|
unitPrice: bomResult.grand_total,
|
||||||
|
|||||||
@@ -140,15 +140,31 @@ export function QuoteSummaryPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subtotals = selectedLocation.bomResult.subtotals;
|
const subtotals = selectedLocation.bomResult.subtotals;
|
||||||
|
const groupedItems = selectedLocation.bomResult.grouped_items;
|
||||||
const result: DetailCategory[] = [];
|
const result: DetailCategory[] = [];
|
||||||
|
|
||||||
Object.entries(subtotals).forEach(([key, value]) => {
|
Object.entries(subtotals).forEach(([key, value]) => {
|
||||||
if (typeof value === "object" && value !== null) {
|
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({
|
result.push({
|
||||||
label: value.name || key,
|
label: value.name || key,
|
||||||
count: value.count || 0,
|
count: value.count || 0,
|
||||||
amount: value.subtotal || 0,
|
amount: value.subtotal || 0,
|
||||||
items: value.items || [],
|
items: groupItems,
|
||||||
});
|
});
|
||||||
} else if (typeof value === "number") {
|
} else if (typeof value === "number") {
|
||||||
result.push({
|
result.push({
|
||||||
|
|||||||
@@ -796,6 +796,8 @@ export interface FinishedGoods {
|
|||||||
item_category: string;
|
item_category: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
has_bom?: boolean;
|
||||||
|
bom?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFinishedGoods(category?: string): Promise<{
|
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) || '',
|
item_category: (item.item_category as string) || '',
|
||||||
specification: item.specification as string | undefined,
|
specification: item.specification as string | undefined,
|
||||||
unit: item.unit 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) {
|
} catch (error) {
|
||||||
@@ -896,28 +900,59 @@ export interface BomCalculateItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BomCalculationResult {
|
export interface BomCalculationResult {
|
||||||
|
success?: boolean;
|
||||||
finished_goods: {
|
finished_goods: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
item_category?: string;
|
item_category?: string;
|
||||||
};
|
};
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
item_code: string;
|
item_code: string;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
|
item_category?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
quantity_formula?: string;
|
||||||
|
base_price?: number;
|
||||||
|
multiplier?: number;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
total_price: number;
|
total_price: number;
|
||||||
|
calculation_note?: string;
|
||||||
|
category_group?: string;
|
||||||
process_group?: string;
|
process_group?: string;
|
||||||
|
process_group_key?: string;
|
||||||
}>;
|
}>;
|
||||||
subtotals: Record<string, { name?: string; count?: number; subtotal?: number } | number>;
|
grouped_items?: Record<string, {
|
||||||
|
name: string;
|
||||||
|
items: Array<unknown>;
|
||||||
|
subtotal: number;
|
||||||
|
}>;
|
||||||
|
subtotals: Record<string, { name?: string; count?: number; subtotal?: number; items?: unknown[] } | number>;
|
||||||
grand_total: number;
|
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<string, unknown>;
|
||||||
|
result: BomCalculationResult;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: BomCalculationResult[];
|
data: BomBulkResponse | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
@@ -934,7 +969,7 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
|||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
data: [],
|
data: null,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
__authError: error.code === 'UNAUTHORIZED',
|
__authError: error.code === 'UNAUTHORIZED',
|
||||||
};
|
};
|
||||||
@@ -943,7 +978,7 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
|||||||
if (!response) {
|
if (!response) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
data: [],
|
data: null,
|
||||||
error: 'BOM 계산에 실패했습니다.',
|
error: 'BOM 계산에 실패했습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -954,21 +989,21 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
|||||||
if (!response.ok || !result.success) {
|
if (!response.ok || !result.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
data: [],
|
data: null,
|
||||||
error: result.message || 'BOM 계산에 실패했습니다.',
|
error: result.message || 'BOM 계산에 실패했습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result.data || [],
|
data: result.data || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNextRedirectError(error)) throw error;
|
if (isNextRedirectError(error)) throw error;
|
||||||
console.error('[QuoteActions] calculateBomBulk error:', error);
|
console.error('[QuoteActions] calculateBomBulk error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
data: [],
|
data: null,
|
||||||
error: '서버 오류가 발생했습니다.',
|
error: '서버 오류가 발생했습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user