Files
tms-erp-web/src/views/settlement/formal-settlement.vue
T

630 lines
23 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">
<section class="formal-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="formal-page__search-grid">
<el-form-item v-for="field in visibleSearchFields" :key="field.prop" :label="field.label">
<el-date-picker
v-if="field.type === 'daterange'"
v-model="query[field.prop]"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
range-separator="~"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-select
v-else-if="field.type === 'select'"
v-model="query[field.prop]"
clearable
placeholder="请选择"
>
<el-option
v-for="item in field.options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-cascader
v-else-if="field.type === 'cascader'"
v-model="query[field.prop]"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
clearable
filterable
style="width: 100%"
placeholder="请选择"
/>
<el-input v-else v-model="query[field.prop]" clearable placeholder="请输入" />
</el-form-item>
<div class="formal-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button text :icon="searchExpanded ? ArrowUp : ArrowDown" @click="toggleSearch">
{{ searchExpanded ? '收起' : '展开' }}
</el-button>
</div>
</div>
</el-form>
</section>
<section class="formal-page__table-panel">
<el-tabs
type="border-card"
v-model="activeSettlementType"
@tab-change="handleSettlementTypeChange"
>
<el-tab-pane label="应付" name="payable">
<formal-settlement-table-panel
:key="activeSettlementType"
:rows="rows"
:columns="columns"
:loading="loading"
:page="page"
:has-permission="hasPermissionFn"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@export="handleExport"
@refresh="loadTable"
@page-change="onPageChange"
@action="onAction"
@update:selection="selection = $event"
/>
</el-tab-pane>
<el-tab-pane label="应收" name="receivable">
<formal-settlement-table-panel
:key="activeSettlementType"
:rows="rows"
:columns="columns"
:loading="loading"
:page="page"
:has-permission="hasPermissionFn"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@export="handleExport"
@refresh="loadTable"
@page-change="onPageChange"
@action="onAction"
@update:selection="selection = $event"
/>
</el-tab-pane>
</el-tabs>
</section>
<formal-settlement-editor
v-model="editor.visible"
:record-id="editor.id"
:readonly="editor.readonly"
@success="loadTable"
/>
<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 { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { createSettlementTransfer } from '@/utils/settlement-transfer';
import { downloadFile } from '@/utils/util';
import * as api from '@/api/settlement/formalSettlement';
import * as paymentApi from '@/api/payment/paymentApplication';
import {
invoiceStatusName,
formalSettlementSearchFields,
paymentStatusOptions,
} from '@/option/settlement/formalSettlementSearch';
import organizationSearch from '@/mixins/organization-search';
import { formalSettlementTableColumns } from '@/option/settlement/formalSettlementTable';
import FormalSettlementEditor from './components/formal-settlement-editor.vue';
import FormalSettlementTablePanel from './components/formal-settlement-table-panel.vue';
const emptyQuery = () => ({
formalSettlementNo: '',
preSettlementNo: '',
projectName: '',
deptName: [],
contractNo: '',
payerName: '',
payeeName: '',
invoiceStatus: '',
paymentStatus: '',
approvalStatus: '',
kingdeeSyncStatus: '',
createDateRange: [],
});
export default {
name: 'FormalSettlement',
components: { FormalSettlementEditor, FormalSettlementTablePanel },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
activeSettlementType: 'payable',
searchFields: formalSettlementSearchFields,
columns: formalSettlementTableColumns,
rows: [],
selection: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
editor: { visible: false, id: null, readonly: false },
paymentDialog: {
visible: false,
loading: false,
row: {},
rows: [],
form: { appliedAmount: 0, remark: '' },
},
};
},
computed: {
...mapGetters(['permission']),
hasPermissionFn() {
return code => this.permission?.[code] !== false;
},
visibleSearchFields() {
return this.searchExpanded ? this.searchFields : this.searchFields.slice(0, 4);
},
paymentAvailable() {
return this.paymentRemaining(this.paymentDialog.row);
},
},
watch: {
'$route.query.detailNo'(detailNo) {
if (detailNo) this.openRouteDetail(detailNo);
},
},
mounted() {
this.loadOrganizationOptions();
this.loadTable();
this.openRouteDetail();
},
methods: {
hasPermission(code) {
return this.permission?.[code] !== false;
},
async loadTable() {
this.loading = true;
try {
const params = this.buildQueryParams();
const response = await api.getList(this.page.current, this.page.size, params);
const data = this.unwrapData(response);
this.rows = data.records || [];
this.page.total = data.total || 0;
} finally {
this.loading = false;
}
},
handleSearch() {
this.page.current = 1;
this.loadTable();
},
resetSearch() {
this.query = emptyQuery();
this.handleSearch();
},
toggleSearch() {
this.searchExpanded = !this.searchExpanded;
},
handleSizeChange() {
this.page.current = 1;
this.loadTable();
},
onPageChange({ current, size }) {
this.page.current = current;
this.page.size = size;
this.loadTable();
},
onAction({ type, row }) {
const handlers = {
view: () => this.openView(row),
edit: () => this.openEdit(row),
delete: () => this.handleDelete(row),
submit: () => this.handleSubmit(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: '新增正式结算',
settlementType: this.activeSettlementType,
},
});
},
openEdit(row) {
this.$router.push({
path: '/settlement/formal-settlement/form',
query: { mode: 'edit', id: row.id, name: '编辑正式结算' },
});
},
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) 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('未找到对应的正式结算单');
return;
}
this.activeSettlementType = row.settlementType || this.activeSettlementType;
this.openView(row);
},
async handleDelete(row) {
await this.$confirm('确认删除该正式结算草稿?', '提示', { type: 'warning' });
await api.remove(row.id);
this.$message.success('删除成功');
this.loadTable();
},
async handleSubmit(row) {
await this.$confirm('提交后来源预结算将保持锁定,确认提交?', '提示', { type: 'warning' });
await api.submit({ id: row.id });
this.$message.success('提交成功');
this.loadTable();
},
async handleApprove(row) {
await this.$confirm(
'审核通过后正式结算生效,金额只能通过结算调整单处理,确认通过?',
'提示',
{ type: 'warning' }
);
await api.approve({ id: row.id });
this.$message.success('审批通过');
this.loadTable();
},
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.loadTable();
},
async handleVoid(row) {
const { value } = await this.$prompt('请输入作废原因', '作废正式结算单', {
inputValidator: value => Boolean(value?.trim()) || '请输入作废原因',
});
await api.voidBill({ id: row.id, reason: value });
this.$message.success('已作废');
this.loadTable();
},
selectedOne(action) {
if (this.selection.length !== 1) {
this.$message.warning(`${action}需选择一条正式结算单`);
return null;
}
return this.selection[0];
},
async handleSync() {
const row = this.selectedOne('同步金蝶');
if (!row) return;
const response = await api.syncKingdee(row.id);
const data = this.unwrapData(response);
this.$message.success(`同步成功,金蝶单据号:${data}`);
this.loadTable();
},
async openPaymentDialog() {
if (!this.selection.length) return this.$message.warning('请至少选择一条正式结算单');
const rows = this.selection;
if (rows.some(row => row.approvalStatus !== 'approved' || row.settlementType !== 'payable'))
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.loadTable();
} 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>正式结算单</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.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`);
},
buildQueryParams() {
// 所属组织级联返回 id 路径,转成组织名称传给后端(用副本,避免污染搜索框回显)
const params = this.normalizeOrganizationSearch({ ...this.query }, 'deptName');
params.settlementType = this.activeSettlementType;
const range = params.createDateRange || [];
delete params.createDateRange;
if (range.length === 2) {
params.createStartDate = range[0];
params.createEndDate = range[1];
}
return params;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
handleSettlementTypeChange() {
this.page.current = 1;
this.loadTable();
},
escapeHtml(value) {
return String(value || '-').replace(
/[&<>"']/g,
char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])
);
},
isEditable(row) {
return ['draft', 'returned'].includes(row.approvalStatus);
},
statusType(status) {
return (
{ approved: 'success', reviewing: 'warning', returned: 'danger', voided: 'info' }[status] ||
''
);
},
invoiceName(value, settlementType) {
return invoiceStatusName(value, settlementType);
},
paymentName(value) {
return paymentStatusOptions.find(item => item.value === value)?.label || value || '-';
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
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__search {
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.formal-page__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
align-items: start;
}
.formal-page__search :deep(.el-form-item) {
margin-bottom: 8px;
}
.formal-page__search :deep(.el-form-item__label) {
white-space: nowrap;
}
.formal-page__search :deep(.el-input),
.formal-page__search :deep(.el-select),
.formal-page__search :deep(.el-date-editor) {
width: 100%;
}
.formal-page__search-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.formal-page__table-panel {
margin-top: 8px;
}
.formal-payment-dialog__batch-tip {
margin-bottom: 12px;
color: #606266;
}
.formal-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.formal-page :deep(.el-table__body tr:nth-child(even) > td.el-table__cell),
.formal-page :deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--left),
.formal-page :deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--right) {
background: #fafafa;
}
:deep(.formal-page.basic-container .basic-container__card > .el-card__body) {
padding: 0;
}
@media (max-width: 1200px) {
.formal-page__search-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.formal-page__search-grid {
grid-template-columns: 1fr;
}
}
</style>