Merge origin/dev into release/test preserving current workflows

This commit is contained in:
2026-09-14 11:05:47 +08:00
18 changed files with 1362 additions and 211 deletions
@@ -4,12 +4,12 @@ import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { cancelDispatch, getDispatchBolPdf, listDispatches, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -48,6 +48,7 @@ describe('DispatchesView', () => {
})
vi.mocked(updateDispatch).mockReset()
vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
canPermission.mockReset().mockReturnValue(true)
@@ -111,10 +112,13 @@ describe('DispatchesView', () => {
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }")
expect(source).toContain('access.can(permission.dispatchEdit) && isPendingDeparture(row)')
expect(source).toContain("access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode)")
expect(source).toContain('普通编辑不会修改排车状态')
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
expect(source).toContain('未生成出库单前可移除托盘')
expect(source).toContain('removeDispatchItem')
expect(source).toContain('托盘箱唛确认')
expect(source).toContain('palletCartonMarks')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, MoreHorizontal, Pencil, Printer, Truck, XCircle } from '@lucide/vue'
import { Eye, MoreHorizontal, Pencil, Printer, Trash2, Truck, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, listDispatches, transferDispatchToDelivery, updateDispatch, type DispatchSummaryDto } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -27,32 +27,86 @@ const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false)
const filters = ref<QueryModel>({
dispatchNo: '',
vehiclePlate: '',
status: '',
fbaAppointmentNo: '',
})
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
const editSaving = ref(false)
const editItems = ref<DispatchItemDto[]>([])
const removingItemId = ref('')
const bolLoadingNo = ref('')
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, cartonMarksJson: '', remark: '' })
const editForm = reactive({
vehiclePlate: '',
driverName: '',
driverPhone: '',
carrierName: '',
route: '',
departAt: '',
fbaAppointmentNo: '',
fbaAppointmentStatus: '',
vehicleAppointmentStatus: '',
estimatedArrivalAt: '',
})
const transferForm = reactive({
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: '',
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: '',
driverPhone: '',
vehiclePlate: '',
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined as number | undefined,
loadingFee: undefined as number | undefined,
appointmentFee: undefined as number | undefined,
cartonMarksJson: '',
remark: '',
})
const deliveryMethodOptions = [
{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' },
{ label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' },
{ label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' },
{ label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' },
{ label: 'FBA自提', value: 'FBA_PICKUP' },
{ label: '客户自提', value: 'CUSTOMER_PICKUP' },
{ label: '卡派打托', value: 'TRUCK_PALLET' },
{ label: '卡派地板', value: 'TRUCK_FLOOR' },
{ label: '快递', value: 'EXPRESS' },
{ label: '外配', value: 'OUTSOURCED' },
{ label: '专车直送', value: 'DEDICATED_TRUCK' },
{ label: '零担派送', value: 'LTL' },
]
const pickupMethodOptions = [
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' }, { label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' }, { label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' },
{ label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' },
{ label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
]
const loadingTypeOptions = [{ label: '托盘', value: 'PALLET' }, { label: '地板', value: 'FLOOR' }, { label: '混装', value: 'MIXED' }]
const loadingTypeOptions = [
{ label: '托盘', value: 'PALLET' },
{ label: '地板', value: 'FLOOR' },
{ label: '混装', value: 'MIXED' },
]
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = ['53尺大车(30托)', '26尺中车(14托)', '16尺小车(8托)', 'Cargo Van(4托)'].map((label) => ({ label, value: label }))
const appointmentAccountOptions = [
{ label: '4号账号(美西)', value: '4' }, { label: '8号账号(美东)', value: '8' },
{ label: '10号账号(美中)', value: '10' }, { label: '12号账号(加拿大)', value: '12' }, { label: '买约', value: 'BUY_APPOINTMENT' },
{ label: '4号账号(美西)', value: '4' },
{ label: '8号账号(美东)', value: '8' },
{ label: '10号账号(美中)', value: '10' },
{ label: '12号账号(加拿大)', value: '12' },
{ label: '买约', value: 'BUY_APPOINTMENT' },
]
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
@@ -68,10 +122,35 @@ const statusOptions = [
]
const fields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号', width: 220 },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号', width: 180 },
{ key: 'status', type: 'select', label: '车辆状态', placeholder: '全部状态', options: statusOptions, width: 180 },
{ key: 'fbaAppointmentNo', type: 'input', label: 'FBA预约号', placeholder: '请输入FBA预约号', width: 220 },
{
key: 'dispatchNo',
type: 'input',
label: '排车单号',
placeholder: '请输入排车单号',
width: 220,
},
{
key: 'vehiclePlate',
type: 'input',
label: '车牌号',
placeholder: '请输入车牌号',
width: 180,
},
{
key: 'status',
type: 'select',
label: '车辆状态',
placeholder: '全部状态',
options: statusOptions,
width: 180,
},
{
key: 'fbaAppointmentNo',
type: 'input',
label: 'FBA预约号',
placeholder: '请输入FBA预约号',
width: 220,
},
])
const columns: DataTableColumn[] = [
@@ -80,17 +159,60 @@ const columns: DataTableColumn[] = [
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
{ key: 'carrierName', prop: 'carrierName', label: '托车公司名称', minWidth: 160 },
{
key: 'carrierName',
prop: 'carrierName',
label: '托车公司名称',
minWidth: 160,
},
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 220 },
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
{ key: 'overseasWarehouse', prop: 'overseasWarehouse', label: '海外仓', minWidth: 160 },
{
key: 'overseasWarehouse',
prop: 'overseasWarehouse',
label: '海外仓',
minWidth: 160,
},
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
{ key: 'fbaAppointment', prop: 'fbaAppointment', label: 'FBA预约', minWidth: 180 },
{ key: 'vehicleAppointment', prop: 'vehicleAppointment', label: '车辆预约', minWidth: 130 },
{ key: 'estimatedArrivalAt', prop: 'estimatedArrivalAt', label: '预计到达时间', minWidth: 170 },
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 90, align: 'right' },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 80, align: 'right' },
{ key: 'status', prop: 'status', label: '车辆状态', width: 150, slotName: 'status' },
{
key: 'fbaAppointment',
prop: 'fbaAppointment',
label: 'FBA预约',
minWidth: 180,
},
{
key: 'vehicleAppointment',
prop: 'vehicleAppointment',
label: '车辆预约',
minWidth: 130,
},
{
key: 'estimatedArrivalAt',
prop: 'estimatedArrivalAt',
label: '预计到达时间',
minWidth: 170,
},
{
key: 'palletCount',
prop: 'palletCount',
label: '托盘数',
width: 90,
align: 'right',
},
{
key: 'packageCount',
prop: 'packageCount',
label: '箱数',
width: 80,
align: 'right',
},
{
key: 'status',
prop: 'status',
label: '车辆状态',
width: 150,
slotName: 'status',
},
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
@@ -153,7 +275,10 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
function dispatchError(error: unknown) {
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
return details.code === 'HTTP_404'
? { ...details, message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布' }
? {
...details,
message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布',
}
: details
}
@@ -183,9 +308,20 @@ async function load(model: QueryModel = filters.value) {
}
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
function search(model: QueryModel) {
page.value = 1
void load(model)
}
function reset(model: QueryModel) {
page.value = 1
void load(model)
}
function openDetail(row: DispatchListItem) {
void router.push({
name: 'overseas-dispatch-detail',
params: { dispatchNo: row.dispatchNo },
})
}
async function openBol(row: DispatchListItem) {
if (bolLoadingNo.value) return
const pdfWindow = window.open('', '_blank')
@@ -204,29 +340,211 @@ async function openBol(row: DispatchListItem) {
bolLoadingNo.value = ''
}
}
function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }
function canEdit(row: DispatchListItem) { return access.can(permission.dispatchEdit) && isPendingDeparture(row) }
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode) }
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, cartonMarksJson: '', remark: '' }); mutationError.value = null; transferOpen.value = true }
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
function isPendingDeparture(row: DispatchListItem) {
return row.statusCode === 'PENDING_DEPARTURE'
}
function canEdit(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && isPendingDeparture(row)
}
function canCancel(row: DispatchListItem) {
return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE'
}
function canTransfer(row: DispatchListItem) {
return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode)
}
function openEdit(row: DispatchListItem) {
if (!canEdit(row)) return
const source = dispatchSnapshots.get(row.dispatchNo)
editRow.value = row
editItems.value = [...(source?.items || [])]
Object.assign(editForm, {
vehiclePlate: source?.vehiclePlate || '',
driverName: source?.driverName || '',
driverPhone: source?.driverPhone || '',
carrierName: source?.carrierName || '',
route: source?.route || '',
departAt: source?.departAt || '',
fbaAppointmentNo: source?.fbaAppointmentNo || '',
fbaAppointmentStatus: source?.fbaAppointmentStatus || '',
vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '',
estimatedArrivalAt: source?.estimatedArrivalAt || '',
})
mutationError.value = null
editOpen.value = true
}
function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
Object.assign(transferForm, {
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: row.carrierName === '—' ? '' : row.carrierName,
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: row.driverName === '—' ? '' : row.driverName,
driverPhone: row.driverPhone === '—' ? '' : row.driverPhone,
vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate,
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined,
loadingFee: undefined,
appointmentFee: undefined,
cartonMarksJson: '',
remark: '',
})
mutationError.value = null
transferOpen.value = true
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
editSaving.value = true
mutationError.value = null
try {
await updateDispatch(editRow.value.dispatchNo, {
vehiclePlate: editForm.vehiclePlate.trim() || null,
driverName: editForm.driverName.trim() || null,
driverPhone: editForm.driverPhone.trim() || null,
carrierName: editForm.carrierName.trim() || null,
route: editForm.route.trim() || null,
departAt: editForm.departAt || null,
fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null,
fbaAppointmentStatus: editForm.fbaAppointmentStatus || null,
vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null,
estimatedArrivalAt: editForm.estimatedArrivalAt || null,
})
editOpen.value = false
ElMessage.success('排车信息已保存')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试')
} finally {
editSaving.value = false
}
}
async function removePallet(itemValue: unknown) {
const item = itemValue as DispatchItemDto
if (!editRow.value || removingItemId.value) return
try {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', {
confirmButtonText: '确认移除',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
removingItemId.value = String(item.id)
mutationError.value = null
try {
const updated = await removeDispatchItem(editRow.value.dispatchNo, String(item.id))
editItems.value = [...(updated.items || [])]
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
if (row) {
row.palletCount = number(updated.palletCount)
row.packageCount = number(updated.packageCount)
row.orderNos = text(updated.orderNos)
}
ElMessage.success('托盘已移除,可重新排车')
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '托盘移除失败,请重试')
} finally {
removingItemId.value = ''
}
}
async function cancel(row: DispatchListItem) {
if (!canCancel(row)) return
try {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', {
confirmButtonText: '确认取消',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
mutationError.value = null
try {
await cancelDispatch(row.dispatchNo)
ElMessage.success('排车单已取消')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
}
}
function handleMore(command: string, row: DispatchListItem) {
if (command === 'edit') openEdit(row)
else if (command === 'cancel') void cancel(row)
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return }
if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return }
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, feeName]) => ({ feeType, feeName, direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: Number(amount), remark: feeName }))
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (needsLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
ElMessage.warning('买约时请填写卖家姓名和买约费用')
return
}
const feeInputs = [
['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'],
['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
.map(([feeType, amount, feeName]) => ({
feeType,
feeName,
direction: 'RECEIVABLE' as const,
currency: 'USD' as const,
amount: Number(amount),
remark: feeName,
}))
let palletCartonMarks: Record<string, string[]> | undefined
if (transferForm.cartonMarksJson.trim()) {
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
}
transferSaving.value = true
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees, palletCartonMarks }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
try {
await transferDispatchToDelivery(transferRow.value.dispatchNo, {
idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`,
plannedDeliveryAt: transferForm.plannedDeliveryAt,
deliveryMethod: transferForm.deliveryMethod,
waybillNo: transferForm.waybillNo.trim() || undefined,
carrierName: transferForm.carrierName.trim() || undefined,
pickupMethod: transferForm.pickupMethod,
loadingType: transferForm.loadingType,
vehicleType: transferForm.vehicleType || undefined,
vehicleSize: transferForm.vehicleSize || undefined,
driverName: transferForm.driverName.trim() || undefined,
driverPhone: transferForm.driverPhone.trim() || undefined,
vehiclePlate: transferForm.vehiclePlate.trim() || undefined,
appointmentAccount: transferForm.appointmentAccount || undefined,
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
palletCartonMarks,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
} finally {
transferSaving.value = false
}
}
onMounted(load)
@@ -243,6 +561,29 @@ onMounted(load)
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {&quot;PALLET-001&quot;:[&quot;箱唛1&quot;,&quot;箱唛2&quot;]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button text type="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)"><Trash2 :size="15" />移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template>
<style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
<style scoped>
.transfer-grid,
.edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
}
.transfer-grid :deep(.el-select),
.transfer-grid :deep(.el-date-editor),
.transfer-grid :deep(.el-input-number),
.edit-grid :deep(.el-select),
.edit-grid :deep(.el-date-editor) {
width: 100%;
}
h3 {
margin: 4px 0 12px;
}
@media (max-width: 900px) {
.transfer-grid,
.edit-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -622,7 +622,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'")
expect(source).toContain("{ key: 'actions', label: '操作', width: 230, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil } from '@lucide/vue'
import { CheckCircle2, Download, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -15,6 +15,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { lastMileStatusLabel, toLastMileOrderListItem } from '../lastMilePresentation'
@@ -35,6 +36,8 @@ const containerDetailOpen = ref(false)
const containerDetailLoading = ref(false)
const containerDetailError = ref<ApiErrorDetails | null>(null)
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
const transferReviewOpen = ref(false)
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const exportLoading = ref(false)
@@ -102,9 +105,10 @@ 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 filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
@@ -184,7 +188,7 @@ function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 230, 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 },
@@ -210,6 +214,7 @@ const columns: DataTableColumn[] = [
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
@@ -305,6 +310,7 @@ async function load(model: QueryModel = filters.value) {
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: String(filters.value.orderType || '').trim() || undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
@@ -734,6 +740,11 @@ function openDetail(source: object) {
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
}
function openTransferReview(source: object) {
transferReviewTarget.value = source as LastMileOrderListItem
transferReviewOpen.value = true
}
onMounted(() => { void load() })
</script>
@@ -796,11 +807,12 @@ onMounted(() => { void load() })
</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="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" plain type="success" @click="openTransferReview(row)"><CheckCircle2 :size="15" />审核</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<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>
@@ -2,8 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { Printer, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformTenants, listSharedReceivingOverseasWarehouseOptions, type PlatformTenantResource } from '@/modules/iam'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory'
@@ -13,7 +12,7 @@ const stockRows = ref<ReturnStockRow[]>([]), cartonRows = ref<ReturnCartonRow[]>
const inventoryTab = ref<'stock' | 'movements'>('stock')
const movementRows = ref<ReturnInventoryMovement[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const warehouses = ref<Array<{ id: string; warehouseName: string }>>([])
const optionsLoading = ref(false)
const query = reactive({ page: 1, size: 20, customerId: '', overseasWarehouseId: '', skuCode: '', cartonMark: '', returnNo: '', status: 'AVAILABLE' })
function params() { return { ...query, customerId: query.customerId || undefined, overseasWarehouseId: query.overseasWarehouseId || undefined, skuCode: query.skuCode || undefined, cartonMark: query.cartonMark || undefined, returnNo: query.returnNo || undefined, status: query.status || undefined } }
@@ -21,19 +20,25 @@ async function load() { loading.value = true; try { if (props.mode === 'inventor
function search() { query.page = 1; void load() }
function changeInventoryTab() { query.page = 1; void load() }
const customerOptions = computed(() => customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })))
const warehouseOptions = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ label: item.warehouseName, value: String(item.id) })))
const warehouseOptions = computed(() => warehouses.value.map((item) => ({ label: item.warehouseName, value: String(item.id) })))
function customerName(customerId?: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户名称待补充' }
function warehouseName(warehouseId?: string) { return warehouses.value.find((item) => String(item.id) === String(warehouseId))?.warehouseName || '海外仓名称待补充' }
function statusLabel(status?: string) { return status === 'AVAILABLE' ? '可用' : status === 'MERGED' ? '已合箱' : '—' }
async function loadOptions() {
optionsLoading.value = true
try {
const [customerPage, warehousePage] = await Promise.all([
const [customerResult, warehouseResult] = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
listSharedReceivingOverseasWarehouseOptions(),
])
customers.value = customerPage.records
warehouses.value = warehousePage.records
if (customerResult.status === 'fulfilled') customers.value = customerResult.value.records
else customers.value = []
if (warehouseResult.status === 'fulfilled') warehouses.value = warehouseResult.value
else warehouses.value = []
if (customerResult.status === 'rejected' || warehouseResult.status === 'rejected') {
const failed = customerResult.status === 'rejected' ? '客户' : '海外仓'
ElMessage.warning(`${failed}筛选数据加载失败,请检查服务或数据库迁移`)
}
} catch (error) {
ElMessage.warning(error instanceof Error ? `筛选名称加载失败:${error.message}` : '筛选名称加载失败')
} finally { optionsLoading.value = false }
@@ -14,6 +14,14 @@ function columnKeys(fileName: string) {
}
describe('overseas warehouse first-batch prototype contract', () => {
it('shows warehouse transfer review only for pending transfers with its dedicated authority', () => {
const value = source('LastMileOrdersView.vue')
expect(value).toContain('access.can(permission.warehouseTransferApprove)')
expect(value).toContain("row.orderTypeCode === 'WAREHOUSE_TRANSFER'")
expect(value).toContain("row.currentStatusCode === 'PENDING_REVIEW'")
expect(value).toContain('<WarehouseTransferReviewDialog')
})
it('gives every formal list query toolbar a unique stable preference key', () => {
const expected = new Map([
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
@@ -247,7 +255,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).toContain('planPalletIds: assignedIds.value')
expect(dialog).toContain('lockDispatchDraftPallet')
expect(dialog).toContain('unlockDispatchDraftPallet')
expect(dialog).toContain('releaseDispatchDraft')
expect(dialog).not.toContain('releaseDispatchDraft')
expect(dialog).toContain('排车明细已同步到预排列表')
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
expect(dialog).not.toContain('customerId:')
expect(dialog).toContain("planIds: planIds.value.length ? planIds.value.join(',') : undefined")
@@ -269,6 +278,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).toContain('退货换标')
expect(dialog).toContain('本土散板')
expect(dialog).toContain('仓储转运')
expect(dialog).toContain('relabelCartonMarks(item)')
expect(dialog).toContain('type="warning">换标</el-tag>')
expect(dialog).not.toContain('function cartonMarks(')
expect(dialog).not.toContain('`normal-${mark}`')
expect(dialog).not.toContain('暂无箱唛')
expect(dialog).toContain('class="warehouse-tabs"')
expect(dialog).not.toContain('排车路线(选填)')
expect(dialog).not.toContain('v-model="route"')
@@ -335,9 +349,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
expect(value.indexOf("label: '入库单号'")).toBeLessThan(value.indexOf("label: '出库单号'"))
expect(value.indexOf("label: '出库单号'")).toBeLessThan(value.indexOf("label: '派送单号'"))
expect(value.indexOf("label: '派送单号'")).toBeLessThan(value.indexOf("label: '换标单号'"))
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))
expect(columns.indexOf("label: '派送单号'")).toBeLessThan(columns.indexOf("label: '换标单号'"))
expect(value).not.toMatch(/router\.push\([^)]*(outbound|dispatch)/)
})
@@ -355,7 +370,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([