feat(fleet): select versioned sales quotes and isolate cost drafts
This commit is contained in:
@@ -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,
|
||||
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,
|
||||
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 }] } }
|
||||
|
||||
@@ -4,11 +4,11 @@ import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
const access = vi.hoisted(() => ({ writable: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.writable }) }))
|
||||
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', () => ({ 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, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
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',
|
||||
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[] = []
|
||||
@@ -24,7 +24,7 @@ function mount() {
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.writable = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
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('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 })
|
||||
@@ -68,7 +68,53 @@ it('can edit an inquiry after closing its read-only details', async () => {
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, recordVersion: 1 }, replayed: false })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', {
|
||||
tripRecordVersion: 4, recordVersion: 0, cargo: row.cargo,
|
||||
tripRecordVersion: 4, recordVersion: 0, cargo: row.cargo, quoteRef: null,
|
||||
})
|
||||
expect(api.listTripBookings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const source = { quoteNo: 'FQ-ZERO', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户甲', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 30, currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: 0, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
|
||||
const candidatePage = { records: [source], page: 1, size: 20, total: 1, pages: 1 }
|
||||
it('queries only on request, keeps the exact source version and saves independently negotiated price', async () => {
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockResolvedValue(candidatePage)
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
expect(api.listTripBookingQuoteCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
|
||||
expect(api.listTripBookingQuoteCandidates).toHaveBeenCalledExactlyOnceWith('TR-1', { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD', priceBasis: 'PALLET', page: 1, size: 20 })
|
||||
wrapper.findAllComponents({ name: 'ElSelect' }).find(component => component.attributes('data-testid') === 'booking-quote-candidate')!.vm.$emit('update:modelValue', 'FQ-ZERO:9007199254740993:LOOSE_DELIVERY'); await flushPromises()
|
||||
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
|
||||
expect(price.props('modelValue')).toBe(0)
|
||||
price.vm.$emit('update:modelValue', 12.34); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }, cargo: expect.objectContaining({ unitPrice: 12.34 }) }))
|
||||
})
|
||||
it('ignores an old candidate response after changing destination and allows manual pricing', async () => {
|
||||
let resolve!: (page: typeof candidatePage) => void
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '查询报价库').trigger('click')
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'PRIVATE:ADDRESS'); await flushPromises()
|
||||
resolve(candidatePage); await flushPromises()
|
||||
expect(wrapper.find('[data-testid="booking-quote-candidate"]').exists()).toBe(false)
|
||||
expect(button(wrapper, '查询报价库').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
|
||||
})
|
||||
it('clears a selected source explicitly without erasing the negotiated price', async () => {
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, quoteSource: source }] })
|
||||
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '改为手工询价').trigger('click')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: null, cargo: row.cargo }))
|
||||
})
|
||||
it('keeps manual entry available when candidate lookup fails and hides lookup without quote permission', async () => {
|
||||
vi.mocked(api.listTripBookingQuoteCandidates).mockRejectedValue(new Error('仓库未配置时区'))
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('报价库查询未完成,可继续手工询价')
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
|
||||
access.quote = false
|
||||
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
|
||||
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking } from '../api/tripBookings'
|
||||
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking, listTripBookingQuoteCandidates } from '../api/tripBookings'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
@@ -25,6 +26,32 @@ const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<
|
||||
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 form = reactive<TripBookingCargo>(blank())
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), quoteRef = ref<FleetQuoteReference | null>(null)
|
||||
const candidates = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null)
|
||||
const quotePage = ref(1), quotePages = ref(0), quoteSearched = ref(false)
|
||||
let quoteSequence = 0
|
||||
const canReadQuotes = computed(() => access.can(permission.fleetQuoteList))
|
||||
const quoteLocked = computed(() => readOnly.value || busy.value || !!pendingCreate.value || !canReadQuotes.value)
|
||||
const canQueryQuotes = computed(() => !!customerId.value && form.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.currency || ''))
|
||||
const sourceKey = (value: FleetQuoteSourceSnapshot | FleetQuoteReference) => `${value.quoteNo}:${value.recordVersion}:${value.priceItem}`
|
||||
function resetCandidates() { quoteSequence++; candidates.value = []; quoteError.value = null; quoteLoading.value = false; quoteSearched.value = false; quotePage.value = 1; quotePages.value = 0 }
|
||||
function clearQuote() { if (quoteLocked.value) return; quoteRef.value = null; quoteSource.value = null }
|
||||
function selectQuote(key: string) {
|
||||
if (quoteLocked.value) return
|
||||
const source = candidates.value.find(row => sourceKey(row) === key)
|
||||
if (!source) return
|
||||
quoteSource.value = source; quoteRef.value = fleetQuoteReference(source); form.unitPrice = source.quotedUnitPrice
|
||||
}
|
||||
async function queryQuotes(page = 1) {
|
||||
if (quoteLocked.value || !canQueryQuotes.value) return
|
||||
const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null
|
||||
try {
|
||||
const result = await listTripBookingQuoteCandidates(props.trip.tripNo, { customerId: customerId.value, destinationKey: form.destinationKey, currency: form.currency as 'CNY' | 'USD', priceBasis: form.priceBasis, page, size: 20 })
|
||||
if (current === quoteSequence) { candidates.value = result.records; quotePage.value = result.page; quotePages.value = result.pages; quoteSearched.value = true }
|
||||
} catch (cause) { if (current === quoteSequence) { candidates.value = []; quoteError.value = getApiErrorDetails(cause, '报价库查询未完成,可继续手工询价') } }
|
||||
finally { if (current === quoteSequence) quoteLoading.value = false }
|
||||
}
|
||||
watch(() => [customerId.value, form.destinationKey, form.currency, form.priceBasis], () => { resetCandidates(); quoteSource.value = null; quoteRef.value = null }, { flush: 'sync' })
|
||||
let generation = 0, searchSequence = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const planned = computed(() => props.trip.status === 'PLANNED')
|
||||
@@ -59,8 +86,9 @@ function edit(row?: TripBooking, viewOnly = false) {
|
||||
readOnly.value = viewOnly
|
||||
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
|
||||
editing.value = row || null
|
||||
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) }
|
||||
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) }
|
||||
resetCandidates()
|
||||
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo); quoteRef.value = pendingCreate.value.quoteRef || null }
|
||||
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()); quoteSource.value = row?.quoteSource || null; quoteRef.value = quoteSource.value ? fleetQuoteReference(quoteSource.value) : null }
|
||||
open.value = true
|
||||
}
|
||||
function accept(value: TripBookingResult) {
|
||||
@@ -75,13 +103,14 @@ function failure(cause: unknown, fallback: string) {
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || readOnly.value || version.value == null) return
|
||||
if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return }
|
||||
if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
let value: TripBookingResult
|
||||
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }) })
|
||||
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null })
|
||||
else {
|
||||
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), idempotencyKey: crypto.randomUUID() }
|
||||
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null, idempotencyKey: crypto.randomUUID() }
|
||||
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
|
||||
}
|
||||
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
|
||||
@@ -106,7 +135,9 @@ async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) })
|
||||
watch(open, value => { if (!value) resetCandidates() })
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion], resetCandidates)
|
||||
onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTimeout(searchTimer) })
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
@@ -136,6 +167,22 @@ onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer
|
||||
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
|
||||
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<section class="booking-quote" aria-label="报价来源">
|
||||
<p v-if="quoteSource">来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }}<br>来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}</p>
|
||||
<p v-else>{{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}</p>
|
||||
<template v-if="!readOnly && canReadQuotes">
|
||||
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button>
|
||||
<el-button v-if="quoteRef" :disabled="quoteLocked" @click="clearQuote">改为手工询价</el-button>
|
||||
<p v-if="!canQueryQuotes">选择客户、FBA卸货站及CNY或USD后可查询;其他场景可继续填写手工价格。</p>
|
||||
<el-select v-if="candidates.length" data-testid="booking-quote-candidate" :model-value="quoteRef ? sourceKey(quoteRef) : ''" :disabled="quoteLocked || quoteLoading" placeholder="选择报价后带入单价,可另行议价" @update:model-value="selectQuote">
|
||||
<el-option v-for="item in candidates" :key="sourceKey(item)" :value="sourceKey(item)" :label="`${item.quoteNo} · ${item.quotedUnitPrice.toFixed(2)} ${item.currency} · ${item.validFrom} 至 ${item.validTo}`" />
|
||||
</el-select>
|
||||
<p v-if="quoteSearched && !candidates.length">暂无符合本车路线、客户、日期和币种的报价,可继续手工询价。</p>
|
||||
<div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
|
||||
<p v-if="quoteError" role="alert">{{ quoteError.message }} <ApiErrorMeta :details="quoteError" /></p>
|
||||
</template>
|
||||
<p v-if="quoteRef">来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。</p>
|
||||
</section>
|
||||
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
|
||||
</AppDialog>
|
||||
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripCostPanel from './TripCostPanel.vue'
|
||||
import * as api from '../api/tripCosts'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ read: true, write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
@@ -21,7 +22,7 @@ function mount() {
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(w: VueWrapper, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost)
|
||||
beforeEach(() => { vi.resetAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost)
|
||||
vi.mocked(api.listTripCostHistory).mockResolvedValue({ records: [{ recordVersion: '9', action: 'UPDATE', reason: '更正', occurredAt: cost.updatedAt, snapshot: { cost: { ...cost, recordVersion: '9' }, summary: result.summary } }], page: 1, size: 1, total: 1, pages: 1 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
it('loads one scoped page and summary without per-row reads or invented zero totals', async () => {
|
||||
@@ -67,7 +68,7 @@ it('keeps the successful save authoritative when pagination changes while it is
|
||||
expect(api.listTripCosts).toHaveBeenLastCalledWith('TR-ONE', 1, 10)
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('does not let an old save clear a new pending save after switching away and back', async () => {
|
||||
it('retains the original request across trip switches and ignores the first response during its retry', async () => {
|
||||
let finishFirst!: (value: api.TripCost) => void
|
||||
let finishSecond!: (value: api.TripCost) => void
|
||||
vi.mocked(api.createTripCost)
|
||||
@@ -77,6 +78,8 @@ it('does not let an old save clear a new pending save after switching away and b
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises()
|
||||
await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
await w.setProps({ tripNo: 'TR-ONE' }); await flushPromises()
|
||||
await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
@@ -84,9 +87,22 @@ it('does not let an old save clear a new pending save after switching away and b
|
||||
finishFirst(cost); await flushPromises()
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
expect(api.createTripCost).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(vi.mocked(api.createTripCost).mock.calls[0])
|
||||
finishSecond(cost); await flushPromises()
|
||||
expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false)
|
||||
})
|
||||
it('unlocks a definite HTTP200 business rejection for correction with a fresh create key', async () => {
|
||||
vi.mocked(api.createTripCost).mockRejectedValueOnce(new ApiResponseError('业务拒绝', 'A0422', 200))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripCost).mock.calls[0]!
|
||||
expect(w.text()).not.toContain('正在核对上次登记结果')
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
|
||||
await button(w, '保存').trigger('click'); await flushPromises()
|
||||
const second = vi.mocked(api.createTripCost).mock.calls[1]!
|
||||
expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10)
|
||||
})
|
||||
it('reads the current version before editing and avoids writing unchanged contents', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '更正').trigger('click'); await flushPromises()
|
||||
expect(api.listTripCostHistory).toHaveBeenCalledExactlyOnceWith('TR-ONE', 'TC-ONE', 1, 1)
|
||||
|
||||
@@ -19,7 +19,7 @@ const rows = ref<TripCost[]>([]), summary = ref<CostSummary | null>(null), page
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false), editing = ref<TripCost | null>(null), opening = ref(false), saving = ref(false), reason = ref('')
|
||||
const form = reactive<{ category: CostCategory; currency: CostCurrency; amount: number | null; incurredAt: string; remark: string }>({ category: 'FUEL', currency: 'USD', amount: null, incurredAt: '', remark: '' })
|
||||
const pendingCreate = ref<{ key: string; details: TripCostDraft } | null>(null)
|
||||
const pendingCreate = ref<{ tripNo: string; key: string; details: TripCostDraft } | null>(null)
|
||||
const writable = computed(() => allowed.value && access.can(editing.value ? permission.tripCostEdit : permission.tripCostCreate))
|
||||
const historyOpen = ref(false), historyNo = ref(''), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const histories = ref<TripCostHistory[]>([]), historyError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -66,13 +66,14 @@ async function load() {
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
watch([() => props.tripNo, page, size, allowed], (current, previous) => {
|
||||
if (previous?.length && current[0] !== previous[0]) { scopeGeneration++; generation++; historyGeneration++; editorOpen.value = false; historyOpen.value = false; pendingCreate.value = null; opening.value = false; saving.value = false }
|
||||
if (previous?.length && current[0] !== previous[0]) { scopeGeneration++; generation++; historyGeneration++; editorOpen.value = false; historyOpen.value = false; opening.value = false; saving.value = false }
|
||||
if (!current[3]) { scopeGeneration++; editorOpen.value = false; historyOpen.value = false; histories.value = []; historyGeneration++; opening.value = false; saving.value = false }
|
||||
if (previous?.length && (current[0] !== previous[0] || current[2] !== previous[2]) && page.value !== 1) { page.value = 1; return }
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
async function beginEdit(row: TripCost | null) {
|
||||
if (opening.value || saving.value) return
|
||||
if (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) { ElMessage.warning(`请先返回车次 ${pendingCreate.value.tripNo} 核对上次登记结果`); return }
|
||||
if (row && pendingCreate.value) { ElMessage.warning('请先重新打开登记窗口,核对上次保存结果'); return }
|
||||
if (!allowed.value || !access.can(row ? permission.tripCostEdit : permission.tripCostCreate)) return
|
||||
const scope = props.tripNo, request = scopeGeneration
|
||||
@@ -86,7 +87,8 @@ async function beginEdit(row: TripCost | null) {
|
||||
}
|
||||
if (request !== scopeGeneration || scope !== props.tripNo) return
|
||||
editing.value = current; reason.value = ''
|
||||
if (!pendingCreate.value) Object.assign(form, current ? { ...current.details, remark: current.details.remark ?? '' }
|
||||
if (pendingCreate.value) Object.assign(form, pendingCreate.value.details, { remark: pendingCreate.value.details.remark ?? '' })
|
||||
else Object.assign(form, current ? { ...current.details, remark: current.details.remark ?? '' }
|
||||
: { category: 'FUEL', currency: 'USD', amount: null, incurredAt: new Date().toISOString(), remark: '' })
|
||||
lastSaved = current ? JSON.stringify(draft()) : ''
|
||||
editorOpen.value = true
|
||||
@@ -95,6 +97,7 @@ async function beginEdit(row: TripCost | null) {
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || !writable.value) return
|
||||
if (pendingCreate.value && pendingCreate.value.tripNo !== props.tripNo) return
|
||||
if (form.amount == null || !Number.isFinite(form.amount) || form.amount < 0 || !form.incurredAt) { ElMessage.warning('请填写金额和实际发生时间'); return }
|
||||
const details = pendingCreate.value?.details ?? draft()
|
||||
if (editing.value && JSON.stringify(details) === lastSaved) { editorOpen.value = false; return }
|
||||
@@ -103,7 +106,7 @@ async function save() {
|
||||
try {
|
||||
if (editing.value) await updateTripCost(scope, editing.value, details, reason.value)
|
||||
else {
|
||||
pendingCreate.value ??= { key: crypto.randomUUID(), details }
|
||||
pendingCreate.value ??= { tripNo: scope, key: crypto.randomUUID(), details }
|
||||
await createTripCost(scope, pendingCreate.value.key, pendingCreate.value.details)
|
||||
}
|
||||
if (scope !== props.tripNo || request !== scopeGeneration) return
|
||||
|
||||
@@ -17,7 +17,7 @@ const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePla
|
||||
const container = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.recordNo"><slot name="actions" :row="row"/><span data-test="status"><slot name="status" :row="row"/></span><span data-test="payment"><slot name="payment" :row="row"/></span><span data-test="cost"><slot name="cost" :row="row"/></span></div></div>' }
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' }
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue', 'showConfirm'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(component: object, props: Record<string, unknown>): VueWrapper {
|
||||
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
|
||||
@@ -164,3 +164,17 @@ it('editing an exception does not mutate the displayed source until a command su
|
||||
expect(exception.details.incident?.description).toBe('原异常描述')
|
||||
expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let an older finance batch overwrite the status just read for the opened record', async () => {
|
||||
const repair: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'REPAIR' } }
|
||||
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [repair], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(api.getVehicleCare).mockResolvedValue(repair)
|
||||
let finish!: (value: Awaited<ReturnType<typeof api.listVehicleCareFinance>>) => void
|
||||
vi.mocked(api.listVehicleCareFinance).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
.mockResolvedValueOnce([{ recordNo: row.recordNo, status: 'PENDING_FINANCE', sourceVersion: '1', submittedRecordVersion: '1', finance: null }])
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR' }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'finance'); await flushPromises()
|
||||
finish([{ recordNo: row.recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null }]); await flushPromises()
|
||||
expect(wrapper.get('[data-test="payment"]').text()).not.toBe('未提交财务')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue'
|
||||
import * as api from '../api/companyVehicleCosts'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const access = vi.hoisted(() => ({ permissions: new Set<string>() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
@@ -98,3 +99,40 @@ it('cannot void or pay again when the server says paid, and booking is an indepe
|
||||
expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'approve', expect.any(String), { expectedVersion: '0', reason: null })
|
||||
expect(api.payCompanyCost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not carry another cost payment draft into the next record', async () => {
|
||||
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
await w.get('input[placeholder="填写银行流水号或支付凭据号"]').setValue('previous-cost-reference')
|
||||
await files(w)
|
||||
vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' })
|
||||
await w.setProps({ costNo: 'VC-TWO' }); await flushPromises()
|
||||
await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
expect(w.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBeNull()
|
||||
expect((w.get('input[placeholder="填写银行流水号或支付凭据号"]').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.text()).not.toContain('已上传:payment.png')
|
||||
})
|
||||
|
||||
it.each(['A0422', 'FIN_409'])('unlocks a definite HTTP200 %s rejection while retaining entered payment and vouchers', async code => {
|
||||
vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new ApiResponseError('明确拒绝', code, 200))
|
||||
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w)
|
||||
await button(w, '确认操作').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(api.payCompanyCost).mock.calls[0]!
|
||||
expect(w.text()).not.toContain('上次操作结果尚未确认'); expect(w.text()).toContain('已上传:payment.png')
|
||||
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
|
||||
await button(w, '确认操作').trigger('click'); await flushPromises()
|
||||
const second = vi.mocked(api.payCompanyCost).mock.calls[1]!
|
||||
expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10)
|
||||
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores an old detail response after another cost is opened', async () => {
|
||||
let finish!: (value: api.CompanyCost) => void
|
||||
vi.mocked(api.getCompanyCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const w = mount()
|
||||
vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' })
|
||||
await w.setProps({ costNo: 'VC-TWO' }); await flushPromises()
|
||||
finish(detail); await flushPromises()
|
||||
expect(w.text()).toContain('VC-TWO'); expect(w.text()).not.toContain('VC-ONE')
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ async function load() {
|
||||
watch([() => props.costNo, allowed], () => {
|
||||
epoch++; readRequest++; recordsRequest++; pending.value = null; action.value = null; saving.value = false; uploading.value = false
|
||||
vouchers.value = []; pendingFiles.value = []; history.value = []; payments.value = []; tab.value = 'current'; historyPage.value = 1; paymentPage.value = 1
|
||||
reason.value = ''; Object.assign(form, { amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
|
||||
writeError.value = null; recordsError.value = null; void load()
|
||||
}, { immediate: true })
|
||||
watch(() => props.active, value => { if (value && !busy.value) void load() })
|
||||
|
||||
@@ -34,6 +34,8 @@ const financeStatus = computed(() => financeTarget.value ? financeStates.value[f
|
||||
const canSubmitFinance = computed(() => access.can(vehicleCareFinanceSubmitPermission) && access.can('platform:supplier:list')
|
||||
&& !!financeStatus.value && ['UNLINKED', 'VOIDED'].includes(financeStatus.value.status))
|
||||
let request = 0, operationEpoch = 0, supplierRequest = 0, disposed = false
|
||||
let financeRequest = 0, financeBatch = 0
|
||||
const financeVersions = new Map<string, number>()
|
||||
watch([() => props.vehicleId, () => props.recordType], () => {
|
||||
operationEpoch++; supplierRequest++; financeOpen.value = false; financeTarget.value = null
|
||||
financeOpening.value = false; financeSaving.value = false; pendingFinance.value = null
|
||||
@@ -74,12 +76,17 @@ function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(p
|
||||
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) },
|
||||
{ key: 'finance', label: '财务', visible: costRecord.value, disabled: financeOpening.value || financeSaving.value }] }
|
||||
async function loadFinance() {
|
||||
const batch = ++financeBatch
|
||||
if (!costRecord.value || !rows.value.length) { financeLoading.value = false; return }
|
||||
const revision = ++financeRequest, numbers = rows.value.map(row => row.recordNo)
|
||||
for (const number of numbers) financeVersions.set(number, revision)
|
||||
const scope = request; financeLoading.value = true; financeError.value = null; financeStates.value = {}
|
||||
try { const result = await listVehicleCareFinance(props.vehicleId, rows.value.map(row => row.recordNo))
|
||||
if (!disposed && scope === request) financeStates.value = Object.fromEntries(result.map(row => [row.recordNo, row]))
|
||||
} catch (cause) { if (!disposed && scope === request) financeError.value = getApiErrorDetails(cause, '财务状态未确认') }
|
||||
finally { if (scope === request) financeLoading.value = false }
|
||||
try { const result = await listVehicleCareFinance(props.vehicleId, numbers)
|
||||
if (!disposed && scope === request && batch === financeBatch) {
|
||||
for (const row of result) if (financeVersions.get(row.recordNo) === revision) financeStates.value[row.recordNo] = row
|
||||
}
|
||||
} catch (cause) { if (!disposed && scope === request && batch === financeBatch) financeError.value = getApiErrorDetails(cause, '财务状态未确认') }
|
||||
finally { if (scope === request && batch === financeBatch) financeLoading.value = false }
|
||||
}
|
||||
async function openFinance(row: VehicleCareRecord) {
|
||||
if (financeOpening.value || financeSaving.value) return
|
||||
@@ -93,10 +100,14 @@ async function openFinance(row: VehicleCareRecord) {
|
||||
financeOpening.value = true; financeSaveError.value = null
|
||||
try {
|
||||
const current = await getVehicleCare(vehicleId, row.recordNo)
|
||||
if (disposed || scope !== operationEpoch) return
|
||||
const revision = ++financeRequest
|
||||
financeVersions.set(row.recordNo, revision)
|
||||
const statuses = await listVehicleCareFinance(vehicleId, [row.recordNo])
|
||||
if (disposed || scope !== operationEpoch) return
|
||||
financeTarget.value = current; financeStates.value[row.recordNo] = statuses[0]!
|
||||
ledgerCostNo.value = statuses[0]?.finance?.costNo ?? ''
|
||||
financeTarget.value = current
|
||||
if (financeVersions.get(row.recordNo) === revision) financeStates.value[row.recordNo] = statuses[0]!
|
||||
ledgerCostNo.value = financeStates.value[row.recordNo]?.finance?.costNo ?? ''
|
||||
if (!pendingFinance.value) { supplierId.value = ''; businessDate.value = new Date(current.details.occurredAt).toLocaleDateString('en-CA'); suppliers.value = [] }
|
||||
financeOpen.value = true
|
||||
} catch (cause) { if (!disposed && scope === operationEpoch) financeError.value = getApiErrorDetails(cause, '财务详情读取失败') }
|
||||
|
||||
Reference in New Issue
Block a user