feat(fleet): select versioned sales quotes and isolate cost drafts

This commit is contained in:
2026-10-03 13:20:35 +08:00
parent 3440f9dd11
commit 5641ea0f7e
15 changed files with 288 additions and 31 deletions
@@ -8,7 +8,8 @@ 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 } from '../api/tripBookings'
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'
@@ -25,6 +26,32 @@ const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<
const version = ref<number | null>(null), customerId = ref('')
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, 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')
@@ -59,8 +86,9 @@ function edit(row?: TripBooking, viewOnly = false) {
readOnly.value = viewOnly
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
editing.value = row || null
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) }
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) }
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) {
@@ -75,13 +103,14 @@ function failure(cause: unknown, fallback: string) {
}
async function save() {
if (busy.value || readOnly.value || version.value == null) return
if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return }
if (!customerId.value || !form.destinationKey || !form.packageCount) { 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 }) })
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 }), idempotencyKey: crypto.randomUUID() }
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('外接询价已保存') }
@@ -106,7 +135,9 @@ async function command(row: TripBooking, action: 'confirm' | 'cancel') {
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) })
watch(open, value => { if (!value) resetCandidates() })
watch(() => [props.trip.tripNo, props.trip.recordVersion], resetCandidates)
onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTimeout(searchTimer) })
</script>
<template>
<div>
@@ -136,6 +167,22 @@ onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer
<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>
<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>
<p v-if="quoteRef">来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。</p>
</section>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />