feat: 完善库存锁定幂等与批次扣减策略

This commit is contained in:
2025-12-04 11:31:26 +08:00
parent cd8862b223
commit 7e6125c687
35 changed files with 1670 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 库存管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/inventory")]
public sealed class InventoryController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 查询库存。
/// </summary>
[HttpGet("{productSkuId:long}")]
[PermissionAuthorize("inventory:read")]
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryItemDto>> Get(long storeId, long productSkuId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetInventoryItemQuery { StoreId = storeId, ProductSkuId = productSkuId }, cancellationToken);
return result is null
? ApiResponse<InventoryItemDto>.Error(ErrorCodes.NotFound, "库存不存在")
: ApiResponse<InventoryItemDto>.Ok(result);
}
/// <summary>
/// 调整库存(入库/盘点/报损)。
/// </summary>
[HttpPost("adjust")]
[PermissionAuthorize("inventory:adjust")]
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
}
/// <summary>
/// 锁定库存(下单占用)。
/// </summary>
[HttpPost("lock")]
[PermissionAuthorize("inventory:lock")]
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
}
/// <summary>
/// 释放库存(取消订单等)。
/// </summary>
[HttpPost("release")]
[PermissionAuthorize("inventory:release")]
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
}
/// <summary>
/// 扣减库存(支付或履约成功)。
/// </summary>
[HttpPost("deduct")]
[PermissionAuthorize("inventory:deduct")]
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
}
/// <summary>
/// 查询批次列表。
/// </summary>
[HttpGet("{productSkuId:long}/batches")]
[PermissionAuthorize("inventory:batch:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<InventoryBatchDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<InventoryBatchDto>>> GetBatches(long storeId, long productSkuId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetInventoryBatchesQuery
{
StoreId = storeId,
ProductSkuId = productSkuId
}, cancellationToken);
return ApiResponse<IReadOnlyList<InventoryBatchDto>>.Ok(result);
}
/// <summary>
/// 新增或更新批次。
/// </summary>
[HttpPost("{productSkuId:long}/batches")]
[PermissionAuthorize("inventory:batch:update")]
[ProducesResponseType(typeof(ApiResponse<InventoryBatchDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<InventoryBatchDto>> 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<InventoryBatchDto>.Ok(result);
}
/// <summary>
/// 释放过期锁定。
/// </summary>
[HttpPost("locks/expire")]
[PermissionAuthorize("inventory:release")]
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
public async Task<ApiResponse<int>> ReleaseExpiredLocks(CancellationToken cancellationToken)
{
var count = await mediator.Send(new ReleaseExpiredInventoryLocksCommand(), cancellationToken);
return ApiResponse<int>.Ok(count);
}
}

View File

@@ -218,6 +218,14 @@
"product-media:update", "product-media:update",
"product-pricing:read", "product-pricing:read",
"product-pricing:update", "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:create",
"order:read", "order:read",
"order:update", "order:update",
@@ -274,6 +282,14 @@
"product-media:update", "product-media:update",
"product-pricing:read", "product-pricing:read",
"product-pricing:update", "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:create",
"order:read", "order:read",
"order:update", "order:update",
@@ -374,6 +390,14 @@
"product-media:update", "product-media:update",
"product-pricing:read", "product-pricing:read",
"product-pricing:update", "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:create",
"order:read", "order:read",
"order:update", "order:update",

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
using TakeoutSaaS.Domain.Inventory.Enums;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 库存调整命令。
/// </summary>
public sealed record AdjustInventoryCommand : IRequest<InventoryItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
/// <summary>
/// 调整数量,正数入库,负数出库。
/// </summary>
public int QuantityDelta { get; init; }
/// <summary>
/// 调整类型。
/// </summary>
public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual;
/// <summary>
/// 原因说明。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 安全库存阈值(可选)。
/// </summary>
public int? SafetyStock { get; init; }
/// <summary>
/// 是否售罄标记。
/// </summary>
public bool? IsSoldOut { get; init; }
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 扣减库存命令(履约/支付成功)。
/// </summary>
public sealed record DeductInventoryCommand : IRequest<InventoryItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
/// <summary>
/// 扣减数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 是否预售锁定转扣减。
/// </summary>
public bool IsPresaleOrder { get; init; }
/// <summary>
/// 幂等键(与锁定请求一致可避免重复扣减)。
/// </summary>
public string? IdempotencyKey { get; init; }
}

View File

