feat(product): add product list/detail/save/soldout/batch api support
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 47s

This commit is contained in:
2026-02-21 17:23:48 +08:00
parent d41f69045f
commit f7c2ae4bac
24 changed files with 10302 additions and 6 deletions

View File

@@ -64,6 +64,56 @@ public sealed class CreateProductCommand : IRequest<ProductDto>
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Draft;
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; set; } = ProductKind.Single;
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; set; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; set; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; set; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; set; }
/// <summary>
/// 沽清后剩余可售库存。
/// </summary>
public int? RemainStock { get; set; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; set; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; set; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; set; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; set; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 商品沽清命令。
/// </summary>
public sealed class SoldoutProductCommand : IRequest<ProductDto?>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode Mode { get; init; } = ProductSoldoutMode.Today;
/// <summary>
/// 剩余可售库存。
/// </summary>
public int RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 恢复时间(定时沽清)。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 是否同步平台。
/// </summary>
public bool SyncToPlatform { get; init; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
}

View File

@@ -69,6 +69,56 @@ public sealed record UpdateProductCommand : IRequest<ProductDto?>
/// </summary>
public ProductStatus Status { get; init; } = ProductStatus.Draft;
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; init; } = ProductKind.Single;
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; init; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; init; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 沽清后剩余可售库存。
/// </summary>
public int? RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; init; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; init; } = true;
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; init; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -78,6 +78,56 @@ public sealed class ProductDto
/// </summary>
public ProductStatus Status { get; init; }
/// <summary>
/// 商品类型。
/// </summary>
public ProductKind Kind { get; init; }
/// <summary>
/// 月销量。
/// </summary>
public int SalesMonthly { get; init; }
/// <summary>
/// 标签 JSON。
/// </summary>
public string? TagsJson { get; init; }
/// <summary>
/// 沽清模式。
/// </summary>
public ProductSoldoutMode? SoldoutMode { get; init; }
/// <summary>
/// 沽清恢复时间。
/// </summary>
public DateTime? RecoverAt { get; init; }
/// <summary>
/// 剩余可售库存。
/// </summary>
public int? RemainStock { get; init; }
/// <summary>
/// 沽清原因。
/// </summary>
public string? SoldoutReason { get; init; }
/// <summary>
/// 是否同步通知平台。
/// </summary>
public bool SyncToPlatform { get; init; }
/// <summary>
/// 是否通知店长。
/// </summary>
public bool NotifyManager { get; init; }
/// <summary>
/// 定时上架时间。
/// </summary>
public DateTime? TimedOnShelfAt { get; init; }
/// <summary>
/// 主图。
/// </summary>

View File

@@ -33,6 +33,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
StockQuantity = request.StockQuantity,
MaxQuantityPerOrder = request.MaxQuantityPerOrder,
Status = request.Status,
Kind = request.Kind,
SalesMonthly = request.SalesMonthly,
TagsJson = request.TagsJson?.Trim(),
SoldoutMode = request.SoldoutMode,
RecoverAt = request.RecoverAt,
RemainStock = request.RemainStock,
SoldoutReason = request.SoldoutReason?.Trim(),
SyncToPlatform = request.SyncToPlatform,
NotifyManager = request.NotifyManager,
TimedOnShelfAt = request.TimedOnShelfAt,
CoverImage = request.CoverImage?.Trim(),
GalleryImages = request.GalleryImages?.Trim(),
Description = request.Description?.Trim(),
@@ -66,6 +76,16 @@ public sealed class CreateProductCommandHandler(IProductRepository productReposi
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -39,17 +39,21 @@ public sealed class SearchProductPickerQueryHandler(
Name = product.Name,
Price = product.Price,
SpuCode = product.SpuCode,
Status = ToPickerStatus(product.Status)
Status = ToPickerStatus(product.Status, product.SoldoutMode.HasValue)
})
.ToList();
}
private static string ToPickerStatus(ProductStatus status)
private static string ToPickerStatus(ProductStatus status, bool isSoldout)
{
if (isSoldout)
{
return "sold_out";
}
return status switch
{
ProductStatus.OnSale => "on_sale",
ProductStatus.Archived => "sold_out",
_ => "off_shelf"
};
}

View File

