feat: 新增工作台与实时监控统计 API

- GET /order/workplace-stats 与 /order/workplace-trend 供工作台使用

- GET /monitor/overview 供实时监控页订单/客户/轨迹统计

- 扩展订单仓储聚合查询,status-counts 同步应用数据权限

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-04 18:24:48 +08:00
parent 991ad49255
commit cde0ccb6b0
9 changed files with 896 additions and 11 deletions

View File

@ -0,0 +1,134 @@
namespace Oms.Application.Dtos.Monitor;
/// <summary>
/// 实时监控页概览数据。
/// </summary>
public class MonitorOverviewOutput
{
public MonitorOrderStatsOutput Order { get; set; } = new();
public MonitorCustomerStatsOutput Customer { get; set; } = new();
/// <summary>
/// 近 7 日新增订单趋势。
/// </summary>
public List<MonitorTrendPointOutput> OrderTrend7d { get; set; } = [];
/// <summary>
/// 近 7 日营收/毛利趋势。
/// </summary>
public List<MonitorRevenueTrendPointOutput> RevenueTrend7d { get; set; } = [];
/// <summary>
/// 各线路订单趋势(周/月由前端切换,此处预载两种维度)。
/// </summary>
public MonitorRegionTrendBundleOutput RegionOrderTrend { get; set; } = new();
/// <summary>
/// 各线路应收趋势。
/// </summary>
public MonitorRegionTrendBundleOutput RegionReceivableTrend { get; set; } = new();
/// <summary>
/// 各线路签收达标率(基于派送成功/失败订单)。
/// </summary>
public List<MonitorRegionRateOutput> RegionSignRates { get; set; } = [];
/// <summary>
/// 轨迹 KPI 汇总(完整率/及时率/OTD,基于订单妥投比例估算)。
/// </summary>
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<string> Labels { get; set; } = [];
public List<MonitorRegionSeriesOutput> Series { get; set; } = [];
}
public class MonitorRegionSeriesOutput
{
public string Region { get; set; } = string.Empty;
public List<int> 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; }
}

View File

@ -0,0 +1,52 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 工作台订单统计概览。
/// </summary>
public class OrderWorkplaceStatsOutput
{
/// <summary>
/// 全部订单数量(不含已删除)。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 按 OrderStatus 分组的数量。
/// </summary>
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 今日新增订单(按创建时间,不含已删除)。
/// </summary>
public int TodayCreated { get; set; }
/// <summary>
/// 昨日新增订单(按创建时间,不含已删除)。
/// </summary>
public int YesterdayCreated { get; set; }
/// <summary>
/// 较昨日新增百分比;昨日为 0 且今日有单时为 100,双方均为 0 时为 null。
/// </summary>
public decimal? DayOverDayGrowthPercent { get; set; }
/// <summary>
/// 待发货数量。
/// </summary>
public int PendingShip { get; set; }
/// <summary>
/// 已发货数量(待上网 + 运输中 + 派送成功/失败)。
/// </summary>
public int Shipped { get; set; }
/// <summary>
/// 待上网数量。
/// </summary>
public int PendingOnline { get; set; }
/// <summary>
/// 问题件数量。
/// </summary>
public int Problematic { get; set; }
}

View File

@ -0,0 +1,38 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 工作台订单趋势(按日/周/月)。
/// </summary>
public class OrderWorkplaceTrendOutput
{
/// <summary>
/// 维度:day / week / month。
/// </summary>
public string Dimension { get; set; } = "day";
/// <summary>
/// 趋势点(按时间正序)。
/// </summary>
public List<OrderWorkplaceTrendPointOutput> Points { get; set; } = [];
}
/// <summary>
/// 单个时间桶的订单数量。
/// </summary>
public class OrderWorkplaceTrendPointOutput
{
/// <summary>
/// 展示标签,如 03-15、第12周、2026-03。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 新增订单数(按创建时间)。
/// </summary>
public int ForecastCount { get; set; }
/// <summary>
/// 发货订单数(按发货时间)。
/// </summary>
public int ShippedCount { get; set; }
}

View File

