Files
admin-web/src/modules/overseas-master-data/components/TripBookingPanel.vue
T
2026-10-20 15:48:27 +08:00

199 lines
22 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 InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.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 { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking, listTripBookingQuoteCandidates } from '../api/tripBookings'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import type { FleetTrip } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
const access = useAccessPreviewStore()
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
function canConfirmReceipt(row: TripBooking) {
return planned.value && ['CONFIRMED', 'RECEIVED'].includes(row.status) && !!row.incomingNo && !row.dispatchNo && access.can(permission.tripBookingConfirm)
}
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') {
if (busy.value || mode === 'receipt' && !canConfirmReceipt(row)) return
fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true
}
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
const readOnly = ref(false)
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
const version = ref<number | null>(null), customerId = ref('')
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive<TripBookingCargo>(blank())
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), quoteRef = ref<FleetQuoteReference | null>(null)
const candidates = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null)
const quotePage = ref(1), quotePages = ref(0), quoteSearched = ref(false)
let quoteSequence = 0
const canReadQuotes = computed(() => access.can(permission.fleetQuoteList))
const quoteLocked = computed(() => readOnly.value || busy.value || !!pendingCreate.value || !canReadQuotes.value)
const canQueryQuotes = computed(() => !!customerId.value && form.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.currency || ''))
const sourceKey = (value: FleetQuoteSourceSnapshot | FleetQuoteReference) => `${value.quoteNo}:${value.recordVersion}:${value.priceItem}`
function resetCandidates() { quoteSequence++; candidates.value = []; quoteError.value = null; quoteLoading.value = false; quoteSearched.value = false; quotePage.value = 1; quotePages.value = 0 }
function clearQuote() { if (quoteLocked.value) return; quoteRef.value = null; quoteSource.value = null }
function selectQuote(key: string) {
if (quoteLocked.value) return
const source = candidates.value.find(row => sourceKey(row) === key)
if (!source) return
quoteSource.value = source; quoteRef.value = fleetQuoteReference(source); form.unitPrice = source.quotedUnitPrice
}
async function queryQuotes(page = 1) {
if (quoteLocked.value || !canQueryQuotes.value) return
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
try {
const result = await listTripBookingQuoteCandidates(props.trip.tripNo, { customerId: customerId.value, destinationKey: form.destinationKey, currency: form.currency as 'CNY' | 'USD', priceBasis: form.priceBasis, page, size: 20 })
if (current === quoteSequence) { candidates.value = result.records; quotePage.value = result.page; quotePages.value = result.pages; quoteSearched.value = true }
} catch (cause) { if (current === quoteSequence) { candidates.value = []; quoteError.value = getApiErrorDetails(cause, '报价库查询未完成,可继续手工询价') } }
finally { if (current === quoteSequence) quoteLoading.value = false }
}
watch(() => [customerId.value, form.destinationKey, form.currency, form.priceBasis], () => { resetCandidates(); quoteSource.value = null; quoteRef.value = null }, { flush: 'sync' })
let generation = 0, searchSequence = 0
let searchTimer: ReturnType<typeof setTimeout> | undefined
const planned = computed(() => props.trip.status === 'PLANNED')
const quotedAmount = computed(() => form.unitPrice == null ? null : form.priceBasis === 'ORDER' ? form.unitPrice : form.palletSpaces == null ? null : Math.round(form.unitPrice * form.palletSpaces * 100) / 100)
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', slotName: 'actions', width: planned.value ? 320 : 170, fixed: 'left' },
{ key: 'bookingNo', prop: 'bookingNo', label: '外接预订号', minWidth: 225 },
{ key: 'customerName', prop: 'customerName', label: '委托客户', minWidth: 170 },
{ key: 'destinationLabel', prop: 'destinationLabel', label: '卸货站', minWidth: 140 },
{ key: 'status', label: '状态', slotName: 'status', width: 130 },
{ key: 'cargo', label: '承运货量', slotName: 'cargo', minWidth: 225 },
{ key: 'amount', label: '约定运费', slotName: 'amount', minWidth: 160 },
{ key: 'documents', label: '来货 / 排车', slotName: 'documents', minWidth: 230 },
])
async function load() {
const current = ++generation; loading.value = true; error.value = null
try { const value = await listTripBookings(props.trip.tripNo); if (current === generation) { records.value = value.records; version.value = value.tripRecordVersion
if (pendingCommand.value) { const row = value.records.find(row => row.bookingNo === pendingCommand.value!.bookingNo); if (row && (row.status === 'CANCELLED' || pendingCommand.value.action === 'confirm' && row.status === 'CONFIRMED')) pendingCommand.value = null } } }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '外接预订加载失败') }
finally { if (current === generation) loading.value = false }
}
function searchCustomers(keyword = '') {
clearTimeout(searchTimer); const current = ++searchSequence
searchTimer = setTimeout(async () => {
customersLoading.value = true
try { const rows = await listFleetCustomers(keyword.trim() || undefined); if (current === searchSequence) customers.value = rows }
catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '客户目录加载失败') }
finally { if (current === searchSequence) customersLoading.value = false }
}, 250)
}
function edit(row?: TripBooking, viewOnly = false) {
readOnly.value = viewOnly || !!row?.offerNo
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
editing.value = row || null
resetCandidates()
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo); quoteRef.value = pendingCreate.value.quoteRef || null }
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()); quoteSource.value = row?.quoteSource || null; quoteRef.value = quoteSource.value ? fleetQuoteReference(quoteSource.value) : null }
open.value = true
}
function accept(value: TripBookingResult) {
version.value = value.tripRecordVersion
const index = records.value.findIndex(row => row.bookingNo === value.booking.bookingNo)
if (index < 0) records.value.push(value.booking); else records.value[index] = value.booking
emit('changed', value.tripRecordVersion)
}
function failure(cause: unknown, fallback: string) {
error.value = getApiErrorDetails(cause, fallback)
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
}
async function save() {
if (busy.value || readOnly.value || editing.value?.offerNo || version.value == null) return
if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return }
if (!customerId.value || !form.destinationKey) { ElMessage.warning('请选择客户和卸货站'); return }
const current = generation; busy.value = true; error.value = null
try {
let value: TripBookingResult
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null })
else {
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null, idempotencyKey: crypto.randomUUID() }
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
}
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
finally { busy.value = false }
}
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
if (busy.value || row.offerNo || version.value == null) return
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
const current = generation
if (!pendingCommand.value) {
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current !== generation) return
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
}
busy.value = true; error.value = null
try {
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
watch(open, value => { if (!value) resetCandidates() })
watch(() => [props.trip.tripNo, props.trip.recordVersion], resetCandidates)
onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTimeout(searchTimer) })
</script>
<template>
<div>
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button><InlineHelp title="外接预订说明"><p>询价不占位,确认承运后预留容量。实收时核对运费,转派送后按确认报价登记应收,付款状态以财务为准。</p></InlineHelp></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
</DataTable>
<AppDialog v-model="open" class="admin-density-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
<p v-if="editing?.offerNo">池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。</p><el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
<el-form-item label="预计箱数"><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="报价方式"><el-select v-model="form.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
<el-form-item :label="form.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.unitPrice" :min="0" :precision="2" placeholder="可先保存询价" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency" filterable allow-create placeholder="选择或输入币种"><el-option v-for="currency in ['USD','CAD','EUR','GBP','CNY']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contactName" placeholder="填写联系人(选填)" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
</el-form>
<section class="booking-quote" aria-label="报价来源">
<p v-if="quoteSource">来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }}<br>来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}</p>
<p v-else>{{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}</p>
<template v-if="!readOnly && canReadQuotes">
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button><InlineHelp title="报价来源说明"><p>来源报价与实际约定价格分别保存;确认承运时重新核对报价版本与适用日期。可选择手工询价。</p></InlineHelp>
<el-button v-if="quoteRef" :disabled="quoteLocked" @click="clearQuote">改为手工询价</el-button>
<p v-if="!canQueryQuotes">查询报价需客户、FBA卸货站和 CNY/USD。</p>
<el-select v-if="candidates.length" data-testid="booking-quote-candidate" :model-value="quoteRef ? sourceKey(quoteRef) : ''" :disabled="quoteLocked || quoteLoading" placeholder="选择报价后带入单价,可另行议价" @update:model-value="selectQuote">
<el-option v-for="item in candidates" :key="sourceKey(item)" :value="sourceKey(item)" :label="`${item.quoteNo} · ${item.quotedUnitPrice.toFixed(2)} ${item.currency} · ${item.validFrom} 至 ${item.validTo}`" />
</el-select>
<p v-if="quoteSearched && !candidates.length">暂无符合本车路线、客户、日期和币种的报价,可继续手工询价。</p>
<div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
<p v-if="quoteError" role="alert">{{ quoteError.message }} <ApiErrorMeta :details="quoteError" /></p>
</template>
</section>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
</div>
</template>
<style scoped>.booking-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.booking-form :deep(.el-select),.booking-form :deep(.el-input-number){width:100%}.booking-wide{grid-column:1/-1}</style>