이슈 #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:
@@ -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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 내보내기
|
||||
|
||||
Reference in New Issue
Block a user