Files
tms-erp-web/src/views/payment/invoice-application-form.vue
T
2026-09-02 03:22:51 +08:00

1286 lines
46 KiB
Vue

<template>
<basic-container class="invoice-form-page">
<div class="archive-page-form__title">
{{ readonly ? '查看开票申请' : recordId ? '编辑开票申请' : '新增开票申请' }}
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
class="invoice-form-page__form"
>
<section-card title="开票基本信息">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item label="结算单" prop="settlementIds">
<el-input :model-value="settlementLabel" readonly placeholder="请选择应收正式结算单">
<template v-if="!readonly" #append>
<el-button @click="openSettlementDialog">选择</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="所属项目">
<el-input v-model="form.projectName" disabled placeholder="根据结算单自动带出" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开票单位">
<el-input v-model="form.issuerName" disabled placeholder="根据结算单自动带出" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="发票类型" prop="invoiceType">
<el-select
v-model="form.invoiceType"
:disabled="readonly"
@change="handleInvoiceTypeChange"
>
<el-option
v-for="item in invoiceTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="可开票金额">
<el-input
:model-value="form.settlements.length ? formatMoney(availableInvoiceAmount) : ''"
disabled
placeholder="根据结算单自动计算"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="本次开票金额">
<el-input
:model-value="invoiceAmount > 0 ? formatMoney(invoiceAmount) : ''"
disabled
placeholder="根据开票明细自动计算"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="申请日期">
<el-input v-model="form.applicationDate" disabled placeholder="提交后自动显示" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="申请人">
<el-input v-model="form.applicantName" disabled />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="承办部门">
<el-input
v-model="form.undertakingDeptName"
disabled
placeholder="根据结算单自动带出"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="部门邮箱" prop="departmentEmails">
<el-select
v-model="form.departmentEmails"
:disabled="readonly"
multiple
filterable
:multiple-limit="3"
placeholder="请选择客商开票信息邮箱"
no-data-text="当前客商开票信息未配置邮箱"
>
<el-option
v-for="email in departmentEmailOptions"
:key="email"
:label="email"
:value="email"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</section-card>
<section-card title="受票方信息">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item label="受票方单位" prop="receiverInvoiceInfoId">
<el-select
v-model="form.receiverInvoiceInfoId"
:disabled="readonly"
filterable
@change="handleReceiverChange"
>
<el-option
v-for="item in receiverInvoiceOptions"
:key="item.id"
:label="item.invoiceTitle"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="纳税人识别号" prop="taxpayerNo">
<el-input v-model="form.taxpayerNo" disabled placeholder="选择受票方单位后自动带出" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户账号" prop="bankAccount">
<el-input
v-model="form.bankAccount"
disabled
placeholder="选择受票方单位后自动带出"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户行" prop="bankName">
<el-input v-model="form.bankName" disabled placeholder="选择受票方单位后自动带出" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="注册地址" prop="registeredAddress">
<el-input
v-model="form.registeredAddress"
disabled
placeholder="选择受票方单位后自动带出"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="联系人">
<el-select
v-model="form.contactName"
:disabled="readonly"
clearable
filterable
allow-create
@change="handleContactChange"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.contactName"
:value="item.contactName"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="form.contactPhone" :disabled="readonly" maxlength="11" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" :disabled="readonly" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
</section-card>
<section-card title="开票内容"
><template #extra
><el-button v-if="!readonly" type="primary" @click="addSheet">添加</el-button></template
>
<div v-for="(sheet, sheetIndex) in form.sheets" :key="sheet.localId" class="invoice-sheet">
<div class="invoice-sheet__header">
<span>第{{ sheetIndex + 1 }}张</span>
</div>
<el-table :data="sheet.lines" border>
<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"
filterable
@change="handleGoodsCategoryChange(row)"
>
<el-option
v-for="item in invoiceItemCategories"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="货物或服务简称" min-width="170">
<template #default="{ row }">
<el-select
v-model="row.goodsName"
:disabled="readonly"
filterable
@change="handleGoodsNameChange(row)"
>
<el-option
v-for="item in invoiceItemNames(row.goodsCategory)"
:key="item.shortName"
:label="item.shortName"
:value="item.shortName"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="单位" min-width="100">
<template #default="{ row }">
<el-select v-model="row.unit" :disabled="readonly" filterable allow-create>
<el-option v-for="item in unitOptions" :key="item" :label="item" :value="item" />
</el-select>
</template>
</el-table-column>
<el-table-column label="数量" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:disabled="readonly"
:min="0"
:precision="2"
:controls="false"
@change="recalculateLine(row)"
/>
</template>
</el-table-column>
<el-table-column label="不含税单价" min-width="130">
<template #default="{ row }">
<el-input-number
v-model="row.unitPriceNoTax"
:disabled="readonly"
:min="0"
:precision="2"
:controls="false"
@change="recalculateLine(row)"
/>
</template>
</el-table-column>
<el-table-column label="不含税金额" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.amountNoTax) }}</template>
</el-table-column>
<el-table-column label="税率" min-width="110">
<template #default="{ row }">
<el-select
v-model="row.taxRate"
:disabled="readonly"
@change="recalculateLine(row)"
>
<el-option
v-for="item in taxRateOptions"
:key="item"
:label="`${item}%`"
:value="item"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="税额" min-width="120">
<template #default="{ row }">{{ formatMoney(row.taxAmount) }}</template>
</el-table-column>
<el-table-column label="合计" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.totalAmount) }}</template>
</el-table-column>
<el-table-column label="备注" min-width="170">
<template #default="{ row }">
<el-input v-model="row.remark" :disabled="readonly" maxlength="200" />
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" width="110" fixed="right" align="center">
<template #default="{ $index }">
<div class="invoice-form-page__links">
<el-link type="primary" @click="addLine(sheet)">添加</el-link>
<el-link
v-if="sheet.lines.length > 1"
type="danger"
@click="sheet.lines.splice($index, 1)"
>删除</el-link
>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="!readonly && form.sheets.length > 1" class="invoice-sheet__actions">
<el-link type="danger" @click="removeSheet(sheetIndex)">删除</el-link>
</div>
</div>
</section-card>
<section-card title="结算信息">
<el-table :data="form.settlements" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="formalSettlementNo" label="结算单号" min-width="170" />
<el-table-column label="结算总金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.settlementAmount) }}</template>
</el-table-column>
<el-table-column label="可开票金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.availableInvoiceAmount) }}</template>
</el-table-column>
<el-table-column label="分摊发票金额" min-width="160">
<template #default="{ row }">
<el-input-number
v-model="row.allocatedInvoiceAmount"
:disabled="readonly"
:min="0"
:max="Number(row.availableInvoiceAmount || 0)"
:precision="2"
:controls="false"
/>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="开票明细">
<el-table
ref="detailTable"
:data="detailRows"
border
@selection-change="handleDetailSelection"
>
<el-table-column
v-if="!readonly"
type="selection"
width="52"
fixed="left"
align="center"
/>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in detailColumns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link
v-if="column.link && row[column.prop]"
type="primary"
@click.stop="handleDetailLink(row, column)"
>
{{ 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-if="column.dateTime">{{ formatDateTime(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="附件信息">
<vehicle-attachment-table v-model="form.attachments" :readonly="readonly" />
</section-card>
<section-card title="备注">
<el-form-item class="invoice-form-page__remark">
<el-input
v-model="form.remark"
:disabled="readonly"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
/>
</el-form-item>
</section-card>
<div class="invoice-form-page__actions">
<el-button @click="goBack">取消</el-button>
<el-button v-if="!readonly" type="primary" plain @click="syncReferenceData">同步</el-button>
<el-button v-if="!readonly && canSave" type="primary" plain @click="saveDraft"
>保存</el-button
>
<el-button
v-if="!readonly && canSave && hasPermission('invoice_application_submit')"
type="primary"
@click="submitForm"
>提交</el-button
>
</div>
</el-form>
<el-dialog
v-model="settlementDialog.visible"
title="选择应收正式结算单"
width="86%"
append-to-body
>
<div class="invoice-form-page__dialog-search">
<el-input
v-model="settlementDialog.keyword"
clearable
placeholder="结算单号项目或合同"
@keyup.enter="handleSettlementSearch"
/>
<el-button type="primary" @click="handleSettlementSearch">查询</el-button>
</div>
<el-table
ref="settlementTable"
v-loading="settlementDialog.loading"
:data="settlementDialog.rows"
row-key="id"
border
@selection-change="settlementDialog.selection = $event"
>
<el-table-column type="selection" width="52" align="center" reserve-selection />
<el-table-column prop="formalSettlementNo" label="结算单号" min-width="170" />
<el-table-column prop="projectName" label="所属项目" min-width="150" />
<el-table-column prop="deptName" label="所属组织" min-width="150" />
<el-table-column prop="contractNo" label="合同编号" min-width="150" />
<el-table-column prop="contractName" label="合同名称" min-width="170" />
<el-table-column prop="issuerName" label="开票方" min-width="150" />
<el-table-column prop="receiverName" label="受票方" min-width="150" />
<el-table-column label="结算金额" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.settlementAmount) }}</template>
</el-table-column>
<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>
<div class="invoice-form-page__dialog-pagination">
<el-pagination
v-model:current-page="settlementDialog.page.current"
v-model:page-size="settlementDialog.page.size"
:total="settlementDialog.page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadSettlementCandidates"
@size-change="handleSettlementSizeChange"
/>
</div>
<template #footer>
<el-button @click="settlementDialog.visible = false">取消</el-button>
<el-button type="primary" @click="confirmSettlements">确定</el-button>
</template>
</el-dialog>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/invoiceApplication';
import { getAll as getInvoiceItems } from '@/api/base/invoice-item';
import { getList as getWaybillList } from '@/api/business/waybill-manage';
import { getList as getReceivablePayableDetailList } from '@/api/settlement/receivable-payable-detail';
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: '',
unit: '吨',
quantity: 0,
unitPriceNoTax: 0,
amountNoTax: 0,
amountWithTax: 0,
totalAmount: 0,
taxRate: 0,
taxAmount: 0,
remark: '',
});
const emptySheet = () => ({ localId: ++localId, lines: [emptyLine()] });
const emptyForm = () => ({
id: null,
applicationNo: '',
projectName: '',
deptName: '',
issuerName: '',
invoiceType: 'electronic_special',
applicationDate: '',
applicantName: '',
undertakingDeptName: '',
departmentEmails: [],
receiverInvoiceInfoId: null,
receiverName: '',
taxpayerNo: '',
bankName: '',
bankAccount: '',
registeredAddress: '',
contactName: '',
contactPhone: '',
email: '',
settlements: [],
sheets: [emptySheet()],
attachments: [],
remark: '',
});
export default {
name: 'InvoiceApplicationForm',
components: {
VehicleAttachmentTable,
},
data() {
return {
form: emptyForm(),
detailRows: [],
selectedDetailIds: [],
receiverInvoiceOptions: [],
contactOptions: [],
departmentEmailOptions: [],
invoiceItems: [],
transportTypeOptions: [],
invoiceTypeOptions: api.invoiceTypeOptions,
detailBaseColumns: invoiceApplicationDetailColumns.filter(
column => !['feeItemsJson', 'freightAmount'].includes(column.prop)
),
feeItemColumns: [],
unitOptions: ['吨', '车', '次', '项'],
settlementDialog: {
visible: false,
loading: false,
keyword: '',
rows: [],
selection: [],
page: { current: 1, size: 10, total: 0 },
},
rules: {
settlementIds: [{ validator: this.validateSettlements, trigger: 'change' }],
invoiceType: [{ required: true, 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' }],
bankAccount: [{ validator: this.validateInvoiceInfoField, trigger: 'change' }],
bankName: [{ validator: this.validateInvoiceInfoField, trigger: 'change' }],
registeredAddress: [{ validator: this.validateInvoiceInfoField, trigger: 'change' }],
email: [{ validator: this.validateReceiverEmails, trigger: 'blur' }],
},
};
},
computed: {
...mapGetters(['permission']),
recordId() {
return this.$route.query.id || '';
},
readonly() {
return this.$route.query.mode === 'view';
},
canSave() {
const code = this.recordId ? 'invoice_application_edit' : 'invoice_application_add';
return this.hasPermission(code);
},
settlementLabel() {
return this.form.settlements
.map(item => item.formalSettlementNo)
.filter(Boolean)
.join(',');
},
settlementIds() {
return this.form.settlements.map(item => item.formalSettlementId || item.settlementId);
},
availableInvoiceAmount() {
return this.form.settlements.reduce(
(total, item) => total + Number(item.availableInvoiceAmount || 0),
0
);
},
invoiceAmount() {
return this.form.sheets.reduce(
(sheetTotal, sheet) =>
sheetTotal +
sheet.lines.reduce((lineTotal, line) => lineTotal + Number(line.totalAmount || 0), 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() {
this.initialize();
},
methods: {
hasPermission(code) {
return this.permission?.[code] !== false;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
parse(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
return JSON.parse(value) || [];
} catch {
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 || {};
if (!this.recordId) {
this.form.applicantName = userInfo.realName || userInfo.userName || '';
return;
}
const data = this.unwrapData(await api.getDetail(this.recordId));
this.form = {
...emptyForm(),
...data,
departmentEmails: this.parseEmails(data.departmentEmails),
settlements: (data.settlements || []).map(item => ({
...item,
settlementId: item.formalSettlementId,
})),
sheets: (data.sheets || []).map(item => ({
...item,
localId: ++localId,
lines: (item.lines || []).map(line => ({ ...line, localId: ++localId })),
})),
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);
} else if (clearInvalid) {
line.goodsName = '';
line.taxRate = 0;
}
this.recalculateLine(line);
},
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('请选择正式结算单'));
return;
}
callback();
},
validateInvoiceInfoField(rule, value, callback) {
const labels = {
taxpayerNo: '纳税人识别号',
bankAccount: '开户账号',
bankName: '开户行',
registeredAddress: '注册地址',
};
if (this.form.invoiceType === 'electronic_special' && !value) {
callback(new Error(`电子专票的${labels[rule.field]}不能为空`));
return;
}
callback();
},
validateReceiverEmails(rule, value, callback) {
if (!value) {
callback();
return;
}
const emails = String(value)
.split(/[;,,;]/)
.map(item => item.trim())
.filter(Boolean);
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emails.length > 3) {
callback(new Error('邮箱最多填写3个'));
return;
}
if (emails.some(email => !valid.test(email))) {
callback(new Error('请输入正确的邮箱,多个邮箱使用分号分隔'));
return;
}
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([
'taxpayerNo',
'bankAccount',
'bankName',
'registeredAddress',
]);
});
},
async openSettlementDialog() {
this.settlementDialog.visible = true;
this.settlementDialog.page.current = 1;
this.settlementDialog.selection = [];
this.$nextTick(() => this.$refs.settlementTable?.clearSelection());
await this.loadSettlementCandidates();
},
async loadSettlementCandidates() {
this.settlementDialog.loading = true;
try {
const data = this.unwrapData(
await api.getSettlementCandidates(
this.settlementDialog.page.current,
this.settlementDialog.page.size,
{
keyword: this.settlementDialog.keyword,
contractCategory: '客户合同',
settlementType: 'receivable',
invoiceStatus: 'unreceived',
}
)
);
const rows = Array.isArray(data) ? data : data?.records || [];
this.settlementDialog.rows = rows;
this.settlementDialog.page.total = Number(
Array.isArray(data) ? data.length : data?.total || 0
);
} finally {
this.settlementDialog.loading = false;
}
},
handleSettlementSearch() {
this.settlementDialog.page.current = 1;
this.settlementDialog.selection = [];
this.$refs.settlementTable?.clearSelection();
this.loadSettlementCandidates();
},
handleSettlementSizeChange() {
this.settlementDialog.page.current = 1;
this.loadSettlementCandidates();
},
async confirmSettlements() {
const rows = this.settlementDialog.selection;
if (!rows.length) {
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 =>
String(item.contractId) !== String(first.contractId) ||
String(item.projectId) !== String(first.projectId) ||
String(item.deptId) !== String(first.deptId) ||
item.issuerName !== first.issuerName ||
item.receiverName !== first.receiverName
);
if (incompatible) {
this.$message.warning('合并开票的结算单必须属于同一合同、项目、组织及收付款方');
return;
}
this.form.settlements = rows.map(item => ({
...item,
formalSettlementId: item.id,
settlementId: item.id,
allocatedInvoiceAmount: rows.length === 1 ? Number(item.availableInvoiceAmount || 0) : 0,
}));
this.form.projectName = first.projectName;
this.form.deptName = first.deptName;
this.form.issuerName = first.issuerName;
this.form.undertakingDeptName = first.deptName;
this.settlementDialog.visible = false;
await Promise.all([this.loadSettlementDetails(), this.loadReceiverInformation()]);
},
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(',')));
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;
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 (!this.form.contactName) {
const contact =
this.contactOptions.find(item => item.isDefault === 1) || this.contactOptions[0];
if (contact) {
this.form.contactName = contact.contactName;
this.handleContactChange(contact.contactName);
}
}
},
handleReceiverChange(id) {
const info = this.receiverInvoiceOptions.find(item => String(item.id) === String(id));
if (!info) return;
Object.assign(this.form, {
receiverName: info.invoiceTitle,
taxpayerNo: info.taxNo,
bankName: info.bankName,
bankAccount: info.bankAccount,
registeredAddress: info.registeredAddress,
email: info.email || '',
});
this.handleInvoiceTypeChange();
},
handleContactChange(name) {
const contact = this.contactOptions.find(item => item.contactName === name);
if (!contact) return;
this.form.contactPhone = contact.contactPhone || '';
this.form.email = contact.email || this.form.email || '';
},
handleDetailSelection(rows) {
if (this.readonly) return;
this.selectedDetailIds = rows.map(item => item.formalSettlementDetailId || item.id);
},
handleDetailLink(row, column) {
if (column.prop === 'documentNo') {
this.openReceivableDetail(row);
return;
}
if (column.prop === 'waybillNo') this.openWaybillDetail(row);
},
async openReceivableDetail(row) {
let detailId = row.sourceDetailId || '';
if (!detailId && row.documentNo) {
try {
const data = this.unwrapData(
await getReceivablePayableDetailList(1, 1, {
documentNo: row.documentNo,
settlementType: 'receivable',
})
);
detailId = (Array.isArray(data) ? data : data?.records || [])[0]?.id || '';
} catch {
detailId = '';
}
}
if (!detailId) {
this.$message.info('当前记录未找到对应的应收明细');
return;
}
this.$router.push({
path: '/settlement/receivable-detail',
query: { detailId },
});
},
async openWaybillDetail(row) {
let waybillId = row.waybillId || '';
if (!waybillId && row.waybillNo) {
try {
const data = this.unwrapData(await getWaybillList(1, 1, { waybillNo: row.waybillNo }));
waybillId = (Array.isArray(data) ? data : data?.records || [])[0]?.id || '';
} catch {
waybillId = '';
}
}
if (!waybillId) {
this.$message.info('当前记录未找到对应的运单详情');
return;
}
this.$router.push({
path: '/business/waybill-manage',
query: { detailId: waybillId },
});
},
restoreDetailSelection() {
if (this.readonly || !this.$refs.detailTable) return;
const selected = new Set(this.selectedDetailIds.map(String));
this.detailRows.forEach(row => {
const id = row.formalSettlementDetailId || row.id;
this.$refs.detailTable.toggleRowSelection(row, selected.has(String(id)));
});
},
addSheet() {
this.form.sheets.push(emptySheet());
},
removeSheet(index) {
this.form.sheets.splice(index, 1);
},
addLine(sheet) {
sheet.lines.push(emptyLine());
},
recalculateLine(row) {
const quantity = Number(row.quantity || 0);
const unitPrice = Number(row.unitPriceNoTax || 0);
const amountNoTax = Number((quantity * unitPrice).toFixed(2));
const rate = Number(row.taxRate || 0) / 100;
row.amountNoTax = amountNoTax;
row.taxAmount = rate ? Number((amountNoTax * rate).toFixed(2)) : 0;
row.totalAmount = Number((amountNoTax + row.taxAmount).toFixed(2));
row.amountWithTax = row.totalAmount;
},
validateBusiness() {
if (!this.selectedDetailIds.length) throw new Error('请至少选择一条开票明细');
if (this.invoiceAmount <= 0) throw new Error('本次开票金额必须大于0');
for (const sheet of this.form.sheets) {
if (!sheet.lines.length) throw new Error('每张发票至少需要一条商品行');
const hasFreight = sheet.lines.some(line => line.goodsName === '运费');
const hasOther = sheet.lines.some(line => line.goodsName !== '运费');
if (hasFreight && hasOther) throw new Error('运费不能与其他费用合并开在同一张发票中');
for (const line of sheet.lines) {
if (!line.goodsCategory || !line.goodsName) throw new Error('请完整填写商品和服务信息');
if (
[line.quantity, line.unitPriceNoTax, line.amountNoTax, line.taxRate].some(
Number.isNaN
)
) {
throw new Error('开票商品行金额格式不正确');
}
}
}
},
validateAvailableInvoiceAmount() {
const availableAmountCents = Math.round(Number(this.availableInvoiceAmount || 0) * 100);
const invoiceAmountCents = Math.round(Number(this.invoiceAmount || 0) * 100);
if (invoiceAmountCents > availableAmountCents) {
throw new Error('本次开票金额不能超过可开票金额');
}
},
payload() {
return {
id: this.form.id,
invoiceType: this.form.invoiceType,
departmentEmails: this.form.departmentEmails.join(';'),
receiverInvoiceInfoId: this.form.receiverInvoiceInfoId,
contactName: this.form.contactName,
contactPhone: this.form.contactPhone,
email: this.form.email,
attachmentsJson: JSON.stringify(this.form.attachments || []),
remark: this.form.remark,
settlements: this.form.settlements.map(item => ({
settlementId: item.formalSettlementId || item.settlementId,
allocatedInvoiceAmount: item.allocatedInvoiceAmount,
})),
detailIds: this.selectedDetailIds,
sheets: this.form.sheets.map(sheet => ({
lines: sheet.lines.map(line => ({
goodsCategory: line.goodsCategory,
goodsName: line.goodsName,
unit: line.unit,
quantity: line.quantity,
unitPriceNoTax: line.unitPriceNoTax,
amountNoTax: line.amountNoTax,
totalAmount: line.totalAmount,
amountWithTax: line.totalAmount,
taxRate: line.taxRate,
taxAmount: line.taxAmount,
remark: line.remark,
})),
})),
};
},
async saveDraft() {
await this.$refs.formRef.validate();
try {
this.validateBusiness();
} catch (error) {
this.$message.warning(error.message);
return false;
}
const id = this.unwrapData(await api.save(this.payload()));
this.form.id = id;
this.$message.success('保存成功');
return true;
},
async submitForm() {
try {
this.validateAvailableInvoiceAmount();
} catch (error) {
this.$message.warning(error.message);
return;
}
const saved = await this.saveDraft();
if (!saved) return;
await api.submit({ id: this.form.id });
this.$message.success('提交成功');
this.goBack();
},
async syncReferenceData() {
if (!this.settlementIds.length) {
this.$message.warning('请先选择正式结算单');
return;
}
await Promise.all([this.loadReceiverInformation(), this.loadSettlementDetails(false)]);
this.$message.success('关联信息已同步');
},
goBack() {
this.$router.push('/payment/invoice-application');
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
formatDateTime(value) {
if (!value) return '-';
return this.$dayjs(value).format('YYYY-MM-DD HH:mm:ss');
},
formatMoney(value) {
return Number(value || 0).toFixed(2);
},
},
};
</script>
<style scoped lang="scss">
.invoice-form-page__form {
padding-bottom: 72px;
}
.invoice-form-page__form :deep(.el-select),
.invoice-form-page__form :deep(.el-input-number) {
width: 100%;
}
.invoice-sheet {
margin-top: 16px;
}
.invoice-sheet__header {
min-height: 42px;
padding: 0 12px;
border: 1px solid #eff1f7;
border-bottom: 0;
background: #fafafa;
font-weight: 600;
}
.invoice-sheet__actions {
display: flex;
justify-content: flex-end;
padding: 8px 12px 0;
}
.invoice-form-page__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.invoice-form-page__remark :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.invoice-form-page__actions {
display: flex;
justify-content: flex-end;
padding: 12px 24px;
position: fixed;
right: 0;
left: 230px;
bottom: 0;
margin: 0;
z-index: 10;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
:global(.avue--collapse .invoice-form-page__actions) {
left: 60px;
}
:global(.avue-layout--horizontal .invoice-form-page__actions) {
left: 0;
}
.invoice-form-page__dialog-search {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 12px;
}
.invoice-form-page__dialog-search .el-input {
width: 360px;
}
.invoice-form-page__dialog-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.invoice-form-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.invoice-form-page :deep(.el-table__row:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
@media (max-width: 1200px) {
.invoice-form-page__form :deep(.el-col-6) {
max-width: 50%;
flex: 0 0 50%;
}
}
@media (max-width: 768px) {
.invoice-form-page__form :deep(.el-col-6) {
max-width: 100%;
flex: 0 0 100%;
}
}
</style>