diff --git a/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs b/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs
index 685a294..7ba2cea 100644
--- a/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs
+++ b/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs
@@ -15,80 +15,128 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// 结束时间
public DateTime EndDate { get; set; }
- /// 店铺 app_key(A.app_key),空/Null 表示全部店铺
+ /// 店铺 shop_id(B.shop_id),空/Null 表示全部店铺
public string ShopKey { get; set; }
}
///
/// 店铺营收统计 - 接口返回的原始明细行。
- /// 对应接口返回字段:
- /// A.app_key, B.create_time, B.order_id, B.status,
- /// B.product_price, B.order_price, B.purchase_price, B.freight,
- /// B.predict_commission_fee, B.commission_amount, B.discounted_price, B.profit,
- /// B.purchase_status, c.refund_status
- /// 其中 A 为店铺表,B 为订单表,c 为售后/退款表。
- /// 金额字段单位均为“分”,展示时需除以 100。
+ /// 与后端 WXOrderAmountInfo 保持一致(camelCase JSON):
+ /// shopName, createTime, orderId, status, productPrice, orderPrice, purchasePrice, freight,
+ /// predictCommissionFee, commissionAmount, discountedPrice, profit, purchaseStatus,
+ /// outAmount, refundStatus, refundStatusString, refundAmount, externalProcurement
+ /// 金额字段单位均为分(显示时需除以 100)。
///
public class ShopRevenueStatisticsRow
{
- /// A.app_key 店铺 app_key
- [JsonProperty("app_key")]
- public string AppKey { get; set; }
+ ///
+ /// 店铺名称
+ ///
+ [JsonProperty("shop_name")]
+ public string ShopName { get; set; }
- /// B.create_time 订单创建时间(接口为秒级时间戳,由 UnixDateTimeConverter 转换为 DateTime)
+ ///
+ /// 下单时间(秒级时间戳)
+ ///
[JsonProperty("create_time")]
[JsonConverter(typeof(UnixDateTimeConverter))]
public DateTime CreateTime { get; set; }
- /// B.order_id 订单号
+ ///
+ /// 订单号
+ ///
[JsonProperty("order_id")]
public string OrderId { get; set; }
- /// B.status 订单状态:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成;200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消
+ ///
+ /// 订单状态
+ ///
+ [JsonProperty("status")]
public int Status { get; set; }
- /// B.product_price 商品总价(分)
+ ///
+ /// 商品总价(分)
+ ///
[JsonProperty("product_price")]
public int ProductPrice { get; set; }
- /// B.order_price 订单金额(分),计算公式:order_price = original_order_price - discounted_price - deduction_price - change_down_price
+ ///
+ /// 订单金额(分)
+ ///
[JsonProperty("order_price")]
public int OrderPrice { get; set; }
- /// B.purchase_price 采购价(分)
+ ///
+ /// 采购价(分)
+ ///
[JsonProperty("purchase_price")]
public int PurchasePrice { get; set; }
- /// B.freight 运费(分)
+ ///
+ /// 运费(分)
+ ///
+ [JsonProperty("freight")]
public int Freight { get; set; }
- /// B.predict_commission_fee 平台服务费(微信小店:预计技术服务费)(分)
+ ///
+ /// 平台服务费(预计技术服务费,分)
+ ///
[JsonProperty("predict_commission_fee")]
public int PredictCommissionFee { get; set; }
- /// B.commission_amount 达人佣金(微信小店:分账金额)(分)
+ ///
+ /// 达人佣金(分)
+ ///
[JsonProperty("commission_amount")]
public int CommissionAmount { get; set; }
- /// B.discounted_price 优惠券优惠金额(分)
+ ///
+ /// 优惠金额(分)
+ ///
[JsonProperty("discounted_price")]
public int DiscountedPrice { get; set; }
- /// B.profit 利润(分),计算公式:实付金额 - 采购价 - 平台服务费 - 达人佣金(采购价、达人佣金为空值则按 0)
+ ///
+ /// 利润(分)
+ ///
+ [JsonProperty("profit")]
public int Profit { get; set; }
- /// B.purchase_status 采购状态:0-未采购;1-采购(默认值 0,推送仓库时修改为 1)
+ ///
+ /// 采购状态
+ ///
[JsonProperty("purchase_status")]
public int PurchaseStatus { get; set; }
- /// c.refund_status 退款状态(4/11 视为已退款,与 RevenueStatisticsAggregator.IsRefunded 保持一致)
+ ///
+ /// 外采金额(分),out_amount为空或0时从中转仓订单表补全
+ ///
+ [JsonProperty("out_amount")]
+ public int OutAmount { get; set; }
+
+ ///
+ /// 售后状态(订单明细 refund_status)
+ ///
[JsonProperty("refund_status")]
public int RefundStatus { get; set; }
- /// 店铺名称(由 app_key 关联店铺表得到,展示用,非接口原始字段)
- [JsonProperty("shop_name")]
- public string ShopName { get; set; }
+ ///
+ /// 售后状态(退款单 status)
+ ///
+ [JsonProperty("refund_status_string")]
+ public string RefundStatusString { get; set; }
+ ///
+ /// 售后退款金额(分)
+ ///
+ [JsonProperty("refund_amount")]
+ public int RefundAmount { get; set; }
+
+ ///
+ /// 是否外采:0=未从中转仓订单表查到,1=查到
+ ///
+ [JsonProperty("external_procurement")]
+ public int ExternalProcurement { get; set; }
}
///
@@ -98,7 +146,6 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
{
/// 销售额(最终有效销售额)
public decimal SalesAmount { get; set; }
-
/// 有效订单数(已扣除退款订单)
public int ValidOrderCount { get; set; }
@@ -111,14 +158,20 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// 商品销量(有效销量)
public int ProductSalesVolume { get; set; }
- /// 客户数(成交客户)
- public int CustomerCount { get; set; }
+ /// 平台服务费总额(元)
+ public decimal PlatformFeeTotal { get; set; }
+
+ /// 达人佣金(元)
+ public decimal Commission { get; set; }
/// 客单价(平均每单金额)
public decimal AvgOrderAmount { get; set; }
/// 退款率(退款订单占比,百分数,如 2.31 表示 2.31%)
public decimal RefundRate { get; set; }
+
+ /// 平均利润率(利润 / 采购金额,百分数,如 12.34 表示 12.34%)
+ public decimal AverageProfitRate { get; set; }
}
///
@@ -174,11 +227,17 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// 退款金额(元)
public decimal RefundAmount { get; set; }
- /// 客户数
- public int CustomerCount { get; set; }
+ /// 平台服务费总额(元)
+ public decimal PlatformFeeTotal { get; set; }
+
+ /// 达人佣金(元)
+ public decimal Commission { get; set; }
/// 客单价(元)
public decimal AvgOrderAmount { get; set; }
+
+ /// 平均利润率(利润 / 采购金额,百分数,如 12.34 表示 12.34%)
+ public decimal AverageProfitRate { get; set; }
}
///
@@ -220,6 +279,15 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// 退款金额(元)
public decimal RefundAmount { get; set; }
+ /// 平台服务费总额(元)
+ public decimal PlatformFeeTotal { get; set; }
+
+ /// 达人佣金(元)
+ public decimal Commission { get; set; }
+
+ /// 平均利润率(利润 / 采购金额,百分数,如 12.34 表示 12.34%)
+ public decimal AverageProfitRate { get; set; }
+
/// 客户数
public int CustomerCount { get; set; }
@@ -234,7 +302,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
{
/// 店铺 app_key(A.app_key)
public string AppKey { get; set; }
-
+ public string ShopId { get; set; }
/// 店铺名称
public string ShopName { get; set; }
}
@@ -299,4 +367,4 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
writer.WriteValue(milliseconds);
}
}
-}
\ No newline at end of file
+}
diff --git a/29WxOrderWpf/Ctrls/Statistics/PeriodRevenueStatisticsView.xaml b/29WxOrderWpf/Ctrls/Statistics/PeriodRevenueStatisticsView.xaml
index 668a765..477222e 100644
--- a/29WxOrderWpf/Ctrls/Statistics/PeriodRevenueStatisticsView.xaml
+++ b/29WxOrderWpf/Ctrls/Statistics/PeriodRevenueStatisticsView.xaml
@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:stat="clr-namespace:_29WxOrderWpf.Ctrls.Statistics"
xmlns:vm="clr-namespace:_29WxOrderWpf.Ctrls.Statistics.ViewModels"
d:Background="#F5F7FB"
d:DataContext="{d:DesignInstance Type=vm:PeriodRevenueStatisticsViewModel}"
@@ -13,11 +14,6 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/29WxOrderWpf/Ctrls/Statistics/RevenueStatisticsSearchBar.xaml.cs b/29WxOrderWpf/Ctrls/Statistics/RevenueStatisticsSearchBar.xaml.cs
new file mode 100644
index 0000000..c1137b5
--- /dev/null
+++ b/29WxOrderWpf/Ctrls/Statistics/RevenueStatisticsSearchBar.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace _29WxOrderWpf.Ctrls.Statistics
+{
+ ///
+ /// 营收统计 - 查询条件工具栏(时间范围 + 日期区间 + 店铺 + 查询/重置)。
+ /// 通过继承父级 DataContext 绑定,供综合统计 / 天周月周期统计共用。
+ ///
+ public partial class RevenueStatisticsSearchBar : UserControl
+ {
+ public RevenueStatisticsSearchBar()
+ {
+ InitializeComponent();
+ }
+ }
+}
\ No newline at end of file
diff --git a/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml b/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml
new file mode 100644
index 0000000..545b14b
--- /dev/null
+++ b/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml.cs b/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml.cs
new file mode 100644
index 0000000..2a9dba2
--- /dev/null
+++ b/29WxOrderWpf/Ctrls/Statistics/RevenueSummaryCards.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace _29WxOrderWpf.Ctrls.Statistics
+{
+ ///
+ /// 营收统计 - 顶部汇总卡片(2 行 x 5 列)。
+ /// 通过继承父级 DataContext 绑定 Summary(ShopRevenueSummary),
+ /// 供综合统计 / 天周月周期统计共用。
+ ///
+ public partial class RevenueSummaryCards : UserControl
+ {
+ public RevenueSummaryCards()
+ {
+ InitializeComponent();
+ }
+ }
+}
\ No newline at end of file
diff --git a/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs b/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs
index 4836f9c..ff29381 100644
--- a/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs
+++ b/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs
@@ -108,9 +108,12 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
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;
+ 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;
+ decimal platformFeeTotal = valid.Sum(r => (decimal)(r.PredictCommissionFee)) / 100m;
+ decimal commission = valid.Sum(r => (decimal)(r.CommissionAmount)) / 100m;
+ decimal purchaseAmount = valid.Sum(r => (decimal)(r.PurchasePrice)) / 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();
@@ -123,9 +126,11 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
Profit = profit,
RefundAmount = refundAmount,
ProductSalesVolume = valid.Count,
- CustomerCount = validOrderCount,
+ PlatformFeeTotal = platformFeeTotal,
+ Commission = commission,
AvgOrderAmount = validOrderCount == 0 ? 0m : salesAmount / validOrderCount,
- RefundRate = totalOrderCount == 0 ? 0m : (decimal)refundOrderCount * 100m / totalOrderCount
+ RefundRate = totalOrderCount == 0 ? 0m : (decimal)refundOrderCount * 100m / totalOrderCount,
+ AverageProfitRate = purchaseAmount == 0m ? 0m : profit / purchaseAmount * 100m
};
}
@@ -140,7 +145,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
g => new RevenueTrendPoint
{
Date = g.Key,
- SalesAmount = g.Sum(r => (decimal)r.OrderPrice) / 100m,
+ SalesAmount = g.Sum(r => (decimal)(r.OrderPrice)) / 100m,
OrderCount = g.Select(r => r.OrderId).Distinct().Count()
});
@@ -159,15 +164,15 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
public static List GetShopSalesProportion(IEnumerable rows)
{
var list = (rows ?? new List()).Where(IsValidOrder).ToList();
- decimal total = list.Sum(r => (decimal)r.OrderPrice) / 100m;
+ 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)
+ 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();
@@ -184,17 +189,21 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
{
var valid = g.Where(IsValidOrder).ToList();
var refunded = g.Where(IsRefunded).ToList();
- decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
+ decimal salesAmount = valid.Sum(r => (decimal)(r.OrderPrice)) / 100m;
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
+ decimal profit = valid.Sum(r => (decimal)(r.Profit)) / 100m;
+ decimal purchaseAmount = valid.Sum(r => (decimal)(r.PurchasePrice)) / 100m;
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
+ Profit = profit,
+ RefundAmount = refunded.Sum(r => (decimal)(r.OrderPrice)) / 100m,
+ PlatformFeeTotal = valid.Sum(r => (decimal)(r.PredictCommissionFee)) / 100m,
+ Commission = valid.Sum(r => (decimal)(r.CommissionAmount)) / 100m,
+ AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount,
+ AverageProfitRate = purchaseAmount == 0m ? 0m : profit / purchaseAmount * 100m
};
})
.OrderByDescending(i => i.SalesAmount)
@@ -215,7 +224,9 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
{
var valid = g.Where(IsValidOrder).ToList();
var refunded = g.Where(IsRefunded).ToList();
- decimal salesAmount = valid.Sum(r => (decimal)r.OrderPrice) / 100m;
+ decimal salesAmount = valid.Sum(r => (decimal)(r.OrderPrice)) / 100m;
+ decimal profit = valid.Sum(r => (decimal)(r.Profit)) / 100m;
+ decimal purchaseAmount = valid.Sum(r => (decimal)(r.PurchasePrice)) / 100m;
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
return new ShopPeriodStatisticsItem
{
@@ -223,8 +234,11 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
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,
+ Profit = profit,
+ RefundAmount = refunded.Sum(r => (decimal)(r.OrderPrice)) / 100m,
+ PlatformFeeTotal = valid.Sum(r => (decimal)(r.PredictCommissionFee)) / 100m,
+ Commission = valid.Sum(r => (decimal)(r.CommissionAmount)) / 100m,
+ AverageProfitRate = purchaseAmount == 0m ? 0m : profit / purchaseAmount * 100m,
CustomerCount = orderCount,
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount
};
@@ -245,6 +259,9 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
SalesAmount = 0m,
Profit = 0m,
RefundAmount = 0m,
+ PlatformFeeTotal = 0m,
+ Commission = 0m,
+ AverageProfitRate = 0m,
CustomerCount = 0,
AvgOrderAmount = 0m
});
@@ -310,19 +327,22 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
}
///
- /// 默认实现:返回模拟数据,用于页面演示/联调。
- /// 接入真实接口时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。
+ /// 默认实现:请求真实接口(shop_revenue_statistics/get_shop_order_amount)。
+ /// 接入其他数据源时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。
///
public class ShopRevenueStatisticsService : IShopRevenueStatisticsService
{
+ /// 缓存有效期:订单/退款数据会持续变化,超过该时长后缓存自动失效并重新请求。
+ private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
+
///
- /// 明细数据缓存。
- /// key = 店铺id|yyyyMMdd(店铺 × 天),value = 该店铺当天的明细行。
- /// 查询按“店铺 × 日期”命中;完全未命中时所有店铺 + 整个时间范围一次请求,
- /// 部分命中时只请求缺失的店铺/日期段。
+ /// 明细数据缓存(按天缓存,纯时间维度,不含店铺)。
+ /// key = 日期(yyyyMMdd),value = 该天全部店铺的明细 + 缓存时间。
+ /// 查询时按天命中:未命中的天合并成连续段一次请求,返回后按天拆分写入;
+ /// 前台选某一个或某多个店铺时,按所选店铺(ShopName)过滤,复用同一批时间数据。
///
- private readonly Dictionary> _rowCache =
- new Dictionary>();
+ private readonly Dictionary _rowCache =
+ new Dictionary();
private readonly object _cacheLock = new object();
private sealed class DateRange
@@ -331,91 +351,161 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
public DateTime End;
}
- private static string CacheKey(string shopId, DateTime day)
+ private sealed class CacheEntry
{
- return shopId + "|" + day.ToString("yyyyMMdd");
+ public List Rows;
+ public DateTime CachedAt;
+ }
+
+ private static string CacheKey(DateTime day)
+ {
+ return day.Date.ToString("yyyyMMdd");
+ }
+
+ /// 枚举 startDate ~ endDate 之间的每一天(含首尾)
+ private static IEnumerable EnumerateDays(DateTime startDate, DateTime endDate)
+ {
+ for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
+ yield return d;
+ }
+
+ /// 该天缓存是否有效(存在且未过期);调用方需持有 _cacheLock
+ private bool IsValidLocked(DateTime day)
+ {
+ return _rowCache.TryGetValue(CacheKey(day), out var entry)
+ && DateTime.Now - entry.CachedAt <= CacheDuration;
+ }
+
+ /// 把缺失日期合并为连续日期段(每段一次请求)
+ private static List BuildRuns(List missingDays)
+ {
+ var sorted = missingDays.OrderBy(d => d).ToList();
+ var runs = new List();
+ 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> QueryRowsAsync(ShopRevenueStatisticsQuery query)
{
- // 1. 展开本次查询涉及的店铺(ShopKey 为空 = 全部店铺)
- List shopIds;
- if (string.IsNullOrEmpty(query.ShopKey))
- shopIds = (await GetShopOptionsAsync()).Select(a => a.AppKey).ToList();
- else
- shopIds = new List { query.ShopKey };
- if (shopIds.Count == 0)
- return new List();
-
var startDay = query.StartDate.Date;
var endDay = query.EndDate.Date;
- int totalDays = (int)(endDay - startDay).Days + 1;
+ if (startDay > endDay)
+ {
+ var tmp = startDay;
+ startDay = endDay;
+ endDay = tmp;
+ }
- // 2. 命中检测:按 店铺 × 天 找出缺失项
- var missingByShop = new Dictionary>();
- int missingCount = 0;
+ // 1. 找出未命中或已过期的天
+ List missingDays;
lock (_cacheLock)
{
- foreach (var shopId in shopIds)
+ missingDays = EnumerateDays(startDay, endDay)
+ .Where(d => !IsValidLocked(d))
+ .ToList();
+ }
+
+ // 2. 有缺失:按连续段请求整个时间范围的全部店铺明细,并按天写入缓存
+ if (missingDays.Count > 0)
+ {
+ var shopIds = GetShopOptions().Select(a => a.ShopId).ToList();
+ if (shopIds.Count == 0)
+ return new List();
+
+ foreach (var run in BuildRuns(missingDays))
{
- var missingDays = new List();
- 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;
+ var rows = await RequestRowsAsync(shopIds, run.Start, run.End);
+ CacheResponse(run.Start, run.End, rows);
}
}
- // 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 { pair.Key }, run.Start, run.End);
- CacheResponse(new List { pair.Key }, run.Start, run.End, rows, pair.Key);
- }
- }
-
- return GetCachedRows(shopIds, startDay, endDay);
+ // 3. 按天从缓存拼装完整范围,再按所选店铺过滤(ShopKey 为空 = 全部店铺)
+ return FilterByShop(GetCachedRows(startDay, endDay), query.ShopKey);
}
- /// 按 店铺 × 日期 从缓存拼装结果
- private List GetCachedRows(List shopIds, DateTime startDate, DateTime endDate)
+ ///
+ /// 把一次请求的结果按天写入缓存。
+ /// 本次请求覆盖的每一天都以返回数据为准整体替换(无数据的天记为空的已缓存,
+ /// 避免“该天无数据”被反复判定为未命中而重复请求)。
+ ///
+ private void CacheResponse(DateTime startDate, DateTime endDate, List rows)
+ {
+ if (rows == null)
+ rows = new List();
+
+ lock (_cacheLock)
+ {
+ RemoveExpiredEntriesLocked();
+
+ var byDay = rows
+ .Where(r => r.CreateTime.Date >= startDate.Date && r.CreateTime.Date <= endDate.Date)
+ .GroupBy(r => r.CreateTime.Date)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ foreach (var day in EnumerateDays(startDate, endDate))
+ {
+ byDay.TryGetValue(day, out var dayRows);
+ _rowCache[CacheKey(day)] = new CacheEntry { Rows = dayRows ?? new List(), CachedAt = DateTime.Now };
+ }
+ }
+ }
+
+ /// 按天从缓存拼装某时间范围的明细(返回新列表,避免外部修改污染缓存)
+ private List GetCachedRows(DateTime startDate, DateTime endDate)
{
var result = new List();
lock (_cacheLock)
{
- for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
+ foreach (var day in EnumerateDays(startDate, endDate))
{
- foreach (var shopId in shopIds)
- {
- if (_rowCache.TryGetValue(CacheKey(shopId, d), out var list))
- result.AddRange(list);
- }
+ if (_rowCache.TryGetValue(CacheKey(day), out var entry))
+ result.AddRange(entry.Rows);
}
}
return result;
}
+ /// 按所选店铺过滤明细:ShopKey 为空返回全部;否则按店铺名(ShopName)精确匹配。
+ private static List FilterByShop(List rows, string shopKey)
+ {
+ if (rows == null)
+ return new List();
+
+ if (string.IsNullOrEmpty(shopKey))
+ return new List(rows);
+
+ var shop = GetShopOptions().FirstOrDefault(a => a.AppKey == shopKey);
+ if (shop == null || string.IsNullOrEmpty(shop.ShopName))
+ return new List();
+
+ return rows
+ .Where(r => string.Equals(r.ShopName, shop.ShopName, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ /// 清理已过期的缓存条目(调用方需持有 _cacheLock)
+ private void RemoveExpiredEntriesLocked()
+ {
+ var expiredKeys = _rowCache
+ .Where(p => DateTime.Now - p.Value.CachedAt > CacheDuration)
+ .Select(p => p.Key)
+ .ToList();
+ foreach (var key in expiredKeys)
+ _rowCache.Remove(key);
+ }
+
/// 请求接口一次(可传多个店铺),返回原始明细行
private async Task> RequestRowsAsync(List shopIds, DateTime startDate, DateTime endDate)
{
@@ -447,81 +537,6 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
}
- ///
- /// 把一次请求的结果写入缓存(按 店铺id|日期 分桶)。
- /// 除有数据的 店铺×天 外,会把本次请求覆盖但无数据的 店铺×天 也标记为已缓存(空列表),
- /// 避免“该店铺当天无数据”被反复判定为未命中而重复请求数据库。
- ///
- private void CacheResponse(List shopIds, DateTime startDate, DateTime endDate, List rows, string fallbackShopId)
- {
- if (rows == null)
- rows = new List();
-
- // 能否按 店铺×天 归属:
- // 单店铺请求一定可以(用请求的店铺兜底);多店铺请求要求行内带 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();
- _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();
- _rowCache[key] = list;
- }
- list.Add(row);
- }
- }
- }
-
- /// 把缺失日期合并为连续日期段(每段一次请求)
- private static List BuildRuns(List missingDays)
- {
- var sorted = missingDays.OrderBy(d => d).ToList();
- var runs = new List();
- 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 GetSummaryAsync(ShopRevenueStatisticsQuery query)
{
var rows = await QueryRowsAsync(query);
@@ -553,16 +568,21 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return RevenueStatisticsAggregator.GetPeriodStatistics(rows, query.StartDate, query.EndDate, periodType);
}
- public Task> GetShopOptionsAsync()
+ /// 店铺下拉列表(同步读取,供查询与过滤使用)
+ private static List GetShopOptions()
{
- var list = GlobalConfig.ShopLoginList.Select(a =>
+ return GlobalConfig.ShopLoginList.Select(a =>
new ShopOption()
{
AppKey = a.ShopId,
+ ShopId = a.ErpShopId,
ShopName = a.ShopAccount
}).ToList();
+ }
- return Task.FromResult(list);
+ public Task> GetShopOptionsAsync()
+ {
+ return Task.FromResult(GetShopOptions());
}
}
diff --git a/29WxOrderWpf/Ctrls/Statistics/ShopRevenueStatisticsView.xaml b/29WxOrderWpf/Ctrls/Statistics/ShopRevenueStatisticsView.xaml
index 26c50cf..8613351 100644
--- a/29WxOrderWpf/Ctrls/Statistics/ShopRevenueStatisticsView.xaml
+++ b/29WxOrderWpf/Ctrls/Statistics/ShopRevenueStatisticsView.xaml
@@ -130,282 +130,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
@@ -500,9 +229,19 @@
Header="退款金额" />
+ Header="平台服务费总额" />
+
+
-
\ No newline at end of file
+
diff --git a/29WxOrderWpf/Ctrls/Statistics/ViewModels/PeriodRevenueStatisticsViewModel.cs b/29WxOrderWpf/Ctrls/Statistics/ViewModels/PeriodRevenueStatisticsViewModel.cs
index 6c8f406..4f3d89f 100644
--- a/29WxOrderWpf/Ctrls/Statistics/ViewModels/PeriodRevenueStatisticsViewModel.cs
+++ b/29WxOrderWpf/Ctrls/Statistics/ViewModels/PeriodRevenueStatisticsViewModel.cs
@@ -30,6 +30,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
private readonly DelegateCommand _lastPageCommand;
private ShopOption _selectedShop;
+ private ShopRevenueSummary _summary;
private ObservableCollection _statistics;
private List _allItems = new List();
private int _pageIndex = 1;
@@ -209,6 +210,17 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
#region 统计结果
+ /// 汇总卡片数据
+ public ShopRevenueSummary Summary
+ {
+ get { return _summary; }
+ private set
+ {
+ _summary = value;
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+
/// 当前页的周期×店铺统计表格数据
public ObservableCollection Statistics
{
@@ -486,6 +498,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
};
var table = await _service.GetPeriodStatisticsAsync(query, _periodType);
+ Summary = await _service.GetSummaryAsync(query);
_allItems = table ?? new List();
TotalCount = _allItems.Count;
TotalPages = TotalCount == 0 ? 1 : (int)Math.Ceiling(TotalCount / (double)PageSize);