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

504 lines
31 KiB
Vue
Raw Normal View History

2026-08-19 16:45:51 +08:00
<script setup lang="ts">
2026-08-21 17:20:40 +08:00
import { computed, onMounted, reactive, ref } from 'vue'
2026-08-19 16:45:51 +08:00
import { Eye } from '@lucide/vue'
2026-08-21 17:20:40 +08:00
import { ElMessage } 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-08-28 13:54:44 +08:00
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
2026-08-21 17:20:40 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-20 09:10:36 +08:00
import { toLastMileOrderListItem } from '../lastMilePresentation'
2026-08-19 16:45:51 +08:00
import type { LastMileOrderListItem } from '../types'
const rows = ref<LastMileOrderListItem[]>([])
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-08-21 17:20:40 +08:00
const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
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('')
let containerOptionRequest = 0
2026-08-28 13:54:44 +08:00
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
2026-08-21 17:20:40 +08:00
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
2026-08-27 17:16:38 +08:00
const allFields: QueryField[] = [
2026-09-02 14:39:34 +08:00
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ 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-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: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '全部状态', options: [
2026-08-20 09:10:36 +08:00
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
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-02 14:39:34 +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' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
2026-08-27 17:16:38 +08:00
]
2026-08-28 13:54:44 +08:00
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
2026-08-27 17:49:05 +08:00
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
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-08-19 16:45:51 +08:00
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, 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' },
2026-08-19 16:45:51 +08:00
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ 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' },
{ 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 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
{ key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100 },
{ 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 },
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130 },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
]
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
2026-09-02 14:39:34 +08:00
return Boolean(container.containerNo) && container.pendingPlanCount > 0
}
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
}
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,
})
2026-09-02 14:39:34 +08:00
const pendingRecords = result.records.filter((container) => pendingInboundCodeCount(container) > 0)
containerRows.value = pendingRecords
containerTotal.value = Math.max(0, result.total - (result.records.length - pendingRecords.length))
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 {
2026-08-21 17:20:40 +08:00
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
2026-08-20 09:10:36 +08:00
const result = await listLastMileOrders({
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
2026-08-21 17:20:40 +08:00
orderType: String(filters.value.orderType || '').trim() || undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
2026-08-20 09:10:36 +08:00
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
2026-08-21 17:20:40 +08:00
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
2026-08-20 09:10:36 +08:00
containerNo: String(filters.value.containerNo || '').trim() || undefined,
2026-08-27 17:49:05 +08:00
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
2026-08-21 17:20:40 +08:00
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
})
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(model: QueryModel) { page.value = 1; void load(model) }
2026-08-27 17:49:05 +08:00
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
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-08-21 17:20:40 +08:00
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
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 }
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-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 }
}
2026-08-27 17:49:05 +08:00
async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
const selected = selectedRows.value[0]
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
if (!customerId) {
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
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-08-27 17:49:05 +08:00
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []
ElMessage.success('入库计划生成成功')
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
} finally {
actionLoading.value = false
}
}
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
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
}
async function convertContainerInboundPlans() {
const container = selectedContainer.value
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
2026-08-28 13:54:44 +08:00
if (!canGenerateContainerPlans(container)) {
actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' }
2026-08-27 17:49:05 +08:00
return
}
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId })
containerConvertOpen.value = false
selectedContainerBookingId.value = ''
ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`)
2026-08-27 16:34:32 +08:00
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试')
} finally {
actionLoading.value = false
}
2026-08-21 17:20:40 +08:00
}
2026-08-20 09:10:36 +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-08-21 11:20:09 +08:00
onMounted(() => { void load() })
2026-08-19 16:45:51 +08:00
</script>
<template>
2026-08-21 17:20:40 +08:00
<AdminPage><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
2026-08-19 16:45:51 +08:00
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
2026-08-20 09:10:36 +08:00
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
2026-08-19 16:45:51 +08:00
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
2026-09-02 14:39:34 +08:00
<el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button>
2026-08-19 16:45:51 +08:00
</el-radio-group>
2026-08-20 09:10:36 +08:00
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
2026-08-19 16:45:51 +08:00
</section>
</template>
2026-08-21 11:20:09 +08:00
<template v-if="loadError" #state>
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
2026-08-20 09:10:36 +08:00
</LoadErrorState>
</template>
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
2026-09-02 14:39:34 +08:00
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel container-workbench" :aria-busy="loading">
<header class="container-workbench__header">
<div><strong>待处理柜工作台</strong><span>按计划到仓时间由近到远排列,已全部生成计划的柜不再展示</span></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个柜待处理</el-tag>
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
<button v-for="container in containerRows" :key="container.sourceContainerBookingId" type="button" class="container-workbench-card" @click="openContainerDetail(container)">
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong><span>查看详情</span></span>
<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>
</span>
</button>
</div>
<el-empty v-else-if="!loading" description="暂无待处理柜,所有柜均已完成计划分配" />
<div v-if="containerTotal" class="container-workbench__pagination">
<span>共 {{ containerTotal }} 个待处理柜</span>
<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-08-28 13:54:44 +08:00
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
2026-08-27 17:49:05 +08:00
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
2026-08-19 16:45:51 +08:00
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
2026-09-01 11:03:45 +08:00
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
2026-08-21 17:20:40 +08:00
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
2026-08-19 16:45:51 +08:00
</DataTable></section>
</ListPageLayout></AdminPage>
2026-08-28 13:54:44 +08:00
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<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-08-21 17:20:40 +08:00
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
2026-08-28 13:54:44 +08:00
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans">
2026-08-27 17:49:05 +08:00
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择">
2026-08-28 13:54:44 +08:00
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!canGenerateContainerPlans(container)" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票${canGenerateContainerPlans(container) ? '' : ` · ${container.blockingReason || '不可生成'}`}`" />
2026-08-27 17:49:05 +08:00
</el-select>
</el-form-item>
2026-08-28 13:54:44 +08:00
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${selectedContainer.pendingPlanCount} 票分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
2026-08-27 17:49:05 +08:00
</el-form>
</AppDialog>
2026-09-02 14:39:34 +08:00
<AppDialog 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>计划到仓:{{ 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-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 #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</template>
2026-09-02 14:39:34 +08:00
</AppDialog>
2026-08-19 16:45:51 +08:00
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
2026-08-27 17:49:05 +08:00
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
2026-08-28 13:54:44 +08:00
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
2026-09-02 14:39:34 +08:00
.container-workbench { padding: 18px; }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.container-workbench__header > div { display: grid; gap: 4px; }
.container-workbench__header strong { color: var(--cc-text); font-size: 16px; }
.container-workbench__header span { color: var(--cc-muted); font-size: 13px; }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
.container-workbench-card:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
.container-workbench-card__arrival { display: grid; gap: 4px; }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
2026-08-19 16:45:51 +08:00
</style>