feat(admin): allow late container number entry
This commit is contained in:
@@ -35,7 +35,7 @@ function detail(id: string) {
|
||||
containers: id === 'booking-1'
|
||||
? [
|
||||
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
|
||||
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
|
||||
{ containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, containerType: '40HC' },
|
||||
]
|
||||
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
|
||||
}
|
||||
@@ -48,7 +48,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
||||
})
|
||||
|
||||
it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => {
|
||||
it('selects a released booking, exposes pending-pickup internal containers, and never emits customer fields', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
@@ -58,7 +58,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
expect(getOceanBooking).toHaveBeenCalledWith('booking-1')
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('CAB-X'))).toBe(false)
|
||||
expect(labels.some((label) => label.includes('CAB-X') && label.includes('40HC') && label.includes('待补录'))).toBe(true)
|
||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||
expect(emitted.oceanBookingId).toBe('booking-1')
|
||||
expect(emitted.billOfLadingNo).toBe('BL-1')
|
||||
|
||||
@@ -6,22 +6,23 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/compone
|
||||
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('requires a released booking before a real-numbered allocated container', () => {
|
||||
it('requires a released booking before an allocated internal container', () => {
|
||||
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
|
||||
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
|
||||
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'")
|
||||
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"'))
|
||||
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次'")
|
||||
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次"'))
|
||||
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
|
||||
expect(source).not.toContain('关联订舱(可选)')
|
||||
expect(source).not.toContain('关联柜次(可选)')
|
||||
})
|
||||
|
||||
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
|
||||
it('loads every container allocated to the selected booking without requiring a physical number', () => {
|
||||
expect(source).toContain('getOceanBooking(bookingId)')
|
||||
expect(source).toContain('containers.value = booking.containers || []')
|
||||
expect(source).toContain('bookingDocuments.value = booking.documents || []')
|
||||
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)')
|
||||
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次')
|
||||
expect(source).toContain('const selectableContainers = computed(() => containers.value)')
|
||||
expect(source).toContain('该订舱暂无已分配柜次')
|
||||
expect(source).not.toContain('isIso6346ContainerNumber')
|
||||
expect(source).not.toContain('listContainers')
|
||||
})
|
||||
|
||||
@@ -54,9 +55,10 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain('请选择有效的合作中拖车供应商')
|
||||
})
|
||||
|
||||
it('labels the generated cabinet reference and physical container number separately', () => {
|
||||
it('labels the internal cabinet, type and pickup-time physical number separately', () => {
|
||||
expect(source).toContain('柜次号:${item.cabinetNo')
|
||||
expect(source).toContain('集装箱号:${item.containerNo}')
|
||||
expect(source).toContain('柜型:${item.containerType')
|
||||
expect(source).toContain("集装箱号:${item.containerNo || '待补录'}")
|
||||
})
|
||||
|
||||
it('keeps the form usable at 390px', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
@@ -37,7 +36,7 @@ const rules: FormRules = {
|
||||
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
||||
}, trigger: 'change' }],
|
||||
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
|
||||
containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的已分配柜次')), trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||||
@@ -54,14 +53,17 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
)))
|
||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
|
||||
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)))
|
||||
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo)))
|
||||
const selectableContainers = computed(() => containers.value)
|
||||
function containerOptionLabel(item: OceanBookingAllocatedContainer) {
|
||||
return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}`
|
||||
}
|
||||
const selectedContainerFallback = computed(() => {
|
||||
const id = draft.containerBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||||
return {
|
||||
id: String(id),
|
||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
|
||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}`,
|
||||
}
|
||||
})
|
||||
const selectedBookingFallback = computed(() => {
|
||||
@@ -154,13 +156,13 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已分配的内部柜次;真实集装箱号可待司机提柜时录入,仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
|
||||
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
|
||||
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择已分配柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已分配柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="containerOptionLabel(item)" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱的内部柜次可以预约拖车。</div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
|
||||
Reference in New Issue
Block a user