140 lines
20 KiB
Vue
140 lines
20 KiB
Vue
<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 } from '../capacityOfferTypes'
|
||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||
import type { TripBookingCargo } from '../bookingTypes'
|
||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||
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 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 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>
|
||
<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.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
|
||
<p v-else>保存后调度可见,尚不保位。已知货量可先填,未知可留空,确认承运前再补齐。</p>
|
||
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
|
||
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
|
||
<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="本土业务类型"><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="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="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>
|
||
<el-form-item label="预计箱数"><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="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||
<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-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 && readonly" aria-label="继续处理本票货物" class="density-actions">
|
||
<el-button v-if="inquiry.actions.canEdit" @click="emit('action', 'edit', inquiry)">补充资料</el-button>
|
||
<el-button v-if="access.can('platform:fleet:capacity-offer:manage') && (inquiry.actions.canEdit || inquiry.actions.canConfirm)" @click="emit('action', 'confirm', inquiry)">核对并确认</el-button>
|
||
<el-button v-if="inquiry.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit')" @click="emit('action', 'incoming', inquiry)">安排来货</el-button>
|
||
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
|
||
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
|
||
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
|
||
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车与审核</el-button>
|
||
</section>
|
||
<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">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ 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>
|