feat: 菜品菜单查询与子资源替换完善
This commit is contained in:
@@ -171,4 +171,89 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
|
||||
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品 SKU。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/skus")]
|
||||
[PermissionAuthorize("product-sku:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductSkuDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductSkuDto>>> ReplaceSkus(long productId, [FromBody] ReplaceProductSkusCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<ProductSkuDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品规格。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/attributes")]
|
||||
[PermissionAuthorize("product-attr:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>> ReplaceAttributes(long productId, [FromBody] ReplaceProductAttributesCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品加料。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/addons")]
|
||||
[PermissionAuthorize("product-addon:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAddonGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductAddonGroupDto>>> ReplaceAddons(long productId, [FromBody] ReplaceProductAddonsCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<ProductAddonGroupDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品媒资。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/media")]
|
||||
[PermissionAuthorize("product-media:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductMediaAssetDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductMediaAssetDto>>> ReplaceMedia(long productId, [FromBody] ReplaceProductMediaCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<ProductMediaAssetDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品价格策略。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/pricing-rules")]
|
||||
[PermissionAuthorize("product-pricing:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductPricingRuleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductPricingRuleDto>>> ReplacePricingRules(long productId, [FromBody] ReplaceProductPricingRulesCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<ProductPricingRuleDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
38
src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs
Normal file
38
src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
using TakeoutSaaS.Application.App.Products.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端菜单查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")]
|
||||
public sealed class MenusController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店菜单(含分类与商品详情)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreMenuDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreMenuDto>> GetMenu(long storeId, [FromQuery] DateTime? updatedAfter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 组装查询
|
||||
var query = new GetStoreMenuQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
UpdatedAfter = updatedAfter
|
||||
};
|
||||
// 2. 拉取菜单
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<StoreMenuDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -19,48 +20,88 @@ public interface IProductRepository
|
||||
/// <summary>
|
||||
/// 按分类与状态筛选商品列表。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户下的商品分类。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店商品分类。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品 SKU。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品 SKU。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品加料组与选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品加料组。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品加料选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品加料选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品规格组与选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品规格组。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品规格选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品规格选项。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品媒资。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品媒资。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品定价规则。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取商品定价规则。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增分类。
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null)
|
||||
{
|
||||
var query = context.Products
|
||||
.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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.ProductCategories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
|
||||
if (onlyEnabled)
|
||||
{
|
||||
query = query.Where(x => x.IsEnabled);
|
||||
}
|
||||
var categories = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return categories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -79,6 +101,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return skus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductSku>();
|
||||
}
|
||||
var skus = await context.ProductSkus
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return skus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -91,6 +128,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAddonGroup>();
|
||||
}
|
||||
var groups = await context.ProductAddonGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -114,6 +166,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (addonGroupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAddonOption>();
|
||||
}
|
||||
var options = await context.ProductAddonOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -126,6 +193,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAttributeGroup>();
|
||||
}
|
||||
var groups = await context.ProductAttributeGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -149,6 +231,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (attributeGroupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAttributeOption>();
|
||||
}
|
||||
var options = await context.ProductAttributeOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -161,6 +258,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductMediaAsset>();
|
||||
}
|
||||
var assets = await context.ProductMediaAssets
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -173,6 +285,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
return rules;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductPricingRule>();
|
||||
}
|
||||
var rules = await context.ProductPricingRules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return rules;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user