feat: 完善库存锁定幂等与批次扣减策略
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user