643 lines
40 KiB
Vue
643 lines
40 KiB
Vue
<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>
|