feat: 完善库存锁定幂等与批次扣减策略
This commit is contained in:
149
src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
Normal file
149
src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期库存锁定命令。
|
||||
/// </summary>
|
||||
public sealed record ReleaseExpiredInventoryLocksCommand : IRequest<int>;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// 剩余数量。
|
||||
/// </summary>
|
||||
public int RemainingQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
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>();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Inventory.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 批次扣减策略。
|
||||
/// </summary>
|
||||
public enum InventoryBatchConsumeStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// 先进先出。
|
||||
/// </summary>
|
||||
Fifo = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 先到期先出。
|
||||
/// </summary>
|
||||
Fefo = 1
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ITenantNotificationRepository, EfTenantNotificationRepository>();
|
||||
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
||||
|
||||
services.AddOptions<AppSeedOptions>()
|
||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||
|
||||
@@ -76,6 +76,7 @@ public sealed class TakeoutAppDbContext(
|
||||
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
|
||||
public DbSet<InventoryAdjustment> InventoryAdjustments => Set<InventoryAdjustment>();
|
||||
public DbSet<InventoryBatch> InventoryBatches => Set<InventoryBatch>();
|
||||
public DbSet<InventoryLockRecord> InventoryLockRecords => Set<InventoryLockRecord>();
|
||||
|
||||
public DbSet<ShoppingCart> ShoppingCarts => Set<ShoppingCart>();
|
||||
public DbSet<CartItem> CartItems => Set<CartItem>();
|
||||
@@ -170,6 +171,7 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureInventoryItem(modelBuilder.Entity<InventoryItem>());
|
||||
ConfigureInventoryAdjustment(modelBuilder.Entity<InventoryAdjustment>());
|
||||
ConfigureInventoryBatch(modelBuilder.Entity<InventoryBatch>());
|
||||
ConfigureInventoryLockRecord(modelBuilder.Entity<InventoryLockRecord>());
|
||||
ConfigureShoppingCart(modelBuilder.Entity<ShoppingCart>());
|
||||
ConfigureCartItem(modelBuilder.Entity<CartItem>());
|
||||
ConfigureCartItemAddon(modelBuilder.Entity<CartItemAddon>());
|
||||
@@ -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<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)
|
||||
{
|
||||
builder.ToTable("shopping_carts");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user