Files
sam-react-prod/src/components/material/StockStatus/actions.ts
2026-03-22 11:49:53 +09:00

407 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 재고 현황 서버 액션
*
* 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,
})),
},
};
}