@@ -22,16 +22,49 @@ public sealed class SearchProductsQueryHandler(
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken);
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
// 沽清查询需要包含 OffShelf 状态后再做二次过滤。
var repositoryStatus = request.IsSoldOut == true ? null : request.Status;
var products = await _productRepository.SearchAsync(
tenantId,
request.StoreId,
request.CategoryId,
repositoryStatus,
cancellationToken);
IEnumerable<Domain.Products.Entities.Product> filtered = products;
if (!string.IsNullOrWhiteSpace(request.Keyword))
{
var keyword = request.Keyword.Trim().ToLowerInvariant();
filtered = filtered.Where(item =>
item.Name.ToLower().Contains(keyword) ||
item.SpuCode.ToLower().Contains(keyword));
}
if (request.Kind.HasValue)
{
filtered = filtered.Where(item => item.Kind == request.Kind.Value);
}
if (request.IsSoldOut == true)
{
filtered = filtered.Where(item => item.SoldoutMode.HasValue);
}
else if (request.Status == Domain.Products.Enums.ProductStatus.OffShelf)
{
filtered = filtered.Where(item => !item.SoldoutMode.HasValue);
}
var filteredList = filtered.ToList();
var sorted = ApplySorting(filteredList, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var items = paged.Select(MapToDto).ToList();
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, products.Count);
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, filteredList.Count);
}
private static IOrderedEnumerable<Domain.Products.Entities.Product> ApplySorting(
@@ -63,6 +96,16 @@ public sealed class SearchProductsQueryHandler(
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 商品沽清命令处理器。
/// </summary>
public sealed class SoldoutProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<SoldoutProductCommandHandler> logger)
: IRequestHandler<SoldoutProductCommand, ProductDto?>
{
/// <inheritdoc />
public async Task<ProductDto?> Handle(SoldoutProductCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
return null;
}
product.Status = ProductStatus.OffShelf;
product.SoldoutMode = request.Mode;
product.RemainStock = Math.Max(0, request.RemainStock);
product.StockQuantity = Math.Max(0, request.RemainStock);
product.SoldoutReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
product.RecoverAt = request.Mode == ProductSoldoutMode.Timed ? request.RecoverAt : null;
product.SyncToPlatform = request.SyncToPlatform;
product.NotifyManager = request.NotifyManager;
await productRepository.UpdateProductAsync(product, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("商品沽清 {ProductId},模式 {Mode}", product.Id, request.Mode);
return ProductMapping.ToDto(product);
}
}

View File

@@ -44,6 +44,16 @@ public sealed class UpdateProductCommandHandler(
existing.StockQuantity = request.StockQuantity;
existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder;
existing.Status = request.Status;
existing.Kind = request.Kind;
existing.SalesMonthly = request.SalesMonthly;
existing.TagsJson = request.TagsJson?.Trim();
existing.SoldoutMode = request.SoldoutMode;
existing.RecoverAt = request.RecoverAt;
existing.RemainStock = request.RemainStock;
existing.SoldoutReason = request.SoldoutReason?.Trim();
existing.SyncToPlatform = request.SyncToPlatform;
existing.NotifyManager = request.NotifyManager;
existing.TimedOnShelfAt = request.TimedOnShelfAt;
existing.CoverImage = request.CoverImage?.Trim();
existing.GalleryImages = request.GalleryImages?.Trim();
existing.Description = request.Description?.Trim();
@@ -76,6 +86,16 @@ public sealed class UpdateProductCommandHandler(
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -28,6 +28,16 @@ public static class ProductMapping
StockQuantity = product.StockQuantity,
MaxQuantityPerOrder = product.MaxQuantityPerOrder,
Status = product.Status,
Kind = product.Kind,
SalesMonthly = product.SalesMonthly,
TagsJson = product.TagsJson,
SoldoutMode = product.SoldoutMode,
RecoverAt = product.RecoverAt,
RemainStock = product.RemainStock,
SoldoutReason = product.SoldoutReason,
SyncToPlatform = product.SyncToPlatform,
NotifyManager = product.NotifyManager,
TimedOnShelfAt = product.TimedOnShelfAt,
CoverImage = product.CoverImage,
GalleryImages = product.GalleryImages,
Description = product.Description,

View File

@@ -25,6 +25,21 @@ public sealed class SearchProductsQuery : IRequest<PagedResult<ProductDto>>
/// </summary>
public ProductStatus? Status { get; init; }
/// <summary>
/// 商品类型过滤。
/// </summary>
public ProductKind? Kind { get; init; }
/// <summary>
/// 关键字(名称/编码)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 是否仅沽清商品。
/// </summary>
public bool? IsSoldOut { get; init; }
/// <summary>
/// 页码。
/// </summary>

View File

@@ -23,6 +23,10 @@ public sealed class CreateProductCommandValidator : AbstractValidator<CreateProd
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.SalesMonthly).GreaterThanOrEqualTo(0);
RuleFor(x => x.TagsJson).MaximumLength(2000);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0).When(x => x.RemainStock.HasValue);
RuleFor(x => x.SoldoutReason).MaximumLength(256);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}

View File

@@ -16,5 +16,6 @@ public sealed class SearchProductsQueryValidator : AbstractValidator<SearchProdu
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
RuleFor(x => x.Keyword).MaximumLength(64);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Domain.Products.Enums;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 商品沽清命令验证器。
/// </summary>
public sealed class SoldoutProductCommandValidator : AbstractValidator<SoldoutProductCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SoldoutProductCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0);
RuleFor(x => x.Reason).MaximumLength(256);
RuleFor(x => x.RecoverAt)
.NotNull()
.When(x => x.Mode == ProductSoldoutMode.Timed);
}
}

View File

@@ -24,6 +24,10 @@ public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProd
RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue);
RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue);
RuleFor(x => x.SalesMonthly).GreaterThanOrEqualTo(0);
RuleFor(x => x.TagsJson).MaximumLength(2000);
RuleFor(x => x.RemainStock).GreaterThanOrEqualTo(0).When(x => x.RemainStock.HasValue);
RuleFor(x => x.SoldoutReason).MaximumLength(256);
RuleFor(x => x.CoverImage).MaximumLength(256);
RuleFor(x => x.GalleryImages).MaximumLength(1024);
}