@@ -0,0 +1,41 @@
using System;
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 锁定库存命令。
/// </summary>
public sealed record LockInventoryCommand : IRequest<InventoryItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
/// <summary>
/// 锁定数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 是否按预售逻辑锁定。
/// </summary>
public bool IsPresaleOrder { get; init; }
/// <summary>
/// 锁定过期时间UTC超时可释放。
/// </summary>
public DateTime? ExpiresAt { get; init; }
/// <summary>
/// 幂等键(同一键重复调用返回同一结果)。
/// </summary>
public string IdempotencyKey { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 释放过期库存锁定命令。
/// </summary>
public sealed record ReleaseExpiredInventoryLocksCommand : IRequest<int>;

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 释放库存命令。
/// </summary>
public sealed record ReleaseInventoryCommand : IRequest<InventoryItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
/// <summary>
/// 释放数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 是否预售锁定释放。
/// </summary>
public bool IsPresaleOrder { get; init; }
/// <summary>
/// 幂等键(与锁定请求一致可避免重复释放)。
/// </summary>
public string? IdempotencyKey { get; init; }
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Commands;
/// <summary>
/// 新增或更新库存批次命令。
/// </summary>
public sealed record UpsertInventoryBatchCommand : IRequest<InventoryBatchDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
/// <summary>
/// 批次号。
/// </summary>
public string BatchNumber { get; init; } = string.Empty;
/// <summary>
/// 生产日期。
/// </summary>
public DateTime? ProductionDate { get; init; }
/// <summary>
/// 过期日期。
/// </summary>
public DateTime? ExpireDate { get; init; }
/// <summary>
/// 入库数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 剩余数量。
/// </summary>
public int RemainingQuantity { get; init; }
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Inventory.Dto;
/// <summary>
/// 库存批次 DTO。
/// </summary>
public sealed record InventoryBatchDto
{
/// <summary>
/// 批次 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long ProductSkuId { get; init; }
/// <summary>
/// 批次号。
/// </summary>
public string BatchNumber { get; init; } = string.Empty;
/// <summary>
/// 生产日期。
/// </summary>
public DateTime? ProductionDate { get; init; }
/// <summary>
/// 过期日期。
/// </summary>
public DateTime? ExpireDate { get; init; }
/// <summary>
/// 入库数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 剩余数量。
/// </summary>
public int RemainingQuantity { get; init; }
}

View File

@@ -0,0 +1,99 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Inventory.Dto;
/// <summary>
/// 库存项 DTO。
/// </summary>
public sealed record InventoryItemDto
{
/// <summary>
/// 库存记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long ProductSkuId { get; init; }
/// <summary>
/// 批次号。
/// </summary>
public string? BatchNumber { get; init; }
/// <summary>
/// 可用库存。
/// </summary>
public int QuantityOnHand { get; init; }
/// <summary>
/// 已锁定库存。
/// </summary>
public int QuantityReserved { get; init; }
/// <summary>
/// 安全库存。
/// </summary>
public int? SafetyStock { get; init; }
/// <summary>
/// 储位。
/// </summary>
public string? Location { get; init; }
/// <summary>
/// 过期日期。
/// </summary>
public DateTime? ExpireDate { get; init; }
/// <summary>
/// 是否预售。
/// </summary>
public bool IsPresale { get; init; }
/// <summary>
/// 预售开始时间。
/// </summary>
public DateTime? PresaleStartTime { get; init; }
/// <summary>
/// 预售结束时间。
/// </summary>
public DateTime? PresaleEndTime { get; init; }
/// <summary>
/// 预售上限。
/// </summary>
public int? PresaleCapacity { get; init; }
/// <summary>
/// 已锁定预售量。
/// </summary>
public int PresaleLocked { get; init; }
/// <summary>
/// 限购数量。
/// </summary>
public int? MaxQuantityPerOrder { get; init; }
/// <summary>
/// 是否售罄。
/// </summary>
public bool IsSoldOut { get; init; }
}

View File

@@ -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;
/// <summary>
/// 库存调整处理器。
/// </summary>
public sealed class AdjustInventoryCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<AdjustInventoryCommandHandler> logger)
: IRequestHandler<AdjustInventoryCommand, InventoryItemDto>
{
/// <inheritdoc />
public async Task<InventoryItemDto> 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;
}
}

View File

