From cb5f0d1becae3d318230707dd2298216671d543e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 11:58:16 +0800 Subject: [PATCH] feat(fleet): show authoritative trip receivables and receipts on demand --- .../overseas-master-data/PROTOTYPE_MATRIX.md | 2 + .../api/tripOperationsFinance.test.ts | 15 +++++++ .../api/tripOperationsFinance.ts | 23 +++++++++++ .../TripOperationsFinanceCell.test.ts | 29 ++++++++++++++ .../components/TripOperationsFinanceCell.vue | 40 +++++++++++++++++++ .../pages/TripOperationsView.vue | 5 ++- 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/modules/overseas-master-data/api/tripOperationsFinance.test.ts create mode 100644 src/modules/overseas-master-data/api/tripOperationsFinance.ts create mode 100644 src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts create mode 100644 src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 7d69750c..3f16d0be 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -85,3 +85,5 @@ | 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立。原实发、实签、本趟实退及未结箱数分别展示,混合结果显示“余货已签收”;一次结案查询提供数量,不逐票补请求,1920×1080真实视觉待验 | | 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 | + +| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 | diff --git a/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts b/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts new file mode 100644 index 00000000..690c583d --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperationsFinance.test.ts @@ -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()), 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('车次不匹配') +}) diff --git a/src/modules/overseas-master-data/api/tripOperationsFinance.ts b/src/modules/overseas-master-data/api/tripOperationsFinance.ts new file mode 100644 index 00000000..66763ede --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperationsFinance.ts @@ -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 & { finance: Omit & { + totals: { currency: string; receivableAmount: string | number; receivedAmount: string | number | null }[] + sources: (Omit & { amount: string | number })[] + documents: (Omit & { receivedAmount: string | number | null })[] +} } +export async function getTripOperationsFinance(tripNo: string): Promise { + const data = unwrapApiResponse(await http.get, ApiEnvelope>(`/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) })), + } } +} diff --git a/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts new file mode 100644 index 00000000..d4fd7000 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.test.ts @@ -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: '' }, ElPopover: { template: '
' }, 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('实收 无读取权限') +}) diff --git a/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue new file mode 100644 index 00000000..927a0c26 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripOperationsFinanceCell.vue @@ -0,0 +1,40 @@ + + + + diff --git a/src/modules/overseas-master-data/pages/TripOperationsView.vue b/src/modules/overseas-master-data/pages/TripOperationsView.vue index 177a6ced..477b427d 100644 --- a/src/modules/overseas-master-data/pages/TripOperationsView.vue +++ b/src/modules/overseas-master-data/pages/TripOperationsView.vue @@ -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++ } - +