Files
tms-erp-web/src/views/settlement/formal-settlement.vue
T
刘泉佳 11d33df46a Merge branch 'master' into feature/20261027-hcs-bif-all-web
# Conflicts:
#	src/views/business/contract-manage.vue
2026-10-31 14:12:05 +08:00

692 lines
26 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="formal-page">
<el-tabs
v-if="!lockedSettlementType"
type="border-card"
v-model="activeSettlementType"
@tab-change="handleSettlementTypeChange"
>
<el-tab-pane label="应付" name="payable">
<formal-settlement-table-panel
v-if="activeSettlementType === 'payable'"
key="payable"
v-bind="tablePanelProps"
v-on="tablePanelListeners"
/>
</el-tab-pane>
<el-tab-pane label="应收" name="receivable">
<formal-settlement-table-panel
v-if="activeSettlementType === 'receivable'"
key="receivable"
v-bind="tablePanelProps"
v-on="tablePanelListeners"
/>
</el-tab-pane>
</el-tabs>
<formal-settlement-table-panel
v-else
key="locked"
v-bind="tablePanelProps"
v-on="tablePanelListeners"
/>
<formal-settlement-editor
v-model="editor.visible"
:record-id="editor.id"
:readonly="editor.readonly"
@success="onLoad(page)"
/>
<el-dialog
v-model="paymentDialog.visible"
title="付款申请"
width="520px"
append-to-body
class="formal-payment-dialog"
>
<section-card title="付款信息">
<template v-if="paymentDialog.rows.length > 1">
<div class="formal-payment-dialog__batch-tip">
已选择 {{ paymentDialog.rows.length }} 条同合同付款结算单,请确认各单申请金额。
</div>
<el-table :data="paymentDialog.rows" border>
<el-table-column prop="formalSettlementNo" label="结算单号" min-width="160" />
<el-table-column label="剩余可申请金额" min-width="150" align="right">
<template #default="{ row }">{{
formatMoney(paymentRemaining(row), row.currency)
}}</template>
</el-table-column>
<el-table-column label="申请付款金额" min-width="150" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.appliedAmount"
:min="0.01"
:max="paymentRemaining(row)"
:precision="2"
:controls="false"
/>
</template>
</el-table-column>
</el-table>
</template>
<el-form v-else :model="paymentDialog.form" label-position="right" label-width="auto">
<el-form-item label="结算单号">{{
paymentDialog.row.formalSettlementNo || '-'
}}</el-form-item>
<el-form-item label="剩余可申请金额">{{
formatMoney(paymentAvailable, paymentDialog.row.currency)
}}</el-form-item>
<el-form-item label="申请付款金额" required
><el-input-number
v-model="paymentDialog.form.appliedAmount"
:min="0.01"
:max="paymentAvailable"
:precision="2"
:controls="false"
/></el-form-item>
<el-form-item label="备注"
><el-input
v-model="paymentDialog.form.remark"
type="textarea"
maxlength="200"
show-word-limit
/></el-form-item>
</el-form>
</section-card>
<template #footer
><el-button @click="paymentDialog.visible = false">取消</el-button
><el-button type="success" :loading="paymentDialog.loading" @click="submitPayment"
>提交</el-button
></template
>
</el-dialog>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import { createSettlementTransfer } from '@/utils/settlement-transfer';
import {
hasSettlementPermission,
settlementBillLabel,
settlementListPath,
} from '@/utils/settlement-menu';
import { isAdministrator } from '@/utils/auth';
import { downloadFile } from '@/utils/util';
import * as api from '@/api/settlement/formalSettlement';
import * as paymentApi from '@/api/payment/paymentApplication';
import { formalSettlementOption } from '@/option/settlement/formalSettlement';
import organizationSearch from '@/mixins/organization-search';
import FormalSettlementEditor from './components/formal-settlement-editor.vue';
import FormalSettlementTablePanel from './components/formal-settlement-table-panel.vue';
import { submitMkApprovalFlow, loadMkEnabled } from '@/utils/mk-approval';
import { getMKApprovalUrl } from '@/api/system/business-process';
export default {
name: 'FormalSettlement',
components: { FormalSettlementEditor, FormalSettlementTablePanel },
mixins: [organizationSearch],
props: {
/** 菜单拆分后锁定结算类型:payable=付款结算单,receivable=收款结算单 */
lockedSettlementType: {
type: String,
default: '',
validator: value => ['', 'payable', 'receivable'].includes(value),
},
},
data() {
return {
option: formalSettlementOption(),
query: {},
activeSettlementType: 'payable',
rows: [],
selection: [],
loading: false,
approveLoading: false,
syncLoading: false,
page: { currentPage: 1, pageSize: 10, pageSizes: [10, 20, 50, 100], total: 0 },
editor: { visible: false, id: null, readonly: false },
paymentDialog: {
visible: false,
loading: false,
row: {},
rows: [],
form: { appliedAmount: 0, remark: '' },
},
mkEnabled: false,
// keep-alive 多标签下仅激活实例响应 detailNo,避免缓存页重复弹窗
isPageActive: true,
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
isAdmin() {
return isAdministrator(this.userInfo);
},
billLabel() {
return settlementBillLabel(this.activeSettlementType);
},
hasPermissionFn() {
return actionOrCode => {
const action = String(actionOrCode || '').replace(
/^(payment_settlement_|receipt_settlement_|formal_settlement_)/,
''
);
return hasSettlementPermission(this, this.activeSettlementType, action);
};
},
tablePanelProps() {
return {
option: this.option,
rows: this.rows,
loading: this.loading,
page: this.page,
hasPermission: this.hasPermissionFn,
syncLoading: this.syncLoading,
mkEnabled: this.mkEnabled,
showPaymentAction: this.activeSettlementType === 'payable',
billLabel: this.billLabel,
};
},
tablePanelListeners() {
return {
create: this.openCreate,
payment: this.openPaymentDialog,
sync: this.handleSync,
export: this.handleExport,
refresh: () => this.onLoad(this.page),
load: this.onLoad,
action: this.onAction,
'search-change': this.searchChange,
'search-reset': this.searchReset,
'update:selection': selection => {
this.selection = selection;
},
};
},
paymentAvailable() {
return this.paymentRemaining(this.paymentDialog.row);
},
},
watch: {
lockedSettlementType: {
immediate: true,
handler(value) {
if (value === 'payable' || value === 'receivable') {
this.activeSettlementType = value;
}
},
},
activeSettlementType() {
// 切换结算类型后由新挂载的 avue-crud 触发 on-load,此处只重置分页,避免重复请求
this.page.currentPage = 1;
},
'$route.query.detailNo'(detailNo) {
if (detailNo) this.openRouteDetail(detailNo);
},
},
created() {
this.loadOrganizationOptions();
},
mounted() {
this.isPageActive = true;
this.openRouteDetail();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
})
.catch(() => {
this.mkEnabled = false;
});
},
activated() {
this.isPageActive = true;
if (sessionStorage.getItem('formal-settlement-list-refresh') === '1') {
sessionStorage.removeItem('formal-settlement-list-refresh');
this.onLoad(this.page);
}
// 从其他页带 detailNo 跳回时,激活态补开详情
if (this.$route.query.detailNo) {
this.openRouteDetail();
}
},
deactivated() {
this.isPageActive = false;
},
methods: {
hasPermission(actionOrCode) {
return this.hasPermissionFn(actionOrCode);
},
async onLoad(page) {
this.loading = true;
try {
const params = this.buildQueryParams();
const response = await api.getList(page.currentPage, page.pageSize, params);
const data = this.unwrapData(response);
this.rows = data.records || [];
this.page.total = data.total || 0;
} finally {
this.loading = false;
}
},
searchChange(params, done) {
// 所属组织级联返回 id 路径,转成组织名称传给后端
this.normalizeOrganizationSearch(params, 'deptName');
const range = params.createDateRange || [];
delete params.createDateRange;
this.query = {
...params,
createStartDate: range[0] || '',
createEndDate: range[1] || '',
};
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
onAction({ type, row }) {
const handlers = {
view: () => this.openView(row),
edit: () => this.openEdit(row),
delete: () => this.handleDelete(row),
submit: () => this.handleSubmit(row),
mkAudit: () => this.handleMkAudit(row),
approve: () => this.handleApprove(row),
return: () => this.handleReturn(row),
void: () => this.handleVoid(row),
print: () => this.handlePrint(row),
};
(handlers[type] || (() => {}))();
},
openCreate() {
this.$router.push({
path: '/settlement/formal-settlement/form',
query: {
mode: 'add',
name: `新增${this.billLabel}`,
settlementType: this.activeSettlementType,
listPath: settlementListPath(this.activeSettlementType),
},
});
},
openEdit(row) {
const settlementType = row.settlementType || this.activeSettlementType;
this.$router.push({
path: '/settlement/formal-settlement/form',
query: {
mode: 'edit',
id: row.id,
name: `编辑${settlementBillLabel(settlementType)}`,
settlementType,
listPath: settlementListPath(settlementType),
},
});
},
openView(row) {
this.editor = { visible: true, id: row.id, readonly: true };
},
async openRouteDetail(detailNo = this.$route.query.detailNo) {
const formalSettlementNo = String(detailNo || '').trim();
if (!formalSettlementNo || !this.isPageActive) return;
const response = await api.getList(1, 10, { formalSettlementNo });
const data = this.unwrapData(response);
const row = (data.records || []).find(item => item.formalSettlementNo === formalSettlementNo);
if (!row) {
this.$message.warning(`未找到对应的${this.billLabel}`);
return;
}
// 锁定菜单(付款/收款)下若单号属于另一类型,跳到对应列表页再打开
if (
this.lockedSettlementType &&
row.settlementType &&
row.settlementType !== this.lockedSettlementType
) {
await this.$router.replace({
path: settlementListPath(row.settlementType),
query: { detailNo: formalSettlementNo },
});
return;
}
if (!this.lockedSettlementType) {
this.activeSettlementType = row.settlementType || this.activeSettlementType;
}
this.openView(row);
},
async handleDelete(row) {
await this.$confirm(`确认删除该${this.billLabel}草稿?`, '提示', { type: 'warning' });
await api.remove(row.id);
this.$message.success('删除成功');
this.onLoad(this.page);
},
async handleSubmit(row) {
await this.$confirm('提交后来源预付结算单将保持锁定,确认提交?', '提示', { type: 'warning' });
const previousApprovalStatus = row.approvalStatus || '';
await api.submit({ id: row.id });
await submitMkApprovalFlow({
bizType: 'formal-settlement',
bizId: row.id,
subjectName: row.formalSettlementNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.onLoad(this.page);
},
handleMkAudit(row) {
if (!row?.id) {
this.$message.warning(`${this.billLabel}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) {
// 防止重复点击:请求进行中忽略后续点击,避免重复审批和重复提示
if (this.approveLoading) return;
this.approveLoading = true;
let loading = null;
try {
await this.$confirm(
`审核通过后${this.billLabel}生效,金额只能通过结算调整单处理,确认通过?`,
'提示',
{ type: 'warning' }
);
loading = this.$loading({
lock: true,
text: '审批处理中,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
});
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.onLoad(this.page);
} finally {
loading?.close();
this.approveLoading = false;
}
},
async handleReturn(row) {
const { value } = await this.$prompt('请输入驳回原因', '审批驳回', {
inputValidator: value => Boolean(value?.trim()) || '请输入驳回原因',
});
await api.returnBill({ id: row.id, reason: value });
this.$message.success('已驳回');
this.onLoad(this.page);
},
async handleVoid(row) {
const { value } = await this.$prompt('请输入作废原因', `作废${this.billLabel}`, {
inputValidator: value => Boolean(value?.trim()) || '请输入作废原因',
});
await api.voidBill({ id: row.id, reason: value });
this.$message.success('已作废');
this.onLoad(this.page);
},
selectedOne(action) {
if (this.selection.length !== 1) {
this.$message.warning(`${action}需选择一条${this.billLabel}`);
return null;
}
return this.selection[0];
},
async handleSync() {
if (this.syncLoading) return;
const rows = (this.selection || []).filter(row => row.approvalStatus === 'approved');
if (!rows.length) {
this.$message.warning(`请选择至少一条审批通过的${this.billLabel}`);
return;
}
this.syncLoading = true;
try {
let successCount = 0;
let failCount = 0;
let lastFailReason = '';
for (const row of rows) {
try {
await api.syncKingdee(row.id, { meta: { suppressErrorMessage: true } });
successCount += 1;
} catch (error) {
failCount += 1;
lastFailReason = String(error?.message || error || '未知错误').trim() || '未知错误';
}
}
await this.onLoad(this.page);
if (rows.length === 1) {
if (failCount) {
await this.$alert(`同步失败,${lastFailReason}`, '同步结果', {
type: 'error',
confirmButtonText: '知道了',
});
} else {
let message = '同步成功';
const freshRow = (this.rows || []).find(item => String(item.id) === String(rows[0].id));
if (freshRow && ['pending', 'partial'].includes(freshRow.attachmentSyncStatus)) {
message += '\n该单附件未全部上传,可重推补传';
}
await this.$alert(message, '同步结果', {
type: 'success',
confirmButtonText: '知道了',
});
}
return;
}
let batchMessage = `完成同步${successCount}条,失败${failCount}条`;
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) {
batchMessage += `\n其中${pendingCount}单附件未全部上传,可重推补传`;
}
await this.$alert(batchMessage, '同步结果', {
type: failCount ? 'warning' : 'success',
confirmButtonText: '知道了',
});
} finally {
this.syncLoading = false;
}
},
async openPaymentDialog() {
if (!this.selection.length) return this.$message.warning(`请至少选择一条${this.billLabel}`);
const rows = this.selection;
if (rows.some(row => row.approvalStatus !== 'approved' || row.settlementType !== 'payable'))
return this.$message.warning('仅审批通过的付款结算单允许发起付款申请');
if (rows.some(row => row.adjustStatus === 'adjusted'))
return this.$message.warning('已调整的结算单不可再发起付款,请使用调整后的新单');
const contractIds = new Set(rows.map(row => String(row.contractId || '')));
if (contractIds.size !== 1 || contractIds.has(''))
return this.$message.warning('付款申请必须选择同一个合同的付款结算单');
const paymentRows = rows.map(row => ({ ...row, appliedAmount: this.paymentRemaining(row) }));
if (paymentRows.some(row => row.appliedAmount <= 0))
return this.$message.warning('所选付款结算单均须存在剩余可申请金额');
try {
const sourceFormalSettlements = await Promise.all(
paymentRows.map(async row => {
const [amountResponse, detailResponse] = await Promise.all([
paymentApi.getReferenceAmount('settlement_payment', row.id),
api.getDetail(row.id),
]);
const amount = this.unwrapData(amountResponse) || {};
const detail = this.unwrapData(detailResponse) || {};
const remainingAmount = Number(amount.payableAmount ?? this.paymentRemaining(row));
return {
...row,
id: row.id,
settlementId: row.id,
projectId: detail.projectId ?? row.projectId,
projectName: detail.projectName || row.projectName,
deptId: detail.deptId ?? row.deptId,
deptName: detail.deptName || row.deptName,
contractId: detail.contractId ?? row.contractId,
contractNo: detail.contractNo || row.contractNo,
contractName: detail.contractName || row.contractName,
payerName: detail.payerName || row.payerName,
payeeName: detail.payeeName || row.payeeName,
formalSettlementNo: detail.formalSettlementNo || row.formalSettlementNo,
settlementAmount: Number(
amount.settlementAmount ?? detail.settlementAmount ?? row.settlementAmount ?? 0
),
payableAmount: remainingAmount,
appliedAmount: remainingAmount,
invoices: detail.invoices || [],
paymentRecords: detail.paymentRecords || detail.paymentApplications || [],
};
})
);
if (sourceFormalSettlements.some(row => row.appliedAmount <= 0)) {
this.$message.warning('所选付款结算单均须存在剩余可申请金额');
return;
}
const transferToken = createSettlementTransfer({
sourceFormalSettlements,
settlementType: 'payable',
});
await this.$router.push({
path: '/payment/payment-application/form',
query: { mode: 'add', paymentType: 'settlement_payment', transferToken },
});
this.selection = [];
} catch (error) {
this.$message.error('结算信息加载失败,请稍后重试');
}
},
async submitPayment() {
const paymentRows = this.paymentDialog.rows;
if (paymentRows.length > 1) {
if (paymentRows.some(row => Number(row.appliedAmount || 0) <= 0))
return this.$message.warning('请输入各付款结算单的申请付款金额');
if (paymentRows.some(row => Number(row.appliedAmount) > this.paymentRemaining(row)))
return this.$message.warning('申请付款金额不能超过剩余可申请金额');
} else if (Number(this.paymentDialog.form.appliedAmount || 0) <= 0) {
return this.$message.warning('请输入申请付款金额');
}
this.paymentDialog.loading = true;
try {
const response =
paymentRows.length > 1
? await api.applyPayments({
items: paymentRows.map(row => ({ id: row.id, appliedAmount: row.appliedAmount })),
remark: this.paymentDialog.form.remark,
})
: await api.applyPayment({ id: this.paymentDialog.row.id, ...this.paymentDialog.form });
const data = this.unwrapData(response);
this.$message.success(
Array.isArray(data) ? `已生成 ${data.length} 条付款申请` : `付款申请已生成:${data}`
);
this.paymentDialog.visible = false;
this.selection = [];
this.onLoad(this.page);
} finally {
this.paymentDialog.loading = false;
}
},
handlePrint(row) {
if (!row) {
row = this.selectedOne('打印');
if (!row) return;
}
const win = window.open('', '_blank');
if (!win) return this.$message.warning('浏览器阻止了打印窗口,请允许弹窗后重试');
win.document.write(
`<!doctype html><html><head><title>${
row.formalSettlementNo
}</title><style>body{font-family:Arial,"Microsoft YaHei",sans-serif;padding:32px;color:#222}h1{text-align:center}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px 28px;margin-top:30px}.item{border-bottom:1px solid #ddd;padding:8px 0}.actions{text-align:center;margin-top:36px}@media print{.actions{display:none}}</style></head><body><h1>${this.escapeHtml(
settlementBillLabel(row.settlementType || this.activeSettlementType)
)}</h1><div class="grid"><div class="item">结算单号:${this.escapeHtml(
row.formalSettlementNo
)}</div><div class="item">预付结算单号:${this.escapeHtml(
row.preSettlementNos
)}</div><div class="item">项目:${this.escapeHtml(
row.projectName
)}</div><div class="item">合同编号:${this.escapeHtml(
row.contractNo
)}</div><div class="item">合同名称:${this.escapeHtml(
row.contractName
)}</div><div class="item">所属组织:${this.escapeHtml(
row.deptName
)}</div><div class="item">付款方:${this.escapeHtml(
row.payerName
)}</div><div class="item">收款方:${this.escapeHtml(
row.payeeName
)}</div><div class="item">结算金额:${this.formatMoney(
row.settlementAmount,
row.currency
)}</div></div><div class="actions"><button onclick="window.print()">打印</button></div></body></html>`
);
win.document.close();
},
async handleExport() {
const params = this.buildQueryParams();
if (this.selection.length) params.ids = this.selection.map(item => item.id).join(',');
const { data } = await api.exportList(params);
downloadFile(
data,
`${this.billLabel}${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`
);
},
buildQueryParams() {
const params = { ...this.query };
params.settlementType = this.lockedSettlementType || this.activeSettlementType;
return params;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
handleSettlementTypeChange() {
// 分页重置与数据加载由 activeSettlementType watcher + 新挂载 crud 的 on-load 完成
this.selection = [];
},
escapeHtml(value) {
return String(value || '-').replace(
/[&<>"']/g,
char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])
);
},
formatMoney(value, currency = 'RMB') {
return `${Number(value || 0).toFixed(2)} ${currency || 'RMB'}`;
},
paymentRemaining(row) {
const remaining = Number(row?.settlementAmount || 0) - Number(row?.paidAmount || 0);
return Math.max(0, Number(remaining || 0));
},
},
};
</script>
<style scoped lang="scss">
.formal-page {
:deep(.el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.el-table__body tr:nth-child(even) > td.el-table__cell),
:deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--left),
:deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--right) {
background: #fafafa;
}
}
.formal-payment-dialog__batch-tip {
margin-bottom: 12px;
color: #606266;
}
:deep(.formal-page.basic-container .basic-container__card > .el-card__body) {
padding: 0;
}
</style>