From e3757cc9f9f1d91c33ad5b1731ef8d145e267e74 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:41:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/finance/api/finance.test.ts | 27 ++- src/modules/finance/api/finance.ts | 57 ++++-- .../finance/pages/FinanceManualPage.test.ts | 35 ++++ .../finance/pages/FinanceManualPage.vue | 164 ++++++++++++++---- src/modules/finance/types.ts | 22 ++- 5 files changed, 247 insertions(+), 58 deletions(-) create mode 100644 src/modules/finance/pages/FinanceManualPage.test.ts diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index e22856db..2d1b69d0 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { createFinanceCost, createFinanceReceipt, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts } from './finance' +import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { @@ -35,6 +35,31 @@ describe('finance api', () => { expect(post.mock.calls[1]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) + it('maps the real cost fee_item_name and cost_amount fields', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ id: 71, tenant_id: 10001, cost_type: 'CONTAINER_SHARED', container_id: 81, fee_item_name: '拖车费', cost_amount: '680.500000', voucher_file_ids: '["90001"]', created_at: '2026-08-11T10:00:00' }] }) + + const result = await listFinanceCosts('10001') + + expect(result.records[0]).toMatchObject({ id: '71', customerId: '10001', containerId: '81', expenseName: '拖车费', amount: 680.5, voucherFileIds: ['90001'] }) + }) + + it('normalizes bill details and uses update, adjust and void contracts', async () => { + const raw = { id: 81, tenant_id: 10001, bill_no: 'B-81', customer_name_snapshot: '客户A', bill_date: '2026-08-11', due_date: '2026-09-10', total_amount: 100, received_amount: 0, outstanding_amount: 100, status: 'DRAFT', version: 3, lines: [{ source_id: 91, source_version: 2, fee_item_name: '运费', amount: 100 }] } + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: raw }) + const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: raw }) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: raw }) + + const detail = await getFinanceBill('81') + await updateFinanceBill('81', { customerId: '10001', expectedVersion: 3, lines: [] }) + await adjustFinanceBill('81', { expectedVersion: 3, reason: '金额修正', lines: [] }) + await voidFinanceBill('81', 3, '重复账单') + + expect(detail).toMatchObject({ id: '81', customerId: '10001', version: 3, lines: [{ sourceFeeId: '91', sourceFeeVersion: 2, feeItemName: '运费', amount: 100 }] }) + expect(put).toHaveBeenCalledWith('/platform/finance/v1/bills/81', expect.objectContaining({ expectedVersion: 3 }), { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(post).toHaveBeenNthCalledWith(1, '/platform/finance/v1/bills/81/adjust', expect.objectContaining({ expectedVersion: 3, reason: '金额修正' }), { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/bills/81/void', { expectedVersion: 3, reason: '重复账单' }, { headers: { 'Idempotency-Key': expect.any(String) } }) + }) + it('issues a bill with optimistic version and an idempotency key', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 24f3008b..b98188ad 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -1,5 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types' +import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types' const basePath = '/platform/finance/v1' function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` } @@ -18,24 +18,59 @@ function normalizePage(payload: RawPage, map: (item: RawResource) => T): Page const records = Array.isArray(payload) ? payload : payload.records || [] return { records: records.map(map), total: Array.isArray(payload) ? records.length : Number(payload.total ?? records.length), serverPaged: !Array.isArray(payload) } } - -export async function listFinanceBills(params: { customerId: string; status?: string; page: number; pageSize: number }) { - const response = await http.get, ApiEnvelope>(`${basePath}/bills`, { params }) - return normalizePage(unwrapApiResponse(response), (item) => ({ - id: stringValue(item.id), billNo: stringValue(item.bill_no ?? item.billNo), customerId: params.customerId, - customerName: stringValue(item.customer_name_snapshot ?? item.customerName) || `客户 ${params.customerId}`, +function normalizeBill(item: RawResource, customerId = stringValue(item.tenant_id ?? item.customerId)): FinanceBillResource { + return { + id: stringValue(item.id), billNo: stringValue(item.bill_no ?? item.billNo), customerId, + customerName: stringValue(item.customer_name_snapshot ?? item.customerName) || `客户 ${customerId}`, billDate: stringValue(item.bill_date ?? item.billDate), dueDate: stringValue(item.due_date ?? item.dueDate), currency: 'CNY', totalAmount: numberValue(item.total_amount ?? item.totalAmount), paidAmount: numberValue(item.received_amount ?? item.paidAmount), outstandingAmount: numberValue(item.outstanding_amount ?? item.outstandingAmount), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), - })) + } +} +function normalizeBillLine(item: RawResource): FinanceBillLine { + return { + sourceFeeId: stringValue(item.source_id ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceFeeVersion) || 1, + orderId: item.order_id == null && item.orderId == null ? null : stringValue(item.order_id ?? item.orderId), + inboundId: item.inbound_id == null && item.inboundId == null ? null : stringValue(item.inbound_id ?? item.inboundId), + feeItemName: stringValue(item.fee_item_name ?? item.feeItemName), amount: numberValue(item.amount), + } +} +function normalizeBillDetail(item: RawResource): FinanceBillDetail { + return { ...normalizeBill(item), remark: stringValue(item.remark), lines: Array.isArray(item.lines) ? item.lines.map((line) => normalizeBillLine(line as RawResource)) : [] } +} +function mutationHeaders(operation: string) { return { headers: { 'Idempotency-Key': idempotencyKey(operation) } } } + +export async function listFinanceBills(params: { customerId: string; status?: string; page: number; pageSize: number }) { + const response = await http.get, ApiEnvelope>(`${basePath}/bills`, { params }) + return normalizePage(unwrapApiResponse(response), (item) => normalizeBill(item, params.customerId)) } export async function createFinanceBill(payload: Record) { - const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload }, { headers: { 'Idempotency-Key': idempotencyKey('bill') } }) + const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload }, mutationHeaders('bill')) return unwrapApiResponse(response) } +export async function getFinanceBill(billId: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/bills/${billId}`) + return normalizeBillDetail(unwrapApiResponse(response)) +} + +export async function updateFinanceBill(billId: string, payload: Record) { + const response = await http.put, ApiEnvelope>(`${basePath}/bills/${billId}`, payload, mutationHeaders('bill-update')) + return normalizeBillDetail(unwrapApiResponse(response)) +} + +export async function adjustFinanceBill(billId: string, payload: Record) { + const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/adjust`, payload, mutationHeaders('bill-adjust')) + return normalizeBillDetail(unwrapApiResponse(response)) +} + +export async function voidFinanceBill(billId: string, expectedVersion: number, reason: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/void`, { expectedVersion, reason }, mutationHeaders('bill-void')) + return normalizeBillDetail(unwrapApiResponse(response)) +} + export async function listFinanceReceipts(customerId?: string) { const response = await http.get, ApiEnvelope>(`${basePath}/receipts`, { params: { customerId: customerId || undefined } }) const payload = unwrapApiResponse(response) @@ -64,7 +99,7 @@ export async function listFinanceCosts(customerId?: string) { return normalizePage(unwrapApiResponse(response), (item) => ({ id: stringValue(item.id), customerId: stringValue(item.tenant_id), costType: stringValue(item.cost_type) as FinanceCostResource['costType'], orderId: item.order_id == null ? null : stringValue(item.order_id), containerId: item.container_id == null ? null : stringValue(item.container_id), - expenseName: stringValue(item.expense_name), amount: numberValue(item.amount), currency: 'CNY', + expenseName: stringValue(item.fee_item_name ?? item.expense_name), amount: numberValue(item.cost_amount ?? item.amount), currency: 'CNY', voucherFileIds: fileIds(item.voucher_file_ids), createdAt: stringValue(item.created_at), })) } @@ -75,6 +110,6 @@ export async function createFinanceCost(payload: Record, vouche } export async function issueFinanceBill(billId: string, expectedVersion: number) { - const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, { headers: { 'Idempotency-Key': idempotencyKey('bill-issue') } }) + const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, mutationHeaders('bill-issue')) return unwrapApiResponse(response) } diff --git a/src/modules/finance/pages/FinanceManualPage.test.ts b/src/modules/finance/pages/FinanceManualPage.test.ts new file mode 100644 index 00000000..5e2836d2 --- /dev/null +++ b/src/modules/finance/pages/FinanceManualPage.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8') + +describe('FinanceManualPage delivery contract', () => { + it('keeps every business table operation column first and fixed left', () => { + const declarations = [...source.matchAll(/return \[([^\n]+)\]/g)].map((match) => match[1] || '').filter((value) => value.includes("key: 'actions'")) + expect(declarations).toHaveLength(3) + for (const declaration of declarations) { + expect(declaration.trim().startsWith("{ key: 'actions'")).toBe(true) + expect(declaration).toContain("fixed: 'left'") + } + }) + + it('exposes supported bill operations with permission checks and traceable errors', () => { + expect(source).toContain("platform:finance:bill:update") + expect(source).toContain("platform:finance:bill:adjust") + expect(source).toContain("platform:finance:bill:void") + expect(source).toContain("openBill(row, 'detail')") + expect(source).toContain("openBill(row, 'edit')") + expect(source).toContain("openBill(row, 'adjust')") + expect(source).toContain(' { + expect(source).toContain('凭证上传接口尚未接通') + expect(source).toContain('type="file" accept="image/*" disabled') + expect(source).toContain('@media (max-width: 720px)') + expect(source).toContain('@media (max-width: 480px)') + expect(source).toContain('grid-template-columns: 1fr') + }) +}) diff --git a/src/modules/finance/pages/FinanceManualPage.vue b/src/modules/finance/pages/FinanceManualPage.vue index 9badd54f..2f6de2cf 100644 --- a/src/modules/finance/pages/FinanceManualPage.vue +++ b/src/modules/finance/pages/FinanceManualPage.vue @@ -1,11 +1,11 @@ @@ -144,12 +193,22 @@ onMounted(() => void load()) +
@@ -158,48 +217,77 @@ onMounted(() => void load())
- - + + + +
- - + +
-
- - -
- - diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts index 69bf494c..98929bc5 100644 --- a/src/modules/finance/types.ts +++ b/src/modules/finance/types.ts @@ -17,6 +17,20 @@ export interface FinanceBillResource { version: number } +export interface FinanceBillLine { + sourceFeeId: string + sourceFeeVersion: number + orderId: string | null + inboundId: string | null + feeItemName: string + amount: number +} + +export interface FinanceBillDetail extends FinanceBillResource { + remark: string + lines: FinanceBillLine[] +} + export interface FinanceReceiptResource { id: string customerId: string @@ -41,11 +55,3 @@ export interface FinanceCostResource { voucherFileIds: string[] createdAt: string } - -export interface FinanceReportResource { - customerName: string - billedAmount: number - receivedAmount: number - costAmount: number - profitAmount: number -}