This commit is contained in:
2026-11-03 16:15:24 +08:00
parent 604e7fb2e1
commit 1afc7034cd
7 changed files with 83 additions and 20 deletions
@@ -2,13 +2,16 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type LooseboardPhotoPhase = 'PICKUP' | 'ARRIVAL'
export interface LooseboardPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: string | number }
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean }
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[] }
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean; warehouseNotice?: string | null; pickupRequired?: boolean }
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[]; canCompletePickup?: boolean; pickupCompleted?: boolean }
const path = (number: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}/looseboard-execution`
const itemPath = (number: string, item: string, phase: LooseboardPhotoPhase) => `${path(number)}/${encodeURIComponent(item)}/${phase}`
export async function getLooseboardExecution(number: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(path(number)))
}
export async function completeLooseboardPickup(number: string, body: { recordVersion: string | number; idempotencyKey: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(`${path(number)}/pickup-complete`, body))
}
export async function uploadLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, file: File) {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardPhoto>, ApiEnvelope<LooseboardPhoto>>(`${itemPath(number, item, phase)}/uploads`, body))
@@ -6,10 +6,12 @@ import DispatchDocumentsDialog from './DispatchDocumentsDialog.vue'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { destination, detail } from '../dispatchTestFixtures'
import { getDispatch } from '../api/dispatches'
import { getLooseboardExecution, completeLooseboardPickup } from '../api/dispatchLooseboardExecution'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, type DeliveryDocuments } from '../api/deliveryDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), completeLooseboardPickup: vi.fn(), readLooseboardPhoto: vi.fn() }))
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn() }))
const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits: ['confirm'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', onClick: () => emit('confirm') }, '保存资料') : null]) : null } })
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
@@ -96,3 +98,24 @@ it('does not request delivery documents without delivery list permission', async
expect(wrapper.text()).toContain('当前账号无派送资料查看权限')
wrapper.unmount()
})
it.each([true, false])('closes pickup dialog only after successful confirmation (success=%s)', async success => {
const value = detail({ stage: 'DELIVERING' })
value.orderDetails[0]!.sourceBusinessType = 'LOCAL_LOOSEBOARD'
vi.mocked(getDispatch).mockResolvedValue(value)
const snapshot = { dispatchNo: 'PC/1', recordVersion: '3', stage: 'DELIVERING', direct: false, items: [], canCompletePickup: true, pickupCompleted: false }
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
if (success) vi.mocked(completeLooseboardPickup).mockResolvedValue({ ...snapshot, canCompletePickup: false, pickupCompleted: true })
else vi.mocked(completeLooseboardPickup).mockRejectedValue(new Error('取货确认失败'))
const wrapper = setup(); await flushPromises()
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(completeLooseboardPickup).toHaveBeenCalledTimes(1)
if (success) {
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
expect(wrapper.emitted('saved')).toHaveLength(1)
} else {
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).toContain('取货确认失败')
}
wrapper.unmount()
})
@@ -56,6 +56,7 @@ async function selectDelivery(value: string) { if (value !== selected.value && a
async function beforeClose(done: () => void) { if (await canLeave()) done() }
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') }
function pickupCompleted() { open.value = false; emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
@@ -63,11 +64,11 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
}, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'LOOSEBOARD' ? Boolean(looseboardEditor?.canCompletePickup) : tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || busy || (tab === 'RECEIPT' && (editor?.loading || editor?.conflict))" :confirm-text="tab === 'LOOSEBOARD' ? '取货完成' : '保存资料'" cancel-text="关闭" @confirm="tab === 'LOOSEBOARD' ? looseboardEditor?.confirmPickup() : editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" @pickup-completed="pickupCompleted" />
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
@@ -207,10 +207,10 @@ it('allows saving photos while loading and keeps the loading stage', async () =>
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' }))
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }))
const wrapper = setup(); await flushPromises()
expect(wrapper.text()).toContain('可边装车边上传')
expect(wrapper.text()).not.toContain('可边装车边上传')
await upload(wrapper)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('可边装车边上传'); wrapper.unmount()
expect(wrapper.text()).not.toContain('可边装车边上传'); wrapper.unmount()
})
it('renders the authenticated saved photo and fullscreen preview for a read-only Admin', async () => {
@@ -93,8 +93,6 @@ defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="loading-documents">
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -2,13 +2,39 @@ import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import { getLooseboardExecution, completeLooseboardPickup, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), completeLooseboardPickup: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false, pickupRequired: true }] })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
it('requires the explicit pickup completion command after photos are ready', async () => {
const snapshot = { ...value(false), canCompletePickup: true }
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
vi.mocked(completeLooseboardPickup).mockResolvedValue({ ...snapshot, canCompletePickup: false, pickupCompleted: true })
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(completeLooseboardPickup).not.toHaveBeenCalled()
const editor = wrapper.vm as unknown as { canCompletePickup: boolean; confirmPickup: () => Promise<void> }
expect(editor.canCompletePickup).toBe(true)
await editor.confirmPickup(); await flushPromises()
expect(completeLooseboardPickup).toHaveBeenCalledWith('PC1', { recordVersion: '3', idempotencyKey: expect.any(String) })
expect(editor.canCompletePickup).toBe(false)
expect(wrapper.emitted('pickupCompleted')).toHaveLength(1)
expect(wrapper.emitted('saved')).toBeUndefined()
wrapper.unmount()
})
it('hides loaded warehouse cargo and keeps remote pickup and pending inbound notice', async () => {
const snapshot = value(false)
snapshot.items.push({ ...snapshot.items[0]!, itemId: '102', orderNo: 'WAREHOUSE-LOADED', pickupRequired: false })
snapshot.items.push({ ...snapshot.items[0]!, itemId: '103', orderNo: 'WAIT-INBOUND', pickupRequired: false, warehouseNotice: 'WAIT-INBOUND:还没拣货入库' })
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).toContain('LB1')
expect(wrapper.text()).not.toContain('WAREHOUSE-LOADED')
expect(wrapper.text()).toContain('WAIT-INBOUND:还没拣货入库')
wrapper.unmount()
})
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
@@ -31,7 +57,8 @@ it('keeps pickup upload visible but disabled until mixed warehouse cargo departs
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('混排货物按原流程')
expect(wrapper.text()).toContain('上传取货照片')
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
expect(wrapper.text()).not.toContain('完成仓内拣货并确认装车后可上传')
expect(wrapper.text()).not.toContain('尚未上传')
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
@@ -6,15 +6,27 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
import { getLooseboardExecution, completeLooseboardPickup, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const emit = defineEmits<{ saved: []; pickupCompleted: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const canCompletePickup = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.canCompletePickup === true)
async function confirmPickup() {
if (!canCompletePickup.value || !snapshot.value || busy.value || pending.value) return
busy.value = true; error.value = null
try { accept(await completeLooseboardPickup(number, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID() })); ElMessage.success('取货完成'); emit('pickupCompleted') }
catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '取货确认失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留当前错误。 */ }
}
} finally { busy.value = false }
}
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
@@ -98,24 +110,23 @@ async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
defineExpose({ busy, dirty, canCompletePickup, confirmPickup })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<section v-for="item in (snapshot?.items || []).filter(row => row.pickupRequired || row.warehouseNotice)" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<p v-if="item.warehouseNotice" class="warehouse-notice">{{ item.warehouseNotice }}</p>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-if="(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length" class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
@@ -141,7 +152,7 @@ defineExpose({ busy, dirty })
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.warehouse-notice { margin: 8px 12px; color: var(--el-color-warning); }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>