应收账单功能实现

This commit is contained in:
caojia
2026-09-16 11:02:18 +08:00
parent 58d13904ec
commit 7172621fd3
64 changed files with 4103 additions and 4 deletions
+45
View File
@@ -0,0 +1,45 @@
import request from '@/utils/request'
export function listArBill(query) {
return request({ url: '/ar/bill/list', method: 'get', params: query })
}
export function getArBill(arBillId) {
return request({ url: '/ar/bill/' + arBillId, method: 'get', timeout: 30000 })
}
export function getDefaultArBankAccount(currency = 'CNY') {
return request({ url: '/ar/bill/default-bank-account', method: 'get', params: { currency } })
}
export function generateArBill(data) {
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 60000 })
}
export function previewArBill(query) {
return request({ url: '/ar/bill/generate/preview', method: 'get', params: query })
}
export function confirmArBill(arBillId, data = {}) {
return request({ url: '/ar/bill/' + arBillId + '/confirm', method: 'put', data })
}
export function voidArBill(arBillId, data) {
return request({ url: '/ar/bill/' + arBillId + '/void', method: 'put', data })
}
export function exportArBill(arBillId) {
return request({ url: '/ar/bill/' + arBillId + '/export', method: 'post' })
}
export function listArBillExports(arBillId) {
return request({ url: '/ar/bill/' + arBillId + '/exports', method: 'get' })
}
export function downloadArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' })
}
export function listArBillLogs(query) {
return request({ url: '/ar/bill/log/list', method: 'get', params: query })
}
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listArReceipt(query) {
return request({ url: '/ar/receipt/list', method: 'get', params: query })
}
export function addArReceipt(data) {
return request({ url: '/ar/receipt', method: 'post', data })
}
export function cancelArReceipt(receiptId, data = {}) {
return request({ url: '/ar/receipt/' + receiptId + '/cancel', method: 'put', data })
}
export function listArWriteoffs(receiptId) {
return request({ url: '/ar/receipt/' + receiptId + '/writeoffs', method: 'get' })
}
export function writeoffArReceipt(data) {
return request({ url: '/ar/receipt/writeoff', method: 'post', data })
}
export function cancelArWriteoff(writeoffId, data = {}) {
return request({ url: '/ar/receipt/writeoff/' + writeoffId + '/cancel', method: 'put', data })
}
@@ -13,7 +13,11 @@
v-show="showSearch"
>
<template v-for="name in searchSlotNames" :key="name" #[toFormSlot(name)]>
<slot :name="name" />
<slot
:name="name"
:model="queryParams"
:item="props.searchColumns.find((item) => item.prop === name.slice('search-'.length))"
/>
</template>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
+23
View File
@@ -0,0 +1,23 @@
export const AR_BILL_STATUS = Object.freeze({
DRAFT: 'DRAFT',
CONFIRMED: 'CONFIRMED',
PARTIAL_WRITTEN_OFF: 'PARTIAL_WRITTEN_OFF',
WRITTEN_OFF: 'WRITTEN_OFF',
VOID: 'VOID'
})
export const AR_BILL_STATUS_OPTIONS = Object.freeze([
{ label: '草稿', value: AR_BILL_STATUS.DRAFT },
{ label: '已确认', value: AR_BILL_STATUS.CONFIRMED },
{ label: '部分核销', value: AR_BILL_STATUS.PARTIAL_WRITTEN_OFF },
{ label: '已核销', value: AR_BILL_STATUS.WRITTEN_OFF },
{ label: '已作废', value: AR_BILL_STATUS.VOID }
])
export const AR_BILL_STATUS_TYPES = Object.freeze({
[AR_BILL_STATUS.DRAFT]: 'info',
[AR_BILL_STATUS.CONFIRMED]: 'primary',
[AR_BILL_STATUS.PARTIAL_WRITTEN_OFF]: 'warning',
[AR_BILL_STATUS.WRITTEN_OFF]: 'success',
[AR_BILL_STATUS.VOID]: 'danger'
})
@@ -0,0 +1,76 @@
<template>
<el-drawer :model-value="modelValue" :title="detail?.billNo || '应收账单详情'" size="1000px" append-to-body destroy-on-close @update:model-value="$emit('update:modelValue', $event)">
<div v-loading="loading">
<el-empty v-if="!detail" description="暂无详情" />
<template v-else>
<el-descriptions :column="4" border size="small" class="summary">
<el-descriptions-item label="账单名称">{{ detail.billName || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.customerName }} ({{ detail.customerCode || '-' }})</el-descriptions-item>
<el-descriptions-item label="账单截止日">截至 {{ date(detail.billPeriodEnd) }}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="statusType(detail.status)">{{ statusText(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency }}</el-descriptions-item>
<el-descriptions-item label="应收总额"><span class="total">{{ money(detail.totalAmount) }}</span></el-descriptions-item>
<el-descriptions-item label="已核销">{{ money(detail.writtenOffAmount) }}</el-descriptions-item>
<el-descriptions-item label="未核销">{{ money(detail.unwrittenAmount) }}</el-descriptions-item>
<el-descriptions-item label="收款账户">{{ detail.bankAccountName || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行">{{ detail.bankName || '-' }}</el-descriptions-item>
<el-descriptions-item label="收款账号">{{ mask(detail.bankAccountNo) }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">订单快照({{ detail.orderCount || 0 }})</div>
<el-table :data="detail.details || []" border size="small">
<el-table-column type="expand">
<template #default="{ row }">
<el-table :data="row.fees || []" border size="small" class="fee-table">
<el-table-column prop="feeType" label="费用类型" width="140" />
<el-table-column prop="feeName" label="费用名称" min-width="160" />
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
<el-table-column prop="currency" label="币种" width="80" />
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
</el-table>
</template>
</el-table-column>
<el-table-column prop="detailNo" label="#" width="55" align="center" />
<el-table-column prop="orderNo" label="订单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="160" show-overflow-tooltip />
<el-table-column label="产品" min-width="150"><template #default="{ row }">{{ row.productName || row.productCode || '-' }}</template></el-table-column>
<el-table-column label="目的地" min-width="120"><template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template></el-table-column>
<el-table-column label="重量/尺寸" min-width="170"><template #default="{ row }">{{ row.chargeWeight || '-' }} kg / {{ dimensions(row) }}</template></el-table-column>
<el-table-column prop="totalAmount" label="订单金额" width="120" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column>
</el-table>
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" @click="$emit('export')">生成新文件</el-button></div>
<el-table :data="detail.exports || []" border size="small">
<el-table-column prop="fileName" label="文件名" min-width="250" show-overflow-tooltip />
<el-table-column prop="templateVersion" label="模板版本" width="100" />
<el-table-column prop="exportStatus" label="状态" width="100"><template #default="{ row }"><el-tag :type="row.exportStatus === 'SUCCESS' ? 'success' : 'danger'">{{ row.exportStatus }}</el-tag></template></el-table-column>
<el-table-column prop="exportBy" label="导出人" width="100" />
<el-table-column prop="exportTime" label="导出时间" width="170" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.exportStatus === 'SUCCESS'" link type="primary" @click="$emit('download', row)">下载</el-button></template></el-table-column>
</el-table>
</template>
</div>
</el-drawer>
</template>
<script setup>
defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
defineEmits(['update:modelValue', 'download', 'export'])
const statusOptions = [{ label: '草稿', value: 'DRAFT' }, { label: '已确认', value: 'CONFIRMED' }, { label: '部分核销', value: 'PARTIAL_WRITTEN_OFF' }, { label: '已核销', value: 'WRITTEN_OFF' }, { label: '已作废', value: 'VOID' }]
function date(value) { return value ? String(value).slice(0, 10) : '-' }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function statusText(value) { return statusOptions.find(item => item.value === value)?.label || value || '-' }
function statusType(value) { return { DRAFT: 'info', CONFIRMED: 'primary', PARTIAL_WRITTEN_OFF: 'warning', WRITTEN_OFF: 'success', VOID: 'danger' }[value] || 'info' }
function dimensions(row) { return [row.pkgLength, row.pkgWidth, row.pkgHeight].map(v => v == null ? '-' : v).join(' × ') }
function mask(value) { if (!value) return '-'; return value.length <= 4 ? '****' : '****' + value.slice(-4) }
</script>
<style scoped>
.summary { margin-bottom: 18px; }
.total { color: #f56c6c; font-size: 16px; font-weight: 600; }
.section-title { display: flex; align-items: center; gap: 12px; margin: 18px 0 10px; font-weight: 600; }
.section-title::before { content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--el-color-primary); }
.export-title { justify-content: space-between; }
.export-title::before { margin-right: auto; }
.fee-table { background: #fafafa; }
</style>
@@ -0,0 +1,224 @@
<template>
<div class="generate-bill-dialogs">
<el-dialog v-model="dialogOpen" class="generate-dialog" title="生成应收账单" width="1200px" append-to-body destroy-on-close>
<el-form ref="generateFormRef" :model="generateForm" :rules="generateRules" label-width="110px" class="generate-form">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="客户" prop="customerId"><customer-select v-model="generateForm.customerId" placeholder="请选择客户" @change="handleCustomerChange" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="币种" prop="currency"><el-select v-model="generateForm.currency" placeholder="请选择币种" style="width: 100%;" @change="handleCurrencyChange"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="账单截止日" prop="cutoffDate"><el-date-picker v-model="generateForm.cutoffDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择账单截止日" style="width: 100%;" @change="resetPreview" /></el-form-item></el-col>
</el-row>
</el-form>
<div class="payee-card" v-loading="bankAccountLoading">
<div class="payee-title">收款方信息(按币种自动匹配)</div>
<template v-if="bankAccount">
<span><b>开户名:</b>{{ bankAccount.accountName }}</span>
<span><b>开户行:</b>{{ bankAccount.bankName }}</span>
<span><b>账号:</b>{{ bankAccount.accountNo }}</span>
</template>
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
</div>
<div class="preview-toolbar">
<span v-if="previewSearched" class="preview-summary">可选 {{ previewRows.length }} 条订单,已选 <b>{{ selectedRows.length }}</b> 条,应收合计 <b>{{ money(selectedAmount) }}</b></span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
</div>
<el-table ref="previewTableRef" v-loading="previewLoading" :data="previewRows" row-key="billId" border stripe max-height="400" empty-text="暂无符合条件的计费订单" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="跟踪号" min-width="140" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="130" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="105" :formatter="formatDateColumn" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryName" label="国家/地区" min-width="110" show-overflow-tooltip />
<el-table-column prop="actualWeight" label="实际重(kg)" width="105" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="chargeWeight" label="计费重(kg)" width="105" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="75" />
</el-table>
<template #footer>
<span class="generate-footer-summary">已选 {{ selectedRows.length }} 条订单 · {{ money(selectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !selectedRows.length" @click="submitGenerate">生成账单</el-button>
<el-button @click="dialogOpen = false">取消</el-button>
</template>
</el-dialog>
<el-dialog v-model="billNameOpen" title="填写账单名称" width="520px" append-to-body destroy-on-close>
<el-form ref="billNameFormRef" :model="billNameForm" :rules="billNameRules" label-width="90px">
<el-form-item label="账单名称" prop="billName">
<el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" />
</el-form-item>
</el-form>
<div class="bill-name-summary">已选择 {{ selectedRows.length }} 条订单,应收合计 {{ money(selectedAmount) }}</div>
<template #footer>
<el-button @click="billNameOpen = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="GenerateBillDialog">
import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const { proxy } = getCurrentInstance()
const dialogOpen = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const billNameOpen = ref(false)
const generateLoading = ref(false)
const previewLoading = ref(false)
const previewSearched = ref(false)
const previewRows = ref([])
const previewTableRef = ref()
const selectedRows = ref([])
const bankAccountLoading = ref(false)
const bankAccount = ref(null)
const generateRequestId = ref('')
const generateFormRef = ref()
const billNameFormRef = ref()
const generateForm = reactive({ customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
const billNameForm = reactive({ billName: '' })
const generateRules = {
customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
cutoffDate: [{ required: true, message: '请选择账单截止日', trigger: 'change' }],
currency: [{ required: true, message: '请选择币种', trigger: 'change' }]
}
const billNameRules = { billName: [{ required: true, message: '请输入账单名称', trigger: 'blur' }] }
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
watch(() => props.modelValue, visible => {
if (visible) {
openGenerate()
}
})
function openGenerate() {
Object.assign(generateForm, { customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
Object.assign(billNameForm, { billName: '' })
billNameOpen.value = false
resetPreview()
bankAccount.value = null
loadDefaultBankAccount()
nextTick(() => generateFormRef.value?.clearValidate())
}
function handleCustomerChange(_, item) {
generateForm.customerCode = item?.customerCode || ''
resetPreview()
}
function handleCurrencyChange() {
resetPreview()
loadDefaultBankAccount()
}
function loadDefaultBankAccount() {
bankAccountLoading.value = true
bankAccount.value = null
getDefaultArBankAccount(generateForm.currency).then(res => {
bankAccount.value = res.data || null
}).finally(() => {
bankAccountLoading.value = false
})
}
function resetPreview() {
previewRows.value = []
selectedRows.value = []
previewSearched.value = false
generateRequestId.value = ''
nextTick(() => previewTableRef.value?.clearSelection())
}
function buildGenerateQuery() {
return {
customerId: Number(generateForm.customerId),
customerCode: generateForm.customerCode?.trim() || undefined,
billPeriodEnd: generateForm.cutoffDate,
currency: generateForm.currency || undefined
}
}
function buildGenerateRequest(requestId) {
return {
...buildGenerateQuery(),
billName: billNameForm.billName.trim(),
sourceBillIds: selectedRows.value.map(row => row.billId),
requestId
}
}
function queryGeneratePreview() {
generateFormRef.value.validate(valid => {
if (!valid) return
resetPreview()
previewLoading.value = true
previewArBill(buildGenerateQuery()).then(res => {
previewRows.value = res.rows || []
previewSearched.value = true
}).finally(() => {
previewLoading.value = false
})
})
}
function handleSelectionChange(rows) {
selectedRows.value = rows
if (!generateLoading.value) generateRequestId.value = ''
}
function submitGenerate() {
if (!previewSearched.value || !selectedRows.value.length) {
proxy.$modal.msgError('请至少选择一条计费订单')
return
}
Object.assign(billNameForm, { billName: '' })
billNameOpen.value = true
nextTick(() => billNameFormRef.value?.clearValidate())
}
function confirmGenerate() {
billNameFormRef.value.validate(valid => {
if (!valid) return
if (!generateRequestId.value) generateRequestId.value = createRequestId()
generateLoading.value = true
generateArBill(buildGenerateRequest(generateRequestId.value)).then(res => {
proxy.$modal.msgSuccess('账单生成成功')
billNameOpen.value = false
dialogOpen.value = false
emit('success', res.data)
}).finally(() => {
generateLoading.value = false
})
})
}
function date(value) { return value ? String(value).slice(0, 10) : '-' }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function formatDateColumn(_, __, value) { return date(value) }
function formatNumberColumn(_, __, value) { return Number(value || 0).toFixed(2) }
function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${Date.now()}-${Math.random().toString(16).slice(2)}` }
</script>
<style scoped>
.generate-dialog :deep(.el-dialog__body) { padding: 18px 24px 10px; }
.generate-form { padding: 4px 6px 0; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; background: var(--el-fill-color-blank); }
.generate-form :deep(.el-form-item) { margin-bottom: 16px; }
.payee-card { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; min-height: 42px; margin: 4px 0 12px; padding: 9px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 4px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 13px; }
.payee-title { color: var(--el-text-color-primary); font-weight: 600; }
.payee-empty { color: var(--el-color-danger); }
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 10px; padding: 10px 14px; border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.preview-summary { flex: 1; font-size: 14px; }
.preview-summary b { color: var(--el-color-primary); }
.bill-name-summary { margin: -4px 0 8px 90px; color: var(--el-text-color-secondary); font-size: 13px; }
.generate-footer-summary { margin-right: auto; color: var(--el-text-color-secondary); font-size: 13px; }
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<div class="app-container ar-bill-page">
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :query="queryList" row-key="arBillId" :selection="false">
<template #toolbar>
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:bill:generate']" @click="generateOpen = true">生成账单</el-button></el-col>
</template>
<template #col-status="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
<template #col-totalAmount="{ row }"><span class="amount">{{ money(row.totalAmount) }}</span></template>
<template #col-writtenOffAmount="{ row }"><span class="written">{{ money(row.writtenOffAmount) }}</span></template>
<template #col-unwrittenAmount="{ row }"><span class="unwritten">{{ money(row.unwrittenAmount) }}</span></template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="viewBill(row)">详情</el-button>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出</el-button>
</template>
</basic-table>
<bill-detail v-model="detailOpen" :detail="detail" :loading="detailLoading" @download="downloadExport" @export="exportBill(detail)" />
<generate-bill-dialog v-model="generateOpen" @success="handleGenerateSuccess" />
</div>
</template>
<script setup name="ArBill">
import { saveAs } from 'file-saver'
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, downloadArBillExport } from '@/api/ar/bill'
import BillDetail from './components/BillDetail.vue'
import GenerateBillDialog from './components/GenerateBillDialog.vue'
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const generateOpen = ref(false)
const statusOptions = AR_BILL_STATUS_OPTIONS
const columns = [
{ label: '账单号', prop: 'billNo', minWidth: 180, showOverflowTooltip: true }, { label: '账单名称', prop: 'billName', minWidth: 180, showOverflowTooltip: true }, { label: '客户', prop: 'customerName', minWidth: 130, showOverflowTooltip: true },
{ label: '账单截止日', prop: 'billPeriodEnd', minWidth: 120, formatter: row => `截至 ${date(row.billPeriodEnd)}` }, { label: '订单数', prop: 'orderCount', width: 80 },
{ label: '应收总额', prop: 'totalAmount', width: 120, align: 'right', slot: 'totalAmount' }, { label: '已核销', prop: 'writtenOffAmount', width: 110, align: 'right', slot: 'writtenOffAmount' },
{ label: '未核销', prop: 'unwrittenAmount', width: 110, align: 'right', slot: 'unwrittenAmount' }, { label: '状态', prop: 'status', width: 110, slot: 'status' },
{ label: '账单日期', prop: 'billDate', width: 120, formatter: row => date(row.billDate) }, { label: '操作', prop: 'action', width: 260, fixed: 'right', slot: 'action', showInToolbar: false }
]
const searchColumns = [
{ label: '账单号', prop: 'billNo', component: 'input', placeholder: '账单号', width: '180px' }, { label: '账单名称', prop: 'billName', component: 'input', placeholder: '账单名称', width: '180px' }, { label: '客户编码', prop: 'customerCode', component: 'input', placeholder: '客户编码', width: '140px' },
{ label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '全部状态', width: '150px' }, { label: '账期', prop: 'periodRange', component: 'daterange', width: '300px' }
]
function queryList(params) { const query = { ...params }; if (query.periodRange?.length === 2) { query.billPeriodStart = query.periodRange[0]; query.billPeriodEnd = query.periodRange[1] }; delete query.periodRange; return listArBill(query) }
function handleGenerateSuccess(bill) { tableRef.value.refresh(); viewBill(bill) }
function viewBill(row) { if (!row?.arBillId) return; detailOpen.value = true; detailLoading.value = true; detail.value = null; getArBill(row.arBillId).then(res => { detail.value = res.data || null }).finally(() => { detailLoading.value = false }) }
function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function exportBill(row) { if (!row?.arBillId) return; exportArBill(row.arBillId).then(res => { proxy.$modal.msgSuccess('导出成功'); tableRef.value.refresh(); if (detailOpen.value) viewBill(row); if (res.data?.exportId) downloadExport(res.data) }) }
function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) }
function date(value) { return value ? String(value).slice(0, 10) : '-' }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function statusText(value) { return statusOptions.find(item => item.value === value)?.label || value || '-' }
function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
</script>
<style scoped>
.ar-bill-page .amount { color: #f56c6c; font-weight: 600; }
.ar-bill-page .written { color: #67c23a; }
.ar-bill-page .unwritten { color: #e6a23c; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="app-container">
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :query="listArBillLogs" row-key="logId" :selection="false">
<template #col-result="{ row }"><el-tag :type="row.result === 'SUCCESS' ? 'success' : 'danger'">{{ resultText(row.result) }}</el-tag></template>
</basic-table>
</div>
</template>
<script setup name="ArBillLog">
import { listArBillLogs } from '@/api/ar/bill'
const tableRef = ref()
const searchColumns = [
{ label: '对象类型', prop: 'objectType', component: 'select', options: [{ label: '应收账单', value: 'BILL' }, { label: '账单导出', value: 'EXPORT' }, { label: '应收收款', value: 'RECEIPT' }, { label: '收款核销', value: 'WRITEOFF' }], width: '130px' },
{ label: '操作类型', prop: 'operationType', component: 'input', width: '140px' }, { label: '结果', prop: 'result', component: 'select', options: [{ label: '成功', value: 'SUCCESS' }, { label: '失败', value: 'FAILED' }], width: '120px' }
]
const columns = [
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },
{ label: '状态变化', prop: 'status', width: 160, formatter: row => `${statusText(row.fromStatus)} → ${statusText(row.toStatus)}` }, { label: '操作人', prop: 'operatorName', width: 110 },
{ label: '结果', prop: 'result', width: 90, slot: 'result' }, { label: '时间', prop: 'createTime', width: 170, parseTime: true }
]
const objectTypeLabels = { BILL: '应收账单', EXPORT: '账单导出', RECEIPT: '应收收款', WRITEOFF: '收款核销' }
const operationLabels = { CONFIRM: '确认账单', VOID: '作废账单', EXPORT: '导出账单', RECEIPT: '登记收款', CANCEL_RECEIPT: '取消收款', WRITEOFF: '收款核销', CANCEL_WRITEOFF: '取消核销' }
const statusLabels = { DRAFT: '草稿', CONFIRMED: '已确认', PARTIAL_WRITTEN_OFF: '部分核销', WRITTEN_OFF: '已核销', VOID: '已作废', REGISTERED: '已登记', CANCELLED: '已取消', EFFECTIVE: '有效' }
function objectTypeText(value) { return objectTypeLabels[value] || value || '-' }
function operationText(value) { return operationLabels[value] || value || '-' }
function statusText(value) { return statusLabels[value] || value || '-' }
function resultText(value) { return value === 'SUCCESS' ? '成功' : value === 'FAILED' ? '失败' : value || '-' }
</script>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div class="app-container ar-receipt-page">
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :query="queryList" row-key="receiptId" :selection="false">
<template #search-customerId="{ model }">
<customer-select v-model="model.customerId" placeholder="请选择客户" class="receipt-customer-search" />
</template>
<template #toolbar><el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:receipt:add']" @click="openRegister">登记收款</el-button></el-col></template>
<template #col-receiptAmount="{ row }"><span class="income">{{ row.currency }} {{ money(row.receiptAmount) }}</span></template>
<template #col-unallocatedAmount="{ row }"><span class="pending">{{ money(row.unallocatedAmount) }}</span></template>
<template #col-status="{ row }"><el-tag :type="receiptStatusType(row.status)">{{ receiptStatusText(row.status) }}</el-tag></template>
<template #col-action="{ row }">
<el-button v-if="row.status !== 'CANCELLED' && Number(row.unallocatedAmount) > 0" link type="primary" v-hasPermi="['ar:receipt:writeoff']" @click="openWriteoff(row)">核销</el-button>
<el-button v-if="row.status === 'REGISTERED'" link type="warning" v-hasPermi="['ar:receipt:cancel']" @click="cancelReceipt(row)">取消收款</el-button>
<el-button link type="primary" @click="openWriteoffHistory(row)">核销记录</el-button>
</template>
</basic-table>
<el-dialog v-model="registerOpen" title="登记应收收款" width="620px" append-to-body>
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-width="110px">
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="客户" prop="customerId"><customer-select v-model="registerForm.customerId" placeholder="请选择客户" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="到账日期" prop="receiptDate"><el-date-picker v-model="registerForm.receiptDate" type="date" value-format="YYYY-MM-DD" style="width: 100%;" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="到账金额" prop="receiptAmount"><el-input v-model="registerForm.receiptAmount" type="number" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="币种"><el-select v-model="registerForm.currency" style="width: 100%;"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="收款方式"><el-select v-model="registerForm.paymentMethod" style="width: 100%;"><el-option label="银行转账" value="BANK" /><el-option label="支付宝" value="ALIPAY" /><el-option label="微信" value="WECHAT" /><el-option label="现金" value="CASH" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="银行流水号"><el-input v-model="registerForm.bankReference" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="收款单号"><el-input v-model="registerForm.receiptNo" placeholder="不填则自动生成" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="registerForm.remark" type="textarea" :rows="2" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer><el-button type="primary" :loading="registerLoading" @click="submitRegister">登记</el-button><el-button @click="registerOpen = false">取消</el-button></template>
</el-dialog>
<el-dialog v-model="writeoffOpen" title="批量核销收款" width="680px" append-to-body>
<el-descriptions :column="3" border size="small" class="writeoff-summary"><el-descriptions-item label="收款单号">{{ writeoffForm.receiptNo }}</el-descriptions-item><el-descriptions-item label="币种">{{ writeoffForm.currency }}</el-descriptions-item><el-descriptions-item label="可核销">{{ money(writeoffForm.unallocatedAmount) }}</el-descriptions-item></el-descriptions>
<el-form label-width="90px" class="allocation-form"><div v-for="(item, index) in writeoffForm.allocations" :key="index" class="allocation-row"><el-form-item label="账单"><el-select v-model="item.arBillId" filterable clearable :loading="billLoading" placeholder="请选择账单" class="bill-select"><el-option v-for="bill in billOptions" :key="bill.arBillId" :label="billOptionLabel(bill)" :value="bill.arBillId" /></el-select></el-form-item><el-form-item label="核销金额"><el-input v-model="item.amount" type="number" /></el-form-item><el-button v-if="writeoffForm.allocations.length > 1" link type="danger" icon="Delete" @click="removeAllocation(index)" /></div><div v-if="writeoffOpen && !billLoading && !billOptions.length" class="bill-empty">当前客户暂无可核销账单</div><el-button link type="primary" icon="Plus" @click="addAllocation">添加账单</el-button><el-form-item label="核销日期"><el-date-picker v-model="writeoffForm.writeoffDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="writeoffForm.remark" /></el-form-item></el-form>
<template #footer><el-button type="primary" :loading="writeoffLoading" @click="submitWriteoff">确认核销</el-button><el-button @click="writeoffOpen = false">取消</el-button></template>
</el-dialog>
<el-dialog v-model="historyOpen" title="核销记录" width="820px" append-to-body>
<el-table :data="historyRows" border size="small" v-loading="historyLoading">
<el-table-column prop="writeoffNo" label="核销单号" min-width="180" />
<el-table-column prop="billNo" label="账单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="writeoffAmount" label="核销金额" width="120" align="right"><template #default="{ row }">{{ money(row.writeoffAmount) }}</template></el-table-column>
<el-table-column prop="writeoffDate" label="核销日期" width="120" />
<el-table-column prop="status" label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'EFFECTIVE' ? 'success' : 'info'">{{ row.status === 'EFFECTIVE' ? '有效' : '已取消' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.status === 'EFFECTIVE'" link type="danger" v-hasPermi="['ar:receipt:cancel']" @click="cancelWriteoff(row)">取消</el-button></template></el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup name="ArReceipt">
import { listArReceipt, addArReceipt, cancelArReceipt, listArWriteoffs, writeoffArReceipt, cancelArWriteoff } from '@/api/ar/receipt'
import { listArBill } from '@/api/ar/bill'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const registerOpen = ref(false), registerLoading = ref(false), registerFormRef = ref()
const writeoffOpen = ref(false), writeoffLoading = ref(false)
const billLoading = ref(false), billOptions = ref([])
const historyOpen = ref(false), historyLoading = ref(false), historyRows = ref([]), historyReceiptId = ref(null)
const today = () => proxy.parseTime(new Date(), '{y}-{m}-{d}')
const registerForm = reactive({ customerId: '', receiptDate: today(), receiptAmount: '', currency: 'CNY', paymentMethod: 'BANK', bankReference: '', receiptNo: '', remark: '' })
const registerRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], receiptDate: [{ required: true, message: '到账日期不能为空', trigger: 'change' }], receiptAmount: [{ required: true, message: '到账金额不能为空', trigger: 'blur' }] }
const writeoffForm = reactive({ receiptId: null, receiptNo: '', currency: '', unallocatedAmount: 0, writeoffDate: today(), remark: '', allocations: [{ arBillId: '', amount: '' }] })
const columns = [
{ label: '收款单号', prop: 'receiptNo', minWidth: 190, showOverflowTooltip: true }, { label: '客户', prop: 'customerName', minWidth: 180, showOverflowTooltip: true, formatter: row => customerText(row) }, { label: '到账日期', prop: 'receiptDate', width: 120 },
{ label: '到账金额', prop: 'receiptAmount', width: 135, align: 'right', slot: 'receiptAmount' }, { label: '未分配', prop: 'unallocatedAmount', width: 120, align: 'right', slot: 'unallocatedAmount' },
{ label: '收款方式', prop: 'paymentMethod', width: 110, formatter: row => methodText(row.paymentMethod) }, { label: '流水号', prop: 'bankReference', minWidth: 160, showOverflowTooltip: true },
{ label: '状态', prop: 'status', width: 120, slot: 'status' }, { label: '登记人', prop: 'createBy', width: 100 }, { label: '操作', prop: 'action', width: 150, fixed: 'right', slot: 'action', showInToolbar: false }
]
const searchColumns = [
{ label: '客户', prop: 'customerId', component: 'input', width: '160px' }, { label: '收款单号', prop: 'receiptNo', component: 'input', width: '180px' },
{ label: '状态', prop: 'status', component: 'select', options: [{ label: '已登记', value: 'REGISTERED' }, { label: '部分核销', value: 'PARTIAL_WRITTEN_OFF' }, { label: '已核销', value: 'WRITTEN_OFF' }, { label: '已取消', value: 'CANCELLED' }], width: '140px' },
{ label: '到账日期', prop: 'dateRange', component: 'daterange', width: '280px' }
]
function queryList(params) { const query = { ...params }; if (query.dateRange?.length === 2) { query.receiptDateStart = query.dateRange[0]; query.receiptDateEnd = query.dateRange[1] }; delete query.dateRange; return listArReceipt(query) }
function openRegister() { Object.assign(registerForm, { customerId: '', receiptDate: today(), receiptAmount: '', currency: 'CNY', paymentMethod: 'BANK', bankReference: '', receiptNo: '', remark: '' }); registerOpen.value = true; nextTick(() => registerFormRef.value?.clearValidate()) }
function submitRegister() { registerFormRef.value.validate(valid => { if (!valid) return; registerLoading.value = true; const data = { ...registerForm, customerId: Number(registerForm.customerId), receiptAmount: String(registerForm.receiptAmount) }; addArReceipt(data).then(() => { proxy.$modal.msgSuccess('收款登记成功'); registerOpen.value = false; tableRef.value.refresh() }).finally(() => { registerLoading.value = false }) }) }
function cancelReceipt(row) { proxy.$modal.confirm(`确认取消收款「${row.receiptNo}」?`).then(() => cancelArReceipt(row.receiptId, { remark: '页面取消收款' })).then(() => { proxy.$modal.msgSuccess('收款已取消'); tableRef.value.refresh() }).catch(() => {}) }
function openWriteoff(row) { Object.assign(writeoffForm, { receiptId: row.receiptId, receiptNo: row.receiptNo, currency: row.currency, unallocatedAmount: row.unallocatedAmount, writeoffDate: today(), remark: '', allocations: [{ arBillId: '', amount: '' }] }); billOptions.value = []; writeoffOpen.value = true; loadBillOptions(row) }
function loadBillOptions(row) { billLoading.value = true; return listArBill({ pageNum: 1, pageSize: 1000, customerId: row.customerId, currency: row.currency }).then(res => { billOptions.value = (res.rows || []).filter(bill => ['CONFIRMED', 'PARTIAL_WRITTEN_OFF'].includes(bill.status) && Number(bill.unwrittenAmount) > 0) }).finally(() => { billLoading.value = false }) }
function billOptionLabel(bill) { return `${bill.billNo || '未命名账单'}(未核销:${money(bill.unwrittenAmount)})` }
function addAllocation() { writeoffForm.allocations.push({ arBillId: '', amount: '' }) }
function removeAllocation(index) { writeoffForm.allocations.splice(index, 1) }
function submitWriteoff() { const allocations = writeoffForm.allocations.filter(item => item.arBillId && item.amount).map(item => ({ arBillId: Number(item.arBillId), amount: String(item.amount) })); if (!allocations.length) { proxy.$modal.msgError('请至少填写一条账单核销金额'); return }; writeoffLoading.value = true; writeoffArReceipt({ receiptId: writeoffForm.receiptId, currency: writeoffForm.currency, writeoffDate: writeoffForm.writeoffDate, remark: writeoffForm.remark, allocations }).then(() => { proxy.$modal.msgSuccess('核销成功'); writeoffOpen.value = false; tableRef.value.refresh(); if (historyOpen.value) openWriteoffHistory({ receiptId: writeoffForm.receiptId }) }).finally(() => { writeoffLoading.value = false }) }
function openWriteoffHistory(row) { historyReceiptId.value = row.receiptId; historyOpen.value = true; historyLoading.value = true; listArWriteoffs(row.receiptId).then(res => { historyRows.value = res.data || [] }).finally(() => { historyLoading.value = false }) }
function cancelWriteoff(row) { proxy.$modal.confirm(`确认取消核销「${row.writeoffNo}」?`).then(() => cancelArWriteoff(row.writeoffId, { remark: '页面取消核销' })).then(() => { proxy.$modal.msgSuccess('核销已取消'); openWriteoffHistory({ receiptId: historyReceiptId.value }); tableRef.value.refresh() }).catch(() => {}) }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function customerText(row) { return row.customerName ? `${row.customerName}${row.customerCode ? `(${row.customerCode})` : ''}` : row.customerCode || '-' }
function methodText(value) { return { BANK: '银行转账', ALIPAY: '支付宝', WECHAT: '微信', CASH: '现金' }[value] || value || '-' }
function receiptStatusText(value) { return { REGISTERED: '已登记', PARTIAL_WRITTEN_OFF: '部分核销', WRITTEN_OFF: '已核销', CANCELLED: '已取消' }[value] || value || '-' }
function receiptStatusType(value) { return { REGISTERED: 'info', PARTIAL_WRITTEN_OFF: 'warning', WRITTEN_OFF: 'success', CANCELLED: 'danger' }[value] || 'info' }
</script>
<style scoped>
.ar-receipt-page .income { color: #67c23a; font-weight: 600; }
.ar-receipt-page .pending { color: #e6a23c; font-weight: 600; }
.writeoff-summary { margin-bottom: 18px; }
.allocation-row { display: flex; align-items: center; gap: 10px; }
.allocation-row .el-form-item { flex: 1; }
.bill-select { width: 100%; }
.bill-empty { margin: -4px 0 12px 90px; color: var(--el-text-color-secondary); font-size: 13px; }
</style>