/** * 출고 관리 서버 액션 * * 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).shipment_date as string | undefined, status: data.status, priority: data.priority, deliveryMethod: data.delivery_method, freightCost: (data as unknown as Record).freight_cost as FreightCostType | undefined, freightCostLabel: (data as unknown as Record).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).zip_code as string | undefined, address: (data as unknown as Record).address as string | undefined, addressDetail: (data as unknown as Record).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 = { 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 { const result: Record = { 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 ): Record { const result: Record = {}; 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({ 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 ): 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 = { 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({ 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({ 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({ 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 }; }