Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts
T

421 lines
33 KiB
TypeScript
Raw Normal View History

2026-09-14 20:21:02 +08:00
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
2026-09-14 20:21:02 +08:00
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLastMileOrderFee, getLastMileOrder } from '../api/lastMileOrders'
2026-09-14 20:21:02 +08:00
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'
2026-10-26 13:43:08 +08:00
import { listOverseasFeeItems, type OverseasFee } from '@/modules/overseas-business'
2026-10-15 15:45:16 +08:00
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
2026-09-14 20:21:02 +08:00
const can = vi.hoisted(() => vi.fn(() => false))
2026-09-14 20:21:02 +08:00
const push = vi.hoisted(() => vi.fn())
2026-10-26 13:43:08 +08:00
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 }))
2026-10-15 15:45:16 +08:00
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
2026-09-14 20:21:02 +08:00
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() }))
2026-09-14 20:21:02 +08:00
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()
2026-10-26 13:43:08 +08:00
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('托')
}
2026-09-14 20:21:02 +08:00
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
2026-10-20 11:33:39 +08:00
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: [] }] } })
2026-10-20 11:33:39 +08:00
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')
})
2026-10-10 15:45:20 +08:00
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
2026-10-10 14:42:41 +08:00
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('箱数')
})
2026-10-10 15:45:20 +08:00
it('keeps customer forecast after completion instead of using actual signed cargo', async () => {
2026-10-10 14:42:41 +08:00
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()
2026-10-10 15:45:20 +08:00
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('箱数')
2026-10-10 14:42:41 +08:00
})
2026-10-09 15:25:19 +08:00
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('不得提前出库')
})
2026-10-15 15:45:16 +08:00
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('查看箱号明细')
})
2026-09-14 20:21:02 +08:00
beforeEach(() => {
2026-10-26 13:43:08 +08:00
getDocumentFees.mockReset().mockResolvedValue([])
can.mockReset().mockReturnValue(false)
2026-10-26 13:43:08 +08:00
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 })
2026-10-26 13:43:08 +08:00
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: '' })
2026-09-14 20:21:02 +08:00
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
2026-10-15 15:45:16 +08:00
vi.mocked(getPlatformWarehouse).mockReset()
2026-09-14 20:21:02 +08:00
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' },
2026-09-14 20:21:02 +08:00
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
})
})
2026-11-03 11:43:50 +08:00
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 }))
})
2026-11-04 10:39:15 +08:00
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: [] })
2026-11-03 11:43:50 +08:00
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()
})
2026-09-14 20:21:02 +08:00
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')])
2026-09-14 20:21:02 +08:00
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')
2026-09-14 20:21:02 +08:00
expect(fields.text()).toContain('CONT-001')
expect(fields.text()).toContain('完整保留客户备注')
2026-09-15 15:36:26 +08:00
expect(fields.findAll('.wide')).toHaveLength(3)
2026-09-14 20:21:02 +08:00
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')
}
})
2026-09-17 13:36:31 +08:00
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('待客户确认实测')
2026-09-17 13:36:31 +08:00
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('实收总箱数')
})
2026-09-22 08:47:43 +08:00
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')
2026-10-26 13:43:08 +08:00
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', {
2026-10-26 13:43:08 +08:00
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,
2026-10-26 13:43:08 +08:00
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40,
})
})
2026-10-28 11:30:00 +08:00
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()
})
2026-11-03 11:43:50 +08:00
it('uses the live catalog including rent and only loads suppliers for payables', async () => {
2026-10-26 13:43:08 +08:00
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)
2026-11-03 11:43:50 +08:00
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071', 'FEE_TYPE_077'])
2026-10-26 13:43:08 +08:00
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' })
})
2026-09-14 20:21:02 +08:00
})