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);
+ }
}