613 lines
20 KiB
Vue
613 lines
20 KiB
Vue
<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>
|