admin/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
zzh a383ee6f32 feat: 实现仓储模块与平台店铺订单后端能力
新增仓储领域实体、仓储库、WarehouseService 全套接口及数据库迁移脚本;
实现店铺授权、店铺订单服务与库存匹配创建小包单;补充仓储模块 RBAC 权限种子数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 15:19:35 +08:00

613 lines
24 KiB
C#

using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.PlatformStore;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.Domain.WarehouseAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Repositories;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 店铺订单 API 服务。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Order")]
public class PlatformStoreOrderService : ApplicationService
{
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService<ISalesStoreRepository>();
private ISalesStoreOrderRepository StoreOrderRepository => LazyServiceProvider.GetRequiredService<ISalesStoreOrderRepository>();
private ISalesStoreOrderLineRepository StoreOrderLineRepository => LazyServiceProvider.GetRequiredService<ISalesStoreOrderLineRepository>();
private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService<ICSkuRepository>();
private ICSkuPlatformMapRepository SkuPlatformMapRepository => LazyServiceProvider.GetRequiredService<ICSkuPlatformMapRepository>();
private ICInventoryRepository InventoryRepository => LazyServiceProvider.GetRequiredService<ICInventoryRepository>();
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
[HttpPost("/order/platform-store-order/list")]
public async Task<OmsPageResult<PlatformStoreOrderOutput>> GetListAsync(PlatformStoreOrderListInput input)
{
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var predicate = BuildListPredicate(customerId, input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await StoreOrderRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(SalesStoreOrder.UpdateTime)] = false, [nameof(SalesStoreOrder.Id)] = false });
var total = await StoreOrderRepository.CountAsync(predicate);
var storeIds = entities.Select(x => x.StoreId).Distinct().ToArray();
var stores = storeIds.Length == 0
? []
: await StoreRepository.GetListAsync(x => storeIds.Contains(x.Id));
var storeMap = stores.ToDictionary(x => x.Id);
var linkedOrderIds = entities.Where(x => x.LinkedOrderId is > 0).Select(x => x.LinkedOrderId!.Value).Distinct().ToArray();
var linkedOrders = linkedOrderIds.Length == 0
? []
: await OrderRepository.GetListAsync(x => linkedOrderIds.Contains(x.Id));
var linkedOrderMap = linkedOrders.ToDictionary(x => x.Id);
return new OmsPageResult<PlatformStoreOrderOutput>
{
Items = entities.Select(x =>
{
storeMap.TryGetValue(x.StoreId, out var store);
string? linkedServiceNo = null;
if (x.LinkedOrderId is > 0 && linkedOrderMap.TryGetValue(x.LinkedOrderId.Value, out var linkedOrder))
{
linkedServiceNo = linkedOrder.ServiceNo;
}
return MapOrderOutput(x, store?.StoreNickname ?? string.Empty, [], linkedServiceNo: linkedServiceNo);
}).ToList(),
Total = total
};
}
[HttpGet("/order/platform-store-order/detail")]
public async Task<PlatformStoreOrderOutput> GetDetailAsync([FromQuery] int id, [FromQuery] int? customerId = null)
{
var resolvedCustomerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
customerId);
var order = await GetOrderOrThrowAsync(resolvedCustomerId, id);
var store = await StoreRepository.FirstOrDefaultAsync(x => x.Id == order.StoreId);
var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
var skuIds = lines.Where(x => x.MappedSkuId.HasValue).Select(x => x.MappedSkuId!.Value).Distinct().ToArray();
var skus = skuIds.Length == 0
? []
: await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id));
var skuMap = skus.ToDictionary(x => x.Id);
var linkedServiceNo = await ResolveLinkedServiceNoAsync(order.LinkedOrderId);
return MapOrderOutput(order, store?.StoreNickname ?? string.Empty, lines, skuMap, linkedServiceNo);
}
[HttpGet("/order/platform-store-order/summary")]
public async Task<PlatformStoreOrderSummaryOutput> GetSummaryAsync([FromQuery] int? customerId = null, [FromQuery] int? storeId = null)
{
var resolvedCustomerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
customerId);
var orders = await StoreOrderRepository.GetListAsync(x =>
x.CustomerId == resolvedCustomerId
&& (!storeId.HasValue || x.StoreId == storeId.Value));
return new PlatformStoreOrderSummaryOutput
{
Pending = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Pending),
ToShip = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.ToShip),
Shipped = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Shipped),
Canceled = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Canceled),
Suspended = orders.Count(x => x.IsSuspended),
Completed = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Completed)
};
}
[HttpPost("/order/platform-store-order/sync-status")]
public async Task<PlatformStoreOrderActionOutput> SyncStatusAsync(PlatformStoreOrderIdsInput input)
{
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
return new PlatformStoreOrderActionOutput
{
Message = "平台订单同步待 SDK 接入,当前为占位实现"
};
}
[HttpPost("/order/platform-store-order/ready-to-ship")]
public async Task<PlatformStoreOrderActionOutput> ReadyToShipAsync(PlatformStoreOrderIdsInput input)
{
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
return new PlatformStoreOrderActionOutput
{
Message = "RTS 待平台 SDK 接入,当前为占位实现"
};
}
[HttpPost("/order/platform-store-order/fetch-label")]
public async Task<PlatformStoreOrderActionOutput> FetchLabelAsync(PlatformStoreOrderIdInput input)
{
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
return new PlatformStoreOrderActionOutput
{
Message = "面单拉取待平台 SDK 接入,当前为占位实现"
};
}
[HttpPost("/order/platform-store-order/toggle-suspend")]
public async Task ToggleSuspendAsync(PlatformStoreOrderToggleSuspendInput input)
{
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var order = await GetOrderOrThrowAsync(customerId, input.Id);
order.IsSuspended = input.IsSuspended;
order.FulfillStatus = input.IsSuspended
? SalesStoreFulfillStatusCodes.Suspended
: SalesStoreFulfillStatusCodes.Pending;
await StoreOrderRepository.UpdateAsync(order);
}
[HttpPost("/order/platform-store-order/delete")]
public async Task DeleteAsync(PlatformStoreOrderIdsInput input)
{
if (input.Ids.Count == 0)
{
throw new BusinessException("请选择要删除的订单");
}
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var orders = await StoreOrderRepository.GetListAsync(x => x.CustomerId == customerId && input.Ids.Contains(x.Id));
foreach (var order in orders)
{
var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id);
foreach (var line in lines)
{
await StoreOrderLineRepository.DeleteAsync(line);
}
await StoreOrderRepository.DeleteAsync(order);
}
}
[HttpPost("/order/platform-store-order/match-inventory")]
public async Task<PlatformStoreOrderMatchInventoryOutput> MatchInventoryAsync(PlatformStoreOrderMatchInventoryInput input)
{
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var result = await MatchInventoryCoreAsync(
customerId,
input.Id,
input.WarehouseId,
input.ProductCode,
input.TrackNo,
input.Receiver);
return new PlatformStoreOrderMatchInventoryOutput
{
Success = true,
Message = $"库存匹配成功,已创建小包订单 {result.ServiceNo}",
LinkedOrderId = result.OrderId,
ServiceNo = result.ServiceNo,
ClientNo = result.ClientNo
};
}
[HttpPost("/order/platform-store-order/batch-match-inventory")]
public async Task<PlatformStoreOrderBatchMatchInventoryOutput> BatchMatchInventoryAsync(
PlatformStoreOrderBatchMatchInventoryInput input)
{
if (input.Ids.Count == 0)
{
throw new BusinessException("请选择要匹配的订单");
}
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var output = new PlatformStoreOrderBatchMatchInventoryOutput();
foreach (var id in input.Ids.Distinct())
{
var storeOrder = await StoreOrderRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId);
var item = new PlatformStoreOrderBatchMatchItemOutput
{
Id = id,
PlatformOrderNo = storeOrder?.PlatformOrderNo ?? id.ToString()
};
try
{
var result = await MatchInventoryCoreAsync(
customerId,
id,
input.WarehouseId,
input.ProductCode,
null,
null);
item.Success = true;
item.Message = $"已创建小包订单 {result.ServiceNo}";
item.LinkedOrderId = result.OrderId;
item.ServiceNo = result.ServiceNo;
output.SuccessCount++;
}
catch (BusinessException ex)
{
item.Success = false;
item.Message = ex.Message;
output.FailCount++;
}
catch (Exception ex)
{
item.Success = false;
item.Message = ex.Message;
output.FailCount++;
}
output.Items.Add(item);
}
return output;
}
private async Task<OrderParcelCreateOutput> MatchInventoryCoreAsync(
int customerId,
int storeOrderId,
int? warehouseId,
string? productCode,
string? trackNo,
OrderParcelReceiverInput? receiver)
{
var order = await GetOrderOrThrowAsync(customerId, storeOrderId);
await EnsureMatchableAsync(order);
var resolvedWarehouseId = warehouseId ?? order.WarehouseId;
if (!resolvedWarehouseId.HasValue || resolvedWarehouseId.Value <= 0)
{
throw new BusinessException("请指定发货仓库");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value)
?? throw new BusinessException("仓库不存在");
var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded);
if (lines.Count == 0)
{
throw new BusinessException("没有可发货的商品行");
}
await ResolveMappedSkuLinesAsync(customerId, order, lines);
var requiredQty = lines
.Where(x => x.MappedSkuId.HasValue)
.GroupBy(x => x.MappedSkuId!.Value)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
await EnsureInventoryAvailableAsync(customerId, resolvedWarehouseId.Value, requiredQty);
var skuIds = requiredQty.Keys.ToArray();
var skus = await SkuRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.Id));
var skuMap = skus.ToDictionary(x => x.Id);
if (skuMap.Count != skuIds.Length)
{
throw new BusinessException("部分库内 SKU 不存在");
}
var resolvedProductCode = await ResolveProductCodeAsync(productCode);
var parcelInput = PlatformStoreOrderParcelBuilder.Build(
order,
lines,
skuMap,
warehouse,
resolvedProductCode,
resolvedWarehouseId.Value,
customerId,
receiver,
trackNo);
List<InventoryDeduction> deductions = [];
try
{
foreach (var (skuId, qty) in requiredQty)
{
deductions.AddRange(await DeductInventoryAsync(customerId, resolvedWarehouseId.Value, skuId, qty));
}
var parcelOutput = await OrderService.CreateParcelForBatchAsync(parcelInput, CurrentUser.GetId<int>());
order.WarehouseId = resolvedWarehouseId;
order.LinkedOrderId = parcelOutput.OrderId;
order.FulfillStatus = SalesStoreFulfillStatusCodes.ToShip;
order.LastSyncTime = DateTime.Now;
if (!string.IsNullOrWhiteSpace(parcelOutput.TrackNo))
{
order.ShippingLabelNo = parcelOutput.TrackNo;
}
await StoreOrderRepository.UpdateAsync(order);
return parcelOutput;
}
catch
{
await RestoreInventoryAsync(customerId, resolvedWarehouseId.Value, deductions);
throw;
}
}
private async Task EnsureMatchableAsync(SalesStoreOrder order)
{
if (order.IsSuspended)
{
throw new BusinessException("订单已搁置,不能匹配库存");
}
if (order.FulfillStatus is SalesStoreFulfillStatusCodes.Shipped or SalesStoreFulfillStatusCodes.Completed)
{
throw new BusinessException("订单已发货或完成,不能重复匹配");
}
if (order.LinkedOrderId is > 0)
{
var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.LinkedOrderId.Value);
if (linked != null && linked.OrderStatus != OrderStatusCodes.Deleted)
{
throw new BusinessException($"已关联小包订单 {linked.ServiceNo},不能重复匹配");
}
}
}
private async Task ResolveMappedSkuLinesAsync(int customerId, SalesStoreOrder order, List<SalesStoreOrderLine> lines)
{
var mappings = await SkuPlatformMapRepository.GetListAsync(x => x.CustomerId == customerId && x.StoreId == order.StoreId);
var mappingDict = mappings.ToDictionary(x => x.PlatformSku, x => x, StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
if (line.MappedSkuId.HasValue)
{
continue;
}
if (!string.IsNullOrWhiteSpace(line.PlatformSku) && mappingDict.TryGetValue(line.PlatformSku, out var map))
{
line.MappedSkuId = map.SkuId;
await StoreOrderLineRepository.UpdateAsync(line);
continue;
}
throw new BusinessException($"平台 SKU「{line.PlatformSku}」未映射库内 SKU");
}
}
private async Task EnsureInventoryAvailableAsync(int customerId, int warehouseId, Dictionary<int, int> requiredQty)
{
foreach (var (skuId, qty) in requiredQty)
{
var total = await InventoryRepository.GetListAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
&& x.Qty > 0);
var available = total.Sum(x => x.Qty);
if (available < qty)
{
throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}");
}
}
}
private async Task<string> ResolveProductCodeAsync(string? productCode)
{
var normalized = productCode?.Trim();
if (!string.IsNullOrWhiteSpace(normalized))
{
var product = await SalesProductRepository.GetByProductCodeAsync(normalized);
if (product == null || product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("销售产品不存在或未启用");
}
return normalized;
}
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
var first = products.OrderBy(x => x.ProductCode).FirstOrDefault();
if (first == null || string.IsNullOrWhiteSpace(first.ProductCode))
{
throw new BusinessException("请指定销售产品编码");
}
return first.ProductCode;
}
private async Task<List<InventoryDeduction>> DeductInventoryAsync(int customerId, int warehouseId, int skuId, int requiredQty)
{
var rows = await InventoryRepository.GetListAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
&& x.Qty > 0);
rows = rows.OrderBy(x => x.BinLocationId).ToList();
var deductions = new List<InventoryDeduction>();
var remaining = requiredQty;
foreach (var row in rows)
{
if (remaining <= 0)
{
break;
}
var deduct = Math.Min(row.Qty, remaining);
row.Qty -= deduct;
remaining -= deduct;
await InventoryRepository.UpdateAsync(row);
deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct));
}
if (remaining > 0)
{
throw new BusinessException($"SKU {skuId} 库存扣减失败");
}
return deductions;
}
private async Task RestoreInventoryAsync(int customerId, int warehouseId, IReadOnlyCollection<InventoryDeduction> deductions)
{
foreach (var item in deductions)
{
var inventory = await InventoryRepository.FirstOrDefaultAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == item.SkuId
&& x.BinLocationId == item.BinLocationId);
if (inventory == null)
{
inventory = new Domain.WarehouseAggregate.Entities.CInventory
{
CustomerId = customerId,
WarehouseId = warehouseId,
SkuId = item.SkuId,
BinLocationId = item.BinLocationId,
Qty = item.Qty
};
await InventoryRepository.InsertAsync(inventory);
continue;
}
inventory.Qty += item.Qty;
await InventoryRepository.UpdateAsync(inventory);
}
}
private async Task<string?> ResolveLinkedServiceNoAsync(int? linkedOrderId)
{
if (linkedOrderId is not > 0)
{
return null;
}
var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == linkedOrderId.Value);
return linked?.ServiceNo;
}
private async Task<SalesStoreOrder> GetOrderOrThrowAsync(int customerId, int id)
{
var entity = await StoreOrderRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId);
if (entity == null)
{
throw new BusinessException("店铺订单不存在");
}
return entity;
}
private static Expression<Func<SalesStoreOrder, bool>> BuildListPredicate(int customerId, PlatformStoreOrderListInput input)
{
var fulfillStatus = input.FulfillStatus?.Trim();
var platformOrderNo = input.PlatformOrderNo?.Trim();
return x =>
x.CustomerId == customerId
&& (!input.StoreId.HasValue || x.StoreId == input.StoreId.Value)
&& (string.IsNullOrWhiteSpace(fulfillStatus) || x.FulfillStatus == fulfillStatus)
&& (!input.IsSuspended.HasValue || x.IsSuspended == input.IsSuspended.Value)
&& (string.IsNullOrWhiteSpace(platformOrderNo) || x.PlatformOrderNo.Contains(platformOrderNo));
}
private static PlatformStoreOrderOutput MapOrderOutput(
SalesStoreOrder order,
string storeNickname,
IReadOnlyCollection<SalesStoreOrderLine> lines,
IReadOnlyDictionary<int, Domain.WarehouseAggregate.Entities.CSku>? skuMap = null,
string? linkedServiceNo = null) =>
new()
{
Id = order.Id,
CustomerId = order.CustomerId,
StoreId = order.StoreId,
StoreNickname = storeNickname,
PlatformCode = order.PlatformCode,
PlatformOrderNo = order.PlatformOrderNo,
OrderCountry = order.OrderCountry,
ItemCount = order.ItemCount,
TotalAmount = order.TotalAmount,
Currency = order.Currency,
PlatformOrderTime = order.PlatformOrderTime,
FulfillStatus = order.FulfillStatus,
PlatformStatusLabel = order.PlatformStatusLabel,
ShippingLabelNo = order.ShippingLabelNo,
ShippingLabelUrl = order.ShippingLabelUrl,
IsMultiLabel = order.IsMultiLabel,
CustomTags = order.CustomTags,
IsSuspended = order.IsSuspended,
WarehouseId = order.WarehouseId,
LinkedOrderId = order.LinkedOrderId,
LinkedServiceNo = linkedServiceNo,
LastSyncTime = order.LastSyncTime,
Lines = lines.Select(line =>
{
string? mappedSkuCode = null;
if (line.MappedSkuId.HasValue && skuMap != null && skuMap.TryGetValue(line.MappedSkuId.Value, out var sku))
{
mappedSkuCode = sku.SkuCode;
}
return new PlatformStoreOrderLineOutput
{
Id = line.Id,
PlatformLineId = line.PlatformLineId,
PlatformSku = line.PlatformSku,
ProductName = line.ProductName,
ProductImageUrl = line.ProductImageUrl,
VariantText = line.VariantText,
UnitPrice = line.UnitPrice,
Quantity = line.Quantity,
Currency = line.Currency,
IsShipExcluded = line.IsShipExcluded,
MappedSkuId = line.MappedSkuId,
MappedSkuCode = mappedSkuCode
};
}).ToList()
};
private sealed record InventoryDeduction(int SkuId, int BinLocationId, int Qty);
}