@@ -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;
/// <summary>
/// 库存扣减处理器。
/// </summary>
public sealed class DeductInventoryCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<DeductInventoryCommandHandler> logger)
: IRequestHandler<DeductInventoryCommand, InventoryItemDto>
{
/// <inheritdoc />
public async Task<InventoryItemDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 库存批次查询处理器。
/// </summary>
public sealed class GetInventoryBatchesQueryHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetInventoryBatchesQuery, IReadOnlyList<InventoryBatchDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryBatchDto>> 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();
}
}

View File

@@ -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;
/// <summary>
/// 查询库存处理器。
/// </summary>
public sealed class GetInventoryItemQueryHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetInventoryItemQuery, InventoryItemDto?>
{
/// <inheritdoc />
public async Task<InventoryItemDto?> 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);
}
}

View File

@@ -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;
/// <summary>
/// 库存锁定处理器。
/// </summary>
public sealed class LockInventoryCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<LockInventoryCommandHandler> logger)
: IRequestHandler<LockInventoryCommand, InventoryItemDto>
{
/// <inheritdoc />
public async Task<InventoryItemDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 释放过期锁定处理器。
/// </summary>
public sealed class ReleaseExpiredInventoryLocksCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<ReleaseExpiredInventoryLocksCommandHandler> logger)
: IRequestHandler<ReleaseExpiredInventoryLocksCommand, int>
{
/// <inheritdoc />
public async Task<int> 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;
}
}

View File

@@ -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;
/// <summary>
/// 库存释放处理器。
/// </summary>
public sealed class ReleaseInventoryCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<ReleaseInventoryCommandHandler> logger)
: IRequestHandler<ReleaseInventoryCommand, InventoryItemDto>
{
/// <inheritdoc />
public async Task<InventoryItemDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 批次维护处理器。
/// </summary>
public sealed class UpsertInventoryBatchCommandHandler(
IInventoryRepository inventoryRepository,
ITenantProvider tenantProvider,
ILogger<UpsertInventoryBatchCommandHandler> logger)
: IRequestHandler<UpsertInventoryBatchCommand, InventoryBatchDto>
{
/// <inheritdoc />
public async Task<InventoryBatchDto> 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);
}
}

View File

@@ -0,0 +1,49 @@
using TakeoutSaaS.Application.App.Inventory.Dto;
using TakeoutSaaS.Domain.Inventory.Entities;
namespace TakeoutSaaS.Application.App.Inventory;
/// <summary>
/// 库存映射辅助。
/// </summary>
public static class InventoryMapping
{
/// <summary>
/// 映射库存 DTO。
/// </summary>
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
};
/// <summary>
/// 映射批次 DTO。
/// </summary>
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
};
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Queries;
/// <summary>
/// 查询库存批次列表。
/// </summary>
public sealed record GetInventoryBatchesQuery : IRequest<IReadOnlyList<InventoryBatchDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Inventory.Dto;
namespace TakeoutSaaS.Application.App.Inventory.Queries;
/// <summary>
/// 按门店与 SKU 查询库存。
/// </summary>
public sealed record GetInventoryItemQuery : IRequest<InventoryItemDto?>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; init; }
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Inventory.Commands;
namespace TakeoutSaaS.Application.App.Inventory.Validators;
/// <summary>
/// 库存调整命令验证器。
/// </summary>
public sealed class AdjustInventoryCommandValidator : AbstractValidator<AdjustInventoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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);
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Inventory.Commands;
namespace TakeoutSaaS.Application.App.Inventory.Validators;
/// <summary>
/// 扣减库存命令验证器。
/// </summary>
public sealed class DeductInventoryCommandValidator : AbstractValidator<DeductInventoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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);
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Inventory.Commands;
namespace TakeoutSaaS.Application.App.Inventory.Validators;
/// <summary>
/// 库存锁定命令验证器。
/// </summary>
public sealed class LockInventoryCommandValidator : AbstractValidator<LockInventoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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);
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Inventory.Commands;
namespace TakeoutSaaS.Application.App.Inventory.Validators;
/// <summary>
/// 释放库存命令验证器。
/// </summary>
public sealed class ReleaseInventoryCommandValidator : AbstractValidator<ReleaseInventoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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);
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Inventory.Commands;
namespace TakeoutSaaS.Application.App.Inventory.Validators;
/// <summary>
/// 批次维护命令验证器。
/// </summary>
public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator<UpsertInventoryBatchCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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);
}
}

View File

