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

613 lines
20 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-page">
<section v-if="searchVisible" class="invoice-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="invoice-page__search-grid">
<el-form-item label="单据号">
<el-input v-model="query.applicationNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="所属项目">
<el-input v-model="query.projectName" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="所属组织">
<el-cascader
v-model="query.deptPath"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
filterable
clearable
style="width: 100%"
placeholder="请选择 所属组织"
@change="val => { query.deptName = findOrgById(val)?.rawLabel || '' }"
/>
</el-form-item>
<el-form-item class="invoice-kingdee-item" label="金蝶单据状态">
<el-select v-model="query.kingdeeStatus" clearable placeholder="请选择">
<el-option
v-for="item in kingdeeStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="searchExpanded" label="状态">
<el-select v-model="query.approvalStatus" clearable placeholder="请选择">
<el-option
v-for="item in approvalStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<div class="invoice-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button
v-if="showSearchToggle"
text
:icon="searchExpanded ? ArrowUp : ArrowDown"
@click="searchExpanded = !searchExpanded"
>{{ searchExpanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
<section class="invoice-page__table-panel">
<div class="invoice-page__toolbar">
<div class="invoice-page__toolbar-left">
<el-button
v-if="hasPermission('invoice_application_sync')"
type="primary"
plain
:loading="batchSyncing"
@click="handleBatchSync"
>批量同步</el-button
>
<el-button
v-if="hasPermission('invoice_application_add')"
type="primary"
@click="openCreate"
>新增</el-button
>
<el-button
v-if="hasPermission('invoice_application_export')"
type="primary"
plain
@click="handleExport"
>导出</el-button
>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column 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 visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
:show-overflow-tooltip="column.prop !== 'settlementNos'"
>
<template #default="{ row }">
<div v-if="column.prop === 'settlementNos'" class="invoice-page__settlement-nos">
<span
v-for="settlementNo in settlementNumberList(row.settlementNos)"
:key="settlementNo"
class="invoice-page__settlement-no"
@click.stop="openSettlementDetail(settlementNo)"
>
{{ settlementNo }}
</span>
<span v-if="!settlementNumberList(row.settlementNos).length">-</span>
</div>
<el-link v-else-if="column.link && row[column.prop]" type="primary" @click="openView(row)">
{{ row[column.prop] }}
</el-link>
<el-tag v-else-if="column.status" :type="statusType(row.approvalStatus)" class="status-text">
{{ displayValue(row[column.prop]) }}
</el-tag>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<template v-else-if="column.prop === 'kingdeeLockStatus'">
<el-tooltip v-if="lockTag(row).show" :content="lockTag(row).tip" placement="top">
<el-tag :type="lockTag(row).type" class="status-text">{{ lockTag(row).label }}</el-tag>
</el-tooltip>
<span v-else>-</span>
</template>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right" align="center">
<template #default="{ row }">
<div class="invoice-page__links">
<el-link
v-if="hasPermission('invoice_application_view')"
type="primary"
@click="openView(row)"
>查看</el-link
>
<el-link
v-if="
hasPermission('invoice_application_edit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="openEdit(row)"
>编辑</el-link
>
<el-link
v-if="hasPermission('invoice_application_delete') && row.approvalStatus === 'draft'"
type="danger"
@click="handleDelete(row)"
>删除</el-link
>
<el-link
v-if="
hasPermission('invoice_application_submit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="handleSubmit(row)"
>提交</el-link
>
<el-link
v-if="
mkEnabled &&
hasPermission('invoice_application_approve') &&
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
>流程</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('invoice_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleApprove(row)"
>通过</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('invoice_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="danger"
@click="handleReturn(row)"
>驳回</el-link
>
<el-link
v-if="
hasPermission('invoice_application_void') &&
row.approvalStatus === 'approved' &&
row.kingdeeLockStatus !== 'locked'
"
type="danger"
@click="handleVoid(row)"
>作废</el-link
>
</div>
</template>
</el-table-column>
</el-table>
<div class="invoice-page__pagination">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTable"
@size-change="handleSizeChange"
/>
</div>
</section>
</basic-container>
</template>
<script>
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
import * as api from '@/api/payment/invoiceApplication';
import * as formalSettlementApi from '@/api/settlement/formalSettlement';
import { invoiceApplicationTableColumns } from '@/option/payment/invoiceApplication';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
const emptyQuery = () => ({
applicationNo: '',
projectName: '',
deptName: '',
deptPath: [],
kingdeeStatus: '',
approvalStatus: '',
});
export default {
name: 'InvoiceApplication',
components: { TableHeaderTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 5,
searchVisible: true,
tableSize: 'default',
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
columns: invoiceApplicationTableColumns,
visibleColumnKeys: invoiceApplicationTableColumns.map(column => column.prop),
rows: [],
selection: [],
loading: false,
batchSyncing: false,
page: { current: 1, size: 10, total: 0 },
mkEnabled: false,
};
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
},
mounted() {
this.loadOrganizationOptions();
this.loadTable();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
})
.catch(() => {
this.mkEnabled = false;
});
},
methods: {
hasPermission(code) {
return this.permission?.[code] !== false;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
async loadTable() {
this.loading = true;
try {
const data = this.unwrapData(
await api.getList(this.page.current, this.page.size, this.query)
);
this.rows = data.records || [];
this.page.total = Number(data.total || 0);
} finally {
this.loading = false;
}
},
handleSearch() {
this.page.current = 1;
this.loadTable();
},
resetSearch() {
this.query = emptyQuery();
this.handleSearch();
},
handleSizeChange() {
this.page.current = 1;
this.loadTable();
},
openCreate() {
this.$router.push({ path: '/payment/invoice-application/form', query: { mode: 'add' } });
},
openEdit(row) {
this.$router.push({
path: '/payment/invoice-application/form',
query: { mode: 'edit', id: row.id },
});
},
openView(row) {
this.$router.push({
path: '/payment/invoice-application/form',
query: { mode: 'view', id: row.id },
});
},
settlementNumberList(value) {
return String(value || '')
.split(/[、,,]/)
.map(item => item.trim())
.filter(Boolean);
},
async openSettlementDetail(settlementNo) {
const normalizedNo = String(settlementNo || '').trim();
if (!normalizedNo) return;
try {
const data = this.unwrapData(
await formalSettlementApi.getList(1, 10, { formalSettlementNo: normalizedNo })
);
const row = (data.records || []).find(
item => String(item.formalSettlementNo || '').trim() === normalizedNo
);
if (!row?.id) {
this.$message.warning('未找到对应的正式结算单');
return;
}
this.$router.push({
path: '/settlement/formal-settlement/form',
query: { mode: 'view', id: row.id, name: '查看正式结算' },
});
} catch (error) {
this.$message.error('正式结算单详情加载失败');
}
},
async handleDelete(row) {
await this.$confirm('确认删除该开票申请?', '提示', { type: 'warning' });
await api.remove(row.id);
this.$message.success('删除成功');
this.loadTable();
},
async handleSubmit(row) {
const previousApprovalStatus = row.approvalStatus || '';
await api.submit({ id: row.id });
await submitMkApprovalFlow({
bizType: 'invoice-application',
bizId: row.id,
subjectName: row.applicationNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
},
handleMkAudit(row) {
if (!row?.id) {
this.$message.warning('开票申请ID为空,无法打开审核页');
return;
}
getMKApprovalUrl({ bizId: String(row.id) })
.then(res => {
const url = res?.data?.data;
if (!url) {
this.$message.error('获取审核跳转地址失败');
return;
}
window.open(url, '_blank');
})
.catch(() => {
this.$message.error('获取审核跳转地址失败');
});
},
async handleApprove(row) {
await this.$confirm('确认审批通过该开票申请?', '提示', { type: 'warning' });
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.loadTable();
},
async handleReturn(row) {
const { value } = await this.$prompt('请输入驳回原因', '审批驳回');
await api.returnBill({ id: row.id, reason: value });
this.$message.success('已驳回');
this.loadTable();
},
async handleVoid(row) {
const { value } = await this.$prompt('请输入作废原因', '作废开票申请');
await api.voidBill({ id: row.id, reason: value });
this.$message.success('作废成功');
this.loadTable();
},
async handleBatchSync() {
if (this.batchSyncing) return;
const rows = this.selection.filter(row => row.approvalStatus === 'approved');
if (!rows.length) {
this.$message.warning('请选择至少一条审批通过的开票申请');
return;
}
this.batchSyncing = true;
try {
await Promise.all(rows.map(row => api.syncKingdee(row.id)));
this.$message.success(`已同步${rows.length}条开票申请`);
await this.loadTable();
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
const idSet = new Set(rows.map(row => String(row.id)));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
if (pendingCount) {
this.$message({
type: 'warning',
message: `其中${pendingCount}单附件未全部上传,可重推补传`,
duration: 8000,
});
}
} finally {
this.batchSyncing = false;
}
},
async handleExport() {
const data = this.unwrapData(await api.getList(1, 100000, this.query));
const exportRows = (data.records || []).map(item => ({
单据号: item.applicationNo,
结算单号: item.settlementNos,
所属项目: item.projectName,
所属组织: item.deptName,
开票方: item.issuerName,
受票方: item.receiverName,
开票金额: Number(item.invoiceAmount || 0).toFixed(2),
创建人: item.createUserName,
创建时间: item.createTime,
状态: item.approvalStatusName,
当前节点: item.currentNode,
当前处理人: item.currentProcessor,
金蝶单据号: item.kingdeeBillNo,
金蝶单据状态: item.kingdeeStatusName,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(exportRows), '开票申请');
XLSX.writeFile(workbook, `开票申请${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`);
},
statusType(status) {
return (
{ approved: 'success', reviewing: 'warning', returned: 'danger', voided: 'info' }[status] ||
''
);
},
lockTag(row) {
if (row.kingdeeLockStatus === 'locked') {
return { show: true, type: 'danger', label: '金蝶锁定中', tip: '该单据已被金蝶锁定,不可编辑/作废' };
}
if (row.kingdeeLockStatus === 'failed') {
if ((row.kingdeeTryCount || 0) >= 5) {
return {
show: true,
type: 'danger',
label: '锁定失败·已达上限',
tip: row.kingdeeLockFailReason || '自动补偿次数已用完,请重推「同步金蝶」立即补锁',
};
}
return {
show: true,
type: 'warning',
label: '锁定失败·补偿中',
tip: row.kingdeeLockFailReason || '系统将自动补偿重锁',
};
}
// 金蝶解锁终态标识:删单解锁的源单已作废,此标签用于区分人工作废与金蝶删单
if (row.kingdeeUnlockTime) {
const isDelete = row.kingdeeUnlockOperationName === '金蝶删单';
return {
show: true,
type: isDelete ? 'danger' : 'warning',
label: row.kingdeeUnlockOperationName || '金蝶解锁',
tip: `${row.kingdeeUnlockOperator || '-'} 于 ${this.$dayjs(row.kingdeeUnlockTime).format('YYYY-MM-DD HH:mm:ss')} 解锁`,
};
}
return { show: false };
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
formatMoney(value) {
return Number(value || 0).toFixed(2);
},
},
};
</script>
<style scoped lang="scss">
.invoice-page__search {
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.invoice-page__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
.invoice-page__search :deep(.el-form-item) {
margin-bottom: 8px;
}
.invoice-page__search :deep(.el-input),
.invoice-page__search :deep(.el-select) {
width: 100%;
}
// 金蝶单据状态 label 为 6 个汉字,去掉 EP 默认 line-height:32px,避免与控件行高不对齐
.invoice-page__search .invoice-kingdee-item :deep(.el-form-item__label) {
line-height: normal;
}
.invoice-page__search-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
.invoice-page__table-panel {
margin-top: 8px;
}
.invoice-page__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.invoice-page__toolbar-left,
.invoice-page__links {
display: flex;
align-items: center;
gap: 8px;
}
.invoice-page__links {
flex-wrap: wrap;
justify-content: center;
}
.invoice-page__settlement-nos {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 8px;
white-space: normal;
}
.invoice-page__settlement-no {
color: #409eff;
cursor: pointer;
white-space: normal;
word-break: break-all;
}
.invoice-page__settlement-no:hover {
text-decoration: underline;
}
.invoice-page__pagination {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
.invoice-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.invoice-page :deep(.el-table__row:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
.invoice-page__flow-table {
margin-top: 16px;
}
@media (max-width: 1200px) {
.invoice-page__search-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>