Files
sam-react-prod/src/components/stocks/actions.ts
김보곤 f4f2f4b9af fix: [stocks] Server Action 직렬화 오류 수정 (undefined → null)
- Next.js Server Actions는 undefined 직렬화 불가
- bendingLot: undefined → null
- rawLotNo/fabricLotNo/material: undefined → null (via ??)
- itemId: undefined → null
2026-03-18 22:31:08 +09:00

841 lines
24 KiB
TypeScript

'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { formatDate } from '@/lib/utils/date';
// ============================================================================
// API 타입 정의
// ============================================================================
interface ApiStockOrder {
id: number;
tenant_id: number;
order_no: string;
order_type_code: string;
status_code: string;
site_name: string | null;
quantity: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
memo: string | null;
remarks: string | null;
options: {
production_reason?: string;
target_stock_qty?: number;
manager_name?: string;
bending_lot?: {
lot_number?: string;
prod_code?: string;
spec_code?: string;
length_code?: string;
raw_lot_no?: string;
fabric_lot_no?: string;
material?: string;
};
} | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
items?: ApiStockOrderItem[];
}
interface ApiStockOrderItem {
id: number;
order_id: number;
item_id: number | null;
item_code: string | null;
item_name: string;
specification: string | null;
quantity: number;
unit: string | null;
unit_price: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
sort_order: number;
}
interface ApiStockOrderStats {
total: number;
draft: number;
confirmed: number;
in_progress: number;
completed: number;
cancelled: number;
total_amount: number;
confirmed_amount: number;
}
// ============================================================================
// Frontend 타입 정의
// ============================================================================
export type StockStatus =
| 'draft'
| 'confirmed'
| 'in_progress'
| 'in_production'
| 'produced'
| 'completed'
| 'cancelled';
export interface StockOrder {
id: string;
orderNo: string;
statusCode: string;
status: StockStatus;
siteName: string;
quantity: number;
memo: string;
remarks: string;
productionReason: string;
targetStockQty: number;
manager: string;
itemCount: number;
itemSummary: string;
createdAt: string;
items: StockOrderItem[];
bendingLot?: {
lotNumber: string;
prodCode: string;
specCode: string;
lengthCode: string;
rawLotNo?: string | null;
fabricLotNo?: string | null;
material?: string | null;
} | null;
}
export interface StockOrderItem {
id: string;
itemId?: number | null;
itemCode: string;
itemName: string;
specification: string;
quantity: number;
unit: string;
unitPrice: number;
supplyAmount: number;
taxAmount: number;
totalAmount: number;
sortOrder: number;
}
export interface StockOrderFormData {
orderTypeCode: string;
memo: string;
remarks: string;
productionReason: string;
targetStockQty: number;
items: StockOrderItemFormData[];
}
export interface StockOrderItemFormData {
itemId?: number;
itemCode?: string;
itemName: string;
specification?: string;
quantity: number;
unit?: string;
unitPrice: number;
}
export interface StockOrderStats {
total: number;
draft: number;
confirmed: number;
inProgress: number;
completed: number;
cancelled: number;
}
// ============================================================================
// 상태 매핑
// ============================================================================
const API_TO_FRONTEND_STATUS: Record<string, StockStatus> = {
'DRAFT': 'draft',
'CONFIRMED': 'confirmed',
'IN_PROGRESS': 'in_progress',
'IN_PRODUCTION': 'in_production',
'PRODUCED': 'produced',
'COMPLETED': 'completed',
'CANCELLED': 'cancelled',
};
const FRONTEND_TO_API_STATUS: Record<StockStatus, string> = {
'draft': 'DRAFT',
'confirmed': 'CONFIRMED',
'in_progress': 'IN_PROGRESS',
'in_production': 'IN_PRODUCTION',
'produced': 'PRODUCED',
'completed': 'COMPLETED',
'cancelled': 'CANCELLED',
};
// ============================================================================
// 데이터 변환 함수
// ============================================================================
function transformApiToFrontend(apiData: ApiStockOrder): StockOrder {
const items = apiData.items?.map(transformItemApiToFrontend) || [];
const firstItemName = items[0]?.itemName || '';
const extraCount = items.length > 1 ? `${items.length - 1}` : '';
const bendingLotData = apiData.options?.bending_lot;
return {
id: String(apiData.id),
orderNo: apiData.order_no,
statusCode: apiData.status_code,
status: API_TO_FRONTEND_STATUS[apiData.status_code] || 'draft',
siteName: apiData.site_name || '재고생산',
quantity: Math.floor(Number(apiData.quantity) || 0),
memo: apiData.memo || '',
remarks: apiData.remarks || '',
productionReason: apiData.options?.production_reason || '',
targetStockQty: apiData.options?.target_stock_qty || 0,
manager: apiData.options?.manager_name || '',
itemCount: items.length,
itemSummary: firstItemName ? `${firstItemName}${extraCount}` : '-',
createdAt: formatDate(apiData.created_at),
items,
bendingLot: bendingLotData ? {
lotNumber: bendingLotData.lot_number || '',
prodCode: bendingLotData.prod_code || '',
specCode: bendingLotData.spec_code || '',
lengthCode: bendingLotData.length_code || '',
rawLotNo: bendingLotData.raw_lot_no ?? null,
fabricLotNo: bendingLotData.fabric_lot_no ?? null,
material: bendingLotData.material ?? null,
} : null,
};
}
function transformItemApiToFrontend(apiItem: ApiStockOrderItem): StockOrderItem {
return {
id: String(apiItem.id),
itemId: apiItem.item_id ?? null,
itemCode: apiItem.item_code || '',
itemName: apiItem.item_name,
specification: apiItem.specification || '',
quantity: Math.floor(Number(apiItem.quantity) || 0),
unit: apiItem.unit || 'EA',
unitPrice: Number(apiItem.unit_price) || 0,
supplyAmount: Number(apiItem.supply_amount) || 0,
taxAmount: Number(apiItem.tax_amount) || 0,
totalAmount: Number(apiItem.total_amount) || 0,
sortOrder: apiItem.sort_order,
};
}
function transformFrontendToApi(data: StockOrderFormData): Record<string, unknown> {
return {
order_type_code: 'STOCK',
memo: data.memo || null,
remarks: data.remarks || null,
options: {
production_reason: data.productionReason || null,
target_stock_qty: data.targetStockQty || null,
},
// STOCK 전용: 불필요 필드 명시적 null
client_id: null,
client_name: null,
site_name: null, // API 자동 설정 '재고생산'
delivery_date: null,
delivery_method_code: null,
discount_rate: 0,
discount_amount: 0,
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: data.items.map((item) => {
const quantity = Number(item.quantity) || 0;
const unitPrice = Number(item.unitPrice) || 0;
const supplyAmount = quantity * unitPrice;
const taxAmount = Math.round(supplyAmount * 0.1);
return {
item_id: item.itemId || null,
item_code: item.itemCode || null,
item_name: item.itemName,
specification: item.specification || null,
quantity,
unit: item.unit || 'EA',
unit_price: unitPrice,
supply_amount: supplyAmount,
tax_amount: taxAmount,
total_amount: supplyAmount + taxAmount,
};
}),
};
}
// ============================================================================
// API 함수
// ============================================================================
/**
* 재고생산 목록 조회
*/
export async function getStockOrders(params?: {
page?: number;
size?: number;
q?: string;
status?: string;
date_from?: string;
date_to?: string;
}): Promise<{
success: boolean;
data?: { items: StockOrder[]; total: number; page: number; totalPages: number };
error?: string;
__authError?: boolean;
}> {
const apiStatus = params?.status ? FRONTEND_TO_API_STATUS[params.status as StockStatus] : undefined;
const result = await executeServerAction<PaginatedApiResponse<ApiStockOrder>>({
url: buildApiUrl('/api/v1/orders', {
order_type: 'STOCK',
page: params?.page,
size: params?.size,
q: params?.q,
status: apiStatus,
date_from: params?.date_from,
date_to: params?.date_to,
}),
errorMessage: '재고생산 목록 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
items: result.data.data.map(transformApiToFrontend),
total: result.data.total,
page: result.data.current_page,
totalPages: result.data.last_page,
},
};
}
/**
* 재고생산 상세 조회
*/
export async function getStockOrderById(id: string): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/orders/${id}`),
transform: (data: ApiStockOrder) => transformApiToFrontend(data),
errorMessage: '재고생산 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 재고생산 생성
*/
export async function createStockOrder(data: StockOrderFormData): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: buildApiUrl('/api/v1/orders'),
method: 'POST',
body: apiData,
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
errorMessage: '재고생산 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 재고생산 수정
*/
export async function updateStockOrder(id: string, data: StockOrderFormData): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/orders/${id}`),
method: 'PUT',
body: apiData,
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
errorMessage: '재고생산 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 재고생산 삭제
*/
export async function deleteStockOrder(id: string): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/orders/${id}`),
method: 'DELETE',
errorMessage: '재고생산 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
/**
* 재고생산 일괄 삭제
*/
export async function deleteStockOrders(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
skippedCount?: number;
skippedIds?: number[];
error?: string;
__authError?: boolean;
}> {
interface BulkDeleteResponse {
deleted_count: number;
skipped_count: number;
skipped_ids: number[];
}
const result = await executeServerAction<BulkDeleteResponse>({
url: buildApiUrl('/api/v1/orders/bulk'),
method: 'DELETE',
body: { ids: ids.map(Number) },
errorMessage: '재고생산 일괄 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
deletedCount: result.data.deleted_count,
skippedCount: result.data.skipped_count,
skippedIds: result.data.skipped_ids,
};
}
/**
* 재고생산 상태 변경
*/
export async function updateStockOrderStatus(id: string, status: StockStatus): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiStatus = FRONTEND_TO_API_STATUS[status];
if (!apiStatus) {
return { success: false, error: '유효하지 않은 상태입니다.' };
}
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/orders/${id}/status`),
method: 'PATCH',
body: { status: apiStatus },
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
errorMessage: '상태 변경에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 재고생산 통계 조회
*/
export async function getStockOrderStats(): Promise<{
success: boolean;
data?: StockOrderStats;
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction<ApiStockOrderStats>({
url: buildApiUrl('/api/v1/orders/stats', { order_type: 'STOCK' }),
errorMessage: '재고생산 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
total: result.data.total,
draft: result.data.draft,
confirmed: result.data.confirmed,
inProgress: result.data.in_progress,
completed: result.data.completed,
cancelled: result.data.cancelled,
},
};
}
/**
* 재고생산 → 생산지시 생성
*/
export async function createStockProductionOrder(
orderId: string,
data?: { priority?: string; memo?: string }
): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiData: Record<string, unknown> = {};
if (data?.priority) apiData.priority = data.priority;
if (data?.memo) apiData.memo = data.memo;
const result = await executeServerAction<{ order: ApiStockOrder }>({
url: buildApiUrl(`/api/v1/orders/${orderId}/production-order`),
method: 'POST',
body: apiData,
errorMessage: '생산지시 생성에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, data: transformApiToFrontend(result.data.order) };
}
// ============================================================================
// 절곡품 LOT API 타입 정의
// ============================================================================
export interface BendingProduct {
code: string;
name: string;
}
export interface BendingSpec {
code: string;
name: string;
products: string[];
}
export interface BendingLength {
code: string;
name: string;
}
export interface BendingCodeMap {
products: BendingProduct[];
specs: BendingSpec[];
lengths: {
smoke_barrier: BendingLength[];
general: BendingLength[];
};
material_map: Record<string, string>;
}
export interface BendingResolvedItem {
item_id: number;
item_code: string;
item_name: string;
specification: string;
unit: string;
expected_code?: string;
}
export interface BendingLotResult {
lot_base: string;
lot_number: string;
date_code: string;
material: string;
}
export interface BendingLotFormData {
lot_number: string;
prod_code: string;
spec_code: string;
length_code: string;
raw_lot_no?: string;
fabric_lot_no?: string;
material?: string;
}
export interface MaterialLot {
id: number;
lot_no: string;
supplier_lot: string;
item_name: string;
specification: string;
receiving_qty: string;
receiving_date: string;
supplier: string;
options?: {
inspection_status?: string;
inspection_result?: string;
};
}
// ============================================================================
// 절곡품 LOT API 함수
// ============================================================================
/**
* 절곡품 코드맵 조회 (캐스케이딩 드롭다운 옵션)
*/
export async function getBendingCodeMap(): Promise<{
success: boolean;
data?: BendingCodeMap;
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction<BendingCodeMap>({
url: buildApiUrl('/api/v1/bending/code-map'),
errorMessage: '절곡품 코드맵 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 절곡품 품목 매핑 조회 (드롭다운 3개 선택 후)
*/
export async function resolveBendingItem(
prod: string,
spec: string,
length: string
): Promise<{
success: boolean;
data?: BendingResolvedItem;
error?: string;
__authError?: boolean;
}> {
const { serverFetch } = await import('@/lib/api/fetch-wrapper');
const { safeResponseJson } = await import('@/lib/api/safe-json-parse');
const url = buildApiUrl('/api/v1/bending/resolve-item', { prod, spec, length });
const { response, error: fetchError } = await serverFetch(url, { method: 'GET', cache: 'no-store' });
if (fetchError) {
return { success: false, error: fetchError.message, __authError: fetchError.__authError };
}
if (!response) {
return { success: false, error: '품목 매핑 조회에 실패했습니다.' };
}
const raw = await safeResponseJson(response) as Record<string, unknown>;
if (!response.ok || !raw.success) {
// NOT_MAPPED: error.expected_code 추출
const errorObj = raw.error as Record<string, unknown> | undefined;
const expectedCode = (errorObj?.expected_code as string) || undefined;
return {
success: false,
data: expectedCode ? { expected_code: expectedCode } as BendingResolvedItem : undefined,
error: (raw.message as string) || '해당 조합에 매핑된 품목이 없습니다.',
};
}
// 성공: data에 expected_code 포함
const data = raw.data as BendingResolvedItem;
return { success: true, data };
}
/**
* 절곡품 LOT 번호 생성
*/
export async function generateBendingLot(
prodCode: string,
specCode: string,
lengthCode: string,
regDate?: string
): Promise<{
success: boolean;
data?: BendingLotResult;
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction<BendingLotResult>({
url: buildApiUrl('/api/v1/bending/generate-lot'),
method: 'POST',
body: {
prod_code: prodCode,
spec_code: specCode,
length_code: lengthCode,
reg_date: regDate,
},
errorMessage: 'LOT 번호 생성에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 절곡품 재고생산 저장 (기존 orders API + bending_lot 확장)
*/
export async function createBendingStockOrder(params: {
memo?: string;
targetStockQty: number;
bendingLot: BendingLotFormData;
item: {
itemId?: number;
itemCode?: string;
itemName: string;
specification?: string;
quantity: number;
unit?: string;
};
}): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiData = {
order_type_code: 'STOCK',
memo: params.memo || null,
remarks: null,
options: {
production_reason: '절곡품 재고생산',
target_stock_qty: params.targetStockQty || null,
bending_lot: {
lot_number: params.bendingLot.lot_number,
prod_code: params.bendingLot.prod_code,
spec_code: params.bendingLot.spec_code,
length_code: params.bendingLot.length_code,
raw_lot_no: params.bendingLot.raw_lot_no || null,
fabric_lot_no: params.bendingLot.fabric_lot_no || null,
material: params.bendingLot.material || null,
},
},
client_id: null,
client_name: null,
site_name: null,
delivery_date: null,
delivery_method_code: null,
discount_rate: 0,
discount_amount: 0,
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [
{
item_id: params.item.itemId || null,
item_code: params.item.itemCode || null,
item_name: params.item.itemName,
specification: params.item.specification || null,
quantity: params.item.quantity,
unit: params.item.unit || 'EA',
unit_price: 0,
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
},
],
};
const result = await executeServerAction({
url: buildApiUrl('/api/v1/orders'),
method: 'POST',
body: apiData,
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
errorMessage: '절곡품 재고생산 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 절곡품 재고생산 수정 (기존 orders API + bending_lot 확장)
*/
export async function updateBendingStockOrder(id: string, params: {
memo?: string;
targetStockQty: number;
bendingLot: BendingLotFormData;
item: {
itemId?: number;
itemCode?: string;
itemName: string;
specification?: string;
quantity: number;
unit?: string;
};
}): Promise<{
success: boolean;
data?: StockOrder;
error?: string;
__authError?: boolean;
}> {
const apiData = {
order_type_code: 'STOCK',
memo: params.memo || null,
remarks: null,
options: {
production_reason: '절곡품 재고생산',
target_stock_qty: params.targetStockQty || null,
bending_lot: {
lot_number: params.bendingLot.lot_number,
prod_code: params.bendingLot.prod_code,
spec_code: params.bendingLot.spec_code,
length_code: params.bendingLot.length_code,
raw_lot_no: params.bendingLot.raw_lot_no || null,
fabric_lot_no: params.bendingLot.fabric_lot_no || null,
material: params.bendingLot.material || null,
},
},
client_id: null,
client_name: null,
site_name: null,
delivery_date: null,
delivery_method_code: null,
discount_rate: 0,
discount_amount: 0,
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [
{
item_id: params.item.itemId || null,
item_code: params.item.itemCode || null,
item_name: params.item.itemName,
specification: params.item.specification || null,
quantity: params.item.quantity,
unit: params.item.unit || 'EA',
unit_price: 0,
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
},
],
};
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/orders/${id}`),
method: 'PUT',
body: apiData,
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
errorMessage: '절곡품 재고생산 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 원자재 LOT 목록 조회 (수입검사 완료 입고 건)
*/
export async function getMaterialLots(material: string): Promise<{
success: boolean;
data?: MaterialLot[];
error?: string;
__authError?: boolean;
}> {
const result = await executeServerAction<MaterialLot[]>({
url: buildApiUrl('/api/v1/bending/material-lots', { material }),
errorMessage: '원자재 LOT 목록 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}