完善收付款管理

This commit is contained in:
2026-08-27 23:52:17 +08:00
parent e98aa61cd2
commit b446e34c94
14 changed files with 783 additions and 629 deletions
+229 -119
View File
@@ -93,9 +93,9 @@
:disabled="readonly"
multiple
filterable
allow-create
default-first-option
:multiple-limit="3"
placeholder="请选择客商开票信息邮箱"
no-data-text="当前客商开票信息未配置邮箱"
>
<el-option
v-for="email in departmentEmailOptions"
@@ -203,9 +203,14 @@
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column label="商品和服务分类" min-width="160">
<template #default="{ row }">
<el-select v-model="row.goodsCategory" :disabled="readonly">
<el-select
v-model="row.goodsCategory"
:disabled="readonly"
filterable
@change="handleGoodsCategoryChange(row)"
>
<el-option
v-for="item in goodsCategoryOptions"
v-for="item in invoiceItemCategories"
:key="item"
:label="item"
:value="item"
@@ -215,12 +220,17 @@
</el-table-column>
<el-table-column label="货物或服务简称" min-width="170">
<template #default="{ row }">
<el-select v-model="row.goodsName" :disabled="readonly" filterable allow-create>
<el-select
v-model="row.goodsName"
:disabled="readonly"
filterable
@change="handleGoodsNameChange(row)"
>
<el-option
v-for="item in goodsNameOptions"
:key="item"
:label="item"
:value="item"
v-for="item in invoiceItemNames(row.goodsCategory)"
:key="item.shortName"
:label="item.shortName"
:value="item.shortName"
/>
</el-select>
</template>
@@ -358,7 +368,15 @@
<el-link v-if="column.link && row[column.prop]" type="primary">
{{ row[column.prop] }}
</el-link>
<span
v-else-if="
['transportQuantity', 'mileage'].includes(column.prop) &&
Number(row[column.prop]) === -1
"
></span>
<span v-else-if="column.feeItemKeys">{{ formatMoney(getFeeItemValue(row, column.feeItemKeys)) }}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'transportType'">{{ transportTypeLabel(row.transportType) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
@@ -366,41 +384,7 @@
</section-card>
<section-card title="附件信息">
<el-table :data="form.attachments" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="originalName" label="文件名" min-width="220" />
<el-table-column label="附件描述" min-width="240">
<template #default="{ row }">
<el-input v-model="row.description" :disabled="readonly" maxlength="200" />
</template>
</el-table-column>
<el-table-column prop="size" label="文件大小" width="120" />
<el-table-column prop="uploadUserName" label="上传人" width="140" />
<el-table-column prop="uploadTime" label="上传时间" width="170" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row, $index }">
<div class="invoice-form-page__links">
<el-link type="primary" :href="row.url || row.link" target="_blank">查看</el-link>
<el-link v-if="!readonly" type="danger" @click="form.attachments.splice($index, 1)"
>删除</el-link
>
</div>
</template>
</el-table-column>
</el-table>
<div class="invoice-form-page__attachment-upload">
<vehicle-attachment-upload
v-model="form.attachments"
:readonly="readonly"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
:show-file-list="false"
button-text="上传"
@change="normalizeAttachments"
/>
</div>
<vehicle-attachment-table v-model="form.attachments" :readonly="readonly" />
</section-card>
<section-card title="备注">
@@ -469,6 +453,11 @@
<el-table-column label="可开票金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.availableInvoiceAmount) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectSettlement(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="settlementDialog.visible = false">取消</el-button>
@@ -481,13 +470,16 @@
<script>
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/invoiceApplication';
import { getAll as getInvoiceItems } from '@/api/base/invoice-item';
import { getDictionary } from '@/api/system/dictbiz';
import { invoiceApplicationDetailColumns } from '@/option/payment/invoiceApplication';
import VehicleAttachmentTable from '@/components/vehicle-attachment-table/main.vue';
let localId = 0;
const emptyLine = () => ({
localId: ++localId,
goodsCategory: '交通运输服务',
goodsName: '运费',
goodsCategory: '',
goodsName: '',
unit: '吨',
quantity: 0,
unitPriceNoTax: 0,
@@ -525,6 +517,9 @@ const emptyForm = () => ({
export default {
name: 'InvoiceApplicationForm',
components: {
VehicleAttachmentTable,
},
data() {
return {
form: emptyForm(),
@@ -533,29 +528,14 @@ export default {
receiverInvoiceOptions: [],
contactOptions: [],
departmentEmailOptions: [],
invoiceItems: [],
transportTypeOptions: [],
invoiceTypeOptions: api.invoiceTypeOptions,
detailColumns: invoiceApplicationDetailColumns,
goodsCategoryOptions: ['交通运输服务', '生产生活服务', '其他现代服务'],
goodsNameOptions: ['运费', '装卸搬运服务', '过路费', '仓储服务', '代理服务费'],
detailBaseColumns: invoiceApplicationDetailColumns.filter(
column => !['feeItemsJson', 'freightAmount'].includes(column.prop)
),
feeItemColumns: [],
unitOptions: ['吨', '车', '次', '项'],
taxRateOptions: [0, 1, 3, 6, 9, 13],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
'rar',
],
settlementDialog: {
visible: false,
loading: false,
@@ -566,9 +546,7 @@ export default {
rules: {
settlementIds: [{ validator: this.validateSettlements, trigger: 'change' }],
invoiceType: [{ required: true, message: '请选择发票类型', trigger: 'change' }],
departmentEmails: [
{ type: 'array', required: true, min: 1, message: '请选择部门邮箱', trigger: 'change' },
],
departmentEmails: [{ validator: this.validateDepartmentEmails, trigger: 'change' }],
receiverInvoiceInfoId: [{ required: true, message: '请选择受票方单位', trigger: 'change' }],
contactPhone: [{ pattern: /^\d{11}$/, message: '联系电话必须为11位数字', trigger: 'blur' }],
taxpayerNo: [{ validator: this.validateInvoiceInfoField, trigger: 'change' }],
@@ -614,11 +592,19 @@ export default {
0
);
},
allocatedTotal() {
return this.form.settlements.reduce(
(total, item) => total + Number(item.allocatedInvoiceAmount || 0),
0
);
detailColumns() {
return [...this.detailBaseColumns, ...this.feeItemColumns];
},
invoiceItemCategories() {
return [...new Set(this.invoiceItems.map(item => item.categoryName).filter(Boolean))];
},
taxRateOptions() {
const rates = new Set([0, 1, 3, 6, 9, 13]);
this.invoiceItems.forEach(item => {
const rate = Number(item.defaultTaxRate);
if (Number.isFinite(rate) && rate >= 0 && rate <= 100) rates.add(rate);
});
return [...rates].sort((left, right) => left - right);
},
},
created() {
@@ -641,22 +627,25 @@ export default {
return [];
}
},
parseEmails(value) {
const values = Array.isArray(value) ? value : [value];
return values
.flatMap(item => String(item || '').split(/[;,,;]/))
.map(item => item.trim())
.filter(Boolean);
},
async initialize() {
await Promise.all([this.loadInvoiceItems(), this.loadTransportTypeOptions()]);
const userInfo = this.$store.getters.userInfo || {};
const userEmail = userInfo.email || '';
this.departmentEmailOptions = userEmail ? [userEmail] : [];
if (!this.recordId) {
this.form.applicantName = userInfo.realName || userInfo.userName || '';
if (userEmail) this.form.departmentEmails = [userEmail];
return;
}
const data = this.unwrapData(await api.getDetail(this.recordId));
this.form = {
...emptyForm(),
...data,
departmentEmails: String(data.departmentEmails || '')
.split(';')
.filter(Boolean),
departmentEmails: this.parseEmails(data.departmentEmails),
settlements: (data.settlements || []).map(item => ({
...item,
settlementId: item.formalSettlementId,
@@ -669,12 +658,76 @@ export default {
attachments: this.parse(data.attachmentsJson),
};
if (!this.form.sheets.length) this.form.sheets = [emptySheet()];
this.form.sheets.forEach(sheet =>
sheet.lines.forEach(line => this.syncInvoiceItemLine(line, false))
);
this.selectedDetailIds = (data.details || []).map(item => item.formalSettlementDetailId);
if (this.settlementIds.length) {
await Promise.all([this.loadReceiverInformation(), this.loadSettlementDetails(false)]);
}
if (this.readonly) this.$nextTick(() => this.$refs.formRef?.clearValidate('departmentEmails'));
this.$nextTick(this.restoreDetailSelection);
},
async loadInvoiceItems() {
const data = this.unwrapData(await getInvoiceItems());
this.invoiceItems = Array.isArray(data) ? data : data?.records || [];
this.form.sheets.forEach(sheet =>
sheet.lines.forEach(line => this.syncInvoiceItemLine(line, false))
);
},
async loadTransportTypeOptions() {
const data = this.unwrapData(await getDictionary({ code: 'transport_type' }));
const records = Array.isArray(data) ? data : data?.records || [];
this.transportTypeOptions = records.map(item => ({
label: item.dictValue || item.label || item.name,
value: item.dictKey || item.value || item.dictValue || item.name,
}));
},
transportTypeLabel(value) {
if (!value) return '-';
return (
this.transportTypeOptions.find(item => String(item.value) === String(value))?.label ||
value
);
},
invoiceItemNames(categoryName) {
return this.invoiceItems.filter(item => item.categoryName === categoryName);
},
findInvoiceItem(line) {
return this.invoiceItems.find(
item => item.categoryName === line.goodsCategory && item.shortName === line.goodsName
);
},
syncInvoiceItemLine(line, clearInvalid = true) {
const categoryExists = this.invoiceItemCategories.includes(line.goodsCategory);
if (!categoryExists && clearInvalid) line.goodsCategory = '';
const item = this.findInvoiceItem(line);
if (item) {
line.taxRate = Number(item.defaultTaxRate || 0);
this.recalculateLine(line);
} else if (clearInvalid) {
line.goodsName = '';
line.taxRate = 0;
}
},
handleGoodsCategoryChange(line) {
const names = this.invoiceItemNames(line.goodsCategory);
if (!names.some(item => item.shortName === line.goodsName)) line.goodsName = '';
line.taxRate = 0;
if (names.length === 1) {
line.goodsName = names[0].shortName;
this.handleGoodsNameChange(line);
}
},
handleGoodsNameChange(line) {
const item = this.findInvoiceItem(line);
if (!item) {
line.taxRate = 0;
return;
}
line.taxRate = Number(item.defaultTaxRate || 0);
this.recalculateLine(line);
},
validateSettlements(rule, value, callback) {
if (!this.form.settlements.length) {
callback(new Error('请选择正式结算单'));
@@ -715,6 +768,27 @@ export default {
}
callback();
},
validateDepartmentEmails(rule, value, callback) {
if (this.readonly) {
callback();
return;
}
const emails = this.parseEmails(value);
if (!emails.length) {
callback(new Error('请选择部门邮箱'));
return;
}
if (emails.length > 3) {
callback(new Error('部门邮箱最多选择3个'));
return;
}
const options = new Set(this.departmentEmailOptions.map(item => item.toLowerCase()));
if (emails.some(email => !options.has(email.toLowerCase()))) {
callback(new Error('部门邮箱必须选择当前客商开票信息中的邮箱'));
return;
}
callback();
},
handleInvoiceTypeChange() {
this.$nextTick(() => {
this.$refs.formRef?.validateField([
@@ -746,6 +820,12 @@ export default {
this.$message.warning('请至少选择一张应付正式结算单');
return;
}
await this.applySettlements(rows);
},
async selectSettlement(row) {
await this.applySettlements([row]);
},
async applySettlements(rows) {
const first = rows[0];
const incompatible = rows.some(
item =>
@@ -775,22 +855,83 @@ export default {
async loadSettlementDetails(resetSelection = true) {
this.detailRows =
this.unwrapData(await api.getSettlementDetails(this.settlementIds.join(','))) || [];
this.buildFeeItemColumns();
if (resetSelection) this.selectedDetailIds = this.detailRows.map(item => item.id);
this.$nextTick(this.restoreDetailSelection);
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
},
buildFeeItemColumns() {
const groupedNames = new Map();
this.detailRows.forEach(row => {
Object.keys(this.parseFeeItems(row.feeItemsJson || row.feeItems)).forEach(name => {
const normalizedName = this.normalizeFeeItemName(name);
if (!normalizedName) return;
const key = this.feeItemNameKey(name);
const group = groupedNames.get(key) || { label: normalizedName, keys: [] };
if (!group.keys.includes(name)) group.keys.push(name);
groupedNames.set(key, group);
});
});
this.feeItemColumns = [...groupedNames.values()].map((group, index) => ({
prop: `feeItem_${index}`,
label: group.label,
minWidth: 130,
money: true,
feeItemKeys: group.keys,
}));
},
normalizeFeeItemName(name) {
return String(name || '')
.replace(/[\s\u200b-\u200d\ufeff]/g, '')
.trim();
},
feeItemNameKey(name) {
return this.normalizeFeeItemName(name).toLowerCase();
},
getFeeItemValue(row, names) {
const items = this.parseFeeItems(row.feeItemsJson || row.feeItems);
return names.reduce((total, name) => total + Number(items[name] || 0), 0);
},
async loadReceiverInformation() {
const data = this.unwrapData(await api.getReceiverInformation(this.settlementIds.join(',')));
this.receiverInvoiceOptions = data.invoiceInfos || [];
const invoiceInfos = data.invoices || data.invoiceInfos || data.customer?.invoices || [];
this.receiverInvoiceOptions = Array.isArray(invoiceInfos) ? invoiceInfos : [];
this.contactOptions = data.contacts || [];
const invoiceInfoEmails = [
...this.receiverInvoiceOptions.flatMap(item => this.parseEmails(item.email)),
];
this.departmentEmailOptions = invoiceInfoEmails
.filter(Boolean)
.reduce((emails, email) => {
if (!emails.some(item => item.toLowerCase() === email.toLowerCase())) emails.push(email);
return emails;
}, []);
this.form.departmentEmails = this.form.departmentEmails
.filter(email =>
this.departmentEmailOptions.some(option => option.toLowerCase() === email.toLowerCase())
)
.slice(0, 3);
this.form.issuerName = data.issuerName || this.form.issuerName;
if (!this.form.receiverInvoiceInfoId) {
const invoiceInfo =
let invoiceInfo = this.receiverInvoiceOptions.find(
item => String(item.id) === String(this.form.receiverInvoiceInfoId)
);
if (!invoiceInfo) {
invoiceInfo =
this.receiverInvoiceOptions.find(item => item.isDefault === 1) ||
this.receiverInvoiceOptions[0];
if (invoiceInfo) {
this.form.receiverInvoiceInfoId = invoiceInfo.id;
this.handleReceiverChange(invoiceInfo.id);
}
}
if (invoiceInfo) {
this.form.receiverInvoiceInfoId = invoiceInfo.id;
this.handleReceiverChange(invoiceInfo.id);
}
if (!this.form.contactName) {
const contact =
@@ -846,23 +987,9 @@ export default {
const rate = Number(row.taxRate || 0) / 100;
row.taxAmount = rate ? Number((amount - amount / (1 + rate)).toFixed(2)) : 0;
},
normalizeAttachments(files) {
const userInfo = this.$store.getters.userInfo || {};
const userName = userInfo.realName || userInfo.userName || '';
const time = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.form.attachments = (files || []).map(file => ({
...file,
description: file.description || '',
uploadUserName: file.uploadUserName || userName,
uploadTime: file.uploadTime || time,
}));
},
validateBusiness() {
if (!this.selectedDetailIds.length) throw new Error('请至少选择一条开票明细');
if (this.invoiceAmount <= 0) throw new Error('本次开票金额必须大于0');
if (Math.abs(this.invoiceAmount - this.allocatedTotal) > 0.001) {
throw new Error('结算单分摊金额合计必须等于本次开票金额');
}
for (const sheet of this.form.sheets) {
if (!sheet.lines.length) throw new Error('每张发票至少需要一条商品行');
const hasFreight = sheet.lines.some(line => line.goodsName === '运费');
@@ -968,12 +1095,6 @@ export default {
background: #fafafa;
font-weight: 600;
}
.invoice-form-page__links {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.invoice-form-page__remark :deep(.el-form-item__content) {
margin-left: 0 !important;
}
@@ -997,17 +1118,6 @@ export default {
:global(.avue-layout--horizontal .invoice-form-page__actions) {
left: 0;
}
.invoice-form-page__attachment-upload {
display: flex;
justify-content: flex-start;
margin-top: 12px;
}
.invoice-form-page__attachment-upload .vehicle-attachment-upload {
width: auto;
}
.invoice-form-page__attachment-upload .el-upload {
display: inline-flex;
}
.invoice-form-page__dialog-search {
display: flex;
justify-content: flex-end;