fix: close appointment cancellation bypass and clarify verified cargo

This commit is contained in:
2026-10-11 14:14:49 +08:00
parent 240aa5d6d0
commit ee174f8265
13 changed files with 131 additions and 24 deletions
@@ -41,6 +41,7 @@ export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
export interface OfferCustomerOption { id: string; code: string; name: string }
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
@@ -8,7 +8,7 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
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 { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
@@ -47,6 +47,7 @@ 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))
@@ -127,12 +128,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<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>已知货量(可先填写,未知可留空)</summary><div class="capacity-inquiry-form">
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></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="预计标准托位"><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="`${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>
@@ -2,7 +2,7 @@
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'
import { inquiryCargoLabel, 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] }>()
@@ -26,7 +26,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '货量', minWidth: 270, slotName: 'cargo', singleLine: false },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
@@ -37,7 +37,7 @@ const columns: DataTableColumn[] = [
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></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>
@@ -38,6 +38,27 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
const list = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
DataTable: { props: ['data', 'columns'], template: '<div><slot name="cargo" :row="data[0]"/></div>' },
} } })
wrappers.push(list)
expect(list.text()).toContain(`${label}:12 箱 · 3 托位 · 80 kg · 2 m³`)
expect(list.text()).toContain('预计实托数:7')
expect(list.text()).not.toContain('7 实托')
await list.setProps({ rows: [{ ...row, expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
expect(list.text()).toContain(`${label}:— 箱`)
expect(list.text()).toContain('预计实托数:—')
const { wrapper: detail } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
await flushPromises()
for (const suffix of ['箱数', '标准托位', '重量(kg)', '体积(m³)']) expect(detail.find(`[label="${label}${suffix}"]`).exists()).toBe(true)
expect(detail.findAll('summary').map(item => item.text())).toContain(label === '已核实' ? '已核实货量与预计实托数' : `${label}货量`)
expect(detail.find('[label="预计实托数"]').exists()).toBe(true)
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()