修改统计功能 使用新API接口

This commit is contained in:
chfei committed 2026-10-08 12:07:30 +08:00
1 parent 03f71a5d64
commit 8431a8f6b0
6 files changed
+1117 -708

No files matched your search

@@ -1,4 +1,4 @@
using Newtonsoft.Json;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using WPFCommon.Models;
@@ -6,152 +6,219 @@ using WPFCommon.Models;
namespace _29WxOrderWpf.Ctrls.Statistics.Models
{
/// <summary>
/// 店铺营收统计 - 查询参数
/// 店铺营收统计 - 查询参数。
/// 对应新接口 shop_revenue_statistics/get_shop_order_amount 与
/// shop_revenue_statistics/get_shop_stats_details 的请求体(StartDate / EndDate / ShopIds)。
/// </summary>
public class ShopRevenueStatisticsQuery
{
/// <summary>开始时间</summary>
/// <summary>开始日期</summary>
public DateTime StartDate { get; set; }
/// <summary>结束时间</summary>
/// <summary>结束日期</summary>
public DateTime EndDate { get; set; }
/// <summary>选中的店铺 app_key 列表;空/Null 表示全部店铺</summary>
public List<string> ShopKeys { get; set; }
/// <summary>
/// 选中的店铺 ERP 店铺ID(ShopLoginInfo.ErpShopId)列表;
/// 空/Null 表示全部店铺(服务层会取全部店铺的 ErpShopId)。
/// </summary>
public List<string> ShopIds { get; set; }
}
/// <summary>
/// 店铺营收统计 - 接口返回的原始明细行。
/// 与后端 WXOrderAmountInfo 保持一致(camelCase JSON):
/// shopName, createTime, orderId, status, productPrice, orderPrice, purchasePrice, freight,
/// predictCommissionFee, commissionAmount, discountedPrice, profit, purchaseStatus,
/// outAmount, refundStatus, refundStatusString, refundAmount, externalProcurement
/// 金额字段单位均为分(显示时需除以 100)。
/// 店铺统计记录(接口返回)。
/// 接口返回结构:
/// shop_revenue_statistics/GetShopStats -> Result&lt;Dictionary&lt;string, List&lt;ShopStatsResponse&gt;&gt;&gt;
/// Key = 店铺ID,Value = 该店铺按 <see cref="ShopStatsGroupType"/> 聚合后的记录列表
/// (汇总=1 条;天/周/月=每个自然日/自然周/自然月各 1 条)。
/// 注意:接口只返回按 GroupType 聚合后的记录,不返回“整段汇总”,
/// 页面上的汇总卡片由客户端对返回记录自行合计(GroupType=Summary 时每店铺一条,直接相加即可)。
/// 金额字段单位均为“分”,显示时需除以 100 转为元。
/// </summary>
public class ShopRevenueStatisticsRow
public class ShopStatsResponse
{
/// <summary>
/// 店铺名称
/// 店铺ID
/// </summary>
[JsonProperty("shop_name")]
public string ShopName { get; set; }
public string ShopId { get; set; }
/// <summary>
/// 下单时间(秒级时间戳)
/// 销售订单创建日期
/// </summary>
[JsonProperty("create_time")]
[JsonConverter(typeof(UnixDateTimeConverter))]
public DateTime CreateTime { get; set; }
public DateTime StatDate { get; set; }
/// <summary>
/// 订单号
/// 订单数(有效订单,不含退款订单)
/// </summary>
[JsonProperty("order_id")]
public string OrderId { get; set; }
public int OrderCount { get; set; }
/// <summary>
/// 订单状态
/// 销售总额(分)
/// </summary>
[JsonProperty("status")]
public int Status { get; set; }
/// <summary>
/// 商品总价(分)
/// </summary>
[JsonProperty("product_price")]
public int ProductPrice { get; set; }
/// <summary>
/// 订单金额(分)
/// </summary>
[JsonProperty("order_price")]
public int OrderPrice { get; set; }
/// <summary>
/// 采购价(分)
/// </summary>
[JsonProperty("purchase_price")]
public int PurchasePrice { get; set; }
/// <summary>
/// 运费(分)
/// </summary>
[JsonProperty("freight")]
public int Freight { get; set; }
/// <summary>
/// 平台服务费(预计技术服务费,分)
/// </summary>
[JsonProperty("predict_commission_fee")]
public int PredictCommissionFee { get; set; }
/// <summary>
/// 达人佣金(分)
/// </summary>
[JsonProperty("commission_amount")]
public int CommissionAmount { get; set; }
/// <summary>
/// 优惠金额(分)
/// </summary>
[JsonProperty("discounted_price")]
public int DiscountedPrice { get; set; }
public long SalesAmount { get; set; }
/// <summary>
/// 利润(分)
/// </summary>
[JsonProperty("profit")]
public int Profit { get; set; }
public long ProfitAmount { get; set; }
/// <summary>
/// 采购状态
/// 平台佣金(分)
/// </summary>
[JsonProperty("purchase_status")]
public int PurchaseStatus { get; set; }
public long PlatformFee { get; set; }
/// <summary>
/// 外采金额(分),out_amount为空或0时从中转仓订单表补全
/// 退款订单数
/// </summary>
public int RefundOrderCount { get; set; }
/// <summary>
/// 已成功退款金额(分,店铺端已退款成功)
/// </summary>
public long RefundDone { get; set; }
/// <summary>
/// 退款处理中金额(分,店铺端退款中)
/// </summary>
public long RefundPending { get; set; }
/// <summary>
/// 采购单数
/// </summary>
public int PurchaseCount { get; set; }
/// <summary>
/// 总花费金额(分,买手号采购付款金额)
/// </summary>
public long TotalPurchase { get; set; }
/// <summary>
/// 售后中买手总金额(分)
/// </summary>
public long TotalRefund => DoneRefund + PendingRefund + RejectedRefund;
/// <summary>
/// 已成功退款金额(分,买手号采购退款成功)
/// </summary>
public long DoneRefund { get; set; }
/// <summary>
/// 未退回金额(分)
/// </summary>
public long PendingRefund { get; set; }
/// <summary>
/// 被拒绝金额(分)
/// </summary>
public long RejectedRefund { get; set; }
}
/// <summary>
/// 销售趋势记录(接口返回):跨店铺合计后的按天趋势,供综合统计页的销售趋势折线图使用。
/// 接口返回结构:
/// shop_revenue_statistics/GetShopStatsTrend
/// -> Result&lt;List&lt;ShopStatsTrendResponse&gt;&gt;
/// 请求参数:StartDate / EndDate / ShopIds(不需要 GroupType,固定按天返回)。
/// 金额字段单位为“分”,显示时需除以 100 转为元。
/// </summary>
public class ShopStatsTrendResponse
{
/// <summary>
/// 统计日期(天)
/// </summary>
public DateTime StatDate { get; set; }
/// <summary>
/// 销售额(分)
/// </summary>
public long SalesAmount { get; set; }
/// <summary>
/// 订单数
/// </summary>
public int OrderCount { get; set; }
}
/// <summary>
/// 订单明细(接口返回):订单列表弹窗 ShowOrderDialogView 的数据行。
/// 接口返回结构:
/// shop_revenue_statistics/GetShopOrderDetails
/// -> Result&lt;List&lt;ShopOrderDetailResponse&gt;&gt;
/// 请求参数:StartDate / EndDate / ShopIds / OrderType(见 <see cref="ShopOrderDetailType"/>)。
/// 金额字段单位均为“分”,显示时需除以 100 转为元。
/// </summary>
public class ShopOrderDetailResponse
{
/// <summary>
/// 店铺ID
/// </summary>
public long ShopId { get; set; }
/// <summary>
/// 店铺名称
/// </summary>
public string ShopName { get; set; }
/// <summary>
/// 商铺订单号
/// </summary>
public string OrderId { get; set; }
/// <summary>
/// 订单创建时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 订单金额(分)
/// </summary>
public int OrderPrice { get; set; }
/// <summary>
/// 订单退款金额(分),即商铺订单商品明细的退款金额之和;有效订单为 0
/// </summary>
public int OrderRefundAmount { get; set; }
/// <summary>
/// 外采金额(分),即该订单下所有买手订单的购买金额之和
/// </summary>
[JsonProperty("out_amount")]
public int OutAmount { get; set; }
/// <summary>
/// 售后状态(订单明细 refund_status)
/// 买手未退回金额(分),即买手订单中退款未成功的退款金额之和;无售后时为 0
/// </summary>
[JsonProperty("refund_status")]
public int RefundStatus { get; set; }
public int UnrefundedAmount { get; set; }
/// <summary>
/// 售后状态(退款单 status)
/// 买手订单信息(一个订单号对应多个买手订单)
/// </summary>
[JsonProperty("refund_status_string")]
public string RefundStatusString { get; set; }
public List<BuyerOrderInfo> BuyerOrders { get; set; } = new List<BuyerOrderInfo>();
}
/// <summary>
/// 买手订单信息(订单明细下的子项,一个商铺订单可对应多个买手订单)
/// </summary>
public class BuyerOrderInfo
{
/// <summary>
/// 外部订单(买手订单)号
/// </summary>
public string OutOrderSn { get; set; }
/// <summary>
/// 售后退款金额(分)
/// 金额(分)
/// </summary>
public int OutAmount { get; set; }
/// <summary>
/// 退款金额(分)
/// </summary>
[JsonProperty("refund_amount")]
public int RefundAmount { get; set; }
/// <summary>
/// 是否外采:0=未从中转仓订单表查到,1=查到
/// 退款状态
/// </summary>
[JsonProperty("external_procurement")]
public int ExternalProcurement { get; set; }
/// <summary>
/// 买手退款金额
/// </summary>
[JsonProperty("buyer_refund_amount")]
public int BuyerRefundAmount { get; set; }
/// <summary>
/// 买手退款状态
/// </summary>
[JsonProperty("buyer_refund_status")]
public string BuyerRefundStatus { get; set; }
public string RefundStatusText { get; set; }
}
/// <summary>
@@ -167,7 +234,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>利润(实际利润)</summary>
public decimal Profit { get; set; }
/// <summary>退款金额(统计期间退款)</summary>
/// <summary>退款金额(统计期间店铺端退款:已成功 + 处理中)</summary>
public decimal RefundAmount { get; set; }
/// <summary>退款订单数</summary>
@@ -176,7 +243,10 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>平台服务费总额(元)</summary>
public decimal PlatformFeeTotal { get; set; }
/// <summary>达人佣金(元)</summary>
/// <summary>
/// 达人佣金(元)。
/// 新接口未提供该字段,固定为 0,保留属性与页面展示,待后端补充字段后直接接上。
/// </summary>
public decimal Commission { get; set; }
/// <summary>客单价(平均每单金额)</summary>
@@ -239,7 +309,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>店铺名称</summary>
public string ShopName { get; set; }
/// <summary>订单数</summary>
/// <summary>订单数(有效订单,不含退款订单)</summary>
public int OrderCount { get; set; }
/// <summary>销售额(元)</summary>
@@ -255,7 +325,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>平台服务费总额(元)</summary>
public decimal PlatformFeeTotal { get; set; }
/// <summary>达人佣金(元)</summary>
/// <summary>达人佣金(元)。新接口未提供该字段,固定为 0。</summary>
public decimal Commission { get; set; }
/// <summary>客单价(元)</summary>
@@ -280,6 +350,51 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
Month = 2
}
/// <summary>
/// 接口聚合方式(请求参数 GroupType,按数字传值)。
/// 每种方式都返回 Dictionary&lt;店铺ID, List&lt;ShopStatsResponse&gt;&gt;:
/// 汇总时每个店铺 1 条,天/周/月时每个店铺每个周期 1 条。
/// </summary>
public enum ShopStatsGroupType
{
/// <summary>
/// 汇总:每个商铺只返回一条(整个查询范围合计)
/// </summary>
Summary = 0,
/// <summary>
/// 天:按自然日聚合,每个商铺每天一条
/// </summary>
Day = 1,
/// <summary>
/// 周:按自然周聚合(周一为一周开始),每个商铺每周一条
/// </summary>
Week = 2,
/// <summary>
/// 月:按自然月聚合,每个商铺每月一条
/// </summary>
Month = 3
}
/// <summary>
/// 订单明细类型(订单列表弹窗的入口类型,对应接口参数 OrderType,按数字传值)。
/// 数值与后端枚举保持一致:有效订单=1、退款订单=2、买手未退回金额的订单=3
/// (后端枚举里的“全部订单 All=0”本页不使用,故不定义)。
/// </summary>
public enum ShopOrderDetailType
{
/// <summary>有效订单:没有发生售后的订单(对应统计里的订单数 / 销售额 / 利润口径)</summary>
Valid = 1,
/// <summary>退款订单:发生过售后的订单(对应统计里的退款订单数口径)</summary>
Refund = 2,
/// <summary>买手未退回金额的订单:退款订单中买手款项还没退回的(对应统计里的未退回金额口径)</summary>
BuyerUnrefunded = 3
}
/// <summary>
/// 天/周/月周期统计表格行(金额单位:元)。
/// 按所选店铺(或全部店铺)统计:天统计每天一行;周统计每周一行;月统计每月一行。
@@ -292,7 +407,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>周期显示文本(天:yyyy-MM-dd;周:yyyy-MM-dd ~ yyyy-MM-dd;月:yyyy-MM)</summary>
public string Period { get; set; }
/// <summary>订单数</summary>
/// <summary>订单数(有效订单,不含退款订单)</summary>
public int OrderCount { get; set; }
/// <summary>销售额(元)</summary>
@@ -309,7 +424,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
/// <summary>平台服务费总额(元)</summary>
public decimal PlatformFeeTotal { get; set; }
/// <summary>达人佣金(元)</summary>
/// <summary>达人佣金(元)。新接口未提供该字段,固定为 0。</summary>
public decimal Commission { get; set; }
/// <summary>平均利润率(利润 / 采购金额,百分数,如 12.34 表示 12.34%)</summary>
@@ -322,14 +437,51 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
public decimal AvgOrderAmount { get; set; }
}
/// <summary>
/// 店铺营收统计 - 一次查询的全部聚合结果(综合统计页使用)。
/// 由两次请求组成:
/// shop_revenue_statistics/GetShopStats(GroupType=Summary)-> 卡片/店铺统计表/销售占比(每店铺一条,与区间长度无关)
/// shop_revenue_statistics/GetShopStatsTrend -> 销售趋势(跨店铺合计的按天序列)
/// </summary>
public class ShopRevenueStatisticsResult
{
/// <summary>汇总卡片数据</summary>
public ShopRevenueSummary Summary { get; set; }
/// <summary>销售趋势(按天,缺失日期补 0)</summary>
public List<RevenueTrendPoint> Trend { get; set; }
/// <summary>店铺销售占比(饼图)</summary>
public List<ShopSalesProportionItem> ShopProportions { get; set; }
/// <summary>店铺统计表格</summary>
public List<ShopStatisticsItem> ShopStatistics { get; set; }
}
/// <summary>
/// 店铺营收统计 - 天/周/月周期统计查询结果(周期统计页使用)。
/// 接口不返回整段汇总,Summary 由返回的周期数据在客户端合计得出。
/// </summary>
public class ShopPeriodStatisticsResult
{
/// <summary>汇总卡片数据(整个查询时间段,客户端合计)</summary>
public ShopRevenueSummary Summary { get; set; }
/// <summary>周期统计表格(天/周/月各一行)</summary>
public List<ShopPeriodStatisticsItem> Items { get; set; }
}
/// <summary>
/// 店铺下拉选项
/// </summary>
public class ShopOption
{
/// <summary>店铺 app_key(A.app_key)</summary>
public string AppKey { get; set; }
/// <summary>店铺ID(ShopLoginInfo.ShopId)</summary>
public string ShopId { get; set; }
/// <summary>ERP 店铺ID(ShopLoginInfo.ErpShopId),统计接口的 ShopIds 使用该字段</summary>
public string ErpShopId { get; set; }
/// <summary>店铺名称</summary>
public string ShopName { get; set; }
}
@@ -346,55 +498,4 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models
public string Value { get; set; }
}
class UnixDateTimeConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return objectType == typeof(DateTime);
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
// 读取数值(支持 long 和 int 类型)
if (reader.TokenType == JsonToken.Integer)
{
long milliseconds = (long)reader.Value;
// Unix 纪元:1970-01-01 UTC
//DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime epoch = TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1));
if (milliseconds.ToString().Length == 13)
{
return epoch.AddMilliseconds(milliseconds);
}
return epoch.AddSeconds(milliseconds);
}
// 如果传入的是字符串(如 "1700000000000"),也支持
else if (reader.TokenType == JsonToken.String)
{
if (long.TryParse((string)reader.Value, out long milliseconds))
{
//DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime epoch = TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1));
if (milliseconds.ToString().Length == 13)
{
return epoch.AddMilliseconds(milliseconds);
}
return epoch.AddSeconds(milliseconds);
}
}
throw new JsonSerializationException($"无法将 {reader.Value} 转换为 DateTime");
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
// 序列化时转回时间戳
//DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime epoch = TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1));
long milliseconds = (long)((DateTime)value - epoch).TotalMilliseconds;
writer.WriteValue(milliseconds);
}
}
}
@@ -1,14 +1,10 @@
using _29WxOrderWpf.Ctrls.Statistics.Models;
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.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Work;
using WxOrder.Framework.Commons;
@@ -17,127 +13,99 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
{
/// <summary>
/// 店铺营收统计 - 数据服务调用接口。
/// 页面只依赖此接口,接入真实数据源时新建实现类(如对接 HTTP 接口 / SQL 查询),
/// 并通过 ShopRevenueStatisticsViewModel 构造函数注入即可,无需改动页面。
/// 页面只依赖此接口,接入其他数据源时新建实现类,
/// 并通过 ViewModel 构造函数注入即可,无需改动页面。
/// </summary>
public interface IShopRevenueStatisticsService
{
/// <summary>
/// 查询原始统计明细(接口返回的 A.* / B.* / c.* 原始字段)。
/// 真实实现通常通过 SQL / HTTP 一次性返回这些行,再由聚合器计算。
/// 综合统计查询:返回汇总卡片、销售趋势(按天)、店铺销售占比、店铺统计表格。
/// 用 GroupType=Day 一次请求取回按店铺按天的记录,再由客户端汇总,无缓存。
/// </summary>
Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query);
Task<ShopRevenueStatisticsResult> QueryAsync(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 由返回的周期记录在客户端合计。
/// </summary>
Task<ShopPeriodStatisticsResult> QueryPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType);
/// <summary>获取店铺下拉列表(用于查询条件)</summary>
Task<List<ShopOption>> GetShopOptionsAsync();
/// <summary>
/// 订单明细列表(有效订单 / 退款订单 / 买手未退回金额的订单),供订单列表弹窗使用。
/// </summary>
Task<List<ShopOrderDetailResponse>> QueryOrderRowsAsync(ShopRevenueStatisticsQuery query, ShopOrderDetailType detailType);
}
/// <summary>
/// 统计聚合器:把接口返回的原始明细行聚合成页面所需的汇总/趋势/占比/表格数据。
/// 聚合口径说明(依据接口字段描述):
/// - 订单状态 status:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成;
/// 200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消。
/// - 有效订单:已付款(20/21/30/100)且未退款;10/250 未付款不计入销售额。
/// - 已退款:c.refund_status 为 4/11,或订单状态为 200(全部商品售后后取消)。
/// 统计聚合器:把接口返回的 <see cref="ShopStatsResponse"/> 聚合成页面所需的汇总/趋势/占比/表格数据。
/// 数据来源:shop_revenue_statistics/GetShopStats(GroupType = Day/Week/Month),
/// 接口只返回“按周期聚合的记录”,页面上所有汇总口径都在这里由客户端合计。
/// 口径说明(依据接口字段含义):
/// - OrderCount 为“有效订单数”,已不含退款订单;因此退款率分母 = OrderCount + RefundOrderCount。
/// - 店铺端退款:RefundDone(已成功)+ RefundPending(处理中)→ 退款金额。
/// - 买手端(采购)退款:TotalRefund = DoneRefund + PendingRefund + RejectedRefund → 售后采购金额;
/// DoneRefund → 已退回采购金额;剩余(待退回)= TotalRefund - DoneRefund。
/// - 接口无“达人佣金”字段,Commission 固定为 0(保留属性与页面展示,待后端补字段)。
/// - 金额字段单位均为“分”,除以 100 转为元。
/// </summary>
public static class RevenueStatisticsAggregator
public static class ShopStatsAggregator
{
/// <summary>订单状态 - 待付款</summary>
public const int StatusPendingPay = 10;
/// <summary>金额换算:分 → 元</summary>
private const decimal FenPerYuan = 100m;
/// <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)
/// <summary>分转元</summary>
public static decimal ToYuan(long fen)
{
if (row == null) return false;
return row.Status == StatusWaitSend
|| row.Status == StatusPartSend
|| row.Status == StatusWaitReceive
|| row.Status == StatusFinished;
return fen / FenPerYuan;
}
/// <summary>是否已退款(c.refund_status 为 4/11,或订单状态为 200 全部商品售后后取消)</summary>
public static bool IsRefunded(ShopRevenueStatisticsRow row)
private static List<ShopStatsResponse> ToList(IEnumerable<ShopStatsResponse> stats)
{
if (row == null) return false;
return row.RefundStatus > 1 || row.RefundStatusString == "MERCHANT_REFUND_SUCCESS" || row.RefundStatusString == "MERCHANT_RETURN_SUCCESS";
return (stats ?? Enumerable.Empty<ShopStatsResponse>())
.Where(a => a != null)
.ToList();
}
/// <summary>是否有效订单(已付款且未退款)</summary>
public static bool IsValidOrder(ShopRevenueStatisticsRow row)
private static IEnumerable<IGrouping<string, ShopStatsResponse>> GroupByShop(IEnumerable<ShopStatsResponse> stats)
{
return !IsRefunded(row);
return ToList(stats).GroupBy(a => a.ShopId ?? string.Empty);
}
/// <summary>买手号退款成功</summary>
public static bool IsBuyerRefundSuccessful(ShopRevenueStatisticsRow row)
/// <summary>店铺显示名:优先按 ERP 店铺ID匹配,其次按平台店铺ID匹配,都匹配不到时退回店铺ID</summary>
public static string ResolveShopName(string shopId)
{
return row.BuyerRefundStatus == "退款成功";
if (string.IsNullOrEmpty(shopId))
return "未知店铺";
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(a => a.ErpShopId == shopId)
?? GlobalConfig.ShopLoginList.FirstOrDefault(a => a.ShopId == shopId);
if (shop == null || string.IsNullOrEmpty(shop.ShopAccount))
return shopId;
return shop.ShopAccount;
}
private static List<ShopRevenueStatisticsRow> GetPaymentOrderList(IEnumerable<ShopRevenueStatisticsRow> rows)
/// <summary>汇总卡片(整个查询时间段,所有选中店铺合计)</summary>
public static ShopRevenueSummary GetSummary(IEnumerable<ShopStatsResponse> stats)
{
return (rows ?? new List<ShopRevenueStatisticsRow>()).Where(a => a.OutAmount > 0).ToList(); //买手付钱后的订单数量
}
/// <summary>汇总卡片</summary>
public static ShopRevenueSummary GetSummary(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = GetPaymentOrderList(rows); //买手付钱后的订单数量
var valid = list.Where(IsValidOrder).ToList();
var refunded = list.Where(IsRefunded).ToList();
var list = ToList(stats);
decimal salesAmount = valid.Sum(r => (decimal)(r.OrderPrice)) / 100m;//有效订单总金额
decimal profit = valid.Concat(refunded).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.OutAmount)) / 100m;
decimal buyerPaymentAmount = valid.Sum(r => (decimal)(r.OutAmount)) / 100m; //买手号付款金额(采购金额)
decimal buyerRefundAmount = refunded.Sum(r => (decimal)(r.OutAmount)) / 100m; //买手号退款金额(采购退款金额)
decimal buyerRefundSuccessedAmount = refunded.Where(IsBuyerRefundSuccessful).Sum(r => (decimal)(r.BuyerRefundAmount)) / 100m; //买手号退款成功金额
decimal salesAmount = ToYuan(list.Sum(a => a.SalesAmount));
decimal profit = ToYuan(list.Sum(a => a.ProfitAmount));
decimal platformFeeTotal = ToYuan(list.Sum(a => a.PlatformFee));
decimal refundAmount = ToYuan(list.Sum(a => a.RefundDone) + list.Sum(a => a.RefundPending));
decimal buyerPaymentAmount = ToYuan(list.Sum(a => a.TotalPurchase));
decimal buyerRefundAmount = ToYuan(list.Sum(a => a.TotalRefund));
decimal buyerRefundSuccessedAmount = ToYuan(list.Sum(a => a.DoneRefund));
int validOrderCount = valid.Select(r => r.OrderId).Distinct().Count();
int validOrderCount = list.Sum(a => a.OrderCount); // 新接口 OrderCount 不含退款订单
int refundOrderCount = list.Sum(a => a.RefundOrderCount);
int totalOrderCount = validOrderCount + refundOrderCount;
// 总订单数(退款率分母)= 已付款订单 + 已退款订单,不含未付款的 10/250
int totalOrderCount = list.Count();
int refundOrderCount = refunded.Select(r => r.OrderId).Distinct().Count();
Debug.WriteLine($"计算利润:{(salesAmount - buyerPaymentAmount - platformFeeTotal)}");
Debug.WriteLine($"正常订单利润:{valid.Sum(r => (decimal)(r.Profit)) / 100}");
Debug.WriteLine($"售后订单利润:{refunded.Sum(r => (decimal)(r.Profit)) / 100}");
return new ShopRevenueSummary
{
SalesAmount = salesAmount,
@@ -146,65 +114,65 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
RefundAmount = refundAmount,
RefundOrderCount = refundOrderCount,
PlatformFeeTotal = platformFeeTotal,
Commission = commission,
Commission = 0m, // 新接口无“达人佣金”字段,保留占位
AvgOrderAmount = validOrderCount == 0 ? 0m : salesAmount / validOrderCount,
RefundRate = totalOrderCount == 0 ? 0m : (decimal)refundOrderCount * 100m / totalOrderCount,
AverageProfitRate = purchaseAmount == 0m ? 0m : profit / purchaseAmount * 100m,
AverageProfitRate = buyerPaymentAmount == 0m ? 0m : profit / buyerPaymentAmount * 100m,
BuyerPaymentAmount = buyerPaymentAmount,
BuyerRefundAmount = buyerRefundAmount,
BuyerRefundSuccessedAmount = buyerRefundSuccessedAmount,
};
}
/// <summary>
/// 获取有效订单
/// </summary>
/// <param name="rows"></param>
/// <returns></returns>
public static List<ShopRevenueStatisticsRow> GetValidOrder(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = GetPaymentOrderList(rows); //买手付钱后的订单数量
return list.Where(IsValidOrder).ToList();
}
/// <summary>
/// 获取退款订单
/// </summary>
/// <param name="rows"></param>
/// <returns></returns>
public static List<ShopRevenueStatisticsRow> GetRefundOrder(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = GetPaymentOrderList(rows); //买手付钱后的订单数量
return list.Where(IsRefunded).ToList();
}
/// <summary>
/// 获取退款订单
/// </summary>
/// <param name="rows"></param>
/// <returns></returns>
public static List<ShopRevenueStatisticsRow> GetBuyerRefundRemainingOrder(IEnumerable<ShopRevenueStatisticsRow> rows)
{
var list = GetPaymentOrderList(rows); //买手付钱后的订单数量
var refunded = list.Where(IsRefunded).ToList();
decimal buyerRefundAmount = refunded.Sum(r => (decimal)(r.OutAmount)) / 100m; //买手号退款金额(采购退款金额)
decimal buyerRefundSuccessedAmount = refunded.Where(a => IsBuyerRefundSuccessful(a) == false).Sum(r => (decimal)(r.BuyerRefundAmount)) / 100m; //买手号退款成功金额
return refunded.Where(a => IsBuyerRefundSuccessful(a) == false).ToList();
/// <summary>店铺统计表格(按店铺汇总,销售额倒序)</summary>
public static List<ShopStatisticsItem> GetShopStatistics(IEnumerable<ShopStatsResponse> stats)
{
return GroupByShop(stats)
.Select(g => BuildShopStatisticsItem(g.Key, g))
.OrderByDescending(i => i.SalesAmount)
.ToList();
}
/// <summary>销售趋势(按天,缺失日期补 0,仅统计有效订单)</summary>
public static List<RevenueTrendPoint> GetSalesTrend(IEnumerable<ShopRevenueStatisticsRow> rows, DateTime startDate, DateTime endDate)
/// <summary>店铺销售占比(饼图,按店铺汇总,销售额倒序)</summary>
public static List<ShopSalesProportionItem> GetShopSalesProportion(IEnumerable<ShopStatsResponse> stats)
{
var list = GetPaymentOrderList(rows);
list = list.Where(IsValidOrder).ToList();
var items = GroupByShop(stats)
.Select(g => new ShopSalesProportionItem
{
ShopName = ResolveShopName(g.Key),
SalesAmount = ToYuan(g.Sum(a => a.SalesAmount))
})
.OrderByDescending(i => i.SalesAmount)
.ToList();
decimal total = items.Sum(i => i.SalesAmount);
foreach (var item in items)
{
item.Percentage = total == 0m ? 0d : (double)(item.SalesAmount / total * 100m);
}
return items;
}
/// <summary>
/// 销售趋势(按天,缺失日期补 0)。
/// 数据来自 GetShopStatsTrend:服务端已经跨店铺合计,这里只做日期规整与补零。
/// </summary>
public static List<RevenueTrendPoint> GetSalesTrend(IEnumerable<ShopStatsTrendResponse> trend, DateTime startDate, DateTime endDate)
{
var list = (trend ?? Enumerable.Empty<ShopStatsTrendResponse>())
.Where(a => a != null && a.StatDate != default(DateTime))
.ToList();
var map = list
.GroupBy(r => r.CreateTime.Date)
.Where(a => a.StatDate.Date >= startDate.Date && a.StatDate.Date <= endDate.Date)
.GroupBy(a => a.StatDate.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()
SalesAmount = ToYuan(g.Sum(a => a.SalesAmount)),
OrderCount = g.Sum(a => a.OrderCount)
});
var result = new List<RevenueTrendPoint>();
@@ -218,95 +186,23 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return result;
}
/// <summary>店铺销售占比(饼图,仅统计有效订单)</summary>
public static List<ShopSalesProportionItem> GetShopSalesProportion(IEnumerable<ShopRevenueStatisticsRow> rows)
/// <summary>
/// 天/周/月周期统计表格(每天/每周/每月各一行,无数据的周期也补一行,金额单位:元)。
/// 结果按周期倒序(最新的周期在最上面)。
/// 说明:接口按“自然周期”聚合(周从周一起、月从 1 号起),所以筛选也按周期起点比较,
/// 否则区间从周三开始时,那一周(周一起)的数据会被误判为越界而丢掉。
/// </summary>
public static List<ShopPeriodStatisticsItem> GetPeriodStatistics(IEnumerable<ShopStatsResponse> stats, DateTime startDate, DateTime endDate, PeriodStatisticType periodType)
{
var list = GetPaymentOrderList(rows);
list = list.Where(IsValidOrder).ToList();
decimal total = list.Sum(r => (decimal)(r.OrderPrice)) / 100m;
var startPeriod = GetPeriodStart(startDate, periodType);
var endPeriod = GetPeriodStart(endDate, periodType);
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 = GetPaymentOrderList(rows); //买手付钱后的订单
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; //与 GetSummary 一致:不含已退款订单的销售额
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
decimal profit = valid.Concat(refunded).Sum(r => (decimal)(r.Profit)) / 100m; //与 GetSummary 一致:有效+退款订单利润
decimal purchaseAmount = valid.Sum(r => (decimal)(r.OutAmount)) / 100m; //与 GetSummary 一致:买手付款金额(采购金额)
decimal buyerPaymentAmount = valid.Sum(r => (decimal)(r.OutAmount)) / 100m; //买手号付款金额(采购金额)
return new ShopStatisticsItem
{
ShopName = string.IsNullOrEmpty(g.Key) ? "未知店铺" : g.Key,
OrderCount = orderCount,
SalesAmount = salesAmount,
BuyerPaymentAmount = buyerPaymentAmount,
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)
.ToList();
}
/// <summary>天/周/月周期统计表格(每天/每周/每月各一行,无数据的周期也补一行,金额单位:元)</summary>
public static List<ShopPeriodStatisticsItem> GetPeriodStatistics(IEnumerable<ShopRevenueStatisticsRow> rows, DateTime startDate, DateTime endDate, PeriodStatisticType periodType)
{
var list = (rows ?? new List<ShopRevenueStatisticsRow>()).Where(a => a.OutAmount > 0).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; //与 GetSummary 一致:不含已退款订单的销售额
decimal profit = valid.Concat(refunded).Sum(r => (decimal)(r.Profit)) / 100m; //与 GetSummary 一致:有效+退款订单利润
decimal purchaseAmount = valid.Sum(r => (decimal)(r.OutAmount)) / 100m; //与 GetSummary 一致:买手付款金额(采购金额)
int orderCount = valid.Select(r => r.OrderId).Distinct().Count();
decimal buyerPaymentAmount = valid.Sum(r => (decimal)(r.OutAmount)) / 100m; //买手号付款金额(采购金额)
return new ShopPeriodStatisticsItem
{
PeriodStart = g.Key,
Period = GetPeriodText(g.Key, periodType),
OrderCount = orderCount,
SalesAmount = salesAmount,
BuyerPaymentAmount = buyerPaymentAmount,
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
};
});
var map = ToList(stats)
.Where(a => a.StatDate != default(DateTime))
.Select(a => new { Stat = a, PeriodStart = GetPeriodStart(a.StatDate, periodType) })
.Where(a => a.PeriodStart >= startPeriod && a.PeriodStart <= endPeriod)
.GroupBy(a => a.PeriodStart)
.ToDictionary(g => g.Key, g => BuildPeriodStatisticsItem(g.Key, g.Select(a => a.Stat), periodType));
// 按周期补齐整段时间:无数据的周期也生成一行(全 0)
var result = new List<ShopPeriodStatisticsItem>();
@@ -321,6 +217,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
Period = GetPeriodText(periodStart, periodType),
OrderCount = 0,
SalesAmount = 0m,
BuyerPaymentAmount = 0m,
Profit = 0m,
RefundAmount = 0m,
PlatformFeeTotal = 0m,
@@ -336,6 +233,56 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return result;
}
/// <summary>店铺统计行(金额单位:元)</summary>
private static ShopStatisticsItem BuildShopStatisticsItem(string shopId, IEnumerable<ShopStatsResponse> shopStats)
{
var list = ToList(shopStats);
decimal salesAmount = ToYuan(list.Sum(a => a.SalesAmount));
decimal profit = ToYuan(list.Sum(a => a.ProfitAmount));
decimal buyerPaymentAmount = ToYuan(list.Sum(a => a.TotalPurchase));
int orderCount = list.Sum(a => a.OrderCount);
return new ShopStatisticsItem
{
ShopName = ResolveShopName(shopId),
OrderCount = orderCount,
SalesAmount = salesAmount,
BuyerPaymentAmount = buyerPaymentAmount,
Profit = profit,
RefundAmount = ToYuan(list.Sum(a => a.RefundDone) + list.Sum(a => a.RefundPending)),
PlatformFeeTotal = ToYuan(list.Sum(a => a.PlatformFee)),
Commission = 0m, // 新接口无“达人佣金”字段,保留占位
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount,
AverageProfitRate = buyerPaymentAmount == 0m ? 0m : profit / buyerPaymentAmount * 100m
};
}
/// <summary>周期统计行(金额单位:元)</summary>
private static ShopPeriodStatisticsItem BuildPeriodStatisticsItem(DateTime periodStart, IEnumerable<ShopStatsResponse> periodStats, PeriodStatisticType periodType)
{
var list = ToList(periodStats);
decimal salesAmount = ToYuan(list.Sum(a => a.SalesAmount));
decimal profit = ToYuan(list.Sum(a => a.ProfitAmount));
decimal buyerPaymentAmount = ToYuan(list.Sum(a => a.TotalPurchase));
int orderCount = list.Sum(a => a.OrderCount);
return new ShopPeriodStatisticsItem
{
PeriodStart = periodStart,
Period = GetPeriodText(periodStart, periodType),
OrderCount = orderCount,
SalesAmount = salesAmount,
BuyerPaymentAmount = buyerPaymentAmount,
Profit = profit,
RefundAmount = ToYuan(list.Sum(a => a.RefundDone) + list.Sum(a => a.RefundPending)),
PlatformFeeTotal = ToYuan(list.Sum(a => a.PlatformFee)),
Commission = 0m, // 新接口无“达人佣金”字段,保留占位
AverageProfitRate = buyerPaymentAmount == 0m ? 0m : profit / buyerPaymentAmount * 100m,
CustomerCount = orderCount,
AvgOrderAmount = orderCount == 0 ? 0m : salesAmount / orderCount
};
}
/// <summary>枚举 startDate~endDate 覆盖的所有周期开始日期(天/周/月)</summary>
private static IEnumerable<DateTime> EnumeratePeriods(DateTime startDate, DateTime endDate, PeriodStatisticType periodType)
{
@@ -387,252 +334,261 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return periodStart.ToString("yyyy-MM-dd");
}
}
}
/// <summary>
/// 默认实现:请求真实接口(shop_revenue_statistics/get_shop_order_amount)。
/// 默认实现:请求新接口(无缓存,每次查询实时获取)。
/// shop_revenue_statistics/GetShopStats
/// -> Result&lt;Dictionary&lt;店铺ID, List&lt;ShopStatsResponse&gt;&gt;&gt;
/// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds, GroupType });
/// GroupType 决定聚合粒度(0=汇总 1=天 2=周 3=月),汇总时每个店铺只有一条。
/// shop_revenue_statistics/GetShopStatsTrend
/// -> Result&lt;List&lt;ShopStatsTrendResponse&gt;&gt;
/// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds });
/// 服务端跨店铺合计,固定按天返回,用于销售趋势折线图。
/// shop_revenue_statistics/GetShopOrderDetails
/// -> Result&lt;List&lt;ShopOrderDetailResponse&gt;&gt;
/// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds, OrderType });
/// 订单列表弹窗的明细数据(OrderType 见 ShopOrderDetailType)。
/// 接口不返回“整段汇总”,页面上的汇总卡片一律由客户端对返回记录合计。
/// 接入其他数据源时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。
/// </summary>
public class ShopRevenueStatisticsService : IShopRevenueStatisticsService
{
/// <summary>缓存有效期:订单/退款数据会持续变化,超过该时长后缓存自动失效并重新请求。</summary>
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
/// <summary>统计接口地址(聚合粒度由请求参数 GroupType 决定)</summary>
private const string GetShopStatsApi = "shop_revenue_statistics/GetShopStats";
/// <summary>
/// 明细数据缓存(按天缓存,纯时间维度,不含店铺)。
/// key = 日期(yyyyMMdd),value = 该天全部店铺的明细 + 缓存时间。
/// 查询时按天命中:未命中的天合并成连续段一次请求,返回后按天拆分写入;
/// 前台选某一个或某多个店铺时,按所选店铺(ShopName)过滤,复用同一批时间数据。
/// </summary>
private static readonly Dictionary<string, CacheEntry> _rowCache =
new Dictionary<string, CacheEntry>();
private readonly object _cacheLock = new object();
/// <summary>销售趋势接口地址(服务端跨店铺合计,固定按天)</summary>
private const string GetShopStatsTrendApi = "shop_revenue_statistics/GetShopStatsTrend";
private sealed class DateRange
/// <summary>订单明细接口地址(订单列表弹窗用)</summary>
private const string GetShopOrderDetailsApi = "shop_revenue_statistics/GetShopOrderDetails";
/// <summary>Unix 纪元(本地时区),用于解析以时间戳作为 Key 的返回数据</summary>
private static readonly DateTime UnixEpoch = TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1));
public async Task<ShopRevenueStatisticsResult> QueryAsync(ShopRevenueStatisticsQuery query)
{
public DateTime Start;
public DateTime End;
}
// 综合统计页分两次取数,各取所需:
// 1) Summary:每店铺一条(与区间长度无关),作为卡片/店铺统计表/销售占比的权威合计
// 2) Trend :跨店铺合计的按天序列,只给趋势折线图用(数据量只与天数有关,不随店铺数膨胀)
// 顺序请求:签名中的 time/code 由当前时间生成,并发请求有被判重复签名的风险。
var summaryStats = await RequestStatsAsync(query, ShopStatsGroupType.Summary);
var trend = await RequestTrendAsync(query);
private sealed class CacheEntry
{
public List<ShopRevenueStatisticsRow> Rows;
public DateTime CachedAt;
}
private static string CacheKey(DateTime day)
{
return day.Date.ToString("yyyyMMdd");
}
/// <summary>枚举 startDate ~ endDate 之间的每一天(含首尾)</summary>
private static IEnumerable<DateTime> EnumerateDays(DateTime startDate, DateTime endDate)
{
for (var d = startDate.Date; d <= endDate.Date; d = d.AddDays(1))
yield return d;
}
/// <summary>该天缓存是否有效(存在且未过期);调用方需持有 _cacheLock</summary>
private bool IsValidLocked(DateTime day)
{
return _rowCache.TryGetValue(CacheKey(day), out var entry)
&& DateTime.Now - entry.CachedAt <= CacheDuration;
}
/// <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)
return new ShopRevenueStatisticsResult
{
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;
Summary = ShopStatsAggregator.GetSummary(summaryStats),
Trend = ShopStatsAggregator.GetSalesTrend(trend, query.StartDate, query.EndDate),
ShopProportions = ShopStatsAggregator.GetShopSalesProportion(summaryStats),
ShopStatistics = ShopStatsAggregator.GetShopStatistics(summaryStats),
};
}
public async Task<List<ShopRevenueStatisticsRow>> QueryRowsAsync(ShopRevenueStatisticsQuery query)
public async Task<ShopPeriodStatisticsResult> QueryPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType)
{
var startDay = query.StartDate.Date;
var endDay = query.EndDate.Date;
if (startDay > endDay)
// 按天/周/月粒度取数;接口不返回整段汇总,卡片由返回的周期记录在客户端合计
var stats = await RequestStatsAsync(query, ToGroupType(periodType));
return new ShopPeriodStatisticsResult
{
var tmp = startDay;
startDay = endDay;
endDay = tmp;
}
Summary = ShopStatsAggregator.GetSummary(stats),
Items = ShopStatsAggregator.GetPeriodStatistics(stats, query.StartDate, query.EndDate, periodType),
};
}
// 1. 找出未命中或已过期的天
List<DateTime> missingDays;
lock (_cacheLock)
/// <summary>页面统计周期 → 接口聚合方式</summary>
private static ShopStatsGroupType ToGroupType(PeriodStatisticType periodType)
{
switch (periodType)
{
missingDays = EnumerateDays(startDay, endDay)
.Where(d => !IsValidLocked(d))
.ToList();
case PeriodStatisticType.Week:
return ShopStatsGroupType.Week;
case PeriodStatisticType.Month:
return ShopStatsGroupType.Month;
default:
return ShopStatsGroupType.Day;
}
// 2. 有缺失:按连续段请求整个时间范围的全部店铺明细,并按天写入缓存
if (missingDays.Count > 0)
{
var shopIds = GetShopOptions().Select(a => a.ShopId).ToList();
if (shopIds.Count == 0)
return new List<ShopRevenueStatisticsRow>();
foreach (var run in BuildRuns(missingDays))
{
var rows = await RequestRowsAsync(shopIds, run.Start, run.End);
CacheResponse(run.Start, run.End, rows);
}
}
// 3. 按天从缓存拼装完整范围,再按所选店铺过滤(ShopKeys 为空 = 全部店铺)
return FilterByShop(GetCachedRows(startDay, endDay), query.ShopKeys);
}
/// <summary>
/// 把一次请求的结果按天写入缓存。
/// 本次请求覆盖的每一天都以返回数据为准整体替换(无数据的天记为空的已缓存,
/// 避免“该天无数据”被反复判定为未命中而重复请求)。
/// 请求 GetShopStats:返回按 GroupType 聚合的记录
/// (汇总时每店铺一条;天/周/月时每店铺每个自然日/自然周/自然月一条)。
/// </summary>
private void CacheResponse(DateTime startDate, DateTime endDate, List<ShopRevenueStatisticsRow> rows)
private static async Task<List<ShopStatsResponse>> RequestStatsAsync(ShopRevenueStatisticsQuery query, ShopStatsGroupType groupType)
{
if (rows == null)
rows = new List<ShopRevenueStatisticsRow>();
var shopIds = BuildShopIds(query);
if (shopIds.Count == 0)
return new List<ShopStatsResponse>();
lock (_cacheLock)
var url = BaseClass.Build29WxObjString(GetShopStatsApi);
var html = await GetHtml.PostJsonAsync(url, BuildPostData(query, shopIds, groupType));
var ret = Deserialize<Result<Dictionary<string, List<ShopStatsResponse>>>>(html);
EnsureSuccess(ret);
var result = new List<ShopStatsResponse>();
if (ret.Data == null)
return result;
foreach (var kv in ret.Data)
result.AddRange(Normalize(kv.Key, kv.Value));
return result;
}
/// <summary>
/// 请求 GetShopStatsTrend:跨店铺合计的按天销售趋势(服务端已合计,不需要 GroupType)。
/// </summary>
private static async Task<List<ShopStatsTrendResponse>> RequestTrendAsync(ShopRevenueStatisticsQuery query)
{
var shopIds = BuildShopIds(query);
if (shopIds.Count == 0)
return new List<ShopStatsTrendResponse>();
var url = BaseClass.Build29WxObjString(GetShopStatsTrendApi);
var html = await GetHtml.PostJsonAsync(url, BuildTrendPostData(query, shopIds));
var ret = Deserialize<Result<List<ShopStatsTrendResponse>>>(html);
EnsureSuccess(ret);
return ret.Data ?? new List<ShopStatsTrendResponse>();
}
/// <summary>
/// 组装请求体:StartDate / EndDate 传日期,ShopIds 传 ERP 店铺ID 列表,
/// GroupType 传枚举数字(0=汇总 1=天 2=周 3=月)。
/// </summary>
private static object BuildPostData(ShopRevenueStatisticsQuery query, List<string> shopIds, ShopStatsGroupType groupType)
{
return UserLogin.GetSignPostData(new
{
RemoveExpiredEntriesLocked();
StartDate = query.StartDate.Date,
EndDate = query.EndDate.Date,
ShopIds = shopIds,
GroupType = (int)groupType,
});
}
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());
/// <summary>组装趋势接口请求体:StartDate / EndDate + ShopIds(无 GroupType)</summary>
private static object BuildTrendPostData(ShopRevenueStatisticsQuery query, List<string> shopIds)
{
return UserLogin.GetSignPostData(new
{
StartDate = query.StartDate.Date,
EndDate = query.EndDate.Date,
ShopIds = shopIds,
});
}
foreach (var day in EnumerateDays(startDate, endDate))
{
byDay.TryGetValue(day, out var dayRows);
_rowCache[CacheKey(day)] = new CacheEntry { Rows = dayRows ?? new List<ShopRevenueStatisticsRow>(), CachedAt = DateTime.Now };
}
/// <summary>
/// 组装订单明细请求体:StartDate / EndDate + ShopIds + OrderType
/// (OrderType 传枚举数字:1=有效订单 2=退款订单 3=买手未退回金额的订单)。
/// </summary>
private static object BuildOrderDetailPostData(ShopRevenueStatisticsQuery query, List<string> shopIds, ShopOrderDetailType detailType)
{
return UserLogin.GetSignPostData(new
{
StartDate = query.StartDate,
EndDate = query.EndDate,
ShopIds = shopIds,
OrderType = (int)detailType,
});
}
/// <summary>
/// 查询店铺范围:query.ShopIds 为空表示全部店铺(取全部店铺的 ERP 店铺ID)。
/// 接口的 ShopIds 使用 ShopLoginInfo.ErpShopId。
/// </summary>
private static List<string> BuildShopIds(ShopRevenueStatisticsQuery query)
{
var selected = (query == null || query.ShopIds == null)
? new List<string>()
: query.ShopIds.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
if (selected.Count > 0)
return selected;
return GetShopOptions()
.Select(a => a.ErpShopId)
.Where(a => !string.IsNullOrEmpty(a))
.Distinct()
.ToList();
}
/// <summary>
/// 反序列化接口返回;失败时统一抛出可读异常。
/// 说明:Newtonsoft 默认按属性名匹配(忽略大小写),因此 PascalCase / camelCase 均可解析。
/// </summary>
private static T Deserialize<T>(string html)
{
try
{
return JsonConvert.DeserializeObject<T>(html);
}
catch (Exception ex)
{
throw new Exception("查询失败:接口返回数据解析异常", ex);
}
}
/// <summary>按天从缓存拼装某时间范围的明细(返回新列表,避免外部修改污染缓存)</summary>
private List<ShopRevenueStatisticsRow> GetCachedRows(DateTime startDate, DateTime endDate)
/// <summary>校验接口返回码</summary>
private static void EnsureSuccess(Result result)
{
var result = new List<ShopRevenueStatisticsRow>();
lock (_cacheLock)
if (result == null)
throw new Exception("查询失败:接口无返回数据");
if (result.Code != 200)
throw new Exception(string.IsNullOrEmpty(result.Msg) ? "查询失败" : result.Msg);
}
/// <summary>
/// 摊平某个 Key(店铺ID)下的记录,并用 Key 兜底补齐缺失的 ShopId / StatDate。
/// </summary>
private static List<ShopStatsResponse> Normalize(string key, List<ShopStatsResponse> items)
{
var result = new List<ShopStatsResponse>();
if (items == null)
return result;
foreach (var item in items)
{
foreach (var day in EnumerateDays(startDate, endDate))
if (item == null)
continue;
if (string.IsNullOrEmpty(item.ShopId))
item.ShopId = key;
if (item.StatDate == default(DateTime))
{
if (_rowCache.TryGetValue(CacheKey(day), out var entry))
result.AddRange(entry.Rows);
var date = ParseDateFromKey(key);
if (date.HasValue)
item.StatDate = date.Value;
}
result.Add(item);
}
return result;
}
/// <summary>按所选店铺过滤明细:ShopKeys 为空/Null 返回全部;否则按店铺名(ShopName)精确匹配。</summary>
private static List<ShopRevenueStatisticsRow> FilterByShop(List<ShopRevenueStatisticsRow> rows, List<string> shopKeys)
/// <summary>尝试把返回数据的 Key 解析为日期(支持 yyyy-MM-dd / yyyyMMdd / 秒或毫秒时间戳)</summary>
private static DateTime? ParseDateFromKey(string key)
{
if (rows == null)
return new List<ShopRevenueStatisticsRow>();
if (string.IsNullOrEmpty(key))
return null;
if (shopKeys == null || shopKeys.Count == 0)
return new List<ShopRevenueStatisticsRow>(rows);
if (DateTime.TryParse(key, out var date))
return date;
var shops = GetShopOptions()
.Where(a => shopKeys.Contains(a.AppKey))
.Where(a => !string.IsNullOrEmpty(a.ShopName))
.ToList();
if (shops.Count == 0)
return new List<ShopRevenueStatisticsRow>();
if (DateTime.TryParseExact(key, "yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out date))
return date;
return rows
.Where(r => shops.Any(s => string.Equals(r.ShopName, s.ShopName, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
/// <summary>清理已过期的缓存条目(调用方需持有 _cacheLock)</summary>
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);
}
/// <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
if (long.TryParse(key, out var timestamp))
{
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("查询失败");
return timestamp.ToString().Length == 13
? UnixEpoch.AddMilliseconds(timestamp)
: UnixEpoch.AddSeconds(timestamp);
}
}
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);
return null;
}
/// <summary>店铺下拉列表(同步读取,供查询与过滤使用)</summary>
@@ -641,8 +597,8 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return GlobalConfig.ShopLoginList.Select(a =>
new ShopOption()
{
AppKey = a.ShopId,
ShopId = a.ErpShopId,
ShopId = a.ShopId,
ErpShopId = a.ErpShopId,
ShopName = a.ShopAccount
}).ToList();
}
@@ -652,5 +608,23 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services
return Task.FromResult(GetShopOptions());
}
/// <summary>
/// 订单明细列表(有效订单 / 退款订单 / 买手未退回金额的订单),供订单列表弹窗使用。
/// 请求 shop_revenue_statistics/GetShopOrderDetails:
/// StartDate / EndDate / ShopIds / OrderType(OrderType 由 detailType 决定,按数字传值)。
/// </summary>
public async Task<List<ShopOrderDetailResponse>> QueryOrderRowsAsync(ShopRevenueStatisticsQuery query, ShopOrderDetailType detailType)
{
var shopIds = BuildShopIds(query);
if (shopIds.Count == 0)
return new List<ShopOrderDetailResponse>();
var url = BaseClass.Build29WxObjString(GetShopOrderDetailsApi);
var html = await GetHtml.PostJsonAsync(url, BuildOrderDetailPostData(query, shopIds, detailType));
var ret = Deserialize<Result<List<ShopOrderDetailResponse>>>(html);
EnsureSuccess(ret);
return ret.Data ?? new List<ShopOrderDetailResponse>();
}
}
}
@@ -1,15 +1,20 @@
<UserControl
<UserControl
x:Class="_29WxOrderWpf.Ctrls.Statistics.ShowOrderDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:comn="clr-namespace:_29WxOrderWpf.Common"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf.Ctrls.Statistics"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wpf="WPFCommon"
comn:DialogWindowProperty.Height="620"
comn:DialogWindowProperty.ResizeMode="CanResizeWithGrip"
comn:DialogWindowProperty.Width="1320"
d:Background="White"
d:DesignHeight="450"
d:DesignWidth="800"
Focusable="True"
PreviewKeyDown="ShowOrderDialogView_PreviewKeyDown"
mc:Ignorable="d">
<UserControl.Resources>
<!-- 单元格文字居中 -->
@@ -18,6 +23,21 @@
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
选中行高亮:用主题色填充选中行,并且不区分表格是否拥有焦点
(默认样式的选中色在焦点离开表格后会变成浅灰,点了“复制数据”就看不出选中了哪些行)
-->
<Style x:Key="SelectedCellStyle" TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource pageColorBrush}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid>
@@ -36,15 +56,15 @@
Width="180"
Placement="Left"
TitleVerticalAlignment="Center">
<TextBox Height="22" Text="{Binding OrderId, UpdateSourceTrigger=PropertyChanged}" />
<TextBox
Height="22"
Text="{Binding OrderId, UpdateSourceTrigger=PropertyChanged}"
ToolTip="按订单号筛选;表格内可用 Ctrl+A 全选、Ctrl+C 复制数据" />
</wpf:TitleContent>
<Button
Command="{Binding SelectAllCommand}"
Content="复制数据"
Style="{StaticResource BaseButtonLightStyle}" />
</wpf:SpacingStackPanel>
</Border>
<DataGrid
x:Name="dgOrders"
Grid.Row="1"
d:ItemsSource="{d:SampleData ItemCount=10}"
AlternatingRowBackground="#FAFAFA"
@@ -55,6 +75,7 @@
CanUserDeleteRows="False"
CanUserReorderColumns="False"
CanUserResizeRows="False"
CellStyle="{StaticResource SelectedCellStyle}"
ColumnHeaderStyle="{StaticResource BaseDataGridColumnHeaderStyle}"
GridLinesVisibility="Horizontal"
HeadersVisibility="Column"
@@ -62,14 +83,9 @@
IsReadOnly="True"
ItemsSource="{Binding DataView}"
RowHeight="44"
SelectionMode="Single"
SelectionMode="Extended"
SelectionUnit="FullRow"
VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<!-- 店名 -->
<DataGridTextColumn
@@ -93,7 +109,6 @@
<Button
Margin="5,0,0,0"
Click="CopyOrderId_Click"
Foreground="{StaticResource pageColorBrush}"
ToolTip="复制订单号">
<Button.Style>
<Style BasedOn="{StaticResource IconButtonStyle}" TargetType="Button">
@@ -113,6 +128,13 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- 创建时间 -->
<DataGridTextColumn
Width="1.4*"
Binding="{Binding CreateTime, StringFormat={}{0:yyyy-MM-dd HH:mm}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="创建时间" />
<!-- 金额(分转元) -->
<DataGridTextColumn
Width="*"
@@ -121,7 +143,7 @@
Header="金额" />
<DataGridTextColumn
Width="*"
Binding="{Binding RefundAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
Binding="{Binding OrderRefundAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="退款金额" />
<DataGridTextColumn
@@ -129,6 +151,33 @@
Binding="{Binding OutAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="外采金额" />
<DataGridTextColumn
Width="*"
Binding="{Binding UnrefundedAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="未退回金额" />
<!-- 买手订单:一笔买手订单占一行,不合并 -->
<DataGridTextColumn
Width="1.6*"
Binding="{Binding OutOrderSn}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="买手单号" />
<DataGridTextColumn
Width="*"
Binding="{Binding BuyerOutAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="买手金额" />
<DataGridTextColumn
Width="*"
Binding="{Binding BuyerRefundAmount, Converter={StaticResource FenToYuanConverter}, StringFormat={}¥{0:N2}}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="买手退款" />
<DataGridTextColumn
Width="1.1*"
Binding="{Binding BuyerRefundStatusText}"
ElementStyle="{StaticResource CenterCellTextStyle}"
Header="退款状态" />
</DataGrid.Columns>
</DataGrid>
</Grid>
@@ -1,4 +1,4 @@
using _29WxOrderWpf.BaseWindows;
using _29WxOrderWpf.BaseWindows;
using _29WxOrderWpf.Commands;
using _29WxOrderWpf.Ctrls.Statistics.Models;
using _29WxOrderWpf.ToolCtrls;
@@ -11,6 +11,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
@@ -27,10 +28,37 @@ namespace _29WxOrderWpf.Ctrls.Statistics;
/// </summary>
public partial class ShowOrderDialogView : UserControl
{
public ShowOrderDialogView(List<ShopRevenueStatisticsRow> list)
private readonly KeyEventHandler shortcutKeyHandler;
public ShowOrderDialogView(List<ShopOrderDetailResponse> list)
{
InitializeComponent();
this.DataContext = new ShowOrderDialogViewModel(list);
shortcutKeyHandler = ShowOrderDialogView_PreviewKeyDown;
// 快捷键必须挂到弹窗窗口上:弹窗刚打开时键盘焦点在窗口本身(不在本控件的可视树里),
// 事件路由中就不会有本控件,只在本控件上挂 PreviewKeyDown 的话,
// 必须先用鼠标点一下表格(焦点进到表格里)Ctrl+A/Ctrl+C 才会生效。
Loaded += (s, e) => AttachShortcutHandler();
Unloaded += (s, e) => DetachShortcutHandler();
}
/// <summary>把快捷键处理器挂到所在窗口(隧道事件,焦点在窗口上的任意位置都能收到)</summary>
private void AttachShortcutHandler()
{
Window window = Window.GetWindow(this);
if (window == null)
return;
// 先移除再挂,避免重复挂载后按一次键执行两次
window.RemoveHandler(PreviewKeyDownEvent, shortcutKeyHandler);
window.AddHandler(PreviewKeyDownEvent, shortcutKeyHandler);
}
private void DetachShortcutHandler()
{
Window.GetWindow(this)?.RemoveHandler(PreviewKeyDownEvent, shortcutKeyHandler);
}
/// <summary>
@@ -39,7 +67,7 @@ public partial class ShowOrderDialogView : UserControl
private void CopyOrderId_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element
&& element.DataContext is ShopRevenueStatisticsRow row
&& element.DataContext is ShopOrderDetailRow row
&& !string.IsNullOrEmpty(row.OrderId))
{
Window window = Window.GetWindow(this);
@@ -54,14 +82,142 @@ public partial class ShowOrderDialogView : UserControl
}
}
}
/// <summary>
/// 弹窗级快捷键:Ctrl+A 全选(筛选后显示的)行,Ctrl+C 复制数据。
/// 焦点在文本框里(如订单号搜索框)时不拦截,保留文本自身的 Ctrl+A/Ctrl+C/Ctrl+V 行为。
/// </summary>
private void ShowOrderDialogView_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Handled || Keyboard.Modifiers != ModifierKeys.Control)
return;
// 文本框(含搜索框)优先,走文本自己的全选/复制/粘贴
if (Keyboard.FocusedElement is TextBoxBase)
return;
if (DataContext is not ShowOrderDialogViewModel viewModel)
return;
if (e.Key == Key.A)
{
SelectAllRows(viewModel);
e.Handled = true;
}
else if (e.Key == Key.C)
{
// 有选中就复制选中的行,没有选中则复制筛选后的全部数据
viewModel.CopyRows(GetSelectedRows());
e.Handled = true;
}
}
/// <summary>
/// Ctrl+A:全选当前筛选后显示的所有行(被过滤掉的行不会选中)。
/// 选中行取自与复制同一份筛选结果,保证“选中的”和“复制的”完全一致。
/// </summary>
private void SelectAllRows(ShowOrderDialogViewModel viewModel)
{
// 让键盘焦点回到表格,全选后仍可用键盘继续操作
dgOrders.Focus();
dgOrders.SelectedItems.Clear();
foreach (var row in viewModel.GetFilteredRows())
{
dgOrders.SelectedItems.Add(row);
}
}
/// <summary>当前选中的行</summary>
private List<ShopOrderDetailRow> GetSelectedRows()
{
return dgOrders.SelectedItems.OfType<ShopOrderDetailRow>().ToList();
}
}
/// <summary>
/// 订单明细表格行:把一个商铺订单按买手订单“展平”成多行(有几笔买手订单就几行,
/// 没有买手订单时占一行且买手字段为空),不把多笔买手订单合并到同一行。
/// 商铺订单级字段(店名/订单号/创建时间/金额等)在多行之间重复,保证每行都是一条完整记录。
/// </summary>
class ShopOrderDetailRow
{
public string ShopName { get; set; }
public string OrderId { get; set; }
public DateTime CreateTime { get; set; }
public int OrderPrice { get; set; }
public int OrderRefundAmount { get; set; }
public int OutAmount { get; set; }
public int UnrefundedAmount { get; set; }
/// <summary>买手(外部)订单号;无买手订单时为空</summary>
public string OutOrderSn { get; set; }
/// <summary>买手订单金额(分)</summary>
public int BuyerOutAmount { get; set; }
/// <summary>买手订单退款金额(分)</summary>
public int BuyerRefundAmount { get; set; }
/// <summary>买手订单退款状态</summary>
public string BuyerRefundStatusText { get; set; }
/// <summary>把接口返回的订单明细按买手订单展平成表格行</summary>
public static List<ShopOrderDetailRow> Flatten(IEnumerable<ShopOrderDetailResponse> list)
{
var rows = new List<ShopOrderDetailRow>();
if (list == null)
return rows;
foreach (var item in list)
{
if (item == null)
continue;
if (item.BuyerOrders == null || item.BuyerOrders.Count == 0)
{
rows.Add(Create(item, null));
continue;
}
foreach (var buyerOrder in item.BuyerOrders)
rows.Add(Create(item, buyerOrder));
}
return rows;
}
private static ShopOrderDetailRow Create(ShopOrderDetailResponse item, BuyerOrderInfo buyerOrder)
{
return new ShopOrderDetailRow
{
ShopName = item.ShopName,
OrderId = item.OrderId,
CreateTime = item.CreateTime,
OrderPrice = item.OrderPrice,
OrderRefundAmount = item.OrderRefundAmount,
OutAmount = item.OutAmount,
UnrefundedAmount = item.UnrefundedAmount,
OutOrderSn = buyerOrder == null ? null : buyerOrder.OutOrderSn,
BuyerOutAmount = buyerOrder == null ? 0 : buyerOrder.OutAmount,
BuyerRefundAmount = buyerOrder == null ? 0 : buyerOrder.RefundAmount,
BuyerRefundStatusText = buyerOrder == null ? null : buyerOrder.RefundStatusText,
};
}
}
class ShowOrderDialogViewModel : BindableBaseEx
{
public ObservableCollection<ShopRevenueStatisticsRow> Data { get; set; }
public ObservableCollection<ShopOrderDetailRow> Data { get; set; }
public ICollectionView DataView { get; private set; }
public ShowOrderDialogViewModel(List<ShopRevenueStatisticsRow> list)
public ShowOrderDialogViewModel(List<ShopOrderDetailResponse> list)
{
Data = new ObservableCollection<ShopRevenueStatisticsRow>(list ?? new List<ShopRevenueStatisticsRow>());
// 按买手订单展平:一笔买手订单一一行,不合并
Data = new ObservableCollection<ShopOrderDetailRow>(ShopOrderDetailRow.Flatten(list));
DataView = CollectionViewSource.GetDefaultView(Data);
DataView.Filter = FilterRow;
}
@@ -70,9 +226,9 @@ class ShowOrderDialogViewModel : BindableBaseEx
{
if (string.IsNullOrWhiteSpace(OrderId))
return true;
if (obj is ShopRevenueStatisticsRow row)
if (obj is ShopOrderDetailRow row)
{
return row.OrderId.Contains(OrderId);
return (row.OrderId ?? string.Empty).Contains(OrderId);
}
return false;
}
@@ -83,33 +239,74 @@ class ShowOrderDialogViewModel : BindableBaseEx
set => SetValue(value, () => DataView.Refresh());
}
public ICommand SelectAllCommand => new DelegateCommand(SelectAll);
/// <summary>当前筛选后显示的行(按表格显示顺序)</summary>
public List<ShopOrderDetailRow> GetFilteredRows()
{
return DataView.Cast<ShopOrderDetailRow>().ToList();
}
private void SelectAll()
/// <summary>
/// 复制指定行到剪贴板(带表头,制表符分隔)。
/// rows 为空时复制筛选后的全部数据;复制结果用提示条反馈。
/// </summary>
public void CopyRows(IEnumerable<ShopOrderDetailRow> rows)
{
List<ShopOrderDetailRow> list = rows?.ToList() ?? new List<ShopOrderDetailRow>();
if (list.Count == 0)
{
// 没有选中行时,复制筛选后的全部数据
list = GetFilteredRows();
}
Window window = Application.Current.Windows
.OfType<Window>()
.FirstOrDefault(w => w.IsActive) ?? Application.Current.MainWindow;
if (list.Count == 0)
{
ToastHelper.Show(window, "没有可复制的数据", type: ToastType.Warning);
return;
}
try
{
string text = BuildCopyText(list);
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
ToastHelper.Show(window, "复制成功");
}
catch (Exception ex)
{
ToastHelper.Show(window, "复制失败 " + ex.Message, type: ToastType.Error);
}
}
/// <summary>把行拼成制表符分隔的文本(第一行是表头)</summary>
private string BuildCopyText(IEnumerable<ShopOrderDetailRow> rows)
{
var sb = new StringBuilder();
// 表头
sb.AppendLine("店名\t订单\t金额\t退款金额\t外采金额");
sb.AppendLine("店名\t订单\t创建时间\t金额\t退款金额\t外采金额\t未退回金额\t买手单号\t买手金额\t买手退款\t退款状态");
// 数据行
foreach (var item in Data)
foreach (var item in rows)
{
string shopName = CsvEscape(item.ShopName);
string orderId = item.OrderId;
string createTime = item.CreateTime.ToString("yyyy-MM-dd HH:mm");
string orderPrice = (item.OrderPrice / 100m).ToString("0.00");
string refundAmount = (item.RefundAmount / 100m).ToString("0.00");
string refundAmount = (item.OrderRefundAmount / 100m).ToString("0.00");
string outAmount = (item.OutAmount / 100m).ToString("0.00");
string unrefundedAmount = (item.UnrefundedAmount / 100m).ToString("0.00");
string buyerOrderSn = CsvEscape(item.OutOrderSn);
string buyerOutAmount = (item.BuyerOutAmount / 100m).ToString("0.00");
string buyerRefundAmount = (item.BuyerRefundAmount / 100m).ToString("0.00");
string buyerRefundStatus = CsvEscape(item.BuyerRefundStatusText);
sb.AppendLine($"{shopName}\t{orderId}\t{orderPrice}\t{refundAmount}\t{outAmount}");
sb.AppendLine($"{shopName}\t{orderId}\t{createTime}\t{orderPrice}\t{refundAmount}\t{outAmount}\t{unrefundedAmount}\t{buyerOrderSn}\t{buyerOutAmount}\t{buyerRefundAmount}\t{buyerRefundStatus}");
}
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(sb.ToString()));
var window = Application.Current.Windows
.OfType<Window>()
.FirstOrDefault(w => w.IsActive) ?? Application.Current.MainWindow;
ToastHelper.Show(window, "复制成功");
return sb.ToString();
}
private string CsvEscape(string value)
@@ -1,4 +1,4 @@
using _29WxOrderWpf.Commands;
using _29WxOrderWpf.Commands;
using _29WxOrderWpf.Common;
using _29WxOrderWpf.Ctrls.Statistics.Models;
using _29WxOrderWpf.Ctrls.Statistics.Services;
@@ -39,6 +39,12 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
private int _totalCount;
private string _pagerText = string.Empty;
/// <summary>是否已完成首次初始化(用于避免页签来回切换时重复初始化、重复查询)</summary>
private bool _initialized;
/// <summary>上一次真正生效(并据此计算过日期)的时间范围选项,避免同一个选项被重复应用</summary>
private TimeRangeOption _appliedTimeRange;
public PeriodRevenueStatisticsViewModel(PeriodStatisticType periodType)
: this(periodType, new ShopRevenueStatisticsService())
{
@@ -65,8 +71,18 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
}
/// <summary>
/// 页面首次显示时初始化查询条件并加载数据(由外层 TabControl 的选中事件调用)。
/// 重复调用(在“综合统计 / 天统计 / 周统计 / 月统计”页签之间来回切换)会被忽略:
/// 否则每次切回来都会把时间范围和店铺重置成默认值,并重新发起一次查询。
/// 需要刷新数据时点“查询”按钮即可。
/// </summary>
public void Init()
{
if (_initialized)
return;
_initialized = true;
InitializeAsync();
}
@@ -172,7 +188,16 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
set
{
this.SetValue(x => x.SelectedTimeRange, value);
ApplyTimeRange();
// 只有用户真正切换到“另一个”时间范围选项时才重新计算起止日期。
// 页签切换时下拉框会把同一个选项(甚至先 null 再回写)重放一遍,
// 若照样重算,就会把用户手工调过的日期覆盖掉(例如自定义区间被改回“本周”)。
if (value != null && !ReferenceEquals(_appliedTimeRange, value))
{
_appliedTimeRange = value;
ApplyTimeRange();
}
OnPropertyChanged(nameof(IsCustomDateRange));
}
}
@@ -481,25 +506,28 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
}
}
/// <summary>根据选中店铺生成查询店铺 key 列表:未选/全选/选中“全部店铺”时返回空列表(表示全部店铺)</summary>
private List<string> BuildSelectedShopKeys()
/// <summary>
/// 根据选中店铺生成查询用 ERP 店铺ID 列表(接口 ShopIds 使用 ShopLoginInfo.ErpShopId):
/// 未选/全选/选中“全部店铺”时返回空列表(表示全部店铺)。
/// </summary>
private List<string> BuildSelectedShopIds()
{
var selected = SelectedShops;
if (selected == null || selected.Count == 0)
return new List<string>();
var keys = selected.Select(s => s == null ? string.Empty : s.AppKey).ToList();
if (keys.Any(string.IsNullOrEmpty))
var ids = selected.Select(s => s == null ? string.Empty : s.ErpShopId).ToList();
if (ids.Any(string.IsNullOrEmpty))
return new List<string>();
if (selected.Count >= ShopOptions.Count)
return new List<string>();
return keys;
return ids;
}
/// <summary>执行查询,刷新周期统计表格并回到第一页</summary>
private async Task LoadAsync()
/// <summary>组装查询参数(起止日期已保证先后顺序)</summary>
private ShopRevenueStatisticsQuery BuildQuery()
{
if (StartDate > EndDate)
{
@@ -508,19 +536,23 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
EndDate = temp;
}
return new ShopRevenueStatisticsQuery
{
StartDate = StartDate,
EndDate = EndDate,
ShopIds = BuildSelectedShopIds(),
};
}
/// <summary>执行查询,刷新周期统计表格并回到第一页</summary>
private async Task LoadAsync()
{
IsLoading = true;
try
{
var query = new ShopRevenueStatisticsQuery
{
StartDate = StartDate,
EndDate = EndDate,
ShopKeys = BuildSelectedShopKeys(),
};
var table = await _service.GetPeriodStatisticsAsync(query, _periodType);
Summary = await _service.GetSummaryAsync(query);
_allItems = table ?? new List<ShopPeriodStatisticsItem>();
var result = await _service.QueryPeriodStatisticsAsync(BuildQuery(), _periodType);
Summary = result.Summary;
_allItems = result.Items ?? new List<ShopPeriodStatisticsItem>();
TotalCount = _allItems.Count;
TotalPages = TotalCount == 0 ? 1 : (int)Math.Ceiling(TotalCount / (double)PageSize);
GoToPage(1);
@@ -553,35 +585,51 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
private async Task ShowOrderList(string type)
{
var query = new ShopRevenueStatisticsQuery
var detailType = ParseDetailType(type);
List<ShopOrderDetailResponse> showList;
// 明细接口数据量大、耗时长,期间显示页面统一的加载遮罩,避免点了没反应
IsLoading = true;
try
{
StartDate = StartDate,
EndDate = EndDate,
ShopKeys = BuildSelectedShopKeys(),
};
var rows = await _service.QueryRowsAsync(query);
List<ShopRevenueStatisticsRow> showList = new List<ShopRevenueStatisticsRow>();
switch (type)
showList = await _service.QueryOrderRowsAsync(BuildQuery(), detailType)
?? new List<ShopOrderDetailResponse>();
}
catch (Exception ex)
{
case "ValidOrderCount":
showList = RevenueStatisticsAggregator.GetValidOrder(rows);
break; //有效订单
case "RefundOrderCount":
showList = RevenueStatisticsAggregator.GetRefundOrder(rows);
break;//退款订单
case "BuyerRefundRemainingAmount":
showList = RevenueStatisticsAggregator.GetBuyerRefundRemainingOrder(rows);
break;//待退回采购金额
default:
break;
MessageBox.Show("订单明细查询失败:" + ex.Message, "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
finally
{
IsLoading = false;
}
if (showList.Count == 0)
{
MessageBox.Show("未查询到符合条件的订单明细。", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
new DialogWindow()
{
Title = "订单列表",
Content = new ShowOrderDialogView(showList),
DataContext = rows
DataContext = showList
}.ShowDialog();
}
/// <summary>卡片“明细”按钮参数 → 订单明细类型(对应接口参数 OrderType)</summary>
private static ShopOrderDetailType ParseDetailType(string type)
{
switch (type)
{
case "RefundOrderCount":
return ShopOrderDetailType.Refund; //退款订单
case "BuyerRefundRemainingAmount":
return ShopOrderDetailType.BuyerUnrefunded; //买手未退回金额的订单
default:
return ShopOrderDetailType.Valid; //有效订单
}
}
}
}
@@ -1,4 +1,4 @@
using _29WxOrderWpf.Commands;
using _29WxOrderWpf.Commands;
using _29WxOrderWpf.Common;
using _29WxOrderWpf.Ctrls.Statistics.Models;
using _29WxOrderWpf.Ctrls.Statistics.Services;
@@ -12,6 +12,7 @@ using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using Work.BaseForms;
namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
{
@@ -31,6 +32,9 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
private ObservableCollection<ShopOption> _selectedShops;
private string _selectedPlatform;
/// <summary>上一次真正生效(并据此计算过日期)的时间范围选项,避免同一个选项被重复应用</summary>
private TimeRangeOption _appliedTimeRange;
public ShopRevenueStatisticsViewModel()
: this(new ShopRevenueStatisticsService())
{
@@ -70,7 +74,16 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
set
{
this.SetValue(x => x.SelectedTimeRange, value);
ApplyTimeRange();
// 只有用户真正切换到“另一个”时间范围选项时才重新计算起止日期。
// 页签切换时下拉框会把同一个选项(甚至先 null 再回写)重放一遍,
// 若照样重算,就会把用户手工调过的日期覆盖掉。
if (value != null && !ReferenceEquals(_appliedTimeRange, value))
{
_appliedTimeRange = value;
ApplyTimeRange();
}
OnPropertyChanged(nameof(IsCustomDateRange));
}
}
@@ -235,57 +248,66 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
}
}
/// <summary>根据选中店铺生成查询店铺 key 列表:未选/全选/选中“全部店铺”时返回空列表(表示全部店铺)</summary>
private List<string> BuildSelectedShopKeys()
/// <summary>
/// 根据选中店铺生成查询用 ERP 店铺ID 列表(接口 ShopIds 使用 ShopLoginInfo.ErpShopId):
/// 未选/全选/选中“全部店铺”时返回空列表(表示全部店铺)。
/// </summary>
private List<string> BuildSelectedShopIds()
{
var selected = SelectedShops;
if (selected == null || selected.Count == 0)
return new List<string>();
var keys = selected.Select(s => s == null ? string.Empty : s.AppKey).ToList();
if (keys.Any(string.IsNullOrEmpty))
var ids = selected.Select(s => s == null ? string.Empty : s.ErpShopId).ToList();
if (ids.Any(string.IsNullOrEmpty))
return new List<string>();
if (selected.Count >= ShopOptions.Count)
return new List<string>();
return keys;
return ids;
}
/// <summary>执行查询,刷新页面所有统计数据</summary>
private async Task LoadAsync()
/// <summary>组装查询参数(起止日期已保证先后顺序)</summary>
private ShopRevenueStatisticsQuery BuildQuery()
{
if (StartDate > EndDate)
if (StartDate == EndDate)
{
EndDate = EndDate.AddDays(1).AddSeconds(-1);
}
else if (StartDate > EndDate)
{
var temp = StartDate;
StartDate = EndDate;
EndDate = temp;
}
return new ShopRevenueStatisticsQuery
{
StartDate = StartDate,
EndDate = EndDate,
ShopIds = BuildSelectedShopIds(),
};
}
/// <summary>执行查询,刷新页面所有统计数据</summary>
private async Task LoadAsync()
{
IsLoading = true;
try
{
var query = new ShopRevenueStatisticsQuery
{
StartDate = StartDate,
EndDate = EndDate,
ShopKeys = BuildSelectedShopKeys(),
};
var result = await _service.QueryAsync(BuildQuery());
Summary = await _service.GetSummaryAsync(query);
var trend = await _service.GetSalesTrendAsync(query);
TrendData = new ObservableCollection<RevenueTrendPoint>(trend);
var proportion = await _service.GetShopSalesProportionAsync(query);
ShopProportionData = new ObservableCollection<ShopSalesProportionItem>(proportion);
var table = await _service.GetShopStatisticsAsync(query);
ShopStatistics = new ObservableCollection<ShopStatisticsItem>(table);
Summary = result.Summary;
TrendData = new ObservableCollection<RevenueTrendPoint>(result.Trend ?? new List<RevenueTrendPoint>());
ShopProportionData = new ObservableCollection<ShopSalesProportionItem>(result.ShopProportions ?? new List<ShopSalesProportionItem>());
ShopStatistics = new ObservableCollection<ShopStatisticsItem>(result.ShopStatistics ?? new List<ShopStatisticsItem>());
}
catch (Exception)
catch (Exception ex)
{
// 查询异常:保留上一次数据,可在此接入日志/错误提示
// 查询异常:保留上一次数据,并弹框提示
BaseMsgForm.ShowError("查询失败:" + ex.Message, "提示");
}
finally
{
@@ -309,35 +331,53 @@ namespace _29WxOrderWpf.Ctrls.Statistics.ViewModels
private async Task ShowOrderList(string type)
{
var query = new ShopRevenueStatisticsQuery
var detailType = ParseDetailType(type);
List<ShopOrderDetailResponse> showList;
// 明细接口数据量大、耗时长,期间显示页面统一的加载遮罩,避免点了没反应
IsLoading = true;
try
{
StartDate = StartDate,
EndDate = EndDate,
ShopKeys = BuildSelectedShopKeys(),
};
var rows = await _service.QueryRowsAsync(query);
List<ShopRevenueStatisticsRow> showList = new List<ShopRevenueStatisticsRow>();
switch (type)
{
case "ValidOrderCount":
showList = RevenueStatisticsAggregator.GetValidOrder(rows);
break; //有效订单
case "RefundOrderCount":
showList = RevenueStatisticsAggregator.GetRefundOrder(rows);
break;//退款订单
case "BuyerRefundRemainingAmount":
showList = RevenueStatisticsAggregator.GetBuyerRefundRemainingOrder(rows);
break;//待退回采购金额
default:
break;
showList = await _service.QueryOrderRowsAsync(BuildQuery(), detailType)
?? new List<ShopOrderDetailResponse>();
}
catch (Exception ex)
{
BaseMsgForm.ShowError("订单明细查询失败:" + ex.Message, "提示");
return;
}
finally
{
IsLoading = false;
}
if (showList.Count == 0)
{
BaseMsgForm.ShowInfo("未查询到符合条件的订单明细。", "提示");
return;
}
new DialogWindow(GetWindow())
{
Title="订单列表",
Title = "订单列表",
Content = new ShowOrderDialogView(showList),
DataContext = rows
DataContext = showList
}.Show();
}
/// <summary>卡片“明细”按钮参数 → 订单明细类型(对应接口参数 OrderType)</summary>
private static ShopOrderDetailType ParseDetailType(string type)
{
switch (type)
{
case "RefundOrderCount":
return ShopOrderDetailType.Refund; //退款订单
case "BuyerRefundRemainingAmount":
return ShopOrderDetailType.BuyerUnrefunded; //买手未退回金额的订单
default:
return ShopOrderDetailType.Valid; //有效订单
}
}
public Func<Window> GetWindow;
}
}