Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-10-28 11:30:02 +08:00
21 changed files with 522 additions and 150 deletions
@@ -4,7 +4,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchDocumentsDialog from './DispatchDocumentsDialog.vue'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { detail } from '../dispatchTestFixtures'
import { destination, detail } from '../dispatchTestFixtures'
import { getDispatch } from '../api/dispatches'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, type DeliveryDocuments } from '../api/deliveryDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
@@ -37,8 +37,13 @@ it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s be
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('reads existing delivery files in one dialog and saves receipt without requiring loading or BOL files', async () => {
it('hides the single delivery selector and saves receipt files to that delivery without requiring loading or BOL files', async () => {
const value = detail({ stage: 'DELIVERING' })
value.documents.deliveries = [{ deliveryNo: 'DL-1', customerId: '101', customerName: '客户甲', groupKey: 'A', outboundNo: 'OB-1', destination: destination(), status: 'DELIVERING', packageCount: 1, palletCount: 1 }]
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = setup(); await flushPromises()
expect(wrapper.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
expect(wrapper.find('[aria-label="选择派送单"]').exists()).toBe(false)
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
expect(wrapper.text()).toContain('existing-pod.pdf')
expect(wrapper.text()).not.toContain('资料核对')
@@ -52,6 +57,7 @@ it('reads existing delivery files in one dialog and saves receipt without requir
})
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
const wrapper = setup(); await flushPromises()
expect(wrapper.findComponent({ name: 'ElSelect' }).exists()).toBe(true)
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(receipt.text()).not.toContain('existing-pod.pdf')
@@ -63,7 +63,7 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :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>
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form 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>
<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>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
@@ -199,7 +199,7 @@ describe('dispatch workflow native layout', () => {
expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话')
expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0)
const photos = dialog.findAllComponents(DispatchEvidenceFiles)
expect(photos.map(photo => photo.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(photos.map(photo => photo.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
expect(photos.every(photo => photo.props('readonly'))).toBe(true)
expect(dialog.get('[aria-label="操作原因与意见"] h3').text()).toBe('审核意见')
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
@@ -246,7 +246,7 @@ describe('dispatch workflow native layout', () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT', undefined, true)
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields.map(item => item.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
expect(fields[0]!.props('modelValue')).toEqual(['301'])
expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3)
for (const [index, files] of fields.entries()) {
@@ -280,7 +280,7 @@ describe('dispatch workflow native layout', () => {
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields.map(item => item.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
@@ -137,7 +137,7 @@ function payload(): object {
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT':
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车牌图片;当前图片尚未保存')
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
@@ -268,7 +268,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
<template #attachments>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
@@ -1,6 +1,6 @@
import type { DispatchAction, DispatchActions, DispatchDetail, ReviewAttachments, Reviews } from './dispatchContracts'
export const dispatchReviewAttachmentFields = [
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车辆图片' },
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车牌图片' },
{ key: 'driverPhotoFileIds', kind: 'DRIVER_PHOTO', label: '司机照片' },
{ key: 'licenseFileIds', kind: 'DRIVER', label: '驾驶证照片' },
] as const
@@ -402,6 +402,7 @@ it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers B
const wrapper = mount(DispatchesView, options); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((actions.props('actions') as Array<{ key: string }>).some(item => item.key === 'bol')).toBe(status === 'APPROVED')
expect(actions.findAll('button').some(button => button.text() === '下载BOL')).toBe(status === 'APPROVED')
actions.vm.$emit('action', 'bol'); await flushPromises()
expect(getDispatchBolPdf).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
expect(window.open).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
@@ -449,10 +450,12 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
it('opens the exception form from the dispatching row and hides it without permission', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', actions: { MARK_EXCEPTION: gate } })]))
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } }, actions: { MARK_EXCEPTION: gate } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'MARK_EXCEPTION', label: '标记异常', tone: 'danger' })]))
expect(row.findAll('button').map(button => button.text())).toEqual(['管理资料', '标记异常', '下载BOL', '更多'])
row.vm.$emit('action', 'MARK_EXCEPTION'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'MARK_EXCEPTION', dispatchNo: 'PC/1' })
wrapper.unmount(); can.mockReturnValue(false)
@@ -72,7 +72,7 @@ const availableFields = computed<QueryField[]>(() => [
])
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus', afterColumn: 'dispatchNo' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage', afterColumn: 'dispatchNo' },
@@ -152,7 +152,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
priority: value === primaryAction ? 600 : 400 - index * 10,
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 550, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
}
/** 已送达仅保留详情,取货和送达照片在对应作业阶段维护。 */
function pickupExecutionLabel(row: DispatchListRow) {
@@ -235,7 +235,7 @@ onBeforeUnmount(() => { sequence++ })
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}</template>
<template #driverPhone="{ row }">{{ row.resources.driver?.phone || '—' }}</template>
@@ -688,7 +688,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'],
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'],
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01'],
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
@@ -698,6 +698,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
const source = wrapper.get('[aria-label="入库来源"]')
expect(source.text()).toContain(expectedText)
expect(source.get('.inbound-plan-arrival').text()).not.toContain('默认推算')
if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('confirmDisabled')).toBe(false)
@@ -125,8 +125,8 @@ const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value
|| inboundPlanSourcePalletCount.value <= 0)))
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const inboundPlanExpectedArrival = computed(() => {
const { date, time, isDefault } = inboundPlanArrivalProjection.value
return date ? `${date}${time ? ` ${time}` : ''}${isDefault ? '(默认推算)' : ''}` : '—'
const { date, time } = inboundPlanArrivalProjection.value
return date ? `${date}${time ? ` ${time}` : ''}` : '—'
})
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })