- WorkOrderCreate: 수동 모드 품목 검색/추가/수량 관리 UI 구현 - WorkOrders/actions: items 파라미터 추가, searchItemsForWorkOrder 함수 추가 - StockStatusList: 품목분류(BENDING/SCREEN/STEEL/ALUMINUM) 필터 추가 - StockStatus/actions: itemCategory 파라미터 지원
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
/**
|
||
* 재고 현황 서버 액션
|
||
*
|
||
* 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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>).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<string, unknown>).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<string, unknown>;
|
||
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<string, unknown>).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<ItemApiData, StockItem>({
|
||
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<StockApiStatsByTypeResponse>({
|
||
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 };
|
||
}
|