Files
admin-web/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
T
2026-10-19 17:56:31 +08:00

172 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 { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
import { ElMessage } from 'element-plus'
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 { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const followupActions = computed(() => {
const row = props.inquiry
if (!row) return []
const manage = access.can('platform:fleet:capacity-offer:manage')
return [
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && (manage || access.can('platform:fleet:capacity-offer:inquiry-edit')) },
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:dispatch:create') },
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
].filter(action => action.visible)
})
const primaryFollowup = computed(() => {
const key = props.inquiry?.progress.primaryAction
if (key === 'VIEW') return followupActions.value.find(action => action.key === (props.inquiry?.dispatchNo ? 'dispatch' : 'fulfillment'))
return followupActions.value.find(action => action.key.toUpperCase() === key)
})
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
const canReadOrder = computed(() => props.inquiry && inquiryOrderTarget(props.inquiry, access.can))
const blankCargo = (): 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({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
const pending = ref<{ target: string; confirm: boolean; body: InquiryWrite } | null>(null)
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value)
const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
let generation = 0, customerSequence = 0, quoteSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
watch(() => [form.customerId, form.cargo.destinationKey, form.cargo.currency, form.cargo.priceBasis], () => { resetQuotes(); form.quoteRef = null; quoteSource.value = null }, { flush: 'sync' })
watch(() => [open.value, target.value], async () => {
generation++; customerSequence++; clearTimeout(timer); resetQuotes(); customerLoading.value = false
if (!open.value) return
if (pending.value && pending.value.target !== target.value) { ElMessage.warning('请先核对或重试上一条结果未知的意向'); open.value = false; return }
error.value = null; customerError.value = null; customerSearched.value = false; customers.value = []
if (pending.value) Object.assign(form, structuredClone(toRaw(pending.value.body)))
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
}, { immediate: true })
function searchCustomers(keyword = '') {
if (lock.value || props.inquiry) return
clearTimeout(timer); const current = ++customerSequence
timer = setTimeout(async () => {
customerLoading.value = true; customerError.value = null
try { const rows = await listOfferCustomers(keyword.trim() || undefined); if (current === customerSequence && open.value) { customers.value = rows; customerSearched.value = true } }
catch (cause) { if (current === customerSequence) { customers.value = []; customerError.value = getApiErrorDetails(cause, '客户候选加载失败') } }
finally { if (current === customerSequence) customerLoading.value = false }
}, 250)
}
async function queryQuotes(page = 1) {
if (!props.offer || lock.value || !canQuote.value || !quoteReady.value) return
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
try { const value = await listOfferQuotes(props.offer.offerNo, { customerId: form.customerId, destinationKey: form.cargo.destinationKey, currency: form.cargo.currency as 'USD' | 'CNY', priceBasis: form.cargo.priceBasis, page, size: 20 }); if (current === quoteSequence && open.value) { quoteRows.value = value.records; quotePage.value = value.page; quotePages.value = value.pages; quoteSearched.value = true } }
catch (cause) { if (current === quoteSequence) quoteError.value = getApiErrorDetails(cause, '报价候选读取失败,可继续手工询价') }
finally { if (current === quoteSequence) quoteLoading.value = false }
}
const sourceKey = (row: FleetQuoteReference) => `${row.quoteNo}:${row.recordVersion}:${row.priceItem}`
function selectQuote(key: string) { const row = quoteRows.value.find(item => sourceKey(item) === key); if (row && !lock.value) { quoteSource.value = row; form.quoteRef = fleetQuoteReference(row); form.cargo.unitPrice = row.quotedUnitPrice } }
async function save() {
if (busy.value || !writable.value || !props.offer) return
if (!form.customerId || !form.cargo.destinationKey) { ElMessage.warning('请选择客户和本车卸货站'); return }
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
<section v-if="confirmMode" aria-label="本目的站可接余位">
<strong>本目的站可接余位</strong>
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
</section>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
<section v-if="inquiry && readonly" aria-label="继续处理本票货物">
<el-button v-if="primaryFollowup" type="primary" @click="emit('action', primaryFollowup.key, inquiry)">{{ primaryFollowup.label }}</el-button>
<details v-if="secondaryFollowups.length"><summary>其他操作与关联单据</summary><div class="density-actions">
<el-button v-for="action in secondaryFollowups" :key="action.key" @click="emit('action', action.key, inquiry)">{{ action.label }}</el-button>
</div></details>
</section>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
<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-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
<el-form-item :label="form.cargo.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.cargo.unitPrice" :min="0" :precision="2" placeholder="未知可留空" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>其他说明(选填)</summary>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item></details>
</el-form>
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<details><summary>参考报价(可选)</summary><section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
<p v-if="quoteSearched && !quoteRows.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>
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
</section></details>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
<p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p>
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
</section>
<p v-if="inquiry">本土订单:<el-button v-if="canReadOrder" link @click="emit('action', 'business-order', inquiry)">{{ inquiry.businessOrderNo }}</el-button><span v-else>{{ inquiry.businessOrderNo || '尚未成交' }}</span> · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog>
</template>
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>