Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
T

1366 lines
97 KiB
Vue
Raw Normal View History

2026-08-19 16:45:51 +08:00
<script setup lang="ts">
2026-10-08 17:08:03 +08:00
import { useExportTask } from '@/modules/downloads'
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-09-09 00:44:07 +08:00
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
2026-09-17 13:36:31 +08:00
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-19 16:45:51 +08:00
import { useRouter } from 'vue-router'
2026-08-28 13:54:44 +08:00
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
2026-08-20 09:10:36 +08:00
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
2026-08-19 16:45:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-21 17:20:40 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-20 09:10:36 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-19 16:45:51 +08:00
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-20 09:10:36 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-21 17:20:40 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-19 16:45:51 +08:00
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-09-18 16:48:40 +08:00
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-10-10 14:58:15 +08:00
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
2026-09-13 21:10:05 +08:00
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
2026-10-09 18:03:34 +08:00
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
2026-08-21 17:20:40 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
2026-09-17 13:36:31 +08:00
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
2026-08-19 16:45:51 +08:00
import type { LastMileOrderListItem } from '../types'
2026-10-09 17:27:47 +08:00
import { reworkRelabelApplications } from '../relabelRework'
const reworkOpen = ref(false)
const reworkSaving = ref(false)
const reworkOptions = ref<Array<{ relabelNo: string; status: string }>>([])
const reworkForm = reactive({ relabelNo: '', remark: '' })
2026-08-19 16:45:51 +08:00
const rows = ref<LastMileOrderListItem[]>([])
2026-10-08 17:08:03 +08:00
const submitExport = useExportTask()
2026-08-21 17:20:40 +08:00
const access = useAccessPreviewStore()
2026-08-20 09:10:36 +08:00
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
2026-08-19 16:45:51 +08:00
const router = useRouter()
const page = ref(1)
2026-08-22 12:24:15 +08:00
const pageSize = ref(10)
2026-08-20 09:10:36 +08:00
const total = ref(0)
2026-08-28 13:54:44 +08:00
const containerTotal = ref(0)
const containerRows = ref<ContainerPlanProgressSummaryDto[]>([])
const containerDetailOpen = ref(false)
const containerDetailLoading = ref(false)
const containerDetailError = ref<ApiErrorDetails | null>(null)
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
2026-09-13 21:10:05 +08:00
const transferReviewOpen = ref(false)
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
2026-08-21 17:20:40 +08:00
const selectedRows = ref<LastMileOrderListItem[]>([])
2026-10-09 18:03:34 +08:00
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
2026-09-29 20:52:21 +08:00
function openReturnPackingPlan() {
2026-10-09 18:03:34 +08:00
const selected = selectedReturnRows.value
if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
if (new Set(selected.map(row => row.customerId)).size !== 1 || new Set(selected.map(row => row.overseasWarehouseId)).size !== 1) {
ElMessage.warning('合箱请选择同一客户、同一海外仓的退货订单'); return
}
void router.push({ name: 'overseas-return-inventory', query: {
packing: '1', customerId: selected[0]!.customerId, overseasWarehouseId: selected[0]!.overseasWarehouseId,
returnNos: selected.map(row => row.sourceOrderNo || row.orderNo).join(','),
} })
2026-09-29 20:52:21 +08:00
}
2026-08-30 18:50:16 +08:00
const exportLoading = ref(false)
2026-10-09 18:03:34 +08:00
const actionLoading = ref(false)
2026-10-08 17:08:03 +08:00
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
2026-10-10 14:58:15 +08:00
const palletMergeSelectionEditable = computed(() => selectedRows.value.length > 0
&& selectedRows.value.every(row => row.customerId && row.currentStatusCode !== 'INBOUND_COMPLETED' && row.palletMergeFlag !== 'MERGED')
&& new Set(selectedRows.value.map(row => row.customerId)).size === 1)
const canCancelPalletMerge = computed(() => palletMergeSelectionEditable.value
&& selectedRows.value.every(row => row.palletMergeFlag === 'REQUIRED'))
async function submitPalletMerge(required: boolean) {
if (actionLoading.value || !access.can(permission.lastMileOrderPalletMerge)
|| !(required ? palletMergeSelectionEditable.value : canCancelPalletMerge.value)) return
const selection = [...selectedRows.value]
const label = required ? '合托' : '取消合托'
try {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, label,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
actionError.value = null
try {
await updateLastMileOrderPalletMerge(selection.map(row => row.id), selection[0]!.customerId, required)
ElMessage.success(required ? '已标记合托' : '已取消合托标记')
selectedRows.value = []
await load()
} catch (cause) {
actionError.value = getApiErrorDetails(cause, `${label}失败,请重试`)
} finally { actionLoading.value = false }
}
2026-08-21 17:20:40 +08:00
const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false)
const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWarehouseCode, actionTarget.value?.warehouse]
.filter((value) => value && value !== '—')
.join(' · '))
const inboundPlanMinimumPalletCount = ref(1)
const inboundPlanSourcePalletCount = ref<number | null>(null)
const inboundPlanPreviewLoading = ref(false)
const inboundPlanPreviewError = ref('')
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
2026-09-15 12:35:46 +08:00
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|| (inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0)))
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
const manualNodesOpen = ref(false)
const manualNodesLoading = ref(false)
const manualNodesVersion = ref(0)
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
{ code: 'UNLOADED', label: '卸柜' },
]
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
2026-08-21 17:20:40 +08:00
const transferOpen = ref(false)
2026-08-28 13:54:44 +08:00
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
const transferWarehouseOptions = ref<TransferWarehouseOption[]>([])
2026-08-21 17:20:40 +08:00
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
2026-08-27 17:49:05 +08:00
const containerConvertOpen = ref(false)
2026-08-28 13:54:44 +08:00
const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
2026-08-27 17:49:05 +08:00
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
2026-09-08 23:57:12 +08:00
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
const containerPreviewLoading = ref(false)
const containerPreviewError = ref('')
2026-09-09 00:44:07 +08:00
type ContainerConversionPlan = Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>[number]
interface ContainerConversionResult {
plans: ContainerConversionPlan[]
existingPlanNos: Set<string>
newPlanCount: number
existingPlanCount: number
resourceRecovered: boolean
2026-09-09 00:44:07 +08:00
}
const containerConversionResult = ref<ContainerConversionResult | null>(null)
let containerPreviewRequest = 0
let containerPreviewLoadingBookingId = ''
let containerConversionIdempotencyKey = ''
const containerIsResourceRecovery = computed(() => Boolean(containerPreview.value?.resourceRetry || containerConversionResult.value?.resourceRecovered))
const containerConfirmText = computed(() => containerConversionResult.value
? containerConversionResult.value.resourceRecovered ? '已确认' : '已生成'
: containerPreview.value?.resourceRetry ? '重试资源确认' : '确认生成')
const containerConfirmDisabled = computed(() => !access.can(permission.inboundOrderCreate)
|| !containerPreview.value?.ready
|| (!containerPreview.value.resourceRetry && containerPreview.value.pendingOrderCount <= 0)
|| containerOptionsLoading.value
2026-09-09 00:44:07 +08:00
|| containerPreviewLoading.value
|| actionLoading.value
|| Boolean(containerConversionResult.value))
2026-09-07 15:07:39 +08:00
const receivingWarehouseOpen = ref(false)
const receivingWarehouseLoading = ref(false)
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseActionError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseOptions = ref<TransferWarehouseOption[]>([])
const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(null)
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
2026-08-27 17:49:05 +08:00
let containerOptionRequest = 0
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ businessType: '', businessOrderNo: '', externalBookingNo: '', orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
2026-08-27 17:16:38 +08:00
const allFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '业务来源', width: 180, placeholder: '请选择业务来源', options: [
{ label: '本土散板(全部来源)', value: 'LOCAL_LOOSEBOARD' }, { label: '本土卡车', value: 'US_TRUCK' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '头程订单', value: 'FIRST_LEG' },
{ label: '换标下单', value: 'RELABEL_APPLICATION' }, { label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] },
{ key: 'businessOrderNo', type: 'input', label: '关联商业单号', width: 210, placeholder: '请输入商业单号' },
{ key: 'externalBookingNo', type: 'input', label: '外接预订号', width: 210, placeholder: '请输入外接预订号' },
2026-09-02 14:39:34 +08:00
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
2026-09-13 21:10:05 +08:00
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
2026-09-02 14:39:34 +08:00
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '履约类型', width: 170, placeholder: '请选择履约类型', options: [
2026-08-22 12:24:15 +08:00
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
2026-08-21 17:20:40 +08:00
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
2026-09-15 15:20:28 +08:00
{ label: '货物废弃订单', value: 'CARGO_DISCARD' },
2026-08-21 17:20:40 +08:00
] },
2026-09-02 14:39:34 +08:00
{ key: 'warehouse', type: 'input', label: '海外仓', width: 210, placeholder: '请输入仓库名称或编码' },
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
2026-09-18 11:41:27 +08:00
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
2026-08-20 09:10:36 +08:00
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
2026-09-17 13:36:31 +08:00
{ label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' },
2026-08-21 17:20:40 +08:00
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
2026-08-20 09:10:36 +08:00
] },
2026-09-18 11:41:27 +08:00
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '请选择是否异常', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
2026-09-02 14:39:34 +08:00
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
2026-08-27 17:16:38 +08:00
]
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
2026-08-28 13:54:44 +08:00
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: showAdvancedFilters.value
? availableFields.value
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
2026-08-27 17:49:05 +08:00
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
&& row.warehouseId !== receivingWarehouseTarget.value?.overseasWarehouseId
)))
2026-08-28 13:54:44 +08:00
interface TransferWarehouseOption {
warehouseId: string
warehouseCode: string
warehouseName: string
region: string
label: string
}
function warehouseRegion(warehouse: PlatformWarehouseSummaryResource) {
const addressRegion = [warehouse.primaryAddress?.stateProvince, warehouse.primaryAddress?.city]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' / ')
return addressRegion || String(warehouse.addressSummary || warehouse.countryCode || '').trim()
}
function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource): TransferWarehouseOption {
const warehouseId = String(warehouse.id)
const warehouseCode = warehouse.warehouseCode.trim()
const warehouseName = warehouse.warehouseName.trim()
const region = warehouseRegion(warehouse)
return {
warehouseId,
warehouseCode,
warehouseName,
region,
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
}
}
2026-09-07 15:07:39 +08:00
function receivingWarehouseLabel(container: ContainerPlanProgressSummaryDto | null | undefined) {
if (!container) return '—'
const code = String(container.overseasWarehouseCode || '').trim()
const name = String(container.overseasWarehouseName || '').trim()
return [code, name].filter(Boolean).join(' · ') || '—'
}
function receivingWarehouseChangeDisabledReason(container: ContainerPlanProgressSummaryDto) {
if (!access.can(permission.lastMileOrderTransfer)) return '暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限'
if (!container.canChangeReceivingWarehouse) return container.receivingWarehouseLockReason || '当前柜已锁定,不能修改接柜仓'
return ''
}
2026-09-07 15:07:39 +08:00
function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
2026-08-19 16:45:51 +08:00
const columns: DataTableColumn[] = [
2026-09-14 20:21:02 +08:00
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
2026-08-21 17:20:40 +08:00
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'businessSource', prop: 'businessSource', label: '业务来源', minWidth: 220 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '关联商业单号', minWidth: 230, slotName: 'businessOrderNo' },
{ key: 'externalBookingNo', prop: 'externalBookingNo', label: '外接预订号', minWidth: 220 },
2026-08-31 14:42:28 +08:00
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
2026-08-19 16:45:51 +08:00
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '履约类型', minWidth: 110 },
2026-08-19 16:45:51 +08:00
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
2026-09-06 15:53:48 +08:00
{ key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
2026-08-19 16:45:51 +08:00
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
2026-09-17 13:36:31 +08:00
{ key: 'cartonDifference', prop: 'cartonDifference', label: '差异(箱)', width: 100, align: 'right', slotName: 'cartonDifference' },
2026-08-19 16:45:51 +08:00
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
2026-09-13 21:10:05 +08:00
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
2026-08-19 16:45:51 +08:00
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
2026-09-22 08:47:43 +08:00
{ key: 'relabelOrderNo', slotName: 'relabelOrderNo', label: '换标单号', minWidth: 220 },
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180 },
2026-08-19 16:45:51 +08:00
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
2026-08-22 12:24:15 +08:00
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
2026-08-19 16:45:51 +08:00
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
2026-08-28 13:54:44 +08:00
const containerDetailColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130, slotName: 'orderStatus' },
2026-08-28 13:54:44 +08:00
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130, slotName: 'planStatus' },
2026-08-28 13:54:44 +08:00
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
]
2026-08-19 16:45:51 +08:00
2026-08-28 13:54:44 +08:00
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function inboundPlanStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({
PENDING_ASSIGNMENT: '待入库',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
PENDING_RECEIVING: '待收货',
PICKING: '拣货中',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
CANCELLED: '已取消',
} as Record<string, string>)[code] || (code ? '未知状态' : '—')
}
2026-08-28 13:54:44 +08:00
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
2026-09-10 16:33:43 +08:00
return Boolean(container.containerNo
2026-09-07 16:36:21 +08:00
&& container.overseasWarehouseId
&& container.overseasWarehouseCode
&& container.overseasWarehouseName
&& container.assignmentVersion >= 0
2026-09-10 16:33:43 +08:00
&& (container.pendingPlanCount > 0 || container.resourcePendingPlanCount > 0))
2026-09-07 16:36:21 +08:00
}
function containerSnapshotBlockReason(container: ContainerPlanProgressSummaryDto) {
if (!container.containerNo) return container.blockingReason || '来源柜真实集装箱号缺失、格式无效或存在漂移'
if (!container.overseasWarehouseId || !container.overseasWarehouseCode
|| !container.overseasWarehouseName || container.assignmentVersion < 0) {
return '柜内接柜仓业务快照不一致'
}
return ''
2026-09-02 14:39:34 +08:00
}
function pendingInboundCodeCount(container: ContainerPlanProgressSummaryDto) {
return Math.max(0, container.inboundCodeCount - container.plannedInboundCodeCount)
}
function changeContainerPage(value: number) {
page.value = value
void load()
}
function changeContainerPageSize(value: number) {
pageSize.value = value
page.value = 1
void load()
2026-08-28 13:54:44 +08:00
}
function orderQuery(): LastMileOrderQuery {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
return {
page: page.value,
size: pageSize.value,
businessType: String(filters.value.businessType || '').trim() || undefined,
businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined,
externalBookingNo: String(filters.value.externalBookingNo || '').trim() || undefined,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG',
sailingStage: statusTab.value === '开船前' ? 'BEFORE_DEPARTURE' : statusTab.value === '开船后' ? 'AFTER_DEPARTURE' : undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
lxCode: String(filters.value.lxCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
}
}
2026-08-20 09:10:36 +08:00
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
2026-08-28 13:54:44 +08:00
if (statusTab.value === '柜子') {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
try {
const result = await listContainerPlanProgress({
page: page.value,
size: pageSize.value,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
containerRows.value = result.records
containerTotal.value = result.total
2026-08-28 13:54:44 +08:00
} catch (error) {
containerRows.value = []
containerTotal.value = 0
loadError.value = getApiErrorDetails(error, '柜级入库计划进度加载失败,请重试')
} finally {
loading.value = false
}
return
}
2026-08-20 09:10:36 +08:00
try {
const result = await listLastMileOrders(orderQuery())
2026-08-21 11:20:09 +08:00
rows.value = result.records.map(toLastMileOrderListItem)
2026-08-20 09:10:36 +08:00
total.value = result.total
} catch (error) {
rows.value = []
total.value = 0
2026-08-21 11:20:09 +08:00
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
2026-08-20 09:10:36 +08:00
} finally {
loading.value = false
}
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset() {
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
page.value = 1
void load()
}
2026-08-27 17:49:05 +08:00
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
function selectStatusTab(value: typeof statusTabs[number]) {
if (statusTab.value === value) return
statusTab.value = value
changeStatusTab()
}
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.length - 1
else return
event.preventDefault()
const tab = statusTabs[nextIndex]
if (!tab) return
selectStatusTab(tab)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
2026-08-28 13:54:44 +08:00
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
containerDetailLoading.value = true
containerDetailError.value = null
containerDetail.value = null
try {
containerDetail.value = await getContainerPlanProgress(row.sourceContainerBookingId)
} catch (error) {
containerDetailError.value = getApiErrorDetails(error, '柜级详情加载失败,请重试')
} finally {
containerDetailLoading.value = false
}
}
2026-09-07 15:07:39 +08:00
async function loadReceivingWarehouseOptions() {
receivingWarehouseLoading.value = true
receivingWarehouseError.value = null
receivingWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
receivingWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
receivingWarehouseError.value = getApiErrorDetails(error, '海外接柜仓候选加载失败,请重试')
} finally {
receivingWarehouseLoading.value = false
}
}
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
const disabledReason = receivingWarehouseChangeDisabledReason(container)
if (disabledReason) {
ElMessage.warning(disabledReason)
return
}
2026-09-07 15:07:39 +08:00
receivingWarehouseTarget.value = container
Object.assign(receivingWarehouseForm, { warehouseId: '', remark: '' })
2026-09-07 15:07:39 +08:00
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
receivingWarehouseOptions.value = []
receivingWarehouseError.value = null
receivingWarehouseActionError.value = null
actionError.value = null
receivingWarehouseOpen.value = true
void loadReceivingWarehouseOptions()
}
async function submitReceivingWarehouseChange() {
const target = receivingWarehouseTarget.value
const selected = selectedReceivingWarehouse.value
2026-09-07 15:07:39 +08:00
if (!target || !selected) {
ElMessage.warning('请选择与当前仓不同的有效海外接柜仓')
2026-09-07 15:07:39 +08:00
return
}
actionLoading.value = true
actionError.value = null
receivingWarehouseActionError.value = null
2026-09-07 15:07:39 +08:00
try {
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
overseasWarehouseId: selected.warehouseId,
expectedVersion: target.assignmentVersion,
remark: receivingWarehouseForm.remark.trim() || undefined,
}, receivingWarehouseIdempotencyKey)
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
ElMessage.success('海外接柜仓修改成功')
await load()
} catch (error) {
const details = getApiErrorDetails(error, '海外接柜仓修改失败,请重试')
receivingWarehouseActionError.value = details.code === 'A0409'
? { ...details, message: '接柜仓信息已被其他操作更新,请刷新后重试' }
: details
actionError.value = receivingWarehouseActionError.value
if (details.code === 'A0409') {
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
await load()
}
} finally {
actionLoading.value = false
}
}
2026-08-21 17:20:40 +08:00
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
function isRejectedReturn(source: object) {
const row = source as LastMileOrderListItem
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
}
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
2026-09-20 21:52:12 +08:00
function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) }
2026-09-28 17:26:26 +08:00
const inboundPlanExcludedOrderTypes = new Set(['WAREHOUSE_TRANSFER', 'RELABEL_APPLICATION', 'RETURN_RELABEL'])
function canGenerateInboundPlan(row: LastMileOrderListItem) {
return !inboundPlanExcludedOrderTypes.has(String(row.orderTypeCode || row.orderType || ''))
}
2026-08-28 13:54:44 +08:00
async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true
transferWarehouseError.value = null
transferWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
transferWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
transferWarehouseError.value = getApiErrorDetails(error, 'FBA 仓候选加载失败,请重试')
} finally {
transferWarehouseLoading.value = false
}
}
function selectTransferWarehouse(warehouseId: string) {
const selected = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === String(warehouseId || ''))
Object.assign(transferForm, selected
? { warehouseId: selected.warehouseId, warehouseCode: selected.warehouseCode, warehouseName: selected.warehouseName }
: { warehouseId: '', warehouseCode: '', warehouseName: '' })
}
2026-08-21 17:20:40 +08:00
function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
if (hasSelectedRejectedReturn()) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
2026-08-21 17:20:40 +08:00
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
2026-08-28 13:54:44 +08:00
transferWarehouseOptions.value = []
transferWarehouseError.value = null
2026-08-21 17:20:40 +08:00
actionError.value = null
transferOpen.value = true
2026-08-28 13:54:44 +08:00
void loadTransferWarehouseOptions()
2026-08-31 14:42:28 +08:00
}
2026-08-21 17:20:40 +08:00
async function submitTransfer() {
2026-08-28 13:54:44 +08:00
const selectedWarehouse = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === transferForm.warehouseId)
if (!selectedWarehouse) {
ElMessage.warning('请选择目的 FBA 仓')
2026-08-21 17:20:40 +08:00
return
}
actionLoading.value = true; actionError.value = null
try {
2026-08-28 13:54:44 +08:00
await transferLastMileOrders({
orderIds: selectedRows.value.map((row) => String(row.id)),
warehouseId: selectedWarehouse.warehouseId,
warehouseCode: selectedWarehouse.warehouseCode,
warehouseName: selectedWarehouse.warehouseName,
remark: transferForm.remark.trim() || undefined,
})
2026-08-21 17:20:40 +08:00
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
function idempotencyKey(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
2026-09-20 21:52:12 +08:00
if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum,
typeof target.pallets === 'number' ? target.pallets : 0)
inboundPlanSourcePalletCount.value = null
inboundPlanPreviewError.value = ''
2026-09-15 12:35:46 +08:00
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
actionError.value = null
inboundPlanOpen.value = true
void refreshManualPalletMinimum(target)
}
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
if (!target.sourceContainerBookingId) return
inboundPlanPreviewLoading.value = true
inboundPlanPreviewError.value = ''
try {
const preview = await getContainerInboundPlanPreview(target.sourceContainerBookingId)
if (actionTarget.value?.id !== target.id || !inboundPlanOpen.value) return
const order = preview.groups.flatMap((group) => group.orders).find((item) => item.orderId === target.id)
if (target.orderTypeCode === 'ONE_CLICK_DROPSHIP') {
const sourcePalletCount = Number(order?.sourcePalletCount || 0)
if (sourcePalletCount <= 0) throw new Error('一件代发订单缺少头程封托托盘快照')
inboundPlanSourcePalletCount.value = sourcePalletCount
inboundPlanMinimumPalletCount.value = sourcePalletCount
inboundPlanForm.palletCount = sourcePalletCount
return
}
const suggested = Number(order?.suggestedPalletCount || 0)
inboundPlanMinimumPalletCount.value = Math.max(inboundPlanMinimumPalletCount.value, suggested)
inboundPlanForm.palletCount = Math.max(inboundPlanForm.palletCount, inboundPlanMinimumPalletCount.value)
} catch (error) {
if (actionTarget.value?.id === target.id && inboundPlanOpen.value) {
inboundPlanPreviewError.value = getApiErrorDetails(error, '柜级托盘预览加载失败,请重试').message
}
} finally {
if (actionTarget.value?.id === target.id) inboundPlanPreviewLoading.value = false
}
}
async function submitInboundPlan() {
const row = actionTarget.value
const frozenInboundCode = String(row?.inboundCode || '').trim()
if (inboundPlanIsDropship.value && (inboundPlanSourcePalletCount.value == null
|| inboundPlanForm.palletCount !== inboundPlanSourcePalletCount.value)) {
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
return
}
2026-09-15 12:35:46 +08:00
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
2026-08-27 16:34:32 +08:00
return
}
actionLoading.value = true; actionError.value = null
2026-08-21 17:20:40 +08:00
try {
2026-09-10 17:05:48 +08:00
const result = await createManualInboundPlan({
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan'))
2026-09-10 17:05:48 +08:00
const palletCodesReady = result.pallets?.length === result.palletCount
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
inboundPlanOpen.value = false; selectedRows.value = []
ElMessage.success(palletCodesReady
? '入库计划和托盘码已自动生成'
: '入库计划已保存,整柜计划齐全后将自动生成托盘码')
await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
}
function openHold(source: object) {
const row = source as LastMileOrderListItem
2026-09-18 09:59:12 +08:00
if (!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED') {
ElMessage.warning('货物完成入库后才能暂扣;暂扣只限制后续排车和出库')
return
}
actionTarget.value = row
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
actionError.value = null
holdOpen.value = true
}
async function submitHold() {
const row = actionTarget.value
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false }
}
async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
manualNodesOpen.value = true
manualNodesLoading.value = true
actionError.value = null
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
try {
const result = await getLastMileOrderManualNodes(row.id)
manualNodesVersion.value = result.version
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
finally { manualNodesLoading.value = false }
}
async function submitManualNodes() {
const row = actionTarget.value
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
finally { actionLoading.value = false }
2026-08-27 17:49:05 +08:00
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
containerOptionsLoading.value = true
containerLoadError.value = null
try {
2026-08-28 13:54:44 +08:00
const result = await listContainerPlanProgress({
2026-08-27 17:49:05 +08:00
page: 1,
size: 50,
containerNo: containerNo.trim() || undefined,
})
if (request !== containerOptionRequest) return
containerOptions.value = result.records
} catch (error) {
if (request !== containerOptionRequest) return
containerOptions.value = []
containerLoadError.value = getApiErrorDetails(error, '待分配柜列表加载失败,请重试')
} finally {
if (request === containerOptionRequest) containerOptionsLoading.value = false
}
}
function openContainerConversion() {
selectedContainerBookingId.value = ''
containerOptions.value = []
containerLoadError.value = null
2026-09-08 23:57:12 +08:00
containerPreview.value = null
containerPreviewError.value = ''
2026-09-09 00:44:07 +08:00
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
2026-08-27 17:49:05 +08:00
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
}
2026-09-02 15:15:47 +08:00
function openContainerConversionFor(container: ContainerPlanProgressSummaryDto) {
selectedContainerBookingId.value = container.sourceContainerBookingId
containerOptions.value = [container]
containerLoadError.value = null
2026-09-08 23:57:12 +08:00
containerPreview.value = null
containerPreviewError.value = ''
2026-09-09 00:44:07 +08:00
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
2026-09-02 15:15:47 +08:00
actionError.value = null
containerConvertOpen.value = true
2026-09-08 23:57:12 +08:00
void loadContainerPreview(container.sourceContainerBookingId)
2026-09-02 15:15:47 +08:00
}
2026-09-09 00:44:07 +08:00
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value, preserveResult = false) {
if (containerPreviewLoading.value && containerPreviewLoadingBookingId === sourceContainerBookingId) return
if (containerPreview.value?.sourceContainerBookingId === sourceContainerBookingId) return
const request = ++containerPreviewRequest
2026-09-08 23:57:12 +08:00
containerPreview.value = null
containerPreviewError.value = ''
2026-09-09 00:44:07 +08:00
if (!preserveResult) {
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
}
if (!sourceContainerBookingId) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
return
}
2026-09-08 23:57:12 +08:00
containerPreviewLoading.value = true
2026-09-09 00:44:07 +08:00
containerPreviewLoadingBookingId = sourceContainerBookingId
2026-08-27 17:49:05 +08:00
try {
2026-09-09 00:44:07 +08:00
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreview.value = preview
if (!preserveResult) containerConversionIdempotencyKey = preview.resourceRetry
? preview.resourceRetry.idempotencyKey : idempotencyKey('container-inbound-plan')
2026-08-27 16:34:32 +08:00
} catch (error) {
2026-09-09 00:44:07 +08:00
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
2026-09-08 23:57:12 +08:00
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
2026-08-27 16:34:32 +08:00
} finally {
2026-09-09 00:44:07 +08:00
if (request === containerPreviewRequest) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
}
}
}
async function confirmContainerInboundPlans() {
const preview = containerPreview.value
if (!preview || containerConfirmDisabled.value) return
if (preview.resourceRetry) containerConversionIdempotencyKey = preview.resourceRetry.idempotencyKey
else if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
const resourceRecovery = Boolean(preview.resourceRetry)
2026-09-09 00:44:07 +08:00
const bookingId = preview.sourceContainerBookingId
const commandSnapshot = preview.resourceRetry ?? preview
2026-09-09 00:44:07 +08:00
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
.filter((order) => order.readiness !== 'READY' && order.existingPlanNo)
2026-09-09 00:44:07 +08:00
.map((order) => String(order.existingPlanNo)))
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({
sourceContainerBookingId: bookingId,
snapshotVersion: commandSnapshot.snapshotVersion,
snapshotHash: commandSnapshot.snapshotHash,
2026-09-09 00:44:07 +08:00
}, containerConversionIdempotencyKey)
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
containerConversionResult.value = {
plans,
existingPlanNos,
newPlanCount: plans.length - existingPlanCount,
existingPlanCount,
resourceRecovered: resourceRecovery,
2026-09-09 00:44:07 +08:00
}
ElMessage.success(resourceRecovery
? `已完成 ${plans.length} 个已有计划的资源确认`
: `已生成 ${plans.length - existingPlanCount} 个入库计划`)
2026-09-09 00:44:07 +08:00
containerPreview.value = null
await Promise.all([load(), loadContainerPreview(bookingId, true)])
} catch (error) {
actionError.value = getApiErrorDetails(error, '按柜生成入库计划失败,请使用原操作重试')
} finally {
actionLoading.value = false
2026-08-27 16:34:32 +08:00
}
2026-08-21 17:20:40 +08:00
}
2026-08-20 09:10:36 +08:00
2026-09-08 23:57:12 +08:00
function previewGroupTitle(group: ContainerInboundPlanPreviewGroupDto) {
if (group.destinationType === 'FBA') return `${group.destinationWarehouseName || group.destinationWarehouseCode || '未配置目的仓'} FBA仓`
return group.destinationType === 'ONE_CLICK_DROPSHIP' ? '一件代发' : '私人地址'
}
function destinationText(order: { destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null }) {
return [order.destinationWarehouseCode, order.destinationWarehouseName].filter(Boolean).join(' / ') || '—'
}
function orderTypeText(orderType: string) {
const labels: Record<string, string> = {
FIRST_LEG: '头程订单',
RELABEL_APPLICATION: '换标下单',
WAREHOUSE_TRANSFER: '仓储转运',
LOCAL_LOOSEBOARD: '本土散板',
RETURN_RELABEL: '退货换标',
ONE_CLICK_DROPSHIP: '一件代发',
2026-09-15 15:20:28 +08:00
CARGO_DISCARD: '货物废弃订单',
2026-09-08 23:57:12 +08:00
}
return labels[orderType] || orderType
}
function readinessText(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' ? '可生成' : readiness === 'RESOURCE_PENDING' ? '将自动补齐' : readiness === 'PLANNED' ? '已有计划' : '已阻断'
2026-09-08 23:57:12 +08:00
}
function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' || readiness === 'RESOURCE_PENDING' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger'
2026-09-08 23:57:12 +08:00
}
function blockerText(blockers: string[]) {
const labels: Record<string, string> = {
MISSING_INBOUND_CODE: '缺少入仓码',
ORDER_STATUS_NOT_READY: '订单状态未就绪',
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
DESTINATION_NOT_READY: '目的地未就绪',
CARGO_FACT_INVALID: '货物数据无效',
PLAN_ALREADY_STARTED: '已有计划已开始作业,不能再补生成托盘',
PLAN_PALLET_BINDING_INCOMPLETE: '已有计划只绑定了部分真实托盘,请联系管理员处理',
2026-09-08 23:57:12 +08:00
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
DROPSHIP_PALLET_COUNT_MISMATCH: '一件代发计划托数与头程已封托托盘数不一致,请重新走单',
2026-09-09 00:44:07 +08:00
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
2026-09-08 23:57:12 +08:00
}
2026-09-09 00:44:07 +08:00
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
2026-09-08 23:57:12 +08:00
}
2026-09-09 00:44:07 +08:00
watch(containerConvertOpen, (open) => {
if (open) return
containerPreviewRequest += 1
containerPreviewLoadingBookingId = ''
containerPreviewLoading.value = false
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
})
const discardReviewBusy = ref(false)
function canReviewDiscard(row: LastMileOrderListItem) {
return access.can(permission.cargoDiscardApprove)
&& row.orderTypeCode === 'CARGO_DISCARD'
&& row.currentStatusCode === 'PENDING_REVIEW'
&& Boolean(row.orderNo && row.orderNo !== '—')
&& /^[1-9]\d*$/.test(row.customerId)
}
async function reviewDiscard(row: LastMileOrderListItem) {
if (discardReviewBusy.value || !canReviewDiscard(row)) return
discardReviewBusy.value = true
try {
let decision: 'APPROVE' | 'REJECT' = 'APPROVE'
let remark: string | undefined
try {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, '废弃申请审核', {
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
})
} catch (action) {
if (action !== 'cancel') throw action
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃申请', {
customClass: 'admin-density-confirm', inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
confirmButtonText: '确认驳回', cancelButtonText: '取消',
})
decision = 'REJECT'
remark = value.trim()
}
await reviewCargoDiscard(row.orderNo, row.customerId, decision, remark)
ElMessage.success(decision === 'APPROVE' ? '废弃申请已审核通过' : '废弃申请已驳回')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '废弃申请审核失败').message)
} finally {
discardReviewBusy.value = false
}
}
async function reviewReturn(source: object) {
const row = source as LastMileOrderListItem
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, row.customerId, 'APPROVE')
ElMessage.success('退货预报已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回退货预报', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '退货预报审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
}
}
2026-09-23 11:25:23 +08:00
async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
2026-09-23 11:25:23 +08:00
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
2026-09-23 11:25:23 +08:00
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '贴标单审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message)
}
}
2026-09-17 13:36:31 +08:00
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
2026-10-09 17:27:47 +08:00
function canReworkRelabel(row: LastMileOrderListItem) { return reworkRelabelApplications(row).length > 0 }
2026-09-20 15:47:56 +08:00
function hasRelabelActions(row: LastMileOrderListItem) {
return access.can(permission.relabelOrderStatus)
&& Boolean(row.relabelOrderNo)
&& (canConfirmRelabel(row) || canReworkRelabel(row))
}
2026-09-20 21:52:12 +08:00
const inboundPlanCreatedStatuses = new Set([
'INBOUND_PLANNED',
'PENDING_RECEIVING',
'WAIT_PDA_RECEIVING',
'INBOUND_MEASURING',
'WAIT_CUSTOMER_CONFIRMATION',
'PARTIALLY_RECEIVED',
'INBOUND_COMPLETED',
])
function hasInboundPlan(row: LastMileOrderListItem) {
return inboundPlanCreatedStatuses.has(row.currentStatusCode)
|| Boolean(row.inboundOrderNo && row.inboundOrderNo !== '—')
}
2026-09-18 16:48:40 +08:00
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
2026-09-20 21:52:12 +08:00
const inboundPlanCreated = hasInboundPlan(row)
2026-09-18 16:48:40 +08:00
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review-discard', label: '审核', priority: 480, tone: 'warning', visible: canReviewDiscard(row), disabled: discardReviewBusy.value },
2026-09-23 11:25:23 +08:00
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
2026-09-18 16:48:40 +08:00
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
2026-10-09 11:48:40 +08:00
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
2026-09-20 22:04:59 +08:00
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
2026-09-18 16:48:40 +08:00
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
2026-10-09 17:27:47 +08:00
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
2026-09-18 16:48:40 +08:00
]
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review-discard') void reviewDiscard(row)
2026-09-23 11:25:23 +08:00
else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
else if (command === 'review-return') void reviewReturn(row)
2026-09-18 16:48:40 +08:00
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
else if (command === 'hold') openHold(row)
else if (command === 'manual-nodes') void openManualNodes(row)
else if (command === 'confirm-relabel') void confirmRelabel(row)
else if (command === 'rework-relabel') void reworkRelabel(row)
2026-09-17 13:36:31 +08:00
}
async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
2026-09-17 13:36:31 +08:00
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败').message) }
}
2026-10-09 17:27:47 +08:00
function reworkRelabel(row: LastMileOrderListItem) {
reworkOptions.value = reworkRelabelApplications(row)
if (!reworkOptions.value.length) return
reworkForm.relabelNo = reworkOptions.value[0].relabelNo
reworkForm.remark = ''
reworkOpen.value = true
}
async function submitReworkRelabel() {
if (reworkSaving.value || !reworkForm.relabelNo || !reworkForm.remark.trim()) return
const relabelNo = reworkForm.relabelNo
reworkSaving.value = true
2026-09-17 13:36:31 +08:00
try {
const task = await getRelabelReviewTask(relabelNo)
2026-10-09 17:27:47 +08:00
if (task.taskStatus !== 'WAIT_REVIEW') { ElMessage.warning('仓库尚未提交完工复核,暂不能退回重做'); return }
await returnRelabelForRework(relabelNo, task.version, reworkForm.remark.trim(), crypto.randomUUID())
2026-09-17 13:36:31 +08:00
ElMessage.success('已退回重新换标')
2026-10-09 17:27:47 +08:00
reworkOpen.value = false
2026-09-17 13:36:31 +08:00
await load()
2026-10-09 17:27:47 +08:00
} catch (error) { ElMessage.error(getApiErrorDetails(error, '重新换标失败').message) }
finally { reworkSaving.value = false }
2026-09-14 20:21:02 +08:00
}
2026-08-19 16:45:51 +08:00
function openDetail(source: object) {
const row = source as LastMileOrderListItem
2026-08-21 11:20:09 +08:00
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
2026-08-19 16:45:51 +08:00
}
2026-08-20 09:10:36 +08:00
2026-09-13 21:10:05 +08:00
function openTransferReview(source: object) {
transferReviewTarget.value = source as LastMileOrderListItem
transferReviewOpen.value = true
}
2026-08-21 11:20:09 +08:00
onMounted(() => { void load() })
2026-08-19 16:45:51 +08:00
</script>
<template>
2026-09-14 20:03:27 +08:00
<AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
2026-09-30 14:01:58 +08:00
<DataTable appearance="integrated" :stripe="false" :results-visible="!loadError" :column-settings-mode="statusTab === '柜子' ? 'hide' : 'show'" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="statusTab === '柜子' ? undefined : total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
2026-08-19 16:45:51 +08:00
</section>
</template>
2026-09-30 14:01:58 +08:00
<template #notices><template v-if="loadError">
2026-08-21 11:20:09 +08:00
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
2026-08-20 09:10:36 +08:00
</LoadErrorState>
2026-09-30 14:01:58 +08:00
</template><LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
2026-09-30 14:01:58 +08:00
</LoadErrorState></template>
<template v-if="statusTab === '柜子'" #results><section class="container-workbench" :aria-busy="loading">
2026-09-02 14:39:34 +08:00
<header class="container-workbench__header">
2026-09-10 17:05:48 +08:00
<div><strong>货柜工作台</strong><span>按柜查看和修改海外接柜仓;整柜计划齐全后自动生成托盘码</span></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
2026-09-02 14:39:34 +08:00
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
2026-09-02 15:15:47 +08:00
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
2026-09-07 15:07:39 +08:00
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked" :title="containerSnapshotBlockReason(container)">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked" :title="container.receivingWarehouseLockReason">{{ container.receivingWarehouseLockReason }}</small>
2026-09-07 15:07:39 +08:00
</span>
2026-09-02 14:39:34 +08:00
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
<span class="container-workbench-card__pending">
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
</span>
<span class="container-workbench-card__facts">
<span>共 {{ container.inboundCodeCount }} 个入仓码</span>
2026-09-10 17:05:48 +08:00
<span v-if="container.resourcePendingPlanCount">托盘码自动生成异常,可重试</span>
2026-09-02 14:39:34 +08:00
</span>
2026-09-14 20:03:27 +08:00
<span class="container-workbench-card__actions density-actions">
2026-09-02 15:15:47 +08:00
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
<el-tooltip :content="receivingWarehouseChangeDisabledReason(container)" :disabled="!receivingWarehouseChangeDisabledReason(container)" placement="top">
<span><el-button plain :title="receivingWarehouseChangeDisabledReason(container) || '修改接柜仓'" :disabled="Boolean(receivingWarehouseChangeDisabledReason(container))" @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button></span>
</el-tooltip>
2026-09-10 17:05:48 +08:00
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">{{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '生成入库计划' }}</el-button>
2026-09-02 15:15:47 +08:00
</span>
</article>
2026-09-02 14:39:34 +08:00
</div>
<el-empty v-else-if="!loading" description="暂无货柜数据" />
2026-09-02 14:39:34 +08:00
<div v-if="containerTotal" class="container-workbench__pagination">
<span>共 {{ containerTotal }} 个货柜</span>
2026-09-02 14:39:34 +08:00
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
</div>
2026-09-30 14:01:58 +08:00
</section></template>
2026-10-10 14:58:15 +08:00
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
2026-09-22 08:47:43 +08:00
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
2026-10-12 21:19:07 +08:00
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
2026-09-30 14:01:58 +08:00
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
2026-09-01 11:03:45 +08:00
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
2026-09-17 13:36:31 +08:00
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
2026-08-21 17:20:40 +08:00
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
2026-09-30 14:01:58 +08:00
</DataTable>
2026-08-19 16:45:51 +08:00
</ListPageLayout></AdminPage>
2026-09-13 21:10:05 +08:00
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
2026-08-28 13:54:44 +08:00
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
2026-09-14 20:03:27 +08:00
<el-form class="density-form" v-else label-position="top">
2026-08-28 13:54:44 +08:00
<el-form-item label="目的 FBA 仓" required>
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
</el-select>
</el-form-item>
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
2026-09-02 15:15:47 +08:00
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
2026-08-21 17:20:40 +08:00
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
2026-09-09 00:44:07 +08:00
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
2026-08-27 17:49:05 +08:00
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
2026-09-14 20:03:27 +08:00
<el-form class="density-form" v-else label-position="top">
2026-08-27 17:49:05 +08:00
<el-form-item label="选择待分配柜" required>
2026-09-09 00:44:07 +08:00
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
2026-09-08 23:57:12 +08:00
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
2026-08-27 17:49:05 +08:00
</el-select>
</el-form-item>
</el-form>
2026-09-08 23:57:12 +08:00
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview">
<div class="container-preview__context">
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
2026-09-08 23:57:12 +08:00
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
</div>
<div v-if="!containerIsResourceRecovery" class="container-preview__metrics">
<div class="container-preview__metric"><span>头程已有托盘码</span><strong>{{ containerPreview.sourcePalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>海外仓补充托盘码</span><strong>{{ containerPreview.supplementPalletCount }}</strong></div>
<div class="container-preview__metric"><span>最终生成托盘</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
<div class="container-preview__metric"><span>本次处理订单</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
2026-09-08 23:57:12 +08:00
</div>
<div v-else class="container-preview__metrics">
<div class="container-preview__metric"><span>已保存计划托盘</span><strong>{{ containerPreview.totalPlannedPalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>资源确认</span><strong>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</strong></div>
</div>
2026-09-08 23:57:12 +08:00
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" class="container-convert-alert" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
2026-09-09 00:44:07 +08:00
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</strong><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
2026-09-09 00:44:07 +08:00
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
</article>
</div>
</section>
2026-09-08 23:57:12 +08:00
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</strong>
<span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span>
<span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
2026-09-08 23:57:12 +08:00
</header>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="businessOrderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column label="客户" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="业务类型" min-width="112" show-overflow-tooltip><template #default="{ row }">{{ orderTypeText(row.orderType) }}</template></el-table-column>
<el-table-column label="目的地" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ destinationText(row) }}</template></el-table-column>
<el-table-column label="头程已有" width="94" align="right"><template #default="{ row }">{{ row.sourcePalletCount }}</template></el-table-column>
<el-table-column label="海外仓补充" width="102" align="right"><template #default="{ row }">{{ row.supplementPalletCount }}</template></el-table-column>
<el-table-column label="最终托数" width="94" align="right"><template #default="{ row }">{{ row.targetPalletCount }}</template></el-table-column>
2026-09-08 23:57:12 +08:00
<el-table-column label="已有计划托数" width="118" align="right"><template #default="{ row }">{{ row.plannedPalletCount ?? '—' }}</template></el-table-column>
<el-table-column label="可生成性" width="104"><template #default="{ row }"><el-tag :type="readinessType(row.readiness)" effect="light">{{ readinessText(row.readiness) }}</el-tag></template></el-table-column>
<el-table-column label="阻断原因" min-width="210" show-overflow-tooltip><template #default="{ row }">{{ blockerText(row.blockers) }}</template></el-table-column>
</el-table>
</section>
</div>
</div>
2026-08-28 14:33:57 +08:00
</AppDialog>
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
2026-08-28 13:54:44 +08:00
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
<template #details><ApiErrorMeta :details="containerDetailError" /></template>
</LoadErrorState>
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
2026-08-28 13:54:44 +08:00
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
2026-09-02 14:39:34 +08:00
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
2026-09-07 15:07:39 +08:00
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
2026-08-28 13:54:44 +08:00
</div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #orderStatus="{ row }">{{ lastMileStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).orderStatus) }}</template>
<template #planStatus="{ row }">{{ inboundPlanStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).planStatus) }}</template>
2026-08-28 13:54:44 +08:00
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</template>
2026-09-02 14:39:34 +08:00
</AppDialog>
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
2026-09-07 15:07:39 +08:00
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
2026-09-14 20:03:27 +08:00
<el-form class="density-form" v-else label-position="top">
2026-09-07 15:07:39 +08:00
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
<el-form-item label="新的海外接柜仓" required>
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" :disabled="warehouse.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" />
2026-09-07 15:07:39 +08:00
</el-select>
</el-form-item>
<el-alert v-if="receivingWarehouseForm.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" title="新的海外接柜仓必须与当前仓不同" type="warning" :closable="false" show-icon />
2026-09-07 15:07:39 +08:00
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入修改备注(选填)" /></el-form-item>
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
<template #details><ApiErrorMeta :details="receivingWarehouseActionError" /></template>
</LoadErrorState>
</el-form>
</AppDialog>
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
2026-09-14 20:03:27 +08:00
<el-form class="command-form density-form" label-position="top">
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。</small><el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
2026-09-14 20:03:27 +08:00
<el-form class="command-form density-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
</AppDialog>
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
</AppDialog>
2026-10-09 17:27:47 +08:00
<AppDialog class="admin-density-dialog" v-model="reworkOpen" title="换标退回重做" width="min(620px, calc(100vw - 28px))" confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<el-form class="density-form" label-position="top">
<el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item>
<el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item>
</el-form>
</AppDialog>
2026-08-19 16:45:51 +08:00
</template>
<style scoped>
2026-09-22 08:47:43 +08:00
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
2026-09-30 14:01:58 +08:00
.last-mile-toolbar { min-width: 0; }
2026-09-14 20:03:27 +08:00
.row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
2026-09-14 20:03:27 +08:00
.manual-node-order { display: flex; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: var(--admin-space-5); overflow: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 58fr); align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
2026-09-30 14:01:58 +08:00
2026-09-14 20:03:27 +08:00
.container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: var(--admin-space-5); }
.container-preview { margin-top: var(--admin-space-5); }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-preview__identity { color: var(--cc-text); font-size: var(--admin-font-title); font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric span { color: var(--cc-muted); font-size: var(--admin-font-body); }
2026-09-08 23:57:12 +08:00
.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; }
.container-preview__metric--primary strong { color: var(--cc-primary); }
.container-preview__metric--danger strong { color: var(--el-color-danger); }
2026-09-14 20:03:27 +08:00
.container-preview__scroll { max-height: 48vh; margin-top: var(--admin-space-5); padding-right: var(--admin-space-2); overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: var(--admin-space-5); }
.container-preview__group-header { min-height: 42px; padding: 0 var(--admin-space-5); border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
2026-09-08 23:57:12 +08:00
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
2026-09-14 20:03:27 +08:00
.container-conversion-result { margin-top: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid #86efac; border-radius: var(--admin-radius); background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
.container-conversion-result__plan { min-width: 0; padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #bbf7d0; border-radius: var(--admin-radius); background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); margin: var(--admin-space-3) 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: var(--admin-radius); background: #dcfce7; color: #166534; font-size: var(--admin-font-caption); text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
2026-09-08 23:57:12 +08:00
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2026-09-14 20:03:27 +08:00
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2026-09-14 20:03:27 +08:00
.container-workbench { padding: var(--admin-space-5); }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.container-workbench__header > div { display: grid; gap: var(--admin-space-1); }
.container-workbench__header strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench__header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
.container-workbench-card { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: none; }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: var(--admin-font-body); }
.container-workbench-card__arrival { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench-card__warehouse { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
2026-09-14 20:03:27 +08:00
.container-workbench-card__pending { display: flex; align-items: baseline; gap: var(--admin-space-3); padding: var(--admin-space-5) var(--admin-space-5); border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: var(--admin-font-title); line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: var(--admin-font-body); }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
2026-09-02 14:39:34 +08:00
.container-workbench-card__facts .is-warning { color: #c2410c; }
2026-09-14 20:03:27 +08:00
.container-workbench-card__actions { display: flex; justify-content: flex-end; padding-top: 2px; white-space: nowrap; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--admin-font-body); }
2026-09-02 14:39:34 +08:00
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
2026-09-14 20:03:27 +08:00
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: var(--admin-space-5) 0; border: 1px solid var(--cc-border); }
.container-preview__metric { display: flex; min-height: var(--admin-row-height); padding: var(--admin-space-5) var(--admin-space-5); border: 0; border-radius: 0; background: var(--admin-surface); flex-direction: row; justify-content: flex-start; align-items: baseline; gap: var(--admin-space-3); border-right: 1px solid var(--cc-border); }
.container-preview__metric:last-child { border-right: 0; }
.container-preview__metric strong { margin-top: var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-body); line-height: 20px; margin: 0; }
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
2026-08-19 16:45:51 +08:00
</style>