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

643 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { saveCsv } from '@/shared/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const router = useRouter()
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
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)
const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
const transferWarehouseOptions = ref<TransferWarehouseOption[]>([])
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const containerConvertOpen = ref(false)
const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
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 = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入领星入库单代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] },
{ 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: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
] },
{ 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 },
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
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(' · '),
}
}
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 newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ 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: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
{ 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: 'status', prop: 'status', label: '当前状态', width: 110 },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
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) {
return Boolean(container.containerNo
&& container.overseasWarehouseId
&& container.overseasWarehouseCode
&& container.overseasWarehouseName
&& container.assignmentVersion >= 0
&& container.pendingPlanCount > 0)
}
function containerSnapshotBlockReason(container: ContainerPlanProgressSummaryDto) {
if (!container.containerNo) return container.blockingReason || '来源柜真实集装箱号缺失、格式无效或存在漂移'
if (!container.overseasWarehouseId || !container.overseasWarehouseCode
|| !container.overseasWarehouseName || container.assignmentVersion < 0) {
return '柜内接柜仓业务快照不一致'
}
return ''
}
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()
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
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,
})
const pendingRecords = result.records.filter((container) => pendingInboundCodeCount(container) > 0)
containerRows.value = pendingRecords
containerTotal.value = Math.max(0, result.total - (result.records.length - pendingRecords.length))
} catch (error) {
containerRows.value = []
containerTotal.value = 0
loadError.value = getApiErrorDetails(error, '柜级入库计划进度加载失败,请重试')
} finally {
loading.value = false
}
return
}
try {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
const result = await listLastMileOrders({
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
orderType: String(filters.value.orderType || '').trim() || 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: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : 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,
})
rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total
} catch (error) {
rows.value = []
total.value = 0
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
} finally {
loading.value = false
}
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
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
}
}
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) {
if (!container.canChangeReceivingWarehouse) return
receivingWarehouseTarget.value = container
Object.assign(receivingWarehouseForm, { warehouseId: container.overseasWarehouseId || '', remark: '' })
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 = receivingWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === receivingWarehouseForm.warehouseId)
if (!target || !selected) {
ElMessage.warning('请选择有效的海外接柜仓')
return
}
actionLoading.value = true
actionError.value = null
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
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
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: '' })
}
function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
transferWarehouseOptions.value = []
transferWarehouseError.value = null
actionError.value = null
transferOpen.value = true
void loadTransferWarehouseOptions()
}
async function submitTransfer() {
const selectedWarehouse = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === transferForm.warehouseId)
if (!selectedWarehouse) {
ElMessage.warning('请选择目的 FBA 仓')
return
}
actionLoading.value = true; actionError.value = null
try {
await transferLastMileOrders({
orderIds: selectedRows.value.map((row) => String(row.id)),
warehouseId: selectedWarehouse.warehouseId,
warehouseCode: selectedWarehouse.warehouseCode,
warehouseName: selectedWarehouse.warehouseName,
remark: transferForm.remark.trim() || undefined,
})
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
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: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
return
}
actionLoading.value = true
actionError.value = null
try {
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 {
const result = await listContainerPlanProgress({
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()
}
function openContainerConversionFor(container: ContainerPlanProgressSummaryDto) {
selectedContainerBookingId.value = container.sourceContainerBookingId
containerOptions.value = [container]
containerLoadError.value = null
actionError.value = null
containerConvertOpen.value = true
}
async function convertContainerInboundPlans() {
const container = selectedContainer.value
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
if (!canGenerateContainerPlans(container)) {
actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' }
return
}
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId })
containerConvertOpen.value = false
selectedContainerBookingId.value = ''
ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`)
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试')
} finally {
actionLoading.value = false
}
}
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
}
onMounted(() => { void load() })
</script>
<template>
<AdminPage><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
<el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button>
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
</section>
</template>
<template v-if="loadError" #state>
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<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">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong>{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked">{{ container.receivingWarehouseLockReason }}</small>
</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>
<span class="container-workbench-card__actions">
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
<el-button v-if="access.can(permission.lastMileOrderTransfer) && container.canChangeReceivingWarehouse" plain @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button>
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">生成入库计划</el-button>
</span>
</article>
</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>
</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()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><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>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<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 />
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans">
<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="输入柜号搜索并选择">
<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 || '不可生成'}`}`" />
</el-select>
</el-form-item>
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${pendingInboundCodeCount(selectedContainer)} 个入仓码分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
</el-form>
</AppDialog>
<AppDialog v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<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>海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
</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>
</AppDialog>
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!receivingWarehouseForm.warehouseId || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<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" />
</el-select>
</el-form-item>
<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>
</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; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.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; }
.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; 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__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__warehouse { display: grid; gap: 4px; }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__warehouse strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
.container-workbench-card__warehouse .is-locked { color: #c2410c; }
.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-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.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)); } }
</style>