优化
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user