Commit fee503452094725225b3c4b82207d0070b546993

Authored by “wangming”
1 parent 02926d33

feat: 实现KPI数据穿透功能

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