407 lines
14 KiB
TypeScript
407 lines
14 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;
|
||
max_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,
|
||
maxStock: hasStock ? (parseFloat(String(stock.max_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,
|
||
maxStock: hasStock ? (parseFloat(String(stock.max_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; maxStock: 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,
|
||
max_stock: data.maxStock,
|
||
is_active: data.useStatus === 'active',
|
||
},
|
||
errorMessage: '재고 수정에 실패했습니다.',
|
||
});
|
||
if (result.__authError) return { success: false, __authError: true };
|
||
return { success: result.success, error: result.error };
|
||
}
|
||
|
||
// ===== 재고 조정 이력 조회 =====
|
||
export interface StockAdjustmentRecord {
|
||
id: number;
|
||
adjusted_at: string;
|
||
quantity: number;
|
||
balance_qty: number;
|
||
remark: string | null;
|
||
inspector: string;
|
||
}
|
||
|
||
export async function getStockAdjustments(stockId: string): Promise<{ success: boolean; data?: StockAdjustmentRecord[]; error?: string; __authError?: boolean }> {
|
||
const result = await executeServerAction<{ data: StockAdjustmentRecord[] }, StockAdjustmentRecord[]>({
|
||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||
transform: (d) => d.data || [],
|
||
errorMessage: '재고 조정 이력 조회에 실패했습니다.',
|
||
});
|
||
if (result.__authError) return { success: false, __authError: true };
|
||
return { success: result.success, data: result.data, error: result.error };
|
||
}
|
||
|
||
// ===== 재고 조정 등록 =====
|
||
export async function createStockAdjustment(
|
||
stockId: string,
|
||
data: { quantity: number; remark?: string }
|
||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||
const result = await executeServerAction({
|
||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||
method: 'POST',
|
||
body: data,
|
||
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 };
|
||
}
|
||
|
||
// ===== 사용현황(거래이력) 조회 =====
|
||
export interface StockTransaction {
|
||
id: number;
|
||
type: string;
|
||
typeLabel: string;
|
||
qty: number;
|
||
balanceQty: number;
|
||
referenceType: string;
|
||
referenceId: number;
|
||
referenceNo: string | null;
|
||
lotNo: string;
|
||
reason: string | null;
|
||
remark: string | null;
|
||
itemCode: string;
|
||
itemName: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface StockUsageData {
|
||
itemCode: string;
|
||
itemName: string;
|
||
currentQty: number;
|
||
availableQty: number;
|
||
transactions: StockTransaction[];
|
||
}
|
||
|
||
export async function getStockTransactions(
|
||
itemId: string
|
||
): Promise<{ success: boolean; data?: StockUsageData; error?: string }> {
|
||
const result = await executeServerAction<{
|
||
item_code: string; item_name: string; current_qty: number; available_qty: number;
|
||
transactions: Array<{
|
||
id: number; type: string; type_label: string; qty: number; balance_qty: number;
|
||
reference_type: string; reference_id: number; reference_no: string | null;
|
||
lot_no: string; reason: string | null; remark: string | null;
|
||
item_code: string; item_name: string; created_at: string;
|
||
}>;
|
||
}>({
|
||
url: buildApiUrl(`/api/v1/stocks/${itemId}/transactions`),
|
||
errorMessage: '사용현황 조회에 실패했습니다.',
|
||
});
|
||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||
return {
|
||
success: true,
|
||
data: {
|
||
itemCode: result.data.item_code,
|
||
itemName: result.data.item_name,
|
||
currentQty: result.data.current_qty,
|
||
availableQty: result.data.available_qty,
|
||
transactions: result.data.transactions.map((t: { id: number; type: string; type_label: string; qty: number; balance_qty: number; reference_type: string; reference_id: number; reference_no: string | null; lot_no: string; reason: string | null; remark: string | null; item_code: string; item_name: string; created_at: string }) => ({
|
||
id: t.id, type: t.type, typeLabel: t.type_label, qty: t.qty, balanceQty: t.balance_qty,
|
||
referenceType: t.reference_type, referenceId: t.reference_id, referenceNo: t.reference_no,
|
||
lotNo: t.lot_no, reason: t.reason, remark: t.remark,
|
||
itemCode: t.item_code, itemName: t.item_name, createdAt: t.created_at,
|
||
})),
|
||
},
|
||
};
|
||
}
|