/** * 재고 현황 서버 액션 * * 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 { executeServerAction } from '@/lib/api/execute-server-action'; import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; import { buildApiUrl } from '@/lib/api/query-params'; 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 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; // 규격: attributes.spec → thickness/width/length 조합 → stock.specification let specification = ''; if (data.attributes && typeof data.attributes === 'object') { const attrs = data.attributes as Record; if (attrs.spec && String(attrs.spec).trim()) { specification = String(attrs.spec).trim(); } else { const parts: string[] = []; if (attrs.thickness) parts.push(`${attrs.thickness}T`); if (attrs.width) parts.push(`${attrs.width}`); if (attrs.length) parts.push(`${attrs.length}`); if (parts.length > 0) specification = parts.join('×'); } } if (!specification && hasStock) { const stockSpec = (stock as unknown as Record).specification; if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim(); } return { id: String(data.id), itemCode: data.code, itemName: data.name, itemType: data.item_type, specification, unit: data.unit || 'EA', calculatedQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0, actualQty: hasStock ? (parseFloat(String(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; // 규격: attributes.spec → thickness/width/length 조합 → stock.specification let specification = '-'; if (data.attributes && typeof data.attributes === 'object') { const attrs = data.attributes as Record; if (attrs.spec && String(attrs.spec).trim()) { specification = String(attrs.spec).trim(); } else { const parts: string[] = []; if (attrs.thickness) parts.push(`${attrs.thickness}T`); if (attrs.width) parts.push(`${attrs.width}`); if (attrs.length) parts.push(`${attrs.length}`); if (parts.length > 0) specification = parts.join('×'); } } if (specification === '-' && hasStock) { const stockSpec = (stock as unknown as Record).specification; if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim(); } 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, }; } // ===== 재고 목록 조회 ===== export async function getStocks(params?: { page?: number; perPage?: number; search?: string; itemType?: string; itemCategory?: string; status?: string; useStatus?: string; location?: string; sortBy?: string; sortDir?: string; startDate?: string; endDate?: string; }) { return executePaginatedAction({ url: buildApiUrl('/api/v1/stocks', { page: params?.page, per_page: params?.perPage, search: params?.search, item_type: params?.itemType !== 'all' ? params?.itemType : undefined, item_category: params?.itemCategory !== 'all' ? params?.itemCategory : undefined, status: params?.status !== 'all' ? params?.status : undefined, is_active: params?.useStatus && params.useStatus !== 'all' ? (params.useStatus === 'active' ? '1' : '0') : undefined, location: params?.location, sort_by: params?.sortBy, sort_dir: params?.sortDir, start_date: params?.startDate, end_date: params?.endDate, }), transform: transformApiToListItem, errorMessage: '재고 목록 조회에 실패했습니다.', }); } // ===== 재고 통계 조회 ===== export async function getStockStats(): Promise<{ success: boolean; data?: StockStats; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/stocks/stats'), transform: (data: StockApiStatsResponse) => transformApiToStats(data), errorMessage: '재고 통계 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 품목유형별 통계 조회 ===== export async function getStockStatsByType(): Promise<{ success: boolean; data?: StockApiStatsByTypeResponse; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/stocks/stats-by-type'), errorMessage: '품목유형별 통계 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 재고 상세 조회 (Item 기준, LOT 포함) ===== export async function getStockById(id: string): Promise<{ success: boolean; data?: StockDetail; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl(`/api/v1/stocks/${id}`), transform: (data: ItemApiData) => transformApiToDetail(data), errorMessage: '재고 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 재고 단건 수정 ===== export async function updateStock( id: string, data: { safetyStock: number; useStatus: 'active' | 'inactive' } ): Promise<{ success: boolean; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl(`/api/v1/stocks/${id}`), method: 'PUT', body: { safety_stock: data.safetyStock, is_active: data.useStatus === 'active' }, errorMessage: '재고 수정에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, error: result.error }; } // ===== 재고 실사 (일괄 업데이트) ===== export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/stocks/audit'), method: 'POST', body: { items: updates.map((u) => ({ item_id: u.id, actual_qty: u.actualQty })) }, errorMessage: '재고 실사 저장에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, error: result.error }; }