From 93bc072b8d57aa4984232e534bbdf303a745a209 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 21 Feb 2026 08:44:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8A=A0=E6=96=99?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Product/ProductAddonContracts.cs | 279 ++++++++++++++++++ .../Controllers/ProductAddonController.cs | 204 +++++++++++++ .../BindProductAddonGroupProductsCommand.cs | 25 ++ .../ChangeProductAddonGroupStatusCommand.cs | 25 ++ .../DeleteProductAddonGroupCommand.cs | 19 ++ .../Commands/SaveProductAddonGroupCommand.cs | 65 ++++ .../Commands/SaveProductAddonItemCommand.cs | 37 +++ .../Dto/ProductAddonTemplateItemDto.cs | 67 +++++ .../Dto/ProductAddonTemplateOptionDto.cs | 37 +++ ...ProductAddonGroupProductsCommandHandler.cs | 71 +++++ ...geProductAddonGroupStatusCommandHandler.cs | 52 ++++ .../DeleteProductAddonGroupCommandHandler.cs | 35 +++ .../GetProductAddonGroupListQueryHandler.cs | 75 +++++ .../SaveProductAddonGroupCommandHandler.cs | 241 +++++++++++++++ .../SaveProductSpecTemplateCommandHandler.cs | 13 + .../ProductAddonTemplateDtoFactory.cs | 55 ++++ .../Products/ProductAddonTemplateMapping.cs | 45 +++ .../Products/ProductSpecTemplateMapping.cs | 7 +- .../Queries/GetProductAddonGroupListQuery.cs | 25 ++ .../Products/Entities/ProductSpecTemplate.cs | 15 + .../Entities/ProductSpecTemplateOption.cs | 10 + .../Products/Enums/ProductSpecTemplateType.cs | 7 +- .../Repositories/IProductRepository.cs | 15 + .../App/Persistence/TakeoutAppDbContext.cs | 7 +- .../App/Repositories/EfProductRepository.cs | 57 +++- ...ExtendProductSpecTemplateForAddonGroups.cs | 99 +++++++ .../TakeoutAppDbContextModelSnapshot.cs | 24 +- 27 files changed, 1605 insertions(+), 6 deletions(-) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductAddonContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/ProductAddonController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/BindProductAddonGroupProductsCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ChangeProductAddonGroupStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductAddonGroupCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonGroupCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonItemCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateOptionDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/BindProductAddonGroupProductsCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ChangeProductAddonGroupStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductAddonGroupCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductAddonGroupListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductAddonGroupCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateDtoFactory.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductAddonGroupListQuery.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260221090000_ExtendProductSpecTemplateForAddonGroups.cs diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductAddonContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductAddonContracts.cs new file mode 100644 index 0000000..29f574c --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Product/ProductAddonContracts.cs @@ -0,0 +1,279 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Product; + +/// +/// 加料组列表查询请求。 +/// +public sealed class ProductAddonGroupListRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 状态(enabled/disabled)。 + /// + public string? Status { get; set; } +} + +/// +/// 保存加料组请求。 +/// +public sealed class SaveProductAddonGroupRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 加料组 ID(编辑时传)。 + /// + public string? Id { get; set; } + + /// + /// 加料组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 加料组描述。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 是否必选。 + /// + public bool Required { get; set; } + + /// + /// 最小可选数。 + /// + public int MinSelect { get; set; } + + /// + /// 最大可选数。 + /// + public int MaxSelect { get; set; } + + /// + /// 排序值。 + /// + public int Sort { get; set; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; + + /// + /// 关联商品 ID 列表。 + /// + public List ProductIds { get; set; } = []; + + /// + /// 加料项列表。 + /// + public List Items { get; set; } = []; +} + +/// +/// 保存加料项请求。 +/// +public sealed class SaveProductAddonItemRequest +{ + /// + /// 加料项 ID(编辑时传)。 + /// + public string? Id { get; set; } + + /// + /// 加料项名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 加价金额。 + /// + public decimal Price { get; set; } + + /// + /// 库存数量。 + /// + public int Stock { get; set; } = 999; + + /// + /// 排序值。 + /// + public int Sort { get; set; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; +} + +/// +/// 删除加料组请求。 +/// +public sealed class DeleteProductAddonGroupRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 加料组 ID。 + /// + public string GroupId { get; set; } = string.Empty; +} + +/// +/// 修改加料组状态请求。 +/// +public sealed class ChangeProductAddonGroupStatusRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 加料组 ID。 + /// + public string GroupId { get; set; } = string.Empty; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; +} + +/// +/// 绑定加料组商品请求。 +/// +public sealed class BindProductAddonGroupProductsRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 加料组 ID。 + /// + public string GroupId { get; set; } = string.Empty; + + /// + /// 商品 ID 列表。 + /// + public List ProductIds { get; set; } = []; +} + +/// +/// 加料项响应。 +/// +public sealed class ProductAddonItemResponse +{ + /// + /// 加料项 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 加料项名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 加价金额。 + /// + public decimal Price { get; set; } + + /// + /// 库存数量。 + /// + public int Stock { get; set; } + + /// + /// 排序值。 + /// + public int Sort { get; set; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; +} + +/// +/// 加料组响应。 +/// +public sealed class ProductAddonGroupItemResponse +{ + /// + /// 加料组 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 加料组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 描述。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 是否必选。 + /// + public bool Required { get; set; } + + /// + /// 最小可选数。 + /// + public int MinSelect { get; set; } + + /// + /// 最大可选数。 + /// + public int MaxSelect { get; set; } + + /// + /// 排序值。 + /// + public int Sort { get; set; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; + + /// + /// 关联商品数量。 + /// + public int ProductCount { get; set; } + + /// + /// 关联商品 ID 列表。 + /// + public List ProductIds { get; set; } = []; + + /// + /// 加料项列表。 + /// + public List Items { get; set; } = []; + + /// + /// 更新时间。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductAddonController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductAddonController.cs new file mode 100644 index 0000000..156cfb3 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/ProductAddonController.cs @@ -0,0 +1,204 @@ +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; + +/// +/// 租户端加料管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/product")] +public sealed class ProductAddonController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 加料组列表。 + /// + [HttpGet("addon/group/list")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetAddonGroupList( + [FromQuery] ProductAddonGroupListRequest request, + CancellationToken cancellationToken) + { + // 1. 校验门店访问权限。 + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + // 2. 查询并返回列表。 + var result = await mediator.Send(new GetProductAddonGroupListQuery + { + StoreId = storeId, + Keyword = request.Keyword, + Status = request.Status + }, cancellationToken); + + return ApiResponse>.Ok(result.Select(MapAddonGroupItem).ToList()); + } + + /// + /// 保存加料组。 + /// + [HttpPost("addon/group/save")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaveAddonGroup( + [FromBody] SaveProductAddonGroupRequest request, + CancellationToken cancellationToken) + { + // 1. 校验门店访问权限。 + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + // 2. 提交保存命令。 + var result = await mediator.Send(new SaveProductAddonGroupCommand + { + StoreId = storeId, + GroupId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id), + Name = request.Name, + Description = request.Description, + Required = request.Required, + MinSelect = request.MinSelect, + MaxSelect = request.MaxSelect, + Sort = request.Sort, + Status = request.Status, + ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds), + Items = (request.Items ?? []) + .Select(item => new SaveProductAddonItemCommand + { + Id = StoreApiHelpers.ParseSnowflakeOrNull(item.Id), + Name = item.Name, + Price = item.Price, + Stock = item.Stock, + Sort = item.Sort, + Status = item.Status + }) + .ToList() + }, cancellationToken); + + return ApiResponse.Ok(MapAddonGroupItem(result)); + } + + /// + /// 删除加料组。 + /// + [HttpPost("addon/group/delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteAddonGroup( + [FromBody] DeleteProductAddonGroupRequest request, + CancellationToken cancellationToken) + { + // 1. 校验门店访问权限。 + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + // 2. 提交删除命令。 + await mediator.Send(new DeleteProductAddonGroupCommand + { + StoreId = storeId, + GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId)) + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 修改加料组状态。 + /// + [HttpPost("addon/group/status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangeAddonGroupStatus( + [FromBody] ChangeProductAddonGroupStatusRequest request, + CancellationToken cancellationToken) + { + // 1. 校验门店访问权限。 + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + // 2. 提交状态命令并返回更新后的快照。 + var result = await mediator.Send(new ChangeProductAddonGroupStatusCommand + { + StoreId = storeId, + GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId)), + Status = request.Status + }, cancellationToken); + + return ApiResponse.Ok(MapAddonGroupItem(result)); + } + + /// + /// 绑定加料组商品。 + /// + [HttpPost("addon/group/products/bind")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> BindAddonGroupProducts( + [FromBody] BindProductAddonGroupProductsRequest request, + CancellationToken cancellationToken) + { + // 1. 校验门店访问权限。 + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + // 2. 提交绑定命令并返回更新后的快照。 + var result = await mediator.Send(new BindProductAddonGroupProductsCommand + { + StoreId = storeId, + GroupId = StoreApiHelpers.ParseRequiredSnowflake(request.GroupId, nameof(request.GroupId)), + ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds) + }, cancellationToken); + + return ApiResponse.Ok(MapAddonGroupItem(result)); + } + + private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken) + { + // 1. 读取当前租户上下文。 + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + + // 2. 校验门店是否属于当前租户商户。 + await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken); + } + + private static ProductAddonGroupItemResponse MapAddonGroupItem(ProductAddonTemplateItemDto source) + { + // 1. 映射加料项列表。 + var items = source.Items + .Select(item => new ProductAddonItemResponse + { + Id = item.Id.ToString(), + Name = item.Name, + Price = item.Price, + Stock = item.Stock, + Sort = item.Sort, + Status = item.Status + }) + .ToList(); + + // 2. 映射加料组响应。 + return new ProductAddonGroupItemResponse + { + Id = source.Id.ToString(), + Name = source.Name, + Description = source.Description, + Required = source.Required, + MinSelect = source.MinSelect, + MaxSelect = source.MaxSelect, + Sort = source.Sort, + Status = source.Status, + ProductCount = source.ProductCount, + ProductIds = source.ProductIds.Select(item => item.ToString()).ToList(), + Items = items, + UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss") + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/BindProductAddonGroupProductsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/BindProductAddonGroupProductsCommand.cs new file mode 100644 index 0000000..64e8f7a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/BindProductAddonGroupProductsCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 绑定加料组商品命令。 +/// +public sealed class BindProductAddonGroupProductsCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 加料组 ID。 + /// + public long GroupId { get; init; } + + /// + /// 商品 ID 列表。 + /// + public IReadOnlyList ProductIds { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ChangeProductAddonGroupStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ChangeProductAddonGroupStatusCommand.cs new file mode 100644 index 0000000..5bab957 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ChangeProductAddonGroupStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 修改加料组状态命令。 +/// +public sealed class ChangeProductAddonGroupStatusCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 加料组 ID。 + /// + public long GroupId { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductAddonGroupCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductAddonGroupCommand.cs new file mode 100644 index 0000000..c9416ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductAddonGroupCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 删除加料组命令。 +/// +public sealed class DeleteProductAddonGroupCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 加料组 ID。 + /// + public long GroupId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonGroupCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonGroupCommand.cs new file mode 100644 index 0000000..957ddc2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonGroupCommand.cs @@ -0,0 +1,65 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 保存加料组命令。 +/// +public sealed class SaveProductAddonGroupCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 加料组 ID(编辑时传)。 + /// + public long? GroupId { get; init; } + + /// + /// 加料组名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string Description { get; init; } = string.Empty; + + /// + /// 是否必选。 + /// + public bool Required { get; init; } + + /// + /// 最小可选数量。 + /// + public int MinSelect { get; init; } + + /// + /// 最大可选数量。 + /// + public int MaxSelect { get; init; } + + /// + /// 排序值。 + /// + public int Sort { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; + + /// + /// 关联商品 ID。 + /// + public IReadOnlyList ProductIds { get; init; } = []; + + /// + /// 加料项列表。 + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonItemCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonItemCommand.cs new file mode 100644 index 0000000..4985c5b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/SaveProductAddonItemCommand.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 保存加料项命令。 +/// +public sealed class SaveProductAddonItemCommand +{ + /// + /// 加料项 ID(编辑时可传)。 + /// + public long? Id { get; init; } + + /// + /// 加料项名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 加价金额。 + /// + public decimal Price { get; init; } + + /// + /// 库存数量。 + /// + public int Stock { get; init; } = 999; + + /// + /// 排序值。 + /// + public int Sort { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateItemDto.cs new file mode 100644 index 0000000..599b888 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateItemDto.cs @@ -0,0 +1,67 @@ +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料模板列表项 DTO。 +/// +public sealed class ProductAddonTemplateItemDto +{ + /// + /// 模板 ID。 + /// + public long Id { get; init; } + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string Description { get; init; } = string.Empty; + + /// + /// 是否必选。 + /// + public bool Required { get; init; } + + /// + /// 最小可选数。 + /// + public int MinSelect { get; init; } + + /// + /// 最大可选数。 + /// + public int MaxSelect { get; init; } + + /// + /// 排序值。 + /// + public int Sort { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; + + /// + /// 关联商品数量。 + /// + public int ProductCount { get; init; } + + /// + /// 关联商品 ID 列表。 + /// + public IReadOnlyList ProductIds { get; init; } = []; + + /// + /// 加料项列表。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateOptionDto.cs new file mode 100644 index 0000000..1608bd7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonTemplateOptionDto.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料模板选项 DTO。 +/// +public sealed class ProductAddonTemplateOptionDto +{ + /// + /// 选项 ID。 + /// + public long Id { get; init; } + + /// + /// 选项名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 加价金额。 + /// + public decimal Price { get; init; } + + /// + /// 库存数量。 + /// + public int Stock { get; init; } + + /// + /// 排序值。 + /// + public int Sort { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/BindProductAddonGroupProductsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/BindProductAddonGroupProductsCommandHandler.cs new file mode 100644 index 0000000..66b7ef2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/BindProductAddonGroupProductsCommandHandler.cs @@ -0,0 +1,71 @@ +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; + +/// +/// 绑定加料组商品命令处理器。 +/// +public sealed class BindProductAddonGroupProductsCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(BindProductAddonGroupProductsCommand request, CancellationToken cancellationToken) + { + // 1. 校验加料组是否存在且归属当前门店。 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await productRepository.FindAddonTemplateByIdAsync(request.GroupId, tenantId, cancellationToken); + if (existing is null || existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.NotFound, "加料组不存在"); + } + + // 2. 过滤有效商品并替换关联关系。 + var normalizedProductIds = request.ProductIds + .Where(item => item > 0) + .Distinct() + .ToList(); + var validProductIds = await productRepository.FilterExistingProductIdsAsync( + tenantId, + request.StoreId, + normalizedProductIds, + cancellationToken); + + await productRepository.RemoveSpecTemplateProductsAsync(existing.Id, tenantId, request.StoreId, cancellationToken); + + var relations = validProductIds + .Select(productId => new ProductSpecTemplateProduct + { + StoreId = request.StoreId, + TemplateId = existing.Id, + ProductId = productId + }) + .ToList(); + if (relations.Count > 0) + { + await productRepository.AddSpecTemplateProductsAsync(relations, cancellationToken); + } + + await productRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回最新快照。 + var options = await productRepository.GetSpecTemplateOptionsByTemplateIdsAsync([existing.Id], tenantId, cancellationToken); + var latestRelations = await productRepository.GetSpecTemplateProductsByTemplateIdsAsync([existing.Id], tenantId, request.StoreId, cancellationToken); + var optionsLookup = options + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).ToList()); + var productIdsLookup = latestRelations + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.Select(item => item.ProductId).ToList()); + + return ProductAddonTemplateDtoFactory.ToDto(existing, optionsLookup, productIdsLookup); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ChangeProductAddonGroupStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ChangeProductAddonGroupStatusCommandHandler.cs new file mode 100644 index 0000000..99eb11c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ChangeProductAddonGroupStatusCommandHandler.cs @@ -0,0 +1,52 @@ +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; + +/// +/// 修改加料组状态命令处理器。 +/// +public sealed class ChangeProductAddonGroupStatusCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(ChangeProductAddonGroupStatusCommand request, CancellationToken cancellationToken) + { + // 1. 解析状态并验证加料组归属。 + if (!ProductAddonTemplateMapping.TryParseStatus(request.Status, out var isEnabled)) + { + throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法"); + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await productRepository.FindAddonTemplateByIdAsync(request.GroupId, tenantId, cancellationToken); + if (existing is null || existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.NotFound, "加料组不存在"); + } + + // 2. 保存状态变更。 + existing.IsEnabled = isEnabled; + await productRepository.UpdateSpecTemplateAsync(existing, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回完整快照。 + var options = await productRepository.GetSpecTemplateOptionsByTemplateIdsAsync([existing.Id], tenantId, cancellationToken); + var relations = await productRepository.GetSpecTemplateProductsByTemplateIdsAsync([existing.Id], tenantId, request.StoreId, cancellationToken); + var optionsLookup = options + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).ToList()); + var productIdsLookup = relations + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.Select(item => item.ProductId).ToList()); + + return ProductAddonTemplateDtoFactory.ToDto(existing, optionsLookup, productIdsLookup); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductAddonGroupCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductAddonGroupCommandHandler.cs new file mode 100644 index 0000000..53f09ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductAddonGroupCommandHandler.cs @@ -0,0 +1,35 @@ +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; + +/// +/// 删除加料组命令处理器。 +/// +public sealed class DeleteProductAddonGroupCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(DeleteProductAddonGroupCommand request, CancellationToken cancellationToken) + { + // 1. 校验加料组是否存在且归属当前门店。 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await productRepository.FindAddonTemplateByIdAsync(request.GroupId, tenantId, cancellationToken); + if (existing is null || existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.NotFound, "加料组不存在"); + } + + // 2. 删除选项、关联商品和模板主记录。 + await productRepository.RemoveSpecTemplateOptionsAsync(existing.Id, tenantId, cancellationToken); + await productRepository.RemoveSpecTemplateProductsAsync(existing.Id, tenantId, request.StoreId, cancellationToken); + await productRepository.DeleteSpecTemplateAsync(existing.Id, tenantId, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductAddonGroupListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductAddonGroupListQueryHandler.cs new file mode 100644 index 0000000..f256c76 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductAddonGroupListQueryHandler.cs @@ -0,0 +1,75 @@ +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; + +/// +/// 加料组列表查询处理器。 +/// +public sealed class GetProductAddonGroupListQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(GetProductAddonGroupListQuery request, CancellationToken cancellationToken) + { + // 1. 读取门店下的加料模板。 + var tenantId = tenantProvider.GetCurrentTenantId(); + var templates = await productRepository.GetAddonTemplatesByStoreAsync(tenantId, request.StoreId, cancellationToken); + if (templates.Count == 0) + { + return []; + } + + // 2. 按状态与关键字过滤。 + IEnumerable filtered = templates; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + if (!ProductAddonTemplateMapping.TryParseStatus(request.Status, out var isEnabled)) + { + return []; + } + + filtered = filtered.Where(item => item.IsEnabled == isEnabled); + } + + var normalizedKeyword = request.Keyword?.Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(normalizedKeyword)) + { + filtered = filtered.Where(item => + item.Name.ToLower().Contains(normalizedKeyword) || + item.Description.ToLower().Contains(normalizedKeyword)); + } + + // 3. 批量读取选项与关联商品。 + var filteredList = filtered + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Id) + .ToList(); + if (filteredList.Count == 0) + { + return []; + } + + var templateIds = filteredList.Select(item => item.Id).ToList(); + var options = await productRepository.GetSpecTemplateOptionsByTemplateIdsAsync(templateIds, tenantId, cancellationToken); + var relations = await productRepository.GetSpecTemplateProductsByTemplateIdsAsync(templateIds, tenantId, request.StoreId, cancellationToken); + + // 4. 构建字典并映射 DTO。 + var optionsLookup = options + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).ToList()); + var productIdsLookup = relations + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.Select(item => item.ProductId).ToList()); + + return filteredList + .Select(template => ProductAddonTemplateDtoFactory.ToDto(template, optionsLookup, productIdsLookup)) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductAddonGroupCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductAddonGroupCommandHandler.cs new file mode 100644 index 0000000..f754b82 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductAddonGroupCommandHandler.cs @@ -0,0 +1,241 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; +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; + +/// +/// 保存加料组命令处理器。 +/// +public sealed class SaveProductAddonGroupCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(SaveProductAddonGroupCommand request, CancellationToken cancellationToken) + { + // 1. 校验基础输入并归一化参数。 + var tenantId = tenantProvider.GetCurrentTenantId(); + var normalizedName = request.Name.Trim(); + if (string.IsNullOrWhiteSpace(normalizedName)) + { + throw new BusinessException(ErrorCodes.BadRequest, "加料组名称不能为空"); + } + + if (!ProductAddonTemplateMapping.TryParseStatus(request.Status, out var isEnabled)) + { + throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法"); + } + + var normalizedDescription = request.Description.Trim(); + var normalizedMinSelect = Math.Max(0, request.MinSelect); + var normalizedMaxSelect = Math.Max(1, request.MaxSelect); + if (normalizedMaxSelect < normalizedMinSelect) + { + normalizedMaxSelect = normalizedMinSelect; + } + + if (request.Required && normalizedMinSelect == 0) + { + normalizedMinSelect = 1; + if (normalizedMaxSelect < 1) + { + normalizedMaxSelect = 1; + } + } + + var normalizedItems = NormalizeItems(request.Items); + if (normalizedItems.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "请至少添加一个加料项"); + } + + // 2. 校验同门店名称唯一。 + var isDuplicate = await productRepository.ExistsAddonTemplateNameAsync( + tenantId, + request.StoreId, + normalizedName, + request.GroupId, + cancellationToken); + if (isDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "加料组名称已存在"); + } + + // 3. 过滤有效商品并创建或更新模板主记录。 + var normalizedProductIds = request.ProductIds + .Where(id => id > 0) + .Distinct() + .ToList(); + var validProductIds = await productRepository.FilterExistingProductIdsAsync( + tenantId, + request.StoreId, + normalizedProductIds, + cancellationToken); + + ProductSpecTemplate template; + var isCreate = !request.GroupId.HasValue; + if (isCreate) + { + template = new ProductSpecTemplate + { + StoreId = request.StoreId, + Name = normalizedName, + Description = normalizedDescription, + TemplateType = ProductSpecTemplateType.Addon, + SelectionType = ProductAddonTemplateMapping.ToSelectionType(normalizedMaxSelect), + IsRequired = request.Required, + MinSelect = normalizedMinSelect, + MaxSelect = normalizedMaxSelect, + SortOrder = Math.Max(1, request.Sort), + IsEnabled = isEnabled + }; + + await productRepository.AddSpecTemplateAsync(template, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + } + else + { + var groupId = request.GroupId + ?? throw new BusinessException(ErrorCodes.BadRequest, "加料组标识不能为空"); + + var existing = await productRepository.FindAddonTemplateByIdAsync( + groupId, + tenantId, + cancellationToken); + if (existing is null || existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.NotFound, "加料组不存在"); + } + + template = existing; + template.Name = normalizedName; + template.Description = normalizedDescription; + template.SelectionType = ProductAddonTemplateMapping.ToSelectionType(normalizedMaxSelect); + template.IsRequired = request.Required; + template.MinSelect = normalizedMinSelect; + template.MaxSelect = normalizedMaxSelect; + template.SortOrder = Math.Max(1, request.Sort); + template.IsEnabled = isEnabled; + + await productRepository.UpdateSpecTemplateAsync(template, cancellationToken); + } + + // 4. 替换选项与关联商品。 + await productRepository.RemoveSpecTemplateOptionsAsync(template.Id, tenantId, cancellationToken); + await productRepository.RemoveSpecTemplateProductsAsync(template.Id, tenantId, request.StoreId, cancellationToken); + + var optionEntities = normalizedItems + .Select(item => new ProductSpecTemplateOption + { + TemplateId = template.Id, + Name = item.Name, + ExtraPrice = item.Price, + Stock = item.Stock, + IsEnabled = item.IsEnabled, + SortOrder = item.Sort + }) + .ToList(); + if (optionEntities.Count > 0) + { + await productRepository.AddSpecTemplateOptionsAsync(optionEntities, cancellationToken); + } + + var relationEntities = validProductIds + .Select(productId => new ProductSpecTemplateProduct + { + StoreId = request.StoreId, + TemplateId = template.Id, + ProductId = productId + }) + .ToList(); + if (relationEntities.Count > 0) + { + await productRepository.AddSpecTemplateProductsAsync(relationEntities, cancellationToken); + } + + await productRepository.SaveChangesAsync(cancellationToken); + + // 5. 回读并返回最新快照。 + return await LoadAddonTemplateSnapshotAsync(template, tenantId, request.StoreId, cancellationToken); + } + + private static IReadOnlyList NormalizeItems(IReadOnlyList? items) + { + var source = items ?? []; + var normalized = source + .Select((item, index) => + { + if (!ProductAddonTemplateMapping.TryParseStatus(item.Status, out var isEnabled)) + { + throw new BusinessException(ErrorCodes.BadRequest, "加料项状态不合法"); + } + + return new NormalizedAddonItem + { + Name = item.Name.Trim(), + Price = item.Price, + Stock = Math.Max(0, item.Stock), + Sort = item.Sort > 0 ? item.Sort : index + 1, + IsEnabled = isEnabled + }; + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .OrderBy(item => item.Sort) + .ToList(); + + var duplicate = normalized + .GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Any(group => group.Count() > 1); + if (duplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "加料项名称重复"); + } + + for (var index = 0; index < normalized.Count; index++) + { + normalized[index].Sort = index + 1; + } + + return normalized; + } + + private async Task LoadAddonTemplateSnapshotAsync( + ProductSpecTemplate template, + long tenantId, + long storeId, + CancellationToken cancellationToken) + { + var options = await productRepository.GetSpecTemplateOptionsByTemplateIdsAsync([template.Id], tenantId, cancellationToken); + var relations = await productRepository.GetSpecTemplateProductsByTemplateIdsAsync([template.Id], tenantId, storeId, cancellationToken); + + var optionsLookup = options + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).ToList()); + var productIdsLookup = relations + .GroupBy(x => x.TemplateId) + .ToDictionary(group => group.Key, group => group.Select(item => item.ProductId).ToList()); + + return ProductAddonTemplateDtoFactory.ToDto(template, optionsLookup, productIdsLookup); + } + + private sealed class NormalizedAddonItem + { + public bool IsEnabled { get; init; } + + public string Name { get; init; } = string.Empty; + + public decimal Price { get; init; } + + public int Sort { get; set; } + + public int Stock { get; init; } + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductSpecTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductSpecTemplateCommandHandler.cs index 107d6cd..02e5edf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductSpecTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SaveProductSpecTemplateCommandHandler.cs @@ -2,6 +2,7 @@ using MediatR; using TakeoutSaaS.Application.App.Products.Commands; using TakeoutSaaS.Application.App.Products.Dto; using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; using TakeoutSaaS.Domain.Products.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; @@ -49,6 +50,11 @@ public sealed class SaveProductSpecTemplateCommandHandler( throw new BusinessException(ErrorCodes.BadRequest, "请至少添加一个选项"); } + var normalizedMinSelect = request.IsRequired ? 1 : 0; + var normalizedMaxSelect = selectionType == AttributeSelectionType.Single + ? 1 + : Math.Max(1, normalizedValues.Count); + // 2. 校验同门店模板名称唯一。 var isDuplicate = await productRepository.ExistsSpecTemplateNameAsync( tenantId, @@ -80,9 +86,12 @@ public sealed class SaveProductSpecTemplateCommandHandler( { StoreId = request.StoreId, Name = normalizedName, + Description = string.Empty, TemplateType = templateType, SelectionType = selectionType, IsRequired = request.IsRequired, + MinSelect = normalizedMinSelect, + MaxSelect = normalizedMaxSelect, SortOrder = Math.Max(1, request.Sort), IsEnabled = isEnabled }; @@ -105,6 +114,8 @@ public sealed class SaveProductSpecTemplateCommandHandler( template.TemplateType = templateType; template.SelectionType = selectionType; template.IsRequired = request.IsRequired; + template.MinSelect = normalizedMinSelect; + template.MaxSelect = normalizedMaxSelect; template.SortOrder = Math.Max(1, request.Sort); template.IsEnabled = isEnabled; await productRepository.UpdateSpecTemplateAsync(template, cancellationToken); @@ -120,6 +131,8 @@ public sealed class SaveProductSpecTemplateCommandHandler( TemplateId = template.Id, Name = value.Name, ExtraPrice = value.ExtraPrice, + Stock = 999, + IsEnabled = true, SortOrder = value.Sort }) .ToList(); diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateDtoFactory.cs new file mode 100644 index 0000000..288c62e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateDtoFactory.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; + +namespace TakeoutSaaS.Application.App.Products; + +/// +/// 加料模板 DTO 映射工厂。 +/// +internal static class ProductAddonTemplateDtoFactory +{ + /// + /// 将模板实体映射为页面 DTO。 + /// + public static ProductAddonTemplateItemDto ToDto( + ProductSpecTemplate template, + IReadOnlyDictionary> optionsLookup, + IReadOnlyDictionary> productIdsLookup) + { + var options = optionsLookup.TryGetValue(template.Id, out var optionList) + ? optionList + : []; + + var productIds = productIdsLookup.TryGetValue(template.Id, out var ids) + ? ids + : []; + + return new ProductAddonTemplateItemDto + { + Id = template.Id, + Name = template.Name, + Description = template.Description, + Required = template.IsRequired, + MinSelect = template.MinSelect, + MaxSelect = template.MaxSelect, + Sort = template.SortOrder, + Status = ProductAddonTemplateMapping.ToStatusText(template.IsEnabled), + ProductCount = productIds.Count, + ProductIds = productIds, + Items = options + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .Select(option => new ProductAddonTemplateOptionDto + { + Id = option.Id, + Name = option.Name, + Price = option.ExtraPrice, + Stock = option.Stock, + Sort = option.SortOrder, + Status = ProductAddonTemplateMapping.ToStatusText(option.IsEnabled) + }) + .ToList(), + UpdatedAt = template.UpdatedAt ?? template.CreatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateMapping.cs new file mode 100644 index 0000000..94d4b04 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductAddonTemplateMapping.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Products.Enums; + +namespace TakeoutSaaS.Application.App.Products; + +/// +/// 加料模板映射辅助。 +/// +internal static class ProductAddonTemplateMapping +{ + /// + /// 解析状态字符串。 + /// + public static bool TryParseStatus(string? status, out bool isEnabled) + { + var normalized = status?.Trim().ToLowerInvariant(); + switch (normalized) + { + case "enabled": + isEnabled = true; + return true; + case "disabled": + isEnabled = false; + return true; + default: + isEnabled = true; + return false; + } + } + + /// + /// 状态转字符串。 + /// + public static string ToStatusText(bool isEnabled) + { + return isEnabled ? "enabled" : "disabled"; + } + + /// + /// 依据最大可选数推导选择类型。 + /// + public static AttributeSelectionType ToSelectionType(int maxSelect) + { + return maxSelect <= 1 ? AttributeSelectionType.Single : AttributeSelectionType.Multiple; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductSpecTemplateMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductSpecTemplateMapping.cs index 533ebae..1c5978f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/ProductSpecTemplateMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductSpecTemplateMapping.cs @@ -56,7 +56,12 @@ internal static class ProductSpecTemplateMapping /// public static string ToTemplateTypeText(ProductSpecTemplateType templateType) { - return templateType == ProductSpecTemplateType.Method ? "method" : "spec"; + return templateType switch + { + ProductSpecTemplateType.Method => "method", + ProductSpecTemplateType.Addon => "addon", + _ => "spec" + }; } /// diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductAddonGroupListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductAddonGroupListQuery.cs new file mode 100644 index 0000000..620ba4d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductAddonGroupListQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 查询加料组列表。 +/// +public sealed class GetProductAddonGroupListQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string? Status { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplate.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplate.cs index 2824583..f16aa2f 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplate.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplate.cs @@ -18,6 +18,11 @@ public sealed class ProductSpecTemplate : MultiTenantEntityBase /// public string Name { get; set; } = string.Empty; + /// + /// 模板描述。 + /// + public string Description { get; set; } = string.Empty; + /// /// 模板类型。 /// @@ -33,6 +38,16 @@ public sealed class ProductSpecTemplate : MultiTenantEntityBase /// public bool IsRequired { get; set; } = true; + /// + /// 最小可选数。 + /// + public int MinSelect { get; set; } + + /// + /// 最大可选数。 + /// + public int MaxSelect { get; set; } = 1; + /// /// 排序值。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplateOption.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplateOption.cs index 3abe65d..efcb1d0 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplateOption.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSpecTemplateOption.cs @@ -22,6 +22,16 @@ public sealed class ProductSpecTemplateOption : MultiTenantEntityBase /// public decimal ExtraPrice { get; set; } + /// + /// 库存数量。 + /// + public int Stock { get; set; } = 999; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + /// /// 排序值。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSpecTemplateType.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSpecTemplateType.cs index 0f8f5b9..528abdf 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSpecTemplateType.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductSpecTemplateType.cs @@ -13,5 +13,10 @@ public enum ProductSpecTemplateType /// /// 做法模板。 /// - Method = 1 + Method = 1, + + /// + /// 加料模板。 + /// + Addon = 2 } diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index ebdb9e5..9420ecb 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -48,16 +48,31 @@ public interface IProductRepository /// Task> GetSpecTemplatesByStoreAsync(long tenantId, long storeId, CancellationToken cancellationToken = default); + /// + /// 按门店读取加料模板。 + /// + Task> GetAddonTemplatesByStoreAsync(long tenantId, long storeId, CancellationToken cancellationToken = default); + /// /// 依据标识读取规格做法模板。 /// Task FindSpecTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识读取加料模板。 + /// + Task FindAddonTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default); + /// /// 判断门店内模板名称是否已存在。 /// Task ExistsSpecTemplateNameAsync(long tenantId, long storeId, string name, long? excludeTemplateId = null, CancellationToken cancellationToken = default); + /// + /// 判断门店内加料模板名称是否已存在。 + /// + Task ExistsAddonTemplateNameAsync(long tenantId, long storeId, string name, long? excludeTemplateId = null, CancellationToken cancellationToken = default); + /// /// 按模板读取规格做法选项。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 04bae8d..9ecd4c8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -1185,12 +1185,15 @@ public sealed class TakeoutAppDbContext( builder.HasKey(x => x.Id); builder.Property(x => x.StoreId).IsRequired(); builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256).IsRequired(); builder.Property(x => x.TemplateType).HasConversion(); builder.Property(x => x.SelectionType).HasConversion(); + builder.Property(x => x.MinSelect).IsRequired(); + builder.Property(x => x.MaxSelect).IsRequired(); builder.Property(x => x.SortOrder).IsRequired(); builder.Property(x => x.IsEnabled).IsRequired(); builder.Property(x => x.IsRequired).IsRequired(); - builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.TemplateType, x.Name }).IsUnique(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.TemplateType, x.IsEnabled }); } @@ -1201,6 +1204,8 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.TemplateId).IsRequired(); builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); builder.Property(x => x.ExtraPrice).HasPrecision(18, 2); + builder.Property(x => x.Stock).IsRequired(); + builder.Property(x => x.IsEnabled).IsRequired(); builder.Property(x => x.SortOrder).IsRequired(); builder.HasIndex(x => new { x.TenantId, x.TemplateId, x.Name }).IsUnique(); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 1086137..2620a86 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -138,7 +138,24 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR { return await context.ProductSpecTemplates .AsNoTracking() - .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .Where(x => + x.TenantId == tenantId && + x.StoreId == storeId && + x.TemplateType != ProductSpecTemplateType.Addon) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetAddonTemplatesByStoreAsync(long tenantId, long storeId, CancellationToken cancellationToken = default) + { + return await context.ProductSpecTemplates + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.StoreId == storeId && + x.TemplateType == ProductSpecTemplateType.Addon) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Id) .ToListAsync(cancellationToken); @@ -148,7 +165,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR public Task FindSpecTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default) { return context.ProductSpecTemplates - .Where(x => x.TenantId == tenantId && x.Id == templateId) + .Where(x => + x.TenantId == tenantId && + x.Id == templateId && + x.TemplateType != ProductSpecTemplateType.Addon) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindAddonTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default) + { + return context.ProductSpecTemplates + .Where(x => + x.TenantId == tenantId && + x.Id == templateId && + x.TemplateType == ProductSpecTemplateType.Addon) .FirstOrDefaultAsync(cancellationToken); } @@ -162,6 +193,28 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR .Where(x => x.TenantId == tenantId && x.StoreId == storeId && + x.TemplateType != ProductSpecTemplateType.Addon && + x.Name.ToLower() == normalizedLower); + + if (excludeTemplateId.HasValue) + { + query = query.Where(x => x.Id != excludeTemplateId.Value); + } + + return query.AnyAsync(cancellationToken); + } + + /// + public Task ExistsAddonTemplateNameAsync(long tenantId, long storeId, string name, long? excludeTemplateId = null, CancellationToken cancellationToken = default) + { + var normalizedName = (name ?? string.Empty).Trim(); + var normalizedLower = normalizedName.ToLowerInvariant(); + var query = context.ProductSpecTemplates + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.StoreId == storeId && + x.TemplateType == ProductSpecTemplateType.Addon && x.Name.ToLower() == normalizedLower); if (excludeTemplateId.HasValue) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260221090000_ExtendProductSpecTemplateForAddonGroups.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260221090000_ExtendProductSpecTemplateForAddonGroups.cs new file mode 100644 index 0000000..2aff01a --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260221090000_ExtendProductSpecTemplateForAddonGroups.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class ExtendProductSpecTemplateForAddonGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_product_spec_templates_TenantId_StoreId_Name", + table: "product_spec_templates"); + + migrationBuilder.AddColumn( + name: "Description", + table: "product_spec_templates", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: "", + comment: "模板描述。"); + + migrationBuilder.AddColumn( + name: "MaxSelect", + table: "product_spec_templates", + type: "integer", + nullable: false, + defaultValue: 1, + comment: "最大可选数。"); + + migrationBuilder.AddColumn( + name: "MinSelect", + table: "product_spec_templates", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "最小可选数。"); + + migrationBuilder.AddColumn( + name: "IsEnabled", + table: "product_spec_template_options", + type: "boolean", + nullable: false, + defaultValue: true, + comment: "是否启用。"); + + migrationBuilder.AddColumn( + name: "Stock", + table: "product_spec_template_options", + type: "integer", + nullable: false, + defaultValue: 999, + comment: "库存数量。"); + + migrationBuilder.CreateIndex( + name: "IX_product_spec_templates_TenantId_StoreId_TemplateType_Name", + table: "product_spec_templates", + columns: new[] { "TenantId", "StoreId", "TemplateType", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_product_spec_templates_TenantId_StoreId_TemplateType_Name", + table: "product_spec_templates"); + + migrationBuilder.DropColumn( + name: "Description", + table: "product_spec_templates"); + + migrationBuilder.DropColumn( + name: "MaxSelect", + table: "product_spec_templates"); + + migrationBuilder.DropColumn( + name: "MinSelect", + table: "product_spec_templates"); + + migrationBuilder.DropColumn( + name: "IsEnabled", + table: "product_spec_template_options"); + + migrationBuilder.DropColumn( + name: "Stock", + table: "product_spec_template_options"); + + migrationBuilder.CreateIndex( + name: "IX_product_spec_templates_TenantId_StoreId_Name", + table: "product_spec_templates", + columns: new[] { "TenantId", "StoreId", "Name" }, + unique: true); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs index 50d1754..4354413 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -4689,6 +4689,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("bigint") .HasComment("删除人用户标识(软删除),未删除时为 null。"); + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("模板描述。"); + b.Property("IsEnabled") .HasColumnType("boolean") .HasComment("是否启用。"); @@ -4697,12 +4703,20 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("boolean") .HasComment("是否必选。"); + b.Property("MaxSelect") + .HasColumnType("integer") + .HasComment("最大可选数。"); + b.Property("Name") .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasComment("模板名称。"); + b.Property("MinSelect") + .HasColumnType("integer") + .HasComment("最小可选数。"); + b.Property("SelectionType") .HasColumnType("integer") .HasComment("选择方式。"); @@ -4733,7 +4747,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations b.HasKey("Id"); - b.HasIndex("TenantId", "StoreId", "Name") + b.HasIndex("TenantId", "StoreId", "TemplateType", "Name") .IsUnique(); b.HasIndex("TenantId", "StoreId", "TemplateType", "IsEnabled"); @@ -4774,6 +4788,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("numeric(18,2)") .HasComment("附加价格。"); + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + b.Property("Name") .IsRequired() .HasMaxLength(64) @@ -4784,6 +4802,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("integer") .HasComment("排序值。"); + b.Property("Stock") + .HasColumnType("integer") + .HasComment("库存数量。"); + b.Property("TemplateId") .HasColumnType("bigint") .HasComment("模板 ID。");