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,330 @@
namespace TakeoutSaaS.TenantApi.Contracts.Product;
/// <summary>
/// 分类列表查询请求。
/// </summary>
public sealed class ProductCategoryListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 分类管理列表查询请求。
/// </summary>
public sealed class ProductCategoryManageListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string? Status { get; set; }
}
/// <summary>
/// 保存分类请求。
/// </summary>
public sealed class SaveProductCategoryRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID编辑时传
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 分类描述。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 图标地址。
/// </summary>
public string Icon { get; set; } = string.Empty;
/// <summary>
/// 渠道列表。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 删除分类请求。
/// </summary>
public sealed class DeleteProductCategoryRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
}
/// <summary>
/// 变更分类状态请求。
/// </summary>
public sealed class ChangeProductCategoryStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 分类排序请求项。
/// </summary>
public sealed class ProductCategorySortItemRequest
{
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 排序值。
/// </summary>
public int Sort { get; set; }
}
/// <summary>
/// 分类排序请求。
/// </summary>
public sealed class SortProductCategoryRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 排序项。
/// </summary>
public List<ProductCategorySortItemRequest> Items { get; set; } = [];
}
/// <summary>
/// 分类绑定商品请求。
/// </summary>
public sealed class BindCategoryProductsRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID 列表。
/// </summary>
public List<string> ProductIds { get; set; } = [];
}
/// <summary>
/// 分类解绑商品请求。
/// </summary>
public sealed class UnbindCategoryProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 商品 ID。
/// </summary>
public string ProductId { get; set; } = string.Empty;
}
/// <summary>
/// 商品选择器查询请求。
/// </summary>
public sealed class ProductPickerListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string? CategoryId { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 返回数量上限。
/// </summary>
public int? Limit { get; set; }
}
/// <summary>
/// 分类列表项响应。
/// </summary>
public class ProductCategoryListItemResponse
{
/// <summary>
/// 分类 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 商品数。
/// </summary>
public int ProductCount { get; set; }
/// <summary>
/// 排序。
/// </summary>
public int Sort { get; set; }
}
/// <summary>
/// 分类管理项响应。
/// </summary>
public sealed class ProductCategoryManageItemResponse : ProductCategoryListItemResponse
{
/// <summary>
/// 分类描述。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 图标地址。
/// </summary>
public string Icon { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 渠道列表。
/// </summary>
public List<string> Channels { get; set; } = [];
}
/// <summary>
/// 分类绑定结果响应。
/// </summary>
public sealed class ProductBindResultResponse
{
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 成功条数。
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailedCount { get; set; }
}
/// <summary>
/// 商品选择器项响应。
/// </summary>
public sealed class ProductPickerItemResponse
{
/// <summary>
/// 商品 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 分类 ID。
/// </summary>
public string CategoryId { get; set; } = string.Empty;
/// <summary>
/// 分类名称。
/// </summary>
public string CategoryName { get; set; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// SPU 编码。
/// </summary>
public string SpuCode { get; set; } = string.Empty;
/// <summary>
/// 状态on_sale/off_shelf/sold_out
/// </summary>
public string Status { get; set; } = "off_shelf";
}

View File

@@ -0,0 +1,282 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Product;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 租户端分类管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/product")]
public sealed class ProductCategoryController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
/// <summary>
/// 分类列表(侧栏)。
/// </summary>
[HttpGet("category/list")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>> GetCategoryList(
[FromQuery] ProductCategoryListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetProductCategoryListQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<IReadOnlyList<ProductCategoryListItemResponse>>.Ok(result.Select(MapCategoryListItem).ToList());
}
/// <summary>
/// 分类管理列表。
/// </summary>
[HttpGet("category/manage/list")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>> GetCategoryManageList(
[FromQuery] ProductCategoryManageListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetProductCategoryManageListQuery
{
StoreId = storeId,
Keyword = request.Keyword,
Status = request.Status
}, cancellationToken);
return ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>.Ok(result.Select(MapCategoryManageItem).ToList());
}
/// <summary>
/// 保存分类。
/// </summary>
[HttpPost("category/manage/save")]
[ProducesResponseType(typeof(ApiResponse<ProductCategoryManageItemResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductCategoryManageItemResponse>> SaveCategory(
[FromBody] SaveProductCategoryRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SaveProductCategoryCommand
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
Name = request.Name,
Description = request.Description,
Icon = request.Icon,
Channels = request.Channels,
Sort = request.Sort,
Status = request.Status
}, cancellationToken);
return ApiResponse<ProductCategoryManageItemResponse>.Ok(MapCategoryManageItem(result));
}
/// <summary>
/// 删除分类。
/// </summary>
[HttpPost("category/manage/delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteCategory(
[FromBody] DeleteProductCategoryRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeleteProductCategoryCommand
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 修改分类状态。
/// </summary>
[HttpPost("category/manage/status")]
[ProducesResponseType(typeof(ApiResponse<ProductCategoryManageItemResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductCategoryManageItemResponse>> ChangeCategoryStatus(
[FromBody] ChangeProductCategoryStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangeProductCategoryStatusCommand
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<ProductCategoryManageItemResponse>.Ok(MapCategoryManageItem(result));
}
/// <summary>
/// 分类排序。
/// </summary>
[HttpPost("category/manage/sort")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>> SortCategory(
[FromBody] SortProductCategoryRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SortProductCategoryCommand
{
StoreId = storeId,
Items = (request.Items ?? [])
.Select(item => new SortProductCategoryItem
{
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(item.CategoryId, nameof(item.CategoryId)),
Sort = item.Sort
})
.ToList()
}, cancellationToken);
return ApiResponse<IReadOnlyList<ProductCategoryManageItemResponse>>.Ok(result.Select(MapCategoryManageItem).ToList());
}
/// <summary>
/// 绑定分类商品。
/// </summary>
[HttpPost("category/manage/products/bind")]
[ProducesResponseType(typeof(ApiResponse<ProductBindResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductBindResultResponse>> BindCategoryProducts(
[FromBody] BindCategoryProductsRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new BindCategoryProductsCommand
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds)
}, cancellationToken);
return ApiResponse<ProductBindResultResponse>.Ok(new ProductBindResultResponse
{
TotalCount = result.TotalCount,
SuccessCount = result.SuccessCount,
FailedCount = result.FailedCount
});
}
/// <summary>
/// 解绑分类商品。
/// </summary>
[HttpPost("category/manage/products/unbind")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> UnbindCategoryProduct(
[FromBody] UnbindCategoryProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new UnbindCategoryProductCommand
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId)),
ProductId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 商品选择器。
/// </summary>
[HttpGet("picker/list")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductPickerItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductPickerItemResponse>>> PickerList(
[FromQuery] ProductPickerListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SearchProductPickerQuery
{
StoreId = storeId,
CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
Keyword = request.Keyword,
Limit = request.Limit ?? 200
}, cancellationToken);
return ApiResponse<IReadOnlyList<ProductPickerItemResponse>>.Ok(result.Select(MapPickerItem).ToList());
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static ProductCategoryListItemResponse MapCategoryListItem(ProductCategoryListItemDto source)
{
return new ProductCategoryListItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
ProductCount = source.ProductCount,
Sort = source.Sort
};
}
private static ProductCategoryManageItemResponse MapCategoryManageItem(ProductCategoryManageItemDto source)
{
return new ProductCategoryManageItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
ProductCount = source.ProductCount,
Sort = source.Sort,
Description = source.Description,
Icon = source.Icon,
Status = source.Status,
Channels = source.Channels.ToList()
};
}
private static ProductPickerItemResponse MapPickerItem(ProductPickerItemDto source)
{
return new ProductPickerItemResponse
{
Id = source.Id.ToString(),
CategoryId = source.CategoryId.ToString(),
CategoryName = source.CategoryName,
Name = source.Name,
Price = source.Price,
SpuCode = source.SpuCode,
Status = source.Status
};
}
}

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);
}
}

View File

@@ -31,4 +31,14 @@ public sealed class ProductCategory : MultiTenantEntityBase
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 分类图标。
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// 分类可见渠道 JSON。
/// </summary>
public string ChannelsJson { get; set; } = "[\"wm\"]";
}

View File

@@ -28,6 +28,36 @@ public interface IProductRepository
/// </summary>
Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default);
/// <summary>
/// 依据标识读取分类。
/// </summary>
Task<ProductCategory?> FindCategoryByIdAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 判断门店内分类名称是否已存在。
/// </summary>
Task<bool> ExistsCategoryNameAsync(long tenantId, long storeId, string name, long? excludeCategoryId = null, CancellationToken cancellationToken = default);
/// <summary>
/// 统计分类下商品数量。
/// </summary>
Task<Dictionary<long, int>> CountProductsByCategoryIdsAsync(long tenantId, long storeId, IReadOnlyCollection<long> categoryIds, CancellationToken cancellationToken = default);
/// <summary>
/// 查询商品选择器列表。
/// </summary>
Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default);
/// <summary>
/// 批量更新商品分类。
/// </summary>
Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default);
/// <summary>
/// 单商品换绑分类。
/// </summary>
Task<bool> MoveProductToCategoryAsync(long tenantId, long storeId, long fromCategoryId, long toCategoryId, long productId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取商品 SKU。
/// </summary>

View File

@@ -687,6 +687,8 @@ public sealed class TakeoutAppDbContext(
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Description).HasMaxLength(256);
builder.Property(x => x.Icon).HasMaxLength(512);
builder.Property(x => x.ChannelsJson).HasColumnType("text").HasDefaultValue("[\"wm\"]");
builder.HasIndex(x => new { x.TenantId, x.StoreId });
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Products.Entities;
@@ -87,6 +88,123 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return categories;
}
/// <inheritdoc />
public Task<ProductCategory?> FindCategoryByIdAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default)
{
return context.ProductCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == categoryId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsCategoryNameAsync(long tenantId, long storeId, string name, long? excludeCategoryId = null, CancellationToken cancellationToken = default)
{
var normalizedName = (name ?? string.Empty).Trim();
var normalizedLower = normalizedName.ToLowerInvariant();
var query = context.ProductCategories
.AsNoTracking()
.Where(x =>
x.TenantId == tenantId &&
x.StoreId == storeId &&
x.Name.ToLower() == normalizedLower);
if (excludeCategoryId.HasValue)
{
query = query.Where(x => x.Id != excludeCategoryId.Value);
}
return query.AnyAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<long, int>> CountProductsByCategoryIdsAsync(long tenantId, long storeId, IReadOnlyCollection<long> categoryIds, CancellationToken cancellationToken = default)
{
if (categoryIds.Count == 0)
{
return [];
}
return await context.Products
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && categoryIds.Contains(x.CategoryId))
.GroupBy(x => x.CategoryId)
.Select(group => new { CategoryId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default)
{
var query = context.Products
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
if (categoryId.HasValue)
{
query = query.Where(x => x.CategoryId == categoryId.Value);
}
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var loweredKeyword = normalizedKeyword.ToLowerInvariant();
query = query.Where(x =>
x.Name.ToLower().Contains(loweredKeyword) ||
x.SpuCode.ToLower().Contains(loweredKeyword));
}
var normalizedLimit = Math.Clamp(limit, 1, 500);
return await query
.OrderBy(x => x.Name)
.ThenBy(x => x.Id)
.Take(normalizedLimit)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return 0;
}
var products = await context.Products
.Where(x =>
x.TenantId == tenantId &&
x.StoreId == storeId &&
productIds.Contains(x.Id))
.ToListAsync(cancellationToken);
foreach (var product in products)
{
product.CategoryId = categoryId;
}
return products.Count;
}
/// <inheritdoc />
public async Task<bool> MoveProductToCategoryAsync(long tenantId, long storeId, long fromCategoryId, long toCategoryId, long productId, CancellationToken cancellationToken = default)
{
var product = await context.Products
.Where(x =>
x.TenantId == tenantId &&
x.StoreId == storeId &&
x.Id == productId &&
x.CategoryId == fromCategoryId)
.FirstOrDefaultAsync(cancellationToken);
if (product is null)
{
return false;
}
product.CategoryId = toCategoryId;
return true;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProductCategoryIconAndChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ChannelsJson",
table: "product_categories",
type: "text",
nullable: false,
defaultValue: "[\"wm\"]",
comment: "分类可见渠道 JSON。");
migrationBuilder.AddColumn<string>(
name: "Icon",
table: "product_categories",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "分类图标。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChannelsJson",
table: "product_categories");
migrationBuilder.DropColumn(
name: "Icon",
table: "product_categories");
}
}
}

View File

@@ -4351,6 +4351,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ChannelsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[\"wm\"]")
.HasComment("分类可见渠道 JSON。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
@@ -4372,6 +4379,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(256)")
.HasComment("分类描述。");
b.Property<string>("Icon")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("分类图标。");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasComment("是否启用。");
@@ -5862,7 +5874,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea")
.HasComment("并发控制字段。");
@@ -5949,7 +5960,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea")
.HasComment("并发控制字段。");