Commit fee503452094725225b3c4b82207d0070b546993
1 parent
02926d33
feat: 实现KPI数据穿透功能
- 新增kpi-drill-dialog组件,支持科技感弹窗展示数据穿透 - 实现本月成交总额穿透功能,包含: * 每日开单金额和人数趋势图 * 开单金额最高会员、开单次数最多会员统计 * 品项类型雷达图、业绩类型占比饼图、科美类型业绩柱状图 * 本月成交明细列表(支持真实后端分页和筛选) - 新增get-billing-drill-statistics接口,提供整月统计数据 - 优化billing-item-detail-list接口,支持业绩类型和科美类型字段 - 修复LqHytkHytkService时间参数解析问题 - 科美类型统计只显示有设置的数据,过滤未设置项
Showing
7 changed files
with
1737 additions
and
21 deletions
antis-ncc-admin/src/components/kpi-drill-dialog.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <el-dialog :visible.sync="visibleSync" :title="title" :width="dialogWidth" append-to-body | |
| 3 | + custom-class="tech-drill-dialog" :close-on-click-modal="false" @closed="handleClosed"> | |
| 4 | + <div class="drill-header" v-if="type !== 'billing'"> | |
| 5 | + <div class="drill-meta"> | |
| 6 | + <span class="meta-chip"> | |
| 7 | + <i class="el-icon-date"></i> | |
| 8 | + {{ dateRangeText || '全部时间' }} | |
| 9 | + </span> | |
| 10 | + <span class="meta-chip" v-if="storeNamesText"> | |
| 11 | + <i class="el-icon-office-building"></i> | |
| 12 | + {{ storeNamesText }} | |
| 13 | + </span> | |
| 14 | + <span class="meta-chip info" v-if="summary.totalAmount !== null"> | |
| 15 | + <i class="el-icon-collection"></i> | |
| 16 | + 本页金额合计:{{ formatMoney(summary.totalAmount) }} | |
| 17 | + </span> | |
| 18 | + <span class="meta-chip info" v-if="summary.totalCount !== null"> | |
| 19 | + <i class="el-icon-s-data"></i> | |
| 20 | + 本页记录:{{ summary.totalCount }} 条 | |
| 21 | + </span> | |
| 22 | + <span class="meta-chip success" v-if="type === 'net' && extra.refundAmount !== undefined"> | |
| 23 | + <i class="el-icon-coin"></i> | |
| 24 | + 净额=开单-退款:{{ formatMoney((extra.actualAmount || 0) - (extra.refundAmount || 0)) }} | |
| 25 | + </span> | |
| 26 | + <span class="meta-chip warning" v-if="type === 'target' && extra.actualAmount !== undefined"> | |
| 27 | + <i class="el-icon-aim"></i> | |
| 28 | + 本月实际:{{ formatMoney(extra.actualAmount || 0) }} | |
| 29 | + </span> | |
| 30 | + </div> | |
| 31 | + <div class="drill-actions"> | |
| 32 | + <el-select v-model="innerType" size="mini" style="width: 140px" v-if="type === 'net'"> | |
| 33 | + <el-option label="开单明细" value="billing"></el-option> | |
| 34 | + <el-option label="退卡明细" value="refund"></el-option> | |
| 35 | + </el-select> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <!-- 账单穿透专属布局:左右结构 --> | |
| 40 | + <div v-if="type === 'billing'" class="billing-wrapper"> | |
| 41 | + <div class="billing-layout"> | |
| 42 | + <!-- 左侧:趋势 + 列表 --> | |
| 43 | + <div class="billing-left"> | |
| 44 | + <div class="chart-card trend-card"> | |
| 45 | + <div class="chart-title"> | |
| 46 | + <i class="el-icon-date"></i> | |
| 47 | + 每日开单金额 & 人数 | |
| 48 | + </div> | |
| 49 | + <div ref="billingTrendChart" class="chart-mini"></div> | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + <div class="table-card"> | |
| 53 | + <div class="table-header"> | |
| 54 | + <div class="table-title"> | |
| 55 | + <i class="el-icon-document"></i> | |
| 56 | + 本月成交明细 | |
| 57 | + </div> | |
| 58 | + <div class="list-filters inline"> | |
| 59 | + <el-input v-model="listFilter.keyword" size="mini" placeholder="搜索会员/单号" | |
| 60 | + style="width: 200px; margin-right: 8px;" clearable @input="applyListFilter" /> | |
| 61 | + <el-select v-model="listFilter.store" size="mini" placeholder="门店" style="width: 180px;" clearable | |
| 62 | + @change="applyListFilter"> | |
| 63 | + <el-option v-for="s in storeOptions" :key="s.id" :label="s.fullName || s.dm || s.name || s.label" | |
| 64 | + :value="s.id" /> | |
| 65 | + </el-select> | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + <el-table v-loading="loading" :data="displayList" size="small" height="650px" border stripe> | |
| 70 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 71 | + :width="col.width" :min-width="col.minWidth"> | |
| 72 | + <template slot-scope="scope"> | |
| 73 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 74 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 75 | + </template> | |
| 76 | + </el-table-column> | |
| 77 | + </el-table> | |
| 78 | + | |
| 79 | + <div class="pagination-bar"> | |
| 80 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 81 | + :total="pagination.total" :current-page="pagination.pageIndex" :page-size="pagination.pageSize" | |
| 82 | + @size-change="handleSizeChange" @current-change="handleCurrentChange" /> | |
| 83 | + </div> | |
| 84 | + </div> | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + <!-- 右侧:关键指标 + 雷达图 --> | |
| 88 | + <div class="billing-right"> | |
| 89 | + <div class="stat-card neon-green compact"> | |
| 90 | + <div class="stat-icon-circle"> | |
| 91 | + <i class="el-icon-trophy"></i> | |
| 92 | + </div> | |
| 93 | + <div class="stat-content"> | |
| 94 | + <div class="stat-title">开单金额最高会员</div> | |
| 95 | + <div class="stat-body"> | |
| 96 | + <div class="highlight text-ellipsis-2"> | |
| 97 | + {{ billingStats.topMemberAmount.name || '无' }} | |
| 98 | + <span class="value-inline">¥{{ formatMoney(billingStats.topMemberAmount.value) }}</span> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <div class="stat-card neon-orange compact"> | |
| 105 | + <div class="stat-icon-circle"> | |
| 106 | + <i class="el-icon-user"></i> | |
| 107 | + </div> | |
| 108 | + <div class="stat-content"> | |
| 109 | + <div class="stat-title">开单次数最多会员</div> | |
| 110 | + <div class="stat-body"> | |
| 111 | + <div class="highlight text-ellipsis-2"> | |
| 112 | + {{ billingStats.topMemberTimes.name || '无' }} | |
| 113 | + <span class="value-inline">{{ billingStats.topMemberTimes.count || 0 }} 次</span> | |
| 114 | + </div> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="chart-card"> | |
| 120 | + <div class="chart-title"> | |
| 121 | + <i class="el-icon-data-analysis"></i> | |
| 122 | + 品项类型雷达图 | |
| 123 | + </div> | |
| 124 | + <div ref="itemTypeRadarChart" class="chart-mini"></div> | |
| 125 | + </div> | |
| 126 | + | |
| 127 | + <div class="chart-card" style="display: none;"> | |
| 128 | + <div class="chart-title"> | |
| 129 | + <i class="el-icon-pie-chart"></i> | |
| 130 | + 业绩类型占比 | |
| 131 | + </div> | |
| 132 | + <div ref="performanceTypePieChart" class="chart-mini"></div> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <div class="chart-card"> | |
| 136 | + <div class="chart-title"> | |
| 137 | + <i class="el-icon-s-data"></i> | |
| 138 | + 科美类型业绩 | |
| 139 | + </div> | |
| 140 | + <div ref="beautyTypeBarChart" class="chart-mini"></div> | |
| 141 | + </div> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <div class="drill-summary" v-if="type !== 'billing' && analysis.length"> | |
| 147 | + <div class="summary-item" v-for="item in analysis" :key="item.name"> | |
| 148 | + <div class="summary-name">{{ item.name }}</div> | |
| 149 | + <div class="summary-value">{{ formatMoney(item.value) }}</div> | |
| 150 | + </div> | |
| 151 | + </div> | |
| 152 | + | |
| 153 | + <!-- 非 billing 类型仍使用通用表格+分页 --> | |
| 154 | + <div v-if="type !== 'billing'"> | |
| 155 | + <el-table v-loading="loading" :data="displayList" size="small" height="480px" border stripe> | |
| 156 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" | |
| 157 | + :min-width="col.minWidth"> | |
| 158 | + <template slot-scope="scope"> | |
| 159 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 160 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 161 | + </template> | |
| 162 | + </el-table-column> | |
| 163 | + </el-table> | |
| 164 | + | |
| 165 | + <div class="pagination-bar"> | |
| 166 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="pagination.total" | |
| 167 | + :current-page="pagination.pageIndex" :page-size="pagination.pageSize" @size-change="handleSizeChange" | |
| 168 | + @current-change="handleCurrentChange" /> | |
| 169 | + </div> | |
| 170 | + </div> | |
| 171 | + </el-dialog> | |
| 172 | +</template> | |
| 173 | + | |
| 174 | +<script> | |
| 175 | +import request from '@/utils/request' | |
| 176 | +import dayjs from 'dayjs' | |
| 177 | + | |
| 178 | +export default { | |
| 179 | + name: 'KpiDrillDialog', | |
| 180 | + props: { | |
| 181 | + visible: { type: Boolean, default: false }, | |
| 182 | + title: { type: String, default: '数据穿透' }, | |
| 183 | + type: { type: String, default: 'billing' }, // billing | consume | refund | target | net | tk | |
| 184 | + filters: { | |
| 185 | + type: Object, | |
| 186 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 187 | + }, | |
| 188 | + extra: { type: Object, default: () => ({}) }, | |
| 189 | + storeOptions: { type: Array, default: () => [] } | |
| 190 | + }, | |
| 191 | + data() { | |
| 192 | + return { | |
| 193 | + loading: false, | |
| 194 | + list: [], | |
| 195 | + displayList: [], | |
| 196 | + billingStats: { | |
| 197 | + itemTypeTop: [], | |
| 198 | + topMemberAmount: { name: '', value: 0 }, | |
| 199 | + topMemberTimes: { name: '', count: 0 }, | |
| 200 | + debtTotal: 0, | |
| 201 | + topDebtMember: { name: '', value: 0 } | |
| 202 | + }, | |
| 203 | + summary: { totalAmount: null, totalCount: null }, | |
| 204 | + analysis: [], | |
| 205 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 206 | + visibleSync: false, | |
| 207 | + innerType: this.type, // 用于净额切换开单/退卡 | |
| 208 | + listFilter: { | |
| 209 | + keyword: '', | |
| 210 | + store: '' | |
| 211 | + } | |
| 212 | + } | |
| 213 | + }, | |
| 214 | + watch: { | |
| 215 | + visible: { | |
| 216 | + immediate: true, | |
| 217 | + handler(v) { | |
| 218 | + this.visibleSync = v | |
| 219 | + if (v) { | |
| 220 | + this.resetAndFetch() | |
| 221 | + } | |
| 222 | + } | |
| 223 | + }, | |
| 224 | + type(newType) { | |
| 225 | + this.innerType = newType === 'net' ? 'billing' : newType | |
| 226 | + if (this.visibleSync) this.resetAndFetch() | |
| 227 | + }, | |
| 228 | + filters: { | |
| 229 | + deep: true, | |
| 230 | + handler() { | |
| 231 | + if (this.visibleSync) this.resetAndFetch() | |
| 232 | + } | |
| 233 | + }, | |
| 234 | + innerType() { | |
| 235 | + if (this.visibleSync) this.resetAndFetch() | |
| 236 | + } | |
| 237 | + }, | |
| 238 | + computed: { | |
| 239 | + dialogWidth() { | |
| 240 | + return '1360px' | |
| 241 | + }, | |
| 242 | + dateRangeText() { | |
| 243 | + const { startTime, endTime } = this.filters || {} | |
| 244 | + if (!startTime && !endTime) return '' | |
| 245 | + const fmt = v => dayjs(v).format('YYYY-MM-DD') | |
| 246 | + return `${fmt(startTime)} ~ ${fmt(endTime)}` | |
| 247 | + }, | |
| 248 | + storeNamesText() { | |
| 249 | + if (!this.filters || !this.filters.storeIds || this.filters.storeIds.length === 0) return '' | |
| 250 | + if (!this.storeOptions || this.storeOptions.length === 0) return '' | |
| 251 | + const names = this.filters.storeIds | |
| 252 | + .map(id => { | |
| 253 | + const hit = this.storeOptions.find(s => s.id === id) | |
| 254 | + return hit ? (hit.fullName || hit.dm || hit.name || hit.label) : id | |
| 255 | + }) | |
| 256 | + .filter(Boolean) | |
| 257 | + return names.join(' / ') | |
| 258 | + }, | |
| 259 | + columns() { | |
| 260 | + const base = { | |
| 261 | + billing: [ | |
| 262 | + { prop: 'billingTime', label: '开单时间', minWidth: 120 }, | |
| 263 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 264 | + { prop: 'memberName', label: '会员', minWidth: 120 }, | |
| 265 | + { prop: 'itemName', label: '品项', minWidth: 140 }, | |
| 266 | + { prop: 'itemType', label: '品项类型', minWidth: 120 }, | |
| 267 | + { prop: 'projectNumber', label: '项目数', width: 90 }, | |
| 268 | + { prop: 'actualPrice', label: '实付金额', width: 110, type: 'money' }, | |
| 269 | + { prop: 'sourceType', label: '来源类型', minWidth: 110 }, | |
| 270 | + { prop: 'performanceType', label: '业绩类型', minWidth: 110 }, | |
| 271 | + { prop: 'beautyType', label: '科美类型', minWidth: 110 } | |
| 272 | + ], | |
| 273 | + consume: [ | |
| 274 | + { prop: 'consumeTime', label: '耗卡时间', minWidth: 120 }, | |
| 275 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 276 | + { prop: 'memberName', label: '会员', minWidth: 120 }, | |
| 277 | + { prop: 'itemName', label: '品项', minWidth: 140 }, | |
| 278 | + { prop: 'itemType', label: '品项类型', minWidth: 120 }, | |
| 279 | + { prop: 'projectNumber', label: '项目数', width: 90 }, | |
| 280 | + { prop: 'totalPrice', label: '消耗金额', width: 110, type: 'money' } | |
| 281 | + ], | |
| 282 | + refund: [ | |
| 283 | + { prop: 'tksj', label: '退卡时间', minWidth: 120 }, | |
| 284 | + { prop: 'mdmc', label: '门店', minWidth: 120 }, | |
| 285 | + { prop: 'hymc', label: '会员', minWidth: 120 }, | |
| 286 | + { prop: 'gklx', label: '顾客类型', minWidth: 100 }, | |
| 287 | + { prop: 'tkje', label: '退款金额', width: 110, type: 'money' }, | |
| 288 | + { prop: 'tkyy', label: '退款原因', minWidth: 140 } | |
| 289 | + ], | |
| 290 | + target: [ | |
| 291 | + { prop: 'storeName', label: '门店', minWidth: 160 }, | |
| 292 | + { prop: 'monthText', label: '月份', width: 90 }, | |
| 293 | + { prop: 'storeTarget', label: '门店业绩目标', width: 140, type: 'money' }, | |
| 294 | + { prop: 'actualBilling', label: '开单业绩', width: 120, type: 'money' }, | |
| 295 | + { prop: 'refundAmount', label: '退卡业绩', width: 120, type: 'money' }, | |
| 296 | + { prop: 'netBilling', label: '净业绩', width: 120, type: 'money' }, | |
| 297 | + { prop: 'achieved', label: '是否达成', width: 100 } | |
| 298 | + ], | |
| 299 | + tk: [ | |
| 300 | + { prop: 'expansionTime', label: '拓客时间', minWidth: 120 }, | |
| 301 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 302 | + { prop: 'customerName', label: '客户', minWidth: 120 }, | |
| 303 | + { prop: 'teamName', label: '团队', minWidth: 110 }, | |
| 304 | + { prop: 'eventName', label: '活动', minWidth: 110 }, | |
| 305 | + { prop: 'buyNumber', label: '到店/成交', width: 110 } | |
| 306 | + ] | |
| 307 | + } | |
| 308 | + if (this.innerType === 'billing' && this.type === 'net') return base.billing | |
| 309 | + return base[this.innerType] || base.billing | |
| 310 | + } | |
| 311 | + }, | |
| 312 | + methods: { | |
| 313 | + handleClosed() { | |
| 314 | + this.$emit('update:visible', false) | |
| 315 | + this.$emit('closed') | |
| 316 | + }, | |
| 317 | + resetAndFetch() { | |
| 318 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 319 | + this.fetchData() | |
| 320 | + }, | |
| 321 | + handleSizeChange(size) { | |
| 322 | + this.pagination.pageSize = size | |
| 323 | + this.pagination.pageIndex = 1 // 改变页大小时重置到第一页 | |
| 324 | + this.fetchData() | |
| 325 | + }, | |
| 326 | + handleCurrentChange(page) { | |
| 327 | + this.pagination.pageIndex = page | |
| 328 | + this.fetchData() | |
| 329 | + }, | |
| 330 | + formatMoney(v) { | |
| 331 | + const num = Number(v || 0) | |
| 332 | + return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) | |
| 333 | + }, | |
| 334 | + buildDateRange() { | |
| 335 | + const { startTime, endTime } = this.filters || {} | |
| 336 | + if (!startTime || !endTime) return null | |
| 337 | + const start = dayjs(startTime).format('YYYY-MM-DD') | |
| 338 | + const end = dayjs(endTime).format('YYYY-MM-DD') | |
| 339 | + // 后端部分接口(如退卡、拓客)可接受时间戳,这里同时提供毫秒时间戳 | |
| 340 | + const startTs = dayjs(`${start} 00:00:00`).valueOf() | |
| 341 | + const endTs = dayjs(`${end} 23:59:59`).valueOf() | |
| 342 | + return { start, end, startTs, endTs } | |
| 343 | + }, | |
| 344 | + async fetchData() { | |
| 345 | + this.loading = true | |
| 346 | + try { | |
| 347 | + const range = this.buildDateRange() | |
| 348 | + const storeId = (this.filters && this.filters.storeIds && this.filters.storeIds.length === 1) ? this.filters.storeIds[0] : undefined | |
| 349 | + let url = '' | |
| 350 | + let method = 'GET' | |
| 351 | + let data = { currentPage: this.pagination.pageIndex, pageSize: this.pagination.pageSize } | |
| 352 | + | |
| 353 | + const activeType = this.type === 'net' ? this.innerType : this.type | |
| 354 | + if (activeType === 'billing') { | |
| 355 | + // 1)列表使用真实的后端分页 | |
| 356 | + url = '/api/Extend/LqKdKdjlb/billing-item-detail-list' | |
| 357 | + data.currentPage = this.pagination.pageIndex | |
| 358 | + data.pageSize = this.pagination.pageSize | |
| 359 | + if (range) { | |
| 360 | + data.startTime = `${range.start} 00:00:00` | |
| 361 | + data.endTime = `${range.end} 23:59:59` | |
| 362 | + } | |
| 363 | + // 传递筛选条件到后端 | |
| 364 | + if (this.listFilter.keyword) { | |
| 365 | + const keyword = this.listFilter.keyword.trim() | |
| 366 | + // 如果看起来像ID(长度较长且主要是字母数字),则作为开单ID查询,否则作为会员名称查询 | |
| 367 | + if (keyword.length > 10 && /^[A-Za-z0-9]+$/.test(keyword)) { | |
| 368 | + data.BillingId = keyword | |
| 369 | + } else { | |
| 370 | + data.MemberName = keyword | |
| 371 | + } | |
| 372 | + } | |
| 373 | + if (this.listFilter.store) { | |
| 374 | + data.StoreId = this.listFilter.store | |
| 375 | + } else if (storeId) { | |
| 376 | + data.StoreId = storeId | |
| 377 | + } | |
| 378 | + | |
| 379 | + // 2)整月统计从新的报表接口获取,避免受列表分页影响 | |
| 380 | + const month = this.filters && this.filters.month | |
| 381 | + ? this.filters.month.toString() | |
| 382 | + : (range ? dayjs(range.start).format('YYYYMM') : dayjs().format('YYYYMM')) | |
| 383 | + const statsRes = await request({ | |
| 384 | + url: '/api/Extend/LqReport/get-billing-drill-statistics', | |
| 385 | + method: 'POST', | |
| 386 | + data: { | |
| 387 | + statisticsMonth: month, | |
| 388 | + storeIds: this.filters && this.filters.storeIds ? this.filters.storeIds : [] | |
| 389 | + } | |
| 390 | + }) | |
| 391 | + this.applyBillingStatistics(statsRes && statsRes.data) | |
| 392 | + } else if (activeType === 'consume') { | |
| 393 | + url = '/api/Extend/LqXhHyhk/consume-item-detail-list' | |
| 394 | + if (range) { | |
| 395 | + data.startTime = `${range.start} 00:00:00` | |
| 396 | + data.endTime = `${range.end} 23:59:59` | |
| 397 | + } | |
| 398 | + if (storeId) data.storeId = storeId | |
| 399 | + } else if (activeType === 'refund') { | |
| 400 | + url = '/api/Extend/LqHytkHytk' | |
| 401 | + if (range) { | |
| 402 | + data.tksj = `${range.startTs},${range.endTs}` | |
| 403 | + } | |
| 404 | + if (storeId) data.md = storeId | |
| 405 | + } else if (activeType === 'target') { | |
| 406 | + url = '/api/Extend/LqMdTarget' | |
| 407 | + if (this.filters && this.filters.month) data.Month = this.filters.month | |
| 408 | + if (storeId) data.StoreId = storeId | |
| 409 | + } else if (activeType === 'tk') { | |
| 410 | + url = '/api/Extend/LqTkjlb' | |
| 411 | + if (range) data.expansionTime = `${range.startTs},${range.endTs}` | |
| 412 | + if (storeId) data.storeId = storeId | |
| 413 | + } else { | |
| 414 | + url = '/api/Extend/LqKdKdjlb/billing-item-detail-list' | |
| 415 | + } | |
| 416 | + | |
| 417 | + const res = await request({ url, method, data }) | |
| 418 | + await this.handleResponse(res, activeType, range) | |
| 419 | + } catch (error) { | |
| 420 | + console.error('Drill dialog load error:', error) | |
| 421 | + this.$message.error(error.message || '加载数据失败') | |
| 422 | + this.list = [] | |
| 423 | + this.summary = { totalAmount: null, totalCount: null } | |
| 424 | + this.analysis = [] | |
| 425 | + this.displayList = [] | |
| 426 | + } finally { | |
| 427 | + this.loading = false | |
| 428 | + } | |
| 429 | + }, | |
| 430 | + async handleResponse(res, activeType, range) { | |
| 431 | + let list = [] | |
| 432 | + let pagination = this.pagination | |
| 433 | + | |
| 434 | + if (res && res.data) { | |
| 435 | + // 兼容分页结构 | |
| 436 | + if (res.data.list && res.data.pagination) { | |
| 437 | + list = res.data.list | |
| 438 | + pagination = { | |
| 439 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 440 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 441 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 442 | + } | |
| 443 | + } else if (res.data.list && res.data.total) { | |
| 444 | + list = res.data.list | |
| 445 | + pagination = { pageIndex: res.data.pageIndex || 1, pageSize: res.data.pageSize || this.pagination.pageSize, total: res.data.total } | |
| 446 | + } else if (Array.isArray(res.data.list)) { | |
| 447 | + list = res.data.list | |
| 448 | + } else if (Array.isArray(res.data)) { | |
| 449 | + list = res.data | |
| 450 | + } | |
| 451 | + } | |
| 452 | + | |
| 453 | + // 字段适配 | |
| 454 | + if (activeType === 'billing') { | |
| 455 | + list = list.map(i => ({ | |
| 456 | + billingTime: i.billingTime || i.yjsj || i.CreateTime ? dayjs(i.billingTime || i.yjsj || i.CreateTime).format('YYYY-MM-DD HH:mm') : '', | |
| 457 | + storeName: i.storeName || i.djmdmc || i.store, | |
| 458 | + storeId: i.djmd || i.Djmd || i.storeId, | |
| 459 | + memberName: i.memberName || i.kdhyc || i.MemberName, | |
| 460 | + itemName: i.itemName || i.ItemName, | |
| 461 | + itemType: i.itemType || i.ItemType, | |
| 462 | + projectNumber: i.projectNumber || i.ProjectNumber, | |
| 463 | + actualPrice: i.actualPrice || i.ActualPrice || 0, | |
| 464 | + totalPrice: i.zdyj || i.Zdyj || 0, | |
| 465 | + debt: (i.qk || i.Qk || 0) - (i.paidDebt || i.PaidDebt || 0), | |
| 466 | + paidDebt: i.paidDebt || i.PaidDebt || 0, | |
| 467 | + orderNo: i.id || i.Id || '', | |
| 468 | + sourceType: i.sourceType || i.SourceType || '', | |
| 469 | + performanceType: i.performanceType || i.PerformanceType || '', | |
| 470 | + beautyType: i.beautyType || i.BeautyType || '' | |
| 471 | + })) | |
| 472 | + // 使用后端分页,不再使用前端分页 | |
| 473 | + this.list = list | |
| 474 | + this.displayList = list | |
| 475 | + // 更新分页信息 | |
| 476 | + if (res && res.data && res.data.pagination) { | |
| 477 | + this.pagination = { | |
| 478 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || this.pagination.pageIndex, | |
| 479 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 480 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 481 | + } | |
| 482 | + } | |
| 483 | + return | |
| 484 | + } else if (activeType === 'consume') { | |
| 485 | + list = list.map(i => ({ | |
| 486 | + consumeTime: i.consumeTime || i.hksj || i.CreateTime ? dayjs(i.consumeTime || i.hksj || i.CreateTime).format('YYYY-MM-DD HH:mm') : '', | |
| 487 | + storeName: i.storeName || i.mdmc, | |
| 488 | + memberName: i.memberName || i.hymc, | |
| 489 | + itemName: i.itemName || i.pxmc || i.ItemName, | |
| 490 | + itemType: i.itemType || i.ItemType, | |
| 491 | + projectNumber: i.projectNumber || i.projectNumber || i.ProjectNumber, | |
| 492 | + totalPrice: i.totalPrice || i.totalPrice || i.xfje || 0 | |
| 493 | + })) | |
| 494 | + } else if (activeType === 'refund') { | |
| 495 | + // res.data.list 已经是退卡主表字段 | |
| 496 | + } else if (activeType === 'target') { | |
| 497 | + const fmtMonth = v => { | |
| 498 | + const s = (v || '').toString() | |
| 499 | + if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4)}` | |
| 500 | + return s | |
| 501 | + } | |
| 502 | + list = list.map(i => ({ | |
| 503 | + storeId: i.StoreId || i.storeId, | |
| 504 | + storeName: i.StoreName || i.storeName || '', | |
| 505 | + month: i.Month || i.month, | |
| 506 | + monthText: fmtMonth(i.Month || i.month), | |
| 507 | + storeTarget: i.StoreTarget || i.storeTarget || i.F_StoreTarget | |
| 508 | + })) | |
| 509 | + // 追加每个门店的实际开单、退款、净业绩 | |
| 510 | + const timeRange = range || this.buildDateRange() | |
| 511 | + const metrics = await Promise.all(list.map(async row => { | |
| 512 | + if (!row.storeId) return null | |
| 513 | + const resKpi = await request({ | |
| 514 | + url: '/api/Extend/LqReport/get-business-statistics', | |
| 515 | + method: 'POST', | |
| 516 | + data: { | |
| 517 | + startTime: timeRange ? `${timeRange.start} 00:00:00` : null, | |
| 518 | + endTime: timeRange ? `${timeRange.end} 23:59:59` : null, | |
| 519 | + storeIds: [row.storeId] | |
| 520 | + } | |
| 521 | + }) | |
| 522 | + const d = resKpi.data || {} | |
| 523 | + return { | |
| 524 | + storeId: row.storeId, | |
| 525 | + billing: d.TotalBillingAmount || d.billing_amount || 0, | |
| 526 | + refund: d.TotalRefundAmount || d.refund_amount || 0, | |
| 527 | + net: (d.TotalBillingAmount || 0) - (d.TotalRefundAmount || 0) | |
| 528 | + } | |
| 529 | + })) | |
| 530 | + const map = {} | |
| 531 | + metrics.filter(Boolean).forEach(m => { map[m.storeId] = m }) | |
| 532 | + list = list.map(row => { | |
| 533 | + const m = map[row.storeId] || {} | |
| 534 | + const achieved = (row.storeTarget || 0) > 0 ? ((m.net || 0) >= row.storeTarget ? '达成' : '未达成') : '未设置' | |
| 535 | + return { ...row, actualBilling: m.billing || 0, refundAmount: m.refund || 0, netBilling: m.net || 0, achieved } | |
| 536 | + }) | |
| 537 | + } else if (activeType === 'tk') { | |
| 538 | + list = list.map(i => ({ | |
| 539 | + expansionTime: i.expansionTime || i.ExpansionTime ? dayjs(i.expansionTime || i.ExpansionTime).format('YYYY-MM-DD HH:mm') : '', | |
| 540 | + storeName: i.storeName || i.StoreName || i.dm, | |
| 541 | + customerName: i.customerName || i.CustomerName, | |
| 542 | + teamName: i.teamName || i.TeamName, | |
| 543 | + eventName: i.eventName || i.EventName, | |
| 544 | + buyNumber: i.buyNumber || i.BuyNumber | |
| 545 | + })) | |
| 546 | + } | |
| 547 | + | |
| 548 | + this.list = list | |
| 549 | + this.pagination = pagination | |
| 550 | + this.calcSummary(list, activeType) | |
| 551 | + if (activeType !== 'billing') { | |
| 552 | + this.displayList = list | |
| 553 | + } | |
| 554 | + }, | |
| 555 | + calcSummary(list, activeType) { | |
| 556 | + if (!Array.isArray(list)) { | |
| 557 | + this.summary = { totalAmount: null, totalCount: null } | |
| 558 | + this.analysis = [] | |
| 559 | + return | |
| 560 | + } | |
| 561 | + let amountKey = 'actualPrice' | |
| 562 | + if (activeType === 'consume') amountKey = 'totalPrice' | |
| 563 | + if (activeType === 'refund') amountKey = 'tkje' | |
| 564 | + if (activeType === 'target') amountKey = 'storeTarget' | |
| 565 | + | |
| 566 | + const totalAmount = list.reduce((sum, cur) => sum + Number(cur[amountKey] || 0), 0) | |
| 567 | + const totalCount = list.length | |
| 568 | + | |
| 569 | + // 简单分组:按品项类型或门店 | |
| 570 | + const map = {} | |
| 571 | + const groupField = (activeType === 'refund' || activeType === 'target') ? 'mdmc' : 'itemType' | |
| 572 | + list.forEach(item => { | |
| 573 | + const key = item[groupField] || (groupField === 'mdmc' ? item.storeName : '未分类') | |
| 574 | + map[key] = (map[key] || 0) + Number(item[amountKey] || 0) | |
| 575 | + }) | |
| 576 | + const analysis = Object.entries(map) | |
| 577 | + .map(([name, value]) => ({ name, value })) | |
| 578 | + .sort((a, b) => b.value - a.value) | |
| 579 | + .slice(0, 6) | |
| 580 | + | |
| 581 | + this.summary = { totalAmount, totalCount } | |
| 582 | + this.analysis = analysis | |
| 583 | + }, | |
| 584 | + applyBillingStatistics(statistics) { | |
| 585 | + if (!statistics) { | |
| 586 | + this.billingStats = { | |
| 587 | + itemTypeTop: [], | |
| 588 | + topMemberAmount: { name: '', value: 0 }, | |
| 589 | + topMemberTimes: { name: '', count: 0 }, | |
| 590 | + debtTotal: 0, | |
| 591 | + topDebtMember: { name: '', value: 0 } | |
| 592 | + } | |
| 593 | + this.renderBillingTrend([]) | |
| 594 | + this.renderItemTypeRadar([]) | |
| 595 | + this.renderSourceAndTypeCharts(null) | |
| 596 | + return | |
| 597 | + } | |
| 598 | + | |
| 599 | + const trend = (statistics.DailyTrend || []).map(i => ({ | |
| 600 | + date: i.Date, | |
| 601 | + amount: i.Amount, | |
| 602 | + memberCount: i.MemberCount | |
| 603 | + })) | |
| 604 | + this.renderBillingTrend(trend) | |
| 605 | + | |
| 606 | + const itemTypeRadar = statistics.ItemTypeRadar || [] | |
| 607 | + this.renderItemTypeRadar(itemTypeRadar) | |
| 608 | + | |
| 609 | + this.renderSourceAndTypeCharts({ | |
| 610 | + performanceTypeStats: statistics.PerformanceTypeStats || [], | |
| 611 | + beautyTypeStats: statistics.BeautyTypeStats || [] | |
| 612 | + }) | |
| 613 | + | |
| 614 | + const itemTypeTop = (itemTypeRadar || []).sort((a, b) => (b.Amount || 0) - (a.Amount || 0)).slice(0, 3) | |
| 615 | + | |
| 616 | + const memberStats = statistics.MemberStats || {} | |
| 617 | + this.billingStats = { | |
| 618 | + itemTypeTop, | |
| 619 | + topMemberAmount: { | |
| 620 | + name: memberStats.TopAmountMemberName || '—', | |
| 621 | + value: memberStats.TopAmountValue || 0 | |
| 622 | + }, | |
| 623 | + topMemberTimes: { | |
| 624 | + name: memberStats.TopTimesMemberName || '—', | |
| 625 | + count: memberStats.TopTimesCount || 0 | |
| 626 | + }, | |
| 627 | + debtTotal: memberStats.DebtTotal || 0, | |
| 628 | + topDebtMember: { name: '', value: 0 } | |
| 629 | + } | |
| 630 | + }, | |
| 631 | + renderSourceAndTypeCharts(payload) { | |
| 632 | + if (!payload) { | |
| 633 | + ['performanceTypePieChart', 'beautyTypeBarChart'].forEach(refName => { | |
| 634 | + const dom = this.$refs[refName] | |
| 635 | + if (dom) { | |
| 636 | + const chart = echarts.init(dom) | |
| 637 | + chart.clear() | |
| 638 | + } | |
| 639 | + }) | |
| 640 | + return | |
| 641 | + } | |
| 642 | + | |
| 643 | + const perfStats = payload.performanceTypeStats || [] | |
| 644 | + const beautyStats = payload.beautyTypeStats || [] | |
| 645 | + | |
| 646 | + // 业绩类型金额饼图 | |
| 647 | + const perfDom = this.$refs.performanceTypePieChart | |
| 648 | + if (perfDom) { | |
| 649 | + const chart = echarts.init(perfDom) | |
| 650 | + chart.setOption({ | |
| 651 | + tooltip: { | |
| 652 | + trigger: 'item', | |
| 653 | + formatter: params => { | |
| 654 | + const val = Number(params.value || 0) | |
| 655 | + return `${params.name}<br/>金额:¥${this.formatMoney(val)}` | |
| 656 | + } | |
| 657 | + }, | |
| 658 | + legend: { | |
| 659 | + bottom: 0, | |
| 660 | + left: 'center', | |
| 661 | + textStyle: { fontSize: 10, color: '#606266' } | |
| 662 | + }, | |
| 663 | + series: [ | |
| 664 | + { | |
| 665 | + type: 'pie', | |
| 666 | + radius: ['35%', '70%'], | |
| 667 | + avoidLabelOverlap: false, | |
| 668 | + label: { show: false }, | |
| 669 | + labelLine: { show: false }, | |
| 670 | + data: perfStats.map(i => ({ | |
| 671 | + name: i.Name, | |
| 672 | + value: i.Amount | |
| 673 | + })) | |
| 674 | + } | |
| 675 | + ] | |
| 676 | + }) | |
| 677 | + } | |
| 678 | + | |
| 679 | + // 科美类型金额柱状图 | |
| 680 | + const beautyDom = this.$refs.beautyTypeBarChart | |
| 681 | + if (beautyDom) { | |
| 682 | + const chart = echarts.init(beautyDom) | |
| 683 | + chart.setOption({ | |
| 684 | + tooltip: { | |
| 685 | + trigger: 'axis', | |
| 686 | + formatter: params => { | |
| 687 | + const p = Array.isArray(params) ? params[0] : params | |
| 688 | + const val = Number(p.value || 0) | |
| 689 | + return `${p.name}<br/>金额:¥${this.formatMoney(val)}` | |
| 690 | + } | |
| 691 | + }, | |
| 692 | + grid: { left: '8%', right: '4%', top: '10%', bottom: '18%' }, | |
| 693 | + xAxis: { | |
| 694 | + type: 'category', | |
| 695 | + data: beautyStats.map(i => i.Name), | |
| 696 | + axisLabel: { color: '#606266', fontSize: 10 } | |
| 697 | + }, | |
| 698 | + yAxis: { | |
| 699 | + type: 'value', | |
| 700 | + axisLabel: { color: '#606266' }, | |
| 701 | + splitLine: { lineStyle: { color: '#ebeef5' } } | |
| 702 | + }, | |
| 703 | + series: [ | |
| 704 | + { | |
| 705 | + type: 'bar', | |
| 706 | + data: beautyStats.map(i => i.Amount), | |
| 707 | + barWidth: 14, | |
| 708 | + itemStyle: { color: '#909399' } | |
| 709 | + } | |
| 710 | + ] | |
| 711 | + }) | |
| 712 | + } | |
| 713 | + }, | |
| 714 | + computeBillingStats(list, itemList) { | |
| 715 | + const debtTotal = list.reduce((s, cur) => s + Number(cur.debt || 0), 0) | |
| 716 | + | |
| 717 | + const memberAgg = {} | |
| 718 | + list.forEach(cur => { | |
| 719 | + const key = cur.memberName || '未知' | |
| 720 | + memberAgg[key] = memberAgg[key] || { amount: 0, count: 0 } | |
| 721 | + memberAgg[key].amount += Number(cur.actualPrice || 0) | |
| 722 | + memberAgg[key].count += 1 | |
| 723 | + }) | |
| 724 | + const topAmount = Object.entries(memberAgg).map(([k, v]) => ({ name: k, value: v.amount })) | |
| 725 | + .sort((a, b) => b.value - a.value)[0] || { name: '无', value: 0 } | |
| 726 | + const topTimes = Object.entries(memberAgg).map(([k, v]) => ({ name: k, count: v.count })) | |
| 727 | + .sort((a, b) => b.count - a.count)[0] || { name: '无', count: 0 } | |
| 728 | + | |
| 729 | + const itemTypeMap = {} | |
| 730 | + itemList.forEach(i => { | |
| 731 | + const key = i.itemType || '未分类' | |
| 732 | + itemTypeMap[key] = (itemTypeMap[key] || 0) + Number(i.actualPrice || 0) | |
| 733 | + }) | |
| 734 | + let itemTypeArr = Object.entries(itemTypeMap).map(([name, value]) => ({ name, value })) | |
| 735 | + .sort((a, b) => b.value - a.value) | |
| 736 | + const topN = 6 | |
| 737 | + const topList = itemTypeArr.slice(0, topN) | |
| 738 | + if (itemTypeArr.length > topN) { | |
| 739 | + const otherSum = itemTypeArr.slice(topN).reduce((s, cur) => s + cur.value, 0) | |
| 740 | + topList.push({ name: '其他', value: otherSum }) | |
| 741 | + } | |
| 742 | + const itemTypeTop = topList.slice(0, 3) | |
| 743 | + | |
| 744 | + const trendMap = {} | |
| 745 | + list.forEach(cur => { | |
| 746 | + const day = (cur.billingTime || '').slice(0, 10) | |
| 747 | + if (!day) return | |
| 748 | + if (!trendMap[day]) trendMap[day] = { amount: 0, members: new Set() } | |
| 749 | + trendMap[day].amount += Number(cur.actualPrice || 0) | |
| 750 | + if (cur.memberName) trendMap[day].members.add(cur.memberName) | |
| 751 | + }) | |
| 752 | + const trend = Object.keys(trendMap).sort().map(d => ({ | |
| 753 | + date: d, | |
| 754 | + amount: trendMap[d].amount, | |
| 755 | + memberCount: trendMap[d].members.size | |
| 756 | + })) | |
| 757 | + this.renderBillingTrend(trend) | |
| 758 | + | |
| 759 | + const itemRankMap = {} | |
| 760 | + itemList.forEach(i => { | |
| 761 | + const key = i.itemName || '未命名' | |
| 762 | + itemRankMap[key] = (itemRankMap[key] || 0) + Number(i.actualPrice || 0) | |
| 763 | + }) | |
| 764 | + const itemRank = Object.entries(itemRankMap).map(([name, value]) => ({ name, value })) | |
| 765 | + .sort((a, b) => b.value - a.value).slice(0, 10) | |
| 766 | + this.renderBarChart('itemRankChart', itemRank, '#E6A23C') | |
| 767 | + | |
| 768 | + const storeMap = {} | |
| 769 | + list.forEach(cur => { | |
| 770 | + const key = cur.storeName || '未知门店' | |
| 771 | + storeMap[key] = (storeMap[key] || 0) + Number(cur.actualPrice || 0) | |
| 772 | + }) | |
| 773 | + const storeRank = Object.entries(storeMap).map(([name, value]) => ({ name, value })) | |
| 774 | + .sort((a, b) => b.value - a.value).slice(0, 10) | |
| 775 | + this.renderBarChart('storeRankChart', storeRank, '#67C23A') | |
| 776 | + this.renderItemTypeRadar(topList) | |
| 777 | + | |
| 778 | + this.billingStats = { | |
| 779 | + itemTypeTop, | |
| 780 | + topMemberAmount: topAmount, | |
| 781 | + topMemberTimes: topTimes, | |
| 782 | + debtTotal, | |
| 783 | + topDebtMember: { name: '', value: 0 } | |
| 784 | + } | |
| 785 | + }, | |
| 786 | + renderBillingTrend(trend) { | |
| 787 | + const dom = this.$refs.billingTrendChart | |
| 788 | + if (!dom) return | |
| 789 | + const chart = echarts.init(dom) | |
| 790 | + const dates = trend.map(i => i.date) | |
| 791 | + chart.setOption({ | |
| 792 | + tooltip: { trigger: 'axis' }, | |
| 793 | + legend: { data: ['开单金额', '开单人数'], textStyle: { color: '#606266' } }, | |
| 794 | + grid: { left: '6%', right: '4%', top: '10%', bottom: '14%' }, | |
| 795 | + xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#dcdfe6' } }, axisLabel: { color: '#606266', rotate: 40 } }, | |
| 796 | + yAxis: [ | |
| 797 | + { type: 'value', name: '金额', axisLabel: { color: '#606266' }, splitLine: { lineStyle: { color: '#ebeef5' } } }, | |
| 798 | + { type: 'value', name: '人数', axisLabel: { color: '#606266' }, splitLine: { show: false } } | |
| 799 | + ], | |
| 800 | + series: [ | |
| 801 | + { name: '开单金额', type: 'bar', data: trend.map(i => i.amount), itemStyle: { color: '#409EFF' }, barWidth: 12 }, | |
| 802 | + { name: '开单人数', type: 'line', yAxisIndex: 1, data: trend.map(i => i.memberCount), smooth: true, itemStyle: { color: '#67C23A' } } | |
| 803 | + ] | |
| 804 | + }) | |
| 805 | + }, | |
| 806 | + renderBarChart(refName, list, color) { | |
| 807 | + const dom = this.$refs[refName] | |
| 808 | + if (!dom) return | |
| 809 | + const chart = echarts.init(dom) | |
| 810 | + chart.setOption({ | |
| 811 | + tooltip: { trigger: 'axis' }, | |
| 812 | + grid: { left: '30%', right: '8%', top: '12%', bottom: '12%' }, | |
| 813 | + xAxis: { type: 'value', axisLabel: { color: '#606266' }, splitLine: { lineStyle: { color: '#ebeef5' } } }, | |
| 814 | + yAxis: { type: 'category', data: list.map(i => i.name), axisLabel: { color: '#606266' } }, | |
| 815 | + series: [{ | |
| 816 | + type: 'bar', | |
| 817 | + data: list.map(i => i.value), | |
| 818 | + barWidth: 12, | |
| 819 | + itemStyle: { color: color || '#67C23A' } | |
| 820 | + }] | |
| 821 | + }) | |
| 822 | + }, | |
| 823 | + renderItemTypeRadar(list) { | |
| 824 | + const dom = this.$refs.itemTypeRadarChart | |
| 825 | + if (!dom) return | |
| 826 | + const chart = echarts.init(dom) | |
| 827 | + if (!list || !list.length) { | |
| 828 | + chart.clear() | |
| 829 | + return | |
| 830 | + } | |
| 831 | + const maxVal = Math.max(...list.map(x => Number(x.value || x.Amount || 0))) || 1 | |
| 832 | + const indicators = list.map(i => ({ | |
| 833 | + name: i.name || i.Name, | |
| 834 | + max: maxVal | |
| 835 | + })) | |
| 836 | + chart.setOption({ | |
| 837 | + tooltip: {}, | |
| 838 | + radar: { | |
| 839 | + indicator: indicators, | |
| 840 | + splitNumber: 4, | |
| 841 | + radius: '70%', | |
| 842 | + name: { textStyle: { color: '#606266', fontSize: 11 } }, | |
| 843 | + splitLine: { lineStyle: { color: ['#dcdfe6', '#ebeef5'] } }, | |
| 844 | + splitArea: { areaStyle: { color: ['#f5f7fa', '#fff'] } }, | |
| 845 | + axisLine: { lineStyle: { color: '#dcdfe6' } } | |
| 846 | + }, | |
| 847 | + series: [{ | |
| 848 | + type: 'radar', | |
| 849 | + data: [{ | |
| 850 | + value: list.map(i => Number(i.value || i.Amount || 0)), | |
| 851 | + name: '金额', | |
| 852 | + areaStyle: { color: 'rgba(64,158,255,0.25)' }, | |
| 853 | + lineStyle: { color: '#409EFF' }, | |
| 854 | + symbol: 'circle', | |
| 855 | + symbolSize: 3 | |
| 856 | + }] | |
| 857 | + }] | |
| 858 | + }) | |
| 859 | + }, | |
| 860 | + applyListFilter(resetPage = false) { | |
| 861 | + // 使用后端分页,筛选时重新调用接口 | |
| 862 | + if (resetPage) { | |
| 863 | + this.pagination.pageIndex = 1 | |
| 864 | + } | |
| 865 | + this.fetchData() | |
| 866 | + } | |
| 867 | + } | |
| 868 | +} | |
| 869 | +</script> | |
| 870 | + | |
| 871 | +<style lang="scss" scoped> | |
| 872 | +.tech-drill-dialog { | |
| 873 | + .el-dialog { | |
| 874 | + border-radius: 14px; | |
| 875 | + overflow: hidden; | |
| 876 | + } | |
| 877 | + | |
| 878 | + .el-dialog__header { | |
| 879 | + padding: 16px 20px; | |
| 880 | + border-bottom: 1px solid #ebeef5; | |
| 881 | + } | |
| 882 | + | |
| 883 | + .el-dialog__body { | |
| 884 | + padding: 20px; | |
| 885 | + background: #f5f7fa; | |
| 886 | + color: #303133; | |
| 887 | + overflow-y: auto; | |
| 888 | + max-height: calc(90vh - 100px); | |
| 889 | + } | |
| 890 | +} | |
| 891 | + | |
| 892 | +.billing-wrapper { | |
| 893 | + width: 100%; | |
| 894 | + overflow: hidden; | |
| 895 | + | |
| 896 | + .billing-layout { | |
| 897 | + display: flex; | |
| 898 | + align-items: stretch; | |
| 899 | + justify-content: space-between; | |
| 900 | + gap: 16px; | |
| 901 | + width: 100%; | |
| 902 | + } | |
| 903 | + | |
| 904 | + .billing-left { | |
| 905 | + flex: 0 0 75%; | |
| 906 | + max-width: 75%; | |
| 907 | + display: flex; | |
| 908 | + flex-direction: column; | |
| 909 | + gap: 12px; | |
| 910 | + min-width: 0; | |
| 911 | + } | |
| 912 | + | |
| 913 | + .billing-right { | |
| 914 | + flex: 0 0 25%; | |
| 915 | + max-width: 23.5%; | |
| 916 | + display: flex; | |
| 917 | + flex-direction: column; | |
| 918 | + gap: 12px; | |
| 919 | + min-width: 0; | |
| 920 | + } | |
| 921 | + | |
| 922 | + .billing-grid { | |
| 923 | + display: grid; | |
| 924 | + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); | |
| 925 | + gap: 12px; | |
| 926 | + margin-bottom: 14px; | |
| 927 | + } | |
| 928 | + | |
| 929 | + .stat-card { | |
| 930 | + background: #fff; | |
| 931 | + border: 1px solid #ebeef5; | |
| 932 | + border-radius: 10px; | |
| 933 | + padding: 10px 12px; | |
| 934 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 935 | + flex-shrink: 0; | |
| 936 | + | |
| 937 | + &.compact { | |
| 938 | + display: flex; | |
| 939 | + align-items: center; | |
| 940 | + gap: 10px; | |
| 941 | + min-height: 70px; | |
| 942 | + } | |
| 943 | + | |
| 944 | + .stat-icon-circle { | |
| 945 | + width: 32px; | |
| 946 | + height: 32px; | |
| 947 | + border-radius: 50%; | |
| 948 | + display: flex; | |
| 949 | + align-items: center; | |
| 950 | + justify-content: center; | |
| 951 | + color: #fff; | |
| 952 | + flex-shrink: 0; | |
| 953 | + | |
| 954 | + i { | |
| 955 | + font-size: 16px; | |
| 956 | + } | |
| 957 | + } | |
| 958 | + | |
| 959 | + &.neon-green .stat-icon-circle { | |
| 960 | + background: #67C23A; | |
| 961 | + } | |
| 962 | + | |
| 963 | + &.neon-orange .stat-icon-circle { | |
| 964 | + background: #E6A23C; | |
| 965 | + } | |
| 966 | + | |
| 967 | + &.neon-red .stat-icon-circle { | |
| 968 | + background: #F56C6C; | |
| 969 | + } | |
| 970 | + | |
| 971 | + .stat-content { | |
| 972 | + flex: 1; | |
| 973 | + min-width: 0; | |
| 974 | + } | |
| 975 | + | |
| 976 | + .stat-title { | |
| 977 | + font-size: 13px; | |
| 978 | + color: #606266; | |
| 979 | + margin-bottom: 4px; | |
| 980 | + line-height: 1.3; | |
| 981 | + } | |
| 982 | + | |
| 983 | + .stat-body { | |
| 984 | + color: #303133; | |
| 985 | + | |
| 986 | + .value-lg { | |
| 987 | + font-size: 18px; | |
| 988 | + font-weight: 700; | |
| 989 | + margin-top: 4px; | |
| 990 | + } | |
| 991 | + | |
| 992 | + .highlight { | |
| 993 | + font-size: 14px; | |
| 994 | + font-weight: 600; | |
| 995 | + line-height: 1.3; | |
| 996 | + display: flex; | |
| 997 | + align-items: center; | |
| 998 | + gap: 8px; | |
| 999 | + flex-wrap: wrap; | |
| 1000 | + | |
| 1001 | + .value-inline { | |
| 1002 | + font-size: 16px; | |
| 1003 | + font-weight: 700; | |
| 1004 | + color: #409EFF; | |
| 1005 | + white-space: nowrap; | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + .mini-line { | |
| 1010 | + display: flex; | |
| 1011 | + justify-content: space-between; | |
| 1012 | + font-size: 12px; | |
| 1013 | + color: #606266; | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + &.two-col { | |
| 1017 | + display: grid; | |
| 1018 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 1019 | + gap: 6px; | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + .stat-line { | |
| 1023 | + display: flex; | |
| 1024 | + justify-content: space-between; | |
| 1025 | + font-size: 12px; | |
| 1026 | + background: #f7f8fa; | |
| 1027 | + padding: 6px 8px; | |
| 1028 | + border-radius: 6px; | |
| 1029 | + } | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + &.neon-blue { | |
| 1033 | + border-color: #d8e9ff; | |
| 1034 | + } | |
| 1035 | + | |
| 1036 | + &.neon-green { | |
| 1037 | + border-color: #e1f3d8; | |
| 1038 | + } | |
| 1039 | + | |
| 1040 | + &.neon-orange { | |
| 1041 | + border-color: #fde3c9; | |
| 1042 | + } | |
| 1043 | + | |
| 1044 | + &.neon-red { | |
| 1045 | + border-color: #fde2e2; | |
| 1046 | + } | |
| 1047 | + } | |
| 1048 | + | |
| 1049 | + .chart-row { | |
| 1050 | + display: grid; | |
| 1051 | + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); | |
| 1052 | + gap: 12px; | |
| 1053 | + margin-bottom: 10px; | |
| 1054 | + } | |
| 1055 | + | |
| 1056 | + .chart-card { | |
| 1057 | + background: #fff; | |
| 1058 | + border: 1px solid #ebeef5; | |
| 1059 | + border-radius: 10px; | |
| 1060 | + padding: 10px; | |
| 1061 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 1062 | + flex-shrink: 0; | |
| 1063 | + | |
| 1064 | + .chart-title { | |
| 1065 | + font-size: 13px; | |
| 1066 | + color: #606266; | |
| 1067 | + margin-bottom: 6px; | |
| 1068 | + display: flex; | |
| 1069 | + align-items: center; | |
| 1070 | + gap: 6px; | |
| 1071 | + | |
| 1072 | + i { | |
| 1073 | + color: #409EFF; | |
| 1074 | + } | |
| 1075 | + } | |
| 1076 | + | |
| 1077 | + .chart-mini { | |
| 1078 | + height: 220px; | |
| 1079 | + width: 100%; | |
| 1080 | + } | |
| 1081 | + } | |
| 1082 | + | |
| 1083 | + .table-card { | |
| 1084 | + background: #fff; | |
| 1085 | + border: 1px solid #ebeef5; | |
| 1086 | + border-radius: 10px; | |
| 1087 | + padding: 10px 12px; | |
| 1088 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 1089 | + flex: 1; | |
| 1090 | + min-height: 0; | |
| 1091 | + display: flex; | |
| 1092 | + flex-direction: column; | |
| 1093 | + | |
| 1094 | + .table-header { | |
| 1095 | + display: flex; | |
| 1096 | + align-items: center; | |
| 1097 | + justify-content: space-between; | |
| 1098 | + margin-bottom: 8px; | |
| 1099 | + flex-shrink: 0; | |
| 1100 | + } | |
| 1101 | + | |
| 1102 | + .table-title { | |
| 1103 | + font-size: 14px; | |
| 1104 | + font-weight: 600; | |
| 1105 | + color: #303133; | |
| 1106 | + display: flex; | |
| 1107 | + align-items: center; | |
| 1108 | + gap: 6px; | |
| 1109 | + | |
| 1110 | + i { | |
| 1111 | + color: #409EFF; | |
| 1112 | + } | |
| 1113 | + } | |
| 1114 | + | |
| 1115 | + .el-table { | |
| 1116 | + flex: 1; | |
| 1117 | + min-height: 0; | |
| 1118 | + } | |
| 1119 | + | |
| 1120 | + .pagination-bar { | |
| 1121 | + flex-shrink: 0; | |
| 1122 | + margin-top: 8px; | |
| 1123 | + } | |
| 1124 | + } | |
| 1125 | +} | |
| 1126 | + | |
| 1127 | +.drill-header { | |
| 1128 | + display: flex; | |
| 1129 | + justify-content: space-between; | |
| 1130 | + align-items: center; | |
| 1131 | + margin-bottom: 8px; | |
| 1132 | + | |
| 1133 | + .drill-meta { | |
| 1134 | + display: flex; | |
| 1135 | + flex-wrap: wrap; | |
| 1136 | + gap: 6px; | |
| 1137 | + } | |
| 1138 | + | |
| 1139 | + .meta-chip { | |
| 1140 | + display: inline-flex; | |
| 1141 | + align-items: center; | |
| 1142 | + gap: 4px; | |
| 1143 | + background: rgba(255, 255, 255, 0.08); | |
| 1144 | + border: 1px solid rgba(255, 255, 255, 0.12); | |
| 1145 | + border-radius: 12px; | |
| 1146 | + padding: 4px 10px; | |
| 1147 | + font-size: 12px; | |
| 1148 | + color: #e9ecf3; | |
| 1149 | + | |
| 1150 | + &.info { | |
| 1151 | + border-color: rgba(64, 158, 255, 0.6); | |
| 1152 | + color: #d5e8ff; | |
| 1153 | + } | |
| 1154 | + | |
| 1155 | + &.success { | |
| 1156 | + border-color: rgba(103, 194, 58, 0.6); | |
| 1157 | + color: #d7f4c8; | |
| 1158 | + } | |
| 1159 | + | |
| 1160 | + &.warning { | |
| 1161 | + border-color: rgba(230, 162, 60, 0.6); | |
| 1162 | + color: #ffe1b8; | |
| 1163 | + } | |
| 1164 | + } | |
| 1165 | +} | |
| 1166 | + | |
| 1167 | +.drill-summary { | |
| 1168 | + display: grid; | |
| 1169 | + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); | |
| 1170 | + gap: 8px; | |
| 1171 | + margin-bottom: 8px; | |
| 1172 | + | |
| 1173 | + .summary-item { | |
| 1174 | + background: #fff; | |
| 1175 | + border: 1px solid #ebeef5; | |
| 1176 | + border-radius: 8px; | |
| 1177 | + padding: 8px 10px; | |
| 1178 | + color: #303133; | |
| 1179 | + | |
| 1180 | + .summary-name { | |
| 1181 | + font-size: 12px; | |
| 1182 | + color: #606266; | |
| 1183 | + margin-bottom: 4px; | |
| 1184 | + } | |
| 1185 | + | |
| 1186 | + .summary-value { | |
| 1187 | + font-weight: 600; | |
| 1188 | + font-size: 14px; | |
| 1189 | + } | |
| 1190 | + } | |
| 1191 | +} | |
| 1192 | + | |
| 1193 | +.pagination-bar { | |
| 1194 | + display: flex; | |
| 1195 | + justify-content: flex-end; | |
| 1196 | + padding: 10px 0 4px 0; | |
| 1197 | +} | |
| 1198 | + | |
| 1199 | +.list-filters { | |
| 1200 | + display: flex; | |
| 1201 | + align-items: center; | |
| 1202 | + justify-content: flex-start; | |
| 1203 | + margin: 8px 0; | |
| 1204 | + | |
| 1205 | + &.inline { | |
| 1206 | + margin: 0; | |
| 1207 | + } | |
| 1208 | +} | |
| 1209 | + | |
| 1210 | +.text-ellipsis-2 { | |
| 1211 | + display: -webkit-box; | |
| 1212 | + -webkit-line-clamp: 2; | |
| 1213 | + -webkit-box-orient: vertical; | |
| 1214 | + overflow: hidden; | |
| 1215 | +} | |
| 1216 | +</style> | ... | ... |
antis-ncc-admin/src/views/statisticsList/form9.vue
| ... | ... | @@ -34,7 +34,7 @@ |
| 34 | 34 | <!-- 第一层:核心 KPI 指标 (6个) --> |
| 35 | 35 | <el-row :gutter="16" class="kpi-row"> |
| 36 | 36 | <el-col :span="4" v-for="(kpi, index) in kpiList" :key="index"> |
| 37 | - <div class="kpi-card" :class="kpi.type"> | |
| 37 | + <div class="kpi-card" :class="kpi.type" @click="openKpiDrill(kpi)"> | |
| 38 | 38 | <div class="kpi-icon"><i :class="kpi.icon"></i></div> |
| 39 | 39 | <div class="kpi-info"> |
| 40 | 40 | <div class="kpi-label">{{ kpi.label }}</div> |
| ... | ... | @@ -422,6 +422,10 @@ |
| 422 | 422 | </div> |
| 423 | 423 | </el-dialog> |
| 424 | 424 | |
| 425 | + <!-- KPI 穿透弹窗 --> | |
| 426 | + <kpi-drill-dialog :visible.sync="drillDialog.visible" :title="drillDialog.title" :type="drillDialog.type" | |
| 427 | + :filters="drillDialog.filters" :extra="drillDialog.extra" :store-options="storeOptions" /> | |
| 428 | + | |
| 425 | 429 | <!-- 科技感弹窗预览 --> |
| 426 | 430 | <el-dialog :visible.sync="showTechModal" :title="techModalTitle" :width="techModalWidth" custom-class="tech-dialog" |
| 427 | 431 | append-to-body :close-on-click-modal="false"> |
| ... | ... | @@ -474,9 +478,11 @@ |
| 474 | 478 | import request from '@/utils/request' |
| 475 | 479 | import * as echarts from 'echarts' |
| 476 | 480 | import dayjs from 'dayjs' |
| 481 | +import KpiDrillDialog from '@/components/kpi-drill-dialog.vue' | |
| 477 | 482 | |
| 478 | 483 | export default { |
| 479 | 484 | name: 'LeadershipCockpit', |
| 485 | + components: { KpiDrillDialog }, | |
| 480 | 486 | data() { |
| 481 | 487 | return { |
| 482 | 488 | query: { |
| ... | ... | @@ -484,6 +490,19 @@ export default { |
| 484 | 490 | storeIds: [] |
| 485 | 491 | }, |
| 486 | 492 | storeOptions: [], |
| 493 | + // KPI 穿透 | |
| 494 | + drillDialog: { | |
| 495 | + visible: false, | |
| 496 | + type: 'billing', | |
| 497 | + title: '数据穿透', | |
| 498 | + filters: {}, | |
| 499 | + extra: {} | |
| 500 | + }, | |
| 501 | + currentDateParams: { | |
| 502 | + startTime: null, | |
| 503 | + endTime: null, | |
| 504 | + month: null | |
| 505 | + }, | |
| 487 | 506 | // 核心 KPI 数据 |
| 488 | 507 | kpiData: {}, |
| 489 | 508 | trendType: 'day', |
| ... | ... | @@ -551,13 +570,14 @@ export default { |
| 551 | 570 | // 6大核心 KPI 装饰器 |
| 552 | 571 | kpiList() { |
| 553 | 572 | const d = this.kpiData || {} |
| 573 | + const tkCount = (this.tkStatisticsData && this.tkStatisticsData.TkCount) ? this.tkStatisticsData.TkCount : 0 | |
| 554 | 574 | return [ |
| 555 | - { label: '本月成交总额', value: this.formatMoney(d.TotalBillingAmount), icon: 'el-icon-wallet', type: 'primary', isMoney: true }, | |
| 556 | - { label: '本月消耗金额', value: this.formatMoney(d.TotalConsumeAmount), icon: 'el-icon-medal', type: 'success', isMoney: true }, | |
| 557 | - { label: '完成业绩(净额)', value: this.formatMoney(d.CompletedBillingAmount), icon: 'el-icon-trophy', type: 'warning', isMoney: true }, | |
| 558 | - { label: '开单目标达成', value: d.BillingCompletionRate || 0, icon: 'el-icon-pie-chart', type: 'info', isPercent: true, target: this.formatMoney(d.TargetBillingAmount), status: (d.BillingCompletionRate >= 100) ? 'up' : 'down' }, | |
| 559 | - { label: '本月拓客人数', value: (this.tkStatisticsData && this.tkStatisticsData.TkCount) ? this.tkStatisticsData.TkCount : 0, icon: 'el-icon-user-solid', type: 'danger', isPercent: false, target: null, status: null }, | |
| 560 | - { label: '退卡总计', value: this.formatMoney(d.TotalRefundAmount), icon: 'el-icon-warning-outline', type: 'error', isMoney: true } | |
| 575 | + { key: 'billing', label: '本月成交总额', value: this.formatMoney(d.TotalBillingAmount), raw: d.TotalBillingAmount || 0, icon: 'el-icon-wallet', type: 'primary', isMoney: true }, | |
| 576 | + { key: 'consume', label: '本月消耗金额', value: this.formatMoney(d.TotalConsumeAmount), raw: d.TotalConsumeAmount || 0, icon: 'el-icon-medal', type: 'success', isMoney: true }, | |
| 577 | + { key: 'net', label: '完成业绩(净额)', value: this.formatMoney(d.CompletedBillingAmount), raw: d.CompletedBillingAmount || 0, icon: 'el-icon-trophy', type: 'warning', isMoney: true }, | |
| 578 | + { key: 'target', label: '开单目标达成', value: d.BillingCompletionRate || 0, raw: d.BillingCompletionRate || 0, icon: 'el-icon-pie-chart', type: 'info', isPercent: true, target: this.formatMoney(d.TargetBillingAmount), targetRaw: d.TargetBillingAmount || 0, status: (d.BillingCompletionRate >= 100) ? 'up' : 'down' }, | |
| 579 | + { key: 'tk', label: '本月拓客人数', value: tkCount, raw: tkCount, icon: 'el-icon-user-solid', type: 'danger', isPercent: false, target: null, status: null }, | |
| 580 | + { key: 'refund', label: '退卡总计', value: this.formatMoney(d.TotalRefundAmount), raw: d.TotalRefundAmount || 0, icon: 'el-icon-warning-outline', type: 'error', isMoney: true } | |
| 561 | 581 | ] |
| 562 | 582 | }, |
| 563 | 583 | // 健康师排行榜数据切换 |
| ... | ... | @@ -623,6 +643,29 @@ export default { |
| 623 | 643 | openTechModal() { |
| 624 | 644 | this.showTechModal = true |
| 625 | 645 | }, |
| 646 | + // 打开KPI穿透弹窗 | |
| 647 | + openKpiDrill(kpi) { | |
| 648 | + if (!kpi) return | |
| 649 | + const filters = { | |
| 650 | + startTime: this.currentDateParams.startTime, | |
| 651 | + endTime: this.currentDateParams.endTime, | |
| 652 | + storeIds: this.query.storeIds || [], | |
| 653 | + month: this.currentDateParams.month | |
| 654 | + } | |
| 655 | + const extra = { | |
| 656 | + actualAmount: kpi.raw || 0, | |
| 657 | + targetAmount: kpi.targetRaw || 0, | |
| 658 | + refundAmount: this.kpiData ? (this.kpiData.TotalRefundAmount || 0) : 0 | |
| 659 | + } | |
| 660 | + this.drillDialog = { | |
| 661 | + ...this.drillDialog, | |
| 662 | + visible: true, | |
| 663 | + type: kpi.key, | |
| 664 | + title: `${kpi.label}穿透`, | |
| 665 | + filters, | |
| 666 | + extra | |
| 667 | + } | |
| 668 | + }, | |
| 626 | 669 | // 重置弹窗大小与标题 |
| 627 | 670 | resetTechModal() { |
| 628 | 671 | this.techModalTitle = '会员画像穿透预览' |
| ... | ... | @@ -670,6 +713,12 @@ export default { |
| 670 | 713 | endTime: monthEnd.endOf('day').format('YYYY-MM-DD HH:mm:ss'), |
| 671 | 714 | storeIds: this.query.storeIds || [] |
| 672 | 715 | } |
| 716 | + // 存储当前时间范围与月份,供穿透使用 | |
| 717 | + this.currentDateParams = { | |
| 718 | + startTime: dateParams.startTime, | |
| 719 | + endTime: dateParams.endTime, | |
| 720 | + month: currentMonth | |
| 721 | + } | |
| 673 | 722 | |
| 674 | 723 | // 根据趋势类型构建不同的查询参数 |
| 675 | 724 | let trendParams = {} |
| ... | ... | @@ -1596,6 +1645,7 @@ export default { |
| 1596 | 1645 | height: 100px; |
| 1597 | 1646 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.04); |
| 1598 | 1647 | transition: transform 0.3s; |
| 1648 | + cursor: pointer; | |
| 1599 | 1649 | |
| 1600 | 1650 | &:hover { |
| 1601 | 1651 | transform: translateY(-4px); | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqKdKdjlb/BillingItemDetailListOutput.cs
| ... | ... | @@ -63,6 +63,16 @@ namespace NCC.Extend.Entitys.Dto.LqKdKdjlb |
| 63 | 63 | public string sourceType { get; set; } |
| 64 | 64 | |
| 65 | 65 | /// <summary> |
| 66 | + /// 业绩类型(来源:品项表fl3,对应F_PerformanceType) | |
| 67 | + /// </summary> | |
| 68 | + public string performanceType { get; set; } | |
| 69 | + | |
| 70 | + /// <summary> | |
| 71 | + /// 科美类型(来源:lq_xmzl.F_BeautyType) | |
| 72 | + /// </summary> | |
| 73 | + public string beautyType { get; set; } | |
| 74 | + | |
| 75 | + /// <summary> | |
| 66 | 76 | /// 备注 |
| 67 | 77 | /// </summary> |
| 68 | 78 | public string remark { get; set; } | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/BillingDrillStatisticsDto.cs
0 → 100644
| 1 | +using System; | |
| 2 | +using System.Collections.Generic; | |
| 3 | + | |
| 4 | +namespace NCC.Extend.Entitys.Dto.LqReport | |
| 5 | +{ | |
| 6 | + /// <summary> | |
| 7 | + /// 本月成交总额穿透统计请求参数 | |
| 8 | + /// </summary> | |
| 9 | + public class BillingDrillStatisticsInput | |
| 10 | + { | |
| 11 | + /// <summary> | |
| 12 | + /// 统计月份(格式:yyyyMM) | |
| 13 | + /// </summary> | |
| 14 | + public string StatisticsMonth { get; set; } | |
| 15 | + | |
| 16 | + /// <summary> | |
| 17 | + /// 门店ID列表 | |
| 18 | + /// </summary> | |
| 19 | + public List<string> StoreIds { get; set; } = new List<string>(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// <summary> | |
| 23 | + /// 本月成交总额穿透统计返回结果 | |
| 24 | + /// </summary> | |
| 25 | + public class BillingDrillStatisticsOutput | |
| 26 | + { | |
| 27 | + /// <summary> | |
| 28 | + /// 每日开单趋势 | |
| 29 | + /// </summary> | |
| 30 | + public List<BillingDailyTrendOutput> DailyTrend { get; set; } = new List<BillingDailyTrendOutput>(); | |
| 31 | + | |
| 32 | + /// <summary> | |
| 33 | + /// 会员极值统计 | |
| 34 | + /// </summary> | |
| 35 | + public BillingMemberStatsOutput MemberStats { get; set; } = new BillingMemberStatsOutput(); | |
| 36 | + | |
| 37 | + /// <summary> | |
| 38 | + /// 品项类型雷达图数据 | |
| 39 | + /// </summary> | |
| 40 | + public List<CommonNameAmountOutput> ItemTypeRadar { get; set; } = new List<CommonNameAmountOutput>(); | |
| 41 | + | |
| 42 | + /// <summary> | |
| 43 | + /// 来源类型统计 | |
| 44 | + /// </summary> | |
| 45 | + public List<CommonNameCountAmountOutput> SourceTypeStats { get; set; } = new List<CommonNameCountAmountOutput>(); | |
| 46 | + | |
| 47 | + /// <summary> | |
| 48 | + /// 业绩类型统计 | |
| 49 | + /// </summary> | |
| 50 | + public List<CommonNameAmountOutput> PerformanceTypeStats { get; set; } = new List<CommonNameAmountOutput>(); | |
| 51 | + | |
| 52 | + /// <summary> | |
| 53 | + /// 科美类型统计 | |
| 54 | + /// </summary> | |
| 55 | + public List<CommonNameAmountOutput> BeautyTypeStats { get; set; } = new List<CommonNameAmountOutput>(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /// <summary> | |
| 59 | + /// 每日开单趋势 | |
| 60 | + /// </summary> | |
| 61 | + public class BillingDailyTrendOutput | |
| 62 | + { | |
| 63 | + /// <summary> | |
| 64 | + /// 日期(yyyy-MM-dd) | |
| 65 | + /// </summary> | |
| 66 | + public string Date { get; set; } | |
| 67 | + | |
| 68 | + /// <summary> | |
| 69 | + /// 金额 | |
| 70 | + /// </summary> | |
| 71 | + public decimal Amount { get; set; } | |
| 72 | + | |
| 73 | + /// <summary> | |
| 74 | + /// 会员人数 | |
| 75 | + /// </summary> | |
| 76 | + public int MemberCount { get; set; } | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// <summary> | |
| 80 | + /// 会员极值统计 | |
| 81 | + /// </summary> | |
| 82 | + public class BillingMemberStatsOutput | |
| 83 | + { | |
| 84 | + /// <summary> | |
| 85 | + /// 开单金额最高会员姓名 | |
| 86 | + /// </summary> | |
| 87 | + public string TopAmountMemberName { get; set; } | |
| 88 | + | |
| 89 | + /// <summary> | |
| 90 | + /// 开单金额最高会员的金额 | |
| 91 | + /// </summary> | |
| 92 | + public decimal TopAmountValue { get; set; } | |
| 93 | + | |
| 94 | + /// <summary> | |
| 95 | + /// 开单次数最多会员姓名 | |
| 96 | + /// </summary> | |
| 97 | + public string TopTimesMemberName { get; set; } | |
| 98 | + | |
| 99 | + /// <summary> | |
| 100 | + /// 开单次数最多会员的次数 | |
| 101 | + /// </summary> | |
| 102 | + public int TopTimesCount { get; set; } | |
| 103 | + | |
| 104 | + /// <summary> | |
| 105 | + /// 整月欠款总额 | |
| 106 | + /// </summary> | |
| 107 | + public decimal DebtTotal { get; set; } | |
| 108 | + | |
| 109 | + /// <summary> | |
| 110 | + /// 欠款最多会员姓名 | |
| 111 | + /// </summary> | |
| 112 | + public string TopDebtMemberName { get; set; } | |
| 113 | + | |
| 114 | + /// <summary> | |
| 115 | + /// 欠款最多会员金额 | |
| 116 | + /// </summary> | |
| 117 | + public decimal TopDebtValue { get; set; } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// <summary> | |
| 121 | + /// 通用名称+金额 | |
| 122 | + /// </summary> | |
| 123 | + public class CommonNameAmountOutput | |
| 124 | + { | |
| 125 | + /// <summary> | |
| 126 | + /// 名称 | |
| 127 | + /// </summary> | |
| 128 | + public string Name { get; set; } | |
| 129 | + | |
| 130 | + /// <summary> | |
| 131 | + /// 金额 | |
| 132 | + /// </summary> | |
| 133 | + public decimal Amount { get; set; } | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// <summary> | |
| 137 | + /// 通用名称+数量+金额 | |
| 138 | + /// </summary> | |
| 139 | + public class CommonNameCountAmountOutput | |
| 140 | + { | |
| 141 | + /// <summary> | |
| 142 | + /// 名称 | |
| 143 | + /// </summary> | |
| 144 | + public string Name { get; set; } | |
| 145 | + | |
| 146 | + /// <summary> | |
| 147 | + /// 数量 | |
| 148 | + /// </summary> | |
| 149 | + public int Count { get; set; } | |
| 150 | + | |
| 151 | + /// <summary> | |
| 152 | + /// 金额 | |
| 153 | + /// </summary> | |
| 154 | + public decimal Amount { get; set; } | |
| 155 | + } | |
| 156 | +} | |
| 157 | + | |
| 158 | + | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs
| ... | ... | @@ -163,8 +163,8 @@ namespace NCC.Extend.LqHytkHytk |
| 163 | 163 | .WhereIF(!string.IsNullOrEmpty(input.tkzt), p => p.Tkzt.Contains(input.tkzt)) |
| 164 | 164 | .WhereIF(!string.IsNullOrEmpty(input.tkyy), p => p.Tkyy.Contains(input.tkyy)) |
| 165 | 165 | .WhereIF(!string.IsNullOrEmpty(input.fileUrl), p => p.FileUrl.Contains(input.fileUrl)) |
| 166 | - .WhereIF(queryTksj != null, p => p.Tksj >= new DateTime(startTksj.ToDate().Year, startTksj.ToDate().Month, startTksj.ToDate().Day, 0, 0, 0)) | |
| 167 | - .WhereIF(queryTksj != null, p => p.Tksj <= new DateTime(endTksj.ToDate().Year, endTksj.ToDate().Month, endTksj.ToDate().Day, 23, 59, 59)) | |
| 166 | + .WhereIF(startTksj.HasValue, p => p.Tksj >= new DateTime(startTksj.Value.Year, startTksj.Value.Month, startTksj.Value.Day, 0, 0, 0)) | |
| 167 | + .WhereIF(endTksj.HasValue, p => p.Tksj <= new DateTime(endTksj.Value.Year, endTksj.Value.Month, endTksj.Value.Day, 23, 59, 59)) | |
| 168 | 168 | .WhereIF(!string.IsNullOrEmpty(input.czry), p => p.Czry.Equals(input.czry)) |
| 169 | 169 | .WhereIF(input.isEffective != 0, p => p.IsEffective == input.isEffective) |
| 170 | 170 | .Select(it => new LqHytkHytkListOutput | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqKdKdjlbService.cs
| ... | ... | @@ -3805,26 +3805,22 @@ namespace NCC.Extend.LqKdKdjlb |
| 3805 | 3805 | string startTimeStr = !string.IsNullOrEmpty(input.StartBillingTime) ? input.StartBillingTime : input.startTime; |
| 3806 | 3806 | string endTimeStr = !string.IsNullOrEmpty(input.EndBillingTime) ? input.EndBillingTime : input.endTime; |
| 3807 | 3807 | |
| 3808 | - if (!string.IsNullOrEmpty(startTimeStr) && !string.IsNullOrEmpty(endTimeStr)) | |
| 3808 | + // 开始时间:单独解析,解析失败则忽略过滤,避免直接抛500 | |
| 3809 | + if (!string.IsNullOrWhiteSpace(startTimeStr)) | |
| 3809 | 3810 | { |
| 3810 | - // 尝试解析日期字符串(支持多种格式) | |
| 3811 | 3811 | if (DateTime.TryParse(startTimeStr, out DateTime startDate)) |
| 3812 | 3812 | { |
| 3813 | 3813 | startBillingTime = startDate; |
| 3814 | 3814 | } |
| 3815 | - else | |
| 3816 | - { | |
| 3817 | - throw NCCException.Oh($"开始时间格式错误:{startTimeStr}"); | |
| 3818 | - } | |
| 3815 | + } | |
| 3819 | 3816 | |
| 3817 | + // 结束时间:单独解析,解析失败则忽略过滤 | |
| 3818 | + if (!string.IsNullOrWhiteSpace(endTimeStr)) | |
| 3819 | + { | |
| 3820 | 3820 | if (DateTime.TryParse(endTimeStr, out DateTime endDate)) |
| 3821 | 3821 | { |
| 3822 | 3822 | endBillingTime = endDate; |
| 3823 | 3823 | } |
| 3824 | - else | |
| 3825 | - { | |
| 3826 | - throw NCCException.Oh($"结束时间格式错误:{endTimeStr}"); | |
| 3827 | - } | |
| 3828 | 3824 | } |
| 3829 | 3825 | |
| 3830 | 3826 | // 优化查询:先分页查询主表,再批量查询关联数据,避免子查询性能问题 |
| ... | ... | @@ -3850,6 +3846,27 @@ namespace NCC.Extend.LqKdKdjlb |
| 3850 | 3846 | // 3. 先分页查询主表数据(查询实体类,提高性能) |
| 3851 | 3847 | var pagedData = await baseQuery.OrderBy(sidx + " " + sort).ToPagedListAsync(input.currentPage, input.pageSize); |
| 3852 | 3848 | |
| 3849 | + // 检查分页结果 | |
| 3850 | + if (pagedData == null) | |
| 3851 | + { | |
| 3852 | + throw NCCException.Oh("分页查询返回结果为空"); | |
| 3853 | + } | |
| 3854 | + | |
| 3855 | + if (pagedData.list == null) | |
| 3856 | + { | |
| 3857 | + pagedData.list = new List<LqKdPxmxEntity>(); | |
| 3858 | + } | |
| 3859 | + | |
| 3860 | + if (pagedData.pagination == null) | |
| 3861 | + { | |
| 3862 | + pagedData.pagination = new PagedModel | |
| 3863 | + { | |
| 3864 | + PageIndex = input.currentPage, | |
| 3865 | + PageSize = input.pageSize, | |
| 3866 | + Total = 0 | |
| 3867 | + }; | |
| 3868 | + } | |
| 3869 | + | |
| 3853 | 3870 | // 4. 批量查询关联数据 |
| 3854 | 3871 | var itemIds = pagedData.list.Select(x => x.Id).ToList(); |
| 3855 | 3872 | var memberIds = pagedData.list.Where(x => !string.IsNullOrEmpty(x.MemberId)).Select(x => x.MemberId).Distinct().ToList(); |
| ... | ... | @@ -3925,6 +3942,8 @@ namespace NCC.Extend.LqKdKdjlb |
| 3925 | 3942 | actualPrice = pxmx.ActualPrice, |
| 3926 | 3943 | projectNumber = pxmx.ProjectNumber, |
| 3927 | 3944 | sourceType = pxmx.SourceType, |
| 3945 | + performanceType = pxmx.PerformanceType, | |
| 3946 | + beautyType = pxmx.BeautyType, | |
| 3928 | 3947 | remark = pxmx.Remark, |
| 3929 | 3948 | storeId = pxmx.Glkdbh != null && billingStoreDict.ContainsKey(pxmx.Glkdbh) ? billingStoreDict[pxmx.Glkdbh] : "", |
| 3930 | 3949 | storeName = pxmx.Glkdbh != null && billingStoreDict.ContainsKey(pxmx.Glkdbh) && !string.IsNullOrEmpty(billingStoreDict[pxmx.Glkdbh]) && storeDict.ContainsKey(billingStoreDict[pxmx.Glkdbh]) ? storeDict[billingStoreDict[pxmx.Glkdbh]] : "", |
| ... | ... | @@ -3946,8 +3965,8 @@ namespace NCC.Extend.LqKdKdjlb |
| 3946 | 3965 | } |
| 3947 | 3966 | catch (Exception ex) |
| 3948 | 3967 | { |
| 3949 | - _logger.LogError(ex, $"获取开单品项明细记录列表失败: {ex.ToString()}"); | |
| 3950 | - throw NCCException.Oh(ErrorCode.COM1005, $"获取开单品项明细记录列表失败: {ex.Message}"); | |
| 3968 | + _logger.LogError(ex, $"获取开单品项明细记录列表失败 - 开始时间: {input?.StartBillingTime}, 结束时间: {input?.EndBillingTime}, 页码: {input?.currentPage}, 页大小: {input?.pageSize}, 错误详情: {ex.ToString()}"); | |
| 3969 | + throw NCCException.Oh($"获取开单品项明细记录列表失败: {ex.Message}"); | |
| 3951 | 3970 | } |
| 3952 | 3971 | } |
| 3953 | 3972 | #endregion | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs
| ... | ... | @@ -23,6 +23,7 @@ using NCC.Extend.Entitys.lq_mdxx; |
| 23 | 23 | using NCC.Extend.Entitys.lq_ycsd_jsj; |
| 24 | 24 | using NCC.Extend.Entitys.lq_jinsanjiao_user; |
| 25 | 25 | using NCC.Extend.Entitys.lq_kd_kdjlb; |
| 26 | +using NCC.Extend.Entitys.lq_kd_pxmx; | |
| 26 | 27 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 27 | 28 | using NCC.Extend.Entitys.lq_khxx; |
| 28 | 29 | using NCC.Extend.Entitys.Dto.LqReport; |
| ... | ... | @@ -467,6 +468,268 @@ namespace NCC.Extend |
| 467 | 468 | |
| 468 | 469 | #endregion |
| 469 | 470 | |
| 471 | + #region 本月成交总额穿透统计 | |
| 472 | + /// <summary> | |
| 473 | + /// 本月成交总额穿透统计 | |
| 474 | + /// </summary> | |
| 475 | + /// <remarks> | |
| 476 | + /// 用于 /statisticsList/form9 页面“本月成交总额”KPI 的穿透分析,基于整月完整数据做统计。 | |
| 477 | + /// | |
| 478 | + /// 统计范围: | |
| 479 | + /// - 只统计有效开单:lq_kd_kdjlb.F_IsEffective = 1、lq_kd_pxmx.F_IsEffective = 1 | |
| 480 | + /// - 按业绩时间 yjsj(品项表)落在指定月份内 | |
| 481 | + /// - 可按门店ID列表筛选(Djmd) | |
| 482 | + /// | |
| 483 | + /// 示例请求: | |
| 484 | + /// ```json | |
| 485 | + /// { | |
| 486 | + /// "statisticsMonth": "202512", | |
| 487 | + /// "storeIds": ["门店ID1","门店ID2"] | |
| 488 | + /// } | |
| 489 | + /// ``` | |
| 490 | + /// | |
| 491 | + /// 参数说明: | |
| 492 | + /// - statisticsMonth: 统计月份,格式yyyyMM(必填) | |
| 493 | + /// - storeIds: 门店ID列表(可选,不传则统计所有门店) | |
| 494 | + /// | |
| 495 | + /// 返回字段说明: | |
| 496 | + /// - DailyTrend: 每日开单金额 & 人数趋势 | |
| 497 | + /// - Date: 日期(yyyy-MM-dd) | |
| 498 | + /// - Amount: 当日实付金额合计(开单品项实付金额合计) | |
| 499 | + /// - MemberCount: 当日开单会员数(按会员ID去重) | |
| 500 | + /// - MemberStats: 会员相关极值 | |
| 501 | + /// - TopAmountMemberName: 开单金额最高会员姓名 | |
| 502 | + /// - TopAmountValue: 该会员整月实付金额 | |
| 503 | + /// - TopTimesMemberName: 开单次数最多会员姓名 | |
| 504 | + /// - TopTimesCount: 该会员整月开单次数 | |
| 505 | + /// - DebtTotal: 整月欠款总额(sfyj+ck金额以外的qk累加) | |
| 506 | + /// - TopDebtMemberName: 欠款最多会员姓名 | |
| 507 | + /// - TopDebtValue: 欠款最多会员的欠款金额 | |
| 508 | + /// - ItemTypeRadar: 品项类型维度金额(用于雷达图) | |
| 509 | + /// - Name: 品项类型名称 | |
| 510 | + /// - Value: 实付金额合计 | |
| 511 | + /// - SourceTypeStats: 来源类型统计 | |
| 512 | + /// - Name: 来源类型(购买/赠送/体验等) | |
| 513 | + /// - Count: 该来源的品项条数 | |
| 514 | + /// - Amount: 该来源的实付金额合计 | |
| 515 | + /// - PerformanceTypeStats: 业绩类型统计 | |
| 516 | + /// - Name: 业绩类型 | |
| 517 | + /// - Amount: 实付金额合计 | |
| 518 | + /// - BeautyTypeStats: 科美类型统计 | |
| 519 | + /// - Name: 科美类型 | |
| 520 | + /// - Amount: 实付金额合计 | |
| 521 | + /// </remarks> | |
| 522 | + /// <param name="input">查询参数</param> | |
| 523 | + /// <returns>本月成交总额穿透统计结果</returns> | |
| 524 | + /// <response code="200">成功返回统计数据</response> | |
| 525 | + /// <response code="400">参数错误</response> | |
| 526 | + /// <response code="500">服务器错误</response> | |
| 527 | + [HttpPost("get-billing-drill-statistics")] | |
| 528 | + public async Task<object> GetBillingDrillStatistics([FromBody] BillingDrillStatisticsInput input) | |
| 529 | + { | |
| 530 | + try | |
| 531 | + { | |
| 532 | + if (input == null || string.IsNullOrWhiteSpace(input.StatisticsMonth)) | |
| 533 | + { | |
| 534 | + throw NCCException.Oh("统计月份不能为空,格式为yyyyMM"); | |
| 535 | + } | |
| 536 | + | |
| 537 | + if (!DateTime.TryParseExact(input.StatisticsMonth + "01", "yyyyMMdd", null, | |
| 538 | + global::System.Globalization.DateTimeStyles.None, out var monthStart)) | |
| 539 | + { | |
| 540 | + throw NCCException.Oh($"统计月份格式错误:{input.StatisticsMonth},应为yyyyMM"); | |
| 541 | + } | |
| 542 | + | |
| 543 | + var startTime = monthStart; | |
| 544 | + var endTime = monthStart.AddMonths(1).AddSeconds(-1); | |
| 545 | + | |
| 546 | + // 1. 基础查询:开单品项明细 + 开单主表(用于获取门店和欠款等) | |
| 547 | + var storeIds = input.StoreIds ?? new List<string>(); | |
| 548 | + | |
| 549 | + var baseQuery = _db.Queryable<LqKdPxmxEntity, LqKdKdjlbEntity>((px, kd) => new JoinQueryInfos( | |
| 550 | + JoinType.Inner, px.Glkdbh == kd.Id)) | |
| 551 | + .Where((px, kd) => px.IsEffective == StatusEnum.有效.GetHashCode() | |
| 552 | + && kd.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 553 | + .Where((px, kd) => px.Yjsj >= startTime && px.Yjsj <= endTime) | |
| 554 | + .WhereIF(storeIds.Any(), (px, kd) => storeIds.Contains(kd.Djmd)); | |
| 555 | + | |
| 556 | + // 为后续多次统计复用,先拉到内存(注意:仅限当月+门店内的数据量) | |
| 557 | + var rawList = await baseQuery.Select((px, kd) => new | |
| 558 | + { | |
| 559 | + BillingId = kd.Id, | |
| 560 | + BillingDate = kd.Kdrq, | |
| 561 | + StoreId = kd.Djmd, | |
| 562 | + MemberId = px.MemberId, | |
| 563 | + MemberName = "", // 如需姓名可再Join lq_khxx,这里留空给前端展示列表时使用原接口 | |
| 564 | + Amount = px.ActualPrice, | |
| 565 | + Debt = kd.Qk, | |
| 566 | + ItemType = px.ItemCategory, | |
| 567 | + SourceType = px.SourceType, | |
| 568 | + PerformanceType = px.PerformanceType, | |
| 569 | + BeautyType = px.BeautyType | |
| 570 | + }).ToListAsync(); | |
| 571 | + | |
| 572 | + // 保护:无数据时直接返回空结构 | |
| 573 | + if (!rawList.Any()) | |
| 574 | + { | |
| 575 | + return new BillingDrillStatisticsOutput | |
| 576 | + { | |
| 577 | + DailyTrend = new List<BillingDailyTrendOutput>(), | |
| 578 | + MemberStats = new BillingMemberStatsOutput(), | |
| 579 | + ItemTypeRadar = new List<CommonNameAmountOutput>(), | |
| 580 | + SourceTypeStats = new List<CommonNameCountAmountOutput>(), | |
| 581 | + PerformanceTypeStats = new List<CommonNameAmountOutput>(), | |
| 582 | + BeautyTypeStats = new List<CommonNameAmountOutput>() | |
| 583 | + }; | |
| 584 | + } | |
| 585 | + | |
| 586 | + // 2. 每日趋势:按yjsj日期聚合金额和会员数 | |
| 587 | + var dailyTrend = rawList | |
| 588 | + .GroupBy(x => x.BillingDate.HasValue ? x.BillingDate.Value.ToString("yyyy-MM-dd") : "") | |
| 589 | + .Where(g => !string.IsNullOrEmpty(g.Key)) | |
| 590 | + .Select(g => new BillingDailyTrendOutput | |
| 591 | + { | |
| 592 | + Date = g.Key, | |
| 593 | + Amount = g.Sum(x => x.Amount), | |
| 594 | + MemberCount = g.Select(x => x.MemberId).Where(m => !string.IsNullOrEmpty(m)).Distinct().Count() | |
| 595 | + }) | |
| 596 | + .OrderBy(x => x.Date) | |
| 597 | + .ToList(); | |
| 598 | + | |
| 599 | + // 3. 会员极值:按会员聚合金额和次数 | |
| 600 | + var memberAgg = rawList | |
| 601 | + .Where(x => !string.IsNullOrEmpty(x.MemberId)) | |
| 602 | + .GroupBy(x => x.MemberId) | |
| 603 | + .Select(g => new | |
| 604 | + { | |
| 605 | + MemberId = g.Key, | |
| 606 | + Amount = g.Sum(x => x.Amount), | |
| 607 | + Times = g.Count() | |
| 608 | + }) | |
| 609 | + .ToList(); | |
| 610 | + | |
| 611 | + var topAmount = memberAgg | |
| 612 | + .OrderByDescending(x => x.Amount) | |
| 613 | + .FirstOrDefault(); | |
| 614 | + | |
| 615 | + var topTimes = memberAgg | |
| 616 | + .OrderByDescending(x => x.Times) | |
| 617 | + .FirstOrDefault(); | |
| 618 | + | |
| 619 | + // 查询会员姓名 | |
| 620 | + var memberIds = new List<string>(); | |
| 621 | + if (topAmount != null && !string.IsNullOrEmpty(topAmount.MemberId)) | |
| 622 | + { | |
| 623 | + memberIds.Add(topAmount.MemberId); | |
| 624 | + } | |
| 625 | + if (topTimes != null && !string.IsNullOrEmpty(topTimes.MemberId) && !memberIds.Contains(topTimes.MemberId)) | |
| 626 | + { | |
| 627 | + memberIds.Add(topTimes.MemberId); | |
| 628 | + } | |
| 629 | + | |
| 630 | + var memberNameDict = new Dictionary<string, string>(); | |
| 631 | + if (memberIds.Any()) | |
| 632 | + { | |
| 633 | + var members = await _db.Queryable<LqKhxxEntity>() | |
| 634 | + .Where(x => memberIds.Contains(x.Id)) | |
| 635 | + .Select(x => new { x.Id, x.Khmc }) | |
| 636 | + .ToListAsync(); | |
| 637 | + memberNameDict = members.ToDictionary(x => x.Id, x => x.Khmc ?? ""); | |
| 638 | + } | |
| 639 | + | |
| 640 | + // 欠款统计:按会员聚合欠款(使用主表Qk) | |
| 641 | + var debtAgg = rawList | |
| 642 | + .GroupBy(x => x.MemberId) | |
| 643 | + .Select(g => new | |
| 644 | + { | |
| 645 | + MemberId = g.Key, | |
| 646 | + Debt = g.Max(x => x.Debt) // 每个会员按最大欠款估算,避免重复累加 | |
| 647 | + }) | |
| 648 | + .Where(x => !string.IsNullOrEmpty(x.MemberId)) | |
| 649 | + .ToList(); | |
| 650 | + | |
| 651 | + var debtTotal = debtAgg.Sum(x => x.Debt); | |
| 652 | + var topDebt = debtAgg | |
| 653 | + .OrderByDescending(x => x.Debt) | |
| 654 | + .FirstOrDefault(); | |
| 655 | + | |
| 656 | + var memberStats = new BillingMemberStatsOutput | |
| 657 | + { | |
| 658 | + TopAmountMemberName = topAmount != null && memberNameDict.ContainsKey(topAmount.MemberId) ? memberNameDict[topAmount.MemberId] : "", | |
| 659 | + TopAmountValue = topAmount?.Amount ?? 0m, | |
| 660 | + TopTimesMemberName = topTimes != null && memberNameDict.ContainsKey(topTimes.MemberId) ? memberNameDict[topTimes.MemberId] : "", | |
| 661 | + TopTimesCount = topTimes?.Times ?? 0, | |
| 662 | + DebtTotal = debtTotal, | |
| 663 | + TopDebtMemberName = "", | |
| 664 | + TopDebtValue = topDebt?.Debt ?? 0m | |
| 665 | + }; | |
| 666 | + | |
| 667 | + // 4. 品项类型雷达:按ItemType汇总金额 | |
| 668 | + var itemTypeRadar = rawList | |
| 669 | + .GroupBy(x => string.IsNullOrEmpty(x.ItemType) ? "未分类" : x.ItemType) | |
| 670 | + .Select(g => new CommonNameAmountOutput | |
| 671 | + { | |
| 672 | + Name = g.Key, | |
| 673 | + Amount = g.Sum(x => x.Amount) | |
| 674 | + }) | |
| 675 | + .OrderByDescending(x => x.Amount) | |
| 676 | + .ToList(); | |
| 677 | + | |
| 678 | + // 5. 来源类型统计 | |
| 679 | + var sourceTypeStats = rawList | |
| 680 | + .GroupBy(x => string.IsNullOrEmpty(x.SourceType) ? "未设置" : x.SourceType) | |
| 681 | + .Select(g => new CommonNameCountAmountOutput | |
| 682 | + { | |
| 683 | + Name = g.Key, | |
| 684 | + Count = g.Count(), | |
| 685 | + Amount = g.Sum(x => x.Amount) | |
| 686 | + }) | |
| 687 | + .OrderByDescending(x => x.Amount) | |
| 688 | + .ToList(); | |
| 689 | + | |
| 690 | + // 6. 业绩类型统计 | |
| 691 | + var performanceTypeStats = rawList | |
| 692 | + .GroupBy(x => string.IsNullOrEmpty(x.PerformanceType) ? "未设置" : x.PerformanceType) | |
| 693 | + .Select(g => new CommonNameAmountOutput | |
| 694 | + { | |
| 695 | + Name = g.Key, | |
| 696 | + Amount = g.Sum(x => x.Amount) | |
| 697 | + }) | |
| 698 | + .OrderByDescending(x => x.Amount) | |
| 699 | + .ToList(); | |
| 700 | + | |
| 701 | + // 7. 科美类型统计(只显示有设置的,过滤掉未设置和空值) | |
| 702 | + var beautyTypeStats = rawList | |
| 703 | + .Where(x => !string.IsNullOrEmpty(x.BeautyType)) | |
| 704 | + .GroupBy(x => x.BeautyType) | |
| 705 | + .Select(g => new CommonNameAmountOutput | |
| 706 | + { | |
| 707 | + Name = g.Key, | |
| 708 | + Amount = g.Sum(x => x.Amount) | |
| 709 | + }) | |
| 710 | + .OrderByDescending(x => x.Amount) | |
| 711 | + .ToList(); | |
| 712 | + | |
| 713 | + var result = new BillingDrillStatisticsOutput | |
| 714 | + { | |
| 715 | + DailyTrend = dailyTrend, | |
| 716 | + MemberStats = memberStats, | |
| 717 | + ItemTypeRadar = itemTypeRadar, | |
| 718 | + SourceTypeStats = sourceTypeStats, | |
| 719 | + PerformanceTypeStats = performanceTypeStats, | |
| 720 | + BeautyTypeStats = beautyTypeStats | |
| 721 | + }; | |
| 722 | + | |
| 723 | + return result; | |
| 724 | + } | |
| 725 | + catch (Exception ex) | |
| 726 | + { | |
| 727 | + _logger.LogError(ex, $"获取本月成交总额穿透统计失败 - 统计月份: {input?.StatisticsMonth}"); | |
| 728 | + throw NCCException.Oh($"获取本月成交总额穿透统计失败: {ex.Message}"); | |
| 729 | + } | |
| 730 | + } | |
| 731 | + #endregion | |
| 732 | + | |
| 470 | 733 | #region 人员业绩报表 |
| 471 | 734 | |
| 472 | 735 | /// <summary> | ... | ... |