Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
5 changed files with 113 additions and 18 deletions
Showing only changes of commit d6b2ea0938 - Show all commits
+23 -1
View File
@@ -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 })
+15 -1
View File
@@ -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<string, unknown>, vou
return unwrapApiResponse(response)
}
export async function uploadFinanceVoucher(customerId: string, file: File): Promise<FinanceVoucherResource> {
const body = new FormData()
body.append('file', file)
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${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<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } })
return normalizePage<FinanceCostResource>(unwrapApiResponse(response), (item) => ({
@@ -25,11 +25,20 @@ describe('FinanceManualPage delivery contract', () => {
expect(source).toContain('<ApiErrorMeta :details="drawerError"')
})
it('blocks uncontracted voucher uploads and declares narrow-screen layouts', () => {
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')
})
})
+55 -13
View File
@@ -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<FinanceBillDetail | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const file = ref<File | null>(null)
const uploadedVoucher = ref<FinanceVoucherResource | null>(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())
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
</template>
<el-form-item v-if="props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
<template v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'">
<div class="allocation-title">柜内订单体积快照</div>
<el-alert type="info" :closable="false" show-icon title="按当前柜内同客户有效订单的实际体积填写;该快照将随成本保存并用于分摊。" />
<div v-for="(item, index) in containerOrders" :key="index" class="container-volume-row">
<el-input v-model="item.orderId" inputmode="numeric" placeholder="订单ID" />
<el-input v-model="item.inboundId" inputmode="numeric" placeholder="入库单ID(可选)" />
<el-input v-model="item.volumeCbm" inputmode="decimal" placeholder="体积(CBM)" />
<el-button link type="danger" aria-label="删除体积快照" @click="removeContainerOrder(index)"><Trash2 :size="16" /></el-button>
</div>
<el-button link type="primary" @click="addContainerOrder"><Plus :size="15" />添加柜内订单</el-button>
</template>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
</template>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="后端上传契约提供后才允许形成财务台账。"><el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止。" /><label class="file-picker is-disabled"><FileUp :size="16" />凭证选择将在接口接通后启用<input type="file" accept="image/*" disabled @change="selectFile" /></label></FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。</p>
</FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : '等待凭证接口' }}</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
@@ -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%; } }
</style>
+8
View File
@@ -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