feat: 菜品菜单查询与子资源替换完善

This commit is contained in:
2025-12-04 10:42:58 +08:00
parent de5f13ec83
commit b8d93337f2
25 changed files with 1133 additions and 10 deletions

View File

@@ -171,4 +171,89 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在") ? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
: ApiResponse<ProductDto>.Ok(result); : ApiResponse<ProductDto>.Ok(result);
} }
/// <summary>
/// 替换商品 SKU。
/// </summary>
[HttpPut("{productId:long}/skus")]
[PermissionAuthorize("product-sku:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductSkuDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductSkuDto>>> ReplaceSkus(long productId, [FromBody] ReplaceProductSkusCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<IReadOnlyList<ProductSkuDto>>.Ok(result);
}
/// <summary>
/// 替换商品规格。
/// </summary>
[HttpPut("{productId:long}/attributes")]
[PermissionAuthorize("product-attr:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>> ReplaceAttributes(long productId, [FromBody] ReplaceProductAttributesCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>.Ok(result);
}
/// <summary>
/// 替换商品加料。
/// </summary>
[HttpPut("{productId:long}/addons")]
[PermissionAuthorize("product-addon:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAddonGroupDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductAddonGroupDto>>> ReplaceAddons(long productId, [FromBody] ReplaceProductAddonsCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<IReadOnlyList<ProductAddonGroupDto>>.Ok(result);
}
/// <summary>
/// 替换商品媒资。
/// </summary>
[HttpPut("{productId:long}/media")]
[PermissionAuthorize("product-media:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductMediaAssetDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductMediaAssetDto>>> ReplaceMedia(long productId, [FromBody] ReplaceProductMediaCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<IReadOnlyList<ProductMediaAssetDto>>.Ok(result);
}
/// <summary>
/// 替换商品价格策略。
/// </summary>
[HttpPut("{productId:long}/pricing-rules")]
[PermissionAuthorize("product-pricing:update")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductPricingRuleDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<ProductPricingRuleDto>>> ReplacePricingRules(long productId, [FromBody] ReplaceProductPricingRulesCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<IReadOnlyList<ProductPricingRuleDto>>.Ok(result);
}
} }

View File

@@ -113,6 +113,16 @@
"product:update", "product:update",
"product:delete", "product:delete",
"product:publish", "product:publish",
"product-sku:read",
"product-sku:update",
"product-attr:read",
"product-attr:update",
"product-addon:read",
"product-addon:update",
"product-media:read",
"product-media:update",
"product-pricing:read",
"product-pricing:update",
"order:create", "order:create",
"order:read", "order:read",
"order:update", "order:update",
@@ -197,6 +207,17 @@
"product:read", "product:read",
"product:update", "product:update",
"product:delete", "product:delete",
"product:publish",
"product-sku:read",
"product-sku:update",
"product-attr:read",
"product-attr:update",
"product-addon:read",
"product-addon:update",
"product-media:read",
"product-media:update",
"product-pricing:read",
"product-pricing:update",
"order:create", "order:create",
"order:read", "order:read",
"order:update", "order:update",
@@ -242,6 +263,17 @@
"product:create", "product:create",
"product:read", "product:read",
"product:update", "product:update",
"product:publish",
"product-sku:read",
"product-sku:update",
"product-attr:read",
"product-attr:update",
"product-addon:read",
"product-addon:update",
"product-media:read",
"product-media:update",
"product-pricing:read",
"product-pricing:update",
"order:create", "order:create",
"order:read", "order:read",
"order:update", "order:update",
@@ -331,6 +363,17 @@
"product:read", "product:read",
"product:update", "product:update",
"product:delete", "product:delete",
"product:publish",
"product-sku:read",
"product-sku:update",
"product-attr:read",
"product-attr:update",
"product-addon:read",
"product-addon:update",
"product-media:read",
"product-media:update",
"product-pricing:read",
"product-pricing:update",
"order:create", "order:create",
"order:read", "order:read",
"order:update", "order:update",

View File

@@ -0,0 +1,38 @@
using System;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序端菜单查询。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")]
public sealed class MenusController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取门店菜单(含分类与商品详情)。
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<StoreMenuDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreMenuDto>> GetMenu(long storeId, [FromQuery] DateTime? updatedAfter, CancellationToken cancellationToken)
{
// 1. 组装查询
var query = new GetStoreMenuQuery
{
StoreId = storeId,
UpdatedAfter = updatedAfter
};
// 2. 拉取菜单
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<StoreMenuDto>.Ok(result);
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 替换商品加料命令。
/// </summary>
public sealed record ReplaceProductAddonsCommand : IRequest<IReadOnlyList<ProductAddonGroupDto>>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 加料组。
/// </summary>
public IReadOnlyList<ProductAddonGroupDto> AddonGroups { get; init; } = [];
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 替换商品规格命令。
/// </summary>
public sealed record ReplaceProductAttributesCommand : IRequest<IReadOnlyList<ProductAttributeGroupDto>>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 规格组。
/// </summary>
public IReadOnlyList<ProductAttributeGroupDto> AttributeGroups { get; init; } = [];
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 替换商品媒资命令。
/// </summary>
public sealed record ReplaceProductMediaCommand : IRequest<IReadOnlyList<ProductMediaAssetDto>>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 媒资列表。
/// </summary>
public IReadOnlyList<ProductMediaAssetDto> MediaAssets { get; init; } = [];
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 替换商品价格策略命令。
/// </summary>
public sealed record ReplaceProductPricingRulesCommand : IRequest<IReadOnlyList<ProductPricingRuleDto>>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 价格策略。
/// </summary>
public IReadOnlyList<ProductPricingRuleDto> PricingRules { get; init; } = [];
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Commands;
/// <summary>
/// 替换商品 SKU 命令。
/// </summary>
public sealed record ReplaceProductSkusCommand : IRequest<IReadOnlyList<ProductSkuDto>>
{
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// SKU 列表。
/// </summary>
public IReadOnlyList<ProductSkuDto> Skus { get; init; } = [];
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 门店菜单分类 DTO。
/// </summary>
public sealed record ProductCategoryMenuDto
{
/// <summary>
/// 分类 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 分类名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 分类描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; init; }
/// <summary>
/// 分类下商品列表。
/// </summary>
public IReadOnlyList<ProductDetailDto> Products { get; init; } = [];
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Products.Dto;
/// <summary>
/// 门店菜单数据传输对象。
/// </summary>
public sealed record StoreMenuDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 分类与商品集合。
/// </summary>
public IReadOnlyList<ProductCategoryMenuDto> Categories { get; init; } = [];
/// <summary>
/// 菜单生成时间UTC
/// </summary>
public DateTime GeneratedAt { get; init; }
/// <summary>
/// 客户端请求的增量时间UTC
/// </summary>
public DateTime? UpdatedAfter { get; init; }
}

View File

@@ -37,21 +37,25 @@ public sealed class GetProductDetailQueryHandler(
await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask); await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask);
// 3. 组装 DTO // 3. 组装 DTO
var attrOptions = attrOptionsTask.Result.ToLookup(x => x.AttributeGroupId); var skus = await skusTask;
var addonOptions = addonOptionsTask.Result.ToLookup(x => x.AddonGroupId); var attrGroups = await attrGroupsTask;
var attrOptions = (await attrOptionsTask).ToLookup(x => x.AttributeGroupId);
var addonGroups = await addonGroupsTask;
var addonOptions = (await addonOptionsTask).ToLookup(x => x.AddonGroupId);
var mediaAssets = await mediaTask;
var pricingRules = await pricingTask;
var detail = new ProductDetailDto var detail = new ProductDetailDto
{ {
Product = ProductMapping.ToDto(product), Product = ProductMapping.ToDto(product),
Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(), Skus = skus.Select(ProductMapping.ToDto).ToList(),
AttributeGroups = attrGroupsTask.Result AttributeGroups = attrGroups
.Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList()))
.ToList(), .ToList(),
AddonGroups = addonGroupsTask.Result AddonGroups = addonGroups
.Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList()))
.ToList(), .ToList(),
MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(), MediaAssets = mediaAssets.Select(ProductMapping.ToDto).ToList(),
PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList() PricingRules = pricingRules.Select(ProductMapping.ToDto).ToList()
}; };
return detail; return detail;

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.Entities;
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 GetStoreMenuQueryHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<GetStoreMenuQueryHandler> logger)
: IRequestHandler<GetStoreMenuQuery, StoreMenuDto>
{
/// <inheritdoc />
public async Task<StoreMenuDto> Handle(GetStoreMenuQuery request, CancellationToken cancellationToken)
{
// 1. 准备上下文
var tenantId = tenantProvider.GetCurrentTenantId();
var updatedAfterUtc = request.UpdatedAfter?.ToUniversalTime();
// 2. 获取分类
var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, true, cancellationToken);
// 3. 读取上架商品(支持增量)
var products = await productRepository.SearchAsync(tenantId, request.StoreId, null, ProductStatus.OnSale, cancellationToken, updatedAfterUtc);
if (products.Count == 0)
{
logger.LogInformation("门店 {StoreId} 没有上架商品,返回空菜单", request.StoreId);
return new StoreMenuDto
{
StoreId = request.StoreId,
GeneratedAt = DateTime.UtcNow,
UpdatedAfter = updatedAfterUtc,
Categories = categories
.OrderBy(x => x.SortOrder)
.Select(category => new ProductCategoryMenuDto
{
Id = category.Id,
StoreId = category.StoreId,
Name = category.Name,
Description = category.Description,
SortOrder = category.SortOrder,
IsEnabled = category.IsEnabled,
Products = []
})
.ToList()
};
}
// 4. 并发加载子表数据
var productIds = products.Select(x => x.Id).ToList();
var skusTask = productRepository.GetSkusByProductIdsAsync(productIds, tenantId, cancellationToken);
var attributeGroupsTask = productRepository.GetAttributeGroupsByProductIdsAsync(productIds, tenantId, cancellationToken);
var addonGroupsTask = productRepository.GetAddonGroupsByProductIdsAsync(productIds, tenantId, cancellationToken);
var mediaTask = productRepository.GetMediaAssetsByProductIdsAsync(productIds, tenantId, cancellationToken);
var pricingTask = productRepository.GetPricingRulesByProductIdsAsync(productIds, tenantId, cancellationToken);
await Task.WhenAll(skusTask, attributeGroupsTask, addonGroupsTask, mediaTask, pricingTask);
var attributeGroups = await attributeGroupsTask;
var addonGroups = await addonGroupsTask;
// 批量读取规格与加料选项
var attributeOptionsTask = attributeGroups.Count == 0
? Task.FromResult<IReadOnlyList<ProductAttributeOption>>(Array.Empty<ProductAttributeOption>())
: productRepository.GetAttributeOptionsByGroupIdsAsync(attributeGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken);
var addonOptionsTask = addonGroups.Count == 0
? Task.FromResult<IReadOnlyList<ProductAddonOption>>(Array.Empty<ProductAddonOption>())
: productRepository.GetAddonOptionsByGroupIdsAsync(addonGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken);
await Task.WhenAll(attributeOptionsTask, addonOptionsTask);
// 5. 建立查找表
var skuLookup = (await skusTask).ToLookup(x => x.ProductId);
var attrGroupLookup = attributeGroups.ToLookup(x => x.ProductId);
var attrOptionLookup = (await attributeOptionsTask).ToLookup(x => x.AttributeGroupId);
var addonGroupLookup = addonGroups.ToLookup(x => x.ProductId);
var addonOptionLookup = (await addonOptionsTask).ToLookup(x => x.AddonGroupId);
var mediaLookup = (await mediaTask).ToLookup(x => x.ProductId);
var pricingLookup = (await pricingTask).ToLookup(x => x.ProductId);
// 6. 组装商品详情
var productDetails = products.ToDictionary(
product => product.Id,
product =>
{
var attributeDtos = attrGroupLookup[product.Id]
.Select(group => ProductMapping.ToDto(group, attrOptionLookup[group.Id].ToList()))
.ToList();
var addonDtos = addonGroupLookup[product.Id]
.Select(group => ProductMapping.ToDto(group, addonOptionLookup[group.Id].ToList()))
.ToList();
return new ProductDetailDto
{
Product = ProductMapping.ToDto(product),
Skus = skuLookup[product.Id].Select(ProductMapping.ToDto).ToList(),
AttributeGroups = attributeDtos,
AddonGroups = addonDtos,
MediaAssets = mediaLookup[product.Id].Select(ProductMapping.ToDto).ToList(),
PricingRules = pricingLookup[product.Id].Select(ProductMapping.ToDto).ToList()
};
});
// 7. 组装分类菜单
var productsByCategory = products.ToLookup(x => x.CategoryId);
var categoryMenu = categories
.OrderBy(x => x.SortOrder)
.Select(category =>
{
var categoryProducts = productsByCategory[category.Id]
.Select(p => productDetails[p.Id])
.ToList();
return new ProductCategoryMenuDto
{
Id = category.Id,
StoreId = category.StoreId,
Name = category.Name,
Description = category.Description,
SortOrder = category.SortOrder,
IsEnabled = category.IsEnabled,
Products = categoryProducts
};
})
.ToList();
return new StoreMenuDto
{
StoreId = request.StoreId,
GeneratedAt = DateTime.UtcNow,
UpdatedAfter = updatedAfterUtc,
Categories = categoryMenu
};
}
}

