Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts
T
2026-11-04 10:39:15 +08:00

421 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLastMileOrderFee, getLastMileOrder } from '../api/lastMileOrders'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOverseasFeeItems, type OverseasFee } from '@/modules/overseas-business'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
const can = vi.hoisted(() => vi.fn(() => false))
const push = vi.hoisted(() => vi.fn())
const getDocumentFees = vi.hoisted(() => vi.fn(async (): Promise<OverseasFee[]> => []))
vi.mock('@/modules/overseas-business', async () => ({ BusinessDocumentFees: { name: 'BusinessDocumentFees', props: { businessType: String, businessId: String, businessNo: String, customerId: String, relatedDocuments: Array, direction: String, canCreate: Boolean, hideWhenEmpty: Boolean, heading: String }, template: '<div><slot name="toolbar-actions" /></div>', methods: { refresh() {}, getFees: getDocumentFees } }, listOverseasFeeItems: vi.fn(), sameProjectFees: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFees, sameProjectFeeSummary: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFeeSummary, feeStatus: (await import('../../overseas-business/overseasFeePresentation')).feeStatus, overseasBusinessPermissions: (await import('../../overseas-business/permissions')).overseasBusinessPermissions }))
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
const feeSupplier: Supplier = { id: '9223372036854775001', tenantId: '0', name: '测试供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
async function openFeeEditor() {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
return { wrapper, dialog }
}
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
selects[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
await flushPromises()
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
}
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL', receivedQuantity: 1, boxCount: 0, signedAt: '2026-10-20T10:00:00' }, { receiptNo: 'B1', unitType: 'BOX', receivedQuantity: 1, boxCount: 1, signedAt: '2026-10-20T10:00:00', length: 30, width: 20, height: 10, weight: 2 }],
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', countStatus: 'COUNTED', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', countStatus: 'COUNTED', items: [] }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)')
expect(wrapper.text()).not.toContain('2 包裹')
expect(wrapper.text()).toContain('长 30 cm · 宽 20 cm · 高 10 cm · 体积 0.006 m³ · 重量 2 kg')
})
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', status, expectedQuantity: 30, receivedQuantity: 5 }, receipts: [],
items: [{ skuCode: 'SKU-FORECAST', productNameZh: '预报商品', expectedQuantity: 30, receivedQuantity: 5 }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('[aria-label="基本信息"]').text()).not.toContain('箱数')
await wrapper.findAll('[role="tab"]').find(tab => tab.text().includes('货物信息'))!.trigger('click'); await flushPromises()
const panel = wrapper.get('.table-panel')
expect(panel.text()).toContain('SKU-FORECAST')
expect(panel.text()).toContain('预报商品')
expect(panel.text()).toContain('预报件数')
expect(panel.text()).toContain('30')
expect(panel.text()).not.toContain('箱数')
})
it('keeps customer forecast after completion instead of using actual signed cargo', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: 'INBOUND_COMPLETED' },
cargo: [{ id: 'actual', skuCode: 'SKU-ACTUAL', productNameZh: '实收商品', productQuantity: 25, packageCount: 2 }], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', status: 'COMPLETED', expectedQuantity: 30, receivedQuantity: 25 }, receipts: [], items: [{ skuCode: 'SKU-FORECAST', expectedQuantity: 30, receivedQuantity: 25 }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('[role="tab"]').find(tab => tab.text().includes('货物信息'))!.trigger('click'); await flushPromises()
expect(wrapper.get('.table-panel').text()).not.toContain('SKU-ACTUAL')
expect(wrapper.get('.table-panel').text()).toContain('SKU-FORECAST')
expect(wrapper.get('.table-panel').text()).toContain('30')
expect(wrapper.get('.table-panel').text()).not.toContain('箱数')
})
it('shows signed box contents and actual quantities on return orders', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail-return', customerId: 'customer-1', orderNo: 'RETU-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', receivedQuantity: 10, expectedQuantity: 10 }, receipts: [{ receiptNo: 'RR-1', unitType: 'PALLET', unitCode: 'PLT-1', receivedQuantity: 10, boxCount: 2, signedAt: '' }],
cartons: [{ receiptNo: 'RR-1', cartonMark: 'RC#2-1', countStatus: 'CONFIRMED', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 7 }] }, { receiptNo: 'RR-2', cartonMark: 'RC-PENDING', countStatus: 'PENDING', items: [] }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('[role="tab"]').find(tab => tab.text().includes('退货签收与箱内清点'))!.trigger('click'); await flushPromises()
const panel = wrapper.get('[aria-label="退货签收与箱内清点"]')
expect(panel.text()).toContain('实收 10 / 预报 10 件')
expect(panel.text()).toContain('RC#2-1')
expect(panel.text()).toContain('SKU-A')
expect(panel.text()).toContain('实际签收数量')
expect(panel.text()).toContain('待清点')
})
it('loads transfer fulfillment only on the plan tab using sourceOrderNo, never the tail order number', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-transfer-tail', customerId: 'customer-1', orderNo: 'WT-TAIL-1', sourceOrderNo: 'WT-SOURCE-1', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
vi.mocked(getWarehouseTransfer).mockResolvedValue({ id: 'internal-transfer', transferNo: 'WT-SOURCE-1', needRelabel: true, status: 'WAIT_RELABEL', relabelNo: 'RL-1', relabelStatus: 'CUSTOMER_CONFIRM_PENDING', packageCount: 2, items: [], cartons: [], pallets: [], relabelBindings: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(getWarehouseTransfer).not.toHaveBeenCalled()
await wrapper.findAll('[role="tab"]').find(x => x.text().includes('计划信息'))!.trigger('click'); await flushPromises()
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-SOURCE-1')
expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('待客户确认')
expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('不得提前出库')
})
it('fills the receiving warehouse from its ID when the order name is absent', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'C1', orderNo: 'WT-TAIL', orderType: 'WAREHOUSE_TRANSFER', overseasWarehouseId: '193000000000200003' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
vi.mocked(getPlatformWarehouse).mockResolvedValue({ warehouse: { id: '193000000000200003', warehouseName: '洛杉矶收货仓', warehouseCode: 'US-LAX-01', tenantId: '0', internalCode: null, warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }, addresses: [], capabilities: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(getPlatformWarehouse).toHaveBeenCalledWith('193000000000200003')
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('海外仓洛杉矶收货仓')
expect(wrapper.get('[aria-label="基本信息"]').text()).toContain('海外仓洛杉矶收货仓')
})
it('loads transfer declarations and actual cartons on the cargo tab using the source transfer', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'customer-1', orderNo: 'TAIL-NO', sourceOrderNo: 'WT-SOURCE', orderType: 'WAREHOUSE_TRANSFER' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
vi.mocked(getWarehouseTransfer).mockResolvedValue({ id: 'transfer', transferNo: 'WT-SOURCE', status: 'WAIT_PDA_RECEIVING', needRelabel: false, packageCount: 5, items: [], cartons: [], pallets: [], relabelBindings: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(getWarehouseTransfer).not.toHaveBeenCalled()
await wrapper.get('#tab-cargo').trigger('click'); await flushPromises()
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-SOURCE')
const panel = wrapper.get('[aria-label="仓储转运预报与实际签收"]')
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('预报箱数5 箱')
expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('实收箱数0 箱')
expect(panel.text()).toContain('预报与实际签收对比')
expect(panel.text()).toContain('签收箱号明细(0 箱)')
expect(panel.text()).toContain('查看箱号明细')
})
beforeEach(() => {
getDocumentFees.mockReset().mockResolvedValue([])
can.mockReset().mockReturnValue(false)
vi.mocked(listOverseasFeeItems).mockReset().mockResolvedValue([{ feeCode: 'FEE_TYPE_071', feeTypeName: '贴标费', defaultPrice: 1 }, { feeCode: 'FEE_TYPE_077', feeTypeName: '仓租', defaultPrice: 1 }])
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(getPlatformWarehouse).mockReset()
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
})
})
it('allows selecting and submitting manual storage rent', async () => {
const { dialog } = await openFeeEditor()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' }).some(option => option.props('value') === 'FEE_TYPE_077')).toBe(true)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 80 }))
})
it.each(['RETURN_RELABEL', 'WAREHOUSE_TRANSFER'])('creates a %s fee without charging quantity or unit inputs', async orderType => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType, currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const { dialog } = await openFeeEditor()
expect(dialog.text()).not.toContain('计费数量')
expect(dialog.text()).not.toContain('计费单位')
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 80)
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledOnce()
const payload = vi.mocked(createLastMileOrderFee).mock.calls[0]![1]
expect(payload.amount).toBe(80)
expect(payload).not.toHaveProperty('chargeableQuantity')
expect(payload).not.toHaveProperty('quantityUnit')
expect(payload).not.toHaveProperty('unitPrice')
})
it('shows business references without changing the transfer workflow or requiring business-list permission', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
const source = wrapper.get('[aria-label="商业来源与关联单据"]')
expect(source.text()).toContain('本土散板 · 拼车需求池')
expect(source.text()).toContain('LB-1')
expect(source.text()).toContain('FB-1')
expect(source.find('button').exists()).toBe(false)
expect(wrapper.get('[aria-label="基本信息"]').text()).toContain('履约类型仓储转运')
expect(getWarehouseTransfer).not.toHaveBeenCalled()
})
it('shows one business heading, a semantic status and summary without losing basic fields', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('h1')).toHaveLength(1)
expect(wrapper.get('h1').text()).toBe('FL-WH-001')
const header = wrapper.get('.density-detail-header')
expect(header.text()).not.toContain('尾程订单详情')
expect(header.text()).not.toContain('尾程单号')
expect(header.get('.status-badge').classes()).toContain('is-warning')
expect(header.get('.order-reference-text__copy').classes()).toContain('is-text')
const summary = wrapper.get('[aria-label="尾程订单概览"]')
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '30 kg', '22 kg', '6.374 m³', expect.stringContaining('2026-09-14')])
const fields = wrapper.get('dl[aria-label="基本信息"]')
expect(fields.findAll('dt')).toHaveLength(22)
expect(fields.text()).toContain('货物毛重(kg)30')
expect(fields.text()).toContain('货物净重(kg)22')
expect(fields.text()).toContain('CONT-001')
expect(fields.text()).toContain('完整保留客户备注')
expect(fields.findAll('.wide')).toHaveLength(3)
expect(wrapper.text()).not.toContain('internal-order-id')
await header.get('.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-last-mile-orders' })
})
it('keeps all five tab panels reachable after applying the shared tab style', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map(tab => tab.text())).toEqual(['📋基本信息', '📦货物信息', '🗓️计划信息', '💰费用明细', '📝操作记录'])
for (const [index, tab] of tabs.entries()) {
await tab.trigger('click')
await flushPromises()
expect(tab.attributes('aria-selected')).toBe('true')
const panel = wrapper.findAll('[role="tabpanel"]')[index]!
expect(panel.isVisible()).toBe(true)
if (index === 1) expect(panel.text()).toContain('SKU-001')
}
})
it('shows declared, received and highlighted signed difference only for warehouse transfers', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'transfer-id', customerId: 'customer-1', orderNo: 'WT-001', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_CUSTOMER_CONFIRMATION', packageCount: 10, receivedPackageCount: 12, packageDifference: 2 },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const fields = wrapper.get('dl[aria-label="基本信息"]')
expect(fields.text()).toContain('客户预报箱数10')
expect(fields.text()).toContain('实收箱数12')
expect(fields.text()).toContain('箱数差异+2')
expect(fields.get('.package-difference--nonzero').text()).toBe('+2')
expect(wrapper.get('.status-badge').text()).toBe('待客户确认实测')
const overview = wrapper.get('[aria-label="尾程订单概览"]')
expect(overview.text()).toContain('实收总箱数12 箱')
expect(overview.text()).not.toContain('客户预报总箱数')
})
it('shows the declared total before a warehouse transfer has a received count', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'transfer-id', customerId: 'customer-1', orderNo: 'WT-002', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_PDA_RECEIVING', packageCount: 10, receivedPackageCount: null, packageDifference: null },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const overview = wrapper.get('[aria-label="尾程订单概览"]')
expect(overview.text()).toContain('客户预报总箱数10 箱')
expect(overview.text()).not.toContain('实收总箱数')
})
it('renders both return relabel documents with independent states and quantities', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: {
id: 'return-id', customerId: 'customer-1', orderNo: 'RT-001', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED',
relabelOrderNo: 'RL-SECOND', relabelStatus: 'PROCESSING',
relabelApplications: [
{ relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
{ relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
],
},
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-plan').trigger('click')
await flushPromises()
const rows = wrapper.findAll('.related-panel .el-table__body tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('RL-FIRST')
expect(rows[0].text()).toContain('已完成')
expect(rows[0].text()).toContain('10')
expect(rows[1].text()).toContain('RL-SECOND')
expect(rows[1].text()).toContain('处理中')
expect(rows[1].text()).toContain('5')
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('keeps %s fee fields and exact payload through fullscreen editing', async direction => {
const { dialog } = await openFeeEditor()
expect(dialog.props('title')).toContain('FL-WH-001')
expect(dialog.props('width')).toContain('720px')
expect(dialog.text()).not.toContain('业务日期')
expect(dialog.text()).not.toContain('计费开始日期')
expect(dialog.text()).not.toContain('计费结束日期')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
expect(dialog.get('[aria-label="尾程费用登记"]').attributes('data-tone')).toBeUndefined()
await fillFee(dialog, direction)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
const payable = direction === 'PAYABLE'
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: new Date().toISOString().slice(0, 10),
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40,
})
})
it.each(['BILL', 'WALLET'])('submits receivable settlement mode %s from the fee editor', async settlementMode => {
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '按业务默认规则')!.vm.$emit('update:modelValue', settlementMode)
await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click')
await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ direction: 'RECEIVABLE', settlementMode }))
})
it('keeps fee required-field validation before a request', async () => {
const { dialog } = await openFeeEditor()
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('uses the live catalog including rent and only loads suppliers for payables', async () => {
const { dialog } = await openFeeEditor()
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
expect(listSuppliers).not.toHaveBeenCalled()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.props('allowCreate')).toBe(false)
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071', 'FEE_TYPE_077'])
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledOnce()
})
it('blocks saving when the catalog fails and supports retry', async () => {
vi.mocked(listOverseasFeeItems).mockRejectedValueOnce(new Error('目录暂不可用'))
const { dialog } = await openFeeEditor()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('费用项目加载失败')
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('keeps supplier failures in the editor, blocks payables and reloads the shared supplier list', async () => {
vi.mocked(listSuppliers).mockRejectedValueOnce(new ApiResponseError('仓储数据库暂不可用', 'S0503', 503, null, 'supplier-request'))
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(dialog.text()).toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.findAll('button').find(button => button.text() === '重试加载供应商')!.trigger('click'); await flushPromises()
expect(dialog.text()).not.toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(false)
const supplier = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual([feeSupplier.id])
})
it('ignores a supplier response after switching back to customer receivables', async () => {
let reject!: (reason: unknown) => void
vi.mocked(listSuppliers).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openFeeEditor()
const direction = dialog.findAllComponents({ name: 'ElSelect' })[1]!
direction.vm.$emit('update:modelValue', 'PAYABLE'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
direction.vm.$emit('update:modelValue', 'RECEIVABLE'); await flushPromises()
reject(new Error('旧请求失败')); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.text()).not.toContain('供应商加载失败')
})
it('places fees and related source charges inside the exact order without a duplicated receivable table', async () => {
const result = await getLastMileOrder('internal-order-id')
vi.mocked(getLastMileOrder).mockResolvedValue({ ...result, order: { ...result.order, orderType: 'RETURN_RELABEL', sourceOrderNo: 'TH-01' } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const panels = wrapper.findAllComponents({ name: 'BusinessDocumentFees' })
expect(panels).toHaveLength(2)
for (const [index, panel] of panels.entries()) expect(panel.props()).toMatchObject({
businessType: 'LASTLEG_ORDER', businessId: result.order.id, businessNo: result.order.orderNo, customerId: result.order.customerId,
direction: index === 0 ? 'RECEIVABLE' : 'PAYABLE',
relatedDocuments: [{ businessType: 'RETURN_ORDER', businessNo: 'TH-01', customerId: result.order.customerId }],
})
expect(panels[0]!.props()).toMatchObject({ heading: '应收明细', hideWhenEmpty: true, canCreate: false })
expect(panels[1]!.props()).toMatchObject({ heading: '应付明细', hideWhenEmpty: true, canCreate: false })
expect(wrapper.findAll('#pane-fees .el-table').length).toBe(0)
})
it('uses one create button and selects payable inside the shared editor', async () => {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const createButtons = wrapper.findAll('#pane-fees button').filter(button => button.text().trim() === '新增费用')
expect(createButtons).toHaveLength(1)
await createButtons[0]!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.findAllComponents({ name: 'ElSelect' })[1]!.props('modelValue')).toBe('RECEIVABLE')
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({
direction: 'PAYABLE', supplierId: feeSupplier.id, settlementMode: 'ON_ACCOUNT',
}))
})
it('shows existing amount and approval before allowing another fee with a reason', async () => {
getDocumentFees.mockResolvedValue([{ sourceFeeId: 'old-fee', feeItemCode: 'FEE_TYPE_071', direction: 'RECEIVABLE', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'USD', originalAmount: 80 } as OverseasFee])
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
expect(dialog.text()).toContain('USD 80.00 · 已通过')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.get('textarea[placeholder="补收或重复作业时,请说明原因"]').setValue('第二次贴标作业')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeRemark: '第二次贴标作业' }))
})
it('locks fee controls and preserves failed values and diagnostic context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
const { wrapper, dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="如 箱、托、天、m³"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('费用保存失败', 'TEST_REJECTED', 409, null, 'fee-test-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.props('modelValue')).toBe(80)
expect(wrapper.findComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'fee-test-request' })
})
})