fix(delivery): version status commands and manage three document categories

This commit is contained in:
2026-09-09 11:17:12 +08:00
parent 239733a33e
commit 89d237e129
12 changed files with 493 additions and 14 deletions
@@ -29,8 +29,8 @@ describe('customer visit API', () => {
it('updates delivery status so selecting delivered can generate a visit', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', remark: '完成' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', remark: '完成' }, { params: { customerId: '100' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }, { params: { customerId: '100' } })
})
it('uploads and saves repeatable visit material', async () => {
@@ -107,7 +107,7 @@ export async function completeDeliveryOrder(deliveryNo: string, customerId: stri
return unwrapApiResponse(response)
}
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; remark?: string }) {
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) {
const response = await overseasHttp.put<ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
{ params: { customerId } },
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument } from './deliveryDocuments'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('delivery document API', () => {
beforeEach(() => vi.clearAllMocks())
it('scopes reads and full three-category snapshots by customer and delivery number', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: {} })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
await getDeliveryDocuments('DL/1', '9007199254740993')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } })
const body = { expectedVersion: 8, idempotencyKey: 'save-1', bolNo: null, documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) }
await saveDeliveryDocuments('DL/1', '9007199254740993', body)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } })
})
it('uploads only the file and downloads through the authenticated category endpoint', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } })
const file = new File(['data'], 'proof.bin')
await uploadDeliveryDocument('DL/1', '100', 'RECEIPT', file)
const [url, body, config] = vi.mocked(overseasHttp.post).mock.calls[0]!
expect(url).toBe('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files')
expect(Array.from((body as FormData).keys())).toEqual(['file'])
expect((body as FormData).get('file')).toBe(file)
expect(config).toEqual({ params: { customerId: '100' } })
await downloadDeliveryDocument('DL/1', '100', 'RECEIPT', '9007199254740995')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' })
})
})
@@ -0,0 +1,33 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export const deliveryDocumentCategories = [
{ category: 'LOADING_PHOTO', label: '装车照片' },
{ category: 'BOL', label: 'BOL文件(含签名)' },
{ category: 'RECEIPT', label: '签收文件(POD签收/快递签收)' },
] as const
export type DeliveryDocumentCategory = typeof deliveryDocumentCategories[number]['category']
export interface DeliveryDocumentFile { fileId: string; fileName: string; contentType: string | null; sizeBytes: number; uploadedAt: string }
export interface DeliveryDocumentCategorySummary { category: DeliveryDocumentCategory; status: 'UPLOADED' | 'MISSING'; fileCount: number }
export interface DeliveryDocumentSummary { uploadedCategoryCount: number; totalCategoryCount: number; categories: DeliveryDocumentCategorySummary[] }
export interface DeliveryDocuments extends DeliveryDocumentSummary {
deliveryNo: string; customerId: string; version: number; bolNo: string | null
categories: Array<DeliveryDocumentCategorySummary & { files: DeliveryDocumentFile[] }>
allowedActions: string[]; replayed: boolean
}
export interface SaveDeliveryDocuments { expectedVersion: number; idempotencyKey: string; bolNo: string | null; documents: Array<{ category: DeliveryDocumentCategory; fileIds: string[] }> }
const path = (deliveryNo: string) => `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`
export async function getDeliveryDocuments(deliveryNo: string, customerId: string) {
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<DeliveryDocuments>, ApiEnvelope<DeliveryDocuments>>(path(deliveryNo), { params: { customerId } }))
}
export async function saveDeliveryDocuments(deliveryNo: string, customerId: string, body: SaveDeliveryDocuments) {
return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<DeliveryDocuments>, ApiEnvelope<DeliveryDocuments>>(path(deliveryNo), body, { params: { customerId } }))
}
export async function uploadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, file: File) {
const body = new FormData()
body.append('file', file)
return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<DeliveryDocumentFile>, ApiEnvelope<DeliveryDocumentFile>>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } }))
}
export async function downloadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, fileId: string) {
return overseasHttp.get<Blob, Blob>(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
@@ -2,6 +2,7 @@ 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 {
page: number
@@ -20,6 +21,10 @@ export interface DeliveryOrderQuery {
}
export interface DeliveryOrderRow {
version: number
allowedActions: string[]
bolNo?: string | null
documentSummary?: DeliveryDocumentSummary
id: string
customerId: string
customerName?: string | null
@@ -0,0 +1,154 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { commonPhase5Stubs } from '../pages/phase5TestStubs'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, type DeliveryDocuments } from '../api/deliveryDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn(), uploadDeliveryDocument: vi.fn(), downloadDeliveryDocument: vi.fn() }))
const dialogStub = defineComponent({
name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'],
setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, '保存资料') : null, h('button', { class: 'close', onClick: () => emit('update:modelValue', false) }, '关闭')]) : null },
})
const uploadStub = defineComponent({
name: 'ElUpload', props: ['onChange', 'disabled'],
setup(props, { slots }) { return () => h('label', [slots.default?.(), h('input', { type: 'file', disabled: props.disabled, onChange: (event: Event) => props.onChange({ raw: (event.target as HTMLInputElement).files?.[0] }) })]) },
})
const originalFile = { fileId: '9007199254740995', fileName: 'original.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-09-09T09:00:00' }
function response(): DeliveryDocuments {
return { deliveryNo: 'DL/1', customerId: '100', version: 9, 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' ? [{ ...originalFile }] : [] })) }
}
function mountDialog() {
const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: dialogStub, ElUpload: uploadStub } } })
return wrapper
}
async function choose(input: ReturnType<ReturnType<typeof mountDialog>['find']>, file: File) {
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
}
describe('delivery document drafts', () => {
beforeEach(() => {
can.mockReset().mockReturnValue(true)
vi.mocked(getDeliveryDocuments).mockReset().mockResolvedValue(response())
vi.mocked(saveDeliveryDocuments).mockReset().mockResolvedValue(response())
vi.mocked(uploadDeliveryDocument).mockReset().mockResolvedValue({ ...originalFile, fileId: '9007199254740996', fileName: 'new.pdf' })
})
it('loads once and stages replacement and deletion until a full versioned save', async () => {
const wrapper = mountDialog(); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('1/3 已上传')
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['next'], 'new.pdf'))
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('original.pdf')
expect(wrapper.text()).toContain('new.pdf')
await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(wrapper.text()).toContain('0/3 已上传')
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) })
expect(wrapper.emitted('saved')).toHaveLength(1)
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'))
expect(wrapper.text()).toContain('2/3 已上传')
await wrapper.find('.close').trigger('click')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.text()).not.toContain('new.pdf')
wrapper.unmount()
})
it('preserves the draft and idempotency key on a failed save, and prevents duplicate submissions', async () => {
const wrapper = mountDialog(); await flushPromises()
let reject!: (error: Error) => void
vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
await wrapper.find('.save').trigger('click'); await wrapper.find('.save').trigger('click')
expect(saveDeliveryDocuments).toHaveBeenCalledTimes(1)
reject(new Error('保存失败')); await flushPromises()
expect(wrapper.text()).toContain('original.pdf')
expect(wrapper.emitted('saved')).toBeUndefined()
const key = vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].idempotencyKey
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(vi.mocked(saveDeliveryDocuments).mock.calls[1]![2].idempotencyKey).toBe(key)
wrapper.unmount()
})
it('retains the existing file after replacement upload fails and rejects invalid file sizes', async () => {
const wrapper = mountDialog(); await flushPromises()
vi.mocked(uploadDeliveryDocument).mockRejectedValueOnce(new Error('上传失败'))
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'bad.pdf'))
expect(wrapper.text()).toContain('original.pdf')
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File([], 'empty.bin'))
const oversized = new File(['data'], 'large.bin'); Object.defineProperty(oversized, 'size', { value: 52428801 })
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), oversized)
expect(uploadDeliveryDocument).toHaveBeenCalledTimes(1)
const exact = new File(['data'], 'valid.bin'); Object.defineProperty(exact, 'size', { value: 52428800 })
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), exact)
expect(uploadDeliveryDocument).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it.each(['permission', 'action'])('is read-only without the required %s', async kind => {
if (kind === 'permission') can.mockReturnValue(false)
else vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), allowedActions: [] })
const wrapper = mountDialog(); await flushPromises()
expect(wrapper.find('.save').exists()).toBe(false)
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(wrapper.text()).toContain('original.pdf')
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
wrapper.unmount()
})
it('blocks retries after a version conflict without losing draft files', async () => {
vi.mocked(saveDeliveryDocuments).mockRejectedValue({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本已变化', requestId: 'req-1', traceId: 'trace-1' } } })
const wrapper = mountDialog(); await flushPromises()
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(wrapper.find('.save').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('original.pdf')
expect(wrapper.emitted('saved')).toBeUndefined()
wrapper.unmount()
})
it('limits each category to twenty files while allowing replacement', async () => {
const snapshot = response()
snapshot.categories.find(item => item.category === 'BOL')!.files = Array.from({ length: 20 }, (_, index) => ({ ...originalFile, fileId: String(index + 1) }))
snapshot.categories.find(item => item.category === 'BOL')!.fileCount = 20
vi.mocked(getDeliveryDocuments).mockResolvedValue(snapshot)
const wrapper = mountDialog(); await flushPromises()
expect(wrapper.find('[data-category="BOL"] header input[type="file"]').attributes('disabled')).toBeDefined()
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'replace.pdf'))
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toHaveLength(20)
expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toContain('9007199254740996')
wrapper.unmount()
})
it('ignores a late save response after switching to another customer delivery', async () => {
let finish!: (value: DeliveryDocuments) => void
vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mountDialog(); await flushPromises()
await wrapper.find('.save').trigger('click')
vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), deliveryNo: 'DL/2', customerId: '200', bolNo: 'BOL-2' })
await wrapper.setProps({ deliveryNo: 'DL/2', customerId: '200' }); await flushPromises()
finish(response()); await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.find('.save').exists()).toBe(true)
wrapper.unmount()
})
it('ignores a late upload failure after closing and reopening on another delivery', async () => {
let fail!: (reason: Error) => void
vi.mocked(uploadDeliveryDocument).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject }))
const wrapper = mountDialog(); await flushPromises()
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['data'], 'pending.pdf'))
await wrapper.setProps({ modelValue: false }); await flushPromises()
await wrapper.setProps({ modelValue: true, deliveryNo: 'DL/2', customerId: '200' }); await flushPromises()
fail(new Error('old upload failed')); await flushPromises()
expect(wrapper.text()).not.toContain('old upload failed')
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
})
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const props = defineProps<{ deliveryNo: string; customerId: string }>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const snapshot = ref<DeliveryDocuments | null>(null)
const draft = ref<Array<{ category: DeliveryDocumentCategory; label: string; files: DeliveryDocumentFile[] }>>([])
const bolNo = ref('')
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const downloading = ref(false)
const uploadKey = ref(0)
const error = ref<ApiErrorDetails | null>(null)
const conflict = ref(false)
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
const busy = computed(() => saving.value || uploading.value)
const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length)
let sequence = 0
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
let attempt: { body: string; key: string } | null = null
async function load() {
const current = ++sequence
loading.value = true; saving.value = false; uploading.value = false; downloading.value = false; error.value = null; conflict.value = false; snapshot.value = null; draft.value = []; attempt = null
try {
const result = await getDeliveryDocuments(props.deliveryNo, props.customerId)
if (current !== sequence || !open.value) return
snapshot.value = result; bolNo.value = result.bolNo || ''
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '派送资料加载失败') }
finally { if (current === sequence) loading.value = false }
}
async function reload() {
if (busy.value) return
if (snapshot.value && canEdit.value) {
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { type: 'warning' }) } catch { return }
}
await load()
}
watch(() => [open.value, props.deliveryNo, props.customerId] as const, ([visible]) => {
if (visible && props.deliveryNo && props.customerId) void load()
else { sequence++; snapshot.value = null; draft.value = []; error.value = null; bolNo.value = ''; attempt = null }
}, { immediate: true })
async function upload(category: DeliveryDocumentCategory, file: UploadFile, replaceId?: string) {
if (!canEdit.value || busy.value || conflict.value || !file.raw) return
const group = draft.value.find(item => item.category === category)
if (!group || (!replaceId && group.files.length >= 20)) { ElMessage.warning('每类最多上传20个文件'); return }
if (file.raw.size === 0 || file.raw.size > 52428800) { ElMessage.warning('文件不能为空,单个文件不能超过50MiB'); return }
uploading.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props
try {
const result = await uploadDeliveryDocument(deliveryNo, customerId, category, file.raw)
if (!isCurrent(current, deliveryNo, customerId)) return
const index = replaceId ? group.files.findIndex(item => item.fileId === replaceId) : -1
if (index >= 0) group.files.splice(index, 1, result)
else group.files.push(result)
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '资料上传失败') }
finally { if (isCurrent(current, deliveryNo, customerId)) { uploading.value = false; uploadKey.value++ } }
}
function remove(category: DeliveryDocumentCategory, fileId: string) {
if (!canEdit.value || busy.value || conflict.value) return
const group = draft.value.find(item => item.category === category)
if (group) group.files = group.files.filter(file => file.fileId !== fileId)
}
function isSaved(category: DeliveryDocumentCategory, fileId: string) { return snapshot.value?.categories.find(item => item.category === category)?.files.some(file => file.fileId === fileId) }
async function download(category: DeliveryDocumentCategory, file: DeliveryDocumentFile) {
if (downloading.value || !isSaved(category, file.fileId)) return
downloading.value = true
const current = sequence; const { deliveryNo, customerId } = props
try {
const blob = await downloadDeliveryDocument(deliveryNo, customerId, category, file.fileId)
if (!isCurrent(current, deliveryNo, customerId)) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '文件下载失败') }
finally { if (isCurrent(current, deliveryNo, customerId)) downloading.value = false }
}
async function save() {
if (!open.value || !canEdit.value || !snapshot.value || busy.value || conflict.value || loading.value) return
const body = { expectedVersion: snapshot.value.version, bolNo: bolNo.value.trim() || null, documents: draft.value.map(item => ({ category: item.category, fileIds: item.files.map(file => file.fileId) })) }
const serialized = JSON.stringify(body)
if (attempt?.body !== serialized) attempt = { body: serialized, key: crypto.randomUUID() }
saving.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props
try {
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
if (!isCurrent(current, deliveryNo, customerId)) return
open.value = false; emit('saved'); ElMessage.success('派送资料已保存')
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
finally { if (current === sequence) saving.value = false }
}
</script>
<template>
<AppDialog v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<div class="delivery-documents">
<div class="document-identifiers"><label>派送单号<el-input :model-value="deliveryNo" readonly /></label><label>BOL号<el-input v-model="bolNo" :readonly="!canEdit" :disabled="busy || conflict" placeholder="未填写" /></label></div>
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="snapshot">
<div class="document-summary"><span>{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</span><strong>{{ uploadedCount }}/3 已上传</strong></div>
<div class="document-groups">
<section v-for="group in draft" :key="group.category" class="document-group" :data-category="group.category">
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="document-empty">尚未上传文件</p>
<div v-for="file in group.files" :key="file.fileId" class="document-file">
<div class="document-file-info"><button type="button" class="document-file-name" :title="file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">{{ file.fileName }}</button><small>{{ file.uploadedAt?.replace('T', ' ') || '—' }}{{ isSaved(group.category, file.fileId) ? '' : ' · 待保存' }}</small></div>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button :disabled="busy || conflict">替换</el-button></el-upload><el-button type="danger" link :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
</div>
</section>
</div>
</template>
</div>
</AppDialog>
</template>
<style scoped>
.delivery-documents { display:grid; gap:16px; max-height:66vh; overflow-y:auto; padding:2px 6px 4px 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:18px; }.document-identifiers label { display:grid; gap:8px; font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:12px 16px; background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:8px; }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:8px; overflow:hidden; }.document-group { padding:14px 16px; }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:10px; }.document-group header > span { color:var(--cc-muted); font-size:12px; }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:14px; padding-top:10px; }.document-file-info { display:grid; min-width:0; flex:1; gap:4px; }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:10px 0 0; }
</style>
@@ -13,6 +13,7 @@ describe('DeliveryOrderDetailView', () => {
routeState.query = { customerId: '100' }
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({
order: {
version: 3, allowedActions: ['COMPLETE_DELIVERY'],
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
createdAt: '2026-09-01T10:00:00',
@@ -1,19 +1,23 @@
import ElementPlus from 'element-plus'
import { defineComponent, h, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
import { getDeliveryDocuments, deliveryDocumentCategories } from '../api/deliveryDocuments'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn(), listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() }))
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'],
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',
}
@@ -25,6 +29,8 @@ function mountView() {
describe('DeliveryOrdersView permissions and commands', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
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(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
@@ -47,9 +53,11 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERED')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'WAIT_DELIVERY', remark: undefined })
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
@@ -75,4 +83,64 @@ describe('DeliveryOrdersView permissions and commands', () => {
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
it('maps delivering separately from completion and blocks duplicate or conflicted submission', async () => {
canPermission.mockReturnValue(true)
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
expect(select.find('option[value="DELIVERED"]').text()).toBe('派送完成')
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
await select.setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
let reject!: (error: unknown) => void
vi.mocked(updateDeliveryOrderStatus).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
const button = wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm')
await button.trigger('click'); await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '123' })
reject({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突', requestId: 'req-conflict', traceId: 'trace-conflict' } } })
await flushPromises()
const state = wrapper.vm as unknown as { statusConflict: boolean; statusOpen: boolean; statusForm: { remark: string }; refreshStatus: () => Promise<void> }
expect(state.statusConflict).toBe(true)
expect(state.statusOpen).toBe(true)
expect(state.statusForm.remark).toBe('123')
await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
await state.refreshStatus(); await flushPromises()
expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['DELIVERED'])
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 })
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('编辑状态')
expect(getDeliveryOrder).not.toHaveBeenCalled()
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 })) }
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, documentSummary }], page: 1, size: 10, total: 1, pages: 1 })
const table = defineComponent({ props: { data: Array as PropType<object[]> }, setup(props, { slots }) { return () => h('div', props.data?.flatMap(item => slots.documents?.({ row: item }) || [])) } })
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table, DeliveryDocumentsDialog: true } } })
await flushPromises()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.text()).toContain('签收文件(POD签收/快递签收) · 已上传')
expect(getDeliveryDocuments).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text().includes('管理资料'))!.trigger('click')
const dialog = wrapper.findComponent({ name: 'DeliveryDocumentsDialog' })
expect(dialog.props()).toMatchObject({ deliveryNo: 'DEL/1', customerId: '100', modelValue: true })
const calls = vi.mocked(listDeliveryOrders).mock.calls.length
dialog.vm.$emit('saved'); await flushPromises()
expect(listDeliveryOrders).toHaveBeenCalledTimes(calls + 1)
wrapper.unmount()
})
})
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ describe('OutboundDetailView', () => {
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
delivery: { version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
})
})
@@ -19,6 +19,7 @@ export const overseasWarehousePermissions = {
exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create',
deliveryReturn: 'platform:overseas-warehouse:delivery:return',
deliveryStatus: 'platform:overseas-warehouse:delivery:status',
deliveryDocuments: 'platform:overseas-warehouse:delivery:documents',
customerVisitList: 'platform:overseas-warehouse:customer-visit:list',
customerVisitEdit: 'platform:overseas-warehouse:customer-visit:edit',
inventoryList: 'platform:overseas-warehouse:inventory:list',