Files
sam-react-prod/src/components/material/StockStatus/actions.ts
권혁성 f5fbe1efc8 feat(WEB): 절곡품 선생산→재고적재 Phase 2 - 수동 작업지시 및 재고 필터
- WorkOrderCreate: 수동 모드 품목 검색/추가/수량 관리 UI 구현
- WorkOrders/actions: items 파라미터 추가, searchItemsForWorkOrder 함수 추가
- StockStatusList: 품목분류(BENDING/SCREEN/STEEL/ALUMINUM) 필터 추가
- StockStatus/actions: itemCategory 파라미터 지원
2026-02-22 04:19:41 +09:00

306 lines
11 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;
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 };
}