@@ -1,3 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Shared.Abstractions.Entities; using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Inventory.Entities; namespace TakeoutSaaS.Domain.Inventory.Entities;
@@ -41,4 +43,10 @@ public sealed class InventoryBatch : MultiTenantEntityBase
/// 剩余数量。 /// 剩余数量。
/// </summary> /// </summary>
public int RemainingQuantity { get; set; } public int RemainingQuantity { get; set; }
/// <summary>
/// 并发控制字段。
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
} }

View File

@@ -1,4 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Shared.Abstractions.Entities; using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Domain.Inventory.Enums;
namespace TakeoutSaaS.Domain.Inventory.Entities; namespace TakeoutSaaS.Domain.Inventory.Entities;
@@ -46,4 +49,50 @@ public sealed class InventoryItem : MultiTenantEntityBase
/// 过期日期。 /// 过期日期。
/// </summary> /// </summary>
public DateTime? ExpireDate { get; set; } public DateTime? ExpireDate { get; set; }
/// <summary>
/// 是否预售商品。
/// </summary>
public bool IsPresale { get; set; }
/// <summary>
/// 预售开始时间UTC
/// </summary>
public DateTime? PresaleStartTime { get; set; }
/// <summary>
/// 预售结束时间UTC
/// </summary>
public DateTime? PresaleEndTime { get; set; }
/// <summary>
/// 预售名额(上限)。
/// </summary>
public int? PresaleCapacity { get; set; }
/// <summary>
/// 当前预售已锁定数量。
/// </summary>
public int PresaleLocked { get; set; }
/// <summary>
/// 单品限购(覆盖商品级 MaxQuantityPerOrder
/// </summary>
public int? MaxQuantityPerOrder { get; set; }
/// <summary>
/// 是否标记售罄。
/// </summary>
public bool IsSoldOut { get; set; }
/// <summary>
/// 批次扣减策略。
/// </summary>
public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo;
/// <summary>
/// 并发控制字段。
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
} }

View File

@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Domain.Inventory.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Inventory.Entities;
/// <summary>
/// 库存锁定记录。
/// </summary>
public sealed class InventoryLockRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// SKU ID。
/// </summary>
public long ProductSkuId { get; set; }
/// <summary>
/// 锁定数量。
/// </summary>
public int Quantity { get; set; }
/// <summary>
/// 是否预售锁定。
/// </summary>
public bool IsPresale { get; set; }
/// <summary>
/// 幂等键。
/// </summary>
public string IdempotencyKey { get; set; } = string.Empty;
/// <summary>
/// 过期时间UTC
/// </summary>
public DateTime? ExpiresAt { get; set; }
/// <summary>
/// 锁定状态。
/// </summary>
public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked;
/// <summary>
/// 并发控制字段。
/// </summary>
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Inventory.Enums;
/// <summary>
/// 批次扣减策略。
/// </summary>
public enum InventoryBatchConsumeStrategy
{
/// <summary>
/// 先进先出。
/// </summary>
Fifo = 0,
/// <summary>
/// 先到期先出。
/// </summary>
Fefo = 1
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Inventory.Enums;
/// <summary>
/// 库存锁定状态。
/// </summary>
public enum InventoryLockStatus
{
/// <summary>
/// 已锁定。
/// </summary>
Locked = 0,
/// <summary>
/// 已释放。
/// </summary>
Released = 1,
/// <summary>
/// 已扣减。
/// </summary>
Deducted = 2
}

View File

@@ -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;
/// <summary>
/// 库存仓储契约。
/// </summary>
public interface IInventoryRepository
{
/// <summary>
/// 依据标识查询库存。
/// </summary>
Task<InventoryItem?> FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按门店与 SKU 查询库存(只读)。
/// </summary>
Task<InventoryItem?> FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
/// <summary>
/// 按门店与 SKU 查询库存(跟踪用于更新)。
/// </summary>
Task<InventoryItem?> GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增库存记录。
/// </summary>
Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// 更新库存记录。
/// </summary>
Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// 新增库存调整记录。
/// </summary>
Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default);
/// <summary>
/// 新增锁定记录。
/// </summary>
Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default);
/// <summary>
/// 按幂等键查询锁记录。
/// </summary>
Task<InventoryLockRecord?> FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>
/// 更新锁状态。
/// </summary>
Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default);
/// <summary>
/// 查询过期锁定。
/// </summary>
Task<IReadOnlyList<InventoryLockRecord>> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default);
/// <summary>
/// 查询批次列表。
/// </summary>
Task<IReadOnlyList<InventoryBatch>> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default);
/// <summary>
/// 批次扣减读取(带排序策略)。
/// </summary>
Task<IReadOnlyList<InventoryBatch>> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default);
/// <summary>
/// 查询批次(跟踪用于更新)。
/// </summary>
Task<InventoryBatch?> GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default);
/// <summary>
/// 新增批次。
/// </summary>
Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default);
/// <summary>
/// 更新批次。
/// </summary>
Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Domain.Deliveries.Repositories; using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Orders.Repositories; using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Domain.Payments.Repositories;
@@ -45,6 +46,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>(); services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>(); services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>(); services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddOptions<AppSeedOptions>() services.AddOptions<AppSeedOptions>()
.Bind(configuration.GetSection(AppSeedOptions.SectionName)) .Bind(configuration.GetSection(AppSeedOptions.SectionName))

