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-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",
|
||||||
|
|||||||
@@ -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;
|
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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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