This commit is contained in:
2026-09-09 12:50:39 +08:00
parent 7b75f40e28
commit c83d03e775
7 changed files with 395 additions and 25 deletions
@@ -28,7 +28,7 @@ describe('delivery order API', () => {
await listDeliveryReturnInboundOptions('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', packageCount: 2 }
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
await markDeliveryReturn('DEL/1', '1001', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
@@ -1,7 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { ExceptionRelation } from './exceptionOrders'
import type { DeliveryDocumentSummary } from './deliveryDocuments'
export interface DeliveryOrderQuery {
@@ -63,6 +62,29 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
export interface DeliveryReturnSource {
outboundPalletItemId: string
inventoryPositionId: string
palletNo?: string | null
skuCode?: string | null
maxReturnPackages: number
}
export interface DeliveryReturnRelation {
inboundOrderId: string
inboundCode?: string | null
lastMileOrderNo?: string | null
customerId: string
returnSources: DeliveryReturnSource[]
}
export interface MarkDeliveryReturnPayload {
inboundOrderId: string
reason: string
returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER'
returnLogisticsNo?: string
items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }>
idempotencyKey: string
}
export async function listDeliveryOrders(query: DeliveryOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>, ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>>(
@@ -79,13 +101,13 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
}
export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<ExceptionRelation[]>, ApiEnvelope<ExceptionRelation[]>>(
const response = await overseasHttp.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: { inboundOrderId: string; reason: string; returnDirection: string; returnLogisticsNo?: string; packageCount?: number; skuCode?: string }) {
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
const response = await overseasHttp.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } },
)
@@ -1,6 +1,6 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { commonPhase5Stubs } from '../pages/phase5TestStubs'
@@ -50,6 +50,41 @@ describe('delivery document drafts', () => {
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => {
const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' }
const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' }
const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' }
vi.mocked(uploadDeliveryDocument).mockResolvedValueOnce(receipt).mockResolvedValueOnce(courier).mockResolvedValueOnce(replacement)
const wrapper = mount(DeliveryDocumentsDialog, { attachTo: document.body, props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus] } })
const body = new DOMWrapper(document.body)
try {
await flushPromises()
const dialog = body.find('[role="dialog"]')
expect(dialog.findAll('[data-category]')).toHaveLength(3)
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['pod'], 'pod.pdf'))
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['courier'], 'courier.pdf'))
expect(dialog.text()).toContain('2/3 已上传')
await choose(dialog.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['signed'], 'signed-bol.pdf'))
await dialog.find('[data-category="RECEIPT"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(dialog.text()).not.toContain('pod.pdf')
expect(dialog.text()).toContain('courier.pdf')
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] })
expect(wrapper.emitted('saved')).toHaveLength(1)
const persisted = response()
persisted.version = 10; persisted.uploadedCategoryCount = 2
persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' }))
vi.mocked(getDeliveryDocuments).mockResolvedValue(persisted)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const reopened = body.find('[role="dialog"]')
expect(reopened.text()).toContain('2/3 已上传')
expect(reopened.text()).toContain('signed-bol.pdf')
expect(reopened.text()).toContain('courier.pdf')
expect(reopened.text()).not.toContain('original.pdf')
expect(getDeliveryDocuments).toHaveBeenCalledTimes(2)
} finally { wrapper.unmount() }
})
it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => {
const wrapper = mountDialog(); await flushPromises()
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf'))
@@ -1,6 +1,6 @@
import ElementPlus from 'element-plus'
import { defineComponent, h, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
@@ -17,7 +17,7 @@ vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn() }))
const row = {
version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY'],
version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY', 'MARK_RETURN'],
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00',
}
@@ -32,7 +32,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
vi.mocked(getDeliveryDocuments).mockReset()
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { ...row, version: 7 }, skus: [], fees: [], operations: [] })
vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }])
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }])
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false })
})
@@ -63,11 +63,13 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog="标记退货"] input[type="checkbox"]').setValue(true)
await wrapper.find('[data-dialog="标记退货"] input[type="number"]').setValue(2)
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await flushPromises()
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
returnLogisticsNo: undefined, packageCount: undefined, skuCode: undefined,
returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: expect.any(String),
})
wrapper.unmount()
})
@@ -116,6 +118,71 @@ describe('DeliveryOrdersView permissions and commands', () => {
wrapper.unmount()
})
it('keeps return hidden without the server action even when the user has permission', async () => {
canPermission.mockImplementation(code => code.endsWith(':return'))
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'RETURNED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('标记退货')
expect(listDeliveryReturnInboundOptions).not.toHaveBeenCalled()
wrapper.unmount()
})
it('validates selected return sources and reuses the idempotency key after a failed request', async () => {
canPermission.mockImplementation(code => code.endsWith(':return'))
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)')
expect(dialog.text()).not.toContain('9007199254740995')
expect(dialog.text()).not.toContain('可自由填写')
await dialog.find('textarea').setValue('拒收')
await dialog.find('.dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
await dialog.find('input[type="checkbox"]').setValue(true)
for (const invalid of [0, 4, 1.5]) {
await dialog.find('input[type="number"]').setValue(invalid)
await dialog.find('.dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
}
await dialog.find('input[type="number"]').setValue(2)
let reject!: (error: Error) => void
vi.mocked(markDeliveryReturn).mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
await dialog.find('.dialog-confirm').trigger('click'); await dialog.find('.dialog-confirm').trigger('click')
expect(markDeliveryReturn).toHaveBeenCalledTimes(1)
const first = vi.mocked(markDeliveryReturn).mock.calls[0]![2]
reject(new Error('网络异常')); await flushPromises()
expect(dialog.text()).toContain('标记退货失败')
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('拒收')
await dialog.find('.dialog-confirm').trigger('click'); await flushPromises()
expect(vi.mocked(markDeliveryReturn).mock.calls[1]![2]).toEqual(first)
expect(first.items).toEqual([{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }])
expect(first).not.toHaveProperty('skuCode')
expect(first).not.toHaveProperty('packageCount')
} finally { wrapper.unmount() }
})
it('clears the selected source when a different inbound order is chosen', async () => {
canPermission.mockImplementation(code => code.endsWith(':return'))
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([
{ inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] },
{ inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] },
])
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
await dialog.find('select').setValue('9')
await dialog.find('input[type="checkbox"]').setValue(true)
await dialog.find('input[type="number"]').setValue(2)
await dialog.find('textarea').setValue('拒收')
await dialog.find('select').setValue('10')
expect(dialog.find('input[type="checkbox"]').exists()).toBe(false)
await dialog.find('.dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
it('hides status editing when the server supplies no actions', async () => {
canPermission.mockReturnValue(true)
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 })
@@ -125,6 +192,39 @@ describe('DeliveryOrdersView permissions and commands', () => {
wrapper.unmount()
})
it('selects completion through the real dialog and select, then retries a conflict with the refreshed version', async () => {
canPermission.mockImplementation(code => code.endsWith(':status'))
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
vi.mocked(updateDeliveryOrderStatus).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突' } } })
const wrapper = mount(DeliveryOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: false, ElInput: false, ElSelect: false, ElOption: false } } })
const body = new DOMWrapper(document.body)
try {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const dialog = body.find('[role="dialog"][aria-label="编辑派送状态"]')
const save = () => dialog.findAll('button').find(button => button.text() === '保存状态')!
expect(save().attributes('disabled')).toBeDefined()
await dialog.find('.el-select__wrapper').trigger('click')
await flushPromises()
const options = body.findAll('.el-select-dropdown__item')
expect(options.map(option => option.text())).toEqual(['派送完成'])
await options[0]!.trigger('click')
await dialog.find('textarea').setValue('已交付')
await save().trigger('click'); await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '已交付' })
expect(save().attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('请刷新最新状态并重新确认')
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
await dialog.findAll('button').find(button => button.text().includes('重新加载'))!.trigger('click')
await flushPromises()
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('已交付')
await save().trigger('click'); await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 8, remark: '已交付' })
expect(listDeliveryOrders).toHaveBeenCalledTimes(2)
} finally { wrapper.unmount() }
})
it('renders category counts from the list without per-row document requests', async () => {
canPermission.mockReturnValue(true)
const documentSummary = { uploadedCategoryCount: 1, totalCategoryCount: 3, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' as const : 'MISSING' as const, fileCount: item.category === 'RECEIPT' ? 3 : 0 })) }
File diff suppressed because one or more lines are too long