feat: implement tenant product category management APIs
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s

This commit is contained in:
2026-02-20 18:45:48 +08:00
parent eea8a53da3
commit 1b3525862a
41 changed files with 9951 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 分类绑定商品命令。
/// </summary>
public sealed class BindCategoryProductsCommand : IRequest<ProductCategoryProductBindResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 商品 ID 列表。
/// </summary>
public IReadOnlyList<long> ProductIds { get; init; } = [];
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 变更分类状态命令。
/// </summary>
public sealed class ChangeProductCategoryStatusCommand : IRequest<ProductCategoryManageItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 删除分类命令。
/// </summary>
public sealed class DeleteProductCategoryCommand : IRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 保存分类命令。
/// </summary>
public sealed class SaveProductCategoryCommand : IRequest<ProductCategoryManageItemDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID编辑时传
/// </summary>
public long? CategoryId { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 分类描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 图标地址。
/// </summary>
public string Icon { get; init; } = string.Empty;
/// <summary>
/// 渠道列表。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 排序。
/// </summary>
public int Sort { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 分类排序项。
/// </summary>
public sealed class SortProductCategoryItem
{
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; init; }
}
/// <summary>
/// 分类排序命令。
/// </summary>
public sealed class SortProductCategoryCommand : IRequest<IReadOnlyList<ProductCategoryManageItemDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 排序项。
/// </summary>
public IReadOnlyList<SortProductCategoryItem> Items { get; init; } = [];
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 分类解绑商品命令。
/// </summary>
public sealed class UnbindCategoryProductCommand : IRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long CategoryId { get; init; }
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 分类列表项。
/// </summary>
public class ProductCategoryListItemDto
{
/// <summary>
/// 分类 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 商品数量。
/// </summary>
public int ProductCount { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 分类管理列表项。
/// </summary>
public sealed class ProductCategoryManageItemDto : ProductCategoryListItemDto
{
/// <summary>
/// 分类描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 图标地址。
/// </summary>
public string Icon { get; init; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 销售渠道。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 分类绑定商品结果。
/// </summary>
public sealed class ProductCategoryProductBindResultDto
{
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 成功条数。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailedCount { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 商品选择器项。
/// </summary>
public sealed class ProductPickerItemDto
{
/// <summary>
/// 商品 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long CategoryId { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; init; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; init; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; init; } = "off_shelf";
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类绑定商品命令处理器。
/// </summary>
public sealed class BindCategoryProductsCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<BindCategoryProductsCommand, ProductCategoryProductBindResultDto>
{
/// <inheritdoc />
public async Task<ProductCategoryProductBindResultDto> Handle(BindCategoryProductsCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var category = await productRepository.FindCategoryByIdAsync(request.CategoryId, tenantId, cancellationToken);
if (category is null || category.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "分类不存在");
}
var distinctProductIds = request.ProductIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (distinctProductIds.Count == 0)
{
return new ProductCategoryProductBindResultDto
{
TotalCount = 0,
SuccessCount = 0,
FailedCount = 0
};
}
var successCount = await productRepository.BatchUpdateProductCategoryAsync(
tenantId,
request.StoreId,
request.CategoryId,
distinctProductIds,
cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
return new ProductCategoryProductBindResultDto
{
TotalCount = distinctProductIds.Count,
SuccessCount = successCount,
FailedCount = Math.Max(0, distinctProductIds.Count - successCount)
};
}
}

View File

@@ -0,0 +1,56 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类状态变更处理器。
/// </summary>
public sealed class ChangeProductCategoryStatusCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeProductCategoryStatusCommand, ProductCategoryManageItemDto>
{
/// <inheritdoc />
public async Task<ProductCategoryManageItemDto> Handle(ChangeProductCategoryStatusCommand request, CancellationToken cancellationToken)
{
if (!ProductCategoryManageMapping.TryParseStatus(request.Status, out var isEnabled))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var category = await productRepository.FindCategoryByIdAsync(request.CategoryId, tenantId, cancellationToken);
if (category is null || category.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "分类不存在");
}
category.IsEnabled = isEnabled;
await productRepository.UpdateCategoryAsync(category, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
var productCounts = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.StoreId,
[category.Id],
cancellationToken);
return new ProductCategoryManageItemDto
{
Id = category.Id,
Name = category.Name,
Sort = category.SortOrder,
ProductCount = productCounts.GetValueOrDefault(category.Id),
Description = category.Description ?? string.Empty,
Icon = category.Icon ?? string.Empty,
Status = ProductCategoryManageMapping.ToStatusText(category.IsEnabled),
Channels = ProductCategoryManageMapping.DeserializeChannels(category.ChannelsJson)
};
}
}

View File

@@ -0,0 +1,47 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 删除分类命令处理器。
/// </summary>
public sealed class DeleteProductCategoryCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteProductCategoryCommand>
{
/// <inheritdoc />
public async Task Handle(DeleteProductCategoryCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var category = await productRepository.FindCategoryByIdAsync(request.CategoryId, tenantId, cancellationToken);
if (category is null || category.StoreId != request.StoreId)
{
return;
}
var categoryCounts = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.StoreId,
[request.CategoryId],
cancellationToken);
if (categoryCounts.GetValueOrDefault(request.CategoryId) > 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "分类下仍有商品,不能删除");
}
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
if (categories.Count <= 1)
{
throw new BusinessException(ErrorCodes.BadRequest, "至少保留一个分类");
}
await productRepository.DeleteCategoryAsync(request.CategoryId, tenantId, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类列表查询处理器。
/// </summary>
public sealed class GetProductCategoryListQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetProductCategoryListQuery, IReadOnlyList<ProductCategoryListItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategoryListItemDto>> Handle(GetProductCategoryListQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
if (categories.Count == 0)
{
return [];
}
var categoryIds = categories.Select(x => x.Id).ToList();
var productCounts = await productRepository.CountProductsByCategoryIdsAsync(tenantId, request.StoreId, categoryIds, cancellationToken);
return categories
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.Select(category => new ProductCategoryListItemDto
{
Id = category.Id,
Name = category.Name,
Sort = category.SortOrder,
ProductCount = productCounts.GetValueOrDefault(category.Id)
})
.ToList();
}
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类管理列表查询处理器。
/// </summary>
public sealed class GetProductCategoryManageListQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetProductCategoryManageListQuery, IReadOnlyList<ProductCategoryManageItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategoryManageItemDto>> Handle(GetProductCategoryManageListQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
if (categories.Count == 0)
{
return [];
}
IEnumerable<ProductCategory> filtered = categories;
if (ProductCategoryManageMapping.TryParseStatus(request.Status, out var parsedStatus))
{
filtered = filtered.Where(item => item.IsEnabled == parsedStatus);
}
var normalizedKeyword = request.Keyword?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
filtered = filtered.Where(item =>
item.Name.ToLower().Contains(normalizedKeyword) ||
(item.Description != null && item.Description.ToLower().Contains(normalizedKeyword)));
}
var filteredList = filtered
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToList();
if (filteredList.Count == 0)
{
return [];
}
var productCounts = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.StoreId,
filteredList.Select(item => item.Id).ToList(),
cancellationToken);
return filteredList
.Select(category => new ProductCategoryManageItemDto
{
Id = category.Id,
Name = category.Name,
Sort = category.SortOrder,
ProductCount = productCounts.GetValueOrDefault(category.Id),
Description = category.Description ?? string.Empty,
Icon = category.Icon ?? string.Empty,
Status = ProductCategoryManageMapping.ToStatusText(category.IsEnabled),
Channels = ProductCategoryManageMapping.DeserializeChannels(category.ChannelsJson)
})
.ToList();
}
}

View File

@@ -0,0 +1,100 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 保存分类命令处理器。
/// </summary>
public sealed class SaveProductCategoryCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveProductCategoryCommand, ProductCategoryManageItemDto>
{
/// <inheritdoc />
public async Task<ProductCategoryManageItemDto> Handle(SaveProductCategoryCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedName = request.Name.Trim();
var isDuplicate = await productRepository.ExistsCategoryNameAsync(
tenantId,
request.StoreId,
normalizedName,
request.CategoryId,
cancellationToken);
if (isDuplicate)
{
throw new BusinessException(ErrorCodes.Conflict, "分类名称已存在");
}
if (!ProductCategoryManageMapping.TryParseStatus(request.Status, out var isEnabled))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
var normalizedChannels = ProductCategoryManageMapping.NormalizeChannels(request.Channels);
if (normalizedChannels.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "请至少选择一个销售渠道");
}
ProductCategory category;
if (request.CategoryId.HasValue)
{
var existing = await productRepository.FindCategoryByIdAsync(request.CategoryId.Value, tenantId, cancellationToken);
if (existing is null || existing.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "分类不存在");
}
category = existing;
category.Name = normalizedName;
category.Description = request.Description.Trim();
category.Icon = request.Icon.Trim();
category.SortOrder = Math.Max(1, request.Sort);
category.IsEnabled = isEnabled;
category.ChannelsJson = ProductCategoryManageMapping.SerializeChannels(normalizedChannels);
await productRepository.UpdateCategoryAsync(category, cancellationToken);
}
else
{
category = new ProductCategory
{
StoreId = request.StoreId,
Name = normalizedName,
Description = request.Description.Trim(),
Icon = request.Icon.Trim(),
SortOrder = Math.Max(1, request.Sort),
IsEnabled = isEnabled,
ChannelsJson = ProductCategoryManageMapping.SerializeChannels(normalizedChannels)
};
await productRepository.AddCategoryAsync(category, cancellationToken);
}
await productRepository.SaveChangesAsync(cancellationToken);
var productCounts = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.StoreId,
[category.Id],
cancellationToken);
return new ProductCategoryManageItemDto
{
Id = category.Id,
Name = category.Name,
ProductCount = productCounts.GetValueOrDefault(category.Id),
Sort = category.SortOrder,
Description = category.Description ?? string.Empty,
Icon = category.Icon ?? string.Empty,
Status = ProductCategoryManageMapping.ToStatusText(category.IsEnabled),
Channels = ProductCategoryManageMapping.DeserializeChannels(category.ChannelsJson)
};
}
}

View File

@@ -0,0 +1,56 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
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 SearchProductPickerQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchProductPickerQuery, IReadOnlyList<ProductPickerItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPickerItemDto>> Handle(SearchProductPickerQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
var categoryNameLookup = categories.ToDictionary(category => category.Id, category => category.Name);
var products = await productRepository.SearchPickerAsync(
tenantId,
request.StoreId,
request.CategoryId,
request.Keyword,
request.Limit,
cancellationToken);
return products
.Select(product => new ProductPickerItemDto
{
Id = product.Id,
CategoryId = product.CategoryId,
CategoryName = categoryNameLookup.GetValueOrDefault(product.CategoryId, string.Empty),
Name = product.Name,
Price = product.Price,
SpuCode = product.SpuCode,
Status = ToPickerStatus(product.Status)
})
.ToList();
}
private static string ToPickerStatus(ProductStatus status)
{
return status switch
{
ProductStatus.OnSale => "on_sale",
ProductStatus.Archived => "sold_out",
_ => "off_shelf"
};
}
}

View File

@@ -0,0 +1,75 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类排序命令处理器。
/// </summary>
public sealed class SortProductCategoryCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SortProductCategoryCommand, IReadOnlyList<ProductCategoryManageItemDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategoryManageItemDto>> Handle(SortProductCategoryCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
if (categories.Count == 0)
{
return [];
}
var sortMap = request.Items
.Where(item => item.Sort > 0)
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Last().Sort);
var sorted = categories
.Select(category => new
{
Category = category,
Sort = sortMap.GetValueOrDefault(category.Id, category.SortOrder)
})
.OrderBy(item => item.Sort)
.ThenBy(item => item.Category.SortOrder)
.ThenBy(item => item.Category.Id)
.Select((item, index) =>
{
item.Category.SortOrder = index + 1;
return item.Category;
})
.ToList();
foreach (var category in sorted)
{
await productRepository.UpdateCategoryAsync(category, cancellationToken);
}
await productRepository.SaveChangesAsync(cancellationToken);
var productCounts = await productRepository.CountProductsByCategoryIdsAsync(
tenantId,
request.StoreId,
sorted.Select(item => item.Id).ToList(),
cancellationToken);
return sorted
.Select(category => new ProductCategoryManageItemDto
{
Id = category.Id,
Name = category.Name,
Sort = category.SortOrder,
ProductCount = productCounts.GetValueOrDefault(category.Id),
Description = category.Description ?? string.Empty,
Icon = category.Icon ?? string.Empty,
Status = ProductCategoryManageMapping.ToStatusText(category.IsEnabled),
Channels = ProductCategoryManageMapping.DeserializeChannels(category.ChannelsJson)
})
.ToList();
}
}

