From d6b2ea0938c3976ee5c21b65a27ea75ec2597755 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 13:55:23 +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 | 24 ++++++- src/modules/finance/api/finance.ts | 16 ++++- .../finance/pages/FinanceManualPage.test.ts | 15 +++- .../finance/pages/FinanceManualPage.vue | 68 +++++++++++++++---- src/modules/finance/types.ts | 8 +++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 2d1b69d0..8679acee 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 { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from './finance' +import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { @@ -43,6 +43,28 @@ describe('finance api', () => { expect(result.records[0]).toMatchObject({ id: '71', customerId: '10001', containerId: '81', expenseName: '拖车费', amount: 680.5, voucherFileIds: ['90001'] }) }) + it('uploads a real voucher through the Finance Controller proxy', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { fileId: 90001, tenantId: 10001, fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 128 } }) + const file = new File(['image'], 'receipt.png', { type: 'image/png' }) + + const result = await uploadFinanceVoucher('10001', file) + + expect(result).toEqual({ fileId: '90001', customerId: '10001', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 128 }) + expect(post.mock.calls[0]?.[0]).toBe('/platform/finance/v1/vouchers') + expect(post.mock.calls[0]?.[1]).toBeInstanceOf(FormData) + expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(file) + expect(post.mock.calls[0]?.[2]).toEqual({ params: { customerId: '10001' }, headers: { 'Content-Type': 'multipart/form-data' } }) + }) + + it('submits a non-empty container volume snapshot with the uploaded voucher id', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) + const payload = { customerId: '10001', costType: 'CONTAINER_SHARED', containerId: '81', expenseName: '拖车费', amount: '600.000000', containerOrders: [{ orderId: '91', inboundId: '101', containerId: '81', volumeCbm: '2.500000' }] } + + await createFinanceCost(payload, ['90001']) + + expect(post.mock.calls[0]?.[1]).toMatchObject({ ...payload, currency: 'CNY', voucherFileIds: ['90001'], containerOrders: [{ orderId: '91', volumeCbm: '2.500000' }] }) + }) + 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 }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index b98188ad..95b7458c 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 { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types' +import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource, FinanceVoucherResource } from '../types' const basePath = '/platform/finance/v1' function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` } @@ -94,6 +94,20 @@ export async function createFinanceReceipt(payload: Record, vou return unwrapApiResponse(response) } +export async function uploadFinanceVoucher(customerId: string, file: File): Promise { + const body = new FormData() + body.append('file', file) + const response = await http.post, ApiEnvelope>(`${basePath}/vouchers`, body, { + params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' }, + }) + const item = unwrapApiResponse(response) + return { + fileId: stringValue(item.fileId ?? item.file_id), customerId: stringValue(item.tenantId ?? item.customerId ?? customerId), + fileName: stringValue(item.fileName ?? item.file_name), contentType: stringValue(item.contentType ?? item.content_type), + sizeBytes: numberValue(item.sizeBytes ?? item.size_bytes), + } +} + export async function listFinanceCosts(customerId?: string) { const response = await http.get, ApiEnvelope>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } }) return normalizePage(unwrapApiResponse(response), (item) => ({ diff --git a/src/modules/finance/pages/FinanceManualPage.test.ts b/src/modules/finance/pages/FinanceManualPage.test.ts index 5e2836d2..f107252a 100644 --- a/src/modules/finance/pages/FinanceManualPage.test.ts +++ b/src/modules/finance/pages/FinanceManualPage.test.ts @@ -25,11 +25,20 @@ describe('FinanceManualPage delivery contract', () => { expect(source).toContain(' { - expect(source).toContain('凭证上传接口尚未接通') - expect(source).toContain('type="file" accept="image/*" disabled') + it('uses real voucher uploads, requires container volume snapshots and declares narrow-screen layouts', () => { + expect(source).toContain('uploadFinanceVoucher(form.customerId, file.value)') + expect(source).toContain('voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : []') + expect(source).toContain("form.costType === 'CONTAINER_SHARED' && containerOrders.some") + expect(source).toContain('containerOrders.map((item) =>') + expect(source).toContain('体积(CBM)') + expect(source).toContain('type="file" accept="image/*" :disabled="saving"') expect(source).toContain('@media (max-width: 720px)') expect(source).toContain('@media (max-width: 480px)') expect(source).toContain('grid-template-columns: 1fr') }) + + it('shows an explicit revision query blocker instead of fabricated history', () => { + expect(source).toContain('修订记录查询接口尚未提供') + expect(source).not.toContain('mockRevision') + }) }) diff --git a/src/modules/finance/pages/FinanceManualPage.vue b/src/modules/finance/pages/FinanceManualPage.vue index 2f6de2cf..2057d7ae 100644 --- a/src/modules/finance/pages/FinanceManualPage.vue +++ b/src/modules/finance/pages/FinanceManualPage.vue @@ -4,8 +4,8 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem import { Eye, FileCheck2, FileUp, MoreHorizontal, Pencil, Plus, Save, Trash2 } from '@lucide/vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { adjustFinanceBill, createFinanceBill, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from '../api/finance' -import type { FinanceBillDetail, FinanceBillResource, FinanceCostResource, FinanceReceiptResource } from '../types' +import { adjustFinanceBill, createFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from '../api/finance' +import type { FinanceBillDetail, FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceVoucherResource } from '../types' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDrawer from '@/shared/components/AppDrawer.vue' @@ -37,9 +37,12 @@ const selectedBill = ref(null) const saving = ref(false) const formRef = ref() const file = ref(null) +const uploadedVoucher = ref(null) +const voucherError = ref('') const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' }) const allocations = reactive([{ billId: '', amount: '' }]) const billLines = reactive([{ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }]) +const containerOrders = reactive([{ orderId: '', inboundId: '', volumeCbm: '' }]) 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])) @@ -110,14 +113,22 @@ 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: '' }) billLines.splice(0, billLines.length, { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }) - file.value = null; drawerOpen.value = true + containerOrders.splice(0, containerOrders.length, { orderId: '', inboundId: '', volumeCbm: '' }) + file.value = null; uploadedVoucher.value = null; voucherError.value = ''; drawerOpen.value = true +} +function selectFile(event: Event) { + const selected = (event.target as HTMLInputElement).files?.[0] || null + uploadedVoucher.value = null; voucherError.value = '' + if (selected && !selected.type.startsWith('image/')) { file.value = null; voucherError.value = '凭证仅支持图片文件'; return } + file.value = selected } -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 addBillLine() { billLines.push({ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }) } function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) } +function addContainerOrder() { containerOrders.push({ orderId: '', inboundId: '', volumeCbm: '' }) } +function removeContainerOrder(index: number) { if (containerOrders.length > 1) containerOrders.splice(index, 1) } function billPayloadLines() { return billLines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, orderId: line.orderId || undefined, inboundId: line.inboundId || undefined, feeItemName: line.feeItemName.trim(), amount: Number(line.amount) })) } function validateBusinessForm() { if (!/^\d+$/.test(form.customerId)) return '客户ID必须为数字' @@ -126,24 +137,37 @@ function validateBusinessForm() { if (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '每条账单费用都必须填写数字格式的费用来源ID、费用项目和大于 0 的金额' if (drawerMode.value === 'adjust' && !form.remark.trim()) return '调整原因不能为空' if (props.mode === 'costs' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return `请填写数字格式的${form.costType === 'SINGLE_ORDER' ? '订单ID' : '柜次ID'}和费用项目` + if (props.mode === 'costs' && form.costType === 'CONTAINER_SHARED' && containerOrders.some((item) => !/^\d+$/.test(item.orderId) || (item.inboundId && !/^\d+$/.test(item.inboundId)) || !positiveNumber(item.volumeCbm))) return '整柜成本必须填写每票数字格式的订单ID、可选入库单ID和大于 0 的体积快照' 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)} 元,必须等于收款金额` } + if (props.mode !== 'bills' && uploadedVoucher.value && uploadedVoucher.value.customerId !== form.customerId) return '客户ID已变化,请重新选择并上传对应客户的凭证' + if (props.mode !== 'bills' && !file.value && !uploadedVoucher.value) return '请先选择收款或成本凭证图片' 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 drawerError.value = null try { - const lines = billPayloadLines() - if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version }) - else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines }) - else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines }) + if (props.mode === 'bills') { + const lines = billPayloadLines() + if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version }) + else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines }) + else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines }) + } else { + if (!uploadedVoucher.value && file.value) uploadedVoucher.value = await uploadFinanceVoucher(form.customerId, file.value) + const voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : [] + if (!voucherFileIds.length) throw new Error('凭证上传未返回有效 fileId') + if (props.mode === 'receipts') { + await createFinanceReceipt({ customerId: form.customerId, receivedDate: form.billDate, amount: String(form.amount), paymentMethod: form.paymentMethod, remark: form.remark, allocations: allocations.map((item) => ({ billId: item.billId, amount: String(item.amount) })) }, voucherFileIds) + } else { + await createFinanceCost({ customerId: form.customerId, costType: form.costType, orderId: form.costType === 'SINGLE_ORDER' ? form.referenceNo : undefined, containerId: form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined, expenseName: form.feeName.trim(), amount: String(form.amount), remark: form.remark, containerOrders: form.costType === 'CONTAINER_SHARED' ? containerOrders.map((item) => ({ orderId: item.orderId, inboundId: item.inboundId || undefined, containerId: form.referenceNo, volumeCbm: String(item.volumeCbm) })) : [] }, voucherFileIds) + } + } drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load() } catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); drawerError.value = details; ElMessage.error(details.message) } finally { saving.value = false } @@ -262,12 +286,27 @@ onMounted(() => void load()) 添加账单分配 + - + + + +

凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。

+
- + @@ -280,14 +319,17 @@ onMounted(() => void load()) .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.is-disabled { cursor: not-allowed; color: var(--cc-muted); background: var(--el-fill-color-light); } .file-picker input { display: none; } +.voucher-error { margin: 8px 0 0; color: var(--el-color-danger); } +.voucher-success { margin: 8px 0 0; color: var(--el-color-success); overflow-wrap: anywhere; } .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; } .bill-line-row { display: grid; grid-template-columns: minmax(110px, .8fr) minmax(140px, 1.2fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; } +.container-volume-row { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(130px, 1fr) minmax(110px, .8fr) 32px; gap: 8px; margin: 8px 0; align-items: center; } .bill-summary { margin-bottom: 16px; } .detail-line { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); } .detail-line span { color: var(--cc-muted); overflow-wrap: anywhere; } .detail-line b { white-space: nowrap; } -@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } } -@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button { justify-self: end; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } } +@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row, .container-volume-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } } +@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .container-volume-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button, .container-volume-row .el-button { justify-self: end; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } } diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts index 98929bc5..2eeaacf8 100644 --- a/src/modules/finance/types.ts +++ b/src/modules/finance/types.ts @@ -2,6 +2,14 @@ export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED' export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER' +export interface FinanceVoucherResource { + fileId: string + customerId: string + fileName: string + contentType: string + sizeBytes: number +} + export interface FinanceBillResource { id: string billNo: string