feat(admin): 排车默认发布散板需求并直接确认装车

This commit is contained in:
2026-10-21 18:51:10 +08:00
parent 7c43fcacc4
commit a4cb6c3757
18 changed files with 216 additions and 36 deletions
@@ -82,3 +82,24 @@ it('allows saving photos while loading and keeps the loading stage', async () =>
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('可边装车边上传'); wrapper.unmount()
})
it('renders the authenticated saved photo and fullscreen preview for a read-only Admin', async () => {
can.mockReturnValue(false)
const originalURL = URL
const create = vi.fn(() => 'blob:admin-loading-photo'), revoke = vi.fn()
vi.stubGlobal('URL', class extends originalURL { static createObjectURL = create; static revokeObjectURL = revoke })
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING', photos: [photo] }))
const image = new Blob(['verified image'], { type: 'image/png' })
vi.mocked(api.readLoadingPhoto).mockResolvedValue(image)
const wrapper = setup()
try {
await flushPromises()
expect(api.readLoadingPhoto).toHaveBeenCalledWith('PC/1', photo)
expect(create).toHaveBeenCalledWith(image)
const preview = wrapper.findComponent({ name: 'ElImage' })
expect(preview.props('src')).toBe('blob:admin-loading-photo')
expect(preview.props('previewSrcList')).toEqual(['blob:admin-loading-photo'])
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
} finally { wrapper.unmount(); vi.stubGlobal('URL', originalURL) }
expect(revoke.mock.calls[0]?.[0]).toBe('blob:admin-loading-photo')
})
@@ -85,7 +85,7 @@ onMounted(() => {
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
if (!corrupted.value) void load()
})
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(URL.revokeObjectURL) })
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
defineExpose({ busy })
</script>
<template>
@@ -64,7 +64,7 @@ describe('dispatch workflow native layout', () => {
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
})
it('saves all three resource photos before submitting review with the refreshed version', async () => {
const { dialog } = await render('EDIT')
@@ -212,14 +212,10 @@ describe('dispatch workflow native layout', () => {
})
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
const { dialog } = await render('LOADING_COMPLETE')
const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' })
expect(numbers[1]!.props('modelValue')).toBeNull()
expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled()
numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises()
expect(dialog.get('[aria-label="确认装车完成"]').findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', remark: '真实备注' })
})
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
let resolve!: (value: DispatchEvidenceFile) => void
@@ -12,24 +12,24 @@ 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', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<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 }
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): 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; publishLooseboardDemand: boolean }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('keeps order allocation references out of actual measurements and submits native version zero only after real input', async () => {
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
const { wrapper, state } = await open('LOADING_COMPLETE')
expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
expect(wrapper.find('[aria-label="确认装车完成"] input').exists()).toBe(false)
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest' }))
expect(vi.mocked(api.completeDispatchLoading).mock.calls[0]![1]).not.toHaveProperty('measurements')
expect(api.completeDispatchLoading).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
@@ -277,3 +277,14 @@ it('recovers an unknown review submission without saving the vehicle again', asy
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
})
it('persists the explicit publication choice with the existing vehicle save', async () => {
const { wrapper, state } = await open('EDIT')
expect(state.form.publishLooseboardDemand).toBe(true)
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('发布公共散板需求')
state.form.publishLooseboardDemand = false
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
wrapper.unmount()
})
@@ -40,7 +40,7 @@ const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds |
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.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: '' })
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
@@ -70,7 +70,7 @@ async function load() {
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
Object.assign(form, { publishLooseboardDemand: value.publishLooseboardDemand, plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
if (currentOperation.value === 'ROUTE') {
const data = await api.getDispatchRoute(no)
if (sequence !== generation) return
@@ -105,7 +105,7 @@ function payload(): object {
case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, 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('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
@@ -114,8 +114,8 @@ function payload(): object {
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
if (!measurements.value.length || measurements.value.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new DispatchInputError('托盘卸货站尚未匹配,请调度核对路线')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
}
case 'SUBMIT_REVIEW': return { ...common, remark: null }
default: return { ...common, remark: form.remark || null }
@@ -229,6 +229,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
<template v-else-if="vehicleEditor">
<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>
<el-form-item v-if="detail?.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">发布公共散板需求</el-checkbox></el-form-item>
<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>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
@@ -238,7 +239,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
</template>
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
@@ -174,3 +174,15 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ cargo: [source().selection] }) })] }))
} finally { wrapper.unmount() }
})
it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => {
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; form: { publishLooseboardDemand: boolean } }
state.restoreDraft('server-token'); await flushPromises()
expect(state.form.publishLooseboardDemand).toBe(true)
state.form.publishLooseboardDemand = false
await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] }))
wrapper.unmount()
})
@@ -44,10 +44,10 @@ const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string } }>>({})
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string; publishLooseboardDemand: boolean } }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } }
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
@@ -164,7 +164,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
return { publishLooseboardDemand: config?.form.publishLooseboardDemand ?? true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value || pending.value) return false
@@ -303,6 +303,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" 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 || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)">发布公共散板需求</el-checkbox></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
</section>