View File

@@ -0,0 +1,53 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Products.Handlers;
/// <summary>
/// 分类解绑商品命令处理器。
/// </summary>
public sealed class UnbindCategoryProductCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider)
: IRequestHandler<UnbindCategoryProductCommand>
{
/// <inheritdoc />
public async Task Handle(UnbindCategoryProductCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var category = await productRepository.FindCategoryByIdAsync(request.CategoryId, tenantId, cancellationToken);
if (category is null || category.StoreId != request.StoreId)
{
throw new BusinessException(ErrorCodes.NotFound, "分类不存在");
}
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, false, cancellationToken);
var fallbackCategory = categories
.Where(item => item.Id != request.CategoryId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.FirstOrDefault();
if (fallbackCategory is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "无可用目标分类");
}
var moved = await productRepository.MoveProductToCategoryAsync(
tenantId,
request.StoreId,
request.CategoryId,
fallbackCategory.Id,
request.ProductId,
cancellationToken);
if (!moved)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
await productRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,114 @@
using System.Text.Json;
namespace TakeoutSaaS.Application.App.Products;
/// <summary>
/// 分类管理映射辅助。
/// </summary>
internal static class ProductCategoryManageMapping
{
private static readonly string[] ChannelOrder = ["wm", "pickup", "dine_in"];
private static readonly HashSet<string> AllowedChannels = [.. ChannelOrder];
/// <summary>
/// 解析状态字符串。
/// </summary>
public static bool TryParseStatus(string? status, out bool isEnabled)
{
var normalized = NormalizeStatus(status);
if (normalized is null)
{
isEnabled = true;
return false;
}
isEnabled = normalized == "enabled";
return true;
}
/// <summary>
/// 状态转字符串。
/// </summary>
public static string ToStatusText(bool isEnabled)
{
return isEnabled ? "enabled" : "disabled";
}
/// <summary>
/// 渠道集合标准化。
/// </summary>
public static List<string> NormalizeChannels(IEnumerable<string>? channels)
{
var normalized = new HashSet<string>(
(channels ?? [])
.Select(channel => channel?.Trim().ToLowerInvariant() ?? string.Empty)
.Where(channel => AllowedChannels.Contains(channel)));
return ChannelOrder
.Where(normalized.Contains)
.ToList();
}
/// <summary>
/// 渠道集合序列化。
/// </summary>
public static string SerializeChannels(IEnumerable<string>? channels)
{
var normalized = NormalizeChannels(channels);
if (normalized.Count == 0)
{
normalized = ["wm"];
}
return JsonSerializer.Serialize(normalized);
}
/// <summary>
/// 渠道集合反序列化。
/// </summary>
public static List<string> DeserializeChannels(string? channelsJson)
{
if (string.IsNullOrWhiteSpace(channelsJson))
{
return ["wm"];
}
try
{
var parsed = JsonSerializer.Deserialize<List<string>>(channelsJson);
var normalized = NormalizeChannels(parsed);
return normalized.Count > 0 ? normalized : ["wm"];
}
catch
{
return ["wm"];
}
}
/// <summary>
/// 判断状态值是否合法。
/// </summary>
public static bool IsValidStatus(string? status)
{
return NormalizeStatus(status) is not null;
}
/// <summary>
/// 判断渠道值是否合法。
/// </summary>
public static bool IsValidChannel(string? channel)
{
if (string.IsNullOrWhiteSpace(channel))
{
return false;
}
return AllowedChannels.Contains(channel.Trim().ToLowerInvariant());
}
private static string? NormalizeStatus(string? status)
{
var normalized = status?.Trim().ToLowerInvariant();
return normalized is "enabled" or "disabled" ? normalized : null;
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 查询分类列表。
/// </summary>
public sealed class GetProductCategoryListQuery : IRequest<IReadOnlyList<ProductCategoryListItemDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 查询分类管理列表。
/// </summary>
public sealed class GetProductCategoryManageListQuery : IRequest<IReadOnlyList<ProductCategoryManageItemDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string? Status { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 查询商品选择器列表。
/// </summary>
public sealed class SearchProductPickerQuery : IRequest<IReadOnlyList<ProductPickerItemDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 分类 ID。
/// </summary>
public long? CategoryId { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 返回数量上限。
/// </summary>
public int Limit { get; init; } = 200;
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类绑定商品命令验证器。
/// </summary>
public sealed class BindCategoryProductsCommandValidator : AbstractValidator<BindCategoryProductsCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public BindCategoryProductsCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.ProductIds).NotEmpty();
RuleForEach(x => x.ProductIds).GreaterThan(0);
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类状态变更命令验证器。
/// </summary>
public sealed class ChangeProductCategoryStatusCommandValidator : AbstractValidator<ChangeProductCategoryStatusCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ChangeProductCategoryStatusCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.Status)
.Must(ProductCategoryManageMapping.IsValidStatus)
.WithMessage("status 必须为 enabled 或 disabled");
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 删除分类命令验证器。
/// </summary>
public sealed class DeleteProductCategoryCommandValidator : AbstractValidator<DeleteProductCategoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public DeleteProductCategoryCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Queries;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类列表查询验证器。
/// </summary>
public sealed class GetProductCategoryListQueryValidator : AbstractValidator<GetProductCategoryListQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetProductCategoryListQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Queries;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类管理列表查询验证器。
/// </summary>
public sealed class GetProductCategoryManageListQueryValidator : AbstractValidator<GetProductCategoryManageListQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetProductCategoryManageListQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Status)
.Must(status => string.IsNullOrWhiteSpace(status) || ProductCategoryManageMapping.IsValidStatus(status))
.WithMessage("status 必须为空或 enabled/disabled");
RuleFor(x => x.Keyword).MaximumLength(64);
}
}

