Merge origin/release/test and preserve pickup workflow

This commit is contained in:
2026-10-21 16:22:08 +08:00
38 changed files with 485 additions and 95 deletions
+5 -5
View File
@@ -1,4 +1,4 @@
import { addDays } from './calendar'
import { projectWarehouseArrival } from '@/modules/container-loading'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
@@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
const planned = projected.date
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
await listContainerArrivalTimes(['9001'])
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
})
it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
}
})
})
@@ -6,6 +6,14 @@ export interface ContainerArrivalTime {
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
version: number
planCount: number
}
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
const count = Number(item.planCount)
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
return { ...item, version: Number(item.version), planCount: count }
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
@@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
queryPath,
{ sourceContainerBookingIds },
)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
return (unwrapApiResponse(response) || []).map(normalizeArrival)
}
export async function updateContainerExpectedArrival(
@@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival(
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
return { ...result, version: Number(result.version || 0) }
return normalizeArrival(result)
}
+1
View File
@@ -1,5 +1,6 @@
export * from './api/platformContainerLoading'
export * from './types'
export { projectWarehouseArrival } from './warehouseArrival'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
}
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
})
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
@@ -46,6 +46,7 @@ import {
} from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
@@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) {
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const arrivalTime = ref<ContainerArrivalTime | null>(null)
const expectedArrivalText = computed(() => {
const row = workbenchDetail.value
const arrival = arrivalTime.value
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
: projected.date ? `${projected.date}(默认推算)` : '—'
})
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
@@ -726,7 +735,7 @@ onMounted(async () => {
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
@@ -584,6 +584,30 @@ describe('ContainersView', () => {
})
})
it('shows the calendar default from actual port arrival without submitting an invented time', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
})) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
expect(picker).toBeDefined()
expect(picker.props('modelValue')).toBe('')
expect(updateContainerExpectedArrival).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a frozen missing arrival undated and prevents editing it', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')!
expect(picker.props('disabled')).toBe(true)
wrapper.unmount()
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
@@ -40,6 +40,7 @@ import {
type TerminalDrayageView,
} from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -285,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function defaultArrivalDate(row: ContainerWorkbenchRow) {
const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return projected.isDefault ? projected.date : null
}
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
@@ -577,7 +584,7 @@ async function saveDrayageAction() {
}
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
const current = arrivalTimes.value[row.id]
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
arrivalSavingIds.value.push(row.id)
try {
const updated = await updateContainerExpectedArrival(row.id, {
@@ -760,7 +767,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row as ContainerWorkbenchRow) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row as ContainerWorkbenchRow) ? `${defaultArrivalDate(row as ContainerWorkbenchRow)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
@@ -772,7 +779,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
</DataTable></div></section>
</ListPageLayout>
+1
View File
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
}
export interface ContainerWorkbenchActualFacts {
arrivedDestinationPortAt?: string | null
actualSailingAt?: string | null
actualPortArrivalAt?: string | null
actualWarehouseArrivalAt?: string | null
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { projectWarehouseArrival } from './warehouseArrival'
describe('shared warehouse arrival projection', () => {
it.each([
['2026-10-21T15:49:00', '2026-10-23'],
['2026-10-30T23:30:00-07:00', '2026-11-01'],
['2026-12-31T23:30:00', '2027-01-02'],
['2026-03-07T23:30:00-08:00', '2026-03-09'],
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
})
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
}
})
it('does not replace inconsistent or frozen missing dates with defaults', () => {
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
}
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
})
})
@@ -0,0 +1,20 @@
export interface WarehouseArrivalPlan {
plannedAt: string | null
version: number
planCount: number
}
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
let date = plan?.plannedAt?.slice(0, 10) || null
if (!date && canDefault && arrivalAt) {
const day = arrivalAt.slice(0, 10)
const calendarDate = new Date(`${day}T12:00:00Z`)
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
date = calendarDate.toISOString().slice(0, 10)
}
}
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
}
@@ -63,6 +63,16 @@
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
## 自有业务列表字段补齐(2026-10-21)
依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。
| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 |
|---|---|---|---|
| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 |
## 自有业务行操作样式核对(2026-10-21)
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
@@ -10,7 +10,7 @@ export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
}
}
@@ -10,7 +10,7 @@ export function ownedBusinessFixture(): OwnedBusinessDetail {
actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
}
}
@@ -37,11 +37,17 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' },
{ key: 'latestLoadingCompletedAt', label: '最晚装车完成时间', minWidth: 180, slotName: 'latestLoaded' },
{ key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'scheduledArrival', label: '预约到达时间', minWidth: 190, slotName: 'scheduledArrival' },
{ key: 'actualArrival', label: '实际到达时间', minWidth: 190, slotName: 'actualArrival' },
{ key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
async function load() {
@@ -97,9 +103,13 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination">{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</div><span v-if="!row.destinations.length">—</span></template>
<template #scheduledArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div><span v-if="!row.destinations.length">—</span></template>
<template #actualArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div>{{ time(destination.actualArrivedAt) }}</div></div><span v-if="!row.destinations.length">—</span></template>
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}</template><span v-else>未登记</span></template>
<template #unitPrice="{ row }"><template v-if="row.operations.money && row.unitPrice != null">{{ row.operations.money.currency }} {{ row.unitPrice }}</template><span v-else>{{ row.operations.money ? '—' : '未登记' }}</span></template>
<template #latestLoaded>—</template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
</DataTable></div>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
@@ -17,7 +17,7 @@ const Button = defineComponent({ props: ['disabled'], template: '<button :disabl
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([name,slot]) => h('section', { 'data-column': name }, slot?.({row})))))]) } })
const OperationsEditor = defineComponent({ name: 'OwnedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const PhotosEditor = defineComponent({ name: 'OwnedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: OperationsEditor, OwnedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
@@ -30,6 +30,27 @@ beforeEach(() => {
})
describe('owned business views', () => {
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
const detail = ownedBusinessFixture()
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
detail.row.destinations[0]!.appointments[0]!.scheduledAt = '2026-10-21T09:00:00Z'
detail.row.destinations.push({ destinationKey: 'destination-2', warehouseCode: 'FBA-2', warehouseName: null, address: null, appointments: [], actualArrivedAt: '2026-10-21T10:00:00Z' })
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
expect(calls.detail).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
const detail = ownedBusinessFixture()
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
@@ -13,6 +13,10 @@
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
加入货物反馈修复(截图 `codex-clipboard-6ef9c642-96b3-4c97-a234-dbfefc5821f8.png`):正常请求暂存也触发待核对警告、重试按钮,候选刷新清空列表且触发整窗遮罩。目标为仅在请求空闲且仍待确认时展示恢复提示;同仓刷新保留不可操作的旧列表直至原子替换,失败或换仓清空,刷新期间拒绝使用旧来源;整窗遮罩只用于初始化。共享候选读取还有预约排车消费者,追加该消费者定向验证,不改其业务逻辑。6个文件110项定向检查通过,包含慢加入、刷新期间列表保留且禁止操作、刷新失败清空、原请求重试和空单创建;未运行全量测试。应用类型检查、差异检查通过;视觉效果待人工截图确认。
托盘移动与删除反馈(用户截图 `codex-clipboard-3317655c-a74e-4775-b0ff-65aa46ccc4be.png`、`codex-clipboard-2459ecf1-8447-4835-a571-486fd540289a.png`,参考原型 `app.js` 的 `renderDispatchPalletDetails/renderDispatchTrucks`):中栏保留托盘号与箱数,拖拽或点击编号移入选中车辆;按用户“直接移过去”要求,服务端确认加入后从中栏移走,右栏“×”移回,删除每行加入/已加入和目的仓文字。整托关联来源仍可展开,阻断保留“暂不可排车”及可展开原因,候选与整组重复原因去重。非空车辆隐藏删除入口且命令层拦截;首次明确拒绝解除待核对锁,历史已标记明确拒绝的浏览器记录重开时清除锁并恢复原草稿;未知结果及未知后再次拒绝继续保留原请求。自动检查:6文件107项定向测试通过;补充恢复断言后3文件102项(含样式合同)通过,未运行全量。应用类型检查被其他在途模块阻断:ContainersView.vue 行类型、OwnedBusinessFixtures.ts/OutsourcedBusinessFixtures.ts 缺少 exceptions;本轮文件未报告类型错误。差异检查通过,未部署;视觉效果待人工截图确认。换标单轮次/实托/库存来源校验仅确认代码来源,未核实该单据实际数据,不宣称业务阻断已解决。
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
## 2026-10-21 智能排车截图复核与样式纠偏
@@ -405,3 +409,5 @@
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
@@ -37,6 +37,7 @@ export const submitDispatchReview = (no: string, body: VersionCommand & { remark
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
export const markDispatchException = (no: string, body: MarkDispatchException) => postCommand(`${path(no)}/exceptions`, versioned(body))
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
@@ -18,8 +18,8 @@ const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiE
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
const uploadTypes = computed(() => props.kind === 'DRIVER_PHOTO' ? types.slice(1) : types)
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
function valid(value: DispatchEvidenceFile, expected?: string) {
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
@@ -11,8 +11,8 @@ const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -197,3 +197,21 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s
expect(wrapper.emitted('rejected')).toBeUndefined()
} finally { wrapper.unmount() }
})
it('requires an exception type and submits optional direction, description and images once', async () => {
const { wrapper, state } = await open('MARK_EXCEPTION')
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择异常类型')
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
expect(api.markDispatchException).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场部分签收', imageFileIds: ['91'] }))
expect(api.markDispatchException).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('does not submit an exception when the server disallows the action', async () => {
const { wrapper, state } = await open('MARK_EXCEPTION', false)
state.exception.exceptionType = 'REFUSED'; await state.submit()
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -10,10 +10,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import type { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -38,15 +38,16 @@ const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null,
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const reviewAttachments = ref(emptyReviewAttachments())
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
async function load() {
@@ -62,6 +63,7 @@ async function load() {
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
reviewAttachments.value = emptyReviewAttachments()
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
@@ -98,6 +100,9 @@ function payload(): object {
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
}
switch (currentAction.value) {
case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
@@ -117,6 +122,7 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
const no = command.dispatchNo, body = command.body
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
switch (command.action) {
case 'MARK_EXCEPTION': return api.markDispatchException(no, body as unknown as MarkDispatchException)
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
@@ -179,7 +185,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
@@ -192,6 +198,12 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
<template v-else-if="currentAction === 'MARK_EXCEPTION'">
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
</template>
<template v-else-if="currentAction === 'EDIT'">
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
@@ -218,7 +230,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" v-model="reviewAttachments[field.key]" :reference-ids="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" save-hint="上传后提交审核,附件与本次审核结果一起保存。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy[field.kind] = $event" />
</template>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
@@ -77,7 +77,7 @@ describe('smart dispatch three-column cargo board', () => {
await select(wrapper); await wrapper.setProps({ disabled: true })
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
await pallet(wrapper).trigger('dragstart', { dataTransfer: transfer() })
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
@@ -106,13 +106,30 @@ describe('smart dispatch three-column cargo board', () => {
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
for (const index of [0, 1]) {
await select(wrapper, index)
expect(pallet(wrapper).attributes('draggable')).toBe('false')
expect(pallet(wrapper).get('button').text()).toBe('已加入第 1 辆车')
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
}
expect(wrapper.emitted('add')).toBeUndefined()
})
it('moves a confirmed pallet out of the middle list and restores it when removed; only empty cars can be deleted', async () => {
const source = candidate(), state = draft(), wrapper = render([source])
await select(wrapper)
expect(pallet(wrapper).findAll('td')).toHaveLength(2)
expect(pallet(wrapper).get('button').text()).toBe('PALLET-1')
expect(wrapper.text()).not.toContain('加入这辆车')
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
await wrapper.setProps({ draft: clone(state) })
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
const loaded = wrapper.get('[aria-label="第 1 辆车"]')
expect(loaded.text()).not.toContain('删除车辆')
expect(wrapper.get('[aria-label="第 2 辆车"]').text()).toContain('删除车辆')
await loaded.get('[aria-label="移回托盘 PALLET-1"]').trigger('click')
expect(wrapper.emitted('removeCargo')).toEqual([[source, 'car-1']])
await wrapper.setProps({ draft: draft() })
expect(pallet(wrapper).text()).toContain('PALLET-1')
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('删除车辆')
})
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
source.selectionGroup.complete = false
@@ -63,7 +63,7 @@ const orders = computed(() => {
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
})
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []))
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []).filter(source => !assignedVehicle(source)))
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
function assignedVehicle(source: Candidate) {
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
@@ -88,14 +88,14 @@ function dragStart(event: DragEvent, source: Candidate) {
if (event.dataTransfer) {
// Transfer only a harmless marker. The live component reference is the authority.
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.effectAllowed = 'move'
}
}
function dragOver(event: DragEvent, id: string) {
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
event.preventDefault()
dropTarget.value = id
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function drop(event: DragEvent, id: string) {
event.preventDefault()
@@ -189,24 +189,22 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
<tbody>
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<td>
<strong>{{ palletLabel(source) }}</strong>
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
<el-button class="cargo-board-pallet-move" link :disabled="!canAdd(source) || !validTarget(activeContainerId)" :aria-label="`将托盘 ${palletLabel(source)} 移入选中车辆`" @click="add(source, activeContainerId)">{{ palletLabel(source) }}</el-button>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
<details v-if="selectionGroupBlockReason(source)" class="cargo-board-block"><summary>暂不可排车</summary><p>{{ selectionGroupBlockReason(source) }}</p></details>
</td>
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
</tr>
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">该订单本页暂无托盘</td></tr>
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">暂无待装托盘</td></tr>
</tbody>
</table>
</div>
</section>
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
<div class="cargo-board-vehicle-content">
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<div class="cargo-board-vehicle-list">
@@ -216,11 +214,11 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details></div>
<el-button link :disabled="disabled" @click="emit('removeCargo', source, vehicle.draftContainerId)">移除货物</el-button>
<el-button link :disabled="disabled" :aria-label="`移回托盘 ${palletLabel(source)}`" title="移回托盘" @click="emit('removeCargo', source, vehicle.draftContainerId)">×</el-button>
</div>
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
</div>
<footer v-if="vehicle.draftContainerId" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
<footer v-if="vehicle.draftContainerId && !vehicle.sourceKeys.length" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
</article>
</div>
</slot>
@@ -261,7 +259,8 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
.cargo-board-pallet.is-draggable { cursor: grab; }
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
.cargo-board-pallet-move { max-width: 100%; height: auto; white-space: normal; text-align: left; justify-content: flex-start; }
.cargo-board-pallet.is-draggable { user-select: none; }
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.cargo-board-group-details summary { cursor: pointer; }
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -107,6 +108,34 @@ describe('new dispatch native layout', () => {
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
})
it('does not show a recovery warning or blank the board during a successful slow addition', async () => {
const row = candidate()
const empty = { ...draft(), sources: [], containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
const { dialog } = await render(); await restore(dialog); await selectFirstOrder(dialog)
let finishLock!: (value: DispatchDraft) => void
let finishRefresh!: (value: Awaited<ReturnType<typeof api.listDispatchCandidates>>) => void
vi.mocked(api.lockDispatchDraftPallet).mockReturnValueOnce(new Promise(resolve => { finishLock = resolve }))
vi.mocked(api.listDispatchCandidates).mockReturnValueOnce(new Promise(resolve => { finishRefresh = resolve }))
const content = view(dialog)
await content.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(content.text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
expect(dialog.props('confirmLoading')).toBe(true)
finishLock({ ...empty, recordVersion: 1, sources: [row], containers: [{ ...empty.containers[0]!, sourceKeys: [row.sourceKey] }] })
await flushPromises()
expect(dialog.props('loading')).toBe(false)
expect(content.get('[aria-label="待排订单"]').text()).toContain(row.orderNo)
expect(content.get('[aria-label="托盘明细"]').text()).not.toContain('请选择左侧订单')
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('true')
expect(content.text()).not.toContain('原请求结果尚待核对')
finishRefresh({ total: 1, page: 1, size: 20, pages: 1, candidates: [row], complete: true, snapshotAt: '' })
await flushPromises()
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('false')
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
@@ -124,7 +153,7 @@ describe('new dispatch native layout', () => {
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
expect(dialog.props('confirmDisabled')).toBe(true)
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
@@ -132,7 +161,7 @@ describe('new dispatch native layout', () => {
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
expect(dialog.props('confirmDisabled')).toBe(false)
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第 1 辆车')!.attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
})
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
@@ -140,7 +169,7 @@ describe('new dispatch native layout', () => {
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
const { dialog } = await render(); await selectWarehouse(dialog)
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
@@ -171,6 +200,9 @@ describe('new dispatch native layout', () => {
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(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)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
@@ -195,6 +227,9 @@ describe('new dispatch native layout', () => {
await field(dialog, '备注').get('input').setValue('第二组')
await radios[0]!.setValue(true); await flushPromises()
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
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)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
@@ -213,11 +248,15 @@ describe('new dispatch native layout', () => {
const { wrapper, dialog } = await render(); await restore(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('重试原请求')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -260,18 +299,63 @@ describe('new dispatch native layout', () => {
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(1)
})
it('rejects removal of a populated car before sending a command and keeps the draft editable', async () => {
const { dialog } = await render(); await restore(dialog)
dialog.getComponent(SmartDispatchCargoBoard).vm.$emit('removeContainer', 'container-1'); await flushPromises()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).text()).toContain('请先移回车内托盘')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
const { dialog } = await render(); await restore(dialog)
await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
if (failure === 'rejection') {
expect(view(dialog).text()).toContain('仅空容器可以删除')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
} else {
const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
}
})
it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it('keeps creation unavailable without its write permission', async () => {
can.mockReturnValue(false)
@@ -92,7 +92,7 @@ async function warehouseSelected(dialog: VueWrapper) {
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
}
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
describe('three-column cargo planning through visible controls', () => {
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
@@ -179,7 +179,8 @@ describe('three-column cargo planning through visible controls', () => {
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
expect(view(dialog).text()).toContain(`request-${step}`)
expect(view(dialog).text()).toContain('按明确驳回结果重新核对')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
})
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
lostReply = step
@@ -205,9 +206,9 @@ describe('three-column cargo planning through visible controls', () => {
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const dialog = await render(); await warehouseSelected(dialog)
await selectOrder(dialog, 'PALLET-1')
await cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click')
await cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').trigger('click')
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
expect(cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.attributes('disabled')).toBeDefined()
expect(cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
@@ -236,8 +237,7 @@ describe('three-column cargo planning through visible controls', () => {
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
for (const source of catalog) {
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false)
}
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
@@ -31,6 +31,9 @@ const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
// Only a recorded first-attempt rejection proves that no write occurred.
let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
@@ -49,6 +52,7 @@ const currentSettings = computed(() => {
})
const form = computed(() => currentSettings.value.form)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
@@ -112,7 +116,9 @@ async function init() {
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
rejectedDraftToken = ''
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
@@ -147,7 +153,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value) return false
if (!canCreate.value || loading.value || saving.value || pending.value) return false
error.value = null
try {
if (!pending.value) {
@@ -160,7 +166,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
else if (action === 'DELETE') {
const container = current.containers.find(row => row.draftContainerId === containerId)
if (!container || container.sourceKeys.length) throw new DispatchInputError('请先移回车内托盘,再删除空车辆')
body = { ...common, reason: '移除本草稿装车容器' }
}
else if (action === 'RELEASE') body = common
else {
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
@@ -215,7 +225,9 @@ async function retryDraft() {
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
} catch (cause) { // A later rejection cannot erase an earlier unknown write result.
if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
persist(); throw cause }
finally { saving.value = false }
}
async function submit() {
@@ -234,7 +246,7 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -243,8 +255,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
@@ -257,13 +268,13 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
@@ -25,7 +25,7 @@ async function open(dispatchNo: string | null = 'PC/1') {
await flushPromises()
return wrapper
}
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await button(wrapper, '加入这辆车').trigger('click'); await flushPromises() }
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
expect(button(wrapper, '加入这辆车').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
})
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
@@ -4,7 +4,7 @@ export type Version = string | number
export class DispatchInputError extends Error {}
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type DispatchAction = 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
export type DispatchAction = 'MARK_EXCEPTION' | 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
@@ -31,7 +31,11 @@ export interface OutboundReference { customerId: string; customerName: string; o
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] }
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
@@ -53,7 +57,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
@@ -109,7 +113,7 @@ export function selectionIdentity(value: CargoSelection): string {
export function requireSelectionGroup(row: Candidate): SelectionGroup {
const group = row.selectionGroup
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...new Set([...row.blockReasons, ...group.blockReasons])].join(';') || '选货组来源尚未完整,请重新读取候选')
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
@@ -16,12 +16,12 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
return result
}
export const dispatchActionLabels: Record<DispatchAction, string> = {
CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
}
export const dispatchActionPermissions: Record<DispatchAction, string> = {
CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
MARK_EXCEPTION: 'platform:overseas-warehouse:dispatch:edit', CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
ADD_CARGO: 'platform:overseas-warehouse:dispatch:edit', REMOVE_CARGO: 'platform:overseas-warehouse:dispatch:edit',
CANCEL: 'platform:overseas-warehouse:dispatch:cancel', SUBMIT_REVIEW: 'platform:overseas-warehouse:dispatch:edit',
DISPATCH_REVIEW: 'platform:fleet:trip:dispatch-review', LOADING_REVIEW: 'platform:fleet:trip:loading-review',
@@ -36,3 +36,6 @@ export function dispatchRejectionReason(reviews: Reviews | undefined): string {
.filter(([, review]) => review.status === 'REJECTED')
.map(([label, review]) => `${label}退回:${review.reason?.trim() || '原因未提供'}`).join(';')
}
export const dispatchExceptionLabels = { REFUSED: '拒收', PARTIAL_RECEIPT: '部分签收', VEHICLE_EXCEPTION: '车辆异常' } as const
export const dispatchReturnDirectionLabels = { OVERSEAS_WAREHOUSE: '退回海外仓', OTHER: '其他' } as const
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest'
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, selectionGroupBlockReason, type SelectionGroup } from './dispatchContracts'
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
@@ -41,3 +41,9 @@ it('normalizes nested group Long quantities and preserves source identities and
value.selectionGroup.sources[0]!.packages = '9007199254740993'
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
})
it('deduplicates row and full-group blockers while preserving distinct reasons', () => {
const row = candidate({ blockReasons: ['换标作业轮次缺失', '实托绑定缺失'] })
row.selectionGroup.blockReasons = ['换标作业轮次缺失', '实托绑定缺失', '同托库存未核齐']
expect(selectionGroupBlockReason(row)).toBe('换标作业轮次缺失;实托绑定缺失;同托库存未核齐')
})
@@ -8,7 +8,7 @@ export function sharedPalletCandidates(): Candidate[] {
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
return rows.map(row => ({ ...row, selectionGroup: group }))
}
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
@@ -173,3 +173,16 @@ describe('whole-dispatch four-tab detail', () => {
wrapper.unmount()
})
})
it('shows saved dispatch exceptions and their own images in the existing dispatch tab', async () => {
const value = detail({ stage: 'DELIVERING' })
value.dispatchInfo.exceptions = [{ exceptionType: 'REFUSED', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场拒收待处理', imageFileIds: ['901'], occurredAt: '2026-10-21T10:00:00Z' }]
vi.mocked(getDispatch).mockResolvedValue(value)
vi.mocked(getDispatchEvidence).mockResolvedValue({ fileId: '901', fileName: '异常现场.png', contentType: 'image/png', sizeBytes: 3 })
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('拒收'); expect(wrapper.text()).toContain('退回海外仓'); expect(wrapper.text()).toContain('现场拒收待处理')
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'EXCEPTION', '901')
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4); expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
wrapper.unmount()
})
@@ -13,7 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
@@ -78,7 +78,13 @@ onBeforeUnmount(() => { generation++ })
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
<h3>派送异常记录</h3>
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
<dl class="density-fields"><div class="density-field"><dt>异常类型</dt><dd>{{ dispatchExceptionLabels[record.exceptionType] }}</dd></div><div class="density-field"><dt>登记时间</dt><dd>{{ time(record.occurredAt) }}</dd></div><div class="density-field"><dt>退货去向</dt><dd>{{ record.returnDirection ? dispatchReturnDirectionLabels[record.returnDirection] : '—' }}</dd></div><div class="density-field"><dt>异常说明</dt><dd>{{ record.description || '—' }}</dd></div></dl>
<DispatchEvidenceFiles v-if="record.imageFileIds.length" :model-value="record.imageFileIds" :dispatch-no="dispatchNo" kind="EXCEPTION" label="异常图片" readonly />
</article>
</section></el-tab-pane>
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
@@ -214,6 +214,19 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
})
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: true, AppDialog: { template: '<div><slot /></div>' } } } }); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('reads one BOL on demand and preserves popup failure handling', async () => {
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -259,3 +272,18 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
expect(wrapper.text()).not.toContain('请补齐本趟资料')
wrapper.unmount()
})
it('opens the exception form from the dispatching row and hides it without permission', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', actions: { MARK_EXCEPTION: gate } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'MARK_EXCEPTION', label: '标记异常', tone: 'danger' })]))
row.vm.$emit('action', 'MARK_EXCEPTION'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'MARK_EXCEPTION', dispatchNo: 'PC/1' })
wrapper.unmount(); can.mockReturnValue(false)
const denied = mount(DispatchesView, options); await flushPromises()
const deniedRow = denied.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((deniedRow.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain('MARK_EXCEPTION')
denied.unmount()
})
@@ -84,7 +84,7 @@ function search(value: QueryModel) { model.value = value; page.value = 1; void l
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
if (showSections.value && section.value !== 'VEHICLE_INFO') {
const review = section.value
@@ -99,7 +99,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
label: dispatchActionLabels[value],
ariaLabel: dispatchActionLabels[value],
priority: value === primaryAction ? 600 : 400 - index * 10,
tone: value === 'CANCEL' ? 'danger' as const : undefined,
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
}
@@ -71,3 +71,25 @@ it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response inst
await state.load({ warehouseId: '321' })
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
})
it.each(['success', 'failure'])('keeps rows stable during a refresh but rejects their use until %s is resolved', async outcome => {
const state = setup(), refresh = deferred()
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response()).mockReturnValueOnce(refresh.promise)
await state.load({ warehouseId: '321' })
const previous = state.candidates.value
const request = state.load({ warehouseId: '321' })
expect(state.candidates.value).toBe(previous)
expect(state.total.value).toBe(41)
expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成')
if (outcome === 'success') refresh.resolve(response({ total: 1 }))
else refresh.reject(new Error('refresh failed'))
await request
if (outcome === 'success') {
expect(state.total.value).toBe(1)
expect(() => state.assertCurrent(state.candidates.value)).not.toThrow()
} else {
expect(state.candidates.value).toEqual([])
expect(state.error.value?.message).toBeTruthy()
expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成')
}
})
@@ -6,17 +6,18 @@ import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type
/** One visible server page; selected cargo remains owned by the caller or server draft. */
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
let generation = 0, warehouse = ''
let generation = 0, warehouse = '', current = false
let pending: { key: string; promise: Promise<void> } | undefined
const groupVersions = new Map<string, string>()
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
function reset() {
generation++; pending = undefined; warehouse = ''
generation++; pending = undefined; warehouse = ''; current = false
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
groupVersions.clear(); sourceGroups.clear()
}
function assertCurrent(rows: Candidate[]) {
if (rows.length && (loading.value || !current)) throw new DispatchInputError('货物候选尚未读取完成,请刷新后重试')
assertSelectionGroupVersions(rows, candidates.value)
for (const row of rows) {
const group = row.selectionGroup
@@ -34,8 +35,9 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
const key = JSON.stringify(request)
if (pending?.key === key) return pending.promise
const sequence = ++generation
// Preserve the last total while loading: zero would make the pager emit page 1.
loading.value = true; error.value = null; candidates.value = []
// Keep the previous page visible but non-actionable until its replacement is validated.
// Preserve the total too: zero would make the pager emit page 1.
loading.value = true; current = false; error.value = null
const promise = (async () => {
try {
const result = await fetchCandidates(request)
@@ -46,9 +48,13 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
}
current = true
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
} catch (cause) {
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
if (sequence === generation) {
candidates.value = []; current = false
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
}
} finally {
if (sequence === generation) { loading.value = false; pending = undefined }
}