This commit is contained in:
2026-09-01 15:43:19 +08:00
parent 0b676ddb74
commit c59608ff2c
5 changed files with 172 additions and 43 deletions
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking' import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentCandidates, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data })) vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
@@ -13,6 +13,15 @@ describe('truck appointment API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } }) expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } })
}) })
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 13, containerType: '40HC', requirementQuantity: '2', containerBookingId: 14, cabinetNo: 'CAB-14', containerNo: null, loadingPlanId: 15, loadingPlanNo: 'LP-15', loadingPlanStatus: 'EDITING', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
records: [{ oceanBookingId: '11', warehouseId: '12', requirementId: '13', containerBookingId: '14', loadingPlanId: '15', requirementQuantity: 2, bookingDocumentReady: true }],
total: 1,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
})
it('uses detail, create, and status contracts', async () => { it('uses detail, create, and status contracts', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } }) vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } })
vi.mocked(http.post).mockResolvedValue({ data: '9001' }) vi.mocked(http.post).mockResolvedValue({ data: '9001' })
@@ -6,6 +6,9 @@ import type {
TruckAppointmentQuery, TruckAppointmentQuery,
TruckAppointmentResource, TruckAppointmentResource,
TruckAppointmentActionRequest, TruckAppointmentActionRequest,
TruckAppointmentCandidate,
TruckAppointmentCandidatePage,
TruckAppointmentCandidateQuery,
TruckAppointmentStatusHistory, TruckAppointmentStatusHistory,
TruckAttachmentReference, TruckAttachmentReference,
TruckAttachmentType, TruckAttachmentType,
@@ -60,6 +63,41 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
return normalizePage(unwrapApiResponse(response)) return normalizePage(unwrapApiResponse(response))
} }
type CandidatePagePayload = Omit<TruckAppointmentCandidatePage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
records?: Array<TruckAppointmentCandidate & Record<string, unknown>>
page: number | string
size: number | string
total: number | string
pages?: number | string
}
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
const normalized = { ...candidate } as Record<string, unknown>
for (const key of ['oceanBookingId', 'warehouseId', 'requirementId', 'containerBookingId', 'loadingPlanId']) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
}
normalized.requirementQuantity = Number(normalized.requirementQuantity || 0)
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
return normalized as unknown as TruckAppointmentCandidate
}
export async function listTruckAppointmentCandidates(query: TruckAppointmentCandidateQuery = {}) {
const response = await http.get<ApiEnvelope<CandidatePagePayload>, ApiEnvelope<CandidatePagePayload>>(`${basePath}/candidates`, {
params: { keyword: query.keyword?.trim() || undefined, page: query.page || 1, size: query.size || 200 },
})
const payload = unwrapApiResponse(response)
const page = Number(payload.page || 1)
const size = Number(payload.size || 200)
const total = Number(payload.total || 0)
return {
records: (payload.records || []).map(normalizeCandidate),
page,
size,
total,
pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)),
} satisfies TruckAppointmentCandidatePage
}
export async function getTruckAppointment(id: string | number) { export async function getTruckAppointment(id: string | number) {
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`) const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
return normalizeResource(unwrapApiResponse(response)) return normalizeResource(unwrapApiResponse(response))
@@ -87,6 +87,36 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('ignores stale booking details when the user switches selections quickly', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
else resolveSecond = resolve as typeof resolveSecond
}) as never)
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const bookingSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]!
bookingSelect.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
bookingSelect.vm.$emit('update:modelValue', 'booking-2')
await flushPromises()
resolveSecond(detail('booking-2'))
await flushPromises()
resolveFirst({ ...detail('booking-1'), documents: [] })
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-1'))).toBe(false)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.billOfLadingNo).toBe('BL-2')
wrapper.unmount()
})
it('preserves the business code and request trace when booking readiness fails', async () => { it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking')) vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
@@ -2,9 +2,10 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue' import { Eye, Save } from '@lucide/vue'
import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types' import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
import { listSuppliers, type Supplier } from '@/modules/suppliers' import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings' import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -15,6 +16,7 @@ const relationWarning = ref('')
const bookingContainersLoading = ref(false) const bookingContainersLoading = ref(false)
const bookingContainersError = ref<ApiErrorDetails | null>(null) const bookingContainersError = ref<ApiErrorDetails | null>(null)
const bookingDocuments = ref<OceanBookingDocument[]>([]) const bookingDocuments = ref<OceanBookingDocument[]>([])
const relationLocked = computed(() => Boolean(props.resource))
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } } function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue)) const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
@@ -31,12 +33,12 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
}, trigger: 'blur' }) }, trigger: 'blur' })
const rules: FormRules = { const rules: FormRules = {
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => { oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱')) if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择仍有可预约柜次的已放舱订舱'))
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试')) if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号')) if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件')) return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
}, trigger: 'change' }], }, 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)) || (relationLocked.value && 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' }], 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' }], plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')], fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
@@ -44,33 +46,47 @@ const rules: FormRules = {
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')],
} }
const carriers = ref<Supplier[]>([]) const carriers = ref<Supplier[]>([])
const containers = ref<OceanBookingAllocatedContainer[]>([]) const candidates = ref<TruckAppointmentCandidate[]>([])
const bookings = ref<OceanBookingResource[]>([])
const optionsLoading = ref(false) const optionsLoading = ref(false)
const optionsError = ref('') const optionsError = ref('')
let bookingLoadSequence = 0
const selectableCarriers = computed(() => carriers.value.filter((item) => ( const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' item.status === 'ACTIVE'
))) )))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null) const bookings = computed(() => {
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
const selectableContainers = computed(() => containers.value) for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
function containerOptionLabel(item: OceanBookingAllocatedContainer) { id: item.oceanBookingId,
bookingNo: item.oceanBookingNo,
billOfLadingNo: item.billOfLadingNo,
warehouseId: item.warehouseId,
warehouseName: item.warehouseName,
bookingDocumentReady: item.bookingDocumentReady,
})
return [...result.values()]
})
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
const bookingDocumentReady = computed(() => relationLocked.value
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
const selectableContainers = computed(() => candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId))
function containerOptionLabel(item: TruckAppointmentCandidate) {
return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}` return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}`
} }
const selectedContainerFallback = computed(() => { const selectedContainerFallback = computed(() => {
const id = draft.containerBookingId const id = draft.containerBookingId
const snapshot = props.resource const snapshot = props.resource
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null if (!id || !relationLocked.value || String(snapshot?.containerBookingId) !== String(id)) return null
return { return {
id: String(id), id: String(id),
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}`, label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}(当前绑定,不可重选)`,
} }
}) })
const selectedBookingFallback = computed(() => { const selectedBookingFallback = computed(() => {
const id = draft.oceanBookingId const id = draft.oceanBookingId
const snapshot = props.resource const snapshot = props.resource
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' } return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
}) })
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null } function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
function selectContainer(containerId: string) { function selectContainer(containerId: string) {
@@ -81,48 +97,37 @@ function selectContainer(containerId: string) {
draft.loadingPlanId = null draft.loadingPlanId = null
return return
} }
draft.loadingPlanId = null draft.loadingPlanId = item.loadingPlanId
} }
async function loadBookingContainers(bookingId: string) { async function loadCurrentBookingContext(bookingId: string) {
const sequence = ++bookingLoadSequence
bookingContainersLoading.value = true bookingContainersLoading.value = true
bookingContainersError.value = null bookingContainersError.value = null
try { try {
const booking = await getOceanBooking(bookingId) const booking = await getOceanBooking(bookingId)
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
if (booking.status !== 'RELEASED') { if (booking.status !== 'RELEASED') {
containers.value = []
bookingDocuments.value = [] bookingDocuments.value = []
draft.containerBookingId = null bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
draft.loadingPlanId = null
bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' }
return return
} }
const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id)
if (bookingIndex >= 0) bookings.value[bookingIndex] = booking
else bookings.value.push(booking)
draft.warehouseId = booking.warehouseId draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo draft.billOfLadingNo = booking.billOfLadingNo
containers.value = booking.containers || []
bookingDocuments.value = booking.documents || [] bookingDocuments.value = booking.documents || []
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
draft.containerBookingId = null
draft.loadingPlanId = null
}
} catch (cause) { } catch (cause) {
containers.value = [] if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
bookingDocuments.value = [] bookingDocuments.value = []
draft.containerBookingId = null bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
draft.loadingPlanId = null
bookingContainersError.value = getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')
} finally { } finally {
bookingContainersLoading.value = false if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
} }
} }
async function selectBooking(bookingId: string) { async function selectBooking(bookingId: string) {
bookingLoadSequence += 1
const item = bookings.value.find((entry) => entry.id === bookingId) const item = bookings.value.find((entry) => entry.id === bookingId)
draft.oceanBookingId = item?.id || null draft.oceanBookingId = item?.id || null
draft.containerBookingId = null draft.containerBookingId = null
draft.loadingPlanId = null draft.loadingPlanId = null
containers.value = []
bookingDocuments.value = [] bookingDocuments.value = []
relationWarning.value = '' relationWarning.value = ''
if (!item) { if (!item) {
@@ -132,26 +137,39 @@ async function selectBooking(bookingId: string) {
} }
draft.warehouseId = item.warehouseId draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo draft.billOfLadingNo = item.billOfLadingNo
await loadBookingContainers(item.id)
} }
async function loadOptions() { async function loadOptions() {
optionsLoading.value = true; optionsError.value = '' optionsLoading.value = true; optionsError.value = ''
if (relationLocked.value) candidates.value = []
const results = await Promise.allSettled([ const results = await Promise.allSettled([
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }), relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
]) ])
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED') if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected') const failed = ['承运商', '可预约订舱柜次'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败` if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false optionsLoading.value = false
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId)) if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
else if (draft.oceanBookingId) {
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId && item.containerBookingId === draft.containerBookingId)
if (candidate) {
draft.warehouseId = candidate.warehouseId
draft.billOfLadingNo = candidate.billOfLadingNo
draft.loadingPlanId = candidate.loadingPlanId
} else {
draft.oceanBookingId = null
draft.containerBookingId = null
draft.loadingPlanId = null
}
}
} }
async function validate() { return formRef.value?.validate() } async function validate() { return formRef.value?.validate() }
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') } async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate }) defineExpose({ validate })
onMounted(() => { void loadOptions() }) onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() }) watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })
</script> </script>
<template> <template>
@@ -161,8 +179,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<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-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-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col> <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="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" 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" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${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="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 class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading" placeholder="请先选择订舱,再选择可预约柜次" :loading="optionsLoading || 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 /><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 && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !optionsLoading && !bookingContainersLoading && !selectableContainers.length && !relationLocked" class="field-hint">该订舱当前没有可创建拖车预约的柜次。</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></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 && !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> <el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
+34
View File
@@ -198,6 +198,40 @@ export interface TruckAppointmentQuery {
containerType?: string | '' containerType?: string | ''
} }
export interface TruckAppointmentCandidateQuery {
page?: number
size?: number
keyword?: string
}
export interface TruckAppointmentCandidate {
oceanBookingId: string
oceanBookingNo: string
billOfLadingNo: string
warehouseId: string
warehouseName: string | null
channelCode: string | null
channelName: string | null
requirementId: string
containerType: string
requirementQuantity: number
containerBookingId: string
cabinetNo: string
containerNo: string | null
loadingPlanId: string | null
loadingPlanNo: string | null
loadingPlanStatus: string | null
bookingDocumentReady: boolean
}
export interface TruckAppointmentCandidatePage {
records: TruckAppointmentCandidate[]
page: number
size: number
total: number
pages: number
}
export interface TruckAppointmentActionRequest { export interface TruckAppointmentActionRequest {
expectedVersion: number expectedVersion: number
reason?: string | null reason?: string | null