This commit is contained in:
2026-10-21 15:49:35 +08:00
parent e6c864a2a6
commit a4c7f8edd5
38 changed files with 466 additions and 95 deletions
@@ -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()
@@ -213,11 +242,15 @@ describe('new dispatch native layout', () => {
const { wrapper, dialog } = await render(); await restore(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('重试原请求')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -260,18 +293,63 @@ describe('new dispatch native layout', () => {
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(1)
})
it('rejects removal of a populated car before sending a command and keeps the draft editable', async () => {
const { dialog } = await render(); await restore(dialog)
dialog.getComponent(SmartDispatchCargoBoard).vm.$emit('removeContainer', 'container-1'); await flushPromises()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).text()).toContain('请先移回车内托盘')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
const { dialog } = await render(); await restore(dialog)
await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
if (failure === 'rejection') {
expect(view(dialog).text()).toContain('仅空容器可以删除')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
} else {
const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
}
})
it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it('keeps creation unavailable without its write permission', async () => {
can.mockReturnValue(false)
@@ -92,7 +92,7 @@ async function warehouseSelected(dialog: VueWrapper) {
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
}
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
describe('three-column cargo planning through visible controls', () => {
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
@@ -179,7 +179,8 @@ describe('three-column cargo planning through visible controls', () => {
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
expect(view(dialog).text()).toContain(`request-${step}`)
expect(view(dialog).text()).toContain('按明确驳回结果重新核对')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
})
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
lostReply = step
@@ -205,9 +206,9 @@ describe('three-column cargo planning through visible controls', () => {
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const dialog = await render(); await warehouseSelected(dialog)
await selectOrder(dialog, 'PALLET-1')
await cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click')
await cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').trigger('click')
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
expect(cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.attributes('disabled')).toBeDefined()
expect(cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
@@ -236,8 +237,7 @@ describe('three-column cargo planning through visible controls', () => {
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
for (const source of catalog) {
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false)
}
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
@@ -31,6 +31,9 @@ const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
// Only a recorded first-attempt rejection proves that no write occurred.
let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
@@ -49,6 +52,7 @@ const currentSettings = computed(() => {
})
const form = computed(() => currentSettings.value.form)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
@@ -112,7 +116,9 @@ async function init() {
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
rejectedDraftToken = ''
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
@@ -147,7 +153,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value) return false
if (!canCreate.value || loading.value || saving.value || pending.value) return false
error.value = null
try {
if (!pending.value) {
@@ -160,7 +166,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
else if (action === 'DELETE') {
const container = current.containers.find(row => row.draftContainerId === containerId)
if (!container || container.sourceKeys.length) throw new DispatchInputError('请先移回车内托盘,再删除空车辆')
body = { ...common, reason: '移除本草稿装车容器' }
}
else if (action === 'RELEASE') body = common
else {
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
@@ -215,7 +225,9 @@ async function retryDraft() {
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
} catch (cause) { // A later rejection cannot erase an earlier unknown write result.
if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
persist(); throw cause }
finally { saving.value = false }
}
async function submit() {
@@ -234,7 +246,7 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -243,8 +255,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
@@ -257,13 +268,13 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
@@ -25,7 +25,7 @@ async function open(dispatchNo: string | null = 'PC/1') {
await flushPromises()
return wrapper
}
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await button(wrapper, '加入这辆车').trigger('click'); await flushPromises() }
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
expect(button(wrapper, '加入这辆车').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
})
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
@@ -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 }] } } }
@@ -203,3 +203,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 { DispatchAction, DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
@@ -89,7 +89,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>
@@ -204,3 +204,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()
})
@@ -78,7 +78,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
@@ -92,7 +92,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,
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
}
function handleAction(value: string, row: DispatchListRow) {
@@ -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 }
}