2026-06-03 17:46:01 +08:00

737 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>
<div class="finance-layout-page">
<div class="page-top">
<div>
<h2>
利润概览
<em>{{ financialOverview.is_business_owner ? '业主机构' : '分销机构' }}</em>
</h2>
<p>
核算机构<b>{{ displayValue(financialOverview.accounting_orgname) }}</b>
<span>编号 {{ displayValue(financialOverview.accounting_orgid) }}</span>
</p>
<div class="chips">
<span>含下级分销客户<b>{{ customerScope.include_sub_reseller_customers ? '是' : '否' }}</b></span>
<span>下级分销机构<b>{{ formatNumber(customerScope.descendant_reseller_count) }}</b> </span>
<span>账单总数<b>{{ formatNumber(financialOverview.bill_count) }}</b></span>
<span>仅已入账<b>{{ filters.only_accounted ? '是' : '否' }}</b></span>
<span>数据截断<b>{{ financialOverview.truncated ? '是' : '否' }}</b></span>
</div>
</div>
<!-- <div class="view-meta">
查看计费统计
</div> -->
</div>
<div class="summary-grid">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.type">
<i></i>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<p>{{ card.desc }}</p>
</div>
</div>
<el-card shadow="never" class="panel">
<div class="panel-head">
<h3>收益构成按客户来源</h3>
</div>
<el-table
v-loading="loading"
:data="sourceRows"
size="small"
class="finance-table"
show-summary
:summary-method="getSourceSummary"
>
<el-table-column label="客户来源" min-width="180">
<template slot-scope="{ row }">
<span class="source-cell">
<i class="dot" :class="row.type"></i>{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="销售额" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column label="利润" align="right" min-width="140">
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column label="利润率" align="right" width="120">
<template slot-scope="{ row }">
<span :class="amountClass(row.margin)">{{ percent(row.margin) }}</span>
</template>
</el-table-column>
<el-table-column :label="settleLabel" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column label="账单数" align="right" width="110">
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="panel">
<div class="panel-head">
<h3>供应商 / 产品明细</h3>
</div>
<el-table
v-loading="loading"
:data="filteredProductRows"
size="small"
class="finance-table product-table"
show-summary
:summary-method="getProductSummary"
@row-click="openProductDetail"
>
<el-table-column label="供应商" min-width="170" show-overflow-tooltip>
<template slot-scope="{ row }">
{{ row.providerName }}
<el-tag v-if="row.hasSubReseller" size="mini" type="primary" effect="plain">含下级</el-tag>
</template>
</el-table-column>
<el-table-column prop="productName" label="产品" min-width="190" show-overflow-tooltip />
<el-table-column prop="sales_total" label="销售额" align="right" min-width="140" sortable>
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column prop="profit_total" label="利润" align="right" min-width="140" sortable>
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column prop="margin" label="利润率" align="right" width="120" sortable>
<template slot-scope="{ row }">
<span :class="amountClass(row.margin)">{{ percent(row.margin) }}</span>
</template>
</el-table-column>
<el-table-column prop="settle_upstream_total" :label="settleLabel" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column prop="bill_count" label="账单数" align="right" width="110" sortable>
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
<p class="note"> {{ productRows.length }} 个供应商/产品组合</p>
</el-card>
<el-dialog
:visible.sync="detailVisible"
width="680px"
custom-class="finance-detail-dialog"
append-to-body
:show-close="false"
>
<div v-if="currentProduct" class="detail-card">
<button class="detail-close" @click="detailVisible = false">×</button>
<div class="detail-head">
<h3>{{ currentProduct.providerName }} · {{ currentProduct.productName }}</h3>
<p>
销售额 {{ money(currentProduct.sales_total) }} ·
利润率 {{ percent(currentProduct.margin) }}
</p>
</div>
<el-table :data="detailRows" size="small" class="detail-table">
<el-table-column label="客户来源" min-width="160">
<template slot-scope="{ row }">
<span class="source-cell">
<i class="dot" :class="row.type"></i>{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="销售额" align="right" min-width="150">
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column label="利润" align="right" min-width="150">
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column :label="settleLabel" align="right" min-width="150">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column label="账单数" align="right" width="110">
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</div>
</template>
<script>
import { reqFinancialOverview } from '@/api/FinancialOverview/FinancialOverview'
export default {
name: 'FinancialOverview',
data() {
return {
loading: false,
keyword: '',
orgid: '',
financialOverview: {},
detailVisible: false,
currentProduct: null
}
},
computed: {
// 客户范围信息:是否包含下级分销客户、下级分销数量等。
customerScope() {
return this.financialOverview.customer_scope || {}
},
// 接口筛选条件展示,例如是否仅统计已入账账单。
filters() {
return this.financialOverview.filters || {}
},
// 统计周期,接口为空时页面展示“全部周期”。
period() {
return this.financialOverview.period || {}
},
// 财务汇总数据,包含直客、下级分销和总计。
totals() {
return this.financialOverview.totals || {}
},
// 总计数据用于顶部卡片和表格合计行。
grandTotal() {
return this.totals.grand_total || {}
},
// 上游结算字段文案由接口返回,业主机构时通常为“应付供应商”。
settleLabel() {
return this.financialOverview.settle_upstream_label || '应付供应商'
},
// 格式化统计周期文案。
periodText() {
if (this.period.start_date || this.period.end_date) {
return `${this.period.start_date || '-'}${this.period.end_date || '-'}`
}
return '全部周期'
},
// 顶部四个核心指标卡片。
summaryCards() {
return [
{
label: '销售总额',
value: this.money(this.grandTotal.sales_total),
desc: 'grand_total · sales_total',
type: 'blue'
},
{
label: '利润总额',
value: this.money(this.grandTotal.profit_total),
desc: `利润率 ${this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total))}`,
type: 'green'
},
{
label: this.settleLabel,
value: this.money(this.grandTotal.settle_upstream_total),
desc: 'settle_upstream_total',
type: 'amber'
},
{
label: '账单总数',
value: this.formatNumber(this.grandTotal.bill_count),
desc: `${this.formatNumber(this.totals.from_sub_resellers && this.totals.from_sub_resellers.bill_count)} 笔下级分销`,
type: 'purple'
}
]
},
// 收益构成表:按直接客户和下级分销客户拆分。
sourceRows() {
return [
this.buildSourceRow('直接客户', this.totals.direct_customers, 'blue-dot'),
this.buildSourceRow('下级分销客户', this.totals.from_sub_resellers, 'purple-dot')
]
},
// 供应商/产品明细表:把接口 by_provider_product 结构扁平化,便于 el-table 渲染。
productRows() {
return (this.financialOverview.by_provider_product || []).map((item, index) => {
const total = item.total || {}
const direct = item.direct_customers || {}
const sub = item.from_sub_resellers || {}
return {
index,
providerName: this.displayValue(item.provider && item.provider.name),
productName: this.displayValue(item.product && item.product.name),
direct_customers: direct,
from_sub_resellers: sub,
sales_total: Number(total.sales_total || 0),
profit_total: Number(total.profit_total || 0),
settle_upstream_total: Number(total.settle_upstream_total || 0),
bill_count: Number(total.bill_count || 0),
margin: this.margin(total.profit_total, total.sales_total),
hasSubReseller: Number(sub.bill_count || 0) > 0 || Number(sub.sales_total || 0) !== 0 || Number(sub.profit_total || 0) !== 0
}
})
},
// 根据搜索关键词过滤供应商或产品名称。
filteredProductRows() {
const keyword = this.keyword.trim().toLowerCase()
if (!keyword) return this.productRows
return this.productRows.filter(item => {
return `${item.providerName} ${item.productName}`.toLowerCase().includes(keyword)
})
},
// 明细弹窗数据:展示当前产品的直客、下级分销和合计拆分。
detailRows() {
if (!this.currentProduct) return []
return [
this.buildSourceRow('直接客户', this.currentProduct.direct_customers, 'blue-dot'),
this.buildSourceRow('下级分销客户', this.currentProduct.from_sub_resellers, 'purple-dot'),
this.buildSourceRow('合计', this.currentProduct, 'total-dot')
]
}
},
mounted() {
this.getFinancialOverview()
},
methods: {
// 获取当前核算机构 orgid优先使用路由参数其次使用本地缓存。
getOrgId() {
const orgid = this.$route.query.orgid || sessionStorage.getItem('orgid') || localStorage.getItem('orgid') || ''
this.orgid = orgid
return orgid
},
// 请求财务概览接口,并保存原始返回数据供 computed 统一派生。
async getFinancialOverview() {
this.loading = true
try {
const orgid = this.getOrgId()
const res = await reqFinancialOverview({ accounting_orgid:orgid })
if (res && res.status === true) {
this.financialOverview = res.data || {}
}
} finally {
this.loading = false
}
},
// 统一构造金额行,避免直客、下级分销、合计三类数据重复处理。
buildSourceRow(name, data = {}, type) {
return {
name,
type,
sales_total: Number(data.sales_total || 0),
profit_total: Number(data.profit_total || 0),
settle_upstream_total: Number(data.settle_upstream_total || 0),
bill_count: Number(data.bill_count || 0),
margin: this.margin(data.profit_total, data.sales_total)
}
},
// 收益构成表合计行,取接口 totals.grand_total 保持与后端汇总一致。
getSourceSummary() {
return [
'合计',
this.money(this.grandTotal.sales_total),
this.money(this.grandTotal.profit_total),
this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total)),
this.money(this.grandTotal.settle_upstream_total),
this.formatNumber(this.grandTotal.bill_count)
]
},
// 供应商/产品表合计行,金额同样以接口 grand_total 为准。
getProductSummary() {
return [
'合计',
'',
this.money(this.grandTotal.sales_total),
this.money(this.grandTotal.profit_total),
this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total)),
this.money(this.grandTotal.settle_upstream_total),
this.formatNumber(this.grandTotal.bill_count)
]
},
// 打开单个供应商/产品的拆分详情弹窗。
openProductDetail(row) {
this.currentProduct = row
this.detailVisible = true
},
// 计算利润率:利润 / 销售额。
margin(profit, sales) {
const salesValue = Number(sales || 0)
return salesValue ? (Number(profit || 0) / salesValue) * 100 : 0
},
// 金额格式化,统一显示人民币和两位小数。
money(value) {
return `¥${Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`
},
// 百分比格式化,统一保留一位小数。
percent(value) {
return `${Number(value || 0).toFixed(1)}%`
},
// 数字格式化,补千分位。
formatNumber(value) {
return Number(value || 0).toLocaleString('zh-CN')
},
// 空值兜底,避免供应商或产品名称为空时页面留白。
displayValue(value) {
if (value === undefined || value === null || value === '') return '未命名'
return String(value)
},
// 金额正负颜色:负数标红,非负数标绿。
amountClass(value) {
return Number(value || 0) < 0 ? 'red-text' : 'green-text'
}
}
}
</script>
<style scoped lang="less">
.finance-layout-page {
min-height: 100vh;
padding: 18px 20px 36px;
color: #1f2733;
background: #f4f6fb;
}
.page-top {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
.crumb {
margin-bottom: 8px;
color: #667085;
font-size: 12px;
span {
margin: 0 6px;
color: #c4cad5;
}
}
h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 6px;
font-size: 22px;
line-height: 1;
em {
padding: 2px 9px;
color: #6d5df6;
font-size: 12px;
font-style: normal;
background: #f0edff;
border-radius: 999px;
}
}
p {
margin: 0;
color: #667085;
font-size: 13px;
span {
margin-left: 8px;
}
}
}
.view-meta {
color: #667085;
font-size: 13px;
text-align: right;
small {
display: block;
margin-top: 4px;
font-size: 12px;
}
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
span {
padding: 4px 10px;
color: #667085;
font-size: 12px;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 8px;
}
b {
color: #1f2733;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.summary-card {
position: relative;
min-height: 102px;
padding: 16px 18px 14px;
overflow: hidden;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.06), 0 8px 24px rgba(20, 30, 55, 0.05);
i {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
}
span {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 13px;
}
strong {
display: block;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 7px 0 0;
color: #98a2b3;
font-size: 12px;
}
&.blue {
i { background: #3a6df0; }
strong { color: #3a6df0; }
}
&.green {
i { background: #18a058; }
strong { color: #18a058; }
}
&.amber {
i { background: #d08e18; }
strong { color: #d08e18; }
}
&.purple {
i { background: #7a5af0; }
strong { color: #7a5af0; }
}
}
.panel {
margin-bottom: 14px;
border: 1px solid #e6eaf1;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.05);
.el-card__body {
padding: 14px 18px 10px;
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h3 {
margin: 0;
color: #1f2733;
font-size: 15px;
}
}
.panel-tools {
display: flex;
align-items: center;
gap: 10px;
color: #98a2b3;
font-size: 12px;
.el-input {
width: 220px;
}
}
.finance-table {
th {
padding: 8px 0;
color: #667085;
font-size: 12px;
font-weight: 600;
background: #fafbfe;
}
td {
padding: 7px 0;
color: #273142;
font-size: 12px;
}
.el-table__footer td {
color: #1f2733;
font-weight: 700;
background: #fafbfe;
}
}
.product-table {
.el-table__row {
cursor: pointer;
}
.el-table__row:hover > td {
background: #f0f5ff;
}
}
/deep/ .finance-detail-dialog {
overflow: hidden;
background: #fff;
border-radius: 18px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.detail-card {
position: relative;
padding: 22px 24px 22px;
}
.detail-close {
position: absolute;
top: 18px;
right: 22px;
width: 34px;
height: 34px;
color: #667085;
font-size: 18px;
font-weight: 700;
line-height: 32px;
text-align: center;
background: #f5f7fb;
border: 0;
border-radius: 10px;
cursor: pointer;
}
.detail-head {
padding-right: 48px;
margin-bottom: 18px;
h3 {
margin: 0 0 6px;
color: #1f2733;
font-size: 20px;
font-weight: 700;
}
p {
margin: 0;
color: #667085;
font-size: 13px;
}
}
.detail-table {
/deep/ th {
padding: 12px 0;
color: #667085;
font-size: 14px;
font-weight: 700;
background: #f7f8fb;
}
/deep/ td {
padding: 12px 0;
color: #273142;
font-size: 13px;
}
/deep/ .el-table__row:last-child td {
font-weight: 700;
background: #fafbfe;
}
}
.source-cell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.blue-dot {
background: #3a6df0;
}
.purple-dot {
background: #7a5af0;
}
.total-dot {
background: #1f2733;
}
.green-text {
color: #18a058 !important;
font-weight: 600;
}
.red-text {
color: #d03050 !important;
font-weight: 600;
}
.note {
margin: 10px 0 2px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
@media (max-width: 1100px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.page-top,
.panel-head,
.panel-tools {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>