View File

@@ -76,6 +76,7 @@ public sealed class TakeoutAppDbContext(
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>(); public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
public DbSet<InventoryAdjustment> InventoryAdjustments => Set<InventoryAdjustment>(); public DbSet<InventoryAdjustment> InventoryAdjustments => Set<InventoryAdjustment>();
public DbSet<InventoryBatch> InventoryBatches => Set<InventoryBatch>(); public DbSet<InventoryBatch> InventoryBatches => Set<InventoryBatch>();
public DbSet<InventoryLockRecord> InventoryLockRecords => Set<InventoryLockRecord>();
public DbSet<ShoppingCart> ShoppingCarts => Set<ShoppingCart>(); public DbSet<ShoppingCart> ShoppingCarts => Set<ShoppingCart>();
public DbSet<CartItem> CartItems => Set<CartItem>(); public DbSet<CartItem> CartItems => Set<CartItem>();
@@ -170,6 +171,7 @@ public sealed class TakeoutAppDbContext(
ConfigureInventoryItem(modelBuilder.Entity<InventoryItem>()); ConfigureInventoryItem(modelBuilder.Entity<InventoryItem>());
ConfigureInventoryAdjustment(modelBuilder.Entity<InventoryAdjustment>()); ConfigureInventoryAdjustment(modelBuilder.Entity<InventoryAdjustment>());
ConfigureInventoryBatch(modelBuilder.Entity<InventoryBatch>()); ConfigureInventoryBatch(modelBuilder.Entity<InventoryBatch>());
ConfigureInventoryLockRecord(modelBuilder.Entity<InventoryLockRecord>());
ConfigureShoppingCart(modelBuilder.Entity<ShoppingCart>()); ConfigureShoppingCart(modelBuilder.Entity<ShoppingCart>());
ConfigureCartItem(modelBuilder.Entity<CartItem>()); ConfigureCartItem(modelBuilder.Entity<CartItem>());
ConfigureCartItemAddon(modelBuilder.Entity<CartItemAddon>()); ConfigureCartItemAddon(modelBuilder.Entity<CartItemAddon>());
@@ -703,6 +705,7 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64); builder.Property(x => x.BatchNumber).HasMaxLength(64);
builder.Property(x => x.Location).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 }); 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.StoreId).IsRequired();
builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64).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(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique();
} }
private static void ConfigureInventoryLockRecord(EntityTypeBuilder<InventoryLockRecord> 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<int>();
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<ShoppingCart> builder) private static void ConfigureShoppingCart(EntityTypeBuilder<ShoppingCart> builder)
{ {
builder.ToTable("shopping_carts"); builder.ToTable("shopping_carts");

View File

@@ -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;
/// <summary>
/// 库存仓储 EF 实现。
/// </summary>
/// <remarks>
/// 提供库存与批次的读写能力。
/// </remarks>
public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository
{
/// <inheritdoc />
public Task<InventoryItem?> FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default)
{
return context.InventoryItems
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == inventoryItemId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<InventoryItem?> 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);
}
/// <inheritdoc />
public Task<InventoryItem?> 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);
}
/// <inheritdoc />
public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
{
return context.InventoryItems.AddAsync(item, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
{
context.InventoryItems.Update(item);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default)
{
return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default)
{
return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task<InventoryLockRecord?> FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default)
{
return context.InventoryLockRecords
.Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default)
{
lockRecord.Status = status;
context.InventoryLockRecords.Update(lockRecord);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryLockRecord>> 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;
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryBatch>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryBatch>> 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;
}
/// <inheritdoc />
public Task<InventoryBatch?> 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);
}
/// <inheritdoc />
public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
{
return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
{
context.InventoryBatches.Update(batch);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}