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:
parent
991ad49255
commit
cde0ccb6b0
134
src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
Normal file
134
src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
Normal 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; }
|
||||
}
|
||||
52
src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs
Normal file
52
src/Oms.Application/Dtos/Orders/OrderWorkplaceStatsOutput.cs
Normal 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; }
|
||||
}
|
||||
38
src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs
Normal file
38
src/Oms.Application/Dtos/Orders/OrderWorkplaceTrendOutput.cs
Normal 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; }
|
||||
}
|
||||
78
src/Oms.Application/Monitor/MonitorRegionMapper.cs
Normal file
78
src/Oms.Application/Monitor/MonitorRegionMapper.cs
Normal 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);
|
||||
}
|
||||
313
src/Oms.Application/Services/Orders/OrderService.Monitor.cs
Normal file
313
src/Oms.Application/Services/Orders/OrderService.Monitor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
176
src/Oms.Application/Services/Orders/OrderService.Workplace.cs
Normal file
176
src/Oms.Application/Services/Orders/OrderService.Workplace.cs
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user