diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
new file mode 100644
index 0000000..410d5f2
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
@@ -0,0 +1,149 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Application.App.Inventory.Queries;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 库存管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/inventory")]
+public sealed class InventoryController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 查询库存。
+ ///
+ [HttpGet("{productSkuId:long}")]
+ [PermissionAuthorize("inventory:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Get(long storeId, long productSkuId, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetInventoryItemQuery { StoreId = storeId, ProductSkuId = productSkuId }, cancellationToken);
+ return result is null
+ ? ApiResponse.Error(ErrorCodes.NotFound, "库存不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 调整库存(入库/盘点/报损)。
+ ///
+ [HttpPost("adjust")]
+ [PermissionAuthorize("inventory:adjust")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Adjust(long storeId, [FromBody] AdjustInventoryCommand command, CancellationToken cancellationToken)
+ {
+ if (command.StoreId == 0)
+ {
+ command = command with { StoreId = storeId };
+ }
+
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 锁定库存(下单占用)。
+ ///
+ [HttpPost("lock")]
+ [PermissionAuthorize("inventory:lock")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Lock(long storeId, [FromBody] LockInventoryCommand command, CancellationToken cancellationToken)
+ {
+ if (command.StoreId == 0)
+ {
+ command = command with { StoreId = storeId };
+ }
+
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 释放库存(取消订单等)。
+ ///
+ [HttpPost("release")]
+ [PermissionAuthorize("inventory:release")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Release(long storeId, [FromBody] ReleaseInventoryCommand command, CancellationToken cancellationToken)
+ {
+ if (command.StoreId == 0)
+ {
+ command = command with { StoreId = storeId };
+ }
+
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 扣减库存(支付或履约成功)。
+ ///
+ [HttpPost("deduct")]
+ [PermissionAuthorize("inventory:deduct")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Deduct(long storeId, [FromBody] DeductInventoryCommand command, CancellationToken cancellationToken)
+ {
+ if (command.StoreId == 0)
+ {
+ command = command with { StoreId = storeId };
+ }
+
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 查询批次列表。
+ ///
+ [HttpGet("{productSkuId:long}/batches")]
+ [PermissionAuthorize("inventory:batch:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetBatches(long storeId, long productSkuId, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetInventoryBatchesQuery
+ {
+ StoreId = storeId,
+ ProductSkuId = productSkuId
+ }, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 新增或更新批次。
+ ///
+ [HttpPost("{productSkuId:long}/batches")]
+ [PermissionAuthorize("inventory:batch:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> UpsertBatch(long storeId, long productSkuId, [FromBody] UpsertInventoryBatchCommand command, CancellationToken cancellationToken)
+ {
+ if (command.StoreId == 0 || command.ProductSkuId == 0)
+ {
+ command = command with { StoreId = storeId, ProductSkuId = productSkuId };
+ }
+
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 释放过期锁定。
+ ///
+ [HttpPost("locks/expire")]
+ [PermissionAuthorize("inventory:release")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ReleaseExpiredLocks(CancellationToken cancellationToken)
+ {
+ var count = await mediator.Send(new ReleaseExpiredInventoryLocksCommand(), cancellationToken);
+ return ApiResponse.Ok(count);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json
index 1af6231..4c483b1 100644
--- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json
+++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json
@@ -218,6 +218,14 @@
"product-media:update",
"product-pricing:read",
"product-pricing:update",
+ "inventory:read",
+ "inventory:adjust",
+ "inventory:lock",
+ "inventory:release",
+ "inventory:deduct",
+ "inventory:batch:read",
+ "inventory:batch:update",
+ "inventory:lock:expire",
"order:create",
"order:read",
"order:update",
@@ -274,6 +282,14 @@
"product-media:update",
"product-pricing:read",
"product-pricing:update",
+ "inventory:read",
+ "inventory:adjust",
+ "inventory:lock",
+ "inventory:release",
+ "inventory:deduct",
+ "inventory:batch:read",
+ "inventory:batch:update",
+ "inventory:lock:expire",
"order:create",
"order:read",
"order:update",
@@ -374,6 +390,14 @@
"product-media:update",
"product-pricing:read",
"product-pricing:update",
+ "inventory:read",
+ "inventory:adjust",
+ "inventory:lock",
+ "inventory:release",
+ "inventory:deduct",
+ "inventory:batch:read",
+ "inventory:batch:update",
+ "inventory:lock:expire",
"order:create",
"order:read",
"order:update",
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs
new file mode 100644
index 0000000..7ee93f7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Enums;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 库存调整命令。
+///
+public sealed record AdjustInventoryCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 调整数量,正数入库,负数出库。
+ ///
+ public int QuantityDelta { get; init; }
+
+ ///
+ /// 调整类型。
+ ///
+ public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual;
+
+ ///
+ /// 原因说明。
+ ///
+ public string? Reason { get; init; }
+
+ ///
+ /// 安全库存阈值(可选)。
+ ///
+ public int? SafetyStock { get; init; }
+
+ ///
+ /// 是否售罄标记。
+ ///
+ public bool? IsSoldOut { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs
new file mode 100644
index 0000000..d97026f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 扣减库存命令(履约/支付成功)。
+///
+public sealed record DeductInventoryCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 扣减数量。
+ ///
+ public int Quantity { get; init; }
+
+ ///
+ /// 是否预售锁定转扣减。
+ ///
+ public bool IsPresaleOrder { get; init; }
+
+ ///
+ /// 幂等键(与锁定请求一致可避免重复扣减)。
+ ///
+ public string? IdempotencyKey { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs
new file mode 100644
index 0000000..f19754b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs
@@ -0,0 +1,41 @@
+using System;
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 锁定库存命令。
+///
+public sealed record LockInventoryCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 锁定数量。
+ ///
+ public int Quantity { get; init; }
+
+ ///
+ /// 是否按预售逻辑锁定。
+ ///
+ public bool IsPresaleOrder { get; init; }
+
+ ///
+ /// 锁定过期时间(UTC),超时可释放。
+ ///
+ public DateTime? ExpiresAt { get; init; }
+
+ ///
+ /// 幂等键(同一键重复调用返回同一结果)。
+ ///
+ public string IdempotencyKey { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs
new file mode 100644
index 0000000..a721448
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 释放过期库存锁定命令。
+///
+public sealed record ReleaseExpiredInventoryLocksCommand : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs
new file mode 100644
index 0000000..dd7c889
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 释放库存命令。
+///
+public sealed record ReleaseInventoryCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 释放数量。
+ ///
+ public int Quantity { get; init; }
+
+ ///
+ /// 是否预售锁定释放。
+ ///
+ public bool IsPresaleOrder { get; init; }
+
+ ///
+ /// 幂等键(与锁定请求一致可避免重复释放)。
+ ///
+ public string? IdempotencyKey { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs
new file mode 100644
index 0000000..8943014
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs
@@ -0,0 +1,45 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Commands;
+
+///
+/// 新增或更新库存批次命令。
+///
+public sealed record UpsertInventoryBatchCommand : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 批次号。
+ ///
+ public string BatchNumber { get; init; } = string.Empty;
+
+ ///
+ /// 生产日期。
+ ///
+ public DateTime? ProductionDate { get; init; }
+
+ ///
+ /// 过期日期。
+ ///
+ public DateTime? ExpireDate { get; init; }
+
+ ///
+ /// 入库数量。
+ ///
+ public int Quantity { get; init; }
+
+ ///
+ /// 剩余数量。
+ ///
+ public int RemainingQuantity { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs
new file mode 100644
index 0000000..eed3c53
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs
@@ -0,0 +1,53 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Inventory.Dto;
+
+///
+/// 库存批次 DTO。
+///
+public sealed record InventoryBatchDto
+{
+ ///
+ /// 批次 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 门店 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 批次号。
+ ///
+ public string BatchNumber { get; init; } = string.Empty;
+
+ ///
+ /// 生产日期。
+ ///
+ public DateTime? ProductionDate { get; init; }
+
+ ///
+ /// 过期日期。
+ ///
+ public DateTime? ExpireDate { get; init; }
+
+ ///
+ /// 入库数量。
+ ///
+ public int Quantity { get; init; }
+
+ ///
+ /// 剩余数量。
+ ///
+ public int RemainingQuantity { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs
new file mode 100644
index 0000000..f02902a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs
@@ -0,0 +1,99 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Inventory.Dto;
+
+///
+/// 库存项 DTO。
+///
+public sealed record InventoryItemDto
+{
+ ///
+ /// 库存记录 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 门店 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long ProductSkuId { get; init; }
+
+ ///
+ /// 批次号。
+ ///
+ public string? BatchNumber { get; init; }
+
+ ///
+ /// 可用库存。
+ ///
+ public int QuantityOnHand { get; init; }
+
+ ///
+ /// 已锁定库存。
+ ///
+ public int QuantityReserved { get; init; }
+
+ ///
+ /// 安全库存。
+ ///
+ public int? SafetyStock { get; init; }
+
+ ///
+ /// 储位。
+ ///
+ public string? Location { get; init; }
+
+ ///
+ /// 过期日期。
+ ///
+ public DateTime? ExpireDate { get; init; }
+
+ ///
+ /// 是否预售。
+ ///
+ public bool IsPresale { get; init; }
+
+ ///
+ /// 预售开始时间。
+ ///
+ public DateTime? PresaleStartTime { get; init; }
+
+ ///
+ /// 预售结束时间。
+ ///
+ public DateTime? PresaleEndTime { get; init; }
+
+ ///
+ /// 预售上限。
+ ///
+ public int? PresaleCapacity { get; init; }
+
+ ///
+ /// 已锁定预售量。
+ ///
+ public int PresaleLocked { get; init; }
+
+ ///
+ /// 限购数量。
+ ///
+ public int? MaxQuantityPerOrder { get; init; }
+
+ ///
+ /// 是否售罄。
+ ///
+ public bool IsSoldOut { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs
new file mode 100644
index 0000000..0590589
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs
@@ -0,0 +1,85 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Entities;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 库存调整处理器。
+///
+public sealed class AdjustInventoryCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(AdjustInventoryCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 读取库存
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ // 2. 初始化或校验存在性
+ if (item is null)
+ {
+ if (request.QuantityDelta < 0)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减");
+ }
+
+ // 初始化库存记录
+ item = new InventoryItem
+ {
+ TenantId = tenantId,
+ StoreId = request.StoreId,
+ ProductSkuId = request.ProductSkuId,
+ QuantityOnHand = request.QuantityDelta,
+ QuantityReserved = 0,
+ SafetyStock = request.SafetyStock,
+ IsSoldOut = false
+ };
+ await inventoryRepository.AddItemAsync(item, cancellationToken);
+ }
+
+ // 3. 应用调整
+ var newQuantity = item.QuantityOnHand + request.QuantityDelta;
+ if (newQuantity < 0)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法扣减");
+ }
+
+ item.QuantityOnHand = newQuantity;
+ item.SafetyStock = request.SafetyStock ?? item.SafetyStock;
+ item.IsSoldOut = request.IsSoldOut ?? IsSoldOut(item);
+
+ // 4. 写入调整记录
+ var adjustment = new InventoryAdjustment
+ {
+ TenantId = tenantId,
+ InventoryItemId = item.Id,
+ AdjustmentType = request.AdjustmentType,
+ Quantity = request.QuantityDelta,
+ Reason = request.Reason,
+ OperatorId = null,
+ OccurredAt = DateTime.UtcNow
+ };
+ await inventoryRepository.AddAdjustmentAsync(adjustment, cancellationToken);
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("调整库存 SKU {ProductSkuId} 门店 {StoreId} 变更 {Delta}", request.ProductSkuId, request.StoreId, request.QuantityDelta);
+ return InventoryMapping.ToDto(item);
+ }
+
+ // 辅助:售罄判定
+ private static bool IsSoldOut(InventoryItem item)
+ {
+ var available = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked;
+ var safety = item.SafetyStock ?? 0;
+ return available <= safety || item.QuantityOnHand <= 0;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs
new file mode 100644
index 0000000..0cbde90
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs
@@ -0,0 +1,110 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 库存扣减处理器。
+///
+public sealed class DeductInventoryCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(DeductInventoryCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 读取库存
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ if (item is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
+ }
+
+ // 1.1 幂等:若锁记录已扣减/释放则直接返回
+ if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
+ {
+ var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
+ if (lockRecord is not null)
+ {
+ if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Deducted)
+ {
+ return InventoryMapping.ToDto(item);
+ }
+
+ if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Locked)
+ {
+ request = request with { Quantity = lockRecord.Quantity, IsPresaleOrder = lockRecord.IsPresale };
+ await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Deducted, cancellationToken);
+ }
+ }
+ }
+
+ // 2. 计算扣减来源
+ var isPresale = request.IsPresaleOrder || item.IsPresale;
+ if (isPresale)
+ {
+ if (item.PresaleLocked < request.Quantity)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足,无法扣减");
+ }
+
+ item.PresaleLocked -= request.Quantity;
+ }
+ else
+ {
+ if (item.QuantityReserved < request.Quantity)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足,无法扣减");
+ }
+
+ item.QuantityReserved -= request.Quantity;
+ }
+
+ var remaining = item.QuantityOnHand - request.Quantity;
+ if (remaining < 0)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "可用库存不足,无法扣减");
+ }
+
+ // 3. 扣减可用量并按批次消耗
+ item.QuantityOnHand = remaining;
+ // 3.1 批次扣减(非预售)
+ if (!isPresale)
+ {
+ var batches = await inventoryRepository.GetBatchesForConsumeAsync(tenantId, request.StoreId, request.ProductSkuId, item.BatchConsumeStrategy, cancellationToken);
+ var need = request.Quantity;
+ foreach (var batch in batches)
+ {
+ if (need <= 0)
+ {
+ break;
+ }
+
+ var take = Math.Min(batch.RemainingQuantity, need);
+ batch.RemainingQuantity -= take;
+ need -= take;
+ await inventoryRepository.UpdateBatchAsync(batch, cancellationToken);
+ }
+
+ if (need > 0)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "批次数量不足,无法扣减");
+ }
+ }
+
+ item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
+ await inventoryRepository.UpdateItemAsync(item, cancellationToken);
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("扣减库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
+ return InventoryMapping.ToDto(item);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs
new file mode 100644
index 0000000..3796fda
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Application.App.Inventory.Queries;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 库存批次查询处理器。
+///
+public sealed class GetInventoryBatchesQueryHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 读取批次
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ // 2. 映射
+ return batches.Select(InventoryMapping.ToDto).ToList();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs
new file mode 100644
index 0000000..f9a940c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Application.App.Inventory.Queries;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 查询库存处理器。
+///
+public sealed class GetInventoryItemQueryHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetInventoryItemQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 读取库存
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ // 2. 返回 DTO
+ return item is null ? null : InventoryMapping.ToDto(item);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs
new file mode 100644
index 0000000..42f69fa
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs
@@ -0,0 +1,92 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 库存锁定处理器。
+///
+public sealed class LockInventoryCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(LockInventoryCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 读取库存
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ if (item is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
+ }
+
+ // 1.1 幂等处理
+ var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
+ if (existingLock is not null)
+ {
+ return InventoryMapping.ToDto(item);
+ }
+
+ // 2. 校验可用量
+ var now = DateTime.UtcNow;
+ var isPresale = request.IsPresaleOrder || item.IsPresale;
+ if (isPresale)
+ {
+ if (item.PresaleStartTime.HasValue && now < item.PresaleStartTime.Value)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "预售尚未开始");
+ }
+
+ if (item.PresaleEndTime.HasValue && now > item.PresaleEndTime.Value)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "预售已结束");
+ }
+ }
+
+ var available = isPresale
+ ? (item.PresaleCapacity ?? item.QuantityOnHand) - item.PresaleLocked
+ : item.QuantityOnHand - item.QuantityReserved;
+ if (available < request.Quantity)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法锁定");
+ }
+
+ // 3. 执行锁定
+ if (isPresale)
+ {
+ item.PresaleLocked += request.Quantity;
+ }
+ else
+ {
+ item.QuantityReserved += request.Quantity;
+ }
+
+ item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
+ await inventoryRepository.UpdateItemAsync(item, cancellationToken);
+ var lockRecord = new Domain.Inventory.Entities.InventoryLockRecord
+ {
+ TenantId = tenantId,
+ StoreId = request.StoreId,
+ ProductSkuId = request.ProductSkuId,
+ Quantity = request.Quantity,
+ IsPresale = isPresale,
+ IdempotencyKey = request.IdempotencyKey,
+ ExpiresAt = request.ExpiresAt,
+ Status = Domain.Inventory.Enums.InventoryLockStatus.Locked
+ };
+
+ await inventoryRepository.AddLockAsync(lockRecord, cancellationToken);
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("锁定库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
+ return InventoryMapping.ToDto(item);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs
new file mode 100644
index 0000000..0ef2884
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs
@@ -0,0 +1,60 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Domain.Inventory.Enums;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 释放过期锁定处理器。
+///
+public sealed class ReleaseExpiredInventoryLocksCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询过期锁
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var now = DateTime.UtcNow;
+ var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken);
+ if (expiredLocks.Count == 0)
+ {
+ return 0;
+ }
+
+ // 2. 释放锁对应库存
+ var affected = 0;
+ foreach (var lockRecord in expiredLocks)
+ {
+ var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken);
+ if (item is null)
+ {
+ continue;
+ }
+
+ if (lockRecord.IsPresale)
+ {
+ item.PresaleLocked = Math.Max(0, item.PresaleLocked - lockRecord.Quantity);
+ }
+ else
+ {
+ item.QuantityReserved = Math.Max(0, item.QuantityReserved - lockRecord.Quantity);
+ }
+
+ item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
+ await inventoryRepository.UpdateItemAsync(item, cancellationToken);
+ await inventoryRepository.MarkLockStatusAsync(lockRecord, InventoryLockStatus.Released, cancellationToken);
+ affected++;
+ }
+
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("释放过期库存锁定 {Count} 条", affected);
+ return affected;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs
new file mode 100644
index 0000000..b159471
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs
@@ -0,0 +1,76 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 库存释放处理器。
+///
+public sealed class ReleaseInventoryCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 读取库存
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken);
+ if (item is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "库存不存在");
+ }
+
+ // 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放
+ if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
+ {
+ var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken);
+ if (lockRecord is not null)
+ {
+ if (lockRecord.Status != Domain.Inventory.Enums.InventoryLockStatus.Locked)
+ {
+ return InventoryMapping.ToDto(item);
+ }
+
+ // 将数量同步为锁记录数,避免重复释放不一致
+ request = request with { Quantity = lockRecord.Quantity };
+ await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Released, cancellationToken);
+ }
+ }
+
+ // 2. 计算释放
+ var isPresale = request.IsPresaleOrder || item.IsPresale;
+ if (isPresale)
+ {
+ if (item.PresaleLocked < request.Quantity)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足");
+ }
+
+ item.PresaleLocked -= request.Quantity;
+ }
+ else
+ {
+ if (item.QuantityReserved < request.Quantity)
+ {
+ throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足");
+ }
+
+ item.QuantityReserved -= request.Quantity;
+ }
+
+ item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0);
+ await inventoryRepository.UpdateItemAsync(item, cancellationToken);
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("释放库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity);
+ return InventoryMapping.ToDto(item);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs
new file mode 100644
index 0000000..dea621d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs
@@ -0,0 +1,57 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Entities;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Inventory.Handlers;
+
+///
+/// 批次维护处理器。
+///
+public sealed class UpsertInventoryBatchCommandHandler(
+ IInventoryRepository inventoryRepository,
+ ITenantProvider tenantProvider,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 读取批次
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken);
+ // 2. 创建或更新
+ if (batch is null)
+ {
+ batch = new InventoryBatch
+ {
+ TenantId = tenantId,
+ StoreId = request.StoreId,
+ ProductSkuId = request.ProductSkuId,
+ BatchNumber = request.BatchNumber,
+ ProductionDate = request.ProductionDate,
+ ExpireDate = request.ExpireDate,
+ Quantity = request.Quantity,
+ RemainingQuantity = request.RemainingQuantity
+ };
+ await inventoryRepository.AddBatchAsync(batch, cancellationToken);
+ }
+ else
+ {
+ batch.ProductionDate = request.ProductionDate;
+ batch.ExpireDate = request.ExpireDate;
+ batch.Quantity = request.Quantity;
+ batch.RemainingQuantity = request.RemainingQuantity;
+ await inventoryRepository.UpdateBatchAsync(batch, cancellationToken);
+ }
+
+ await inventoryRepository.SaveChangesAsync(cancellationToken);
+ logger.LogInformation("维护批次 门店 {StoreId} SKU {ProductSkuId} 批次 {BatchNumber}", request.StoreId, request.ProductSkuId, request.BatchNumber);
+ return InventoryMapping.ToDto(batch);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs
new file mode 100644
index 0000000..b4c9cbd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs
@@ -0,0 +1,49 @@
+using TakeoutSaaS.Application.App.Inventory.Dto;
+using TakeoutSaaS.Domain.Inventory.Entities;
+
+namespace TakeoutSaaS.Application.App.Inventory;
+
+///
+/// 库存映射辅助。
+///
+public static class InventoryMapping
+{
+ ///
+ /// 映射库存 DTO。
+ ///
+ public static InventoryItemDto ToDto(InventoryItem item) => new()
+ {
+ Id = item.Id,
+ TenantId = item.TenantId,
+ StoreId = item.StoreId,
+ ProductSkuId = item.ProductSkuId,
+ BatchNumber = item.BatchNumber,
+ QuantityOnHand = item.QuantityOnHand,
+ QuantityReserved = item.QuantityReserved,
+ SafetyStock = item.SafetyStock,
+ Location = item.Location,
+ ExpireDate = item.ExpireDate,
+ IsPresale = item.IsPresale,
+ PresaleStartTime = item.PresaleStartTime,
+ PresaleEndTime = item.PresaleEndTime,
+ PresaleCapacity = item.PresaleCapacity,
+ PresaleLocked = item.PresaleLocked,
+ MaxQuantityPerOrder = item.MaxQuantityPerOrder,
+ IsSoldOut = item.IsSoldOut
+ };
+
+ ///
+ /// 映射批次 DTO。
+ ///
+ public static InventoryBatchDto ToDto(InventoryBatch batch) => new()
+ {
+ Id = batch.Id,
+ StoreId = batch.StoreId,
+ ProductSkuId = batch.ProductSkuId,
+ BatchNumber = batch.BatchNumber,
+ ProductionDate = batch.ProductionDate,
+ ExpireDate = batch.ExpireDate,
+ Quantity = batch.Quantity,
+ RemainingQuantity = batch.RemainingQuantity
+ };
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs
new file mode 100644
index 0000000..c95f4cc
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Queries;
+
+///
+/// 查询库存批次列表。
+///
+public sealed record GetInventoryBatchesQuery : IRequest>
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs
new file mode 100644
index 0000000..446b59a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Inventory.Dto;
+
+namespace TakeoutSaaS.Application.App.Inventory.Queries;
+
+///
+/// 按门店与 SKU 查询库存。
+///
+public sealed record GetInventoryItemQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs
new file mode 100644
index 0000000..e74ea14
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs
@@ -0,0 +1,21 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+
+namespace TakeoutSaaS.Application.App.Inventory.Validators;
+
+///
+/// 库存调整命令验证器。
+///
+public sealed class AdjustInventoryCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public AdjustInventoryCommandValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ProductSkuId).GreaterThan(0);
+ RuleFor(x => x.QuantityDelta).NotEqual(0);
+ RuleFor(x => x.SafetyStock).GreaterThanOrEqualTo(0).When(x => x.SafetyStock.HasValue);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs
new file mode 100644
index 0000000..53eba84
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs
@@ -0,0 +1,21 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+
+namespace TakeoutSaaS.Application.App.Inventory.Validators;
+
+///
+/// 扣减库存命令验证器。
+///
+public sealed class DeductInventoryCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public DeductInventoryCommandValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ProductSkuId).GreaterThan(0);
+ RuleFor(x => x.Quantity).GreaterThan(0);
+ RuleFor(x => x.IdempotencyKey).MaximumLength(128);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs
new file mode 100644
index 0000000..38aa7ec
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs
@@ -0,0 +1,21 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+
+namespace TakeoutSaaS.Application.App.Inventory.Validators;
+
+///
+/// 库存锁定命令验证器。
+///
+public sealed class LockInventoryCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public LockInventoryCommandValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ProductSkuId).GreaterThan(0);
+ RuleFor(x => x.Quantity).GreaterThan(0);
+ RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(128);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs
new file mode 100644
index 0000000..ed0b8dc
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs
@@ -0,0 +1,21 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+
+namespace TakeoutSaaS.Application.App.Inventory.Validators;
+
+///
+/// 释放库存命令验证器。
+///
+public sealed class ReleaseInventoryCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ReleaseInventoryCommandValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ProductSkuId).GreaterThan(0);
+ RuleFor(x => x.Quantity).GreaterThan(0);
+ RuleFor(x => x.IdempotencyKey).MaximumLength(128);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs
new file mode 100644
index 0000000..abcb278
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs
@@ -0,0 +1,22 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Inventory.Commands;
+
+namespace TakeoutSaaS.Application.App.Inventory.Validators;
+
+///
+/// 批次维护命令验证器。
+///
+public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public UpsertInventoryBatchCommandValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ProductSkuId).GreaterThan(0);
+ RuleFor(x => x.BatchNumber).NotEmpty().MaximumLength(64);
+ RuleFor(x => x.Quantity).GreaterThan(0);
+ RuleFor(x => x.RemainingQuantity).GreaterThanOrEqualTo(0);
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs
index eee47f4..7f7b12f 100644
--- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs
@@ -1,3 +1,5 @@
+using System;
+using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Inventory.Entities;
@@ -41,4 +43,10 @@ public sealed class InventoryBatch : MultiTenantEntityBase
/// 剩余数量。
///
public int RemainingQuantity { get; set; }
+
+ ///
+ /// 并发控制字段。
+ ///
+ [Timestamp]
+ public byte[] RowVersion { get; set; } = Array.Empty();
}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs
index 6aca234..32390c0 100644
--- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs
@@ -1,4 +1,7 @@
+using System;
+using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Shared.Abstractions.Entities;
+using TakeoutSaaS.Domain.Inventory.Enums;
namespace TakeoutSaaS.Domain.Inventory.Entities;
@@ -46,4 +49,50 @@ public sealed class InventoryItem : MultiTenantEntityBase
/// 过期日期。
///
public DateTime? ExpireDate { get; set; }
+
+ ///
+ /// 是否预售商品。
+ ///
+ public bool IsPresale { get; set; }
+
+ ///
+ /// 预售开始时间(UTC)。
+ ///
+ public DateTime? PresaleStartTime { get; set; }
+
+ ///
+ /// 预售结束时间(UTC)。
+ ///
+ public DateTime? PresaleEndTime { get; set; }
+
+ ///
+ /// 预售名额(上限)。
+ ///
+ public int? PresaleCapacity { get; set; }
+
+ ///
+ /// 当前预售已锁定数量。
+ ///
+ public int PresaleLocked { get; set; }
+
+ ///
+ /// 单品限购(覆盖商品级 MaxQuantityPerOrder)。
+ ///
+ public int? MaxQuantityPerOrder { get; set; }
+
+ ///
+ /// 是否标记售罄。
+ ///
+ public bool IsSoldOut { get; set; }
+
+ ///
+ /// 批次扣减策略。
+ ///
+ public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo;
+
+ ///
+ /// 并发控制字段。
+ ///
+ [Timestamp]
+ public byte[] RowVersion { get; set; } = Array.Empty();
}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs
new file mode 100644
index 0000000..89e6304
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs
@@ -0,0 +1,52 @@
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Domain.Inventory.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Inventory.Entities;
+
+///
+/// 库存锁定记录。
+///
+public sealed class InventoryLockRecord : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// SKU ID。
+ ///
+ public long ProductSkuId { get; set; }
+
+ ///
+ /// 锁定数量。
+ ///
+ public int Quantity { get; set; }
+
+ ///
+ /// 是否预售锁定。
+ ///
+ public bool IsPresale { get; set; }
+
+ ///
+ /// 幂等键。
+ ///
+ public string IdempotencyKey { get; set; } = string.Empty;
+
+ ///
+ /// 过期时间(UTC)。
+ ///
+ public DateTime? ExpiresAt { get; set; }
+
+ ///
+ /// 锁定状态。
+ ///
+ public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked;
+
+ ///
+ /// 并发控制字段。
+ ///
+ [Timestamp]
+ public byte[] RowVersion { get; set; } = Array.Empty();
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs
new file mode 100644
index 0000000..8ee0cef
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Inventory.Enums;
+
+///
+/// 批次扣减策略。
+///
+public enum InventoryBatchConsumeStrategy
+{
+ ///
+ /// 先进先出。
+ ///
+ Fifo = 0,
+
+ ///
+ /// 先到期先出。
+ ///
+ Fefo = 1
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs
new file mode 100644
index 0000000..a2be4a6
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Inventory.Enums;
+
+///
+/// 库存锁定状态。
+///
+public enum InventoryLockStatus
+{
+ ///
+ /// 已锁定。
+ ///
+ Locked = 0,
+
+ ///
+ /// 已释放。
+ ///
+ Released = 1,
+
+ ///
+ /// 已扣减。
+ ///
+ Deducted = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs
new file mode 100644
index 0000000..bba4734
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs
@@ -0,0 +1,95 @@
+using System;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Domain.Inventory.Entities;
+using TakeoutSaaS.Domain.Inventory.Enums;
+
+namespace TakeoutSaaS.Domain.Inventory.Repositories;
+
+///
+/// 库存仓储契约。
+///
+public interface IInventoryRepository
+{
+ ///
+ /// 依据标识查询库存。
+ ///
+ Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按门店与 SKU 查询库存(只读)。
+ ///
+ Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按门店与 SKU 查询库存(跟踪用于更新)。
+ ///
+ Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增库存记录。
+ ///
+ Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新库存记录。
+ ///
+ Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增库存调整记录。
+ ///
+ Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增锁定记录。
+ ///
+ Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按幂等键查询锁记录。
+ ///
+ Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新锁状态。
+ ///
+ Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询过期锁定。
+ ///
+ Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询批次列表。
+ ///
+ Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 批次扣减读取(带排序策略)。
+ ///
+ Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询批次(跟踪用于更新)。
+ ///
+ Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增批次。
+ ///
+ Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新批次。
+ ///
+ Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default);
+
+ ///
+ /// 持久化变更。
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index ed6f4eb..7333909 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Domain.Deliveries.Repositories;
+using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Repositories;
@@ -45,6 +46,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddOptions()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index f2f6177..1630554 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -76,6 +76,7 @@ public sealed class TakeoutAppDbContext(
public DbSet InventoryItems => Set();
public DbSet InventoryAdjustments => Set();
public DbSet InventoryBatches => Set();
+ public DbSet InventoryLockRecords => Set();
public DbSet ShoppingCarts => Set();
public DbSet CartItems => Set();
@@ -170,6 +171,7 @@ public sealed class TakeoutAppDbContext(
ConfigureInventoryItem(modelBuilder.Entity());
ConfigureInventoryAdjustment(modelBuilder.Entity());
ConfigureInventoryBatch(modelBuilder.Entity());
+ ConfigureInventoryLockRecord(modelBuilder.Entity());
ConfigureShoppingCart(modelBuilder.Entity());
ConfigureCartItem(modelBuilder.Entity());
ConfigureCartItemAddon(modelBuilder.Entity());
@@ -703,6 +705,7 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64);
builder.Property(x => x.Location).HasMaxLength(64);
+ builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber });
}
@@ -723,9 +726,24 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique();
}
+ private static void ConfigureInventoryLockRecord(EntityTypeBuilder builder)
+ {
+ builder.ToTable("inventory_lock_records");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.ProductSkuId).IsRequired();
+ builder.Property(x => x.Quantity).IsRequired();
+ builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.Status).HasConversion();
+ builder.Property(x => x.RowVersion).IsRowVersion();
+ builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status });
+ }
+
private static void ConfigureShoppingCart(EntityTypeBuilder builder)
{
builder.ToTable("shopping_carts");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs
new file mode 100644
index 0000000..0cc6526
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs
@@ -0,0 +1,145 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Inventory.Entities;
+using TakeoutSaaS.Domain.Inventory.Enums;
+using TakeoutSaaS.Domain.Inventory.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 库存仓储 EF 实现。
+///
+///
+/// 提供库存与批次的读写能力。
+///
+public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository
+{
+ ///
+ public Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryItems
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && x.Id == inventoryItemId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryItems
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryItems
+ .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryItems.AddAsync(item, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
+ {
+ context.InventoryItems.Update(item);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryLockRecords
+ .Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default)
+ {
+ lockRecord.Status = status;
+ context.InventoryLockRecords.Update(lockRecord);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default)
+ {
+ var locks = await context.InventoryLockRecords
+ .Where(x => x.TenantId == tenantId && x.Status == InventoryLockStatus.Locked && x.ExpiresAt != null && x.ExpiresAt <= utcNow)
+ .ToListAsync(cancellationToken);
+ return locks;
+ }
+
+ ///
+ public async Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default)
+ {
+ var query = context.InventoryBatches
+ .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId);
+
+ query = strategy == InventoryBatchConsumeStrategy.Fefo
+ ? query.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue).ThenBy(x => x.BatchNumber)
+ : query.OrderBy(x => x.BatchNumber);
+
+ return await query.ToListAsync(cancellationToken);
+ }
+
+ ///
+ public async Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
+ {
+ var batches = await context.InventoryBatches
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
+ .OrderBy(x => x.ExpireDate ?? DateTime.MaxValue)
+ .ThenBy(x => x.BatchNumber)
+ .ToListAsync(cancellationToken);
+
+ return batches;
+ }
+
+ ///
+ public Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryBatches
+ .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId && x.BatchNumber == batchNumber)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
+ {
+ return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
+ {
+ context.InventoryBatches.Update(batch);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return context.SaveChangesAsync(cancellationToken);
+ }
+}