修复采购审核竞态并整理管理页面展示

This commit is contained in:
2026-10-30 15:42:37 +08:00
parent 8cc8b4fb94
commit 4abd6ffd5d
23 changed files with 791 additions and 75 deletions
@@ -4,6 +4,7 @@ import { computed, ref, watch } 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'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
@@ -66,7 +67,7 @@ function selectDriver(id: string) {
<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>
<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="包括返程或转场" /><DispatchTimeZonePreview :value="driver.evidence.expectedTaskEndAt" label="预计任务结束" /></el-form-item></td></tr>
</tbody></table>
</template>
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
it.each([
['2026-07-01T10:00:00Z', '2026-07-01 06:00:00', '2026-07-01 03:00:00'],
['2026-12-01T10:00:00Z', '2026-12-01 05:00:00', '2026-12-01 02:00:00'],
['2026-07-01T08:00:00+08:00', '2026-06-30 20:00:00', '2026-06-30 17:00:00'],
['2026-11-01T05:30:00Z', '2026-11-01 01:30:00', '2026-10-31 22:30:00'],
['2026-11-01T06:30:00Z', '2026-11-01 01:30:00', '2026-10-31 23:30:00'],
])('converts the same instant with regional daylight saving rules: %s', (value, eastern, pacific) => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '计划发车时间' } })
expect(wrapper.attributes('aria-label')).toBe('计划发车时间时区换算')
expect(wrapper.findAll('dt').map(item => item.text())).toEqual(['美东', '美西'])
expect(wrapper.findAll('dd').map(item => item.text())).toEqual([eastern, pacific])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each([null, '', 'invalid', '2026-07-01T10:00:00', '2026-07-01'])('does not invent an instant for an empty or unzoned value: %s', value => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '发车时间' } })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
wrapper.unmount()
})
it('updates and clears both previews without emitting a changed input', async () => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value: '2026-07-01T10:00:00Z', label: '预计到达时间' } })
await wrapper.setProps({ value: '2026-12-01T10:00:00Z' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['2026-12-01 05:00:00', '2026-12-01 02:00:00'])
await wrapper.setProps({ value: '' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
expect(wrapper.emitted()).toEqual({})
wrapper.unmount()
})
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value?: string | null; label: string }>()
const zones = [
{ label: '美东', timeZone: 'America/New_York' },
{ label: '美西', timeZone: 'America/Los_Angeles' },
]
// 只换算带时差的同一时间点,地区时区负责夏令时;不回写原始录入值。
const times = computed(() => {
const instant = props.value && /(?:Z|[+-]\d{2}:\d{2})$/i.test(props.value) ? Date.parse(props.value) : NaN
return zones.map(zone => {
if (!Number.isFinite(instant)) return { label: zone.label, value: '—' }
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
timeZone: zone.timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(instant).map(part => [part.type, part.value]))
return { label: zone.label, value: `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` }
})
})
</script>
<template>
<dl class="dispatch-time-zone-preview" :aria-label="`${label}时区换算`">
<div v-for="time in times" :key="time.label"><dt>{{ time.label }}</dt><dd>{{ time.value }}</dd></div>
</dl>
</template>
<style scoped>
.dispatch-time-zone-preview { display: flex; flex: 0 0 100%; flex-wrap: wrap; gap: 2px 12px; width: 100%; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.dispatch-time-zone-preview > div { display: flex; flex-wrap: wrap; gap: 0 6px; }
.dispatch-time-zone-preview dt, .dispatch-time-zone-preview dd { margin: 0; white-space: nowrap; }
</style>
@@ -4,6 +4,7 @@ import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
import { formatOverseasDateTime } from '../lastMilePresentation'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
@@ -18,9 +19,9 @@ const loadTypes = ['托盘', '地板', '混装']
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
function confirmedTime(appointment: DispatchDestinationAppointment) {
function confirmedAt(appointment: DispatchDestinationAppointment) {
const fact = appointmentFacts.value.get(appointment.destinationKey)
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null)
return fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null
}
</script>
@@ -30,12 +31,12 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<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>
<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 /><DispatchTimeZonePreview :value="detail.dispatchInfo.departedAt" label="发车时间" /></el-form-item></td></tr></tbody></table>
<DispatchResourceFields :dispatch-no="detail.dispatchNo" 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>
<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="请选择计划发车时间" /><DispatchTimeZonePreview :value="plannedDepartureAt" label="计划发车时间" /></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="请选择预计到达时间" /><DispatchTimeZonePreview :value="estimatedArrivalAt" label="预计到达时间" /></el-form-item></td></tr></tbody></table>
</section>
<section class="density-dialog-section" aria-label="派送补充资料">
<h3 class="density-dialog-section__title">派送信息</h3>
@@ -49,7 +50,7 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
</div>
<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>
<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>{{ formatOverseasDateTime(confirmedAt(appointment)) }}<DispatchTimeZonePreview v-if="confirmedAt(appointment)" :value="confirmedAt(appointment)" label="确认送达时间" /></td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</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>
@@ -88,6 +88,8 @@ describe('dispatch workflow native layout', () => {
expect(table.findAll('input')).toHaveLength(1)
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美东2026-10-25 13:00:00')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美西2026-10-25 10:00:00')
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
await table.get('input').setValue('ISA-CHANGED')
expect(table.findAll('tbody td')[2]!.text()).toBe('—')
@@ -144,6 +146,26 @@ describe('dispatch workflow native layout', () => {
await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises()
expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' }))
})
it('shows eastern and pacific previews for maintenance times and saves the original offset values', async () => {
const source = detail()
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo,
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
} })
expect(field(dialog, '发车时间').get('[aria-label="发车时间时区换算"]').text()).toBe('美东2026-07-01 11:00:00美西2026-07-01 08:00:00')
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-07-02T08:00:00+08:00')
await flushPromises()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 20:00:00美西2026-07-01 17:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
plannedDepartureAt: '2026-07-02T08:00:00+08:00', estimatedArrivalAt: '2026-07-01T17:00:00Z',
driver: expect.objectContaining({ evidence: expect.objectContaining({ expectedTaskEndAt: '2026-11-01T10:00:00Z' }) }),
}))
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
})
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆 · PC/1')