feat: 工作台扩展代发/拣货统计与业务趋势接口

补充 Fulfill/Pickup 工作台统计 DTO 与查询实现,并扩展订单工作台聚合输出。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-08-28 10:59:10 +08:00
parent ce1349572d
commit 78e06d01b9
8 changed files with 316 additions and 0 deletions

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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();
}

View File

@ -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; }
}

View File

@ -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} 个国内在途包裹,请先到仓确认后再标记已打包");
}
}

View File

@ -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(

View File

@ -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>

View File

@ -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