From 7e6125c6879002bf5f3c10a2634d02b73f605731 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:31:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E5=B9=82=E7=AD=89=E4=B8=8E=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E6=89=A3=E5=87=8F=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/InventoryController.cs | 149 ++++++++++++++++++ .../appsettings.Seed.Development.json | 24 +++ .../Commands/AdjustInventoryCommand.cs | 46 ++++++ .../Commands/DeductInventoryCommand.cs | 35 ++++ .../Commands/LockInventoryCommand.cs | 41 +++++ .../ReleaseExpiredInventoryLocksCommand.cs | 8 + .../Commands/ReleaseInventoryCommand.cs | 35 ++++ .../Commands/UpsertInventoryBatchCommand.cs | 45 ++++++ .../App/Inventory/Dto/InventoryBatchDto.cs | 53 +++++++ .../App/Inventory/Dto/InventoryItemDto.cs | 99 ++++++++++++ .../Handlers/AdjustInventoryCommandHandler.cs | 85 ++++++++++ .../Handlers/DeductInventoryCommandHandler.cs | 110 +++++++++++++ .../GetInventoryBatchesQueryHandler.cs | 26 +++ .../Handlers/GetInventoryItemQueryHandler.cs | 26 +++ .../Handlers/LockInventoryCommandHandler.cs | 92 +++++++++++ ...easeExpiredInventoryLocksCommandHandler.cs | 60 +++++++ .../ReleaseInventoryCommandHandler.cs | 76 +++++++++ .../UpsertInventoryBatchCommandHandler.cs | 57 +++++++ .../App/Inventory/InventoryMapping.cs | 49 ++++++ .../Queries/GetInventoryBatchesQuery.cs | 20 +++ .../Queries/GetInventoryItemQuery.cs | 20 +++ .../AdjustInventoryCommandValidator.cs | 21 +++ .../DeductInventoryCommandValidator.cs | 21 +++ .../LockInventoryCommandValidator.cs | 21 +++ .../ReleaseInventoryCommandValidator.cs | 21 +++ .../UpsertInventoryBatchCommandValidator.cs | 22 +++ .../Inventory/Entities/InventoryBatch.cs | 8 + .../Inventory/Entities/InventoryItem.cs | 49 ++++++ .../Inventory/Entities/InventoryLockRecord.cs | 52 ++++++ .../Enums/InventoryBatchConsumeStrategy.cs | 17 ++ .../Inventory/Enums/InventoryLockStatus.cs | 22 +++ .../Repositories/IInventoryRepository.cs | 95 +++++++++++ .../AppServiceCollectionExtensions.cs | 2 + .../App/Persistence/TakeoutAppDbContext.cs | 18 +++ .../App/Repositories/EfInventoryRepository.cs | 145 +++++++++++++++++ 35 files changed, 1670 insertions(+) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs 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); + } +}