可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。
请上传 5 张装车照片,全部保存成功后自动进入派送中。
-
-
有装车照片尚未确认保存。{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}
-
-
- 装车照片 {{ index + 1 }}
-
+
+
diff --git a/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts
index 0363fcd5..3508b74e 100644
--- a/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts
+++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts
@@ -3,18 +3,28 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchResourceFields from './DispatchResourceFields.vue'
import { resources } from '../dispatchTestFixtures'
-import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/overseas-master-data'
+import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
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('carries the selected master license for preview without carrying another driver’s trip photos', async () => {
+ const candidate: DriverDetail = { id: '22', driverNo: 'DR22', details: { name: '司机乙', phone: '555-0022', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE22', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2028-01-01', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '33', recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-23T00:00:00Z', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2028-01-01' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
+ vi.mocked(listDrivers).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
+ const value = resources()
+ const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false }, global: { plugins: [ElementPlus] } })
+ const state = wrapper.vm as unknown as { searchDrivers(): Promise
; selectDriver(id: string): void }
+ await state.searchDrivers(); state.selectDriver('22'); await flushPromises()
+ expect(wrapper.emitted('update:driver')!.at(-1)![0]).toMatchObject({ driverId: '22', evidence: { fileIds: [], masterLicenseFileId: '33' } })
+ wrapper.unmount()
+})
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 +35,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; selectVehicle(id: string): void }
@@ -54,3 +64,20 @@ it.each([false, true])('requires each directory permission before reading candid
const state = wrapper.vm as unknown as { searchVehicles(): Promise; searchDrivers(): Promise }; 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; 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()
+})
diff --git a/src/modules/overseas-warehouse/components/DispatchResourceFields.vue b/src/modules/overseas-warehouse/components/DispatchResourceFields.vue
index c7f2c563..3b08ffc4 100644
--- a/src/modules/overseas-warehouse/components/DispatchResourceFields.vue
+++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.vue
@@ -1,21 +1,36 @@
+
- | 车辆来源 临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。 | |
+ | 车辆来源 临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。 | |
| |
| |
@@ -56,7 +74,7 @@ function selectDriver(id: string) {
{{ error }}
车辆来源临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。
-
+
diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts
index b40e8ba5..9b9990ce 100644
--- a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts
+++ b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts
@@ -19,39 +19,43 @@ 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="油耗图片"]')
+ expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '图片类别', '文件名称', '保存时间'])
+ expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['起始油耗表盘', '结束油耗表盘'])
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
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 () => {
diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue
index b460c24e..8219f34f 100644
--- a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue
+++ b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue
@@ -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(null), busy = ref(false), error = ref(null)
-interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
-const pending = ref(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,31 +50,35 @@ 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 })
-
-
有图片尚未确认保存。刷新版本并保存已上传图片核对原请求并继续保存
-
+
+
diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue b/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue
index 3003a4f3..0aaf092e 100644
--- a/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue
+++ b/src/modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue
@@ -1,22 +1,27 @@
@@ -26,7 +31,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
排车时间
@@ -42,10 +47,10 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
-
- 发布公共散板需求
- | 目的仓 | 已确认预约号 | 确认送达时间 |
|---|
| {{ appointment.destinationWarehouseCode || '—' }} | {{ appointment.appointmentNo || appointment.batchNo || '—' }} | {{ formatOverseasDateTime(appointment.confirmedAt) }} |
+ | 目的仓 | 预约号 | 确认送达时间 | 货件号 |
+ | {{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }} | | {{ confirmedTime(appointment) }} | {{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }} |
+
费用信息
{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
index 92013dc6..b8b9e9ca 100644
--- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
+import { downloadDriverLicense } from '@/modules/overseas-master-data'
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -16,7 +17,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
-vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
+vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), downloadDriverLicense: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
enableAutoUnmount(afterEach)
@@ -53,11 +54,53 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
function field(wrapper: Pick, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
describe('dispatch workflow native layout', () => {
+ it('previews the selected master license without treating it as a trip upload and clears it on driver change', async () => {
+ vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
+ const { dialog } = await render('EDIT')
+ const fields = dialog.findComponent(DispatchResourceFields)
+ const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } }
+ fields.vm.$emit('update:driver', selected); await flushPromises()
+ const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
+ expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
+ expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
+ expect(license().props('modelValue')).toEqual([])
+ expect(license().text()).not.toContain('移除引用')
+ expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
+ fields.vm.$emit('update:driver', { ...selected, driverId: '23', evidence: { ...selected.evidence, masterLicenseFileId: null } }); await flushPromises()
+ expect(license().find('img').exists()).toBe(false)
+ expect(license().text()).toContain('暂无照片')
+ })
+ it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
+ const saved = resources()
+ saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
+ const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved })
+ const license = dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
+ expect(license.findAll('img')).toHaveLength(1)
+ expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
+ expect(downloadDriverLicense).not.toHaveBeenCalled()
+ })
+ 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 +110,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 +156,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, 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 +193,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.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 +263,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 +306,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()
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts
index e5bdf3de..89433e9a 100644
--- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts
@@ -12,13 +12,13 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '' } }))
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; retryError(): Promise; 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 }
+interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
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()) })
+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)
@@ -90,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()
@@ -111,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('原操作结果尚待核对')
@@ -122,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'))
@@ -133,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()=>{
@@ -161,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()
@@ -178,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()
})
@@ -239,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}))
@@ -260,13 +260,23 @@ it('allows a new save-and-review click after review submission fails',async()=>{
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
-it('persists the explicit publication choice with the existing vehicle save', async () => {
+it('keeps publication fixed when saving vehicle maintenance', async () => {
const { wrapper, state } = await open('EDIT')
- expect(state.form.publishLooseboardDemand).toBe(true)
- expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('发布公共散板需求')
- state.form.publishLooseboardDemand = false
+ expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('发布公共散板需求')
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
await state.submit()
- expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
+ expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0 }))
+ expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('publishLooseboardDemand')
+ 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()
})
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
index 2342054d..093ebb9f 100644
--- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
@@ -16,6 +16,7 @@ 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 { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -39,20 +40,33 @@ const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: n
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref(blankVehicle()), driver = ref(blankDriver())
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
+// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
+const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
+const unsavedMasterLicense = computed(() => {
+ const fileId = masterLicenseReference.value, driverId = driver.value.driverId
+ const saved = detail.value?.vehicleInfo.driver
+ if (!fileId || !driverId || saved?.driverId === driverId && saved.evidence?.masterLicenseFileId === fileId) return null
+ return { driverId, fileId }
+})
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value))
const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null)
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: '' })
+const form = reactive({ 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))
@@ -64,18 +78,19 @@ 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()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
- Object.assign(form, { publishLooseboardDemand: value.publishLooseboardDemand, plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
+ Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
if (currentOperation.value === 'ROUTE') {
const data = await api.getDispatchRoute(no)
if (sequence !== generation) return
@@ -110,7 +125,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, 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('驳回时必须填写原因')
@@ -153,7 +168,7 @@ async function submit(submitReviewAfterSave = false) {
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 || '')
@@ -163,7 +178,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)
@@ -182,7 +197,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() {
@@ -193,6 +208,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
@@ -211,7 +227,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
-
+
@@ -230,21 +246,29 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
-
+
-
+
- | 车牌 | {{ detail.vehicleInfo.vehicle?.plate || '—' }} | 司机 | {{ detail.vehicleInfo.driver?.name || '—' }} |
|---|
| 托盘数 | {{ detail.counts.palletCount }} 托 | 箱数 | {{ detail.counts.packageCount }} 箱 |
|---|
- | 计划来车/发车(北京时间) | {{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }} |
|---|
| 预计送达(北京时间) | {{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }} |
|---|
- | 目的仓 | 预约单号 | 预约状态 | 确认送达(北京时间) |
|---|
| {{ appointment.destinationWarehouseCode }} | {{ appointment.appointmentNo || appointment.batchNo || '—' }} | {{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }} | {{ reviewTime(appointment.confirmedAt) }} |
+
+ 车辆与货物
+
+ | 车牌 | {{ detail.vehicleInfo.vehicle?.plate || '—' }} | 司机 | {{ detail.vehicleInfo.driver?.name || '—' }} | 联系电话 | {{ detail.vehicleInfo.driver?.phone || '—' }} |
+ | 托盘数 | {{ detail.counts.palletCount }} 托 | 箱数 | {{ detail.counts.packageCount }} 箱 | 路线 | {{ detail.dispatchInfo.route || '—' }} |
+
+
+
+ 排车与预约
+ | 计划来车/发车(北京时间) | {{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }} | 预计送达(北京时间) | {{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }} |
|---|
+
@@ -254,10 +278,13 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
-
-
-
-
+
+
{{ isReview ? '审核意见' : '操作说明' }}
diff --git a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts
index 1c10273a..6768977a 100644
--- a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts
+++ b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts
@@ -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()
})
diff --git a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue
index fcb3dd47..b7a48937 100644
--- a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue
+++ b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue
@@ -22,13 +22,12 @@ type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity:
const receipts = ref([]), targets = ref([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
const pendingByScope = reactive(new Map())
-const unknownUploads = reactive(new Set())
const savedEvidence = new Map()
const selectedHistory = ref([])
const inFlight = reactive(new Map())
const uploadingScopes = reactive(new Set())
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
关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。
- 存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。
- 证据上传结果未知,需人工核对后继续。
- 重试原操作
- {{ pending ? '重试原开始请求' : '开始处理' }}
- {{ pending ? '重试原取消请求' : '取消异常' }}
- {{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}
+ {{ '开始处理' }}
+ {{ '取消异常' }}
+ {{ warehouseReturn ? '提交本批实收' : '完成处理' }}
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
index 44418914..d3fa4a7d 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
@@ -200,7 +200,7 @@ describe('new dispatch native layout', () => {
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
})
- it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
+ it('keeps publication available before cargo selection without extra planning fields', async () => {
const { dialog } = await render()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
@@ -213,14 +213,16 @@ describe('new dispatch native layout', () => {
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
})
- it('creates an unassigned empty dispatch while preserving optional dates and remark through fullscreen', async () => {
+ it('creates an empty dispatch with optional publication preserved through fullscreen', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
- field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
- await field(dialog, '备注').get('input').setValue('本趟备注')
+ expect(view(dialog).find('[aria-label="排车计划时间与备注"]').exists()).toBe(false)
+ expect(view(dialog).findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
+ const publication = view(dialog).get('.dispatch-candidate-query input[type="checkbox"]')
+ expect(publication.element).toHaveProperty('checked', false)
+ await publication.setValue(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
@@ -229,7 +231,7 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
- expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
+ expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicle: null, driver: null })
})
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
@@ -239,15 +241,14 @@ describe('new dispatch native layout', () => {
expect(listVehicles).not.toHaveBeenCalled()
expect(listDrivers).not.toHaveBeenCalled()
})
- it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
+ it('applies publication to all cars and keeps it editable after conversion read failure', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
- await field(dialog, '备注').get('input').setValue('第一组')
+ await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
await radios[1]!.setValue(true); await flushPromises()
- await field(dialog, '备注').get('input').setValue('第二组')
await radios[0]!.setValue(true); await flushPromises()
- expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
@@ -256,7 +257,7 @@ describe('new dispatch native layout', () => {
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
expect(request.recordVersion).toBe(0)
- expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
+ expect(request.groups.map(group => [group.dispatch.publishLooseboardDemand, group.dispatch.plannedDepartureAt, group.dispatch.estimatedArrivalAt, group.dispatch.remark])).toEqual([[true, null, null, null], [true, null, null, null]])
expect(wrapper.emitted('created')).toBeUndefined()
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
dialog.vm.$emit('confirm'); await flushPromises()
@@ -273,12 +274,12 @@ describe('new dispatch native layout', () => {
expect(dialog.props('confirmText')).toBe('生成排车单')
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
- expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
- expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
index 8d8a44dd..e7a8e440 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
@@ -79,7 +79,6 @@ async function render() {
return wrapper.getComponent(AppDialog)
}
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
-function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! }
function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! }
function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) }
function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) }
@@ -128,24 +127,24 @@ describe('three-column cargo planning through visible controls', () => {
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(dialog.props('confirmDisabled')).toBe(true)
})
- it('retains the first car cargo, isolates per-car fields and adds later cargo to the car selected again', async () => {
+ it('retains car cargo and the publication choice when switching cars', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
- await field(dialog, '备注').get('input').setValue('第一车独立备注')
+ await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(car(dialog, 1).text()).toContain('PALLET-1')
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- await field(dialog, '备注').get('input').setValue('第二车独立备注'); await join(dialog, 'PALLET-2')
+ await join(dialog, 'PALLET-2')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2')
await chooseCar(dialog, 1)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一车独立备注')
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
await join(dialog, 'PALLET-3')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1')
expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2')
await chooseCar(dialog, 2)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第二车独立备注')
+ expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
})
it('selects the newly added ID even when its response lists the new container first and old container last', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
index 83dbeb23..4e377296 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
@@ -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: '' } }))
-vi.mock('./SmartDispatchExistingCargo.vue', () => ({ default: { name: 'SmartDispatchExistingCargo', emits: ['saved', 'busy'], template: '' } }))
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 () => {
@@ -133,13 +132,12 @@ it('keeps planning remarks independent with unassigned resources and permits res
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
- const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; activeContainerId: string; form: { remark: string } }
- state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
- state.activeContainerId = 'container-B'; state.form.remark = '车乙'
+ const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise }
+ state.restoreDraft('server-token'); await flushPromises()
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
- expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
+ expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, null], [null, null, null]])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('clears an old departure request without blocking new empty creation',async()=>{
@@ -149,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}
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 }] }]
@@ -178,14 +176,14 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
} finally { wrapper.unmount() }
})
-it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => {
+it.each([false, true])('defaults publication off and carries publication %s in the atomic draft conversion', async publish => {
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
- const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; form: { publishLooseboardDemand: boolean } }
+ const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; publishLooseboardDemand: boolean }
state.restoreDraft('server-token'); await flushPromises()
- expect(state.form.publishLooseboardDemand).toBe(true)
- state.form.publishLooseboardDemand = false
+ expect(state.publishLooseboardDemand).toBe(false)
+ state.publishLooseboardDemand = publish
await state.submit()
- expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] }))
+ expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: publish }) })] }))
wrapper.unmount()
})
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
index 2b29c8a5..88443f99 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
@@ -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; containerId?: string; priorContainerIds?: string[] }
const pending = ref(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([])
const draft = ref(null), drafts = ref([]), activeContainerId = ref(''), capacity = ref(30)
@@ -41,17 +38,9 @@ const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref('ALL')
-const settings = reactive>({})
-const currentSettings = computed(() => {
- const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
- settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } }
- return settings[key]!
-})
-const form = computed(() => currentSettings.value.form)
+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)
@@ -113,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
@@ -130,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
@@ -147,12 +128,11 @@ async function loadCandidates(sequence = generation) {
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
-function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
+function creation(cargo: Candidate[]): CreateDispatch {
candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
- const config = containerId ? settings[containerId] : currentSettings.value
- return { publishLooseboardDemand: config?.form.publishLooseboardDemand ?? true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
+ return { publishLooseboardDemand: publishLooseboardDemand.value, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise {
if (!canCreate.value || loading.value || saving.value || pending.value) return false
@@ -178,7 +158,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
- return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
+ return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[]) }
})
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
body = { ...common, groups } satisfies ConvertDraft
@@ -226,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 {
@@ -250,21 +219,15 @@ async function submit() {
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
}
-watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
+watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
-
-
-
-
-
-
-
+
- 查询重置排车设置
+ 查询重置排车设置发布公共需求
@@ -289,18 +252,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
-
-
- {{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}选填时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。
-
-
-
-
diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
index 1b069130..b19bbb64 100644
--- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
+++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
@@ -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[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('运输操作正在提交,确认离开?'))
- 核对原请求
+
diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts
index 55949235..83af0c7f 100644
--- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts
+++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts
@@ -18,7 +18,7 @@ function setup(value = container, realDialog = false) {
}
describe('original container schedule', () => {
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getPortSchedule).mockResolvedValue(result()); vi.mocked(updatePortSchedule).mockResolvedValue(result()); vi.mocked(createPortSchedule).mockResolvedValue(result()) })
- it('loads only the server selected ID, preserves original fields, and keeps key/body after failure', async () => {
+ it('loads only the server selected ID, preserves original fields, and unlocks editing and uses a fresh key after failure', async () => {
const { wrapper, vm } = setup(container, true); await vm.begin()
expect(getPortSchedule).toHaveBeenCalledExactlyOnceWith('S1')
vm.form.vesselName = 'NEW'; vm.form.voyageNo = 'V2'; vm.form.reason = '原船满载改配'
@@ -27,12 +27,12 @@ describe('original container schedule', () => {
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('原柜船期与修改历史 · D1')
- expect(dialog.getComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
+ expect(dialog.getComponent({ name: 'ElForm' }).props('disabled')).toBe(false)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
expect(dialog.get('input[placeholder="请输入船名"]').element.value).toBe('NEW')
await vm.save()
- expect(vi.mocked(updatePortSchedule).mock.calls[1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0])
+ expect(vi.mocked(updatePortSchedule).mock.calls[1]![1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0]![1]); expect(vi.mocked(updatePortSchedule).mock.calls[1]![2]).not.toBe(vi.mocked(updatePortSchedule).mock.calls[0]![2])
expect(updatePortSchedule).toHaveBeenLastCalledWith('S1', { expectedVersion: 5, reason: '原船满载改配', data: expect.objectContaining({ vesselName: 'NEW', voyageNo: 'V2', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', observedAt: original.observedAt, expiresAt: original.expiresAt, portCutoffAt: original.portCutoffAt, remark: '原备注', containerBookingId: 'C1' }) }, expect.any(String))
expect(createPortSchedule).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue
index 3cc68a72..21776d17 100644
--- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue
+++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue
@@ -96,7 +96,7 @@ async function save() {
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期保存失败').message
if (cause instanceof ApiResponseError && cause.code === 'A0409') error.value += ';输入已保留,请关闭后重新打开核对最新版本。'
}
- } finally { if (current === generation) busy.value = false }
+ } finally { pending.value = null; if (current === generation) busy.value = false }
}
watch(open, value => { if (!value) { generation++; loading.value = false; busy.value = false } })
watch(scope, () => { open.value = false; generation++; schedule.value = null; pending.value = null })
@@ -108,7 +108,7 @@ onBeforeUnmount(() => { generation++ })
{{ prerequisite }}
当前账号无船期读取权限。
-
+
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts b/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts
index 79cbf6db..5544ec70 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.test.ts
@@ -61,6 +61,8 @@ describe('SailingCalendarView integrated workspace', () => {
const view = await mountView()
expect(view.get('h1').text()).toBe('船期日历')
expect(view.get('.data-table__business-actions').text()).toContain('新增单个船次')
+ expect(view.get('.data-table__business-actions').text()).toContain('渠道与周模板')
+ expect(view.get('.data-table__business-actions').text()).not.toContain('按周批量生成')
expect(view.find('button[aria-label="列设置"]').exists()).toBe(false)
expect(view.find('.data-table__pagination').exists()).toBe(false)
expect(view.get('.detail-panel').text()).toContain('SAIL-TEST')
@@ -116,7 +118,10 @@ async function clickAction(view: VueWrapper, label: string) {
async function editWeeklyTemplate() {
vi.mocked(api.listSailingTemplates).mockResolvedValue([weeklyTemplate])
const view = await mountView()
- await clickAction(view, '按周批量生成')
+ await clickAction(view, '渠道与周模板')
+ const templateTab = view.get('[role="tab"][id="tab-templates"]')
+ await templateTab.trigger('click')
+ await flushPromises()
await clickAction(view, '编辑')
const dialog = view.findAllComponents(AppDialog).find(item => item.props('title').startsWith('编辑周模板'))!
expect(dialog.props('title')).toBe('编辑周模板 · ST-TEST-01')
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index ac82b643..17a15640 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -421,7 +421,6 @@ onMounted(() => void load())
diff --git a/src/modules/trucking/AGENTS.md b/src/modules/trucking/AGENTS.md
index c306f66a..58f3ba9d 100644
--- a/src/modules/trucking/AGENTS.md
+++ b/src/modules/trucking/AGENTS.md
@@ -13,13 +13,12 @@
| 拖车预约列表、状态与筛选 | `pages/TruckAppointmentsView.vue`;`trucking` | `api/platformTrucking.ts`;`firstleg/service/TruckAppointmentService.java` |
| 新建拖车预约、订舱候选 | `pages/TruckAppointmentCreateView.vue`、`components/TruckAppointmentForm.vue`;`trucking/new` | `api/platformTrucking.ts` 的 `/candidates` |
| 预约详情、附件与承运商 | `pages/TruckAppointmentDetailView.vue`、`components/TruckAttachmentDialog.vue`;`trucking/:id` | `api/platformTrucking.ts`;附件 `file/` |
-| 集装箱号校验 | `iso6346.ts` | 本地格式校验,服务端仍复核 |
## 关键边界
- 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商;不选择或提交客户、柜次、装柜计划或真实集装箱号。
- 一个订舱最多一张非终态拖车单;重复创建时转入现有单继续编辑,不新建第二张。
-- 提单号和仓库由订舱冻结事实带入,表单不自行拼接或改写。
+- 提单号和仓库由订舱冻结事实带入,表单不自行拼接或改写;创建及派车不要求上传订舱文件。
- PDA 完成装柜不自动推进拖车状态;拖车生命周期与 PDA 任务解耦。
- 附件为平台作用域,不重复上传提单文件;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护。
diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts
index aa1cc580..4641c69f 100644
--- a/src/modules/trucking/api/platformTrucking.test.ts
+++ b/src/modules/trucking/api/platformTrucking.test.ts
@@ -14,9 +14,9 @@ describe('truck appointment API', () => {
})
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
- vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
+ vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运' }], page: '1', size: '200', total: '1', pages: '1' } })
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
- records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
+ records: [{ oceanBookingId: '11', warehouseId: '12' }],
total: 1,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts
index 7048ab93..2f4d6c37 100644
--- a/src/modules/trucking/api/platformTrucking.ts
+++ b/src/modules/trucking/api/platformTrucking.ts
@@ -75,7 +75,6 @@ function normalizeCandidate(candidate: TruckAppointmentCandidate & Record item.oceanBookingId === id)!
@@ -46,6 +46,21 @@ describe('TruckAppointmentForm booking-only behavior', () => {
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
})
+ it.each([false, true])('validates a booking without documents when editing is %s', async (editing) => {
+ vi.mocked(getOceanBooking).mockResolvedValue({ ...detail('booking-1'), documents: [] } as never)
+ const wrapper = mount(TruckAppointmentForm, {
+ props: {
+ modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-10-25T10:00:00' },
+ resource: editing ? resource('appointment-1', 'booking-1') : null,
+ },
+ global: { plugins: [ElementPlus] },
+ })
+ await flushPromises()
+ expect(await wrapper.vm.validate()).toBe(true)
+ expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
+ wrapper.unmount()
+ })
+
it('selects only server-approved released bookings and never exposes container identity', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts
index 15a29951..9d155f9d 100644
--- a/src/modules/trucking/components/TruckAppointmentForm.test.ts
+++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts
@@ -39,7 +39,7 @@ describe('TruckAppointmentForm interaction contract', () => {
})
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
- expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
+ expect(source).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(source).toContain('所选订舱缺少有效提单号')
expect(source).toContain("booking.status !== 'RELEASED'")
expect(source).toContain('bookingContainersError')
diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue
index 1ace18a7..86704559 100644
--- a/src/modules/trucking/components/TruckAppointmentForm.vue
+++ b/src/modules/trucking/components/TruckAppointmentForm.vue
@@ -7,7 +7,7 @@ import { Eye, Save } from '@lucide/vue'
import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
-import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
+import { getOceanBooking } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -18,7 +18,6 @@ const relationWarning = ref('')
const dispatchDetailsOpen = ref(false)
const bookingContainersLoading = ref(false)
const bookingContainersError = ref(null)
-const bookingDocuments = ref([])
const relationLocked = computed(() => Boolean(props.resource))
const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
@@ -39,7 +38,7 @@ const rules: FormRules = {
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
- return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
+ return callback()
}, trigger: 'change' }],
carrierId: [{ required: true, message: '请选择拖车供应商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预约装柜时间', trigger: 'change' }],
@@ -57,21 +56,17 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC'
)))
const bookings = computed(() => {
- const result = new Map()
+ const result = new Map()
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
id: item.oceanBookingId,
bookingNo: item.oceanBookingNo,
billOfLadingNo: item.billOfLadingNo,
warehouseId: item.warehouseId,
warehouseName: item.warehouseName,
- bookingDocumentReady: item.bookingDocumentReady,
})
return [...result.values()]
})
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
-const bookingDocumentReady = computed(() => relationLocked.value
- ? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
- : Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
const selectedBookingFallback = computed(() => {
const id = draft.oceanBookingId
const snapshot = props.resource
@@ -97,16 +92,13 @@ async function loadCurrentBookingContext(bookingId: string) {
const booking = await getOceanBooking(bookingId)
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
if (booking.status !== 'RELEASED') {
- bookingDocuments.value = []
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
return
}
draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo
- bookingDocuments.value = booking.documents || []
} catch (cause) {
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
- bookingDocuments.value = []
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
} finally {
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
@@ -116,7 +108,6 @@ async function selectBooking(bookingId: string) {
bookingLoadSequence += 1
const item = bookings.value.find((entry) => entry.id === bookingId)
draft.oceanBookingId = item?.id || null
- bookingDocuments.value = []
relationWarning.value = ''
if (!item) {
draft.warehouseId = null
@@ -196,7 +187,6 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
-
元
diff --git a/src/modules/trucking/types.ts b/src/modules/trucking/types.ts
index b7ab370c..346d922a 100644
--- a/src/modules/trucking/types.ts
+++ b/src/modules/trucking/types.ts
@@ -201,7 +201,6 @@ export interface TruckAppointmentCandidate {
warehouseName: string | null
channelCode: string | null
channelName: string | null
- bookingDocumentReady: boolean
}
export interface TruckAppointmentCandidatePage {
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
index 3ff95218..7fcfc964 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
@@ -190,6 +190,28 @@ describe('UnifiedOrdersView list request coordination', () => {
}
})
+ it.each(['全部', '测量数据审核'])('keeps the %s tab bar mounted without a loading text row during query', async (label) => {
+ const wrapper = mountView()
+ await flushPromises()
+ if (label !== '全部') {
+ await wrapper.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
+ await flushPromises()
+ }
+ const tabList = wrapper.get('[role="tablist"]').element
+ let resolveCounts!: (value: { counts: {} }) => void
+ vi.mocked(getPlatformUnifiedOrderStatusCounts).mockImplementationOnce(() => new Promise(resolve => { resolveCounts = resolve }))
+ wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
+ await flushPromises()
+ expect(wrapper.get('[role="tablist"]').element).toBe(tabList)
+ expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('true')
+ expect(wrapper.find('.order-status-feedback').exists()).toBe(false)
+ resolveCounts({ counts: {} })
+ await flushPromises()
+ expect(wrapper.get('[role="tablist"]').element).toBe(tabList)
+ expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('false')
+ wrapper.unmount()
+ })
+
it.each(['success', 'failure'] as const)('ignores an older tab request %s after the current tab has loaded', async (outcome) => {
const wrapper = mountView()
await flushPromises()
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
index 98501ef2..5738e767 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
@@ -559,13 +559,13 @@ describe('UnifiedOrdersView contract', () => {
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
expect(source).toContain('')
expect(source).toContain(':file-id="weightResult.fileId"')
expect(source).toContain('openMeasurementEvidence(weightResult.fileId')
expect(source).toContain('measurementDetail?.actualGrossWeightKg')
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue
index 47d0a8b8..2cc2b0fb 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue
@@ -2701,10 +2701,21 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
)">保存外箱
-
- {{ row.boxCode || '—' }}
+
+
+
+
+
+
+ {{ row.boxCode?.split('#').pop() || '—' }}
-
{{ formatNumber(row.outerLengthCm) }}
@@ -2718,45 +2729,40 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
{{ measurementValue(row as Record, column.key) }}
-
-
-
-
-
合计箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}
-
-
-
称重图片与确认重量
-
- 实际总毛重
- {{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg
+
+ 称重图片与确认重量
+
+
+
+
+
+
+
+
+ {{ weightResult.palletType || '未记录类型' }}
+
+
+ {{ formatNumber(weightResult.grossWeightKg) }}
+
+
+ {{ formatNumber(weightResult.palletTareWeightKg) }}
+
+
+ {{ formatNumber(weightResult.netWeightKg) }}
+
+
-
-
-
-
- - 托盘类型
- {{ weightResult.palletType || '未记录类型' }}
- - 服务端确认毛重
- {{ formatNumber(weightResult.grossWeightKg) }} kg
- - 托盘皮重
- {{ formatNumber(weightResult.palletTareWeightKg) }} kg
- - 货物重量
- {{ formatNumber(weightResult.netWeightKg) }} kg
-
-
-
- 暂无称重图片与确认结果
+ 合计实际总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg
@@ -2811,8 +2817,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}({{ tabCount(tab.value) }})
- 页签数量加载中…
-
+
{{ statusCountsError.message }}
重试数量
@@ -2855,8 +2860,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
>
{{ tab.label }}({{ tabCount(tab.value) }})
-
页签数量加载中…
-
+
{{ statusCountsError.message }}
重试数量
@@ -2991,14 +2995,13 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
-
-
+
本次审核范围
预览 {{ batchApprovalPreviewRows.length }} 票已勾选 {{ batchApprovalSelectedRows.length }} 票
-
+
待审核订单
@@ -3025,7 +3028,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
{{ coLoadGroupSummary(coLoadDetailSummary) }}{{ coLoadDetailSummary.memberCount }} 个成员
-