diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 4fd43b3..3eb9fe1 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -171,4 +171,89 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); } + + /// + /// 替换商品 SKU。 + /// + [HttpPut("{productId:long}/skus")] + [PermissionAuthorize("product-sku:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 替换商品规格。 + /// + [HttpPut("{productId:long}/attributes")] + [PermissionAuthorize("product-attr:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 替换商品加料。 + /// + [HttpPut("{productId:long}/addons")] + [PermissionAuthorize("product-addon:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 替换商品媒资。 + /// + [HttpPut("{productId:long}/media")] + [PermissionAuthorize("product-media:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 替换商品价格策略。 + /// + [HttpPut("{productId:long}/pricing-rules")] + [PermissionAuthorize("product-pricing:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 47d4125..1af6231 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -113,6 +113,16 @@ "product:update", "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:read", "order:update", @@ -197,6 +207,17 @@ "product:read", "product:update", "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:read", "order:update", @@ -242,6 +263,17 @@ "product:create", "product:read", "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:read", "order:update", @@ -331,6 +363,17 @@ "product:read", "product:update", "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:read", "order:update", diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs new file mode 100644 index 0000000..549db99 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs @@ -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; + +/// +/// 小程序端菜单查询。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")] +public sealed class MenusController(IMediator mediator) : BaseApiController +{ + /// + /// 获取门店菜单(含分类与商品详情)。 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs new file mode 100644 index 0000000..41ec401 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品加料命令。 +/// +public sealed record ReplaceProductAddonsCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 加料组。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs new file mode 100644 index 0000000..4ec120f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品规格命令。 +/// +public sealed record ReplaceProductAttributesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 规格组。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs new file mode 100644 index 0000000..6e099a0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品媒资命令。 +/// +public sealed record ReplaceProductMediaCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs new file mode 100644 index 0000000..067b11f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品价格策略命令。 +/// +public sealed record ReplaceProductPricingRulesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs new file mode 100644 index 0000000..8f82fdf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品 SKU 命令。 +/// +public sealed record ReplaceProductSkusCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs new file mode 100644 index 0000000..d758957 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 门店菜单分类 DTO。 +/// +public sealed record ProductCategoryMenuDto +{ + /// + /// 分类 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 分类描述。 + /// + public string? Description { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } + + /// + /// 分类下商品列表。 + /// + public IReadOnlyList Products { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs new file mode 100644 index 0000000..5429d43 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs @@ -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; + +/// +/// 门店菜单数据传输对象。 +/// +public sealed record StoreMenuDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类与商品集合。 + /// + public IReadOnlyList Categories { get; init; } = []; + + /// + /// 菜单生成时间(UTC)。 + /// + public DateTime GeneratedAt { get; init; } + + /// + /// 客户端请求的增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs index 5445bb0..828f1c8 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs @@ -37,21 +37,25 @@ public sealed class GetProductDetailQueryHandler( await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask); // 3. 组装 DTO - var attrOptions = attrOptionsTask.Result.ToLookup(x => x.AttributeGroupId); - var addonOptions = addonOptionsTask.Result.ToLookup(x => x.AddonGroupId); - + var skus = await skusTask; + 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 { Product = ProductMapping.ToDto(product), - Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(), - AttributeGroups = attrGroupsTask.Result + Skus = skus.Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attrGroups .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) .ToList(), - AddonGroups = addonGroupsTask.Result + AddonGroups = addonGroups .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) .ToList(), - MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(), - PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList() + MediaAssets = mediaAssets.Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingRules.Select(ProductMapping.ToDto).ToList() }; return detail; diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs new file mode 100644 index 0000000..bde1465 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs @@ -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; + +/// +/// 门店菜单查询处理器。 +/// +public sealed class GetStoreMenuQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task 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>(Array.Empty()) + : productRepository.GetAttributeOptionsByGroupIdsAsync(attributeGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken); + var addonOptionsTask = addonGroups.Count == 0 + ? Task.FromResult>(Array.Empty()) + : 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs new file mode 100644 index 0000000..af61144 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs @@ -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; + +/// +/// 替换加料处理器。 +/// +public sealed class ReplaceProductAddonsCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs new file mode 100644 index 0000000..9cef4d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs @@ -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; + +/// +/// 替换规格处理器。 +/// +public sealed class ReplaceProductAttributesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs new file mode 100644 index 0000000..4c704f4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs @@ -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; + +/// +/// 替换媒资处理器。 +/// +public sealed class ReplaceProductMediaCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs new file mode 100644 index 0000000..8c02739 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs @@ -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; + +/// +/// 替换价格策略处理器。 +/// +public sealed class ReplaceProductPricingRulesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs new file mode 100644 index 0000000..9f62d45 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs @@ -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; + +/// +/// 替换 SKU 处理器。 +/// +public sealed class ReplaceProductSkusCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs new file mode 100644 index 0000000..472806b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs @@ -0,0 +1,21 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 获取门店菜单查询。 +/// +public sealed record GetStoreMenuQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs new file mode 100644 index 0000000..37f52a8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换加料验证器。 +/// +public sealed class ReplaceProductAddonsCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs new file mode 100644 index 0000000..7cc5e10 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换规格验证器。 +/// +public sealed class ReplaceProductAttributesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs new file mode 100644 index 0000000..fa27cb7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换媒资验证器。 +/// +public sealed class ReplaceProductMediaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs new file mode 100644 index 0000000..5cdac59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换价格策略验证器。 +/// +public sealed class ReplaceProductPricingRulesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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(); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs new file mode 100644 index 0000000..7e4a387 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换 SKU 验证器。 +/// +public sealed class ReplaceProductSkusCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + }); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 833f555..451c535 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -19,48 +20,88 @@ public interface IProductRepository /// /// 按分类与状态筛选商品列表。 /// - Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null); /// /// 获取租户下的商品分类。 /// Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取门店商品分类。 + /// + Task> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default); + /// /// 获取商品 SKU。 /// Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品 SKU。 + /// + Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料组与选项。 /// Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料组。 + /// + Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料选项。 /// Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料选项。 + /// + Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格组与选项。 /// Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格组。 + /// + Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格选项。 /// Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格选项。 + /// + Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品媒资。 /// Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品媒资。 + /// + Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品定价规则。 /// Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品定价规则。 + /// + Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 新增分类。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 41d6ace..8d0ae9c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Products.Entities; @@ -27,7 +28,7 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR } /// - public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null) { var query = context.Products .AsNoTracking() @@ -48,6 +49,11 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR 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 .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -67,6 +73,22 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return categories; } + /// + public async Task> 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; + } + /// public async Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -79,6 +101,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return skus; } + /// + public async Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var skus = await context.ProductSkus + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return skus; + } + /// public async Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -91,6 +128,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAddonGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -114,6 +166,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (addonGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAddonOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -126,6 +193,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAttributeGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -149,6 +231,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (attributeGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAttributeOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -161,6 +258,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return assets; } + /// + public async Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var assets = await context.ProductMediaAssets + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return assets; + } + /// public async Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -173,6 +285,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return rules; } + /// + public async Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var rules = await context.ProductPricingRules + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return rules; + } + /// public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) {