Files
sam-react-prod/src/components/outbound/ShipmentManagement/actions.ts
권혁성 83a23701a7 feat: [shipment] 배차정보 다중 행 API 연동 — actions.ts transform 함수 수정
- ShipmentApiData에 vehicle_dispatches 타입 추가
- transformApiToDetail: vehicle_dispatches 배열 매핑 (레거시 단일필드 fallback 유지)
- transformCreateFormToApi/transformEditFormToApi: vehicleDispatches → vehicle_dispatches 변환 추가
- transformApiToListItem: 첫 번째 배차의 arrival_datetime 반영
2026-03-04 22:28:16 +09:00

521 lines
20 KiB
TypeScript

/**
* 출고 관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/shipments - 목록 조회
* - GET /api/v1/shipments/stats - 통계 조회
* - GET /api/v1/shipments/stats-by-status - 상태별 통계 조회
* - GET /api/v1/shipments/{id} - 상세 조회
* - POST /api/v1/shipments - 등록
* - PUT /api/v1/shipments/{id} - 수정
* - PATCH /api/v1/shipments/{id}/status - 상태 변경
* - DELETE /api/v1/shipments/{id} - 삭제
* - GET /api/v1/shipments/options/lots - LOT 옵션 조회
* - GET /api/v1/shipments/options/logistics - 물류사 옵션 조회
* - GET /api/v1/shipments/options/vehicle-tonnage - 차량 톤수 옵션 조회
*/
'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 {
ShipmentItem,
ShipmentDetail,
ShipmentProduct,
ShipmentStats,
ShipmentStatusStats,
ShipmentStatus,
ShipmentPriority,
DeliveryMethod,
FreightCostType,
ShipmentCreateFormData,
ShipmentEditFormData,
LotOption,
LogisticsOption,
VehicleTonnageOption,
} from './types';
// ===== API 데이터 타입 =====
// 수주 연동 정보 (Order → Shipment)
interface OrderInfoApiData {
order_id?: number;
order_no?: string;
order_status?: string;
client_id?: number;
customer_name?: string;
site_name?: string;
delivery_address?: string;
contact?: string;
delivery_date?: string;
writer_id?: number;
writer_name?: string;
}
interface ShipmentApiData {
id: number;
shipment_no: string;
lot_no?: string;
order_id?: number;
scheduled_date: string;
status: ShipmentStatus;
priority: ShipmentPriority;
delivery_method: DeliveryMethod;
client_id?: number;
customer_name?: string;
site_name?: string;
delivery_address?: string;
receiver?: string;
receiver_contact?: string;
// 수주 연동 정보 (order_info accessor)
order_info?: OrderInfoApiData;
can_ship: boolean;
deposit_confirmed: boolean;
invoice_issued: boolean;
customer_grade?: string;
loading_manager?: string;
loading_time?: string;
loading_completed_at?: string;
logistics_company?: string;
vehicle_tonnage?: string;
shipping_cost?: string | number;
vehicle_no?: string;
driver_name?: string;
driver_contact?: string;
expected_arrival?: string;
confirmed_arrival?: string;
remarks?: string;
created_by?: number;
updated_by?: number;
creator?: { id: number; name: string };
created_at?: string;
updated_at?: string;
items?: ShipmentItemApiData[];
vehicle_dispatches?: Array<{
id: number;
seq: number;
logistics_company?: string;
arrival_datetime?: string;
tonnage?: string;
vehicle_no?: string;
driver_contact?: string;
remarks?: string;
}>;
status_label?: string;
priority_label?: string;
delivery_method_label?: string;
total_quantity?: number;
item_count?: number;
}
interface ShipmentItemApiData {
id: number;
shipment_id: number;
seq: number;
item_code?: string;
item_name: string;
floor_unit?: string;
specification?: string;
quantity: string | number;
unit?: string;
lot_no?: string;
stock_lot_id?: number;
remarks?: string;
}
interface ShipmentApiStatsResponse {
today_shipment_count: number;
scheduled_count: number;
shipping_count: number;
urgent_count: number;
}
interface ShipmentApiStatsByStatusResponse {
all: number;
scheduled: number;
ready: number;
shipping: number;
completed: number;
}
// ===== API → Frontend 변환 (목록용) =====
function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
return {
id: String(data.id),
shipmentNo: data.shipment_no,
lotNo: data.lot_no || '',
scheduledDate: data.scheduled_date,
status: data.status,
priority: data.priority,
deliveryMethod: data.delivery_method,
deliveryMethodLabel: data.delivery_method_label || data.delivery_method,
// 발주처/배송 정보: order_info 우선 참조 (Order가 Single Source of Truth)
customerName: data.order_info?.customer_name || data.customer_name || '',
siteName: data.order_info?.site_name || data.site_name || '',
manager: data.loading_manager,
canShip: data.can_ship,
depositConfirmed: data.deposit_confirmed,
invoiceIssued: data.invoice_issued,
deliveryTime: data.vehicle_dispatches?.[0]?.arrival_datetime || data.expected_arrival,
// 수신/작성자/출고일 매핑
receiver: data.receiver || '',
receiverAddress: data.order_info?.delivery_address || data.delivery_address || '',
receiverCompany: data.order_info?.customer_name || data.customer_name || '',
writer: data.order_info?.writer_name || data.creator?.name || '',
shipmentDate: data.scheduled_date || '',
};
}
// ===== API → Frontend 변환 (품목용) =====
function transformApiToProduct(data: ShipmentItemApiData): ShipmentProduct {
return {
id: String(data.id),
no: data.seq,
itemCode: data.item_code || '',
itemName: data.item_name,
floorUnit: data.floor_unit || '',
specification: data.specification || '',
quantity: parseFloat(String(data.quantity)) || 0,
lotNo: data.lot_no || '',
};
}
// ===== API → Frontend 변환 (상세용) =====
function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
return {
id: String(data.id),
shipmentNo: data.shipment_no,
lotNo: data.lot_no || '',
scheduledDate: data.scheduled_date,
shipmentDate: (data as unknown as Record<string, unknown>).shipment_date as string | undefined,
status: data.status,
priority: data.priority,
deliveryMethod: data.delivery_method,
freightCost: (data as unknown as Record<string, unknown>).freight_cost as FreightCostType | undefined,
freightCostLabel: (data as unknown as Record<string, unknown>).freight_cost_label as string | undefined,
depositConfirmed: data.deposit_confirmed,
invoiceIssued: data.invoice_issued,
customerGrade: data.customer_grade || '',
canShip: data.can_ship,
loadingManager: data.loading_manager,
loadingCompleted: data.loading_completed_at,
registrant: data.creator?.name,
// 발주처/배송 정보: order_info 우선 참조 (Order가 Single Source of Truth)
customerName: data.order_info?.customer_name || data.customer_name || '',
siteName: data.order_info?.site_name || data.site_name || '',
deliveryAddress: data.order_info?.delivery_address || data.delivery_address || '',
receiver: data.receiver,
receiverContact: data.order_info?.contact || data.receiver_contact,
zipCode: (data as unknown as Record<string, unknown>).zip_code as string | undefined,
address: (data as unknown as Record<string, unknown>).address as string | undefined,
addressDetail: (data as unknown as Record<string, unknown>).address_detail as string | undefined,
// 배차 정보 - vehicle_dispatches 테이블에서 조회, 없으면 레거시 단일 필드 fallback
vehicleDispatches: data.vehicle_dispatches && data.vehicle_dispatches.length > 0
? data.vehicle_dispatches.map((vd) => ({
id: String(vd.id),
logisticsCompany: vd.logistics_company || '-',
arrivalDateTime: vd.arrival_datetime || '-',
tonnage: vd.tonnage || '-',
vehicleNo: vd.vehicle_no || '-',
driverContact: vd.driver_contact || '-',
remarks: vd.remarks || '',
}))
: (data.vehicle_no || data.logistics_company || data.driver_contact
? [{
id: `vd-legacy-${data.id}`,
logisticsCompany: data.logistics_company || '-',
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
tonnage: data.vehicle_tonnage || '-',
vehicleNo: data.vehicle_no || '-',
driverContact: data.driver_contact || '-',
remarks: '',
}]
: []),
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
productGroups: [],
otherParts: [],
products: (data.items || []).map(transformApiToProduct),
logisticsCompany: data.logistics_company,
vehicleTonnage: data.vehicle_tonnage,
shippingCost: data.shipping_cost ? parseFloat(String(data.shipping_cost)) : undefined,
vehicleNo: data.vehicle_no,
driverName: data.driver_name,
driverContact: data.driver_contact,
remarks: data.remarks,
};
}
// ===== API → Frontend 변환 (통계용) =====
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats {
return {
todayShipmentCount: data.today_shipment_count,
scheduledCount: data.scheduled_count,
shippingCount: data.shipping_count,
urgentCount: data.urgent_count,
totalCount: data.total_count || 0,
};
}
// ===== API → Frontend 변환 (상태별 통계용) =====
const STATUS_TAB_LABELS: Record<string, string> = {
all: '전체',
scheduled: '출고예정',
ready: '출고대기',
shipping: '배송중',
completed: '배송완료',
};
function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): ShipmentStatusStats {
const result: ShipmentStatusStats = {};
for (const [key, count] of Object.entries(data)) {
if (key !== 'all') { // all은 탭에서 제외 (전체 탭은 별도 처리)
result[key] = {
label: STATUS_TAB_LABELS[key] || key,
count: count as number,
};
}
}
return result;
}
// ===== Frontend → API 변환 (등록용) =====
function transformCreateFormToApi(
data: ShipmentCreateFormData
): Record<string, unknown> {
const result: Record<string, unknown> = {
lot_no: data.lotNo,
scheduled_date: data.scheduledDate,
priority: data.priority,
delivery_method: data.deliveryMethod,
logistics_company: data.logisticsCompany,
vehicle_tonnage: data.vehicleTonnage,
loading_time: data.loadingTime,
loading_manager: data.loadingManager,
remarks: data.remarks,
};
if (data.vehicleDispatches && data.vehicleDispatches.length > 0) {
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
seq: idx + 1,
logistics_company: vd.logisticsCompany || null,
arrival_datetime: vd.arrivalDateTime || null,
tonnage: vd.tonnage || null,
vehicle_no: vd.vehicleNo || null,
driver_contact: vd.driverContact || null,
remarks: vd.remarks || null,
}));
}
return result;
}
// ===== Frontend → API 변환 (수정용) =====
function transformEditFormToApi(
data: Partial<ShipmentEditFormData>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
if (data.priority !== undefined) result.priority = data.priority;
if (data.deliveryMethod !== undefined) result.delivery_method = data.deliveryMethod;
if (data.receiver !== undefined) result.receiver = data.receiver;
if (data.receiverContact !== undefined) result.receiver_contact = data.receiverContact;
// 주소: zipCode + address + addressDetail → delivery_address로 결합
if (data.address !== undefined || data.zipCode !== undefined || data.addressDetail !== undefined) {
const parts = [
data.zipCode ? `[${data.zipCode}]` : '',
data.address || '',
data.addressDetail || '',
].filter(Boolean);
result.delivery_address = parts.join(' ');
}
if (data.loadingManager !== undefined) result.loading_manager = data.loadingManager;
if (data.logisticsCompany !== undefined) result.logistics_company = data.logisticsCompany;
if (data.vehicleTonnage !== undefined) result.vehicle_tonnage = data.vehicleTonnage;
if (data.vehicleNo !== undefined) result.vehicle_no = data.vehicleNo;
if (data.shippingCost !== undefined) result.shipping_cost = data.shippingCost;
if (data.driverName !== undefined) result.driver_name = data.driverName;
if (data.driverContact !== undefined) result.driver_contact = data.driverContact;
if (data.expectedArrival !== undefined) result.expected_arrival = data.expectedArrival;
if (data.confirmedArrival !== undefined) result.confirmed_arrival = data.confirmedArrival;
if (data.changeReason !== undefined) result.change_reason = data.changeReason;
if (data.remarks !== undefined) result.remarks = data.remarks;
if (data.vehicleDispatches) {
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
seq: idx + 1,
logistics_company: vd.logisticsCompany || null,
arrival_datetime: vd.arrivalDateTime || null,
tonnage: vd.tonnage || null,
vehicle_no: vd.vehicleNo || null,
driver_contact: vd.driverContact || null,
remarks: vd.remarks || null,
}));
}
return result;
}
// ===== 출고 목록 조회 =====
export async function getShipments(params?: {
page?: number; perPage?: number; search?: string; status?: string;
priority?: string; deliveryMethod?: string; scheduledFrom?: string; scheduledTo?: string;
canShip?: boolean; depositConfirmed?: boolean; sortBy?: string; sortDir?: string;
}) {
return executePaginatedAction<ShipmentApiData, ShipmentItem>({
url: buildApiUrl('/api/v1/shipments', {
page: params?.page,
per_page: params?.perPage,
search: params?.search,
status: params?.status !== 'all' ? params?.status : undefined,
priority: params?.priority !== 'all' ? params?.priority : undefined,
delivery_method: params?.deliveryMethod !== 'all' ? params?.deliveryMethod : undefined,
scheduled_from: params?.scheduledFrom,
scheduled_to: params?.scheduledTo,
can_ship: params?.canShip,
deposit_confirmed: params?.depositConfirmed,
sort_by: params?.sortBy,
sort_dir: params?.sortDir,
}),
transform: transformApiToListItem,
errorMessage: '출고 목록 조회에 실패했습니다.',
});
}
// ===== 출고 통계 조회 =====
export async function getShipmentStats(): Promise<{ success: boolean; data?: ShipmentStats; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/shipments/stats'),
transform: (data: ShipmentApiStatsResponse & { total_count?: number }) => transformApiToStats(data),
errorMessage: '출고 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 상태별 통계 조회 (탭용) =====
export async function getShipmentStatsByStatus(): Promise<{ success: boolean; data?: ShipmentStatusStats; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/shipments/stats-by-status'),
transform: (data: ShipmentApiStatsByStatusResponse) => transformApiToStatsByStatus(data),
errorMessage: '상태별 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 상세 조회 =====
export async function getShipmentById(id: string): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/shipments/${id}`),
transform: (data: ShipmentApiData) => transformApiToDetail(data),
errorMessage: '출고 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 등록 =====
export async function createShipment(
data: ShipmentCreateFormData
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
const apiData = transformCreateFormToApi(data);
const result = await executeServerAction({
url: buildApiUrl('/api/v1/shipments'),
method: 'POST',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '출고 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 수정 =====
export async function updateShipment(
id: string, data: Partial<ShipmentEditFormData>
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
const apiData = transformEditFormToApi(data);
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/shipments/${id}`),
method: 'PUT',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '출고 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 상태 변경 =====
export async function updateShipmentStatus(
id: string, status: ShipmentStatus,
additionalData?: {
loadingTime?: string; loadingCompletedAt?: string; vehicleNo?: string;
driverName?: string; driverContact?: string; confirmedArrival?: string;
}
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
const apiData: Record<string, unknown> = { status };
if (additionalData?.loadingTime) apiData.loading_time = additionalData.loadingTime;
if (additionalData?.loadingCompletedAt) apiData.loading_completed_at = additionalData.loadingCompletedAt;
if (additionalData?.vehicleNo) apiData.vehicle_no = additionalData.vehicleNo;
if (additionalData?.driverName) apiData.driver_name = additionalData.driverName;
if (additionalData?.driverContact) apiData.driver_contact = additionalData.driverContact;
if (additionalData?.confirmedArrival) apiData.confirmed_arrival = additionalData.confirmedArrival;
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/shipments/${id}/status`),
method: 'PATCH',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '상태 변경에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 삭제 =====
export async function deleteShipment(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/shipments/${id}`),
method: 'DELETE',
errorMessage: '출고 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
// ===== LOT 옵션 조회 =====
export async function getLotOptions(): Promise<{ success: boolean; data: LotOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<LotOption[]>({
url: buildApiUrl('/api/v1/shipments/options/lots'),
errorMessage: 'LOT 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 물류사 옵션 조회 =====
export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<LogisticsOption[]>({
url: buildApiUrl('/api/v1/shipments/options/logistics'),
errorMessage: '물류사 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 차량 톤수 옵션 조회 =====
export async function getVehicleTonnageOptions(): Promise<{ success: boolean; data: VehicleTonnageOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<VehicleTonnageOption[]>({
url: buildApiUrl('/api/v1/shipments/options/vehicle-tonnage'),
errorMessage: '차량 톤수 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}