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

@@ -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);
// 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;

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);
});
}
}