From 8431a8f6b01b703c37ecccde19fdbc00cc53242f Mon Sep 17 00:00:00 2001 From: chfei Date: Thu, 8 Oct 2026 12:07:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BB=9F=E8=AE=A1=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20=E4=BD=BF=E7=94=A8=E6=96=B0API=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/ShopRevenueStatisticsModels.cs | 419 +++++---- .../Services/ShopRevenueStatisticsService.cs | 826 +++++++++--------- .../Ctrls/Statistics/ShowOrderDialogView.xaml | 77 +- .../Statistics/ShowOrderDialogView.xaml.cs | 237 ++++- .../PeriodRevenueStatisticsViewModel.cs | 126 ++- .../ShopRevenueStatisticsViewModel.cs | 140 +-- 6 files changed, 1117 insertions(+), 708 deletions(-) diff --git a/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs b/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs index 3094804..e11a639 100644 --- a/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs +++ b/29WxOrderWpf/Ctrls/Statistics/Models/ShopRevenueStatisticsModels.cs @@ -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 { /// - /// 店铺营收统计 - 查询参数 + /// 店铺营收统计 - 查询参数。 + /// 对应新接口 shop_revenue_statistics/get_shop_order_amount 与 + /// shop_revenue_statistics/get_shop_stats_details 的请求体(StartDate / EndDate / ShopIds)。 /// public class ShopRevenueStatisticsQuery { - /// 开始时间 + /// 开始日期 public DateTime StartDate { get; set; } - /// 结束时间 + /// 结束日期 public DateTime EndDate { get; set; } - /// 选中的店铺 app_key 列表;空/Null 表示全部店铺 - public List ShopKeys { get; set; } + /// + /// 选中的店铺 ERP 店铺ID(ShopLoginInfo.ErpShopId)列表; + /// 空/Null 表示全部店铺(服务层会取全部店铺的 ErpShopId)。 + /// + public List ShopIds { get; set; } } /// - /// 店铺营收统计 - 接口返回的原始明细行。 - /// 与后端 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<Dictionary<string, List<ShopStatsResponse>>> + /// Key = 店铺ID,Value = 该店铺按 聚合后的记录列表 + /// (汇总=1 条;天/周/月=每个自然日/自然周/自然月各 1 条)。 + /// 注意:接口只返回按 GroupType 聚合后的记录,不返回“整段汇总”, + /// 页面上的汇总卡片由客户端对返回记录自行合计(GroupType=Summary 时每店铺一条,直接相加即可)。 + /// 金额字段单位均为“分”,显示时需除以 100 转为元。 /// - public class ShopRevenueStatisticsRow + public class ShopStatsResponse { /// - /// 店铺名称 + /// 店铺ID /// - [JsonProperty("shop_name")] - public string ShopName { get; set; } + public string ShopId { get; set; } /// - /// 下单时间(秒级时间戳) + /// 销售订单创建日期 /// - [JsonProperty("create_time")] - [JsonConverter(typeof(UnixDateTimeConverter))] - public DateTime CreateTime { get; set; } + public DateTime StatDate { get; set; } /// - /// 订单号 + /// 订单数(有效订单,不含退款订单) /// - [JsonProperty("order_id")] - public string OrderId { get; set; } + public int OrderCount { get; set; } /// - /// 订单状态 + /// 销售总额(分) /// - [JsonProperty("status")] - public int Status { get; set; } - - /// - /// 商品总价(分) - /// - [JsonProperty("product_price")] - public int ProductPrice { get; set; } - - /// - /// 订单金额(分) - /// - [JsonProperty("order_price")] - public int OrderPrice { get; set; } - - /// - /// 采购价(分) - /// - [JsonProperty("purchase_price")] - public int PurchasePrice { get; set; } - - /// - /// 运费(分) - /// - [JsonProperty("freight")] - public int Freight { get; set; } - - /// - /// 平台服务费(预计技术服务费,分) - /// - [JsonProperty("predict_commission_fee")] - public int PredictCommissionFee { get; set; } - - /// - /// 达人佣金(分) - /// - [JsonProperty("commission_amount")] - public int CommissionAmount { get; set; } - - /// - /// 优惠金额(分) - /// - [JsonProperty("discounted_price")] - public int DiscountedPrice { get; set; } + public long SalesAmount { get; set; } /// /// 利润(分) /// - [JsonProperty("profit")] - public int Profit { get; set; } + public long ProfitAmount { get; set; } /// - /// 采购状态 + /// 平台佣金(分) /// - [JsonProperty("purchase_status")] - public int PurchaseStatus { get; set; } + public long PlatformFee { get; set; } /// - /// 外采金额(分),out_amount为空或0时从中转仓订单表补全 + /// 退款订单数 + /// + public int RefundOrderCount { get; set; } + + /// + /// 已成功退款金额(分,店铺端已退款成功) + /// + public long RefundDone { get; set; } + + /// + /// 退款处理中金额(分,店铺端退款中) + /// + public long RefundPending { get; set; } + + /// + /// 采购单数 + /// + public int PurchaseCount { get; set; } + + /// + /// 总花费金额(分,买手号采购付款金额) + /// + public long TotalPurchase { get; set; } + + /// + /// 售后中买手总金额(分) + /// + public long TotalRefund => DoneRefund + PendingRefund + RejectedRefund; + + /// + /// 已成功退款金额(分,买手号采购退款成功) + /// + public long DoneRefund { get; set; } + + /// + /// 未退回金额(分) + /// + public long PendingRefund { get; set; } + + /// + /// 被拒绝金额(分) + /// + public long RejectedRefund { get; set; } + } + + /// + /// 销售趋势记录(接口返回):跨店铺合计后的按天趋势,供综合统计页的销售趋势折线图使用。 + /// 接口返回结构: + /// shop_revenue_statistics/GetShopStatsTrend + /// -> Result<List<ShopStatsTrendResponse>> + /// 请求参数:StartDate / EndDate / ShopIds(不需要 GroupType,固定按天返回)。 + /// 金额字段单位为“分”,显示时需除以 100 转为元。 + /// + public class ShopStatsTrendResponse + { + /// + /// 统计日期(天) + /// + public DateTime StatDate { get; set; } + + /// + /// 销售额(分) + /// + public long SalesAmount { get; set; } + + /// + /// 订单数 + /// + public int OrderCount { get; set; } + } + + /// + /// 订单明细(接口返回):订单列表弹窗 ShowOrderDialogView 的数据行。 + /// 接口返回结构: + /// shop_revenue_statistics/GetShopOrderDetails + /// -> Result<List<ShopOrderDetailResponse>> + /// 请求参数:StartDate / EndDate / ShopIds / OrderType(见 )。 + /// 金额字段单位均为“分”,显示时需除以 100 转为元。 + /// + public class ShopOrderDetailResponse + { + /// + /// 店铺ID + /// + public long ShopId { get; set; } + + /// + /// 店铺名称 + /// + public string ShopName { get; set; } + + /// + /// 商铺订单号 + /// + public string OrderId { get; set; } + + /// + /// 订单创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 订单金额(分) + /// + public int OrderPrice { get; set; } + + /// + /// 订单退款金额(分),即商铺订单商品明细的退款金额之和;有效订单为 0 + /// + public int OrderRefundAmount { get; set; } + + /// + /// 外采金额(分),即该订单下所有买手订单的购买金额之和 /// - [JsonProperty("out_amount")] public int OutAmount { get; set; } /// - /// 售后状态(订单明细 refund_status) + /// 买手未退回金额(分),即买手订单中退款未成功的退款金额之和;无售后时为 0 /// - [JsonProperty("refund_status")] - public int RefundStatus { get; set; } + public int UnrefundedAmount { get; set; } /// - /// 售后状态(退款单 status) + /// 买手订单信息(一个订单号对应多个买手订单) /// - [JsonProperty("refund_status_string")] - public string RefundStatusString { get; set; } + public List BuyerOrders { get; set; } = new List(); + } + + /// + /// 买手订单信息(订单明细下的子项,一个商铺订单可对应多个买手订单) + /// + public class BuyerOrderInfo + { + /// + /// 外部订单(买手订单)号 + /// + public string OutOrderSn { get; set; } /// - /// 售后退款金额(分) + /// 金额(分) + /// + public int OutAmount { get; set; } + + /// + /// 退款金额(分) /// - [JsonProperty("refund_amount")] public int RefundAmount { get; set; } /// - /// 是否外采:0=未从中转仓订单表查到,1=查到 + /// 退款状态 /// - [JsonProperty("external_procurement")] - public int ExternalProcurement { get; set; } - - - /// - /// 买手退款金额 - /// - [JsonProperty("buyer_refund_amount")] - public int BuyerRefundAmount { get; set; } - - /// - /// 买手退款状态 - /// - [JsonProperty("buyer_refund_status")] - public string BuyerRefundStatus { get; set; } - + public string RefundStatusText { get; set; } } /// @@ -167,7 +234,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 利润(实际利润) public decimal Profit { get; set; } - /// 退款金额(统计期间退款) + /// 退款金额(统计期间店铺端退款:已成功 + 处理中) public decimal RefundAmount { get; set; } /// 退款订单数 @@ -176,7 +243,10 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 平台服务费总额(元) public decimal PlatformFeeTotal { get; set; } - /// 达人佣金(元) + /// + /// 达人佣金(元)。 + /// 新接口未提供该字段,固定为 0,保留属性与页面展示,待后端补充字段后直接接上。 + /// public decimal Commission { get; set; } /// 客单价(平均每单金额) @@ -239,7 +309,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 店铺名称 public string ShopName { get; set; } - /// 订单数 + /// 订单数(有效订单,不含退款订单) public int OrderCount { get; set; } /// 销售额(元) @@ -255,7 +325,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 平台服务费总额(元) public decimal PlatformFeeTotal { get; set; } - /// 达人佣金(元) + /// 达人佣金(元)。新接口未提供该字段,固定为 0。 public decimal Commission { get; set; } /// 客单价(元) @@ -280,6 +350,51 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models Month = 2 } + /// + /// 接口聚合方式(请求参数 GroupType,按数字传值)。 + /// 每种方式都返回 Dictionary<店铺ID, List<ShopStatsResponse>>: + /// 汇总时每个店铺 1 条,天/周/月时每个店铺每个周期 1 条。 + /// + public enum ShopStatsGroupType + { + /// + /// 汇总:每个商铺只返回一条(整个查询范围合计) + /// + Summary = 0, + + /// + /// 天:按自然日聚合,每个商铺每天一条 + /// + Day = 1, + + /// + /// 周:按自然周聚合(周一为一周开始),每个商铺每周一条 + /// + Week = 2, + + /// + /// 月:按自然月聚合,每个商铺每月一条 + /// + Month = 3 + } + + /// + /// 订单明细类型(订单列表弹窗的入口类型,对应接口参数 OrderType,按数字传值)。 + /// 数值与后端枚举保持一致:有效订单=1、退款订单=2、买手未退回金额的订单=3 + /// (后端枚举里的“全部订单 All=0”本页不使用,故不定义)。 + /// + public enum ShopOrderDetailType + { + /// 有效订单:没有发生售后的订单(对应统计里的订单数 / 销售额 / 利润口径) + Valid = 1, + + /// 退款订单:发生过售后的订单(对应统计里的退款订单数口径) + Refund = 2, + + /// 买手未退回金额的订单:退款订单中买手款项还没退回的(对应统计里的未退回金额口径) + BuyerUnrefunded = 3 + } + /// /// 天/周/月周期统计表格行(金额单位:元)。 /// 按所选店铺(或全部店铺)统计:天统计每天一行;周统计每周一行;月统计每月一行。 @@ -292,7 +407,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 周期显示文本(天:yyyy-MM-dd;周:yyyy-MM-dd ~ yyyy-MM-dd;月:yyyy-MM) public string Period { get; set; } - /// 订单数 + /// 订单数(有效订单,不含退款订单) public int OrderCount { get; set; } /// 销售额(元) @@ -309,7 +424,7 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models /// 平台服务费总额(元) public decimal PlatformFeeTotal { get; set; } - /// 达人佣金(元) + /// 达人佣金(元)。新接口未提供该字段,固定为 0。 public decimal Commission { get; set; } /// 平均利润率(利润 / 采购金额,百分数,如 12.34 表示 12.34%) @@ -322,14 +437,51 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Models public decimal AvgOrderAmount { get; set; } } + /// + /// 店铺营收统计 - 一次查询的全部聚合结果(综合统计页使用)。 + /// 由两次请求组成: + /// shop_revenue_statistics/GetShopStats(GroupType=Summary)-> 卡片/店铺统计表/销售占比(每店铺一条,与区间长度无关) + /// shop_revenue_statistics/GetShopStatsTrend -> 销售趋势(跨店铺合计的按天序列) + /// + public class ShopRevenueStatisticsResult + { + /// 汇总卡片数据 + public ShopRevenueSummary Summary { get; set; } + + /// 销售趋势(按天,缺失日期补 0) + public List Trend { get; set; } + + /// 店铺销售占比(饼图) + public List ShopProportions { get; set; } + + /// 店铺统计表格 + public List ShopStatistics { get; set; } + } + + /// + /// 店铺营收统计 - 天/周/月周期统计查询结果(周期统计页使用)。 + /// 接口不返回整段汇总,Summary 由返回的周期数据在客户端合计得出。 + /// + public class ShopPeriodStatisticsResult + { + /// 汇总卡片数据(整个查询时间段,客户端合计) + public ShopRevenueSummary Summary { get; set; } + + /// 周期统计表格(天/周/月各一行) + public List Items { get; set; } + } + /// /// 店铺下拉选项 /// public class ShopOption { - /// 店铺 app_key(A.app_key) - public string AppKey { get; set; } + /// 店铺ID(ShopLoginInfo.ShopId) public string ShopId { get; set; } + + /// ERP 店铺ID(ShopLoginInfo.ErpShopId),统计接口的 ShopIds 使用该字段 + public string ErpShopId { get; set; } + /// 店铺名称 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); - } - } } diff --git a/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs b/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs index ee66da0..819dc07 100644 --- a/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs +++ b/29WxOrderWpf/Ctrls/Statistics/Services/ShopRevenueStatisticsService.cs @@ -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 { /// /// 店铺营收统计 - 数据服务调用接口。 - /// 页面只依赖此接口,接入真实数据源时新建实现类(如对接 HTTP 接口 / SQL 查询), - /// 并通过 ShopRevenueStatisticsViewModel 构造函数注入即可,无需改动页面。 + /// 页面只依赖此接口,接入其他数据源时新建实现类, + /// 并通过 ViewModel 构造函数注入即可,无需改动页面。 /// public interface IShopRevenueStatisticsService { /// - /// 查询原始统计明细(接口返回的 A.* / B.* / c.* 原始字段)。 - /// 真实实现通常通过 SQL / HTTP 一次性返回这些行,再由聚合器计算。 + /// 综合统计查询:返回汇总卡片、销售趋势(按天)、店铺销售占比、店铺统计表格。 + /// 用 GroupType=Day 一次请求取回按店铺按天的记录,再由客户端汇总,无缓存。 /// - Task> QueryRowsAsync(ShopRevenueStatisticsQuery query); + Task QueryAsync(ShopRevenueStatisticsQuery query); - /// 获取顶部汇总卡片数据 - Task GetSummaryAsync(ShopRevenueStatisticsQuery query); - - /// 获取销售趋势(按天) - Task> GetSalesTrendAsync(ShopRevenueStatisticsQuery query); - - /// 获取店铺销售占比(饼图) - Task> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query); - - /// 获取店铺统计表格 - Task> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query); - - /// 获取天/周/月周期统计表格(每天/每周/每月各一行,按所选店铺或全部店铺统计) - Task> GetPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType); + /// + /// 天/周/月周期统计查询:返回汇总卡片 + 按周期(天/周/月)聚合的表格数据。 + /// 接口不返回整段汇总,Summary 由返回的周期记录在客户端合计。 + /// + Task QueryPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType); /// 获取店铺下拉列表(用于查询条件) Task> GetShopOptionsAsync(); - - + /// + /// 订单明细列表(有效订单 / 退款订单 / 买手未退回金额的订单),供订单列表弹窗使用。 + /// + Task> QueryOrderRowsAsync(ShopRevenueStatisticsQuery query, ShopOrderDetailType detailType); } /// - /// 统计聚合器:把接口返回的原始明细行聚合成页面所需的汇总/趋势/占比/表格数据。 - /// 聚合口径说明(依据接口字段描述): - /// - 订单状态 status:10-待付款;20-待发货;21-部分发货;30-待收货;100-完成; - /// 200-全部商品售后之后订单取消;250-未付款用户主动取消或超时未付款订单自动取消。 - /// - 有效订单:已付款(20/21/30/100)且未退款;10/250 未付款不计入销售额。 - /// - 已退款:c.refund_status 为 4/11,或订单状态为 200(全部商品售后后取消)。 + /// 统计聚合器:把接口返回的 聚合成页面所需的汇总/趋势/占比/表格数据。 + /// 数据来源:shop_revenue_statistics/GetShopStats(GroupType = Day/Week/Month), + /// 接口只返回“按周期聚合的记录”,页面上所有汇总口径都在这里由客户端合计。 + /// 口径说明(依据接口字段含义): + /// - OrderCount 为“有效订单数”,已不含退款订单;因此退款率分母 = OrderCount + RefundOrderCount。 + /// - 店铺端退款:RefundDone(已成功)+ RefundPending(处理中)→ 退款金额。 + /// - 买手端(采购)退款:TotalRefund = DoneRefund + PendingRefund + RejectedRefund → 售后采购金额; + /// DoneRefund → 已退回采购金额;剩余(待退回)= TotalRefund - DoneRefund。 + /// - 接口无“达人佣金”字段,Commission 固定为 0(保留属性与页面展示,待后端补字段)。 /// - 金额字段单位均为“分”,除以 100 转为元。 /// - public static class RevenueStatisticsAggregator + public static class ShopStatsAggregator { - /// 订单状态 - 待付款 - public const int StatusPendingPay = 10; + /// 金额换算:分 → 元 + private const decimal FenPerYuan = 100m; - /// 订单状态 - 待发货 - public const int StatusWaitSend = 20; - - /// 订单状态 - 部分发货 - public const int StatusPartSend = 21; - - /// 订单状态 - 待收货 - public const int StatusWaitReceive = 30; - - /// 订单状态 - 完成 - public const int StatusFinished = 100; - - /// 订单状态 - 全部商品售后之后订单取消 - public const int StatusCancelAfterAfterSale = 200; - - /// 订单状态 - 未付款用户主动取消/超时未付款自动取消 - public const int StatusCancelNotPay = 250; - - /// 是否已付款的订单(20/21/30/100;10 待付款、250 未付款取消不计入) - public static bool IsPaidOrder(ShopRevenueStatisticsRow row) + /// 分转元 + 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; } - /// 是否已退款(c.refund_status 为 4/11,或订单状态为 200 全部商品售后后取消) - public static bool IsRefunded(ShopRevenueStatisticsRow row) + private static List ToList(IEnumerable stats) { - if (row == null) return false; - return row.RefundStatus > 1 || row.RefundStatusString == "MERCHANT_REFUND_SUCCESS" || row.RefundStatusString == "MERCHANT_RETURN_SUCCESS"; + return (stats ?? Enumerable.Empty()) + .Where(a => a != null) + .ToList(); } - /// 是否有效订单(已付款且未退款) - public static bool IsValidOrder(ShopRevenueStatisticsRow row) + private static IEnumerable> GroupByShop(IEnumerable stats) { - return !IsRefunded(row); + return ToList(stats).GroupBy(a => a.ShopId ?? string.Empty); } - /// 买手号退款成功 - public static bool IsBuyerRefundSuccessful(ShopRevenueStatisticsRow row) + + /// 店铺显示名:优先按 ERP 店铺ID匹配,其次按平台店铺ID匹配,都匹配不到时退回店铺ID + 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 GetPaymentOrderList(IEnumerable rows) + + /// 汇总卡片(整个查询时间段,所有选中店铺合计) + public static ShopRevenueSummary GetSummary(IEnumerable stats) { - return (rows ?? new List()).Where(a => a.OutAmount > 0).ToList(); //买手付钱后的订单数量 - } - /// 汇总卡片 - public static ShopRevenueSummary GetSummary(IEnumerable 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, }; } - /// - /// 获取有效订单 - /// - /// - /// - public static List GetValidOrder(IEnumerable rows) - { - var list = GetPaymentOrderList(rows); //买手付钱后的订单数量 - return list.Where(IsValidOrder).ToList(); - } - /// - /// 获取退款订单 - /// - /// - /// - public static List GetRefundOrder(IEnumerable rows) - { - var list = GetPaymentOrderList(rows); //买手付钱后的订单数量 - return list.Where(IsRefunded).ToList(); - } - /// - /// 获取退款订单 - /// - /// - /// - public static List GetBuyerRefundRemainingOrder(IEnumerable 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(); + /// 店铺统计表格(按店铺汇总,销售额倒序) + public static List GetShopStatistics(IEnumerable stats) + { + return GroupByShop(stats) + .Select(g => BuildShopStatisticsItem(g.Key, g)) + .OrderByDescending(i => i.SalesAmount) + .ToList(); } - /// 销售趋势(按天,缺失日期补 0,仅统计有效订单) - public static List GetSalesTrend(IEnumerable rows, DateTime startDate, DateTime endDate) + /// 店铺销售占比(饼图,按店铺汇总,销售额倒序) + public static List GetShopSalesProportion(IEnumerable 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; + } + + /// + /// 销售趋势(按天,缺失日期补 0)。 + /// 数据来自 GetShopStatsTrend:服务端已经跨店铺合计,这里只做日期规整与补零。 + /// + public static List GetSalesTrend(IEnumerable trend, DateTime startDate, DateTime endDate) + { + var list = (trend ?? Enumerable.Empty()) + .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(); @@ -218,95 +186,23 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services return result; } - /// 店铺销售占比(饼图,仅统计有效订单) - public static List GetShopSalesProportion(IEnumerable rows) + /// + /// 天/周/月周期统计表格(每天/每周/每月各一行,无数据的周期也补一行,金额单位:元)。 + /// 结果按周期倒序(最新的周期在最上面)。 + /// 说明:接口按“自然周期”聚合(周从周一起、月从 1 号起),所以筛选也按周期起点比较, + /// 否则区间从周三开始时,那一周(周一起)的数据会被误判为越界而丢掉。 + /// + public static List GetPeriodStatistics(IEnumerable 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(); - } - - /// 店铺统计表格 - public static List GetShopStatistics(IEnumerable 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(); - } - - /// 天/周/月周期统计表格(每天/每周/每月各一行,无数据的周期也补一行,金额单位:元) - public static List GetPeriodStatistics(IEnumerable rows, DateTime startDate, DateTime endDate, PeriodStatisticType periodType) - { - var list = (rows ?? new List()).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(); @@ -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; } + /// 店铺统计行(金额单位:元) + private static ShopStatisticsItem BuildShopStatisticsItem(string shopId, IEnumerable 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 + }; + } + + /// 周期统计行(金额单位:元) + private static ShopPeriodStatisticsItem BuildPeriodStatisticsItem(DateTime periodStart, IEnumerable 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 + }; + } + /// 枚举 startDate~endDate 覆盖的所有周期开始日期(天/周/月) private static IEnumerable EnumeratePeriods(DateTime startDate, DateTime endDate, PeriodStatisticType periodType) { @@ -387,252 +334,261 @@ namespace _29WxOrderWpf.Ctrls.Statistics.Services return periodStart.ToString("yyyy-MM-dd"); } } - } /// - /// 默认实现:请求真实接口(shop_revenue_statistics/get_shop_order_amount)。 + /// 默认实现:请求新接口(无缓存,每次查询实时获取)。 + /// shop_revenue_statistics/GetShopStats + /// -> Result<Dictionary<店铺ID, List<ShopStatsResponse>>> + /// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds, GroupType }); + /// GroupType 决定聚合粒度(0=汇总 1=天 2=周 3=月),汇总时每个店铺只有一条。 + /// shop_revenue_statistics/GetShopStatsTrend + /// -> Result<List<ShopStatsTrendResponse>> + /// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds }); + /// 服务端跨店铺合计,固定按天返回,用于销售趋势折线图。 + /// shop_revenue_statistics/GetShopOrderDetails + /// -> Result<List<ShopOrderDetailResponse>> + /// 请求体:UserLogin.GetSignPostData(new { StartDate, EndDate, ShopIds, OrderType }); + /// 订单列表弹窗的明细数据(OrderType 见 ShopOrderDetailType)。 + /// 接口不返回“整段汇总”,页面上的汇总卡片一律由客户端对返回记录合计。 /// 接入其他数据源时,新建实现类并注入即可(见 IShopRevenueStatisticsService 注释)。 /// public class ShopRevenueStatisticsService : IShopRevenueStatisticsService { - /// 缓存有效期:订单/退款数据会持续变化,超过该时长后缓存自动失效并重新请求。 - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); + /// 统计接口地址(聚合粒度由请求参数 GroupType 决定) + private const string GetShopStatsApi = "shop_revenue_statistics/GetShopStats"; - /// - /// 明细数据缓存(按天缓存,纯时间维度,不含店铺)。 - /// key = 日期(yyyyMMdd),value = 该天全部店铺的明细 + 缓存时间。 - /// 查询时按天命中:未命中的天合并成连续段一次请求,返回后按天拆分写入; - /// 前台选某一个或某多个店铺时,按所选店铺(ShopName)过滤,复用同一批时间数据。 - /// - private static readonly Dictionary _rowCache = - new Dictionary(); - private readonly object _cacheLock = new object(); + /// 销售趋势接口地址(服务端跨店铺合计,固定按天) + private const string GetShopStatsTrendApi = "shop_revenue_statistics/GetShopStatsTrend"; - private sealed class DateRange + /// 订单明细接口地址(订单列表弹窗用) + private const string GetShopOrderDetailsApi = "shop_revenue_statistics/GetShopOrderDetails"; + + /// Unix 纪元(本地时区),用于解析以时间戳作为 Key 的返回数据 + private static readonly DateTime UnixEpoch = TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1)); + + public async Task 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 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) + 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> QueryRowsAsync(ShopRevenueStatisticsQuery query) + public async Task 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 missingDays; - lock (_cacheLock) + /// 页面统计周期 → 接口聚合方式 + 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(); - - 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); } /// - /// 把一次请求的结果按天写入缓存。 - /// 本次请求覆盖的每一天都以返回数据为准整体替换(无数据的天记为空的已缓存, - /// 避免“该天无数据”被反复判定为未命中而重复请求)。 + /// 请求 GetShopStats:返回按 GroupType 聚合的记录 + /// (汇总时每店铺一条;天/周/月时每店铺每个自然日/自然周/自然月一条)。 /// - private void CacheResponse(DateTime startDate, DateTime endDate, List rows) + private static async Task> RequestStatsAsync(ShopRevenueStatisticsQuery query, ShopStatsGroupType groupType) { - if (rows == null) - rows = new List(); + var shopIds = BuildShopIds(query); + if (shopIds.Count == 0) + return new List(); - lock (_cacheLock) + var url = BaseClass.Build29WxObjString(GetShopStatsApi); + var html = await GetHtml.PostJsonAsync(url, BuildPostData(query, shopIds, groupType)); + + var ret = Deserialize>>>(html); + EnsureSuccess(ret); + + var result = new List(); + if (ret.Data == null) + return result; + + foreach (var kv in ret.Data) + result.AddRange(Normalize(kv.Key, kv.Value)); + + return result; + } + + /// + /// 请求 GetShopStatsTrend:跨店铺合计的按天销售趋势(服务端已合计,不需要 GroupType)。 + /// + private static async Task> RequestTrendAsync(ShopRevenueStatisticsQuery query) + { + var shopIds = BuildShopIds(query); + if (shopIds.Count == 0) + return new List(); + + var url = BaseClass.Build29WxObjString(GetShopStatsTrendApi); + var html = await GetHtml.PostJsonAsync(url, BuildTrendPostData(query, shopIds)); + + var ret = Deserialize>>(html); + EnsureSuccess(ret); + return ret.Data ?? new List(); + } + + /// + /// 组装请求体:StartDate / EndDate 传日期,ShopIds 传 ERP 店铺ID 列表, + /// GroupType 传枚举数字(0=汇总 1=天 2=周 3=月)。 + /// + private static object BuildPostData(ShopRevenueStatisticsQuery query, List 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()); + /// 组装趋势接口请求体:StartDate / EndDate + ShopIds(无 GroupType) + private static object BuildTrendPostData(ShopRevenueStatisticsQuery query, List 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(), CachedAt = DateTime.Now }; - } + /// + /// 组装订单明细请求体:StartDate / EndDate + ShopIds + OrderType + /// (OrderType 传枚举数字:1=有效订单 2=退款订单 3=买手未退回金额的订单)。 + /// + private static object BuildOrderDetailPostData(ShopRevenueStatisticsQuery query, List shopIds, ShopOrderDetailType detailType) + { + return UserLogin.GetSignPostData(new + { + StartDate = query.StartDate, + EndDate = query.EndDate, + ShopIds = shopIds, + OrderType = (int)detailType, + }); + } + + /// + /// 查询店铺范围:query.ShopIds 为空表示全部店铺(取全部店铺的 ERP 店铺ID)。 + /// 接口的 ShopIds 使用 ShopLoginInfo.ErpShopId。 + /// + private static List BuildShopIds(ShopRevenueStatisticsQuery query) + { + var selected = (query == null || query.ShopIds == null) + ? new List() + : 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(); + } + + /// + /// 反序列化接口返回;失败时统一抛出可读异常。 + /// 说明:Newtonsoft 默认按属性名匹配(忽略大小写),因此 PascalCase / camelCase 均可解析。 + /// + private static T Deserialize(string html) + { + try + { + return JsonConvert.DeserializeObject(html); + } + catch (Exception ex) + { + throw new Exception("查询失败:接口返回数据解析异常", ex); } } - /// 按天从缓存拼装某时间范围的明细(返回新列表,避免外部修改污染缓存) - private List GetCachedRows(DateTime startDate, DateTime endDate) + /// 校验接口返回码 + private static void EnsureSuccess(Result result) { - var result = new List(); - lock (_cacheLock) + if (result == null) + throw new Exception("查询失败:接口无返回数据"); + if (result.Code != 200) + throw new Exception(string.IsNullOrEmpty(result.Msg) ? "查询失败" : result.Msg); + } + + /// + /// 摊平某个 Key(店铺ID)下的记录,并用 Key 兜底补齐缺失的 ShopId / StatDate。 + /// + private static List Normalize(string key, List items) + { + var result = new List(); + 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; } - /// 按所选店铺过滤明细:ShopKeys 为空/Null 返回全部;否则按店铺名(ShopName)精确匹配。 - private static List FilterByShop(List rows, List shopKeys) + /// 尝试把返回数据的 Key 解析为日期(支持 yyyy-MM-dd / yyyyMMdd / 秒或毫秒时间戳) + private static DateTime? ParseDateFromKey(string key) { - if (rows == null) - return new List(); + if (string.IsNullOrEmpty(key)) + return null; - if (shopKeys == null || shopKeys.Count == 0) - return new List(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(); + 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(); - } - - /// 清理已过期的缓存条目(调用方需持有 _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) - { - 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>>(html, new JsonSerializerSettings - { - ContractResolver = new CamelCasePropertyNamesContractResolver() - }); - if (ret.Code != 200) - { - throw new Exception(ret.Msg); - } - return ret.Data ?? new List(); - - } - catch - { - throw new Exception("查询失败"); + return timestamp.ToString().Length == 13 + ? UnixEpoch.AddMilliseconds(timestamp) + : UnixEpoch.AddSeconds(timestamp); } - } - - public async Task GetSummaryAsync(ShopRevenueStatisticsQuery query) - { - var rows = await QueryRowsAsync(query); - return RevenueStatisticsAggregator.GetSummary(rows); - } - - public async Task> GetSalesTrendAsync(ShopRevenueStatisticsQuery query) - { - var rows = await QueryRowsAsync(query); - return RevenueStatisticsAggregator.GetSalesTrend(rows, query.StartDate, query.EndDate); - } - - public async Task> GetShopSalesProportionAsync(ShopRevenueStatisticsQuery query) - { - var rows = await QueryRowsAsync(query); - return RevenueStatisticsAggregator.GetShopSalesProportion(rows); - } - - public async Task> GetShopStatisticsAsync(ShopRevenueStatisticsQuery query) - { - var rows = await QueryRowsAsync(query); - return RevenueStatisticsAggregator.GetShopStatistics(rows); - } - - /// 获取天/周/月周期统计表格 - public async Task> GetPeriodStatisticsAsync(ShopRevenueStatisticsQuery query, PeriodStatisticType periodType) - { - var rows = await QueryRowsAsync(query); - return RevenueStatisticsAggregator.GetPeriodStatistics(rows, query.StartDate, query.EndDate, periodType); + return null; } /// 店铺下拉列表(同步读取,供查询与过滤使用) @@ -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()); } + /// + /// 订单明细列表(有效订单 / 退款订单 / 买手未退回金额的订单),供订单列表弹窗使用。 + /// 请求 shop_revenue_statistics/GetShopOrderDetails: + /// StartDate / EndDate / ShopIds / OrderType(OrderType 由 detailType 决定,按数字传值)。 + /// + public async Task> QueryOrderRowsAsync(ShopRevenueStatisticsQuery query, ShopOrderDetailType detailType) + { + var shopIds = BuildShopIds(query); + if (shopIds.Count == 0) + return new List(); + + var url = BaseClass.Build29WxObjString(GetShopOrderDetailsApi); + var html = await GetHtml.PostJsonAsync(url, BuildOrderDetailPostData(query, shopIds, detailType)); + + var ret = Deserialize>>(html); + EnsureSuccess(ret); + return ret.Data ?? new List(); + } } } diff --git a/29WxOrderWpf/Ctrls/Statistics/ShowOrderDialogView.xaml b/29WxOrderWpf/Ctrls/Statistics/ShowOrderDialogView.xaml index 460362c..49811dd 100644 --- a/29WxOrderWpf/Ctrls/Statistics/ShowOrderDialogView.xaml +++ b/29WxOrderWpf/Ctrls/Statistics/ShowOrderDialogView.xaml @@ -1,15 +1,20 @@ - @@ -18,6 +23,21 @@ + + + @@ -36,15 +56,15 @@ Width="180" Placement="Left" TitleVerticalAlignment="Center"> - + -