feat(fleet): add internal sales capacity pool workflow
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
<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 } from '../api/capacityOffers'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
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 }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
|
||||
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; 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), () => { open.value = false })
|
||||
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() })
|
||||
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, 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 = 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('意向已保存,尚未占用运力') }
|
||||
} 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 :model-value="open" @update:model-value="!$event && guard.requestClose()" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :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="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</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" 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 v-model="form.cargo.cargoName" placeholder="选填" /></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>
|
||||
<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>
|
||||
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
|
||||
<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>
|
||||
<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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
||||
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
||||
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
</script>
|
||||
<template><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
</DataTable></template>
|
||||
<style scoped>.inquiry-reason{display:block;color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal}</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } 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 { listCapacityOffers, saveCapacityOffer, commandCapacityOffer } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, type CapacityOffer, type OfferWrite, type OfferCommand } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip }>()
|
||||
const emit = defineEmits<{ saved: [version: number] }>()
|
||||
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const loaded = ref(false)
|
||||
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
|
||||
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
|
||||
let generation = 0
|
||||
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value), () => { open.value = false })
|
||||
async function load() {
|
||||
if (!writable.value || !open.value || pending.value) return
|
||||
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
|
||||
try { const value = await listCapacityOffers({ tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value || !writable.value) return
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
|
||||
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
|
||||
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
|
||||
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
|
||||
await execute()
|
||||
}
|
||||
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
|
||||
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
|
||||
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
|
||||
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
|
||||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template><AppDialog :model-value="open" @update:model-value="!$event && guard.requestClose()" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
|
||||
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
|
||||
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
|
||||
</AppDialog></template>
|
||||
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
|
||||
@@ -7,7 +7,7 @@ import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
|
||||
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const booking: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
|
||||
|
||||
@@ -9,7 +9,7 @@ const access = vi.hoisted(() => ({ writable: true, quote: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission === 'platform:fleet:quote:list' ? access.quote : access.writable }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/tripBookings', () => ({ listTripBookingQuoteCandidates: vi.fn(), listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
|
||||
const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
|
||||
const wrappers: VueWrapper[] = []
|
||||
@@ -27,6 +27,16 @@ function mount(realNumber?: Component, realInput?: Component) {
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => {
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
|
||||
const wrapper = mount(); await flushPromises()
|
||||
for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
|
||||
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -24,7 +24,7 @@ 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: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
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)
|
||||
@@ -83,7 +83,7 @@ function searchCustomers(keyword = '') {
|
||||
}, 250)
|
||||
}
|
||||
function edit(row?: TripBooking, viewOnly = false) {
|
||||
readOnly.value = viewOnly
|
||||
readOnly.value = viewOnly || !!row?.offerNo
|
||||
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
|
||||
editing.value = row || null
|
||||
resetCandidates()
|
||||
@@ -102,9 +102,9 @@ function failure(cause: unknown, fallback: string) {
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || readOnly.value || version.value == null) return
|
||||
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 || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
|
||||
if (!customerId.value || !form.destinationKey) { ElMessage.warning('请选择客户和卸货站'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
let value: TripBookingResult
|
||||
@@ -118,7 +118,7 @@ async function save() {
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
if (busy.value || version.value == null) return
|
||||
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) {
|
||||
@@ -145,18 +145,18 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
|
||||
<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></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="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="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="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="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" 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 #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="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" 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.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</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">
|
||||
<el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<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="箱数" required><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.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>
|
||||
|
||||
Reference in New Issue
Block a user