@ -0,0 +1,78 @@
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Monitor;
/// <summary>
/// 将目的国映射为监控页线路 key(us/eu/sea/au/jp/uk/other)。
/// </summary>
public static class MonitorRegionMapper
{
public static readonly string[] RegionKeys = ["us", "eu", "sea", "au", "jp", "uk"];
private static readonly HashSet<string> UsCodes = new(StringComparer.OrdinalIgnoreCase)
{
"US", "USA", "840",
};
private static readonly HashSet<string> UkCodes = new(StringComparer.OrdinalIgnoreCase)
{
"GB", "UK", "826",
};
private static readonly HashSet<string> AuCodes = new(StringComparer.OrdinalIgnoreCase)
{
"AU", "AUS", "036",
};
private static readonly HashSet<string> JpCodes = new(StringComparer.OrdinalIgnoreCase)
{
"JP", "JPN", "392",
};
private static readonly HashSet<string> 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<string> 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);
}

View File

@ -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
{
/// <summary>
/// 实时监控页概览(订单/客户统计与趋势)。
/// </summary>
[HttpGet("/monitor/overview")]
public async Task<MonitorOverviewOutput> 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<List<MonitorTrendPointOutput>> BuildMonitorOrderTrendAsync(
OrderListFilter filter,
int count)
{
var buckets = BuildTrendBuckets("day", count);
var points = new List<MonitorTrendPointOutput>(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<List<MonitorRevenueTrendPointOutput>> BuildMonitorRevenueTrendAsync(
OrderListFilter filter,
int count)
{
var buckets = BuildTrendBuckets("day", count);
var points = new List<MonitorRevenueTrendPointOutput>(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<string> Labels, List<MonitorRegionSeriesOutput> 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<string> Labels, List<MonitorRegionSeriesOutput> 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<MonitorRegionRateOutput> BuildRegionSignRates(IReadOnlyCollection<Domain.OrdersAggregate.Entities.Order> 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<Domain.OrdersAggregate.Entities.Order> 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;
}
}

View File

@ -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
{
/// <summary>
/// 工作台订单统计概览。
/// </summary>
[HttpGet("/order/workplace-stats")]
public async Task<OrderWorkplaceStatsOutput> 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),
};
}
/// <summary>
/// 工作台订单趋势(新增 vs 发货)。
/// </summary>
[HttpGet("/order/workplace-trend")]
public async Task<OrderWorkplaceTrendOutput> 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<OrderWorkplaceTrendPointOutput>(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<OrderListFilter> BuildScopedListFilterAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
var filter = new OrderListFilter { CustomerId = customerId };
await ApplyInternalOrderDataScopeAsync(filter);
return filter;
}
private static List<TrendBucket> BuildTrendBuckets(string dimension, int count)
{
var today = DateTime.Today;
var buckets = new List<TrendBucket>(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);
}

View File

@ -64,11 +64,8 @@ public partial class OrderService : ApplicationService
[HttpGet("/order/status-counts")]
public async Task<OrderStatusCountsOutput> 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,

View File

@ -70,4 +70,33 @@ public interface IOrderRepository : IRepository<Order>
CancellationToken cancellationToken = default);
Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default);
Task<List<OrderStatusCountRow>> GetStatusCountsByFilterAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default);
Task<int> CountByFilterAndCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default);
Task<int> CountByFilterAndShipTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default);
Task<decimal> SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default);
Task<List<Order>> GetOrdersByFilterAndCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default);
}

View File

@ -403,14 +403,17 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
}
public async Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default)
{
var filter = new OrderListFilter { CustomerId = customerId };
return await GetStatusCountsByFilterAsync(filter, cancellationToken);
}
public async Task<List<OrderStatusCountRow>> GetStatusCountsByFilterAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = db.Queryable<Order>();
if (customerId.HasValue)
{
var cid = customerId.Value;
query = query.Where(x => x.CustomerId == cid);
}
var query = ApplyListFilter(db.Queryable<Order>(), filter);
return await query
.GroupBy(x => x.OrderStatus)
@ -421,4 +424,69 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
})
.ToListAsync(cancellationToken);
}
public async Task<int> CountByFilterAndCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = ApplyListFilter(db.Queryable<Order>(), filter)
.Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive)
.Where(x => x.OrderStatus != OrderStatusCodes.Deleted);
return await query.CountAsync(cancellationToken);
}
public async Task<int> CountByFilterAndShipTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = ApplyListFilter(db.Queryable<Order>(), 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<decimal> SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var orderQuery = ApplyListFilter(db.Queryable<Order>(), 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<OrderFee>()
.Where(x => orderIds.Contains(x.OrderId))
.Where(x => x.Type == feeType)
.SumAsync(x => x.Amount);
}
public async Task<List<Order>> GetOrdersByFilterAndCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await ApplyListFilter(db.Queryable<Order>(), filter)
.Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive)
.Where(x => x.OrderStatus != OrderStatusCodes.Deleted)
.ToListAsync(cancellationToken);
}
}