优化
This commit is contained in:
@@ -18,8 +18,8 @@ const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiE
|
||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||
const uploadTypes = computed(() => props.kind === 'DRIVER_PHOTO' ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
|
||||
|
||||
@@ -11,8 +11,8 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -197,3 +197,21 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s
|
||||
expect(wrapper.emitted('rejected')).toBeUndefined()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('requires an exception type and submits optional direction, description and images once', async () => {
|
||||
const { wrapper, state } = await open('MARK_EXCEPTION')
|
||||
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请选择异常类型')
|
||||
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
|
||||
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
|
||||
await state.submit()
|
||||
expect(api.markDispatchException).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场部分签收', imageFileIds: ['91'] }))
|
||||
expect(api.markDispatchException).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('does not submit an exception when the server disallows the action', async () => {
|
||||
const { wrapper, state } = await open('MARK_EXCEPTION', false)
|
||||
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
||||
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -10,10 +10,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -38,15 +38,16 @@ const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null,
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const reviewAttachments = ref(emptyReviewAttachments())
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
|
||||
async function load() {
|
||||
@@ -62,6 +63,7 @@ async function load() {
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
reviewAttachments.value = emptyReviewAttachments()
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||||
@@ -98,6 +100,9 @@ function payload(): object {
|
||||
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
|
||||
}
|
||||
switch (currentAction.value) {
|
||||
case 'MARK_EXCEPTION':
|
||||
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': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -117,6 +122,7 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
|
||||
const no = command.dispatchNo, body = command.body
|
||||
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
|
||||
switch (command.action) {
|
||||
case 'MARK_EXCEPTION': return api.markDispatchException(no, body as unknown as MarkDispatchException)
|
||||
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
|
||||
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
|
||||
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
|
||||
@@ -179,7 +185,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
@@ -192,6 +198,12 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
||||
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<template v-else-if="currentAction === 'MARK_EXCEPTION'">
|
||||
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'EDIT'">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||
@@ -218,7 +230,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" v-model="reviewAttachments[field.key]" :reference-ids="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" save-hint="上传后提交审核,附件与本次审核结果一起保存。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy[field.kind] = $event" />
|
||||
</template>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
await select(wrapper); await wrapper.setProps({ disabled: true })
|
||||
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
||||
await pallet(wrapper).trigger('dragstart', { dataTransfer: transfer() })
|
||||
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
|
||||
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
@@ -106,13 +106,30 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
|
||||
for (const index of [0, 1]) {
|
||||
await select(wrapper, index)
|
||||
expect(pallet(wrapper).attributes('draggable')).toBe('false')
|
||||
expect(pallet(wrapper).get('button').text()).toBe('已加入第 1 辆车')
|
||||
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||
}
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves a confirmed pallet out of the middle list and restores it when removed; only empty cars can be deleted', async () => {
|
||||
const source = candidate(), state = draft(), wrapper = render([source])
|
||||
await select(wrapper)
|
||||
expect(pallet(wrapper).findAll('td')).toHaveLength(2)
|
||||
expect(pallet(wrapper).get('button').text()).toBe('PALLET-1')
|
||||
expect(wrapper.text()).not.toContain('加入这辆车')
|
||||
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
|
||||
await wrapper.setProps({ draft: clone(state) })
|
||||
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||
const loaded = wrapper.get('[aria-label="第 1 辆车"]')
|
||||
expect(loaded.text()).not.toContain('删除车辆')
|
||||
expect(wrapper.get('[aria-label="第 2 辆车"]').text()).toContain('删除车辆')
|
||||
await loaded.get('[aria-label="移回托盘 PALLET-1"]').trigger('click')
|
||||
expect(wrapper.emitted('removeCargo')).toEqual([[source, 'car-1']])
|
||||
await wrapper.setProps({ draft: draft() })
|
||||
expect(pallet(wrapper).text()).toContain('PALLET-1')
|
||||
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('删除车辆')
|
||||
})
|
||||
|
||||
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
|
||||
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
|
||||
source.selectionGroup.complete = false
|
||||
|
||||
@@ -63,7 +63,7 @@ const orders = computed(() => {
|
||||
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
|
||||
})
|
||||
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
|
||||
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []))
|
||||
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []).filter(source => !assignedVehicle(source)))
|
||||
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
|
||||
function assignedVehicle(source: Candidate) {
|
||||
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
|
||||
@@ -88,14 +88,14 @@ function dragStart(event: DragEvent, source: Candidate) {
|
||||
if (event.dataTransfer) {
|
||||
// Transfer only a harmless marker. The live component reference is the authority.
|
||||
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
function dragOver(event: DragEvent, id: string) {
|
||||
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
|
||||
event.preventDefault()
|
||||
dropTarget.value = id
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
function drop(event: DragEvent, id: string) {
|
||||
event.preventDefault()
|
||||
@@ -189,24 +189,22 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
|
||||
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
|
||||
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
|
||||
<td>
|
||||
<strong>{{ palletLabel(source) }}</strong>
|
||||
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
|
||||
<el-button class="cargo-board-pallet-move" link :disabled="!canAdd(source) || !validTarget(activeContainerId)" :aria-label="`将托盘 ${palletLabel(source)} 移入选中车辆`" @click="add(source, activeContainerId)">{{ palletLabel(source) }}</el-button>
|
||||
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
|
||||
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
|
||||
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
|
||||
<details v-if="selectionGroupBlockReason(source)" class="cargo-board-block"><summary>暂不可排车</summary><p>{{ selectionGroupBlockReason(source) }}</p></details>
|
||||
</td>
|
||||
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
|
||||
</tr>
|
||||
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">该订单本页暂无托盘</td></tr>
|
||||
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">暂无待装托盘</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
|
||||
<div class="cargo-board-vehicle-content">
|
||||
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
|
||||
<div class="cargo-board-vehicle-list">
|
||||
@@ -216,11 +214,11 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
|
||||
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
|
||||
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details></div>
|
||||
<el-button link :disabled="disabled" @click="emit('removeCargo', source, vehicle.draftContainerId)">移除货物</el-button>
|
||||
<el-button link :disabled="disabled" :aria-label="`移回托盘 ${palletLabel(source)}`" title="移回托盘" @click="emit('removeCargo', source, vehicle.draftContainerId)">×</el-button>
|
||||
</div>
|
||||
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
|
||||
</div>
|
||||
<footer v-if="vehicle.draftContainerId" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
|
||||
<footer v-if="vehicle.draftContainerId && !vehicle.sourceKeys.length" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</slot>
|
||||
@@ -261,7 +259,8 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
|
||||
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
|
||||
.cargo-board-pallet.is-draggable { cursor: grab; }
|
||||
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
|
||||
.cargo-board-pallet-move { max-width: 100%; height: auto; white-space: normal; text-align: left; justify-content: flex-start; }
|
||||
.cargo-board-pallet.is-draggable { user-select: none; }
|
||||
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.cargo-board-group-details summary { cursor: pointer; }
|
||||
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
|
||||
|
||||
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -107,6 +108,34 @@ describe('new dispatch native layout', () => {
|
||||
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
|
||||
})
|
||||
|
||||
it('does not show a recovery warning or blank the board during a successful slow addition', async () => {
|
||||
const row = candidate()
|
||||
const empty = { ...draft(), sources: [], containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
|
||||
const { dialog } = await render(); await restore(dialog); await selectFirstOrder(dialog)
|
||||
let finishLock!: (value: DispatchDraft) => void
|
||||
let finishRefresh!: (value: Awaited<ReturnType<typeof api.listDispatchCandidates>>) => void
|
||||
vi.mocked(api.lockDispatchDraftPallet).mockReturnValueOnce(new Promise(resolve => { finishLock = resolve }))
|
||||
vi.mocked(api.listDispatchCandidates).mockReturnValueOnce(new Promise(resolve => { finishRefresh = resolve }))
|
||||
const content = view(dialog)
|
||||
await content.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||
expect(content.text()).not.toContain('原请求结果尚待核对')
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
expect(dialog.props('confirmLoading')).toBe(true)
|
||||
finishLock({ ...empty, recordVersion: 1, sources: [row], containers: [{ ...empty.containers[0]!, sourceKeys: [row.sourceKey] }] })
|
||||
await flushPromises()
|
||||
expect(dialog.props('loading')).toBe(false)
|
||||
expect(content.get('[aria-label="待排订单"]').text()).toContain(row.orderNo)
|
||||
expect(content.get('[aria-label="托盘明细"]').text()).not.toContain('请选择左侧订单')
|
||||
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('true')
|
||||
expect(content.text()).not.toContain('原请求结果尚待核对')
|
||||
finishRefresh({ total: 1, page: 1, size: 20, pages: 1, candidates: [row], complete: true, snapshotAt: '' })
|
||||
await flushPromises()
|
||||
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('false')
|
||||
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
||||
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
|
||||
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
||||
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
|
||||
@@ -124,7 +153,7 @@ describe('new dispatch native layout', () => {
|
||||
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
await selectFirstOrder(dialog)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
||||
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
|
||||
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
|
||||
@@ -132,7 +161,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
||||
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第 1 辆车')!.attributes('disabled')).toBeDefined()
|
||||
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||
})
|
||||
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
|
||||
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
||||
@@ -140,7 +169,7 @@ describe('new dispatch native layout', () => {
|
||||
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
await selectFirstOrder(dialog)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
||||
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -213,11 +242,15 @@ describe('new dispatch native layout', () => {
|
||||
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||
expect(view(dialog).text()).toContain('new-dispatch-request')
|
||||
expect(view(dialog).text()).toContain('原请求结果尚待核对')
|
||||
expect(dialog.props('confirmText')).toBe('重试原请求')
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -260,18 +293,63 @@ describe('new dispatch native layout', () => {
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
|
||||
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
|
||||
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
await selectFirstOrder(dialog)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
||||
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||
expect(view(dialog).text()).toContain('draft-rejected-request')
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('rejects removal of a populated car before sending a command and keeps the draft editable', async () => {
|
||||
const { dialog } = await render(); await restore(dialog)
|
||||
dialog.getComponent(SmartDispatchCargoBoard).vm.$emit('removeContainer', 'container-1'); await flushPromises()
|
||||
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
|
||||
expect(view(dialog).text()).toContain('请先移回车内托盘')
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
|
||||
})
|
||||
it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
|
||||
const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
|
||||
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
|
||||
? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
|
||||
const { dialog } = await render(); await restore(dialog)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
|
||||
expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
|
||||
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
|
||||
if (failure === 'rejection') {
|
||||
expect(view(dialog).text()).toContain('仅空容器可以删除')
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||
} else {
|
||||
const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
|
||||
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
|
||||
expect(view(dialog).text()).toContain('原请求结果尚待核对')
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
|
||||
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||
}
|
||||
})
|
||||
it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
|
||||
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
|
||||
const { dialog } = await render()
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
|
||||
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
|
||||
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
|
||||
})
|
||||
it('keeps creation unavailable without its write permission', async () => {
|
||||
can.mockReturnValue(false)
|
||||
|
||||
@@ -92,7 +92,7 @@ async function warehouseSelected(dialog: VueWrapper) {
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
|
||||
}
|
||||
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
|
||||
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
|
||||
describe('three-column cargo planning through visible controls', () => {
|
||||
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
|
||||
@@ -179,7 +179,8 @@ describe('three-column cargo planning through visible controls', () => {
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
|
||||
expect(view(dialog).text()).toContain(`request-${step}`)
|
||||
expect(view(dialog).text()).toContain('按明确驳回结果重新核对')
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||
})
|
||||
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
|
||||
lostReply = step
|
||||
@@ -205,9 +206,9 @@ describe('three-column cargo planning through visible controls', () => {
|
||||
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const dialog = await render(); await warehouseSelected(dialog)
|
||||
await selectOrder(dialog, 'PALLET-1')
|
||||
await cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click')
|
||||
await cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').trigger('click')
|
||||
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
|
||||
expect(cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.attributes('disabled')).toBeDefined()
|
||||
expect(cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
|
||||
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
@@ -236,8 +237,7 @@ describe('three-column cargo planning through visible controls', () => {
|
||||
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
|
||||
for (const source of catalog) {
|
||||
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
|
||||
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
|
||||
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
|
||||
expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false)
|
||||
}
|
||||
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -31,6 +31,9 @@ const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}
|
||||
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
|
||||
const pending = ref<DraftCommand | null>(null)
|
||||
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
|
||||
// Only a recorded first-attempt rejection proves that no write occurred.
|
||||
let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
|
||||
if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
|
||||
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
|
||||
const settingsExpanded = ref(false)
|
||||
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
@@ -49,6 +52,7 @@ const currentSettings = computed(() => {
|
||||
})
|
||||
const form = computed(() => currentSettings.value.form)
|
||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
||||
const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
|
||||
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
||||
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
||||
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
||||
@@ -112,7 +116,9 @@ async function init() {
|
||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||
if (sequence !== generation) return
|
||||
warehouses.value = directory; drafts.value = saved
|
||||
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
|
||||
const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
|
||||
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
|
||||
rejectedDraftToken = ''
|
||||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||||
if (!warehouseId.value) {
|
||||
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
||||
@@ -147,7 +153,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||
}
|
||||
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
|
||||
if (!canCreate.value || loading.value || saving.value) return false
|
||||
if (!canCreate.value || loading.value || saving.value || pending.value) return false
|
||||
error.value = null
|
||||
try {
|
||||
if (!pending.value) {
|
||||
@@ -160,7 +166,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||||
else if (action === 'DELETE') {
|
||||
const container = current.containers.find(row => row.draftContainerId === containerId)
|
||||
if (!container || container.sourceKeys.length) throw new DispatchInputError('请先移回车内托盘,再删除空车辆')
|
||||
body = { ...common, reason: '移除本草稿装车容器' }
|
||||
}
|
||||
else if (action === 'RELEASE') body = common
|
||||
else {
|
||||
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
||||
@@ -215,7 +225,9 @@ async function retryDraft() {
|
||||
// No new source mutation before the original response has been validated.
|
||||
pending.value = null; persist()
|
||||
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
||||
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
|
||||
} catch (cause) { // A later rejection cannot erase an earlier unknown write result.
|
||||
if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
|
||||
persist(); throw cause }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
@@ -234,7 +246,7 @@ async function submit() {
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
|
||||
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
|
||||
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
|
||||
@@ -243,8 +255,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
<div class="dispatch-candidate-query">
|
||||
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
|
||||
@@ -257,13 +268,13 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
|
||||
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
|
||||
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
|
||||
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
||||
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
||||
</div>
|
||||
</section>
|
||||
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||||
<template v-if="mode === 'cargo'">
|
||||
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
|
||||
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
|
||||
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
|
||||
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
|
||||
<template #vehicle-controls>
|
||||
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function open(dispatchNo: string | null = 'PC/1') {
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await button(wrapper, '加入这辆车').trigger('click'); await flushPromises() }
|
||||
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
|
||||
expect(button(wrapper, '加入这辆车').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
||||
|
||||
Reference in New Issue
Block a user