View File

@@ -0,0 +1,33 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 保存分类命令验证器。
/// </summary>
public sealed class SaveProductCategoryCommandValidator : AbstractValidator<SaveProductCategoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SaveProductCategoryCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0).When(x => x.CategoryId.HasValue);
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Description).MaximumLength(256);
RuleFor(x => x.Icon).MaximumLength(512);
RuleFor(x => x.Sort).GreaterThan(0);
RuleFor(x => x.Status)
.Must(ProductCategoryManageMapping.IsValidStatus)
.WithMessage("status 必须为 enabled 或 disabled");
RuleFor(x => x.Channels)
.NotEmpty()
.WithMessage("channels 至少选择一个渠道");
RuleForEach(x => x.Channels)
.Must(ProductCategoryManageMapping.IsValidChannel)
.WithMessage("channels 包含非法值");
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Queries;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 商品选择器查询验证器。
/// </summary>
public sealed class SearchProductPickerQueryValidator : AbstractValidator<SearchProductPickerQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchProductPickerQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0).When(x => x.CategoryId.HasValue);
RuleFor(x => x.Keyword).MaximumLength(64);
RuleFor(x => x.Limit).InclusiveBetween(1, 500);
}
}

View File

@@ -0,0 +1,35 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类排序命令验证器。
/// </summary>
public sealed class SortProductCategoryCommandValidator : AbstractValidator<SortProductCategoryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SortProductCategoryCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Items).NotNull();
RuleForEach(x => x.Items).SetValidator(new SortProductCategoryItemValidator());
}
}
/// <summary>
/// 分类排序项验证器。
/// </summary>
public sealed class SortProductCategoryItemValidator : AbstractValidator<SortProductCategoryItem>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SortProductCategoryItemValidator()
{
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.Sort).GreaterThan(0);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 分类解绑商品命令验证器。
/// </summary>
public sealed class UnbindCategoryProductCommandValidator : AbstractValidator<UnbindCategoryProductCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UnbindCategoryProductCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.ProductId).GreaterThan(0);
}
}