Files
admin-web/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue
T

164 lines
19 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/adminWorkbench.css'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
import { getInquiryFulfillment, commandInquiryFulfillment } from '../api/capacityOffers'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'> & Partial<Pick<FleetTrip, 'status'>>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const access = useAccessPreviewStore()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const measurements = reactive({ weightKg: {} as Record<string, number | undefined>, volumeCbm: {} as Record<string, number | undefined> })
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy)
defineExpose({ hasPending: computed(() => !!pending.value) })
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const receiptAllowed = computed(() => props.pool || props.trip.status === 'PLANNED' && !!props.booking
&& ['CONFIRMED', 'RECEIVED'].includes(props.booking.status) && !!props.booking.incomingNo && !props.booking.dispatchNo && access.can(permission.tripBookingConfirm))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const receiptComparison = computed(() => {
const incoming = preview.value?.incoming
const pallets = incoming?.pallets || []
const previous = props.booking?.status === 'RECEIVED' ? props.booking.receipt : null
const forecast = props.booking?.status === 'CONFIRMED' || previous ? props.booking?.cargo : null
const measured = (row: typeof pallets[number], key: 'weightKg' | 'volumeCbm') => row[key] ?? measurements[key][row.warehousePalletId]
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => measured(row, key) != null && Number.isFinite(measured(row, key)))
? pallets.reduce((total, row) => total + measured(row, key)!, 0) : null
const values = [
{ label: '箱数', expected: previous?.source.receivedPackages ?? forecast?.packageCount, actual: incoming?.receivedPackages },
{ label: '实托数', expected: previous ? previous.source.pallets.length : forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
{ label: '标准托位(待核对)', expected: previous ? previous.pallets.reduce((total, row) => total + row.palletSpaces, 0) : forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
]
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
})
const receiptChanged = computed(() => receiptComparison.value.some(row => row.difference != null && row.difference !== 0)
|| unitPrice.value !== props.booking?.cargo.unitPrice
|| !!props.booking?.receipt && (props.booking.receipt.source.receiptHash !== preview.value?.incoming?.receiptHash
|| props.booking.receipt.pallets.some(row => spaces[row.warehousePalletId] !== row.palletSpaces)))
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
const incomingStatusLabel = computed(() => {
const status = preview.value?.incoming?.status
if (status === 'READY_FOR_DISPATCH' && props.booking?.status === 'BOUND') return '已确认实收,已排入本车'
return statuses[status || ''] || '等待读取'
})
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'weightKg', singleLine: false, showOverflowTooltip: false } : {}) }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'volumeCbm', singleLine: false, showOverflowTooltip: false } : {}) },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
function resetMeasurements() {
for (const field of ['weightKg', 'volumeCbm'] as const) Object.keys(measurements[field]).forEach(key => delete measurements[field][key])
}
async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
await nextTick(); guard.capture()
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, target.value], () => {
generation++; busy.value = false; error.value = null; preview.value = null
if (!open.value || !props.booking) return
if (pending.value) return
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
preview.value = props.initialPreview; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
void nextTick(() => guard.capture())
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
else void nextTick(() => guard.capture())
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
if (props.mode === 'receipt' && !receiptAllowed.value) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
if (mode === 'incoming' && !form.estimatedArrivalAt) { ElMessage.warning('请确认预计到仓时间'); return }
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
else if (mode === 'bind') {
if (!props.pool && !dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
if (preview.value.incoming.pallets.some(row => ['weightKg', 'volumeCbm'].some(key => {
const field = key as 'weightKg' | 'volumeCbm', value = row[field] ?? measurements[field][row.warehousePalletId]
return value == null || !Number.isFinite(value) || value <= 0
}))) { ElMessage.warning('拆合托后的来源须重新填写该票货物在每托的实测重量和体积,不沿用旧托测量'); return }
if (receiptChanged.value) {
busy.value = true
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
catch { return }
finally { pending.value = null; busy.value = false }
if (current !== generation || !receiptAllowed.value) return
}
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]!, ...(row.weightKg == null ? { weightKg: measurements.weightKg[row.warehousePalletId]! } : {}), ...(row.volumeCbm == null ? { volumeCbm: measurements.volumeCbm[row.warehousePalletId]! } : {}) })) } }
}
}
busy.value = true; error.value = null
try {
const body = pending.value.body
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { pending.value = null; busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p v-if="mode === 'receipt' && !receiptAllowed" role="alert">当前车次或预订不能核对实收,请刷新并确认车次仍在计划中、预订未绑定排车且具有核对实收权限。</p>
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
<div class="workbench-table"><DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable></div>
<p v-if="mode === 'receipt'">托盘组成变化后须填写该票外接货的当前实测重量、体积;未变化托盘只读沿用原收货证据,不填写整托混合货的总量。</p><section v-if="mode === 'receipt'" aria-label="预报与实收对照">
<p>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实与当前实收对照' : '预报与实收对照' }};差异须明确确认,容量与费用仍由服务端核对。</p>
<p v-if="booking.status !== 'CONFIRMED' && !booking.receipt">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
<table class="density-table"><thead><tr><th>项目</th><th>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实' : '承运预报' }}</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
</table>
</section>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p v-if="pool">将本票全部实收货物排入原车次。系统复用合格排车单,没有时生成并关联;来源冲突或货量不完整时停止。成功后仍需核对容量并完成审核,不代表已装车或发车。</p><p v-else>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item v-if="!pool" label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-spaces-control{display:flex;width:100%;min-width:0}.receipt-spaces-control :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>