164 lines
19 KiB
Vue
164 lines
19 KiB
Vue
<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>
|