fix(appointments): resume existing batch dispatch after refresh

This commit is contained in:
2026-10-19 08:44:26 +08:00
parent 67d917010d
commit 4bb21641d9
5 changed files with 115 additions and 18 deletions
@@ -171,4 +171,6 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
@@ -1,24 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
let guard: LeaveGuard
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); access.allowed = true
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
vi.mocked(listDispatchDrafts).mockResolvedValue([])
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
})
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('DS-EXISTING')
expect(wrapper.text()).not.toContain('创建本客户排车单')
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
await button(wrapper, '查看原排车单').trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
})
it('fails closed when transport relations cannot be read', async () => {
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
expect(wrapper.text()).toContain('DS-EXISTING')
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
@@ -6,8 +6,10 @@ import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchD
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import { getReceivingContext } from './receivingApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
@@ -15,10 +17,12 @@ const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
const existingReady = ref(false)
const selectionBaseline = ref('[]')
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
function reasons(row: BatchDispatchCandidate) {
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
@@ -28,9 +32,24 @@ const chosen = computed(() => rows.value.filter(row => selected.value.includes(r
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
async function load() {
if (!readable.value || pending.value) return
loading.value = true; error.value = null
loading.value = true; error.value = null; existingReady.value = false
try {
contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates))
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
const batches = [...new Set(props.batchKeys)]
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
const references: typeof existingDispatches.value = []
receiving.forEach((context, index) => {
const batchKey = batches[index]!
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
}
})
existingDispatches.value = references
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
if (references.length && !arrangingRemaining.value) return
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
selectionBaseline.value = JSON.stringify(selected.value)
@@ -81,18 +100,22 @@ async function refreshRejected() {
finally { saving.value = false }
}
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; await load() } }
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
onMounted(load)
</script>
<template>
<section class="work-form" aria-label="按预约批次排车">
<h3>按批次选择真实托盘</h3><p>单客户单批入口仅预选本批当前合格实物;每次选择同一客户创建排车单,再安排车辆和司机。逐来源数量由服务端候选确定。</p>
<el-button v-if="batchKeys.length === 1 && !result" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、客户与实物来源…</p>
<template v-if="existingDispatches.length && !loading && !error && !result">
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
</template>
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
</template>
@@ -5,8 +5,22 @@ import { getDispatch } from '@/modules/overseas-warehouse'
const state = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
it.each(['customer-b', undefined])('rejects FBA dispatch references outside the exact batch customer: %s', async customerId => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: 'DS-ORIGINAL', customerId }]); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.find('table').exists()).toBe(false)
wrapper.unmount()
})
it('reads shared dispatch once and verifies every FBA batch reference', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: ['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, batchNo: `AP-${id}`, customerId: 'customer-a', sources: [] })) })
const wrapper = render(['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, dispatchNo: 'DS-ORIGINAL', customerId: 'customer-a' }))); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
wrapper.unmount()
})
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
const wrapper = render(); await flushPromises()
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
@@ -6,21 +6,27 @@ import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string] }>()
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
async function load() {
if (!readable.value) return
loading.value = true; error.value = null
loading.value = true; error.value = null; emit('ready', false)
try {
rows.value = await Promise.all(props.dispatches.map(async reference => {
const row = await getDispatch(reference.dispatchNo)
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
rows.value = await Promise.all([...new Set(props.dispatches.map(reference => reference.dispatchNo))].map(async dispatchNo => {
const row = await getDispatch(dispatchNo)
// 预约批次必须仍由这张原排车单持有同一客户的分配,不能仅凭展示业务号续办。
const matches = props.dispatches.filter(reference => reference.dispatchNo === dispatchNo).every(reference =>
reference.batchKey.startsWith('FBA_APPOINTMENT:')
? !!reference.customerId && row.status !== 'CANCELLED' && row.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId)
: `DISPATCH:${row.id}` === reference.batchKey)
if (!matches || row.dispatchNo !== dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
return row
}))
emit('ready', true)
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
finally { loading.value = false }
}
@@ -29,7 +35,7 @@ function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false;
onMounted(load)
</script>
<template>
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>沿用本批已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>