修复订单货柜预约与排车维护界面
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import * as api from './dispatches'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
|
||||
import type { CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
@@ -16,6 +17,19 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
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 })
|
||||
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')
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
|
||||
const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
|
||||
const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { appointmentFormError, appointmentPayload, createAppointmentForm } from './appointmentForm'
|
||||
|
||||
it('allows an undated arrangement without inventing confirmation or loading facts', () => {
|
||||
it('requires an existing external confirmation for a new platform registration', () => {
|
||||
const form = createAppointmentForm()
|
||||
expect(appointmentFormError(form)).toContain('外部约号与确认时间')
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'SUCCESS' })
|
||||
form.confirmationNo = 'EXT-CONFIRMED'
|
||||
form.appointmentAt = '2026-10-25T09:30:00'
|
||||
expect(appointmentFormError(form)).toBeNull()
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'PENDING' })
|
||||
expect(appointmentPayload(form)).not.toHaveProperty('expectedLoadingAt')
|
||||
})
|
||||
it('retains an already confirmed repeated local time using its stored UTC instant', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AppointmentForm {
|
||||
}
|
||||
export function createAppointmentForm(detail?: FbaAppointmentDto): AppointmentForm {
|
||||
return { plannedDate: detail?.plannedDate || '', plannedAt: detail?.plannedAt || '', plannedEndAt: detail?.plannedEndAt || '', confirmationNo: detail?.confirmationNo || '',
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'PENDING') as AppointmentForm['appointmentStatus'],
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'SUCCESS') as AppointmentForm['appointmentStatus'],
|
||||
appointmentRemark: detail?.appointmentRemark || '', startOffset: confirmedOffset(detail?.appointmentAt, detail?.appointmentStartUtc), endOffset: confirmedOffset(detail?.appointmentEndAt, detail?.appointmentEndUtc) }
|
||||
}
|
||||
function confirmedOffset(local?: string | null, utc?: string | null): string {
|
||||
|
||||
@@ -27,6 +27,8 @@ it('renders authenticated photos above their caption and releases thumbnails on
|
||||
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
|
||||
const figure = wrapper.get('figure')
|
||||
expect(figure.get('img').attributes('src')).toBe('blob:authenticated')
|
||||
expect(wrapper.get('.dispatch-evidence').attributes('data-tone')).toBeUndefined()
|
||||
expect(figure.get('.evidence-photo-name').text()).toBe('本车证据.png')
|
||||
expect(figure.get('figcaption').text()).toBe('车辆图片')
|
||||
expect(figure.element.lastElementChild?.tagName).toBe('FIGCAPTION')
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
|
||||
@@ -119,13 +119,14 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.ma
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<section class="dispatch-evidence density-dialog-section" :data-tone="photoCard ? undefined : 'source'" :class="{ 'dispatch-evidence--readonly': readonly, 'dispatch-evidence--photos': photoCard }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<figure v-if="photoCard" class="evidence-photo-card">
|
||||
<div v-if="allIds.length" class="evidence-photo-list">
|
||||
<div v-for="id in allIds" :key="id">
|
||||
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
<p class="density-dialog-note evidence-photo-name">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="evidence-photo-empty">暂无照片</div>
|
||||
@@ -135,7 +136,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
</template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button class="evidence-upload-button" type="primary" plain :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewBusy" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="evidence-preview" :aria-busy="previewBusy">
|
||||
<LoadErrorState v-if="imageFailure" title="图片无法预览" :message="imageFailure" compact retry-text="重新预览" @retry="preview(previewId)" />
|
||||
@@ -150,8 +151,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.dispatch-evidence--photos { min-width: 0; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; }
|
||||
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
|
||||
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.evidence-photo-actions { justify-content: center; }
|
||||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
@@ -3,11 +3,39 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { resources } from '../dispatchTestFixtures'
|
||||
import { listDrivers, listVehicles } from '@/modules/overseas-master-data'
|
||||
import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous master snapshot fields read-only and uses a dash for missing facts (%s)', async (carrierName, phone) => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.driver.mode = 'MASTER'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const labels = wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))
|
||||
expect(labels).not.toContain('拖车公司名称'); expect(labels).not.toContain('联系电话'); wrapper.unmount()
|
||||
})
|
||||
it('shows the newly selected authorized vehicle company without copying it into the master command', async () => {
|
||||
const candidate: VehicleDetail = { id: 'vehicle-2', plateNumber: 'CA-200', model: '厢式货车', driverOrDispatchCompany: '新承运公司', phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValueOnce({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string): void }
|
||||
await state.searchVehicles(); state.selectVehicle('vehicle-2')
|
||||
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
|
||||
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
|
||||
await wrapper.setProps({ vehicle: selected })
|
||||
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
|
||||
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
@@ -8,6 +8,9 @@ const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
|
||||
@@ -41,7 +44,9 @@ function selectDriver(id: string) {
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
|
||||
import type { DeliveryReference } from '../dispatchContracts'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
|
||||
const reference: DeliveryReference = { groupKey: 'group-a', customerId: 'C1', customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DL-1', status: 'CREATED', packageCount: 2, palletCount: 1, destination: { key: 'WAREHOUSE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null } }
|
||||
function result(): DeliveryOrderDetail {
|
||||
return { order: { id: '11', version: 0, allowedActions: [], customerId: 'C1', deliveryNo: 'DL-1', outboundNo: 'OUT-1', dispatchNo: 'PC/1', status: 'CREATED', palletCount: 1, packageCount: 2, signedPackages: null, createdAt: '2026-10-21T10:00:00Z' }, skus: [], operations: [], fees: [{ id: '21', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 28, voidStatus: 'CONFIRMED' }] }
|
||||
}
|
||||
function render(deliveries = [reference]) { return mount(DispatchVehicleMaintenanceFees, { props: { dispatchNo: 'PC/1', deliveries }, global: { plugins: [ElementPlus] } }) }
|
||||
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(getDeliveryOrder).mockResolvedValue(result()) })
|
||||
|
||||
it('reads official fees once per customer and delivery and preserves their currency and status', async () => {
|
||||
const wrapper = render([reference, reference]); await flushPromises()
|
||||
expect(getDeliveryOrder).toHaveBeenCalledExactlyOnceWith('DL-1', 'C1')
|
||||
expect(wrapper.text()).toContain('派送费用'); expect(wrapper.text()).toContain('USD'); expect(wrapper.text()).toContain('28'); expect(wrapper.text()).toContain('已作废')
|
||||
expect(wrapper.find('input').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('does not request or invent fees before a formal delivery exists', async () => {
|
||||
const wrapper = render([]); await flushPromises()
|
||||
expect(getDeliveryOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('尚未生成派送单费用'); wrapper.unmount()
|
||||
})
|
||||
it('preserves the existing delivery-list read permission', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDeliveryOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('暂无派送单费用查看权限'); wrapper.unmount()
|
||||
})
|
||||
it('rejects a fee response belonging to another dispatch and keeps failures separate from empty records', async () => {
|
||||
const wrong = result(); wrong.order.dispatchNo = 'PC/OTHER'
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValueOnce(wrong)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('费用读取失败'); expect(wrapper.text()).not.toContain('暂无正式费用记录'); expect(wrapper.text()).not.toContain('USD')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a late response from the previous dispatch', async () => {
|
||||
let resolve!: (value: DeliveryOrderDetail) => void
|
||||
vi.mocked(getDeliveryOrder).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2', deliveries: [] })
|
||||
resolve(result()); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未生成派送单费用'); expect(wrapper.text()).not.toContain('USD'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDeliveryOrder, type DeliveryFeeRow } from '../api/deliveryOrders'
|
||||
import type { DeliveryReference } from '../dispatchContracts'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; deliveries: DeliveryReference[] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const allowed = computed(() => access.can(permission.deliveryList))
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fees = ref<Array<DeliveryFeeRow & { deliveryNo: string; customerName: string }>>([])
|
||||
const labels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
|
||||
let generation = 0
|
||||
/** 只读本车正式关联派送单费用,不把未生成或读取失败展示成零费。 */
|
||||
async function load() {
|
||||
const current = ++generation, number = props.dispatchNo
|
||||
fees.value = []; error.value = null; loading.value = false
|
||||
if (!allowed.value || !props.deliveries.length) return
|
||||
loading.value = true
|
||||
try {
|
||||
const references = [...new Map(props.deliveries.map(row => [`${row.customerId}:${row.deliveryNo}`, row])).values()]
|
||||
const rows = await Promise.all(references.map(async row => {
|
||||
const value = await getDeliveryOrder(row.deliveryNo, row.customerId)
|
||||
if (value.order.deliveryNo !== row.deliveryNo || value.order.customerId !== row.customerId || value.order.dispatchNo !== number) throw new Error('费用返回的派送单与本车关联不一致')
|
||||
return value.fees.map(fee => ({ ...fee, deliveryNo: row.deliveryNo, customerName: row.customerName }))
|
||||
}))
|
||||
if (current === generation) fees.value = rows.flat()
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '正式费用读取失败,请重试') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function status(fee: DeliveryFeeRow) {
|
||||
if (fee.voidStatus === 'CONFIRMED') return '已作废'
|
||||
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
|
||||
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
|
||||
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
|
||||
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
|
||||
}
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.deliveries, allowed.value]), load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<p v-if="!deliveries.length" class="density-dialog-note">尚未生成派送单费用。</p>
|
||||
<p v-else-if="!allowed" class="density-dialog-note">暂无派送单费用查看权限。</p>
|
||||
<LoadErrorState v-else-if="error" title="费用读取失败" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-else class="density-table"><thead><tr><th>客户</th><th>派送单号</th><th>费用项目</th><th>币种</th><th>金额</th><th>费用状态</th></tr></thead><tbody><tr v-for="fee in fees" :key="`${fee.deliveryNo}-${fee.id}`"><td>{{ fee.customerName || '—' }}</td><td>{{ fee.deliveryNo }}</td><td>{{ labels[fee.feeType] || fee.feeType || '—' }}</td><td>{{ fee.currency || '—' }}</td><td>{{ fee.amount ?? '—' }}</td><td>{{ status(fee) }}</td></tr><tr v-if="!loading && !fees.length"><td colspan="6">暂无正式费用记录</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import type { DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
|
||||
defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
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 = ['一提一卸', '一提多卸']
|
||||
const loadTypes = ['托盘', '地板', '混装']
|
||||
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs maintenance-tabs">
|
||||
<el-tab-pane label="排车信息" name="info">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
<h3 class="density-dialog-section__title">排车时间</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="派送补充资料">
|
||||
<h3 class="density-dialog-section__title">派送信息</h3>
|
||||
<div class="maintenance-delivery-fields">
|
||||
<el-form-item label="预派送日期"><el-date-picker v-model="maintenance.plannedDeliveryDate" type="date" value-format="YYYY-MM-DD" :disabled="disabled" placeholder="请选择预派送日期" /></el-form-item>
|
||||
<el-form-item label="派送方式"><el-select v-model="maintenance.deliveryMethod" :disabled="disabled" clearable placeholder="请选择派送方式"><el-option v-for="value in deliveryMethods" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式"><el-select v-model="maintenance.pickupMethod" :disabled="disabled" clearable placeholder="请选择提货方式"><el-option v-for="value in pickupMethods" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="装车类型"><el-select v-model="maintenance.loadType" :disabled="disabled" clearable placeholder="请选择装车类型"><el-option v-for="value in loadTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="车队邮箱"><el-input v-model="maintenance.fleetEmail" :disabled="disabled" maxlength="254" placeholder="请输入车队邮箱" /></el-form-item>
|
||||
<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>
|
||||
<el-form-item label="预约号"><el-input v-model="maintenance.appointmentNo" :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="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>已确认预约号</th><th>确认送达时间</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
|
||||
<section class="density-dialog-section" aria-label="车辆维护附件"><h3 class="density-dialog-section__title">附件</h3><slot name="attachments" /></section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
|
||||
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-table { table-layout: fixed; }
|
||||
.maintenance-table td { width: 50%; }
|
||||
.maintenance-delivery-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-4); }
|
||||
.maintenance-delivery-fields > * { min-width: 0; }
|
||||
.maintenance-delivery-fields :deep(.el-date-editor), .maintenance-table :deep(.el-date-editor) { width: 100%; }
|
||||
.maintenance-orders { overflow-x: auto; }
|
||||
.maintenance-orders table { min-width: 1000px; }
|
||||
</style>
|
||||
@@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, loadingPreview, resources } from '../dispatchTestFixtures'
|
||||
import type { DispatchAction, DispatchActions, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
|
||||
import type { DispatchAction, DispatchActions, DispatchDetail, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
@@ -43,8 +44,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
|
||||
})
|
||||
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
|
||||
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
async function render(action: DispatchAction, operation?: 'ROUTE', files = false, overrides: Partial<DispatchDetail> = {}) {
|
||||
const initial = detail({ actions, ...overrides }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
|
||||
@@ -52,6 +53,20 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => {
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-SAVED' }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved } })
|
||||
expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test')
|
||||
expect((field(dialog, '预约号').get('input').element as HTMLInputElement).value).toBe('APPT-SAVED')
|
||||
expect(dialog.text()).toContain('关联订单 (1)')
|
||||
const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!
|
||||
await orders.trigger('click')
|
||||
expect(dialog.get('.maintenance-orders').text()).toContain('LM-1')
|
||||
expect(dialog.get('.maintenance-orders').findAll('button, input')).toHaveLength(0)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -71,11 +86,17 @@ describe('dispatch workflow native layout', () => {
|
||||
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||
field(dialog, '预派送日期').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02')
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
await field(dialog, '预约号').get('input').setValue(' APPT-NEW ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
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: '新司机' }, 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: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', appointmentNo: 'APPT-NEW' }, 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')
|
||||
@@ -179,6 +200,7 @@ describe('dispatch workflow native layout', () => {
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
|
||||
const { wrapper, dialog } = await render(action)
|
||||
const value = detail({ actions })
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['401']
|
||||
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
|
||||
@@ -186,6 +208,9 @@ describe('dispatch workflow native layout', () => {
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
|
||||
expect(fields[0]!.props('modelValue')).toEqual(['401'])
|
||||
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
|
||||
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
|
||||
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -42,7 +44,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: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
@@ -67,6 +69,7 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
@@ -107,7 +110,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, 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, 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('驳回时必须填写原因')
|
||||
@@ -231,9 +234,16 @@ 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">
|
||||
<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>
|
||||
<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">
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
@@ -248,12 +258,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="vehicleEditor" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
<div v-else-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<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 v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="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>
|
||||
@@ -274,8 +279,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-editor-table { table-layout: fixed; }
|
||||
.workflow-editor-table td { width: 50%; }
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import type { AppointmentForm } from '../appointmentForm'
|
||||
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
|
||||
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
|
||||
watchEffect(() => {
|
||||
if (!props.disabled && ['PENDING', 'FAILED'].includes(props.model.appointmentStatus)) props.model.appointmentStatus = 'SUCCESS'
|
||||
})
|
||||
|
||||
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
|
||||
</script>
|
||||
<template>
|
||||
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
|
||||
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
|
||||
<div v-if="continuous" class="density-dialog-sections">
|
||||
<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>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" 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"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="140px" :disabled="disabled"><table class="density-table density-table--editable appointment-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
|
||||
@@ -32,7 +36,7 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
|
||||
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
||||
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
||||
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
|
||||
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DispatchVehicleMaintenance } from './dispatchVehicleMaintenance'
|
||||
// Models after the dispatch API boundary: Long quantities are validated safe numbers.
|
||||
// dispatchWire.ts declares their HTTP string/number representation; IDs/versions remain lossless.
|
||||
export type Version = string | number
|
||||
@@ -35,11 +36,11 @@ export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXC
|
||||
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
|
||||
export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] }
|
||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean }
|
||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources & { maintenance?: DispatchVehicleMaintenance | null }; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean }
|
||||
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 { 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; publishLooseboardDemand: boolean }
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface DispatchVehicleMaintenance {
|
||||
plannedDeliveryDate: string | null
|
||||
deliveryMethod: string | null
|
||||
pickupMethod: string | null
|
||||
loadType: string | null
|
||||
fleetEmail: string | null
|
||||
vehicleType: string | null
|
||||
appointmentAccount: string | null
|
||||
appointmentNo: string | null
|
||||
}
|
||||
|
||||
/** 本车补充资料保持未填为空,不从原型示例或实际发车时间推定。 */
|
||||
export function blankVehicleMaintenance(): DispatchVehicleMaintenance {
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, appointmentNo: null }
|
||||
}
|
||||
|
||||
/** 保存前只整理已填写资料,不据补充资料触发派送、预约或费用。 */
|
||||
export function vehicleMaintenancePayload(value: DispatchVehicleMaintenance): DispatchVehicleMaintenance {
|
||||
const text = (item: string | null) => item?.trim() || null
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), appointmentNo: text(value.appointmentNo) }
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted() { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted(this: { $emit: (event: string, value: boolean) => void }) { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
|
||||
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
|
||||
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
@@ -88,7 +88,8 @@ describe('inbound appointment native dialogs', () => {
|
||||
vi.mocked(updateFbaAppointment).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...appointment })
|
||||
const { dialog } = await openEditAppointment()
|
||||
const fields = dialog.getComponent(FbaAppointmentFields)
|
||||
fields.props('model').appointmentRemark = '交接备注'; await flushPromises()
|
||||
Object.assign(fields.props('model'), { appointmentRemark: '交接备注', confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
|
||||
fields.vm.$emit('confirmationReady', true); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
const original = vi.mocked(updateFbaAppointment).mock.calls[0]!
|
||||
|
||||
@@ -256,7 +256,7 @@ onMounted(() => { void load() })
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
||||
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields continuous v-if="appointmentOpen && !appointmentLoading" ref="appointmentFields" :sources="appointmentSources" @confirmation-ready="confirmationReady = $event" :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
||||
|
||||
Reference in New Issue
Block a user