diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 8fa1a96e..b91e8660 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -93,7 +93,7 @@ describe('adminNavigation', () => { expect(paths).not.toContain('/prototype/container-management/preload-list') expect(paths).not.toContain('/prototype/container-management/manifest') expect(paths).not.toContain('/prototype/general/fee-items') - expect(paths).toContain('/prototype/finance/profit-report') + expect(paths).toContain('/finance/bills') expect(paths).toContain('/prototype/system-management/brand-settings') expect(paths).toContain('/system/permissions') expect(new Set(paths).size).toBe(paths.length) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9d9568d9..1892c6fb 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -137,7 +137,6 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'finance-bills', title: '客户账单', icon: FileCheck2, path: '/finance/bills', permission: 'platform:finance:bill:view' }, { key: 'finance-receipts', title: '收款登记', icon: CircleDollarSign, path: '/finance/receipts', permission: 'platform:finance:receipt:view' }, { key: 'finance-costs', title: '成本录入', icon: Database, path: '/finance/costs', permission: 'platform:finance:cost:view' }, - { key: 'finance-reports', title: '财务统计', icon: BarChart3, path: '/finance/reports', permission: 'platform:finance:report:view' }, ], }, { diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts new file mode 100644 index 00000000..e22856db --- /dev/null +++ b/src/modules/finance/api/finance.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { createFinanceCost, createFinanceReceipt, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts } from './finance' + +describe('finance api', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('uses the OpenAPI bill path and pageSize parameter', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ + code: '200', message: 'ok', data: [{ id: 1, bill_no: 'B-1', 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: 2 }], + }) + + const result = await listFinanceBills({ customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 }) + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/bills', { params: { customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 } }) + expect(result.records[0]).toMatchObject({ id: '1', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 }) + }) + + it('uses the receipt and cost entry paths and fixes currency to CNY', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] }) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) + + await listFinanceReceipts('10001') + await listFinanceCosts('10001') + await createFinanceReceipt({ customerId: '10001', amount: 100 }, ['90001']) + await createFinanceCost({ customerId: '10001', amount: 100 }, ['90001']) + + expect(get).toHaveBeenNthCalledWith(1, '/platform/finance/v1/receipts', { params: { customerId: '10001' } }) + expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries', { params: { customerId: '10001' } }) + expect(post.mock.calls[0]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] }) + expect(post.mock.calls[1]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] }) + expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) + expect(post.mock.calls[1]?.[2]).toMatchObject({ 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: {} }) + + await issueFinanceBill('81001', 3) + + expect(post).toHaveBeenCalledWith('/platform/finance/v1/bills/81001/issue', { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } }) + }) +}) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 2e81e5f6..24f3008b 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -1,46 +1,80 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types' +import type { FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types' -const basePath = '/platform/v1/finance' -interface PageResult { records: T[]; total: number; page: number; size: number } +const basePath = '/platform/finance/v1' +function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` } +type RawResource = Record +interface PageResult { records: T[]; total: number; serverPaged: boolean } +type RawPage = RawResource[] | { records?: RawResource[]; total?: number } -export async function listFinanceBills(params: Record) { - const response = await http.get>, ApiEnvelope>>(`${basePath}/bills`, { params: { ...params, currency: 'CNY' } }) - return unwrapApiResponse(response) +function stringValue(value: unknown) { return value == null ? '' : String(value) } +function numberValue(value: unknown) { return Number(value ?? 0) } +function fileIds(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String) + if (typeof value !== 'string' || !value.trim()) return [] + try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.map(String) : [] } catch { return [] } +} +function normalizePage(payload: RawPage, map: (item: RawResource) => T): PageResult { + 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}`, + 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), + })) } export async function createFinanceBill(payload: Record) { - const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload, currency: 'CNY' }) + const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload }, { headers: { 'Idempotency-Key': idempotencyKey('bill') } }) return unwrapApiResponse(response) } -export async function listFinanceReceipts(params: Record) { - const response = await http.get>, ApiEnvelope>>(`${basePath}/receipts`, { params: { ...params, currency: 'CNY' } }) +export async function listFinanceReceipts(customerId?: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/receipts`, { params: { customerId: customerId || undefined } }) + const payload = unwrapApiResponse(response) + const normalized = normalizePage(payload, (item) => ({ + id: stringValue(item.id), customerId: stringValue(item.tenant_id), customerName: `客户 ${stringValue(item.tenant_id)}`, + receivedDate: stringValue(item.received_at).slice(0, 10), amount: numberValue(item.received_amount), currency: 'CNY', + paymentMethod: stringValue(item.payment_method) as FinancePaymentMethod, voucherFileIds: fileIds(item.voucher_file_ids), + allocationCount: item.bill_id == null ? 0 : 1, + })) + const grouped = new Map() + for (const item of normalized.records) { + const existing = grouped.get(item.id) + if (existing) existing.allocationCount += item.allocationCount + else grouped.set(item.id, { ...item }) + } + return { ...normalized, records: [...grouped.values()], total: normalized.serverPaged ? normalized.total : grouped.size } +} + +export async function createFinanceReceipt(payload: Record, voucherFileIds: string[]) { + const response = await http.post, ApiEnvelope>(`${basePath}/receipts`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('receipt') } }) return unwrapApiResponse(response) } -export async function createFinanceReceipt(payload: Record, voucher?: File) { - const body = new FormData() - body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' })) - if (voucher) body.append('file', voucher) - const response = await http.post, ApiEnvelope>(`${basePath}/receipts`, body) +export async function listFinanceCosts(customerId?: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } }) + 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', + voucherFileIds: fileIds(item.voucher_file_ids), createdAt: stringValue(item.created_at), + })) +} + +export async function createFinanceCost(payload: Record, voucherFileIds: string[]) { + const response = await http.post, ApiEnvelope>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } }) return unwrapApiResponse(response) } -export async function listFinanceCosts(params: Record) { - const response = await http.get>, ApiEnvelope>>(`${basePath}/costs`, { params: { ...params, currency: 'CNY' } }) - return unwrapApiResponse(response) -} - -export async function createFinanceCost(payload: Record, voucher?: File) { - const body = new FormData() - body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' })) - if (voucher) body.append('file', voucher) - const response = await http.post, ApiEnvelope>(`${basePath}/costs`, body) - return unwrapApiResponse(response) -} - -export async function listFinanceReports(params: Record) { - const response = await http.get, ApiEnvelope>(`${basePath}/reports`, { params: { ...params, currency: 'CNY' } }) +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') } }) return unwrapApiResponse(response) } diff --git a/src/modules/finance/pages/FinanceManualPage.vue b/src/modules/finance/pages/FinanceManualPage.vue index fba70d2c..9badd54f 100644 --- a/src/modules/finance/pages/FinanceManualPage.vue +++ b/src/modules/finance/pages/FinanceManualPage.vue @@ -1,11 +1,11 @@ @@ -91,11 +145,14 @@ onMounted(() => void load())
- + - - - + + + @@ -105,32 +162,44 @@ onMounted(() => void load())
- - + +
- +
- - - - + + +
- + +
- + diff --git a/src/modules/finance/pages/FinanceReportsView.vue b/src/modules/finance/pages/FinanceReportsView.vue deleted file mode 100644 index af61cb23..00000000 --- a/src/modules/finance/pages/FinanceReportsView.vue +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/modules/finance/routes.ts b/src/modules/finance/routes.ts index 89427e2a..0dbb8ef2 100644 --- a/src/modules/finance/routes.ts +++ b/src/modules/finance/routes.ts @@ -4,5 +4,4 @@ export const financeRoutes = [ { path: 'finance/bills', name: 'finance-bills', component: () => import('./pages/FinanceBillsView.vue'), meta: { title: '客户账单', description: '人工维护 CNY 客户账单并在出账后冻结', permission: 'platform:finance:bill:view' } }, { path: 'finance/receipts', name: 'finance-receipts', component: () => import('./pages/FinanceReceiptsView.vue'), meta: { title: '收款登记', description: '人工登记收款并上传凭证图片', permission: 'platform:finance:receipt:view' } }, { path: 'finance/costs', name: 'finance-costs', component: () => import('./pages/FinanceCostsView.vue'), meta: { title: '成本录入', description: '人工登记单票成本和整柜公共成本', permission: 'platform:finance:cost:view' } }, - { path: 'finance/reports', name: 'finance-reports', component: () => import('./pages/FinanceReportsView.vue'), meta: { title: '财务统计', description: '按账单统计 CNY 收入、成本和利润', permission: 'platform:finance:report:view' } }, ] satisfies RouteRecordRaw[] diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts index f71c0fee..69bf494c 100644 --- a/src/modules/finance/types.ts +++ b/src/modules/finance/types.ts @@ -1,5 +1,6 @@ -export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOID' -export type FinanceCostType = 'WAYBILL' | 'CONTAINER' +export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' +export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED' +export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER' export interface FinanceBillResource { id: string @@ -13,29 +14,31 @@ export interface FinanceBillResource { paidAmount: number outstandingAmount: number status: FinanceBillStatus + version: number } export interface FinanceReceiptResource { id: string customerId: string customerName: string - receivedAt: string + receivedDate: string amount: number currency: 'CNY' - paymentMethod: string - voucherFileName: string | null + paymentMethod: FinancePaymentMethod + voucherFileIds: string[] allocationCount: number } export interface FinanceCostResource { id: string costType: FinanceCostType - referenceNo: string - feeName: string - supplierName: string | null + customerId: string + orderId: string | null + containerId: string | null + expenseName: string amount: number currency: 'CNY' - voucherFileName: string | null + voucherFileIds: string[] createdAt: string }