diff --git a/antis-ncc-admin/src/components/kpi-drill-dialog.vue b/antis-ncc-admin/src/components/kpi-drill-dialog.vue index 5b55bcf..b7fe45f 100644 --- a/antis-ncc-admin/src/components/kpi-drill-dialog.vue +++ b/antis-ncc-admin/src/components/kpi-drill-dialog.vue @@ -1,182 +1,28 @@ + + + diff --git a/antis-ncc-admin/src/components/kpi-drill/common-styles.scss b/antis-ncc-admin/src/components/kpi-drill/common-styles.scss new file mode 100644 index 0000000..c62913a --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/common-styles.scss @@ -0,0 +1,238 @@ +// KPI穿透组件公共样式 + +.billing-wrapper { + width: 100%; + overflow: hidden; + + .billing-layout { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 16px; + width: 100%; + } + + .billing-left { + flex: 0 0 75%; + max-width: 75%; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + } + + .billing-right { + flex: 0 0 25%; + max-width: 23.5%; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + } + + .chart-card { + background: #fff; + border: 1px solid #ebeef5; + border-radius: 10px; + padding: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + flex-shrink: 0; + + .chart-title { + font-size: 13px; + color: #606266; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; + + i { + color: #409EFF; + } + } + + .chart-mini { + height: 220px; + width: 100%; + } + } + + .table-card { + background: #fff; + border: 1px solid #ebeef5; + border-radius: 10px; + padding: 10px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + flex: 1; + min-height: 700px; + display: flex; + flex-direction: column; + + .table-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + flex-shrink: 0; + } + + .table-title { + font-size: 14px; + font-weight: 600; + color: #303133; + display: flex; + align-items: center; + gap: 6px; + + i { + color: #409EFF; + } + } + + .el-table { + flex: 1; + min-height: 0; + } + + .pagination-bar { + flex-shrink: 0; + margin-top: 8px; + } + } + + .stat-card { + background: #fff; + border: 1px solid #ebeef5; + border-radius: 10px; + padding: 10px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + flex-shrink: 0; + + &.compact { + display: flex; + align-items: center; + gap: 10px; + min-height: 70px; + } + + .stat-icon-circle { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + + i { + font-size: 16px; + } + } + + &.neon-green .stat-icon-circle { + background: #67C23A; + } + + &.neon-orange .stat-icon-circle { + background: #E6A23C; + } + + &.neon-red .stat-icon-circle { + background: #F56C6C; + } + + &.neon-blue .stat-icon-circle { + background: #409EFF; + } + + &.neon-purple .stat-icon-circle { + background: #9C27B0; + } + + &.neon-cyan .stat-icon-circle { + background: #17A2B8; + } + + .stat-content { + flex: 1; + min-width: 0; + } + + .stat-title { + font-size: 13px; + color: #606266; + margin-bottom: 4px; + line-height: 1.3; + } + + .stat-body { + color: #303133; + + .value-lg { + font-size: 18px; + font-weight: 700; + margin-top: 4px; + } + + .highlight { + font-size: 14px; + font-weight: 600; + line-height: 1.3; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + .value-inline { + font-size: 16px; + font-weight: 700; + color: #409EFF; + white-space: nowrap; + } + } + } + + &.neon-green { + border-color: #e1f3d8; + } + + &.neon-orange { + border-color: #fde3c9; + } + + &.neon-red { + border-color: #fde2e2; + } + + &.neon-cyan { + border-color: #d1ecf1; + } + } +} + +.pagination-bar { + display: flex; + justify-content: flex-end; + padding: 10px 0 4px 0; +} + +.list-filters { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 8px 0; + + &.inline { + margin: 0; + } +} + +.text-ellipsis-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + diff --git a/antis-ncc-admin/src/components/kpi-drill/consume-analysis.vue b/antis-ncc-admin/src/components/kpi-drill/consume-analysis.vue new file mode 100644 index 0000000..3a67b7a --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/consume-analysis.vue @@ -0,0 +1,332 @@ + + + + + + diff --git a/antis-ncc-admin/src/components/kpi-drill/mixins.js b/antis-ncc-admin/src/components/kpi-drill/mixins.js new file mode 100644 index 0000000..2d3a825 --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/mixins.js @@ -0,0 +1,34 @@ +import request from '@/utils/request' +import dayjs from 'dayjs' + +/** + * KPI穿透组件公共mixin + */ +export const kpiDrillMixin = { + methods: { + formatMoney(v) { + const num = Number(v || 0) + return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + }, + buildDateRange() { + const { startTime, endTime } = this.filters || {} + if (!startTime || !endTime) return null + const start = dayjs(startTime).format('YYYY-MM-DD') + const end = dayjs(endTime).format('YYYY-MM-DD') + const startTs = dayjs(`${start} 00:00:00`).valueOf() + const endTs = dayjs(`${end} 23:59:59`).valueOf() + return { start, end, startTs, endTs } + }, + getStoreId() { + return (this.filters && this.filters.storeIds && this.filters.storeIds.length === 1) + ? this.filters.storeIds[0] + : undefined + }, + getMonth() { + return this.filters && this.filters.month + ? this.filters.month.toString() + : (this.buildDateRange() ? dayjs(this.buildDateRange().start).format('YYYYMM') : dayjs().format('YYYYMM')) + } + } +} + diff --git a/antis-ncc-admin/src/components/kpi-drill/net-analysis.vue b/antis-ncc-admin/src/components/kpi-drill/net-analysis.vue new file mode 100644 index 0000000..7bd3cf1 --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/net-analysis.vue @@ -0,0 +1,319 @@ + + + + + + diff --git a/antis-ncc-admin/src/components/kpi-drill/refund-analysis.vue b/antis-ncc-admin/src/components/kpi-drill/refund-analysis.vue new file mode 100644 index 0000000..564532d --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/refund-analysis.vue @@ -0,0 +1,920 @@ + + + + + diff --git a/antis-ncc-admin/src/components/kpi-drill/target-analysis.vue b/antis-ncc-admin/src/components/kpi-drill/target-analysis.vue new file mode 100644 index 0000000..22f0053 --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/target-analysis.vue @@ -0,0 +1,853 @@ + + + + + diff --git a/antis-ncc-admin/src/components/kpi-drill/tk-analysis.vue b/antis-ncc-admin/src/components/kpi-drill/tk-analysis.vue new file mode 100644 index 0000000..89dc234 --- /dev/null +++ b/antis-ncc-admin/src/components/kpi-drill/tk-analysis.vue @@ -0,0 +1,792 @@ + + + + + diff --git a/antis-ncc-admin/src/components/member-portrait-dialog.vue b/antis-ncc-admin/src/components/member-portrait-dialog.vue new file mode 100644 index 0000000..86b2b63 --- /dev/null +++ b/antis-ncc-admin/src/components/member-portrait-dialog.vue @@ -0,0 +1,951 @@ + + + + + diff --git a/antis-ncc-admin/src/views/statisticsList/form9.vue b/antis-ncc-admin/src/views/statisticsList/form9.vue index 5321ec2..9308f5f 100644 --- a/antis-ncc-admin/src/views/statisticsList/form9.vue +++ b/antis-ncc-admin/src/views/statisticsList/form9.vue @@ -154,7 +154,8 @@ - + 最高剩余权益金额: {{ memberStatistics.topRemainingMemberName || '无' }} ¥{{ @@ -163,7 +164,8 @@ - + 本月开单最高: {{ memberStatistics.topBillingMemberName || '无' }} ¥{{ @@ -172,7 +174,8 @@ - + 本月消耗最高: {{ memberStatistics.topConsumeMemberName || '无' }} ¥{{ @@ -426,6 +429,9 @@ + + + @@ -479,10 +485,11 @@ import request from '@/utils/request' import * as echarts from 'echarts' import dayjs from 'dayjs' import KpiDrillDialog from '@/components/kpi-drill-dialog.vue' +import MemberPortraitDialog from '@/components/member-portrait-dialog.vue' export default { name: 'LeadershipCockpit', - components: { KpiDrillDialog }, + components: { KpiDrillDialog, MemberPortraitDialog }, data() { return { query: { @@ -498,6 +505,11 @@ export default { filters: {}, extra: {} }, + // 会员画像弹窗 + memberPortraitDialog: { + visible: false, + memberId: '' + }, currentDateParams: { startTime: null, endTime: null, @@ -532,10 +544,13 @@ export default { activeRate30: 0, totalRemainingAmount: 0, avgRemainingAmount: 0, + topRemainingMemberId: '', topRemainingMemberName: '', topRemainingAmount: 0, + topBillingMemberId: '', topBillingMemberName: '', topBillingAmount: 0, + topConsumeMemberId: '', topConsumeMemberName: '', topConsumeAmount: 0, totalSleepMembers: 0, @@ -652,16 +667,30 @@ export default { storeIds: this.query.storeIds || [], month: this.currentDateParams.month } + // 对于净额类型,actualAmount应该是开单总额,而不是净额 + const actualAmount = kpi.key === 'net' + ? (this.kpiData ? (this.kpiData.TotalBillingAmount || 0) : 0) + : (kpi.raw || 0) const extra = { - actualAmount: kpi.raw || 0, + actualAmount: actualAmount, targetAmount: kpi.targetRaw || 0, refundAmount: this.kpiData ? (this.kpiData.TotalRefundAmount || 0) : 0 } + // 根据类型设置专业的标题名称 + const titleMap = { + billing: '成交数据深度分析', + consume: '消耗数据深度分析', + net: '净业绩完成度分析', + target: '开单目标达成度分析', + tk: '拓客数据深度分析', + refund: '退卡数据深度分析' + } + this.drillDialog = { ...this.drillDialog, visible: true, type: kpi.key, - title: `${kpi.label}穿透`, + title: titleMap[kpi.key] || `${kpi.label}数据分析`, filters, extra } @@ -677,6 +706,17 @@ export default { // 自动触发查询 this.search() }, + // 打开会员画像弹窗 + openMemberPortrait(memberId) { + if (!memberId) { + this.$message.warning('会员ID不能为空') + return + } + this.memberPortraitDialog = { + visible: true, + memberId: memberId + } + }, // 将日期转换为月份格式 (YYYYMM) formatDateToMonth(dateStr) { @@ -1012,10 +1052,13 @@ export default { activeRate30, totalRemainingAmount: ms.TotalRemainingAmount || 0, avgRemainingAmount: ms.AvgRemainingAmount || 0, + topRemainingMemberId: ms.TopRemainingMemberId || '', topRemainingMemberName: ms.TopRemainingMemberName || '', topRemainingAmount: ms.TopRemainingAmount || 0, + topBillingMemberId: ms.TopBillingMemberId || '', topBillingMemberName: ms.TopBillingMemberName || '', topBillingAmount: ms.TopBillingAmount || 0, + topConsumeMemberId: ms.TopConsumeMemberId || '', topConsumeMemberName: ms.TopConsumeMemberName || '', topConsumeAmount: ms.TopConsumeAmount || 0, totalSleepMembers: ms.TotalSleepMembers || 0, diff --git a/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/ConsumeDrillStatisticsDto.cs b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/ConsumeDrillStatisticsDto.cs new file mode 100644 index 0000000..1da915b --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/ConsumeDrillStatisticsDto.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +namespace NCC.Extend.Entitys.Dto.LqReport +{ + /// + /// 本月消耗金额穿透统计请求参数 + /// + public class ConsumeDrillStatisticsInput + { + /// + /// 统计月份(格式:yyyyMM) + /// + public string StatisticsMonth { get; set; } + + /// + /// 门店ID列表 + /// + public List StoreIds { get; set; } = new List(); + } + + /// + /// 本月消耗金额穿透统计返回结果 + /// + public class ConsumeDrillStatisticsOutput + { + /// + /// 每日消耗趋势 + /// + public List DailyTrend { get; set; } = new List(); + + /// + /// 会员极值统计 + /// + public ConsumeMemberStatsOutput MemberStats { get; set; } = new ConsumeMemberStatsOutput(); + + /// + /// 科技老师手工费合计 + /// + public decimal TechTeacherLaborCostTotal { get; set; } + + /// + /// 健康师手工费合计 + /// + public decimal HealthCoachLaborCostTotal { get; set; } + + /// + /// 单次消耗最大金额 + /// + public decimal MaxSingleConsumeAmount { get; set; } + } + + /// + /// 每日消耗趋势 + /// + public class ConsumeDailyTrendOutput + { + /// + /// 日期(yyyy-MM-dd) + /// + public string Date { get; set; } + + /// + /// 金额 + /// + public decimal Amount { get; set; } + + /// + /// 会员人数 + /// + public int MemberCount { get; set; } + } + + /// + /// 会员极值统计 + /// + public class ConsumeMemberStatsOutput + { + /// + /// 消耗金额最高会员姓名 + /// + public string TopAmountMemberName { get; set; } + + /// + /// 消耗金额最高会员的金额 + /// + public decimal TopAmountValue { get; set; } + + /// + /// 消耗次数最多会员姓名 + /// + public string TopTimesMemberName { get; set; } + + /// + /// 消耗次数最多会员的次数 + /// + public int TopTimesCount { get; set; } + } +} + diff --git a/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/RefundDrillStatisticsInput.cs b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/RefundDrillStatisticsInput.cs new file mode 100644 index 0000000..148feb4 --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/RefundDrillStatisticsInput.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace NCC.Extend.Entitys.Dto.LqReport +{ + /// + /// 退卡穿透统计输入参数 + /// + public class RefundDrillStatisticsInput + { + /// + /// 开始时间 + /// + public DateTime? StartTime { get; set; } + + /// + /// 结束时间 + /// + public DateTime? EndTime { get; set; } + + /// + /// 门店ID列表 + /// + public List StoreIds { get; set; } = new List(); + } +} + diff --git a/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/TkDrillStatisticsInput.cs b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/TkDrillStatisticsInput.cs new file mode 100644 index 0000000..a50ceed --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqReport/TkDrillStatisticsInput.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace NCC.Extend.Entitys.Dto.LqReport +{ + /// + /// 拓客穿透统计输入参数 + /// + public class TkDrillStatisticsInput + { + /// + /// 开始时间 + /// + public DateTime? StartTime { get; set; } + + /// + /// 结束时间 + /// + public DateTime? EndTime { get; set; } + + /// + /// 门店ID列表 + /// + public List StoreIds { get; set; } = new List(); + + /// + /// 活动ID(可选,如果提供则只统计该活动的数据) + /// + public string EventId { get; set; } + + /// + /// 选中的日期(用于获取24小时走势,格式:yyyy-MM-dd) + /// + public string SelectedDate { get; set; } + } +} + diff --git a/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/MemberPortrait/MemberPortraitDtos.cs b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/MemberPortrait/MemberPortraitDtos.cs new file mode 100644 index 0000000..f34cbf5 --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/MemberPortrait/MemberPortraitDtos.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; + +namespace NCC.Extend.Entitys.Dto.MemberPortrait +{ + /// + /// 会员画像概览输出 + /// + public class MemberPortraitOverviewOutput + { + /// + /// 基础档案信息(会员类型和基础信息) + /// + public MemberBaseInfo BaseInfo { get; set; } + + /// + /// 消费行为概要 + /// + public MemberBehaviorSummary BehaviorSummary { get; set; } + + /// + /// 消费趋势(近12个月) + /// + public List MonthlyTrend { get; set; } = new List(); + + /// + /// 消费分析 + /// + public ConsumptionAnalysis ConsumptionAnalysis { get; set; } + + /// + /// 权益资产 + /// + public MemberAssets Assets { get; set; } + } + + /// + /// 会员权益资产 + /// + public class MemberAssets + { + /// + /// 剩余权益明细 + /// + public List RemainingItems { get; set; } = new List(); + } + + /// + /// 剩余权益明细项 + /// + public class RemainingItemDto + { + public string ItemId { get; set; } + public string ItemName { get; set; } + public decimal UnitPrice { get; set; } + public string SourceType { get; set; } + public int TotalQuantity { get; set; } + public int ConsumedQuantity { get; set; } + public int RefundedQuantity { get; set; } + public int DeductedQuantity { get; set; } + public int RemainingQuantity { get; set; } + public decimal RemainingValue { get; set; } + } + + /// + /// 会员基础信息简版 + /// + public class MemberBaseInfo + { + public string MemberId { get; set; } + + public string MemberCode { get; set; } + + public string MemberName { get; set; } + + public string Mobile { get; set; } + + public string StoreId { get; set; } + + public string StoreName { get; set; } + + public string Channel { get; set; } + + public DateTime? FirstVisitTime { get; set; } + + public DateTime? LastVisitTime { get; set; } + + public int SleepDays { get; set; } + + public DateTime? SleepStartTime { get; set; } + + public int ConsumeLevel { get; set; } + + public DateTime? ConsumeLevelUpdateTime { get; set; } + + /// + /// 会员类型列表(生美、医美、科技部、教育部) + /// + public List MemberTypes { get; set; } = new List(); + } + + /// + /// 会员类型信息 + /// + public class MemberTypeInfo + { + /// + /// 会员类型名称(生美、医美、科技部、教育部) + /// + public string TypeName { get; set; } + + /// + /// 成为会员时间 + /// + public DateTime? BecomeTime { get; set; } + } + + /// + /// 会员行为概要 + /// + public class MemberBehaviorSummary + { + /// + /// 累计开单金额 + /// + public decimal TotalBillingAmount { get; set; } + + /// + /// 累计消耗金额 + /// + public decimal TotalConsumeAmount { get; set; } + + /// + /// 累计退卡金额 + /// + public decimal TotalRefundAmount { get; set; } + + /// + /// 剩余权益总金额 + /// + public decimal RemainingRightsAmount { get; set; } + + /// + /// 最近一次开单时间 + /// + public DateTime? LastBillingTime { get; set; } + + /// + /// 最近一次消耗时间 + /// + public DateTime? LastConsumeTime { get; set; } + + /// + /// 开单次数 + /// + public int BillingCount { get; set; } + + /// + /// 消耗次数 + /// + public int ConsumeCount { get; set; } + + /// + /// 退卡次数 + /// + public int RefundCount { get; set; } + + /// + /// 平均开单金额 + /// + public decimal AvgBillingAmount { get; set; } + + /// + /// 平均消耗金额 + /// + public decimal AvgConsumeAmount { get; set; } + + /// + /// 首次开单时间 + /// + public DateTime? FirstBillingTime { get; set; } + + /// + /// 首次消耗时间 + /// + public DateTime? FirstConsumeTime { get; set; } + } + + /// + /// 消费分析数据 + /// + public class ConsumptionAnalysis + { + /// + /// 消费频率(次/月) + /// + public decimal ConsumeFrequency { get; set; } + + /// + /// 开单频率(次/月) + /// + public decimal BillingFrequency { get; set; } + + /// + /// 消费活跃度(最近3个月是否有消费) + /// + public bool IsActive { get; set; } + + /// + /// 消费偏好(品项类型分布) + /// + public List ItemTypePreferences { get; set; } = new List(); + + /// + /// 门店偏好(消费门店分布) + /// + public List StorePreferences { get; set; } = new List(); + } + + /// + /// 品项类型偏好 + /// + public class ItemTypePreference + { + /// + /// 品项类型 + /// + public string ItemType { get; set; } + + /// + /// 消费金额 + /// + public decimal Amount { get; set; } + + /// + /// 消费次数 + /// + public int Count { get; set; } + + /// + /// 占比(百分比) + /// + public decimal Percentage { get; set; } + } + + /// + /// 门店偏好 + /// + public class StorePreference + { + /// + /// 门店ID + /// + public string StoreId { get; set; } + + /// + /// 门店名称 + /// + public string StoreName { get; set; } + + /// + /// 消费金额 + /// + public decimal Amount { get; set; } + + /// + /// 消费次数 + /// + public int Count { get; set; } + + /// + /// 占比(百分比) + /// + public decimal Percentage { get; set; } + } + + /// + /// 会员月度趋势点 + /// + public class MemberMonthlyTrendPoint + { + /// + /// 月份(yyyy-MM) + /// + public string Month { get; set; } + + /// + /// 当月消费金额 + /// + public decimal ConsumeAmount { get; set; } + + /// + /// 当月开单金额 + /// + public decimal BillingAmount { get; set; } + + /// + /// 当月退卡金额 + /// + public decimal RefundAmount { get; set; } + } +} + + diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs index b2b0bfd..6ebe09a 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqHytkHytkService.cs @@ -167,6 +167,7 @@ namespace NCC.Extend.LqHytkHytk .WhereIF(endTksj.HasValue, p => p.Tksj <= new DateTime(endTksj.Value.Year, endTksj.Value.Month, endTksj.Value.Day, 23, 59, 59)) .WhereIF(!string.IsNullOrEmpty(input.czry), p => p.Czry.Equals(input.czry)) .WhereIF(input.isEffective != 0, p => p.IsEffective == input.isEffective) + .WhereIF(input.isEffective == 0, p => p.IsEffective == StatusEnum.有效.GetHashCode()) // 如果未指定,默认只返回有效的 .Select(it => new LqHytkHytkListOutput { id = it.Id, diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs index 1ce0574..b081f30 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqReportService.cs @@ -25,9 +25,19 @@ using NCC.Extend.Entitys.lq_jinsanjiao_user; using NCC.Extend.Entitys.lq_kd_kdjlb; using NCC.Extend.Entitys.lq_kd_pxmx; using NCC.Extend.Entitys.lq_xh_hyhk; +using NCC.Extend.Entitys.lq_xh_pxmx; +using NCC.Extend.Entitys.lq_xh_kjbsyj; +using NCC.Extend.Entitys.lq_xh_jksyj; using NCC.Extend.Entitys.lq_khxx; using NCC.Extend.Entitys.Dto.LqReport; using NCC.Extend.Entitys.Enum; +using NCC.Extend.Entitys.lq_tkjlb; +using NCC.Extend.Entitys.lq_yaoyjl; +using NCC.Extend.Entitys.lq_yyjl; +using NCC.Extend.Entitys.lq_event; +using NCC.Extend.Entitys.lq_hytk_hytk; +using NCC.Extend.Entitys.lq_xh_hyhk; +using NCC.System.Entitys.Permission; using SqlSugar; namespace NCC.Extend @@ -388,18 +398,22 @@ namespace NCC.Extend try { // 先尝试从统计表查询 + // 使用F_ActualPerformance作为净业绩(如果为0或NULL,则使用F_TotalOrderPerformance - F_RefundAmount计算) var sql = @" SELECT s.F_StoreId, s.F_StoreName, - s.F_TotalPerformance, + CASE + WHEN COALESCE(s.F_ActualPerformance, 0) != 0 THEN s.F_ActualPerformance + ELSE COALESCE(s.F_TotalOrderPerformance, 0) - COALESCE(s.F_RefundAmount, 0) + END as F_TotalPerformance, s.F_TotalOrderPerformance, s.F_FirstOrderCount, s.F_UpgradeOrderCount, s.F_ItemQuantity FROM lq_statistics_store_total_performance s WHERE s.F_StatisticsMonth = @statisticsMonth - ORDER BY s.F_TotalPerformance DESC"; + ORDER BY F_TotalPerformance DESC"; if (input.TopCount > 0) { @@ -415,21 +429,32 @@ namespace NCC.Extend var startDate = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1); var endDate = DateTime.Now; + // 计算净业绩(开单业绩 - 退卡业绩) var realTimeSql = $@" SELECT kd.djmd as F_StoreId, mdxx.dm as F_StoreName, - COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as F_TotalPerformance, + COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) - COALESCE(refund.F_RefundAmount, 0) as F_TotalPerformance, COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as F_TotalOrderPerformance, 0 as F_FirstOrderCount, 0 as F_UpgradeOrderCount, 0 as F_ItemQuantity FROM lq_kd_kdjlb kd LEFT JOIN lq_mdxx mdxx ON kd.djmd = mdxx.F_Id + LEFT JOIN ( + SELECT + md as F_StoreId, + COALESCE(SUM(CAST(COALESCE(F_ActualRefundAmount, tkje, 0) AS DECIMAL(18,2))), 0) as F_RefundAmount + FROM lq_hytk_hytk + WHERE F_IsEffective = 1 + AND tksj >= '{startDate:yyyy-MM-dd 00:00:00}' + AND tksj <= '{endDate:yyyy-MM-dd HH:mm:ss}' + GROUP BY md + ) refund ON kd.djmd = refund.F_StoreId WHERE kd.F_IsEffective = 1 AND kd.kdrq >= '{startDate:yyyy-MM-dd 00:00:00}' AND kd.kdrq <= '{endDate:yyyy-MM-dd HH:mm:ss}' - GROUP BY kd.djmd, mdxx.dm + GROUP BY kd.djmd, mdxx.dm, refund.F_RefundAmount ORDER BY F_TotalPerformance DESC"; if (input.TopCount > 0) @@ -728,6 +753,191 @@ namespace NCC.Extend throw NCCException.Oh($"获取本月成交总额穿透统计失败: {ex.Message}"); } } + + /// + /// 获取本月消耗金额穿透统计 + /// + /// + /// 获取指定月份的消耗金额穿透统计数据,包括: + /// - DailyTrend: 每日消耗趋势(金额和人数) + /// - MemberStats: 会员极值统计(消耗金额最高会员、消耗次数最多会员) + /// - TechTeacherLaborCostTotal: 本月科技老师手工费合计(科美类型) + /// - HealthCoachLaborCostTotal: 健康师手工费合计 + /// - MaxSingleConsumeAmount: 单次消耗最大金额 + /// + /// 示例请求: + /// ```json + /// POST /api/Extend/LqReport/get-consume-drill-statistics + /// { + /// "statisticsMonth": "202512", + /// "storeIds": ["门店ID1", "门店ID2"] + /// } + /// ``` + /// + /// 查询参数 + /// 本月消耗金额穿透统计结果 + /// 成功返回统计数据 + /// 参数错误 + /// 服务器错误 + [HttpPost("get-consume-drill-statistics")] + public async Task GetConsumeDrillStatistics([FromBody] ConsumeDrillStatisticsInput input) + { + try + { + if (input == null || string.IsNullOrWhiteSpace(input.StatisticsMonth)) + { + throw NCCException.Oh("统计月份不能为空,格式为yyyyMM"); + } + + if (!DateTime.TryParseExact(input.StatisticsMonth + "01", "yyyyMMdd", null, + global::System.Globalization.DateTimeStyles.None, out var monthStart)) + { + throw NCCException.Oh($"统计月份格式错误:{input.StatisticsMonth},应为yyyyMM"); + } + + var startTime = monthStart; + var endTime = monthStart.AddMonths(1).AddSeconds(-1); + + // 1. 基础查询:耗卡品项明细 + 耗卡主表(用于获取门店和会员等) + var storeIds = input.StoreIds ?? new List(); + + var baseQuery = _db.Queryable((px, hyhk) => new JoinQueryInfos( + JoinType.Inner, px.ConsumeInfoId == hyhk.Id)) + .Where((px, hyhk) => px.IsEffective == StatusEnum.有效.GetHashCode() + && hyhk.IsEffective == StatusEnum.有效.GetHashCode()) + .Where((px, hyhk) => px.Yjsj >= startTime && px.Yjsj <= endTime) + .WhereIF(storeIds.Any(), (px, hyhk) => storeIds.Contains(hyhk.Md)); + + // 为后续多次统计复用,先拉到内存 + var rawList = await baseQuery.Select((px, hyhk) => new + { + ConsumeId = hyhk.Id, + ConsumeDate = hyhk.Hksj, + StoreId = hyhk.Md, + MemberId = px.MemberId, + Amount = px.TotalPrice, + Yjsj = px.Yjsj + }).ToListAsync(); + + // 保护:无数据时直接返回空结构 + if (!rawList.Any()) + { + return new ConsumeDrillStatisticsOutput + { + DailyTrend = new List(), + MemberStats = new ConsumeMemberStatsOutput(), + TechTeacherLaborCostTotal = 0, + HealthCoachLaborCostTotal = 0, + MaxSingleConsumeAmount = 0 + }; + } + + // 2. 每日趋势:按耗卡日期聚合金额和会员数 + var dailyTrend = rawList + .GroupBy(x => x.ConsumeDate.HasValue ? x.ConsumeDate.Value.ToString("yyyy-MM-dd") : "") + .Where(g => !string.IsNullOrEmpty(g.Key)) + .Select(g => new ConsumeDailyTrendOutput + { + Date = g.Key, + Amount = g.Sum(x => x.Amount), + MemberCount = g.Select(x => x.MemberId).Where(m => !string.IsNullOrEmpty(m)).Distinct().Count() + }) + .OrderBy(x => x.Date) + .ToList(); + + // 3. 会员极值:按会员聚合金额和次数 + var memberAgg = rawList + .Where(x => !string.IsNullOrEmpty(x.MemberId)) + .GroupBy(x => x.MemberId) + .Select(g => new + { + MemberId = g.Key, + Amount = g.Sum(x => x.Amount), + Times = g.Count() + }) + .ToList(); + + var topAmount = memberAgg + .OrderByDescending(x => x.Amount) + .FirstOrDefault(); + + var topTimes = memberAgg + .OrderByDescending(x => x.Times) + .FirstOrDefault(); + + // 查询会员姓名 + var memberIds = new List(); + if (topAmount != null && !string.IsNullOrEmpty(topAmount.MemberId)) + { + memberIds.Add(topAmount.MemberId); + } + if (topTimes != null && !string.IsNullOrEmpty(topTimes.MemberId) && !memberIds.Contains(topTimes.MemberId)) + { + memberIds.Add(topTimes.MemberId); + } + + var memberNameDict = new Dictionary(); + if (memberIds.Any()) + { + var members = await _db.Queryable() + .Where(x => memberIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Khmc }) + .ToListAsync(); + memberNameDict = members.ToDictionary(x => x.Id, x => x.Khmc ?? ""); + } + + var memberStats = new ConsumeMemberStatsOutput + { + TopAmountMemberName = topAmount != null && memberNameDict.ContainsKey(topAmount.MemberId) ? memberNameDict[topAmount.MemberId] : "", + TopAmountValue = topAmount?.Amount ?? 0m, + TopTimesMemberName = topTimes != null && memberNameDict.ContainsKey(topTimes.MemberId) ? memberNameDict[topTimes.MemberId] : "", + TopTimesCount = topTimes?.Times ?? 0 + }; + + // 4. 科技老师手工费合计(科美类型) + var techTeacherLaborCost = await _db.Queryable((kjb, hyhk) => new JoinQueryInfos( + JoinType.Inner, kjb.Glkdbh == hyhk.Id)) + .Where((kjb, hyhk) => kjb.IsEffective == StatusEnum.有效.GetHashCode() + && hyhk.IsEffective == StatusEnum.有效.GetHashCode() + && kjb.ItemCategory == "科美" + && kjb.Yjsj >= startTime && kjb.Yjsj <= endTime) + .WhereIF(storeIds.Any(), (kjb, hyhk) => storeIds.Contains(hyhk.Md)) + .SumAsync((kjb, hyhk) => kjb.LaborCost ?? 0); + + // 5. 健康师手工费合计 + var healthCoachLaborCost = await _db.Queryable((jks, hyhk) => new JoinQueryInfos( + JoinType.Inner, jks.Glkdbh == hyhk.Id)) + .Where((jks, hyhk) => jks.IsEffective == StatusEnum.有效.GetHashCode() + && hyhk.IsEffective == StatusEnum.有效.GetHashCode() + && jks.Yjsj >= startTime && jks.Yjsj <= endTime) + .WhereIF(storeIds.Any(), (jks, hyhk) => storeIds.Contains(hyhk.Md)) + .SumAsync((jks, hyhk) => jks.LaborCost ?? 0); + + // 6. 单次消耗最大金额(按耗卡记录聚合,取最大单次消耗金额) + var consumeAmounts = rawList + .GroupBy(x => x.ConsumeId) + .Select(g => g.Sum(x => x.Amount)) + .ToList(); + + var maxSingleConsumeAmount = consumeAmounts.Any() ? consumeAmounts.Max() : 0m; + + var result = new ConsumeDrillStatisticsOutput + { + DailyTrend = dailyTrend, + MemberStats = memberStats, + TechTeacherLaborCostTotal = techTeacherLaborCost, + HealthCoachLaborCostTotal = healthCoachLaborCost, + MaxSingleConsumeAmount = maxSingleConsumeAmount + }; + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取本月消耗金额穿透统计失败 - 统计月份: {input?.StatisticsMonth}"); + throw NCCException.Oh($"获取本月消耗金额穿透统计失败: {ex.Message}"); + } + } #endregion #region 人员业绩报表 @@ -1206,6 +1416,7 @@ namespace NCC.Extend // 7. 最高剩余权益会员、本月开单金额最高会员、本月消耗金额最高会员 var topRemainingSql = @" SELECT + kh.F_Id as MemberId, kh.Khmc as MemberName, COALESCE(kh.F_RemainingRightsAmount, 0) as Amount FROM lq_khxx kh @@ -1217,6 +1428,7 @@ namespace NCC.Extend var topBillingSql = @" SELECT + kh.F_Id as MemberId, kh.Khmc as MemberName, COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as Amount FROM lq_kd_kdjlb kd @@ -1231,6 +1443,7 @@ namespace NCC.Extend var topConsumeSql = @" SELECT + kh.F_Id as MemberId, kh.Khmc as MemberName, COALESCE(SUM(CAST(xh.xfje AS DECIMAL(18,2))), 0) as Amount FROM lq_xh_hyhk xh @@ -1306,10 +1519,13 @@ namespace NCC.Extend TotalSleepMembers = sleep60_89 + sleep90_179 + sleep180_359 + sleep360Plus, TotalRemainingAmount = totalRemainingAmount, AvgRemainingAmount = avgRemainingAmount, + TopRemainingMemberId = topRemaining?.MemberId?.ToString() ?? string.Empty, TopRemainingMemberName = topRemaining?.MemberName ?? string.Empty, TopRemainingAmount = Convert.ToDecimal(topRemaining?.Amount ?? 0m), + TopBillingMemberId = topBilling?.MemberId?.ToString() ?? string.Empty, TopBillingMemberName = topBilling?.MemberName ?? string.Empty, TopBillingAmount = Convert.ToDecimal(topBilling?.Amount ?? 0m), + TopConsumeMemberId = topConsume?.MemberId?.ToString() ?? string.Empty, TopConsumeMemberName = topConsume?.MemberName ?? string.Empty, TopConsumeAmount = Convert.ToDecimal(topConsume?.Amount ?? 0m), BeautyMembers = Convert.ToInt32(memberStats?.BeautyMembers ?? 0), @@ -3569,5 +3785,504 @@ namespace NCC.Extend } #endregion + + #region 拓客穿透统计 + + /// + /// 拓客穿透统计 + /// + /// + /// 用于本月拓客人数穿透分析,提供门店排名、人员排名、活动筛选、每日走势等统计数据 + /// + /// 示例请求: + /// ```json + /// { + /// "startTime": "2025-12-01 00:00:00", + /// "endTime": "2025-12-31 23:59:59", + /// "storeIds": ["门店ID1", "门店ID2"], + /// "eventId": "活动ID(可选)" + /// } + /// ``` + /// + /// 参数说明: + /// - startTime: 开始时间 + /// - endTime: 结束时间 + /// - storeIds: 门店ID列表(可选) + /// - eventId: 活动ID(可选,如果提供则只统计该活动的数据) + /// + /// 查询参数 + /// 拓客穿透统计数据 + /// 成功返回统计数据 + /// 参数错误 + /// 服务器内部错误 + [HttpPost("get-tk-drill-statistics")] + public async Task GetTkDrillStatistics([FromBody] TkDrillStatisticsInput input) + { + try + { + if (input == null) + { + throw NCCException.Oh("参数不能为空"); + } + + var startTime = input.StartTime ?? DateTime.Now.Date.AddDays(1 - DateTime.Now.Day); + var endTime = input.EndTime ?? DateTime.Now; + + // 基础查询条件 + var baseQuery = _db.Queryable() + .Where(x => x.ExpansionTime >= startTime && x.ExpansionTime <= endTime); + + // 活动筛选 + if (!string.IsNullOrEmpty(input.EventId)) + { + baseQuery = baseQuery.Where(x => x.EventId == input.EventId); + } + + // 门店筛选 + if (input.StoreIds != null && input.StoreIds.Any()) + { + baseQuery = baseQuery.Where(x => input.StoreIds.Contains(x.StoreId)); + } + + // 如果指定了活动,需要过滤出参与该活动的门店 + if (!string.IsNullOrEmpty(input.EventId)) + { + // 获取参与该活动的门店ID列表 + var eventStoreIds = await _db.Queryable() + .Where(x => x.EventId == input.EventId) + .Select(x => x.StoreId) + .Distinct() + .ToListAsync(); + + if (input.StoreIds != null && input.StoreIds.Any()) + { + // 取交集:筛选条件中的门店且参与活动的门店 + var validStoreIds = input.StoreIds.Intersect(eventStoreIds).ToList(); + if (!validStoreIds.Any()) + { + // 如果没有符合条件的门店,返回空数据 + return new + { + Success = true, + Data = new + { + StoreRanking = new List(), + PersonRanking = new List(), + EventList = new List(), + DailyTrend = new List(), + HourlyTrend = new List() + }, + Message = "没有符合条件的门店数据" + }; + } + baseQuery = baseQuery.Where(x => validStoreIds.Contains(x.StoreId)); + } + else + { + baseQuery = baseQuery.Where(x => eventStoreIds.Contains(x.StoreId)); + } + } + + // 1. 门店拓客人数排名 + var storeRanking = await baseQuery + .GroupBy(x => x.StoreId) + .Select(x => new + { + StoreId = x.StoreId, + StoreName = SqlFunc.Subqueryable().Where(m => m.Id == x.StoreId).Select(m => m.Dm), + TkCount = SqlFunc.AggregateCount(x.Id) + }) + .OrderBy(x => x.TkCount, OrderByType.Desc) + .ToListAsync(); + + // 2. 拓客人员拓客人数排名前五 + var personRanking = await baseQuery + .GroupBy(x => x.ExpansionUserId) + .Select(x => new + { + UserId = x.ExpansionUserId, + UserName = SqlFunc.Subqueryable().Where(u => u.Id == x.ExpansionUserId).Select(u => u.RealName), + TkCount = SqlFunc.AggregateCount(x.Id) + }) + .OrderBy(x => x.TkCount, OrderByType.Desc) + .Take(5) + .ToListAsync(); + + // 3. 活动列表(用于筛选) + var eventList = await _db.Queryable() + .Where(x => x.ExpansionTime >= startTime && x.ExpansionTime <= endTime) + .WhereIF(input.StoreIds != null && input.StoreIds.Any(), x => input.StoreIds.Contains(x.StoreId)) + .Where(x => !string.IsNullOrEmpty(x.EventId)) + .GroupBy(x => x.EventId) + .Select(x => new + { + EventId = x.EventId, + EventName = SqlFunc.Subqueryable().Where(e => e.Id == x.EventId).Select(e => e.EventName), + TkCount = SqlFunc.AggregateCount(x.Id) + }) + .OrderBy(x => x.TkCount, OrderByType.Desc) + .ToListAsync(); + + // 4. 每日拓客数量走势(本月)- 使用原生SQL确保正确分组 + var dailyTrendSql = $@" + SELECT + DATE_FORMAT(tk.F_ExpansionTime, '%Y-%m-%d') as DateStr, + COUNT(tk.F_Id) as TkCount + FROM lq_tkjlb tk + WHERE tk.F_ExpansionTime >= '{startTime:yyyy-MM-dd 00:00:00}' + AND tk.F_ExpansionTime <= '{endTime:yyyy-MM-dd HH:mm:ss}'"; + + if (!string.IsNullOrEmpty(input.EventId)) + { + dailyTrendSql += $" AND tk.F_EventId = '{input.EventId}'"; + } + + if (input.StoreIds != null && input.StoreIds.Any()) + { + var storeIdsStr = string.Join("','", input.StoreIds); + dailyTrendSql += $" AND tk.F_StoreId IN ('{storeIdsStr}')"; + } + + dailyTrendSql += @" + GROUP BY DATE_FORMAT(tk.F_ExpansionTime, '%Y-%m-%d') + ORDER BY DateStr"; + + var dailyTrend = await _db.Ado.SqlQueryAsync(dailyTrendSql); + + // 5. 如果指定了日期,获取该日期的24小时走势 + List hourlyTrend = new List(); + if (!string.IsNullOrEmpty(input.SelectedDate)) + { + if (DateTime.TryParse(input.SelectedDate, out var selectedDate)) + { + var dayStart = selectedDate.Date; + var dayEnd = dayStart.AddDays(1).AddSeconds(-1); + + var hourlyQuery = _db.Queryable() + .Where(x => x.ExpansionTime >= dayStart && x.ExpansionTime <= dayEnd); + + if (!string.IsNullOrEmpty(input.EventId)) + { + hourlyQuery = hourlyQuery.Where(x => x.EventId == input.EventId); + } + + if (input.StoreIds != null && input.StoreIds.Any()) + { + hourlyQuery = hourlyQuery.Where(x => input.StoreIds.Contains(x.StoreId)); + } + + // 使用原生SQL获取24小时走势 + var hourlySql = $@" + SELECT + HOUR(tk.F_ExpansionTime) as Hour, + CONCAT(LPAD(HOUR(tk.F_ExpansionTime), 2, '0'), ':00') as HourStr, + COUNT(tk.F_Id) as TkCount + FROM lq_tkjlb tk + WHERE tk.F_ExpansionTime >= '{dayStart:yyyy-MM-dd 00:00:00}' + AND tk.F_ExpansionTime <= '{dayEnd:yyyy-MM-dd HH:mm:ss}'"; + + if (!string.IsNullOrEmpty(input.EventId)) + { + hourlySql += $" AND tk.F_EventId = '{input.EventId}'"; + } + + if (input.StoreIds != null && input.StoreIds.Any()) + { + var storeIdsStr = string.Join("','", input.StoreIds); + hourlySql += $" AND tk.F_StoreId IN ('{storeIdsStr}')"; + } + + hourlySql += @" + GROUP BY HOUR(tk.F_ExpansionTime) + ORDER BY Hour"; + + var hourlyData = await _db.Ado.SqlQueryAsync(hourlySql); + hourlyTrend = hourlyData.Cast().ToList(); + } + } + + return new + { + Success = true, + Data = new + { + StoreRanking = storeRanking, + PersonRanking = personRanking, + EventList = eventList, + DailyTrend = dailyTrend, + HourlyTrend = hourlyTrend + }, + Message = "拓客穿透统计数据获取成功" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取拓客穿透统计数据失败 - 开始时间: {input?.StartTime}, 结束时间: {input?.EndTime}"); + throw NCCException.Oh($"获取拓客穿透统计数据失败: {ex.Message}"); + } + } + + #endregion + + #region 退卡穿透统计 + + /// + /// 退卡穿透统计 + /// + /// + /// 用于退卡总计穿透分析,提供门店分布、总计数据、最大金额/次数人员等统计数据 + /// + /// 示例请求: + /// ```json + /// { + /// "startTime": "2025-12-01 00:00:00", + /// "endTime": "2025-12-31 23:59:59", + /// "storeIds": ["门店ID1", "门店ID2"] + /// } + /// ``` + /// + /// 参数说明: + /// - startTime: 开始时间 + /// - endTime: 结束时间 + /// - storeIds: 门店ID列表(可选) + /// + /// 查询参数 + /// 退卡穿透统计数据 + /// 成功返回统计数据 + /// 参数错误 + /// 服务器内部错误 + [HttpPost("get-refund-drill-statistics")] + public async Task GetRefundDrillStatistics([FromBody] RefundDrillStatisticsInput input) + { + try + { + if (input == null) + { + throw NCCException.Oh("参数不能为空"); + } + + var startTime = input.StartTime ?? DateTime.Now.Date.AddDays(1 - DateTime.Now.Day); + var endTime = input.EndTime ?? DateTime.Now; + + // 确保结束时间包含当天的23:59:59 + if (endTime.Hour == 0 && endTime.Minute == 0 && endTime.Second == 0) + { + endTime = endTime.Date.AddDays(1).AddSeconds(-1); + } + + _logger.LogInformation($"退卡穿透统计 - 开始时间: {startTime:yyyy-MM-dd HH:mm:ss}, 结束时间: {endTime:yyyy-MM-dd HH:mm:ss}"); + + // 构建门店筛选条件 + var storeFilter = ""; + if (input.StoreIds != null && input.StoreIds.Any()) + { + var storeIdsStr = string.Join("','", input.StoreIds); + storeFilter = $" AND hytk.md IN ('{storeIdsStr}')"; + } + + // 1. 各门店退卡金额分布(不包含转卡)- 使用原生SQL确保正确 + var storeDistributionSql = $@" + SELECT + hytk.md as StoreId, + COALESCE(SUM(CAST(hytk.tkje AS DECIMAL(18,2))), 0) as RefundAmount, + COUNT(hytk.F_Id) as RefundCount + FROM lq_hytk_hytk hytk + WHERE hytk.F_IsEffective = 1 + AND hytk.tksj IS NOT NULL + AND hytk.tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND hytk.tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + AND (hytk.tkyy IS NULL OR hytk.tkyy = '' OR hytk.tkyy != '转卡') + {storeFilter} + GROUP BY hytk.md + ORDER BY RefundAmount DESC"; + + var storeDistributionRaw = await _db.Ado.SqlQueryAsync(storeDistributionSql); + + // 批量获取门店名称 + var storeIds = storeDistributionRaw.Select(x => x.StoreId?.ToString()).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList(); + var storeNameMap = new Dictionary(); + if (storeIds.Any()) + { + var stores = await _db.Queryable() + .Where(x => storeIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Dm }) + .ToListAsync(); + storeNameMap = stores.ToDictionary(x => x.Id, x => x.Dm ?? ""); + } + + // 构建最终结果,包含门店名称 + var storeDistribution = storeDistributionRaw.Select(x => new + { + StoreId = x.StoreId?.ToString(), + StoreName = storeNameMap.ContainsKey(x.StoreId?.ToString() ?? "") ? storeNameMap[x.StoreId.ToString()] : "未知门店", + RefundAmount = Convert.ToDecimal(x.RefundAmount ?? 0), + RefundCount = Convert.ToInt32(x.RefundCount ?? 0) + }).OrderByDescending(x => x.RefundAmount).ToList(); + + // 2. 总计数据 - 使用原生SQL确保正确(包含转卡,与驾驶舱保持一致) + var totalStatsSql = $@" + SELECT + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, + COALESCE(SUM(CAST(COALESCE(F_ActualRefundAmount, 0) AS DECIMAL(18,2))), 0) as TotalActualRefundAmount, + COUNT(F_Id) as TotalRefundCount + FROM lq_hytk_hytk + WHERE F_IsEffective = 1 + AND tksj IS NOT NULL + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + {storeFilter.Replace("hytk.md", "md")}"; + + var totalStatsResult = await _db.Ado.SqlQueryAsync(totalStatsSql); + var totalStats = totalStatsResult?.FirstOrDefault(); + var totalRefundAmount = totalStats != null ? Convert.ToDecimal(totalStats.TotalRefundAmount ?? 0) : 0m; + var totalActualRefundAmount = totalStats != null ? Convert.ToDecimal(totalStats.TotalActualRefundAmount ?? 0) : 0m; + var totalRefundCount = totalStats != null ? Convert.ToInt32(totalStats.TotalRefundCount ?? 0) : 0; + + // 转卡总计(单独统计)- 使用原生SQL + var transferCardSql = $@" + SELECT + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalTransferAmount, + COUNT(F_Id) as TotalTransferCount + FROM lq_hytk_hytk + WHERE F_IsEffective = 1 + AND tksj IS NOT NULL + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + AND tkyy = '转卡' + {storeFilter.Replace("hytk.md", "md")}"; + + var transferCardResult = await _db.Ado.SqlQueryAsync(transferCardSql); + var transferCardStats = transferCardResult?.FirstOrDefault(); + var totalTransferAmount = transferCardStats != null ? Convert.ToDecimal(transferCardStats.TotalTransferAmount ?? 0) : 0m; + var totalTransferCount = transferCardStats != null ? Convert.ToInt32(transferCardStats.TotalTransferCount ?? 0) : 0; + + // 3. 退卡金额最大的人 - 使用原生SQL + // 注意:按 hymc(会员名称)分组,因为同一个会员可能有不同的 hy(会员ID) + var maxAmountPersonSql = $@" + SELECT + MAX(hy) as MemberId, + hymc as MemberName, + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, + COUNT(F_Id) as RefundCount + FROM lq_hytk_hytk + WHERE F_IsEffective = 1 + AND tksj IS NOT NULL + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + AND (tkyy IS NULL OR tkyy = '' OR tkyy != '转卡') + AND hymc IS NOT NULL + AND hymc != '' + {storeFilter.Replace("hytk.md", "md")} + GROUP BY hymc + ORDER BY TotalRefundAmount DESC + LIMIT 1"; + + var maxAmountPersonResult = await _db.Ado.SqlQueryAsync(maxAmountPersonSql); + var maxAmountPerson = maxAmountPersonResult?.FirstOrDefault(); + + // 4. 退卡次数最多的人 - 使用原生SQL(按退卡单数统计,一个退卡单算一次) + // 注意:按 hymc(会员名称)分组,因为同一个会员可能有不同的 hy(会员ID) + var maxCountPersonSql = $@" + SELECT + MAX(hy) as MemberId, + hymc as MemberName, + COALESCE(SUM(CAST(tkje AS DECIMAL(18,2))), 0) as TotalRefundAmount, + COUNT(F_Id) as RefundCount + FROM lq_hytk_hytk + WHERE F_IsEffective = 1 + AND tksj IS NOT NULL + AND tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + AND (tkyy IS NULL OR tkyy = '' OR tkyy != '转卡') + AND hymc IS NOT NULL + AND hymc != '' + {storeFilter.Replace("hytk.md", "md")} + GROUP BY hymc + HAVING RefundCount > 0 + ORDER BY RefundCount DESC, TotalRefundAmount DESC + LIMIT 1"; + + var maxCountPersonResult = await _db.Ado.SqlQueryAsync(maxCountPersonSql); + var maxCountPerson = maxCountPersonResult?.FirstOrDefault(); + + // 5. 退卡金额与实际退款金额差距在1元以上的记录 - 使用原生SQL + var gapRefundSql = $@" + SELECT + hytk.F_Id as RefundId, + hytk.hy as MemberId, + hytk.hymc as MemberName, + CAST(hytk.tkje AS DECIMAL(18,2)) as RefundAmount, + CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2)) as ActualRefundAmount, + (CAST(hytk.tkje AS DECIMAL(18,2)) - CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2))) as GapAmount, + hytk.tksj as RefundTime, + hytk.tkyy as RefundReason + FROM lq_hytk_hytk hytk + WHERE hytk.F_IsEffective = 1 + AND hytk.tksj IS NOT NULL + AND hytk.tksj >= '{startTime:yyyy-MM-dd HH:mm:ss}' + AND hytk.tksj <= '{endTime:yyyy-MM-dd HH:mm:ss}' + AND (CAST(hytk.tkje AS DECIMAL(18,2)) - CAST(COALESCE(hytk.F_ActualRefundAmount, 0) AS DECIMAL(18,2))) >= 1.00 + {storeFilter.Replace("hytk.md", "md")} + ORDER BY GapAmount DESC + LIMIT 50"; + + var gapRefundResult = await _db.Ado.SqlQueryAsync(gapRefundSql); + var gapRefundList = new List(); + if (gapRefundResult != null) + { + foreach (var x in gapRefundResult) + { + gapRefundList.Add(new + { + RefundId = x.RefundId?.ToString(), + MemberId = x.MemberId?.ToString(), + MemberName = x.MemberName?.ToString(), + RefundAmount = Convert.ToDecimal(x.RefundAmount ?? 0), + ActualRefundAmount = Convert.ToDecimal(x.ActualRefundAmount ?? 0), + GapAmount = Convert.ToDecimal(x.GapAmount ?? 0), + RefundTime = x.RefundTime != null ? Convert.ToDateTime(x.RefundTime) : (DateTime?)null, + RefundReason = x.RefundReason?.ToString() ?? "" + }); + } + } + + return new + { + Success = true, + Data = new + { + StoreDistribution = storeDistribution, + TotalRefundAmount = totalRefundAmount, + TotalActualRefundAmount = totalActualRefundAmount, + TotalRefundCount = totalRefundCount, + TotalTransferAmount = totalTransferAmount, + TotalTransferCount = totalTransferCount, + MaxAmountPerson = maxAmountPerson != null ? new + { + MemberId = maxAmountPerson.MemberId?.ToString(), + MemberName = maxAmountPerson.MemberName?.ToString(), + TotalRefundAmount = Convert.ToDecimal(maxAmountPerson.TotalRefundAmount ?? 0), + RefundCount = Convert.ToInt32(maxAmountPerson.RefundCount ?? 0) + } : null, + MaxCountPerson = maxCountPerson != null ? new + { + MemberId = maxCountPerson.MemberId?.ToString(), + MemberName = maxCountPerson.MemberName?.ToString(), + TotalRefundAmount = Convert.ToDecimal(maxCountPerson.TotalRefundAmount ?? 0), + RefundCount = Convert.ToInt32(maxCountPerson.RefundCount ?? 0) + } : null, + GapRefundList = gapRefundList + }, + Message = "退卡穿透统计数据获取成功" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取退卡穿透统计数据失败 - 开始时间: {input?.StartTime}, 结束时间: {input?.EndTime}"); + throw NCCException.Oh($"获取退卡穿透统计数据失败: {ex.Message}"); + } + } + + #endregion } } diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqTkjlbService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqTkjlbService.cs index 1277ce4..4b7e68b 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqTkjlbService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqTkjlbService.cs @@ -21,6 +21,9 @@ using NCC.Extend.Entitys.lq_event; using NCC.Extend.Entitys.lq_eventuser; using NCC.Extend.Entitys.lq_kd_kdjlb; using NCC.Extend.Entitys.lq_kd_pxmx; +using NCC.Extend.Entitys.lq_yaoyjl; +using NCC.Extend.Entitys.lq_yyjl; +using NCC.Extend.Entitys.lq_xh_hyhk; using NCC.Extend.Entitys.lq_khxx; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Entitys.lq_tkjlb; @@ -138,6 +141,14 @@ namespace NCC.Extend.LqTkjlb eventName = SqlFunc.Subqueryable().Where(u => u.Id == it.EventId).Select(u => u.EventName), depId = it.DepId, depName = SqlFunc.Subqueryable().Where(u => u.Id == it.DepId).Select(u => u.FullName), + // 是否邀约:通过会员ID关联邀约表(yykh字段存储的是会员ID) + hasInvite = SqlFunc.Subqueryable().Where(y => y.Yykh == it.MemberId).Any() ? "是" : "否", + // 是否预约:通过会员ID关联预约表(gk字段存储的是会员ID) + hasAppointment = SqlFunc.Subqueryable().Where(y => y.Gk == it.MemberId).Any() ? "是" : "否", + // 是否消耗:通过会员ID关联耗卡表(hy字段存储的是会员ID) + hasConsume = SqlFunc.Subqueryable().Where(x => x.Hy == it.MemberId).Any() ? "是" : "否", + // 是否开卡:通过会员ID关联开单表(kdhy字段存储的是会员ID) + hasBilling = SqlFunc.Subqueryable().Where(k => k.Kdhy == it.MemberId).Any() ? "是" : "否" }) .MergeTable() .OrderBy(sidx + " " + input.sort) diff --git a/netcore/src/Modularity/Extend/NCC.Extend/MemberPortraitService.cs b/netcore/src/Modularity/Extend/NCC.Extend/MemberPortraitService.cs new file mode 100644 index 0000000..51459fa --- /dev/null +++ b/netcore/src/Modularity/Extend/NCC.Extend/MemberPortraitService.cs @@ -0,0 +1,616 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NCC.DynamicApiController; +using NCC.FriendlyException; +using NCC.Extend.Entitys.Dto.MemberPortrait; +using NCC.Extend.Entitys.lq_kd_kdjlb; +using NCC.Extend.Entitys.lq_kd_pxmx; +using NCC.Extend.Entitys.lq_khxx; +using NCC.Extend.Entitys.lq_xh_hyhk; +using NCC.Extend.Entitys.lq_xh_pxmx; +using NCC.Extend.Entitys.lq_hytk_hytk; +using NCC.Extend.Entitys.lq_hytk_mx; +using NCC.Extend.Entitys.lq_kd_deductinfo; +using NCC.Extend.Entitys.lq_mdxx; +using NCC.Extend.Entitys.lq_package_info; +using NCC.Extend.Entitys.Enum; +using NCC.Dependency; +using SqlSugar; +using SqlSugar.IOC; + +namespace NCC.Extend +{ + /// + /// 会员画像数据服务 + /// + [ApiDescriptionSettings(Tag = "会员画像数据服务", Name = "MemberPortrait", Order = 600)] + [Route("api/Extend/[controller]")] + public class MemberPortraitService : IDynamicApiController, ITransient + { + private readonly SqlSugarScope _db; + private readonly ILogger _logger; + + /// + /// 初始化会员画像服务 + /// + public MemberPortraitService(ISqlSugarRepository khxxRepository, ILogger logger) + { + _db = khxxRepository.Context; + _logger = logger; + } + + /// + /// 获取会员画像概览数据 + /// + /// + /// 根据会员ID聚合基础档案、消费概要和近12个月趋势数据。 + /// + /// 示例请求: + /// ```http + /// GET /api/Extend/MemberPortrait/overview?memberId=会员ID + /// ``` + /// + /// 参数说明: + /// - memberId: 会员主键ID(lq_khxx.F_Id) + /// + /// 会员ID + /// 会员画像概览数据 + /// 返回会员画像概览数据 + /// 参数错误或会员不存在 + /// 服务器错误 + [HttpGet("overview")] + public async Task GetOverview(string memberId) + { + if (string.IsNullOrEmpty(memberId)) + { + throw NCCException.Oh("memberId 参数不能为空"); + } + + try + { + // 基础信息 + var member = await _db.Queryable() + .FirstAsync(x => x.Id == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()); + + if (member == null) + { + throw NCCException.Oh($"会员不存在或已失效, memberId={memberId}"); + } + + // 查询门店名称 + string storeName = null; + if (!string.IsNullOrEmpty(member.Gsmd)) + { + storeName = await _db.Queryable() + .Where(x => x.Id == member.Gsmd) + .Select(x => x.Dm) + .FirstAsync(); + } + + // 构建会员类型列表 + var memberTypes = new List(); + if (member.IsBeautyMember == StatusEnum.有效.GetHashCode()) + { + memberTypes.Add(new MemberTypeInfo + { + TypeName = "生美", + BecomeTime = member.BeautyMemberTime + }); + } + if (member.IsMedicalMember == StatusEnum.有效.GetHashCode()) + { + memberTypes.Add(new MemberTypeInfo + { + TypeName = "医美", + BecomeTime = member.MedicalMemberTime + }); + } + if (member.IsTechMember == StatusEnum.有效.GetHashCode()) + { + memberTypes.Add(new MemberTypeInfo + { + TypeName = "科技部", + BecomeTime = member.TechMemberTime + }); + } + if (member.IsEducationMember == StatusEnum.有效.GetHashCode()) + { + memberTypes.Add(new MemberTypeInfo + { + TypeName = "教育部", + BecomeTime = member.EducationMemberTime + }); + } + + var baseInfo = new MemberBaseInfo + { + MemberId = member.Id, + MemberCode = member.Dah, + MemberName = member.Khmc, + Mobile = member.Sjh, + StoreId = member.Gsmd, + StoreName = storeName, + Channel = member.Jdqd, // 进店渠道 + FirstVisitTime = member.FirstVisitTime, + LastVisitTime = member.LastVisitTime, + SleepDays = member.SleepDays, + SleepStartTime = member.SleepStartTime, + ConsumeLevel = member.ConsumeLevel, + ConsumeLevelUpdateTime = member.ConsumeLevelUpdateTime, + MemberTypes = memberTypes + }; + + // 行为概要(使用个人累计字段 + 明细表兜底) + var behavior = new MemberBehaviorSummary + { + TotalBillingAmount = member.TotalBillingAmount, + TotalConsumeAmount = member.TotalConsumeAmount, + RemainingRightsAmount = member.RemainingRightsAmount + }; + + // 退卡总金额(如实体中未维护,则从退卡表统计) + var refundTotal = await _db.Queryable() + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .Where(x => x.Hy == member.Id) + .SumAsync(x => (decimal?)x.Tkje) ?? 0m; + + behavior.TotalRefundAmount = refundTotal; + + // 开单统计 + behavior.BillingCount = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) + .CountAsync(); + + if (behavior.BillingCount > 0) + { + behavior.FirstBillingTime = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) + .MinAsync(x => x.Kdrq); + + behavior.LastBillingTime = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Kdhy == member.Id) + .MaxAsync(x => x.Kdrq); + + behavior.AvgBillingAmount = behavior.TotalBillingAmount > 0 && behavior.BillingCount > 0 + ? (behavior.TotalBillingAmount / behavior.BillingCount) + : 0m; + } + else + { + behavior.FirstBillingTime = null; + behavior.LastBillingTime = null; + behavior.AvgBillingAmount = 0m; + } + + // 消耗统计 + behavior.ConsumeCount = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) + .CountAsync(); + + if (behavior.ConsumeCount > 0) + { + behavior.FirstConsumeTime = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) + .MinAsync(x => x.Hksj); + + behavior.LastConsumeTime = await _db.Queryable() + .Where(x => x.IsEffective == 1 && x.Hy == member.Id) + .MaxAsync(x => x.Hksj); + + behavior.AvgConsumeAmount = behavior.TotalConsumeAmount > 0 && behavior.ConsumeCount > 0 + ? (behavior.TotalConsumeAmount / behavior.ConsumeCount) + : 0m; + } + else + { + behavior.FirstConsumeTime = null; + behavior.LastConsumeTime = null; + behavior.AvgConsumeAmount = 0m; + } + + // 退卡次数 + behavior.RefundCount = await _db.Queryable() + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .Where(x => x.Hy == member.Id) + .CountAsync(); + + // 近12个月趋势(以当前月份往前推12个月) + var now = DateTime.Now; + var trendStart = new DateTime(now.Year, now.Month, 1).AddMonths(-11); + var trendEnd = new DateTime(now.Year, now.Month, 1).AddMonths(1).AddSeconds(-1); + + // 开单趋势 + var billingTrendSql = $@" + SELECT + DATE_FORMAT(kd.kdrq, '%Y-%m') as Month, + COALESCE(SUM(CAST(kd.sfyj AS DECIMAL(18,2))), 0) as BillingAmount + FROM lq_kd_kdjlb kd + WHERE kd.F_IsEffective = 1 + AND kd.kdrq >= @trendStart + AND kd.kdrq <= @trendEnd + AND kd.kdhy = @memberId + GROUP BY DATE_FORMAT(kd.kdrq, '%Y-%m')"; + + var billingTrend = await _db.Ado.SqlQueryAsync(billingTrendSql, + new { trendStart, trendEnd, memberId }); + + // 耗卡趋势 + var consumeTrendSql = $@" + SELECT + DATE_FORMAT(xh.hksj, '%Y-%m') as Month, + COALESCE(SUM(CAST(xh.xfje AS DECIMAL(18,2))), 0) as ConsumeAmount + FROM lq_xh_hyhk xh + WHERE xh.F_IsEffective = 1 + AND xh.hksj >= @trendStart + AND xh.hksj <= @trendEnd + AND xh.hy = @memberId + GROUP BY DATE_FORMAT(xh.hksj, '%Y-%m')"; + + var consumeTrend = await _db.Ado.SqlQueryAsync(consumeTrendSql, + new { trendStart, trendEnd, memberId }); + + // 退卡趋势 + var refundTrendSql = $@" + SELECT + DATE_FORMAT(hytk.tksj, '%Y-%m') as Month, + COALESCE(SUM(CAST(hytk.tkje AS DECIMAL(18,2))), 0) as RefundAmount + FROM lq_hytk_hytk hytk + WHERE hytk.F_IsEffective = 1 + AND hytk.tksj >= @trendStart + AND hytk.tksj <= @trendEnd + AND hytk.hy = @memberId + GROUP BY DATE_FORMAT(hytk.tksj, '%Y-%m')"; + + var refundTrend = await _db.Ado.SqlQueryAsync(refundTrendSql, + new { trendStart, trendEnd, memberId }); + + // 组合趋势数据,补全没有数据的月份 + var trendDict = new Dictionary(); + for (int i = 0; i < 12; i++) + { + var month = trendStart.AddMonths(i).ToString("yyyy-MM"); + trendDict[month] = new MemberMonthlyTrendPoint + { + Month = month, + BillingAmount = 0m, + ConsumeAmount = 0m, + RefundAmount = 0m + }; + } + + foreach (var item in billingTrend) + { + var month = Convert.ToString(item.Month); + if (trendDict.ContainsKey(month)) + { + trendDict[month].BillingAmount = Convert.ToDecimal(item.BillingAmount ?? 0m); + } + } + + foreach (var item in consumeTrend) + { + var month = Convert.ToString(item.Month); + if (trendDict.ContainsKey(month)) + { + trendDict[month].ConsumeAmount = Convert.ToDecimal(item.ConsumeAmount ?? 0m); + } + } + + foreach (var item in refundTrend) + { + var month = Convert.ToString(item.Month); + if (trendDict.ContainsKey(month)) + { + trendDict[month].RefundAmount = Convert.ToDecimal(item.RefundAmount ?? 0m); + } + } + + // 查询权益明细 + var baseItems = await _db.Queryable() + .Where(x => x.MemberId == memberId) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .Select(x => new + { + x.Id, + x.Px, + x.Pxmc, + x.Pxjg, + x.SourceType, + x.ProjectNumber + }) + .ToListAsync(); + + var consumedData = await _db.Queryable() + .Where(x => baseItems.Select(b => b.Id).Contains(x.BillingItemId)) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .GroupBy(x => x.BillingItemId) + .Select(x => new + { + BillingItemId = x.BillingItemId, + TotalConsumed = SqlFunc.AggregateSum(x.OriginalProjectNumber) + }) + .ToListAsync(); + + var refundedData = await _db.Queryable() + .Where(x => baseItems.Select(b => b.Id).Contains(x.BillingItemId)) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .GroupBy(x => x.BillingItemId) + .Select(x => new + { + BillingItemId = x.BillingItemId, + TotalRefunded = SqlFunc.AggregateSum(x.ProjectNumber) + }) + .ToListAsync(); + + var deductData = await _db.Queryable() + .Where(x => baseItems.Select(b => b.Id).Contains(x.DeductId)) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .GroupBy(x => x.DeductId) + .Select(x => new + { + BillingItemId = x.DeductId, + TotalDeduct = SqlFunc.AggregateSum(x.ProjectNumber) + }) + .ToListAsync(); + + var remainingItems = baseItems.Select(item => new RemainingItemDto + { + ItemId = item.Px, + ItemName = item.Pxmc, + UnitPrice = item.Pxjg, + SourceType = item.SourceType, + TotalQuantity = (int)item.ProjectNumber, + ConsumedQuantity = (int)(consumedData.FirstOrDefault(c => c.BillingItemId == item.Id)?.TotalConsumed ?? 0m), + RefundedQuantity = (int)(refundedData.FirstOrDefault(r => r.BillingItemId == item.Id)?.TotalRefunded ?? 0m), + DeductedQuantity = (int)(deductData.FirstOrDefault(d => d.BillingItemId == item.Id)?.TotalDeduct ?? 0m) + }).ToList(); + + foreach (var item in remainingItems) + { + item.RemainingQuantity = item.TotalQuantity - item.ConsumedQuantity - item.RefundedQuantity - item.DeductedQuantity; + item.RemainingValue = item.UnitPrice * item.RemainingQuantity; + } + + // 只返回剩余数量大于0的 + remainingItems = remainingItems.Where(x => x.RemainingQuantity > 0).OrderByDescending(x => x.RemainingValue).ToList(); + + var assets = new MemberAssets + { + RemainingItems = remainingItems + }; + + // 计算消费分析数据 + var consumptionAnalysis = new ConsumptionAnalysis(); + + // 计算消费频率(次/月) + if (behavior.FirstConsumeTime.HasValue && behavior.LastConsumeTime.HasValue) + { + var months = Math.Max(1, (behavior.LastConsumeTime.Value - behavior.FirstConsumeTime.Value).Days / 30.0); + consumptionAnalysis.ConsumeFrequency = months > 0 ? (decimal)(behavior.ConsumeCount / months) : 0m; + } + + // 计算开单频率(次/月) + if (behavior.FirstBillingTime.HasValue && behavior.LastBillingTime.HasValue) + { + var months = Math.Max(1, (behavior.LastBillingTime.Value - behavior.FirstBillingTime.Value).Days / 30.0); + consumptionAnalysis.BillingFrequency = months > 0 ? (decimal)(behavior.BillingCount / months) : 0m; + } + + // 判断消费活跃度(最近3个月是否有消费) + var threeMonthsAgo = DateTime.Now.AddMonths(-3); + consumptionAnalysis.IsActive = behavior.LastConsumeTime.HasValue && behavior.LastConsumeTime.Value >= threeMonthsAgo; + + // 品项类型偏好(按品项类型统计消费金额和次数) + var itemTypeStats = await _db.Queryable() + .Where(x => x.MemberId == memberId) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .GroupBy(x => x.SourceType) + .Select(x => new + { + ItemType = x.SourceType, + Amount = SqlFunc.AggregateSum(x.TotalPrice), + Count = SqlFunc.AggregateCount(x.Id) + }) + .ToListAsync(); + + var totalItemAmount = itemTypeStats.Sum(x => (decimal)x.Amount); + consumptionAnalysis.ItemTypePreferences = itemTypeStats.Select(x => new ItemTypePreference + { + ItemType = x.ItemType ?? "未设置", + Amount = (decimal)x.Amount, + Count = x.Count, + Percentage = totalItemAmount > 0 ? ((decimal)x.Amount / totalItemAmount * 100) : 0m + }).OrderByDescending(x => x.Amount).ToList(); + + // 门店偏好(按门店统计消费金额和次数) + var storeStats = await _db.Queryable() + .Where(x => x.Hy == memberId) + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .GroupBy(x => x.Md) + .Select(x => new + { + StoreId = x.Md, + Amount = SqlFunc.AggregateSum(x.Xfje), + Count = SqlFunc.AggregateCount(x.Id) + }) + .ToListAsync(); + + // 查询门店名称 + var storeIds = storeStats.Select(x => x.StoreId).ToList(); + var storeNames = await _db.Queryable() + .Where(x => storeIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Dm }) + .ToListAsync(); + + var totalStoreAmount = storeStats.Sum(x => (decimal)x.Amount); + consumptionAnalysis.StorePreferences = storeStats.Select(x => new StorePreference + { + StoreId = x.StoreId, + StoreName = storeNames.FirstOrDefault(s => s.Id == x.StoreId)?.Dm ?? "未知门店", + Amount = (decimal)x.Amount, + Count = x.Count, + Percentage = totalStoreAmount > 0 ? ((decimal)x.Amount / totalStoreAmount * 100) : 0m + }).OrderByDescending(x => x.Amount).ToList(); + + var overview = new MemberPortraitOverviewOutput + { + BaseInfo = baseInfo, // 会员类型和基础信息放在上面 + BehaviorSummary = behavior, // 消费行为概要 + MonthlyTrend = trendDict.Values.OrderBy(x => x.Month).ToList(), // 消费趋势 + ConsumptionAnalysis = consumptionAnalysis, // 消费分析 + Assets = assets // 权益资产放在最后 + }; + + return overview; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取会员画像概览失败, memberId={memberId}"); + throw NCCException.Oh($"获取会员画像概览失败: {ex.Message}"); + } + } + + /// + /// 获取会员开单列表 + /// + /// 会员ID + /// 页码 + /// 每页数量 + /// 开单列表 + [HttpGet("billing-list")] + public async Task GetBillingList(string memberId, int pageIndex = 1, int pageSize = 10) + { + if (string.IsNullOrEmpty(memberId)) + { + throw NCCException.Oh("memberId 参数不能为空"); + } + + try + { + var query = _db.Queryable() + .Where(x => x.Kdhy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) + .OrderBy(x => x.Kdrq, OrderByType.Desc) + .Select(x => new + { + Id = x.Id, + BillingDate = x.Kdrq, + StoreName = SqlFunc.Subqueryable().Where(md => md.Id == x.Djmd).Select(md => md.Dm), + Amount = x.Sfyj, + DebtAmount = x.Qk, + ActivityName = SqlFunc.Subqueryable().Where(pkg => pkg.Id == x.ActivityId).Select(pkg => pkg.ActivityName) + }); + + var total = await query.CountAsync(); + var result = await query.ToPageListAsync(pageIndex, pageSize); + + return new + { + Total = total, + List = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取会员开单列表失败, memberId={memberId}"); + throw NCCException.Oh($"获取会员开单列表失败: {ex.Message}"); + } + } + + /// + /// 获取会员消耗列表 + /// + /// 会员ID + /// 页码 + /// 每页数量 + /// 消耗列表 + [HttpGet("consume-list")] + public async Task GetConsumeList(string memberId, int pageIndex = 1, int pageSize = 10) + { + if (string.IsNullOrEmpty(memberId)) + { + throw NCCException.Oh("memberId 参数不能为空"); + } + + try + { + var query = _db.Queryable() + .Where(x => x.Hy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) + .OrderBy(x => x.Hksj, OrderByType.Desc) + .Select(x => new + { + Id = x.Id, + ConsumeDate = x.Hksj, + StoreName = SqlFunc.Subqueryable().Where(md => md.Id == x.Md).Select(md => md.Dm), + Amount = x.Xfje, + LaborCost = x.Sgfy + }); + + var total = await query.CountAsync(); + var result = await query.ToPageListAsync(pageIndex, pageSize); + + return new + { + Total = total, + List = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取会员消耗列表失败, memberId={memberId}"); + throw NCCException.Oh($"获取会员消耗列表失败: {ex.Message}"); + } + } + + /// + /// 获取会员退卡列表 + /// + /// 会员ID + /// 页码 + /// 每页数量 + /// 退卡列表 + [HttpGet("refund-list")] + public async Task GetRefundList(string memberId, int pageIndex = 1, int pageSize = 10) + { + if (string.IsNullOrEmpty(memberId)) + { + throw NCCException.Oh("memberId 参数不能为空"); + } + + try + { + var query = _db.Queryable() + .Where(x => x.Hy == memberId && x.IsEffective == StatusEnum.有效.GetHashCode()) + .OrderBy(x => x.Tksj, OrderByType.Desc) + .Select(x => new + { + Id = x.Id, + RefundDate = x.Tksj, + StoreName = SqlFunc.Subqueryable().Where(md => md.Id == x.Md).Select(md => md.Dm), + RefundAmount = x.Tkje, + ActualRefundAmount = x.ActualRefundAmount, + RefundReason = x.Tkyy + }); + + var total = await query.CountAsync(); + var result = await query.ToPageListAsync(pageIndex, pageSize); + + return new + { + Total = total, + List = result + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取会员退卡列表失败, memberId={memberId}"); + throw NCCException.Oh($"获取会员退卡列表失败: {ex.Message}"); + } + } + } +} + +