feat: implement tenant product category management APIs
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s
This commit is contained in:
@@ -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";
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 包含非法值");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,14 @@ public sealed class ProductCategory : MultiTenantEntityBase
|
|||||||
/// 是否启用。
|
/// 是否启用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsEnabled { get; set; } = true;
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类图标。
|
||||||
|
/// </summary>
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类可见渠道 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelsJson { get; set; } = "[\"wm\"]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ public interface IProductRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// 获取商品 SKU。
|
/// 获取商品 SKU。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -687,6 +687,8 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.HasKey(x => x.Id);
|
builder.HasKey(x => x.Id);
|
||||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||||
builder.Property(x => x.Description).HasMaxLength(256);
|
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 });
|
builder.HasIndex(x => new { x.TenantId, x.StoreId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TakeoutSaaS.Domain.Products.Entities;
|
using TakeoutSaaS.Domain.Products.Entities;
|
||||||
@@ -87,6 +88,123 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
|||||||
return categories;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4351,6 +4351,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ChannelsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasDefaultValue("[\"wm\"]")
|
||||||
|
.HasComment("分类可见渠道 JSON。");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasComment("创建时间(UTC)。");
|
.HasComment("创建时间(UTC)。");
|
||||||
@@ -4372,6 +4379,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasComment("分类描述。");
|
.HasComment("分类描述。");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasComment("分类图标。");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasComment("是否启用。");
|
.HasComment("是否启用。");
|
||||||
@@ -5862,7 +5874,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
b.Property<byte[]>("RowVersion")
|
b.Property<byte[]>("RowVersion")
|
||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAddOrUpdate()
|
|
||||||
.HasColumnType("bytea")
|
.HasColumnType("bytea")
|
||||||
.HasComment("并发控制字段。");
|
.HasComment("并发控制字段。");
|
||||||
|
|
||||||
@@ -5949,7 +5960,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
b.Property<byte[]>("RowVersion")
|
b.Property<byte[]>("RowVersion")
|
||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAddOrUpdate()
|
|
||||||
.HasColumnType("bytea")
|
.HasColumnType("bytea")
|
||||||
.HasComment("并发控制字段。");
|
.HasComment("并发控制字段。");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user