2026-08-19 16:45:51 +08:00
< script setup lang = "ts" >
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-10-15 15:45:16 +08:00
import { computed , onMounted , reactive , ref , watch } from 'vue'
2026-08-26 11:10:22 +08:00
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
2026-08-19 16:45:51 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-08-26 11:10:22 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-10-15 15:45:16 +08:00
import { getPlatformWarehouse } from '@/modules/warehouses'
2026-09-09 16:37:36 +08:00
import { listSuppliers , type Supplier } from '@/modules/suppliers'
2026-08-20 09:10:36 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-26 11:10:22 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-19 16:45:51 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-20 09:10:36 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-26 11:10:22 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee , getLastMileOrder , type LastMileOrderDetailDto } from '../api/lastMileOrders'
2026-10-05 07:28:36 +08:00
import { relabelApplicationStatusLabel , relabelBusinessStatusLabel , formatOverseasDateTime , lastMileOrderReference , lastMileOrderTypeLabel , lastMileStatusLabel , packageDifferenceText } from '../lastMilePresentation'
2026-08-26 11:10:22 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-19 16:45:51 +08:00
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
2026-10-09 16:11:59 +08:00
import { businessOrderLink } from '../businessOrderLink'
2026-08-27 15:56:13 +08:00
import OrderReferenceText from '../components/OrderReferenceText.vue'
2026-10-04 21:19:11 +08:00
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
2026-10-16 16:52:43 +08:00
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
2026-10-15 15:45:16 +08:00
import WarehouseTransferReceiptPanel from '../components/WarehouseTransferReceiptPanel.vue'
2026-08-19 16:45:51 +08:00
2026-09-14 20:21:02 +08:00
interface DetailField { label : string ; value : string | number ; wide ?: boolean }
2026-08-20 09:10:36 +08:00
interface CargoRow { id : string ; sku : string ; productName : string ; pallets : number ; cartons : number ; quantity : number ; weight : number ; volume : number }
2026-09-22 08:47:43 +08:00
interface DocumentRow { status ?: string ; quantity ?: number ; type : string ; number : string ; createdAt : string ; quote : string }
2026-08-26 11:10:22 +08:00
interface FeeRow { id : string ; item : string ; currency : string ; amount : string ; status : string ; description : string }
interface LogRow { id : string ; occurredAt : string ; node : string ; operator : string ; result : string ; sortAt : string }
2026-08-20 09:10:36 +08:00
2026-08-19 16:45:51 +08:00
const route = useRoute ()
const router = useRouter ()
2026-08-26 11:10:22 +08:00
const access = useAccessPreviewStore ()
2026-08-20 09:10:36 +08:00
const orderId = computed (() => String ( route . params . orderId || '' ))
const detail = ref < LastMileOrderDetailDto | null >( null )
2026-10-16 16:52:43 +08:00
const receiptSummary = ref < { boxes : number ; forecastBoxes : number ; forecastSkus : number ; forecastQuantity : number | null ; skus : number ; quantity : number ; pallets : number ; weight : number ; volume : number ; warehouseId ?: string | null } | null > ( null )
2026-10-15 15:45:16 +08:00
const resolvedWarehouseName = ref ( '' )
const warehouseLookupFailed = ref ( false )
const warehouseDisplayName = computed (() => detail . value ? . order . overseasWarehouseName || resolvedWarehouseName . value || detail . value ? . order . overseasWarehouseCode || ( warehouseLookupFailed . value ? '仓库名称加载失败' : '—' ))
watch (() => [ detail . value ? . order . overseasWarehouseId || receiptSummary . value ? . warehouseId , detail . value ? . order . overseasWarehouseName ] as const , async ([ warehouseId , warehouseName ], _ , onCleanup ) => {
let current = true
onCleanup (() => { current = false })
resolvedWarehouseName . value = '' ; warehouseLookupFailed . value = false
if ( ! warehouseId || warehouseName ) return
try {
const result = await getPlatformWarehouse ( String ( warehouseId ))
if ( current ) resolvedWarehouseName . value = result . warehouse . warehouseName || result . warehouse . warehouseCode
} catch { if ( current ) warehouseLookupFailed . value = true }
})
2026-08-20 09:10:36 +08:00
const loading = ref ( false )
2026-08-26 11:10:22 +08:00
const saving = ref ( false )
2026-08-20 09:10:36 +08:00
const loadError = ref < ApiErrorDetails | null >( null )
2026-08-26 11:10:22 +08:00
const mutationError = ref < ApiErrorDetails | null >( null )
const activeTab = ref ( 'basic' )
const feeDialogOpen = ref ( false )
2026-09-09 16:37:36 +08:00
const supplierOptions = ref < Supplier [] >([])
const feeForm = reactive ({ itemCode : '' , itemName : '' , direction : 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE' , currency : 'USD' as 'CNY' | 'USD' , amount : 0 , businessDate : new Date (). toISOString (). slice ( 0 , 10 ), supplierId : '' , quantity : undefined as number | undefined , quantityUnit : '' , periodStart : '' , periodEnd : '' })
const feeItemOptions = [{ code : 'STORAGE_FEE' , name : '仓租' }, { code : 'INBOUND_OPERATION' , name : '入库操作费' }, { code : 'OUTBOUND_OPERATION' , name : '出库操作费' }, { code : 'RELABEL_FEE' , name : '换标费' }, { code : 'REBOX_FEE' , name : '换箱费' }, { code : 'PALLET_FEE' , name : '托盘费' }, { code : 'DELIVERY_FEE' , name : '派送费' }, { code : 'TRANSFER_FEE' , name : '转仓费' }, { code : 'EXCEPTION_FEE' , name : '异常处理费' }, { code : 'OTHER' , name : '其他' }]
2026-08-20 09:10:36 +08:00
function text ( value : unknown ) { return String ( value ?? '' ). trim () || '—' }
2026-09-11 16:17:21 +08:00
function containerNodeLabel ( value : unknown ) {
const code = String ( value ?? '' ). trim ()
return ({
DEPARTED_WAREHOUSE : '已离开国内仓库' , AT_ORIGIN_PORT : '重箱已进入起运港' ,
LOADED_ON_VESSEL : '已装船' , DEPARTED_ORIGIN_PORT : '船舶已离开起运港' , IN_TRANSIT : '海运途中' ,
ARRIVED_DESTINATION_PORT : '船舶已到目的港' , DISCHARGED_FROM_VESSEL : '已卸船、未清关' ,
PICKUP_READY : '已清关、可提柜' , PICKED_UP : '已提柜' , EMPTY_RETURNED : '已还柜' ,
} as Record < string , string >)[ code ] || text ( code )
}
2026-08-20 09:10:36 +08:00
function yesNo ( value : boolean | null | undefined ) { return value == null ? '—' : value ? '是' : '否' }
2026-08-26 11:10:22 +08:00
function feeStatusLabel ( value : string ) { return ({ PENDING_REVIEW : '待审核' , APPROVED : '审核通过' , REJECTED : '已驳回' } as Record < string , string >)[ value ] || value }
2026-09-07 21:06:39 +08:00
function isSingleLineBusinessField ( field : DetailField ) { return field . label === '入仓码' || field . label === '集装箱号' }
2026-08-20 09:10:36 +08:00
2026-09-01 11:03:45 +08:00
const referenceValue = computed (() => lastMileOrderReference ( detail . value ? . order . orderNo , '订单编码待补充' ))
2026-10-10 14:42:41 +08:00
const isReturnOrder = computed (() => detail . value ? . order . orderType === 'RETURN_RELABEL' )
2026-10-13 16:49:08 +08:00
const returnReceiptUnitLabel = computed (() => detail . value ? . returnReceipt ? . receipts . some ( receipt => receipt . unitType === 'PARCEL' ) ? '包裹' : '箱' )
2026-10-13 17:14:56 +08:00
const signedReturnCartons = computed (() => ( detail . value ? . returnReceipt ? . cartons || []). map ( box => {
const parcel = detail . value ? . returnReceipt ? . receipts . find ( receipt => receipt . receiptNo === box . receiptNo && receipt . unitType === 'PARCEL' )
return { ... box , length : box . length ?? parcel ? . length , width : box . width ?? parcel ? . width , height : box . height ?? parcel ? . height , weight : box . weight ?? parcel ? . weight }
}))
function returnCartonVolume ( box : { length ?: number | null ; width ?: number | null ; height ?: number | null }) {
if ( box . length == null || box . width == null || box . height == null ) return '—'
const volume = box . length * box . width * box . height / 1 _000_000
return Number . isFinite ( volume ) && volume >= 0 ? volume . toFixed ( 6 ). replace ( /\.?0+$/ , '' ) : '—'
}
2026-10-10 14:42:41 +08:00
const showReturnForecast = computed (() => isReturnOrder . value && ( detail . value ? . returnReceipt ? . order . status
? detail . value . returnReceipt . order . status !== 'COMPLETED'
: [ 'PENDING_REVIEW' , 'PENDING_RECEIPT' , 'PARTIALLY_RECEIVED' , 'REJECTED' , 'CANCELLED' ]. includes ( detail . value ? . order . currentStatus || '' )))
2026-08-20 09:10:36 +08:00
const orderFields = computed < DetailField [] >(() => {
const order = detail . value ? . order
2026-09-17 13:36:31 +08:00
const fields : DetailField [] = [
2026-10-09 16:11:59 +08:00
{ label : '履约类型' , value : text ( order ? . orderTypeName || lastMileOrderTypeLabel ( order ? . orderType )) },
2026-08-26 11:10:22 +08:00
{ label : '入仓码' , value : text ( order ? . inboundCode ) },
2026-08-20 09:10:36 +08:00
{ label : '货主' , value : text ( order ? . customerName ) },
2026-10-15 15:45:16 +08:00
{ label : '海外仓' , value : warehouseDisplayName . value },
2026-08-20 09:10:36 +08:00
{ label : '转仓目的仓' , value : text ( order ? . transferWarehouseName ) },
2026-08-26 11:10:22 +08:00
{ label : '目的仓' , value : text ( order ? . fbaWarehouseCode || order ? . transferWarehouseName ) },
{ label : '是否暂扣' , value : yesNo ( order ? . detained ) },
{ label : '是否异常' , value : yesNo ( order ? . abnormal ) },
2026-09-14 20:21:02 +08:00
{ label : '异常原因' , wide : true , value : order ? . abnormal ? text ( order . subOrderStatus ) : '—' },
2026-09-15 15:36:26 +08:00
{ label : '仓库异常作业备注' , wide : true , value : text ( order ? . warehouseRemark ) },
2026-09-14 20:21:02 +08:00
{ label : '备注(客户)' , wide : true , value : text ( order ? . customerRemark ) },
2026-08-20 09:10:36 +08:00
]
2026-09-17 13:36:31 +08:00
if ( order ? . orderType === 'WAREHOUSE_TRANSFER' ) fields . splice ( 6 , 0 ,
{ label : '客户预报箱数' , value : order . packageCount == null ? '—' : order . packageCount },
{ label : '实收箱数' , value : order . receivedPackageCount == null ? '—' : order . receivedPackageCount },
{ label : '箱数差异' , value : packageDifferenceText ( order . packageDifference ) },
)
return fields
2026-08-20 09:10:36 +08:00
})
2026-09-17 13:36:31 +08:00
function isNonzeroDifference ( field : DetailField ) { return field . label === '箱数差异' && field . value !== '—' && field . value !== '0' }
2026-08-26 11:10:22 +08:00
const containerFields = computed < DetailField [] >(() => {
2026-08-20 09:10:36 +08:00
const order = detail . value ? . order
return [
2026-08-26 11:10:22 +08:00
{ label : '运输渠道' , value : text ( order ? . orderTypeName || lastMileOrderTypeLabel ( order ? . orderType )) },
{ label : '集装箱号' , value : text ( order ? . containerNo ) },
2026-08-20 09:10:36 +08:00
{ label : '托数' , value : Number ( order ? . palletCount || 0 ) },
{ label : '箱数' , value : Number ( order ? . packageCount || 0 ) },
2026-09-18 14:58:40 +08:00
{ label : '货物毛重(kg) ' , value : Number ( order ? . grossWeightKg || 0 ) },
{ label : '货物净重(kg) ' , value : Number ( order ? . netWeightKg || 0 ) },
2026-08-26 11:10:22 +08:00
{ label : '体积(m³)' , value : Number ( order ? . volumeCbm || 0 ) },
2026-08-20 09:10:36 +08:00
{ label : '预计到港时间' , value : formatOverseasDateTime ( order ? . estimatedArrivalAt ) },
{ label : '实际到港时间' , value : formatOverseasDateTime ( order ? . actualArrivalAt ) },
2026-08-26 11:10:22 +08:00
{ label : '预计到仓时间' , value : formatOverseasDateTime ( order ? . estimatedWarehouseAt ) },
2026-09-11 16:17:21 +08:00
{ label : '货柜节点' , value : containerNodeLabel ( order ? . containerNode ) },
2026-10-10 14:42:41 +08:00
]. filter ( field => ! isReturnOrder . value || field . label !== '箱数' )
2026-08-20 09:10:36 +08:00
})
2026-10-09 16:11:59 +08:00
const commercialLink = computed (() => businessOrderLink ( detail . value ? . order . businessSource ? . businessType , detail . value ? . order . businessSource ? . businessOrderNo , access . can ))
2026-09-14 20:21:02 +08:00
const basicFields = computed (() => [... orderFields . value , ... containerFields . value ])
const overviewFields = computed (() => {
const order = detail . value ? . order
2026-09-17 13:36:31 +08:00
const warehouseTransfer = order ? . orderType === 'WAREHOUSE_TRANSFER'
const hasReceivedPackages = warehouseTransfer && order ? . receivedPackageCount != null
2026-10-15 15:45:16 +08:00
if ( warehouseTransfer && receiptSummary . value ) {
const summary = receiptSummary . value
return [
{ label : '客户' , value : text ( order ? . customerName ) },
{ label : '海外仓' , value : warehouseDisplayName . value },
{ label : '预报箱数' , value : ` ${ summary . forecastBoxes } 箱` },
{ label : '预报 SKU' , value : ` ${ summary . forecastSkus } 种` },
2026-10-16 16:52:43 +08:00
{ label : '预报 SKU 总件数' , value : summary . forecastQuantity == null ? '未记录' : ` ${ summary . forecastQuantity } 件` },
2026-10-15 15:45:16 +08:00
{ label : '实收箱数' , value : ` ${ summary . boxes } 箱` },
{ label : '实收 SKU' , value : ` ${ summary . skus } 种` },
{ label : '实收件数' , value : ` ${ summary . quantity } 件` },
{ label : '总托数' , value : ` ${ summary . pallets } 托` },
{ label : '总重量' , value : ` ${ summary . weight . toFixed ( 3 ) } kg` },
{ label : '总体积' , value : ` ${ summary . volume . toFixed ( 6 ) } m³` },
{ label : '最近更新' , value : formatOverseasDateTime ( order ? . updatedAt ) },
]
}
2026-09-14 20:21:02 +08:00
return [
{ label : '客户' , value : text ( order ? . customerName ) },
2026-10-15 15:45:16 +08:00
{ label : '海外仓' , value : warehouseDisplayName . value },
2026-10-10 14:42:41 +08:00
...( isReturnOrder . value ? [{ label : showReturnForecast . value ? '预报总件数' : '实收总件数' , value : ` ${ showReturnForecast . value ? detail . value ? . returnReceipt ? . order . expectedQuantity ?? order ? . productQuantity ?? 0 : detail . value ? . returnReceipt ? . order . receivedQuantity ?? order ? . productQuantity ?? 0 } 件` }]
: [{ label : warehouseTransfer ? ( hasReceivedPackages ? '实收总箱数' : '客户预报总箱数' ) : '总箱数' , value : ( hasReceivedPackages ? order ? . receivedPackageCount : order ? . packageCount ) == null ? '—' : ` ${ hasReceivedPackages ? order ? . receivedPackageCount : order ? . packageCount } 箱` }]),
2026-09-14 20:21:02 +08:00
{ label : '总托数' , value : order ? . palletCount == null ? '—' : order . palletCount + ' 托' },
2026-09-18 14:58:40 +08:00
{ label : '货物毛重' , value : order ? . grossWeightKg == null ? '—' : order . grossWeightKg + ' kg' },
{ label : '货物净重' , value : order ? . netWeightKg == null ? '—' : order . netWeightKg + ' kg' },
2026-09-14 20:21:02 +08:00
{ label : '体积' , value : order ? . volumeCbm == null ? '—' : order . volumeCbm + ' m³' },
{ label : '最近更新' , value : formatOverseasDateTime ( order ? . updatedAt ) },
]
})
2026-08-20 09:10:36 +08:00
const planFields = computed < DetailField [] >(() => {
const order = detail . value ? . order
return [
{ label : '入仓码' , value : text ( order ? . inboundCode ) },
{ label : '计划入库仓' , value : text ( order ? . transferWarehouseName || order ? . overseasWarehouseName ) },
{ label : '合托批次' , value : text ( order ? . palletMergeFlag ) },
2026-08-26 11:10:22 +08:00
{ label : '计划托数' , value : Number ( order ? . palletCount || 0 ) },
{ label : '入库单号' , value : text ( order ? . inboundOrderNo ) },
2026-10-05 07:28:36 +08:00
{ label : '换标子单' , value : order ? . relabelApplications ? . length ? ` ${ order . relabelApplications . length } 张(详见关联单据)` : relabelBusinessStatusLabel ( order ? . relabelStatus ) },
2026-08-20 09:10:36 +08:00
]
})
const documents = computed < DocumentRow [] >(() => {
const order = detail . value ? . order
if ( ! order ) return []
2026-09-22 08:47:43 +08:00
return [[ '入库单' , order . inboundOrderNo ], [ '出库单(只读关联)' , order . outboundOrderNo ], [ '派送单(只读关联)' , order . deliveryOrderNo ], [ '换标单' , order . relabelApplications ? . length ? undefined : order . relabelOrderNo ]]
. filter (( item ) => item [ 1 ]). map (([ type , number ]) => ({ type : String ( type ), number : String ( number ), createdAt : '—' , quote : '—' } as DocumentRow ))
. concat (( order . relabelApplications || []). map ( application => ({ type : '换标单' , number : application . relabelNo , createdAt : formatOverseasDateTime ( application . createdAt ), quote : '—' , status : relabelApplicationStatusLabel ( application . status ), quantity : application . quantity })))
2026-08-20 09:10:36 +08:00
})
2026-10-10 15:45:20 +08:00
const cargo = computed < CargoRow [] >(() => isReturnOrder . value
? ( detail . value ? . returnReceipt ? . items || []). map ( item => ({ id : item . skuCode , sku : item . skuCode , productName : text ( item . productNameZh ),
2026-10-10 14:42:41 +08:00
quantity : item . expectedQuantity , pallets : 0 , cartons : 0 , weight : 0 , volume : 0 }))
: ( detail . value ? . cargo || []). map (( item ) => ({
2026-08-20 09:10:36 +08:00
id : String ( item . id ), sku : text ( item . skuCode ), productName : text ( item . productNameZh ), pallets : Number ( item . palletCount || 0 ),
cartons : Number ( item . packageCount || 0 ), quantity : Number ( item . productQuantity || 0 ), weight : Number ( item . grossWeightKg || 0 ), volume : Number ( item . volumeCbm || 0 ),
})))
2026-08-26 11:10:22 +08:00
const fees = computed < FeeRow [] >(() => ( detail . value ? . fees || []). map (( item ) => ({
2026-09-09 16:37:36 +08:00
id : String ( item . id ), item : item . feeItemName , currency : item . originalCurrency , amount : ` ${ item . originalCurrency } ${ Number ( item . originalAmount ). toFixed ( 2 ) } ` ,
status : feeStatusLabel ( item . reviewStatus === 'PENDING' ? 'PENDING_REVIEW' : item . reviewStatus ), description : text ( item . supplierName || ( item . chargeableQuantity == null ? null : ` ${ item . chargeableQuantity } ${ item . quantityUnit || '' } ` )),
2026-08-20 09:10:36 +08:00
})))
2026-08-26 11:10:22 +08:00
const logs = computed < LogRow [] >(() => {
const statusRows = ( detail . value ? . statusHistory || []). map (( item ) => ({
id : `status- ${ item . id } ` , occurredAt : formatOverseasDateTime ( item . createdAt ), sortAt : item . createdAt || '' , node : lastMileStatusLabel ( item . toStatus ), operator : '系统' ,
result : text ( item . remark || ` ${ lastMileStatusLabel ( item . fromStatus ) } → ${ lastMileStatusLabel ( item . toStatus ) } ` ),
}))
const operationRows = ( detail . value ? . operationHistory || []). map (( item ) => ({
id : `operation- ${ item . id } ` , occurredAt : formatOverseasDateTime ( item . createdAt ), sortAt : item . createdAt || '' , node : item . operationNode , operator : text ( item . operatorName ), result : text ( item . result ),
}))
return [... statusRows , ... operationRows ]. sort (( left , right ) => right . sortAt . localeCompare ( left . sortAt ))
})
2026-08-19 16:45:51 +08:00
2026-10-10 15:45:20 +08:00
const cargoColumns = computed < DataTableColumn [] >(() => isReturnOrder . value ? [
2026-10-10 14:42:41 +08:00
{ key : 'sku' , prop : 'sku' , label : 'SKU' , minWidth : 150 },
{ key : 'productName' , prop : 'productName' , label : '中文品名' , minWidth : 180 },
{ key : 'quantity' , prop : 'quantity' , label : '预报件数' , width : 120 , align : 'right' },
] : [
2026-08-20 09:10:36 +08:00
{ key : 'sku' , prop : 'sku' , label : 'SKU' , minWidth : 150 }, { key : 'productName' , prop : 'productName' , label : '中文品名' , minWidth : 180 },
2026-08-26 11:10:22 +08:00
{ key : 'pallets' , prop : 'pallets' , label : '托数' , width : 100 , align : 'right' }, { key : 'cartons' , prop : 'cartons' , label : '箱数' , width : 100 , align : 'right' },
{ key : 'quantity' , prop : 'quantity' , label : '件数' , width : 100 , align : 'right' }, { key : 'weight' , prop : 'weight' , label : '重量(kg)' , width : 120 , align : 'right' },
{ key : 'volume' , prop : 'volume' , label : '体积(m³)' , width : 120 , align : 'right' },
2026-10-10 14:42:41 +08:00
])
2026-08-26 11:10:22 +08:00
const documentColumns : DataTableColumn [] = [
2026-10-13 13:46:56 +08:00
{ key : 'status' , prop : 'status' , label : '状态' , minWidth : 130 }, { key : 'quantity' , prop : 'quantity' , label : '换标数量(箱/件)' , minWidth : 150 , formatter : ( row : { quantity ?: number ; quantityUnit ?: string }) => ` ${ row . quantity ?? '—' }${ row . quantityUnit || '件' } ` },
2026-08-26 11:10:22 +08:00
{ key : 'type' , prop : 'type' , label : '单据类型' , minWidth : 150 }, { key : 'number' , prop : 'number' , label : '单据编号' , minWidth : 200 },
{ key : 'createdAt' , prop : 'createdAt' , label : '生成时间' , minWidth : 170 }, { key : 'quote' , prop : 'quote' , label : '报价' , minWidth : 150 },
]
const feeColumns : DataTableColumn [] = [
{ key : 'item' , prop : 'item' , label : '费用项目' , minWidth : 180 }, { key : 'currency' , prop : 'currency' , label : '币种' , width : 120 },
{ key : 'amount' , prop : 'amount' , label : '金额' , minWidth : 160 }, { key : 'status' , prop : 'status' , label : '状态' , width : 140 },
{ key : 'description' , prop : 'description' , label : '供应商/说明' , minWidth : 260 },
2026-08-19 16:45:51 +08:00
]
const logColumns : DataTableColumn [] = [
2026-08-26 11:10:22 +08:00
{ key : 'occurredAt' , prop : 'occurredAt' , label : '时间' , minWidth : 180 }, { key : 'node' , prop : 'node' , label : '操作节点' , minWidth : 180 },
{ key : 'operator' , prop : 'operator' , label : '操作人' , minWidth : 150 }, { key : 'result' , prop : 'result' , label : '处理结果' , minWidth : 320 },
2026-08-19 16:45:51 +08:00
]
2026-08-20 09:10:36 +08:00
async function load () {
2026-08-26 11:10:22 +08:00
if ( ! orderId . value ) { loadError . value = { message : '详情缺少尾程订单号' }; return }
loading . value = true ; loadError . value = null
2026-08-21 11:20:09 +08:00
try { detail . value = await getLastMileOrder ( orderId . value ) }
2026-08-26 11:10:22 +08:00
catch ( error ) { loadError . value = error instanceof Error && error . message === '当前环境未签发海外访问令牌' ? { message : error . message } : getApiErrorDetails ( error , '尾程订单详情加载失败,请重试' ) }
2026-08-20 09:10:36 +08:00
finally { loading . value = false }
2026-08-19 16:45:51 +08:00
}
2026-09-09 16:37:36 +08:00
async function openFeeDialog () {
Object . assign ( feeForm , { itemCode : '' , itemName : '' , direction : 'RECEIVABLE' , currency : 'USD' , amount : 0 , businessDate : new Date (). toISOString (). slice ( 0 , 10 ), supplierId : '' , quantity : undefined , quantityUnit : '' , periodStart : '' , periodEnd : '' })
2026-08-26 11:10:22 +08:00
mutationError . value = null ; feeDialogOpen . value = true
2026-09-09 16:37:36 +08:00
if ( access . can ( 'platform:supplier:list' ) && supplierOptions . value . length === 0 ) {
try { supplierOptions . value = ( await listSuppliers ({ page : 1 , size : 200 , status : 'ACTIVE' })). records . filter (( item ) => item . status === 'ACTIVE' ) }
catch ( error ) { mutationError . value = getApiErrorDetails ( error , '供应商列表加载失败' ) }
}
2026-08-26 11:10:22 +08:00
}
async function saveFee () {
2026-09-09 16:37:36 +08:00
if ( ! feeForm . itemCode || ! feeForm . itemName . trim () || feeForm . amount <= 0 || ! feeForm . businessDate ) { ElMessage . warning ( '请填写费用项目、业务日期和大于 0 的金额' ); return }
const supplier = supplierOptions . value . find (( item ) => String ( item . id ) === feeForm . supplierId )
if ( feeForm . direction === 'PAYABLE' && ! supplier ) { ElMessage . warning ( '应付费用必须选择供应商' ); return }
2026-08-26 11:10:22 +08:00
saving . value = true ; mutationError . value = null
try {
2026-09-09 16:37:36 +08:00
await createLastMileOrderFee ( orderId . value , { feeItemCode : feeForm . itemCode , feeItemName : feeForm . itemName . trim (), direction : feeForm . direction , currency : feeForm . currency , amount : feeForm . amount , businessDate : feeForm . businessDate , supplierId : supplier ? String ( supplier . id ) : undefined , supplierName : supplier ? . name , supplierCategory : supplier ? . category , settlementMode : supplier ? 'ON_ACCOUNT' : undefined , chargeableQuantity : feeForm . quantity , quantityUnit : feeForm . quantity == null ? undefined : feeForm . quantityUnit . trim (), unitPrice : feeForm . quantity ? feeForm . amount / feeForm . quantity : undefined , periodStart : feeForm . periodStart || undefined , periodEnd : feeForm . periodEnd || undefined })
feeDialogOpen . value = false ; ElMessage . success ( '费用已提交财务审核' ); await load (); activeTab . value = 'fees'
2026-08-26 11:10:22 +08:00
} catch ( error ) { mutationError . value = getApiErrorDetails ( error , '费用新增失败,请重试' ) }
finally { saving . value = false }
}
2026-08-20 09:10:36 +08:00
function back () { void router . push ({ name : 'overseas-last-mile-orders' }) }
onMounted ( load )
2026-08-19 16:45:51 +08:00
</ script >
< template >
2026-09-14 20:03:27 +08:00
< OverseasDetailShell class = "admin-density" dense title = "尾程订单详情" reference -label =" 尾程单号 " :reference-value = "referenceValue" back -label =" 返回尾程订单 " domain = "尾程订单详情" :pending = "false" : status -text =" detail ? lastMileStatusLabel ( detail.order.currentStatus ) : ' 加载中 '" @back ="back" >
2026-09-14 20:21:02 +08:00
< template # reference >< OrderReferenceText :value = "referenceValue" copyable compact /></ template >
< template # overview >
< dl v-if = "detail" class="density-detail-overview" aria-label="尾程订单概览" >
< div v-for = "field in overviewFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div >
</ dl >
</ template >
2026-08-26 11:10:22 +08:00
< OperationErrorToast :details = "mutationError" />
2026-08-20 09:10:36 +08:00
< LoadErrorState v-if = "loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
2026-08-26 11:10:22 +08:00
< section v-else v-loading = "loading" class="detail-surface" :aria-busy="loading" >
2026-09-14 20:03:27 +08:00
< el-tabs v-model = "activeTab" class="detail-tabs density-element-tabs density-contained-tabs" >
< el-tab-pane name = "basic" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 📋 </ span >< span > 基本信息 </ span ></ template >
2026-10-09 16:11:59 +08:00
< section aria -label =" 商业来源与关联单据 " >
< h2 class = "source-heading" > 关联单据 </ h2 >
< dl class = "detail-fields density-fields" >
< div class = "wide" >< dt > 业务来源 </ dt >< dd >{{ text ( detail ? . order . businessSource ? . label ) }}</ dd ></ div >
< div class = "wide" >< dt > 关联商业单号 </ dt >< dd >< el-button v-if = "commercialLink" link type="primary" @click="router.push(commercialLink)" >{{ detail ? .order.businessSource ? .businessOrderNo }} </ el -button >< span v-else >{{ text ( detail ? .order.businessSource ? .businessOrderNo ) }}</ span ></ dd ></ div >
< div class = "wide" >< dt > 外接预订号 </ dt >< dd >{{ text ( detail ? . order . businessSource ? . externalBookingNo ) }}</ dd ></ div >
< div class = "wide" >< dt > 来源单号 </ dt >< dd >{{ text ( detail ? . order . sourceOrderNo ) }}</ dd ></ div >
</ dl >
</ section >
2026-09-14 20:21:02 +08:00
< dl class = "detail-fields density-fields" aria -label =" 基本信息 " >
2026-09-17 13:36:31 +08:00
< div v-for = "field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined" >{{ field.value }}</ dd ></ div >
2026-09-14 20:21:02 +08:00
</ dl >
2026-08-26 11:10:22 +08:00
</ el -tab-pane >
2026-10-16 16:52:43 +08:00
< el-tab-pane v-if = "access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files" label="贴标文件" >
< template v-if = "activeTab === 'relabel-files'" >
< RelabelDocumentsPanel v-for = "application in detail?.order.relabelApplications || []" :key="application.relabelNo" :relabel-no="application.relabelNo" />
< RelabelDocumentsPanel v-if = "!detail?.order.relabelApplications?.length && detail?.order.relabelOrderNo" :relabel-no="detail.order.relabelOrderNo" />
</ template >
</ el-tab-pane >
2026-10-15 15:45:16 +08:00
< el-tab-pane name = "cargo" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 📦 </ span >< span > 货物信息 </ span ></ template >< section class = "table-panel" >< WarehouseTransferReceiptPanel v-if = "detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'" ></ DataTable ></ section > </ el -tab-pane >
2026-10-09 15:25:19 +08:00
< el-tab-pane v-if = "detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true" > 📦 </ span >< span > 退货签收与箱内清点 </ span ></ template >
< section v-if = "detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点" >
< h3 >{{ detail . returnReceipt . order . returnNo }} · 实收 {{ detail . returnReceipt . order . receivedQuantity }} / 预报 {{ detail . returnReceipt . order . expectedQuantity }} 件 </ h3 >
< el-table :data = "detail.returnReceipt.receipts" size = "small" border >
< el-table-column prop = "receiptNo" label = "签收单号" min -width =" 190 " />
< el-table-column label = "签收方式" width = "90" >< template # default = "{ row }" >{{ row . unitType === 'PALLET' ? '托盘' : row . unitType === 'BOX' ? '箱子' : '包裹' }}</ template ></ el-table-column >
< el-table-column prop = "unitCode" label = "签收载体" min -width =" 220 " />
< el-table-column prop = "boxCount" label = "实收箱数" width = "100" />
< el-table-column prop = "receivedQuantity" label = "实收数量" width = "100" />
</ el-table >
2026-10-13 16:49:08 +08:00
< h3 > 签收 {{ returnReceiptUnitLabel }} 明细 ( {{ detail . returnReceipt . cartons ? . length || 0 }} {{ returnReceiptUnitLabel }} ) </ h3 >
2026-10-09 15:25:19 +08:00
< el-collapse v-if = "detail.returnReceipt.cartons?.length" >
2026-10-13 17:14:56 +08:00
< el-collapse-item v-for = "box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark" >
< template # title >< span class = "return-carton-title" >< span >{{ box . cartonMark }} · {{ box . countStatus === 'CONFIRMED' ? `已确认 · ${ box . items . length } 种 SKU · ${ box . items . reduce (( sum , item ) => sum + Number ( item . quantity ), 0 ) } 件` : box . countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</ span >< span class = "return-carton-measurements" > 长 {{ box . length ?? '—' }} cm · 宽 {{ box . width ?? '—' }} cm · 高 {{ box . height ?? '—' }} cm · 体积 {{ returnCartonVolume ( box ) }} m³ · 重量 {{ box . weight ?? '—' }} kg </ span ></ span ></ template >
2026-10-09 15:25:19 +08:00
< p > 签收单 {{ box . receiptNo }}< template v-if = "box.palletCode"> · 托盘 {{ box.palletCode }}</template><template v-if="box.countedAt" > · 确认时间 {{ formatOverseasDateTime ( box.countedAt ) }}</ template ></ p >
2026-10-13 16:49:08 +08:00
< p v-if = "box.length != null || box.width != null || box.height != null || box.weight != null" > 测量 : {{ box.length ?? '—' }} × {{ box.width ?? '—' }} × {{ box.height ?? '—' }} cm · 重量 {{ box.weight ?? '—' }} kg </ p >
2026-10-09 15:25:19 +08:00
< el-table v-if = "box.countStatus === 'CONFIRMED'" :data="box.items" size="small" border >
< el-table-column prop = "skuCode" label = "SKU" min -width =" 220 " />
< el-table-column prop = "productNameZh" label = "中文品名" min -width =" 180 " />
< el-table-column prop = "quantity" label = "实际签收数量" width = "130" />
</ el-table >
< p v-else >{{ box.countStatus === ' PENDING ' ? ' 箱内数量待仓库清点确认 ' : ' 未留存原始箱内明细 , 不以当前库存代替签收数量 ' }}</ p >
</ el -collapse-item >
</ el-collapse >
< el-empty v-else description = "暂无签收箱号明细" />
2026-10-14 11:43:32 +08:00
< h3 > 合箱记录 </ h3 >
< el-table : data = "detail.returnReceipt.packingDetails || []" size = "small" border empty -text =" 暂无合箱记录 " >
< el-table-column prop = "planNo" label = "合箱计划" min -width =" 210 " />
< el-table-column prop = "sourceCartonMark" label = "来源箱唛" min -width =" 220 " />
< el-table-column prop = "targetCartonMark" label = "目标箱唛" min -width =" 220 " />
< el-table-column prop = "skuCode" label = "SKU" min -width =" 180 " />
< el-table-column prop = "quantity" label = "本单合入数量" width = "130" />
</ el-table >
< h3 > 当前关联实物箱 </ h3 >
< el-collapse v-if = "detail.returnReceipt.currentCartons?.length" >
< el-collapse-item v-for = "current in detail.returnReceipt.currentCartons" :key="current.carton.cartonMark" :name="`current-${current.carton.cartonMark}`" >
< template # title >{{ current . carton . cartonMark }} · 整箱 {{ current . carton . totalQuantity }} 件 · 本单 {{ current . orderQuantity }} 件 · {{ current . carton . sourceReturnNo ? . includes ( ',' ) ? '跨来源单合箱' : '单一来源' }}</ template >
< p > 来源单 : {{ current . carton . sourceReturnNo }} · 状态 : {{ current . carton . status }} · 我方箱唛 : {{ current . labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</ p >
< el-table :data = "current.ownership" size = "small" border >
< el-table-column prop = "returnNo" label = "来源退货单" min -width =" 200 " />
< el-table-column prop = "skuCode" label = "SKU" min -width =" 190 " />
< el-table-column prop = "quantity" label = "来源贡献数量" width = "130" />
</ el-table >
< p >{{ current . canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current . blockReason }}</ p >
</ el-collapse-item >
</ el-collapse >
< el-empty v-else description = "暂无当前关联实物箱" />
2026-10-09 15:25:19 +08:00
</ section >
< el-empty v-else description = "暂无退货签收记录" />
</ el-tab-pane >
2026-10-04 21:19:11 +08:00
< el-tab-pane name = "plan" lazy >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 🗓️ </ span >< span > 计划信息 </ span ></ template >
< WarehouseTransferFulfillmentPanel v-if = "detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="detail.order.customerId" :transfer-no="detail.order.sourceOrderNo || ''" />
2026-09-14 20:03:27 +08:00
< section class = "info-panel plan-panel" aria -label =" 计划信息 " >< dl class = "detail-fields plan-fields density-fields" >< div v-for = "field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined" >{{ field.value }}</ dd ></ div ></ dl ></ section >
2026-08-26 11:10:22 +08:00
< section class = "table-panel related-panel" >< DataTable :data = "documents" :columns = "documentColumns" column -settings-mode =" hide " empty -text =" 暂无关联单据 " >< template # toolbar -start >< h2 > 关联单据 </ h2 ></ template ></ DataTable ></ section >
</ el-tab-pane >
2026-09-14 20:03:27 +08:00
< el-tab-pane name = "fees" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 💰 </ span >< span > 费用明细 </ span ></ template >< section class = "table-panel" >< DataTable :data = "fees" :columns = "feeColumns" column -settings-mode =" hide " row -key =" id " empty -text =" 暂无费用明细 " >< template # toolbar -actions >< el-button v-if = "access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" /> 新增费用 </ el-button ></ template ></ DataTable ></ section ></ el-tab-pane >
< el-tab-pane name = "logs" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 📝 </ span >< span > 操作记录 </ span ></ template >< section class = "table-panel" >< DataTable :data = "logs" :columns = "logColumns" column -settings-mode =" hide " row -key =" id " empty -text =" 暂无操作记录 " ></ DataTable ></ section ></ el-tab-pane >
2026-08-26 11:10:22 +08:00
</ el-tabs >
</ section >
2026-09-14 20:03:27 +08:00
< AppDialog class = "admin-density-dialog" v-model = "feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee" >
< el-form class = "fee-form density-form" label -position =" top " >< div class = "fee-form-grid" >
2026-09-09 17:17:05 +08:00
< el-form-item label = "费用项目" required >< el-select v-model = "feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></ el-select ></ el-form-item >
2026-09-09 16:37:36 +08:00
< el-form-item label = "收付方向" required >< el-select v-model = "feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></ el-select ></ el-form-item >
< el-form-item v-if = "feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></ el-select ></ el-form-item >
< el-form-item label = "业务日期" required >< el-date-picker v-model = "feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></ el-form-item >
< el-form-item label = "币种" required >< el-select v-model = "feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></ el-select ></ el-form-item >
2026-09-07 19:59:34 +08:00
< el-form-item label = "金额" required >< el-input-number v-model = "feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></ el-form-item >
2026-09-09 17:17:05 +08:00
< el-form-item label = "计费数量" >< el-input-number v-model = "feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></ el-form-item >
2026-09-09 16:37:36 +08:00
< el-form-item label = "计费开始日期" >< el-date-picker v-model = "feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></ el-form-item >
2026-09-07 19:59:34 +08:00
</ div ></ el-form >
2026-08-26 11:10:22 +08:00
</ AppDialog >
2026-08-19 16:45:51 +08:00
</ OverseasDetailShell >
</ template >
< style scoped >
2026-10-09 16:11:59 +08:00
. source - heading { margin : 0 ; padding : var ( -- admin - space - 3 ); font - size : var ( -- admin - font - section ); font - weight : 600 ; }
2026-10-13 17:14:56 +08:00
. return - carton - title { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 8 px 16 px ; padding : 4 px 0 ; line - height : 1.5 ; }
. return - carton - measurements { color : var ( -- cc - text - secondary ); font - weight : 400 ; }
2026-09-14 20:21:02 +08:00
. detail - fields > div . wide { grid - column : span 2 ; }
2026-09-07 21:06:39 +08:00
. detail - field - value -- business { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
2026-09-17 13:36:31 +08:00
. package - difference -- nonzero { color : var ( -- el - color - danger ); font - weight : 600 ; }
2026-09-14 20:03:27 +08:00
. plan - panel { margin - bottom : var ( -- admin - space - 5 ); }
. related - panel { margin - top : var ( -- admin - space - 5 ); }
2026-09-07 19:59:34 +08:00
. fee - form { min - width : 0 ; overflow - x : hidden ; }
2026-09-14 20:03:27 +08:00
. fee - form - grid { display : grid ; min - width : 0 ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); column - gap : var ( -- admin - space - 5 ); }
2026-09-07 19:59:34 +08:00
. fee - form - full { grid - column : 1 / - 1 ; }
. fee - form : deep (. el - select ), . fee - form : deep (. el - input - number ) { width : 100 % ; }
2026-09-14 20:21:02 +08:00
@ media ( max - width : 1100 px ) { . plan - fields { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); } }
@ media ( max - width : 720 px ) { . detail - tabs : deep (. el - tabs__header ) { padding : 0 10 px ; } . detail - tabs : deep (. el - tabs__content ) { padding : 12 px ; } . detail - tabs : deep (. el - tabs__item ) { padding : 0 12 px ; } . detail - fields , . plan - fields , . fee - form - grid { grid - template - columns : 1 fr ; } . fee - form - full , . detail - fields > div . wide { grid - column : auto ; } }
2026-09-14 20:03:27 +08:00
. detail - surface { min - width : 0 ; min - height : 0 ; margin - top : 0 ; border : 0 ; border - radius : 0 ; background : # fff ; overflow : hidden ; }
. info - panel , . table - panel { min - width : 0 ; border : 0 ; border - radius : 0 ; background : # fff ; overflow : hidden ; }
. info - panel h2 , . table - panel h2 { margin : 0 ; padding : 0 0 var ( -- admin - space - 3 ); border - bottom : 0 ; color : var ( -- cc - text ); font - size : var ( -- admin - font - section ); }
2026-08-19 16:45:51 +08:00
</ style >