- 新增 batch-create-logistics / batch-create-fulfill:按平台行建物流单并回写 LinkedOrderId,代发支持绑定冻结与失败回滚 - ParcelBuilder 支持无 SKU 映射的平台行申报;批量创建可指定 OrderSource=platform - 库存绑定候选接口支持 orderId=0 草稿选仓 - 店铺订单增加结构化收件人字段(db.sql + 迁移脚本),拉单回填;补充 customer9 店铺/揽收种子与 ConcurrencyStamp 修复脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
797 lines
29 KiB
C#
797 lines
29 KiB
C#
using Fake.Application;
|
||
using Fake.Domain.Exceptions;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Application.Dtos.Shared;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.OrdersAggregate.Consts;
|
||
using Oms.Domain.OrdersAggregate.Entities;
|
||
using Oms.Domain.OrdersAggregate.Repositories;
|
||
using Oms.Domain.WarehouseAggregate.Entities;
|
||
using Oms.Domain.WarehouseAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Orders;
|
||
|
||
public partial class OrderService
|
||
{
|
||
private IOrderStockBindRepository OrderStockBindRepository =>
|
||
LazyServiceProvider.GetRequiredService<IOrderStockBindRepository>();
|
||
|
||
private ICInventoryRepository StockBindInventoryRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICInventoryRepository>();
|
||
|
||
private ICParcelRepository StockBindParcelRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICParcelRepository>();
|
||
|
||
private ICParcelItemRepository StockBindParcelItemRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICParcelItemRepository>();
|
||
|
||
private ICSkuRepository StockBindSkuRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICSkuRepository>();
|
||
|
||
private ICBinLocationRepository StockBindBinLocationRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICBinLocationRepository>();
|
||
|
||
/// <summary>
|
||
/// 查询物流单当前仓储绑定明细。
|
||
/// </summary>
|
||
[HttpGet("/order/stock-bind/detail")]
|
||
public async Task<OrderStockBindDetailOutput> GetStockBindDetailAsync([FromQuery] int orderId)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(orderId);
|
||
return await BuildStockBindDetailAsync(order);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 库存候选(左侧库存 Tab)。
|
||
/// </summary>
|
||
[HttpPost("/order/stock-bind/inventory-candidates")]
|
||
public async Task<OmsPageResult<OrderStockBindInventoryCandidateOutput>> GetStockBindInventoryCandidatesAsync(
|
||
[FromBody] OrderStockBindCandidatesInput input)
|
||
{
|
||
var (warehouseId, customerId) = await ResolveCandidatesContextAsync(input);
|
||
|
||
var inventories = await StockBindInventoryRepository.GetListAsync(x =>
|
||
x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
|
||
|
||
var skuIds = inventories.Select(x => x.SkuId).Distinct().ToArray();
|
||
var skus = skuIds.Length == 0
|
||
? []
|
||
: await StockBindSkuRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.Id));
|
||
var skuMap = skus.ToDictionary(x => x.Id);
|
||
var keyword = input.Keyword?.Trim();
|
||
|
||
var grouped = inventories
|
||
.GroupBy(x => x.SkuId)
|
||
.Select(g =>
|
||
{
|
||
skuMap.TryGetValue(g.Key, out var sku);
|
||
return new OrderStockBindInventoryCandidateOutput
|
||
{
|
||
SkuId = g.Key,
|
||
SkuCode = sku?.SkuCode ?? string.Empty,
|
||
SkuName = sku?.SkuName ?? string.Empty,
|
||
SkuImageUrl = sku?.SkuImageUrl,
|
||
WarehouseId = warehouseId,
|
||
TotalQty = g.Sum(x => x.Qty),
|
||
FrozenQty = g.Sum(x => x.FrozenQty),
|
||
AvailableQty = g.Sum(x => x.AvailableQty),
|
||
BinCount = g.Count()
|
||
};
|
||
})
|
||
.Where(x =>
|
||
string.IsNullOrWhiteSpace(keyword)
|
||
|| x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase)
|
||
|| x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(x => x.SkuCode)
|
||
.ToList();
|
||
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
var skip = (pageIndex - 1) * pageSize;
|
||
return new OmsPageResult<OrderStockBindInventoryCandidateOutput>
|
||
{
|
||
Items = grouped.Skip(skip).Take(pageSize).ToList(),
|
||
Total = grouped.Count
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 包裹候选(左侧包裹 Tab,允许全部生命周期)。
|
||
/// </summary>
|
||
[HttpPost("/order/stock-bind/parcel-candidates")]
|
||
public async Task<OmsPageResult<OrderStockBindParcelCandidateOutput>> GetStockBindParcelCandidatesAsync(
|
||
[FromBody] OrderStockBindCandidatesInput input)
|
||
{
|
||
var (warehouseId, customerId) = await ResolveCandidatesContextAsync(input);
|
||
|
||
var lifecycle = input.LifecycleStatus?.Trim();
|
||
var filterLifecycle = !string.IsNullOrWhiteSpace(lifecycle)
|
||
&& !string.Equals(lifecycle, "all", StringComparison.OrdinalIgnoreCase);
|
||
var keyword = input.Keyword?.Trim();
|
||
|
||
var parcels = await StockBindParcelRepository.GetListAsync(x =>
|
||
x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& (!filterLifecycle || x.LifecycleStatus == lifecycle));
|
||
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
{
|
||
parcels = parcels
|
||
.Where(x =>
|
||
x.TrackingNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|
||
|| (x.CarrierName?.Contains(keyword, StringComparison.OrdinalIgnoreCase) ?? false))
|
||
.ToList();
|
||
}
|
||
|
||
parcels = parcels.OrderByDescending(x => x.Id).ToList();
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
var pageParcels = parcels.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
|
||
var parcelIds = pageParcels.Select(x => x.Id).ToArray();
|
||
var allItems = parcelIds.Length == 0
|
||
? []
|
||
: await StockBindParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId));
|
||
var itemsByParcel = allItems.GroupBy(x => x.ParcelId).ToDictionary(g => g.Key, g => g.ToList());
|
||
|
||
var outputs = new List<OrderStockBindParcelCandidateOutput>();
|
||
foreach (var parcel in pageParcels)
|
||
{
|
||
itemsByParcel.TryGetValue(parcel.Id, out var items);
|
||
items ??= [];
|
||
var availableItems = items
|
||
.Where(x => GetParcelItemAvailableQty(x) > 0)
|
||
.Select(x => new OrderStockBindParcelItemCandidateOutput
|
||
{
|
||
ParcelItemId = x.Id,
|
||
ProductName = x.ProductName,
|
||
AttributeText = x.AttributeText,
|
||
ImageUrl = x.ImageUrl,
|
||
SkuId = x.SkuId,
|
||
SkuCode = x.SkuCode,
|
||
Quantity = x.Quantity,
|
||
FrozenQty = x.FrozenQty,
|
||
AvailableQty = GetParcelItemAvailableQty(x)
|
||
})
|
||
.ToList();
|
||
if (availableItems.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
outputs.Add(new OrderStockBindParcelCandidateOutput
|
||
{
|
||
ParcelId = parcel.Id,
|
||
TrackingNo = parcel.TrackingNo,
|
||
CarrierName = parcel.CarrierName,
|
||
LifecycleStatus = parcel.LifecycleStatus,
|
||
WarehouseId = parcel.WarehouseId,
|
||
Items = availableItems
|
||
});
|
||
}
|
||
|
||
return new OmsPageResult<OrderStockBindParcelCandidateOutput>
|
||
{
|
||
Items = outputs,
|
||
Total = parcels.Count
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存绑定(全量覆盖 + 冻结)。
|
||
/// </summary>
|
||
[HttpPost("/order/stock-bind/save")]
|
||
public async Task<OrderStockBindDetailOutput> SaveStockBindAsync([FromBody] OrderStockBindSaveInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||
EnsureOrderCanBindStock(order);
|
||
await EnsureNoDeductedBindsAsync(order.Id);
|
||
var customerId = await ResolveStockBindCustomerIdAsync(order);
|
||
var warehouseId = ResolveBindWarehouseId(order, null);
|
||
var items = input.Items ?? [];
|
||
|
||
await ReleaseBoundBindsAsync(order.Id);
|
||
|
||
foreach (var item in items)
|
||
{
|
||
if (item.BindQty <= 0)
|
||
{
|
||
throw new BusinessException("绑定数量必须大于 0");
|
||
}
|
||
|
||
var bindType = (item.BindType ?? string.Empty).Trim();
|
||
if (string.Equals(bindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
await BindInventoryLineAsync(order, customerId, warehouseId, item);
|
||
}
|
||
else if (string.Equals(bindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
await BindParcelItemLineAsync(order, customerId, warehouseId, item);
|
||
}
|
||
else
|
||
{
|
||
throw new BusinessException($"不支持的绑定类型:{item.BindType}");
|
||
}
|
||
}
|
||
|
||
if (!order.WarehouseId.HasValue || order.WarehouseId.Value <= 0)
|
||
{
|
||
order.WarehouseId = warehouseId;
|
||
await UpdateOrderAsync(order);
|
||
}
|
||
|
||
return await BuildStockBindDetailAsync(order);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清空绑定并释放冻结。
|
||
/// </summary>
|
||
[HttpPost("/order/stock-bind/clear")]
|
||
public async Task<OrderStockBindDetailOutput> ClearStockBindAsync([FromBody] OrderStockBindOrderInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||
EnsureOrderCanBindStock(order);
|
||
await EnsureNoDeductedBindsAsync(order.Id);
|
||
await ReleaseBoundBindsAsync(order.Id);
|
||
return await BuildStockBindDetailAsync(order);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 确认扣减:将 bound 明细转为 deducted,扣减实物并释放冻结。
|
||
/// </summary>
|
||
[HttpPost("/order/stock-bind/confirm-deduct")]
|
||
public async Task<OrderStockBindDetailOutput> ConfirmStockBindDeductAsync([FromBody] OrderStockBindOrderInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||
var binds = await OrderStockBindRepository.GetListAsync(x =>
|
||
x.OrderId == order.Id && x.Status == OrderStockBindStatusCodes.Bound);
|
||
foreach (var bind in binds)
|
||
{
|
||
await DeductBoundLineAsync(bind);
|
||
bind.Status = OrderStockBindStatusCodes.Deducted;
|
||
await OrderStockBindRepository.UpdateAsync(bind);
|
||
}
|
||
|
||
return await BuildStockBindDetailAsync(order);
|
||
}
|
||
|
||
private async Task BindInventoryLineAsync(
|
||
Order order,
|
||
int customerId,
|
||
int warehouseId,
|
||
OrderStockBindSaveItemInput item)
|
||
{
|
||
if (!item.SkuId.HasValue || item.SkuId.Value <= 0)
|
||
{
|
||
throw new BusinessException("库存绑定必须指定 skuId");
|
||
}
|
||
|
||
var sku = await StockBindSkuRepository.FirstOrDefaultAsync(x =>
|
||
x.Id == item.SkuId.Value && x.CustomerId == customerId)
|
||
?? throw new BusinessException("SKU 不存在");
|
||
|
||
var deductions = await FreezeInventoryAsync(
|
||
customerId,
|
||
warehouseId,
|
||
item.SkuId.Value,
|
||
item.BindQty,
|
||
item.InventoryId);
|
||
|
||
foreach (var d in deductions)
|
||
{
|
||
await OrderStockBindRepository.InsertAsync(new OrderStockBind
|
||
{
|
||
OrderId = order.Id,
|
||
CustomerId = customerId,
|
||
WarehouseId = warehouseId,
|
||
BindType = OrderStockBindTypeCodes.Inventory,
|
||
SkuId = sku.Id,
|
||
InventoryId = d.InventoryId,
|
||
BinLocationId = d.BinLocationId,
|
||
BindQty = d.Qty,
|
||
Status = OrderStockBindStatusCodes.Bound,
|
||
SkuCode = sku.SkuCode,
|
||
SkuName = sku.SkuName,
|
||
ImageUrl = sku.SkuImageUrl
|
||
});
|
||
}
|
||
}
|
||
|
||
private async Task BindParcelItemLineAsync(
|
||
Order order,
|
||
int customerId,
|
||
int warehouseId,
|
||
OrderStockBindSaveItemInput item)
|
||
{
|
||
if (!item.ParcelItemId.HasValue || item.ParcelItemId.Value <= 0)
|
||
{
|
||
throw new BusinessException("包裹绑定必须指定 parcelItemId");
|
||
}
|
||
|
||
var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == item.ParcelItemId.Value)
|
||
?? throw new BusinessException("包裹明细不存在");
|
||
var parcel = await StockBindParcelRepository.FirstOrDefaultAsync(x =>
|
||
x.Id == parcelItem.ParcelId && x.CustomerId == customerId)
|
||
?? throw new BusinessException("包裹不存在");
|
||
if (parcel.WarehouseId != warehouseId)
|
||
{
|
||
throw new BusinessException("包裹仓库与订单仓库不一致");
|
||
}
|
||
|
||
if (item.ParcelId.HasValue && item.ParcelId.Value != parcel.Id)
|
||
{
|
||
throw new BusinessException("parcelId 与 parcelItemId 不匹配");
|
||
}
|
||
|
||
var available = GetParcelItemAvailableQty(parcelItem);
|
||
if (item.BindQty > available)
|
||
{
|
||
throw new BusinessException($"包裹明细可用不足,需要 {item.BindQty},可用 {available}");
|
||
}
|
||
|
||
parcelItem.FrozenQty += item.BindQty;
|
||
SyncParcelItemAvailable(parcelItem);
|
||
await StockBindParcelItemRepository.UpdateAsync(parcelItem);
|
||
|
||
// 已上架行同步冻结库存,避免重复售卖
|
||
if (parcelItem.SkuId is > 0)
|
||
{
|
||
await FreezeInventoryAsync(customerId, warehouseId, parcelItem.SkuId.Value, item.BindQty, null);
|
||
}
|
||
|
||
CSku? sku = null;
|
||
if (parcelItem.SkuId is > 0)
|
||
{
|
||
sku = await StockBindSkuRepository.FirstOrDefaultAsync(x => x.Id == parcelItem.SkuId.Value);
|
||
}
|
||
|
||
await OrderStockBindRepository.InsertAsync(new OrderStockBind
|
||
{
|
||
OrderId = order.Id,
|
||
CustomerId = customerId,
|
||
WarehouseId = warehouseId,
|
||
BindType = OrderStockBindTypeCodes.ParcelItem,
|
||
SkuId = parcelItem.SkuId,
|
||
ParcelId = parcel.Id,
|
||
ParcelItemId = parcelItem.Id,
|
||
BindQty = item.BindQty,
|
||
Status = OrderStockBindStatusCodes.Bound,
|
||
SkuCode = sku?.SkuCode ?? parcelItem.SkuCode,
|
||
SkuName = sku?.SkuName,
|
||
TrackingNo = parcel.TrackingNo,
|
||
ProductName = parcelItem.ProductName,
|
||
ImageUrl = parcelItem.ImageUrl,
|
||
AttributeText = parcelItem.AttributeText
|
||
});
|
||
}
|
||
|
||
private async Task ReleaseBoundBindsAsync(int orderId)
|
||
{
|
||
var binds = await OrderStockBindRepository.GetListAsync(x =>
|
||
x.OrderId == orderId && x.Status == OrderStockBindStatusCodes.Bound);
|
||
foreach (var bind in binds)
|
||
{
|
||
await UnfreezeBoundLineAsync(bind);
|
||
bind.Status = OrderStockBindStatusCodes.Released;
|
||
bind.IsDeleted = true;
|
||
await OrderStockBindRepository.UpdateAsync(bind);
|
||
}
|
||
}
|
||
|
||
private async Task UnfreezeBoundLineAsync(OrderStockBind bind)
|
||
{
|
||
if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (bind.InventoryId is > 0)
|
||
{
|
||
await UnfreezeInventoryByIdAsync(bind.InventoryId.Value, bind.BindQty);
|
||
}
|
||
else if (bind.SkuId is > 0)
|
||
{
|
||
await UnfreezeInventoryBySkuAsync(bind.CustomerId, bind.WarehouseId, bind.SkuId.Value, bind.BindQty);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (string.Equals(bind.BindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase)
|
||
&& bind.ParcelItemId is > 0)
|
||
{
|
||
var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value);
|
||
if (parcelItem != null)
|
||
{
|
||
parcelItem.FrozenQty = Math.Max(0, parcelItem.FrozenQty - bind.BindQty);
|
||
SyncParcelItemAvailable(parcelItem);
|
||
await StockBindParcelItemRepository.UpdateAsync(parcelItem);
|
||
|
||
if (parcelItem.SkuId is > 0)
|
||
{
|
||
await UnfreezeInventoryBySkuAsync(
|
||
bind.CustomerId,
|
||
bind.WarehouseId,
|
||
parcelItem.SkuId.Value,
|
||
bind.BindQty);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task DeductBoundLineAsync(OrderStockBind bind)
|
||
{
|
||
if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (bind.InventoryId is > 0)
|
||
{
|
||
var inv = await StockBindInventoryRepository.FirstOrDefaultAsync(x => x.Id == bind.InventoryId.Value)
|
||
?? throw new BusinessException("库存行不存在");
|
||
if (inv.FrozenQty < bind.BindQty || inv.Qty < bind.BindQty)
|
||
{
|
||
throw new BusinessException("库存冻结量不足,无法扣减");
|
||
}
|
||
|
||
inv.Qty -= bind.BindQty;
|
||
inv.FrozenQty -= bind.BindQty;
|
||
SyncInventoryAvailable(inv);
|
||
await StockBindInventoryRepository.UpdateAsync(inv);
|
||
}
|
||
else if (bind.SkuId is > 0)
|
||
{
|
||
await DeductFrozenInventoryBySkuAsync(bind.CustomerId, bind.WarehouseId, bind.SkuId.Value, bind.BindQty);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (string.Equals(bind.BindType, OrderStockBindTypeCodes.ParcelItem, StringComparison.OrdinalIgnoreCase)
|
||
&& bind.ParcelItemId is > 0)
|
||
{
|
||
var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value)
|
||
?? throw new BusinessException("包裹明细不存在");
|
||
if (parcelItem.FrozenQty < bind.BindQty || parcelItem.Quantity < bind.BindQty)
|
||
{
|
||
throw new BusinessException("包裹冻结量不足,无法扣减");
|
||
}
|
||
|
||
parcelItem.Quantity -= bind.BindQty;
|
||
parcelItem.FrozenQty -= bind.BindQty;
|
||
SyncParcelItemAvailable(parcelItem);
|
||
await StockBindParcelItemRepository.UpdateAsync(parcelItem);
|
||
|
||
if (parcelItem.SkuId is > 0)
|
||
{
|
||
await DeductFrozenInventoryBySkuAsync(
|
||
bind.CustomerId,
|
||
bind.WarehouseId,
|
||
parcelItem.SkuId.Value,
|
||
bind.BindQty);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task<List<(int InventoryId, int BinLocationId, int Qty)>> FreezeInventoryAsync(
|
||
int customerId,
|
||
int warehouseId,
|
||
int skuId,
|
||
int requiredQty,
|
||
int? inventoryId)
|
||
{
|
||
List<CInventory> rows;
|
||
if (inventoryId is > 0)
|
||
{
|
||
var row = await StockBindInventoryRepository.FirstOrDefaultAsync(x =>
|
||
x.Id == inventoryId.Value
|
||
&& x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& x.SkuId == skuId)
|
||
?? throw new BusinessException("指定库存行不存在");
|
||
rows = [row];
|
||
}
|
||
else
|
||
{
|
||
rows = await StockBindInventoryRepository.GetListAsync(x =>
|
||
x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& x.SkuId == skuId
|
||
&& (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
|
||
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
||
}
|
||
|
||
var available = rows.Sum(GetInventoryAvailableQty);
|
||
if (available < requiredQty)
|
||
{
|
||
throw new BusinessException($"SKU {skuId} 库存可用不足,需要 {requiredQty},可用 {available}");
|
||
}
|
||
|
||
var result = new List<(int InventoryId, int BinLocationId, int Qty)>();
|
||
var remaining = requiredQty;
|
||
foreach (var row in rows)
|
||
{
|
||
if (remaining <= 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
var take = Math.Min(GetInventoryAvailableQty(row), remaining);
|
||
if (take <= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.FrozenQty += take;
|
||
SyncInventoryAvailable(row);
|
||
await StockBindInventoryRepository.UpdateAsync(row);
|
||
result.Add((row.Id, row.BinLocationId, take));
|
||
remaining -= take;
|
||
}
|
||
|
||
if (remaining > 0)
|
||
{
|
||
throw new BusinessException($"SKU {skuId} 冻结失败");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private async Task UnfreezeInventoryByIdAsync(int inventoryId, int qty)
|
||
{
|
||
var inv = await StockBindInventoryRepository.FirstOrDefaultAsync(x => x.Id == inventoryId);
|
||
if (inv == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
inv.FrozenQty = Math.Max(0, inv.FrozenQty - qty);
|
||
SyncInventoryAvailable(inv);
|
||
await StockBindInventoryRepository.UpdateAsync(inv);
|
||
}
|
||
|
||
private async Task UnfreezeInventoryBySkuAsync(int customerId, int warehouseId, int skuId, int qty)
|
||
{
|
||
var rows = await StockBindInventoryRepository.GetListAsync(x =>
|
||
x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& x.SkuId == skuId
|
||
&& x.FrozenQty > 0);
|
||
rows = rows.OrderByDescending(x => x.FrozenQty).ToList();
|
||
var remaining = qty;
|
||
foreach (var row in rows)
|
||
{
|
||
if (remaining <= 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
var release = Math.Min(row.FrozenQty, remaining);
|
||
row.FrozenQty -= release;
|
||
SyncInventoryAvailable(row);
|
||
await StockBindInventoryRepository.UpdateAsync(row);
|
||
remaining -= release;
|
||
}
|
||
}
|
||
|
||
private async Task DeductFrozenInventoryBySkuAsync(int customerId, int warehouseId, int skuId, int qty)
|
||
{
|
||
var rows = await StockBindInventoryRepository.GetListAsync(x =>
|
||
x.CustomerId == customerId
|
||
&& x.WarehouseId == warehouseId
|
||
&& x.SkuId == skuId
|
||
&& x.FrozenQty > 0);
|
||
rows = rows.OrderBy(x => x.BinLocationId).ToList();
|
||
var remaining = qty;
|
||
foreach (var row in rows)
|
||
{
|
||
if (remaining <= 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
var take = Math.Min(row.FrozenQty, remaining);
|
||
take = Math.Min(take, row.Qty);
|
||
if (take <= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.Qty -= take;
|
||
row.FrozenQty -= take;
|
||
SyncInventoryAvailable(row);
|
||
await StockBindInventoryRepository.UpdateAsync(row);
|
||
remaining -= take;
|
||
}
|
||
|
||
if (remaining > 0)
|
||
{
|
||
throw new BusinessException($"SKU {skuId} 扣减失败,冻结量不足");
|
||
}
|
||
}
|
||
|
||
private async Task EnsureNoDeductedBindsAsync(int orderId)
|
||
{
|
||
var hasDeducted = await OrderStockBindRepository.AnyAsync(x =>
|
||
x.OrderId == orderId && x.Status == OrderStockBindStatusCodes.Deducted);
|
||
if (hasDeducted)
|
||
{
|
||
throw new BusinessException("订单已确认扣减库存,不能改绑;如需调整请联系运营");
|
||
}
|
||
}
|
||
|
||
private static void EnsureOrderCanBindStock(Order order)
|
||
{
|
||
if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new BusinessException("已删除订单不能绑定");
|
||
}
|
||
}
|
||
|
||
private async Task<(int WarehouseId, int CustomerId)> ResolveCandidatesContextAsync(OrderStockBindCandidatesInput input)
|
||
{
|
||
if (input.OrderId > 0)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.OrderId);
|
||
EnsureOrderCanBindStock(order);
|
||
var warehouseId = ResolveBindWarehouseId(order, input.WarehouseId);
|
||
var customerId = await ResolveStockBindCustomerIdAsync(order);
|
||
return (warehouseId, customerId);
|
||
}
|
||
|
||
if (input.WarehouseId is not > 0)
|
||
{
|
||
throw new BusinessException("请指定发货仓库");
|
||
}
|
||
|
||
var sessionCustomerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
||
CustomerRepository,
|
||
CurrentUser,
|
||
input.CustomerId);
|
||
if (sessionCustomerId is not > 0)
|
||
{
|
||
throw new BusinessException("请指定客户");
|
||
}
|
||
|
||
return (input.WarehouseId.Value, sessionCustomerId.Value);
|
||
}
|
||
|
||
private static int RequireOrderCustomerId(Order order)
|
||
{
|
||
if (!order.CustomerId.HasValue || order.CustomerId.Value <= 0)
|
||
{
|
||
throw new BusinessException("订单未关联客户,无法绑定仓储资源");
|
||
}
|
||
|
||
return order.CustomerId.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 绑定客户上下文:客户登录与仓储模块一致(会话客户);管理端按订单客户。
|
||
/// </summary>
|
||
private async Task<int> ResolveStockBindCustomerIdAsync(Order order)
|
||
{
|
||
var sessionCustomerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
||
CustomerRepository,
|
||
CurrentUser,
|
||
null);
|
||
if (sessionCustomerId is > 0)
|
||
{
|
||
return sessionCustomerId.Value;
|
||
}
|
||
|
||
return RequireOrderCustomerId(order);
|
||
}
|
||
|
||
private static int ResolveBindWarehouseId(Order order, int? requestedWarehouseId)
|
||
{
|
||
if (requestedWarehouseId is > 0)
|
||
{
|
||
return requestedWarehouseId.Value;
|
||
}
|
||
|
||
if (order.WarehouseId is > 0)
|
||
{
|
||
return order.WarehouseId.Value;
|
||
}
|
||
|
||
throw new BusinessException("请先为订单指定仓库");
|
||
}
|
||
|
||
private async Task<OrderStockBindDetailOutput> BuildStockBindDetailAsync(Order order)
|
||
{
|
||
var binds = await OrderStockBindRepository.GetListAsync(x =>
|
||
x.OrderId == order.Id
|
||
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
|
||
binds = binds.OrderBy(x => x.Id).ToList();
|
||
|
||
string? warehouseName = null;
|
||
var warehouseId = order.WarehouseId ?? binds.FirstOrDefault()?.WarehouseId;
|
||
if (warehouseId is > 0)
|
||
{
|
||
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
|
||
warehouseName = warehouse?.WarehouseName;
|
||
}
|
||
|
||
var binIds = binds.Where(x => x.BinLocationId.HasValue).Select(x => x.BinLocationId!.Value).Distinct().ToArray();
|
||
var bins = binIds.Length == 0
|
||
? []
|
||
: await StockBindBinLocationRepository.GetListAsync(x => binIds.Contains(x.Id));
|
||
var binMap = bins.ToDictionary(x => x.Id);
|
||
|
||
var items = new List<OrderStockBindItemOutput>();
|
||
foreach (var bind in binds)
|
||
{
|
||
var availableQty = 0;
|
||
if (string.Equals(bind.BindType, OrderStockBindTypeCodes.Inventory, StringComparison.OrdinalIgnoreCase)
|
||
&& bind.SkuId is > 0
|
||
&& warehouseId is > 0)
|
||
{
|
||
var invRows = await StockBindInventoryRepository.GetListAsync(x =>
|
||
x.CustomerId == bind.CustomerId
|
||
&& x.WarehouseId == warehouseId.Value
|
||
&& x.SkuId == bind.SkuId.Value);
|
||
availableQty = invRows.Sum(GetInventoryAvailableQty) + bind.BindQty;
|
||
}
|
||
else if (bind.ParcelItemId is > 0)
|
||
{
|
||
var parcelItem = await StockBindParcelItemRepository.FirstOrDefaultAsync(x => x.Id == bind.ParcelItemId.Value);
|
||
if (parcelItem != null)
|
||
{
|
||
availableQty = GetParcelItemAvailableQty(parcelItem) + bind.BindQty;
|
||
}
|
||
}
|
||
|
||
binMap.TryGetValue(bind.BinLocationId ?? 0, out var bin);
|
||
items.Add(new OrderStockBindItemOutput
|
||
{
|
||
Id = bind.Id,
|
||
BindType = bind.BindType,
|
||
SkuId = bind.SkuId,
|
||
SkuCode = bind.SkuCode,
|
||
SkuName = bind.SkuName,
|
||
InventoryId = bind.InventoryId,
|
||
BinLocationId = bind.BinLocationId,
|
||
BinCode = bin?.BinCode,
|
||
ParcelId = bind.ParcelId,
|
||
ParcelItemId = bind.ParcelItemId,
|
||
TrackingNo = bind.TrackingNo,
|
||
ProductName = bind.ProductName,
|
||
AttributeText = bind.AttributeText,
|
||
ImageUrl = bind.ImageUrl,
|
||
BindQty = bind.BindQty,
|
||
AvailableQty = availableQty,
|
||
Status = bind.Status
|
||
});
|
||
}
|
||
|
||
var bindStatus = "none";
|
||
if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Deducted))
|
||
{
|
||
bindStatus = OrderStockBindStatusCodes.Deducted;
|
||
}
|
||
else if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Bound))
|
||
{
|
||
bindStatus = OrderStockBindStatusCodes.Bound;
|
||
}
|
||
|
||
return new OrderStockBindDetailOutput
|
||
{
|
||
OrderId = order.Id,
|
||
WarehouseId = warehouseId,
|
||
WarehouseName = warehouseName,
|
||
BindStatus = bindStatus,
|
||
Items = items
|
||
};
|
||
}
|
||
|
||
private static int GetInventoryAvailableQty(CInventory inv) =>
|
||
inv.AvailableQty > 0 ? inv.AvailableQty : Math.Max(0, inv.Qty - inv.FrozenQty);
|
||
|
||
private static int GetParcelItemAvailableQty(CParcelItem item) =>
|
||
Math.Max(0, item.Quantity - item.FrozenQty);
|
||
|
||
private static void SyncInventoryAvailable(CInventory inv) =>
|
||
inv.AvailableQty = Math.Max(0, inv.Qty - inv.FrozenQty);
|
||
|
||
private static void SyncParcelItemAvailable(CParcelItem item) =>
|
||
item.AvailableQty = Math.Max(0, item.Quantity - item.FrozenQty);
|
||
}
|