<template>
|
<div class="app-container">
|
<el-form :model="filters" :inline="true">
|
<el-form-item label="客户:">
|
<el-select v-model="filters.customerId" placeholder="请选择客户" clearable filterable style="width: 200px;">
|
<el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="对账期间:">
|
<el-date-picker v-model="filters.startMonth" type="month" placeholder="开始月份" value-format="YYYY-MM" style="width: 140px;" />
|
<span style="margin: 0 10px;">至</span>
|
<el-date-picker v-model="filters.endMonth" type="month" placeholder="结束月份" value-format="YYYY-MM" style="width: 140px;" />
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" @click="getTableData">搜索</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
</el-form-item>
|
</el-form>
|
<div class="table_list">
|
<div class="actions">
|
<div>
|
<el-button type="primary" @click="generateStatement" icon="Document">生成对账单</el-button>
|
</div>
|
<div>
|
<el-button @click="handleOut" icon="Download">导出对账单</el-button>
|
</div>
|
</div>
|
<PIMTable
|
rowKey="id"
|
:column="columns"
|
:tableData="dataList"
|
:tableLoading="tableLoading"
|
:page="{
|
current: pagination.currentPage,
|
size: pagination.pageSize,
|
total: pagination.total,
|
}"
|
@pagination="changePage"
|
>
|
<template #openingBalance="{ row }">
|
<span :class="row.openingBalance >= 0 ? 'text-success' : 'text-danger'">¥{{ formatMoney(row.openingBalance) }}</span>
|
</template>
|
<template #currentPlan="{ row }">
|
<span class="text-primary">¥{{ formatMoney(row.currentPlan) }}</span>
|
</template>
|
<template #currentActually="{ row }">
|
<span class="text-success">¥{{ formatMoney(row.currentActually) }}</span>
|
</template>
|
<template #closingBalance="{ row }">
|
<span :class="row.closingBalance >= 0 ? 'text-success' : 'text-danger'">¥{{ formatMoney(row.closingBalance) }}</span>
|
</template>
|
<template #operation="{ row }">
|
<el-button type="primary" link @click="viewDetail(row)">查看明细</el-button>
|
<!-- <el-button type="primary" link @click="printStatement(row)">打印</el-button> -->
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
</template>
|
</PIMTable>
|
</div>
|
|
<FormDialog title="对账明细" v-model="detailDialogVisible" width="900px" @confirm="printDetail" @cancel="detailDialogVisible = false" operationType="detail">
|
<div class="statement-header">
|
<h3>{{ currentCustomer }} 应收对账单</h3>
|
<p>对账期间: {{ currentPeriod }}</p>
|
</div>
|
<el-table :data="detailData" border style="width: 100%" v-loading="detailLoading">
|
<el-table-column prop="date" label="日期" width="120" />
|
<el-table-column prop="type" label="类型" width="100">
|
<template #default="{ row }">
|
<el-tag :type="row.type === '出库' ? 'success' : row.type === '退货' ? 'danger' : 'primary'">{{ row.type }}</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="code" label="单据编号" width="150" />
|
<el-table-column prop="debit" label="借方(应收)" width="120">
|
<template #default="{ row }">
|
<span v-if="row.debit > 0" class="text-danger">¥{{ formatMoney(row.debit) }}</span>
|
<span v-else>-</span>
|
</template>
|
</el-table-column>
|
<el-table-column prop="credit" label="贷方(收款)" width="120">
|
<template #default="{ row }">
|
<span v-if="row.credit > 0" class="text-success">¥{{ formatMoney(row.credit) }}</span>
|
<span v-else>-</span>
|
</template>
|
</el-table-column>
|
<el-table-column prop="balance" label="余额" width="120">
|
<template #default="{ row }">
|
<span :class="row.balance >= 0 ? 'text-success' : 'text-danger'">¥{{ formatMoney(row.balance) }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
</el-table>
|
<template #footer>
|
<el-button type="primary" @click="printDetail">打印</el-button>
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
</template>
|
</FormDialog>
|
|
<FormDialog title="生成对账单" v-model="generateDialogVisible" width="1000px" @confirm="confirmGenerate" @cancel="generateDialogVisible = false">
|
<el-form :model="generateForm" label-width="100px">
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="选择客户" prop="customerId">
|
<el-select
|
v-model="generateForm.customerId"
|
placeholder="请选择客户"
|
style="width: 100%;"
|
filterable
|
@change="onCustomerChange"
|
>
|
<el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
|
</el-select>
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="对账月份" prop="statementMonth">
|
<el-date-picker v-model="generateForm.statementMonth" type="month" placeholder="选择月份" value-format="YYYY-MM" style="width: 100%;" @change="onStatementMonthChange" />
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-form>
|
|
<div v-if="statementDetailLoaded" class="sales-section">
|
<div v-if="salesData.length > 0" class="section-title">本月销售数据</div>
|
<el-table
|
v-if="salesData.length > 0"
|
ref="salesTableRef"
|
:data="salesData"
|
border
|
row-key="id"
|
style="width: 100%; margin-bottom: 15px;"
|
v-loading="salesLoading"
|
@selection-change="handleSalesSelectionChange"
|
>
|
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column prop="occurrenceDate" label="日期" width="120" />
|
<el-table-column prop="receiptNumber" label="单据编号" width="150" />
|
<el-table-column prop="type" label="类型" width="100">
|
<template #default="{ row }">
|
<el-tag :type="getDetailTypeTagType(row.type)">{{ row.typeLabel }}</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="amount" label="金额" width="120">
|
<template #default="{ row }">
|
<span :class="getDetailAmountClass(row.type)">¥{{ formatMoney(row.amount) }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column prop="remark" label="备注" />
|
</el-table>
|
<el-empty v-else description="该客户本月暂无明细数据" :image-size="80" />
|
|
<div class="summary-row">
|
<span>期初余额: <strong class="text-primary">¥{{ formatMoney(generateForm.openingBalance) }}</strong></span>
|
<span>本期应收: <strong class="text-primary">¥{{ formatMoney(generateForm.currentPlan) }}</strong></span>
|
<span>本期收款: <strong class="text-success">¥{{ formatMoney(generateForm.currentActually) }}</strong></span>
|
<span>期末余额: <strong :class="displayClosingBalance >= 0 ? 'text-success' : 'text-danger'">¥{{ formatMoney(displayClosingBalance) }}</strong></span>
|
</div>
|
</div>
|
|
<div v-else-if="generateForm.customerId && generateForm.statementMonth && !salesLoading" class="empty-tip">
|
<el-empty description="该客户本月暂无销售数据" />
|
</div>
|
|
<template #footer>
|
<el-button type="primary" @click="confirmGenerate" :disabled="!canGenerate" :loading="submitLoading">确认生成</el-button>
|
<el-button @click="generateDialogVisible = false">取消</el-button>
|
</template>
|
</FormDialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, computed, nextTick, getCurrentInstance } from "vue";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
import FormDialog from "@/components/Dialog/FormDialog.vue";
|
import { listCustomer } from "@/api/basicData/customer.js";
|
import {
|
getAccountStatementDetailsByMonth,
|
addAccountStatement,
|
listPageAccountStatement,
|
deleteAccountStatement,
|
} from "@/api/financialManagement/accountStatement.js";
|
|
const ACCOUNT_TYPE_RECEIVABLE = 1;
|
|
const { proxy } = getCurrentInstance();
|
|
defineOptions({
|
name: "应收对账",
|
});
|
|
const filters = reactive({
|
customerId: "",
|
startMonth: "",
|
endMonth: "",
|
});
|
|
const pagination = reactive({
|
currentPage: 1,
|
pageSize: 10,
|
total: 0,
|
});
|
|
const columns = [
|
{ label: "对账单号", prop: "statementNumber", width: "150" },
|
{ label: "客户名称", prop: "customerName", width: "180" },
|
{ label: "对账期间", prop: "statementMonth", width: "150" },
|
{ label: "期初余额", prop: "openingBalance", dataType: "slot", slot: "openingBalance" },
|
{ label: "本期应收", prop: "currentPlan", dataType: "slot", slot: "currentPlan" },
|
{ label: "本期收款", prop: "currentActually", dataType: "slot", slot: "currentActually" },
|
{ label: "期末余额", prop: "closingBalance", dataType: "slot", slot: "closingBalance" },
|
{ label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "200", fixed: "right" },
|
];
|
|
const dataList = ref([]);
|
const tableLoading = ref(false);
|
const submitLoading = ref(false);
|
const detailDialogVisible = ref(false);
|
const currentCustomer = ref("");
|
const currentPeriod = ref("");
|
const detailData = ref([]);
|
const detailLoading = ref(false);
|
|
const generateDialogVisible = ref(false);
|
const salesLoading = ref(false);
|
const statementDetailLoaded = ref(false);
|
const salesData = ref([]);
|
const selectedSales = ref([]);
|
const salesTableRef = ref(null);
|
const customerList = ref([]);
|
|
/** 明细 type:1出库 2入库 3收款 4付款 5退货 */
|
const STATEMENT_DETAIL_TYPE_MAP = {
|
1: "出库",
|
2: "入库",
|
3: "收款",
|
4: "付款",
|
5: "退货",
|
};
|
|
const calculateEndBalance = (openingBalance, currentPlan, currentActually) => {
|
return openingBalance + currentPlan - currentActually;
|
};
|
|
const getDetailTypeLabel = (type) => STATEMENT_DETAIL_TYPE_MAP[Number(type)] ?? "";
|
|
const getDetailTypeTagType = (type) => {
|
const t = Number(type);
|
if (t === 1) return "success";
|
if (t === 3) return "primary";
|
if (t === 5) return "danger";
|
return "info";
|
};
|
|
const getDetailAmountClass = (type) => {
|
const t = Number(type);
|
if (t === 1) return "text-primary";
|
if (t === 3) return "text-success";
|
return "text-danger";
|
};
|
|
const generateForm = reactive({
|
customerId: "",
|
customerName: "",
|
statementMonth: "",
|
openingBalance: 0,
|
currentPlan: 0,
|
currentActually: 0,
|
closingBalance: 0,
|
});
|
|
const displayClosingBalance = computed(() => {
|
return calculateEndBalance(
|
generateForm.openingBalance,
|
generateForm.currentPlan,
|
generateForm.currentActually
|
);
|
});
|
|
const canGenerate = computed(() => {
|
return generateForm.customerId && generateForm.statementMonth && selectedSales.value.length > 0;
|
});
|
|
const applyStatementSummary = (data) => {
|
generateForm.openingBalance = Number(data.openingBalance ?? 0);
|
generateForm.currentPlan = Number(data.currentPlan ?? 0);
|
generateForm.currentActually = Number(data.currentActually ?? 0);
|
generateForm.closingBalance = Number(
|
data.closingBalance ??
|
calculateEndBalance(
|
generateForm.openingBalance,
|
generateForm.currentPlan,
|
generateForm.currentActually
|
)
|
);
|
};
|
|
const getCustomerList = () => {
|
listCustomer({ current: -1, size: -1, type: 0 }).then((res) => {
|
if (res.code === 200) {
|
customerList.value = res.data?.records || [];
|
}
|
});
|
};
|
|
const normalizeSalesRows = (list) => {
|
const rows = Array.isArray(list) ? list : [];
|
return rows.map((item, index) => {
|
const type = Number(item.type);
|
return {
|
id: item.id ?? `detail-${index}`,
|
accountStatementId: item.accountStatementId,
|
occurrenceDate: item.occurrenceDate ?? "",
|
receiptNumber: item.receiptNumber ?? "",
|
type,
|
typeLabel: getDetailTypeLabel(type),
|
amount: Math.abs(Number(item.amount ?? 0)),
|
remark: item.remark ?? "",
|
};
|
});
|
};
|
|
const selectAllSalesRows = (keepApiSummary = false) => {
|
nextTick(() => {
|
const table = salesTableRef.value;
|
if (!table) return;
|
table.clearSelection();
|
salesData.value.forEach((row) => table.toggleRowSelection(row, true));
|
selectedSales.value = [...salesData.value];
|
if (!keepApiSummary) {
|
calculateSummary();
|
}
|
});
|
};
|
|
const isNumericId = (id) => id !== undefined && id !== null && id !== "" && /^\d+$/.test(String(id));
|
|
const buildFilterParams = (params = {}) => {
|
const result = { ...params, accountType: ACCOUNT_TYPE_RECEIVABLE };
|
if (filters.customerId) {
|
result.customerId = filters.customerId;
|
}
|
if (filters.startMonth && filters.endMonth && filters.startMonth === filters.endMonth) {
|
result.statementMonth = filters.startMonth;
|
} else if (filters.startMonth) {
|
result.startMonth = filters.startMonth;
|
}
|
if (filters.endMonth && filters.startMonth !== filters.endMonth) {
|
result.endMonth = filters.endMonth;
|
}
|
return result;
|
};
|
|
const buildListParams = () =>
|
buildFilterParams({
|
current: pagination.currentPage,
|
size: pagination.pageSize,
|
});
|
|
const buildExportParams = () => buildFilterParams({});
|
|
const buildDetailSubmitItem = (row) => {
|
const item = {
|
occurrenceDate: row.occurrenceDate,
|
receiptNumber: row.receiptNumber,
|
type: row.type,
|
amount: row.amount,
|
remark: row.remark ?? "",
|
};
|
if (isNumericId(row.id)) {
|
item.id = Number(row.id);
|
}
|
if (row.accountStatementId) {
|
item.accountStatementId = row.accountStatementId;
|
}
|
return item;
|
};
|
|
const buildAddPayload = () => ({
|
customerId: generateForm.customerId,
|
customerName: generateForm.customerName,
|
statementMonth: generateForm.statementMonth,
|
accountType: ACCOUNT_TYPE_RECEIVABLE,
|
statementNumber: "",
|
openingBalance: generateForm.openingBalance,
|
currentPlan: generateForm.currentPlan,
|
currentActually: generateForm.currentActually,
|
closingBalance: generateForm.closingBalance,
|
accountStatementDetails: selectedSales.value.map(buildDetailSubmitItem),
|
});
|
|
const formatMoney = (value) => {
|
if (value === undefined || value === null) return "0.00";
|
return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
};
|
|
const getTableData = () => {
|
tableLoading.value = true;
|
listPageAccountStatement(buildListParams())
|
.then((res) => {
|
const ok = res.code === 200 || res.code === 0;
|
if (ok && res.data) {
|
pagination.total = res.data.total ?? 0;
|
dataList.value = res.data.records ?? [];
|
} else {
|
ElMessage.error(res.msg || "查询失败");
|
dataList.value = [];
|
pagination.total = 0;
|
}
|
})
|
.catch(() => {
|
dataList.value = [];
|
pagination.total = 0;
|
ElMessage.error("查询失败");
|
})
|
.finally(() => {
|
tableLoading.value = false;
|
});
|
};
|
|
const resetFilters = () => {
|
filters.customerId = "";
|
filters.startMonth = "";
|
filters.endMonth = "";
|
pagination.currentPage = 1;
|
getTableData();
|
};
|
|
const changePage = ({ current, size }) => {
|
pagination.currentPage = current;
|
pagination.pageSize = size;
|
getTableData();
|
};
|
|
const generateStatement = () => {
|
generateForm.customerId = "";
|
generateForm.customerName = "";
|
generateForm.statementMonth = "";
|
generateForm.openingBalance = 0;
|
generateForm.currentPlan = 0;
|
generateForm.currentActually = 0;
|
generateForm.closingBalance = 0;
|
statementDetailLoaded.value = false;
|
salesData.value = [];
|
selectedSales.value = [];
|
generateDialogVisible.value = true;
|
};
|
|
const onCustomerChange = (customerId) => {
|
const customer = customerList.value.find((item) => item.id === customerId);
|
generateForm.customerName = customer?.customerName ?? "";
|
loadSalesData();
|
};
|
|
const onStatementMonthChange = () => {
|
loadSalesData();
|
};
|
|
const loadSalesData = () => {
|
if (!generateForm.customerId || !generateForm.statementMonth) {
|
salesData.value = [];
|
selectedSales.value = [];
|
statementDetailLoaded.value = false;
|
generateForm.openingBalance = 0;
|
generateForm.currentPlan = 0;
|
generateForm.currentActually = 0;
|
generateForm.closingBalance = 0;
|
return;
|
}
|
|
salesLoading.value = true;
|
selectedSales.value = [];
|
statementDetailLoaded.value = false;
|
|
getAccountStatementDetailsByMonth({
|
accountType: ACCOUNT_TYPE_RECEIVABLE,
|
customerId: generateForm.customerId,
|
statementMonth: generateForm.statementMonth,
|
})
|
.then((res) => {
|
if (res.code === 200) {
|
const data = res.data ?? {};
|
const details = data.accountStatementDetails;
|
const list = Array.isArray(details) ? details : [];
|
salesData.value = normalizeSalesRows(list);
|
applyStatementSummary(data);
|
statementDetailLoaded.value = true;
|
|
if (salesData.value.length > 0) {
|
selectAllSalesRows(true);
|
}
|
} else {
|
salesData.value = [];
|
statementDetailLoaded.value = false;
|
ElMessage.error(res.msg || "查询对账明细失败");
|
}
|
})
|
.catch(() => {
|
salesData.value = [];
|
statementDetailLoaded.value = false;
|
ElMessage.error("查询对账明细失败");
|
})
|
.finally(() => {
|
salesLoading.value = false;
|
});
|
};
|
|
const calculateSummary = () => {
|
let receivable = 0;
|
let receipt = 0;
|
|
selectedSales.value.forEach((item) => {
|
if (item.type === 1) {
|
receivable += item.amount;
|
} else if (item.type === 5) {
|
receivable -= item.amount;
|
} else if (item.type === 3) {
|
receipt += item.amount;
|
}
|
});
|
|
generateForm.currentPlan = receivable;
|
generateForm.currentActually = receipt;
|
generateForm.closingBalance = calculateEndBalance(
|
generateForm.openingBalance,
|
generateForm.currentPlan,
|
generateForm.currentActually
|
);
|
};
|
|
const handleSalesSelectionChange = (selection) => {
|
selectedSales.value = selection;
|
calculateSummary();
|
};
|
|
const confirmGenerate = () => {
|
if (!canGenerate.value) return;
|
submitLoading.value = true;
|
addAccountStatement(buildAddPayload())
|
.then((res) => {
|
if (res.code === 200) {
|
generateDialogVisible.value = false;
|
ElMessage.success("对账单生成成功");
|
pagination.currentPage = 1;
|
getTableData();
|
} else {
|
ElMessage.error(res.msg || "生成失败");
|
}
|
})
|
.catch(() => {
|
ElMessage.error("生成失败");
|
})
|
.finally(() => {
|
submitLoading.value = false;
|
});
|
};
|
|
const handleDelete = (row) => {
|
ElMessageBox.confirm(`确认删除对账单「${row.statementNumber || row.id}」吗?`, "提示", {
|
confirmButtonText: "确定",
|
cancelButtonText: "取消",
|
type: "warning",
|
}).then(() => {
|
deleteAccountStatement([row.id])
|
.then((res) => {
|
if (res.code === 200) {
|
ElMessage.success("删除成功");
|
getTableData();
|
} else {
|
ElMessage.error(res.msg || "删除失败");
|
}
|
})
|
.catch(() => {
|
ElMessage.error("删除失败");
|
});
|
});
|
};
|
|
const buildDetailTableFromApi = (data, statementMonth) => {
|
const details = Array.isArray(data.accountStatementDetails) ? data.accountStatementDetails : [];
|
let runningBalance = Number(data.openingBalance ?? 0);
|
const rows = [
|
{
|
date: statementMonth ?? "",
|
type: "期初",
|
code: "-",
|
debit: 0,
|
credit: 0,
|
balance: runningBalance,
|
remark: "期初余额",
|
},
|
];
|
|
details.forEach((item) => {
|
const amount = Math.abs(Number(item.amount ?? 0));
|
const type = Number(item.type);
|
let debit = 0;
|
let credit = 0;
|
|
if (type === 1) {
|
debit = amount;
|
runningBalance += amount;
|
} else if (type === 3 || type === 5) {
|
credit = amount;
|
runningBalance -= amount;
|
}
|
|
rows.push({
|
date: item.occurrenceDate ?? "",
|
type: getDetailTypeLabel(type),
|
code: item.receiptNumber ?? "",
|
debit,
|
credit,
|
balance: runningBalance,
|
remark: item.remark ?? "",
|
});
|
});
|
|
return rows;
|
};
|
|
const viewDetail = (row) => {
|
if (!row.customerId || !row.statementMonth) {
|
ElMessage.warning("缺少客户或对账月份,无法查询明细");
|
return;
|
}
|
|
currentCustomer.value = row.customerName ?? "";
|
currentPeriod.value = row.statementMonth ?? "";
|
detailData.value = [];
|
detailDialogVisible.value = true;
|
detailLoading.value = true;
|
|
getAccountStatementDetailsByMonth({
|
accountType: ACCOUNT_TYPE_RECEIVABLE,
|
customerId: row.customerId,
|
statementMonth: row.statementMonth,
|
})
|
.then((res) => {
|
if (res.code === 200) {
|
detailData.value = buildDetailTableFromApi(res.data ?? {}, row.statementMonth);
|
} else {
|
ElMessage.error(res.msg || "查询明细失败");
|
detailDialogVisible.value = false;
|
}
|
})
|
.catch(() => {
|
ElMessage.error("查询明细失败");
|
detailDialogVisible.value = false;
|
})
|
.finally(() => {
|
detailLoading.value = false;
|
});
|
};
|
|
const printStatement = (row) => {
|
ElMessage.info(`打印对账单: ${row.statementNumber}`);
|
};
|
|
const printDetail = () => {
|
ElMessage.info("打印明细");
|
};
|
|
const handleOut = () => {
|
const params = buildExportParams();
|
proxy.download("/accountStatement/exportAccountStatement", params, `应收对账单_${Date.now()}.xlsx`);
|
};
|
|
onMounted(() => {
|
getCustomerList();
|
getTableData();
|
});
|
</script>
|
|
<style lang="scss" scoped>
|
.actions {
|
display: flex;
|
justify-content: space-between;
|
margin-bottom: 15px;
|
}
|
|
.text-success {
|
color: #67c23a;
|
}
|
|
.text-danger {
|
color: #f56c6c;
|
}
|
|
.text-primary {
|
color: #409eff;
|
}
|
|
.statement-header {
|
text-align: center;
|
margin-bottom: 20px;
|
h3 {
|
margin: 0 0 10px 0;
|
}
|
p {
|
color: #909399;
|
margin: 0;
|
}
|
}
|
|
.sales-section {
|
margin-top: 20px;
|
|
.section-title {
|
font-size: 16px;
|
font-weight: bold;
|
margin-bottom: 15px;
|
padding-left: 10px;
|
border-left: 4px solid #409eff;
|
}
|
}
|
|
.summary-row {
|
display: flex;
|
justify-content: space-around;
|
padding: 15px;
|
background-color: #f5f7fa;
|
border-radius: 4px;
|
margin-top: 15px;
|
|
span {
|
font-size: 14px;
|
|
strong {
|
font-size: 16px;
|
margin-left: 5px;
|
}
|
}
|
}
|
|
.empty-tip {
|
margin-top: 30px;
|
}
|
</style>
|