This commit is contained in:
2026-11-02 17:15:55 +08:00
parent 63193d04de
commit 5a1cde6c0f
4 changed files with 106 additions and 16 deletions
@@ -27,6 +27,16 @@ export interface BusinessOrderQuery {
}
export interface TransportSummary {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
orderNo: string
transportType?: string
@@ -63,6 +73,16 @@ export interface TransportDetail {
}
export interface TransportCreate {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
productName?: string
estimatedPalletCount: number
origin: string
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg'
import type { FormInstance } from 'element-plus'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
@@ -13,6 +14,20 @@ const props = withDefaults(defineProps<{
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
const emit = defineEmits<{ submit: []; cancel: [] }>()
const formRef = ref<FormInstance>()
const warehouses = ref<MerchantWarehouse[]>([]), warehouseError = ref(''), warehouseLoading = ref(false)
async function loadWarehouses() {
if (props.kind !== 'looseboard') return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = (await getMerchantWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE', size: 200 })).records }
catch (cause) { warehouseError.value = cause instanceof Error ? cause.message : '仓库读取失败' }
finally { warehouseLoading.value = false }
}
function chooseWarehouse() {
const warehouse = warehouses.value.find(row => String(row.id) === String(props.model.operatingWarehouseId))
props.model.deliveryContactName = warehouse?.contactName || ''; props.model.deliveryContactPhone = warehouse?.contactPhone || ''
}
onMounted(loadWarehouses)
watch([() => props.model.operatingWarehouseId, warehouses], chooseWarehouse, { immediate: true })
const rules = computed(() => transportDemandRules(props.kind, props.model))
const isCreate = computed(() => !props.editing)
const createPopperClass = computed(() => isCreate.value ? 'transport-create-popper' : undefined)
@@ -25,24 +40,38 @@ defineExpose({
<template>
<el-form ref="formRef" class="transport-demand-form" :class="{ 'transport-demand-form--create': isCreate }" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" />
<section v-if="kind === 'looseboard'" class="transport-demand-section">
<h3>入仓安排</h3>
<div class="transport-demand-grid">
<el-form-item class="transport-demand-wide" label="送货方式" prop="deliveryMode"><el-radio-group v-model="model.deliveryMode"><el-radio-button value="DELIVERY_TO_WAREHOUSE">送货到仓</el-radio-button><el-radio-button value="WAREHOUSE_PICKUP">仓库上门取货</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="收货仓库" prop="operatingWarehouseId"><el-select v-model="model.operatingWarehouseId" :loading="warehouseLoading" filterable placeholder="请选择收货仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select><span v-if="warehouseError" role="alert">{{ warehouseError }} <el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<template v-if="model.deliveryMode === 'DELIVERY_TO_WAREHOUSE'">
<el-form-item label="到仓联系人" prop="deliveryContactName"><el-input v-model="model.deliveryContactName" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="到仓联系电话" prop="deliveryContactPhone"><el-input v-model="model.deliveryContactPhone" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="预计到货时间" prop="estimatedArrivalAt"><el-date-picker v-model="model.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item>
<el-form-item label="物流跟踪号" prop="trackingNo"><el-input v-model.trim="model.trackingNo" maxlength="255" /></el-form-item>
</template>
</div>
</section>
<section class="transport-demand-section">
<h3>运输信息</h3>
<div class="transport-demand-grid transport-demand-route-grid">
<el-form-item v-if="kind === 'looseboard' && model.destinationType === 'PRIVATE'" label="目的地邮编" prop="destinationPostalCode"><el-input v-model.trim="model.destinationPostalCode" maxlength="32" /></el-form-item>
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide transport-demand-vehicle" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" :popper-class="createPopperClass" :placeholder="isCreate ? '请选择车辆类型' : ''"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item>
<el-form-item :label="kind === 'truck' ? '取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item v-if="!isCreate" :label="kind === 'truck' ? '送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? '取货联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系人姓名' : '请输入起运地联系人姓名') : ''" /></el-form-item>
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系人姓名' : '请输入起运地联系人姓名') : ''" /></el-form-item>
<el-form-item v-if="!isCreate" :label="kind === 'truck' ? '送达联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="" /></el-form-item>
<el-form-item :label="kind === 'truck' ? '取货联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系电话' : '请输入起运地联系电话') : ''" /></el-form-item>
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系电话' : '请输入起运地联系电话') : ''" /></el-form-item>
<template v-if="isCreate">
<el-form-item :label="kind === 'truck' ? '送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? '送达联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" :placeholder="kind === 'truck' ? '请输入送达联系人姓名' : '请输入目的地联系人姓名'" /></el-form-item>
</template>
<el-form-item :label="kind === 'truck' ? '送达联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入送达联系电话' : '请输入目的地联系电话') : ''" /></el-form-item>
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" class="transport-demand-address" :label="kind === 'truck' ? '取货地址' : '起运详细地址'" prop="originAddress"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="请输入详细地址" /></el-form-item>
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" :placeholder="kind === 'looseboard' && model.destinationType === 'PRIVATE' ? '请输入目的详细地址' : '街道 / 门牌等补充信息(选填)'" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
@@ -7,7 +7,14 @@ import ts from 'typescript'
const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`)
const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', estimatedPalletCount: 3, budgetAmount: 100, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
const valid = () => ({ ...createTransportDemandForm(), deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '9', destinationType: 'FBA', packageCount: 8, vehicleType: 'PALLET_15', estimatedPalletCount: 3, budgetAmount: 100, origin: ' LA ', originAddress: '123 Test Road', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
test('delivery to warehouse permits no tracking number while requiring receiving contacts and arrival time', () => {
const form = { ...valid(), deliveryMode: 'DELIVERY_TO_WAREHOUSE', deliveryContactName: '仓库收货员', deliveryContactPhone: '10003', estimatedArrivalAt: '2026-10-20T09:00:00', trackingNo: '' }
assert.equal(transportDemandError('looseboard', form), undefined)
assert.equal(buildTransportDemand('looseboard', form).trackingNo, undefined)
for (const field of ['deliveryContactName', 'deliveryContactPhone', 'estimatedArrivalAt']) assert.ok(transportDemandError('looseboard', { ...form, [field]: '' }))
})
test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => {
const form = createTransportDemandForm()
@@ -61,3 +68,11 @@ test('truck accepts only the two vehicle types and does not submit looseboard ca
assert.equal(loose.estimatedPalletCount, 3)
assert.equal(loose.packageCount, 8)
})
test('warehouse delivery clears hidden origin data and pickup requires the full origin address', () => {
const form={...valid(),deliveryMode:'DELIVERY_TO_WAREHOUSE',deliveryContactName:'warehouse',deliveryContactPhone:'000',estimatedArrivalAt:'2026-10-20T09:00:00'}
for(const field of ['origin','originAddress','originContactName','originContactPhone']) assert.equal(transportDemandError('looseboard',{...form,[field]:''}),undefined)
const payload=buildTransportDemand('looseboard',form)
assert.equal(payload.origin,'');assert.equal(payload.originAddress,undefined);assert.equal(payload.originContactName,'');assert.equal(payload.originContactPhone,'')
assert.match(transportDemandError('looseboard',{...valid(),originAddress:''}),/详细地址/)
})
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
export type TransportDemandKind = 'truck' | 'looseboard'
export interface TransportDemandForm {
deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId: string
deliveryContactName: string
deliveryContactPhone: string
estimatedArrivalAt: string
trackingNo: string
destinationType: 'FBA' | 'PRIVATE'
destinationPostalCode: string
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
origin: string
destination: string
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
export function createTransportDemandForm(): TransportDemandForm {
return {
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
@@ -32,11 +41,9 @@ export function createTransportDemandForm(): TransportDemandForm {
}
}
const requiredText = (kind: TransportDemandKind) => ({
origin: kind === 'truck' ? '取货地' : '起运地',
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
destination: kind === 'truck' ? '送达地' : '目的地',
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
} as const)
@@ -47,10 +54,21 @@ export function localMinuteInput(value: unknown) {
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {}
const labels = requiredText(kind)
const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) {
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
}
if (kind === 'looseboard') {
if (form.deliveryMode === 'WAREHOUSE_PICKUP') rules.originAddress = [{ required: true, whitespace: true, message: '请输入起运详细地址', trigger: 'blur' }]
rules.operatingWarehouseId = [{ required: true, message: '请选择收货仓库', trigger: 'change' }]
if (form.destinationType === 'PRIVATE') {
rules.destinationAddress = [{ required: true, whitespace: true, message: '请输入目的详细地址', trigger: 'blur' }]
rules.destinationPostalCode = [{ required: true, whitespace: true, message: '请输入目的地邮编', trigger: 'blur' }]
}
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE') {
for (const [field, label] of [['deliveryContactName', '到仓联系人'], ['deliveryContactPhone', '到仓联系电话'], ['estimatedArrivalAt', '预计到货时间']] as const) rules[field] = [{ required: true, message: '请填写' + label, trigger: 'change' }]
}
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'packageCount' && kind === 'truck') continue
@@ -66,8 +84,16 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
}
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'looseboard') {
if (!form.operatingWarehouseId) return '请选择收货仓库'
if (!['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP'].includes(form.deliveryMode)) return '请选择送货方式'
if (form.destinationType === 'PRIVATE' && (!form.destinationAddress.trim() || !form.destinationPostalCode.trim())) return '请填写目的详细地址和邮编'
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' && (!form.deliveryContactName.trim() || !form.deliveryContactPhone.trim() || !form.estimatedArrivalAt)) return '送货到仓须填写仓库联系人和预计到货时间'
if (form.deliveryMode === 'WAREHOUSE_PICKUP' && !form.originAddress.trim()) return '请输入起运详细地址'
if (form.packageCount < 1) return '散板收货件数须大于 0'
}
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
const labels = requiredText(kind)
const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
}
@@ -102,15 +128,15 @@ function commonPayload(form: TransportDemandForm) {
type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
type LooseboardPayload = CommonPayload & { deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'; operatingWarehouseId: string; destinationType: 'FBA' | 'PRIVATE'; destinationPostalCode?: string; deliveryContactName?: string; deliveryContactPhone?: string; estimatedArrivalAt?: string; trackingNo?: string; productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm): TruckPayload | LooseboardPayload {
const failure = transportDemandError(kind, form)
if (failure) throw new Error(failure)
const common = commonPayload(form)
return kind === 'truck'
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
: { ...common, ...(form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? { origin: '', originAddress: undefined, originContactName: '', originContactPhone: '' } : {}), deliveryMode: form.deliveryMode, operatingWarehouseId: form.operatingWarehouseId, deliveryContactName: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactName.trim() : undefined, deliveryContactPhone: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactPhone.trim() : undefined, estimatedArrivalAt: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? localMinuteInput(form.estimatedArrivalAt) : undefined, trackingNo: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.trackingNo.trim() || undefined : undefined, destinationType: form.destinationType, destinationPostalCode: form.destinationType === 'PRIVATE' ? form.destinationPostalCode.trim() : undefined, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
}