feat: 菜品菜单查询与子资源替换完善
This commit is contained in:
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user