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

173 lines
16 KiB
TypeScript

import ElementPlus, { ElMessageBox } from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { DeliveryOrderDetail, DeliveryOrderRow, DeliveryReturnRelation } from '../api/deliveryOrders'
import { deliveryDocumentCategories, type DeliveryDocuments, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), returns: vi.fn(), mark: vi.fn(), status: vi.fn(), documents: vi.fn(), save: vi.fn(), upload: vi.fn(), download: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: api.list, getDeliveryOrder: api.detail, listDeliveryReturnInboundOptions: api.returns, markDeliveryReturn: api.mark }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: api.status }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses }))
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: api.documents, saveDeliveryDocuments: api.save, uploadDeliveryDocument: api.upload, downloadDeliveryDocument: api.download }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
const row = (): DeliveryOrderRow => ({ version: 0, allowedActions: ['START_DELIVERY', 'REJECT_DELIVERY', 'MARK_RETURN'], id: '100', customerId: 'C1', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 4, signedPackages: null, createdAt: '2026-10-20T10:00:00' })
const detail = (value = row()): DeliveryOrderDetail => ({ order: value, skus: [], fees: [], operations: [] })
const relation = (): DeliveryReturnRelation => ({ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundCode: 'IN-1', customerId: 'C1', returnSources: [
{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 },
{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740996', cartonMark: 'RC-1', palletNo: 'PL-2', skuCode: null, maxReturnPackages: 1 },
] })
const file: DeliveryDocumentFile = { fileId: '9007199254740997', fileName: '完整文件名称.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-10-20T10:00:00' }
const documents = (): DeliveryDocuments => ({ deliveryNo: 'DEL/1', customerId: 'C1', version: 0, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...file }] : [] })) })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
api.warehouses.mockResolvedValue({ records: [] })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [row()], total: 1 }); api.detail.mockResolvedValue(detail()); api.returns.mockResolvedValue([relation()]); api.mark.mockResolvedValue({ exceptionNo: 'EXC-1' }); api.status.mockResolvedValue({ deliveryNo: 'DEL/1' })
api.documents.mockResolvedValue(documents()); api.save.mockResolvedValue(documents()); api.upload.mockResolvedValue({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); api.download.mockResolvedValue(new Blob(['file']))
URL.createObjectURL = vi.fn().mockReturnValue('blob:delivery-file'); URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openPage(action: '编辑状态' | '标记退货') {
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises()
const title = action === '编辑状态' ? '编辑派送状态' : action
return { wrapper, dialog: wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
}
async function openDocuments() { const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DEL/1', customerId: 'C1' }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function validReturn(dialog: VueWrapper) { await field(dialog, '退货原因').get('textarea').setValue(' 客户拒收 '); await dialog.get('.return-source input[type="checkbox"]').setValue(true); dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises() }
describe('native delivery operation dialogs', () => {
it('keeps current facts read-only and submits an allowed status with version zero', async () => {
const { dialog } = await openPage('编辑状态')
expect(dialog.props('title')).toBe('编辑派送状态 · DEL/1')
expect(dialog.get('[aria-label="派送状态依据"]').findAll('input')).toHaveLength(0)
const select = field(dialog, '派送状态').getComponent({ name: 'ElSelect' })
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['DELIVERING', 'REJECTED'])
select.vm.$emit('update:modelValue', 'DELIVERING'); await field(dialog, '备注').get('textarea').setValue(' 本次状态备注 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 0, remark: '本次状态备注' })
})
it('locks status loading and preserves notes through conflict and authoritative reload', async () => {
let resolve!: (value: DeliveryOrderDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { dialog } = await openPage('编辑状态')
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('closeOnPressEscape')).toBe(false)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
resolve(detail()); await flushPromises()
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'REJECTED')
await field(dialog, '备注').get('textarea').setValue('保留状态备注')
api.status.mockRejectedValueOnce(new ApiResponseError('状态冲突', 'A0409', 409, null, 'delivery-status-request'))
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('delivery-status-request')
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
api.detail.mockResolvedValueOnce(detail({ ...row(), version: 8, allowedActions: ['START_DELIVERY'] }))
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('保留状态备注')
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DELIVERING'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenLastCalledWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 8, remark: '保留状态备注' })
})
it('explains when the latest record has no available status transition', async () => {
api.detail.mockResolvedValueOnce(detail({ ...row(), allowedActions: [] }))
const { dialog } = await openPage('编辑状态')
expect(dialog.text()).toContain('当前状态已无可操作的派送状态')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
})
it('keeps source identities lossless and original return boxes fixed at one package', async () => {
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
expect(dialog.props('fullscreenable')).toBe(true)
expect(dialog.get('.delivery-return-sources th').text()).toBe('操作')
expect(dialog.text()).toContain('RC-1(整箱退回)'); expect(dialog.text()).not.toContain('9007199254740996')
const sources = dialog.findAll('.return-source'); await sources[1]!.get('input[type="checkbox"]').setValue(true); await flushPromises()
expect(sources[1]!.getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ modelValue: 1, disabled: true })
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', packageCount: 2 }, { outboundPalletItemId: '9007199254740995', returnCartonId: '9007199254740996', packageCount: 1 }], idempotencyKey: expect.any(String) })
})
it('retries failed return source reads within the original delivery and customer', async () => {
api.returns.mockRejectedValueOnce(new ApiResponseError('来源不可用', 'A0503', 503, null, 'return-source-request'))
const { dialog } = await openPage('标记退货'); expect(dialog.text()).toContain('return-source-request')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.mark).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.returns).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.findAll('.return-source')).toHaveLength(2)
})
it('locks duplicate returns and retains failed inputs and the original request', async () => {
let reject!: (cause: Error) => void
api.mark.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledOnce(); expect(dialog.props('confirmLoading')).toBe(true)
expect(field(dialog, '退货原因').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
const original = JSON.parse(JSON.stringify(api.mark.mock.calls[0]![2]))
reject(new ApiResponseError('结果未确认', 'A0503', 503, null, 'return-save-request')); await flushPromises()
expect(dialog.text()).toContain('return-save-request'); expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises(); expect(JSON.parse(JSON.stringify(api.mark.mock.calls[1]![2]))).toEqual(original)
})
it('stages real uploads, locks the parent and saves all categories with version zero', async () => {
let resolve!: (value: DeliveryDocumentFile) => void
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await openDocuments()
expect(dialog.get('.document-files th').text()).toBe('操作')
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises()
expect(api.download).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', 'BOL', file.fileId)
await field(dialog, 'BOL号').get('input').setValue(' BOL-NEW ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
const input = dialog.get('[data-category="RECEIPT"] header input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['next'], 'receipt.pdf')] }); await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); await flushPromises()
expect(dialog.get('[aria-label="下载 receipt.pdf"]').attributes('disabled')).toBeDefined()
expect(api.save).not.toHaveBeenCalled(); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.save).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { expectedVersion: 0, bolNo: 'BOL-NEW', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [file.fileId] }, { category: 'RECEIPT', fileIds: ['9007199254740998'] }], idempotencyKey: expect.any(String) })
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps readonly document facts plain and only exposes saved downloads', async () => {
api.documents.mockResolvedValueOnce({ ...documents(), allowedActions: [] })
const { dialog } = await openDocuments()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('当前账号仅可查看已保存资料')
expect(dialog.findAll('input')).toHaveLength(0); expect(dialog.text()).toContain('BOL-1')
expect(dialog.findAll('[data-category]')).toHaveLength(3)
expect(dialog.findAll('button').some(button => button.text() === '上传')).toBe(false)
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises(); expect(api.download).toHaveBeenCalledOnce()
})
it('shows an unavailable summary after a document read failure and retries within scope', async () => {
api.documents.mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'document-read-request'))
const { dialog } = await openDocuments()
expect(dialog.text()).toContain('document-read-request'); expect(dialog.text()).toContain('资料未返回')
expect(dialog.text()).not.toContain('正在读取…'); expect(dialog.props('showConfirm')).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.documents).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.text()).toContain('1/3 已上传')
})
it('retains conflicting document drafts until confirmed reload', async () => {
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
api.save.mockRejectedValueOnce(new ApiResponseError('资料版本冲突', 'A0409', 409, null, 'document-request'))
const { dialog } = await openDocuments(); await field(dialog, 'BOL号').get('input').setValue('保留草稿')
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('document-request')
expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(confirmation).toHaveBeenCalledOnce(); expect(api.documents).toHaveBeenCalledOnce()
expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('保留草稿')
confirmation.mockRestore()
api.documents.mockResolvedValueOnce({ ...documents(), version: 1, bolNo: 'BOL-LATEST' })
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
const confirm = document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')
expect(confirm).not.toBeNull(); confirm!.click(); await flushPromises()
expect(api.documents).toHaveBeenCalledTimes(2); expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('BOL-LATEST')
})
})