feat: 工作台扩展代发/拣货统计与业务趋势接口
补充 Fulfill/Pickup 工作台统计 DTO 与查询实现,并扩展订单工作台聚合输出。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ce1349572d
commit
78e06d01b9
@ -0,0 +1,19 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 工作台代发订单统计。
|
||||
/// </summary>
|
||||
public class FulfillWorkplaceStatsOutput
|
||||
{
|
||||
/// <summary>待绑定包裹/库存。</summary>
|
||||
public int PendingBind { get; set; }
|
||||
|
||||
/// <summary>国内快递在途。</summary>
|
||||
public int DomesticInTransit { get; set; }
|
||||
|
||||
/// <summary>已到仓,待仓库拣货。</summary>
|
||||
public int ArrivedPendingPick { get; set; }
|
||||
|
||||
/// <summary>已出库(拣货扣减完成)。</summary>
|
||||
public int OutboundDone { get; set; }
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 工作台业务线趋势(运单 / 代发 / 揽收新增量)。
|
||||
/// </summary>
|
||||
public class OrderWorkplaceBusinessTrendOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度:day / week / month。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "day";
|
||||
|
||||
/// <summary>
|
||||
/// 趋势点(按时间正序)。
|
||||
/// </summary>
|
||||
public List<OrderWorkplaceBusinessTrendPointOutput> Points { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个时间桶的三类业务量。
|
||||
/// </summary>
|
||||
public class OrderWorkplaceBusinessTrendPointOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 展示标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 运单新增(非仓内代发)。
|
||||
/// </summary>
|
||||
public int WaybillCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 代发订单新增(仓内履约)。
|
||||
/// </summary>
|
||||
public int FulfillCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 揽收单新增。
|
||||
/// </summary>
|
||||
public int PickupCount { get; set; }
|
||||
}
|
||||
@ -64,4 +64,14 @@ public class OrderWorkplaceStatsOutput
|
||||
/// 店铺订单待处理数量(履约状态 pending)。
|
||||
/// </summary>
|
||||
public int StoreOrderPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 代发订单统计(仓内履约)。
|
||||
/// </summary>
|
||||
public FulfillWorkplaceStatsOutput Fulfill { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 揽收订单统计。
|
||||
/// </summary>
|
||||
public PickupWorkplaceStatsOutput Pickup { get; set; } = new();
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 工作台揽收订单统计。
|
||||
/// </summary>
|
||||
public class PickupWorkplaceStatsOutput
|
||||
{
|
||||
/// <summary>待支付(有应收且未支付,不含取消/失败)。</summary>
|
||||
public int PendingPayment { get; set; }
|
||||
|
||||
/// <summary>草稿,待确认下发。</summary>
|
||||
public int PendingSchedule { get; set; }
|
||||
|
||||
/// <summary>已预约/已派单,待取件。</summary>
|
||||
public int PendingPickup { get; set; }
|
||||
|
||||
/// <summary>已取件,待到仓。</summary>
|
||||
public int PendingInbound { get; set; }
|
||||
}
|
||||
@ -8,6 +8,7 @@ using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.WarehouseAggregate.Consts;
|
||||
using Oms.Domain.WarehouseAggregate.Entities;
|
||||
using Oms.Domain.WarehouseAggregate.Repositories;
|
||||
|
||||
@ -279,6 +280,12 @@ public partial class OrderService
|
||||
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||||
var binds = await OrderStockBindRepository.GetListAsync(x =>
|
||||
x.OrderId == order.Id && x.Status == OrderStockBindStatusCodes.Bound);
|
||||
if (binds.Count == 0)
|
||||
{
|
||||
throw new BusinessException("当前订单无已绑定明细,无法确认扣减");
|
||||
}
|
||||
|
||||
await EnsureStockBindReadyForDeductAsync(binds);
|
||||
foreach (var bind in binds)
|
||||
{
|
||||
await DeductBoundLineAsync(bind);
|
||||
@ -906,4 +913,37 @@ public partial class OrderService
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
}
|
||||
|
||||
private async Task EnsureStockBindReadyForDeductAsync(IReadOnlyList<OrderStockBind> binds)
|
||||
{
|
||||
var parcelIds = binds
|
||||
.Where(x => x.ParcelId is > 0)
|
||||
.Select(x => x.ParcelId!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (parcelIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parcels = await StockBindParcelRepository.GetListAsync(x => parcelIds.Contains(x.Id));
|
||||
var inTransitNos = parcels
|
||||
.Where(x => string.Equals(
|
||||
x.LifecycleStatus,
|
||||
WarehouseParcelLifecycleCodes.InTransit,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Select(x => x.TrackingNo?.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (inTransitNos.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sample = inTransitNos[0];
|
||||
throw new BusinessException(inTransitNos.Length == 1
|
||||
? $"包裹国内在途({sample}),请先到仓确认后再标记已打包"
|
||||
: $"存在 {inTransitNos.Length} 个国内在途包裹,请先到仓确认后再标记已打包");
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,25 @@ public partial class OrderService
|
||||
: 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(),
|
||||
@ -79,6 +98,20 @@ public partial class OrderService
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,6 +231,79 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
/// <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(
|
||||
|
||||
@ -8,6 +8,27 @@ public interface IPickupOrderRepository : IRepository<PickupOrder>
|
||||
Task<PickupOrder?> GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PickupOrder?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PickupWorkplaceCountRow> GetWorkplaceCountsAsync(
|
||||
int? customerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByCreateTimeRangeAsync(
|
||||
int? customerId,
|
||||
DateTime startInclusive,
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PickupWorkplaceCountRow
|
||||
{
|
||||
public int PendingPayment { get; set; }
|
||||
|
||||
public int PendingSchedule { get; set; }
|
||||
|
||||
public int PendingPickup { get; set; }
|
||||
|
||||
public int PendingInbound { get; set; }
|
||||
}
|
||||
|
||||
public interface IPickupOrderLineRepository : IRepository<PickupOrderLine>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.SqlSugarCore.Repositories;
|
||||
@ -17,6 +18,63 @@ public class PickupOrderRepository : OmsSqlSugarRepository<PickupOrder>, IPickup
|
||||
{
|
||||
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PickupWorkplaceCountRow> GetWorkplaceCountsAsync(
|
||||
int? customerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var query = db.Queryable<PickupOrder>();
|
||||
if (customerId is > 0)
|
||||
{
|
||||
query = query.Where(x => x.CustomerId == customerId.Value);
|
||||
}
|
||||
|
||||
var terminal = new[] { PickupOrderStatusCodes.Cancelled, PickupOrderStatusCodes.Failed };
|
||||
|
||||
var pendingPayment = await query
|
||||
.Where(x => !terminal.Contains(x.Status))
|
||||
.Where(x => x.PayStatus != PickupPayStatusCodes.Paid)
|
||||
.Where(x => x.ReceivableAmount != null && x.ReceivableAmount > 0)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var pendingSchedule = await query
|
||||
.Where(x => x.Status == PickupOrderStatusCodes.Draft)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var pendingPickup = await query
|
||||
.Where(x => x.Status == PickupOrderStatusCodes.Scheduled || x.Status == PickupOrderStatusCodes.Dispatched)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var pendingInbound = await query
|
||||
.Where(x => x.Status == PickupOrderStatusCodes.Picked)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new PickupWorkplaceCountRow
|
||||
{
|
||||
PendingPayment = pendingPayment,
|
||||
PendingSchedule = pendingSchedule,
|
||||
PendingPickup = pendingPickup,
|
||||
PendingInbound = pendingInbound,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> CountByCreateTimeRangeAsync(
|
||||
int? customerId,
|
||||
DateTime startInclusive,
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var query = db.Queryable<PickupOrder>()
|
||||
.Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive);
|
||||
if (customerId is > 0)
|
||||
{
|
||||
query = query.Where(x => x.CustomerId == customerId.Value);
|
||||
}
|
||||
|
||||
return await query.CountAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class PickupOrderLineRepository : OmsSqlSugarRepository<PickupOrderLine>, IPickupOrderLineRepository, IScopedDependency
|
||||
|
||||
Loading…
Reference in New Issue
Block a user