feat(bx): 报销支付按未结清余额分次冲抵和发放。

冲抵后的余额不再自动记成发放,发放和冲抵分开提交,未结清单据继续留在待处理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-05 21:14:09 +08:00
1 parent b49c99f1bd
commit e7f942b399
8 files changed
+707 -261

No files matched your search

+222 -184
View File
@@ -252,6 +252,17 @@
}}
</template>
</el-table-column>
<el-table-column
label="未结清余额(元)"
min-width="130"
align="right"
header-align="center"
class-name="col-money"
>
<template #default="{ row }">
{{ formatSumMoney(unpaidBalance(row)) }}
</template>
</el-table-column>
</ListTable>
</div>
<div ref="pendingPaymentBelowRef">
@@ -564,6 +575,17 @@
{{ row.payTime || '-' }}
</template>
</el-table-column>
<el-table-column
label="未结清余额(元)"
min-width="130"
align="right"
header-align="center"
class-name="col-money"
>
<template #default="{ row }">
{{ formatSumMoney(unpaidBalance(row)) }}
</template>
</el-table-column>
</ListTable>
</div>
<div ref="processedPaymentBelowRef">
@@ -639,51 +661,6 @@
</div>
</el-dialog>
<!-- 标记已发放 -->
<el-dialog
v-model="dialogs.issue"
title="标记已发放"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="发放金额(元)" class="field-amount">
<el-input :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
</el-form-item>
<el-form-item label="实际发放金额(元)" class="field-amount">
<el-input v-model="actualAmount" placeholder="请输入实际发放金额" />
</el-form-item>
<el-form-item label="备注" style="grid-column: 1 / -1">
<el-input v-model="issueRemark" type="textarea" :rows="4" placeholder="请输入备注" />
</el-form-item>
</div>
</el-form>
<div class="form-section form-section-last">
<h4>发放附件</h4>
<BxAttachmentUpload
ref="issueAttachmentRef"
v-model="uploadedFiles"
biz-type="reimburse_pay"
:biz-id="currentRow.id"
hint="支持上传发放截图等凭证。"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.issue = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.issueSubmit" @click="submitIssue">
<el-icon><Check /></el-icon>确认发放
</el-button>
</template>
</el-dialog>
<!-- 批量发放 -->
<el-dialog
v-model="dialogs.batchIssue"
@@ -741,10 +718,10 @@
</template>
</el-dialog>
<!-- 冲抵备用金 -->
<!-- 发放 / 冲抵,按入口只展示对应表单 -->
<el-dialog
v-model="dialogs.offset"
title="冲抵备用金"
:title="paymentActionTab === 'pay' ? '发放' : '冲抵备用金'"
width="960px"
class="dialog-form-layout"
align-center
@@ -760,84 +737,127 @@
<el-form-item label="报销金额(元)" class="field-amount">
<el-input :model-value="String(currentRow.amount || '')" disabled />
</el-form-item>
<el-form-item label="冲抵金额(元)" class="field-amount">
<el-input v-model="offsetAmount" placeholder="请输入冲抵金额" />
<el-form-item label="已冲抵金额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(currentRow.offsetAmount || 0)" disabled />
</el-form-item>
<el-form-item label="发放金额(元)" class="field-amount">
<el-input v-model="offsetPayAmount" placeholder="冲抵后剩余直接发放" />
<el-form-item label="已发放金额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(currentRow.actualAmount || 0)" disabled />
</el-form-item>
<el-form-item label="未结清余额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(unpaidBalance(currentRow))" disabled />
</el-form-item>
</div>
<h4 class="dialog-section-title">已发放备用金列表</h4>
<div class="inline-row">
<el-input v-model="offsetAdvanceFilter.keyword" placeholder="搜索单号/申请人" class="inline-grow" clearable />
<el-button class="btn-main" type="primary" :loading="offsetAdvanceLoading" @click="handleOffsetAdvanceSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button class="btn-ghost" @click="handleOffsetAdvanceReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
</div>
<ListTable table-key="bx-payment-3"
class="list-table"
:data="offsetAdvances"
v-loading="offsetAdvanceLoading"
max-height="300"
stripe
@selection-change="handleOffsetSelectionChange"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="applyCode" label="申请单号" min-width="130" align="center" header-align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="80" align="center" header-align="center" />
<el-table-column prop="reason" label="申请原因" min-width="120" align="left" header-align="center" show-overflow-tooltip />
<el-table-column label="已发放金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.actualAmount || row.amount || 0) }}
</template>
</el-table-column>
<el-table-column label="已冲抵金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.offsetAmount || 0) }}
</template>
</el-table-column>
<el-table-column label="剩余可冲抵(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(Number(row.actualAmount || row.amount || 0) - Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
</ListTable>
<div class="offset-pager">
<el-pagination
v-model:current-page="offsetAdvancePage"
v-model:page-size="offsetAdvancePageSize"
:total="offsetAdvanceTotal"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 100, 500]"
@current-change="fetchOffsetAdvances"
@size-change="handleOffsetAdvanceSizeChange"
<template v-if="paymentActionTab === 'offset'">
<div class="form-grid-2">
<el-form-item label="本次冲抵金额(元)" class="field-amount">
<el-input v-model="offsetAmount" placeholder="请输入本次冲抵金额" />
</el-form-item>
</div>
<h4 class="dialog-section-title">已发放备用金列表</h4>
<div class="inline-row">
<el-input v-model="offsetAdvanceFilter.keyword" placeholder="搜索单号/申请人" class="inline-grow" clearable />
<el-button class="btn-main" type="primary" :loading="offsetAdvanceLoading" @click="handleOffsetAdvanceSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button class="btn-ghost" @click="handleOffsetAdvanceReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
</div>
<ListTable table-key="bx-payment-3"
class="list-table"
:data="offsetAdvances"
v-loading="offsetAdvanceLoading"
max-height="300"
stripe
@selection-change="handleOffsetSelectionChange"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="applyCode" label="申请单号" min-width="130" align="center" header-align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="80" align="center" header-align="center" />
<el-table-column prop="reason" label="申请原因" min-width="120" align="left" header-align="center" show-overflow-tooltip />
<el-table-column label="已发放金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.actualAmount || row.amount || 0) }}
</template>
</el-table-column>
<el-table-column label="已冲抵金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.offsetAmount || 0) }}
</template>
</el-table-column>
<el-table-column label="剩余可冲抵(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(Number(row.actualAmount || row.amount || 0) - Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
</ListTable>
<div class="offset-pager">
<el-pagination
v-model:current-page="offsetAdvancePage"
v-model:page-size="offsetAdvancePageSize"
:total="offsetAdvanceTotal"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 100, 500]"
@current-change="fetchOffsetAdvances"
@size-change="handleOffsetAdvanceSizeChange"
/>
</div>
<el-form-item label="备注">
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<h4 class="dialog-section-title">冲抵附件(必填)</h4>
<BxAttachmentUpload
ref="offsetAttachmentRef"
v-model="uploadedFiles"
biz-type="reimburse_offset"
:biz-id="currentRow.id"
hint="请至少上传 1 个冲抵附件。"
/>
</div>
</template>
<el-form-item label="备注">
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<h4 class="dialog-section-title">冲抵附件(必填)</h4>
<BxAttachmentUpload
ref="offsetAttachmentRef"
v-model="uploadedFiles"
biz-type="reimburse_offset"
:biz-id="currentRow.id"
hint="请至少上传 1 个冲抵附件。"
/>
<template v-else>
<div class="form-grid-2">
<el-form-item label="本次发放金额(元)" class="field-amount">
<el-input v-model="actualAmount" placeholder="请输入本次发放金额" />
</el-form-item>
<el-form-item label="发放备注" style="grid-column: 1 / -1">
<el-input v-model="issueRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</div>
<h4 class="dialog-section-title">发放附件</h4>
<BxAttachmentUpload
ref="issueAttachmentRef"
v-model="payFiles"
biz-type="reimburse_pay"
:biz-id="currentRow.id"
hint="支持上传本次发放截图等凭证。"
/>
</template>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.offset = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.offsetSubmit" @click="submitOffset">
<el-button
v-if="paymentActionTab === 'pay'"
class="btn-main"
type="primary"
:loading="loadingMap.issueSubmit"
@click="submitIssue"
>
<el-icon><Check /></el-icon>确认发放
</el-button>
<el-button
v-else
class="btn-main"
type="primary"
:loading="loadingMap.offsetSubmit"
@click="submitOffset"
>
<el-icon><Check /></el-icon>确认冲抵
</el-button>
</template>
@@ -961,7 +981,7 @@ import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { paymentColumnFilterDefs } from './columnFilterDefs'
type DialogKey = 'viewDetail' | 'issue' | 'batchIssue' | 'offset' | 'processedDetail'
type DialogKey = 'viewDetail' | 'batchIssue' | 'offset' | 'processedDetail'
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
@@ -1030,9 +1050,16 @@ function buildPaymentStatsColumns(opts: {
money: PAYMENT_MONEY_PROPS.has(def.prop)
})
}
cols.push({ key: 'unpaidBalance', interactive: false, prop: 'unpaidBalance', money: true })
return cols
}
function unpaidBalance(row: any) {
const value =
Number(row?.amount || 0) - Number(row?.offsetAmount || 0) - Number(row?.actualAmount || 0)
return Math.max(0, Math.round(value * 100) / 100)
}
const pendingPaymentStatsRowColumns = computed(() =>
buildPaymentStatsColumns({ withSelection: true, mode: 'pending' })
)
@@ -1042,6 +1069,7 @@ const processedPaymentStatsRowColumns = computed(() =>
function getPaymentStatCellValue(row: any, prop: string): unknown {
if (prop === 'payAmount') return row?.actualAmount
if (prop === 'unpaidBalance') return unpaidBalance(row)
if (prop === 'processStatus') return processedStatusLabel(row?.status, row?.payStatus)
return row?.[prop]
}
@@ -1049,7 +1077,6 @@ function getPaymentStatCellValue(row: any, prop: string): unknown {
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
viewDetail: false,
issue: false,
batchIssue: false,
offset: false,
processedDetail: false
@@ -1172,15 +1199,10 @@ const viewBasicKvFields = computed<ViewField[]>(() => {
const viewPayKvFields = computed<ViewField[]>(() => {
const d = viewData.value
const payAmount = Number(d.amount || 0) - Number(d.offsetAmount || 0)
return [
bxViewKvMoney('冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('发放金额(元)', formatMoneyYuan(Math.max(0, payAmount)), { long: false }),
bxViewKvMoney(
'实际发放金额(元)',
d.actualAmount != null && d.actualAmount !== '' ? formatMoneyYuan(d.actualAmount) : '—',
{ long: false }
),
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
bxViewKv('发放时间', d.payTime, { long: false }),
bxViewKv('发放操作人', d.payUserName, { long: false }),
bxViewKv('发放备注', d.payRemark, { long: false })
@@ -1203,6 +1225,7 @@ const batchLines = ref<
>([])
const offsetRemark = ref('')
const uploadedFiles = ref<BxAttachmentItem[]>([])
const payFiles = ref<BxAttachmentItem[]>([])
const issueAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const offsetAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const viewAttachments = ref<BxAttachmentItem[]>([])
@@ -1215,18 +1238,7 @@ const offsetAdvancePageSize = ref(5)
const offsetAdvanceLoading = ref(false)
const offsetSelectedAdvances = ref<any[]>([])
const offsetAmount = ref('')
const offsetPayAmount = computed({
get: () => {
const amt = Number(currentRow.value.amount || 0)
const offset = Number(offsetAmount.value) || 0
return String(Math.max(0, amt - offset))
},
set: (val: string) => {
const amt = Number(currentRow.value.amount || 0)
const pay = Number(val) || 0
offsetAmount.value = String(Math.max(0, amt - pay))
}
})
const paymentActionTab = ref<'offset' | 'pay'>('offset')
function getPaymentPendingExportFilterParams() {
@@ -1284,15 +1296,10 @@ const processedBasicKvFields = computed<ViewField[]>(() => {
const processedPayKvFields = computed<ViewField[]>(() => {
const d = processedViewData.value
const payAmount = Number(d.amount || 0) - Number(d.offsetAmount || 0)
return [
bxViewKvMoney('冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('发放金额(元)', formatMoneyYuan(Math.max(0, payAmount)), { long: false }),
bxViewKvMoney(
'实际发放金额(元)',
formatMoneyYuan(d.actualAmount ?? Math.max(0, payAmount)),
{ long: false }
),
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
bxViewKv('发放时间', d.payTime, { long: false }),
bxViewKv('发放操作人', d.payUserName, { long: false }),
bxViewKv('发放备注', d.payRemark, { long: false })
@@ -1322,20 +1329,28 @@ function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
function handleToolbarIssue() {
function requireSinglePendingRow() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选一条待处理记录')
return
return null
}
openIssueDialog(selectedRows.value[0])
if (selectedRows.value.length > 1) {
ElMessage.warning('只能选择一条待处理记录')
return null
}
return selectedRows.value[0]
}
function handleToolbarIssue() {
const row = requireSinglePendingRow()
if (!row) return
openIssueDialog(row)
}
function handleToolbarOffset() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选一条待处理记录')
return
}
openOffsetDialog(selectedRows.value[0])
const row = requireSinglePendingRow()
if (!row) return
openOffsetDialog(row)
}
function buildPendingQuery() {
@@ -1411,11 +1426,7 @@ async function openView(row: any) {
}
function openIssueDialog(row: any) {
currentRow.value = row
issueRemark.value = ''
actualAmount.value = String(row.amount ?? '')
uploadedFiles.value = []
dialogs.issue = true
openOffsetDialog(row, 'pay')
}
function openBatchIssueDialog() {
@@ -1424,14 +1435,13 @@ function openBatchIssueDialog() {
return
}
batchLines.value = selectedRows.value.map((row) => {
const payAmount = String(row.amount ?? '')
return {
id: String(row.id),
applyCode: row.applyCode || '',
applicantName: row.applicantName || '',
title: row.title || '',
payAmount,
actualAmount: payAmount
payAmount: String(row.amount ?? ''),
actualAmount: String(unpaidBalance(row))
}
})
batchIssueRemark.value = ''
@@ -1469,20 +1479,41 @@ async function submitBatchIssue() {
}
}
async function keepOrCloseBill() {
await refresh()
const latest = rows.value.find((item) => item.id === currentRow.value.id)
if (!latest || unpaidBalance(latest) <= 0) {
dialogs.offset = false
return
}
currentRow.value = { ...latest }
}
async function submitIssue() {
if (issueAttachmentRef.value?.hasUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
const amount = Number(actualAmount.value)
if (!Number.isFinite(amount) || amount <= 0) {
ElMessage.warning('请填写本次发放金额')
return
}
if (amount > unpaidBalance(currentRow.value) + 0.001) {
ElMessage.warning('发放金额超过未结清余额')
return
}
loadingMap.issueSubmit = true
try {
await paymentApi.pay(currentRow.value.id, {
payRemark: issueRemark.value,
actualAmount: Number(actualAmount.value) || undefined
actualAmount: amount
})
ElMessage.success('发放成功')
dialogs.issue = false
await refresh()
actualAmount.value = ''
issueRemark.value = ''
payFiles.value = []
await keepOrCloseBill()
} catch (e: any) {
ElMessage.error(e.message || '发放失败')
} finally {
@@ -1532,44 +1563,42 @@ function getAdvanceAvailable(adv: any): number {
return Math.max(0, Number(adv.actualAmount || adv.amount || 0) - Number(adv.offsetAmount || 0))
}
function calcAutoOffsetAmount(selected: any[], reimburseAmount: number): number {
const totalAvailable = selected.reduce((sum, adv) => sum + getAdvanceAvailable(adv), 0)
return Math.min(totalAvailable, reimburseAmount)
}
function handleOffsetSelectionChange(rows: any[]) {
offsetSelectedAdvances.value = rows
const reimburseAmount = Number(currentRow.value.amount || 0)
offsetAmount.value = String(calcAutoOffsetAmount(rows, reimburseAmount))
}
function openOffsetDialog(row: any) {
function openOffsetDialog(row: any, tab: 'offset' | 'pay' = 'offset') {
currentRow.value = row
paymentActionTab.value = tab
offsetAdvanceFilter.keyword = ''
offsetAdvancePage.value = 1
offsetSelectedAdvances.value = []
offsetAmount.value = '0'
offsetAmount.value = ''
actualAmount.value = ''
issueRemark.value = ''
offsetRemark.value = ''
uploadedFiles.value = []
payFiles.value = []
dialogs.offset = true
fetchOffsetAdvances()
if (tab === 'offset') fetchOffsetAdvances()
}
function buildOffsetItems(totalOffset: number) {
const items: Array<{ advanceId: number; offsetAmount: number; applyCode: string }> = []
const items: Array<{ advanceId: string; offsetAmount: number; applyCode: string }> = []
let remaining = totalOffset
for (const adv of offsetSelectedAdvances.value) {
if (remaining <= 0) break
if (remaining <= 0.001) break
const available = getAdvanceAvailable(adv)
if (available <= 0) continue
const toOffset = Math.min(remaining, available)
items.push({
advanceId: adv.id,
offsetAmount: toOffset,
offsetAmount: Math.round(toOffset * 100) / 100,
applyCode: adv.applyCode || String(adv.id)
})
remaining -= toOffset
}
return items
return { items, leftover: remaining }
}
async function submitOffset() {
@@ -1582,6 +1611,10 @@ async function submitOffset() {
ElMessage.warning('请填写冲抵金额')
return
}
if (totalOffset > unpaidBalance(currentRow.value) + 0.001) {
ElMessage.warning('冲抵金额超过未结清余额')
return
}
if (uploadedFiles.value.length === 0) {
ElMessage.warning('请至少上传 1 个冲抵附件')
return
@@ -1591,16 +1624,18 @@ async function submitOffset() {
return
}
const items = buildOffsetItems(totalOffset)
const payAmount = Number(offsetPayAmount.value) || 0
const { items, leftover } = buildOffsetItems(totalOffset)
if (!items.length || leftover > 0.001) {
ElMessage.warning('冲抵金额超过所选备用金剩余可冲抵')
return
}
const advanceLines = items.map((it) => `${it.applyCode}(冲抵 ${formatMoneyYuan(it.offsetAmount)})`).join('\n')
const confirmMsg = [
'请确认冲抵信息:',
'',
`冲抵金额(元):${formatMoneyYuan(totalOffset)}`,
`本次冲抵金额(元):${formatMoneyYuan(totalOffset)}`,
'冲抵备用金:',
advanceLines,
`发放金额(元):${formatMoneyYuan(payAmount)}`,
'',
'确定提交冲抵吗?'
].join('\n')
@@ -1619,12 +1654,15 @@ async function submitOffset() {
try {
await paymentApi.batchOffset(currentRow.value.id, {
items: items.map(({ advanceId, offsetAmount: amt }) => ({ advanceId, offsetAmount: amt })),
payAmount,
payAmount: 0,
remark: offsetRemark.value
})
ElMessage.success('冲抵成功')
dialogs.offset = false
await refresh()
offsetAmount.value = ''
offsetRemark.value = ''
offsetSelectedAdvances.value = []
uploadedFiles.value = []
await keepOrCloseBill()
} catch (e: any) {
ElMessage.error(e.message || '冲抵失败')
} finally {