fix: align audit and dispatch workflows and release failed requests
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
|
||||
@@ -27,7 +27,7 @@ it('reads and writes original dispatch appointment allocations with lossless ver
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'APPT-1' }] }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
|
||||
it('shows each destination once and keeps all shipment numbers from its grouped projection', () => {
|
||||
const first: FbaAppointment = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'A', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [first, { ...first, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8', batchKey: 'FBA_APPOINTMENT:2', shipmentNumbers: ['SHIP-3'] }] } })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('SHIP-1、SHIP-2'); expect(wrapper.text()).toContain('SHIP-3')
|
||||
expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
})
|
||||
it('shows missing confirmation as a dash and never substitutes a batch number or planned time', () => {
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'AP-PLAN', appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: null, status: 'PENDING', confirmedAt: null, blockReason: null }] } })
|
||||
expect(wrapper.findAll('tbody td').map(cell => cell.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.text()).not.toContain('AP-PLAN')
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
defineProps<{ appointments: FbaAppointment[] }>()
|
||||
</script>
|
||||
<template>
|
||||
<table class="density-table" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in appointments" :key="appointment.destinationKey"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td><td>{{ appointment.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
<tr v-if="!appointments.length"><td colspan="4">—</td></tr>
|
||||
</tbody></table>
|
||||
</template>
|
||||
@@ -66,16 +66,13 @@ it('switches customer scope exactly and keeps unsaved delivery edits when switch
|
||||
expect(wrapper.find('[data-category="BOL"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-category="LOADING_PHOTO"]').exists()).toBe(false)
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(true)
|
||||
expect(receipt.isVisible()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('油耗图片')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '签收文件')!.trigger('click')
|
||||
expect(receipt.isVisible()).toBe(true)
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(false)
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(true)
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||
|
||||
@@ -12,7 +12,6 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
||||
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -20,13 +19,13 @@ const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
|
||||
const tab = ref('RECEIPT'), selected = ref('')
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
|
||||
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
async function load() {
|
||||
@@ -49,11 +48,11 @@ async function canLeave() {
|
||||
}
|
||||
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
|
||||
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
|
||||
function saved() { 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'; fuelVisited.value = false; error.value = null
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
|
||||
if (visible && props.dispatchNo) void load()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -61,15 +60,14 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
<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()">
|
||||
<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 [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', 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>
|
||||
<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>
|
||||
<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>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,17 @@ vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchE
|
||||
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
|
||||
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
|
||||
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('上传失败'))
|
||||
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
|
||||
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
|
||||
|
||||
@@ -23,7 +23,7 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
|
||||
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
||||
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(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||
const uploadTypes = computed(() => (props.photoCard || ['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) {
|
||||
|
||||
@@ -38,26 +38,26 @@ it('offers five whole-dispatch slots and only reports saved after binding succee
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
|
||||
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(item => item.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', original)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ fileId: original.fileId })); expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definitive version conflict while retaining the uploaded file', async () => {
|
||||
it('uses the current version when the operator chooses a photo after a conflict', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }))
|
||||
await wrapper.findAll('button').find(item => item.text() === '刷新并重试保存')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('shows existing photos without exposing upload for a read-only account', async () => {
|
||||
can.mockReturnValue(false)
|
||||
|
||||
@@ -7,19 +7,17 @@ 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 { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
||||
async function previews() {
|
||||
const photos = snapshot.value?.photos || []
|
||||
@@ -29,62 +27,30 @@ async function previews() {
|
||||
}))
|
||||
}
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
try {
|
||||
const result = await bindLoadingPhoto(number, item.slot, item.binding)
|
||||
accept(result); pending.value = null; persist(); emit('saved')
|
||||
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
|
||||
await previews()
|
||||
} catch (cause) {
|
||||
const failure = getApiErrorDetails(cause, '装车照片保存失败')
|
||||
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
|
||||
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
|
||||
await bind()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
await previews()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
|
||||
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
|
||||
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
@@ -92,11 +58,10 @@ defineExpose({ busy })
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
|
||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>装车照片 {{ index + 1 }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
|
||||
@@ -7,14 +7,14 @@ import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/oversea
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(listVehicles).mockResolvedValue({ records: [], page: 1, size: 100, total: 0, pages: 0 }) })
|
||||
it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous master snapshot fields read-only and uses a dash for missing facts (%s)', async (carrierName, phone) => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
@@ -25,7 +25,7 @@ it('keeps master company and phone fields out of the existing non-continuous cre
|
||||
})
|
||||
it('shows the newly selected authorized vehicle company without copying it into the master command', async () => {
|
||||
const candidate: VehicleDetail = { id: 'vehicle-2', plateNumber: 'CA-200', model: '厢式货车', driverOrDispatchCompany: '新承运公司', phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValueOnce({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string): void }
|
||||
@@ -54,3 +54,20 @@ it.each([false, true])('requires each directory permission before reading candid
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('filters real unavailable statuses, sends dispatch context, and lets an invalid selection be cleared', async () => {
|
||||
const base: VehicleDetail = { id: 'good', plateNumber: 'GOOD', model: '厢式货车', driverOrDispatchCompany: null, phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [base, { ...base, id: 'bad', plateNumber: 'BAD', status: 'MAINTENANCE' }], page: 1, size: 100, total: 2, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'bad'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true, dispatchNo: 'PC-REAL' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前车辆已不可用')
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string | null): void }
|
||||
await state.searchVehicles(); await flushPromises()
|
||||
expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' }))
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false)
|
||||
expect(options.find(item => item.props('value') === 'bad')?.props('disabled')).toBe(true)
|
||||
state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined()
|
||||
state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
|
||||
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
|
||||
const availableVehicle = (row: VehicleDetail) => row.region === 'OVERSEAS' && row.status === 'IN_SERVICE'
|
||||
let eligibilitySequence = 0
|
||||
watch(() => [vehicle.value.mode, vehicle.value.vehicleId, props.dispatchNo] as const, async ([mode, id]) => {
|
||||
const sequence = ++eligibilitySequence
|
||||
currentVehicle.value = null; vehicleUnavailable.value = false
|
||||
if (mode !== 'MASTER' || !id || !vehicle.value.plate || !access.can(resourcePermission.vehicleList)) return
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 100, keyword: vehicle.value.plate, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo })
|
||||
if (sequence !== eligibilitySequence) return
|
||||
currentVehicle.value = result.records.find(row => row.id === id && availableVehicle(row)) || null
|
||||
vehicleUnavailable.value = !currentVehicle.value
|
||||
} catch (cause) { if (sequence === eligibilitySequence) error.value = cause instanceof Error ? cause.message : '当前车辆资格读取失败' }
|
||||
}, { immediate: true })
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
|
||||
const sequence = ++vehicleSequence
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (sequence === vehicleSequence) vehicles.value = result.records }
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo }); if (sequence === vehicleSequence) vehicles.value = result.records.filter(availableVehicle) }
|
||||
catch (cause) { if (sequence === vehicleSequence) error.value = cause instanceof Error ? cause.message : '车辆候选读取失败' }
|
||||
}
|
||||
async function searchDrivers(keyword = '') {
|
||||
@@ -26,8 +41,10 @@ async function searchDrivers(keyword = '') {
|
||||
}
|
||||
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
|
||||
function changeDriver() { driver.value = { mode: driver.value.mode, driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||
function selectVehicle(id: string) {
|
||||
const selected = vehicles.value.find(row => row.id === id)
|
||||
function selectVehicle(id: string | null) {
|
||||
if (!id) { changeVehicle(); return }
|
||||
const selected = vehicleOptions.value.find(row => row.id === id)
|
||||
if (!selected || !availableVehicle(selected)) { error.value = '车辆当前不可用,请重新选择'; return }
|
||||
vehicle.value = { mode: 'MASTER', vehicleId: id, plate: selected?.plateNumber || null, carrierName: null, capacity: null, evidenceFileIds: [] }
|
||||
}
|
||||
function selectDriver(id: string) {
|
||||
@@ -36,10 +53,11 @@ function selectDriver(id: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-alert v-if="vehicleUnavailable" title="当前车辆已不可用,请清除或重新选择车辆;草稿可分步保存。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="continuous">
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<template v-if="vehicle.mode === 'EXTERNAL'">
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
@@ -56,7 +74,7 @@ function selectDriver(id: string) {
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div class="resource-grid">
|
||||
<el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
|
||||
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
|
||||
|
||||
@@ -19,7 +19,7 @@ beforeEach(() => {
|
||||
vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo)
|
||||
vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] })
|
||||
})
|
||||
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
|
||||
it('does not restore failed bindings after reopening and allows a new upload', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
|
||||
const first = setup(); await flushPromises(); await upload(first)
|
||||
const table = first.get('table[aria-label="油耗图片"]')
|
||||
@@ -29,33 +29,33 @@ it('binds the uploaded file to the car and restores the original binding after r
|
||||
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
|
||||
first.unmount()
|
||||
const reopened = setup(); await flushPromises()
|
||||
await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(reopened)
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(reopened.text()).toContain('fuel.png')
|
||||
expect(reopened.get('table tbody tr').text()).toContain('2026-10-21 00:00:00Z')
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
reopened.unmount()
|
||||
})
|
||||
it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => {
|
||||
it('unlocks uncertain uploads for a new file selection', async () => {
|
||||
vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3]
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(recoverVehiclePhoto).not.toHaveBeenCalled(); expect(vi.mocked(uploadVehiclePhoto).mock.calls[1]![3]).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(bindVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes only a known version conflict and reuses the uploaded file', async () => {
|
||||
it('allows a new photo and current version after a binding conflict', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey
|
||||
vi.mocked(getVehicleDocuments).mockResolvedValue(response('8'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) })
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer upload with list permission alone', async () => {
|
||||
|
||||
@@ -7,70 +7,41 @@ 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 { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, uploadVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
||||
const preview = ref('')
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
let result: VehicleDocuments
|
||||
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
|
||||
catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '图片保存失败')
|
||||
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function refreshBinding() {
|
||||
if (busy.value || !editable.value || !pending.value?.versionConflict) return
|
||||
await load()
|
||||
if (error.value || !pending.value) return
|
||||
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
|
||||
await recover()
|
||||
}
|
||||
async function upload(slot: FuelSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
|
||||
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
ensureScope()
|
||||
const photo = await uploadVehiclePhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getVehicleDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindVehiclePhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function view(photo: VehiclePhoto) {
|
||||
if (busy.value) return
|
||||
@@ -79,23 +50,16 @@ async function view(photo: VehiclePhoto) {
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="role in roles" :key="role.slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>{{ role.label }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">未上传</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import type { DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
|
||||
defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
const maintenance = defineModel<DispatchVehicleMaintenance>('maintenance', { required: true })
|
||||
@@ -17,6 +17,12 @@ const deliveryMethods = ['FBA自提', '客户自提', '卡派打托', '卡派地
|
||||
const pickupMethods = ['一提一卸', '一提多卸']
|
||||
const loadTypes = ['托盘', '地板', '混装']
|
||||
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
|
||||
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
|
||||
function confirmedTime(appointment: DispatchDestinationAppointment) {
|
||||
const fact = appointmentFacts.value.get(appointment.destinationKey)
|
||||
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,7 +32,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
<h3 class="density-dialog-section__title">排车时间</h3>
|
||||
@@ -42,17 +48,17 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<el-form-item label="车队邮箱"><el-input v-model="maintenance.fleetEmail" :disabled="disabled" maxlength="254" placeholder="请输入车队邮箱" /></el-form-item>
|
||||
<el-form-item label="车辆类型"><el-select v-model="maintenance.vehicleType" :disabled="disabled" clearable placeholder="请选择车辆类型"><el-option v-for="value in vehicleTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
|
||||
<el-form-item label="预约号"><el-input v-model="maintenance.appointmentNo" :disabled="disabled" maxlength="128" placeholder="请输入预约号" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="detail.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="publishLooseboardDemand" :disabled="disabled">发布公共散板需求</el-checkbox></el-form-item>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>已确认预约号</th><th>确认送达时间</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
|
||||
<section class="density-dialog-section" aria-label="车辆维护附件"><h3 class="density-dialog-section__title">附件</h3><slot name="attachments" /></section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
|
||||
<slot name="appointments" />
|
||||
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
@@ -53,11 +53,28 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('brings ten orders at one destination into one appointment input with confirmed time and all shipments', async () => {
|
||||
const initial = detail(), shipmentNumbers = Array.from({ length: 10 }, (_, index) => `SHIP-${index}`)
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: 'ISA-ONE', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers }
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: shipmentNumbers.map((_, index) => ({ ...initial.orderDetails[0]!, orderNo: `LM-${index}`, customerId: `C-${index}` })), dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
const table = dialog.get('[aria-label="目的仓预约"]')
|
||||
expect(table.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(table.findAll('input')).toHaveLength(1)
|
||||
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
|
||||
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
|
||||
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
|
||||
await table.get('input').setValue('ISA-CHANGED')
|
||||
expect(table.findAll('tbody td')[2]!.text()).toBe('—')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-CHANGED' }] }) }))
|
||||
})
|
||||
it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => {
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-SAVED' }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved } })
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'CONF-REAL' }] }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved }, dispatchInfo: { ...detail().dispatchInfo, fbaAppointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: ['SHIP-REAL'], batchNo: 'AP-1', appointmentNo: 'CONF-REAL', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null }] } })
|
||||
expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test')
|
||||
expect((field(dialog, '预约号').get('input').element as HTMLInputElement).value).toBe('APPT-SAVED')
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('CONF-REAL')
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(1)
|
||||
expect(dialog.text()).not.toContain('关联正式预约')
|
||||
expect(dialog.text()).toContain('关联订单 (1)')
|
||||
const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!
|
||||
await orders.trigger('click')
|
||||
@@ -67,6 +84,29 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it('edits one number per destination and can save an incomplete second destination', async () => {
|
||||
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: [...initial.orderDetails, { ...initial.orderDetails[0]!, customerId: 'OTHER', orderNo: 'LM-OTHER' }], dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: destinations } })
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue(' ISA-ONT8 ')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-ONT8' }, { destinationKey: 'FBA:LGB8', appointmentNo: null }] }) }))
|
||||
})
|
||||
it('keeps input editable after rejection and adds concurrent destinations when current facts are refreshed', async () => {
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue('ISA-KEEP')
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('排车版本已变化', 'A0409', 200))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2, actions, dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment, { ...appointment, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8' }] } }))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(field(dialog, 'ONT8 预约号').get('input').attributes('disabled')).toBeUndefined()
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('ISA-KEEP')
|
||||
expect(field(dialog, 'LGB8 预约号')).toBeDefined()
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -90,13 +130,12 @@ describe('dispatch workflow native layout', () => {
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
await field(dialog, '预约号').get('input').setValue(' APPT-NEW ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', appointmentNo: 'APPT-NEW' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
})
|
||||
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
@@ -128,8 +167,15 @@ describe('dispatch workflow native layout', () => {
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.props('width')).toBe('1100px')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
|
||||
expect(dialog.get('[aria-label="本车审核依据"] h3').text()).toBe('车辆与货物')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话')
|
||||
expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0)
|
||||
const photos = dialog.get('[aria-label="审核照片"]').findAllComponents(DispatchEvidenceFiles)
|
||||
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('请核对本车资源与本趟证据')
|
||||
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
|
||||
expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
|
||||
@@ -191,6 +237,8 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
|
||||
expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)})
|
||||
@@ -232,9 +280,9 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
|
||||
})
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('replaces the old %s editor with the smart dispatch entry notice', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('keeps the retired %s editor unavailable', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(dialog.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(dialog.findComponent({ name: 'ElForm' }).exists()).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -11,7 +11,6 @@ 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('./DispatchAppointmentAllocationsDialog.vue', () => ({ default: { name: 'DispatchAppointmentAllocationsDialog', props: ['modelValue', 'dispatchNo'], emits: ['saved', 'update:modelValue'], template: '<div />' } }))
|
||||
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(submitReviewAfterSave?: boolean): 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; publishLooseboardDemand: boolean }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
@@ -19,19 +18,7 @@ async function open(action: DispatchAction, allowed = true) {
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('opens appointment linking from vehicle maintenance and refreshes the original version without losing unsaved inputs', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
state.form.remark = '尚未保存的车辆备注'
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '关联正式预约')
|
||||
expect(button).toBeDefined(); await button!.trigger('click'); await flushPromises()
|
||||
const child = wrapper.findComponent({ name: 'DispatchAppointmentAllocationsDialog' })
|
||||
expect(child.props('dispatchNo')).toBe('PC/1')
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
|
||||
child.vm.$emit('saved'); await flushPromises()
|
||||
expect(state.detail.recordVersion).toBe(2); expect(state.form.remark).toBe('尚未保存的车辆备注')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
@@ -103,17 +90,17 @@ it('refuses a route that drops an authoritative destination', async () => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
|
||||
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
|
||||
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
wrapper.unmount()
|
||||
@@ -124,7 +111,7 @@ it('retains edited values and the original validation response when the action i
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(state.detail.recordVersion).toBe(7)
|
||||
expect(state.detail.recordVersion).toBe(0)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
expect(wrapper.text()).toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
@@ -135,7 +122,7 @@ it('retains edited values and the original validation response when the action i
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
|
||||
it('allows another click after rejection even when a later read would fail', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
|
||||
@@ -146,7 +133,7 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(state.form.remark).toBe('保留本次备注')
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ends every failed request and displays a later business rejection without replaying',async()=>{
|
||||
@@ -174,10 +161,10 @@ it('unlocks when a successful write is followed by a failed read',async()=>{
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command after the existing cargo editor is retired', async action => {
|
||||
const { wrapper, state } = await open(action)
|
||||
try {
|
||||
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(wrapper.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
await state.submit()
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
@@ -191,7 +178,7 @@ it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when open
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const {wrapper,state}=await open('EDIT')
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
@@ -252,7 +239,7 @@ it('allows a fresh save and review after a failed save without persisting review
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
|
||||
await state.submit(true)
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
|
||||
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
|
||||
@@ -283,3 +270,14 @@ it('persists the explicit publication choice with the existing vehicle save', as
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves a cleared unavailable master vehicle as an empty draft resource', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: { mode: 'MASTER'; vehicleId: string | null } }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: null }))
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
@@ -33,9 +33,6 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
|
||||
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const factsStale = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can))
|
||||
async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') }
|
||||
const preview = ref<LoadingPreview | null>(null)
|
||||
const loading = computed(() => working.value)
|
||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||
@@ -49,14 +46,19 @@ const approvalBlock = computed(() => isReview.value && detail.value?.actions[cur
|
||||
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
/** 并发加货后补入新目的仓,保留客服已填写的同仓编号。 */
|
||||
function fillDestinationAppointments(value: DispatchDetail, keepInput = false) {
|
||||
const entered = new Map(keepInput ? form.maintenance.destinationAppointments.map(row => [row.destinationKey, row.appointmentNo]) : [])
|
||||
form.maintenance.destinationAppointments = value.dispatchInfo.fbaAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: entered.has(row.destinationKey) ? entered.get(row.destinationKey)! : row.appointmentNo }))
|
||||
}
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
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 vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
@@ -68,12 +70,13 @@ async function load() {
|
||||
route.value = null; stops.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
fillDestinationAppointments(value)
|
||||
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()
|
||||
@@ -114,7 +117,7 @@ function payload(): object {
|
||||
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, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? 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':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -152,13 +155,12 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||||
await submit()
|
||||
}
|
||||
async function submit(submitReviewAfterSave = false) {
|
||||
if (appointmentsOpen.value) return
|
||||
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
if (!pending.value) {
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||
@@ -168,7 +170,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
|
||||
const readCurrent = async (no: string) => {
|
||||
const value = await api.getDispatch(no)
|
||||
if (value.dispatchNo === currentNo.value) detail.value = value
|
||||
if (value.dispatchNo === currentNo.value) { detail.value = value; fillDestinationAppointments(value, true) }
|
||||
return value
|
||||
}
|
||||
let result = await recovery.execute(send, readCurrent)
|
||||
@@ -187,7 +189,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
|
||||
editorRequestKey = ''
|
||||
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
|
||||
if (!(cause instanceof DispatchInputError)) await refreshFacts()
|
||||
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
async function refreshFacts() {
|
||||
@@ -198,6 +200,7 @@ async function refreshFacts() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
fillDestinationAppointments(value, true)
|
||||
if (currentOperation.value === 'ROUTE') {
|
||||
const data = await api.getDispatchRoute(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -216,13 +219,12 @@ 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'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @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'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : 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 />
|
||||
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div>
|
||||
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||||
@@ -237,7 +239,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<template v-else-if="vehicleEditor">
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<template #appointments><div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div></template>
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
@@ -249,9 +250,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<table class="density-table"><tbody><tr><th>计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td></tr><tr><th>预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th><th>确认送达(北京时间)</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}</td><td>{{ reviewTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
|
||||
<h3 class="density-dialog-section__title">车辆与货物</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||||
<tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td><th scope="row">路线</th><td>{{ detail.dispatchInfo.route || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="排车与预约">
|
||||
<h3 class="density-dialog-section__title">排车与预约</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" />
|
||||
<el-alert v-if="approvalBlock" :title="approvalBlock" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
@@ -261,10 +270,13 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<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="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
</section>
|
||||
<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">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_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="备注"><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>
|
||||
@@ -280,7 +292,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<DispatchAppointmentAllocationsDialog v-if="appointmentsOpen" v-model="appointmentsOpen" :dispatch-no="currentNo" @saved="appointmentsSaved" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
|
||||
@@ -141,23 +141,24 @@ it('receives the native original carton whole into a verified target without inv
|
||||
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
|
||||
it('allows editing after unknown failure and submits a new command', async () => {
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
|
||||
expect(wrapper.text()).toContain('已保留原请求')
|
||||
expect(wrapper.get('[data-test="notes"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('[data-test="notes"]').setValue('changed')
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
|
||||
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
|
||||
@@ -246,7 +247,7 @@ it('allows cancellation and ordinary completion with standard audit text and sti
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
|
||||
it('closing and reopening blocks only the active request and failures release each subsequent command', async () => {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
@@ -258,15 +259,15 @@ it('closing and reopening cannot replay an in-flight command or lose a later unk
|
||||
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
|
||||
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(originalKey)
|
||||
await fill(wrapper)
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
|
||||
expect(newKey).not.toBe(originalKey)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).not.toBe(newKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,13 +22,12 @@ type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity:
|
||||
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
|
||||
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
|
||||
const pendingByScope = reactive(new Map<string, Attempt>())
|
||||
const unknownUploads = reactive(new Set<string>())
|
||||
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
|
||||
const selectedHistory = ref<string[]>([])
|
||||
const inFlight = reactive(new Map<string, string>())
|
||||
const uploadingScopes = reactive(new Set<string>())
|
||||
const pending = computed(() => pendingByScope.get(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
|
||||
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
|
||||
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
|
||||
let sequence = 0
|
||||
@@ -91,13 +90,12 @@ async function upload(event: Event) {
|
||||
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
|
||||
if (current(ticket)) evidence.value.push(fileResult)
|
||||
} catch (cause) {
|
||||
if (!definite(cause)) unknownUploads.add(scope)
|
||||
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
|
||||
if (current(ticket)) error.value = getApiErrorDetails(cause, '证据上传失败,请重新选择文件').message
|
||||
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
|
||||
}
|
||||
async function execute(action: ExceptionAction) {
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
|
||||
if (pending.value && pending.value.action !== action) return
|
||||
if (!pending.value) {
|
||||
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
|
||||
@@ -128,7 +126,7 @@ async function execute(action: ExceptionAction) {
|
||||
const result = await commandException(customer, number, attempt.action, attempt.payload)
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
|
||||
throw new Error('处理结果无法核对,请保留原请求重试')
|
||||
throw new Error('处理结果无法核对,请刷新当前记录')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
if (!current(ticket)) return
|
||||
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
|
||||
@@ -137,9 +135,9 @@ async function execute(action: ExceptionAction) {
|
||||
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
|
||||
if (!current(ticket)) return
|
||||
const text = getApiErrorDetails(cause, '处理请求失败').message
|
||||
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
|
||||
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
|
||||
error.value = text
|
||||
} finally {
|
||||
if (sameAttempt()) pendingByScope.delete(scope)
|
||||
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
|
||||
if (current(responseSequence)) busy.value = false
|
||||
}
|
||||
@@ -221,12 +219,9 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
</el-form>
|
||||
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
|
||||
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
|
||||
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -14,30 +14,29 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('./SmartDispatchExistingCargo.vue', () => ({ default: { name: 'SmartDispatchExistingCargo', emits: ['saved', 'busy'], template: '<div data-existing-cargo />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
||||
it('switches to existing cargo inside the same dialog without creating a draft or submitting a new dispatch', async () => {
|
||||
it('opens only new dispatch planning and keeps draft cargo selection available', async () => {
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
await wrapper.getComponent({ name: 'ElDialogContent' }).findAll('button').find(button => button.text() === '调整已有排车货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(dialog.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain('调整已有排车货物')
|
||||
expect(dialog.findComponent({ name: 'SmartDispatchCargoBoard' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(true)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledOnce()
|
||||
expect(api.createDispatch).not.toHaveBeenCalled()
|
||||
expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
|
||||
wrapper.getComponent({ name: 'SmartDispatchExistingCargo' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
|
||||
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留')
|
||||
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
|
||||
})
|
||||
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||
@@ -148,7 +147,7 @@ it('clears an old departure request without blocking new empty creation',async()
|
||||
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
|
||||
state.mode='empty';await flushPromises();await state.submit()
|
||||
expect(api.createDispatch).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留');wrapper.unmount()
|
||||
})
|
||||
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
|
||||
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
|
||||
|
||||
@@ -20,7 +20,6 @@ import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
@@ -30,8 +29,6 @@ const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
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[] }
|
||||
const pending = ref<DraftCommand | null>(null)
|
||||
if (owner) 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[]>([])
|
||||
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
|
||||
@@ -44,8 +41,6 @@ const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
const publishLooseboardDemand = ref(false)
|
||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.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 || recovery.busy.value)
|
||||
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
|
||||
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
|
||||
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
|
||||
@@ -107,8 +102,7 @@ async function init() {
|
||||
const sequence = ++generation
|
||||
working.value = true; error.value = null
|
||||
try {
|
||||
if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
|
||||
if (workspace.value === 'existing' || !canCreate.value) return
|
||||
if (!canCreate.value) return
|
||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||
if (sequence !== generation) return
|
||||
warehouses.value = directory; drafts.value = saved
|
||||
@@ -124,13 +118,6 @@ async function init() {
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
function switchWorkspace(value: 'create' | 'existing') {
|
||||
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
|
||||
workspace.value = value
|
||||
existingBusy.value = false
|
||||
candidatePage.reset()
|
||||
void init()
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
if (sequence !== generation) return
|
||||
const candidateWarehouse = draft.value?.warehouseId
|
||||
@@ -219,20 +206,9 @@ async function retryDraft() {
|
||||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||||
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
||||
} catch (cause) {
|
||||
// 请求失败后读取服务器最新草稿,保留本地时间和备注,后续点击按当前版本重新提交。
|
||||
if (command.body.draftToken) {
|
||||
try {
|
||||
const saved = await api.listDispatchDrafts()
|
||||
drafts.value = saved; draft.value = saved.find(row => row.draftToken === command.body.draftToken) || null
|
||||
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
} catch { /* 刷新失败只保留原错误,不锁住其他操作。 */ }
|
||||
}
|
||||
throw cause
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (workspace.value === 'existing') return
|
||||
if (!canCreate.value || preparing.value || saving.value || loading.value) return
|
||||
error.value = null
|
||||
try {
|
||||
@@ -246,13 +222,7 @@ async function submit() {
|
||||
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="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>
|
||||
</div>
|
||||
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
@@ -283,7 +253,6 @@ watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.va
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, listRow, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { DispatchCargoItem, DispatchDetail, DispatchItems } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '', allowed: true }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => identity.allowed }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), listDispatchCandidates: vi.fn(), addDispatchCargo: vi.fn(), removeDispatchItem: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
const current = (overrides: Partial<DispatchDetail> = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides })
|
||||
function cargo(overrides: Partial<DispatchCargoItem> = {}): DispatchCargoItem {
|
||||
const source = candidate()
|
||||
return { ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: allowed, ...overrides }
|
||||
}
|
||||
const manifest = (overrides: Partial<DispatchItems> = {}): DispatchItems => ({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [cargo()], ...overrides })
|
||||
function button(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(row => row.text() === text); if (!found) throw new Error(`Missing button ${text}`); return found }
|
||||
async function open(dispatchNo: string | null = 'PC/1') {
|
||||
const wrapper = mount(SmartDispatchExistingCargo, { props: { dispatchNo: dispatchNo ?? undefined }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
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() {} })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current())
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest())
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
|
||||
})
|
||||
|
||||
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
|
||||
const wrapper = await open()
|
||||
const search = wrapper.get('[aria-label="已有排车查询"]')
|
||||
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
|
||||
expect(search.text()).not.toContain('查询排车')
|
||||
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
|
||||
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
|
||||
const column = wrapper.get('[aria-label="车辆装载"]')
|
||||
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
|
||||
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
|
||||
expect(column.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargo: [first!.selection, second!.selection] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.getDispatchItems).toHaveBeenCalledTimes(3)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(3)
|
||||
expect(wrapper.find('[aria-label="当前排车货物"]').text()).toContain('真实已拣 0 箱')
|
||||
expect(wrapper.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it.each(['complete', 'missing', 'changed'] as const)('reads appointments of a hidden second group source: %s', async scenario => {
|
||||
const first = candidate()
|
||||
const second = candidate({ sourceKey: 'source-2', customerId: 'C2', selection: { ...first.selection, planPalletId: '12' }, appointmentBatches: [{ batchKey: 'B2', batchNo: 'AP2', customerId: 'C2', recordVersion: '9007199254740993', sources: [{ sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }] })
|
||||
const group = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['source-1', 'source-2'], packageCount: 4 }] }
|
||||
first.selectionGroup = group; second.selectionGroup = group
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size !== 100 ? [first] : query.page === 1 ? [first] : scenario === 'missing' ? [] : [{ ...second, selectionGroup: scenario === 'changed' ? { ...group, groupVersion: 'new-composition' } : group }], page: query.page!, size: query.size!, total: 101, pages: 2, complete: true, snapshotAt: '' }))
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
if (scenario === 'complete') {
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith({ warehouseId: '321', warehouseTab: 'ALL', page: 2, size: 100 })
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection], batchAllocations: [{ planPalletId: '12', batchKey: 'B2', customerId: 'C2', recordVersion: '9007199254740993', sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }))
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
} else {
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(scenario === 'missing' ? '同托货物及预约资料未完整返回' : '选货组组成已变化')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation version %s in additions', async recordVersion => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
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(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
||||
const wrapper = await open()
|
||||
await button(wrapper, '移除货物').trigger('click')
|
||||
expect(button(wrapper, '确认移除').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('本车计划调整')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
|
||||
return command('REMOVE_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
|
||||
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' }))
|
||||
expect(wrapper.text()).toContain('本车暂无货物')
|
||||
})
|
||||
|
||||
it('rejects an addition whose authoritative appointment version is missing', async () => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('预约批次版本缺失或无效')
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a candidate page from another warehouse before exposing selectable cargo', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [candidate({ warehouseId: 'OTHER' })], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('候选起运仓或来源版本不完整')
|
||||
expect(wrapper.findAll('button').some(row => row.text() === '加入这辆车')).toBe(false)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['picked', 'item-gate', 'shared-pallet'] as const)('locks removal for %s instead of changing real picking facts', async scenario => {
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ items: scenario === 'shared-pallet' ? [cargo(), cargo({ dispatchItemId: 'other', sourceKey: 'other' })] : [cargo(scenario === 'picked' ? { pickedPackages: 1 } : { remove: { allowed: false, blockCode: 'LOCK', blockReason: '正在作业' } })] }))
|
||||
const wrapper = await open()
|
||||
for (const row of wrapper.findAll('button').filter(row => row.text() === '移除货物')) expect(row.attributes('disabled')).toBeDefined()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
if (scenario === 'shared-pallet') expect(wrapper.text()).toContain('同一实物托盘包含多个来源')
|
||||
})
|
||||
|
||||
it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as const)('fails closed when %s blocks editing', async scenario => {
|
||||
if (scenario === 'loading') { vi.mocked(api.getDispatch).mockResolvedValue(current({ stage: 'LOADING' })); vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ stage: 'LOADING' })) }
|
||||
if (scenario === 'permission') identity.allowed = false
|
||||
if (scenario === 'task-type') vi.mocked(api.getDispatch).mockResolvedValue(current({ taskType: 'PICKUP' }))
|
||||
if (scenario === 'version') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 2 }))
|
||||
if (scenario === 'read-failure') vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
const wrapper = await open()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('button').filter(row => row.text() === '移除货物').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
|
||||
})
|
||||
|
||||
it('unlocks a failed removal and accepts an updated reason on a fresh request',async()=>{
|
||||
const wrapper=await open()
|
||||
await button(wrapper,'移除货物').trigger('click');await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
|
||||
vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown result'))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect((wrapper.get('textarea[aria-label="移除原因"]').element as HTMLTextAreaElement).value).toBe('原移除原因')
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('重新安排')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementationOnce(async(_,__,body)=>command('REMOVE_CARGO',body.idempotencyKey))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
const calls=vi.mocked(api.removeDispatchItem).mock.calls
|
||||
expect(calls).toHaveLength(2);expect(calls[1]![2].reason).toBe('重新安排')
|
||||
expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unlocks a completed request when its follow-up manifest read fails', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
await button(wrapper, '刷新本车').trigger('click'); await flushPromises()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
|
||||
let resolve!: (value: DispatchDetail) => void
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' }), listRow({ dispatchNo: 'PC/2', taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockImplementation(no => no === 'PC/1' ? new Promise(done => { resolve = done }) : Promise.resolve(current({ dispatchNo: 'PC/2' })))
|
||||
vi.mocked(api.getDispatchItems).mockImplementation(async no => manifest({ dispatchNo: no }))
|
||||
const wrapper = await open()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
|
||||
resolve(current()); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
|
||||
})
|
||||
|
||||
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
|
||||
const wrapper = await open(null)
|
||||
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
|
||||
const picker = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(picker.props('remote')).toBe(true)
|
||||
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
await picker.props('remoteMethod')('PC2026'); await flushPromises()
|
||||
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
|
||||
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { useDispatchCandidates } from '../useDispatchCandidates'
|
||||
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import { canDispatchAction } from '../dispatchPresentation'
|
||||
import { DispatchInputError, expandSelectionGroups, requireBatchVersions, requireSelectionGroup, requireVersion, selectionIdentity } from '../dispatchContracts'
|
||||
import type { AddCargo, Candidate, DispatchCargoItem, DispatchDetail, DispatchItems, DispatchListRow, ReasonCommand } from '../dispatchContracts'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
|
||||
const props = defineProps<{ dispatchNo?: string }>()
|
||||
const emit = defineEmits<{ saved: []; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const { pending, busy } = recovery
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
|
||||
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
|
||||
const queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
|
||||
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
|
||||
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
|
||||
const removing = ref<DispatchCargoItem | null>(null), reason = ref('')
|
||||
let generation = 0, listGeneration = 0, alive = true
|
||||
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
|
||||
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
|
||||
const loading = computed(() => reading.value || candidatePage.loading.value)
|
||||
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
|
||||
const canAdd = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'ADD_CARGO', access.can))
|
||||
const canRemove = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'REMOVE_CARGO', access.can))
|
||||
const locked = computed(() => frozen.value || loading.value)
|
||||
const equalVersion = (left: unknown, right: unknown) => String(requireVersion(left)).replace(/^0+(?=\d)/, '') === String(requireVersion(right)).replace(/^0+(?=\d)/, '')
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback)
|
||||
}
|
||||
async function loadList() {
|
||||
const sequence = ++listGeneration
|
||||
listing.value = true
|
||||
try {
|
||||
const result = await api.listDispatches({ page: page.value, size: size.value, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: queriedKeyword.value || undefined })
|
||||
if (!alive || sequence !== listGeneration) return
|
||||
if (result.page !== page.value || result.size !== size.value) throw new DispatchInputError('排车列表页码不一致,请重新查询')
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
|
||||
finally { if (alive && sequence === listGeneration) listing.value = false }
|
||||
}
|
||||
async function searchDispatches(value: string) {
|
||||
if (frozen.value) return
|
||||
queriedKeyword.value = value.trim(); page.value = 1
|
||||
await loadList()
|
||||
}
|
||||
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
|
||||
async function readFacts(no: string) {
|
||||
const [current, cargo] = await Promise.all([api.getDispatch(no), api.getDispatchItems(no)])
|
||||
if (current.dispatchNo !== no || cargo.dispatchNo !== no || !equalVersion(current.recordVersion, cargo.recordVersion) || current.stage !== cargo.stage || current.status !== cargo.status) {
|
||||
throw new DispatchInputError('排车与货物清单已变化,请重新读取一致版本后操作')
|
||||
}
|
||||
return { current, cargo }
|
||||
}
|
||||
async function loadCandidates() {
|
||||
if (!warehouseId.value || !detail.value || !canAdd.value || frozen.value) return
|
||||
await candidatePage.load({ warehouseId: warehouseId.value, warehouseTab: 'ALL', orderNo: queriedCargoKeyword.value || undefined, page: cargoPage.value, size: cargoSize.value })
|
||||
}
|
||||
async function selectDispatch(no: string, row?: DispatchListRow) {
|
||||
if (!no || busy.value || pending.value && (!cargoPending.value || pending.value.dispatchNo !== no)) return
|
||||
const sequence = ++generation
|
||||
selectedNo.value = no; reading.value = true; stale.value = true; error.value = null
|
||||
detail.value = null; items.value = null; removing.value = null; reason.value = ''
|
||||
warehouseId.value = ''; warehouseName.value = ''; candidatePage.reset()
|
||||
try {
|
||||
const origin = row || (await api.listDispatches({ page: 1, size: 10, dispatchNo: no, status: 'ALL' })).records.find(value => value.dispatchNo === no)
|
||||
if (!alive || sequence !== generation) return
|
||||
if (!origin || origin.dispatchNo !== no || !origin.warehouseId) throw new DispatchInputError('排车起运仓未完整返回,请重新读取')
|
||||
const facts = await readFacts(no)
|
||||
if (!alive || sequence !== generation) return
|
||||
warehouseId.value = origin.warehouseId; warehouseName.value = origin.warehouseName || origin.warehouseCode || '—'
|
||||
detail.value = facts.current; items.value = facts.cargo; stale.value = false
|
||||
if (!pending.value) await loadCandidates()
|
||||
} catch (cause) { if (alive && sequence === generation) report(cause, '排车货物读取失败') }
|
||||
finally { if (alive && sequence === generation) reading.value = false }
|
||||
}
|
||||
async function searchCargo() {
|
||||
if (locked.value) return
|
||||
queriedCargoKeyword.value = cargoKeyword.value.trim(); cargoPage.value = 1
|
||||
await loadCandidates()
|
||||
}
|
||||
async function changeCargoPage(value: number) { if (!locked.value) { cargoPage.value = value; await loadCandidates() } }
|
||||
async function changeCargoSize(value: number) { if (!locked.value) { cargoSize.value = value; cargoPage.value = 1; await loadCandidates() } }
|
||||
async function completeGroup(row: Candidate): Promise<Candidate[]> {
|
||||
const group = requireSelectionGroup(row)
|
||||
const found = new Map<string, Candidate>()
|
||||
function collect(values: Candidate[]) {
|
||||
for (const value of values) {
|
||||
const source = group.sources.find(source => source.sourceKey === value.sourceKey)
|
||||
if (!source) continue
|
||||
if (value.warehouseId !== warehouseId.value || value.customerId !== source.customerId || value.sourceVersion !== source.selection.sourceVersion || selectionIdentity(value.selection) !== selectionIdentity(source.selection) || value.selectionGroup.groupKey !== group.groupKey || value.selectionGroup.groupVersion !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请刷新后重试')
|
||||
requireSelectionGroup(value)
|
||||
found.set(value.sourceKey, value)
|
||||
}
|
||||
}
|
||||
collect(candidates.value)
|
||||
// A filtered page may hide another source on this physical pallet, including its appointments.
|
||||
for (let next = 1; found.size < group.sources.length; next++) {
|
||||
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, warehouseTab: 'ALL', page: next, size: 100 })
|
||||
if (result.page !== next || result.size !== 100) throw new DispatchInputError('货物来源页码不一致,请重新读取')
|
||||
collect(result.candidates)
|
||||
if (next >= result.pages || result.candidates.length === 0) break
|
||||
}
|
||||
if (found.size !== group.sources.length) throw new DispatchInputError('同托货物及预约资料未完整返回,请刷新后重试')
|
||||
return group.sources.map(source => found.get(source.sourceKey)!)
|
||||
}
|
||||
function buildAddition(row: Candidate, sources: Candidate[]): AddCargo {
|
||||
if (!canAdd.value || !detail.value || !candidates.value.includes(row)) throw new DispatchInputError('当前货物或排车不允许调整,请刷新核对')
|
||||
candidatePage.assertCurrent(sources); requireBatchVersions(sources.flatMap(source => source.appointmentBatches))
|
||||
if (sources.some(source => source.warehouseId !== warehouseId.value || source.blockReasons.length || !source.sourceVersion || source.sourceVersion !== source.selection.sourceVersion || source.appointmentBatches.some(batch => batch.customerId !== source.customerId))) throw new DispatchInputError('请选择同仓、来源完整且预约客户一致的货物')
|
||||
return {
|
||||
recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID(), cargo: expandSelectionGroups(sources),
|
||||
batchAllocations: sources.flatMap(member => member.appointmentBatches.flatMap(batch => batch.sources.map(source => {
|
||||
if (!member.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||||
return { planPalletId: member.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion }
|
||||
}))),
|
||||
}
|
||||
}
|
||||
async function addCargo(row: Candidate) {
|
||||
if (locked.value) return
|
||||
error.value = null; preparing.value = true
|
||||
const sequence = generation
|
||||
try {
|
||||
const sources = await completeGroup(row)
|
||||
if (!alive || sequence !== generation) return
|
||||
recovery.begin(selectedNo.value, 'ADD_CARGO', buildAddition(row, sources)); preparing.value = false; await execute()
|
||||
}
|
||||
catch (cause) { report(cause, '货物未加入,请核对来源') }
|
||||
finally { preparing.value = false }
|
||||
}
|
||||
function removable(row: DispatchCargoItem) {
|
||||
return canRemove.value && row.remove.allowed === true && row.pickedPackages === 0 && !sharedPallet(row) && Boolean(row.dispatchItemId) && items.value?.items.includes(row)
|
||||
}
|
||||
function sharedPallet(row: DispatchCargoItem) { return Boolean(row.warehousePalletId && (items.value?.items.filter(item => item.warehousePalletId === row.warehousePalletId).length || 0) > 1) }
|
||||
function chooseRemoval(row: DispatchCargoItem) {
|
||||
if (locked.value || !removable(row)) return
|
||||
removing.value = row; reason.value = ''
|
||||
}
|
||||
async function removeCargo() {
|
||||
if (locked.value) return
|
||||
error.value = null
|
||||
try {
|
||||
const row = removing.value
|
||||
if (!row || !removable(row) || !reason.value.trim() || !items.value) throw new DispatchInputError('请选择允许移除的未扫码货物并填写原因')
|
||||
const body: ReasonCommand = { recordVersion: requireVersion(items.value.recordVersion), idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() }
|
||||
recovery.begin(selectedNo.value, 'REMOVE_CARGO', body, { itemId: row.dispatchItemId })
|
||||
await execute()
|
||||
} catch (cause) { report(cause, '货物未移除,请核对当前作业情况') }
|
||||
}
|
||||
function send(command: PendingDispatchCommand) {
|
||||
if (!access.can('platform:overseas-warehouse:dispatch:edit')) throw new DispatchInputError('当前账号没有调整排车货物权限')
|
||||
if (command.action === 'ADD_CARGO') return api.addDispatchCargo(command.dispatchNo, command.body as unknown as AddCargo)
|
||||
if (command.action === 'REMOVE_CARGO' && command.itemId) return api.removeDispatchItem(command.dispatchNo, command.itemId, command.body as unknown as ReasonCommand)
|
||||
throw new DispatchInputError('请回到原操作入口核对该请求')
|
||||
}
|
||||
async function execute() {
|
||||
if (busy.value || !cargoPending.value) return
|
||||
const no = pending.value!.dispatchNo
|
||||
error.value = null
|
||||
try {
|
||||
const result = await recovery.execute(send, async target => {
|
||||
const facts = await readFacts(target)
|
||||
return facts.current
|
||||
})
|
||||
if (!result || !alive) return
|
||||
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
|
||||
emit('saved')
|
||||
await selectDispatch(no)
|
||||
await loadList()
|
||||
} catch (cause) {
|
||||
if (!alive) return
|
||||
stale.value = true
|
||||
report(cause, '本次货物调整失败,请刷新当前资料后重试')
|
||||
const failure = error.value
|
||||
try {
|
||||
const facts = await readFacts(no)
|
||||
if (!alive || selectedNo.value !== no) return
|
||||
detail.value = facts.current; items.value = facts.cargo; stale.value = false
|
||||
if (removing.value && !facts.cargo.items.some(row => row.dispatchItemId === removing.value?.dispatchItemId)) removing.value = null
|
||||
await loadCandidates()
|
||||
} catch { /* 读取失败保留原业务错误,仍允许切换排车或手动刷新。 */ }
|
||||
finally { error.value = failure }
|
||||
}
|
||||
}
|
||||
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
|
||||
watch(() => [loading.value, listing.value, busy.value, preparing.value], () => emit('busy', loading.value || listing.value || busy.value || preparing.value), { immediate: true })
|
||||
watch(() => props.dispatchNo, no => {
|
||||
const target = cargoPending.value ? pending.value!.dispatchNo : no
|
||||
if (target) void selectDispatch(target)
|
||||
}, { immediate: true })
|
||||
void loadList()
|
||||
onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidatePage.reset(); emit('busy', false) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="existing-cargo density-dialog-sections">
|
||||
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
|
||||
<div class="existing-cargo-query">
|
||||
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
|
||||
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
|
||||
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
|
||||
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
|
||||
</el-select>
|
||||
|
||||
<template v-if="selectedNo"><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></template>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<template v-if="selectedNo">
|
||||
|
||||
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
|
||||
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
|
||||
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
|
||||
<template #vehicle-heading>
|
||||
<h3 class="existing-cargo-title">本车货物 · {{ selectedNo }} · {{ warehouseName || '—' }}</h3>
|
||||
<InlineHelp title="本车货物调整说明"><p>加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p></InlineHelp>
|
||||
</template>
|
||||
<template #vehicles="{ dragOver, drop, dropTarget }">
|
||||
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
|
||||
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
|
||||
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
|
||||
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
|
||||
<div><strong>{{ row.palletNo || row.inboundCode || row.orderNo || row.relabelNo || '—' }}</strong><p>{{ row.customerName || '—' }} · {{ row.orderNo || row.inboundCode || row.relabelNo || '—' }}</p><p>{{ row.scheduledPackages }} 箱 · 真实已拣 {{ row.pickedPackages }} 箱 · {{ row.destination.warehouseCode || row.destination.address || '—' }}</p><small v-if="!removable(row)">{{ sharedPallet(row) ? '同一实物托盘包含多个来源,不能单独移除此项' : row.remove.blockReason || detail?.actions.REMOVE_CARGO?.blockReason || '当前状态或权限不允许移除' }}</small></div>
|
||||
<el-button link type="danger" :disabled="locked || !removable(row)" @click="chooseRemoval(row)">移除货物</el-button>
|
||||
</div>
|
||||
<div v-if="removing" class="existing-cargo-removal" aria-label="确认移除本车货物"><strong>移除 {{ removing.palletNo || removing.orderNo || removing.inboundCode || '所选货物' }}</strong><el-input v-model="reason" aria-label="移除原因" placeholder="请填写移除原因(必填)" type="textarea" :disabled="locked" /><el-button type="danger" :disabled="locked || !reason.trim() || !removable(removing)" @click="removeCargo">确认移除</el-button><el-button :disabled="locked" @click="removing = null">取消移除</el-button></div>
|
||||
</section>
|
||||
</template>
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.existing-cargo { margin-top: var(--admin-space-5); }
|
||||
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
|
||||
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.existing-cargo-query > .el-button { margin-left: 0; }
|
||||
.existing-cargo-query > .el-input { width: 240px; }
|
||||
.existing-cargo-query > .el-select { width: 350px; }
|
||||
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
|
||||
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
|
||||
.existing-cargo-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
|
||||
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
|
||||
.existing-cargo-item > div { min-width: 0; }
|
||||
.existing-cargo-item > .el-button { flex: none; }
|
||||
.existing-cargo-item p { margin: var(--admin-space-2) 0; }
|
||||
.existing-cargo-item small { color: var(--cc-muted); }
|
||||
.existing-cargo-removal { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
|
||||
</style>
|
||||
@@ -85,9 +85,8 @@ async function assign() {
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
|
||||
finally { busy.value = false }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
@@ -96,8 +95,8 @@ async function execute() {
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { if (cause instanceof ApiResponseError) { pending.value = null; await load() }; error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { busy.value = false }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
@@ -121,7 +120,7 @@ async function upload(event: Event, pickup: boolean) {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { busy.value = false; input.value = '' }
|
||||
finally { pending.value = null; busy.value = false; input.value = '' }
|
||||
}
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return
|
||||
@@ -134,12 +133,12 @@ async function release() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">核对原请求</el-button>
|
||||
|
||||
<template v-if="detail && trip">
|
||||
<header class="transport-toolbar">
|
||||
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
|
||||
|
||||
@@ -27,7 +27,6 @@ async function load() {
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) { selected.value = []; truck.value = ''; warehouse.value = ''; pending.value = null; void load() } else sequence++ })
|
||||
/** 结果未知时保留相同原请求,禁止重复选择来源另建单。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value) {
|
||||
@@ -49,12 +48,12 @@ async function save() {
|
||||
pending.value = null; emit('created'); emit('update:modelValue', false); ElMessage.success(`已生成排车单 ${detail.dispatchNo}`)
|
||||
await router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: detail.dispatchNo } })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '安排排车未完成'); if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="type === 'PICKUP' ? '安排到仓取货' : '安排本土卡车'" width="1100px" :close-on-click-modal="!busy && !pending" :close-on-press-escape="!busy && !pending" :show-close="!busy && !pending" @update:model-value="emit('update:modelValue', $event)">
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="pending ? save() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form" :disabled="busy || !!pending" v-loading="loading">
|
||||
<template v-if="type === 'PICKUP'">
|
||||
<p>审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。</p>
|
||||
@@ -67,6 +66,6 @@ async function save() {
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="预计司机任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ pending ? '按原请求重试' : '生成排车单' }}</el-button></template>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ '生成排车单' }}</el-button></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -23,41 +23,41 @@ describe('transfer movement UI', () => {
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:warehouse:list')
|
||||
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: false, canReselect: true },
|
||||
{ cause: new Error('response lost'), recorded: false, canReselect: false },
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: true, canReselect: false },
|
||||
])('allows target correction only after definite rejection plus authoritative no-command readback: $canReselect', async ({ cause, recorded, canReselect }) => {
|
||||
const body = { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'original-key' }
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: { type: 'receipt', body } }))
|
||||
const fresh = detail('OUTBOUNDED')
|
||||
if (recorded) fresh.receiptCommands = [{ sourceInventoryPositionId: 'hidden-source', status: 'PREPARED', requestHash: 'hash', request: body, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValue(cause); vi.mocked(getWarehouseTransfer).mockResolvedValue(fresh)
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重新选择目标')
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text().includes('重新选择目标')).toBe(canReselect)
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending.body).toEqual(body)
|
||||
if (canReselect) {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
|
||||
await button(wrapper, '重新选择目标').trigger('click'); await flushPromises()
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending).toBeNull()
|
||||
expect(wrapper.text()).toContain('上次目标选择被拒绝:目标不可用')
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
it.each(['{bad', JSON.stringify({pending:{type:'receipt',body:{idempotencyKey:'old'}}})])('ignores stored failed receipts without blocking current selection', async stored => {
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', stored)
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[aria-label="目标仓实收选择"]').exists()).toBe(true)
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled(); expect(wrapper.text()).not.toContain('重试原操作')
|
||||
})
|
||||
it('recovers a reserved original server command in a fresh browser and never queries arbitrary occupied resources', async () => {
|
||||
it.each(['PREPARED', 'RESERVED'] as const)('continues a %s source from current server state and unlocks other sources after failure', async status => {
|
||||
const command: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'hidden-source', status: 'RESERVED', requestHash: 'server-hash', request: { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'server-original' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
|
||||
command.status = status
|
||||
const d = detail('OUTBOUNDED'); d.receiptCommands = [command]
|
||||
d.allocations!.push({ ...d.allocations![0]!, inventoryPositionId: 'other-source', inboundCode: 'IN-2' })
|
||||
const completed = detail('RECEIVED'); completed.allocations![0]!.status = 'RECEIVED'; completed.receiptCommands = [{ ...command, status: 'COMPLETED' }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockResolvedValue(completed)
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(d)
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(completed)
|
||||
const wrapper = render(d); await flushPromises()
|
||||
expect(wrapper.text()).toContain('从本票服务端记录恢复'); expect(wrapper.text()).not.toContain('登记目标仓实收')
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重试原操作'); expect(wrapper.text()).toContain('登记目标仓实收')
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer', 'WT-1', command.request)
|
||||
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'RECEIVED' })
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(button(wrapper, '登记目标仓实收').attributes('disabled')).toBeUndefined()
|
||||
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')?.at(-1)?.[0]).toEqual(completed)
|
||||
// 服务端已完成时只展示最新结果,不再次提交。
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(completed)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(listPallets).not.toHaveBeenCalled(); expect(getPallet).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('hidden-source')
|
||||
})
|
||||
it('does not use a browser remembered occupied target when server receipt history is missing', async () => {
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: null, previous: { warehouse: { id: 'OTHER' }, pallet: { id: 'OTHER', status: 'OCCUPIED' } } }))
|
||||
@@ -79,20 +79,18 @@ describe('transfer movement UI', () => {
|
||||
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('requires permission and explicit actual-outbound confirmation; unknown outcome survives remount with same body', async () => {
|
||||
it('requires outbound permission and releases a failed confirmation for a fresh click', async () => {
|
||||
vi.mocked(outboundWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('OUTBOUNDED'))
|
||||
const first = render(); await button(first, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(first, '确认实际出库').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(outboundWarehouseTransfer).mock.calls[0]))
|
||||
expect(first.text()).toContain('原操作内容已保留'); first.unmount()
|
||||
const second = render(detail('OUTBOUNDED')); await flushPromises()
|
||||
expect(second.text()).not.toContain('hidden-source'); expect(second.text()).toContain('重试原操作')
|
||||
await button(second, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(second.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
|
||||
const wrapper = render(); await button(wrapper, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(wrapper, '确认实际出库').trigger('click'); await flushPromises()
|
||||
expect(button(wrapper, '确认本票出库').attributes('disabled')).toBeUndefined(); expect(sessionStorage.length).toBe(0)
|
||||
const original = vi.mocked(outboundWarehouseTransfer).mock.calls[0]![2]
|
||||
await button(wrapper, '确认实际出库').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
|
||||
can.mockReturnValue(false); const denied = render(); expect(denied.text()).not.toContain('确认本票出库')
|
||||
})
|
||||
it('queries only selected warehouse page, reads exact bound location and keeps receipt request after response loss', async () => {
|
||||
it('queries only selected warehouse page, reads exact bound location and unlocks the selection after response loss', async () => {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
const p = { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }
|
||||
vi.mocked(listPallets).mockResolvedValue({ records: [p], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -109,10 +107,10 @@ describe('transfer movement UI', () => {
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(receiveWarehouseTransfer).mock.calls[0]))
|
||||
expect(original[2]).toMatchObject({ sourceInventoryPositionId: 'hidden-source', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY' })
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(button(wrapper, '查询目标托盘').attributes('disabled')).toBeUndefined()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]![2].idempotencyKey).not.toBe(original[2].idempotencyKey)
|
||||
expect(wrapper.text()).not.toContain('hidden-source')
|
||||
})
|
||||
it('does not request resources when permission is absent or source already received', async () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload } from '../api/warehouseTransfers'
|
||||
import { getPallet, listPallets, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
|
||||
@@ -15,13 +14,6 @@ const emit = defineEmits<{ updated: [detail: WarehouseTransferDetailDto] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
type Pending = { type: 'outbound'; body: OutboundWarehouseTransferPayload } | { type: 'receipt'; body: ReceiveWarehouseTransferPayload }
|
||||
const pending = ref<Pending | null>(null)
|
||||
const definiteRejection = ref(false), reselectAllowed = ref(false), rejectionEvidence = ref<ApiErrorDetails | null>(null)
|
||||
function definiteBusinessRejection(cause: unknown) {
|
||||
const codes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||||
const statusKnown = (status?: number) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||||
if (cause instanceof ApiResponseError) return codes.includes(cause.code) && statusKnown(cause.status)
|
||||
return axios.isAxiosError<{ code?: string }>(cause) && !!cause.response && statusKnown(cause.response.status) && codes.includes(cause.response.data?.code || '')
|
||||
}
|
||||
const completedTargets = computed(() => props.detail.receiptCommands?.filter(c => c.status === 'COMPLETED').map(c => c.target).filter((t, i, all) => all.findIndex(v => v.warehouseId === t.warehouseId && v.palletId === t.palletId && v.locationId === t.locationId) === i) ?? [])
|
||||
const previous = computed(() => completedTargets.value[0] ?? null)
|
||||
function acknowledgedTarget(value: ReceiptTarget) { return completedTargets.value.find(t => t.warehouseId === value.warehouse.id && t.palletId === value.pallet.id && t.locationId === value.location.id) ?? null }
|
||||
@@ -32,65 +24,23 @@ const pallets = ref<PalletDto[]>([]), target = ref<ReceiptTarget | null>(null),
|
||||
let generation = 0
|
||||
const canManage = computed(() => access.can('lastleg:warehouse-transfer:manage'))
|
||||
const canResources = computed(() => canManage.value && access.can('platform:overseas-warehouse:warehouse:list') && access.can('platform:overseas-warehouse:pallet:list') && access.can('platform:overseas-warehouse:location:list'))
|
||||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED'))
|
||||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED' && !unfinished.value.some(c => c.sourceInventoryPositionId === a.inventoryPositionId)))
|
||||
const outboundReady = computed(() => canManage.value && canOutboundTransfer(props.detail))
|
||||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0 && !unfinished.value.length)
|
||||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0)
|
||||
const selectedValid = computed(() => target.value && shipped.value.some(a => a.inventoryPositionId === sourceId.value) && receiptTargetValid(props.detail, target.value, acknowledgedTarget(target.value)))
|
||||
const storageKey = () => `warehouse-transfer-movement:${props.customerId}:${props.detail.transferNo}`
|
||||
function persist() { sessionStorage.setItem(storageKey(), JSON.stringify({ pending: pending.value, definiteRejection: definiteRejection.value })) }
|
||||
function restore() {
|
||||
generation++; busy.value = false; pending.value = null; error.value = null; notice.value = ''; open.value = false
|
||||
definiteRejection.value = false; reselectAllowed.value = false; rejectionEvidence.value = null
|
||||
function reset() {
|
||||
generation++; pending.value = null; busy.value = false; error.value = null; notice.value = ''; open.value = false
|
||||
warehouses.value = []; pallets.value = []; warehouseId.value = ''; target.value = null; sourceId.value = ''; keyword.value = ''; page.value = 1; total.value = 0
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey())
|
||||
if (!saved) { recoverServerCommand(); return }
|
||||
const value: { pending: Pending | null; definiteRejection?: boolean } = JSON.parse(saved)
|
||||
if (value.pending && (!['outbound', 'receipt'].includes(value.pending.type) || !value.pending.body?.idempotencyKey)) throw new Error('invalid saved command')
|
||||
pending.value = value.pending; definiteRejection.value = value.definiteRejection === true; recoverServerCommand()
|
||||
} catch { error.value = { message: '本次操作记录无法读取,请联系管理员核对原操作,暂不能提交新操作。' }; busy.value = true }
|
||||
}
|
||||
function recoverServerCommand(command?: WarehouseTransferReceiptCommandDto) {
|
||||
if (pending.value?.type === 'receipt' && props.detail.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === pending.value?.body.idempotencyKey)) { pending.value = null; persist() }
|
||||
const server = command ?? (unfinished.value.length === 1 ? unfinished.value[0] : undefined)
|
||||
if (server && canManage.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
pending.value = { type: 'receipt', body: JSON.parse(JSON.stringify(server.request)) as ReceiveWarehouseTransferPayload }
|
||||
notice.value = '已从本票服务端记录恢复原实收操作。'
|
||||
}
|
||||
}
|
||||
watch(() => [props.customerId, props.detail.transferNo], restore, { immediate: true })
|
||||
watch(() => props.detail.receiptCommands, () => { if (!busy.value) recoverServerCommand() })
|
||||
watch(() => [props.customerId, props.detail.transferNo], reset, { immediate: true })
|
||||
async function checkCurrent() {
|
||||
if (busy.value) return
|
||||
const current = generation, customerId = props.customerId, no = props.detail.transferNo
|
||||
const current = generation
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await getWarehouseTransfer(customerId, no)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new Error('返回的转运单不一致')
|
||||
const command = pending.value
|
||||
reselectAllowed.value = !!(command?.type === 'receipt' && definiteRejection.value && Array.isArray(value.receiptCommands)
|
||||
&& !value.receiptCommands.some(c => c.request.idempotencyKey === command.body.idempotencyKey)
|
||||
&& !value.receiptCommands.some(c => c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)
|
||||
&& value.allocations?.some(a => a.inventoryPositionId === command.body.sourceInventoryPositionId && a.status === 'SHIPPED'))
|
||||
if ((command?.type === 'outbound' && ['OUTBOUNDED', 'PARTIALLY_RECEIVED', 'RECEIVED'].includes(value.status))
|
||||
|| (command?.type === 'receipt' && value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey))) { pending.value = null; persist() }
|
||||
emit('updated', value)
|
||||
notice.value = reselectAllowed.value ? '服务端确认原请求未建立实收命令,来源仍待实收,可重新选择目标。' : pending.value ? '已核对当前状态;原操作仍保留,可使用原内容确认结果。' : '当前转运状态已刷新。'
|
||||
}
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态核对失败') }
|
||||
try { const value = await getWarehouseTransfer(props.customerId, props.detail.transferNo); if (current === generation) { emit('updated', value); notice.value = '当前转运状态已刷新。' } }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态读取失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function reselectTarget() {
|
||||
if (!reselectAllowed.value || busy.value || pending.value?.type !== 'receipt' || !definiteRejection.value) return
|
||||
const original = pending.value
|
||||
if (error.value) rejectionEvidence.value = error.value
|
||||
try { pending.value = null; definiteRejection.value = false; persist() }
|
||||
catch (cause) { pending.value = original; definiteRejection.value = true; error.value = getApiErrorDetails(cause, '原操作记录无法更新,暂不能重新选择'); return }
|
||||
reselectAllowed.value = false; target.value = null; sourceId.value = ''; keyword.value = ''; pallets.value = []; error.value = null
|
||||
await openReceipt()
|
||||
}
|
||||
async function openReceipt() {
|
||||
if (!receiptReady.value || busy.value || pending.value) return
|
||||
open.value = true; busy.value = true; error.value = null; const current = generation
|
||||
@@ -122,15 +72,38 @@ async function selectPallet(id: string) {
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
function changeWarehouse() { target.value = null; pallets.value = []; page.value = 1; total.value = 0 }
|
||||
function receiptSourceLabel(source: string) {
|
||||
const allocation = props.detail.allocations?.find(row => row.inventoryPositionId === source)
|
||||
return [allocation?.inboundCode, allocation?.palletNo].filter(Boolean).join(' / ') || '来源信息待核实'
|
||||
}
|
||||
/** 按服务端当前实收记录继续同一来源;不缓存失败请求,不影响其他来源作业。 */
|
||||
async function confirmExistingReceipt(source: string) {
|
||||
if (busy.value || !canResources.value) return
|
||||
const current = generation, customer = props.customerId, no = props.detail.transferNo
|
||||
busy.value = true; error.value = null; notice.value = ''
|
||||
try {
|
||||
const latest = await getWarehouseTransfer(customer, no)
|
||||
if (current !== generation) return
|
||||
if (latest.transferNo !== no) throw new Error('返回的转运单不一致')
|
||||
emit('updated', latest)
|
||||
const command = latest.receiptCommands?.find(row => row.sourceInventoryPositionId === source)
|
||||
if (!command) throw new Error('本来源实收记录已变化,请按当前状态重新选择')
|
||||
if (command.status === 'COMPLETED') { notice.value = '本来源目标实收已确认。'; return }
|
||||
if (!['PREPARED', 'RESERVED'].includes(command.status) || command.request.sourceInventoryPositionId !== source) throw new Error('本来源实收状态无效,请刷新查看')
|
||||
const value = await receiveWarehouseTransfer(customer, no, command.request)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no || !value.receiptCommands?.some(row => row.sourceInventoryPositionId === source && row.status === 'COMPLETED' && row.request.idempotencyKey === command.request.idempotencyKey)) throw new Error('本来源尚未返回实收完成记录,请刷新查看')
|
||||
emit('updated', value); notice.value = '本来源目标实收已确认。'
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本来源实收未完成,请重新操作') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function submit(type: 'outbound' | 'receipt') {
|
||||
if (busy.value || !canManage.value || (type === 'receipt' && !canResources.value)) return
|
||||
if (!pending.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
try {
|
||||
if (type === 'outbound') { if (!outboundReady.value) return; pending.value = { type, body: { idempotencyKey: crypto.randomUUID() } } }
|
||||
else { if (!receiptReady.value || !target.value || !selectedValid.value) return; pending.value = { type, body: createReceiptCommand(props.detail, sourceId.value, target.value, crypto.randomUUID(), acknowledgedTarget(target.value)) } }
|
||||
persist() // Preserve the exact command before transport, including across close/navigation/reload.
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原操作记录保存失败,未提交请求'); pending.value = null; return }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '操作内容校验失败'); pending.value = null; return }
|
||||
}
|
||||
const command = pending.value
|
||||
if (command.type !== type) return
|
||||
@@ -140,19 +113,24 @@ async function submit(type: 'outbound' | 'receipt') {
|
||||
const value = command.type === 'outbound' ? await outboundWarehouseTransfer(customer, no, command.body) : await receiveWarehouseTransfer(customer, no, command.body)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new ApiResponseError('操作响应与当前转运单不一致,请核对原操作', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请保留原操作并只读核对', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||||
pending.value = null; persist(); emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请刷新后重新操作', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||||
pending.value = null; emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||||
notice.value = command.type === 'outbound' ? '本票库存已确认出库。' : '本来源目标实收已确认。'
|
||||
} catch (cause) { if (current === generation) { pending.value = command; definiteRejection.value = definiteBusinessRejection(cause); reselectAllowed.value = false; error.value = getApiErrorDetails(cause, '操作结果尚未确认,已保留原内容,请核对后重试原操作'); if (definiteRejection.value) rejectionEvidence.value = error.value; try { persist() } catch { /* Existing pre-transport record remains authoritative for retry. */ } } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '操作失败,请刷新后重新操作') }
|
||||
finally { if (current === generation) { pending.value = null; busy.value = false } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="转运出库与目标实收" class="movement-panel">
|
||||
<template v-if="canManage">
|
||||
<el-button :disabled="busy" @click="checkCurrent">刷新当前状态</el-button>
|
||||
<el-button v-if="outboundReady && !pending" :disabled="busy" @click="open = !open">确认本票出库</el-button>
|
||||
<el-button v-if="receiptReady && !pending" :disabled="busy" @click="openReceipt">登记目标仓实收</el-button>
|
||||
<div v-for="command in unfinished" :key="command.sourceInventoryPositionId" aria-label="待完成的来源实收">
|
||||
<p>{{ receiptSourceLabel(command.sourceInventoryPositionId) }} → {{ command.target.warehouseName }} / {{ command.target.palletNo }} / {{ command.target.locationCode }}</p>
|
||||
<el-button v-if="canResources" :disabled="busy" :loading="busy" @click="confirmExistingReceipt(command.sourceInventoryPositionId)">确认本来源实收</el-button>
|
||||
</div>
|
||||
<div v-if="open && outboundReady && !pending" aria-label="出库确认"><p>{{ detail.transferNo }} · {{ detail.packageCount }} 箱。确认实际移出后,全部已分配来源将发运。</p><el-button :loading="busy" @click="submit('outbound')">确认实际出库</el-button></div>
|
||||
<div v-if="open && receiptReady && !pending" aria-label="目标仓实收选择">
|
||||
<el-form label-position="top" class="movement-fields">
|
||||
@@ -166,15 +144,10 @@ async function submit(type: 'outbound' | 'receipt') {
|
||||
<p v-if="target">目标:{{ target.warehouse.warehouseCode }} / {{ target.pallet.palletCode }} / {{ target.location.locationCode }} · FBA {{ target.pallet.fbaWarehouseCode }}</p>
|
||||
<el-button :disabled="busy || !selectedValid" :loading="busy" @click="submit('receipt')">确认本来源实收</el-button>
|
||||
</div>
|
||||
<p v-if="pending">原操作内容已保留,请先核对当前状态,再使用原内容确认结果。{{ pending.type === 'receipt' ? `${pending.body.destinationPalletNo} / ${pending.body.warehouseLocationCode}` : detail.transferNo }}</p>
|
||||
<template v-if="unfinished.length > 1 && !pending"><p>本票存在多条未完成实收,请选择需要恢复的原操作。</p><el-button v-for="c in unfinished" :key="c.request.idempotencyKey" :disabled="busy" @click="recoverServerCommand(c)">恢复 {{ c.target.palletNo }} / {{ c.target.locationCode }}</el-button></template>
|
||||
<el-button v-if="pending" :disabled="busy || (pending.type === 'receipt' && !canResources)" :loading="busy" @click="submit(pending.type)">重试原操作</el-button>
|
||||
<el-button v-if="pending" :disabled="busy" @click="checkCurrent">只读核对当前状态</el-button>
|
||||
<el-button v-if="reselectAllowed && pending?.type === 'receipt'" :disabled="busy" @click="reselectTarget">重新选择目标</el-button>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !canResources">登记目标实收需要收货仓、托盘、库位查询及转运管理权限。</p>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !Array.isArray(detail.receiptCommands)">本票尚未返回服务端实收操作记录,请联系管理员核对接口后再登记实收。</p>
|
||||
</template>
|
||||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><div v-if="rejectionEvidence"><p>上次目标选择被拒绝:{{ rejectionEvidence.message }}</p><ApiErrorMeta :details="rejectionEvidence" /></div>
|
||||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -16,12 +16,12 @@ it.each([new Error('network unknown'), new ApiResponseError('server failure','S0
|
||||
expect(send.mock.calls[0]![0].body).toEqual({recordVersion:2,idempotencyKey:'second',remark:'edited'})
|
||||
expect(reopened.pending.value).toBeNull()
|
||||
})
|
||||
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('clears saved failed requests without restoring them',saved=>{
|
||||
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('ignores saved failed requests without accessing storage',saved=>{
|
||||
const owner=crypto.randomUUID()
|
||||
sessionStorage.setItem(`dispatch-command:${owner}`,saved)
|
||||
const state=useDispatchCommandRecovery(()=>owner)
|
||||
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
|
||||
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
|
||||
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBe(saved)
|
||||
})
|
||||
it('prevents double clicks only during the active request',async()=>{
|
||||
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { CommandResult, DispatchAction } from './dispatchContracts'
|
||||
import { requireVersion } from './dispatchContracts'
|
||||
|
||||
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
|
||||
const storageKey = (scope: string) => `dispatch-command:${scope}`
|
||||
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
|
||||
export function isDefiniteDispatchRejection(error: unknown) {
|
||||
@@ -28,7 +27,6 @@ export function validateCurrentDispatch(value: unknown, result: CommandResult) {
|
||||
/** 仅在当前请求期间防止连点;失败立即释放页面,不持久化或恢复历史请求。 */
|
||||
export function useDispatchCommandRecovery(scope: () => string) {
|
||||
const owner = scope()
|
||||
if (owner) sessionStorage.removeItem(storageKey(owner))
|
||||
const existing = sessions.get(owner)
|
||||
const pending = existing?.pending || ref<PendingDispatchCommand | null>(null)
|
||||
const busy = existing?.busy || ref(false)
|
||||
|
||||
@@ -26,11 +26,11 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
|
||||
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
|
||||
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
|
||||
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
|
||||
export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchListRow { createdAt: string | null; fbaAppointments: FbaAppointment[]; dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||
export interface FbaAppointment { batchKey: string | null; batchNo: string | null; customerId: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null; shipmentNumbers: string[] }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
|
||||
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||
|
||||
@@ -9,7 +9,7 @@ export function sharedPalletCandidates(): Candidate[] {
|
||||
return rows.map(row => ({ ...row, selectionGroup: group }))
|
||||
}
|
||||
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { publishLooseboardDemand: true, taskType: 'DELIVERY', dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
|
||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { createdAt: '2026-10-19T08:00:00Z', fbaAppointments: [], dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
||||
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
|
||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export interface DispatchDestinationAppointment { destinationKey: string; appointmentNo: string | null }
|
||||
|
||||
export interface DispatchVehicleMaintenance {
|
||||
plannedDeliveryDate: string | null
|
||||
deliveryMethod: string | null
|
||||
@@ -6,16 +8,16 @@ export interface DispatchVehicleMaintenance {
|
||||
fleetEmail: string | null
|
||||
vehicleType: string | null
|
||||
appointmentAccount: string | null
|
||||
appointmentNo: string | null
|
||||
destinationAppointments: DispatchDestinationAppointment[]
|
||||
}
|
||||
|
||||
/** 本车补充资料保持未填为空,不从原型示例或实际发车时间推定。 */
|
||||
export function blankVehicleMaintenance(): DispatchVehicleMaintenance {
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, appointmentNo: null }
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, destinationAppointments: [] }
|
||||
}
|
||||
|
||||
/** 保存前只整理已填写资料,不据补充资料触发派送、预约或费用。 */
|
||||
export function vehicleMaintenancePayload(value: DispatchVehicleMaintenance): DispatchVehicleMaintenance {
|
||||
const text = (item: string | null) => item?.trim() || null
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), appointmentNo: text(value.appointmentNo) }
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), destinationAppointments: value.destinationAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: text(row.appointmentNo) })) }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmen
|
||||
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
|
||||
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
|
||||
export { overseasWarehousePermissions } from './permissions'
|
||||
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||
export { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
|
||||
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(pane.isVisible()).toBe(true)
|
||||
expect(pane.text()).toContain('LAX → ONT8')
|
||||
expect(pane.text()).toContain('派送异常记录读取失败')
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
|
||||
await pane.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
@@ -101,7 +101,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
|
||||
}
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—'])
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -121,7 +121,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
||||
@@ -133,8 +133,8 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||
expect(dispatchInfo.isVisible()).toBe(true)
|
||||
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.find('[aria-label="正式FBA预约"]').exists()).toBe(true); expect(dispatchInfo.text()).toContain('FBA-AP-1')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||
})
|
||||
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { stageLabel } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
import DispatchAppointments from '../components/DispatchAppointments.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -35,7 +36,6 @@ const vehicleFields = computed(() => [
|
||||
const dispatchFields = computed(() => [
|
||||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||||
])
|
||||
const documentFields = computed(() => [
|
||||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||||
@@ -78,7 +78,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchAppointments :appointments="detail?.dispatchInfo.fbaAppointments || []" />
|
||||
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
|
||||
<section v-else-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||
<h3>派送异常记录</h3>
|
||||
|
||||
@@ -9,7 +9,8 @@ import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { cancelTransport, getTransport } from '../api/transportTasks'
|
||||
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
|
||||
import type { DispatchPage } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { BeforeLoadingSection, DispatchPage, DispatchStage } from '../dispatchContracts'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
@@ -442,3 +443,34 @@ it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_
|
||||
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('splits driver and phone without fallback and shows authoritative appointment and shipment fields', async () => {
|
||||
const row = listRow(); row.resources.driver!.name = null; row.resources.driver!.phone = '555-REAL'
|
||||
row.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', batchNo: 'BATCH-1', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }]
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([row]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const columns = wrapper.getComponent(DataTable).props('columns')
|
||||
expect(columns.map(column => column.label)).toContain('司机'); expect(columns.map(column => column.label)).toContain('联系电话')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(wrapper.text()).toContain('555-REAL'); expect(wrapper.text()).toContain('CONF-1'); expect(wrapper.text()).toContain('SHIP-1、SHIP-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BEFORE_LOADING', 'VEHICLE_INFO'], ['BEFORE_LOADING', 'DISPATCH_REVIEW'], ['BEFORE_LOADING', 'LOADING_REVIEW'],
|
||||
['LOADING', 'VEHICLE_INFO'], ['AFTER_LOADING', 'VEHICLE_INFO'], ['DELIVERING', 'VEHICLE_INFO'], ['DELIVERED', 'VEHICLE_INFO'],
|
||||
] as const)('keeps actual creation time visible in %s / %s for every transport type', async (stage, section) => {
|
||||
const createdAt = '2026-10-22T01:23:45Z'
|
||||
const rows = (['DELIVERY', 'PICKUP', 'LOCAL_TRUCK'] as const).map(taskType => listRow({ dispatchNo: 'PC-' + taskType, taskType, stage, createdAt, plannedDepartureAt: '2026-11-01T11:00:00Z' }))
|
||||
vi.mocked(listDispatches).mockResolvedValue(page(rows))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { stage: DispatchStage; section: BeforeLoadingSection }
|
||||
state.stage = stage; state.section = section; await flushPromises()
|
||||
const table = wrapper.getComponent(DataTable), columns = table.props('columns')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(columns.find(column => column.key === 'createdAt')).toMatchObject({ label: '创建时间', slotName: 'createdAt' })
|
||||
const cells = wrapper.findAll('tbody td').filter(cell => cell.text() === formatOverseasDateTime(createdAt))
|
||||
expect(cells).toHaveLength(3)
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -70,7 +70,12 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机', minWidth: 100, slotName: 'driver' },
|
||||
{ key: 'driverPhone', label: '联系电话', minWidth: 140, slotName: 'driverPhone' },
|
||||
{ key: 'appointmentNo', label: '预约号', minWidth: 160, slotName: 'appointmentNo' },
|
||||
{ key: 'appointmentAt', label: '确认送达时间', minWidth: 170, slotName: 'appointmentAt' },
|
||||
{ key: 'shipmentNumbers', label: '货件号', minWidth: 180, slotName: 'shipmentNumbers' },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||
@@ -215,10 +220,15 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<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 #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 || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</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>
|
||||
<template #appointmentNo="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.appointmentNo || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #appointmentAt="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ time(item.confirmedAt) }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #shipmentNumbers="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.shipmentNumbers?.join('、') || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
|
||||
<template #createdAt="{ row }">{{ time(row.createdAt) }}</template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -1282,11 +1282,17 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
|
||||
})
|
||||
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.props('width')).toBe('min(1160px, calc(100vw - 48px))')
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('top')).toBe('6vh')
|
||||
expect(dialog.get('[aria-label="入库来源"] h3').text()).toContain('来源摘要')
|
||||
expect(dialog.get('[aria-label="入库计划登记"] h3').text()).toBe('计划设置')
|
||||
const sourceRows = dialog.findAll('.inbound-plan-source tbody tr')
|
||||
expect(sourceRows[0].get('td').attributes('colspan')).toBe('3')
|
||||
expect(sourceRows[1].findAll('th').map(cell => cell.text())).toEqual(['计划入库仓', '入仓码'])
|
||||
expect(dialog.props('confirmText')).toBe('保存并生成入库单')
|
||||
expect(dialog.props('title')).toContain('LMO-260827-0001')
|
||||
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
|
||||
await dialog.get('textarea').setValue('全屏后保留')
|
||||
@@ -1297,9 +1303,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
await dialog.get('textarea').setValue('保留入库备注')
|
||||
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -1319,26 +1319,28 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<div class="inbound-plan-content">
|
||||
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts"><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
|
||||
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
|
||||
@@ -1364,6 +1366,14 @@ onMounted(() => { void load() })
|
||||
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
|
||||
.last-mile-dialog-facts th { width: var(--admin-label-width); }
|
||||
.last-mile-command-table td:not([colspan]) { width: 50%; }
|
||||
.inbound-plan-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.inbound-plan-form { margin-top: 0; }
|
||||
.inbound-plan-source__label { width: 112px; }
|
||||
.inbound-plan-source td { overflow-wrap: anywhere; }
|
||||
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.inbound-plan-form :deep(.el-select) { width: 100%; }
|
||||
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
|
||||
.manual-nodes-editor th { width: 180px; }
|
||||
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
|
||||
.last-mile-toolbar { min-width: 0; }
|
||||
|
||||
@@ -262,7 +262,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'vehicle: null, driver: null']) expect(dialog).toContain(symbol)
|
||||
expect(dialog).not.toContain('调整已有排车货物')
|
||||
expect(dialog).not.toContain('SmartDispatchExistingCargo')
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
|
||||
Reference in New Issue
Block a user