Commit 5e0c6e5af8843772662a4c93d628a4907a0b8bab
1 parent
fee50345
feat: 完善会员画像功能,优化KPI数据穿透
- 新增会员画像服务,包含基础信息、消费行为、趋势分析等 - 优化KPI数据穿透功能,新增多个分析组件 - 修复消费行为统计数据查询问题 - 调整会员画像弹窗布局,基础信息独立显示 - 权益明细单独作为选项卡展示
Showing
19 changed files
with
6795 additions
and
1155 deletions
antis-ncc-admin/src/components/kpi-drill-dialog.vue
| 1 | 1 | <template> |
| 2 | 2 | <el-dialog :visible.sync="visibleSync" :title="title" :width="dialogWidth" append-to-body |
| 3 | 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> | |
| 4 | + <component :is="currentComponent" v-bind="componentProps" /> | |
| 171 | 5 | </el-dialog> |
| 172 | 6 | </template> |
| 173 | 7 | |
| 174 | 8 | <script> |
| 175 | -import request from '@/utils/request' | |
| 176 | -import dayjs from 'dayjs' | |
| 9 | +import BillingAnalysis from './kpi-drill/billing-analysis.vue' | |
| 10 | +import ConsumeAnalysis from './kpi-drill/consume-analysis.vue' | |
| 11 | +import NetAnalysis from './kpi-drill/net-analysis.vue' | |
| 12 | +import TargetAnalysis from './kpi-drill/target-analysis.vue' | |
| 13 | +import TkAnalysis from './kpi-drill/tk-analysis.vue' | |
| 14 | +import RefundAnalysis from './kpi-drill/refund-analysis.vue' | |
| 177 | 15 | |
| 178 | 16 | export default { |
| 179 | 17 | name: 'KpiDrillDialog', |
| 18 | + components: { | |
| 19 | + BillingAnalysis, | |
| 20 | + ConsumeAnalysis, | |
| 21 | + NetAnalysis, | |
| 22 | + TargetAnalysis, | |
| 23 | + TkAnalysis, | |
| 24 | + RefundAnalysis | |
| 25 | + }, | |
| 180 | 26 | props: { |
| 181 | 27 | visible: { type: Boolean, default: false }, |
| 182 | 28 | title: { type: String, default: '数据穿透' }, |
| ... | ... | @@ -190,25 +36,7 @@ export default { |
| 190 | 36 | }, |
| 191 | 37 | data() { |
| 192 | 38 | 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 | - } | |
| 39 | + visibleSync: false | |
| 212 | 40 | } |
| 213 | 41 | }, |
| 214 | 42 | watch: { |
| ... | ... | @@ -216,653 +44,36 @@ export default { |
| 216 | 44 | immediate: true, |
| 217 | 45 | handler(v) { |
| 218 | 46 | this.visibleSync = v |
| 219 | - if (v) { | |
| 220 | - this.resetAndFetch() | |
| 221 | - } | |
| 222 | 47 | } |
| 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 | 48 | } |
| 237 | 49 | }, |
| 238 | 50 | computed: { |
| 239 | 51 | dialogWidth() { |
| 240 | 52 | return '1360px' |
| 241 | 53 | }, |
| 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(' / ') | |
| 54 | + currentComponent() { | |
| 55 | + const componentMap = { | |
| 56 | + billing: 'BillingAnalysis', | |
| 57 | + consume: 'ConsumeAnalysis', | |
| 58 | + net: 'NetAnalysis', | |
| 59 | + target: 'TargetAnalysis', | |
| 60 | + tk: 'TkAnalysis', | |
| 61 | + refund: 'RefundAnalysis' | |
| 62 | + } | |
| 63 | + return componentMap[this.type] || 'BillingAnalysis' | |
| 258 | 64 | }, |
| 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 | - ] | |
| 65 | + componentProps() { | |
| 66 | + return { | |
| 67 | + filters: this.filters, | |
| 68 | + extra: this.extra, | |
| 69 | + storeOptions: this.storeOptions | |
| 307 | 70 | } |
| 308 | - if (this.innerType === 'billing' && this.type === 'net') return base.billing | |
| 309 | - return base[this.innerType] || base.billing | |
| 310 | 71 | } |
| 311 | 72 | }, |
| 312 | 73 | methods: { |
| 313 | 74 | handleClosed() { |
| 314 | 75 | this.$emit('update:visible', false) |
| 315 | 76 | 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 | 77 | } |
| 867 | 78 | } |
| 868 | 79 | } |
| ... | ... | @@ -888,329 +99,4 @@ export default { |
| 888 | 99 | max-height: calc(90vh - 100px); |
| 889 | 100 | } |
| 890 | 101 | } |
| 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 | 102 | </style> | ... | ... |
antis-ncc-admin/src/components/kpi-drill/billing-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="billing-wrapper"> | |
| 3 | + <div class="billing-layout"> | |
| 4 | + <!-- 左侧:趋势 + 列表 --> | |
| 5 | + <div class="billing-left"> | |
| 6 | + <div class="chart-card trend-card"> | |
| 7 | + <div class="chart-title"> | |
| 8 | + <i class="el-icon-date"></i> | |
| 9 | + 每日开单金额 & 人数 | |
| 10 | + </div> | |
| 11 | + <div ref="billingTrendChart" class="chart-mini"></div> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + <div class="table-card"> | |
| 15 | + <div class="table-header"> | |
| 16 | + <div class="table-title"> | |
| 17 | + <i class="el-icon-document"></i> | |
| 18 | + 本月成交明细 | |
| 19 | + </div> | |
| 20 | + <div class="list-filters inline"> | |
| 21 | + <el-select v-model="listFilter.store" size="mini" placeholder="门店" style="width: 180px;" clearable | |
| 22 | + @change="applyListFilter"> | |
| 23 | + <el-option v-for="s in storeOptions" :key="s.id" :label="s.fullName || s.dm || s.name || s.label" | |
| 24 | + :value="s.id" /> | |
| 25 | + </el-select> | |
| 26 | + </div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <el-table v-loading="loading" :data="displayList" size="small" height="650" border stripe> | |
| 30 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 31 | + :width="col.width" :min-width="col.minWidth"> | |
| 32 | + <template slot-scope="scope"> | |
| 33 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 34 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 35 | + </template> | |
| 36 | + </el-table-column> | |
| 37 | + </el-table> | |
| 38 | + | |
| 39 | + <div class="pagination-bar"> | |
| 40 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 41 | + :total="pagination.total" :current-page="pagination.pageIndex" :page-size="pagination.pageSize" | |
| 42 | + @size-change="handleSizeChange" @current-change="handleCurrentChange" /> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <!-- 右侧:关键指标 + 雷达图 --> | |
| 48 | + <div class="billing-right"> | |
| 49 | + <div class="stat-card neon-green compact"> | |
| 50 | + <div class="stat-icon-circle"> | |
| 51 | + <i class="el-icon-trophy"></i> | |
| 52 | + </div> | |
| 53 | + <div class="stat-content"> | |
| 54 | + <div class="stat-title">开单金额最高会员</div> | |
| 55 | + <div class="stat-body"> | |
| 56 | + <div class="highlight text-ellipsis-2"> | |
| 57 | + {{ billingStats.topMemberAmount.name || '无' }} | |
| 58 | + <span class="value-inline">¥{{ formatMoney(billingStats.topMemberAmount.value) }}</span> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div class="stat-card neon-orange compact"> | |
| 65 | + <div class="stat-icon-circle"> | |
| 66 | + <i class="el-icon-user"></i> | |
| 67 | + </div> | |
| 68 | + <div class="stat-content"> | |
| 69 | + <div class="stat-title">开单次数最多会员</div> | |
| 70 | + <div class="stat-body"> | |
| 71 | + <div class="highlight text-ellipsis-2"> | |
| 72 | + {{ billingStats.topMemberTimes.name || '无' }} | |
| 73 | + <span class="value-inline">{{ billingStats.topMemberTimes.count || 0 }} 次</span> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + <div class="chart-card"> | |
| 80 | + <div class="chart-title"> | |
| 81 | + <i class="el-icon-data-analysis"></i> | |
| 82 | + 品项类型雷达图 | |
| 83 | + </div> | |
| 84 | + <div ref="itemTypeRadarChart" class="chart-mini"></div> | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + <div class="chart-card" style="display: none;"> | |
| 88 | + <div class="chart-title"> | |
| 89 | + <i class="el-icon-pie-chart"></i> | |
| 90 | + 业绩类型占比 | |
| 91 | + </div> | |
| 92 | + <div ref="performanceTypePieChart" class="chart-mini"></div> | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + <div class="chart-card"> | |
| 96 | + <div class="chart-title"> | |
| 97 | + <i class="el-icon-s-data"></i> | |
| 98 | + 科美类型业绩 | |
| 99 | + </div> | |
| 100 | + <div ref="beautyTypeBarChart" class="chart-mini"></div> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | +</template> | |
| 106 | + | |
| 107 | +<script> | |
| 108 | +import request from '@/utils/request' | |
| 109 | +import dayjs from 'dayjs' | |
| 110 | +import * as echarts from 'echarts' | |
| 111 | +import { kpiDrillMixin } from './mixins' | |
| 112 | + | |
| 113 | +export default { | |
| 114 | + name: 'BillingAnalysis', | |
| 115 | + mixins: [kpiDrillMixin], | |
| 116 | + props: { | |
| 117 | + filters: { | |
| 118 | + type: Object, | |
| 119 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 120 | + }, | |
| 121 | + storeOptions: { type: Array, default: () => [] } | |
| 122 | + }, | |
| 123 | + data() { | |
| 124 | + return { | |
| 125 | + loading: false, | |
| 126 | + list: [], | |
| 127 | + displayList: [], | |
| 128 | + billingStats: { | |
| 129 | + itemTypeTop: [], | |
| 130 | + topMemberAmount: { name: '', value: 0 }, | |
| 131 | + topMemberTimes: { name: '', count: 0 }, | |
| 132 | + debtTotal: 0, | |
| 133 | + topDebtMember: { name: '', value: 0 } | |
| 134 | + }, | |
| 135 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 136 | + listFilter: { | |
| 137 | + store: '' | |
| 138 | + }, | |
| 139 | + columns: [ | |
| 140 | + { prop: 'billingTime', label: '开单时间', minWidth: 120 }, | |
| 141 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 142 | + { prop: 'memberName', label: '会员', minWidth: 120 }, | |
| 143 | + { prop: 'itemName', label: '品项', minWidth: 140 }, | |
| 144 | + { prop: 'itemType', label: '品项类型', minWidth: 120 }, | |
| 145 | + { prop: 'projectNumber', label: '项目数', width: 90 }, | |
| 146 | + { prop: 'actualPrice', label: '实付金额', width: 110, type: 'money' }, | |
| 147 | + { prop: 'sourceType', label: '来源类型', minWidth: 110 }, | |
| 148 | + { prop: 'performanceType', label: '业绩类型', minWidth: 110 }, | |
| 149 | + { prop: 'beautyType', label: '科美类型', minWidth: 110 } | |
| 150 | + ] | |
| 151 | + } | |
| 152 | + }, | |
| 153 | + watch: { | |
| 154 | + filters: { | |
| 155 | + deep: true, | |
| 156 | + handler() { | |
| 157 | + this.resetAndFetch() | |
| 158 | + } | |
| 159 | + } | |
| 160 | + }, | |
| 161 | + mounted() { | |
| 162 | + this.fetchData() | |
| 163 | + }, | |
| 164 | + methods: { | |
| 165 | + resetAndFetch() { | |
| 166 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 167 | + this.fetchData() | |
| 168 | + }, | |
| 169 | + handleSizeChange(size) { | |
| 170 | + this.pagination.pageSize = size | |
| 171 | + this.pagination.pageIndex = 1 | |
| 172 | + this.fetchData() | |
| 173 | + }, | |
| 174 | + handleCurrentChange(page) { | |
| 175 | + this.pagination.pageIndex = page | |
| 176 | + this.fetchData() | |
| 177 | + }, | |
| 178 | + async fetchData() { | |
| 179 | + this.loading = true | |
| 180 | + try { | |
| 181 | + const range = this.buildDateRange() | |
| 182 | + const storeId = this.getStoreId() | |
| 183 | + const url = '/api/Extend/LqKdKdjlb/billing-item-detail-list' | |
| 184 | + const data = { | |
| 185 | + currentPage: this.pagination.pageIndex, | |
| 186 | + pageSize: this.pagination.pageSize | |
| 187 | + } | |
| 188 | + if (range) { | |
| 189 | + data.startTime = `${range.start} 00:00:00` | |
| 190 | + data.endTime = `${range.end} 23:59:59` | |
| 191 | + } | |
| 192 | + if (this.listFilter.store) { | |
| 193 | + data.StoreId = this.listFilter.store | |
| 194 | + } else if (storeId) { | |
| 195 | + data.StoreId = storeId | |
| 196 | + } | |
| 197 | + | |
| 198 | + // 获取统计数据 | |
| 199 | + const month = this.getMonth() | |
| 200 | + const statsRes = await request({ | |
| 201 | + url: '/api/Extend/LqReport/get-billing-drill-statistics', | |
| 202 | + method: 'POST', | |
| 203 | + data: { | |
| 204 | + statisticsMonth: month, | |
| 205 | + storeIds: this.filters && this.filters.storeIds ? this.filters.storeIds : [] | |
| 206 | + } | |
| 207 | + }) | |
| 208 | + this.applyBillingStatistics(statsRes && statsRes.data) | |
| 209 | + | |
| 210 | + // 获取列表数据 | |
| 211 | + const res = await request({ url, method: 'GET', data }) | |
| 212 | + await this.handleResponse(res, range) | |
| 213 | + } catch (error) { | |
| 214 | + console.error('Billing analysis load error:', error) | |
| 215 | + this.$message.error(error.message || '加载数据失败') | |
| 216 | + this.list = [] | |
| 217 | + this.displayList = [] | |
| 218 | + } finally { | |
| 219 | + this.loading = false | |
| 220 | + } | |
| 221 | + }, | |
| 222 | + async handleResponse(res, range) { | |
| 223 | + let list = [] | |
| 224 | + let pagination = this.pagination | |
| 225 | + | |
| 226 | + if (res && res.data) { | |
| 227 | + if (res.data.list && res.data.pagination) { | |
| 228 | + list = res.data.list | |
| 229 | + pagination = { | |
| 230 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 231 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 232 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 233 | + } | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + list = list.map(i => ({ | |
| 238 | + billingTime: i.billingTime || i.yjsj || i.CreateTime ? dayjs(i.billingTime || i.yjsj || i.CreateTime).format('YYYY-MM-DD HH:mm') : '', | |
| 239 | + storeName: i.storeName || i.djmdmc || i.store, | |
| 240 | + storeId: i.djmd || i.Djmd || i.storeId, | |
| 241 | + memberName: i.memberName || i.kdhyc || i.MemberName, | |
| 242 | + itemName: i.itemName || i.ItemName, | |
| 243 | + itemType: i.itemType || i.ItemType, | |
| 244 | + projectNumber: i.projectNumber || i.ProjectNumber, | |
| 245 | + actualPrice: i.actualPrice || i.ActualPrice || 0, | |
| 246 | + totalPrice: i.zdyj || i.Zdyj || 0, | |
| 247 | + debt: (i.qk || i.Qk || 0) - (i.paidDebt || i.PaidDebt || 0), | |
| 248 | + paidDebt: i.paidDebt || i.PaidDebt || 0, | |
| 249 | + orderNo: i.id || i.Id || '', | |
| 250 | + sourceType: i.sourceType || i.SourceType || '', | |
| 251 | + performanceType: i.performanceType || i.PerformanceType || '', | |
| 252 | + beautyType: i.beautyType || i.BeautyType || '' | |
| 253 | + })) | |
| 254 | + | |
| 255 | + this.list = list | |
| 256 | + this.displayList = list | |
| 257 | + if (res && res.data && res.data.pagination) { | |
| 258 | + this.pagination = { | |
| 259 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || this.pagination.pageIndex, | |
| 260 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 261 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 262 | + } | |
| 263 | + } | |
| 264 | + }, | |
| 265 | + applyBillingStatistics(statistics) { | |
| 266 | + if (!statistics) { | |
| 267 | + this.billingStats = { | |
| 268 | + itemTypeTop: [], | |
| 269 | + topMemberAmount: { name: '', value: 0 }, | |
| 270 | + topMemberTimes: { name: '', count: 0 }, | |
| 271 | + debtTotal: 0, | |
| 272 | + topDebtMember: { name: '', value: 0 } | |
| 273 | + } | |
| 274 | + this.renderBillingTrend([]) | |
| 275 | + this.renderItemTypeRadar([]) | |
| 276 | + this.renderSourceAndTypeCharts(null) | |
| 277 | + return | |
| 278 | + } | |
| 279 | + | |
| 280 | + const trend = (statistics.DailyTrend || []).map(i => ({ | |
| 281 | + date: i.Date, | |
| 282 | + amount: i.Amount, | |
| 283 | + memberCount: i.MemberCount | |
| 284 | + })) | |
| 285 | + this.renderBillingTrend(trend) | |
| 286 | + | |
| 287 | + const itemTypeRadar = statistics.ItemTypeRadar || [] | |
| 288 | + this.renderItemTypeRadar(itemTypeRadar) | |
| 289 | + | |
| 290 | + this.renderSourceAndTypeCharts({ | |
| 291 | + performanceTypeStats: statistics.PerformanceTypeStats || [], | |
| 292 | + beautyTypeStats: statistics.BeautyTypeStats || [] | |
| 293 | + }) | |
| 294 | + | |
| 295 | + const memberStats = statistics.MemberStats || {} | |
| 296 | + this.billingStats = { | |
| 297 | + itemTypeTop: (itemTypeRadar || []).sort((a, b) => (b.Amount || 0) - (a.Amount || 0)).slice(0, 3), | |
| 298 | + topMemberAmount: { | |
| 299 | + name: memberStats.TopAmountMemberName || '—', | |
| 300 | + value: memberStats.TopAmountValue || 0 | |
| 301 | + }, | |
| 302 | + topMemberTimes: { | |
| 303 | + name: memberStats.TopTimesMemberName || '—', | |
| 304 | + count: memberStats.TopTimesCount || 0 | |
| 305 | + }, | |
| 306 | + debtTotal: memberStats.DebtTotal || 0, | |
| 307 | + topDebtMember: { name: '', value: 0 } | |
| 308 | + } | |
| 309 | + }, | |
| 310 | + renderBillingTrend(trend) { | |
| 311 | + const dom = this.$refs.billingTrendChart | |
| 312 | + if (!dom) return | |
| 313 | + const chart = echarts.init(dom) | |
| 314 | + const dates = trend.map(i => i.date) | |
| 315 | + chart.setOption({ | |
| 316 | + tooltip: { trigger: 'axis' }, | |
| 317 | + legend: { data: ['开单金额', '开单人数'], textStyle: { color: '#606266' } }, | |
| 318 | + grid: { left: '6%', right: '4%', top: '10%', bottom: '14%' }, | |
| 319 | + xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#dcdfe6' } }, axisLabel: { color: '#606266', rotate: 40 } }, | |
| 320 | + yAxis: [ | |
| 321 | + { type: 'value', name: '金额', axisLabel: { color: '#606266' }, splitLine: { lineStyle: { color: '#ebeef5' } } }, | |
| 322 | + { type: 'value', name: '人数', axisLabel: { color: '#606266' }, splitLine: { show: false } } | |
| 323 | + ], | |
| 324 | + series: [ | |
| 325 | + { name: '开单金额', type: 'bar', data: trend.map(i => i.amount), itemStyle: { color: '#409EFF' }, barWidth: 12 }, | |
| 326 | + { name: '开单人数', type: 'line', yAxisIndex: 1, data: trend.map(i => i.memberCount), smooth: true, itemStyle: { color: '#67C23A' } } | |
| 327 | + ] | |
| 328 | + }) | |
| 329 | + }, | |
| 330 | + renderItemTypeRadar(list) { | |
| 331 | + const dom = this.$refs.itemTypeRadarChart | |
| 332 | + if (!dom) return | |
| 333 | + const chart = echarts.init(dom) | |
| 334 | + if (!list || !list.length) { | |
| 335 | + chart.clear() | |
| 336 | + return | |
| 337 | + } | |
| 338 | + const maxVal = Math.max(...list.map(x => Number(x.value || x.Amount || 0))) || 1 | |
| 339 | + const indicators = list.map(i => ({ | |
| 340 | + name: i.name || i.Name, | |
| 341 | + max: maxVal | |
| 342 | + })) | |
| 343 | + chart.setOption({ | |
| 344 | + tooltip: {}, | |
| 345 | + radar: { | |
| 346 | + indicator: indicators, | |
| 347 | + splitNumber: 4, | |
| 348 | + radius: '70%', | |
| 349 | + name: { textStyle: { color: '#606266', fontSize: 11 } }, | |
| 350 | + splitLine: { lineStyle: { color: ['#dcdfe6', '#ebeef5'] } }, | |
| 351 | + splitArea: { areaStyle: { color: ['#f5f7fa', '#fff'] } }, | |
| 352 | + axisLine: { lineStyle: { color: '#dcdfe6' } } | |
| 353 | + }, | |
| 354 | + series: [{ | |
| 355 | + type: 'radar', | |
| 356 | + data: [{ | |
| 357 | + value: list.map(i => Number(i.value || i.Amount || 0)), | |
| 358 | + name: '金额', | |
| 359 | + areaStyle: { color: 'rgba(64,158,255,0.25)' }, | |
| 360 | + lineStyle: { color: '#409EFF' }, | |
| 361 | + symbol: 'circle', | |
| 362 | + symbolSize: 3 | |
| 363 | + }] | |
| 364 | + }] | |
| 365 | + }) | |
| 366 | + }, | |
| 367 | + renderSourceAndTypeCharts(payload) { | |
| 368 | + if (!payload) { | |
| 369 | + ['performanceTypePieChart', 'beautyTypeBarChart'].forEach(refName => { | |
| 370 | + const dom = this.$refs[refName] | |
| 371 | + if (dom) { | |
| 372 | + const chart = echarts.init(dom) | |
| 373 | + chart.clear() | |
| 374 | + } | |
| 375 | + }) | |
| 376 | + return | |
| 377 | + } | |
| 378 | + | |
| 379 | + const perfStats = payload.performanceTypeStats || [] | |
| 380 | + const beautyStats = payload.beautyTypeStats || [] | |
| 381 | + | |
| 382 | + // 业绩类型金额饼图 | |
| 383 | + const perfDom = this.$refs.performanceTypePieChart | |
| 384 | + if (perfDom) { | |
| 385 | + const chart = echarts.init(perfDom) | |
| 386 | + chart.setOption({ | |
| 387 | + tooltip: { | |
| 388 | + trigger: 'item', | |
| 389 | + formatter: params => { | |
| 390 | + const val = Number(params.value || 0) | |
| 391 | + return `${params.name}<br/>金额:¥${this.formatMoney(val)}` | |
| 392 | + } | |
| 393 | + }, | |
| 394 | + legend: { | |
| 395 | + bottom: 0, | |
| 396 | + left: 'center', | |
| 397 | + textStyle: { fontSize: 10, color: '#606266' } | |
| 398 | + }, | |
| 399 | + series: [ | |
| 400 | + { | |
| 401 | + type: 'pie', | |
| 402 | + radius: ['35%', '70%'], | |
| 403 | + avoidLabelOverlap: false, | |
| 404 | + label: { show: false }, | |
| 405 | + labelLine: { show: false }, | |
| 406 | + data: perfStats.map(i => ({ | |
| 407 | + name: i.Name, | |
| 408 | + value: i.Amount | |
| 409 | + })) | |
| 410 | + } | |
| 411 | + ] | |
| 412 | + }) | |
| 413 | + } | |
| 414 | + | |
| 415 | + // 科美类型金额柱状图 | |
| 416 | + const beautyDom = this.$refs.beautyTypeBarChart | |
| 417 | + if (beautyDom) { | |
| 418 | + const chart = echarts.init(beautyDom) | |
| 419 | + chart.setOption({ | |
| 420 | + tooltip: { | |
| 421 | + trigger: 'axis', | |
| 422 | + formatter: params => { | |
| 423 | + const p = Array.isArray(params) ? params[0] : params | |
| 424 | + const val = Number(p.value || 0) | |
| 425 | + return `${p.name}<br/>金额:¥${this.formatMoney(val)}` | |
| 426 | + } | |
| 427 | + }, | |
| 428 | + grid: { left: '8%', right: '4%', top: '10%', bottom: '18%' }, | |
| 429 | + xAxis: { | |
| 430 | + type: 'category', | |
| 431 | + data: beautyStats.map(i => i.Name), | |
| 432 | + axisLabel: { color: '#606266', fontSize: 10 } | |
| 433 | + }, | |
| 434 | + yAxis: { | |
| 435 | + type: 'value', | |
| 436 | + axisLabel: { color: '#606266' }, | |
| 437 | + splitLine: { lineStyle: { color: '#ebeef5' } } | |
| 438 | + }, | |
| 439 | + series: [ | |
| 440 | + { | |
| 441 | + type: 'bar', | |
| 442 | + data: beautyStats.map(i => i.Amount), | |
| 443 | + barWidth: 14, | |
| 444 | + itemStyle: { color: '#909399' } | |
| 445 | + } | |
| 446 | + ] | |
| 447 | + }) | |
| 448 | + } | |
| 449 | + }, | |
| 450 | + applyListFilter() { | |
| 451 | + this.pagination.pageIndex = 1 | |
| 452 | + this.fetchData() | |
| 453 | + } | |
| 454 | + } | |
| 455 | +} | |
| 456 | +</script> | |
| 457 | + | |
| 458 | +<style lang="scss" scoped> | |
| 459 | +@import './common-styles.scss'; | |
| 460 | +</style> | |
| 461 | + | ... | ... |
antis-ncc-admin/src/components/kpi-drill/common-styles.scss
0 → 100644
| 1 | +// KPI穿透组件公共样式 | |
| 2 | + | |
| 3 | +.billing-wrapper { | |
| 4 | + width: 100%; | |
| 5 | + overflow: hidden; | |
| 6 | + | |
| 7 | + .billing-layout { | |
| 8 | + display: flex; | |
| 9 | + align-items: stretch; | |
| 10 | + justify-content: space-between; | |
| 11 | + gap: 16px; | |
| 12 | + width: 100%; | |
| 13 | + } | |
| 14 | + | |
| 15 | + .billing-left { | |
| 16 | + flex: 0 0 75%; | |
| 17 | + max-width: 75%; | |
| 18 | + display: flex; | |
| 19 | + flex-direction: column; | |
| 20 | + gap: 12px; | |
| 21 | + min-width: 0; | |
| 22 | + } | |
| 23 | + | |
| 24 | + .billing-right { | |
| 25 | + flex: 0 0 25%; | |
| 26 | + max-width: 23.5%; | |
| 27 | + display: flex; | |
| 28 | + flex-direction: column; | |
| 29 | + gap: 12px; | |
| 30 | + min-width: 0; | |
| 31 | + } | |
| 32 | + | |
| 33 | + .chart-card { | |
| 34 | + background: #fff; | |
| 35 | + border: 1px solid #ebeef5; | |
| 36 | + border-radius: 10px; | |
| 37 | + padding: 10px; | |
| 38 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 39 | + flex-shrink: 0; | |
| 40 | + | |
| 41 | + .chart-title { | |
| 42 | + font-size: 13px; | |
| 43 | + color: #606266; | |
| 44 | + margin-bottom: 6px; | |
| 45 | + display: flex; | |
| 46 | + align-items: center; | |
| 47 | + gap: 6px; | |
| 48 | + | |
| 49 | + i { | |
| 50 | + color: #409EFF; | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + .chart-mini { | |
| 55 | + height: 220px; | |
| 56 | + width: 100%; | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + .table-card { | |
| 61 | + background: #fff; | |
| 62 | + border: 1px solid #ebeef5; | |
| 63 | + border-radius: 10px; | |
| 64 | + padding: 10px 12px; | |
| 65 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 66 | + flex: 1; | |
| 67 | + min-height: 700px; | |
| 68 | + display: flex; | |
| 69 | + flex-direction: column; | |
| 70 | + | |
| 71 | + .table-header { | |
| 72 | + display: flex; | |
| 73 | + align-items: center; | |
| 74 | + justify-content: space-between; | |
| 75 | + margin-bottom: 8px; | |
| 76 | + flex-shrink: 0; | |
| 77 | + } | |
| 78 | + | |
| 79 | + .table-title { | |
| 80 | + font-size: 14px; | |
| 81 | + font-weight: 600; | |
| 82 | + color: #303133; | |
| 83 | + display: flex; | |
| 84 | + align-items: center; | |
| 85 | + gap: 6px; | |
| 86 | + | |
| 87 | + i { | |
| 88 | + color: #409EFF; | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + .el-table { | |
| 93 | + flex: 1; | |
| 94 | + min-height: 0; | |
| 95 | + } | |
| 96 | + | |
| 97 | + .pagination-bar { | |
| 98 | + flex-shrink: 0; | |
| 99 | + margin-top: 8px; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + .stat-card { | |
| 104 | + background: #fff; | |
| 105 | + border: 1px solid #ebeef5; | |
| 106 | + border-radius: 10px; | |
| 107 | + padding: 10px 12px; | |
| 108 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 109 | + flex-shrink: 0; | |
| 110 | + | |
| 111 | + &.compact { | |
| 112 | + display: flex; | |
| 113 | + align-items: center; | |
| 114 | + gap: 10px; | |
| 115 | + min-height: 70px; | |
| 116 | + } | |
| 117 | + | |
| 118 | + .stat-icon-circle { | |
| 119 | + width: 32px; | |
| 120 | + height: 32px; | |
| 121 | + border-radius: 50%; | |
| 122 | + display: flex; | |
| 123 | + align-items: center; | |
| 124 | + justify-content: center; | |
| 125 | + color: #fff; | |
| 126 | + flex-shrink: 0; | |
| 127 | + | |
| 128 | + i { | |
| 129 | + font-size: 16px; | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + &.neon-green .stat-icon-circle { | |
| 134 | + background: #67C23A; | |
| 135 | + } | |
| 136 | + | |
| 137 | + &.neon-orange .stat-icon-circle { | |
| 138 | + background: #E6A23C; | |
| 139 | + } | |
| 140 | + | |
| 141 | + &.neon-red .stat-icon-circle { | |
| 142 | + background: #F56C6C; | |
| 143 | + } | |
| 144 | + | |
| 145 | + &.neon-blue .stat-icon-circle { | |
| 146 | + background: #409EFF; | |
| 147 | + } | |
| 148 | + | |
| 149 | + &.neon-purple .stat-icon-circle { | |
| 150 | + background: #9C27B0; | |
| 151 | + } | |
| 152 | + | |
| 153 | + &.neon-cyan .stat-icon-circle { | |
| 154 | + background: #17A2B8; | |
| 155 | + } | |
| 156 | + | |
| 157 | + .stat-content { | |
| 158 | + flex: 1; | |
| 159 | + min-width: 0; | |
| 160 | + } | |
| 161 | + | |
| 162 | + .stat-title { | |
| 163 | + font-size: 13px; | |
| 164 | + color: #606266; | |
| 165 | + margin-bottom: 4px; | |
| 166 | + line-height: 1.3; | |
| 167 | + } | |
| 168 | + | |
| 169 | + .stat-body { | |
| 170 | + color: #303133; | |
| 171 | + | |
| 172 | + .value-lg { | |
| 173 | + font-size: 18px; | |
| 174 | + font-weight: 700; | |
| 175 | + margin-top: 4px; | |
| 176 | + } | |
| 177 | + | |
| 178 | + .highlight { | |
| 179 | + font-size: 14px; | |
| 180 | + font-weight: 600; | |
| 181 | + line-height: 1.3; | |
| 182 | + display: flex; | |
| 183 | + align-items: center; | |
| 184 | + gap: 8px; | |
| 185 | + flex-wrap: wrap; | |
| 186 | + | |
| 187 | + .value-inline { | |
| 188 | + font-size: 16px; | |
| 189 | + font-weight: 700; | |
| 190 | + color: #409EFF; | |
| 191 | + white-space: nowrap; | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + &.neon-green { | |
| 197 | + border-color: #e1f3d8; | |
| 198 | + } | |
| 199 | + | |
| 200 | + &.neon-orange { | |
| 201 | + border-color: #fde3c9; | |
| 202 | + } | |
| 203 | + | |
| 204 | + &.neon-red { | |
| 205 | + border-color: #fde2e2; | |
| 206 | + } | |
| 207 | + | |
| 208 | + &.neon-cyan { | |
| 209 | + border-color: #d1ecf1; | |
| 210 | + } | |
| 211 | + } | |
| 212 | +} | |
| 213 | + | |
| 214 | +.pagination-bar { | |
| 215 | + display: flex; | |
| 216 | + justify-content: flex-end; | |
| 217 | + padding: 10px 0 4px 0; | |
| 218 | +} | |
| 219 | + | |
| 220 | +.list-filters { | |
| 221 | + display: flex; | |
| 222 | + align-items: center; | |
| 223 | + justify-content: flex-start; | |
| 224 | + margin: 8px 0; | |
| 225 | + | |
| 226 | + &.inline { | |
| 227 | + margin: 0; | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 231 | +.text-ellipsis-2 { | |
| 232 | + display: -webkit-box; | |
| 233 | + -webkit-line-clamp: 2; | |
| 234 | + line-clamp: 2; | |
| 235 | + -webkit-box-orient: vertical; | |
| 236 | + overflow: hidden; | |
| 237 | +} | |
| 238 | + | ... | ... |
antis-ncc-admin/src/components/kpi-drill/consume-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="billing-wrapper"> | |
| 3 | + <div class="billing-layout"> | |
| 4 | + <!-- 左侧:趋势 + 列表 --> | |
| 5 | + <div class="billing-left"> | |
| 6 | + <div class="chart-card trend-card"> | |
| 7 | + <div class="chart-title"> | |
| 8 | + <i class="el-icon-date"></i> | |
| 9 | + 每日消耗金额 & 人数 | |
| 10 | + </div> | |
| 11 | + <div ref="consumeTrendChart" class="chart-mini"></div> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + <div class="table-card"> | |
| 15 | + <div class="table-header"> | |
| 16 | + <div class="table-title"> | |
| 17 | + <i class="el-icon-document"></i> | |
| 18 | + 本月消耗明细 | |
| 19 | + </div> | |
| 20 | + <div class="list-filters inline"> | |
| 21 | + <el-select v-model="listFilter.store" size="mini" placeholder="门店" style="width: 180px;" clearable | |
| 22 | + @change="applyListFilter"> | |
| 23 | + <el-option v-for="s in storeOptions" :key="s.id" :label="s.fullName || s.dm || s.name || s.label" | |
| 24 | + :value="s.id" /> | |
| 25 | + </el-select> | |
| 26 | + </div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <el-table v-loading="loading" :data="displayList" size="small" height="650" border stripe> | |
| 30 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 31 | + :width="col.width" :min-width="col.minWidth"> | |
| 32 | + <template slot-scope="scope"> | |
| 33 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 34 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 35 | + </template> | |
| 36 | + </el-table-column> | |
| 37 | + </el-table> | |
| 38 | + | |
| 39 | + <div class="pagination-bar"> | |
| 40 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 41 | + :total="pagination.total" :current-page="pagination.pageIndex" :page-size="pagination.pageSize" | |
| 42 | + @size-change="handleSizeChange" @current-change="handleCurrentChange" /> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <!-- 右侧:关键指标 --> | |
| 48 | + <div class="billing-right"> | |
| 49 | + <div class="stat-card neon-green compact"> | |
| 50 | + <div class="stat-icon-circle"> | |
| 51 | + <i class="el-icon-trophy"></i> | |
| 52 | + </div> | |
| 53 | + <div class="stat-content"> | |
| 54 | + <div class="stat-title">消耗金额最高会员</div> | |
| 55 | + <div class="stat-body"> | |
| 56 | + <div class="highlight text-ellipsis-2"> | |
| 57 | + {{ consumeStats.topMemberAmount.name || '无' }} | |
| 58 | + <span class="value-inline">¥{{ formatMoney(consumeStats.topMemberAmount.value) }}</span> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div class="stat-card neon-orange compact"> | |
| 65 | + <div class="stat-icon-circle"> | |
| 66 | + <i class="el-icon-user"></i> | |
| 67 | + </div> | |
| 68 | + <div class="stat-content"> | |
| 69 | + <div class="stat-title">消耗次数最多会员</div> | |
| 70 | + <div class="stat-body"> | |
| 71 | + <div class="highlight text-ellipsis-2"> | |
| 72 | + {{ consumeStats.topMemberTimes.name || '无' }} | |
| 73 | + <span class="value-inline">{{ consumeStats.topMemberTimes.count || 0 }} 次</span> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + <div class="stat-card neon-blue compact"> | |
| 80 | + <div class="stat-icon-circle"> | |
| 81 | + <i class="el-icon-suitcase"></i> | |
| 82 | + </div> | |
| 83 | + <div class="stat-content"> | |
| 84 | + <div class="stat-title">科技老师手工费合计</div> | |
| 85 | + <div class="stat-body"> | |
| 86 | + <div class="value-lg">¥{{ formatMoney(consumeStats.techTeacherLaborCostTotal) }}</div> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + </div> | |
| 90 | + | |
| 91 | + <div class="stat-card neon-purple compact"> | |
| 92 | + <div class="stat-icon-circle"> | |
| 93 | + <i class="el-icon-user-solid"></i> | |
| 94 | + </div> | |
| 95 | + <div class="stat-content"> | |
| 96 | + <div class="stat-title">健康师手工费合计</div> | |
| 97 | + <div class="stat-body"> | |
| 98 | + <div class="value-lg">¥{{ formatMoney(consumeStats.healthCoachLaborCostTotal) }}</div> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <div class="stat-card neon-red compact"> | |
| 104 | + <div class="stat-icon-circle"> | |
| 105 | + <i class="el-icon-coin"></i> | |
| 106 | + </div> | |
| 107 | + <div class="stat-content"> | |
| 108 | + <div class="stat-title">单次消耗最大金额</div> | |
| 109 | + <div class="stat-body"> | |
| 110 | + <div class="value-lg">¥{{ formatMoney(consumeStats.maxSingleConsumeAmount) }}</div> | |
| 111 | + </div> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | +</template> | |
| 118 | + | |
| 119 | +<script> | |
| 120 | +import request from '@/utils/request' | |
| 121 | +import dayjs from 'dayjs' | |
| 122 | +import * as echarts from 'echarts' | |
| 123 | +import { kpiDrillMixin } from './mixins' | |
| 124 | + | |
| 125 | +export default { | |
| 126 | + name: 'ConsumeAnalysis', | |
| 127 | + mixins: [kpiDrillMixin], | |
| 128 | + props: { | |
| 129 | + filters: { | |
| 130 | + type: Object, | |
| 131 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 132 | + }, | |
| 133 | + storeOptions: { type: Array, default: () => [] } | |
| 134 | + }, | |
| 135 | + data() { | |
| 136 | + return { | |
| 137 | + loading: false, | |
| 138 | + list: [], | |
| 139 | + displayList: [], | |
| 140 | + consumeStats: { | |
| 141 | + topMemberAmount: { name: '', value: 0 }, | |
| 142 | + topMemberTimes: { name: '', count: 0 }, | |
| 143 | + techTeacherLaborCostTotal: 0, | |
| 144 | + healthCoachLaborCostTotal: 0, | |
| 145 | + maxSingleConsumeAmount: 0 | |
| 146 | + }, | |
| 147 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 148 | + listFilter: { | |
| 149 | + store: '' | |
| 150 | + }, | |
| 151 | + columns: [ | |
| 152 | + { prop: 'consumeTime', label: '耗卡时间', minWidth: 120 }, | |
| 153 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 154 | + { prop: 'memberName', label: '会员', minWidth: 120 }, | |
| 155 | + { prop: 'itemName', label: '品项', minWidth: 140 }, | |
| 156 | + { prop: 'itemType', label: '品项类型', minWidth: 120 }, | |
| 157 | + { prop: 'projectNumber', label: '项目数', width: 90 }, | |
| 158 | + { prop: 'totalPrice', label: '消耗金额', width: 110, type: 'money' } | |
| 159 | + ] | |
| 160 | + } | |
| 161 | + }, | |
| 162 | + watch: { | |
| 163 | + filters: { | |
| 164 | + deep: true, | |
| 165 | + handler() { | |
| 166 | + this.resetAndFetch() | |
| 167 | + } | |
| 168 | + } | |
| 169 | + }, | |
| 170 | + mounted() { | |
| 171 | + this.fetchData() | |
| 172 | + }, | |
| 173 | + methods: { | |
| 174 | + resetAndFetch() { | |
| 175 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 176 | + this.fetchData() | |
| 177 | + }, | |
| 178 | + handleSizeChange(size) { | |
| 179 | + this.pagination.pageSize = size | |
| 180 | + this.pagination.pageIndex = 1 | |
| 181 | + this.fetchData() | |
| 182 | + }, | |
| 183 | + handleCurrentChange(page) { | |
| 184 | + this.pagination.pageIndex = page | |
| 185 | + this.fetchData() | |
| 186 | + }, | |
| 187 | + async fetchData() { | |
| 188 | + this.loading = true | |
| 189 | + try { | |
| 190 | + const range = this.buildDateRange() | |
| 191 | + const storeId = this.getStoreId() | |
| 192 | + const url = '/api/Extend/LqXhHyhk/consume-item-detail-list' | |
| 193 | + const data = { | |
| 194 | + currentPage: this.pagination.pageIndex, | |
| 195 | + pageSize: this.pagination.pageSize | |
| 196 | + } | |
| 197 | + if (range) { | |
| 198 | + data.startTime = `${range.start} 00:00:00` | |
| 199 | + data.endTime = `${range.end} 23:59:59` | |
| 200 | + } | |
| 201 | + if (this.listFilter.store) { | |
| 202 | + data.StoreId = this.listFilter.store | |
| 203 | + } else if (storeId) { | |
| 204 | + data.storeId = storeId | |
| 205 | + } | |
| 206 | + | |
| 207 | + // 获取统计数据 | |
| 208 | + const month = this.getMonth() | |
| 209 | + const statsRes = await request({ | |
| 210 | + url: '/api/Extend/LqReport/get-consume-drill-statistics', | |
| 211 | + method: 'POST', | |
| 212 | + data: { | |
| 213 | + statisticsMonth: month, | |
| 214 | + storeIds: this.filters && this.filters.storeIds ? this.filters.storeIds : [] | |
| 215 | + } | |
| 216 | + }) | |
| 217 | + this.applyConsumeStatistics(statsRes && statsRes.data) | |
| 218 | + | |
| 219 | + // 获取列表数据 | |
| 220 | + const res = await request({ url, method: 'GET', data }) | |
| 221 | + await this.handleResponse(res, range) | |
| 222 | + } catch (error) { | |
| 223 | + console.error('Consume analysis load error:', error) | |
| 224 | + this.$message.error(error.message || '加载数据失败') | |
| 225 | + this.list = [] | |
| 226 | + this.displayList = [] | |
| 227 | + } finally { | |
| 228 | + this.loading = false | |
| 229 | + } | |
| 230 | + }, | |
| 231 | + async handleResponse(res, range) { | |
| 232 | + let list = [] | |
| 233 | + let pagination = this.pagination | |
| 234 | + | |
| 235 | + if (res && res.data) { | |
| 236 | + if (res.data.list && res.data.pagination) { | |
| 237 | + list = res.data.list | |
| 238 | + pagination = { | |
| 239 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 240 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 241 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 242 | + } | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + list = list.map(i => ({ | |
| 247 | + consumeTime: i.consumeTime || i.hksj || i.CreateTime ? dayjs(i.consumeTime || i.hksj || i.CreateTime).format('YYYY-MM-DD HH:mm') : '', | |
| 248 | + storeName: i.storeName || i.mdmc, | |
| 249 | + memberName: i.memberName || i.hymc, | |
| 250 | + itemName: i.itemName || i.pxmc || i.ItemName, | |
| 251 | + itemType: i.itemType || i.ItemType, | |
| 252 | + projectNumber: i.projectNumber || i.projectNumber || i.ProjectNumber, | |
| 253 | + totalPrice: i.totalPrice || i.totalPrice || i.xfje || 0 | |
| 254 | + })) | |
| 255 | + | |
| 256 | + this.list = list | |
| 257 | + this.displayList = list | |
| 258 | + if (res && res.data && res.data.pagination) { | |
| 259 | + this.pagination = { | |
| 260 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || this.pagination.pageIndex, | |
| 261 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 262 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 263 | + } | |
| 264 | + } | |
| 265 | + }, | |
| 266 | + applyConsumeStatistics(statistics) { | |
| 267 | + if (!statistics) { | |
| 268 | + this.consumeStats = { | |
| 269 | + topMemberAmount: { name: '', value: 0 }, | |
| 270 | + topMemberTimes: { name: '', count: 0 }, | |
| 271 | + techTeacherLaborCostTotal: 0, | |
| 272 | + healthCoachLaborCostTotal: 0, | |
| 273 | + maxSingleConsumeAmount: 0 | |
| 274 | + } | |
| 275 | + this.renderConsumeTrend([]) | |
| 276 | + return | |
| 277 | + } | |
| 278 | + | |
| 279 | + const trend = (statistics.DailyTrend || []).map(i => ({ | |
| 280 | + date: i.Date, | |
| 281 | + amount: i.Amount, | |
| 282 | + memberCount: i.MemberCount | |
| 283 | + })) | |
| 284 | + this.renderConsumeTrend(trend) | |
| 285 | + | |
| 286 | + const memberStats = statistics.MemberStats || {} | |
| 287 | + this.consumeStats = { | |
| 288 | + topMemberAmount: { | |
| 289 | + name: memberStats.TopAmountMemberName || '—', | |
| 290 | + value: memberStats.TopAmountValue || 0 | |
| 291 | + }, | |
| 292 | + topMemberTimes: { | |
| 293 | + name: memberStats.TopTimesMemberName || '—', | |
| 294 | + count: memberStats.TopTimesCount || 0 | |
| 295 | + }, | |
| 296 | + techTeacherLaborCostTotal: statistics.TechTeacherLaborCostTotal || 0, | |
| 297 | + healthCoachLaborCostTotal: statistics.HealthCoachLaborCostTotal || 0, | |
| 298 | + maxSingleConsumeAmount: statistics.MaxSingleConsumeAmount || 0 | |
| 299 | + } | |
| 300 | + }, | |
| 301 | + renderConsumeTrend(trend) { | |
| 302 | + const dom = this.$refs.consumeTrendChart | |
| 303 | + if (!dom) return | |
| 304 | + const chart = echarts.init(dom) | |
| 305 | + const dates = trend.map(i => i.date) | |
| 306 | + chart.setOption({ | |
| 307 | + tooltip: { trigger: 'axis' }, | |
| 308 | + legend: { data: ['消耗金额', '消耗人数'], textStyle: { color: '#606266' } }, | |
| 309 | + grid: { left: '6%', right: '4%', top: '10%', bottom: '14%' }, | |
| 310 | + xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#dcdfe6' } }, axisLabel: { color: '#606266', rotate: 40 } }, | |
| 311 | + yAxis: [ | |
| 312 | + { type: 'value', name: '金额', axisLabel: { color: '#606266' }, splitLine: { lineStyle: { color: '#ebeef5' } } }, | |
| 313 | + { type: 'value', name: '人数', axisLabel: { color: '#606266' }, splitLine: { show: false } } | |
| 314 | + ], | |
| 315 | + series: [ | |
| 316 | + { name: '消耗金额', type: 'bar', data: trend.map(i => i.amount), itemStyle: { color: '#67C23A' }, barWidth: 12 }, | |
| 317 | + { name: '消耗人数', type: 'line', yAxisIndex: 1, data: trend.map(i => i.memberCount), smooth: true, itemStyle: { color: '#409EFF' } } | |
| 318 | + ] | |
| 319 | + }) | |
| 320 | + }, | |
| 321 | + applyListFilter() { | |
| 322 | + this.pagination.pageIndex = 1 | |
| 323 | + this.fetchData() | |
| 324 | + } | |
| 325 | + } | |
| 326 | +} | |
| 327 | +</script> | |
| 328 | + | |
| 329 | +<style lang="scss" scoped> | |
| 330 | +@import './common-styles.scss'; | |
| 331 | +</style> | |
| 332 | + | ... | ... |
antis-ncc-admin/src/components/kpi-drill/mixins.js
0 → 100644
| 1 | +import request from '@/utils/request' | |
| 2 | +import dayjs from 'dayjs' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * KPI穿透组件公共mixin | |
| 6 | + */ | |
| 7 | +export const kpiDrillMixin = { | |
| 8 | + methods: { | |
| 9 | + formatMoney(v) { | |
| 10 | + const num = Number(v || 0) | |
| 11 | + return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) | |
| 12 | + }, | |
| 13 | + buildDateRange() { | |
| 14 | + const { startTime, endTime } = this.filters || {} | |
| 15 | + if (!startTime || !endTime) return null | |
| 16 | + const start = dayjs(startTime).format('YYYY-MM-DD') | |
| 17 | + const end = dayjs(endTime).format('YYYY-MM-DD') | |
| 18 | + const startTs = dayjs(`${start} 00:00:00`).valueOf() | |
| 19 | + const endTs = dayjs(`${end} 23:59:59`).valueOf() | |
| 20 | + return { start, end, startTs, endTs } | |
| 21 | + }, | |
| 22 | + getStoreId() { | |
| 23 | + return (this.filters && this.filters.storeIds && this.filters.storeIds.length === 1) | |
| 24 | + ? this.filters.storeIds[0] | |
| 25 | + : undefined | |
| 26 | + }, | |
| 27 | + getMonth() { | |
| 28 | + return this.filters && this.filters.month | |
| 29 | + ? this.filters.month.toString() | |
| 30 | + : (this.buildDateRange() ? dayjs(this.buildDateRange().start).format('YYYYMM') : dayjs().format('YYYYMM')) | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | ... | ... |
antis-ncc-admin/src/components/kpi-drill/net-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="net-wrapper"> | |
| 3 | + <div class="net-stats"> | |
| 4 | + <div class="stat-card net-billing"> | |
| 5 | + <div class="stat-icon-circle"> | |
| 6 | + <i class="el-icon-wallet"></i> | |
| 7 | + </div> | |
| 8 | + <div class="stat-content"> | |
| 9 | + <div class="stat-title">开单总额</div> | |
| 10 | + <div class="stat-value">¥{{ formatMoney(extra.actualAmount || 0) }}</div> | |
| 11 | + </div> | |
| 12 | + </div> | |
| 13 | + <div class="stat-card net-refund"> | |
| 14 | + <div class="stat-icon-circle"> | |
| 15 | + <i class="el-icon-warning-outline"></i> | |
| 16 | + </div> | |
| 17 | + <div class="stat-content"> | |
| 18 | + <div class="stat-title">退卡总额</div> | |
| 19 | + <div class="stat-value">¥{{ formatMoney(extra.refundAmount || 0) }}</div> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | + <div class="stat-card net-amount"> | |
| 23 | + <div class="stat-icon-circle"> | |
| 24 | + <i class="el-icon-trophy"></i> | |
| 25 | + </div> | |
| 26 | + <div class="stat-content"> | |
| 27 | + <div class="stat-title">完成业绩(净额)</div> | |
| 28 | + <div class="stat-value">¥{{ formatMoney((extra.actualAmount || 0) - (extra.refundAmount || 0)) }}</div> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + <div class="net-tabs"> | |
| 33 | + <el-radio-group v-model="innerType" size="small" @change="resetAndFetch"> | |
| 34 | + <el-radio-button label="billing">开单明细</el-radio-button> | |
| 35 | + <el-radio-button label="refund">退卡明细</el-radio-button> | |
| 36 | + </el-radio-group> | |
| 37 | + </div> | |
| 38 | + <div class="net-table-card"> | |
| 39 | + <el-table v-loading="loading" :data="displayList" size="small" height="600" border stripe> | |
| 40 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 41 | + :width="col.width" :min-width="col.minWidth"> | |
| 42 | + <template slot-scope="scope"> | |
| 43 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 44 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 45 | + </template> | |
| 46 | + </el-table-column> | |
| 47 | + </el-table> | |
| 48 | + <div class="pagination-bar"> | |
| 49 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="pagination.total" | |
| 50 | + :current-page="pagination.pageIndex" :page-size="pagination.pageSize" @size-change="handleSizeChange" | |
| 51 | + @current-change="handleCurrentChange" /> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | +</template> | |
| 56 | + | |
| 57 | +<script> | |
| 58 | +import request from '@/utils/request' | |
| 59 | +import dayjs from 'dayjs' | |
| 60 | +import { kpiDrillMixin } from './mixins' | |
| 61 | + | |
| 62 | +export default { | |
| 63 | + name: 'NetAnalysis', | |
| 64 | + mixins: [kpiDrillMixin], | |
| 65 | + props: { | |
| 66 | + filters: { | |
| 67 | + type: Object, | |
| 68 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 69 | + }, | |
| 70 | + extra: { type: Object, default: () => ({}) }, | |
| 71 | + storeOptions: { type: Array, default: () => [] } | |
| 72 | + }, | |
| 73 | + data() { | |
| 74 | + return { | |
| 75 | + loading: false, | |
| 76 | + list: [], | |
| 77 | + displayList: [], | |
| 78 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 79 | + innerType: 'billing' | |
| 80 | + } | |
| 81 | + }, | |
| 82 | + computed: { | |
| 83 | + columns() { | |
| 84 | + const base = { | |
| 85 | + billing: [ | |
| 86 | + { prop: 'billingTime', label: '开单时间', minWidth: 120 }, | |
| 87 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 88 | + { prop: 'memberName', label: '会员', minWidth: 120 }, | |
| 89 | + { prop: 'itemName', label: '品项', minWidth: 140 }, | |
| 90 | + { prop: 'itemType', label: '品项类型', minWidth: 120 }, | |
| 91 | + { prop: 'projectNumber', label: '项目数', width: 90 }, | |
| 92 | + { prop: 'actualPrice', label: '实付金额', width: 110, type: 'money' }, | |
| 93 | + { prop: 'sourceType', label: '来源类型', minWidth: 110 }, | |
| 94 | + { prop: 'performanceType', label: '业绩类型', minWidth: 110 }, | |
| 95 | + { prop: 'beautyType', label: '科美类型', minWidth: 110 } | |
| 96 | + ], | |
| 97 | + refund: [ | |
| 98 | + { prop: 'tksj', label: '退卡时间', minWidth: 120 }, | |
| 99 | + { prop: 'mdmc', label: '门店', minWidth: 120 }, | |
| 100 | + { prop: 'hymc', label: '会员', minWidth: 120 }, | |
| 101 | + { prop: 'gklx', label: '顾客类型', minWidth: 100 }, | |
| 102 | + { prop: 'tkje', label: '退款金额', width: 110, type: 'money' }, | |
| 103 | + { prop: 'tkyy', label: '退款原因', minWidth: 140 } | |
| 104 | + ] | |
| 105 | + } | |
| 106 | + return base[this.innerType] || base.billing | |
| 107 | + } | |
| 108 | + }, | |
| 109 | + watch: { | |
| 110 | + filters: { | |
| 111 | + deep: true, | |
| 112 | + handler() { | |
| 113 | + this.resetAndFetch() | |
| 114 | + } | |
| 115 | + }, | |
| 116 | + innerType() { | |
| 117 | + this.resetAndFetch() | |
| 118 | + } | |
| 119 | + }, | |
| 120 | + mounted() { | |
| 121 | + this.fetchData() | |
| 122 | + }, | |
| 123 | + methods: { | |
| 124 | + resetAndFetch() { | |
| 125 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 126 | + this.fetchData() | |
| 127 | + }, | |
| 128 | + handleSizeChange(size) { | |
| 129 | + this.pagination.pageSize = size | |
| 130 | + this.pagination.pageIndex = 1 | |
| 131 | + this.fetchData() | |
| 132 | + }, | |
| 133 | + handleCurrentChange(page) { | |
| 134 | + this.pagination.pageIndex = page | |
| 135 | + this.fetchData() | |
| 136 | + }, | |
| 137 | + async fetchData() { | |
| 138 | + this.loading = true | |
| 139 | + try { | |
| 140 | + const range = this.buildDateRange() | |
| 141 | + const storeId = this.getStoreId() | |
| 142 | + let url = '' | |
| 143 | + let data = { currentPage: this.pagination.pageIndex, pageSize: this.pagination.pageSize } | |
| 144 | + | |
| 145 | + if (this.innerType === 'billing') { | |
| 146 | + url = '/api/Extend/LqKdKdjlb/billing-item-detail-list' | |
| 147 | + if (range) { | |
| 148 | + data.startTime = `${range.start} 00:00:00` | |
| 149 | + data.endTime = `${range.end} 23:59:59` | |
| 150 | + } | |
| 151 | + if (storeId) data.StoreId = storeId | |
| 152 | + } else if (this.innerType === 'refund') { | |
| 153 | + url = '/api/Extend/LqHytkHytk' | |
| 154 | + if (range) { | |
| 155 | + data.tksj = `${range.startTs},${range.endTs}` | |
| 156 | + } | |
| 157 | + if (storeId) data.md = storeId | |
| 158 | + } | |
| 159 | + | |
| 160 | + const res = await request({ url, method: 'GET', data }) | |
| 161 | + await this.handleResponse(res, this.innerType, range) | |
| 162 | + } catch (error) { | |
| 163 | + console.error('Net analysis load error:', error) | |
| 164 | + this.$message.error(error.message || '加载数据失败') | |
| 165 | + this.list = [] | |
| 166 | + this.displayList = [] | |
| 167 | + } finally { | |
| 168 | + this.loading = false | |
| 169 | + } | |
| 170 | + }, | |
| 171 | + async handleResponse(res, activeType, range) { | |
| 172 | + let list = [] | |
| 173 | + let pagination = this.pagination | |
| 174 | + | |
| 175 | + if (res && res.data) { | |
| 176 | + if (res.data.list && res.data.pagination) { | |
| 177 | + list = res.data.list | |
| 178 | + pagination = { | |
| 179 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 180 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 181 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 182 | + } | |
| 183 | + } else if (Array.isArray(res.data.list)) { | |
| 184 | + list = res.data.list | |
| 185 | + } else if (Array.isArray(res.data)) { | |
| 186 | + list = res.data | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + if (activeType === 'billing') { | |
| 191 | + list = list.map(i => ({ | |
| 192 | + billingTime: i.billingTime || i.yjsj || i.CreateTime ? dayjs(i.billingTime || i.yjsj || i.CreateTime).format('YYYY-MM-DD HH:mm') : '', | |
| 193 | + storeName: i.storeName || i.djmdmc || i.store, | |
| 194 | + memberName: i.memberName || i.kdhyc || i.MemberName, | |
| 195 | + itemName: i.itemName || i.ItemName, | |
| 196 | + itemType: i.itemType || i.ItemType, | |
| 197 | + projectNumber: i.projectNumber || i.ProjectNumber, | |
| 198 | + actualPrice: i.actualPrice || i.ActualPrice || 0, | |
| 199 | + sourceType: i.sourceType || i.SourceType || '', | |
| 200 | + performanceType: i.performanceType || i.PerformanceType || '', | |
| 201 | + beautyType: i.beautyType || i.BeautyType || '' | |
| 202 | + })) | |
| 203 | + } else if (activeType === 'refund') { | |
| 204 | + // res.data.list 已经是退卡主表字段,直接使用 | |
| 205 | + } | |
| 206 | + | |
| 207 | + this.list = list | |
| 208 | + this.displayList = list | |
| 209 | + this.pagination = pagination | |
| 210 | + } | |
| 211 | + } | |
| 212 | +} | |
| 213 | +</script> | |
| 214 | + | |
| 215 | +<style lang="scss" scoped> | |
| 216 | +.net-wrapper { | |
| 217 | + width: 100%; | |
| 218 | + display: flex; | |
| 219 | + flex-direction: column; | |
| 220 | + gap: 16px; | |
| 221 | + | |
| 222 | + .net-stats { | |
| 223 | + display: grid; | |
| 224 | + grid-template-columns: repeat(3, 1fr); | |
| 225 | + gap: 16px; | |
| 226 | + margin-bottom: 8px; | |
| 227 | + } | |
| 228 | + | |
| 229 | + .stat-card { | |
| 230 | + background: #fff; | |
| 231 | + border: 1px solid #ebeef5; | |
| 232 | + border-radius: 10px; | |
| 233 | + padding: 16px; | |
| 234 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 235 | + display: flex; | |
| 236 | + align-items: center; | |
| 237 | + gap: 16px; | |
| 238 | + | |
| 239 | + .stat-icon-circle { | |
| 240 | + width: 48px; | |
| 241 | + height: 48px; | |
| 242 | + border-radius: 50%; | |
| 243 | + display: flex; | |
| 244 | + align-items: center; | |
| 245 | + justify-content: center; | |
| 246 | + color: #fff; | |
| 247 | + flex-shrink: 0; | |
| 248 | + font-size: 24px; | |
| 249 | + } | |
| 250 | + | |
| 251 | + .stat-content { | |
| 252 | + flex: 1; | |
| 253 | + min-width: 0; | |
| 254 | + } | |
| 255 | + | |
| 256 | + .stat-title { | |
| 257 | + font-size: 13px; | |
| 258 | + color: #606266; | |
| 259 | + margin-bottom: 8px; | |
| 260 | + } | |
| 261 | + | |
| 262 | + .stat-value { | |
| 263 | + font-size: 20px; | |
| 264 | + font-weight: 700; | |
| 265 | + color: #303133; | |
| 266 | + } | |
| 267 | + | |
| 268 | + &.net-billing { | |
| 269 | + border-left: 4px solid #409EFF; | |
| 270 | + .stat-icon-circle { | |
| 271 | + background: #409EFF; | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + &.net-refund { | |
| 276 | + border-left: 4px solid #F56C6C; | |
| 277 | + .stat-icon-circle { | |
| 278 | + background: #F56C6C; | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 282 | + &.net-amount { | |
| 283 | + border-left: 4px solid #67C23A; | |
| 284 | + .stat-icon-circle { | |
| 285 | + background: #67C23A; | |
| 286 | + } | |
| 287 | + } | |
| 288 | + } | |
| 289 | + | |
| 290 | + .net-tabs { | |
| 291 | + display: flex; | |
| 292 | + justify-content: center; | |
| 293 | + padding: 8px 0; | |
| 294 | + } | |
| 295 | + | |
| 296 | + .net-table-card { | |
| 297 | + background: #fff; | |
| 298 | + border: 1px solid #ebeef5; | |
| 299 | + border-radius: 10px; | |
| 300 | + padding: 12px; | |
| 301 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 302 | + display: flex; | |
| 303 | + flex-direction: column; | |
| 304 | + min-height: 650px; | |
| 305 | + | |
| 306 | + .pagination-bar { | |
| 307 | + flex-shrink: 0; | |
| 308 | + margin-top: 12px; | |
| 309 | + } | |
| 310 | + } | |
| 311 | +} | |
| 312 | + | |
| 313 | +.pagination-bar { | |
| 314 | + display: flex; | |
| 315 | + justify-content: flex-end; | |
| 316 | + padding: 10px 0 4px 0; | |
| 317 | +} | |
| 318 | +</style> | |
| 319 | + | ... | ... |
antis-ncc-admin/src/components/kpi-drill/refund-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="refund-wrapper"> | |
| 3 | + <div class="refund-layout"> | |
| 4 | + <!-- 左侧:门店分布 + 列表 --> | |
| 5 | + <div class="refund-left"> | |
| 6 | + <div class="chart-card"> | |
| 7 | + <div class="chart-title"> | |
| 8 | + <i class="el-icon-s-data"></i> | |
| 9 | + 各门店退卡金额分布(不包含转卡) | |
| 10 | + <el-tooltip content="不包含转卡" placement="top"> | |
| 11 | + <i class="el-icon-info" style="margin-left: 4px; color: #909399; cursor: help;"></i> | |
| 12 | + </el-tooltip> | |
| 13 | + </div> | |
| 14 | + <div ref="storeDistributionChart" class="chart-mini"></div> | |
| 15 | + </div> | |
| 16 | + | |
| 17 | + <div class="table-card"> | |
| 18 | + <div class="table-header"> | |
| 19 | + <div class="table-title"> | |
| 20 | + <i class="el-icon-document"></i> | |
| 21 | + 退卡明细 | |
| 22 | + <el-button v-if="selectedStoreName" type="text" size="small" icon="el-icon-refresh" @click="showAllStores" | |
| 23 | + style="margin-left: 12px; color: #409EFF;"> | |
| 24 | + 显示全部 | |
| 25 | + </el-button> | |
| 26 | + <span v-if="selectedStoreName" class="filter-tag"> | |
| 27 | + 已筛选:{{ selectedStoreName }} | |
| 28 | + </span> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <el-table v-loading="loading" :data="displayList" size="small" height="600" border stripe> | |
| 33 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 34 | + :width="col.width" :min-width="col.minWidth"> | |
| 35 | + <template slot-scope="scope"> | |
| 36 | + <span v-if="col.type === 'money'">¥{{ formatMoney(scope.row[col.prop]) }}</span> | |
| 37 | + <span v-else>{{ scope.row[col.prop] || '—' }}</span> | |
| 38 | + </template> | |
| 39 | + </el-table-column> | |
| 40 | + </el-table> | |
| 41 | + | |
| 42 | + <div class="pagination-bar"> | |
| 43 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="pagination.total" | |
| 44 | + :current-page="pagination.pageIndex" :page-size="pagination.pageSize" @size-change="handleSizeChange" | |
| 45 | + @current-change="handleCurrentChange" /> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + <!-- 右侧:统计卡片 --> | |
| 51 | + <div class="refund-right"> | |
| 52 | + <div class="stat-card neon-blue"> | |
| 53 | + <div class="stat-icon-circle"> | |
| 54 | + <i class="el-icon-money"></i> | |
| 55 | + </div> | |
| 56 | + <div class="stat-content"> | |
| 57 | + <div class="stat-title">退卡总计</div> | |
| 58 | + <div class="stat-value">¥{{ formatMoney(statistics.totalRefundAmount) }}</div> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + | |
| 62 | + <div class="stat-card neon-green"> | |
| 63 | + <div class="stat-icon-circle"> | |
| 64 | + <i class="el-icon-wallet"></i> | |
| 65 | + </div> | |
| 66 | + <div class="stat-content"> | |
| 67 | + <div class="stat-title">实际退卡总计</div> | |
| 68 | + <div class="stat-value">¥{{ formatMoney(statistics.totalActualRefundAmount) }}</div> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + | |
| 72 | + <div class="stat-card neon-orange"> | |
| 73 | + <div class="stat-icon-circle"> | |
| 74 | + <i class="el-icon-refresh"></i> | |
| 75 | + </div> | |
| 76 | + <div class="stat-content"> | |
| 77 | + <div class="stat-title">转卡总计</div> | |
| 78 | + <div class="stat-value">¥{{ formatMoney(statistics.totalTransferAmount) }}</div> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <div class="stat-card neon-purple"> | |
| 83 | + <div class="stat-icon-circle"> | |
| 84 | + <i class="el-icon-trophy"></i> | |
| 85 | + </div> | |
| 86 | + <div class="stat-content"> | |
| 87 | + <div class="stat-title">退卡金额最大的人</div> | |
| 88 | + <div class="stat-body"> | |
| 89 | + <div class="highlight text-ellipsis-2"> | |
| 90 | + {{ (statistics.maxAmountPerson && (statistics.maxAmountPerson.MemberName || | |
| 91 | + statistics.maxAmountPerson.memberName)) || '无' }} | |
| 92 | + <span class="value-inline">¥{{ formatMoney((statistics.maxAmountPerson && | |
| 93 | + (statistics.maxAmountPerson.TotalRefundAmount || statistics.maxAmountPerson.totalRefundAmount)) || 0) | |
| 94 | + }}</span> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + <div class="stat-card neon-cyan" style="display: none;"> | |
| 101 | + <div class="stat-icon-circle"> | |
| 102 | + <i class="el-icon-user"></i> | |
| 103 | + </div> | |
| 104 | + <div class="stat-content"> | |
| 105 | + <div class="stat-title">退卡次数最多的人</div> | |
| 106 | + <div class="stat-body"> | |
| 107 | + <div class="highlight text-ellipsis-2"> | |
| 108 | + {{ (statistics.maxCountPerson && (statistics.maxCountPerson.MemberName || | |
| 109 | + statistics.maxCountPerson.memberName)) || '无' }} | |
| 110 | + <span class="value-inline">{{ (statistics.maxCountPerson && (statistics.maxCountPerson.RefundCount || | |
| 111 | + statistics.maxCountPerson.refundCount)) || 0 }} 次</span> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | + </div> | |
| 116 | + | |
| 117 | + <!-- 退卡金额与实际退款差距清单 --> | |
| 118 | + <div class="stat-card gap-refund-card" v-if="gapRefundList && gapRefundList.length > 0"> | |
| 119 | + <div class="gap-refund-header"> | |
| 120 | + <div class="stat-icon-circle" style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);"> | |
| 121 | + <i class="el-icon-warning"></i> | |
| 122 | + </div> | |
| 123 | + <div class="stat-content"> | |
| 124 | + <div class="stat-title">退卡金额与实际退款差距(≥1元)</div> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + <div class="gap-refund-list-wrapper"> | |
| 128 | + <div class="gap-refund-list"> | |
| 129 | + <div v-for="(item, index) in gapRefundList" :key="index" class="gap-refund-item"> | |
| 130 | + <div class="gap-refund-member">{{ item.MemberName || item.memberName || '—' }}<span | |
| 131 | + class="gap-amount">差距:¥{{ | |
| 132 | + formatMoney(item.GapAmount || item.gapAmount || 0) }}</span></div> | |
| 133 | + <div class="gap-refund-details"> | |
| 134 | + <div class="gap-detail-row"> | |
| 135 | + <span class="gap-label">退卡金额:</span> | |
| 136 | + <span class="gap-value">¥{{ formatMoney(item.RefundAmount || item.refundAmount || 0) }}</span> | |
| 137 | + </div> | |
| 138 | + <div class="gap-detail-row"> | |
| 139 | + <span class="gap-label">实际退款:</span> | |
| 140 | + <span class="gap-value">¥{{ formatMoney(item.ActualRefundAmount || item.actualRefundAmount || | |
| 141 | + 0) }}</span> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + </div> | |
| 148 | + </div> | |
| 149 | + </div> | |
| 150 | + </div> | |
| 151 | +</template> | |
| 152 | + | |
| 153 | +<script> | |
| 154 | +import request from '@/utils/request' | |
| 155 | +import dayjs from 'dayjs' | |
| 156 | +import * as echarts from 'echarts' | |
| 157 | +import { kpiDrillMixin } from './mixins' | |
| 158 | + | |
| 159 | +export default { | |
| 160 | + name: 'RefundAnalysis', | |
| 161 | + mixins: [kpiDrillMixin], | |
| 162 | + props: { | |
| 163 | + filters: { | |
| 164 | + type: Object, | |
| 165 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 166 | + }, | |
| 167 | + storeOptions: { type: Array, default: () => [] } | |
| 168 | + }, | |
| 169 | + data() { | |
| 170 | + return { | |
| 171 | + loading: false, | |
| 172 | + list: [], | |
| 173 | + displayList: [], | |
| 174 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 175 | + statistics: { | |
| 176 | + totalRefundAmount: 0, | |
| 177 | + totalActualRefundAmount: 0, | |
| 178 | + totalTransferAmount: 0, | |
| 179 | + maxAmountPerson: null, | |
| 180 | + maxCountPerson: null | |
| 181 | + }, | |
| 182 | + gapRefundList: [], | |
| 183 | + selectedStoreName: '', // 当前选中的门店名称 | |
| 184 | + selectedStoreId: '', // 当前选中的门店ID | |
| 185 | + storeDistribution: [], | |
| 186 | + columns: [ | |
| 187 | + { prop: 'tksj', label: '退卡时间', minWidth: 160 }, | |
| 188 | + { prop: 'mdmc', label: '门店', minWidth: 120 }, | |
| 189 | + { prop: 'hymc', label: '会员', minWidth: 120 }, | |
| 190 | + { prop: 'gklx', label: '顾客类型', minWidth: 100 }, | |
| 191 | + { prop: 'tkje', label: '退款金额', width: 110, type: 'money' }, | |
| 192 | + { prop: 'actualRefundAmount', label: '实际退款', width: 110, type: 'money' }, | |
| 193 | + { prop: 'tkyy', label: '退款原因', minWidth: 140 } | |
| 194 | + ], | |
| 195 | + chart: null | |
| 196 | + } | |
| 197 | + }, | |
| 198 | + watch: { | |
| 199 | + filters: { | |
| 200 | + deep: true, | |
| 201 | + handler() { | |
| 202 | + this.resetAndFetch() | |
| 203 | + } | |
| 204 | + } | |
| 205 | + }, | |
| 206 | + mounted() { | |
| 207 | + this.fetchData() | |
| 208 | + this.fetchStatistics() | |
| 209 | + }, | |
| 210 | + beforeDestroy() { | |
| 211 | + if (this.chart) this.chart.dispose() | |
| 212 | + }, | |
| 213 | + methods: { | |
| 214 | + resetAndFetch() { | |
| 215 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 216 | + this.fetchData() | |
| 217 | + this.fetchStatistics() | |
| 218 | + }, | |
| 219 | + handleSizeChange(size) { | |
| 220 | + this.pagination.pageSize = size | |
| 221 | + this.pagination.pageIndex = 1 | |
| 222 | + this.fetchData() | |
| 223 | + }, | |
| 224 | + handleCurrentChange(page) { | |
| 225 | + this.pagination.pageIndex = page | |
| 226 | + this.fetchData() | |
| 227 | + }, | |
| 228 | + async fetchStatistics() { | |
| 229 | + try { | |
| 230 | + const range = this.buildDateRange() | |
| 231 | + if (!range) { | |
| 232 | + console.warn('No date range available for refund statistics') | |
| 233 | + return | |
| 234 | + } | |
| 235 | + | |
| 236 | + const url = '/api/Extend/LqReport/get-refund-drill-statistics' | |
| 237 | + const data = { | |
| 238 | + startTime: dayjs(range.start).format('YYYY-MM-DD HH:mm:ss'), | |
| 239 | + endTime: dayjs(range.end).format('YYYY-MM-DD HH:mm:ss'), | |
| 240 | + storeIds: this.filters.storeIds || [] | |
| 241 | + } | |
| 242 | + | |
| 243 | + const res = await request({ url, method: 'POST', data }) | |
| 244 | + console.log('Refund statistics response:', res) // 调试日志 | |
| 245 | + | |
| 246 | + if (res && res.data) { | |
| 247 | + // 兼容不同的响应格式 | |
| 248 | + let stats = null | |
| 249 | + if (res.data.Success && res.data.Data) { | |
| 250 | + stats = res.data.Data | |
| 251 | + } else if (res.data.data) { | |
| 252 | + stats = res.data.data | |
| 253 | + } else if (res.data.Data) { | |
| 254 | + stats = res.data.Data | |
| 255 | + } else { | |
| 256 | + stats = res.data | |
| 257 | + } | |
| 258 | + | |
| 259 | + console.log('Parsed stats:', stats) // 调试日志 | |
| 260 | + | |
| 261 | + if (stats) { | |
| 262 | + // 解析统计数据 | |
| 263 | + this.statistics = { | |
| 264 | + totalRefundAmount: Number(stats.TotalRefundAmount || stats.totalRefundAmount || 0), | |
| 265 | + totalActualRefundAmount: Number(stats.TotalActualRefundAmount || stats.totalActualRefundAmount || 0), | |
| 266 | + totalTransferAmount: Number(stats.TotalTransferAmount || stats.totalTransferAmount || 0), | |
| 267 | + maxAmountPerson: stats.MaxAmountPerson || stats.maxAmountPerson || null, | |
| 268 | + maxCountPerson: stats.MaxCountPerson || stats.maxCountPerson || null | |
| 269 | + } | |
| 270 | + | |
| 271 | + // 解析差距清单 | |
| 272 | + const rawGapList = stats.GapRefundList || stats.gapRefundList || [] | |
| 273 | + this.gapRefundList = rawGapList.map(item => ({ | |
| 274 | + RefundId: item.RefundId || item.refundId || '', | |
| 275 | + MemberId: item.MemberId || item.memberId || '', | |
| 276 | + MemberName: item.MemberName || item.memberName || '—', | |
| 277 | + RefundAmount: Number(item.RefundAmount || item.refundAmount || 0), | |
| 278 | + ActualRefundAmount: Number(item.ActualRefundAmount || item.actualRefundAmount || 0), | |
| 279 | + GapAmount: Number(item.GapAmount || item.gapAmount || 0), | |
| 280 | + RefundTime: item.RefundTime || item.refundTime || null, | |
| 281 | + RefundReason: item.RefundReason || item.refundReason || '—' | |
| 282 | + })) | |
| 283 | + | |
| 284 | + // 处理门店分布数据,确保门店名称正确 | |
| 285 | + const rawStoreDist = stats.StoreDistribution || stats.storeDistribution || [] | |
| 286 | + console.log('Raw store distribution:', rawStoreDist) // 调试日志 | |
| 287 | + | |
| 288 | + this.storeDistribution = rawStoreDist.map(item => { | |
| 289 | + const storeId = item.StoreId || item.storeId || item.StoreId | |
| 290 | + const storeName = this.getStoreName(storeId) || item.StoreName || item.storeName || '未知门店' | |
| 291 | + const refundAmount = Number(item.RefundAmount || item.refundAmount || 0) | |
| 292 | + return { | |
| 293 | + StoreId: storeId, | |
| 294 | + StoreName: storeName, | |
| 295 | + RefundAmount: refundAmount, | |
| 296 | + RefundCount: Number(item.RefundCount || item.refundCount || 0) | |
| 297 | + } | |
| 298 | + }).filter(item => item.StoreId) // 过滤掉没有门店ID的数据 | |
| 299 | + | |
| 300 | + console.log('Processed statistics:', this.statistics) | |
| 301 | + console.log('Processed store distribution:', this.storeDistribution) | |
| 302 | + | |
| 303 | + // 确保在下一个tick渲染图表 | |
| 304 | + this.$nextTick(() => { | |
| 305 | + setTimeout(() => { | |
| 306 | + this.renderStoreDistributionChart() | |
| 307 | + }, 100) | |
| 308 | + }) | |
| 309 | + } else { | |
| 310 | + console.warn('No stats data found in response') | |
| 311 | + } | |
| 312 | + } else { | |
| 313 | + console.warn('No response data') | |
| 314 | + } | |
| 315 | + } catch (error) { | |
| 316 | + console.error('Refund statistics load error:', error) | |
| 317 | + this.$message.error('加载统计数据失败: ' + (error.message || '未知错误')) | |
| 318 | + } | |
| 319 | + }, | |
| 320 | + async fetchData() { | |
| 321 | + this.loading = true | |
| 322 | + try { | |
| 323 | + const range = this.buildDateRange() | |
| 324 | + const storeId = this.getStoreId() | |
| 325 | + const url = '/api/Extend/LqHytkHytk' | |
| 326 | + const data = { | |
| 327 | + currentPage: this.pagination.pageIndex, | |
| 328 | + pageSize: this.pagination.pageSize | |
| 329 | + } | |
| 330 | + if (range) { | |
| 331 | + data.tksj = `${range.startTs},${range.endTs}` | |
| 332 | + } | |
| 333 | + // 优先使用选中的门店ID,否则使用filters中的门店ID | |
| 334 | + if (this.selectedStoreId) { | |
| 335 | + data.md = this.selectedStoreId | |
| 336 | + } else if (storeId) { | |
| 337 | + data.md = storeId | |
| 338 | + } | |
| 339 | + | |
| 340 | + const res = await request({ url, method: 'GET', data }) | |
| 341 | + await this.handleResponse(res) | |
| 342 | + } catch (error) { | |
| 343 | + console.error('Refund analysis load error:', error) | |
| 344 | + this.$message.error(error.message || '加载数据失败') | |
| 345 | + this.list = [] | |
| 346 | + this.displayList = [] | |
| 347 | + } finally { | |
| 348 | + this.loading = false | |
| 349 | + } | |
| 350 | + }, | |
| 351 | + async handleResponse(res) { | |
| 352 | + let list = [] | |
| 353 | + let pagination = this.pagination | |
| 354 | + | |
| 355 | + if (res && res.data) { | |
| 356 | + if (res.data.list && res.data.pagination) { | |
| 357 | + list = res.data.list | |
| 358 | + pagination = { | |
| 359 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 360 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 361 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 362 | + } | |
| 363 | + } else if (Array.isArray(res.data.list)) { | |
| 364 | + list = res.data.list | |
| 365 | + } else if (Array.isArray(res.data)) { | |
| 366 | + list = res.data | |
| 367 | + } | |
| 368 | + } | |
| 369 | + | |
| 370 | + list = list.map(i => { | |
| 371 | + // 处理门店名称 | |
| 372 | + const storeId = i.md || i.Md || i.storeId || i.StoreId | |
| 373 | + const storeName = this.getStoreName(storeId) || i.mdmc || i.Mdmc || '—' | |
| 374 | + | |
| 375 | + // 处理退卡时间 | |
| 376 | + let tksj = '' | |
| 377 | + if (i.tksj) { | |
| 378 | + if (typeof i.tksj === 'string') { | |
| 379 | + tksj = dayjs(i.tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 380 | + } else if (typeof i.tksj === 'number') { | |
| 381 | + tksj = dayjs(i.tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 382 | + } else { | |
| 383 | + tksj = dayjs(i.tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 384 | + } | |
| 385 | + } else if (i.Tksj) { | |
| 386 | + if (typeof i.Tksj === 'string') { | |
| 387 | + tksj = dayjs(i.Tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 388 | + } else if (typeof i.Tksj === 'number') { | |
| 389 | + tksj = dayjs(i.Tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 390 | + } else { | |
| 391 | + tksj = dayjs(i.Tksj).format('YYYY-MM-DD HH:mm:ss') | |
| 392 | + } | |
| 393 | + } | |
| 394 | + | |
| 395 | + return { | |
| 396 | + tksj: tksj || '—', | |
| 397 | + mdmc: storeName, | |
| 398 | + hymc: i.hymc || i.Hymc || '—', | |
| 399 | + gklx: i.gklx || i.Gklx || '—', | |
| 400 | + tkje: Number(i.tkje || i.Tkje || 0), | |
| 401 | + actualRefundAmount: Number(i.actualRefundAmount || i.ActualRefundAmount || i.F_ActualRefundAmount || 0), | |
| 402 | + tkyy: i.tkyy || i.Tkyy || '—' | |
| 403 | + } | |
| 404 | + }) | |
| 405 | + | |
| 406 | + this.list = list | |
| 407 | + this.displayList = list | |
| 408 | + this.pagination = pagination | |
| 409 | + }, | |
| 410 | + renderStoreDistributionChart() { | |
| 411 | + const chartEl = this.$refs.storeDistributionChart | |
| 412 | + if (!chartEl) { | |
| 413 | + console.warn('storeDistributionChart ref not found, retrying...') | |
| 414 | + this.$nextTick(() => { | |
| 415 | + setTimeout(() => this.renderStoreDistributionChart(), 200) | |
| 416 | + }) | |
| 417 | + return | |
| 418 | + } | |
| 419 | + | |
| 420 | + if (this.chart) { | |
| 421 | + this.chart.dispose() | |
| 422 | + this.chart = null | |
| 423 | + } | |
| 424 | + | |
| 425 | + const chart = echarts.init(chartEl) | |
| 426 | + if (!chart) { | |
| 427 | + console.error('Failed to initialize chart') | |
| 428 | + return | |
| 429 | + } | |
| 430 | + | |
| 431 | + if (!this.storeDistribution || this.storeDistribution.length === 0) { | |
| 432 | + chart.setOption({ | |
| 433 | + title: { | |
| 434 | + text: '暂无数据', | |
| 435 | + left: 'center', | |
| 436 | + top: 'middle', | |
| 437 | + textStyle: { color: '#909399', fontSize: 14 } | |
| 438 | + }, | |
| 439 | + grid: { left: 0, right: 0, top: 0, bottom: 0 } | |
| 440 | + }) | |
| 441 | + this.chart = chart | |
| 442 | + return | |
| 443 | + } | |
| 444 | + | |
| 445 | + const stores = this.storeDistribution.map(s => s.StoreName || '未知门店') | |
| 446 | + const amounts = this.storeDistribution.map(s => Number(s.RefundAmount || 0)) | |
| 447 | + | |
| 448 | + console.log('Chart data - stores:', stores, 'amounts:', amounts) // 调试日志 | |
| 449 | + | |
| 450 | + const option = { | |
| 451 | + tooltip: { | |
| 452 | + trigger: 'axis', | |
| 453 | + axisPointer: { type: 'shadow' }, | |
| 454 | + formatter: (params) => { | |
| 455 | + const param = Array.isArray(params) ? params[0] : params | |
| 456 | + return `${param.name}<br/>退卡金额: ¥${this.formatMoney(param.value)}<br/>(不包含转卡)` | |
| 457 | + } | |
| 458 | + }, | |
| 459 | + grid: { left: '12%', right: '5%', bottom: stores.length > 10 ? '25%' : '15%', top: '10%' }, | |
| 460 | + xAxis: { | |
| 461 | + type: 'category', | |
| 462 | + data: stores, | |
| 463 | + axisLabel: { | |
| 464 | + fontSize: 11, | |
| 465 | + interval: 0, | |
| 466 | + rotate: stores.length > 10 ? 45 : 0 | |
| 467 | + } | |
| 468 | + }, | |
| 469 | + yAxis: { | |
| 470 | + type: 'value', | |
| 471 | + name: '金额', | |
| 472 | + axisLabel: { | |
| 473 | + formatter: (value) => { | |
| 474 | + if (value >= 10000) return (value / 10000).toFixed(1) + '万' | |
| 475 | + return value.toFixed(0) | |
| 476 | + } | |
| 477 | + } | |
| 478 | + }, | |
| 479 | + series: [{ | |
| 480 | + name: '退卡金额', | |
| 481 | + type: 'bar', | |
| 482 | + data: amounts, | |
| 483 | + itemStyle: { | |
| 484 | + color: (params) => { | |
| 485 | + // 如果当前门店被选中,高亮显示 | |
| 486 | + const storeName = stores[params.dataIndex] | |
| 487 | + return storeName === this.selectedStoreName ? '#409EFF' : '#F56C6C' | |
| 488 | + }, | |
| 489 | + borderRadius: [4, 4, 0, 0] | |
| 490 | + }, | |
| 491 | + label: { | |
| 492 | + show: true, | |
| 493 | + position: 'top', | |
| 494 | + formatter: (params) => { | |
| 495 | + const value = params.value | |
| 496 | + if (value >= 10000) return (value / 10000).toFixed(1) + '万' | |
| 497 | + return value.toFixed(0) | |
| 498 | + }, | |
| 499 | + fontSize: 10 | |
| 500 | + }, | |
| 501 | + barWidth: stores.length > 20 ? '40%' : '60%', | |
| 502 | + // 添加点击事件 | |
| 503 | + emphasis: { | |
| 504 | + itemStyle: { | |
| 505 | + shadowBlur: 10, | |
| 506 | + shadowOffsetX: 0, | |
| 507 | + shadowColor: 'rgba(64, 158, 255, 0.5)' | |
| 508 | + } | |
| 509 | + } | |
| 510 | + }] | |
| 511 | + } | |
| 512 | + | |
| 513 | + chart.setOption(option) | |
| 514 | + this.chart = chart | |
| 515 | + | |
| 516 | + // 添加点击事件监听 | |
| 517 | + chart.off('click') // 先移除之前的监听,避免重复绑定 | |
| 518 | + chart.on('click', (params) => { | |
| 519 | + if (params.componentType === 'series' && params.seriesType === 'bar') { | |
| 520 | + const clickedStoreName = stores[params.dataIndex] | |
| 521 | + this.filterByStore(clickedStoreName) | |
| 522 | + } | |
| 523 | + }) | |
| 524 | + | |
| 525 | + // 监听窗口大小变化 | |
| 526 | + const resizeHandler = () => { | |
| 527 | + if (this.chart) { | |
| 528 | + this.chart.resize() | |
| 529 | + } | |
| 530 | + } | |
| 531 | + window.removeEventListener('resize', resizeHandler) // 先移除,避免重复绑定 | |
| 532 | + window.addEventListener('resize', resizeHandler) | |
| 533 | + }, | |
| 534 | + getStoreName(storeId) { | |
| 535 | + if (!storeId || !this.storeOptions || this.storeOptions.length === 0) return null | |
| 536 | + const store = this.storeOptions.find(s => s.id === storeId || s.F_Id === storeId) | |
| 537 | + return store ? (store.fullName || store.dm || store.F_FullName || store.FullName) : null | |
| 538 | + }, | |
| 539 | + filterByStore(storeName) { | |
| 540 | + // 如果点击的是已选中的门店,则取消筛选 | |
| 541 | + if (this.selectedStoreName === storeName) { | |
| 542 | + this.showAllStores() | |
| 543 | + return | |
| 544 | + } | |
| 545 | + | |
| 546 | + // 从storeDistribution中找到对应的门店ID | |
| 547 | + const storeInfo = this.storeDistribution.find(s => s.StoreName === storeName) | |
| 548 | + if (!storeInfo || !storeInfo.StoreId) { | |
| 549 | + this.$message.warning('未找到门店信息') | |
| 550 | + return | |
| 551 | + } | |
| 552 | + | |
| 553 | + this.selectedStoreName = storeName | |
| 554 | + this.selectedStoreId = storeInfo.StoreId | |
| 555 | + | |
| 556 | + // 重置分页到第一页 | |
| 557 | + this.pagination.pageIndex = 1 | |
| 558 | + | |
| 559 | + // 重新获取数据(通过接口筛选) | |
| 560 | + this.fetchData() | |
| 561 | + | |
| 562 | + // 重新渲染图表,高亮选中的门店 | |
| 563 | + this.$nextTick(() => { | |
| 564 | + this.renderStoreDistributionChart() | |
| 565 | + }) | |
| 566 | + }, | |
| 567 | + showAllStores() { | |
| 568 | + this.selectedStoreName = '' | |
| 569 | + this.selectedStoreId = '' | |
| 570 | + | |
| 571 | + // 重置分页到第一页 | |
| 572 | + this.pagination.pageIndex = 1 | |
| 573 | + | |
| 574 | + // 重新获取数据(获取全部数据) | |
| 575 | + this.fetchData() | |
| 576 | + | |
| 577 | + // 重新渲染图表,恢复所有门店的颜色 | |
| 578 | + this.$nextTick(() => { | |
| 579 | + this.renderStoreDistributionChart() | |
| 580 | + }) | |
| 581 | + } | |
| 582 | + } | |
| 583 | +} | |
| 584 | +</script> | |
| 585 | + | |
| 586 | +<style lang="scss" scoped> | |
| 587 | +@import './common-styles.scss'; | |
| 588 | + | |
| 589 | +.refund-wrapper { | |
| 590 | + width: 100%; | |
| 591 | + overflow: hidden; | |
| 592 | + padding: 0; | |
| 593 | + | |
| 594 | + .refund-layout { | |
| 595 | + display: flex; | |
| 596 | + align-items: stretch; | |
| 597 | + justify-content: space-between; | |
| 598 | + gap: 16px; | |
| 599 | + width: 100%; | |
| 600 | + } | |
| 601 | + | |
| 602 | + .refund-left { | |
| 603 | + flex: 0 0 75%; | |
| 604 | + max-width: 75%; | |
| 605 | + display: flex; | |
| 606 | + flex-direction: column; | |
| 607 | + gap: 12px; | |
| 608 | + min-width: 0; | |
| 609 | + } | |
| 610 | + | |
| 611 | + .refund-right { | |
| 612 | + flex: 0 0 25%; | |
| 613 | + max-width: 23.5%; | |
| 614 | + display: flex; | |
| 615 | + flex-direction: column; | |
| 616 | + gap: 12px; | |
| 617 | + min-width: 0; | |
| 618 | + } | |
| 619 | + | |
| 620 | + // 复用公共样式中的 chart-card 和 table-card | |
| 621 | + .chart-card { | |
| 622 | + background: #fff; | |
| 623 | + border: 1px solid #ebeef5; | |
| 624 | + border-radius: 10px; | |
| 625 | + padding: 10px; | |
| 626 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 627 | + flex-shrink: 0; | |
| 628 | + | |
| 629 | + .chart-title { | |
| 630 | + font-size: 13px; | |
| 631 | + color: #606266; | |
| 632 | + margin-bottom: 6px; | |
| 633 | + display: flex; | |
| 634 | + align-items: center; | |
| 635 | + gap: 6px; | |
| 636 | + | |
| 637 | + i { | |
| 638 | + color: #409EFF; | |
| 639 | + } | |
| 640 | + } | |
| 641 | + | |
| 642 | + .chart-mini { | |
| 643 | + height: 220px; | |
| 644 | + width: 100%; | |
| 645 | + min-height: 220px; | |
| 646 | + } | |
| 647 | + } | |
| 648 | + | |
| 649 | + .table-card { | |
| 650 | + background: #fff; | |
| 651 | + border: 1px solid #ebeef5; | |
| 652 | + border-radius: 10px; | |
| 653 | + padding: 10px 12px; | |
| 654 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 655 | + flex: 1; | |
| 656 | + min-height: 700px; | |
| 657 | + display: flex; | |
| 658 | + flex-direction: column; | |
| 659 | + | |
| 660 | + .table-header { | |
| 661 | + display: flex; | |
| 662 | + align-items: center; | |
| 663 | + justify-content: space-between; | |
| 664 | + margin-bottom: 8px; | |
| 665 | + flex-shrink: 0; | |
| 666 | + } | |
| 667 | + | |
| 668 | + .table-title { | |
| 669 | + font-size: 14px; | |
| 670 | + font-weight: 600; | |
| 671 | + color: #303133; | |
| 672 | + display: flex; | |
| 673 | + align-items: center; | |
| 674 | + gap: 6px; | |
| 675 | + flex-wrap: wrap; | |
| 676 | + | |
| 677 | + i { | |
| 678 | + color: #409EFF; | |
| 679 | + } | |
| 680 | + | |
| 681 | + .filter-tag { | |
| 682 | + margin-left: 8px; | |
| 683 | + padding: 2px 8px; | |
| 684 | + background: #E6F7FF; | |
| 685 | + border: 1px solid #91D5FF; | |
| 686 | + border-radius: 4px; | |
| 687 | + font-size: 12px; | |
| 688 | + color: #1890FF; | |
| 689 | + } | |
| 690 | + } | |
| 691 | + | |
| 692 | + .el-table { | |
| 693 | + flex: 1; | |
| 694 | + min-height: 0; | |
| 695 | + } | |
| 696 | + | |
| 697 | + .pagination-bar { | |
| 698 | + flex-shrink: 0; | |
| 699 | + margin-top: 8px; | |
| 700 | + } | |
| 701 | + } | |
| 702 | + | |
| 703 | + // 统一统计卡片样式 | |
| 704 | + .stat-card { | |
| 705 | + background: #fff; | |
| 706 | + border: 1px solid #ebeef5; | |
| 707 | + border-radius: 10px; | |
| 708 | + padding: 12px; | |
| 709 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 710 | + flex-shrink: 0; | |
| 711 | + display: flex; | |
| 712 | + align-items: center; | |
| 713 | + gap: 12px; | |
| 714 | + min-height: 80px; | |
| 715 | + | |
| 716 | + .stat-icon-circle { | |
| 717 | + width: 40px; | |
| 718 | + height: 40px; | |
| 719 | + border-radius: 50%; | |
| 720 | + display: flex; | |
| 721 | + align-items: center; | |
| 722 | + justify-content: center; | |
| 723 | + color: #fff; | |
| 724 | + flex-shrink: 0; | |
| 725 | + | |
| 726 | + i { | |
| 727 | + font-size: 18px; | |
| 728 | + } | |
| 729 | + } | |
| 730 | + | |
| 731 | + &.neon-blue .stat-icon-circle { | |
| 732 | + background: #409EFF; | |
| 733 | + } | |
| 734 | + | |
| 735 | + &.neon-green .stat-icon-circle { | |
| 736 | + background: #67C23A; | |
| 737 | + } | |
| 738 | + | |
| 739 | + &.neon-orange .stat-icon-circle { | |
| 740 | + background: #E6A23C; | |
| 741 | + } | |
| 742 | + | |
| 743 | + &.neon-purple .stat-icon-circle { | |
| 744 | + background: #9C27B0; | |
| 745 | + } | |
| 746 | + | |
| 747 | + &.neon-cyan .stat-icon-circle { | |
| 748 | + background: #17A2B8; | |
| 749 | + } | |
| 750 | + | |
| 751 | + .stat-content { | |
| 752 | + flex: 1; | |
| 753 | + min-width: 0; | |
| 754 | + display: flex; | |
| 755 | + flex-direction: column; | |
| 756 | + justify-content: center; | |
| 757 | + } | |
| 758 | + | |
| 759 | + .stat-title { | |
| 760 | + font-size: 13px; | |
| 761 | + color: #606266; | |
| 762 | + margin-bottom: 6px; | |
| 763 | + line-height: 1.3; | |
| 764 | + } | |
| 765 | + | |
| 766 | + .stat-value { | |
| 767 | + font-size: 20px; | |
| 768 | + font-weight: 700; | |
| 769 | + color: #303133; | |
| 770 | + line-height: 1.2; | |
| 771 | + } | |
| 772 | + | |
| 773 | + .stat-body { | |
| 774 | + color: #303133; | |
| 775 | + margin-top: 4px; | |
| 776 | + | |
| 777 | + .highlight { | |
| 778 | + font-size: 14px; | |
| 779 | + font-weight: 600; | |
| 780 | + line-height: 1.4; | |
| 781 | + display: flex; | |
| 782 | + align-items: center; | |
| 783 | + gap: 8px; | |
| 784 | + flex-wrap: wrap; | |
| 785 | + | |
| 786 | + .value-inline { | |
| 787 | + font-size: 16px; | |
| 788 | + font-weight: 700; | |
| 789 | + color: #409EFF; | |
| 790 | + white-space: nowrap; | |
| 791 | + } | |
| 792 | + } | |
| 793 | + } | |
| 794 | + | |
| 795 | + // 差距清单卡片样式 | |
| 796 | + &.gap-refund-card { | |
| 797 | + min-height: auto; | |
| 798 | + max-height: 400px; | |
| 799 | + display: flex; | |
| 800 | + flex-direction: column; | |
| 801 | + align-items: stretch; | |
| 802 | + padding: 12px; | |
| 803 | + | |
| 804 | + .gap-refund-header { | |
| 805 | + display: flex; | |
| 806 | + flex-direction: row; | |
| 807 | + align-items: center; | |
| 808 | + gap: 12px; | |
| 809 | + flex-shrink: 0; | |
| 810 | + margin-bottom: 12px; | |
| 811 | + | |
| 812 | + .stat-content { | |
| 813 | + flex: 1; | |
| 814 | + min-width: 0; | |
| 815 | + } | |
| 816 | + | |
| 817 | + .stat-title { | |
| 818 | + margin-bottom: 0; | |
| 819 | + } | |
| 820 | + } | |
| 821 | + | |
| 822 | + .gap-refund-list-wrapper { | |
| 823 | + flex: 1; | |
| 824 | + overflow-y: auto; | |
| 825 | + padding-right: 4px; | |
| 826 | + min-height: 0; | |
| 827 | + | |
| 828 | + // 自定义滚动条 | |
| 829 | + &::-webkit-scrollbar { | |
| 830 | + width: 6px; | |
| 831 | + } | |
| 832 | + | |
| 833 | + &::-webkit-scrollbar-track { | |
| 834 | + background: #f1f1f1; | |
| 835 | + border-radius: 3px; | |
| 836 | + } | |
| 837 | + | |
| 838 | + &::-webkit-scrollbar-thumb { | |
| 839 | + background: #c1c1c1; | |
| 840 | + border-radius: 3px; | |
| 841 | + | |
| 842 | + &:hover { | |
| 843 | + background: #a8a8a8; | |
| 844 | + } | |
| 845 | + } | |
| 846 | + } | |
| 847 | + | |
| 848 | + .gap-refund-list { | |
| 849 | + display: flex; | |
| 850 | + flex-direction: column; | |
| 851 | + gap: 10px; | |
| 852 | + } | |
| 853 | + | |
| 854 | + .gap-refund-item { | |
| 855 | + padding: 10px; | |
| 856 | + background: #f8f9fa; | |
| 857 | + border-radius: 6px; | |
| 858 | + border-left: 3px solid #ff6b6b; | |
| 859 | + transition: all 0.2s; | |
| 860 | + | |
| 861 | + &:hover { | |
| 862 | + background: #f0f0f0; | |
| 863 | + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| 864 | + } | |
| 865 | + | |
| 866 | + .gap-refund-member { | |
| 867 | + font-size: 14px; | |
| 868 | + font-weight: 600; | |
| 869 | + color: #303133; | |
| 870 | + margin-bottom: 6px; | |
| 871 | + display: flex; | |
| 872 | + align-items: center; | |
| 873 | + justify-content: space-between; | |
| 874 | + gap: 8px; | |
| 875 | + | |
| 876 | + .gap-amount { | |
| 877 | + color: #FFF; | |
| 878 | + font-weight: 700; | |
| 879 | + font-size: 12px; | |
| 880 | + padding: 2px 8px; | |
| 881 | + border-radius: 4px; | |
| 882 | + background: #ff6b6b; | |
| 883 | + white-space: nowrap; | |
| 884 | + flex-shrink: 0; | |
| 885 | + } | |
| 886 | + } | |
| 887 | + | |
| 888 | + .gap-refund-details { | |
| 889 | + display: flex; | |
| 890 | + flex-direction: column; | |
| 891 | + gap: 6px; | |
| 892 | + font-size: 12px; | |
| 893 | + color: #606266; | |
| 894 | + | |
| 895 | + .gap-detail-row { | |
| 896 | + display: flex; | |
| 897 | + align-items: center; | |
| 898 | + flex-wrap: wrap; | |
| 899 | + gap: 4px; | |
| 900 | + | |
| 901 | + .gap-label { | |
| 902 | + color: #909399; | |
| 903 | + white-space: nowrap; | |
| 904 | + } | |
| 905 | + | |
| 906 | + .gap-value { | |
| 907 | + color: #303133; | |
| 908 | + font-weight: 500; | |
| 909 | + white-space: nowrap; | |
| 910 | + } | |
| 911 | + } | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + } | |
| 916 | + } | |
| 917 | + } | |
| 918 | + } | |
| 919 | +} | |
| 920 | +</style> | ... | ... |
antis-ncc-admin/src/components/kpi-drill/target-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="target-wrapper"> | |
| 3 | + <!-- 整体统计卡片 --> | |
| 4 | + <div class="overview-stats"> | |
| 5 | + <div class="stat-card overview-card"> | |
| 6 | + <div class="stat-icon-circle primary"> | |
| 7 | + <i class="el-icon-aim"></i> | |
| 8 | + </div> | |
| 9 | + <div class="stat-content"> | |
| 10 | + <div class="stat-title">本月整体目标</div> | |
| 11 | + <div class="stat-value">¥{{ formatMoney(overview.totalTarget) }}</div> | |
| 12 | + </div> | |
| 13 | + </div> | |
| 14 | + <div class="stat-card overview-card"> | |
| 15 | + <div class="stat-icon-circle success"> | |
| 16 | + <i class="el-icon-check"></i> | |
| 17 | + </div> | |
| 18 | + <div class="stat-content"> | |
| 19 | + <div class="stat-title">已完成目标</div> | |
| 20 | + <div class="stat-value">¥{{ formatMoney(overview.totalCompleted) }}</div> | |
| 21 | + </div> | |
| 22 | + </div> | |
| 23 | + <div class="stat-card overview-card"> | |
| 24 | + <div class="stat-icon-circle" :class="overview.completionRate >= 100 ? 'success' : 'warning'"> | |
| 25 | + <i class="el-icon-data-line"></i> | |
| 26 | + </div> | |
| 27 | + <div class="stat-content"> | |
| 28 | + <div class="stat-title">完成率</div> | |
| 29 | + <div class="stat-value">{{ formatMoney(overview.completionRate) }}%</div> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + <div class="stat-card overview-card" v-if="overview.isCurrentMonth"> | |
| 33 | + <div class="stat-icon-circle" :class="overview.estimatedRate >= 100 ? 'success' : 'warning'"> | |
| 34 | + <i class="el-icon-trends"></i> | |
| 35 | + </div> | |
| 36 | + <div class="stat-content"> | |
| 37 | + <div class="stat-title">预计完成率</div> | |
| 38 | + <div class="stat-value">{{ formatMoney(overview.estimatedRate) }}%</div> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | + <div class="stat-card overview-card" v-else> | |
| 42 | + <div class="stat-icon-circle"> | |
| 43 | + <i class="el-icon-trends"></i> | |
| 44 | + </div> | |
| 45 | + <div class="stat-content"> | |
| 46 | + <div class="stat-title">预计完成率</div> | |
| 47 | + <div class="stat-value">--</div> | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + <!-- 预警提示 --> | |
| 53 | + <div class="alert-card" v-if="overview.isCurrentMonth && overview.estimatedRate < 100 && overview.dailyRequiredFor100 > 0"> | |
| 54 | + <div class="alert-icon"> | |
| 55 | + <i class="el-icon-warning"></i> | |
| 56 | + </div> | |
| 57 | + <div class="alert-content"> | |
| 58 | + <div class="alert-title">完成目标提醒</div> | |
| 59 | + <div class="alert-text"> | |
| 60 | + 预计完成率 <span class="highlight">{{ formatMoney(overview.estimatedRate) }}%</span>,未达到100%。 | |
| 61 | + 剩余 <span class="highlight">{{ overview.remainingDays }}</span> 天,建议每天开单金额达到 | |
| 62 | + <span class="highlight-amount">¥{{ formatMoney(overview.dailyRequiredFor100) }}</span> 才能完成目标。 | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + <!-- 关键指标卡片 --> | |
| 68 | + <div class="key-metrics"> | |
| 69 | + <div class="metric-card"> | |
| 70 | + <div class="metric-header"> | |
| 71 | + <i class="el-icon-success"></i> | |
| 72 | + <span>已完成目标门店</span> | |
| 73 | + </div> | |
| 74 | + <div class="metric-value success">{{ overview.achievedCount }} 家</div> | |
| 75 | + <div class="metric-list scrollable" v-if="topAchievedStores.length > 0"> | |
| 76 | + <div class="metric-item" v-for="(store, idx) in topAchievedStores" :key="store.storeId"> | |
| 77 | + <span class="rank">{{ idx + 1 }}</span> | |
| 78 | + <span class="name">{{ store.storeName || '未知门店' }}</span> | |
| 79 | + <span class="rate">{{ formatMoney(store.completionRate) }}%</span> | |
| 80 | + </div> | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + <div class="metric-card"> | |
| 84 | + <div class="metric-header"> | |
| 85 | + <i class="el-icon-warning"></i> | |
| 86 | + <span>未完成目标门店</span> | |
| 87 | + </div> | |
| 88 | + <div class="metric-value warning">{{ overview.unachievedCount }} 家</div> | |
| 89 | + <div class="metric-list scrollable" v-if="topUnachievedStores.length > 0"> | |
| 90 | + <div class="metric-item" v-for="(store, idx) in topUnachievedStores" :key="store.storeId"> | |
| 91 | + <span class="rank">{{ idx + 1 }}</span> | |
| 92 | + <span class="name">{{ store.storeName || '未知门店' }}</span> | |
| 93 | + <span class="rate">{{ formatMoney(store.completionRate) }}%</span> | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + <div class="metric-card"> | |
| 98 | + <div class="metric-header"> | |
| 99 | + <i class="el-icon-trophy"></i> | |
| 100 | + <span>排名第一门店</span> | |
| 101 | + </div> | |
| 102 | + <div class="metric-value primary" v-if="topStore"> | |
| 103 | + {{ topStore.storeName }} | |
| 104 | + </div> | |
| 105 | + <div class="metric-detail" v-if="topStore"> | |
| 106 | + <div class="detail-item"> | |
| 107 | + <span>完成率:</span> | |
| 108 | + <span class="value">{{ formatMoney(topStore.completionRate) }}%</span> | |
| 109 | + </div> | |
| 110 | + <div class="detail-item"> | |
| 111 | + <span>开单业绩:</span> | |
| 112 | + <span class="value">¥{{ formatMoney(topStore.actualBilling) }}</span> | |
| 113 | + </div> | |
| 114 | + <div class="detail-item"> | |
| 115 | + <span>退卡业绩:</span> | |
| 116 | + <span class="value">¥{{ formatMoney(topStore.refundAmount) }}</span> | |
| 117 | + </div> | |
| 118 | + <div class="detail-item"> | |
| 119 | + <span>净业绩:</span> | |
| 120 | + <span class="value">¥{{ formatMoney(topStore.netBilling) }}</span> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + </div> | |
| 124 | + <div class="metric-card"> | |
| 125 | + <div class="metric-header"> | |
| 126 | + <i class="el-icon-data-analysis"></i> | |
| 127 | + <span>达成100%每日需完成</span> | |
| 128 | + </div> | |
| 129 | + <div class="metric-value" v-if="overview.isCurrentMonth" :class="overview.dailyRequiredFor100 > 0 ? 'warning' : 'success'"> | |
| 130 | + ¥{{ formatMoney(overview.dailyRequiredFor100) }} | |
| 131 | + </div> | |
| 132 | + <div class="metric-value" v-else> | |
| 133 | + -- | |
| 134 | + </div> | |
| 135 | + <div class="metric-detail" v-if="overview.isCurrentMonth && overview.remainingDays > 0"> | |
| 136 | + <div class="detail-item"> | |
| 137 | + <span>剩余天数:</span> | |
| 138 | + <span class="value">{{ overview.remainingDays }} 天</span> | |
| 139 | + </div> | |
| 140 | + <div class="detail-item"> | |
| 141 | + <span>还需完成:</span> | |
| 142 | + <span class="value">¥{{ formatMoney(Math.max(0, overview.totalTarget - overview.totalCompleted)) }}</span> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + <div class="metric-detail" v-else-if="overview.isCurrentMonth"> | |
| 146 | + <div class="detail-item"> | |
| 147 | + <span>本月已结束</span> | |
| 148 | + </div> | |
| 149 | + </div> | |
| 150 | + <div class="metric-detail" v-else> | |
| 151 | + <div class="detail-item"> | |
| 152 | + <span>历史月份不显示预计数据</span> | |
| 153 | + </div> | |
| 154 | + </div> | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + | |
| 158 | + <!-- 门店业绩柱状图 --> | |
| 159 | + <div class="chart-section"> | |
| 160 | + <div class="chart-header"> | |
| 161 | + <h3>门店目标与净业绩对比</h3> | |
| 162 | + <div class="chart-legend"> | |
| 163 | + <span class="legend-item"><i class="legend-target"></i> 目标业绩</span> | |
| 164 | + <span class="legend-item"><i class="legend-completed"></i> 净业绩</span> | |
| 165 | + </div> | |
| 166 | + </div> | |
| 167 | + <div ref="storeChart" class="chart-container"></div> | |
| 168 | + </div> | |
| 169 | + | |
| 170 | + </div> | |
| 171 | +</template> | |
| 172 | + | |
| 173 | +<script> | |
| 174 | +import request from '@/utils/request' | |
| 175 | +import dayjs from 'dayjs' | |
| 176 | +import * as echarts from 'echarts' | |
| 177 | +import { kpiDrillMixin } from './mixins' | |
| 178 | + | |
| 179 | +export default { | |
| 180 | + name: 'TargetAnalysis', | |
| 181 | + mixins: [kpiDrillMixin], | |
| 182 | + props: { | |
| 183 | + filters: { | |
| 184 | + type: Object, | |
| 185 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 186 | + }, | |
| 187 | + extra: { type: Object, default: () => ({}) }, | |
| 188 | + storeOptions: { type: Array, default: () => [] } | |
| 189 | + }, | |
| 190 | + data() { | |
| 191 | + return { | |
| 192 | + loading: false, | |
| 193 | + list: [], | |
| 194 | + displayList: [], | |
| 195 | + overview: { | |
| 196 | + totalTarget: 0, | |
| 197 | + totalCompleted: 0, | |
| 198 | + completionRate: 0, | |
| 199 | + estimatedRate: 0, | |
| 200 | + dailyRequired: 0, | |
| 201 | + remainingDays: 0, | |
| 202 | + achievedCount: 0, | |
| 203 | + unachievedCount: 0, | |
| 204 | + lastMonthRate: null, | |
| 205 | + lastMonthComparison: 0, | |
| 206 | + dailyRequiredFor100: 0, | |
| 207 | + isCurrentMonth: true // 是否是当前月份 | |
| 208 | + }, | |
| 209 | + topStore: null, | |
| 210 | + topAchievedStores: [], | |
| 211 | + topUnachievedStores: [] | |
| 212 | + } | |
| 213 | + }, | |
| 214 | + watch: { | |
| 215 | + filters: { | |
| 216 | + deep: true, | |
| 217 | + handler() { | |
| 218 | + this.resetAndFetch() | |
| 219 | + } | |
| 220 | + } | |
| 221 | + }, | |
| 222 | + mounted() { | |
| 223 | + this.fetchData() | |
| 224 | + }, | |
| 225 | + methods: { | |
| 226 | + resetAndFetch() { | |
| 227 | + this.fetchData() | |
| 228 | + }, | |
| 229 | + async fetchData() { | |
| 230 | + this.loading = true | |
| 231 | + try { | |
| 232 | + // 获取门店目标数据 - 不传分页参数,获取所有数据 | |
| 233 | + const url = '/api/Extend/LqMdTarget' | |
| 234 | + const data = { | |
| 235 | + currentPage: 1, | |
| 236 | + pageSize: 1000 // 设置一个较大的值,确保获取所有门店 | |
| 237 | + } | |
| 238 | + if (this.filters && this.filters.month) data.Month = this.filters.month | |
| 239 | + if (this.filters && this.filters.storeIds && this.filters.storeIds.length > 0) { | |
| 240 | + data.StoreId = this.filters.storeIds[0] // 如果只选了一个门店 | |
| 241 | + // 如果选了多个门店,可能需要调整接口支持 | |
| 242 | + } | |
| 243 | + | |
| 244 | + const res = await request({ url, method: 'GET', data }) | |
| 245 | + await this.handleResponse(res) | |
| 246 | + } catch (error) { | |
| 247 | + console.error('Target analysis load error:', error) | |
| 248 | + this.$message.error(error.message || '加载数据失败') | |
| 249 | + this.list = [] | |
| 250 | + this.displayList = [] | |
| 251 | + } finally { | |
| 252 | + this.loading = false | |
| 253 | + } | |
| 254 | + }, | |
| 255 | + async handleResponse(res) { | |
| 256 | + let list = [] | |
| 257 | + | |
| 258 | + if (res && res.data) { | |
| 259 | + if (res.data.list && Array.isArray(res.data.list)) { | |
| 260 | + list = res.data.list | |
| 261 | + } else if (Array.isArray(res.data)) { | |
| 262 | + list = res.data | |
| 263 | + } | |
| 264 | + } | |
| 265 | + | |
| 266 | + const fmtMonth = v => { | |
| 267 | + const s = (v || '').toString() | |
| 268 | + if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4)}` | |
| 269 | + return s | |
| 270 | + } | |
| 271 | + // 从storeOptions中查找门店名称 | |
| 272 | + const getStoreName = (storeId) => { | |
| 273 | + if (!storeId) return '未知门店' | |
| 274 | + const store = this.storeOptions.find(s => s.id === storeId) | |
| 275 | + return store ? (store.fullName || store.dm || store.name || store.label || '未知门店') : '未知门店' | |
| 276 | + } | |
| 277 | + | |
| 278 | + list = list.map(i => { | |
| 279 | + // 注意:接口返回的字段名可能是小写的 storeId, storeTarget | |
| 280 | + const storeId = i.StoreId || i.storeId || '' | |
| 281 | + const storeTarget = i.StoreTarget || i.storeTarget || i.storeTarget || 0 | |
| 282 | + return { | |
| 283 | + storeId: storeId, | |
| 284 | + storeName: getStoreName(storeId), | |
| 285 | + month: i.Month || i.month || '', | |
| 286 | + monthText: fmtMonth(i.Month || i.month || ''), | |
| 287 | + storeTarget: Number(storeTarget) | |
| 288 | + } | |
| 289 | + }) | |
| 290 | + | |
| 291 | + // 过滤掉没有门店ID的数据 | |
| 292 | + list = list.filter(item => item.storeId) | |
| 293 | + | |
| 294 | + // 获取每个门店的实际业绩 | |
| 295 | + const timeRange = this.buildDateRange() | |
| 296 | + const metrics = await Promise.all(list.map(async row => { | |
| 297 | + if (!row.storeId) return null | |
| 298 | + try { | |
| 299 | + const resKpi = await request({ | |
| 300 | + url: '/api/Extend/LqReport/get-business-statistics', | |
| 301 | + method: 'POST', | |
| 302 | + data: { | |
| 303 | + startTime: timeRange ? `${timeRange.start} 00:00:00` : null, | |
| 304 | + endTime: timeRange ? `${timeRange.end} 23:59:59` : null, | |
| 305 | + storeIds: [row.storeId] | |
| 306 | + } | |
| 307 | + }) | |
| 308 | + const d = resKpi.data || {} | |
| 309 | + return { | |
| 310 | + storeId: row.storeId, | |
| 311 | + billing: Number(d.TotalBillingAmount || d.billing_amount || 0), | |
| 312 | + refund: Number(d.TotalRefundAmount || d.refund_amount || 0), | |
| 313 | + net: Number(d.TotalBillingAmount || 0) - Number(d.TotalRefundAmount || 0) | |
| 314 | + } | |
| 315 | + } catch (error) { | |
| 316 | + console.error(`获取门店 ${row.storeId} 业绩失败:`, error) | |
| 317 | + return { | |
| 318 | + storeId: row.storeId, | |
| 319 | + billing: 0, | |
| 320 | + refund: 0, | |
| 321 | + net: 0 | |
| 322 | + } | |
| 323 | + } | |
| 324 | + })) | |
| 325 | + | |
| 326 | + const map = {} | |
| 327 | + metrics.filter(Boolean).forEach(m => { map[m.storeId] = m }) | |
| 328 | + | |
| 329 | + // 计算完成率和是否达成 | |
| 330 | + list = list.map(row => { | |
| 331 | + const m = map[row.storeId] || { billing: 0, refund: 0, net: 0 } | |
| 332 | + const completionRate = (row.storeTarget || 0) > 0 | |
| 333 | + ? ((m.net || 0) / row.storeTarget * 100) | |
| 334 | + : 0 | |
| 335 | + const achieved = (row.storeTarget || 0) > 0 ? ((m.net || 0) >= row.storeTarget ? '达成' : '未达成') : '未设置' | |
| 336 | + return { | |
| 337 | + ...row, | |
| 338 | + actualBilling: m.billing || 0, | |
| 339 | + refundAmount: m.refund || 0, | |
| 340 | + netBilling: m.net || 0, | |
| 341 | + completionRate: completionRate, | |
| 342 | + achieved | |
| 343 | + } | |
| 344 | + }) | |
| 345 | + | |
| 346 | + this.list = list | |
| 347 | + // 不再需要displayList,因为列表已删除 | |
| 348 | + | |
| 349 | + // 计算整体统计 | |
| 350 | + this.calculateOverview() | |
| 351 | + | |
| 352 | + // 获取上月数据 | |
| 353 | + await this.fetchLastMonthData() | |
| 354 | + | |
| 355 | + // 渲染图表 | |
| 356 | + this.$nextTick(() => { | |
| 357 | + this.renderStoreChart() | |
| 358 | + }) | |
| 359 | + }, | |
| 360 | + calculateOverview() { | |
| 361 | + const totalTarget = this.list.reduce((sum, row) => sum + (row.storeTarget || 0), 0) | |
| 362 | + const totalCompleted = this.list.reduce((sum, row) => sum + (row.netBilling || 0), 0) | |
| 363 | + const completionRate = totalTarget > 0 ? (totalCompleted / totalTarget * 100) : 0 | |
| 364 | + | |
| 365 | + // 判断是否是当前月份或未来月份 | |
| 366 | + const now = dayjs() | |
| 367 | + const timeRange = this.buildDateRange() | |
| 368 | + // 获取查询的月份 | |
| 369 | + const queryMonth = timeRange ? dayjs(timeRange.start) : now | |
| 370 | + const queryMonthStart = queryMonth.startOf('month') | |
| 371 | + const queryMonthEnd = queryMonth.endOf('month') | |
| 372 | + const currentMonthStart = now.startOf('month') | |
| 373 | + | |
| 374 | + // 判断是否是当前月份(查询月份等于当前月份) | |
| 375 | + const isCurrentMonth = queryMonthStart.isSame(currentMonthStart, 'month') | |
| 376 | + // 判断月份是否已过完(查询月份结束时间小于当前时间) | |
| 377 | + const isMonthFinished = queryMonthEnd.isBefore(now, 'day') | |
| 378 | + | |
| 379 | + let estimatedRate = 0 | |
| 380 | + let dailyRequiredFor100 = 0 | |
| 381 | + let remainingDays = 0 | |
| 382 | + | |
| 383 | + // 只有当前月份且未过完时才计算预计完成率和每日需完成 | |
| 384 | + if (isCurrentMonth && !isMonthFinished) { | |
| 385 | + // 计算预计完成率 | |
| 386 | + const monthStart = now.startOf('month') | |
| 387 | + const monthEnd = now.endOf('month') | |
| 388 | + // 已过天数:从月初到现在(包括今天) | |
| 389 | + const actualPassedDays = now.diff(monthStart, 'day') + 1 | |
| 390 | + // 总天数:这个月的总天数 | |
| 391 | + const actualTotalDays = monthEnd.diff(monthStart, 'day') + 1 | |
| 392 | + remainingDays = Math.max(0, actualTotalDays - actualPassedDays) | |
| 393 | + | |
| 394 | + // 预计完成率计算:已完成业绩 / 已过天数 = 平均每天业绩,然后 * 总天数 = 预计完成业绩,最后 / 目标 * 100 | |
| 395 | + const avgDailyPerformance = actualPassedDays > 0 ? (totalCompleted / actualPassedDays) : 0 | |
| 396 | + const estimatedCompleted = avgDailyPerformance * actualTotalDays | |
| 397 | + estimatedRate = totalTarget > 0 ? (estimatedCompleted / totalTarget * 100) : 0 | |
| 398 | + | |
| 399 | + // 计算每天需要达到的金额(用于达成100%) | |
| 400 | + const remainingAmount = Math.max(0, totalTarget - totalCompleted) | |
| 401 | + dailyRequiredFor100 = remainingDays > 0 ? (remainingAmount / remainingDays) : 0 | |
| 402 | + } | |
| 403 | + | |
| 404 | + // 统计达成情况 | |
| 405 | + const achievedList = this.list.filter(row => row.achieved === '达成') | |
| 406 | + const unachievedList = this.list.filter(row => row.achieved === '未达成') | |
| 407 | + | |
| 408 | + // 排名第一的门店 | |
| 409 | + const sortedByNet = [...this.list].sort((a, b) => (b.netBilling || 0) - (a.netBilling || 0)) | |
| 410 | + this.topStore = sortedByNet[0] || null | |
| 411 | + | |
| 412 | + // 已完成门店排名(按完成率) | |
| 413 | + this.topAchievedStores = achievedList.sort((a, b) => (b.completionRate || 0) - (a.completionRate || 0)) | |
| 414 | + | |
| 415 | + // 未完成门店排名(按完成率,从高到低) | |
| 416 | + this.topUnachievedStores = unachievedList.sort((a, b) => (b.completionRate || 0) - (a.completionRate || 0)) | |
| 417 | + | |
| 418 | + this.overview = { | |
| 419 | + totalTarget, | |
| 420 | + totalCompleted, | |
| 421 | + completionRate, | |
| 422 | + estimatedRate, | |
| 423 | + dailyRequired: dailyRequiredFor100, | |
| 424 | + remainingDays, | |
| 425 | + achievedCount: achievedList.length, | |
| 426 | + unachievedCount: unachievedList.length, | |
| 427 | + lastMonthRate: null, | |
| 428 | + lastMonthComparison: 0, | |
| 429 | + dailyRequiredFor100, | |
| 430 | + isCurrentMonth: isCurrentMonth && !isMonthFinished | |
| 431 | + } | |
| 432 | + }, | |
| 433 | + async fetchLastMonthData() { | |
| 434 | + try { | |
| 435 | + const timeRange = this.buildDateRange() | |
| 436 | + const currentMonth = timeRange ? dayjs(timeRange.start) : dayjs() | |
| 437 | + const lastMonthStart = currentMonth.subtract(1, 'month').startOf('month') | |
| 438 | + const lastMonthEnd = currentMonth.subtract(1, 'month').endOf('month') | |
| 439 | + | |
| 440 | + const res = await request({ | |
| 441 | + url: '/api/Extend/LqReport/get-business-statistics', | |
| 442 | + method: 'POST', | |
| 443 | + data: { | |
| 444 | + startTime: `${lastMonthStart.format('YYYY-MM-DD')} 00:00:00`, | |
| 445 | + endTime: `${lastMonthEnd.format('YYYY-MM-DD')} 23:59:59`, | |
| 446 | + storeIds: this.filters && this.filters.storeIds ? this.filters.storeIds : [] | |
| 447 | + } | |
| 448 | + }) | |
| 449 | + | |
| 450 | + const data = res.data || {} | |
| 451 | + const lastMonthTarget = data.TargetBillingAmount || 0 | |
| 452 | + const lastMonthCompleted = data.CompletedBillingAmount || 0 | |
| 453 | + const lastMonthRate = lastMonthTarget > 0 ? (lastMonthCompleted / lastMonthTarget * 100) : 0 | |
| 454 | + | |
| 455 | + this.overview.lastMonthRate = lastMonthRate | |
| 456 | + this.overview.lastMonthComparison = this.overview.completionRate - lastMonthRate | |
| 457 | + } catch (error) { | |
| 458 | + console.error('获取上月数据失败:', error) | |
| 459 | + } | |
| 460 | + }, | |
| 461 | + renderStoreChart() { | |
| 462 | + const dom = this.$refs.storeChart | |
| 463 | + if (!dom) return | |
| 464 | + | |
| 465 | + const chart = echarts.init(dom) | |
| 466 | + // 使用所有门店数据,不限制数量 | |
| 467 | + const chartData = this.list | |
| 468 | + | |
| 469 | + const storeNames = chartData.map(item => item.storeName || '未知门店') | |
| 470 | + const targets = chartData.map(item => item.storeTarget || 0) | |
| 471 | + // 使用净业绩(开单-退卡),与目标对比 | |
| 472 | + const completed = chartData.map(item => item.netBilling || 0) | |
| 473 | + | |
| 474 | + chart.setOption({ | |
| 475 | + tooltip: { | |
| 476 | + trigger: 'axis', | |
| 477 | + axisPointer: { type: 'shadow' }, | |
| 478 | + formatter: params => { | |
| 479 | + const p = Array.isArray(params) ? params : [params] | |
| 480 | + let result = `${p[0].name}<br/>` | |
| 481 | + p.forEach(item => { | |
| 482 | + const value = Number(item.value || 0) | |
| 483 | + result += `${item.seriesName}: ¥${this.formatMoney(value)}<br/>` | |
| 484 | + }) | |
| 485 | + return result | |
| 486 | + } | |
| 487 | + }, | |
| 488 | + legend: { | |
| 489 | + data: ['目标业绩', '净业绩'], | |
| 490 | + top: 10, | |
| 491 | + textStyle: { color: '#606266' } | |
| 492 | + }, | |
| 493 | + grid: { | |
| 494 | + left: '12%', | |
| 495 | + right: '4%', | |
| 496 | + top: '15%', | |
| 497 | + bottom: chartData.length > 20 ? '25%' : '15%', | |
| 498 | + containLabel: false | |
| 499 | + }, | |
| 500 | + xAxis: { | |
| 501 | + type: 'category', | |
| 502 | + data: storeNames, | |
| 503 | + axisLabel: { | |
| 504 | + color: '#606266', | |
| 505 | + fontSize: chartData.length > 20 ? 9 : 11, | |
| 506 | + rotate: chartData.length > 15 ? 45 : 0, | |
| 507 | + interval: 0 | |
| 508 | + }, | |
| 509 | + axisLine: { lineStyle: { color: '#dcdfe6' } } | |
| 510 | + }, | |
| 511 | + yAxis: { | |
| 512 | + type: 'value', | |
| 513 | + name: '金额', | |
| 514 | + axisLabel: { color: '#606266' }, | |
| 515 | + splitLine: { lineStyle: { color: '#ebeef5' } } | |
| 516 | + }, | |
| 517 | + series: [ | |
| 518 | + { | |
| 519 | + name: '目标业绩', | |
| 520 | + type: 'bar', | |
| 521 | + data: targets, | |
| 522 | + itemStyle: { color: '#E6A23C' }, | |
| 523 | + barWidth: chartData.length > 20 ? 8 : 12 | |
| 524 | + }, | |
| 525 | + { | |
| 526 | + name: '净业绩', | |
| 527 | + type: 'bar', | |
| 528 | + data: completed, | |
| 529 | + itemStyle: { color: '#67C23A' }, | |
| 530 | + barWidth: chartData.length > 20 ? 8 : 12 | |
| 531 | + } | |
| 532 | + ] | |
| 533 | + }) | |
| 534 | + } | |
| 535 | + } | |
| 536 | +} | |
| 537 | +</script> | |
| 538 | + | |
| 539 | +<style lang="scss" scoped> | |
| 540 | +.target-wrapper { | |
| 541 | + width: 100%; | |
| 542 | + display: flex; | |
| 543 | + flex-direction: column; | |
| 544 | + gap: 16px; | |
| 545 | +} | |
| 546 | + | |
| 547 | +.overview-stats { | |
| 548 | + display: grid; | |
| 549 | + grid-template-columns: repeat(4, 1fr); | |
| 550 | + gap: 16px; | |
| 551 | + margin-bottom: 8px; | |
| 552 | +} | |
| 553 | + | |
| 554 | +.stat-card.overview-card { | |
| 555 | + background: #fff; | |
| 556 | + border: 1px solid #ebeef5; | |
| 557 | + border-radius: 10px; | |
| 558 | + padding: 16px; | |
| 559 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 560 | + display: flex; | |
| 561 | + align-items: center; | |
| 562 | + gap: 16px; | |
| 563 | + | |
| 564 | + .stat-icon-circle { | |
| 565 | + width: 48px; | |
| 566 | + height: 48px; | |
| 567 | + border-radius: 50%; | |
| 568 | + display: flex; | |
| 569 | + align-items: center; | |
| 570 | + justify-content: center; | |
| 571 | + color: #fff; | |
| 572 | + flex-shrink: 0; | |
| 573 | + font-size: 24px; | |
| 574 | + | |
| 575 | + &.primary { | |
| 576 | + background: #409EFF; | |
| 577 | + } | |
| 578 | + | |
| 579 | + &.success { | |
| 580 | + background: #67C23A; | |
| 581 | + } | |
| 582 | + | |
| 583 | + &.warning { | |
| 584 | + background: #E6A23C; | |
| 585 | + } | |
| 586 | + } | |
| 587 | + | |
| 588 | + .stat-content { | |
| 589 | + flex: 1; | |
| 590 | + min-width: 0; | |
| 591 | + } | |
| 592 | + | |
| 593 | + .stat-title { | |
| 594 | + font-size: 13px; | |
| 595 | + color: #606266; | |
| 596 | + margin-bottom: 8px; | |
| 597 | + } | |
| 598 | + | |
| 599 | + .stat-value { | |
| 600 | + font-size: 20px; | |
| 601 | + font-weight: 700; | |
| 602 | + color: #303133; | |
| 603 | + } | |
| 604 | +} | |
| 605 | + | |
| 606 | +.alert-card { | |
| 607 | + background: #fff3cd; | |
| 608 | + border: 1px solid #ffc107; | |
| 609 | + border-radius: 10px; | |
| 610 | + padding: 16px; | |
| 611 | + display: flex; | |
| 612 | + align-items: flex-start; | |
| 613 | + gap: 12px; | |
| 614 | + | |
| 615 | + .alert-icon { | |
| 616 | + color: #ffc107; | |
| 617 | + font-size: 24px; | |
| 618 | + flex-shrink: 0; | |
| 619 | + } | |
| 620 | + | |
| 621 | + .alert-content { | |
| 622 | + flex: 1; | |
| 623 | + } | |
| 624 | + | |
| 625 | + .alert-title { | |
| 626 | + font-size: 14px; | |
| 627 | + font-weight: 600; | |
| 628 | + color: #856404; | |
| 629 | + margin-bottom: 8px; | |
| 630 | + } | |
| 631 | + | |
| 632 | + .alert-text { | |
| 633 | + font-size: 13px; | |
| 634 | + color: #856404; | |
| 635 | + line-height: 1.6; | |
| 636 | + | |
| 637 | + .highlight { | |
| 638 | + font-weight: 700; | |
| 639 | + color: #d9534f; | |
| 640 | + } | |
| 641 | + | |
| 642 | + .highlight-amount { | |
| 643 | + font-weight: 700; | |
| 644 | + color: #d9534f; | |
| 645 | + font-size: 16px; | |
| 646 | + } | |
| 647 | + } | |
| 648 | +} | |
| 649 | + | |
| 650 | +.key-metrics { | |
| 651 | + display: grid; | |
| 652 | + grid-template-columns: repeat(4, 1fr); | |
| 653 | + gap: 16px; | |
| 654 | + align-items: stretch; // 让所有卡片高度一致 | |
| 655 | + | |
| 656 | + // 确保所有卡片高度一致 | |
| 657 | + .metric-card { | |
| 658 | + height: 100%; | |
| 659 | + min-height: 200px; // 设置最小高度,确保右边两个卡片高度一致 | |
| 660 | + } | |
| 661 | +} | |
| 662 | + | |
| 663 | +.metric-card { | |
| 664 | + background: #fff; | |
| 665 | + border: 1px solid #ebeef5; | |
| 666 | + border-radius: 10px; | |
| 667 | + padding: 16px; | |
| 668 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 669 | + display: flex; | |
| 670 | + flex-direction: column; | |
| 671 | + | |
| 672 | + .metric-header { | |
| 673 | + display: flex; | |
| 674 | + align-items: center; | |
| 675 | + gap: 6px; | |
| 676 | + font-size: 13px; | |
| 677 | + color: #606266; | |
| 678 | + margin-bottom: 12px; | |
| 679 | + | |
| 680 | + i { | |
| 681 | + color: #409EFF; | |
| 682 | + } | |
| 683 | + } | |
| 684 | + | |
| 685 | + .metric-value { | |
| 686 | + font-size: 24px; | |
| 687 | + font-weight: 700; | |
| 688 | + margin-bottom: 12px; | |
| 689 | + flex-shrink: 0; | |
| 690 | + | |
| 691 | + &.success { | |
| 692 | + color: #67C23A; | |
| 693 | + } | |
| 694 | + | |
| 695 | + &.warning { | |
| 696 | + color: #E6A23C; | |
| 697 | + } | |
| 698 | + | |
| 699 | + &.primary { | |
| 700 | + color: #409EFF; | |
| 701 | + } | |
| 702 | + | |
| 703 | + &.danger { | |
| 704 | + color: #F56C6C; | |
| 705 | + } | |
| 706 | + } | |
| 707 | + | |
| 708 | + .metric-list { | |
| 709 | + &.scrollable { | |
| 710 | + // 3个item的高度:每个item padding 6px*2=12px + line-height 18px + border 1px = 31px,3个 = 93px | |
| 711 | + max-height: 93px; | |
| 712 | + overflow-y: auto; | |
| 713 | + // 无缝滚动 | |
| 714 | + scroll-behavior: smooth; | |
| 715 | + // 美化滚动条 | |
| 716 | + &::-webkit-scrollbar { | |
| 717 | + width: 4px; | |
| 718 | + } | |
| 719 | + &::-webkit-scrollbar-track { | |
| 720 | + background: #f5f5f5; | |
| 721 | + border-radius: 2px; | |
| 722 | + } | |
| 723 | + &::-webkit-scrollbar-thumb { | |
| 724 | + background: #c0c4cc; | |
| 725 | + border-radius: 2px; | |
| 726 | + &:hover { | |
| 727 | + background: #909399; | |
| 728 | + } | |
| 729 | + } | |
| 730 | + // 隐藏滚动条但保持滚动功能(可选,如果需要更美观) | |
| 731 | + // scrollbar-width: none; /* Firefox */ | |
| 732 | + // &::-webkit-scrollbar { display: none; } /* Chrome/Safari */ | |
| 733 | + } | |
| 734 | + | |
| 735 | + .metric-item { | |
| 736 | + display: flex; | |
| 737 | + align-items: center; | |
| 738 | + gap: 8px; | |
| 739 | + padding: 6px 0; | |
| 740 | + font-size: 12px; | |
| 741 | + line-height: 18px; | |
| 742 | + height: 31px; // 固定高度:padding 6px*2 + line-height 18px + border 1px = 31px | |
| 743 | + box-sizing: border-box; | |
| 744 | + border-bottom: 1px solid #f5f7fa; | |
| 745 | + | |
| 746 | + &:last-child { | |
| 747 | + border-bottom: none; | |
| 748 | + } | |
| 749 | + | |
| 750 | + .rank { | |
| 751 | + width: 20px; | |
| 752 | + height: 20px; | |
| 753 | + border-radius: 50%; | |
| 754 | + background: #f5f7fa; | |
| 755 | + display: flex; | |
| 756 | + align-items: center; | |
| 757 | + justify-content: center; | |
| 758 | + font-weight: 600; | |
| 759 | + color: #606266; | |
| 760 | + flex-shrink: 0; | |
| 761 | + } | |
| 762 | + | |
| 763 | + .name { | |
| 764 | + flex: 1; | |
| 765 | + color: #303133; | |
| 766 | + min-width: 0; | |
| 767 | + overflow: hidden; | |
| 768 | + text-overflow: ellipsis; | |
| 769 | + white-space: nowrap; | |
| 770 | + } | |
| 771 | + | |
| 772 | + .rate { | |
| 773 | + color: #909399; | |
| 774 | + font-weight: 600; | |
| 775 | + flex-shrink: 0; | |
| 776 | + } | |
| 777 | + } | |
| 778 | + } | |
| 779 | + | |
| 780 | + .metric-detail { | |
| 781 | + margin-top: auto; // 让detail自动推到卡片底部 | |
| 782 | + flex-shrink: 0; | |
| 783 | + | |
| 784 | + .detail-item { | |
| 785 | + display: flex; | |
| 786 | + justify-content: space-between; | |
| 787 | + padding: 6px 0; | |
| 788 | + font-size: 12px; | |
| 789 | + color: #606266; | |
| 790 | + | |
| 791 | + .value { | |
| 792 | + font-weight: 600; | |
| 793 | + color: #303133; | |
| 794 | + } | |
| 795 | + } | |
| 796 | + } | |
| 797 | +} | |
| 798 | + | |
| 799 | +.chart-section { | |
| 800 | + background: #fff; | |
| 801 | + border: 1px solid #ebeef5; | |
| 802 | + border-radius: 10px; | |
| 803 | + padding: 16px; | |
| 804 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 805 | + | |
| 806 | + .chart-header { | |
| 807 | + display: flex; | |
| 808 | + justify-content: space-between; | |
| 809 | + align-items: center; | |
| 810 | + margin-bottom: 16px; | |
| 811 | + | |
| 812 | + h3 { | |
| 813 | + margin: 0; | |
| 814 | + font-size: 16px; | |
| 815 | + font-weight: 600; | |
| 816 | + color: #303133; | |
| 817 | + } | |
| 818 | + | |
| 819 | + .chart-legend { | |
| 820 | + display: flex; | |
| 821 | + gap: 16px; | |
| 822 | + | |
| 823 | + .legend-item { | |
| 824 | + display: flex; | |
| 825 | + align-items: center; | |
| 826 | + gap: 6px; | |
| 827 | + font-size: 12px; | |
| 828 | + color: #606266; | |
| 829 | + | |
| 830 | + i { | |
| 831 | + width: 12px; | |
| 832 | + height: 12px; | |
| 833 | + border-radius: 2px; | |
| 834 | + | |
| 835 | + &.legend-target { | |
| 836 | + background: #E6A23C; | |
| 837 | + } | |
| 838 | + | |
| 839 | + &.legend-completed { | |
| 840 | + background: #67C23A; | |
| 841 | + } | |
| 842 | + } | |
| 843 | + } | |
| 844 | + } | |
| 845 | + } | |
| 846 | + | |
| 847 | + .chart-container { | |
| 848 | + width: 100%; | |
| 849 | + height: 500px; | |
| 850 | + min-height: 500px; | |
| 851 | + } | |
| 852 | +} | |
| 853 | +</style> | ... | ... |
antis-ncc-admin/src/components/kpi-drill/tk-analysis.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="tk-wrapper"> | |
| 3 | + <div class="tk-layout"> | |
| 4 | + <!-- 左侧:走势图 + 列表 --> | |
| 5 | + <div class="tk-left"> | |
| 6 | + <div class="chart-card trend-card"> | |
| 7 | + <div class="chart-title"> | |
| 8 | + <i class="el-icon-date"></i> | |
| 9 | + 活动时间走势图 | |
| 10 | + </div> | |
| 11 | + <div ref="dailyTrendChart" class="chart-mini"></div> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + <div v-if="selectedDate" class="chart-card hourly-card"> | |
| 15 | + <div class="chart-title" style="justify-content: space-between;"> | |
| 16 | + <span style="display: flex; align-items: center; gap: 6px;"> | |
| 17 | + <i class="el-icon-time"></i> | |
| 18 | + {{ selectedDate }} 24小时走势图 | |
| 19 | + </span> | |
| 20 | + <el-button type="text" size="mini" icon="el-icon-close" style="padding: 0 4px;" | |
| 21 | + @click="closeHourlyChart">关闭</el-button> | |
| 22 | + </div> | |
| 23 | + <div ref="hourlyTrendChart" class="chart-mini"></div> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <div class="table-card"> | |
| 27 | + <div class="table-header"> | |
| 28 | + <div class="table-title"> | |
| 29 | + <i class="el-icon-document"></i> | |
| 30 | + 拓客明细 | |
| 31 | + </div> | |
| 32 | + <div class="list-filters inline"> | |
| 33 | + <el-select v-model="listFilter.eventId" size="mini" placeholder="活动" style="width: 200px;" clearable | |
| 34 | + @change="applyListFilter"> | |
| 35 | + <el-option v-for="e in eventList" :key="e.EventId || e.eventId" | |
| 36 | + :label="e.EventName || e.eventName" | |
| 37 | + :value="e.EventId || e.eventId" /> | |
| 38 | + </el-select> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <el-table v-loading="loading" :data="displayList" size="small" height="600" border stripe> | |
| 43 | + <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" | |
| 44 | + :width="col.width" :min-width="col.minWidth"> | |
| 45 | + <template slot-scope="scope"> | |
| 46 | + <span>{{ scope.row[col.prop] || '—' }}</span> | |
| 47 | + </template> | |
| 48 | + </el-table-column> | |
| 49 | + </el-table> | |
| 50 | + | |
| 51 | + <div class="pagination-bar"> | |
| 52 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="pagination.total" | |
| 53 | + :current-page="pagination.pageIndex" :page-size="pagination.pageSize" @size-change="handleSizeChange" | |
| 54 | + @current-change="handleCurrentChange" /> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <!-- 右侧:排名 + 统计 --> | |
| 60 | + <div class="tk-right"> | |
| 61 | + <div class="table-card ranking-card"> | |
| 62 | + <div class="table-title"> | |
| 63 | + <i class="el-icon-s-data"></i> | |
| 64 | + 门店本月拓客人数排名 | |
| 65 | + </div> | |
| 66 | + <div class="ranking-list"> | |
| 67 | + <div v-for="(item, index) in storeRanking" :key="index" class="ranking-item"> | |
| 68 | + <div class="ranking-number" :class="getRankingClass(index)">{{ index + 1 }}</div> | |
| 69 | + <div class="ranking-content"> | |
| 70 | + <div class="ranking-name">{{ item.StoreName || item.storeName || '未知' }}</div> | |
| 71 | + <div class="ranking-value">{{ item.TkCount || item.tkCount || 0 }} 人</div> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + <div v-if="!storeRanking || storeRanking.length === 0" class="empty-ranking"> | |
| 75 | + 暂无数据 | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + | |
| 80 | + <div class="chart-card"> | |
| 81 | + <div class="chart-title"> | |
| 82 | + <i class="el-icon-trophy"></i> | |
| 83 | + 拓客人员拓客人数排名前五 | |
| 84 | + </div> | |
| 85 | + <div ref="personRankingChart" class="chart-mini"></div> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + </div> | |
| 90 | +</template> | |
| 91 | + | |
| 92 | +<script> | |
| 93 | +import request from '@/utils/request' | |
| 94 | +import dayjs from 'dayjs' | |
| 95 | +import * as echarts from 'echarts' | |
| 96 | +import { kpiDrillMixin } from './mixins' | |
| 97 | + | |
| 98 | +export default { | |
| 99 | + name: 'TkAnalysis', | |
| 100 | + mixins: [kpiDrillMixin], | |
| 101 | + props: { | |
| 102 | + filters: { | |
| 103 | + type: Object, | |
| 104 | + default: () => ({ startTime: null, endTime: null, storeIds: [], month: null }) | |
| 105 | + }, | |
| 106 | + storeOptions: { type: Array, default: () => [] } | |
| 107 | + }, | |
| 108 | + data() { | |
| 109 | + return { | |
| 110 | + loading: false, | |
| 111 | + list: [], | |
| 112 | + displayList: [], | |
| 113 | + pagination: { pageIndex: 1, pageSize: 10, total: 0 }, | |
| 114 | + listFilter: { eventId: null }, | |
| 115 | + eventList: [], | |
| 116 | + storeRanking: [], | |
| 117 | + personRanking: [], | |
| 118 | + dailyTrend: [], | |
| 119 | + hourlyTrend: [], | |
| 120 | + selectedDate: null, | |
| 121 | + columns: [ | |
| 122 | + { prop: 'expansionTime', label: '拓客时间', minWidth: 140 }, | |
| 123 | + { prop: 'storeName', label: '门店', minWidth: 120 }, | |
| 124 | + { prop: 'customerName', label: '客户', minWidth: 100 }, | |
| 125 | + { prop: 'expansionUserName', label: '拓客人员', minWidth: 100 }, | |
| 126 | + { prop: 'eventName', label: '活动', minWidth: 120 }, | |
| 127 | + { prop: 'hasInvite', label: '是否邀约', width: 90 }, | |
| 128 | + { prop: 'hasAppointment', label: '是否预约', width: 90 }, | |
| 129 | + { prop: 'hasConsume', label: '是否消耗', width: 90 }, | |
| 130 | + { prop: 'hasBilling', label: '是否开卡', width: 90 } | |
| 131 | + ], | |
| 132 | + charts: { | |
| 133 | + dailyTrend: null, | |
| 134 | + hourlyTrend: null, | |
| 135 | + personRanking: null | |
| 136 | + } | |
| 137 | + } | |
| 138 | + }, | |
| 139 | + watch: { | |
| 140 | + filters: { | |
| 141 | + deep: true, | |
| 142 | + handler() { | |
| 143 | + this.resetAndFetch() | |
| 144 | + } | |
| 145 | + } | |
| 146 | + }, | |
| 147 | + mounted() { | |
| 148 | + this.fetchData() | |
| 149 | + this.fetchStatistics() | |
| 150 | + }, | |
| 151 | + beforeDestroy() { | |
| 152 | + Object.values(this.charts).forEach(chart => { | |
| 153 | + if (chart) chart.dispose() | |
| 154 | + }) | |
| 155 | + }, | |
| 156 | + methods: { | |
| 157 | + resetAndFetch() { | |
| 158 | + this.pagination = { ...this.pagination, pageIndex: 1 } | |
| 159 | + this.listFilter = { eventId: null } | |
| 160 | + this.selectedDate = null | |
| 161 | + this.fetchData() | |
| 162 | + this.fetchStatistics() | |
| 163 | + }, | |
| 164 | + handleSizeChange(size) { | |
| 165 | + this.pagination.pageSize = size | |
| 166 | + this.pagination.pageIndex = 1 | |
| 167 | + this.fetchData() | |
| 168 | + }, | |
| 169 | + handleCurrentChange(page) { | |
| 170 | + this.pagination.pageIndex = page | |
| 171 | + this.fetchData() | |
| 172 | + }, | |
| 173 | + applyListFilter() { | |
| 174 | + this.pagination.pageIndex = 1 | |
| 175 | + this.fetchData() | |
| 176 | + this.fetchStatistics() | |
| 177 | + }, | |
| 178 | + async fetchStatistics() { | |
| 179 | + try { | |
| 180 | + const range = this.buildDateRange() | |
| 181 | + if (!range) { | |
| 182 | + console.warn('No date range available for statistics') | |
| 183 | + return | |
| 184 | + } | |
| 185 | + | |
| 186 | + const url = '/api/Extend/LqReport/get-tk-drill-statistics' | |
| 187 | + const data = { | |
| 188 | + startTime: dayjs(range.start).format('YYYY-MM-DD HH:mm:ss'), | |
| 189 | + endTime: dayjs(range.end).format('YYYY-MM-DD HH:mm:ss'), | |
| 190 | + storeIds: this.filters.storeIds || [], | |
| 191 | + eventId: this.listFilter.eventId || null, | |
| 192 | + selectedDate: this.selectedDate | |
| 193 | + } | |
| 194 | + | |
| 195 | + const res = await request({ url, method: 'POST', data }) | |
| 196 | + if (res && res.data) { | |
| 197 | + // 兼容不同的响应格式 | |
| 198 | + const stats = res.data.Success ? res.data.Data : (res.data.data || res.data) | |
| 199 | + if (stats) { | |
| 200 | + this.storeRanking = stats.StoreRanking || stats.storeRanking || [] | |
| 201 | + this.personRanking = stats.PersonRanking || stats.personRanking || [] | |
| 202 | + this.eventList = stats.EventList || stats.eventList || [] | |
| 203 | + this.dailyTrend = stats.DailyTrend || stats.dailyTrend || [] | |
| 204 | + this.hourlyTrend = stats.HourlyTrend || stats.hourlyTrend || [] | |
| 205 | + | |
| 206 | + // 调试:打印原始数据 | |
| 207 | + console.log('DailyTrend received:', this.dailyTrend) | |
| 208 | + if (this.dailyTrend && this.dailyTrend.length > 0) { | |
| 209 | + console.log('First item keys:', Object.keys(this.dailyTrend[0])) | |
| 210 | + console.log('First item:', this.dailyTrend[0]) | |
| 211 | + } | |
| 212 | + | |
| 213 | + this.$nextTick(() => { | |
| 214 | + this.renderCharts() | |
| 215 | + }) | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } catch (error) { | |
| 219 | + console.error('Tk statistics load error:', error) | |
| 220 | + this.$message.error('加载统计数据失败: ' + (error.message || '未知错误')) | |
| 221 | + } | |
| 222 | + }, | |
| 223 | + async fetchData() { | |
| 224 | + this.loading = true | |
| 225 | + try { | |
| 226 | + const range = this.buildDateRange() | |
| 227 | + const storeId = this.getStoreId() | |
| 228 | + const url = '/api/Extend/LqTkjlb' | |
| 229 | + const data = { | |
| 230 | + currentPage: this.pagination.pageIndex, | |
| 231 | + pageSize: this.pagination.pageSize | |
| 232 | + } | |
| 233 | + if (range) data.expansionTime = `${range.startTs},${range.endTs}` | |
| 234 | + if (storeId) data.storeId = storeId | |
| 235 | + if (this.listFilter.eventId) data.eventId = this.listFilter.eventId | |
| 236 | + | |
| 237 | + const res = await request({ url, method: 'GET', data }) | |
| 238 | + await this.handleResponse(res) | |
| 239 | + } catch (error) { | |
| 240 | + console.error('Tk analysis load error:', error) | |
| 241 | + this.$message.error(error.message || '加载数据失败') | |
| 242 | + this.list = [] | |
| 243 | + this.displayList = [] | |
| 244 | + } finally { | |
| 245 | + this.loading = false | |
| 246 | + } | |
| 247 | + }, | |
| 248 | + async handleResponse(res) { | |
| 249 | + let list = [] | |
| 250 | + let pagination = this.pagination | |
| 251 | + | |
| 252 | + if (res && res.data) { | |
| 253 | + if (res.data.list && res.data.pagination) { | |
| 254 | + list = res.data.list | |
| 255 | + pagination = { | |
| 256 | + pageIndex: res.data.pagination.pageIndex || res.data.pagination.pageNumber || 1, | |
| 257 | + pageSize: res.data.pagination.pageSize || this.pagination.pageSize, | |
| 258 | + total: res.data.pagination.total || res.data.pagination.totalCount || 0 | |
| 259 | + } | |
| 260 | + } else if (Array.isArray(res.data.list)) { | |
| 261 | + list = res.data.list | |
| 262 | + } else if (Array.isArray(res.data)) { | |
| 263 | + list = res.data | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + list = list.map(i => ({ | |
| 268 | + expansionTime: i.expansionTime || i.ExpansionTime ? dayjs(i.expansionTime || i.ExpansionTime).format('YYYY-MM-DD HH:mm') : '', | |
| 269 | + storeName: i.storeName || i.StoreName || i.dm, | |
| 270 | + customerName: i.customerName || i.CustomerName, | |
| 271 | + expansionUserName: i.expansionUserName || i.ExpansionUserName || '—', | |
| 272 | + eventName: i.eventName || i.EventName || '—', | |
| 273 | + hasInvite: i.hasInvite || i.HasInvite || '否', | |
| 274 | + hasAppointment: i.hasAppointment || i.HasAppointment || '否', | |
| 275 | + hasConsume: i.hasConsume || i.HasConsume || '否', | |
| 276 | + hasBilling: i.hasBilling || i.HasBilling || '否' | |
| 277 | + })) | |
| 278 | + | |
| 279 | + this.list = list | |
| 280 | + this.displayList = list | |
| 281 | + this.pagination = pagination | |
| 282 | + }, | |
| 283 | + renderCharts() { | |
| 284 | + this.renderDailyTrendChart() | |
| 285 | + this.renderHourlyTrendChart() | |
| 286 | + this.renderPersonRankingChart() | |
| 287 | + }, | |
| 288 | + closeHourlyChart() { | |
| 289 | + this.selectedDate = null | |
| 290 | + this.hourlyTrend = [] | |
| 291 | + if (this.charts.hourlyTrend) { | |
| 292 | + this.charts.hourlyTrend.dispose() | |
| 293 | + this.charts.hourlyTrend = null | |
| 294 | + } | |
| 295 | + }, | |
| 296 | + getRankingClass(index) { | |
| 297 | + if (index === 0) return 'rank-first' | |
| 298 | + if (index === 1) return 'rank-second' | |
| 299 | + if (index === 2) return 'rank-third' | |
| 300 | + return '' | |
| 301 | + }, | |
| 302 | + renderDailyTrendChart() { | |
| 303 | + if (!this.$refs.dailyTrendChart) { | |
| 304 | + console.warn('dailyTrendChart ref not found') | |
| 305 | + return | |
| 306 | + } | |
| 307 | + if (this.charts.dailyTrend) { | |
| 308 | + this.charts.dailyTrend.dispose() | |
| 309 | + } | |
| 310 | + | |
| 311 | + const chart = echarts.init(this.$refs.dailyTrendChart) | |
| 312 | + if (!chart) { | |
| 313 | + console.error('Failed to initialize chart') | |
| 314 | + return | |
| 315 | + } | |
| 316 | + | |
| 317 | + if (!this.dailyTrend || this.dailyTrend.length === 0) { | |
| 318 | + chart.setOption({ | |
| 319 | + title: { | |
| 320 | + text: '暂无数据', | |
| 321 | + left: 'center', | |
| 322 | + top: 'middle', | |
| 323 | + textStyle: { color: '#909399', fontSize: 14 } | |
| 324 | + } | |
| 325 | + }) | |
| 326 | + this.charts.dailyTrend = chart | |
| 327 | + return | |
| 328 | + } | |
| 329 | + | |
| 330 | + // 获取时间范围,生成完整的月份日期数组 | |
| 331 | + const range = this.buildDateRange() | |
| 332 | + if (!range) { | |
| 333 | + console.warn('No date range available') | |
| 334 | + return | |
| 335 | + } | |
| 336 | + | |
| 337 | + const startDate = dayjs(range.start) | |
| 338 | + const endDate = dayjs(range.end) | |
| 339 | + | |
| 340 | + // 生成从开始日期到结束日期的所有日期 | |
| 341 | + const allDates = [] | |
| 342 | + let currentDate = startDate | |
| 343 | + while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) { | |
| 344 | + allDates.push(currentDate.format('YYYY-MM-DD')) | |
| 345 | + currentDate = currentDate.add(1, 'day') | |
| 346 | + } | |
| 347 | + | |
| 348 | + // 构建日期和数量的映射 | |
| 349 | + const dateCountMap = new Map() | |
| 350 | + console.log('DailyTrend raw data:', this.dailyTrend) // 调试日志 | |
| 351 | + this.dailyTrend.forEach((d, index) => { | |
| 352 | + // 获取所有可能的键名(不区分大小写) | |
| 353 | + const keys = Object.keys(d || {}) | |
| 354 | + const keysLower = keys.map(k => k.toLowerCase()) | |
| 355 | + | |
| 356 | + // 尝试获取日期 - 查找 DateStr 或 dateStr | |
| 357 | + let dateKey = '' | |
| 358 | + const dateStrIndex = keysLower.indexOf('datestr') | |
| 359 | + if (dateStrIndex >= 0) { | |
| 360 | + const originalKey = keys[dateStrIndex] | |
| 361 | + const dateValue = d[originalKey] | |
| 362 | + if (dateValue) { | |
| 363 | + dateKey = String(dateValue).trim() | |
| 364 | + // 确保日期格式正确(yyyy-MM-dd) | |
| 365 | + if (dateKey.length > 10) { | |
| 366 | + dateKey = dateKey.substring(0, 10) | |
| 367 | + } | |
| 368 | + } | |
| 369 | + } | |
| 370 | + | |
| 371 | + // 尝试获取数量 - 查找 TkCount 或 tkCount | |
| 372 | + let count = 0 | |
| 373 | + const tkCountIndex = keysLower.indexOf('tkcount') | |
| 374 | + if (tkCountIndex >= 0) { | |
| 375 | + const originalKey = keys[tkCountIndex] | |
| 376 | + const countValue = d[originalKey] | |
| 377 | + if (countValue !== undefined && countValue !== null) { | |
| 378 | + count = Number(countValue) || 0 | |
| 379 | + } | |
| 380 | + } | |
| 381 | + | |
| 382 | + if (dateKey) { | |
| 383 | + dateCountMap.set(dateKey, count) | |
| 384 | + console.log(`[${index}] Mapped date: ${dateKey}, count: ${count}`, d) // 调试日志 | |
| 385 | + } else { | |
| 386 | + console.warn(`[${index}] Could not extract date from:`, d, 'Keys:', keys) // 调试日志 | |
| 387 | + } | |
| 388 | + }) | |
| 389 | + | |
| 390 | + console.log('Date count map:', Array.from(dateCountMap.entries())) // 调试日志 | |
| 391 | + console.log('All dates to map:', allDates.slice(0, 5), '...', allDates.slice(-5)) // 调试日志 | |
| 392 | + | |
| 393 | + // 使用完整的日期数组,缺失的日期补0 | |
| 394 | + const finalDates = allDates | |
| 395 | + const finalCounts = allDates.map(date => { | |
| 396 | + const count = dateCountMap.get(date) | |
| 397 | + return count !== undefined ? count : 0 | |
| 398 | + }) | |
| 399 | + | |
| 400 | + console.log('Final dates count:', finalDates.length) | |
| 401 | + console.log('Final counts sample (first 5):', finalCounts.slice(0, 5)) | |
| 402 | + console.log('Final counts sample (last 5):', finalCounts.slice(-5)) | |
| 403 | + console.log('Non-zero counts:', finalCounts.filter(c => c > 0).length) | |
| 404 | + | |
| 405 | + const option = { | |
| 406 | + tooltip: { | |
| 407 | + trigger: 'axis', | |
| 408 | + formatter: (params) => { | |
| 409 | + const param = Array.isArray(params) ? params[0] : params | |
| 410 | + return `${param.name}<br/>拓客人数: ${param.value}` | |
| 411 | + } | |
| 412 | + }, | |
| 413 | + grid: { left: '10%', right: '5%', bottom: '15%', top: '10%' }, | |
| 414 | + xAxis: { | |
| 415 | + type: 'category', | |
| 416 | + data: finalDates, | |
| 417 | + axisLabel: { rotate: 45, fontSize: 11 } | |
| 418 | + }, | |
| 419 | + yAxis: { type: 'value', name: '人数' }, | |
| 420 | + series: [{ | |
| 421 | + name: '拓客人数', | |
| 422 | + type: 'line', | |
| 423 | + data: finalCounts, | |
| 424 | + smooth: true, | |
| 425 | + itemStyle: { color: '#409EFF' }, | |
| 426 | + areaStyle: { color: 'rgba(64, 158, 255, 0.1)' } | |
| 427 | + }] | |
| 428 | + } | |
| 429 | + | |
| 430 | + chart.setOption(option) | |
| 431 | + chart.on('click', (params) => { | |
| 432 | + if (params && params.name) { | |
| 433 | + this.selectedDate = params.name | |
| 434 | + this.fetchStatistics() | |
| 435 | + } | |
| 436 | + }) | |
| 437 | + this.charts.dailyTrend = chart | |
| 438 | + }, | |
| 439 | + renderHourlyTrendChart() { | |
| 440 | + if (!this.$refs.hourlyTrendChart || !this.selectedDate) { | |
| 441 | + return | |
| 442 | + } | |
| 443 | + if (this.charts.hourlyTrend) { | |
| 444 | + this.charts.hourlyTrend.dispose() | |
| 445 | + } | |
| 446 | + | |
| 447 | + const chart = echarts.init(this.$refs.hourlyTrendChart) | |
| 448 | + if (!chart) { | |
| 449 | + console.error('Failed to initialize hourly chart') | |
| 450 | + return | |
| 451 | + } | |
| 452 | + | |
| 453 | + if (!this.hourlyTrend || this.hourlyTrend.length === 0) { | |
| 454 | + chart.setOption({ | |
| 455 | + title: { | |
| 456 | + text: '暂无数据', | |
| 457 | + left: 'center', | |
| 458 | + top: 'middle', | |
| 459 | + textStyle: { color: '#909399', fontSize: 14 } | |
| 460 | + } | |
| 461 | + }) | |
| 462 | + this.charts.hourlyTrend = chart | |
| 463 | + return | |
| 464 | + } | |
| 465 | + | |
| 466 | + // 生成完整的24小时数据(0-23点),缺失的小时补0 | |
| 467 | + const allHours = Array.from({ length: 24 }, (_, i) => { | |
| 468 | + const hourStr = `${String(i).padStart(2, '0')}:00` | |
| 469 | + const existing = this.hourlyTrend.find(h => { | |
| 470 | + const hStr = h.HourStr || h.hourStr | |
| 471 | + const hNum = h.Hour || h.hour | |
| 472 | + return hStr === hourStr || (hNum !== undefined && hNum === i) | |
| 473 | + }) | |
| 474 | + return { | |
| 475 | + hour: i, | |
| 476 | + hourStr: hourStr, | |
| 477 | + count: existing ? Number(existing.TkCount || existing.tkCount || 0) : 0 | |
| 478 | + } | |
| 479 | + }) | |
| 480 | + | |
| 481 | + const hours = allHours.map(h => h.hourStr) | |
| 482 | + const counts = allHours.map(h => h.count) | |
| 483 | + | |
| 484 | + const option = { | |
| 485 | + tooltip: { | |
| 486 | + trigger: 'axis', | |
| 487 | + formatter: (params) => { | |
| 488 | + const param = Array.isArray(params) ? params[0] : params | |
| 489 | + return `${param.name}<br/>拓客人数: ${param.value}` | |
| 490 | + } | |
| 491 | + }, | |
| 492 | + grid: { left: '10%', right: '5%', bottom: '15%', top: '10%' }, | |
| 493 | + xAxis: { | |
| 494 | + type: 'category', | |
| 495 | + data: hours, | |
| 496 | + axisLabel: { | |
| 497 | + fontSize: 11, | |
| 498 | + interval: 0, // 显示所有标签 | |
| 499 | + rotate: 45 // 旋转45度避免重叠 | |
| 500 | + } | |
| 501 | + }, | |
| 502 | + yAxis: { type: 'value', name: '人数' }, | |
| 503 | + series: [{ | |
| 504 | + name: '拓客人数', | |
| 505 | + type: 'bar', | |
| 506 | + data: counts, | |
| 507 | + itemStyle: { color: '#67C23A' } | |
| 508 | + }] | |
| 509 | + } | |
| 510 | + | |
| 511 | + chart.setOption(option) | |
| 512 | + this.charts.hourlyTrend = chart | |
| 513 | + }, | |
| 514 | + renderPersonRankingChart() { | |
| 515 | + if (!this.$refs.personRankingChart) { | |
| 516 | + console.warn('personRankingChart ref not found') | |
| 517 | + return | |
| 518 | + } | |
| 519 | + if (this.charts.personRanking) { | |
| 520 | + this.charts.personRanking.dispose() | |
| 521 | + } | |
| 522 | + | |
| 523 | + const chart = echarts.init(this.$refs.personRankingChart) | |
| 524 | + if (!chart) { | |
| 525 | + console.error('Failed to initialize chart') | |
| 526 | + return | |
| 527 | + } | |
| 528 | + | |
| 529 | + if (!this.personRanking || this.personRanking.length === 0) { | |
| 530 | + chart.setOption({ | |
| 531 | + title: { | |
| 532 | + text: '暂无数据', | |
| 533 | + left: 'center', | |
| 534 | + top: 'middle', | |
| 535 | + textStyle: { color: '#909399', fontSize: 14 } | |
| 536 | + } | |
| 537 | + }) | |
| 538 | + this.charts.personRanking = chart | |
| 539 | + return | |
| 540 | + } | |
| 541 | + | |
| 542 | + const persons = this.personRanking.map(p => p.UserName || p.userName || '未知') | |
| 543 | + const counts = this.personRanking.map(p => Number(p.TkCount || p.tkCount || 0)) | |
| 544 | + | |
| 545 | + const option = { | |
| 546 | + tooltip: { | |
| 547 | + trigger: 'axis', | |
| 548 | + axisPointer: { type: 'shadow' }, | |
| 549 | + formatter: (params) => { | |
| 550 | + const param = Array.isArray(params) ? params[0] : params | |
| 551 | + return `${param.name}<br/>拓客人数: ${param.value}` | |
| 552 | + } | |
| 553 | + }, | |
| 554 | + grid: { left: '15%', right: '5%', bottom: '20%', top: '10%' }, | |
| 555 | + xAxis: { | |
| 556 | + type: 'value', | |
| 557 | + name: '人数' | |
| 558 | + }, | |
| 559 | + yAxis: { | |
| 560 | + type: 'category', | |
| 561 | + data: persons, | |
| 562 | + axisLabel: { fontSize: 11 } | |
| 563 | + }, | |
| 564 | + series: [{ | |
| 565 | + name: '拓客人数', | |
| 566 | + type: 'bar', | |
| 567 | + data: counts, | |
| 568 | + itemStyle: { color: '#67C23A' } | |
| 569 | + }] | |
| 570 | + } | |
| 571 | + | |
| 572 | + chart.setOption(option) | |
| 573 | + this.charts.personRanking = chart | |
| 574 | + } | |
| 575 | + } | |
| 576 | +} | |
| 577 | +</script> | |
| 578 | + | |
| 579 | +<style lang="scss" scoped> | |
| 580 | +@import './common-styles.scss'; | |
| 581 | + | |
| 582 | +.tk-wrapper { | |
| 583 | + width: 100%; | |
| 584 | + overflow: hidden; | |
| 585 | + | |
| 586 | + .tk-layout { | |
| 587 | + display: flex; | |
| 588 | + align-items: stretch; | |
| 589 | + justify-content: space-between; | |
| 590 | + gap: 16px; | |
| 591 | + width: 100%; | |
| 592 | + } | |
| 593 | + | |
| 594 | + .tk-left { | |
| 595 | + flex: 0 0 75%; | |
| 596 | + max-width: 75%; | |
| 597 | + display: flex; | |
| 598 | + flex-direction: column; | |
| 599 | + gap: 12px; | |
| 600 | + min-width: 0; | |
| 601 | + } | |
| 602 | + | |
| 603 | + .tk-right { | |
| 604 | + flex: 0 0 25%; | |
| 605 | + max-width: 23.5%; | |
| 606 | + display: flex; | |
| 607 | + flex-direction: column; | |
| 608 | + gap: 12px; | |
| 609 | + min-width: 0; | |
| 610 | + } | |
| 611 | + | |
| 612 | + .ranking-card { | |
| 613 | + background: #fff; | |
| 614 | + border: 1px solid #ebeef5; | |
| 615 | + border-radius: 10px; | |
| 616 | + padding: 10px 12px; | |
| 617 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 618 | + flex-shrink: 0; | |
| 619 | + max-height: 400px; | |
| 620 | + overflow-y: auto; | |
| 621 | + | |
| 622 | + .table-title { | |
| 623 | + font-size: 13px; | |
| 624 | + font-weight: 600; | |
| 625 | + color: #303133; | |
| 626 | + margin-bottom: 10px; | |
| 627 | + display: flex; | |
| 628 | + align-items: center; | |
| 629 | + gap: 6px; | |
| 630 | + | |
| 631 | + i { | |
| 632 | + color: #409EFF; | |
| 633 | + } | |
| 634 | + } | |
| 635 | + | |
| 636 | + .ranking-list { | |
| 637 | + display: flex; | |
| 638 | + flex-direction: column; | |
| 639 | + gap: 8px; | |
| 640 | + } | |
| 641 | + | |
| 642 | + .ranking-item { | |
| 643 | + display: flex; | |
| 644 | + align-items: center; | |
| 645 | + gap: 10px; | |
| 646 | + padding: 8px; | |
| 647 | + border-radius: 6px; | |
| 648 | + background: #f5f7fa; | |
| 649 | + transition: all 0.3s; | |
| 650 | + | |
| 651 | + &:hover { | |
| 652 | + background: #ecf5ff; | |
| 653 | + } | |
| 654 | + | |
| 655 | + .ranking-number { | |
| 656 | + width: 28px; | |
| 657 | + height: 28px; | |
| 658 | + border-radius: 50%; | |
| 659 | + display: flex; | |
| 660 | + align-items: center; | |
| 661 | + justify-content: center; | |
| 662 | + font-weight: 700; | |
| 663 | + font-size: 14px; | |
| 664 | + flex-shrink: 0; | |
| 665 | + background: #909399; | |
| 666 | + color: #fff; | |
| 667 | + | |
| 668 | + &.rank-first { | |
| 669 | + background: #FFD700; | |
| 670 | + color: #333; | |
| 671 | + } | |
| 672 | + | |
| 673 | + &.rank-second { | |
| 674 | + background: #C0C0C0; | |
| 675 | + color: #333; | |
| 676 | + } | |
| 677 | + | |
| 678 | + &.rank-third { | |
| 679 | + background: #CD7F32; | |
| 680 | + color: #fff; | |
| 681 | + } | |
| 682 | + } | |
| 683 | + | |
| 684 | + .ranking-content { | |
| 685 | + flex: 1; | |
| 686 | + min-width: 0; | |
| 687 | + display: flex; | |
| 688 | + justify-content: space-between; | |
| 689 | + align-items: center; | |
| 690 | + } | |
| 691 | + | |
| 692 | + .ranking-name { | |
| 693 | + font-size: 13px; | |
| 694 | + color: #303133; | |
| 695 | + font-weight: 500; | |
| 696 | + overflow: hidden; | |
| 697 | + text-overflow: ellipsis; | |
| 698 | + white-space: nowrap; | |
| 699 | + } | |
| 700 | + | |
| 701 | + .ranking-value { | |
| 702 | + font-size: 13px; | |
| 703 | + color: #409EFF; | |
| 704 | + font-weight: 600; | |
| 705 | + white-space: nowrap; | |
| 706 | + margin-left: 8px; | |
| 707 | + } | |
| 708 | + } | |
| 709 | + | |
| 710 | + .empty-ranking { | |
| 711 | + text-align: center; | |
| 712 | + color: #909399; | |
| 713 | + padding: 20px; | |
| 714 | + font-size: 13px; | |
| 715 | + } | |
| 716 | + } | |
| 717 | + | |
| 718 | + // 复用 common-styles 中的样式 | |
| 719 | + .chart-card { | |
| 720 | + background: #fff; | |
| 721 | + border: 1px solid #ebeef5; | |
| 722 | + border-radius: 10px; | |
| 723 | + padding: 10px; | |
| 724 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 725 | + flex-shrink: 0; | |
| 726 | + | |
| 727 | + .chart-title { | |
| 728 | + font-size: 13px; | |
| 729 | + color: #606266; | |
| 730 | + margin-bottom: 6px; | |
| 731 | + display: flex; | |
| 732 | + align-items: center; | |
| 733 | + gap: 6px; | |
| 734 | + | |
| 735 | + i { | |
| 736 | + color: #409EFF; | |
| 737 | + } | |
| 738 | + } | |
| 739 | + | |
| 740 | + .chart-mini { | |
| 741 | + height: 220px; | |
| 742 | + width: 100%; | |
| 743 | + } | |
| 744 | + } | |
| 745 | + | |
| 746 | + .table-card { | |
| 747 | + background: #fff; | |
| 748 | + border: 1px solid #ebeef5; | |
| 749 | + border-radius: 10px; | |
| 750 | + padding: 10px 12px; | |
| 751 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 752 | + flex: 1; | |
| 753 | + min-height: 700px; | |
| 754 | + display: flex; | |
| 755 | + flex-direction: column; | |
| 756 | + | |
| 757 | + .table-header { | |
| 758 | + display: flex; | |
| 759 | + align-items: center; | |
| 760 | + justify-content: space-between; | |
| 761 | + margin-bottom: 8px; | |
| 762 | + flex-shrink: 0; | |
| 763 | + } | |
| 764 | + | |
| 765 | + .table-title { | |
| 766 | + font-size: 14px; | |
| 767 | + font-weight: 600; | |
| 768 | + color: #303133; | |
| 769 | + display: flex; | |
| 770 | + align-items: center; | |
| 771 | + gap: 6px; | |
| 772 | + | |
| 773 | + i { | |
| 774 | + color: #409EFF; | |
| 775 | + } | |
| 776 | + } | |
| 777 | + | |
| 778 | + .el-table { | |
| 779 | + flex: 1; | |
| 780 | + min-height: 0; | |
| 781 | + } | |
| 782 | + | |
| 783 | + .pagination-bar { | |
| 784 | + flex-shrink: 0; | |
| 785 | + margin-top: 8px; | |
| 786 | + display: flex; | |
| 787 | + justify-content: flex-end; | |
| 788 | + padding: 10px 0 4px 0; | |
| 789 | + } | |
| 790 | + } | |
| 791 | +} | |
| 792 | +</style> | ... | ... |
antis-ncc-admin/src/components/member-portrait-dialog.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <el-dialog :visible.sync="visibleSync" title="会员画像" :width="'1400px'" append-to-body | |
| 3 | + custom-class="member-portrait-dialog" :close-on-click-modal="false" @closed="handleClosed"> | |
| 4 | + <div v-loading="loading" class="portrait-wrapper"> | |
| 5 | + <!-- 顶部:基础信息栏 --> | |
| 6 | + <div class="portrait-header"> | |
| 7 | + <div class="header-main"> | |
| 8 | + <div class="member-name">{{ baseInfo.MemberName || '—' }}</div> | |
| 9 | + <div class="member-meta"> | |
| 10 | + <span class="meta-item"> | |
| 11 | + <i class="el-icon-phone"></i> | |
| 12 | + {{ baseInfo.Mobile || '—' }} | |
| 13 | + </span> | |
| 14 | + <span class="meta-item"> | |
| 15 | + <i class="el-icon-office-building"></i> | |
| 16 | + {{ baseInfo.StoreName || '—' }} | |
| 17 | + </span> | |
| 18 | + <span class="meta-item" v-if="baseInfo.Channel"> | |
| 19 | + <i class="el-icon-connection"></i> | |
| 20 | + {{ baseInfo.Channel }} | |
| 21 | + </span> | |
| 22 | + </div> | |
| 23 | + </div> | |
| 24 | + <div class="header-stats"> | |
| 25 | + <div class="stat-item"> | |
| 26 | + <div class="stat-label">剩余权益</div> | |
| 27 | + <div class="stat-value highlight">¥{{ formatMoney(behaviorSummary.RemainingRightsAmount) }}</div> | |
| 28 | + </div> | |
| 29 | + <div class="stat-item"> | |
| 30 | + <div class="stat-label">累计开单</div> | |
| 31 | + <div class="stat-value">¥{{ formatMoney(behaviorSummary.TotalBillingAmount) }}</div> | |
| 32 | + </div> | |
| 33 | + <div class="stat-item"> | |
| 34 | + <div class="stat-label">累计消耗</div> | |
| 35 | + <div class="stat-value">¥{{ formatMoney(behaviorSummary.TotalConsumeAmount) }}</div> | |
| 36 | + </div> | |
| 37 | + <div class="stat-item"> | |
| 38 | + <div class="stat-label">沉睡天数</div> | |
| 39 | + <div class="stat-value" :class="{ 'text-warning': baseInfo.SleepDays > 30 }"> | |
| 40 | + {{ baseInfo.SleepDays || 0 }} 天 | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <!-- 基础信息区域(独立于标签页) --> | |
| 47 | + <div class="base-info-section"> | |
| 48 | + <div class="base-info-layout"> | |
| 49 | + <!-- 会员类型 --> | |
| 50 | + <div class="card-section"> | |
| 51 | + <div class="section-title"> | |
| 52 | + <i class="el-icon-collection-tag"></i> | |
| 53 | + 会员类型 | |
| 54 | + </div> | |
| 55 | + <div class="tag-list"> | |
| 56 | + <div v-for="type in baseInfo.MemberTypes" :key="type.TypeName" class="member-type-item"> | |
| 57 | + <el-tag :type="getMemberTypeTagType(type.TypeName)" size="medium" class="member-type-tag"> | |
| 58 | + {{ type.TypeName }} | |
| 59 | + </el-tag> | |
| 60 | + <span class="member-type-date" v-if="type.BecomeTime"> | |
| 61 | + {{ formatDate(type.BecomeTime) }} | |
| 62 | + </span> | |
| 63 | + </div> | |
| 64 | + <span v-if="!baseInfo.MemberTypes || baseInfo.MemberTypes.length === 0" class="text-muted">无</span> | |
| 65 | + </div> | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <!-- 基础信息 --> | |
| 69 | + <div class="card-section"> | |
| 70 | + <div class="section-title"> | |
| 71 | + <i class="el-icon-info"></i> | |
| 72 | + 基础信息 | |
| 73 | + </div> | |
| 74 | + <div class="info-list"> | |
| 75 | + <div class="info-item"> | |
| 76 | + <span class="info-label">会员编码:</span> | |
| 77 | + <span class="info-value">{{ baseInfo.MemberCode || '—' }}</span> | |
| 78 | + </div> | |
| 79 | + <div class="info-item"> | |
| 80 | + <span class="info-label">首次到店:</span> | |
| 81 | + <span class="info-value">{{ formatDateTime(baseInfo.FirstVisitTime) }}</span> | |
| 82 | + </div> | |
| 83 | + <div class="info-item"> | |
| 84 | + <span class="info-label">最后到店:</span> | |
| 85 | + <span class="info-value">{{ formatDateTime(baseInfo.LastVisitTime) }}</span> | |
| 86 | + </div> | |
| 87 | + <div class="info-item"> | |
| 88 | + <span class="info-label">消费等级:</span> | |
| 89 | + <span class="info-value">{{ getConsumeLevelText(baseInfo.ConsumeLevel) }}</span> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + | |
| 96 | + <!-- 选项卡内容 --> | |
| 97 | + <el-tabs v-model="activeTab" class="portrait-tabs"> | |
| 98 | + <!-- 概览 --> | |
| 99 | + <el-tab-pane label="概览" name="overview"> | |
| 100 | + <div class="tab-content"> | |
| 101 | + <!-- 消费行为 --> | |
| 102 | + <div class="card-section"> | |
| 103 | + <div class="section-title"> | |
| 104 | + <i class="el-icon-shopping-cart-full"></i> | |
| 105 | + 消费行为 | |
| 106 | + </div> | |
| 107 | + <div class="behavior-grid"> | |
| 108 | + <div class="behavior-item"> | |
| 109 | + <div class="behavior-label">开单次数</div> | |
| 110 | + <div class="behavior-value">{{ behaviorSummary.BillingCount || 0 }}</div> | |
| 111 | + </div> | |
| 112 | + <div class="behavior-item"> | |
| 113 | + <div class="behavior-label">消耗次数</div> | |
| 114 | + <div class="behavior-value">{{ behaviorSummary.ConsumeCount || 0 }}</div> | |
| 115 | + </div> | |
| 116 | + <div class="behavior-item"> | |
| 117 | + <div class="behavior-label">退卡次数</div> | |
| 118 | + <div class="behavior-value">{{ behaviorSummary.RefundCount || 0 }}</div> | |
| 119 | + </div> | |
| 120 | + <div class="behavior-item"> | |
| 121 | + <div class="behavior-label">平均开单金额</div> | |
| 122 | + <div class="behavior-value">¥{{ formatMoney(behaviorSummary.AvgBillingAmount) }}</div> | |
| 123 | + </div> | |
| 124 | + <div class="behavior-item"> | |
| 125 | + <div class="behavior-label">平均消耗金额</div> | |
| 126 | + <div class="behavior-value">¥{{ formatMoney(behaviorSummary.AvgConsumeAmount) }}</div> | |
| 127 | + </div> | |
| 128 | + <div class="behavior-item"> | |
| 129 | + <div class="behavior-label">最近开单</div> | |
| 130 | + <div class="behavior-value">{{ formatDateTime(behaviorSummary.LastBillingTime) }}</div> | |
| 131 | + </div> | |
| 132 | + <div class="behavior-item"> | |
| 133 | + <div class="behavior-label">最近消耗</div> | |
| 134 | + <div class="behavior-value">{{ formatDateTime(behaviorSummary.LastConsumeTime) }}</div> | |
| 135 | + </div> | |
| 136 | + <div class="behavior-item"> | |
| 137 | + <div class="behavior-label">首次开单</div> | |
| 138 | + <div class="behavior-value">{{ formatDateTime(behaviorSummary.FirstBillingTime) }}</div> | |
| 139 | + </div> | |
| 140 | + <div class="behavior-item"> | |
| 141 | + <div class="behavior-label">首次消耗</div> | |
| 142 | + <div class="behavior-value">{{ formatDateTime(behaviorSummary.FirstConsumeTime) }}</div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + | |
| 147 | + <!-- 近12个月趋势图 --> | |
| 148 | + <div class="card-section"> | |
| 149 | + <div class="section-title"> | |
| 150 | + <i class="el-icon-data-line"></i> | |
| 151 | + 近12个月消费趋势 | |
| 152 | + </div> | |
| 153 | + <div ref="trendChart" class="trend-chart"></div> | |
| 154 | + </div> | |
| 155 | + | |
| 156 | + <!-- 消费分析 --> | |
| 157 | + <div v-if="consumptionAnalysis" class="card-section"> | |
| 158 | + <div class="section-title"> | |
| 159 | + <i class="el-icon-data-analysis"></i> | |
| 160 | + 消费分析 | |
| 161 | + </div> | |
| 162 | + <div class="analysis-layout"> | |
| 163 | + <div class="analysis-item"> | |
| 164 | + <div class="analysis-label">消费频率</div> | |
| 165 | + <div class="analysis-value">{{ formatMoney(consumptionAnalysis.ConsumeFrequency) }} 次/月</div> | |
| 166 | + </div> | |
| 167 | + <div class="analysis-item"> | |
| 168 | + <div class="analysis-label">开单频率</div> | |
| 169 | + <div class="analysis-value">{{ formatMoney(consumptionAnalysis.BillingFrequency) }} 次/月</div> | |
| 170 | + </div> | |
| 171 | + <div class="analysis-item"> | |
| 172 | + <div class="analysis-label">消费活跃度</div> | |
| 173 | + <div class="analysis-value"> | |
| 174 | + <el-tag :type="consumptionAnalysis.IsActive ? 'success' : 'info'" size="small"> | |
| 175 | + {{ consumptionAnalysis.IsActive ? '活跃' : '不活跃' }} | |
| 176 | + </el-tag> | |
| 177 | + </div> | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | + </div> | |
| 181 | + </div> | |
| 182 | + </el-tab-pane> | |
| 183 | + | |
| 184 | + <!-- 权益明细 --> | |
| 185 | + <el-tab-pane label="权益明细" name="assets"> | |
| 186 | + <div class="tab-content"> | |
| 187 | + <div class="card-section"> | |
| 188 | + <div class="section-title"> | |
| 189 | + <i class="el-icon-wallet"></i> | |
| 190 | + 权益明细 | |
| 191 | + </div> | |
| 192 | + <el-table :data="remainingItems" size="small" border stripe> | |
| 193 | + <el-table-column prop="ItemName" label="品项名称" min-width="140" /> | |
| 194 | + <el-table-column prop="SourceType" label="来源类型" width="90" /> | |
| 195 | + <el-table-column prop="UnitPrice" label="单价" width="90"> | |
| 196 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.UnitPrice) }}</template> | |
| 197 | + </el-table-column> | |
| 198 | + <el-table-column prop="TotalQuantity" label="总数量" width="80" /> | |
| 199 | + <el-table-column prop="ConsumedQuantity" label="已消费" width="80" /> | |
| 200 | + <el-table-column prop="RefundedQuantity" label="已退款" width="80" /> | |
| 201 | + <el-table-column prop="DeductedQuantity" label="已扣除" width="80" /> | |
| 202 | + <el-table-column prop="RemainingQuantity" label="剩余" width="80" /> | |
| 203 | + <el-table-column prop="RemainingValue" label="剩余价值" width="120"> | |
| 204 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.RemainingValue) }}</template> | |
| 205 | + </el-table-column> | |
| 206 | + </el-table> | |
| 207 | + </div> | |
| 208 | + </div> | |
| 209 | + </el-tab-pane> | |
| 210 | + | |
| 211 | + <!-- 开单列表 --> | |
| 212 | + <el-tab-pane label="开单列表" name="billing"> | |
| 213 | + <div class="tab-content"> | |
| 214 | + <el-table v-loading="billingLoading" :data="billingList" size="small" border stripe> | |
| 215 | + <el-table-column prop="BillingDate" label="开单日期" width="160"> | |
| 216 | + <template slot-scope="scope">{{ formatDateTime(scope.row.BillingDate) }}</template> | |
| 217 | + </el-table-column> | |
| 218 | + <el-table-column prop="StoreName" label="门店" width="150" /> | |
| 219 | + <el-table-column prop="Amount" label="实付金额" width="120"> | |
| 220 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.Amount) }}</template> | |
| 221 | + </el-table-column> | |
| 222 | + <el-table-column prop="DebtAmount" label="欠款金额" width="120"> | |
| 223 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.DebtAmount) }}</template> | |
| 224 | + </el-table-column> | |
| 225 | + <el-table-column prop="ActivityName" label="活动名称" min-width="150" /> | |
| 226 | + </el-table> | |
| 227 | + <div class="pagination-bar"> | |
| 228 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 229 | + :total="billingPagination.total" :current-page="billingPagination.pageIndex" | |
| 230 | + :page-size="billingPagination.pageSize" @size-change="handleBillingSizeChange" | |
| 231 | + @current-change="handleBillingPageChange" /> | |
| 232 | + </div> | |
| 233 | + </div> | |
| 234 | + </el-tab-pane> | |
| 235 | + | |
| 236 | + <!-- 消耗列表 --> | |
| 237 | + <el-tab-pane label="消耗列表" name="consume"> | |
| 238 | + <div class="tab-content"> | |
| 239 | + <el-table v-loading="consumeLoading" :data="consumeList" size="small" border stripe> | |
| 240 | + <el-table-column prop="ConsumeDate" label="消耗日期" width="160"> | |
| 241 | + <template slot-scope="scope">{{ formatDateTime(scope.row.ConsumeDate) }}</template> | |
| 242 | + </el-table-column> | |
| 243 | + <el-table-column prop="StoreName" label="门店" width="150" /> | |
| 244 | + <el-table-column prop="Amount" label="消耗金额" width="120"> | |
| 245 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.Amount) }}</template> | |
| 246 | + </el-table-column> | |
| 247 | + <el-table-column prop="LaborCost" label="手工费" width="120"> | |
| 248 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.LaborCost) }}</template> | |
| 249 | + </el-table-column> | |
| 250 | + </el-table> | |
| 251 | + <div class="pagination-bar"> | |
| 252 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 253 | + :total="consumePagination.total" :current-page="consumePagination.pageIndex" | |
| 254 | + :page-size="consumePagination.pageSize" @size-change="handleConsumeSizeChange" | |
| 255 | + @current-change="handleConsumePageChange" /> | |
| 256 | + </div> | |
| 257 | + </div> | |
| 258 | + </el-tab-pane> | |
| 259 | + | |
| 260 | + <!-- 退卡列表 --> | |
| 261 | + <el-tab-pane label="退卡列表" name="refund"> | |
| 262 | + <div class="tab-content"> | |
| 263 | + <el-table v-loading="refundLoading" :data="refundList" size="small" border stripe> | |
| 264 | + <el-table-column prop="RefundDate" label="退卡日期" width="160"> | |
| 265 | + <template slot-scope="scope">{{ formatDateTime(scope.row.RefundDate) }}</template> | |
| 266 | + </el-table-column> | |
| 267 | + <el-table-column prop="StoreName" label="门店" width="150" /> | |
| 268 | + <el-table-column prop="RefundAmount" label="退卡金额" width="120"> | |
| 269 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.RefundAmount) }}</template> | |
| 270 | + </el-table-column> | |
| 271 | + <el-table-column prop="ActualRefundAmount" label="实际退款" width="120"> | |
| 272 | + <template slot-scope="scope">¥{{ formatMoney(scope.row.ActualRefundAmount) }}</template> | |
| 273 | + </el-table-column> | |
| 274 | + <el-table-column prop="RefundReason" label="退卡原因" min-width="150" /> | |
| 275 | + </el-table> | |
| 276 | + <div class="pagination-bar"> | |
| 277 | + <el-pagination layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" | |
| 278 | + :total="refundPagination.total" :current-page="refundPagination.pageIndex" | |
| 279 | + :page-size="refundPagination.pageSize" @size-change="handleRefundSizeChange" | |
| 280 | + @current-change="handleRefundPageChange" /> | |
| 281 | + </div> | |
| 282 | + </div> | |
| 283 | + </el-tab-pane> | |
| 284 | + </el-tabs> | |
| 285 | + </div> | |
| 286 | + </el-dialog> | |
| 287 | +</template> | |
| 288 | + | |
| 289 | +<script> | |
| 290 | +import request from '@/utils/request' | |
| 291 | +import * as echarts from 'echarts' | |
| 292 | + | |
| 293 | +export default { | |
| 294 | + name: 'MemberPortraitDialog', | |
| 295 | + props: { | |
| 296 | + visible: { type: Boolean, default: false }, | |
| 297 | + memberId: { type: String, default: '' } | |
| 298 | + }, | |
| 299 | + data() { | |
| 300 | + return { | |
| 301 | + visibleSync: false, | |
| 302 | + loading: false, | |
| 303 | + activeTab: 'overview', | |
| 304 | + baseInfo: {}, | |
| 305 | + behaviorSummary: {}, | |
| 306 | + remainingItems: [], | |
| 307 | + monthlyTrend: [], | |
| 308 | + consumptionAnalysis: null, | |
| 309 | + trendChart: null, | |
| 310 | + // 开单列表 | |
| 311 | + billingList: [], | |
| 312 | + billingLoading: false, | |
| 313 | + billingPagination: { | |
| 314 | + pageIndex: 1, | |
| 315 | + pageSize: 10, | |
| 316 | + total: 0 | |
| 317 | + }, | |
| 318 | + // 消耗列表 | |
| 319 | + consumeList: [], | |
| 320 | + consumeLoading: false, | |
| 321 | + consumePagination: { | |
| 322 | + pageIndex: 1, | |
| 323 | + pageSize: 10, | |
| 324 | + total: 0 | |
| 325 | + }, | |
| 326 | + // 退卡列表 | |
| 327 | + refundList: [], | |
| 328 | + refundLoading: false, | |
| 329 | + refundPagination: { | |
| 330 | + pageIndex: 1, | |
| 331 | + pageSize: 10, | |
| 332 | + total: 0 | |
| 333 | + } | |
| 334 | + } | |
| 335 | + }, | |
| 336 | + watch: { | |
| 337 | + visible: { | |
| 338 | + immediate: true, | |
| 339 | + handler(v) { | |
| 340 | + this.visibleSync = v | |
| 341 | + if (v && this.memberId) { | |
| 342 | + this.fetchData() | |
| 343 | + } | |
| 344 | + } | |
| 345 | + }, | |
| 346 | + memberId: { | |
| 347 | + handler(newVal) { | |
| 348 | + if (newVal && this.visibleSync) { | |
| 349 | + this.fetchData() | |
| 350 | + } | |
| 351 | + } | |
| 352 | + }, | |
| 353 | + activeTab(newVal) { | |
| 354 | + if (newVal === 'billing' && this.billingList.length === 0) { | |
| 355 | + this.fetchBillingList() | |
| 356 | + } else if (newVal === 'consume' && this.consumeList.length === 0) { | |
| 357 | + this.fetchConsumeList() | |
| 358 | + } else if (newVal === 'refund' && this.refundList.length === 0) { | |
| 359 | + this.fetchRefundList() | |
| 360 | + } | |
| 361 | + } | |
| 362 | + }, | |
| 363 | + methods: { | |
| 364 | + async fetchData() { | |
| 365 | + if (!this.memberId) { | |
| 366 | + this.$message.warning('会员ID不能为空') | |
| 367 | + return | |
| 368 | + } | |
| 369 | + | |
| 370 | + this.loading = true | |
| 371 | + try { | |
| 372 | + const res = await request({ | |
| 373 | + url: '/api/Extend/MemberPortrait/overview', | |
| 374 | + method: 'GET', | |
| 375 | + params: { memberId: this.memberId } | |
| 376 | + }) | |
| 377 | + | |
| 378 | + if (res.code === 200 && res.data) { | |
| 379 | + this.baseInfo = res.data.BaseInfo || {} | |
| 380 | + this.behaviorSummary = res.data.BehaviorSummary || {} | |
| 381 | + this.remainingItems = (res.data.Assets && res.data.Assets.RemainingItems) || [] | |
| 382 | + this.monthlyTrend = res.data.MonthlyTrend || [] | |
| 383 | + this.consumptionAnalysis = res.data.ConsumptionAnalysis || null | |
| 384 | + | |
| 385 | + this.$nextTick(() => { | |
| 386 | + this.renderTrendChart() | |
| 387 | + }) | |
| 388 | + } else { | |
| 389 | + this.$message.error(res.msg || '获取会员画像数据失败') | |
| 390 | + } | |
| 391 | + } catch (error) { | |
| 392 | + console.error('获取会员画像数据失败:', error) | |
| 393 | + this.$message.error('获取会员画像数据失败: ' + (error.message || '未知错误')) | |
| 394 | + } finally { | |
| 395 | + this.loading = false | |
| 396 | + } | |
| 397 | + }, | |
| 398 | + async fetchBillingList() { | |
| 399 | + if (!this.memberId) return | |
| 400 | + | |
| 401 | + this.billingLoading = true | |
| 402 | + try { | |
| 403 | + const res = await request({ | |
| 404 | + url: '/api/Extend/MemberPortrait/billing-list', | |
| 405 | + method: 'GET', | |
| 406 | + params: { | |
| 407 | + memberId: this.memberId, | |
| 408 | + pageIndex: this.billingPagination.pageIndex, | |
| 409 | + pageSize: this.billingPagination.pageSize | |
| 410 | + } | |
| 411 | + }) | |
| 412 | + | |
| 413 | + if (res.code === 200 && res.data) { | |
| 414 | + this.billingList = res.data.List || [] | |
| 415 | + this.billingPagination.total = res.data.Total || 0 | |
| 416 | + } else { | |
| 417 | + this.$message.error(res.msg || '获取开单列表失败') | |
| 418 | + } | |
| 419 | + } catch (error) { | |
| 420 | + console.error('获取开单列表失败:', error) | |
| 421 | + this.$message.error('获取开单列表失败: ' + (error.message || '未知错误')) | |
| 422 | + } finally { | |
| 423 | + this.billingLoading = false | |
| 424 | + } | |
| 425 | + }, | |
| 426 | + async fetchConsumeList() { | |
| 427 | + if (!this.memberId) return | |
| 428 | + | |
| 429 | + this.consumeLoading = true | |
| 430 | + try { | |
| 431 | + const res = await request({ | |
| 432 | + url: '/api/Extend/MemberPortrait/consume-list', | |
| 433 | + method: 'GET', | |
| 434 | + params: { | |
| 435 | + memberId: this.memberId, | |
| 436 | + pageIndex: this.consumePagination.pageIndex, | |
| 437 | + pageSize: this.consumePagination.pageSize | |
| 438 | + } | |
| 439 | + }) | |
| 440 | + | |
| 441 | + if (res.code === 200 && res.data) { | |
| 442 | + this.consumeList = res.data.List || [] | |
| 443 | + this.consumePagination.total = res.data.Total || 0 | |
| 444 | + } else { | |
| 445 | + this.$message.error(res.msg || '获取消耗列表失败') | |
| 446 | + } | |
| 447 | + } catch (error) { | |
| 448 | + console.error('获取消耗列表失败:', error) | |
| 449 | + this.$message.error('获取消耗列表失败: ' + (error.message || '未知错误')) | |
| 450 | + } finally { | |
| 451 | + this.consumeLoading = false | |
| 452 | + } | |
| 453 | + }, | |
| 454 | + async fetchRefundList() { | |
| 455 | + if (!this.memberId) return | |
| 456 | + | |
| 457 | + this.refundLoading = true | |
| 458 | + try { | |
| 459 | + const res = await request({ | |
| 460 | + url: '/api/Extend/MemberPortrait/refund-list', | |
| 461 | + method: 'GET', | |
| 462 | + params: { | |
| 463 | + memberId: this.memberId, | |
| 464 | + pageIndex: this.refundPagination.pageIndex, | |
| 465 | + pageSize: this.refundPagination.pageSize | |
| 466 | + } | |
| 467 | + }) | |
| 468 | + | |
| 469 | + if (res.code === 200 && res.data) { | |
| 470 | + this.refundList = res.data.List || [] | |
| 471 | + this.refundPagination.total = res.data.Total || 0 | |
| 472 | + } else { | |
| 473 | + this.$message.error(res.msg || '获取退卡列表失败') | |
| 474 | + } | |
| 475 | + } catch (error) { | |
| 476 | + console.error('获取退卡列表失败:', error) | |
| 477 | + this.$message.error('获取退卡列表失败: ' + (error.message || '未知错误')) | |
| 478 | + } finally { | |
| 479 | + this.refundLoading = false | |
| 480 | + } | |
| 481 | + }, | |
| 482 | + handleBillingPageChange(page) { | |
| 483 | + this.billingPagination.pageIndex = page | |
| 484 | + this.fetchBillingList() | |
| 485 | + }, | |
| 486 | + handleBillingSizeChange(size) { | |
| 487 | + this.billingPagination.pageSize = size | |
| 488 | + this.billingPagination.pageIndex = 1 | |
| 489 | + this.fetchBillingList() | |
| 490 | + }, | |
| 491 | + handleConsumePageChange(page) { | |
| 492 | + this.consumePagination.pageIndex = page | |
| 493 | + this.fetchConsumeList() | |
| 494 | + }, | |
| 495 | + handleConsumeSizeChange(size) { | |
| 496 | + this.consumePagination.pageSize = size | |
| 497 | + this.consumePagination.pageIndex = 1 | |
| 498 | + this.fetchConsumeList() | |
| 499 | + }, | |
| 500 | + handleRefundPageChange(page) { | |
| 501 | + this.refundPagination.pageIndex = page | |
| 502 | + this.fetchRefundList() | |
| 503 | + }, | |
| 504 | + handleRefundSizeChange(size) { | |
| 505 | + this.refundPagination.pageSize = size | |
| 506 | + this.refundPagination.pageIndex = 1 | |
| 507 | + this.fetchRefundList() | |
| 508 | + }, | |
| 509 | + renderTrendChart() { | |
| 510 | + if (!this.$refs.trendChart) return | |
| 511 | + | |
| 512 | + if (this.trendChart) { | |
| 513 | + this.trendChart.dispose() | |
| 514 | + } | |
| 515 | + | |
| 516 | + this.trendChart = echarts.init(this.$refs.trendChart) | |
| 517 | + | |
| 518 | + const months = this.monthlyTrend.map(item => item.Month) | |
| 519 | + const billingData = this.monthlyTrend.map(item => item.BillingAmount || 0) | |
| 520 | + const consumeData = this.monthlyTrend.map(item => item.ConsumeAmount || 0) | |
| 521 | + const refundData = this.monthlyTrend.map(item => item.RefundAmount || 0) | |
| 522 | + | |
| 523 | + const option = { | |
| 524 | + tooltip: { | |
| 525 | + trigger: 'axis', | |
| 526 | + axisPointer: { type: 'cross' } | |
| 527 | + }, | |
| 528 | + legend: { | |
| 529 | + data: ['开单金额', '消耗金额', '退卡金额'], | |
| 530 | + bottom: 0 | |
| 531 | + }, | |
| 532 | + grid: { | |
| 533 | + left: '3%', | |
| 534 | + right: '4%', | |
| 535 | + bottom: '15%', | |
| 536 | + containLabel: true | |
| 537 | + }, | |
| 538 | + xAxis: { | |
| 539 | + type: 'category', | |
| 540 | + data: months, | |
| 541 | + axisLabel: { | |
| 542 | + rotate: 45 | |
| 543 | + } | |
| 544 | + }, | |
| 545 | + yAxis: { | |
| 546 | + type: 'value', | |
| 547 | + axisLabel: { | |
| 548 | + formatter: value => '¥' + this.formatMoney(value) | |
| 549 | + } | |
| 550 | + }, | |
| 551 | + series: [ | |
| 552 | + { | |
| 553 | + name: '开单金额', | |
| 554 | + type: 'line', | |
| 555 | + data: billingData, | |
| 556 | + itemStyle: { color: '#409EFF' } | |
| 557 | + }, | |
| 558 | + { | |
| 559 | + name: '消耗金额', | |
| 560 | + type: 'line', | |
| 561 | + data: consumeData, | |
| 562 | + itemStyle: { color: '#67C23A' } | |
| 563 | + }, | |
| 564 | + { | |
| 565 | + name: '退卡金额', | |
| 566 | + type: 'line', | |
| 567 | + data: refundData, | |
| 568 | + itemStyle: { color: '#F56C6C' } | |
| 569 | + } | |
| 570 | + ] | |
| 571 | + } | |
| 572 | + | |
| 573 | + this.trendChart.setOption(option) | |
| 574 | + | |
| 575 | + // 响应式调整 | |
| 576 | + window.addEventListener('resize', this.handleChartResize) | |
| 577 | + }, | |
| 578 | + handleChartResize() { | |
| 579 | + if (this.trendChart) { | |
| 580 | + this.trendChart.resize() | |
| 581 | + } | |
| 582 | + }, | |
| 583 | + formatMoney(amount) { | |
| 584 | + if (amount === null || amount === undefined || amount === '') return '0.00' | |
| 585 | + return Number(amount).toFixed(2) | |
| 586 | + }, | |
| 587 | + formatDateTime(timestamp) { | |
| 588 | + if (!timestamp) return '无' | |
| 589 | + if (typeof timestamp === 'number') { | |
| 590 | + const date = new Date(timestamp) | |
| 591 | + return date.toLocaleString('zh-CN', { | |
| 592 | + year: 'numeric', | |
| 593 | + month: '2-digit', | |
| 594 | + day: '2-digit', | |
| 595 | + hour: '2-digit', | |
| 596 | + minute: '2-digit' | |
| 597 | + }) | |
| 598 | + } | |
| 599 | + return timestamp | |
| 600 | + }, | |
| 601 | + formatDate(timestamp) { | |
| 602 | + if (!timestamp) return '' | |
| 603 | + if (typeof timestamp === 'number') { | |
| 604 | + const date = new Date(timestamp) | |
| 605 | + return date.toLocaleDateString('zh-CN', { | |
| 606 | + year: 'numeric', | |
| 607 | + month: '2-digit', | |
| 608 | + day: '2-digit' | |
| 609 | + }) | |
| 610 | + } | |
| 611 | + return timestamp | |
| 612 | + }, | |
| 613 | + getConsumeLevelText(level) { | |
| 614 | + const levelMap = { | |
| 615 | + 0: '普通', | |
| 616 | + 1: '银卡', | |
| 617 | + 2: '金卡', | |
| 618 | + 3: '钻石', | |
| 619 | + 4: 'VIP' | |
| 620 | + } | |
| 621 | + return levelMap[level] || '普通' | |
| 622 | + }, | |
| 623 | + getMemberTypeTagType(typeName) { | |
| 624 | + const typeMap = { | |
| 625 | + '生美': 'success', | |
| 626 | + '医美': 'warning', | |
| 627 | + '科技部': 'primary', | |
| 628 | + '教育部': 'info' | |
| 629 | + } | |
| 630 | + return typeMap[typeName] || 'info' | |
| 631 | + }, | |
| 632 | + handleClosed() { | |
| 633 | + if (this.trendChart) { | |
| 634 | + window.removeEventListener('resize', this.handleChartResize) | |
| 635 | + this.trendChart.dispose() | |
| 636 | + this.trendChart = null | |
| 637 | + } | |
| 638 | + this.$emit('update:visible', false) | |
| 639 | + this.$emit('closed') | |
| 640 | + } | |
| 641 | + }, | |
| 642 | + beforeDestroy() { | |
| 643 | + if (this.trendChart) { | |
| 644 | + window.removeEventListener('resize', this.handleChartResize) | |
| 645 | + this.trendChart.dispose() | |
| 646 | + } | |
| 647 | + } | |
| 648 | +} | |
| 649 | +</script> | |
| 650 | + | |
| 651 | +<style lang="scss" scoped> | |
| 652 | +.member-portrait-dialog { | |
| 653 | + .el-dialog { | |
| 654 | + border-radius: 14px; | |
| 655 | + overflow: hidden; | |
| 656 | + } | |
| 657 | + | |
| 658 | + .el-dialog__header { | |
| 659 | + padding: 16px 20px; | |
| 660 | + border-bottom: 1px solid #ebeef5; | |
| 661 | + } | |
| 662 | + | |
| 663 | + .el-dialog__body { | |
| 664 | + padding: 20px; | |
| 665 | + background: #f5f7fa; | |
| 666 | + color: #303133; | |
| 667 | + overflow-y: auto; | |
| 668 | + max-height: calc(90vh - 100px); | |
| 669 | + } | |
| 670 | +} | |
| 671 | + | |
| 672 | +.portrait-wrapper { | |
| 673 | + .portrait-header { | |
| 674 | + background: #fff; | |
| 675 | + border: 1px solid #ebeef5; | |
| 676 | + border-radius: 10px; | |
| 677 | + padding: 16px 20px; | |
| 678 | + margin-bottom: 16px; | |
| 679 | + display: flex; | |
| 680 | + justify-content: space-between; | |
| 681 | + align-items: center; | |
| 682 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 683 | + | |
| 684 | + .header-main { | |
| 685 | + flex: 1; | |
| 686 | + | |
| 687 | + .member-name { | |
| 688 | + font-size: 22px; | |
| 689 | + font-weight: 600; | |
| 690 | + color: #303133; | |
| 691 | + margin-bottom: 10px; | |
| 692 | + } | |
| 693 | + | |
| 694 | + .member-meta { | |
| 695 | + display: flex; | |
| 696 | + gap: 16px; | |
| 697 | + flex-wrap: wrap; | |
| 698 | + | |
| 699 | + .meta-item { | |
| 700 | + font-size: 13px; | |
| 701 | + color: #606266; | |
| 702 | + display: flex; | |
| 703 | + align-items: center; | |
| 704 | + gap: 6px; | |
| 705 | + | |
| 706 | + i { | |
| 707 | + color: #909399; | |
| 708 | + } | |
| 709 | + } | |
| 710 | + } | |
| 711 | + } | |
| 712 | + | |
| 713 | + .header-stats { | |
| 714 | + display: flex; | |
| 715 | + gap: 24px; | |
| 716 | + | |
| 717 | + .stat-item { | |
| 718 | + text-align: center; | |
| 719 | + | |
| 720 | + .stat-label { | |
| 721 | + font-size: 12px; | |
| 722 | + color: #909399; | |
| 723 | + margin-bottom: 6px; | |
| 724 | + } | |
| 725 | + | |
| 726 | + .stat-value { | |
| 727 | + font-size: 16px; | |
| 728 | + font-weight: 600; | |
| 729 | + color: #303133; | |
| 730 | + | |
| 731 | + &.highlight { | |
| 732 | + color: #409EFF; | |
| 733 | + } | |
| 734 | + | |
| 735 | + &.text-warning { | |
| 736 | + color: #E6A23C; | |
| 737 | + } | |
| 738 | + } | |
| 739 | + } | |
| 740 | + } | |
| 741 | + } | |
| 742 | + | |
| 743 | + .base-info-section { | |
| 744 | + margin-bottom: 12px; | |
| 745 | + | |
| 746 | + .base-info-layout { | |
| 747 | + display: flex; | |
| 748 | + gap: 12px; | |
| 749 | + | |
| 750 | + .card-section { | |
| 751 | + flex: 1; | |
| 752 | + padding: 10px 12px; | |
| 753 | + margin-bottom: 0; | |
| 754 | + | |
| 755 | + .section-title { | |
| 756 | + font-size: 13px; | |
| 757 | + margin-bottom: 8px; | |
| 758 | + } | |
| 759 | + | |
| 760 | + .tag-list { | |
| 761 | + gap: 6px; | |
| 762 | + | |
| 763 | + .member-type-item { | |
| 764 | + padding: 6px 8px; | |
| 765 | + font-size: 12px; | |
| 766 | + | |
| 767 | + .member-type-tag { | |
| 768 | + font-size: 12px; | |
| 769 | + padding: 2px 10px; | |
| 770 | + } | |
| 771 | + | |
| 772 | + .member-type-date { | |
| 773 | + font-size: 11px; | |
| 774 | + } | |
| 775 | + } | |
| 776 | + } | |
| 777 | + | |
| 778 | + .info-list { | |
| 779 | + .info-item { | |
| 780 | + padding: 4px 0; | |
| 781 | + font-size: 12px; | |
| 782 | + | |
| 783 | + .info-label { | |
| 784 | + width: 75px; | |
| 785 | + font-size: 12px; | |
| 786 | + } | |
| 787 | + | |
| 788 | + .info-value { | |
| 789 | + font-size: 12px; | |
| 790 | + } | |
| 791 | + } | |
| 792 | + } | |
| 793 | + } | |
| 794 | + } | |
| 795 | + } | |
| 796 | + | |
| 797 | + .portrait-tabs { | |
| 798 | + ::v-deep .el-tabs__header { | |
| 799 | + margin-bottom: 16px; | |
| 800 | + } | |
| 801 | + | |
| 802 | + ::v-deep .el-tabs__content { | |
| 803 | + .tab-content { | |
| 804 | + .analysis-layout { | |
| 805 | + display: flex; | |
| 806 | + gap: 20px; | |
| 807 | + flex-wrap: wrap; | |
| 808 | + | |
| 809 | + .analysis-item { | |
| 810 | + min-width: 150px; | |
| 811 | + padding: 12px; | |
| 812 | + background: #f8f9fa; | |
| 813 | + border-radius: 6px; | |
| 814 | + border: 1px solid #ebeef5; | |
| 815 | + | |
| 816 | + .analysis-label { | |
| 817 | + font-size: 12px; | |
| 818 | + color: #909399; | |
| 819 | + margin-bottom: 6px; | |
| 820 | + } | |
| 821 | + | |
| 822 | + .analysis-value { | |
| 823 | + font-size: 14px; | |
| 824 | + color: #303133; | |
| 825 | + font-weight: 500; | |
| 826 | + } | |
| 827 | + } | |
| 828 | + } | |
| 829 | + } | |
| 830 | + } | |
| 831 | + } | |
| 832 | + | |
| 833 | + .card-section { | |
| 834 | + background: #fff; | |
| 835 | + border: 1px solid #ebeef5; | |
| 836 | + border-radius: 10px; | |
| 837 | + padding: 14px; | |
| 838 | + margin-bottom: 16px; | |
| 839 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); | |
| 840 | + | |
| 841 | + .section-title { | |
| 842 | + font-size: 14px; | |
| 843 | + font-weight: 600; | |
| 844 | + color: #303133; | |
| 845 | + margin-bottom: 12px; | |
| 846 | + display: flex; | |
| 847 | + align-items: center; | |
| 848 | + gap: 6px; | |
| 849 | + | |
| 850 | + i { | |
| 851 | + color: #409EFF; | |
| 852 | + } | |
| 853 | + } | |
| 854 | + } | |
| 855 | + | |
| 856 | + .behavior-grid { | |
| 857 | + display: grid; | |
| 858 | + grid-template-columns: repeat(3, 1fr); | |
| 859 | + gap: 12px; | |
| 860 | + | |
| 861 | + .behavior-item { | |
| 862 | + background: #f8f9fa; | |
| 863 | + padding: 10px; | |
| 864 | + border-radius: 6px; | |
| 865 | + border: 1px solid #ebeef5; | |
| 866 | + | |
| 867 | + .behavior-label { | |
| 868 | + font-size: 12px; | |
| 869 | + color: #909399; | |
| 870 | + margin-bottom: 6px; | |
| 871 | + } | |
| 872 | + | |
| 873 | + .behavior-value { | |
| 874 | + font-size: 13px; | |
| 875 | + color: #303133; | |
| 876 | + font-weight: 500; | |
| 877 | + } | |
| 878 | + } | |
| 879 | + } | |
| 880 | + | |
| 881 | + .tag-list { | |
| 882 | + display: flex; | |
| 883 | + flex-direction: column; | |
| 884 | + gap: 8px; | |
| 885 | + | |
| 886 | + .member-type-item { | |
| 887 | + display: flex; | |
| 888 | + align-items: center; | |
| 889 | + gap: 10px; | |
| 890 | + padding: 8px; | |
| 891 | + background: #f8f9fa; | |
| 892 | + border-radius: 6px; | |
| 893 | + border-left: 3px solid #409EFF; | |
| 894 | + | |
| 895 | + .member-type-tag { | |
| 896 | + font-weight: 600; | |
| 897 | + font-size: 13px; | |
| 898 | + padding: 4px 12px; | |
| 899 | + } | |
| 900 | + | |
| 901 | + .member-type-date { | |
| 902 | + font-size: 12px; | |
| 903 | + color: #909399; | |
| 904 | + margin-left: auto; | |
| 905 | + } | |
| 906 | + } | |
| 907 | + } | |
| 908 | + | |
| 909 | + .info-list { | |
| 910 | + .info-item { | |
| 911 | + display: flex; | |
| 912 | + padding: 6px 0; | |
| 913 | + border-bottom: 1px solid #f0f0f0; | |
| 914 | + | |
| 915 | + &:last-child { | |
| 916 | + border-bottom: none; | |
| 917 | + } | |
| 918 | + | |
| 919 | + .info-label { | |
| 920 | + font-size: 13px; | |
| 921 | + color: #909399; | |
| 922 | + width: 90px; | |
| 923 | + flex-shrink: 0; | |
| 924 | + } | |
| 925 | + | |
| 926 | + .info-value { | |
| 927 | + font-size: 13px; | |
| 928 | + color: #303133; | |
| 929 | + flex: 1; | |
| 930 | + } | |
| 931 | + } | |
| 932 | + } | |
| 933 | + | |
| 934 | + .trend-chart { | |
| 935 | + width: 100%; | |
| 936 | + height: 350px; | |
| 937 | + } | |
| 938 | + | |
| 939 | + .pagination-bar { | |
| 940 | + display: flex; | |
| 941 | + justify-content: flex-end; | |
| 942 | + padding: 10px 0 4px 0; | |
| 943 | + margin-top: 12px; | |
| 944 | + } | |
| 945 | + | |
| 946 | + .text-muted { | |
| 947 | + color: #909399; | |
| 948 | + font-size: 13px; | |
| 949 | + } | |
| 950 | +} | |
| 951 | +</style> | ... | ... |
antis-ncc-admin/src/views/statisticsList/form9.vue
| ... | ... | @@ -154,7 +154,8 @@ |
| 154 | 154 | </span> |
| 155 | 155 | </span> |
| 156 | 156 | <span class="stat-tag" v-if="memberStatistics.topRemainingAmount > 0"> |
| 157 | - <span class="stat-tag-inner"> | |
| 157 | + <span class="stat-tag-inner" style="cursor: pointer;" | |
| 158 | + @click="openMemberPortrait(memberStatistics.topRemainingMemberId)"> | |
| 158 | 159 | <i class="el-icon-user-solid"></i> |
| 159 | 160 | <span class="stat-tag-text"> |
| 160 | 161 | 最高剩余权益金额: {{ memberStatistics.topRemainingMemberName || '无' }} ¥{{ |
| ... | ... | @@ -163,7 +164,8 @@ |
| 163 | 164 | </span> |
| 164 | 165 | </span> |
| 165 | 166 | <span class="stat-tag" v-if="memberStatistics.topBillingAmount > 0"> |
| 166 | - <span class="stat-tag-inner"> | |
| 167 | + <span class="stat-tag-inner" style="cursor: pointer;" | |
| 168 | + @click="openMemberPortrait(memberStatistics.topBillingMemberId)"> | |
| 167 | 169 | <i class="el-icon-wallet"></i> |
| 168 | 170 | <span class="stat-tag-text"> |
| 169 | 171 | 本月开单最高: {{ memberStatistics.topBillingMemberName || '无' }} ¥{{ |
| ... | ... | @@ -172,7 +174,8 @@ |
| 172 | 174 | </span> |
| 173 | 175 | </span> |
| 174 | 176 | <span class="stat-tag" v-if="memberStatistics.topConsumeAmount > 0"> |
| 175 | - <span class="stat-tag-inner"> | |
| 177 | + <span class="stat-tag-inner" style="cursor: pointer;" | |
| 178 | + @click="openMemberPortrait(memberStatistics.topConsumeMemberId)"> | |
| 176 | 179 | <i class="el-icon-medal"></i> |
| 177 | 180 | <span class="stat-tag-text"> |
| 178 | 181 | 本月消耗最高: {{ memberStatistics.topConsumeMemberName || '无' }} ¥{{ |
| ... | ... | @@ -426,6 +429,9 @@ |
| 426 | 429 | <kpi-drill-dialog :visible.sync="drillDialog.visible" :title="drillDialog.title" :type="drillDialog.type" |
| 427 | 430 | :filters="drillDialog.filters" :extra="drillDialog.extra" :store-options="storeOptions" /> |
| 428 | 431 | |
| 432 | + <!-- 会员画像弹窗 --> | |
| 433 | + <member-portrait-dialog :visible.sync="memberPortraitDialog.visible" :member-id="memberPortraitDialog.memberId" /> | |
| 434 | + | |
| 429 | 435 | <!-- 科技感弹窗预览 --> |
| 430 | 436 | <el-dialog :visible.sync="showTechModal" :title="techModalTitle" :width="techModalWidth" custom-class="tech-dialog" |
| 431 | 437 | append-to-body :close-on-click-modal="false"> |
| ... | ... | @@ -479,10 +485,11 @@ import request from '@/utils/request' |
| 479 | 485 | import * as echarts from 'echarts' |
| 480 | 486 | import dayjs from 'dayjs' |
| 481 | 487 | import KpiDrillDialog from '@/components/kpi-drill-dialog.vue' |
| 488 | +import MemberPortraitDialog from '@/components/member-portrait-dialog.vue' | |
| 482 | 489 | |
| 483 | 490 | export default { |
| 484 | 491 | name: 'LeadershipCockpit', |
| 485 | - components: { KpiDrillDialog }, | |
| 492 | + components: { KpiDrillDialog, MemberPortraitDialog }, | |
| 486 | 493 | data() { |
| 487 | 494 | return { |
| 488 | 495 | query: { |
| ... | ... | @@ -498,6 +505,11 @@ export default { |
| 498 | 505 | filters: {}, |
| 499 | 506 | extra: {} |
| 500 | 507 | }, |
| 508 | + // 会员画像弹窗 | |
| 509 | + memberPortraitDialog: { | |
| 510 | + visible: false, | |
| 511 | + memberId: '' | |
| 512 | + }, | |
| 501 | 513 | currentDateParams: { |
| 502 | 514 | startTime: null, |
| 503 | 515 | endTime: null, |
| ... | ... | @@ -532,10 +544,13 @@ export default { |
| 532 | 544 | activeRate30: 0, |
| 533 | 545 | totalRemainingAmount: 0, |
| 534 | 546 | avgRemainingAmount: 0, |
| 547 | + topRemainingMemberId: '', | |
| 535 | 548 | topRemainingMemberName: '', |
| 536 | 549 | topRemainingAmount: 0, |
| 550 | + topBillingMemberId: '', | |
| 537 | 551 | topBillingMemberName: '', |
| 538 | 552 | topBillingAmount: 0, |
| 553 | + topConsumeMemberId: '', | |
| 539 | 554 | topConsumeMemberName: '', |
| 540 | 555 | topConsumeAmount: 0, |
| 541 | 556 | totalSleepMembers: 0, |
| ... | ... | @@ -652,16 +667,30 @@ export default { |
| 652 | 667 | storeIds: this.query.storeIds || [], |
| 653 | 668 | month: this.currentDateParams.month |
| 654 | 669 | } |
| 670 | + // 对于净额类型,actualAmount应该是开单总额,而不是净额 | |
| 671 | + const actualAmount = kpi.key === 'net' | |
| 672 | + ? (this.kpiData ? (this.kpiData.TotalBillingAmount || 0) : 0) | |
| 673 | + : (kpi.raw || 0) | |
| 655 | 674 | const extra = { |
| 656 | - actualAmount: kpi.raw || 0, | |
| 675 | + actualAmount: actualAmount, | |
| 657 | 676 | targetAmount: kpi.targetRaw || 0, |
| 658 | 677 | refundAmount: this.kpiData ? (this.kpiData.TotalRefundAmount || 0) : 0 |
| 659 | 678 | } |
| 679 | + // 根据类型设置专业的标题名称 | |
| 680 | + const titleMap = { | |
| 681 | + billing: '成交数据深度分析', | |
| 682 | + consume: '消耗数据深度分析', | |
| 683 | + net: '净业绩完成度分析', | |
| 684 | + target: '开单目标达成度分析', | |
| 685 | + tk: '拓客数据深度分析', | |
| 686 | + refund: '退卡数据深度分析' | |
| 687 | + } | |
| 688 | + | |
| 660 | 689 | this.drillDialog = { |
| 661 | 690 | ...this.drillDialog, |
| 662 | 691 | visible: true, |
| 663 | 692 | type: kpi.key, |
| 664 | - title: `${kpi.label}穿透`, | |
| 693 | + title: titleMap[kpi.key] || `${kpi.label}数据分析`, | |
| 665 | 694 | filters, |
| 666 | 695 | extra |
| 667 | 696 | } |
| ... | ... | @@ -677,6 +706,17 @@ export default { |
| 677 | 706 | // 自动触发查询 |
| 678 | 707 | this.search() |
| 679 | 708 | }, |
| 709 | + // 打开会员画像弹窗 | |
| 710 | + openMemberPortrait(memberId) { | |
| 711 | + if (!memberId) { | |
| 712 | + this.$message.warning('会员ID不能为空') | |
| 713 | + return | |
| 714 | + } | |
| 715 | + this.memberPortraitDialog = { | |
| 716 | + visible: true, | |
| 717 | + memberId: memberId | |
| 718 | + } | |
| 719 | + }, | |
| 680 | 720 | |
| 681 | 721 | // 将日期转换为月份格式 (YYYYMM) |
| 682 | 722 | formatDateToMonth(dateStr) { |
| ... | ... | @@ -1012,10 +1052,13 @@ export default { |
| 1012 | 1052 | activeRate30, |
| 1013 | 1053 | totalRemainingAmount: ms.TotalRemainingAmount || 0, |
| 1014 | 1054 | avgRemainingAmount: ms.AvgRemainingAmount || 0, |
| 1055 | + topRemainingMemberId: ms.TopRemainingMemberId || '', | |
| 1015 | 1056 | topRemainingMemberName: ms.TopRemainingMemberName || '', |
| 1016 | 1057 | topRemainingAmount: ms.TopRemainingAmount || 0, |
| 1058 | + topBillingMemberId: ms.TopBillingMemberId || '', | |
| 1017 | 1059 | topBillingMemberName: ms.TopBillingMemberName || '', |
| 1018 | 1060 | topBillingAmount: ms.TopBillingAmount || 0, |
| 1061 | + topConsumeMemberId: ms.TopConsumeMemberId || '', | |
| 1019 | 1062 | topConsumeMemberName: ms.TopConsumeMemberName || '', |
| 1020 | 1063 | topConsumeAmount: ms.TopConsumeAmount || 0, |
| 1021 | 1064 | totalSleepMembers: ms.TotalSleepMembers || 0, | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/ConsumeDrillStatisticsDto.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 ConsumeDrillStatisticsInput | |
| 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 ConsumeDrillStatisticsOutput | |
| 26 | + { | |
| 27 | + /// <summary> | |
| 28 | + /// 每日消耗趋势 | |
| 29 | + /// </summary> | |
| 30 | + public List<ConsumeDailyTrendOutput> DailyTrend { get; set; } = new List<ConsumeDailyTrendOutput>(); | |
| 31 | + | |
| 32 | + /// <summary> | |
| 33 | + /// 会员极值统计 | |
| 34 | + /// </summary> | |
| 35 | + public ConsumeMemberStatsOutput MemberStats { get; set; } = new ConsumeMemberStatsOutput(); | |
| 36 | + | |
| 37 | + /// <summary> | |
| 38 | + /// 科技老师手工费合计 | |
| 39 | + /// </summary> | |
| 40 | + public decimal TechTeacherLaborCostTotal { get; set; } | |
| 41 | + | |
| 42 | + /// <summary> | |
| 43 | + /// 健康师手工费合计 | |
| 44 | + /// </summary> | |
| 45 | + public decimal HealthCoachLaborCostTotal { get; set; } | |
| 46 | + | |
| 47 | + /// <summary> | |
| 48 | + /// 单次消耗最大金额 | |
| 49 | + /// </summary> | |
| 50 | + public decimal MaxSingleConsumeAmount { get; set; } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// <summary> | |
| 54 | + /// 每日消耗趋势 | |
| 55 | + /// </summary> | |
| 56 | + public class ConsumeDailyTrendOutput | |
| 57 | + { | |
| 58 | + /// <summary> | |
| 59 | + /// 日期(yyyy-MM-dd) | |
| 60 | + /// </summary> | |
| 61 | + public string Date { get; set; } | |
| 62 | + | |
| 63 | + /// <summary> | |
| 64 | + /// 金额 | |
| 65 | + /// </summary> | |
| 66 | + public decimal Amount { get; set; } | |
| 67 | + | |
| 68 | + /// <summary> | |
| 69 | + /// 会员人数 | |
| 70 | + /// </summary> | |
| 71 | + public int MemberCount { get; set; } | |
| 72 | + } | |
| 73 | + | |
| 74 | + /// <summary> | |
| 75 | + /// 会员极值统计 | |
| 76 | + /// </summary> | |
| 77 | + public class ConsumeMemberStatsOutput | |
| 78 | + { | |
| 79 | + /// <summary> | |
| 80 | + /// 消耗金额最高会员姓名 | |
| 81 | + /// </summary> | |
| 82 | + public string TopAmountMemberName { get; set; } | |
| 83 | + | |
| 84 | + /// <summary> | |
| 85 | + /// 消耗金额最高会员的金额 | |
| 86 | + /// </summary> | |
| 87 | + public decimal TopAmountValue { get; set; } | |
| 88 | + | |
| 89 | + /// <summary> | |
| 90 | + /// 消耗次数最多会员姓名 | |
| 91 | + /// </summary> | |
| 92 | + public string TopTimesMemberName { get; set; } | |
| 93 | + | |
| 94 | + /// <summary> | |
| 95 | + /// 消耗次数最多会员的次数 | |
| 96 | + /// </summary> | |
| 97 | + public int TopTimesCount { get; set; } | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/RefundDrillStatisticsInput.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 RefundDrillStatisticsInput | |
| 10 | + { | |
| 11 | + /// <summary> | |
| 12 | + /// 开始时间 | |
| 13 | + /// </summary> | |
| 14 | + public DateTime? StartTime { get; set; } | |
| 15 | + | |
| 16 | + /// <summary> | |
| 17 | + /// 结束时间 | |
| 18 | + /// </summary> | |
| 19 | + public DateTime? EndTime { get; set; } | |
| 20 | + | |
| 21 | + /// <summary> | |
| 22 | + /// 门店ID列表 | |
| 23 | + /// </summary> | |
| 24 | + public List<string> StoreIds { get; set; } = new List<string>(); | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/TkDrillStatisticsInput.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 TkDrillStatisticsInput | |
| 10 | + { | |
| 11 | + /// <summary> | |
| 12 | + /// 开始时间 | |
| 13 | + /// </summary> | |
| 14 | + public DateTime? StartTime { get; set; } | |
| 15 | + | |
| 16 | + /// <summary> | |
| 17 | + /// 结束时间 | |
| 18 | + /// </summary> | |
| 19 | + public DateTime? EndTime { get; set; } | |
| 20 | + | |
| 21 | + /// <summary> | |
| 22 | + /// 门店ID列表 | |
| 23 | + /// </summary> | |
| 24 | + public List<string> StoreIds { get; set; } = new List<string>(); | |
| 25 | + | |
| 26 | + /// <summary> | |
| 27 | + /// 活动ID(可选,如果提供则只统计该活动的数据) | |
| 28 | + /// </summary> | |
| 29 | + public string EventId { get; set; } | |
| 30 | + | |
| 31 | + /// <summary> | |
| 32 | + /// 选中的日期(用于获取24小时走势,格式:yyyy-MM-dd) | |
| 33 | + /// </summary> | |
| 34 | + public string SelectedDate { get; set; } | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/MemberPortrait/MemberPortraitDtos.cs
0 → 100644
| 1 | +using System; | |
| 2 | +using System.Collections.Generic; | |
| 3 | + | |
| 4 | +namespace NCC.Extend.Entitys.Dto.MemberPortrait | |
| 5 | +{ | |
| 6 | + /// <summary> | |
| 7 | + /// 会员画像概览输出 | |
| 8 | + /// </summary> | |
| 9 | + public class MemberPortraitOverviewOutput | |
| 10 | + { | |
| 11 | + /// <summary> | |
| 12 | + /// 基础档案信息(会员类型和基础信息) | |
| 13 | + /// </summary> | |
| 14 | + public MemberBaseInfo BaseInfo { get; set; } | |
| 15 | + | |
| 16 | + /// <summary> | |
| 17 | + /// 消费行为概要 | |
| 18 | + /// </summary> | |
| 19 | + public MemberBehaviorSummary BehaviorSummary { get; set; } | |
| 20 | + | |
| 21 | + /// <summary> | |
| 22 | + /// 消费趋势(近12个月) | |
| 23 | + /// </summary> | |
| 24 | + public List<MemberMonthlyTrendPoint> MonthlyTrend { get; set; } = new List<MemberMonthlyTrendPoint>(); | |
| 25 | + | |
| 26 | + /// <summary> | |
| 27 | + /// 消费分析 | |
| 28 | + /// </summary> | |
| 29 | + public ConsumptionAnalysis ConsumptionAnalysis { get; set; } | |
| 30 | + | |
| 31 | + /// <summary> | |
| 32 | + /// 权益资产 | |
| 33 | + /// </summary> | |
| 34 | + public MemberAssets Assets { get; set; } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// <summary> | |
| 38 | + /// 会员权益资产 | |
| 39 | + /// </summary> | |
| 40 | + public class MemberAssets | |
| 41 | + { | |
| 42 | + /// <summary> | |
| 43 | + /// 剩余权益明细 | |
| 44 | + /// </summary> | |
| 45 | + public List<RemainingItemDto> RemainingItems { get; set; } = new List<RemainingItemDto>(); | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// <summary> | |
| 49 | + /// 剩余权益明细项 | |
| 50 | + /// </summary> | |
| 51 | + public class RemainingItemDto | |
| 52 | + { | |
| 53 | + public string ItemId { get; set; } | |
| 54 | + public string ItemName { get; set; } | |
| 55 | + public decimal UnitPrice { get; set; } | |
| 56 | + public string SourceType { get; set; } | |
| 57 | + public int TotalQuantity { get; set; } | |
| 58 | + public int ConsumedQuantity { get; set; } | |
| 59 | + public int RefundedQuantity { get; set; } | |
| 60 | + public int DeductedQuantity { get; set; } | |
| 61 | + public int RemainingQuantity { get; set; } | |
| 62 | + public decimal RemainingValue { get; set; } | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// <summary> | |
| 66 | + /// 会员基础信息简版 | |
| 67 | + /// </summary> | |
| 68 | + public class MemberBaseInfo | |
| 69 | + { | |
| 70 | + public string MemberId { get; set; } | |
| 71 | + | |
| 72 | + public string MemberCode { get; set; } | |
| 73 | + | |
| 74 | + public string MemberName { get; set; } | |
| 75 | + | |
| 76 | + public string Mobile { get; set; } | |
| 77 | + | |
| 78 | + public string StoreId { get; set; } | |
| 79 | + | |
| 80 | + public string StoreName { get; set; } | |
| 81 | + | |
| 82 | + public string Channel { get; set; } | |
| 83 | + | |
| 84 | + public DateTime? FirstVisitTime { get; set; } | |
| 85 | + | |
| 86 | + public DateTime? LastVisitTime { get; set; } | |
| 87 | + | |
| 88 | + public int SleepDays { get; set; } | |
| 89 | + | |
| 90 | + public DateTime? SleepStartTime { get; set; } | |
| 91 | + | |
| 92 | + public int ConsumeLevel { get; set; } | |
| 93 | + | |
| 94 | + public DateTime? ConsumeLevelUpdateTime { get; set; } | |
| 95 | + | |
| 96 | + /// <summary> | |
| 97 | + /// 会员类型列表(生美、医美、科技部、教育部) | |
| 98 | + /// </summary> | |
| 99 | + public List<MemberTypeInfo> MemberTypes { get; set; } = new List<MemberTypeInfo>(); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// <summary> | |
| 103 | + /// 会员类型信息 | |
| 104 | + /// </summary> | |
| 105 | + public class MemberTypeInfo | |
| 106 | + { | |
| 107 | + /// <summary> | |
| 108 | + /// 会员类型名称(生美、医美、科技部、教育部) | |
| 109 | + /// </summary> | |
| 110 | + public string TypeName { get; set; } | |
| 111 | + | |
| 112 | + /// <summary> | |
| 113 | + /// 成为会员时间 | |
| 114 | + /// </summary> | |
| 115 | + public DateTime? BecomeTime { get; set; } | |
| 116 | + } | |
| 117 | + | |
| 118 | + /// <summary> | |
| 119 | + /// 会员行为概要 | |
| 120 | + /// </summary> | |
| 121 | + public class MemberBehaviorSummary | |
| 122 | + { | |
| 123 | + /// <summary> | |
| 124 | + /// 累计开单金额 | |
| 125 | + /// </summary> | |
| 126 | + public decimal TotalBillingAmount { get; set; } | |
| 127 | + | |
| 128 | + /// <summary> | |
| 129 | + /// 累计消耗金额 | |
| 130 | + /// </summary> | |
| 131 | + public decimal TotalConsumeAmount { get; set; } | |
| 132 | + | |
| 133 | + /// <summary> | |
| 134 | + /// 累计退卡金额 | |
| 135 | + /// </summary> | |
| 136 | + public decimal TotalRefundAmount { get; set; } | |
| 137 | + | |
| 138 | + /// <summary> | |
| 139 | + /// 剩余权益总金额 | |
| 140 | + /// </summary> | |
| 141 | + public decimal RemainingRightsAmount { get; set; } | |
| 142 | + | |
| 143 | + /// <summary> | |
| 144 | + /// 最近一次开单时间 | |
| 145 | + /// </summary> | |
| 146 | + public DateTime? LastBillingTime { get; set; } | |
| 147 | + | |
| 148 | + /// <summary> | |
| 149 | + /// 最近一次消耗时间 | |
| 150 | + /// </summary> | |
| 151 | + public DateTime? LastConsumeTime { get; set; } | |
| 152 | + | |
| 153 | + /// <summary> | |
| 154 | + /// 开单次数 | |
| 155 | + /// </summary> | |
| 156 | + public int BillingCount { get; set; } | |
| 157 | + | |
| 158 | + /// <summary> | |
| 159 | + /// 消耗次数 | |
| 160 | + /// </summary> | |
| 161 | + public int ConsumeCount { get; set; } | |
| 162 | + | |
| 163 | + /// <summary> | |
| 164 | + /// 退卡次数 | |
| 165 | + /// </summary> | |
| 166 | + public int RefundCount { get; set; } | |
| 167 | + | |
| 168 | + /// <summary> | |
| 169 | + /// 平均开单金额 | |
| 170 | + /// </summary> | |
| 171 | + public decimal AvgBillingAmount { get; set; } | |
| 172 | + | |
| 173 | + /// <summary> | |
| 174 | + /// 平均消耗金额 | |
| 175 | + /// </summary> | |
| 176 | + public decimal AvgConsumeAmount { get; set; } | |
| 177 | + | |
| 178 | + /// <summary> | |
| 179 | + /// 首次开单时间 | |
| 180 | + /// </summary> | |
| 181 | + public DateTime? FirstBillingTime { get; set; } | |
| 182 | + | |
| 183 | + /// <summary> | |
| 184 | + /// 首次消耗时间 | |
| 185 | + /// </summary> | |
| 186 | + public DateTime? FirstConsumeTime { get; set; } | |
| 187 | + } | |
| 188 | + | |
| 189 | + /// <summary> | |
| 190 | + /// 消费分析数据 | |
| 191 | + /// </summary> | |
| 192 | + public class ConsumptionAnalysis | |
| 193 | + { | |
| 194 | + /// <summary> | |
| 195 | + /// 消费频率(次/月) | |
| 196 | + /// </summary> | |
| 197 | + public decimal ConsumeFrequency { get; set; } | |
| 198 | + | |
| 199 | + /// <summary> | |
| 200 | + /// 开单频率(次/月) | |
| 201 | + /// </summary> | |
| 202 | + public decimal BillingFrequency { get; set; } | |
| 203 | + | |
| 204 | + /// <summary> | |
| 205 | + /// 消费活跃度(最近3个月是否有消费) | |
| 206 | + /// </summary> | |
| 207 | + public bool IsActive { get; set; } | |
| 208 | + | |
| 209 | + /// <summary> | |
| 210 | + /// 消费偏好(品项类型分布) | |
| 211 | + /// </summary> | |
| 212 | + public List<ItemTypePreference> ItemTypePreferences { get; set; } = new List<ItemTypePreference>(); | |
| 213 | + | |
| 214 | + /// <summary> | |
| 215 | + /// 门店偏好(消费门店分布) | |
| 216 | + /// </summary> | |
| 217 | + public List<StorePreference> StorePreferences { get; set; } = new List<StorePreference>(); | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// <summary> | |
| 221 | + /// 品项类型偏好 | |
| 222 | + /// </summary> | |
| 223 | + public class ItemTypePreference | |
| 224 | + { | |
| 225 | + /// <summary> | |
| 226 | + /// 品项类型 | |
| 227 | + /// </summary> | |
| 228 | + public string ItemType { get; set; } | |
| 229 | + | |
| 230 | + /// <summary> | |
| 231 | + /// 消费金额 | |
| 232 | + /// </summary> | |
| 233 | + public decimal Amount { get; set; } | |
| 234 | + | |
| 235 | + /// <summary> | |
| 236 | + /// 消费次数 | |
| 237 | + /// </summary> | |
| 238 | + public int Count { get; set; } | |
| 239 | + | |
| 240 | + /// <summary> | |
| 241 | + /// 占比(百分比) | |
| 242 | + /// </summary> | |
| 243 | + public decimal Percentage { get; set; } | |
| 244 | + } | |
| 245 | + | |
| 246 | + /// <summary> | |
| 247 | + /// 门店偏好 | |
| 248 | + /// </summary> | |
| 249 | + public class StorePreference | |
| 250 | + { | |
| 251 | + /// <summary> | |
| 252 | + /// 门店ID | |
| 253 | + /// </summary> | |
| 254 | + public string StoreId { get; set; } | |
| 255 | + | |
| 256 | + /// <summary> | |
| 257 | + /// 门店名称 | |
| 258 | + /// </summary> | |
| 259 | + public string StoreName { get; set; } | |
| 260 | + | |
| 261 | + /// <summary> | |
| 262 | + /// 消费金额 | |
| 263 | + /// </summary> | |
| 264 | + public decimal Amount { get; set; } | |
| 265 | + | |
| 266 | + /// <summary> | |
| 267 | + /// 消费次数 | |
| 268 | + /// </summary> | |
| 269 | + public int Count { get; set; } | |
| 270 | + | |
| 271 | + /// <summary> | |
| 272 | + /// 占比(百分比) | |
| 273 | + /// </summary> | |
| 274 | + public decimal Percentage { get; set; } | |
| 275 | + } | |
| 276 | + | |
| 277 | + /// <summary> | |
| 278 | + /// 会员月度趋势点 | |
| 279 | + /// </summary> | |
| 280 | + public class MemberMonthlyTrendPoint | |
| 281 | + { | |
| 282 | + /// <summary> | |
| 283 | + /// 月份(yyyy-MM) | |
| 284 | + /// </summary> | |
| 285 | + public string Month { get; set; } | |
| 286 | + | |
| 287 | + /// <summary> | |
| 288 | + /// 当月消费金额 | |
| 289 | + /// </summary> | |
| 290 | + public decimal ConsumeAmount { get; set; } | |
| 291 | + | |
| 292 | + /// <summary> | |
| 293 | + /// 当月开单金额 | |
| 294 | + /// </summary> | |
| 295 | + public decimal BillingAmount { get; set; } | |
| 296 | + | |
| 297 | + /// <summary> | |
| 298 | + /// 当月退卡金额 | |
| 299 | + /// </summary> | |
| 300 | + public decimal RefundAmount { get; set; } | |
| 301 | + } | |
| 302 | +} | |
| 303 | + | |
| 304 | + | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs
| ... | ... | @@ -167,6 +167,7 @@ namespace NCC.Extend.LqHytkHytk |
| 167 | 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 | + .WhereIF(input.isEffective == 0, p => p.IsEffective == StatusEnum.有效.GetHashCode()) // 如果未指定,默认只返回有效的 | |
| 170 | 171 | .Select(it => new LqHytkHytkListOutput |
| 171 | 172 | { |
| 172 | 173 | id = it.Id, | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs
| ... | ... | @@ -25,9 +25,19 @@ using NCC.Extend.Entitys.lq_jinsanjiao_user; |
| 25 | 25 | using NCC.Extend.Entitys.lq_kd_kdjlb; |
| 26 | 26 | using NCC.Extend.Entitys.lq_kd_pxmx; |
| 27 | 27 | using NCC.Extend.Entitys.lq_xh_hyhk; |
| 28 | +using NCC.Extend.Entitys.lq_xh_pxmx; | |
| 29 | +using NCC.Extend.Entitys.lq_xh_kjbsyj; | |
| 30 | +using NCC.Extend.Entitys.lq_xh_jksyj; | |
| 28 | 31 | using NCC.Extend.Entitys.lq_khxx; |
| 29 | 32 | using NCC.Extend.Entitys.Dto.LqReport; |
| 30 | 33 | using NCC.Extend.Entitys.Enum; |
| 34 | +using NCC.Extend.Entitys.lq_tkjlb; | |
| 35 | +using NCC.Extend.Entitys.lq_yaoyjl; | |
| 36 | +using NCC.Extend.Entitys.lq_yyjl; | |
| 37 | +using NCC.Extend.Entitys.lq_event; | |
| 38 | +using NCC.Extend.Entitys.lq_hytk_hytk; | |
| 39 | +using NCC.Extend.Entitys.lq_xh_hyhk; | |
| 40 | +using NCC.System.Entitys.Permission; | |
| 31 | 41 | using SqlSugar; |
| 32 | 42 | |
| 33 | 43 | namespace NCC.Extend |
| ... | ... | @@ -388,18 +398,22 @@ namespace NCC.Extend |
| 388 | 398 | try |
| 389 | 399 | { |
| 390 | 400 | // 先尝试从统计表查询 |
| 401 | + // 使用F_ActualPerformance作为净业绩(如果为0或NULL,则使用F_TotalOrderPerformance - F_RefundAmount计算) | |
| 391 | 402 | var sql = @" |
| 392 | 403 | SELECT |
| 393 | 404 | s.F_StoreId, |
| 394 | 405 | s.F_StoreName, |
| 395 | - s.F_TotalPerformance, | |
| 406 | + CASE | |
| 407 | + WHEN COALESCE(s.F_ActualPerformance, 0) != 0 THEN s.F_ActualPerformance | |
| 408 | + ELSE COALESCE(s.F_TotalOrderPerformance, 0) - COALESCE(s.F_RefundAmount, 0) | |
| 409 | + END as F_TotalPerformance, | |
| 396 | 410 | s.F_TotalOrderPerformance, |
| 397 | 411 | s.F_FirstOrderCount, |
| 398 | 412 | s.F_UpgradeOrderCount, |
| 399 | 413 | s.F_ItemQuantity |
| 400 | 414 | FROM lq_statistics_store_total_performance s |
| 401 | 415 | WHERE s.F_StatisticsMonth = @statisticsMonth |
| 402 | - ORDER BY s.F_TotalPerformance DESC"; | |
| 416 | + ORDER BY F_TotalPerformance DESC"; | |
| 403 | 417 | |
| 404 | 418 | if (input.TopCount > 0) |
| 405 | 419 | { |
| ... | ... | @@ -415,21 +429,32 @@ namespace NCC.Extend |
| 415 | 429 | var startDate = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1); |
| 416 | 430 | var endDate = DateTime.Now; |
| 417 | 431 | |
| 432 | + // 计算净业绩(开单业绩 - 退卡业绩) | |
| 418 | 433 | var realTimeSql = $@" |
| 419 | 434 | SELECT |
| 420 | 435 | kd.djmd as F_StoreId, |
| 421 | 436 | mdxx.dm as F_StoreName, |
| 422 | - COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as F_TotalPerformance, | |
| 437 | + COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) - COALESCE(refund.F_RefundAmount, 0) as F_TotalPerformance, | |
| 423 | 438 | COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as F_TotalOrderPerformance, |
| 424 | 439 | 0 as F_FirstOrderCount, |
| 425 | 440 | 0 as F_UpgradeOrderCount, |
| 426 | 441 | 0 as F_ItemQuantity |
| 427 | 442 | FROM lq_kd_kdjlb kd |
| 428 | 443 | LEFT JOIN lq_mdxx mdxx ON kd.djmd = mdxx.F_Id |
| 444 | + LEFT JOIN ( | |
| 445 | + SELECT | |
| 446 | + md as F_StoreId, | |
| 447 | + COALESCE(SUM(CAST(COALESCE(F_ActualRefundAmount, tkje, 0) AS DECIMAL(18,2))), 0) as F_RefundAmount | |
| 448 | + FROM lq_hytk_hytk | |
| 449 | + WHERE F_IsEffective = 1 | |
| 450 | + AND tksj >= '{startDate:yyyy-MM-dd 00:00:00}' | |
| 451 | + AND tksj <= '{endDate:yyyy-MM-dd HH:mm:ss}' | |
| 452 | + GROUP BY md | |
| 453 | + ) refund ON kd.djmd = refund.F_StoreId | |
| 429 | 454 | WHERE kd.F_IsEffective = 1 |
| 430 | 455 | AND kd.kdrq >= '{startDate:yyyy-MM-dd 00:00:00}' |
| 431 | 456 | AND kd.kdrq <= '{endDate:yyyy-MM-dd HH:mm:ss}' |
| 432 | - GROUP BY kd.djmd, mdxx.dm | |
| 457 | + GROUP BY kd.djmd, mdxx.dm, refund.F_RefundAmount | |
| 433 | 458 | ORDER BY F_TotalPerformance DESC"; |
| 434 | 459 | |
| 435 | 460 | if (input.TopCount > 0) |
| ... | ... | @@ -728,6 +753,191 @@ namespace NCC.Extend |
| 728 | 753 | throw NCCException.Oh($"获取本月成交总额穿透统计失败: {ex.Message}"); |
| 729 | 754 | } |
| 730 | 755 | } |
| 756 | + | |
| 757 | + /// <summary> | |
| 758 | + /// 获取本月消耗金额穿透统计 | |
| 759 | + /// </summary> | |
| 760 | + /// <remarks> | |
| 761 | + /// 获取指定月份的消耗金额穿透统计数据,包括: | |
| 762 | + /// - DailyTrend: 每日消耗趋势(金额和人数) | |
| 763 | + /// - MemberStats: 会员极值统计(消耗金额最高会员、消耗次数最多会员) | |
| 764 | + /// - TechTeacherLaborCostTotal: 本月科技老师手工费合计(科美类型) | |
| 765 | + /// - HealthCoachLaborCostTotal: 健康师手工费合计 | |
| 766 | + /// - MaxSingleConsumeAmount: 单次消耗最大金额 | |
| 767 | + /// | |
| 768 | + /// 示例请求: | |
| 769 | + /// ```json | |
| 770 | + /// POST /api/Extend/LqReport/get-consume-drill-statistics | |
| 771 | + /// { | |
| 772 | + /// "statisticsMonth": "202512", | |
| 773 | + /// "storeIds": ["门店ID1", "门店ID2"] | |
| 774 | + /// } | |
| 775 | + /// ``` | |
| 776 | + /// </remarks> | |
| 777 | + /// <param name="input">查询参数</param> | |
| 778 | + /// <returns>本月消耗金额穿透统计结果</returns> | |
| 779 | + /// <response code="200">成功返回统计数据</response> | |
| 780 | + /// <response code="400">参数错误</response> | |
| 781 | + /// <response code="500">服务器错误</response> | |
| 782 | + [HttpPost("get-consume-drill-statistics")] | |
| 783 | + public async Task<object> GetConsumeDrillStatistics([FromBody] ConsumeDrillStatisticsInput input) | |
| 784 | + { | |
| 785 | + try | |
| 786 | + { | |
| 787 | + if (input == null || string.IsNullOrWhiteSpace(input.StatisticsMonth)) | |
| 788 | + { | |
| 789 | + throw NCCException.Oh("统计月份不能为空,格式为yyyyMM"); | |
| 790 | + } | |
| 791 | + | |
| 792 | + if (!DateTime.TryParseExact(input.StatisticsMonth + "01", "yyyyMMdd", null, | |
| 793 | + global::System.Globalization.DateTimeStyles.None, out var monthStart)) | |
| 794 | + { | |
| 795 | + throw NCCException.Oh($"统计月份格式错误:{input.StatisticsMonth},应为yyyyMM"); | |
| 796 | + } | |
| 797 | + | |
| 798 | + var startTime = monthStart; | |
| 799 | + var endTime = monthStart.AddMonths(1).AddSeconds(-1); | |
| 800 | + | |
| 801 | + // 1. 基础查询:耗卡品项明细 + 耗卡主表(用于获取门店和会员等) | |
| 802 | + var storeIds = input.StoreIds ?? new List<string>(); | |
| 803 | + | |
| 804 | + var baseQuery = _db.Queryable<LqXhPxmxEntity, LqXhHyhkEntity>((px, hyhk) => new JoinQueryInfos( | |
| 805 | + JoinType.Inner, px.ConsumeInfoId == hyhk.Id)) | |
| 806 | + .Where((px, hyhk) => px.IsEffective == StatusEnum.有效.GetHashCode() | |
| 807 | + && hyhk.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 808 | + .Where((px, hyhk) => px.Yjsj >= startTime && px.Yjsj <= endTime) | |
| 809 | + .WhereIF(storeIds.Any(), (px, hyhk) => storeIds.Contains(hyhk.Md)); | |
| 810 | + | |
| 811 | + // 为后续多次统计复用,先拉到内存 | |
| 812 | + var rawList = await baseQuery.Select((px, hyhk) => new | |
| 813 | + { | |
| 814 | + ConsumeId = hyhk.Id, | |
| 815 | + ConsumeDate = hyhk.Hksj, | |
| 816 | + StoreId = hyhk.Md, | |
| 817 | + MemberId = px.MemberId, | |
| 818 | + Amount = px.TotalPrice, | |
| 819 | + Yjsj = px.Yjsj | |
| 820 | + }).ToListAsync(); | |
| 821 | + | |
| 822 | + // 保护:无数据时直接返回空结构 | |
| 823 | + if (!rawList.Any()) | |
| 824 | + { | |
| 825 | + return new ConsumeDrillStatisticsOutput | |
| 826 | + { | |
| 827 | + DailyTrend = new List<ConsumeDailyTrendOutput>(), | |
| 828 | + MemberStats = new ConsumeMemberStatsOutput(), | |
| 829 | + TechTeacherLaborCostTotal = 0, | |
| 830 | + HealthCoachLaborCostTotal = 0, | |
| 831 | + MaxSingleConsumeAmount = 0 | |
| 832 | + }; | |
| 833 | + } | |
| 834 | + | |
| 835 | + // 2. 每日趋势:按耗卡日期聚合金额和会员数 | |
| 836 | + var dailyTrend = rawList | |
| 837 | + .GroupBy(x => x.ConsumeDate.HasValue ? x.ConsumeDate.Value.ToString("yyyy-MM-dd") : "") | |
| 838 | + .Where(g => !string.IsNullOrEmpty(g.Key)) | |
| 839 | + .Select(g => new ConsumeDailyTrendOutput | |
| 840 | + { | |
| 841 | + Date = g.Key, | |
| 842 | + Amount = g.Sum(x => x.Amount), | |
| 843 | + MemberCount = g.Select(x => x.MemberId).Where(m => !string.IsNullOrEmpty(m)).Distinct().Count() | |
| 844 | + }) | |
| 845 | + .OrderBy(x => x.Date) | |
| 846 | + .ToList(); | |
| 847 | + | |
| 848 | + // 3. 会员极值:按会员聚合金额和次数 | |
| 849 | + var memberAgg = rawList | |
| 850 | + .Where(x => !string.IsNullOrEmpty(x.MemberId)) | |
| 851 | + .GroupBy(x => x.MemberId) | |
| 852 | + .Select(g => new | |
| 853 | + { | |
| 854 | + MemberId = g.Key, | |
| 855 | + Amount = g.Sum(x => x.Amount), | |
| 856 | + Times = g.Count() | |
| 857 | + }) | |
| 858 | + .ToList(); | |
| 859 | + | |
| 860 | + var topAmount = memberAgg | |
| 861 | + .OrderByDescending(x => x.Amount) | |
| 862 | + .FirstOrDefault(); | |
| 863 | + | |
| 864 | + var topTimes = memberAgg | |
| 865 | + .OrderByDescending(x => x.Times) | |
| 866 | + .FirstOrDefault(); | |
| 867 | + | |
| 868 | + // 查询会员姓名 | |
| 869 | + var memberIds = new List<string>(); | |
| 870 | + if (topAmount != null && !string.IsNullOrEmpty(topAmount.MemberId)) | |
| 871 | + { | |
| 872 | + memberIds.Add(topAmount.MemberId); | |
| 873 | + } | |
| 874 | + if (topTimes != null && !string.IsNullOrEmpty(topTimes.MemberId) && !memberIds.Contains(topTimes.MemberId)) | |
| 875 | + { | |
| 876 | + memberIds.Add(topTimes.MemberId); | |
| 877 | + } | |
| 878 | + | |
| 879 | + var memberNameDict = new Dictionary<string, string>(); | |
| 880 | + if (memberIds.Any()) | |
| 881 | + { | |
| 882 | + var members = await _db.Queryable<LqKhxxEntity>() | |
| 883 | + .Where(x => memberIds.Contains(x.Id)) | |
| 884 | + .Select(x => new { x.Id, x.Khmc }) | |
| 885 | + .ToListAsync(); | |
| 886 | + memberNameDict = members.ToDictionary(x => x.Id, x => x.Khmc ?? ""); | |
| 887 | + } | |
| 888 | + | |
| 889 | + var memberStats = new ConsumeMemberStatsOutput | |
| 890 | + { | |
| 891 | + TopAmountMemberName = topAmount != null && memberNameDict.ContainsKey(topAmount.MemberId) ? memberNameDict[topAmount.MemberId] : "", | |
| 892 | + TopAmountValue = topAmount?.Amount ?? 0m, | |
| 893 | + TopTimesMemberName = topTimes != null && memberNameDict.ContainsKey(topTimes.MemberId) ? memberNameDict[topTimes.MemberId] : "", | |
| 894 | + TopTimesCount = topTimes?.Times ?? 0 | |
| 895 | + }; | |
| 896 | + | |
| 897 | + // 4. 科技老师手工费合计(科美类型) | |
| 898 | + var techTeacherLaborCost = await _db.Queryable<LqXhKjbsyjEntity, LqXhHyhkEntity>((kjb, hyhk) => new JoinQueryInfos( | |
| 899 | + JoinType.Inner, kjb.Glkdbh == hyhk.Id)) | |
| 900 | + .Where((kjb, hyhk) => kjb.IsEffective == StatusEnum.有效.GetHashCode() | |
| 901 | + && hyhk.IsEffective == StatusEnum.有效.GetHashCode() | |
| 902 | + && kjb.ItemCategory == "科美" | |
| 903 | + && kjb.Yjsj >= startTime && kjb.Yjsj <= endTime) | |
| 904 | + .WhereIF(storeIds.Any(), (kjb, hyhk) => storeIds.Contains(hyhk.Md)) | |
| 905 | + .SumAsync((kjb, hyhk) => kjb.LaborCost ?? 0); | |
| 906 | + | |
| 907 | + // 5. 健康师手工费合计 | |
| 908 | + var healthCoachLaborCost = await _db.Queryable<LqXhJksyjEntity, LqXhHyhkEntity>((jks, hyhk) => new JoinQueryInfos( | |
| 909 | + JoinType.Inner, jks.Glkdbh == hyhk.Id)) | |
| 910 | + .Where((jks, hyhk) => jks.IsEffective == StatusEnum.有效.GetHashCode() | |
| 911 | + && hyhk.IsEffective == StatusEnum.有效.GetHashCode() | |
| 912 | + && jks.Yjsj >= startTime && jks.Yjsj <= endTime) | |
| 913 | + .WhereIF(storeIds.Any(), (jks, hyhk) => storeIds.Contains(hyhk.Md)) | |
| 914 | + .SumAsync((jks, hyhk) => jks.LaborCost ?? 0); | |
| 915 | + | |
| 916 | + // 6. 单次消耗最大金额(按耗卡记录聚合,取最大单次消耗金额) | |
| 917 | + var consumeAmounts = rawList | |
| 918 | + .GroupBy(x => x.ConsumeId) | |
| 919 | + .Select(g => g.Sum(x => x.Amount)) | |
| 920 | + .ToList(); | |
| 921 | + | |
| 922 | + var maxSingleConsumeAmount = consumeAmounts.Any() ? consumeAmounts.Max() : 0m; | |
| 923 | + | |
| 924 | + var result = new ConsumeDrillStatisticsOutput | |
| 925 | + { | |
| 926 | + DailyTrend = dailyTrend, | |
| 927 | + MemberStats = memberStats, | |
| 928 | + TechTeacherLaborCostTotal = techTeacherLaborCost, | |
| 929 | + HealthCoachLaborCostTotal = healthCoachLaborCost, | |
| 930 | + MaxSingleConsumeAmount = maxSingleConsumeAmount | |
| 931 | + }; | |
| 932 | + | |
| 933 | + return result; | |
| 934 | + } | |
| 935 | + catch (Exception ex) | |
| 936 | + { | |
| 937 | + _logger.LogError(ex, $"获取本月消耗金额穿透统计失败 - 统计月份: {input?.StatisticsMonth}"); | |
| 938 | + throw NCCException.Oh($"获取本月消耗金额穿透统计失败: {ex.Message}"); | |
| 939 | + } | |
| 940 | + } | |
| 731 | 941 | #endregion |
| 732 | 942 | |
| 733 | 943 | #region 人员业绩报表 |
| ... | ... | @@ -1206,6 +1416,7 @@ namespace NCC.Extend |
| 1206 | 1416 | // 7. 最高剩余权益会员、本月开单金额最高会员、本月消耗金额最高会员 |
| 1207 | 1417 | var topRemainingSql = @" |
| 1208 | 1418 | SELECT |
| 1419 | + kh.F_Id as MemberId, | |
| 1209 | 1420 | kh.Khmc as MemberName, |
| 1210 | 1421 | COALESCE(kh.F_RemainingRightsAmount, 0) as Amount |
| 1211 | 1422 | FROM lq_khxx kh |
| ... | ... | @@ -1217,6 +1428,7 @@ namespace NCC.Extend |
| 1217 | 1428 | |
| 1218 | 1429 | var topBillingSql = @" |
| 1219 | 1430 | SELECT |
| 1431 | + kh.F_Id as MemberId, | |
| 1220 | 1432 | kh.Khmc as MemberName, |
| 1221 | 1433 | COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as Amount |
| 1222 | 1434 | FROM lq_kd_kdjlb kd |
| ... | ... | @@ -1231,6 +1443,7 @@ namespace NCC.Extend |
| 1231 | 1443 | |
| 1232 | 1444 | var topConsumeSql = @" |
| 1233 | 1445 | SELECT |
| 1446 | + kh.F_Id as MemberId, | |
| 1234 | 1447 | kh.Khmc as MemberName, |
| 1235 | 1448 | COALESCE(SUM(CAST(xh.xfje AS DECIMAL(18,2))), 0) as Amount |
| 1236 | 1449 | FROM lq_xh_hyhk xh |
| ... | ... | @@ -1306,10 +1519,13 @@ namespace NCC.Extend |
| 1306 | 1519 | TotalSleepMembers = sleep60_89 + sleep90_179 + sleep180_359 + sleep360Plus, |
| 1307 | 1520 | TotalRemainingAmount = totalRemainingAmount, |
| 1308 | 1521 | AvgRemainingAmount = avgRemainingAmount, |
| 1522 | + TopRemainingMemberId = topRemaining?.MemberId?.ToString() ?? string.Empty, | |
| 1309 | 1523 | TopRemainingMemberName = topRemaining?.MemberName ?? string.Empty, |
| 1310 | 1524 | TopRemainingAmount = Convert.ToDecimal(topRemaining?.Amount ?? 0m), |
| 1525 | + TopBillingMemberId = topBilling?.MemberId?.ToString() ?? string.Empty, | |
| 1311 | 1526 | TopBillingMemberName = topBilling?.MemberName ?? string.Empty, |
| 1312 | 1527 | TopBillingAmount = Convert.ToDecimal(topBilling?.Amount ?? 0m), |
| 1528 | + TopConsumeMemberId = topConsume?.MemberId?.ToString() ?? string.Empty, | |
| 1313 | 1529 | TopConsumeMemberName = topConsume?.MemberName ?? string.Empty, |
| 1314 | 1530 | TopConsumeAmount = Convert.ToDecimal(topConsume?.Amount ?? 0m), |
| 1315 | 1531 | BeautyMembers = Convert.ToInt32(memberStats?.BeautyMembers ?? 0), |
| ... | ... | @@ -3569,5 +3785,504 @@ namespace NCC.Extend |
| 3569 | 3785 | } |
| 3570 | 3786 | |
| 3571 | 3787 | #endregion |
| 3788 | + | |
| 3789 | + #region 拓客穿透统计 | |
| 3790 | + | |
| 3791 | + /// <summary> | |
| 3792 | + /// 拓客穿透统计 | |
| 3793 | + /// </summary> | |
| 3794 | + /// <remarks> | |
| 3795 | + /// 用于本月拓客人数穿透分析,提供门店排名、人员排名、活动筛选、每日走势等统计数据 | |
| 3796 | + /// | |
| 3797 | + /// 示例请求: | |
| 3798 | + /// ```json | |
| 3799 | + /// { | |
| 3800 | + /// "startTime": "2025-12-01 00:00:00", | |
| 3801 | + /// "endTime": "2025-12-31 23:59:59", | |
| 3802 | + /// "storeIds": ["门店ID1", "门店ID2"], | |
| 3803 | + /// "eventId": "活动ID(可选)" | |
| 3804 | + /// } | |
| 3805 | + /// ``` | |
| 3806 | + /// | |
| 3807 | + /// 参数说明: | |
| 3808 | + /// - startTime: 开始时间 | |
| 3809 | + /// - endTime: 结束时间 | |
| 3810 | + /// - storeIds: 门店ID列表(可选) | |
| 3811 | + /// - eventId: 活动ID(可选,如果提供则只统计该活动的数据) | |
| 3812 | + /// </remarks> | |
| 3813 | + /// <param name="input">查询参数</param> | |
| 3814 | + /// <returns>拓客穿透统计数据</returns> | |
| 3815 | + /// <response code="200">成功返回统计数据</response> | |
| 3816 | + /// <response code="400">参数错误</response> | |
| 3817 | + /// <response code="500">服务器内部错误</response> | |
| 3818 | + [HttpPost("get-tk-drill-statistics")] | |
| 3819 | + public async Task<object> GetTkDrillStatistics([FromBody] TkDrillStatisticsInput input) | |
| 3820 | + { | |
| 3821 | + try | |
| 3822 | + { | |
| 3823 | + if (input == null) | |
| 3824 | + { | |
| 3825 | + throw NCCException.Oh("参数不能为空"); | |
| 3826 | + } | |
| 3827 | + | |
| 3828 | + var startTime = input.StartTime ?? DateTime.Now.Date.AddDays(1 - DateTime.Now.Day); | |
| 3829 | + var endTime = input.EndTime ?? DateTime.Now; | |
| 3830 | + | |
| 3831 | + // 基础查询条件 | |
| 3832 | + var baseQuery = _db.Queryable<LqTkjlbEntity>() | |
| 3833 | + .Where(x => x.ExpansionTime >= startTime && x.ExpansionTime <= endTime); | |
| 3834 | + | |
| 3835 | + // 活动筛选 | |
| 3836 | + if (!string.IsNullOrEmpty(input.EventId)) | |
| 3837 | + { | |
| 3838 | + baseQuery = baseQuery.Where(x => x.EventId == input.EventId); | |
| 3839 | + } | |
| 3840 | + | |
| 3841 | + // 门店筛选 | |
| 3842 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 3843 | + { | |
| 3844 | + baseQuery = baseQuery.Where(x => input.StoreIds.Contains(x.StoreId)); | |
| 3845 | + } | |
| 3846 | + | |
| 3847 | + // 如果指定了活动,需要过滤出参与该活动的门店 | |
| 3848 | + if (!string.IsNullOrEmpty(input.EventId)) | |
| 3849 | + { | |
| 3850 | + // 获取参与该活动的门店ID列表 | |
| 3851 | + var eventStoreIds = await _db.Queryable<LqTkjlbEntity>() | |
| 3852 | + .Where(x => x.EventId == input.EventId) | |
| 3853 | + .Select(x => x.StoreId) | |
| 3854 | + .Distinct() | |
| 3855 | + .ToListAsync(); | |
| 3856 | + | |
| 3857 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 3858 | + { | |
| 3859 | + // 取交集:筛选条件中的门店且参与活动的门店 | |
| 3860 | + var validStoreIds = input.StoreIds.Intersect(eventStoreIds).ToList(); | |
| 3861 | + if (!validStoreIds.Any()) | |
| 3862 | + { | |
| 3863 | + // 如果没有符合条件的门店,返回空数据 | |
| 3864 | + return new | |
| 3865 | + { | |
| 3866 | + Success = true, | |
| 3867 | + Data = new | |
| 3868 | + { | |
| 3869 | + StoreRanking = new List<object>(), | |
| 3870 | + PersonRanking = new List<object>(), | |
| 3871 | + EventList = new List<object>(), | |
| 3872 | + DailyTrend = new List<object>(), | |
| 3873 | + HourlyTrend = new List<object>() | |
| 3874 | + }, | |
| 3875 | + Message = "没有符合条件的门店数据" | |
| 3876 | + }; | |
| 3877 | + } | |
| 3878 | + baseQuery = baseQuery.Where(x => validStoreIds.Contains(x.StoreId)); | |
| 3879 | + } | |
| 3880 | + else | |
| 3881 | + { | |
| 3882 | + baseQuery = baseQuery.Where(x => eventStoreIds.Contains(x.StoreId)); | |
| 3883 | + } | |
| 3884 | + } | |
| 3885 | + | |
| 3886 | + // 1. 门店拓客人数排名 | |
| 3887 | + var storeRanking = await baseQuery | |
| 3888 | + .GroupBy(x => x.StoreId) | |
| 3889 | + .Select(x => new | |
| 3890 | + { | |
| 3891 | + StoreId = x.StoreId, | |
| 3892 | + StoreName = SqlFunc.Subqueryable<LqMdxxEntity>().Where(m => m.Id == x.StoreId).Select(m => m.Dm), | |
| 3893 | + TkCount = SqlFunc.AggregateCount(x.Id) | |
| 3894 | + }) | |
| 3895 | + .OrderBy(x => x.TkCount, OrderByType.Desc) | |
| 3896 | + .ToListAsync(); | |
| 3897 | + | |
| 3898 | + // 2. 拓客人员拓客人数排名前五 | |
| 3899 | + var personRanking = await baseQuery | |
| 3900 | + .GroupBy(x => x.ExpansionUserId) | |
| 3901 | + .Select(x => new | |
| 3902 | + { | |
| 3903 | + UserId = x.ExpansionUserId, | |
| 3904 | + UserName = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == x.ExpansionUserId).Select(u => u.RealName), | |
| 3905 | + TkCount = SqlFunc.AggregateCount(x.Id) | |
| 3906 | + }) | |
| 3907 | + .OrderBy(x => x.TkCount, OrderByType.Desc) | |
| 3908 | + .Take(5) | |
| 3909 | + .ToListAsync(); | |
| 3910 | + | |
| 3911 | + // 3. 活动列表(用于筛选) | |
| 3912 | + var eventList = await _db.Queryable<LqTkjlbEntity>() | |
| 3913 | + .Where(x => x.ExpansionTime >= startTime && x.ExpansionTime <= endTime) | |
| 3914 | + .WhereIF(input.StoreIds != null && input.StoreIds.Any(), x => input.StoreIds.Contains(x.StoreId)) | |
| 3915 | + .Where(x => !string.IsNullOrEmpty(x.EventId)) | |
| 3916 | + .GroupBy(x => x.EventId) | |
| 3917 | + .Select(x => new | |
| 3918 | + { | |
| 3919 | + EventId = x.EventId, | |
| 3920 | + EventName = SqlFunc.Subqueryable<LqEventEntity>().Where(e => e.Id == x.EventId).Select(e => e.EventName), | |
| 3921 | + TkCount = SqlFunc.AggregateCount(x.Id) | |
| 3922 | + }) | |
| 3923 | + .OrderBy(x => x.TkCount, OrderByType.Desc) | |
| 3924 | + .ToListAsync(); | |
| 3925 | + | |
| 3926 | + // 4. 每日拓客数量走势(本月)- 使用原生SQL确保正确分组 | |
| 3927 | + var dailyTrendSql = $@" | |
| 3928 | + SELECT | |
| 3929 | + DATE_FORMAT(tk.F_ExpansionTime, '%Y-%m-%d') as DateStr, | |
| 3930 | + COUNT(tk.F_Id) as TkCount | |
| 3931 | + FROM lq_tkjlb tk | |
| 3932 | + WHERE tk.F_ExpansionTime >= '{startTime:yyyy-MM-dd 00:00:00}' | |
| 3933 | + AND tk.F_ExpansionTime <= '{endTime:yyyy-MM-dd HH:mm:ss}'"; | |
| 3934 | + | |
| 3935 | + if (!string.IsNullOrEmpty(input.EventId)) | |
| 3936 | + { | |
| 3937 | + dailyTrendSql += $" AND tk.F_EventId = '{input.EventId}'"; | |
| 3938 | + } | |
| 3939 | + | |
| 3940 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 3941 | + { | |
| 3942 | + var storeIdsStr = string.Join("','", input.StoreIds); | |
| 3943 | + dailyTrendSql += $" AND tk.F_StoreId IN ('{storeIdsStr}')"; | |
| 3944 | + } | |
| 3945 | + | |
| 3946 | + dailyTrendSql += @" | |
| 3947 | + GROUP BY DATE_FORMAT(tk.F_ExpansionTime, '%Y-%m-%d') | |
| 3948 | + ORDER BY DateStr"; | |
| 3949 | + | |
| 3950 | + var dailyTrend = await _db.Ado.SqlQueryAsync<dynamic>(dailyTrendSql); | |
| 3951 | + | |
| 3952 | + // 5. 如果指定了日期,获取该日期的24小时走势 | |
| 3953 | + List<object> hourlyTrend = new List<object>(); | |
| 3954 | + if (!string.IsNullOrEmpty(input.SelectedDate)) | |
| 3955 | + { | |
| 3956 | + if (DateTime.TryParse(input.SelectedDate, out var selectedDate)) | |
| 3957 | + { | |
| 3958 | + var dayStart = selectedDate.Date; | |
| 3959 | + var dayEnd = dayStart.AddDays(1).AddSeconds(-1); | |
| 3960 | + | |
| 3961 | + var hourlyQuery = _db.Queryable<LqTkjlbEntity>() | |
| 3962 | + .Where(x => x.ExpansionTime >= dayStart && x.ExpansionTime <= dayEnd); | |
| 3963 | + | |
| 3964 | + if (!string.IsNullOrEmpty(input.EventId)) | |
| 3965 | + { | |
| 3966 | + hourlyQuery = hourlyQuery.Where(x => x.EventId == input.EventId); | |
| 3967 | + } | |
| 3968 | + | |
| 3969 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 3970 | + { | |
| 3971 | + hourlyQuery = hourlyQuery.Where(x => input.StoreIds.Contains(x.StoreId)); | |
| 3972 | + } | |
| 3973 | + | |
| 3974 | + // 使用原生SQL获取24小时走势 | |
| 3975 | + var hourlySql = $@" | |
| 3976 | + SELECT | |
| 3977 | + HOUR(tk.F_ExpansionTime) as Hour, | |
| 3978 | + CONCAT(LPAD(HOUR(tk.F_ExpansionTime), 2, '0'), ':00') as HourStr, | |
| 3979 | + COUNT(tk.F_Id) as TkCount | |
| 3980 | + FROM lq_tkjlb tk | |
| 3981 | + WHERE tk.F_ExpansionTime >= '{dayStart:yyyy-MM-dd 00:00:00}' | |
| 3982 | + AND tk.F_ExpansionTime <= '{dayEnd:yyyy-MM-dd HH:mm:ss}'"; | |
| 3983 | + | |
| 3984 | + if (!string.IsNullOrEmpty(input.EventId)) | |
| 3985 | + { | |
| 3986 | + hourlySql += $" AND tk.F_EventId = '{input.EventId}'"; | |
| 3987 | + } | |
| 3988 | + | |
| 3989 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 3990 | + { | |
| 3991 | + var storeIdsStr = string.Join("','", input.StoreIds); | |
| 3992 | + hourlySql += $" AND tk.F_StoreId IN ('{storeIdsStr}')"; | |
| 3993 | + } | |
| 3994 | + | |
| 3995 | + hourlySql += @" | |
| 3996 | + GROUP BY HOUR(tk.F_ExpansionTime) | |
| 3997 | + ORDER BY Hour"; | |
| 3998 | + | |
| 3999 | + var hourlyData = await _db.Ado.SqlQueryAsync<dynamic>(hourlySql); | |
| 4000 | + hourlyTrend = hourlyData.Cast<object>().ToList(); | |
| 4001 | + } | |
| 4002 | + } | |
| 4003 | + | |
| 4004 | + return new | |
| 4005 | + { | |
| 4006 | + Success = true, | |
| 4007 | + Data = new | |
| 4008 | + { | |
| 4009 | + StoreRanking = storeRanking, | |
| 4010 | + PersonRanking = personRanking, | |
| 4011 | + EventList = eventList, | |
| 4012 | + DailyTrend = dailyTrend, | |
| 4013 | + HourlyTrend = hourlyTrend | |
| 4014 | + }, | |
| 4015 | + Message = "拓客穿透统计数据获取成功" | |
| 4016 | + }; | |
| 4017 | + } | |
| 4018 | + catch (Exception ex) | |
| 4019 | + { | |
| 4020 | + _logger.LogError(ex, $"获取拓客穿透统计数据失败 - 开始时间: {input?.StartTime}, 结束时间: {input?.EndTime}"); | |
| 4021 | + throw NCCException.Oh($"获取拓客穿透统计数据失败: {ex.Message}"); | |
| 4022 | + } | |
| 4023 | + } | |
| 4024 | + | |
| 4025 | + #endregion | |
| 4026 | + | |
| 4027 | + #region 退卡穿透统计 | |
| 4028 | + | |
| 4029 | + /// <summary> | |
| 4030 | + /// 退卡穿透统计 | |
| 4031 | + /// </summary> | |
| 4032 | + /// <remarks> | |
| 4033 | + /// 用于退卡总计穿透分析,提供门店分布、总计数据、最大金额/次数人员等统计数据 | |
| 4034 | + /// | |
| 4035 | + /// 示例请求: | |
| 4036 | + /// ```json | |
| 4037 | + /// { | |
| 4038 | + /// "startTime": "2025-12-01 00:00:00", | |
| 4039 | + /// "endTime": "2025-12-31 23:59:59", | |
| 4040 | + /// "storeIds": ["门店ID1", "门店ID2"] | |
| 4041 | + /// } | |
| 4042 | + /// ``` | |
| 4043 | + /// | |
| 4044 | + /// 参数说明: | |
| 4045 | + /// - startTime: 开始时间 | |
| 4046 | + /// - endTime: 结束时间 | |
| 4047 | + /// - storeIds: 门店ID列表(可选) | |
| 4048 | + /// </remarks> | |
| 4049 | + /// <param name="input">查询参数</param> | |
| 4050 | + /// <returns>退卡穿透统计数据</returns> | |
| 4051 | + /// <response code="200">成功返回统计数据</response> | |
| 4052 | + /// <response code="400">参数错误</response> | |
| 4053 | + /// <response code="500">服务器内部错误</response> | |
| 4054 | + [HttpPost("get-refund-drill-statistics")] | |
| 4055 | + public async Task<object> GetRefundDrillStatistics([FromBody] RefundDrillStatisticsInput input) | |
| 4056 | + { | |
| 4057 | + try | |
| 4058 | + { | |
| 4059 | + if (input == null) | |
| 4060 | + { | |
| 4061 | + throw NCCException.Oh("参数不能为空"); | |
| 4062 | + } | |
| 4063 | + | |
| 4064 | + var startTime = input.StartTime ?? DateTime.Now.Date.AddDays(1 - DateTime.Now.Day); | |
| 4065 | + var endTime = input.EndTime ?? DateTime.Now; | |
| 4066 | + | |
| 4067 | + // 确保结束时间包含当天的23:59:59 | |
| 4068 | + if (endTime.Hour == 0 && endTime.Minute == 0 && endTime.Second == 0) | |
| 4069 | + { | |
| 4070 | + endTime = endTime.Date.AddDays(1).AddSeconds(-1); | |
| 4071 | + } | |
| 4072 | + | |
| 4073 | + _logger.LogInformation($"退卡穿透统计 - 开始时间: {startTime:yyyy-MM-dd HH:mm:ss}, 结束时间: {endTime:yyyy-MM-dd HH:mm:ss}"); | |
| 4074 | + | |
| 4075 | + // 构建门店筛选条件 | |
| 4076 | + var storeFilter = ""; | |
| 4077 | + if (input.StoreIds != null && input.StoreIds.Any()) | |
| 4078 | + { | |
| 4079 | + var storeIdsStr = string.Join("','", input.StoreIds); | |
| 4080 | + storeFilter = $" AND hytk.md IN ('{storeIdsStr}')"; | |
| 4081 | + } | |
| 4082 | + | |
| 4083 | + // 1. 各门店退卡金额分布(不包含转卡)- 使用原生SQL确保正确 | |
| 4084 | + var storeDistributionSql = $@" | |
| 4085 | + SELECT | |
| 4086 | + hytk.md as StoreId, | |
| 4087 | + COALESCE(SUM(CAST(hytk.tkje AS DECIMAL(18,2))), 0) as RefundAmount, | |
| 4088 | + COUNT(hytk.F_Id) as RefundCount | |
| 4089 | + FROM lq_hytk_hytk hytk | |
| 4090 | + WHERE hytk.F_IsEffective = 1 | |
| 4091 | + AND hytk.tksj IS NOT NULL | |
| 4092 | + AND hytk.tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4093 | + AND hytk.tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4094 | + AND (hytk.tkyy IS NULL OR hytk.tkyy = '' OR hytk.tkyy != '转卡') | |
| 4095 | + {storeFilter} | |
| 4096 | + GROUP BY hytk.md | |
| 4097 | + ORDER BY RefundAmount DESC"; | |
| 4098 | + | |
| 4099 | + var storeDistributionRaw = await _db.Ado.SqlQueryAsync<dynamic>(storeDistributionSql); | |
| 4100 | + | |
| 4101 | + // 批量获取门店名称 | |
| 4102 | + var storeIds = storeDistributionRaw.Select(x => x.StoreId?.ToString()).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList(); | |
| 4103 | + var storeNameMap = new Dictionary<string, string>(); | |
| 4104 | + if (storeIds.Any()) | |
| 4105 | + { | |
| 4106 | + var stores = await _db.Queryable<LqMdxxEntity>() | |
| 4107 | + .Where(x => storeIds.Contains(x.Id)) | |
| 4108 | + .Select(x => new { x.Id, x.Dm }) | |
| 4109 | + .ToListAsync(); | |
| 4110 | + storeNameMap = stores.ToDictionary(x => x.Id, x => x.Dm ?? ""); | |
| 4111 | + } | |
| 4112 | + | |
| 4113 | + // 构建最终结果,包含门店名称 | |
| 4114 | + var storeDistribution = storeDistributionRaw.Select(x => new | |
| 4115 | + { | |
| 4116 | + StoreId = x.StoreId?.ToString(), | |
| 4117 | + StoreName = storeNameMap.ContainsKey(x.StoreId?.ToString() ?? "") ? storeNameMap[x.StoreId.ToString()] : "未知门店", | |
| 4118 | + RefundAmount = Convert.ToDecimal(x.RefundAmount ?? 0), | |
| 4119 | + RefundCount = Convert.ToInt32(x.RefundCount ?? 0) | |
| 4120 | + }).OrderByDescending(x => x.RefundAmount).ToList(); | |
| 4121 | + | |
| 4122 | + // 2. 总计数据 - 使用原生SQL确保正确(包含转卡,与驾驶舱保持一致) | |
| 4123 | + var totalStatsSql = $@" | |
| 4124 | + SELECT | |
| 4125 | + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, | |
| 4126 | + COALESCE(SUM(CAST(COALESCE(F_ActualRefundAmount, 0) AS DECIMAL(18,2))), 0) as TotalActualRefundAmount, | |
| 4127 | + COUNT(F_Id) as TotalRefundCount | |
| 4128 | + FROM lq_hytk_hytk | |
| 4129 | + WHERE F_IsEffective = 1 | |
| 4130 | + AND tksj IS NOT NULL | |
| 4131 | + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4132 | + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4133 | + {storeFilter.Replace("hytk.md", "md")}"; | |
| 4134 | + | |
| 4135 | + var totalStatsResult = await _db.Ado.SqlQueryAsync<dynamic>(totalStatsSql); | |
| 4136 | + var totalStats = totalStatsResult?.FirstOrDefault(); | |
| 4137 | + var totalRefundAmount = totalStats != null ? Convert.ToDecimal(totalStats.TotalRefundAmount ?? 0) : 0m; | |
| 4138 | + var totalActualRefundAmount = totalStats != null ? Convert.ToDecimal(totalStats.TotalActualRefundAmount ?? 0) : 0m; | |
| 4139 | + var totalRefundCount = totalStats != null ? Convert.ToInt32(totalStats.TotalRefundCount ?? 0) : 0; | |
| 4140 | + | |
| 4141 | + // 转卡总计(单独统计)- 使用原生SQL | |
| 4142 | + var transferCardSql = $@" | |
| 4143 | + SELECT | |
| 4144 | + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalTransferAmount, | |
| 4145 | + COUNT(F_Id) as TotalTransferCount | |
| 4146 | + FROM lq_hytk_hytk | |
| 4147 | + WHERE F_IsEffective = 1 | |
| 4148 | + AND tksj IS NOT NULL | |
| 4149 | + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4150 | + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4151 | + AND tkyy = '转卡' | |
| 4152 | + {storeFilter.Replace("hytk.md", "md")}"; | |
| 4153 | + | |
| 4154 | + var transferCardResult = await _db.Ado.SqlQueryAsync<dynamic>(transferCardSql); | |
| 4155 | + var transferCardStats = transferCardResult?.FirstOrDefault(); | |
| 4156 | + var totalTransferAmount = transferCardStats != null ? Convert.ToDecimal(transferCardStats.TotalTransferAmount ?? 0) : 0m; | |
| 4157 | + var totalTransferCount = transferCardStats != null ? Convert.ToInt32(transferCardStats.TotalTransferCount ?? 0) : 0; | |
| 4158 | + | |
| 4159 | + // 3. 退卡金额最大的人 - 使用原生SQL | |
| 4160 | + // 注意:按 hymc(会员名称)分组,因为同一个会员可能有不同的 hy(会员ID) | |
| 4161 | + var maxAmountPersonSql = $@" | |
| 4162 | + SELECT | |
| 4163 | + MAX(hy) as MemberId, | |
| 4164 | + hymc as MemberName, | |
| 4165 | + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, | |
| 4166 | + COUNT(F_Id) as RefundCount | |
| 4167 | + FROM lq_hytk_hytk | |
| 4168 | + WHERE F_IsEffective = 1 | |
| 4169 | + AND tksj IS NOT NULL | |
| 4170 | + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4171 | + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4172 | + AND (tkyy IS NULL OR tkyy = '' OR tkyy != '转卡') | |
| 4173 | + AND hymc IS NOT NULL | |
| 4174 | + AND hymc != '' | |
| 4175 | + {storeFilter.Replace("hytk.md", "md")} | |
| 4176 | + GROUP BY hymc | |
| 4177 | + ORDER BY TotalRefundAmount DESC | |
| 4178 | + LIMIT 1"; | |
| 4179 | + | |
| 4180 | + var maxAmountPersonResult = await _db.Ado.SqlQueryAsync<dynamic>(maxAmountPersonSql); | |
| 4181 | + var maxAmountPerson = maxAmountPersonResult?.FirstOrDefault(); | |
| 4182 | + | |
| 4183 | + // 4. 退卡次数最多的人 - 使用原生SQL(按退卡单数统计,一个退卡单算一次) | |
| 4184 | + // 注意:按 hymc(会员名称)分组,因为同一个会员可能有不同的 hy(会员ID) | |
| 4185 | + var maxCountPersonSql = $@" | |
| 4186 | + SELECT | |
| 4187 | + MAX(hy) as MemberId, | |
| 4188 | + hymc as MemberName, | |
| 4189 | + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, | |
| 4190 | + COUNT(F_Id) as RefundCount | |
| 4191 | + FROM lq_hytk_hytk | |
| 4192 | + WHERE F_IsEffective = 1 | |
| 4193 | + AND tksj IS NOT NULL | |
| 4194 | + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4195 | + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4196 | + AND (tkyy IS NULL OR tkyy = '' OR tkyy != '转卡') | |
| 4197 | + AND hymc IS NOT NULL | |
| 4198 | + AND hymc != '' | |
| 4199 | + {storeFilter.Replace("hytk.md", "md")} | |
| 4200 | + GROUP BY hymc | |
| 4201 | + HAVING RefundCount > 0 | |
| 4202 | + ORDER BY RefundCount DESC, TotalRefundAmount DESC | |
| 4203 | + LIMIT 1"; | |
| 4204 | + | |
| 4205 | + var maxCountPersonResult = await _db.Ado.SqlQueryAsync<dynamic>(maxCountPersonSql); | |
| 4206 | + var maxCountPerson = maxCountPersonResult?.FirstOrDefault(); | |
| 4207 | + | |
| 4208 | + // 5. 退卡金额与实际退款金额差距在1元以上的记录 - 使用原生SQL | |
| 4209 | + var gapRefundSql = $@" | |
| 4210 | + SELECT | |
| 4211 | + hytk.F_Id as RefundId, | |
| 4212 | + hytk.hy as MemberId, | |
| 4213 | + hytk.hymc as MemberName, | |
| 4214 | + CAST(hytk.tkje AS DECIMAL(18,2)) as RefundAmount, | |
| 4215 | + CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2)) as ActualRefundAmount, | |
| 4216 | + (CAST(hytk.tkje AS DECIMAL(18,2)) - CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2))) as GapAmount, | |
| 4217 | + hytk.tksj as RefundTime, | |
| 4218 | + hytk.tkyy as RefundReason | |
| 4219 | + FROM lq_hytk_hytk hytk | |
| 4220 | + WHERE hytk.F_IsEffective = 1 | |
| 4221 | + AND hytk.tksj IS NOT NULL | |
| 4222 | + AND hytk.tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' | |
| 4223 | + AND hytk.tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' | |
| 4224 | + AND (CAST(hytk.tkje AS DECIMAL(18,2)) - CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2))) >= 1.00 | |
| 4225 | + {storeFilter.Replace("hytk.md", "md")} | |
| 4226 | + ORDER BY GapAmount DESC | |
| 4227 | + LIMIT 50"; | |
| 4228 | + | |
| 4229 | + var gapRefundResult = await _db.Ado.SqlQueryAsync<dynamic>(gapRefundSql); | |
| 4230 | + var gapRefundList = new List<object>(); | |
| 4231 | + if (gapRefundResult != null) | |
| 4232 | + { | |
| 4233 | + foreach (var x in gapRefundResult) | |
| 4234 | + { | |
| 4235 | + gapRefundList.Add(new | |
| 4236 | + { | |
| 4237 | + RefundId = x.RefundId?.ToString(), | |
| 4238 | + MemberId = x.MemberId?.ToString(), | |
| 4239 | + MemberName = x.MemberName?.ToString(), | |
| 4240 | + RefundAmount = Convert.ToDecimal(x.RefundAmount ?? 0), | |
| 4241 | + ActualRefundAmount = Convert.ToDecimal(x.ActualRefundAmount ?? 0), | |
| 4242 | + GapAmount = Convert.ToDecimal(x.GapAmount ?? 0), | |
| 4243 | + RefundTime = x.RefundTime != null ? Convert.ToDateTime(x.RefundTime) : (DateTime?)null, | |
| 4244 | + RefundReason = x.RefundReason?.ToString() ?? "" | |
| 4245 | + }); | |
| 4246 | + } | |
| 4247 | + } | |
| 4248 | + | |
| 4249 | + return new | |
| 4250 | + { | |
| 4251 | + Success = true, | |
| 4252 | + Data = new | |
| 4253 | + { | |
| 4254 | + StoreDistribution = storeDistribution, | |
| 4255 | + TotalRefundAmount = totalRefundAmount, | |
| 4256 | + TotalActualRefundAmount = totalActualRefundAmount, | |
| 4257 | + TotalRefundCount = totalRefundCount, | |
| 4258 | + TotalTransferAmount = totalTransferAmount, | |
| 4259 | + TotalTransferCount = totalTransferCount, | |
| 4260 | + MaxAmountPerson = maxAmountPerson != null ? new | |
| 4261 | + { | |
| 4262 | + MemberId = maxAmountPerson.MemberId?.ToString(), | |
| 4263 | + MemberName = maxAmountPerson.MemberName?.ToString(), | |
| 4264 | + TotalRefundAmount = Convert.ToDecimal(maxAmountPerson.TotalRefundAmount ?? 0), | |
| 4265 | + RefundCount = Convert.ToInt32(maxAmountPerson.RefundCount ?? 0) | |
| 4266 | + } : null, | |
| 4267 | + MaxCountPerson = maxCountPerson != null ? new | |
| 4268 | + { | |
| 4269 | + MemberId = maxCountPerson.MemberId?.ToString(), | |
| 4270 | + MemberName = maxCountPerson.MemberName?.ToString(), | |
| 4271 | + TotalRefundAmount = Convert.ToDecimal(maxCountPerson.TotalRefundAmount ?? 0), | |
| 4272 | + RefundCount = Convert.ToInt32(maxCountPerson.RefundCount ?? 0) | |
| 4273 | + } : null, | |
| 4274 | + GapRefundList = gapRefundList | |
| 4275 | + }, | |
| 4276 | + Message = "退卡穿透统计数据获取成功" | |
| 4277 | + }; | |
| 4278 | + } | |
| 4279 | + catch (Exception ex) | |
| 4280 | + { | |
| 4281 | + _logger.LogError(ex, $"获取退卡穿透统计数据失败 - 开始时间: {input?.StartTime}, 结束时间: {input?.EndTime}"); | |
| 4282 | + throw NCCException.Oh($"获取退卡穿透统计数据失败: {ex.Message}"); | |
| 4283 | + } | |
| 4284 | + } | |
| 4285 | + | |
| 4286 | + #endregion | |
| 3572 | 4287 | } |
| 3573 | 4288 | } | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/LqTkjlbService.cs
| ... | ... | @@ -21,6 +21,9 @@ using NCC.Extend.Entitys.lq_event; |
| 21 | 21 | using NCC.Extend.Entitys.lq_eventuser; |
| 22 | 22 | using NCC.Extend.Entitys.lq_kd_kdjlb; |
| 23 | 23 | using NCC.Extend.Entitys.lq_kd_pxmx; |
| 24 | +using NCC.Extend.Entitys.lq_yaoyjl; | |
| 25 | +using NCC.Extend.Entitys.lq_yyjl; | |
| 26 | +using NCC.Extend.Entitys.lq_xh_hyhk; | |
| 24 | 27 | using NCC.Extend.Entitys.lq_khxx; |
| 25 | 28 | using NCC.Extend.Entitys.lq_mdxx; |
| 26 | 29 | using NCC.Extend.Entitys.lq_tkjlb; |
| ... | ... | @@ -138,6 +141,14 @@ namespace NCC.Extend.LqTkjlb |
| 138 | 141 | eventName = SqlFunc.Subqueryable<LqEventEntity>().Where(u => u.Id == it.EventId).Select(u => u.EventName), |
| 139 | 142 | depId = it.DepId, |
| 140 | 143 | depName = SqlFunc.Subqueryable<OrganizeEntity>().Where(u => u.Id == it.DepId).Select(u => u.FullName), |
| 144 | + // 是否邀约:通过会员ID关联邀约表(yykh字段存储的是会员ID) | |
| 145 | + hasInvite = SqlFunc.Subqueryable<LqYaoyjlEntity>().Where(y => y.Yykh == it.MemberId).Any() ? "是" : "否", | |
| 146 | + // 是否预约:通过会员ID关联预约表(gk字段存储的是会员ID) | |
| 147 | + hasAppointment = SqlFunc.Subqueryable<LqYyjlEntity>().Where(y => y.Gk == it.MemberId).Any() ? "是" : "否", | |
| 148 | + // 是否消耗:通过会员ID关联耗卡表(hy字段存储的是会员ID) | |
| 149 | + hasConsume = SqlFunc.Subqueryable<LqXhHyhkEntity>().Where(x => x.Hy == it.MemberId).Any() ? "是" : "否", | |
| 150 | + // 是否开卡:通过会员ID关联开单表(kdhy字段存储的是会员ID) | |
| 151 | + hasBilling = SqlFunc.Subqueryable<LqKdKdjlbEntity>().Where(k => k.Kdhy == it.MemberId).Any() ? "是" : "否" | |
| 141 | 152 | }) |
| 142 | 153 | .MergeTable() |
| 143 | 154 | .OrderBy(sidx + " " + input.sort) | ... | ... |
netcore/src/Modularity/Extend/NCC.Extend/MemberPortraitService.cs
0 → 100644
| 1 | +using System; | |
| 2 | +using System.Collections.Generic; | |
| 3 | +using System.Linq; | |
| 4 | +using System.Threading.Tasks; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.Extensions.Logging; | |
| 7 | +using NCC.DynamicApiController; | |
| 8 | +using NCC.FriendlyException; | |
| 9 | +using NCC.Extend.Entitys.Dto.MemberPortrait; | |
| 10 | +using NCC.Extend.Entitys.lq_kd_kdjlb; | |
| 11 | +using NCC.Extend.Entitys.lq_kd_pxmx; | |
| 12 | +using NCC.Extend.Entitys.lq_khxx; | |
| 13 | +using NCC.Extend.Entitys.lq_xh_hyhk; | |
| 14 | +using NCC.Extend.Entitys.lq_xh_pxmx; | |
| 15 | +using NCC.Extend.Entitys.lq_hytk_hytk; | |
| 16 | +using NCC.Extend.Entitys.lq_hytk_mx; | |
| 17 | +using NCC.Extend.Entitys.lq_kd_deductinfo; | |
| 18 | +using NCC.Extend.Entitys.lq_mdxx; | |
| 19 | +using NCC.Extend.Entitys.lq_package_info; | |
| 20 | +using NCC.Extend.Entitys.Enum; | |
| 21 | +using NCC.Dependency; | |
| 22 | +using SqlSugar; | |
| 23 | +using SqlSugar.IOC; | |
| 24 | + | |
| 25 | +namespace NCC.Extend | |
| 26 | +{ | |
| 27 | + /// <summary> | |
| 28 | + /// 会员画像数据服务 | |
| 29 | + /// </summary> | |
| 30 | + [ApiDescriptionSettings(Tag = "会员画像数据服务", Name = "MemberPortrait", Order = 600)] | |
| 31 | + [Route("api/Extend/[controller]")] | |
| 32 | + public class MemberPortraitService : IDynamicApiController, ITransient | |
| 33 | + { | |
| 34 | + private readonly SqlSugarScope _db; | |
| 35 | + private readonly ILogger<MemberPortraitService> _logger; | |
| 36 | + | |
| 37 | + /// <summary> | |
| 38 | + /// 初始化会员画像服务 | |
| 39 | + /// </summary> | |
| 40 | + public MemberPortraitService(ISqlSugarRepository<LqKhxxEntity> khxxRepository, ILogger<MemberPortraitService> logger) | |
| 41 | + { | |
| 42 | + _db = khxxRepository.Context; | |
| 43 | + _logger = logger; | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// <summary> | |
| 47 | + /// 获取会员画像概览数据 | |
| 48 | + /// </summary> | |
| 49 | + /// <remarks> | |
| 50 | + /// 根据会员ID聚合基础档案、消费概要和近12个月趋势数据。 | |
| 51 | + /// | |
| 52 | + /// 示例请求: | |
| 53 | + /// ```http | |
| 54 | + /// GET /api/Extend/MemberPortrait/overview?memberId=会员ID | |
| 55 | + /// ``` | |
| 56 | + /// | |
| 57 | + /// 参数说明: | |
| 58 | + /// - memberId: 会员主键ID(lq_khxx.F_Id) | |
| 59 | + /// </remarks> | |
| 60 | + /// <param name="memberId">会员ID</param> | |
| 61 | + /// <returns>会员画像概览数据</returns> | |
| 62 | + /// <response code="200">返回会员画像概览数据</response> | |
| 63 | + /// <response code="400">参数错误或会员不存在</response> | |
| 64 | + /// <response code="500">服务器错误</response> | |
| 65 | + [HttpGet("overview")] | |
| 66 | + public async Task<MemberPortraitOverviewOutput> GetOverview(string memberId) | |
| 67 | + { | |
| 68 | + if (string.IsNullOrEmpty(memberId)) | |
| 69 | + { | |
| 70 | + throw NCCException.Oh("memberId 参数不能为空"); | |
| 71 | + } | |
| 72 | + | |
| 73 | + try | |
| 74 | + { | |
| 75 | + // 基础信息 | |
| 76 | + var member = await _db.Queryable<LqKhxxEntity>() | |
| 77 | + .FirstAsync(x => x.Id == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()); | |
| 78 | + | |
| 79 | + if (member == null) | |
| 80 | + { | |
| 81 | + throw NCCException.Oh($"会员不存在或已失效, memberId={memberId}"); | |
| 82 | + } | |
| 83 | + | |
| 84 | + // 查询门店名称 | |
| 85 | + string storeName = null; | |
| 86 | + if (!string.IsNullOrEmpty(member.Gsmd)) | |
| 87 | + { | |
| 88 | + storeName = await _db.Queryable<LqMdxxEntity>() | |
| 89 | + .Where(x => x.Id == member.Gsmd) | |
| 90 | + .Select(x => x.Dm) | |
| 91 | + .FirstAsync(); | |
| 92 | + } | |
| 93 | + | |
| 94 | + // 构建会员类型列表 | |
| 95 | + var memberTypes = new List<MemberTypeInfo>(); | |
| 96 | + if (member.IsBeautyMember == StatusEnum.有效.GetHashCode()) | |
| 97 | + { | |
| 98 | + memberTypes.Add(new MemberTypeInfo | |
| 99 | + { | |
| 100 | + TypeName = "生美", | |
| 101 | + BecomeTime = member.BeautyMemberTime | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + if (member.IsMedicalMember == StatusEnum.有效.GetHashCode()) | |
| 105 | + { | |
| 106 | + memberTypes.Add(new MemberTypeInfo | |
| 107 | + { | |
| 108 | + TypeName = "医美", | |
| 109 | + BecomeTime = member.MedicalMemberTime | |
| 110 | + }); | |
| 111 | + } | |
| 112 | + if (member.IsTechMember == StatusEnum.有效.GetHashCode()) | |
| 113 | + { | |
| 114 | + memberTypes.Add(new MemberTypeInfo | |
| 115 | + { | |
| 116 | + TypeName = "科技部", | |
| 117 | + BecomeTime = member.TechMemberTime | |
| 118 | + }); | |
| 119 | + } | |
| 120 | + if (member.IsEducationMember == StatusEnum.有效.GetHashCode()) | |
| 121 | + { | |
| 122 | + memberTypes.Add(new MemberTypeInfo | |
| 123 | + { | |
| 124 | + TypeName = "教育部", | |
| 125 | + BecomeTime = member.EducationMemberTime | |
| 126 | + }); | |
| 127 | + } | |
| 128 | + | |
| 129 | + var baseInfo = new MemberBaseInfo | |
| 130 | + { | |
| 131 | + MemberId = member.Id, | |
| 132 | + MemberCode = member.Dah, | |
| 133 | + MemberName = member.Khmc, | |
| 134 | + Mobile = member.Sjh, | |
| 135 | + StoreId = member.Gsmd, | |
| 136 | + StoreName = storeName, | |
| 137 | + Channel = member.Jdqd, // 进店渠道 | |
| 138 | + FirstVisitTime = member.FirstVisitTime, | |
| 139 | + LastVisitTime = member.LastVisitTime, | |
| 140 | + SleepDays = member.SleepDays, | |
| 141 | + SleepStartTime = member.SleepStartTime, | |
| 142 | + ConsumeLevel = member.ConsumeLevel, | |
| 143 | + ConsumeLevelUpdateTime = member.ConsumeLevelUpdateTime, | |
| 144 | + MemberTypes = memberTypes | |
| 145 | + }; | |
| 146 | + | |
| 147 | + // 行为概要(使用个人累计字段 + 明细表兜底) | |
| 148 | + var behavior = new MemberBehaviorSummary | |
| 149 | + { | |
| 150 | + TotalBillingAmount = member.TotalBillingAmount, | |
| 151 | + TotalConsumeAmount = member.TotalConsumeAmount, | |
| 152 | + RemainingRightsAmount = member.RemainingRightsAmount | |
| 153 | + }; | |
| 154 | + | |
| 155 | + // 退卡总金额(如实体中未维护,则从退卡表统计) | |
| 156 | + var refundTotal = await _db.Queryable<LqHytkHytkEntity>() | |
| 157 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 158 | + .Where(x => x.Hy == member.Id) | |
| 159 | + .SumAsync(x => (decimal?)x.Tkje) ?? 0m; | |
| 160 | + | |
| 161 | + behavior.TotalRefundAmount = refundTotal; | |
| 162 | + | |
| 163 | + // 开单统计 | |
| 164 | + behavior.BillingCount = await _db.Queryable<LqKdKdjlbEntity>() | |
| 165 | + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) | |
| 166 | + .CountAsync(); | |
| 167 | + | |
| 168 | + if (behavior.BillingCount > 0) | |
| 169 | + { | |
| 170 | + behavior.FirstBillingTime = await _db.Queryable<LqKdKdjlbEntity>() | |
| 171 | + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) | |
| 172 | + .MinAsync(x => x.Kdrq); | |
| 173 | + | |
| 174 | + behavior.LastBillingTime = await _db.Queryable<LqKdKdjlbEntity>() | |
| 175 | + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) | |
| 176 | + .MaxAsync(x => x.Kdrq); | |
| 177 | + | |
| 178 | + behavior.AvgBillingAmount = behavior.TotalBillingAmount > 0 && behavior.BillingCount > 0 | |
| 179 | + ? (behavior.TotalBillingAmount / behavior.BillingCount) | |
| 180 | + : 0m; | |
| 181 | + } | |
| 182 | + else | |
| 183 | + { | |
| 184 | + behavior.FirstBillingTime = null; | |
| 185 | + behavior.LastBillingTime = null; | |
| 186 | + behavior.AvgBillingAmount = 0m; | |
| 187 | + } | |
| 188 | + | |
| 189 | + // 消耗统计 | |
| 190 | + behavior.ConsumeCount = await _db.Queryable<LqXhHyhkEntity>() | |
| 191 | + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) | |
| 192 | + .CountAsync(); | |
| 193 | + | |
| 194 | + if (behavior.ConsumeCount > 0) | |
| 195 | + { | |
| 196 | + behavior.FirstConsumeTime = await _db.Queryable<LqXhHyhkEntity>() | |
| 197 | + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) | |
| 198 | + .MinAsync(x => x.Hksj); | |
| 199 | + | |
| 200 | + behavior.LastConsumeTime = await _db.Queryable<LqXhHyhkEntity>() | |
| 201 | + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) | |
| 202 | + .MaxAsync(x => x.Hksj); | |
| 203 | + | |
| 204 | + behavior.AvgConsumeAmount = behavior.TotalConsumeAmount > 0 && behavior.ConsumeCount > 0 | |
| 205 | + ? (behavior.TotalConsumeAmount / behavior.ConsumeCount) | |
| 206 | + : 0m; | |
| 207 | + } | |
| 208 | + else | |
| 209 | + { | |
| 210 | + behavior.FirstConsumeTime = null; | |
| 211 | + behavior.LastConsumeTime = null; | |
| 212 | + behavior.AvgConsumeAmount = 0m; | |
| 213 | + } | |
| 214 | + | |
| 215 | + // 退卡次数 | |
| 216 | + behavior.RefundCount = await _db.Queryable<LqHytkHytkEntity>() | |
| 217 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 218 | + .Where(x => x.Hy == member.Id) | |
| 219 | + .CountAsync(); | |
| 220 | + | |
| 221 | + // 近12个月趋势(以当前月份往前推12个月) | |
| 222 | + var now = DateTime.Now; | |
| 223 | + var trendStart = new DateTime(now.Year, now.Month, 1).AddMonths(-11); | |
| 224 | + var trendEnd = new DateTime(now.Year, now.Month, 1).AddMonths(1).AddSeconds(-1); | |
| 225 | + | |
| 226 | + // 开单趋势 | |
| 227 | + var billingTrendSql = $@" | |
| 228 | + SELECT | |
| 229 | + DATE_FORMAT(kd.kdrq, '%Y-%m') as Month, | |
| 230 | + COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as BillingAmount | |
| 231 | + FROM lq_kd_kdjlb kd | |
| 232 | + WHERE kd.F_IsEffective = 1 | |
| 233 | + AND kd.kdrq >= @trendStart | |
| 234 | + AND kd.kdrq <= @trendEnd | |
| 235 | + AND kd.kdhy = @memberId | |
| 236 | + GROUP BY DATE_FORMAT(kd.kdrq, '%Y-%m')"; | |
| 237 | + | |
| 238 | + var billingTrend = await _db.Ado.SqlQueryAsync<dynamic>(billingTrendSql, | |
| 239 | + new { trendStart, trendEnd, memberId }); | |
| 240 | + | |
| 241 | + // 耗卡趋势 | |
| 242 | + var consumeTrendSql = $@" | |
| 243 | + SELECT | |
| 244 | + DATE_FORMAT(xh.hksj, '%Y-%m') as Month, | |
| 245 | + COALESCE(SUM(CAST(xh.xfje AS DECIMAL(18,2))), 0) as ConsumeAmount | |
| 246 | + FROM lq_xh_hyhk xh | |
| 247 | + WHERE xh.F_IsEffective = 1 | |
| 248 | + AND xh.hksj >= @trendStart | |
| 249 | + AND xh.hksj <= @trendEnd | |
| 250 | + AND xh.hy = @memberId | |
| 251 | + GROUP BY DATE_FORMAT(xh.hksj, '%Y-%m')"; | |
| 252 | + | |
| 253 | + var consumeTrend = await _db.Ado.SqlQueryAsync<dynamic>(consumeTrendSql, | |
| 254 | + new { trendStart, trendEnd, memberId }); | |
| 255 | + | |
| 256 | + // 退卡趋势 | |
| 257 | + var refundTrendSql = $@" | |
| 258 | + SELECT | |
| 259 | + DATE_FORMAT(hytk.tksj, '%Y-%m') as Month, | |
| 260 | + COALESCE(SUM(CAST(hytk.tkje AS DECIMAL(18,2))), 0) as RefundAmount | |
| 261 | + FROM lq_hytk_hytk hytk | |
| 262 | + WHERE hytk.F_IsEffective = 1 | |
| 263 | + AND hytk.tksj >= @trendStart | |
| 264 | + AND hytk.tksj <= @trendEnd | |
| 265 | + AND hytk.hy = @memberId | |
| 266 | + GROUP BY DATE_FORMAT(hytk.tksj, '%Y-%m')"; | |
| 267 | + | |
| 268 | + var refundTrend = await _db.Ado.SqlQueryAsync<dynamic>(refundTrendSql, | |
| 269 | + new { trendStart, trendEnd, memberId }); | |
| 270 | + | |
| 271 | + // 组合趋势数据,补全没有数据的月份 | |
| 272 | + var trendDict = new Dictionary<string, MemberMonthlyTrendPoint>(); | |
| 273 | + for (int i = 0; i < 12; i++) | |
| 274 | + { | |
| 275 | + var month = trendStart.AddMonths(i).ToString("yyyy-MM"); | |
| 276 | + trendDict[month] = new MemberMonthlyTrendPoint | |
| 277 | + { | |
| 278 | + Month = month, | |
| 279 | + BillingAmount = 0m, | |
| 280 | + ConsumeAmount = 0m, | |
| 281 | + RefundAmount = 0m | |
| 282 | + }; | |
| 283 | + } | |
| 284 | + | |
| 285 | + foreach (var item in billingTrend) | |
| 286 | + { | |
| 287 | + var month = Convert.ToString(item.Month); | |
| 288 | + if (trendDict.ContainsKey(month)) | |
| 289 | + { | |
| 290 | + trendDict[month].BillingAmount = Convert.ToDecimal(item.BillingAmount ?? 0m); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 294 | + foreach (var item in consumeTrend) | |
| 295 | + { | |
| 296 | + var month = Convert.ToString(item.Month); | |
| 297 | + if (trendDict.ContainsKey(month)) | |
| 298 | + { | |
| 299 | + trendDict[month].ConsumeAmount = Convert.ToDecimal(item.ConsumeAmount ?? 0m); | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + foreach (var item in refundTrend) | |
| 304 | + { | |
| 305 | + var month = Convert.ToString(item.Month); | |
| 306 | + if (trendDict.ContainsKey(month)) | |
| 307 | + { | |
| 308 | + trendDict[month].RefundAmount = Convert.ToDecimal(item.RefundAmount ?? 0m); | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + // 查询权益明细 | |
| 313 | + var baseItems = await _db.Queryable<LqKdPxmxEntity>() | |
| 314 | + .Where(x => x.MemberId == memberId) | |
| 315 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 316 | + .Select(x => new | |
| 317 | + { | |
| 318 | + x.Id, | |
| 319 | + x.Px, | |
| 320 | + x.Pxmc, | |
| 321 | + x.Pxjg, | |
| 322 | + x.SourceType, | |
| 323 | + x.ProjectNumber | |
| 324 | + }) | |
| 325 | + .ToListAsync(); | |
| 326 | + | |
| 327 | + var consumedData = await _db.Queryable<LqXhPxmxEntity>() | |
| 328 | + .Where(x => baseItems.Select(b => b.Id).Contains(x.BillingItemId)) | |
| 329 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 330 | + .GroupBy(x => x.BillingItemId) | |
| 331 | + .Select(x => new | |
| 332 | + { | |
| 333 | + BillingItemId = x.BillingItemId, | |
| 334 | + TotalConsumed = SqlFunc.AggregateSum(x.OriginalProjectNumber) | |
| 335 | + }) | |
| 336 | + .ToListAsync(); | |
| 337 | + | |
| 338 | + var refundedData = await _db.Queryable<LqHytkMxEntity>() | |
| 339 | + .Where(x => baseItems.Select(b => b.Id).Contains(x.BillingItemId)) | |
| 340 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 341 | + .GroupBy(x => x.BillingItemId) | |
| 342 | + .Select(x => new | |
| 343 | + { | |
| 344 | + BillingItemId = x.BillingItemId, | |
| 345 | + TotalRefunded = SqlFunc.AggregateSum(x.ProjectNumber) | |
| 346 | + }) | |
| 347 | + .ToListAsync(); | |
| 348 | + | |
| 349 | + var deductData = await _db.Queryable<LqKdDeductinfoEntity>() | |
| 350 | + .Where(x => baseItems.Select(b => b.Id).Contains(x.DeductId)) | |
| 351 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 352 | + .GroupBy(x => x.DeductId) | |
| 353 | + .Select(x => new | |
| 354 | + { | |
| 355 | + BillingItemId = x.DeductId, | |
| 356 | + TotalDeduct = SqlFunc.AggregateSum(x.ProjectNumber) | |
| 357 | + }) | |
| 358 | + .ToListAsync(); | |
| 359 | + | |
| 360 | + var remainingItems = baseItems.Select(item => new RemainingItemDto | |
| 361 | + { | |
| 362 | + ItemId = item.Px, | |
| 363 | + ItemName = item.Pxmc, | |
| 364 | + UnitPrice = item.Pxjg, | |
| 365 | + SourceType = item.SourceType, | |
| 366 | + TotalQuantity = (int)item.ProjectNumber, | |
| 367 | + ConsumedQuantity = (int)(consumedData.FirstOrDefault(c => c.BillingItemId == item.Id)?.TotalConsumed ?? 0m), | |
| 368 | + RefundedQuantity = (int)(refundedData.FirstOrDefault(r => r.BillingItemId == item.Id)?.TotalRefunded ?? 0m), | |
| 369 | + DeductedQuantity = (int)(deductData.FirstOrDefault(d => d.BillingItemId == item.Id)?.TotalDeduct ?? 0m) | |
| 370 | + }).ToList(); | |
| 371 | + | |
| 372 | + foreach (var item in remainingItems) | |
| 373 | + { | |
| 374 | + item.RemainingQuantity = item.TotalQuantity - item.ConsumedQuantity - item.RefundedQuantity - item.DeductedQuantity; | |
| 375 | + item.RemainingValue = item.UnitPrice * item.RemainingQuantity; | |
| 376 | + } | |
| 377 | + | |
| 378 | + // 只返回剩余数量大于0的 | |
| 379 | + remainingItems = remainingItems.Where(x => x.RemainingQuantity > 0).OrderByDescending(x => x.RemainingValue).ToList(); | |
| 380 | + | |
| 381 | + var assets = new MemberAssets | |
| 382 | + { | |
| 383 | + RemainingItems = remainingItems | |
| 384 | + }; | |
| 385 | + | |
| 386 | + // 计算消费分析数据 | |
| 387 | + var consumptionAnalysis = new ConsumptionAnalysis(); | |
| 388 | + | |
| 389 | + // 计算消费频率(次/月) | |
| 390 | + if (behavior.FirstConsumeTime.HasValue && behavior.LastConsumeTime.HasValue) | |
| 391 | + { | |
| 392 | + var months = Math.Max(1, (behavior.LastConsumeTime.Value - behavior.FirstConsumeTime.Value).Days / 30.0); | |
| 393 | + consumptionAnalysis.ConsumeFrequency = months > 0 ? (decimal)(behavior.ConsumeCount / months) : 0m; | |
| 394 | + } | |
| 395 | + | |
| 396 | + // 计算开单频率(次/月) | |
| 397 | + if (behavior.FirstBillingTime.HasValue && behavior.LastBillingTime.HasValue) | |
| 398 | + { | |
| 399 | + var months = Math.Max(1, (behavior.LastBillingTime.Value - behavior.FirstBillingTime.Value).Days / 30.0); | |
| 400 | + consumptionAnalysis.BillingFrequency = months > 0 ? (decimal)(behavior.BillingCount / months) : 0m; | |
| 401 | + } | |
| 402 | + | |
| 403 | + // 判断消费活跃度(最近3个月是否有消费) | |
| 404 | + var threeMonthsAgo = DateTime.Now.AddMonths(-3); | |
| 405 | + consumptionAnalysis.IsActive = behavior.LastConsumeTime.HasValue && behavior.LastConsumeTime.Value >= threeMonthsAgo; | |
| 406 | + | |
| 407 | + // 品项类型偏好(按品项类型统计消费金额和次数) | |
| 408 | + var itemTypeStats = await _db.Queryable<LqXhPxmxEntity>() | |
| 409 | + .Where(x => x.MemberId == memberId) | |
| 410 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 411 | + .GroupBy(x => x.SourceType) | |
| 412 | + .Select(x => new | |
| 413 | + { | |
| 414 | + ItemType = x.SourceType, | |
| 415 | + Amount = SqlFunc.AggregateSum(x.TotalPrice), | |
| 416 | + Count = SqlFunc.AggregateCount(x.Id) | |
| 417 | + }) | |
| 418 | + .ToListAsync(); | |
| 419 | + | |
| 420 | + var totalItemAmount = itemTypeStats.Sum(x => (decimal)x.Amount); | |
| 421 | + consumptionAnalysis.ItemTypePreferences = itemTypeStats.Select(x => new ItemTypePreference | |
| 422 | + { | |
| 423 | + ItemType = x.ItemType ?? "未设置", | |
| 424 | + Amount = (decimal)x.Amount, | |
| 425 | + Count = x.Count, | |
| 426 | + Percentage = totalItemAmount > 0 ? ((decimal)x.Amount / totalItemAmount * 100) : 0m | |
| 427 | + }).OrderByDescending(x => x.Amount).ToList(); | |
| 428 | + | |
| 429 | + // 门店偏好(按门店统计消费金额和次数) | |
| 430 | + var storeStats = await _db.Queryable<LqXhHyhkEntity>() | |
| 431 | + .Where(x => x.Hy == memberId) | |
| 432 | + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 433 | + .GroupBy(x => x.Md) | |
| 434 | + .Select(x => new | |
| 435 | + { | |
| 436 | + StoreId = x.Md, | |
| 437 | + Amount = SqlFunc.AggregateSum(x.Xfje), | |
| 438 | + Count = SqlFunc.AggregateCount(x.Id) | |
| 439 | + }) | |
| 440 | + .ToListAsync(); | |
| 441 | + | |
| 442 | + // 查询门店名称 | |
| 443 | + var storeIds = storeStats.Select(x => x.StoreId).ToList(); | |
| 444 | + var storeNames = await _db.Queryable<LqMdxxEntity>() | |
| 445 | + .Where(x => storeIds.Contains(x.Id)) | |
| 446 | + .Select(x => new { x.Id, x.Dm }) | |
| 447 | + .ToListAsync(); | |
| 448 | + | |
| 449 | + var totalStoreAmount = storeStats.Sum(x => (decimal)x.Amount); | |
| 450 | + consumptionAnalysis.StorePreferences = storeStats.Select(x => new StorePreference | |
| 451 | + { | |
| 452 | + StoreId = x.StoreId, | |
| 453 | + StoreName = storeNames.FirstOrDefault(s => s.Id == x.StoreId)?.Dm ?? "未知门店", | |
| 454 | + Amount = (decimal)x.Amount, | |
| 455 | + Count = x.Count, | |
| 456 | + Percentage = totalStoreAmount > 0 ? ((decimal)x.Amount / totalStoreAmount * 100) : 0m | |
| 457 | + }).OrderByDescending(x => x.Amount).ToList(); | |
| 458 | + | |
| 459 | + var overview = new MemberPortraitOverviewOutput | |
| 460 | + { | |
| 461 | + BaseInfo = baseInfo, // 会员类型和基础信息放在上面 | |
| 462 | + BehaviorSummary = behavior, // 消费行为概要 | |
| 463 | + MonthlyTrend = trendDict.Values.OrderBy(x => x.Month).ToList(), // 消费趋势 | |
| 464 | + ConsumptionAnalysis = consumptionAnalysis, // 消费分析 | |
| 465 | + Assets = assets // 权益资产放在最后 | |
| 466 | + }; | |
| 467 | + | |
| 468 | + return overview; | |
| 469 | + } | |
| 470 | + catch (Exception ex) | |
| 471 | + { | |
| 472 | + _logger.LogError(ex, $"获取会员画像概览失败, memberId={memberId}"); | |
| 473 | + throw NCCException.Oh($"获取会员画像概览失败: {ex.Message}"); | |
| 474 | + } | |
| 475 | + } | |
| 476 | + | |
| 477 | + /// <summary> | |
| 478 | + /// 获取会员开单列表 | |
| 479 | + /// </summary> | |
| 480 | + /// <param name="memberId">会员ID</param> | |
| 481 | + /// <param name="pageIndex">页码</param> | |
| 482 | + /// <param name="pageSize">每页数量</param> | |
| 483 | + /// <returns>开单列表</returns> | |
| 484 | + [HttpGet("billing-list")] | |
| 485 | + public async Task<dynamic> GetBillingList(string memberId, int pageIndex = 1, int pageSize = 10) | |
| 486 | + { | |
| 487 | + if (string.IsNullOrEmpty(memberId)) | |
| 488 | + { | |
| 489 | + throw NCCException.Oh("memberId 参数不能为空"); | |
| 490 | + } | |
| 491 | + | |
| 492 | + try | |
| 493 | + { | |
| 494 | + var query = _db.Queryable<LqKdKdjlbEntity>() | |
| 495 | + .Where(x => x.Kdhy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 496 | + .OrderBy(x => x.Kdrq, OrderByType.Desc) | |
| 497 | + .Select(x => new | |
| 498 | + { | |
| 499 | + Id = x.Id, | |
| 500 | + BillingDate = x.Kdrq, | |
| 501 | + StoreName = SqlFunc.Subqueryable<LqMdxxEntity>().Where(md => md.Id == x.Djmd).Select(md => md.Dm), | |
| 502 | + Amount = x.Sfyj, | |
| 503 | + DebtAmount = x.Qk, | |
| 504 | + ActivityName = SqlFunc.Subqueryable<LqPackageInfoEntity>().Where(pkg => pkg.Id == x.ActivityId).Select(pkg => pkg.ActivityName) | |
| 505 | + }); | |
| 506 | + | |
| 507 | + var total = await query.CountAsync(); | |
| 508 | + var result = await query.ToPageListAsync(pageIndex, pageSize); | |
| 509 | + | |
| 510 | + return new | |
| 511 | + { | |
| 512 | + Total = total, | |
| 513 | + List = result | |
| 514 | + }; | |
| 515 | + } | |
| 516 | + catch (Exception ex) | |
| 517 | + { | |
| 518 | + _logger.LogError(ex, $"获取会员开单列表失败, memberId={memberId}"); | |
| 519 | + throw NCCException.Oh($"获取会员开单列表失败: {ex.Message}"); | |
| 520 | + } | |
| 521 | + } | |
| 522 | + | |
| 523 | + /// <summary> | |
| 524 | + /// 获取会员消耗列表 | |
| 525 | + /// </summary> | |
| 526 | + /// <param name="memberId">会员ID</param> | |
| 527 | + /// <param name="pageIndex">页码</param> | |
| 528 | + /// <param name="pageSize">每页数量</param> | |
| 529 | + /// <returns>消耗列表</returns> | |
| 530 | + [HttpGet("consume-list")] | |
| 531 | + public async Task<dynamic> GetConsumeList(string memberId, int pageIndex = 1, int pageSize = 10) | |
| 532 | + { | |
| 533 | + if (string.IsNullOrEmpty(memberId)) | |
| 534 | + { | |
| 535 | + throw NCCException.Oh("memberId 参数不能为空"); | |
| 536 | + } | |
| 537 | + | |
| 538 | + try | |
| 539 | + { | |
| 540 | + var query = _db.Queryable<LqXhHyhkEntity>() | |
| 541 | + .Where(x => x.Hy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 542 | + .OrderBy(x => x.Hksj, OrderByType.Desc) | |
| 543 | + .Select(x => new | |
| 544 | + { | |
| 545 | + Id = x.Id, | |
| 546 | + ConsumeDate = x.Hksj, | |
| 547 | + StoreName = SqlFunc.Subqueryable<LqMdxxEntity>().Where(md => md.Id == x.Md).Select(md => md.Dm), | |
| 548 | + Amount = x.Xfje, | |
| 549 | + LaborCost = x.Sgfy | |
| 550 | + }); | |
| 551 | + | |
| 552 | + var total = await query.CountAsync(); | |
| 553 | + var result = await query.ToPageListAsync(pageIndex, pageSize); | |
| 554 | + | |
| 555 | + return new | |
| 556 | + { | |
| 557 | + Total = total, | |
| 558 | + List = result | |
| 559 | + }; | |
| 560 | + } | |
| 561 | + catch (Exception ex) | |
| 562 | + { | |
| 563 | + _logger.LogError(ex, $"获取会员消耗列表失败, memberId={memberId}"); | |
| 564 | + throw NCCException.Oh($"获取会员消耗列表失败: {ex.Message}"); | |
| 565 | + } | |
| 566 | + } | |
| 567 | + | |
| 568 | + /// <summary> | |
| 569 | + /// 获取会员退卡列表 | |
| 570 | + /// </summary> | |
| 571 | + /// <param name="memberId">会员ID</param> | |
| 572 | + /// <param name="pageIndex">页码</param> | |
| 573 | + /// <param name="pageSize">每页数量</param> | |
| 574 | + /// <returns>退卡列表</returns> | |
| 575 | + [HttpGet("refund-list")] | |
| 576 | + public async Task<dynamic> GetRefundList(string memberId, int pageIndex = 1, int pageSize = 10) | |
| 577 | + { | |
| 578 | + if (string.IsNullOrEmpty(memberId)) | |
| 579 | + { | |
| 580 | + throw NCCException.Oh("memberId 参数不能为空"); | |
| 581 | + } | |
| 582 | + | |
| 583 | + try | |
| 584 | + { | |
| 585 | + var query = _db.Queryable<LqHytkHytkEntity>() | |
| 586 | + .Where(x => x.Hy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) | |
| 587 | + .OrderBy(x => x.Tksj, OrderByType.Desc) | |
| 588 | + .Select(x => new | |
| 589 | + { | |
| 590 | + Id = x.Id, | |
| 591 | + RefundDate = x.Tksj, | |
| 592 | + StoreName = SqlFunc.Subqueryable<LqMdxxEntity>().Where(md => md.Id == x.Md).Select(md => md.Dm), | |
| 593 | + RefundAmount = x.Tkje, | |
| 594 | + ActualRefundAmount = x.ActualRefundAmount, | |
| 595 | + RefundReason = x.Tkyy | |
| 596 | + }); | |
| 597 | + | |
| 598 | + var total = await query.CountAsync(); | |
| 599 | + var result = await query.ToPageListAsync(pageIndex, pageSize); | |
| 600 | + | |
| 601 | + return new | |
| 602 | + { | |
| 603 | + Total = total, | |
| 604 | + List = result | |
| 605 | + }; | |
| 606 | + } | |
| 607 | + catch (Exception ex) | |
| 608 | + { | |
| 609 | + _logger.LogError(ex, $"获取会员退卡列表失败, memberId={memberId}"); | |
| 610 | + throw NCCException.Oh($"获取会员退卡列表失败: {ex.Message}"); | |
| 611 | + } | |
| 612 | + } | |
| 613 | + } | |
| 614 | +} | |
| 615 | + | |
| 616 | + | ... | ... |