View File

@@ -0,0 +1,74 @@
using MediatR;
using Microsoft.Extensions.Logging;
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 ReplaceProductAddonsCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductAddonsCommandHandler> logger)
: IRequestHandler<ReplaceProductAddonsCommand, IReadOnlyList<ProductAddonGroupDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroupDto>> Handle(ReplaceProductAddonsCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
// 2. 校验组名唯一
var names = request.AddonGroups.Select(x => x.Name.Trim()).ToList();
if (names.Count != names.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "加料组名称重复");
}
// 3. 替换
await productRepository.RemoveAddonGroupsAsync(request.ProductId, tenantId, cancellationToken);
// 重新插入组
var groupEntities = request.AddonGroups.Select(g => new ProductAddonGroup
{
ProductId = request.ProductId,
Name = g.Name.Trim(),
MinSelect = g.MinSelect,
MaxSelect = g.MaxSelect,
SortOrder = g.SortOrder
}).ToList();
await productRepository.AddAddonGroupsAsync(groupEntities, [], cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
// 重新建立组与请求的映射
var groupIdLookup = groupEntities.Zip(request.AddonGroups, (entity, dto) => (entity, dto))
.ToDictionary(x => x.dto, x => x.entity.Id);
// 构建选项实体
var optionEntities = request.AddonGroups
.SelectMany(dto => dto.Options.Select(o => new ProductAddonOption
{
AddonGroupId = groupIdLookup[dto],
Name = o.Name.Trim(),
ExtraPrice = o.ExtraPrice,
SortOrder = o.SortOrder
}))
.ToList();
await productRepository.AddAddonGroupsAsync([], optionEntities, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("替换商品 {ProductId} 加料组 {Count} 个", request.ProductId, groupEntities.Count);
return groupEntities
.Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AddonGroupId == g.Id).ToList()))
.ToList();
}
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using Microsoft.Extensions.Logging;
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 ReplaceProductAttributesCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductAttributesCommandHandler> logger)
: IRequestHandler<ReplaceProductAttributesCommand, IReadOnlyList<ProductAttributeGroupDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroupDto>> Handle(ReplaceProductAttributesCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
// 2. 组名唯一
var groupNames = request.AttributeGroups.Select(x => x.Name.Trim()).ToList();
if (groupNames.Count != groupNames.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "规格组名称重复");
}
// 3. 替换
await productRepository.RemoveAttributeGroupsAsync(request.ProductId, tenantId, cancellationToken);
var groupEntities = request.AttributeGroups.Select(g => new ProductAttributeGroup
{
ProductId = request.ProductId,
Name = g.Name.Trim(),
SelectionType = (Domain.Products.Enums.AttributeSelectionType)g.SelectionType,
SortOrder = g.SortOrder
}).ToList();
// 4. 持久化(分批保障 FK 正确)
await productRepository.AddAttributeGroupsAsync(groupEntities, [], cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
// 重新建立选项的 GroupId 映射
var groupIdLookup = groupEntities.Zip(request.AttributeGroups, (entity, dto) => (entity, dto))
.ToDictionary(x => x.dto, x => x.entity.Id);
var optionEntities = request.AttributeGroups
.SelectMany(dto => dto.Options.Select(o => new ProductAttributeOption
{
AttributeGroupId = groupIdLookup[dto],
Name = o.Name.Trim(),
SortOrder = o.SortOrder
}))
.ToList();
await productRepository.AddAttributeGroupsAsync([], optionEntities, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("替换商品 {ProductId} 规格组 {GroupCount} 个", request.ProductId, groupEntities.Count);
// 5. 返回 DTO
return groupEntities
.Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AttributeGroupId == g.Id).ToList()))
.ToList();
}
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using Microsoft.Extensions.Logging;
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 ReplaceProductMediaCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductMediaCommandHandler> logger)
: IRequestHandler<ReplaceProductMediaCommand, IReadOnlyList<ProductMediaAssetDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAssetDto>> Handle(ReplaceProductMediaCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
// 2. 替换
await productRepository.RemoveMediaAssetsAsync(request.ProductId, tenantId, cancellationToken);
var assets = request.MediaAssets.Select(a => new ProductMediaAsset
{
ProductId = request.ProductId,
MediaType = a.MediaType,
Url = a.Url.Trim(),
Caption = a.Caption?.Trim(),
SortOrder = a.SortOrder
}).ToList();
await productRepository.AddMediaAssetsAsync(assets, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("替换商品 {ProductId} 媒资 {Count} 条", request.ProductId, assets.Count);
return assets.Select(ProductMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using Microsoft.Extensions.Logging;
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 ReplaceProductPricingRulesCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductPricingRulesCommandHandler> logger)
: IRequestHandler<ReplaceProductPricingRulesCommand, IReadOnlyList<ProductPricingRuleDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRuleDto>> Handle(ReplaceProductPricingRulesCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
// 2. 替换
await productRepository.RemovePricingRulesAsync(request.ProductId, tenantId, cancellationToken);
var rules = request.PricingRules.Select(r => new ProductPricingRule
{
ProductId = request.ProductId,
RuleType = r.RuleType,
ConditionsJson = r.ConditionsJson.Trim(),
Price = r.Price,
WeekdaysJson = r.WeekdaysJson,
SortOrder = 0
}).ToList();
await productRepository.AddPricingRulesAsync(rules, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("替换商品 {ProductId} 价格策略 {Count} 条", request.ProductId, rules.Count);
return rules.Select(ProductMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using Microsoft.Extensions.Logging;
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>
/// 替换 SKU 处理器。
/// </summary>
public sealed class ReplaceProductSkusCommandHandler(
IProductRepository productRepository,
ITenantProvider tenantProvider,
ILogger<ReplaceProductSkusCommandHandler> logger)
: IRequestHandler<ReplaceProductSkusCommand, IReadOnlyList<ProductSkuDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSkuDto>> Handle(ReplaceProductSkusCommand request, CancellationToken cancellationToken)
{
// 1. 校验商品存在
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
if (product is null)
{
throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
}
// 2. 校验 SKU 唯一性
var codes = request.Skus.Select(x => x.SkuCode.Trim()).ToList();
if (codes.Count != codes.Distinct(StringComparer.OrdinalIgnoreCase).Count())
{
throw new BusinessException(ErrorCodes.Conflict, "SKU 编码重复");
}
// 3. 替换
await productRepository.RemoveSkusAsync(request.ProductId, tenantId, cancellationToken);
var entities = request.Skus.Select(x => new ProductSku
{
ProductId = request.ProductId,
SkuCode = x.SkuCode.Trim(),
Barcode = x.Barcode?.Trim(),
Price = x.Price,
OriginalPrice = x.OriginalPrice,
StockQuantity = x.StockQuantity,
Weight = x.Weight,
AttributesJson = x.AttributesJson ?? string.Empty,
SortOrder = x.SortOrder
}).ToList();
await productRepository.AddSkusAsync(entities, cancellationToken);
await productRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("替换商品 {ProductId} 的 SKU 数量 {Count}", request.ProductId, entities.Count);
return entities.Select(ProductMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,21 @@
using System;
using MediatR;
using TakeoutSaaS.Application.App.Products.Dto;
namespace TakeoutSaaS.Application.App.Products.Queries;
/// <summary>
/// 获取门店菜单查询。
/// </summary>
public sealed record GetStoreMenuQuery : IRequest<StoreMenuDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 增量时间UTC
/// </summary>
public DateTime? UpdatedAfter { get; init; }
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 替换加料验证器。
/// </summary>
public sealed class ReplaceProductAddonsCommandValidator : AbstractValidator<ReplaceProductAddonsCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReplaceProductAddonsCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleForEach(x => x.AddonGroups).ChildRules(group =>
{
group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
group.RuleFor(x => x.MinSelect).GreaterThanOrEqualTo(0);
group.RuleFor(x => x.MaxSelect).GreaterThanOrEqualTo(x => x.MinSelect);
group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
group.RuleForEach(x => x.Options).ChildRules(opt =>
{
opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
opt.RuleFor(x => x.ExtraPrice).GreaterThanOrEqualTo(0).When(x => x.ExtraPrice.HasValue);
opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
});
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 替换规格验证器。
/// </summary>
public sealed class ReplaceProductAttributesCommandValidator : AbstractValidator<ReplaceProductAttributesCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReplaceProductAttributesCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleForEach(x => x.AttributeGroups).ChildRules(group =>
{
group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
group.RuleForEach(x => x.Options).ChildRules(opt =>
{
opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
});
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 替换媒资验证器。
/// </summary>
public sealed class ReplaceProductMediaCommandValidator : AbstractValidator<ReplaceProductMediaCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReplaceProductMediaCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleForEach(x => x.MediaAssets).ChildRules(asset =>
{
asset.RuleFor(x => x.Url).NotEmpty().MaximumLength(512);
asset.RuleFor(x => x.Caption).MaximumLength(256);
asset.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 替换价格策略验证器。
/// </summary>
public sealed class ReplaceProductPricingRulesCommandValidator : AbstractValidator<ReplaceProductPricingRulesCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReplaceProductPricingRulesCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleForEach(x => x.PricingRules).ChildRules(rule =>
{
rule.RuleFor(x => x.Price).GreaterThan(0);
rule.RuleFor(x => x.ConditionsJson).NotEmpty();
});
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Products.Commands;
namespace TakeoutSaaS.Application.App.Products.Validators;
/// <summary>
/// 替换 SKU 验证器。
/// </summary>
public sealed class ReplaceProductSkusCommandValidator : AbstractValidator<ReplaceProductSkusCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReplaceProductSkusCommandValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleForEach(x => x.Skus).ChildRules(sku =>
{
sku.RuleFor(x => x.SkuCode).NotEmpty().MaximumLength(64);
sku.RuleFor(x => x.Price).GreaterThan(0);
sku.RuleFor(x => x.OriginalPrice).GreaterThan(0).When(x => x.OriginalPrice.HasValue);
sku.RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue);
sku.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -19,48 +20,88 @@ public interface IProductRepository
/// <summary> /// <summary>
/// 按分类与状态筛选商品列表。 /// 按分类与状态筛选商品列表。
/// </summary> /// </summary>
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null);
/// <summary> /// <summary>
/// 获取租户下的商品分类。 /// 获取租户下的商品分类。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取门店商品分类。
/// </summary>
Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品 SKU。 /// 获取商品 SKU。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品 SKU。
/// </summary>
Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品加料组与选项。 /// 获取商品加料组与选项。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品加料组。
/// </summary>
Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品加料选项。 /// 获取商品加料选项。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品加料选项。
/// </summary>
Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品规格组与选项。 /// 获取商品规格组与选项。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品规格组。
/// </summary>
Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品规格选项。 /// 获取商品规格选项。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品规格选项。
/// </summary>
Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品媒资。 /// 获取商品媒资。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品媒资。
/// </summary>
Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取商品定价规则。 /// 获取商品定价规则。
/// </summary> /// </summary>
Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取商品定价规则。
/// </summary>
Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 新增分类。 /// 新增分类。
/// </summary> /// </summary>

View File

@@ -1,3 +1,4 @@
using System;
using System.Linq; using System.Linq;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Products.Entities; using TakeoutSaaS.Domain.Products.Entities;
@@ -27,7 +28,7 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null)
{ {
var query = context.Products var query = context.Products
.AsNoTracking() .AsNoTracking()
@@ -48,6 +49,11 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
query = query.Where(x => x.Status == status.Value); query = query.Where(x => x.Status == status.Value);
} }
if (updatedAfter.HasValue)
{
query = query.Where(x => (x.UpdatedAt ?? x.CreatedAt) >= updatedAfter.Value);
}
var products = await query var products = await query
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -67,6 +73,22 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return categories; return categories;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default)
{
var query = context.ProductCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
if (onlyEnabled)
{
query = query.Where(x => x.IsEnabled);
}
var categories = await query
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return categories;
}
/// <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)
{ {
@@ -79,6 +101,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return skus; return skus;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductSku>();
}
var skus = await context.ProductSkus
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return skus;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -91,6 +128,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return groups; return groups;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductAddonGroup>();
}
var groups = await context.ProductAddonGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -114,6 +166,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return options; return options;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default)
{
if (addonGroupIds.Count == 0)
{
return Array.Empty<ProductAddonOption>();
}
var options = await context.ProductAddonOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -126,6 +193,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return groups; return groups;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductAttributeGroup>();
}
var groups = await context.ProductAttributeGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -149,6 +231,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return options; return options;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default)
{
if (attributeGroupIds.Count == 0)
{
return Array.Empty<ProductAttributeOption>();
}
var options = await context.ProductAttributeOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -161,6 +258,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return assets; return assets;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductMediaAsset>();
}
var assets = await context.ProductMediaAssets
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return assets;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{ {
@@ -173,6 +285,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return rules; return rules;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductPricingRule>();
}
var rules = await context.ProductPricingRules
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return rules;
}
/// <inheritdoc /> /// <inheritdoc />
public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
{ {