/** * 재고 현황 서버 액션 * * API Endpoints (Item 기준): * - GET /api/v1/stocks - 목록 조회 (Item + Stock LEFT JOIN) * - GET /api/v1/stocks/stats - 통계 조회 * - GET /api/v1/stocks/stats-by-type - 품목유형별 통계 조회 * - GET /api/v1/stocks/{id} - 상세 조회 (Item 기준, LOT 포함) */ 'use server'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { StockItem, StockDetail, StockStats, LotDetail, ItemType, StockStatusType, LotStatusType, } from './types'; // ===== API 데이터 타입 (Item 기준) ===== // Stock 관계 데이터 interface StockRelationData { id: number; item_id: number; stock_qty: string | number; safety_stock: string | number; reserved_qty: string | number; available_qty: string | number; lot_count: number; oldest_lot_date?: string; days_elapsed?: number; location?: string; status: StockStatusType; last_receipt_date?: string; last_issue_date?: string; lots?: StockLotApiData[]; } // Item API 응답 데이터 interface ItemApiData { id: number; tenant_id: number; code: string; // Item.code (기존 item_code) name: string; // Item.name (기존 item_name) item_type: ItemType; // Item.item_type (RM, SM, CS) unit: string; category_id?: number; category?: { id: number; name: string; }; description?: string; attributes?: Record; is_active: boolean; created_at?: string; updated_at?: string; stock?: StockRelationData | null; // Stock 관계 (없으면 null) } interface StockLotApiData { id: number; stock_id: number; lot_no: string; fifo_order: number; receipt_date: string; days_elapsed?: number; qty: string | number; reserved_qty: string | number; available_qty: string | number; unit?: string; supplier?: string; supplier_lot?: string; po_number?: string; location?: string; status: LotStatusType; status_label?: string; created_at?: string; updated_at?: string; } interface ItemApiPaginatedResponse { data: ItemApiData[]; current_page: number; last_page: number; per_page: number; total: number; } interface StockApiStatsResponse { total_items: number; normal_count: number; low_count: number; out_count: number; no_stock_count: number; } interface StockApiStatsByTypeResponse { [key: string]: { label: string; count: number; total_qty: number | string; }; } // ===== API → Frontend 변환 (목록용) ===== function transformApiToListItem(data: ItemApiData): StockItem { const stock = data.stock; const hasStock = !!stock; // description 또는 attributes에서 규격 정보 추출 let specification = ''; if (data.description) { specification = data.description; } else if (data.attributes && typeof data.attributes === 'object') { const attrs = data.attributes as Record; if (attrs.specification) { specification = String(attrs.specification); } } return { id: String(data.id), stockNumber: hasStock ? String((stock as unknown as Record).stock_number ?? stock.id ?? data.id) : String(data.id), itemCode: data.code, itemName: data.name, itemType: data.item_type, specification, unit: data.unit || 'EA', calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record).calculated_qty ?? stock.stock_qty)) || 0) : 0, actualQty: hasStock ? (parseFloat(String((stock as unknown as Record).actual_qty ?? stock.stock_qty)) || 0) : 0, stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0, safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0, wipQty: hasStock ? (parseFloat(String((stock as unknown as Record).wip_qty)) || 0) : 0, lotCount: hasStock ? (stock.lot_count || 0) : 0, lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0, status: hasStock ? stock.status : null, useStatus: data.is_active === false ? 'inactive' : 'active', location: hasStock ? (stock.location || '-') : '-', hasStock, }; } // ===== API → Frontend 변환 (LOT용) ===== function transformApiToLot(data: StockLotApiData): LotDetail { return { id: String(data.id), fifoOrder: data.fifo_order, lotNo: data.lot_no, receiptDate: data.receipt_date, daysElapsed: data.days_elapsed || 0, supplier: data.supplier || '-', poNumber: data.po_number || '-', qty: parseFloat(String(data.qty)) || 0, unit: data.unit || 'EA', location: data.location || '-', status: data.status, }; } // ===== API → Frontend 변환 (상세용) ===== function transformApiToDetail(data: ItemApiData): StockDetail { const stock = data.stock; const hasStock = !!stock; // description 또는 attributes에서 규격 정보 추출 let specification = '-'; if (data.description) { specification = data.description; } else if (data.attributes && typeof data.attributes === 'object') { // attributes에서 규격 관련 정보 추출 시도 const attrs = data.attributes as Record; if (attrs.specification) { specification = String(attrs.specification); } } return { id: String(data.id), itemCode: data.code, itemName: data.name, itemType: data.item_type, category: data.category?.name || '-', specification, unit: data.unit || 'EA', currentStock: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0, safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0, location: hasStock ? (stock.location || '-') : '-', lotCount: hasStock ? (stock.lot_count || 0) : 0, lastReceiptDate: hasStock ? (stock.last_receipt_date || '-') : '-', status: hasStock ? stock.status : null, hasStock, lots: hasStock && stock.lots ? stock.lots.map(transformApiToLot) : [], }; } // ===== API → Frontend 변환 (통계용) ===== function transformApiToStats(data: StockApiStatsResponse): StockStats { return { totalItems: data.total_items, normalCount: data.normal_count, lowCount: data.low_count, outCount: data.out_count, noStockCount: data.no_stock_count || 0, }; } // ===== 페이지네이션 타입 ===== interface PaginationMeta { currentPage: number; lastPage: number; perPage: number; total: number; } // ===== 재고 목록 조회 ===== export async function getStocks(params?: { page?: number; perPage?: number; search?: string; itemType?: string; status?: string; useStatus?: string; location?: string; sortBy?: string; sortDir?: string; startDate?: string; endDate?: string; }): Promise<{ success: boolean; data: StockItem[]; pagination: PaginationMeta; error?: string; __authError?: boolean; }> { try { const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); if (params?.perPage) searchParams.set('per_page', String(params.perPage)); if (params?.search) searchParams.set('search', params.search); if (params?.itemType && params.itemType !== 'all') { searchParams.set('item_type', params.itemType); } if (params?.status && params.status !== 'all') { searchParams.set('status', params.status); } if (params?.useStatus && params.useStatus !== 'all') { searchParams.set('is_active', params.useStatus === 'active' ? '1' : '0'); } if (params?.location) searchParams.set('location', params.location); if (params?.sortBy) searchParams.set('sort_by', params.sortBy); if (params?.sortDir) searchParams.set('sort_dir', params.sortDir); if (params?.startDate) searchParams.set('start_date', params.startDate); if (params?.endDate) searchParams.set('end_date', params.endDate); const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks${queryString ? `?${queryString}` : ''}`; const { response, error } = await serverFetch(url, { method: 'GET', cache: 'no-store', }); if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, error: error.message, __authError: error.code === 'UNAUTHORIZED', }; } if (!response) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, error: '재고 목록 조회에 실패했습니다.', }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, error: result.message || '재고 목록 조회에 실패했습니다.', }; } const paginatedData: ItemApiPaginatedResponse = result.data || { data: [], current_page: 1, last_page: 1, per_page: 20, total: 0, }; const stocks = (paginatedData.data || []).map(transformApiToListItem); return { success: true, data: stocks, pagination: { currentPage: paginatedData.current_page, lastPage: paginatedData.last_page, perPage: paginatedData.per_page, total: paginatedData.total, }, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] getStocks error:', error); return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, error: '서버 오류가 발생했습니다.', }; } } // ===== 재고 통계 조회 ===== export async function getStockStats(): Promise<{ success: boolean; data?: StockStats; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats`, { 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(); if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '재고 통계 조회에 실패했습니다.' }; } return { success: true, data: transformApiToStats(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] getStockStats error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 품목유형별 통계 조회 ===== export async function getStockStatsByType(): Promise<{ success: boolean; data?: StockApiStatsByTypeResponse; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats-by-type`, { 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(); if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '품목유형별 통계 조회에 실패했습니다.' }; } return { success: true, data: result.data }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] getStockStatsByType error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 재고 상세 조회 (Item 기준, LOT 포함) ===== export async function getStockById(id: string): Promise<{ success: boolean; data?: StockDetail; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`, { 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(); if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '재고 조회에 실패했습니다.' }; } return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] getStockById error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 재고 단건 수정 ===== export async function updateStock( id: string, data: { safetyStock: number; useStatus: 'active' | 'inactive'; } ): Promise<{ success: boolean; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ safety_stock: data.safetyStock, is_active: data.useStatus === 'active', }), } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '재고 수정에 실패했습니다.' }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '재고 수정에 실패했습니다.' }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] updateStock error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 재고 실사 (일괄 업데이트) ===== export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{ success: boolean; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/audit`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ items: updates.map((u) => ({ item_id: u.id, actual_qty: u.actualQty, })), }), } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '재고 실사 저장에 실패했습니다.' }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '재고 실사 저장에 실패했습니다.' }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[StockActions] updateStockAudit error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } }