feat(fleet): show authoritative trip receivables and receipts on demand
This commit is contained in:
@@ -85,3 +85,5 @@
|
||||
| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立。原实发、实签、本趟实退及未结箱数分别展示,混合结果显示“余货已签收”;一次结案查询提供数量,不逐票补请求,1920×1080真实视觉待验 |
|
||||
|
||||
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
|
||||
|
||||
| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getTripOperationsFinance } from './tripOperationsFinance'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('reads only the requested trip, separates currencies and keeps unknown receipts null', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'TR/A', dispatchNos: ['DS-A'], receiptsVisible: true, attributionStatus: 'ATTRIBUTED', attributionReason: 'frozen', finance: { status: 'AVAILABLE', reason: 'authority', totals: [{ currency: 'CNY', receivableAmount: '0', receivedAmount: null }, { currency: 'USD', receivableAmount: '20.12', receivedAmount: '3.5' }], sources: [], documents: [] } } })
|
||||
const result = await getTripOperationsFinance('TR/A')
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations/TR%2FA/finance')
|
||||
expect(result.finance.totals).toEqual([{ currency: 'CNY', receivableAmount: 0, receivedAmount: null }, { currency: 'USD', receivableAmount: 20.12, receivedAmount: 3.5 }])
|
||||
})
|
||||
it('rejects a response belonging to another trip', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tripNo: 'OTHER' } })
|
||||
await expect(getTripOperationsFinance('TR-A')).rejects.toThrow('车次不匹配')
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { fleetMoney } from './fleetQuotes'
|
||||
export interface TripFinanceMoney { currency: string; receivableAmount: number; receivedAmount: number | null }
|
||||
export interface TripFinanceSource { customerId: string; businessNo: string; sourceNo: string | null; feeItemName: string; sourceVersion: number; currency: string; amount: number; status: string }
|
||||
export interface TripFinanceDocument { billNo: string; currency: string; receivedAmount: number | null; allocationStatus: string; reason: string }
|
||||
export interface TripFinanceResult {
|
||||
tripNo: string; dispatchNos: string[]; receiptsVisible: boolean; attributionStatus: string; attributionReason: string
|
||||
finance: { status: string; totals: TripFinanceMoney[]; sources: TripFinanceSource[]; documents: TripFinanceDocument[]; reason: string }
|
||||
}
|
||||
type Wire = Omit<TripFinanceResult, 'finance'> & { finance: Omit<TripFinanceResult['finance'], 'totals' | 'sources' | 'documents'> & {
|
||||
totals: { currency: string; receivableAmount: string | number; receivedAmount: string | number | null }[]
|
||||
sources: (Omit<TripFinanceSource, 'amount'> & { amount: string | number })[]
|
||||
documents: (Omit<TripFinanceDocument, 'receivedAmount'> & { receivedAmount: string | number | null })[]
|
||||
} }
|
||||
export async function getTripOperationsFinance(tripNo: string): Promise<TripFinanceResult> {
|
||||
const data = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/finance`))
|
||||
if (data.tripNo !== tripNo) throw new Error('财务响应车次不匹配')
|
||||
return { ...data, finance: { ...data.finance,
|
||||
totals: data.finance.totals.map(m => ({ ...m, receivableAmount: fleetMoney(m.receivableAmount), receivedAmount: m.receivedAmount == null ? null : fleetMoney(m.receivedAmount) })),
|
||||
sources: data.finance.sources.map(s => ({ ...s, amount: fleetMoney(s.amount) })),
|
||||
documents: data.finance.documents.map(d => ({ ...d, receivedAmount: d.receivedAmount == null ? null : fleetMoney(d.receivedAmount) })),
|
||||
} }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -12,6 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTripOperations, exportTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
|
||||
import { saveFleetCsv } from '../api/fleetQuotes'
|
||||
import TripOperationsFinanceCell from '../components/TripOperationsFinanceCell.vue'
|
||||
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
||||
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
@@ -31,7 +32,7 @@ const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 18
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
|
||||
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', width: 140, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
|
||||
function range(): TripOperationsQuery {
|
||||
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
|
||||
@@ -59,7 +60,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></template>
|
||||
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
|
||||
<template #distance="{ row }">{{ row.measured?.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row }">{{ row.measured?.actualFuelLitres ?? '—' }}</template>
|
||||
<template #agreed="{ row }">{{ money(row.externalAgreedAmounts) }}</template><template #finance>未查询财务</template>
|
||||
<template #agreed="{ row }">{{ money(row.externalAgreedAmounts) }}</template><template #finance="{ row }"><TripOperationsFinanceCell :trip-no="row.tripNo" /></template>
|
||||
<template #cost="{ row }"><span v-if="!row.costs">无成本读取权限</span><span v-else-if="!row.costs.totals.length">未登记成本</span><span v-else>{{ row.costs.totals.map((c: NonNullable<TripOperationsRow['costs']>['totals'][number]) => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') }}</span></template>
|
||||
<template #allocated="{ row }"><span v-if="!row.costs">—</span><span v-else-if="row.costs.allocationBlockReason">{{ row.costs.allocationBlockReason }}</span><span v-else>{{ row.costs.totals.map((c: NonNullable<TripOperationsRow['costs']>['totals'][number]) => `${c.currency} ${c.ownCost == null ? '—' : c.ownCost.toFixed(2)} / ${c.externalCost == null ? '—' : c.externalCost.toFixed(2)}`).join(';') || '—' }}</span></template>
|
||||
</DataTable>
|
||||
|
||||
Reference in New Issue
Block a user