Files
tms-erp-web/src/views/payment/invoice-application-form.vue
T

1411 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="项目利润率">
<el-input
:model-value="profitRatePercent"
:disabled="readonly"
placeholder="选结算单后自动带出,可修改"
@input="handleProfitRateInput"
>
<template #append>%</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="暂估成本(不含税)">
<el-input
:model-value="estimatedCost > 0 ? formatMoney(estimatedCost) : ''"
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="140">
<template #default="{ row }">
<el-input-number
v-model="row.totalAmount"
:disabled="readonly"
:min="0"
:precision="2"
:controls="false"
@change="recalculateLine(row)"
/>
</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="140" align="right">
<template #default="{ row }">{{ formatUnitPrice(row.unitPriceNoTax) }}</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="120" align="right">
<template #default="{ row }">{{ formatMoney(row.taxAmount) }}</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="开票状态" min-width="90" align="center">
<template #default="{ row }">{{ sheet.invoiceStatusName || '未开票' }}</template>
</el-table-column>
<el-table-column v-if="readonly" label="金蝶开票结果备注" min-width="200">
<template #default="{ row }">{{ sheet.kingdeeResultRemark }}</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="150" align="right">
<template #default="{ row }">{{ formatMoney(row.historyInvoicedAmount) }}</template>
</el-table-column>
<el-table-column label="本次开票金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.allocatedInvoiceAmount) }}</template>
</el-table-column>
<el-table-column label="剩余未开票金额" min-width="150" align="right">
<template #default="{ row }">{{ formatMoney(row.remainingUninvoicedAmount) }}</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>
</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 v-if="recordId" title="发票冲销明细">
<invoice-write-off-table biz-type="invoice_apply" :biz-id="recordId" />
</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 v-if="!isPublicViewPage" 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.invoicedAmount) }}</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';
import InvoiceWriteOffTable from '@/components/invoice-write-off-table/main.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
// 提交成功后带标记返回,列表页(keep-alive)据此重新拉取
const invoiceApplicationListRefreshKey = 'invoice-application-list-refresh';
let localId = 0;
const emptyLine = () => ({
localId: ++localId,
goodsCategory: '',
goodsName: '',
taxClassificationCode: '',
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: '',
// 项目利润率(小数,如0.04表示4%;保存时后端快照并重算暂估成本)
profitRate: null,
});
export default {
name: 'InvoiceApplicationForm',
components: {
VehicleAttachmentTable,
InvoiceWriteOffTable,
},
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' || this.isPublicViewPage;
},
isPublicViewPage() {
return this.$route.path === '/payment/invoice-application/public-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
);
},
// 项目利润率百分数展示/输入(后端存小数:0.04 ↔ 4)
profitRatePercent() {
const rate = Number(this.form.profitRate);
if (!Number.isFinite(rate) || rate <= 0) return '';
return String(Math.round(rate * 10000) / 100);
},
// 暂估成本 = 开票不含税金额合计 × 项目利润率(实时计算,后端保存时重算覆盖)
estimatedCost() {
const rate = Number(this.form.profitRate);
if (!Number.isFinite(rate) || rate <= 0) return 0;
const amountNoTaxTotal = this.form.sheets.reduce(
(sheetTotal, sheet) =>
sheetTotal +
sheet.lines.reduce((lineTotal, line) => lineTotal + Number(line.amountNoTax || 0), 0),
0
);
return amountNoTaxTotal * rate;
},
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() {
if (this.isPublicViewPage) {
await this.loadPublicDetail();
return;
}
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.applyDetail(data);
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 loadPublicDetail() {
if (!this.recordId) return;
const data = this.unwrapData(await getMkPublicDetail('invoice-application', this.recordId));
this.applyDetail(data);
this.$nextTick(() => this.$refs.formRef?.clearValidate('departmentEmails'));
},
applyDetail(data) {
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);
},
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
);
},
// 项目利润率输入(百分数),转小数存储:4 → 0.04
handleProfitRateInput(value) {
let normalized = String(value || '').replace(/[^\d.]/g, '');
normalized = normalized.replace(/(\..*)\./g, '$1');
const percent = Number(normalized);
this.form.profitRate = Number.isFinite(percent) && percent > 0 ? percent / 100 : null;
},
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);
line.taxClassificationCode = item.taxClassificationCode || '';
} else if (clearInvalid) {
line.goodsName = '';
line.taxRate = 0;
line.taxClassificationCode = '';
}
this.recalculateLine(line);
},
handleGoodsCategoryChange(line) {
const names = this.invoiceItemNames(line.goodsCategory);
if (!names.some(item => item.shortName === line.goodsName)) line.goodsName = '';
line.taxRate = 0;
line.taxClassificationCode = this.findInvoiceItem(line)?.taxClassificationCode || '';
if (names.length === 1) {
line.goodsName = names[0].shortName;
this.handleGoodsNameChange(line);
}
},
handleGoodsNameChange(line) {
const item = this.findInvoiceItem(line);
if (!item) {
line.taxRate = 0;
line.taxClassificationCode = '';
return;
}
line.taxRate = Number(item.defaultTaxRate || 0);
line.taxClassificationCode = item.taxClassificationCode || '';
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',
}
)
);
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: 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),
...(item.contacts || []).flatMap(contact => this.parseEmails(contact.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;
const invoiceContact = (info.contacts || []).find(item => item.contactName || item.email) || {};
Object.assign(this.form, {
receiverName: info.invoiceTitle,
taxpayerNo: info.taxNo,
bankName: info.bankName,
bankAccount: info.bankAccount,
registeredAddress: info.registeredAddress,
email: invoiceContact.email || '',
});
if (invoiceContact.contactName) {
this.form.contactName = invoiceContact.contactName;
this.form.contactPhone = invoiceContact.contactPhone || '';
}
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/detail',
query: { id: 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());
},
/**
* 填写合计金额 + 税率后反算:
* 合计金额 = 数量 × 不含税单价 × (1 + 税率)
* 不含税单价 = 合计金额 / 数量 / (1 + 税率),保留 6 位小数
* 不含税金额 = 数量 × 不含税单价,保留 2 位小数
*/
recalculateLine(row) {
const quantity = Number(row.quantity || 0);
const totalAmount = Number(Number(row.totalAmount || 0).toFixed(2));
const rate = Number(row.taxRate || 0) / 100;
row.totalAmount = totalAmount;
row.amountWithTax = totalAmount;
if (quantity <= 0 || totalAmount <= 0) {
row.unitPriceNoTax = 0;
row.amountNoTax = 0;
row.taxAmount = 0;
return;
}
const factor = 1 + rate;
const unitPriceNoTax = Number((totalAmount / quantity / factor).toFixed(6));
const amountNoTax = Number((quantity * unitPriceNoTax).toFixed(2));
row.unitPriceNoTax = unitPriceNoTax;
row.amountNoTax = amountNoTax;
row.taxAmount = Number((totalAmount - amountNoTax).toFixed(2));
},
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.totalAmount, line.unitPriceNoTax, line.amountNoTax, line.taxRate].some(
Number.isNaN
)
) {
throw new Error('开票商品行金额格式不正确');
}
if (Number(line.quantity || 0) <= 0) throw new Error('开票商品行数量必须大于0');
if (Number(line.totalAmount || 0) <= 0) throw new Error('开票商品行合计金额必须大于0');
}
}
},
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,
profitRate: Number(this.form.profitRate) > 0 ? Number(this.form.profitRate) : null,
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,
taxClassificationCode: line.taxClassificationCode,
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;
const previousApprovalStatus = this.form.approvalStatus || '';
await api.submit({ id: this.form.id });
await submitMkApprovalFlow({
bizType: 'invoice-application',
bizId: this.form.id,
subjectName: this.form.applicationNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('提交成功');
sessionStorage.setItem(invoiceApplicationListRefreshKey, '1');
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);
},
formatUnitPrice(value) {
return Number(value || 0).toFixed(6);
},
},
};
</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-start;
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>