Commit 5e0c6e5af8843772662a4c93d628a4907a0b8bab

Authored by “wangming”
1 parent fee50345

feat: 完善会员画像功能,优化KPI数据穿透

- 新增会员画像服务,包含基础信息、消费行为、趋势分析等
- 优化KPI数据穿透功能,新增多个分析组件
- 修复消费行为统计数据查询问题
- 调整会员画像弹窗布局,基础信息独立显示
- 权益明细单独作为选项卡展示
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 &#39;@/utils/request&#39;
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 +
... ...