feat(admin): bind booking documents and truck containers
This commit is contained in:
@@ -4,8 +4,8 @@ import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentCreateView independent creation contract', () => {
|
||||
it('starts with optional associations cleared and keeps required business fields explicit', () => {
|
||||
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
||||
it('starts with required booking and container choices empty until the user selects them', () => {
|
||||
expect(source).toContain('customerId: null, carrierId: null')
|
||||
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
|
||||
expect(source).toContain('plannedLoadingAt: null')
|
||||
|
||||
@@ -32,29 +32,32 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain("statuses.indexOf(status) === index")
|
||||
})
|
||||
|
||||
it('initializes independently required fields without inventing associations', () => {
|
||||
expect(source).toContain("customerId: null, carrierId: null, plannedLoadingAt: null")
|
||||
it('initializes the required booking and container associations explicitly', () => {
|
||||
expect(source).toContain("customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
|
||||
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
||||
})
|
||||
|
||||
it('blocks dispatch until a bill number and bill attachment are present', () => {
|
||||
expect(source).toContain("return '确认派车前请先填写提单号'")
|
||||
expect(source).toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||
expect(source).toContain("return '确认派车前请上传“提单文件”附件'")
|
||||
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
|
||||
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
|
||||
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
|
||||
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
|
||||
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
|
||||
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
|
||||
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
|
||||
expect(source).toContain("status === 'DISPATCHED' && dispatchBlockReason.value")
|
||||
expect(source).toContain('attachmentOpen.value = true')
|
||||
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||
})
|
||||
|
||||
it('keeps the main detail available when attachments or history fail', () => {
|
||||
expect(source).toContain('Promise.allSettled([')
|
||||
expect(source).toContain("attachments.status === 'fulfilled'")
|
||||
expect(source).toContain("histories.status === 'fulfilled'")
|
||||
expect(source).toContain('主详情数据不受影响')
|
||||
expect(source).toContain('主详情数据不受影响;派车时仍由服务端最终校验')
|
||||
expect(source).toContain('v-if="relatedError"')
|
||||
})
|
||||
|
||||
it('reloads a cached detail after an error without showing stale resource data', () => {
|
||||
expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain('onActivated(() => {')
|
||||
expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()')
|
||||
})
|
||||
|
||||
@@ -9,12 +9,15 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookings'
|
||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
||||
const relatedError = ref('')
|
||||
let activatedOnce = false
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
@@ -22,15 +25,17 @@ const relationSnapshot = computed(() => resource.value ? [resource.value.custome
|
||||
async function loadRelated() {
|
||||
if (!resource.value) return
|
||||
relatedError.value = ''
|
||||
const [attachments, histories] = await Promise.allSettled([
|
||||
const [attachments, histories, booking] = await Promise.allSettled([
|
||||
listTruckAppointmentAttachments(id.value),
|
||||
listTruckAppointmentHistory(id.value),
|
||||
resource.value.oceanBookingId ? getOceanBooking(resource.value.oceanBookingId) : Promise.reject(new Error('拖车预约缺少订舱关联')),
|
||||
])
|
||||
if (attachments.status === 'fulfilled') resource.value.attachments = attachments.value
|
||||
if (histories.status === 'fulfilled') history.value = histories.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected') relatedError.value = '附件或状态历史暂时未能加载,主详情数据不受影响,可稍后刷新重试。'
|
||||
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
|
||||
}
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
|
||||
@@ -43,13 +48,17 @@ const availableTargets = computed<TruckAppointmentStatus[]>(() => {
|
||||
})
|
||||
const dispatchBlockReason = computed(() => {
|
||||
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
||||
if (!form.billOfLadingNo?.trim()) return '确认派车前请先填写提单号'
|
||||
if (!(resource.value.attachments || []).some((item) => item.attachmentType === 'BILL_OF_LADING')) return '确认派车前请上传“提单文件”附件'
|
||||
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
||||
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
||||
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
|
||||
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
|
||||
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
||||
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
||||
return ''
|
||||
})
|
||||
async function changeStatus(status: TruckAppointmentStatus) {
|
||||
if (!resource.value || status === resource.value.status) return
|
||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; if (!form.billOfLadingNo?.trim()) return; attachmentOpen.value = true; return }
|
||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
||||
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
Reference in New Issue
Block a user