fix(WEB): 견적 타입 및 API 연동 개선 (#1, #2, #7)

이슈 #1: 리스트 화면 담당자/비고 컬럼 표시
이슈 #2: 상세 화면 담당자/연락처 표시
이슈 #7: 수정 화면 기본정보 필드 표시

주요 변경:
- types.ts: Quote/QuoteApiData에 manager, contact, remarks 필드 추가
- types.ts: CalculationInputs, BomMaterial 타입 추가
- types.ts: transformApiToFrontend에서 새 필드 변환 로직 추가
- types.ts: transformQuoteToFormData에서 calculation_inputs 기반 폼 복원
- actions.ts: API 요청/응답 필드 매핑 개선
- api/quote.ts: API 엔드포인트 호출 개선
This commit is contained in:
2026-01-06 21:20:41 +09:00
parent a74f41228d
commit bf08447cd6
3 changed files with 824 additions and 65 deletions

View File

@@ -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<string, unknown>) => ({
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<Quote>
data: Record<string, unknown>
): 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<Quote>
data: Record<string, unknown>
): 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<string, unknown>) => ({
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<string, { name?: string; count?: number; subtotal?: number } | number>;
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: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -33,6 +33,29 @@ export const QUOTE_STATUS_COLORS: Record<QuoteStatus, string> = {
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<string, { name?: string; count?: number; subtotal?: number } | number>;
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<string, unknown> {
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;
}

View File

@@ -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<string, number>;
grand_total: number;
debug_steps?: Array<{
step: number;
label: string;
description: string;
data?: Record<string, unknown>;
}>;
}
/**
* API 응답 공통 형식
*/
@@ -159,6 +217,26 @@ class QuoteApiClient extends ApiClient {
const query = productCategory ? `?product_category=${productCategory}` : '';
return this.get<ApiResponse<Record<string, InputSchemaField>>>(`/api/v1/quotes/calculation-schema${query}`);
}
/**
* BOM 기반 자동 견적 산출 (단건)
*
* @param request - BOM 산출 요청 파라미터
* @returns BOM 계산 결과
*/
async calculateBom(request: CalculateBomRequest): Promise<ApiResponse<BomCalculationResult>> {
return this.post<ApiResponse<BomCalculationResult>>('/api/v1/quotes/calculate/bom', request);
}
/**
* BOM 기반 자동 견적 산출 (다건)
*
* @param request - 다건 BOM 산출 요청 파라미터
* @returns BOM 계산 결과 배열
*/
async calculateBomBulk(request: CalculateBomBulkRequest): Promise<ApiResponse<BomCalculationResult[]>> {
return this.post<ApiResponse<BomCalculationResult[]>>('/api/v1/quotes/calculate/bom/bulk', request);
}
}
// 싱글톤 인스턴스 내보내기