Files
admin-web/src/modules/trucking/components/TruckAppointmentForm.vue
T
2026-09-16 11:06:36 +08:00

272 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const relationWarning = ref('')
const dispatchDetailsOpen = ref(false)
const bookingContainersLoading = ref(false)
const bookingContainersError = ref<ApiErrorDetails | null>(null)
const bookingDocuments = ref<OceanBookingDocument[]>([])
const relationLocked = computed(() => Boolean(props.resource))
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
const nonNegative = (label: string, integer = false, precision = 3) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
if (value === null || value === undefined || value === '') return callback()
const text = String(value).trim(); const valid = integer ? /^\d+$/.test(text) : new RegExp(`^\\d+(?:\\.\\d{1,${precision}})?$`).test(text)
callback(valid ? undefined : new Error(`${label}请输入${integer ? '非负整数' : '非负数字'}`))
}, trigger: ['blur', 'change'] })
const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
if (!value || /^[0-9+()\-\s]{6,32}$/.test(String(value).trim())) callback()
else callback(new Error(`${label}格式不正确`))
}, trigger: 'blur' })
const rules: FormRules = {
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
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 (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.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('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费', false, 2)],
}
const carriers = ref<Supplier[]>([])
const candidates = ref<TruckAppointmentCandidate[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
let bookingLoadSequence = 0
let optionsLoadSequence = 0
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC'
)))
const bookings = computed(() => {
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
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 selectedBookingFallback = computed(() => {
const id = draft.oceanBookingId
const snapshot = props.resource
if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
})
function selectCarrier(carrierId: string) {
if (String(draft.carrierId || '') === carrierId) return
const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId)
draft.carrierId = carrier?.id || null
draft.carrierName = carrier?.name || null
draft.fleetContact = carrier?.contactName || null
draft.fleetPhone = carrier?.phone || null
}
function toggleDispatchDetails(event: Event) {
dispatchDetailsOpen.value = (event.target as HTMLDetailsElement).open
}
async function loadCurrentBookingContext(bookingId: string) {
const sequence = ++bookingLoadSequence
bookingContainersLoading.value = true
bookingContainersError.value = null
try {
const booking = await getOceanBooking(bookingId)
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
if (booking.status !== 'RELEASED') {
bookingDocuments.value = []
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
return
}
draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo
bookingDocuments.value = booking.documents || []
} catch (cause) {
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
bookingDocuments.value = []
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
} finally {
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
}
}
async function selectBooking(bookingId: string) {
bookingLoadSequence += 1
const item = bookings.value.find((entry) => entry.id === bookingId)
draft.oceanBookingId = item?.id || null
bookingDocuments.value = []
relationWarning.value = ''
if (!item) {
draft.warehouseId = null
draft.billOfLadingNo = null
return
}
draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo
}
async function loadOptions() {
const sequence = ++optionsLoadSequence
optionsLoading.value = true; optionsError.value = ''
candidates.value = []
const results = await Promise.allSettled([
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' }),
relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
])
if (sequence !== optionsLoadSequence) return
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC')
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
const failed = ['拖车供应商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
else if (draft.oceanBookingId) {
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId)
if (candidate) {
draft.warehouseId = candidate.warehouseId
draft.billOfLadingNo = candidate.billOfLadingNo
} else {
draft.oceanBookingId = null
}
}
}
async function validate() {
try { return await formRef.value?.validate() }
catch (fields) {
if (fields && typeof fields === 'object' && ('fleetPhone' in fields || 'driverPhone' in fields)) dispatchDetailsOpen.value = true
return false
}
}
async function submitForm() { if (props.submitting) return; if (await validate()) emit('submit') }
defineExpose({ validate, loadOptions })
onMounted(() => { 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>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form" :class="{ 'truck-form--create': !resource }">
<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>
<div class="appointment-grid">
<el-form-item label="订舱" prop="oceanBookingId">
<el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" @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>
<dl v-if="draft.oceanBookingId" class="selection-summary" aria-label="订舱带入信息">
<div><dt>仓库</dt><dd>{{ selectedReleasedBooking?.warehouseName || resource?.warehouseName || '—' }}</dd></div>
<div><dt>提单号</dt><dd>{{ draft.billOfLadingNo || '—' }}</dd></div>
</dl>
<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>
</el-form-item>
<el-form-item label="拖车供应商" prop="carrierId">
<el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择拖车供应商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" @update:model-value="selectCarrier(String($event || ''))">
<el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前供应商(需重新选择)'" :value="String(draft.carrierId)" disabled />
<el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" />
</el-select>
<dl v-if="draft.carrierId" class="selection-summary" aria-label="车队联系方式">
<div><dt>联系人</dt><dd>{{ draft.fleetContact || '—' }}</dd></div>
<div><dt>电话</dt><dd>{{ draft.fleetPhone || '—' }}</dd></div>
</dl>
</el-form-item>
<div v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError" class="appointment-grid__wide readiness-alerts">
<el-alert v-if="!draft.billOfLadingNo?.trim()" title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
<el-alert v-if="!bookingDocumentReady" title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
</div>
<el-form-item label="预约装柜时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约装柜时间" /></el-form-item>
<el-form-item label="拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" inputmode="decimal" placeholder="请输入应付拖车费"><template #append>元</template></el-input><div class="field-hint">人民币;离仓完成后计入应付。</div></el-form-item>
<el-form-item class="appointment-grid__wide" label="备注(选填)"><el-input v-model="draft.remark" type="textarea" :rows="2" maxlength="1000" placeholder="例如到仓时间要求、装柜注意事项" /></el-form-item>
</div>
<details class="dispatch-details" :open="dispatchDetailsOpen" @toggle="toggleDispatchDetails">
<summary>派车信息<span>选填,司机和车辆确定后可补充</span></summary>
<div class="appointment-grid dispatch-grid">
<el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" maxlength="64" placeholder="选择供应商后带入,可修改" /></el-form-item>
<el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" maxlength="32" placeholder="选择供应商后带入,可修改" /></el-form-item>
<el-form-item label="司机"><el-input v-model="draft.driverName" maxlength="64" placeholder="司机姓名" /></el-form-item>
<el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" maxlength="32" placeholder="司机联系电话" /></el-form-item>
<el-form-item label="车牌号"><el-input v-model="draft.vehicleNo" maxlength="32" placeholder="车牌或车头号" /></el-form-item>
</div>
</details>
<details v-if="resource" class="dispatch-details">
<summary>其他已存资料</summary>
<el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" placeholder="请输入件数" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" placeholder="请输入毛重" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input v-model="draft.volumeCbm" inputmode="decimal" placeholder="请输入体积" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
</el-row>
</details>
<div class="form-actions"><el-button v-if="resource" :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />{{ resource ? '保存' : '保存预约' }}</el-button></div>
</el-form>
</template>
<style scoped>
.truck-form { min-width:0; padding-bottom:var(--admin-space-1); }
.truck-form--create { max-width:1040px; margin-inline:auto; }
.relation-note { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4); margin-bottom:var(--admin-space-5); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
.relation-note__actions { display:flex; flex:0 0 auto; }
.relation-alert { margin-bottom:var(--admin-space-5); }
.appointment-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:var(--admin-space-6); }
.appointment-grid__wide { grid-column:1 / -1; }
:deep(.el-form-item), :deep(.el-form-item__content) { min-width:0; }
:deep(.el-select), :deep(.el-date-editor), :deep(.el-input), :deep(.el-textarea) { width:100%; max-width:100%; }
:deep(.el-select__selected-item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.selection-summary { display:flex; flex-wrap:wrap; gap:var(--admin-space-1) var(--admin-space-5); width:100%; margin:var(--admin-space-2) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
.selection-summary div { display:flex; min-width:0; gap:var(--admin-space-2); }
.selection-summary dt { flex-shrink:0; }
.selection-summary dd { margin:0; color:var(--el-text-color-regular); overflow-wrap:anywhere; }
.field-error,.field-hint { width:100%; display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-top:var(--admin-space-1); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
.field-error { color:var(--el-color-danger); }
.readiness-alerts:empty { display:none; }
.readiness-alerts :deep(.el-alert) { margin-bottom:var(--admin-space-4); }
.dispatch-details { border-top:1px solid var(--el-border-color-lighter); }
.dispatch-details summary { padding:var(--admin-space-4) 0; cursor:pointer; font-size:var(--admin-font-body); font-weight:600; color:var(--el-text-color-primary); }
.dispatch-details summary span { margin-left:var(--admin-space-3); color:var(--cc-muted); font-size:var(--admin-font-caption); font-weight:400; }
.dispatch-details summary:focus-visible { outline:2px solid var(--cc-primary); outline-offset:2px; }
.dispatch-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
.form-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); padding-top:var(--admin-space-4); border-top:1px solid var(--el-border-color-lighter); }
@media (max-width:720px) {
.appointment-grid,.dispatch-grid { grid-template-columns:minmax(0,1fr); }
.dispatch-details summary span { display:block; margin-left:var(--admin-space-4); }
}
@media (max-width:480px) {
.form-actions { position:sticky; bottom:0; background:var(--el-bg-color); padding-bottom:var(--admin-space-3); z-index:2; }
.form-actions :deep(.el-button) { flex:1; min-height:40px; }
}
</style>