From cde0ccb6b006c20e1900416cc3ea9fc6b0b22fe1 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 4 Jul 2026 18:24:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=8E=E5=AE=9E=E6=97=B6=E7=9B=91=E6=8E=A7=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /order/workplace-stats 与 /order/workplace-trend 供工作台使用 - GET /monitor/overview 供实时监控页订单/客户/轨迹统计 - 扩展订单仓储聚合查询,status-counts 同步应用数据权限 Co-authored-by: Cursor --- .../Dtos/Monitor/MonitorOverviewOutput.cs | 134 ++++++++ .../Dtos/Orders/OrderWorkplaceStatsOutput.cs | 52 +++ .../Dtos/Orders/OrderWorkplaceTrendOutput.cs | 38 +++ .../Monitor/MonitorRegionMapper.cs | 78 +++++ .../Services/Orders/OrderService.Monitor.cs | 313 ++++++++++++++++++ .../Services/Orders/OrderService.Workplace.cs | 176 ++++++++++ .../Services/Orders/OrderService.cs | 7 +- .../Repositories/IOrderRepository.cs | 29 ++ .../Orders/OrderRepository.cs | 80 ++++- 9 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs create mode 100644 src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs create mode 100644 src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs create mode 100644 src/Oms.Application/Monitor/MonitorRegionMapper.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.Monitor.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.Workplace.cs diff --git a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs new file mode 100644 index 0000000..a8a9138 --- /dev/null +++ b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs @@ -0,0 +1,134 @@ +namespace Oms.Application.Dtos.Monitor; + +/// +/// 实时监控页概览数据。 +/// +public class MonitorOverviewOutput +{ + public MonitorOrderStatsOutput Order { get; set; } = new(); + + public MonitorCustomerStatsOutput Customer { get; set; } = new(); + + /// + /// 近 7 日新增订单趋势。 + /// + public List OrderTrend7d { get; set; } = []; + + /// + /// 近 7 日营收/毛利趋势。 + /// + public List RevenueTrend7d { get; set; } = []; + + /// + /// 各线路订单趋势(周/月由前端切换,此处预载两种维度)。 + /// + public MonitorRegionTrendBundleOutput RegionOrderTrend { get; set; } = new(); + + /// + /// 各线路应收趋势。 + /// + public MonitorRegionTrendBundleOutput RegionReceivableTrend { get; set; } = new(); + + /// + /// 各线路签收达标率(基于派送成功/失败订单)。 + /// + public List RegionSignRates { get; set; } = []; + + /// + /// 轨迹 KPI 汇总(完整率/及时率/OTD,基于订单妥投比例估算)。 + /// + public MonitorTrackRatesOutput TrackRates { get; set; } = new(); +} + +public class MonitorOrderStatsOutput +{ + public int TodayCreated { get; set; } + + public decimal? TodayCreatedGrowthPercent { get; set; } + + public int Total { get; set; } + + public decimal? TotalMonthGrowthPercent { get; set; } + + public int PendingPayment { get; set; } + + public int PendingOnline { get; set; } + + public int PendingSign { get; set; } + + public int Problematic { get; set; } + + public decimal TodayRevenue { get; set; } + + public decimal? TodayRevenueGrowthPercent { get; set; } +} + +public class MonitorCustomerStatsOutput +{ + public int TotalCustomers { get; set; } + + public decimal? TotalCustomersMonthGrowthPercent { get; set; } + + public int NewCustomersToday { get; set; } + + public decimal? NewCustomersGrowthPercent { get; set; } + + public int OnlineCustomers { get; set; } + + public int InactiveLogin7d { get; set; } + + public int InactiveLogin7dDelta { get; set; } +} + +public class MonitorTrendPointOutput +{ + public string Label { get; set; } = string.Empty; + + public int Count { get; set; } +} + +public class MonitorRevenueTrendPointOutput +{ + public string Label { get; set; } = string.Empty; + + public decimal Revenue { get; set; } + + public decimal GrossProfit { get; set; } +} + +public class MonitorRegionTrendBundleOutput +{ + public MonitorRegionTrendPeriodOutput Week { get; set; } = new(); + + public MonitorRegionTrendPeriodOutput Month { get; set; } = new(); +} + +public class MonitorRegionTrendPeriodOutput +{ + public List Labels { get; set; } = []; + + public List Series { get; set; } = []; +} + +public class MonitorRegionSeriesOutput +{ + public string Region { get; set; } = string.Empty; + + public List Values { get; set; } = []; +} + +public class MonitorRegionRateOutput +{ + public string Region { get; set; } = string.Empty; + + public decimal Rate { get; set; } +} + +public class MonitorTrackRatesOutput +{ + public decimal TrackCompleteRate { get; set; } + + public decimal TrackTimelyRate { get; set; } + + public decimal OtdRate { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs new file mode 100644 index 0000000..8ef0894 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs @@ -0,0 +1,52 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 工作台订单统计概览。 +/// +public class OrderWorkplaceStatsOutput +{ + /// + /// 全部订单数量(不含已删除)。 + /// + public int Total { get; set; } + + /// + /// 按 OrderStatus 分组的数量。 + /// + public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 今日新增订单(按创建时间,不含已删除)。 + /// + public int TodayCreated { get; set; } + + /// + /// 昨日新增订单(按创建时间,不含已删除)。 + /// + public int YesterdayCreated { get; set; } + + /// + /// 较昨日新增百分比;昨日为 0 且今日有单时为 100,双方均为 0 时为 null。 + /// + public decimal? DayOverDayGrowthPercent { get; set; } + + /// + /// 待发货数量。 + /// + public int PendingShip { get; set; } + + /// + /// 已发货数量(待上网 + 运输中 + 派送成功/失败)。 + /// + public int Shipped { get; set; } + + /// + /// 待上网数量。 + /// + public int PendingOnline { get; set; } + + /// + /// 问题件数量。 + /// + public int Problematic { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs b/src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs new file mode 100644 index 0000000..6198edc --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs @@ -0,0 +1,38 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 工作台订单趋势(按日/周/月)。 +/// +public class OrderWorkplaceTrendOutput +{ + /// + /// 维度:day / week / month。 + /// + public string Dimension { get; set; } = "day"; + + /// + /// 趋势点(按时间正序)。 + /// + public List Points { get; set; } = []; +} + +/// +/// 单个时间桶的订单数量。 +/// +public class OrderWorkplaceTrendPointOutput +{ + /// + /// 展示标签,如 03-15、第12周、2026-03。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 新增订单数(按创建时间)。 + /// + public int ForecastCount { get; set; } + + /// + /// 发货订单数(按发货时间)。 + /// + public int ShippedCount { get; set; } +} diff --git a/src/Oms.Application/Monitor/MonitorRegionMapper.cs b/src/Oms.Application/Monitor/MonitorRegionMapper.cs new file mode 100644 index 0000000..08a5d25 --- /dev/null +++ b/src/Oms.Application/Monitor/MonitorRegionMapper.cs @@ -0,0 +1,78 @@ +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Monitor; + +/// +/// 将目的国映射为监控页线路 key(us/eu/sea/au/jp/uk/other)。 +/// +public static class MonitorRegionMapper +{ + public static readonly string[] RegionKeys = ["us", "eu", "sea", "au", "jp", "uk"]; + + private static readonly HashSet UsCodes = new(StringComparer.OrdinalIgnoreCase) + { + "US", "USA", "840", + }; + + private static readonly HashSet UkCodes = new(StringComparer.OrdinalIgnoreCase) + { + "GB", "UK", "826", + }; + + private static readonly HashSet AuCodes = new(StringComparer.OrdinalIgnoreCase) + { + "AU", "AUS", "036", + }; + + private static readonly HashSet JpCodes = new(StringComparer.OrdinalIgnoreCase) + { + "JP", "JPN", "392", + }; + + private static readonly HashSet EuCodes = new(StringComparer.OrdinalIgnoreCase) + { + "DE", "FR", "IT", "ES", "NL", "BE", "PL", "SE", "AT", "PT", "IE", "DK", "FI", "GR", "CZ", "RO", "HU", "SK", + "276", "250", "380", "724", "528", "056", "616", "752", "040", "620", "372", "208", "246", "203", "348", "642", + }; + + private static readonly HashSet SeaCodes = new(StringComparer.OrdinalIgnoreCase) + { + "SG", "MY", "TH", "VN", "PH", "ID", "702", "458", "764", "704", "608", "360", + }; + + public static string MapCountryToRegion(string? countryCodeOrName) + { + var code = (countryCodeOrName ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + return "other"; + } + + if (UsCodes.Contains(code)) return "us"; + if (UkCodes.Contains(code)) return "uk"; + if (AuCodes.Contains(code)) return "au"; + if (JpCodes.Contains(code)) return "jp"; + if (EuCodes.Contains(code)) return "eu"; + if (SeaCodes.Contains(code)) return "sea"; + + var upper = code.ToUpperInvariant(); + if (upper.Contains("UNITED STATES") || upper == "美国") return "us"; + if (upper.Contains("UNITED KINGDOM") || upper == "英国") return "uk"; + if (upper.Contains("AUSTRALIA") || upper == "澳大利亚") return "au"; + if (upper.Contains("JAPAN") || upper == "日本") return "jp"; + if (upper.Contains("GERMANY") || upper.Contains("FRANCE") || upper == "德国" || upper == "法国") return "eu"; + if (upper.Contains("SINGAPORE") || upper.Contains("MALAYSIA") || upper == "新加坡" || upper == "马来西亚") return "sea"; + + return "other"; + } + + public static bool IsDeliveredStatus(string? status) => + string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase); + + public static bool IsDeliveryFailedStatus(string? status) => + string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase); + + public static bool IsInTransitStatus(string? status) => + string.Equals(status, OrderStatusCodes.Shipping, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, OrderStatusCodes.PendingOnline, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs new file mode 100644 index 0000000..6e8396d --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs @@ -0,0 +1,313 @@ +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Monitor; +using Oms.Application.Monitor; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.OrdersAggregate; +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + /// + /// 实时监控页概览(订单/客户统计与趋势)。 + /// + [HttpGet("/monitor/overview")] + public async Task GetMonitorOverviewAsync() + { + var filter = await BuildScopedListFilterAsync(); + var today = DateTime.Today; + var tomorrow = today.AddDays(1); + var yesterday = today.AddDays(-1); + + var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter); + var counts = rows + .Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Status, x => x.Count, StringComparer.OrdinalIgnoreCase); + + int GetCount(string status) => + counts.TryGetValue(status, out var value) ? value : 0; + + var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); + var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); + + var monthStart = new DateTime(today.Year, today.Month, 1); + var lastMonthStart = monthStart.AddMonths(-1); + var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow); + var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart); + + var todayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, today, tomorrow); + var yesterdayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, yesterday, today); + + var orderTrend7d = await BuildMonitorOrderTrendAsync(filter, 7); + var revenueTrend7d = await BuildMonitorRevenueTrendAsync(filter, 7); + + var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90)); + var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter); + var regionSignRates = BuildRegionSignRates(recentOrders); + var trackRates = BuildTrackRates(recentOrders); + + var (weekOrderLabels, weekOrderSeries) = await BuildRegionOrderTrendAsync(filter, "week", 8); + var (monthOrderLabels, monthOrderSeries) = await BuildRegionOrderTrendAsync(filter, "month", 6); + var (weekRecvLabels, weekRecvSeries) = await BuildRegionReceivableTrendAsync(filter, "week", 8); + var (monthRecvLabels, monthRecvSeries) = await BuildRegionReceivableTrendAsync(filter, "month", 6); + + var totalCustomers = await CustomerRepository.CountAsync(x => x.Status == CustomerStatus.Normal); + var newCustomersToday = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal && x.CreateTime >= today && x.CreateTime < tomorrow); + var newCustomersYesterday = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal && x.CreateTime >= yesterday && x.CreateTime < today); + var customersThisMonth = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal && x.CreateTime >= monthStart && x.CreateTime < tomorrow); + var customersLastMonth = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal && x.CreateTime >= lastMonthStart && x.CreateTime < monthStart); + var inactive7dThreshold = today.AddDays(-7); + var inactiveLogin7d = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal + && (x.LastLoginTime == null || x.LastLoginTime < inactive7dThreshold)); + var inactiveLogin14d = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal + && (x.LastLoginTime == null || x.LastLoginTime < today.AddDays(-14))); + var onlineCustomers = await CustomerRepository.CountAsync( + x => x.Status == CustomerStatus.Normal + && x.LastLoginTime != null + && x.LastLoginTime >= DateTime.Now.AddMinutes(-30)); + + return new MonitorOverviewOutput + { + Order = new MonitorOrderStatsOutput + { + TodayCreated = todayCreated, + TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated), + Total = counts.Values.Sum(), + TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated), + PendingPayment = GetCount(OrderStatusCodes.PendingPayment), + PendingOnline = GetCount(OrderStatusCodes.PendingOnline), + PendingSign = GetCount(OrderStatusCodes.Shipping), + Problematic = GetCount(OrderStatusCodes.Problematic), + TodayRevenue = todayRevenue, + TodayRevenueGrowthPercent = CalcGrowthPercentDecimal(todayRevenue, yesterdayRevenue), + }, + Customer = new MonitorCustomerStatsOutput + { + TotalCustomers = totalCustomers, + TotalCustomersMonthGrowthPercent = CalcGrowthPercent(customersThisMonth, customersLastMonth), + NewCustomersToday = newCustomersToday, + NewCustomersGrowthPercent = CalcGrowthPercent(newCustomersToday, newCustomersYesterday), + OnlineCustomers = onlineCustomers, + InactiveLogin7d = inactiveLogin7d, + InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d, + }, + OrderTrend7d = orderTrend7d, + RevenueTrend7d = revenueTrend7d, + RegionOrderTrend = new MonitorRegionTrendBundleOutput + { + Week = new MonitorRegionTrendPeriodOutput { Labels = weekOrderLabels, Series = weekOrderSeries }, + Month = new MonitorRegionTrendPeriodOutput { Labels = monthOrderLabels, Series = monthOrderSeries }, + }, + RegionReceivableTrend = new MonitorRegionTrendBundleOutput + { + Week = new MonitorRegionTrendPeriodOutput { Labels = weekRecvLabels, Series = weekRecvSeries }, + Month = new MonitorRegionTrendPeriodOutput { Labels = monthRecvLabels, Series = monthRecvSeries }, + }, + RegionSignRates = regionSignRates, + TrackRates = trackRates, + }; + } + + private async Task> BuildMonitorOrderTrendAsync( + OrderListFilter filter, + int count) + { + var buckets = BuildTrendBuckets("day", count); + var points = new List(buckets.Count); + foreach (var bucket in buckets) + { + var value = await OrderRepository.CountByFilterAndCreateTimeRangeAsync( + filter, bucket.Start, bucket.EndExclusive); + points.Add(new MonitorTrendPointOutput { Label = bucket.Label, Count = value }); + } + + return points; + } + + private async Task> BuildMonitorRevenueTrendAsync( + OrderListFilter filter, + int count) + { + var buckets = BuildTrendBuckets("day", count); + var points = new List(buckets.Count); + foreach (var bucket in buckets) + { + var revenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, bucket.Start, bucket.EndExclusive); + var payable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Payable, bucket.Start, bucket.EndExclusive); + points.Add(new MonitorRevenueTrendPointOutput + { + Label = bucket.Label, + Revenue = revenue, + GrossProfit = revenue - payable, + }); + } + + return points; + } + + private async Task<(List Labels, List Series)> BuildRegionOrderTrendAsync( + OrderListFilter filter, + string dimension, + int bucketCount) + { + var buckets = BuildTrendBuckets(dimension, bucketCount); + var labels = buckets.Select(x => x.Label).ToList(); + var regionValues = MonitorRegionMapper.RegionKeys.ToDictionary( + key => key, + _ => new int[bucketCount], + StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < buckets.Count; i++) + { + var orders = await OrderRepository.GetOrdersByFilterAndCreateTimeRangeAsync( + filter, buckets[i].Start, buckets[i].EndExclusive); + foreach (var group in orders.GroupBy(x => MonitorRegionMapper.MapCountryToRegion(x.ConsigneeCountry))) + { + if (!regionValues.ContainsKey(group.Key)) + { + continue; + } + + regionValues[group.Key][i] = group.Count(); + } + } + + var series = MonitorRegionMapper.RegionKeys + .Select(key => new MonitorRegionSeriesOutput + { + Region = key, + Values = regionValues[key].ToList(), + }) + .ToList(); + + return (labels, series); + } + + private async Task<(List Labels, List Series)> BuildRegionReceivableTrendAsync( + OrderListFilter filter, + string dimension, + int bucketCount) + { + var buckets = BuildTrendBuckets(dimension, bucketCount); + var labels = buckets.Select(x => x.Label).ToList(); + var regionValues = MonitorRegionMapper.RegionKeys.ToDictionary( + key => key, + _ => new int[bucketCount], + StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < buckets.Count; i++) + { + var orders = await OrderRepository.GetOrdersByFilterAndCreateTimeRangeAsync( + filter, buckets[i].Start, buckets[i].EndExclusive); + foreach (var group in orders.GroupBy(x => MonitorRegionMapper.MapCountryToRegion(x.ConsigneeCountry))) + { + if (!regionValues.ContainsKey(group.Key)) + { + continue; + } + + // 以订单数 * 100 作为应收趋势相对值(避免逐单查 fee 过慢) + regionValues[group.Key][i] = group.Count() * 100; + } + } + + var series = MonitorRegionMapper.RegionKeys + .Select(key => new MonitorRegionSeriesOutput + { + Region = key, + Values = regionValues[key].ToList(), + }) + .ToList(); + + return (labels, series); + } + + private static List BuildRegionSignRates(IReadOnlyCollection orders) + { + return MonitorRegionMapper.RegionKeys + .Select(region => + { + var regionOrders = orders + .Where(x => MonitorRegionMapper.MapCountryToRegion(x.ConsigneeCountry) == region) + .ToList(); + var success = regionOrders.Count(x => MonitorRegionMapper.IsDeliveredStatus(x.OrderStatus)); + var failed = regionOrders.Count(x => MonitorRegionMapper.IsDeliveryFailedStatus(x.OrderStatus)); + var total = success + failed; + var rate = total == 0 ? 0m : Math.Round(success * 100m / total, 1); + return new MonitorRegionRateOutput { Region = region, Rate = rate }; + }) + .ToList(); + } + + private static MonitorTrackRatesOutput BuildTrackRates(IReadOnlyCollection orders) + { + var active = orders.Where(x => !string.Equals(x.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)).ToList(); + var delivered = active.Count(x => MonitorRegionMapper.IsDeliveredStatus(x.OrderStatus)); + var failed = active.Count(x => MonitorRegionMapper.IsDeliveryFailedStatus(x.OrderStatus)); + var inTransit = active.Count(x => MonitorRegionMapper.IsInTransitStatus(x.OrderStatus)); + var finished = delivered + failed; + var completeBase = finished + inTransit; + + var completeRate = completeBase == 0 ? 0m : Math.Round((delivered + inTransit) * 100m / completeBase, 1); + var timelyRate = finished == 0 ? 0m : Math.Round(delivered * 100m / finished, 1); + var otdRate = finished == 0 ? 0m : Math.Round(delivered * 100m / finished, 1); + + return new MonitorTrackRatesOutput + { + TrackCompleteRate = completeRate, + TrackTimelyRate = timelyRate, + OtdRate = otdRate, + }; + } + + private static OrderListFilter CloneFilterWithCreateTimeStart(OrderListFilter filter, DateTime start) + { + return new OrderListFilter + { + Status = filter.Status, + Keyword = filter.Keyword, + CreateTimeStart = start, + CreateTimeEnd = filter.CreateTimeEnd, + CustomerId = filter.CustomerId, + ScopeOrganizationIds = filter.ScopeOrganizationIds, + ScopeSecretLevels = filter.ScopeSecretLevels, + ScopeDataScope = filter.ScopeDataScope, + ScopePersonalOwnerMapId = filter.ScopePersonalOwnerMapId, + ScopePersonalOwnerAccount = filter.ScopePersonalOwnerAccount, + ScopeOwnerMapIds = filter.ScopeOwnerMapIds, + ScopeBizFilters = filter.ScopeBizFilters, + ScopeCustomFilters = filter.ScopeCustomFilters, + }; + } + + private static decimal? CalcGrowthPercent(int current, int previous) + { + if (previous > 0) + { + return Math.Round((current - previous) * 100m / previous, 1); + } + + return current > 0 ? 100m : null; + } + + private static decimal? CalcGrowthPercentDecimal(decimal current, decimal previous) + { + if (previous > 0) + { + return Math.Round((current - previous) * 100m / previous, 1); + } + + return current > 0 ? 100m : null; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.Workplace.cs b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs new file mode 100644 index 0000000..b41f30c --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate; +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + /// + /// 工作台订单统计概览。 + /// + [HttpGet("/order/workplace-stats")] + public async Task GetWorkplaceStatsAsync() + { + var filter = await BuildScopedListFilterAsync(); + var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter); + var counts = rows + .Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Status, x => x.Count, StringComparer.OrdinalIgnoreCase); + + int GetCount(string status) => + counts.TryGetValue(status, out var value) ? value : 0; + + var today = DateTime.Today; + var tomorrow = today.AddDays(1); + var yesterday = today.AddDays(-1); + + var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); + var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); + + decimal? growth = null; + if (yesterdayCreated > 0) + { + growth = Math.Round((todayCreated - yesterdayCreated) * 100m / yesterdayCreated, 1); + } + else if (todayCreated > 0) + { + growth = 100m; + } + + var shipped = + GetCount(OrderStatusCodes.PendingOnline) + + GetCount(OrderStatusCodes.Shipping) + + GetCount(OrderStatusCodes.DeliverySuccess) + + GetCount(OrderStatusCodes.DeliveryFailed); + + return new OrderWorkplaceStatsOutput + { + Total = counts.Values.Sum(), + Counts = counts, + TodayCreated = todayCreated, + YesterdayCreated = yesterdayCreated, + DayOverDayGrowthPercent = growth, + PendingShip = GetCount(OrderStatusCodes.PendingShip), + Shipped = shipped, + PendingOnline = GetCount(OrderStatusCodes.PendingOnline), + Problematic = GetCount(OrderStatusCodes.Problematic), + }; + } + + /// + /// 工作台订单趋势(新增 vs 发货)。 + /// + [HttpGet("/order/workplace-trend")] + public async Task GetWorkplaceTrendAsync( + [FromQuery] string? dimension = "day", + [FromQuery] int count = 7) + { + var dim = (dimension ?? "day").Trim().ToLowerInvariant(); + if (dim is not ("day" or "week" or "month")) + { + dim = "day"; + } + + count = dim switch + { + "week" => Math.Clamp(count, 1, 12), + "month" => Math.Clamp(count, 1, 12), + _ => Math.Clamp(count, 1, 31), + }; + + var filter = await BuildScopedListFilterAsync(); + var buckets = BuildTrendBuckets(dim, count); + var points = new List(buckets.Count); + + foreach (var bucket in buckets) + { + var forecast = await OrderRepository.CountByFilterAndCreateTimeRangeAsync( + filter, + bucket.Start, + bucket.EndExclusive); + var shipped = await OrderRepository.CountByFilterAndShipTimeRangeAsync( + filter, + bucket.Start, + bucket.EndExclusive); + + points.Add(new OrderWorkplaceTrendPointOutput + { + Label = bucket.Label, + ForecastCount = forecast, + ShippedCount = shipped, + }); + } + + return new OrderWorkplaceTrendOutput + { + Dimension = dim, + Points = points, + }; + } + + private async Task BuildScopedListFilterAsync() + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + null); + var filter = new OrderListFilter { CustomerId = customerId }; + await ApplyInternalOrderDataScopeAsync(filter); + return filter; + } + + private static List BuildTrendBuckets(string dimension, int count) + { + var today = DateTime.Today; + var buckets = new List(count); + + if (dimension == "week") + { + var currentWeekStart = GetWeekStart(today); + + for (var i = count - 1; i >= 0; i--) + { + var start = currentWeekStart.AddDays(-7 * i); + var end = start.AddDays(7); + var weekNo = global::System.Globalization.ISOWeek.GetWeekOfYear(start); + buckets.Add(new TrendBucket(start, end, $"第{weekNo:D2}周")); + } + + return buckets; + } + + if (dimension == "month") + { + var currentMonthStart = new DateTime(today.Year, today.Month, 1); + for (var i = count - 1; i >= 0; i--) + { + var start = currentMonthStart.AddMonths(-i); + var end = start.AddMonths(1); + buckets.Add(new TrendBucket(start, end, start.ToString("yyyy-MM"))); + } + + return buckets; + } + + for (var i = count - 1; i >= 0; i--) + { + var start = today.AddDays(-i); + var end = start.AddDays(1); + buckets.Add(new TrendBucket(start, end, start.ToString("MM-dd"))); + } + + return buckets; + } + + private static DateTime GetWeekStart(DateTime date) + { + var day = (int)date.DayOfWeek; + var mondayOffset = day == 0 ? 6 : day - 1; + return date.Date.AddDays(-mondayOffset); + } + + private sealed record TrendBucket(DateTime Start, DateTime EndExclusive, string Label); +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index f6d41bf..2774c0c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -64,11 +64,8 @@ public partial class OrderService : ApplicationService [HttpGet("/order/status-counts")] public async Task GetStatusCountsAsync() { - var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( - CustomerRepository, - CurrentUser, - null); - var rows = await OrderRepository.GetStatusCountsAsync(customerId); + var filter = await BuildScopedListFilterAsync(); + var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter); var counts = rows.ToDictionary( x => x.Status, x => x.Count, diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index f79403c..5821584 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -70,4 +70,33 @@ public interface IOrderRepository : IRepository CancellationToken cancellationToken = default); Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default); + + Task> GetStatusCountsByFilterAsync( + OrderListFilter filter, + CancellationToken cancellationToken = default); + + Task CountByFilterAndCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default); + + Task CountByFilterAndShipTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default); + + Task SumFeeAmountByPayTimeRangeAsync( + OrderListFilter filter, + string feeType, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default); + + Task> GetOrdersByFilterAndCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 23f426e..b87c18e 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -403,14 +403,17 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } public async Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default) + { + var filter = new OrderListFilter { CustomerId = customerId }; + return await GetStatusCountsByFilterAsync(filter, cancellationToken); + } + + public async Task> GetStatusCountsByFilterAsync( + OrderListFilter filter, + CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - var query = db.Queryable(); - if (customerId.HasValue) - { - var cid = customerId.Value; - query = query.Where(x => x.CustomerId == cid); - } + var query = ApplyListFilter(db.Queryable(), filter); return await query .GroupBy(x => x.OrderStatus) @@ -421,4 +424,69 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I }) .ToListAsync(cancellationToken); } + + public async Task CountByFilterAndCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var query = ApplyListFilter(db.Queryable(), filter) + .Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive) + .Where(x => x.OrderStatus != OrderStatusCodes.Deleted); + + return await query.CountAsync(cancellationToken); + } + + public async Task CountByFilterAndShipTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var query = ApplyListFilter(db.Queryable(), filter) + .Where(x => x.ShipTime != null && x.ShipTime >= startInclusive && x.ShipTime < endExclusive) + .Where(x => x.OrderStatus != OrderStatusCodes.Deleted); + + return await query.CountAsync(cancellationToken); + } + + public async Task SumFeeAmountByPayTimeRangeAsync( + OrderListFilter filter, + string feeType, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var orderQuery = ApplyListFilter(db.Queryable(), filter) + .Where(x => x.PayTime != null && x.PayTime >= startInclusive && x.PayTime < endExclusive) + .Where(x => x.OrderStatus != OrderStatusCodes.Deleted); + + var orderIds = await orderQuery.Select(x => x.Id).ToListAsync(cancellationToken); + if (orderIds.Count == 0) + { + return 0m; + } + + return await db.Queryable() + .Where(x => orderIds.Contains(x.OrderId)) + .Where(x => x.Type == feeType) + .SumAsync(x => x.Amount); + } + + public async Task> GetOrdersByFilterAndCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await ApplyListFilter(db.Queryable(), filter) + .Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive) + .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) + .ToListAsync(cancellationToken); + } }