fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
@@ -33,7 +33,7 @@ describe('whole-dispatch four-tab detail', () => {
expect(pane.isVisible()).toBe(true)
expect(pane.text()).toContain('LAX → ONT8')
expect(pane.text()).toContain('派送异常记录读取失败')
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
await pane.get('button').trigger('click'); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
@@ -101,7 +101,7 @@ describe('whole-dispatch four-tab detail', () => {
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
}
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—'])
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
wrapper.unmount()
})
@@ -121,7 +121,7 @@ describe('whole-dispatch four-tab detail', () => {
wrapper.unmount()
})
it('uses only four accessible colored tabs and switches without another network read', async () => {
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
@@ -133,8 +133,8 @@ describe('whole-dispatch four-tab detail', () => {
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.isVisible()).toBe(true)
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.find('[aria-label="正式FBA预约"]').exists()).toBe(true); expect(dispatchInfo.text()).toContain('FBA-AP-1')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
})
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
@@ -16,6 +16,7 @@ import { stageLabel } from '../dispatchContracts'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import DispatchAppointments from '../components/DispatchAppointments.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -35,7 +36,6 @@ const vehicleFields = computed(() => [
const dispatchFields = computed(() => [
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
])
const documentFields = computed(() => [
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
@@ -78,7 +78,7 @@ onBeforeUnmount(() => { generation++ })
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchAppointments :appointments="detail?.dispatchInfo.fbaAppointments || []" />
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
<section v-else-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
<h3>派送异常记录</h3>
@@ -9,7 +9,8 @@ import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { cancelTransport, getTransport } from '../api/transportTasks'
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { BeforeLoadingSection, DispatchPage, DispatchStage } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => true))
@@ -442,3 +443,34 @@ it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
wrapper.unmount()
})
it('splits driver and phone without fallback and shows authoritative appointment and shipment fields', async () => {
const row = listRow(); row.resources.driver!.name = null; row.resources.driver!.phone = '555-REAL'
row.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', batchNo: 'BATCH-1', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }]
vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const columns = wrapper.getComponent(DataTable).props('columns')
expect(columns.map(column => column.label)).toContain('司机'); expect(columns.map(column => column.label)).toContain('联系电话')
expect(columns[0]!.key).toBe('actions')
expect(wrapper.text()).toContain('555-REAL'); expect(wrapper.text()).toContain('CONF-1'); expect(wrapper.text()).toContain('SHIP-1、SHIP-2')
wrapper.unmount()
})
it.each([
['BEFORE_LOADING', 'VEHICLE_INFO'], ['BEFORE_LOADING', 'DISPATCH_REVIEW'], ['BEFORE_LOADING', 'LOADING_REVIEW'],
['LOADING', 'VEHICLE_INFO'], ['AFTER_LOADING', 'VEHICLE_INFO'], ['DELIVERING', 'VEHICLE_INFO'], ['DELIVERED', 'VEHICLE_INFO'],
] as const)('keeps actual creation time visible in %s / %s for every transport type', async (stage, section) => {
const createdAt = '2026-10-22T01:23:45Z'
const rows = (['DELIVERY', 'PICKUP', 'LOCAL_TRUCK'] as const).map(taskType => listRow({ dispatchNo: 'PC-' + taskType, taskType, stage, createdAt, plannedDepartureAt: '2026-11-01T11:00:00Z' }))
vi.mocked(listDispatches).mockResolvedValue(page(rows))
const wrapper = mount(DispatchesView, options); await flushPromises()
const state = wrapper.vm as unknown as { stage: DispatchStage; section: BeforeLoadingSection }
state.stage = stage; state.section = section; await flushPromises()
const table = wrapper.getComponent(DataTable), columns = table.props('columns')
expect(columns[0]!.key).toBe('actions')
expect(columns.find(column => column.key === 'createdAt')).toMatchObject({ label: '创建时间', slotName: 'createdAt' })
const cells = wrapper.findAll('tbody td').filter(cell => cell.text() === formatOverseasDateTime(createdAt))
expect(cells).toHaveLength(3)
expect(getDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -70,7 +70,12 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机', minWidth: 100, slotName: 'driver' },
{ key: 'driverPhone', label: '联系电话', minWidth: 140, slotName: 'driverPhone' },
{ key: 'appointmentNo', label: '预约号', minWidth: 160, slotName: 'appointmentNo' },
{ key: 'appointmentAt', label: '确认送达时间', minWidth: 170, slotName: 'appointmentAt' },
{ key: 'shipmentNumbers', label: '货件号', minWidth: 180, slotName: 'shipmentNumbers' },
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
@@ -215,10 +220,15 @@ onBeforeUnmount(() => { sequence++ })
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}</template>
<template #driverPhone="{ row }">{{ row.resources.driver?.phone || '—' }}</template>
<template #appointmentNo="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.appointmentNo || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
<template #appointmentAt="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ time(item.confirmedAt) }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
<template #shipmentNumbers="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.shipmentNumbers?.join('、') || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
<template #createdAt="{ row }">{{ time(row.createdAt) }}</template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable></div>
</ListPageLayout></AdminPage>
@@ -1282,11 +1282,17 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
})
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const wrapper = await mountView()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('width')).toContain('720px')
expect(dialog.props('width')).toBe('min(1160px, calc(100vw - 48px))')
expect(dialog.findComponent({ name: 'ElDialog' }).props('top')).toBe('6vh')
expect(dialog.get('[aria-label="入库来源"] h3').text()).toContain('来源摘要')
expect(dialog.get('[aria-label="入库计划登记"] h3').text()).toBe('计划设置')
const sourceRows = dialog.findAll('.inbound-plan-source tbody tr')
expect(sourceRows[0].get('td').attributes('colspan')).toBe('3')
expect(sourceRows[1].findAll('th').map(cell => cell.text())).toEqual(['计划入库仓', '入仓码'])
expect(dialog.props('confirmText')).toBe('保存并生成入库单')
expect(dialog.props('title')).toContain('LMO-260827-0001')
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
await dialog.get('textarea').setValue('全屏后保留')
@@ -1297,9 +1303,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const wrapper = await mountView()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
await dialog.get('textarea').setValue('保留入库备注')
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
@@ -1319,26 +1319,28 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<div class="inbound-plan-content">
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
@@ -1364,6 +1366,14 @@ onMounted(() => { void load() })
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
.inbound-plan-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
.inbound-plan-form { margin-top: 0; }
.inbound-plan-source__label { width: 112px; }
.inbound-plan-source td { overflow-wrap: anywhere; }
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
.inbound-plan-form :deep(.el-select) { width: 100%; }
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
.manual-nodes-editor th { width: 180px; }
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { min-width: 0; }
@@ -262,7 +262,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'vehicle: null, driver: null']) expect(dialog).toContain(symbol)
expect(dialog).not.toContain('调整已有排车货物')
expect(dialog).not.toContain('SmartDispatchExistingCargo')
// A filtered candidate must still submit its complete server-owned selection group.
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')