199 lines
22 KiB
Vue
199 lines
22 KiB
Vue
<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>
|