灰度晋级:Admin 散板发布入口及累计界面修复 #22

Merged
admin merged 3 commits from release/test into main 2026-10-23 13:40:47 +08:00
11 changed files with 23 additions and 27 deletions
Showing only changes of commit 4f766a76bd - Show all commits
@@ -89,7 +89,7 @@ it('reads remaining physical cargo only after an explicit choice while retaining
})
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
})
@@ -124,7 +124,7 @@ async function save() {
ensureOwner()
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
const body = { publishLooseboardDemand: true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
const proof = await createDispatch(body); ensureOwner()
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
+1 -1
View File
@@ -20,7 +20,7 @@
## 关键边界
- 预装详情进入后先调 `/loading-plans/{planId}/lifecycle`,再按返回状态决定可操作项;不自行推断生命周期阶段。
- 托盘码格式为“真实柜号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 托盘码格式为“柜次号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 确认预装和创建 PDA 任务前必须补齐真实集装箱号;历史上已建缺号任务可在预装详情首次补录,保存后恢复 PDA 可进入状态。
- 柜级公共费用只按服务端柜内客户/订单事实分摊,创建、审核、驳回、调整、作废均整组原子,金额必须守恒;待确认时禁止调整或重复作废。
- 独立清单详情与货柜详情页签共用同一套清单 API 与详情组件;改展示或打印时同步检查 `container-loading` 的装柜清单页签。
@@ -35,7 +35,7 @@ describe('native whole-vehicle dispatch API', () => {
const saved = await api.getDispatch('PC/1')
expect(saved.vehicleInfo.maintenance).toEqual(maintenance)
expect(saved.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['9007199254740993'])
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, publishLooseboardDemand: true })
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', expect.objectContaining({ maintenance, vehicle: expect.objectContaining({ evidenceFileIds: ['9007199254740993'] }) }))
expect(vi.mocked(http.put).mock.calls[0]![1]).not.toHaveProperty('departedAt')
})
@@ -120,7 +120,7 @@ describe('native whole-vehicle dispatch API', () => {
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
})
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
const request: UpdateDispatch = { publishLooseboardDemand: true, ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
})
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { FbaAppointment } from '../dispatchContracts'
import { formatOverseasDateTime } from '../lastMilePresentation'
defineProps<{ appointments: FbaAppointment[] }>()
withDefaults(defineProps<{ appointments: FbaAppointment[]; showShipmentNumbers?: boolean }>(), { showShipmentNumbers: true })
</script>
<template>
<table class="density-table" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
<tr v-for="appointment in appointments" :key="appointment.destinationKey"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td><td>{{ appointment.shipmentNumbers?.join('、') || '—' }}</td></tr>
<tr v-if="!appointments.length"><td colspan="4">—</td></tr>
<table class="density-table" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th v-if="showShipmentNumbers">货件号</th></tr></thead><tbody>
<tr v-for="appointment in appointments" :key="appointment.destinationKey"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td><td v-if="showShipmentNumbers">{{ appointment.shipmentNumbers?.join('、') || '—' }}</td></tr>
<tr v-if="!appointments.length"><td :colspan="showShipmentNumbers ? 4 : 3">—</td></tr>
</tbody></table>
</template>
@@ -11,7 +11,6 @@ const driver = defineModel<Driver>('driver', { required: true })
const maintenance = defineModel<DispatchVehicleMaintenance>('maintenance', { required: true })
const plannedDepartureAt = defineModel<string>('plannedDepartureAt', { required: true })
const estimatedArrivalAt = defineModel<string>('estimatedArrivalAt', { required: true })
const publishLooseboardDemand = defineModel<boolean>('publishLooseboardDemand', { required: true })
const tab = ref('info')
const deliveryMethods = ['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递', '外配']
const pickupMethods = ['一提一卸', '一提多卸']
@@ -49,7 +48,6 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<el-form-item label="车辆类型"><el-select v-model="maintenance.vehicleType" :disabled="disabled" clearable placeholder="请选择车辆类型"><el-option v-for="value in vehicleTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
</div>
<el-form-item v-if="detail.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="publishLooseboardDemand" :disabled="disabled">发布公共散板需求</el-checkbox></el-form-item>
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
</tbody></table>
@@ -161,7 +161,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, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
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: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, 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')
@@ -12,7 +12,7 @@ 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; publishLooseboardDemand: boolean }; 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 }; 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()
@@ -260,14 +260,13 @@ it('allows a new save-and-review click after review submission fails',async()=>{
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('persists the explicit publication choice with the existing vehicle save', async () => {
it('keeps publication fixed when saving vehicle maintenance', async () => {
const { wrapper, state } = await open('EDIT')
expect(state.form.publishLooseboardDemand).toBe(true)
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('发布公共散板需求')
state.form.publishLooseboardDemand = false
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('发布公共散板需求')
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 }))
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0 }))
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('publishLooseboardDemand')
wrapper.unmount()
})
@@ -53,7 +53,7 @@ const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(c
const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null)
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
/** 并发加货后补入新目的仓,保留客服已填写的同仓编号。 */
function fillDestinationAppointments(value: DispatchDetail, keepInput = false) {
const entered = new Map(keepInput ? form.maintenance.destinationAppointments.map(row => [row.destinationKey, row.appointmentNo]) : [])
@@ -90,7 +90,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, { publishLooseboardDemand: value.publishLooseboardDemand, plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
Object.assign(form, { 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
@@ -125,7 +125,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, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
@@ -246,7 +246,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<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="vehicleEditor">
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed">
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
<template #attachments>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
@@ -268,7 +268,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<section class="density-dialog-section" data-tone="source" aria-label="排车与预约">
<h3 class="density-dialog-section__title">排车与预约</h3>
<table class="density-table"><tbody><tr><th scope="row">计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" />
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" :show-shipment-numbers="currentAction !== 'DISPATCH_REVIEW'" />
<el-alert v-if="approvalBlock" :title="approvalBlock" type="warning" :closable="false" show-icon />
</section>
</template>
@@ -284,7 +284,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div>
</section>
<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">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_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="备注"><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>
@@ -40,7 +40,7 @@ export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'P
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[]; publishLooseboardDemand: boolean }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
export interface UpdateDispatch extends VersionCommand { maintenance?: DispatchVehicleMaintenance | null; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; publishLooseboardDemand: boolean }
export interface UpdateDispatch extends VersionCommand { maintenance?: DispatchVehicleMaintenance | null; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface ReasonCommand extends VersionCommand { reason: string }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
+1 -2
View File
@@ -13,13 +13,12 @@
| 拖车预约列表、状态与筛选 | `pages/TruckAppointmentsView.vue`;`trucking` | `api/platformTrucking.ts`;`firstleg/service/TruckAppointmentService.java` |
| 新建拖车预约、订舱候选 | `pages/TruckAppointmentCreateView.vue`、`components/TruckAppointmentForm.vue`;`trucking/new` | `api/platformTrucking.ts` 的 `/candidates` |
| 预约详情、附件与承运商 | `pages/TruckAppointmentDetailView.vue`、`components/TruckAttachmentDialog.vue`;`trucking/:id` | `api/platformTrucking.ts`;附件 `file/` |
| 集装箱号校验 | `iso6346.ts` | 本地格式校验,服务端仍复核 |
## 关键边界
- 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商;不选择或提交客户、柜次、装柜计划或真实集装箱号。
- 一个订舱最多一张非终态拖车单;重复创建时转入现有单继续编辑,不新建第二张。
- 提单号和仓库由订舱冻结事实带入,表单不自行拼接或改写。
- 提单号和仓库由订舱冻结事实带入,表单不自行拼接或改写;创建及派车不要求上传订舱文件。
- PDA 完成装柜不自动推进拖车状态;拖车生命周期与 PDA 任务解耦。
- 附件为平台作用域,不重复上传提单文件;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护。