feat(fleet): show authoritative trip receivables and receipts on demand

This commit is contained in:
2026-10-05 11:58:16 +08:00
parent 110c2e0be2
commit cb5f0d1bec
6 changed files with 112 additions and 2 deletions
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import Cell from './TripOperationsFinanceCell.vue'
import * as api from '../api/tripOperationsFinance'
const access = vi.hoisted(() => ({ read: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
vi.mock('../api/tripOperationsFinance', () => ({ getTripOperationsFinance: vi.fn() }))
const wrappers: VueWrapper[] = []
function mount() { const w = shallowMount(Cell, { props: { tripNo: 'TR-A' }, global: { stubs: { ElButton: { template: '<button><slot/></button>' }, ElPopover: { template: '<div><slot/><slot name="reference"/></div>' }, ApiErrorMeta: true } } }); wrappers.push(w); return w }
const result: api.TripFinanceResult = { tripNo: 'TR-A', dispatchNos: ['DS-A'], receiptsVisible: true, attributionStatus: 'ATTRIBUTED', attributionReason: 'frozen', finance: { status: 'AVAILABLE', totals: [{ currency: 'CNY', receivableAmount: 20, receivedAmount: null }], sources: [], documents: [{ billNo: 'BILL-A', currency: 'CNY', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '混合来源' }], reason: 'authority' } }
beforeEach(() => { vi.resetAllMocks(); access.read = true; vi.mocked(api.getTripOperationsFinance).mockResolvedValue(structuredClone(result)) })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
it('loads only on explicit click and displays pending without inventing zero', async () => {
const w = mount(); await flushPromises(); expect(api.getTripOperationsFinance).not.toHaveBeenCalled()
await w.find('button').trigger('click'); await flushPromises()
expect(api.getTripOperationsFinance).toHaveBeenCalledExactlyOnceWith('TR-A')
expect(w.text()).toContain('应收 20.00 / 实收 待分配'); expect(w.text()).toContain('BILL-A'); expect(w.text()).not.toContain('实收 0.00')
})
it('does not fetch without existing financial permission', async () => { access.read = false; const w = mount(); await flushPromises(); expect(w.text()).toContain('无应收读取权限'); expect(api.getTripOperationsFinance).not.toHaveBeenCalled() })
it('ignores a response arriving after switching trip', async () => {
let resolve!: (v: api.TripFinanceResult) => void
vi.mocked(api.getTripOperationsFinance).mockImplementation(() => new Promise(r => { resolve = r }))
const w = mount(); await w.find('button').trigger('click'); await w.setProps({ tripNo: 'TR-B' }); resolve(result); await flushPromises()
expect(w.text()).not.toContain('20.00'); expect(w.text()).toContain('查看应收/实收')
})
it('separates no receipt permission from unallocated values', async () => {
vi.mocked(api.getTripOperationsFinance).mockResolvedValue({ ...result, receiptsVisible: false, finance: { ...result.finance, documents: [] } })
const w = mount(); await w.find('button').trigger('click'); await flushPromises(); expect(w.text()).toContain('实收 无读取权限')
})
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getTripOperationsFinance, type TripFinanceResult } from '../api/tripOperationsFinance'
const props = defineProps<{ tripNo: string }>()
const access = useAccessPreviewStore(), result = ref<TripFinanceResult | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let sequence = 0
async function load() {
if (!access.can('platform:finance:customer-arap:list') || !access.can('platform:fleet:operations:list') || loading.value) return
const current = ++sequence, tripNo = props.tripNo; loading.value = true; error.value = null
try { const data = await getTripOperationsFinance(tripNo); if (current === sequence) result.value = data }
catch (cause) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(cause, '财务读取失败') } }
finally { if (current === sequence) loading.value = false }
}
watch(() => props.tripNo, () => { sequence++; result.value = null; error.value = null; loading.value = false })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<span v-if="!access.can('platform:finance:customer-arap:list')">无应收读取权限</span>
<template v-else>
<el-button v-if="!result" class="density-action" :loading="loading" @click="load">{{ error ? '重试财务读取' : '查看应收/实收' }}</el-button>
<span v-if="error">{{ error.message }}<ApiErrorMeta :details="error" /></span>
<template v-if="result">
<div v-for="m in result.finance.totals" :key="m.currency">{{ m.currency }} 应收 {{ m.receivableAmount.toFixed(2) }} / 实收 {{ !result.receiptsVisible ? '无读取权限' : m.receivedAmount == null ? '待分配' : m.receivedAmount.toFixed(2) }}</div>
<span v-if="!result.finance.totals.length || result.finance.status === 'PENDING_ALLOCATION' || result.attributionStatus === 'PENDING_ALLOCATION'">完整金额待分配</span>
<el-popover trigger="click" :width="620" placement="left">
<div class="finance-source-details"><p>{{ result.attributionReason }}</p><p>{{ result.finance.reason }}</p>
<p>排车来源:{{ result.dispatchNos.join(' / ') || '—' }}</p>
<div v-for="s in result.finance.sources" :key="`${s.customerId}:${s.businessNo}:${s.sourceNo}:${s.feeItemName}:${s.sourceVersion}`">{{ s.businessNo }} · {{ s.sourceNo || '—' }} · {{ s.feeItemName }} · 版本 {{ s.sourceVersion }} · {{ s.currency }} {{ s.amount.toFixed(2) }}</div>
<div v-for="d in result.finance.documents" :key="d.billNo">{{ d.billNo }} · {{ d.currency }} {{ d.receivedAmount == null ? '待分配' : d.receivedAmount.toFixed(2) }} · {{ d.reason }}</div>
</div><template #reference><el-button class="density-action">金额来源</el-button></template>
</el-popover>
<el-button class="density-action" :loading="loading" @click="load">刷新</el-button>
</template>
</template>
</template>
<style scoped>.finance-source-details { max-height: 50vh; overflow: auto; }</style>