생산관리: - 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>
532 lines
15 KiB
TypeScript
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: '서버 오류가 발생했습니다.' };
|
|
}
|
|
}
|