/** * 단가관리 서버 액션 * * API Endpoints: * - GET /api/v1/pricing - 목록 조회 * - GET /api/v1/pricing/{id} - 상세 조회 * - POST /api/v1/pricing - 등록 * - PUT /api/v1/pricing/{id} - 수정 * - DELETE /api/v1/pricing/{id} - 삭제 * - POST /api/v1/pricing/{id}/finalize - 확정 * - GET /api/v1/pricing/{id}/revisions - 이력 조회 */ 'use server'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { PricingData, ItemInfo } from './types'; // API 응답 타입 interface ApiResponse { success: boolean; data: T; message: string; } // 단가 API 응답 데이터 타입 interface PriceApiData { id: number; tenant_id: number; item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일) item_id: number; client_group_id: number | null; purchase_price: string | null; processing_cost: string | null; loss_rate: string | null; margin_rate: string | null; sales_price: string | null; rounding_rule: 'round' | 'ceil' | 'floor'; rounding_unit: number; supplier: string | null; effective_from: string; effective_to: string | null; status: 'draft' | 'active' | 'finalized'; is_final: boolean; finalized_at: string | null; finalized_by: number | null; note: string | null; created_at: string; updated_at: string; deleted_at: string | null; client_group?: { id: number; name: string; }; product?: { id: number; product_code: string; product_name: string; specification: string | null; unit: string; product_type: string; }; material?: { id: number; item_code: string; item_name: string; specification: string | null; unit: string; product_type: string; }; revisions?: Array<{ id: number; revision_number: number; changed_at: string; changed_by: number; change_reason: string | null; before_snapshot: Record | null; after_snapshot: Record; changed_by_user?: { id: number; name: string; }; }>; } /** * API 데이터 → 프론트엔드 타입 변환 */ function transformApiToFrontend(apiData: PriceApiData): PricingData { const product = apiData.product; const material = apiData.material; const itemCode = product?.product_code || material?.item_code || `ITEM-${apiData.item_id}`; const itemName = product?.product_name || material?.item_name || '품목명 없음'; const specification = product?.specification || material?.specification || undefined; const unit = product?.unit || material?.unit || 'EA'; const itemType = product?.product_type || material?.product_type || 'PT'; // 리비전 변환 const revisions = apiData.revisions?.map((rev) => ({ revisionNumber: rev.revision_number, revisionDate: rev.changed_at, revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`, revisionReason: rev.change_reason || undefined, previousData: rev.before_snapshot as unknown as PricingData, })) || []; return { id: String(apiData.id), itemId: String(apiData.item_id), itemCode, itemName, itemType, specification, unit, effectiveDate: apiData.effective_from, purchasePrice: apiData.purchase_price ? parseFloat(apiData.purchase_price) : undefined, processingCost: apiData.processing_cost ? parseFloat(apiData.processing_cost) : undefined, loss: apiData.loss_rate ? parseFloat(apiData.loss_rate) : undefined, roundingRule: apiData.rounding_rule || 'round', roundingUnit: apiData.rounding_unit || 1, marginRate: apiData.margin_rate ? parseFloat(apiData.margin_rate) : undefined, salesPrice: apiData.sales_price ? parseFloat(apiData.sales_price) : undefined, supplier: apiData.supplier || undefined, note: apiData.note || undefined, currentRevision: revisions.length, isFinal: apiData.is_final, revisions, finalizedDate: apiData.finalized_at || undefined, status: apiData.status, createdAt: apiData.created_at, createdBy: '관리자', updatedAt: apiData.updated_at, updatedBy: '관리자', }; } /** * 프론트엔드 데이터 → API 요청 형식 변환 * item_type_code는 품목 정보(data.itemType)에서 가져옴 (FG, PT, SM, RM, CS 등) */ function transformFrontendToApi(data: PricingData): Record { return { item_type_code: data.itemType, // 품목에서 가져온 실제 item_type 값 사용 item_id: parseInt(data.itemId), purchase_price: data.purchasePrice || null, processing_cost: data.processingCost || null, loss_rate: data.loss || null, margin_rate: data.marginRate || null, sales_price: data.salesPrice || null, rounding_rule: data.roundingRule || 'round', rounding_unit: data.roundingUnit || 1, supplier: data.supplier || null, effective_from: data.effectiveDate, effective_to: null, note: data.note || null, status: data.status || 'draft', }; } /** * 단가 상세 조회 */ export async function getPricingById(id: string): Promise { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`, { method: 'GET', cache: 'no-store', } ); if (error) { console.error('[PricingActions] GET pricing error:', error.message); return null; } if (!response) { console.error('[PricingActions] GET pricing: 응답이 없습니다.'); return null; } if (!response.ok) { console.error('[PricingActions] GET pricing error:', response.status); return null; } const result: ApiResponse = await response.json(); console.log('[PricingActions] GET pricing response:', result); if (!result.success || !result.data) { return null; } return transformApiToFrontend(result.data); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] getPricingById error:', error); return null; } } /** * 품목 정보 조회 (통합 품목 API) * * GET /api/v1/items/{id} */ export async function getItemInfo(itemId: string): Promise { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items/${itemId}`, { method: 'GET', cache: 'no-store', } ); if (error) { console.error('[PricingActions] getItemInfo error:', error.message); return null; } if (!response) { console.error('[PricingActions] getItemInfo: 응답이 없습니다.'); return null; } if (!response.ok) { console.error('[PricingActions] Item not found:', itemId); return null; } const result = await response.json(); if (!result.success || !result.data) { return null; } const item = result.data; return { id: String(item.id), itemCode: item.code, itemName: item.name, itemType: item.item_type || 'PT', specification: item.specification || undefined, unit: item.unit || 'EA', }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] getItemInfo error:', error); return null; } } /** * 단가 등록 * item_type_code는 data.itemType에서 자동으로 가져옴 */ export async function createPricing( data: PricingData ): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> { try { const apiData = transformFrontendToApi(data); console.log('[PricingActions] POST pricing request:', apiData); const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing`, { method: 'POST', body: JSON.stringify(apiData), } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, error: '단가 등록에 실패했습니다.', }; } const result = await response.json(); console.log('[PricingActions] POST pricing response:', result); if (!response.ok || !result.success) { return { success: false, error: result.message || '단가 등록에 실패했습니다.', }; } return { success: true, data: transformApiToFrontend(result.data), }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] createPricing error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 단가 수정 */ export async function updatePricing( id: string, data: PricingData, changeReason?: string ): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> { try { const apiData = { ...transformFrontendToApi(data), change_reason: changeReason || null, } as Record; console.log('[PricingActions] PUT pricing request:', apiData); const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`, { method: 'PUT', body: JSON.stringify(apiData), } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, error: '단가 수정에 실패했습니다.', }; } const result = await response.json(); console.log('[PricingActions] PUT pricing response:', result); if (!response.ok || !result.success) { return { success: false, error: result.message || '단가 수정에 실패했습니다.', }; } return { success: true, data: transformApiToFrontend(result.data), }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] updatePricing error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 단가 삭제 */ export async function deletePricing(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`, { method: 'DELETE', } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, error: '단가 삭제에 실패했습니다.', }; } const result = await response.json(); console.log('[PricingActions] DELETE pricing response:', result); if (!response.ok || !result.success) { return { success: false, error: result.message || '단가 삭제에 실패했습니다.', }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] deletePricing error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 단가 확정 */ export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}/finalize`, { method: 'POST', } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, error: '단가 확정에 실패했습니다.', }; } const result = await response.json(); console.log('[PricingActions] POST finalize response:', result); if (!response.ok || !result.success) { return { success: false, error: result.message || '단가 확정에 실패했습니다.', }; } return { success: true, data: transformApiToFrontend(result.data), }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] finalizePricing error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } // ============================================ // 품목 목록 + 단가 목록 병합 조회 // ============================================ // 품목 API 응답 타입 (GET /api/v1/items) interface ItemApiData { id: number; item_type: string; // FG, PT, SM, RM, CS (품목 유형) code: string; name: string; unit: string; category_id: number | null; created_at: string; deleted_at: string | null; } // 단가 목록 조회용 타입 interface PriceApiListItem { id: number; tenant_id: number; item_type_code: string; item_id: number; client_group_id: number | null; purchase_price: string | null; processing_cost: string | null; loss_rate: string | null; margin_rate: string | null; sales_price: string | null; rounding_rule: 'round' | 'ceil' | 'floor'; rounding_unit: number; supplier: string | null; effective_from: string; effective_to: string | null; status: 'draft' | 'active' | 'finalized'; is_final: boolean; finalized_at: string | null; finalized_by: number | null; note: string | null; created_at: string; updated_at: string; deleted_at: string | null; } // 목록 표시용 타입 export interface PricingListItem { id: string; itemId: string; itemCode: string; itemName: string; itemType: string; specification?: string; unit: string; purchasePrice?: number; processingCost?: number; salesPrice?: number; marginRate?: number; effectiveDate?: string; status: 'draft' | 'active' | 'finalized' | 'not_registered'; currentRevision: number; isFinal: boolean; itemTypeCode: string; } // 품목 유형 매핑 (type_code → 프론트엔드 ItemType) function mapItemTypeForList(typeCode?: string): string { switch (typeCode) { case 'FG': return 'FG'; case 'PT': return 'PT'; case 'SM': return 'SM'; case 'RM': return 'RM'; case 'CS': return 'CS'; default: return 'PT'; } } // API 상태 → 프론트엔드 상태 매핑 function mapStatusForList(apiStatus: string, isFinal: boolean): 'draft' | 'active' | 'finalized' | 'not_registered' { if (isFinal) return 'finalized'; switch (apiStatus) { case 'draft': return 'draft'; case 'active': return 'active'; case 'finalized': return 'finalized'; default: return 'draft'; } } /** * 단가 목록 데이터 조회 (품목 + 단가 병합) */ export async function getPricingListData(): Promise { try { // 품목 목록 조회 const { response: itemsResponse, error: itemsError } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`, { method: 'GET' } ); if (itemsError || !itemsResponse) { console.error('[PricingActions] Items fetch error:', itemsError?.message); return []; } const itemsResult = await itemsResponse.json(); const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : []; // 단가 목록 조회 const { response: pricingResponse, error: pricingError } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`, { method: 'GET' } ); if (pricingError || !pricingResponse) { console.error('[PricingActions] Pricing fetch error:', pricingError?.message); return []; } const pricingResult = await pricingResponse.json(); const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : []; // 단가 정보를 빠르게 찾기 위한 Map 생성 const pricingMap = new Map(); for (const pricing of pricings) { const key = `${pricing.item_type_code}_${pricing.item_id}`; if (!pricingMap.has(key)) { pricingMap.set(key, pricing); } } // 품목 목록을 기준으로 병합 return items.map((item) => { const key = `${item.item_type}_${item.id}`; const pricing = pricingMap.get(key); if (pricing) { return { id: String(pricing.id), itemId: String(item.id), itemCode: item.code, itemName: item.name, itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA', purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined, processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined, salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined, marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined, effectiveDate: pricing.effective_from, status: mapStatusForList(pricing.status, pricing.is_final), currentRevision: 0, isFinal: pricing.is_final, itemTypeCode: item.item_type, }; } else { return { id: `item_${item.id}`, itemId: String(item.id), itemCode: item.code, itemName: item.name, itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA', purchasePrice: undefined, processingCost: undefined, salesPrice: undefined, marginRate: undefined, effectiveDate: undefined, status: 'not_registered' as const, currentRevision: 0, isFinal: false, itemTypeCode: item.item_type, }; } }); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] getPricingListData error:', error); return []; } } /** * 단가 이력 조회 */ export async function getPricingRevisions(priceId: string): Promise<{ success: boolean; data?: Array<{ revisionNumber: number; revisionDate: string; revisionBy: string; revisionReason?: string; beforeSnapshot: Record | null; afterSnapshot: Record; }>; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${priceId}/revisions`, { method: 'GET', cache: 'no-store', } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, error: '이력 조회에 실패했습니다.', }; } const result = await response.json(); console.log('[PricingActions] GET revisions response:', result); if (!response.ok || !result.success) { return { success: false, error: result.message || '이력 조회에 실패했습니다.', }; } const revisions = result.data.data?.map((rev: { revision_number: number; changed_at: string; changed_by: number; change_reason: string | null; before_snapshot: Record | null; after_snapshot: Record; changed_by_user?: { name: string }; }) => ({ revisionNumber: rev.revision_number, revisionDate: rev.changed_at, revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`, revisionReason: rev.change_reason || undefined, beforeSnapshot: rev.before_snapshot, afterSnapshot: rev.after_snapshot, })) || []; return { success: true, data: revisions, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[PricingActions] getPricingRevisions error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } }