210 lines
21 KiB
Vue
210 lines
21 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import { Eye, Save } from '@lucide/vue'
|
||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
|
||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||
|
||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||
const formRef = ref<FormInstance>()
|
||
const relationWarning = ref('')
|
||
const derivedCustomerSource = ref<'booking' | 'container' | null>(null)
|
||
const billOfLadingFromBooking = ref(false)
|
||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
|
||
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) => ({ 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) : /^(?:\d+)(?:\.\d{1,3})?$/.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 = {
|
||
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCustomers.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' }],
|
||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
|
||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
|
||
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
|
||
}
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const carriers = ref<Supplier[]>([])
|
||
const containers = ref<ContainerBookingResource[]>([])
|
||
const bookings = ref<OceanBookingResource[]>([])
|
||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||
const optionsLoading = ref(false)
|
||
const optionsError = ref('')
|
||
const selectableCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
|
||
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
|
||
)))
|
||
const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||
item.status === 'ACTIVE'
|
||
)))
|
||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
|
||
const selectedContainerFallback = computed(() => {
|
||
const id = draft.containerBookingId
|
||
const snapshot = props.resource
|
||
if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||
return {
|
||
id: String(id),
|
||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
|
||
}
|
||
})
|
||
const selectedBookingFallback = computed(() => {
|
||
const id = draft.oceanBookingId
|
||
const snapshot = props.resource
|
||
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
|
||
})
|
||
const selectedWarehouseFallback = computed(() => {
|
||
const id = draft.warehouseId
|
||
const snapshot = props.resource
|
||
if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null
|
||
return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' }
|
||
})
|
||
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
|
||
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
|
||
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 applyRelatedCustomer(customerId: string | null | undefined, customerName: string | null | undefined, source: 'booking' | 'container') {
|
||
if (!customerId) return
|
||
if (!draft.customerId) { draft.customerId = String(customerId); draft.customerName = customerName || null; derivedCustomerSource.value = source; return }
|
||
if (String(draft.customerId) !== String(customerId)) relationWarning.value = '所选关联资源与当前客户不一致,请重新选择客户或清空该关联'
|
||
}
|
||
function selectContainer(containerId: string) {
|
||
const item = containers.value.find((entry) => entry.id === containerId)
|
||
draft.containerBookingId = item?.id || null
|
||
relationWarning.value = ''
|
||
if (!item) {
|
||
draft.loadingPlanId = null; draft.warehouseId = null
|
||
if (derivedCustomerSource.value === 'container') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||
return
|
||
}
|
||
draft.loadingPlanId = item.planId || null; draft.warehouseId = item.warehouseId || null
|
||
applyRelatedCustomer(item.customerId, item.customerName, 'container')
|
||
}
|
||
function selectBooking(bookingId: string) {
|
||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||
const previouslyDerivedBillOfLading = billOfLadingFromBooking.value
|
||
draft.oceanBookingId = item?.id || null
|
||
relationWarning.value = ''
|
||
if (!item) {
|
||
if (billOfLadingFromBooking.value) draft.billOfLadingNo = null
|
||
billOfLadingFromBooking.value = false
|
||
if (derivedCustomerSource.value === 'booking') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||
return
|
||
}
|
||
applyRelatedCustomer(item.customerId, item.customerName, 'booking')
|
||
if (item.status === 'RELEASED') {
|
||
if (draft.billOfLadingNo && draft.billOfLadingNo !== item.billOfLadingNo) relationWarning.value = '已放舱订舱的提单号与当前填写值不一致,已使用订舱冻结快照'
|
||
draft.billOfLadingNo = item.billOfLadingNo || null
|
||
billOfLadingFromBooking.value = true
|
||
} else {
|
||
if (previouslyDerivedBillOfLading) draft.billOfLadingNo = null
|
||
billOfLadingFromBooking.value = false
|
||
}
|
||
}
|
||
async function loadOptions() {
|
||
optionsLoading.value = true; optionsError.value = ''
|
||
const results = await Promise.allSettled([
|
||
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
|
||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||
listContainers({ page: 1, size: 200 }),
|
||
listOceanBookings({ page: 1, size: 200 }),
|
||
listAllPlatformWarehouses(),
|
||
])
|
||
if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE')
|
||
if (results[1].status === 'fulfilled') carriers.value = results[1].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
|
||
if (results[3].status === 'fulfilled') bookings.value = results[3].value.records
|
||
if (results[4].status === 'fulfilled') warehouses.value = results[4].value
|
||
const failed = ['客户', '承运商', '柜次', '订舱', '仓库'].filter((_, index) => results[index]?.status === 'rejected')
|
||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
|
||
optionsLoading.value = false
|
||
}
|
||
async function validate() { return formRef.value?.validate() }
|
||
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
|
||
defineExpose({ validate })
|
||
onMounted(() => { void loadOptions() })
|
||
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
|
||
</script>
|
||
|
||
<template>
|
||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||
<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 :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" no-data-text="暂无启用的客户" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '历史客户(需重新选择)'" :value="String(draft.customerId)" disabled /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @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></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input v-model="draft.billOfLadingNo" :disabled="Boolean(selectedReleasedBooking)" maxlength="128" placeholder="草稿可暂不填写,派车前必填" /><div v-if="selectedReleasedBooking" 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" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" /><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable 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 || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-if="selectedWarehouseFallback" :key="selectedWarehouseFallback.id" :label="selectedWarehouseFallback.label" :value="selectedWarehouseFallback.id" /><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
|
||
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" 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.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" 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.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" 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"><el-divider content-position="left">费用信息</el-divider></el-col>
|
||
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
|
||
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></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 :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 :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
|
||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
|
||
</el-row>
|
||
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||
</el-form>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.truck-form { padding-bottom: 4px; }
|
||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:16px; margin:0 0 8px; padding:6px 8px 6px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||
.relation-note > span { min-width:0; }
|
||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||
.relation-alert { margin-bottom: 16px; }
|
||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
|
||
@media (max-width: 480px) {
|
||
.truck-form { padding-inline: 2px; }
|
||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||
.relation-note__actions { width:100%; }
|
||
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
|
||
.form-actions :deep(.el-button) { flex: 1; min-height: 40px; }
|
||
}
|
||
</style>
|