Files
sam-react-prod/src/components/material/StockStatus/actions.ts
유병철 efcc645e24 feat(WEB): 생산/검사 기능 대폭 확장 및 작업자화면 검사입력 추가
생산관리:
- WipProductionModal 기능 개선
- WorkOrderDetail/Edit 확장 (+265줄)
- 검사성적서 콘텐츠 5종 대폭 확장 (벤딩/벤딩WIP/스크린/슬랫/슬랫조인트바)
- InspectionReportModal 기능 강화

작업자화면:
- WorkerScreen 기능 대폭 확장 (+211줄)
- WorkItemCard 개선
- InspectionInputModal 신규 추가 (작업자 검사입력)

공정관리:
- StepForm 검사항목 설정 기능 추가
- InspectionSettingModal 신규 추가
- InspectionPreviewModal 신규 추가
- process.ts 타입 확장 (+102줄)

자재관리:
- StockStatus 상세/목록/타입/목데이터 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:43:28 +09:00

532 lines
15 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 { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
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 ItemApiPaginatedResponse {
data: ItemApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
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;
// description 또는 attributes에서 규격 정보 추출
let specification = '';
if (data.description) {
specification = data.description;
} else if (data.attributes && typeof data.attributes === 'object') {
const attrs = data.attributes as Record<string, unknown>;
if (attrs.specification) {
specification = String(attrs.specification);
}
}
return {
id: String(data.id),
stockNumber: hasStock ? String((stock as unknown as Record<string, unknown>).stock_number ?? stock.id ?? data.id) : String(data.id),
itemCode: data.code,
itemName: data.name,
itemType: data.item_type,
specification,
unit: data.unit || 'EA',
calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).calculated_qty ?? stock.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? 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;
// description 또는 attributes에서 규격 정보 추출
let specification = '-';
if (data.description) {
specification = data.description;
} else if (data.attributes && typeof data.attributes === 'object') {
// attributes에서 규격 관련 정보 추출 시도
const attrs = data.attributes as Record<string, unknown>;
if (attrs.specification) {
specification = String(attrs.specification);
}
}
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,
};
}
// ===== 페이지네이션 타입 =====
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
// ===== 재고 목록 조회 =====
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;
}): Promise<{
success: boolean;
data: StockItem[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.perPage) searchParams.set('per_page', String(params.perPage));
if (params?.search) searchParams.set('search', params.search);
if (params?.itemType && params.itemType !== 'all') {
searchParams.set('item_type', params.itemType);
}
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.useStatus && params.useStatus !== 'all') {
searchParams.set('is_active', params.useStatus === 'active' ? '1' : '0');
}
if (params?.location) searchParams.set('location', params.location);
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '재고 목록 조회에 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: result.message || '재고 목록 조회에 실패했습니다.',
};
}
const paginatedData: ItemApiPaginatedResponse = result.data || {
data: [],
current_page: 1,
last_page: 1,
per_page: 20,
total: 0,
};
const stocks = (paginatedData.data || []).map(transformApiToListItem);
return {
success: true,
data: stocks,
pagination: {
currentPage: paginatedData.current_page,
lastPage: paginatedData.last_page,
perPage: paginatedData.per_page,
total: paginatedData.total,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] getStocks error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 재고 통계 조회 =====
export async function getStockStats(): Promise<{
success: boolean;
data?: StockStats;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats`,
{ method: 'GET', cache: 'no-store' }
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 통계 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '재고 통계 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToStats(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] getStockStats error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 품목유형별 통계 조회 =====
export async function getStockStatsByType(): Promise<{
success: boolean;
data?: StockApiStatsByTypeResponse;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats-by-type`,
{ method: 'GET', cache: 'no-store' }
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '품목유형별 통계 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '품목유형별 통계 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] getStockStatsByType error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 재고 상세 조회 (Item 기준, LOT 포함) =====
export async function getStockById(id: string): Promise<{
success: boolean;
data?: StockDetail;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`,
{ method: 'GET', cache: 'no-store' }
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '재고 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] getStockById error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 재고 단건 수정 =====
export async function updateStock(
id: string,
data: {
safetyStock: number;
useStatus: 'active' | 'inactive';
}
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
safety_stock: data.safetyStock,
is_active: data.useStatus === 'active',
}),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '재고 수정에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] updateStock error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 재고 실사 (일괄 업데이트) =====
export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/audit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: updates.map((u) => ({
item_id: u.id,
actual_qty: u.actualQty,
})),
}),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '재고 실사 저장에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '재고 실사 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[StockActions] updateStockAudit error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}