Files
29WxOrder/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs
T

570 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using _29WxOrderWpf.Common;
using _29WxOrderWpf.Ctrls.Statistics.Models;
using AllBase;
using AllBase.Helper;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Work;
namespace _29WxOrderWpf.Ctrls.Statistics.Services
{
/// <summary>
/// 店铺营收统计 - 数据服务调用接口。
/// 页面只依赖此接口,接入真实数据源时新建实现类(如对接 HTTP 接口 / SQL 查询),
/// 并通过 ShopRevenueStatisticsViewModel 构造函数注入即可,无需改动页面。
/// </summary>
public interface IShopRevenueStatisticsService
{
/// <summary>
/// 查询原始统计明细(接口返回的 A.* / B.* / c.* 原始字段)。
/// 真实实现通常通过 SQL / HTTP 一次性返回这些行,再由聚合器计算。
/// </summary>
Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query);
/// <summary>获取顶部汇总卡片数据</summary>
Task<ShopRevenueSummary> GetSummaryAsync(ShopRevenueStatisticsQuery query);
/// <summary>获取销售趋势(按天)</summary>
Task<List<RevenueTrendPoint>> GetSalesTrendAsync(ShopRevenueStatisticsQuery query);
/// <summary>获取店铺销售占比(饼图)</summary>
Task<List<ShopSalesProportionItem>> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query);
/// <summary>获取店铺统计表格</summary>
Task<List<ShopStatisticsItem>> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query);
/// <summary>获取天/周/月周期统计表格(每天/每周/每月各一行,按所选店铺或全部店铺统计)</summary>
Task<List<ShopPeriodStatisticsItem>> GetPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType);
/// <summary>获取店铺下拉列表(用于查询条件)</summary>
Task<List<ShopOption>> GetShopOptionsAsync();
}
/// <summary>
/// 统计聚合器:把接口返回的原始明细行聚合成页面所需的汇总/趋势/占比/表格数据。
/// 聚合口径说明(依据接口字段描述):
/// - 订单状态 status:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成;
/// 200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消。
/// - 有效订单:已付款(20/21/30/100)且未退款;10/250 未付款不计入销售额。
/// - 已退款:c.refund_status 为 4/11,或订单状态为 200(全部商品售后后取消)。
/// - 金额字段单位均为“分”,除以 100 转为元。
/// </summary>
public static class RevenueStatisticsAggregator
{
/// <summary>订单状态 - 待付款</summary>
public const int StatusPendingPay = 10;
/// <summary>订单状态 - 待发货</summary>
public const int StatusWaitSend = 20;
/// <summary>订单状态 - 部分发货</summary>
public const int StatusPartSend = 21;
/// <summary>订单状态 - 待收货</summary>
public const int StatusWaitReceive = 30;
/// <summary>订单状态 - 完成</summary>
public const int StatusFinished = 100;
/// <summary>订单状态 - 全部商品售后之后订单取消</summary>
public const int StatusCancelAfterAfterSale = 200;
/// <summary>订单状态 - 未付款用户主动取消/超时未付款自动取消</summary>
public const int StatusCancelNotPay = 250;
/// <summary>是否已付款的订单(20/21/30/100;10 待付款、250 未付款取消不计入)</summary>
public static bool IsPaidOrder(ShopRevenueStatisticsRow row)
{
if (row == null) return false;
return row.Status == StatusWaitSend
|| row.Status == StatusPartSend
|| row.Status == StatusWaitReceive
|| row.Status == StatusFinished;
}
/// <summary>是否已退款(c.refund_status 为 4/11,或订单状态为 200 全部商品售后后取消)</summary>
public static bool IsRefunded(ShopRevenueStatisticsRow row)
{
if (row == null) return false;
return row.RefundStatus == 4 || row.RefundStatus == 11 || row.Status == StatusCancelAfterAfterSale;
}
/// <summary>是否有效订单(已付款且未退款)</summary>
public static bool IsValidOrder(ShopRevenueStatisticsRow row)
{
return IsPaidOrder(row) && !IsRefunded(row);
}
/// <summary>汇总卡片</summary>
public static ShopRevenueSummary GetSummary(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).ToList();
var valid = list.Where(IsValidOrder).ToList();
var refunded = list.Where(IsRefunded).ToList();
decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
decimal profit = valid.Sum(r => (decimal)r.Profit) / 100m;
decimal refundAmount = refunded.Sum(r => (decimal)r.OrderPrice) / 100m;
int validOrderCount = valid.Select(r => r.OrderId).Distinct().Count();
// 总订单数(退款率分母)= 已付款订单 + 已退款订单(含售后取消 200),不含未付款的 10/250
int totalOrderCount = list.Where(r => IsPaidOrder(r) || IsRefunded(r)).Select(r => r.OrderId).Distinct().Count();
int refundOrderCount = refunded.Select(r => r.OrderId).Distinct().Count();
return new ShopRevenueSummary
{
SalesAmount = salesAmount,
ValidOrderCount = validOrderCount,
Profit = profit,
RefundAmount = refundAmount,
ProductSalesVolume = valid.Count,
CustomerCount = validOrderCount,
AvgOrderAmount = validOrderCount == 0 ? 0m : salesAmount / validOrderCount,
RefundRate = totalOrderCount == 0 ? 0m : (decimal)refundOrderCount * 100m / totalOrderCount
};
}
/// <summary>销售趋势(按天,缺失日期补 0,仅统计有效订单)</summary>
public static List<RevenueTrendPoint> GetSalesTrend(IEnumerable<ShopRevenueStatisticsRow> rows, DateTime startDate, DateTime endDate)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).Where(IsValidOrder).ToList();
var map = list
.GroupBy(r => r.CreateTime.Date)
.ToDictionary(
g => g.Key,
g => new RevenueTrendPoint
{
Date = g.Key,
SalesAmount = g.Sum(r => (decimal)r.OrderPrice) / 100m,
OrderCount = g.Select(r => r.OrderId).Distinct().Count()
});
var result = new List<RevenueTrendPoint>();
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
{
if (map.TryGetValue(d, out var point))
result.Add(point);
else
result.Add(new RevenueTrendPoint { Date = d, SalesAmount = 0m, OrderCount = 0 });
}
return result;
}
/// <summary>店铺销售占比(饼图,仅统计有效订单)</summary>
public static List<ShopSalesProportionItem> GetShopSalesProportion(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).Where(IsValidOrder).ToList();
decimal total = list.Sum(r => (decimal)r.OrderPrice) / 100m;
return list
.GroupBy(r => r.ShopName)
.Select(g => new ShopSalesProportionItem
{
ShopName = string.IsNullOrEmpty(g.Key) ? "未知店铺" : g.Key,
SalesAmount = g.Sum(r => (decimal)r.OrderPrice) / 100m,
Percentage = total == 0m ? 0d : (double)(g.Sum(r => (decimal)r.OrderPrice) / 100m / total * 100m)
})
.OrderByDescending(i => i.SalesAmount)
.ToList();
}
/// <summary>店铺统计表格</summary>
public static List<ShopStatisticsItem> GetShopStatistics(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).ToList();
return list
.GroupBy(r => r.ShopName)
.Select(g =>
{
var valid = g.Where(IsValidOrder).ToList();
var refunded = g.Where(IsRefunded).ToList();
decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
return new ShopStatisticsItem
{
ShopName = string.IsNullOrEmpty(g.Key) ? "未知店铺" : g.Key,
OrderCount = orderCount,
SalesAmount = salesAmount,
Profit = valid.Sum(r => (decimal)r.Profit) / 100m,
RefundAmount = refunded.Sum(r => (decimal)r.OrderPrice) / 100m,
CustomerCount = orderCount,
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount
};
})
.OrderByDescending(i => i.SalesAmount)
.ToList();
}
/// <summary>天/周/月周期统计表格(每天/每周/每月各一行,无数据的周期也补一行,金额单位:元)</summary>
public static List<ShopPeriodStatisticsItem> GetPeriodStatistics(IEnumerable<ShopRevenueStatisticsRow> rows, DateTime startDate, DateTime endDate, PeriodStatisticType periodType)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).ToList();
// 有数据的周期
var map = list
.GroupBy(r => GetPeriodStart(r.CreateTime, periodType))
.ToDictionary(
g => g.Key,
g =>
{
var valid = g.Where(IsValidOrder).ToList();
var refunded = g.Where(IsRefunded).ToList();
decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
return new ShopPeriodStatisticsItem
{
PeriodStart = g.Key,
Period = GetPeriodText(g.Key, periodType),
OrderCount = orderCount,
SalesAmount = salesAmount,
Profit = valid.Sum(r => (decimal)r.Profit) / 100m,
RefundAmount = refunded.Sum(r => (decimal)r.OrderPrice) / 100m,
CustomerCount = orderCount,
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount
};
});
// 按周期补齐整段时间:无数据的周期也生成一行(全 0)
var result = new List<ShopPeriodStatisticsItem>();
foreach (var periodStart in EnumeratePeriods(startDate, endDate, periodType))
{
if (map.TryGetValue(periodStart, out var item))
result.Add(item);
else
result.Add(new ShopPeriodStatisticsItem
{
PeriodStart = periodStart,
Period = GetPeriodText(periodStart, periodType),
OrderCount = 0,
SalesAmount = 0m,
Profit = 0m,
RefundAmount = 0m,
CustomerCount = 0,
AvgOrderAmount = 0m
});
}
// 倒序排列:最新的周期在最上面
result.Reverse();
return result;
}
/// <summary>枚举 startDate~endDate 覆盖的所有周期开始日期(天/周/月)</summary>
private static IEnumerable<DateTime> EnumeratePeriods(DateTime startDate, DateTime endDate, PeriodStatisticType periodType)
{
var start = GetPeriodStart(startDate, periodType);
var end = GetPeriodStart(endDate, periodType);
switch (periodType)
{
case PeriodStatisticType.Week:
for (var d = start; d <= end; d = d.AddDays(7))
yield return d;
break;
case PeriodStatisticType.Month:
for (var d = start; d <= end; d = d.AddMonths(1))
yield return d;
break;
default:
for (var d = start; d <= end; d = d.AddDays(1))
yield return d;
break;
}
}
/// <summary>周期开始日期:天=当天;周=周一起始;月=当月1号</summary>
private static DateTime GetPeriodStart(DateTime time, PeriodStatisticType periodType)
{
switch (periodType)
{
case PeriodStatisticType.Week:
var d = time.Date;
int diff = d.DayOfWeek == DayOfWeek.Sunday ? 6 : (int)d.DayOfWeek - 1;
return d.AddDays(-diff);
case PeriodStatisticType.Month:
return new DateTime(time.Year, time.Month, 1);
default:
return time.Date;
}
}
/// <summary>周期显示文本:天=yyyy-MM-dd;周=yyyy-MM-dd ~ yyyy-MM-dd;月=yyyy-MM</summary>
private static string GetPeriodText(DateTime periodStart, PeriodStatisticType periodType)
{
switch (periodType)
{
case PeriodStatisticType.Week:
return periodStart.ToString("yyyy-MM-dd") + " ~ " + periodStart.AddDays(6).ToString("yyyy-MM-dd");
case PeriodStatisticType.Month:
return periodStart.ToString("yyyy-MM");
default:
return periodStart.ToString("yyyy-MM-dd");
}
}
}
/// <summary>
/// 默认实现:返回模拟数据,用于页面演示/联调。
/// 接入真实接口时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。
/// </summary>
public class ShopRevenueStatisticsService : IShopRevenueStatisticsService
{
/// <summary>
/// 明细数据缓存。
/// key = 店铺id|yyyyMMdd(店铺 × 天),value = 该店铺当天的明细行。
/// 查询按“店铺 × 日期”命中;完全未命中时所有店铺 + 整个时间范围一次请求,
/// 部分命中时只请求缺失的店铺/日期段。
/// </summary>
private readonly Dictionary<string, List<ShopRevenueStatisticsRow>> _rowCache =
new Dictionary<string, List<ShopRevenueStatisticsRow>>();
private readonly object _cacheLock = new object();
private sealed class DateRange
{
public DateTime Start;
public DateTime End;
}
private static string CacheKey(string shopId, DateTime day)
{
return shopId + "|" + day.ToString("yyyyMMdd");
}
public async Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query)
{
// 1. 展开本次查询涉及的店铺(ShopKey 为空 = 全部店铺)
List<string> shopIds;
if (string.IsNullOrEmpty(query.ShopKey))
shopIds = (await GetShopOptionsAsync()).Select(a => a.AppKey).ToList();
else
shopIds = new List<string> { query.ShopKey };
if (shopIds.Count == 0)
return new List<ShopRevenueStatisticsRow>();
var startDay = query.StartDate.Date;
var endDay = query.EndDate.Date;
int totalDays = (int)(endDay - startDay).Days + 1;
// 2. 命中检测:按 店铺 × 天 找出缺失项
var missingByShop = new Dictionary<string, List<DateTime>>();
int missingCount = 0;
lock (_cacheLock)
{
foreach (var shopId in shopIds)
{
var missingDays = new List<DateTime>();
for (var d = startDay; d <= endDay; d = d.AddDays(1))
{
if (!_rowCache.ContainsKey(CacheKey(shopId, d)))
missingDays.Add(d);
}
missingCount += missingDays.Count;
missingByShop[shopId] = missingDays;
}
}
// 3. 全部命中:直接从缓存拼装返回
if (missingCount == 0)
return GetCachedRows(shopIds, startDay, endDay);
// 4. 完全未命中:所有店铺 + 整个时间范围一次请求
if (missingCount == shopIds.Count * totalDays)
{
var allRows = await RequestRowsAsync(shopIds, startDay, endDay);
CacheResponse(shopIds, startDay, endDay, allRows, null);
var cached = GetCachedRows(shopIds, startDay, endDay);
// 若接口行内未返回 app_key 导致无法入库,则直接返回本次请求结果
return cached.Count > 0 ? cached : allRows;
}
// 5. 部分命中:只请求未命中的(店铺 × 连续缺失日期段)
foreach (var pair in missingByShop)
{
if (pair.Value.Count == 0)
continue;
foreach (var run in BuildRuns(pair.Value))
{
var rows = await RequestRowsAsync(new List<string> { pair.Key }, run.Start, run.End);
CacheResponse(new List<string> { pair.Key }, run.Start, run.End, rows, pair.Key);
}
}
return GetCachedRows(shopIds, startDay, endDay);
}
/// <summary>按 店铺 × 日期 从缓存拼装结果</summary>
private List<ShopRevenueStatisticsRow> GetCachedRows(List<string> shopIds, DateTime startDate, DateTime endDate)
{
var result = new List<ShopRevenueStatisticsRow>();
lock (_cacheLock)
{
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
{
foreach (var shopId in shopIds)
{
if (_rowCache.TryGetValue(CacheKey(shopId, d), out var list))
result.AddRange(list);
}
}
}
return result;
}
/// <summary>请求接口一次(可传多个店铺),返回原始明细行</summary>
private async Task<List<ShopRevenueStatisticsRow>> RequestRowsAsync(List<string> shopIds, DateTime startDate, DateTime endDate)
{
var url = BaseClass.Build29WxObjString("shop_revenue_statistics/get_shop_order_amount");
var sb = new StringBuilder();
sb.Append(UserLogin.GetSignaturex());
sb.Append("&shop_ids=").Append(string.Join(",", shopIds));
sb.Append("&start_time=").Append(BaseClass.ConvertDateTimeLong(startDate.Date));
sb.Append("&end_time=").Append(BaseClass.ConvertDateTimeLong(endDate.Date.AddDays(1).AddTicks(-1)));
var html = await GetHtml.PostUtf8Async(url, sb.ToString());
try
{
var ret = JsonConvert.DeserializeObject<Result<List<ShopRevenueStatisticsRow>>>(html, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
});
if (ret.Code != 200)
{
throw new Exception(ret.Msg);
}
return ret.Data ?? new List<ShopRevenueStatisticsRow>();
}
catch
{
throw new Exception("查询失败");
}
}
/// <summary>
/// 把一次请求的结果写入缓存(按 店铺id|日期 分桶)。
/// 除有数据的 店铺×天 外,会把本次请求覆盖但无数据的 店铺×天 也标记为已缓存(空列表),
/// 避免“该店铺当天无数据”被反复判定为未命中而重复请求数据库。
/// </summary>
private void CacheResponse(List<string> shopIds, DateTime startDate, DateTime endDate, List<ShopRevenueStatisticsRow> rows, string fallbackShopId)
{
if (rows == null)
rows = new List<ShopRevenueStatisticsRow>();
// 能否按 店铺×天 归属:
// 单店铺请求一定可以(用请求的店铺兜底);多店铺请求要求行内带 app_key;
// 响应为空时也视为可归属(说明覆盖的 店铺×天 都无数据)。
bool canAttribute = shopIds.Count == 1
|| rows.Count == 0
|| rows.Any(r => !string.IsNullOrEmpty(r.AppKey));
lock (_cacheLock)
{
if (canAttribute)
{
// 覆盖的 店铺×天 全部落缓存键(无数据的天记为空的已缓存)
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
{
foreach (var shopId in shopIds)
{
var key = CacheKey(shopId, d);
if (!_rowCache.TryGetValue(key, out var list))
{
list = new List<ShopRevenueStatisticsRow>();
_rowCache[key] = list;
}
}
}
}
// 有数据的行按 店铺×天 追加
foreach (var row in rows)
{
// 行内未带 app_key 时,用本次请求的店铺兜底
var shopId = string.IsNullOrEmpty(row.AppKey) ? fallbackShopId : row.AppKey;
if (string.IsNullOrEmpty(shopId))
continue;
var key = CacheKey(shopId, row.CreateTime.Date);
if (!_rowCache.TryGetValue(key, out var list))
{
list = new List<ShopRevenueStatisticsRow>();
_rowCache[key] = list;
}
list.Add(row);
}
}
}
/// <summary>把缺失日期合并为连续日期段(每段一次请求)</summary>
private static List<DateRange> BuildRuns(List<DateTime> missingDays)
{
var sorted = missingDays.OrderBy(d => d).ToList();
var runs = new List<DateRange>();
int i = 0;
while (i < sorted.Count)
{
var start = sorted[i];
var end = start;
i++;
while (i < sorted.Count && sorted[i] == end.AddDays(1))
{
end = sorted[i];
i++;
}
runs.Add(new DateRange { Start = start, End = end });
}
return runs;
}
public async Task<ShopRevenueSummary> GetSummaryAsync(ShopRevenueStatisticsQuery query)
{
var rows = await QueryRowsAsync(query);
return RevenueStatisticsAggregator.GetSummary(rows);
}
public async Task<List<RevenueTrendPoint>> GetSalesTrendAsync(ShopRevenueStatisticsQuery query)
{
var rows = await QueryRowsAsync(query);
return RevenueStatisticsAggregator.GetSalesTrend(rows, query.StartDate, query.EndDate);
}
public async Task<List<ShopSalesProportionItem>> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query)
{
var rows = await QueryRowsAsync(query);
return RevenueStatisticsAggregator.GetShopSalesProportion(rows);
}
public async Task<List<ShopStatisticsItem>> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query)
{
var rows = await QueryRowsAsync(query);
return RevenueStatisticsAggregator.GetShopStatistics(rows);
}
/// <summary>获取天/周/月周期统计表格</summary>
public async Task<List<ShopPeriodStatisticsItem>> GetPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType)
{
var rows = await QueryRowsAsync(query);
return RevenueStatisticsAggregator.GetPeriodStatistics(rows, query.StartDate, query.EndDate, periodType);
}
public Task<List<ShopOption>> GetShopOptionsAsync()
{
var list = GlobalConfig.ShopLoginList.Select(a =>
new ShopOption()
{
AppKey = a.ShopId,
ShopName = a.ShopAccount
}).ToList();
return Task.FromResult(list);
}
}
}