补充 Fulfill/Pickup 工作台统计 DTO 与查询实现,并扩展订单工作台聚合输出。 Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
13 KiB
C#
370 lines
13 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Oms.Application.Dtos.Orders;
|
|
using Oms.Application.Shared;
|
|
using Oms.Domain.OrdersAggregate;
|
|
using Oms.Domain.OrdersAggregate.Consts;
|
|
using Oms.Domain.OrdersAggregate.Repositories;
|
|
using Oms.Domain.WarehouseAggregate.Consts;
|
|
|
|
namespace Oms.Application.Services.Orders;
|
|
|
|
public partial class OrderService
|
|
{
|
|
private ISalesStoreRepository SalesStoreRepository =>
|
|
LazyServiceProvider.GetRequiredService<ISalesStoreRepository>();
|
|
|
|
private ISalesStoreOrderRepository SalesStoreOrderRepository =>
|
|
LazyServiceProvider.GetRequiredService<ISalesStoreOrderRepository>();
|
|
|
|
/// <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);
|
|
|
|
var customerId = filter.CustomerId;
|
|
var storeCount = customerId is > 0
|
|
? await SalesStoreRepository.CountAsync(x => x.CustomerId == customerId.Value)
|
|
: await SalesStoreRepository.CountAsync(_ => true);
|
|
var storeOrderPending = customerId is > 0
|
|
? await SalesStoreOrderRepository.CountAsync(x =>
|
|
x.CustomerId == customerId.Value
|
|
&& x.FulfillStatus == SalesStoreFulfillStatusCodes.Pending)
|
|
: await SalesStoreOrderRepository.CountAsync(x =>
|
|
x.FulfillStatus == SalesStoreFulfillStatusCodes.Pending);
|
|
|
|
var fulfillFilter = new OrderListFilter
|
|
{
|
|
CustomerId = filter.CustomerId,
|
|
FulfillmentType = FulfillmentTypeCodes.Warehouse,
|
|
ScopeOrganizationIds = filter.ScopeOrganizationIds,
|
|
ScopeSecretLevels = filter.ScopeSecretLevels,
|
|
ScopeDataScope = filter.ScopeDataScope,
|
|
ScopePersonalOwnerMapId = filter.ScopePersonalOwnerMapId,
|
|
ScopePersonalOwnerAccount = filter.ScopePersonalOwnerAccount,
|
|
ScopeOwnerMapIds = filter.ScopeOwnerMapIds,
|
|
ScopeBizFilters = filter.ScopeBizFilters,
|
|
ScopeCustomFilters = filter.ScopeCustomFilters,
|
|
};
|
|
var fulfillCounts = await OrderRepository.GetFulfillProgressCountsByFilterAsync(fulfillFilter);
|
|
int FulfillCount(string key) =>
|
|
fulfillCounts.TryGetValue(key, out var value) ? value : 0;
|
|
|
|
var pickupCounts = await PickupOrderRepository.GetWorkplaceCountsAsync(filter.CustomerId);
|
|
|
|
return new OrderWorkplaceStatsOutput
|
|
{
|
|
Total = counts.Values.Sum(),
|
|
Counts = counts,
|
|
TodayCreated = todayCreated,
|
|
YesterdayCreated = yesterdayCreated,
|
|
DayOverDayGrowthPercent = growth,
|
|
PendingShip = GetCount(OrderStatusCodes.PendingShip),
|
|
Shipped = shipped,
|
|
PendingPayment = GetCount(OrderStatusCodes.PendingPayment),
|
|
Problematic = GetCount(OrderStatusCodes.Problematic),
|
|
Claimed = GetCount(OrderStatusCodes.Claimed),
|
|
StoreCount = storeCount,
|
|
StoreOrderPending = storeOrderPending,
|
|
Fulfill = new FulfillWorkplaceStatsOutput
|
|
{
|
|
PendingBind = FulfillCount(FulfillmentProgressCodes.PendingBind),
|
|
DomesticInTransit = FulfillCount(FulfillmentProgressCodes.DomesticInTransit),
|
|
ArrivedPendingPick = FulfillCount(FulfillmentProgressCodes.ArrivedPendingPick),
|
|
OutboundDone = FulfillCount(FulfillmentProgressCodes.OutboundDone),
|
|
},
|
|
Pickup = new PickupWorkplaceStatsOutput
|
|
{
|
|
PendingPayment = pickupCounts.PendingPayment,
|
|
PendingSchedule = pickupCounts.PendingSchedule,
|
|
PendingPickup = pickupCounts.PendingPickup,
|
|
PendingInbound = pickupCounts.PendingInbound,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 工作台费用趋势(运费支出 vs 单票均价)。
|
|
/// </summary>
|
|
[HttpGet("/order/workplace-spend-trend")]
|
|
public async Task<OrderWorkplaceSpendTrendOutput> GetWorkplaceSpendTrendAsync(
|
|
[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<OrderWorkplaceSpendTrendPointOutput>(buckets.Count);
|
|
|
|
foreach (var bucket in buckets)
|
|
{
|
|
var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
|
|
filter,
|
|
bucket.Start,
|
|
bucket.EndExclusive);
|
|
var spendAmount = await OrderRepository.SumPayableAmountByCreateTimeRangeAsync(
|
|
filter,
|
|
bucket.Start,
|
|
bucket.EndExclusive);
|
|
spendAmount = Math.Round(spendAmount, 2);
|
|
var avg = orderCount > 0
|
|
? Math.Round(spendAmount / orderCount, 2)
|
|
: 0m;
|
|
|
|
points.Add(new OrderWorkplaceSpendTrendPointOutput
|
|
{
|
|
Label = bucket.Label,
|
|
OrderCount = orderCount,
|
|
SpendAmount = spendAmount,
|
|
AvgAmount = avg,
|
|
});
|
|
}
|
|
|
|
var totalOrders = points.Sum(x => x.OrderCount);
|
|
var totalSpend = Math.Round(points.Sum(x => x.SpendAmount), 2);
|
|
var totalAvg = totalOrders > 0
|
|
? Math.Round(totalSpend / totalOrders, 2)
|
|
: 0m;
|
|
|
|
return new OrderWorkplaceSpendTrendOutput
|
|
{
|
|
Dimension = dim,
|
|
TotalSpend = totalSpend,
|
|
TotalOrders = totalOrders,
|
|
AvgAmount = totalAvg,
|
|
Points = points,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 工作台业务线趋势(运单 / 代发 / 揽收新增量)。
|
|
/// </summary>
|
|
[HttpGet("/order/workplace-business-trend")]
|
|
public async Task<OrderWorkplaceBusinessTrendOutput> GetWorkplaceBusinessTrendAsync(
|
|
[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 fulfillFilter = CloneScopedFilter(filter);
|
|
fulfillFilter.FulfillmentType = FulfillmentTypeCodes.Warehouse;
|
|
|
|
var buckets = BuildTrendBuckets(dim, count);
|
|
var points = new List<OrderWorkplaceBusinessTrendPointOutput>(buckets.Count);
|
|
|
|
foreach (var bucket in buckets)
|
|
{
|
|
var totalOrders = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
|
|
filter,
|
|
bucket.Start,
|
|
bucket.EndExclusive);
|
|
var fulfillOrders = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
|
|
fulfillFilter,
|
|
bucket.Start,
|
|
bucket.EndExclusive);
|
|
var pickupOrders = await PickupOrderRepository.CountByCreateTimeRangeAsync(
|
|
filter.CustomerId,
|
|
bucket.Start,
|
|
bucket.EndExclusive);
|
|
|
|
points.Add(new OrderWorkplaceBusinessTrendPointOutput
|
|
{
|
|
Label = bucket.Label,
|
|
WaybillCount = Math.Max(0, totalOrders - fulfillOrders),
|
|
FulfillCount = fulfillOrders,
|
|
PickupCount = pickupOrders,
|
|
});
|
|
}
|
|
|
|
return new OrderWorkplaceBusinessTrendOutput
|
|
{
|
|
Dimension = dim,
|
|
Points = points,
|
|
};
|
|
}
|
|
|
|
private static OrderListFilter CloneScopedFilter(OrderListFilter filter) =>
|
|
new()
|
|
{
|
|
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 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);
|
|
}
|