Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
8 changed files with 233 additions and 86 deletions
Showing only changes of commit b7b220f6e0 - Show all commits
+1 -1
View File
@@ -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)
-1
View File
@@ -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' },
],
},
{
+45
View File
@@ -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) } })
})
})
+63 -29
View File
@@ -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<T> { 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<string, unknown>
interface PageResult<T> { records: T[]; total: number; serverPaged: boolean }
type RawPage = RawResource[] | { records?: RawResource[]; total?: number }
export async function listFinanceBills(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<PageResult<FinanceBillResource>>, ApiEnvelope<PageResult<FinanceBillResource>>>(`${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<T>(payload: RawPage, map: (item: RawResource) => T): PageResult<T> {
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<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/bills`, { params })
return normalizePage<FinanceBillResource>(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<string, unknown>) {
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills`, { ...payload, currency: 'CNY' })
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills`, { ...payload }, { headers: { 'Idempotency-Key': idempotencyKey('bill') } })
return unwrapApiResponse(response)
}
export async function listFinanceReceipts(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<PageResult<FinanceReceiptResource>>, ApiEnvelope<PageResult<FinanceReceiptResource>>>(`${basePath}/receipts`, { params: { ...params, currency: 'CNY' } })
export async function listFinanceReceipts(customerId?: string) {
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/receipts`, { params: { customerId: customerId || undefined } })
const payload = unwrapApiResponse(response)
const normalized = normalizePage<FinanceReceiptResource>(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<string, FinanceReceiptResource>()
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<string, unknown>, voucherFileIds: string[]) {
const response = await http.post<ApiEnvelope<FinanceReceiptResource>, ApiEnvelope<FinanceReceiptResource>>(`${basePath}/receipts`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('receipt') } })
return unwrapApiResponse(response)
}
export async function createFinanceReceipt(payload: Record<string, unknown>, 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<FinanceReceiptResource>, ApiEnvelope<FinanceReceiptResource>>(`${basePath}/receipts`, body)
export async function listFinanceCosts(customerId?: string) {
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } })
return normalizePage<FinanceCostResource>(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<string, unknown>, voucherFileIds: string[]) {
const response = await http.post<ApiEnvelope<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } })
return unwrapApiResponse(response)
}
export async function listFinanceCosts(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<PageResult<FinanceCostResource>>, ApiEnvelope<PageResult<FinanceCostResource>>>(`${basePath}/costs`, { params: { ...params, currency: 'CNY' } })
return unwrapApiResponse(response)
}
export async function createFinanceCost(payload: Record<string, unknown>, 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<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/costs`, body)
return unwrapApiResponse(response)
}
export async function listFinanceReports(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FinanceReportResource[]>, ApiEnvelope<FinanceReportResource[]>>(`${basePath}/reports`, { params: { ...params, currency: 'CNY' } })
export async function issueFinanceBill(billId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, { headers: { 'Idempotency-Key': idempotencyKey('bill-issue') } })
return unwrapApiResponse(response)
}
+112 -43
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { FileUp, Plus, Save } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { FileCheck2, FileUp, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createFinanceBill, createFinanceCost, createFinanceReceipt, listFinanceBills, listFinanceCosts, listFinanceReceipts, listFinanceReports } from '../api/finance'
import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types'
import { createFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts } from '../api/finance'
import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -15,72 +15,126 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
type Mode = 'bills' | 'receipts' | 'costs' | 'reports'
type Mode = 'bills' | 'receipts' | 'costs'
type TableRow = Record<string, string | number | null>
const props = defineProps<{ mode: Mode }>()
const access = useAccessPreviewStore()
const rows = ref<TableRow[]>([])
const allRows = ref<TableRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const serverPaged = ref(false)
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
const drawerOpen = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const file = ref<File | null>(null)
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: '', referenceNo: '', costType: 'WAYBILL', feeName: '', supplierName: '', remark: '' })
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
const allocations = reactive([{ billId: '', amount: '' }])
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入', reports: '财务统计' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view', reports: 'platform:finance:report:view' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create', reports: '' }[props.mode]))
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
const canIssue = computed(() => access.can('platform:finance:bill:issue'))
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
{ key: 'customerId', type: 'input', label: '客户', width: 180, placeholder: '请输入客户名称或ID', props: { 'aria-label': '客户' } },
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOID' }] }] : []),
{ key: 'customerId', type: 'input', label: '客户ID', width: 180, placeholder: props.mode === 'bills' ? '查询账单时必填' : '留空查询全部', props: { 'aria-label': '客户ID' } },
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
])
const columns = computed<DataTableColumn[]>(() => {
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'receivedAt', label: '收款日期', width: 130 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'paymentMethod', label: '收款方式', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'allocationCount', label: '关联账单数', width: 120 }]
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '关联单号', minWidth: 160 }, { key: 'feeName', label: '费用项目', minWidth: 160 }, { key: 'supplierName', label: '成本对象', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'createdAt', label: '登记时间', width: 150 }]
return [{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'billedAmount', label: '账单收入(CNY)', width: 150 }, { key: 'receivedAmount', label: '已收(CNY)', width: 130 }, { key: 'costAmount', label: '成本(CNY)', width: 130 }, { key: 'profitAmount', label: '利润(CNY)', width: 130 }]
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '关联单号', minWidth: 160 }, { key: 'feeName', label: '费用项目', minWidth: 160 }, { key: 'supplierName', label: '客户', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'createdAt', label: '登记时间', width: 150 }]
return []
})
const rules: FormRules = { customerId: [{ required: true, message: '请输入客户', trigger: 'blur' }], amount: [{ required: true, message: '请输入金额', trigger: 'blur' }] }
const rules: FormRules = {
customerId: [{ required: true, message: '请输入客户ID', trigger: 'blur' }],
billDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
referenceNo: [{ required: true, message: '请输入关联ID', trigger: 'blur' }],
feeName: [{ required: true, message: '请输入费用项目', trigger: 'blur' }],
paymentMethod: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
}
function mapBill(item: FinanceBillResource): TableRow { return { id: item.id, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status } }
function mapReceipt(item: FinanceReceiptResource): TableRow { return { id: item.id, customerName: item.customerName, receivedAt: item.receivedAt, amount: item.amount, paymentMethod: item.paymentMethod, voucherFileName: item.voucherFileName || '未上传', allocationCount: item.allocationCount } }
function mapCost(item: FinanceCostResource): TableRow { return { id: item.id, costType: item.costType, referenceNo: item.referenceNo, feeName: item.feeName, supplierName: item.supplierName || '—', amount: item.amount, voucherFileName: item.voucherFileName || '未上传', createdAt: item.createdAt } }
function mapReport(item: FinanceReportResource): TableRow { return { customerName: item.customerName, billedAmount: item.billedAmount, receivedAmount: item.receivedAmount, costAmount: item.costAmount, profitAmount: item.profitAmount } }
function mapBill(item: FinanceBillResource): TableRow { return { id: item.id, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapReceipt(item: FinanceReceiptResource): TableRow { return { id: item.id, customerName: item.customerName, receivedAt: item.receivedDate, amount: item.amount, paymentMethod: item.paymentMethod, voucherFileName: item.voucherFileIds.length ? `${item.voucherFileIds.length} 个文件` : '未上传', allocationCount: item.allocationCount } }
function mapCost(item: FinanceCostResource): TableRow { return { id: item.id, costType: item.costType, referenceNo: item.orderId || item.containerId || '—', feeName: item.expenseName, supplierName: `客户 ${item.customerId}`, amount: item.amount, voucherFileName: item.voucherFileIds.length ? `${item.voucherFileIds.length} 个文件` : '未上传', createdAt: item.createdAt } }
async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try {
const params = { keyword: String(filters.keyword || ''), customerId: String(filters.customerId || ''), status: String(filters.status || ''), page: page.value, size: pageSize.value }
if (props.mode === 'bills') { const result = await listFinanceBills(params); rows.value = result.records.map(mapBill); total.value = result.total }
else if (props.mode === 'receipts') { const result = await listFinanceReceipts(params); rows.value = result.records.map(mapReceipt); total.value = result.total }
else if (props.mode === 'costs') { const result = await listFinanceCosts(params); rows.value = result.records.map(mapCost); total.value = result.total }
else { rows.value = (await listFinanceReports(params)).map(mapReport); total.value = rows.value.length }
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
const customerId = String(filters.customerId || '').trim()
if (props.mode === 'bills' && !customerId) { allRows.value = []; total.value = 0; return }
let source: TableRow[]
let responseTotal = 0
if (props.mode === 'bills') {
const result = await listFinanceBills({ customerId, status: String(filters.status || '') || undefined, page: page.value, pageSize: pageSize.value })
source = result.records.map(mapBill)
responseTotal = result.total; serverPaged.value = result.serverPaged
} else if (props.mode === 'receipts') {
const result = await listFinanceReceipts(customerId); source = result.records.map(mapReceipt); responseTotal = result.total; serverPaged.value = result.serverPaged
} else {
const result = await listFinanceCosts(customerId); source = result.records.map(mapCost); responseTotal = result.total; serverPaged.value = result.serverPaged
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const status = String(filters.status || '')
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
total.value = keyword || status ? allRows.value.length : responseTotal
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
finally { loading.value = false }
}
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
function openCreate() { Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: '', referenceNo: '', costType: 'WAYBILL', feeName: '', supplierName: '', remark: '' }); file.value = null; drawerOpen.value = true }
function changePageSize() { page.value = 1; void load() }
function openCreate() {
Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
allocations.splice(0, allocations.length, { billId: '', amount: '' })
file.value = null; drawerOpen.value = true
}
function selectFile(event: Event) { const selected = (event.target as HTMLInputElement).files?.[0] || null; file.value = selected }
function positiveNumber(value: string) { const amount = Number(value); return Number.isFinite(amount) && amount > 0 }
function addAllocation() { allocations.push({ billId: '', amount: '' }) }
function removeAllocation(index: number) { if (allocations.length > 1) allocations.splice(index, 1) }
function validateBusinessForm() {
if (!/^\d+$/.test(form.customerId)) return '客户ID必须为数字'
if (!form.billDate) return '请选择日期'
if (!positiveNumber(form.amount)) return '金额必须大于 0'
if (props.mode === 'bills' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return '请填写数字格式的费用来源ID和费用项目'
if (props.mode === 'costs' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return `请填写数字格式的${form.costType === 'SINGLE_ORDER' ? '订单ID' : '柜次ID'}和费用项目`
if (props.mode === 'receipts') {
if (allocations.some((item) => !/^\d+$/.test(item.billId) || !positiveNumber(item.amount))) return '每条分配都必须填写数字格式的账单ID和大于 0 的金额'
const allocated = allocations.reduce((sum, item) => sum + Number(item.amount), 0)
if (Math.abs(allocated - Number(form.amount)) > 0.000001) return `分配金额合计 ${allocated.toFixed(2)} 元,必须等于收款金额`
}
return ''
}
async function submit() {
const valid = await formRef.value?.validate().catch(() => false); if (!valid) return
const validationMessage = validateBusinessForm(); if (validationMessage) { ElMessage.warning(validationMessage); return }
if (props.mode !== 'bills') { ElMessage.error('凭证上传接口尚未接通,当前不能保存收款或成本,避免形成无凭证台账'); return }
saving.value = true
try {
if (props.mode === 'bills') await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, amount: Number(form.amount), remark: form.remark })
else if (props.mode === 'receipts') await createFinanceReceipt({ customerId: form.customerId, receivedAt: form.billDate, amount: Number(form.amount), paymentMethod: form.paymentMethod, allocations: form.referenceNo.split(',').map((billId) => ({ billId: billId.trim(), amount: Number(form.amount) })).filter((item) => item.billId) }, file.value || undefined)
else await createFinanceCost({ costType: form.costType, referenceNo: form.referenceNo, feeName: form.feeName, supplierName: form.supplierName, amount: Number(form.amount), remark: form.remark }, file.value || undefined)
await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines: [{ sourceFeeId: form.referenceNo, sourceFeeVersion: 1, feeItemName: form.feeName, amount: Number(form.amount) }] })
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message) }
finally { saving.value = false }
}
async function issueBill(row: TableRow) {
try {
await ElMessageBox.confirm('出账后账单将冻结,不能再按草稿方式修改。确认出账吗?', '确认出账', { type: 'warning', confirmButtonText: '确认出账', cancelButtonText: '取消' })
await issueFinanceBill(String(row.id), Number(row.version))
ElMessage.success('账单已出账并冻结'); await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, '账单出账失败,请重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
}
}
onMounted(() => void load())
</script>
@@ -91,11 +145,14 @@ onMounted(() => void load())
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" /></template>
<template v-if="loadError" #state><LoadErrorState :title="`${title}加载失败`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="props.mode === 'reports' ? undefined : total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据" @update:page="load" @update:page-size="changePageSize">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.mode === 'bills' ? '新建账单' : props.mode === 'receipts' ? '登记收款' : '录入成本' }}</el-button></template>
<template #actions><span class="muted-value">人工台账</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOID' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER' ? '整柜公共成本' : '单票成本' }}</template>
<template #actions="{ row }">
<el-button v-if="props.mode === 'bills' && row.status === 'DRAFT' && canIssue" link type="primary" @click="issueBill(row)"><FileCheck2 :size="15" />出账</el-button>
<span v-else class="muted-value">{{ props.mode === 'bills' && row.status !== 'DRAFT' ? '已冻结' : '人工台账' }}</span>
</template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
<template #empty><el-empty description="暂无数据;后端接口接通后将显示真实财务记录" /></template>
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
</DataTable>
@@ -105,32 +162,44 @@ onMounted(() => void load())
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection title="基本信息" description="首期只支持 CNY,由平台财务人工填写并留存凭证。">
<div class="form-grid two-cols">
<el-form-item label="客户" prop="customerId"><el-input v-model="form.customerId" placeholder="请输入客户名称或ID" /></el-form-item>
<el-form-item label="日期"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
<el-form-item label="客户ID" prop="customerId"><el-input v-model="form.customerId" inputmode="numeric" placeholder="请输入客户ID" /></el-form-item>
<el-form-item label="日期" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
</div>
<div class="form-grid two-cols">
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="WAYBILL" /><el-option label="整柜公共成本" value="CONTAINER" /></el-select></el-form-item>
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" placeholder="请输入金额" /></el-form-item>
</div>
<el-form-item v-if="props.mode !== 'bills'" label="关联单号/账单ID"><el-input v-model="form.referenceNo" placeholder="多个账单用逗号分隔" /></el-form-item>
<el-form-item v-if="props.mode === 'receipts'" label="收款方式"><el-input v-model="form.paymentMethod" placeholder="例如:银行转账" /></el-form-item>
<el-form-item v-if="props.mode === 'costs'" label="费用项目"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
<el-form-item v-if="props.mode === 'costs'" label="供应商/成本对象"><el-input v-model="form.supplierName" placeholder="请输入成本对象" /></el-form-item>
<el-form-item v-if="props.mode !== 'receipts'" label="关联ID" prop="referenceNo"><el-input v-model="form.referenceNo" :placeholder="props.mode === 'bills' ? '请输入费用来源ID' : form.costType === 'SINGLE_ORDER' ? '请输入订单ID' : '请输入柜次ID'" /></el-form-item>
<template v-if="props.mode === 'receipts'">
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
<div class="allocation-title">账单分配</div>
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row">
<el-input v-model="allocation.billId" placeholder="账单ID" />
<el-input v-model="allocation.amount" inputmode="decimal" placeholder="分配金额(CNY)" />
<el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button>
</div>
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
</template>
<el-form-item v-if="props.mode !== 'receipts'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" :placeholder="props.mode === 'bills' ? '请输入账单费用项目' : '例如:清关费、拖车费'" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="当前按人工上传凭证设计;后端未提供接口时会明确返回失败,不使用假数据。">
<label class="file-picker"><FileUp :size="16" />{{ file?.name || '选择凭证图片' }}<input type="file" accept="image/*,.pdf" @change="selectFile" /></label>
<el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止,不会发送空 voucherFileIds。" />
<label class="file-picker"><FileUp :size="16" />{{ file?.name || '选择凭证图片(接口接通后启用)' }}<input type="file" accept="image/*,.pdf" @change="selectFile" /></label>
</FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit"><Save :size="16" />保存</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? '保存' : '等待凭证接口' }}</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-page { min-width: 0; }
.file-picker { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); }
.file-picker { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); overflow-wrap: anywhere; }
.file-picker input { display: none; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
.allocation-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row { grid-template-columns: 1fr; } .allocation-row .el-button { justify-self: end; } }
</style>
@@ -1,2 +0,0 @@
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
<template><FinanceManualPage mode="reports" /></template>
-1
View File
@@ -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[]
+12 -9
View File
@@ -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
}