Files
sam-react-prod/src/components/material/StockStatus/actions.ts
유병철 cbb38d48b9 refactor(WEB): 전체 actions.ts에 공통 API 유틸 적용
- buildApiUrl / executePaginatedAction 패턴으로 전환 (40+ actions 파일)
- 직접 URLSearchParams 조립 → buildApiUrl 유틸 사용
- 수동 페이지네이션 메타 변환 → executePaginatedAction 자동 처리
- HandoverReportDocumentModal, OrderDocumentModal 개선
- 급여관리 SalaryManagement 코드 개선
- CLAUDE.md Server Action 공통 유틸 규칙 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:59:59 +09:00

304 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;
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,
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 };
}