diff --git a/sql/migrate_t_order_stock_bind.sql b/sql/migrate_t_order_stock_bind.sql
new file mode 100644
index 0000000..4b5afd8
--- /dev/null
+++ b/sql/migrate_t_order_stock_bind.sql
@@ -0,0 +1,92 @@
+-- 物流单绑定包裹/库存:t_order_stock_bind + C_inventory/C_parcel_item 冻结与可用字段
+-- 见 oms系统设计-050801.md §4.2.11 / §4.4.6
+-- 可重复执行
+
+CREATE TABLE IF NOT EXISTS `t_order_stock_bind` (
+ `Id` INT NOT NULL AUTO_INCREMENT,
+ `OrderId` INT NOT NULL COMMENT '物流单 t_order.Id',
+ `CustomerId` INT NOT NULL COMMENT '客户隔离键',
+ `WarehouseId` INT NOT NULL COMMENT '仓库',
+ `BindType` VARCHAR(20) NOT NULL COMMENT 'inventory / parcelItem',
+ `SkuId` INT NULL COMMENT 'SKU',
+ `InventoryId` INT NULL COMMENT 'C_inventory.Id,可空=按SKU分摊',
+ `BinLocationId` INT NULL COMMENT '库位快照',
+ `ParcelId` INT NULL COMMENT 'C_parcel.Id',
+ `ParcelItemId` INT NULL COMMENT 'C_parcel_item.Id',
+ `BindQty` INT NOT NULL COMMENT '绑定数量',
+ `Status` VARCHAR(20) NOT NULL DEFAULT 'bound' COMMENT 'bound/deducted/released',
+ `SkuCode` VARCHAR(64) NULL,
+ `SkuName` VARCHAR(128) NULL,
+ `TrackingNo` VARCHAR(64) NULL,
+ `ProductName` VARCHAR(256) NULL,
+ `ImageUrl` VARCHAR(512) NULL,
+ `AttributeText` VARCHAR(256) NULL,
+ `Remark` VARCHAR(512) NULL,
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0,
+ `CreateUserId` INT NOT NULL,
+ `CreateTime` DATETIME NOT NULL,
+ `UpdateUserId` INT NULL,
+ `UpdateTime` DATETIME NULL,
+ PRIMARY KEY (`Id`),
+ KEY `idx_osb_order` (`OrderId`, `IsDeleted`, `Status`),
+ KEY `idx_osb_parcel_item` (`ParcelItemId`, `Status`, `IsDeleted`),
+ KEY `idx_osb_sku` (`CustomerId`, `WarehouseId`, `SkuId`, `Status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物流单仓储绑定明细';
+
+-- C_inventory.FrozenQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_inventory' AND COLUMN_NAME = 'FrozenQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_inventory` ADD COLUMN `FrozenQty` INT NOT NULL DEFAULT 0 COMMENT ''冻结数量'' AFTER `Qty`',
+ 'SELECT ''C_inventory.FrozenQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- C_inventory.AvailableQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_inventory' AND COLUMN_NAME = 'AvailableQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_inventory` ADD COLUMN `AvailableQty` INT NOT NULL DEFAULT 0 COMMENT ''可用=Qty-FrozenQty'' AFTER `FrozenQty`',
+ 'SELECT ''C_inventory.AvailableQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `C_inventory`
+SET `AvailableQty` = GREATEST(0, `Qty` - IFNULL(`FrozenQty`, 0))
+WHERE `AvailableQty` <> GREATEST(0, `Qty` - IFNULL(`FrozenQty`, 0))
+ OR (`AvailableQty` = 0 AND `Qty` > 0 AND IFNULL(`FrozenQty`, 0) = 0);
+
+-- C_parcel_item.FrozenQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_parcel_item' AND COLUMN_NAME = 'FrozenQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_parcel_item` ADD COLUMN `FrozenQty` INT NOT NULL DEFAULT 0 COMMENT ''冻结数量'' AFTER `Quantity`',
+ 'SELECT ''C_parcel_item.FrozenQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- C_parcel_item.AvailableQty
+SET @col_exists := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'C_parcel_item' AND COLUMN_NAME = 'AvailableQty'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `C_parcel_item` ADD COLUMN `AvailableQty` INT NOT NULL DEFAULT 0 COMMENT ''可用=Quantity-FrozenQty'' AFTER `FrozenQty`',
+ 'SELECT ''C_parcel_item.AvailableQty already exists'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+UPDATE `C_parcel_item`
+SET `AvailableQty` = GREATEST(0, `Quantity` - IFNULL(`FrozenQty`, 0))
+WHERE `AvailableQty` <> GREATEST(0, `Quantity` - IFNULL(`FrozenQty`, 0))
+ OR (`AvailableQty` = 0 AND `Quantity` > 0 AND IFNULL(`FrozenQty`, 0) = 0);
diff --git a/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs b/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
new file mode 100644
index 0000000..0d02cde
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderStockBindDtos.cs
@@ -0,0 +1,159 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderStockBindDetailOutput
+{
+ public int OrderId { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public string? WarehouseName { get; set; }
+
+ /// none / bound / deducted
+ public string BindStatus { get; set; } = "none";
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindItemOutput
+{
+ public int Id { get; set; }
+
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public string? SkuCode { get; set; }
+
+ public string? SkuName { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? BinLocationId { get; set; }
+
+ public string? BinCode { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ public string? TrackingNo { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? AttributeText { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public int BindQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
+ public string Status { get; set; } = string.Empty;
+}
+
+public class OrderStockBindCandidatesInput
+{
+ [Required]
+ public int OrderId { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public string? Keyword { get; set; }
+
+ /// 包裹候选:空/all=全部生命周期。
+ public string? LifecycleStatus { get; set; }
+
+ public int Page { get; set; } = 1;
+
+ public int PageSize { get; set; } = 30;
+}
+
+public class OrderStockBindInventoryCandidateOutput
+{
+ public int SkuId { get; set; }
+
+ public string SkuCode { get; set; } = string.Empty;
+
+ public string SkuName { get; set; } = string.Empty;
+
+ public string? SkuImageUrl { get; set; }
+
+ public int WarehouseId { get; set; }
+
+ public int TotalQty { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
+ public int BinCount { get; set; }
+}
+
+public class OrderStockBindParcelCandidateOutput
+{
+ public int ParcelId { get; set; }
+
+ public string TrackingNo { get; set; } = string.Empty;
+
+ public string? CarrierName { get; set; }
+
+ public string LifecycleStatus { get; set; } = string.Empty;
+
+ public int WarehouseId { get; set; }
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindParcelItemCandidateOutput
+{
+ public int ParcelItemId { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? AttributeText { get; set; }
+
+ public string? ImageUrl { get; set; }
+
+ public int? SkuId { get; set; }
+
+ public string? SkuCode { get; set; }
+
+ public int Quantity { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+}
+
+public class OrderStockBindSaveInput
+{
+ [Required]
+ public int OrderId { get; set; }
+
+ public List Items { get; set; } = [];
+}
+
+public class OrderStockBindSaveItemInput
+{
+ [Required]
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int BindQty { get; set; }
+}
+
+public class OrderStockBindOrderInput
+{
+ [Required]
+ public int OrderId { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
index 490478e..f858314 100644
--- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
+++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
@@ -354,6 +354,10 @@ public class WarehouseParcelItemOutput
public int Quantity { get; set; }
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
public int? SkuId { get; set; }
public string? SkuCode { get; set; }
@@ -447,6 +451,10 @@ public class WarehouseInventoryOutput
public int TotalQty { get; set; }
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
+
public int BinCount { get; set; }
}
@@ -461,6 +469,10 @@ public class WarehouseInventoryBinDetailOutput
public string? BinName { get; set; }
public int Qty { get; set; }
+
+ public int FrozenQty { get; set; }
+
+ public int AvailableQty { get; set; }
}
public class WarehouseInventoryRelocateInput
diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
new file mode 100644
index 0000000..d4bbf01
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs
@@ -0,0 +1,757 @@
+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();
+
+ private ICInventoryRepository StockBindInventoryRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICParcelRepository StockBindParcelRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICParcelItemRepository StockBindParcelItemRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICSkuRepository StockBindSkuRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICBinLocationRepository StockBindBinLocationRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 查询物流单当前仓储绑定明细。
+ ///
+ [HttpGet("/order/stock-bind/detail")]
+ public async Task GetStockBindDetailAsync([FromQuery] int orderId)
+ {
+ var order = await GetOrderOrThrowAsync(orderId);
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ ///
+ /// 库存候选(左侧库存 Tab)。
+ ///
+ [HttpPost("/order/stock-bind/inventory-candidates")]
+ public async Task> GetStockBindInventoryCandidatesAsync(
+ [FromBody] OrderStockBindCandidatesInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ var warehouseId = ResolveBindWarehouseId(order, input.WarehouseId);
+ var customerId = RequireOrderCustomerId(order);
+
+ var inventories = await StockBindInventoryRepository.GetListAsync(x =>
+ x.CustomerId == customerId
+ && x.WarehouseId == warehouseId
+ && x.AvailableQty > 0);
+
+ 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
+ {
+ Items = grouped.Skip(skip).Take(pageSize).ToList(),
+ Total = grouped.Count
+ };
+ }
+
+ ///
+ /// 包裹候选(左侧包裹 Tab,允许全部生命周期)。
+ ///
+ [HttpPost("/order/stock-bind/parcel-candidates")]
+ public async Task> GetStockBindParcelCandidatesAsync(
+ [FromBody] OrderStockBindCandidatesInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ var warehouseId = ResolveBindWarehouseId(order, input.WarehouseId);
+ var customerId = RequireOrderCustomerId(order);
+
+ 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();
+ 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
+ {
+ Items = outputs,
+ Total = parcels.Count
+ };
+ }
+
+ ///
+ /// 保存绑定(全量覆盖 + 冻结)。
+ ///
+ [HttpPost("/order/stock-bind/save")]
+ public async Task SaveStockBindAsync([FromBody] OrderStockBindSaveInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ await EnsureNoDeductedBindsAsync(order.Id);
+ var customerId = RequireOrderCustomerId(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);
+ }
+
+ ///
+ /// 清空绑定并释放冻结。
+ ///
+ [HttpPost("/order/stock-bind/clear")]
+ public async Task ClearStockBindAsync([FromBody] OrderStockBindOrderInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.OrderId);
+ EnsureOrderCanBindStock(order);
+ await EnsureNoDeductedBindsAsync(order.Id);
+ await ReleaseBoundBindsAsync(order.Id);
+ return await BuildStockBindDetailAsync(order);
+ }
+
+ ///
+ /// 确认扣减:将 bound 明细转为 deducted,扣减实物并释放冻结。
+ ///
+ [HttpPost("/order/stock-bind/confirm-deduct")]
+ public async Task 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> FreezeInventoryAsync(
+ int customerId,
+ int warehouseId,
+ int skuId,
+ int requiredQty,
+ int? inventoryId)
+ {
+ List 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);
+ 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 static int RequireOrderCustomerId(Order order)
+ {
+ if (!order.CustomerId.HasValue || order.CustomerId.Value <= 0)
+ {
+ throw new BusinessException("订单未关联客户,无法绑定仓储资源");
+ }
+
+ return order.CustomerId.Value;
+ }
+
+ 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 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();
+ 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);
+}
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
index 0433bf3..ae2df79 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs
@@ -487,8 +487,8 @@ public class PlatformStoreOrderService : ApplicationService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
- var available = total.Sum(x => x.Qty);
+ && (x.AvailableQty > 0 || x.Qty - x.FrozenQty > 0));
+ var available = total.Sum(x => Math.Max(0, x.Qty - x.FrozenQty));
if (available < qty)
{
throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}");
@@ -526,7 +526,7 @@ public class PlatformStoreOrderService : ApplicationService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
+ && (x.AvailableQty > 0 || x.Qty > x.FrozenQty));
rows = rows.OrderBy(x => x.BinLocationId).ToList();
var deductions = new List();
@@ -538,8 +538,15 @@ public class PlatformStoreOrderService : ApplicationService
break;
}
- var deduct = Math.Min(row.Qty, remaining);
+ var available = Math.Max(0, row.Qty - row.FrozenQty);
+ var deduct = Math.Min(available, remaining);
+ if (deduct <= 0)
+ {
+ continue;
+ }
+
row.Qty -= deduct;
+ row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
remaining -= deduct;
await InventoryRepository.UpdateAsync(row);
deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct));
@@ -570,13 +577,16 @@ public class PlatformStoreOrderService : ApplicationService
WarehouseId = warehouseId,
SkuId = item.SkuId,
BinLocationId = item.BinLocationId,
- Qty = item.Qty
+ Qty = item.Qty,
+ FrozenQty = 0,
+ AvailableQty = item.Qty
};
await InventoryRepository.InsertAsync(inventory);
continue;
}
inventory.Qty += item.Qty;
+ inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
await InventoryRepository.UpdateAsync(inventory);
}
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
index fd40d93..7b8869d 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs
@@ -64,7 +64,9 @@ public partial class WarehouseService
WarehouseId = warehouseId,
SkuId = skuId,
BinLocationId = binLocationId,
- Qty = deltaQty
+ Qty = deltaQty,
+ FrozenQty = 0,
+ AvailableQty = deltaQty
};
await InventoryRepository.InsertAsync(inventory);
return;
@@ -76,7 +78,13 @@ public partial class WarehouseService
throw new BusinessException("库存不足");
}
+ if (deltaQty < 0 && inventory.AvailableQty < -deltaQty)
+ {
+ throw new BusinessException("库存可用不足");
+ }
+
inventory.Qty = newQty;
+ inventory.AvailableQty = Math.Max(0, inventory.Qty - inventory.FrozenQty);
await InventoryRepository.UpdateAsync(inventory);
}
@@ -95,7 +103,7 @@ public partial class WarehouseService
x.CustomerId == customerId
&& x.WarehouseId == warehouseId
&& x.SkuId == skuId
- && x.Qty > 0);
+ && x.AvailableQty > 0);
rows = rows.OrderBy(x => x.BinLocationId).ToList();
var remaining = requiredQty;
@@ -106,8 +114,15 @@ public partial class WarehouseService
break;
}
- var deduct = Math.Min(row.Qty, remaining);
+ var available = Math.Max(0, row.Qty - row.FrozenQty);
+ var deduct = Math.Min(available, remaining);
+ if (deduct <= 0)
+ {
+ continue;
+ }
+
row.Qty -= deduct;
+ row.AvailableQty = Math.Max(0, row.Qty - row.FrozenQty);
remaining -= deduct;
await InventoryRepository.UpdateAsync(row);
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
index b79bf54..152bd54 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs
@@ -22,7 +22,7 @@ public partial class WarehouseService
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
var inventories = await InventoryRepository.GetListAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
- && x.Qty > 0
+ && x.AvailableQty > 0
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue));
var skuIds = inventories.Select(x => x.SkuId).Distinct().ToArray();
@@ -45,6 +45,8 @@ public partial class WarehouseService
SkuName = sku?.SkuName ?? string.Empty,
WarehouseId = g.Key.WarehouseId,
TotalQty = g.Sum(x => x.Qty),
+ FrozenQty = g.Sum(x => x.FrozenQty),
+ AvailableQty = g.Sum(x => x.AvailableQty > 0 ? x.AvailableQty : Math.Max(0, x.Qty - x.FrozenQty)),
BinCount = g.Count()
};
})
@@ -113,7 +115,9 @@ public partial class WarehouseService
BinLocationId = x.BinLocationId,
BinCode = bin?.BinCode ?? string.Empty,
BinName = bin?.BinName,
- Qty = x.Qty
+ Qty = x.Qty,
+ FrozenQty = x.FrozenQty,
+ AvailableQty = x.AvailableQty > 0 ? x.AvailableQty : Math.Max(0, x.Qty - x.FrozenQty)
};
})
.ToList()
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
index 7549fde..fc1a776 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
@@ -176,6 +176,8 @@ public partial class WarehouseService
: itemInput.AttributeText.Trim(),
ImageUrl = imageUrl,
Quantity = itemInput.Quantity,
+ FrozenQty = 0,
+ AvailableQty = itemInput.Quantity,
Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim()
};
await ParcelItemRepository.InsertAsync(item);
@@ -309,6 +311,8 @@ public partial class WarehouseService
AttributeText = item.AttributeText,
ImageUrl = item.ImageUrl,
Quantity = item.Quantity,
+ FrozenQty = item.FrozenQty,
+ AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty),
SkuId = item.SkuId,
SkuCode = item.SkuCode,
BinLocationId = item.BinLocationId,
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
new file mode 100644
index 0000000..cd4dfce
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderStockBindCodes.cs
@@ -0,0 +1,14 @@
+namespace Oms.Domain.OrdersAggregate.Consts;
+
+public static class OrderStockBindTypeCodes
+{
+ public const string Inventory = "inventory";
+ public const string ParcelItem = "parcelItem";
+}
+
+public static class OrderStockBindStatusCodes
+{
+ public const string Bound = "bound";
+ public const string Deducted = "deducted";
+ public const string Released = "released";
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
new file mode 100644
index 0000000..e086c66
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderStockBind.cs
@@ -0,0 +1,56 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.OrdersAggregate.Entities;
+
+///
+/// 物流单仓储绑定明细(包裹行 / 库存)。
+///
+[SugarTable("t_order_stock_bind")]
+public class OrderStockBind : OmsAuditedAggregateRoot
+{
+ public int OrderId { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public int WarehouseId { get; set; }
+
+ /// inventory / parcelItem
+ public string BindType { get; set; } = string.Empty;
+
+ public int? SkuId { get; set; }
+
+ public int? InventoryId { get; set; }
+
+ public int? BinLocationId { get; set; }
+
+ public int? ParcelId { get; set; }
+
+ public int? ParcelItemId { get; set; }
+
+ public int BindQty { get; set; }
+
+ /// bound / deducted / released
+ public string Status { get; set; } = "bound";
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? SkuCode { get; set; }
+
+ [SugarColumn(Length = 128, IsNullable = true)]
+ public string? SkuName { get; set; }
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? TrackingNo { get; set; }
+
+ [SugarColumn(Length = 256, IsNullable = true)]
+ public string? ProductName { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? ImageUrl { get; set; }
+
+ [SugarColumn(Length = 256, IsNullable = true)]
+ public string? AttributeText { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs
new file mode 100644
index 0000000..55f8303
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderStockBindRepository.cs
@@ -0,0 +1,6 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Domain.OrdersAggregate.Repositories;
+
+public interface IOrderStockBindRepository : IRepository;
diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
index 7bdd3de..91aa6d1 100644
--- a/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs
@@ -15,4 +15,10 @@ public class CInventory : OmsAuditedAggregateRoot
public int BinLocationId { get; set; }
public int Qty { get; set; }
+
+ /// 已绑定未出库冻结量。
+ public int FrozenQty { get; set; }
+
+ /// 可用 = Qty - FrozenQty。
+ public int AvailableQty { get; set; }
}
diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
index 06e870a..0cb75e1 100644
--- a/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs
@@ -18,6 +18,12 @@ public class CParcelItem : OmsAuditedAggregateRoot
public int Quantity { get; set; }
+ /// 已被物流单占用(冻结)数量。
+ public int FrozenQty { get; set; }
+
+ /// 可用 = Quantity - FrozenQty。
+ public int AvailableQty { get; set; }
+
public int? SkuId { get; set; }
public string? SkuCode { get; set; }
diff --git a/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
new file mode 100644
index 0000000..f479c3f
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Orders/OrderStockBindRepository.cs
@@ -0,0 +1,8 @@
+using Fake.DependencyInjection;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Orders;
+
+public class OrderStockBindRepository : OmsSqlSugarRepository, IOrderStockBindRepository, IScopedDependency;