feat(fleet): integrate trip management and fee coverage workflows

This commit is contained in:
2026-10-03 04:35:55 +08:00
parent bca08f8749
commit dc35288a1b
87 changed files with 3774 additions and 233 deletions
@@ -19,8 +19,8 @@ describe('DeliveryOrderDetailView', () => {
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 },
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'PENDING_FINANCE' },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'FINANCE_ATTENTION' },
], operations: [],
})
})
@@ -36,6 +36,9 @@ describe('DeliveryOrderDetailView', () => {
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
expect(wrapper.text()).toContain('作废待财务确认')
expect(wrapper.text()).toContain('作废待财务处理')
expect(wrapper.text()).not.toContain('已作废')
wrapper.unmount()
})
@@ -47,4 +50,17 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('缺少派送单客户范围')
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
source: { financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '100', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original', status: 'AVAILABLE' }, services: ['DELIVERY_FEE', 'LOADING_FEE'],
}] }
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('原费用金额供核对,不计作本次新增')
expect(wrapper.text()).toContain('IN-11 · 原运费 · CNY 100 · 版本 3 · 已含派送、装车')
wrapper.unmount()
})
})
@@ -10,7 +10,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
const route = useRoute()
const router = useRouter()
@@ -26,10 +27,17 @@ const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function feeStatus(fee: DeliveryFeeRow) {
if (fee.voidStatus === 'CONFIRMED') return '已作废'
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
}
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
@@ -95,10 +103,14 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column prop="remark" label="备注" min-width="240" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
</el-table>
</section>
</el-tab-pane>
@@ -29,4 +29,15 @@ describe('DispatchDetailView', () => {
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', status: 'PENDING_DEPARTURE', items: [
{ id: '11', planPalletId: '31', orderNo: 'LM-1', warehousePalletId: '21', grossWeightKg: 88.5, volumeCbm: 1 },
{ id: '12', planPalletId: '32', orderNo: 'LM-1', warehousePalletId: '22', grossWeightKg: null, volumeWeightKg: 999, volumeCbm: 1 },
] })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,7 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number
weight: number | null
palletCount: number
fbaWarehouse: string
volume: number
@@ -84,21 +85,22 @@ const documentFields = computed<DetailField[]>(() => [
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number | null; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
current.weight += Number(item.grossWeightKg || 0)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: Number(value.weight.toFixed(3)),
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
@@ -1,16 +1,18 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { readFileSync } from 'node:fs'
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: 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(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -52,6 +54,8 @@ describe('DispatchesView', () => {
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
canPermission.mockReset().mockReturnValue(true)
})
@@ -150,6 +154,54 @@ describe('DispatchesView', () => {
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
] })
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchExternalFreight).toHaveBeenCalledOnce()
expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
expect(dialog.text()).toContain('FB-1')
expect(dialog.text()).not.toContain('请输入派送费用')
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' }))
})
it('confirms an original delivery fee without submitting a new receivable', async () => {
vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo',
cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
})
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true)
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
expect(dialog.text()).toContain('本次全部货物已覆盖:派送')
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
})
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
@@ -14,7 +14,9 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto, type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices, coveredServiceLabels, type FeeCoveragePreview, type FeeCoverageSelection } from '../feeCoverageTypes'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -38,6 +40,15 @@ const filters = ref<QueryModel>({
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const externalFreight = ref<ExternalFreightPreview | null>(null)
const coveragePreview = ref<FeeCoveragePreview | null>(null)
const coverageSelection = ref<FeeCoverageSelection[]>([])
const coveredServices = computed(() => fullyCoveredServices(coveragePreview.value, coverageSelection.value, externalFreight.value?.quotes.map(row => row.incomingNo) || []))
const requiresDeliveryFee = computed(() => !!externalFreight.value?.requiresDeliveryFee && !coveredServices.value.includes('DELIVERY_FEE'))
const requiresLoadingFee = computed(() => needsLoadingFee.value && !coveredServices.value.includes('LOADING_FEE'))
const requiresAppointmentFee = computed(() => isBuyAppointment.value && !coveredServices.value.includes('APPOINTMENT_FEE'))
const freightLoading = ref(false)
let freightGeneration = 0
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
@@ -373,7 +384,7 @@ function openEdit(row: DispatchListItem) {
mutationError.value = null
editOpen.value = true
}
function openTransfer(row: DispatchListItem) {
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
Object.assign(transferForm, {
@@ -397,6 +408,19 @@ function openTransfer(row: DispatchListItem) {
})
mutationError.value = null
transferOpen.value = true
externalFreight.value = null
coveragePreview.value = null
coverageSelection.value = []
freightLoading.value = true
const generation = ++freightGeneration
try {
const [preview, coverage] = await Promise.all([getDispatchExternalFreight(row.dispatchNo), getDispatchFeeCoverage(row.dispatchNo)])
if (generation === freightGeneration && transferOpen.value) { externalFreight.value = preview; coveragePreview.value = coverage }
} catch (cause) {
if (generation === freightGeneration && transferOpen.value) mutationError.value = getApiErrorDetails(cause, '约定运费读取失败,请重新打开转派送')
} finally {
if (generation === freightGeneration) freightLoading.value = false
}
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
@@ -494,26 +518,27 @@ function handleRowAction(command: string, row: DispatchListItem) {
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (transferForm.deliveryFee == null) {
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (needsLoadingFee.value && transferForm.loadingFee == null) {
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && 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, '买约费用'],
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
@@ -544,6 +569,9 @@ async function submitTransfer() {
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
@@ -569,7 +597,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-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><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>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-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><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" 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 class="admin-density-dialog" 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 class="density-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" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></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 class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</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>
@@ -118,7 +118,7 @@ async function mountView() {
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
@@ -159,6 +159,74 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '待分配'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
await selectRows(wrapper, [selectedRow()])
await tabs[0]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('is-active')
expect(document.activeElement).toBe(tabs[1]!.element)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, currentStatus: 'PENDING_ASSIGNMENT' }))
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
await tabs[1]!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(callCount)
await tabs[1]!.trigger('keydown', { key: 'Home' })
await flushPromises()
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined }))
await tabs[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(13)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'],
})
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
expect(toolbar.props('modelValue').containerNo).toBe(' CONT-15 ')
await button(wrapper, '查询')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
containerNo: 'CONT-15', abnormal: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-30T23:59:59',
}))
await button(wrapper, '重置')!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls + 2)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1, containerNo: undefined, abnormal: undefined, createdFrom: undefined, createdTo: undefined,
}))
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('modelValue').containerNo).toBe('')
expect(toolbar.props('modelValue').createdAt).toEqual([])
})
it('keeps container filters separate from the advanced order fields', async () => {
const wrapper = await mountView()
await button(wrapper, '高级筛选')!.trigger('click')
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.findComponent(QueryToolbar).props('fields').map((field) => field.key)).toEqual(['containerNo', 'createdAt'])
expect(button(wrapper, '收起筛选')).toBeUndefined()
expect(button(wrapper, '高级筛选')).toBeUndefined()
})
it('keeps the original carton count and adds only the signed difference in the list', async () => {
canAccess.mockReturnValue(false)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
@@ -300,7 +368,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
@@ -310,7 +378,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '全部')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, PackagePlus } from '@lucide/vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -143,6 +143,8 @@ const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const statusTabs = ['全部', '待分配'] as const
const showAdvancedFilters = ref(false)
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: '请输入尾程单号' },
@@ -176,7 +178,9 @@ const allFields: QueryField[] = [
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
: showAdvancedFilters.value
? allFields
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
@@ -381,12 +385,35 @@ 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 reset() {
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
page.value = 1
void load()
}
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
function selectStatusTab(value: typeof statusTabs[number]) {
if (statusTab.value === value) return
statusTab.value = value
changeStatusTab()
}
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.length - 1
else return
event.preventDefault()
const tab = statusTabs[nextIndex]
if (!tab) return
selectStatusTab(tab)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
@@ -944,13 +971,22 @@ onMounted(() => { void load() })
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" 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>
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</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" />
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" 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">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</section>
</template>
<template #notices><template v-if="loadError">
@@ -208,7 +208,7 @@ async function submit() {
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
@@ -98,7 +98,7 @@ const tableColumns = computed(() => props.mode === 'cartons' ? cartonColumns : i
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" v-model:page="query.page" v-model:page-size="query.size" :data="tableRows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`" @update:page="load" @update:page-size="query.page = 1; load()">
<template #toolbar-start><h1>{{ mode === 'inventory' ? '退货库存管理' : '退货箱号管理' }}</h1></template>
<template #toolbar-actions><el-button v-if="mode==='inventory'" type="warning" @click="openPackingPlan">创建合箱计划</el-button></template>
<template #filters><el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #filters><el-tabs class="density-element-tabs density-status-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #stock-0="{ row }">{{ customerName(row.customerId) }}</template>
<template #stock-1="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #movement-0="{ row }">{{ customerName(row.customerId) }}</template>
@@ -41,7 +41,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
const toolbar = value.match(/<QueryToolbar\b[^>]*\/>/)?.[0]
const toolbar = value.match(/<QueryToolbar\b[^>]*>/)?.[0]
expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
return preferenceKey
@@ -374,8 +374,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择目的 FBA 仓')
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).toContain("const statusTabs = ['全部', '待分配'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
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: '派送单号'"))