diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 455b188..cb4399c 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -23,7 +23,7 @@ - [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。 - [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - - 当前:Admin 仅有基础商品 CRUD(Product 级),未覆盖 SKU/规格/加料组、价格策略、媒资与上下架流程,Mini 端也未提供完整商品 JSON 拉取接口。 + - 进展:补充商品全量详情 DTO/查询与映射,支持按门店过滤;新增 Admin 上下架接口与全量详情端点,权限新增 `product:publish`。仍需完成 SKU/规格/加料/媒资/价格策略替换接口及 Mini 菜单拉取。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 - 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。 - [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 24e334c..4fd43b3 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -116,4 +116,59 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); } + + /// + /// 获取商品全量详情。 + /// + [HttpGet("{productId:long}/detail")] + [PermissionAuthorize("product:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> FullDetail(long productId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 上架商品。 + /// + [HttpPost("{productId:long}/publish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Publish(long productId, [FromBody] PublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 下架商品。 + /// + [HttpPost("{productId:long}/unpublish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Unpublish(long productId, [FromBody] UnpublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 27eaa88..47d4125 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -112,6 +112,7 @@ "product:read", "product:update", "product:delete", + "product:publish", "order:create", "order:read", "order:update", diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs new file mode 100644 index 0000000..f8aa134 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 上架商品命令。 +/// +public sealed record PublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 上架备注。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs new file mode 100644 index 0000000..d59aef5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 下架商品命令。 +/// +public sealed record UnpublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 下架原因。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs new file mode 100644 index 0000000..c249464 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料组 DTO。 +/// +public sealed record ProductAddonGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 最小选择数。 + /// + public int MinSelect { get; init; } + + /// + /// 最大选择数。 + /// + public int MaxSelect { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 加料选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs new file mode 100644 index 0000000..544ba67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料选项 DTO。 +/// +public sealed record ProductAddonOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 所属加料组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AddonGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 额外价格。 + /// + public decimal? ExtraPrice { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs new file mode 100644 index 0000000..7e5ce67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格组 DTO。 +/// +public sealed record ProductAttributeGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 选择类型。 + /// + public int SelectionType { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 规格选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs new file mode 100644 index 0000000..f9fc8ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格选项 DTO。 +/// +public sealed record ProductAttributeOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 规格组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AttributeGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs new file mode 100644 index 0000000..cc9ec59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品全量详情 DTO。 +/// +public sealed record ProductDetailDto +{ + /// + /// SPU 基础信息。 + /// + public ProductDto Product { get; init; } = new(); + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; + + /// + /// 规格组与选项。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; + + /// + /// 加料组与选项。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs index bfcd321..27adb83 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs @@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.App.Products.Dto; /// -/// 商品 DTO。 +/// 商品 DTO(含 SPU 基础信息)。 /// public sealed class ProductDto { diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs new file mode 100644 index 0000000..d4a3279 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品媒资 DTO。 +/// +public sealed record ProductMediaAssetDto +{ + /// + /// 媒资 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 类型。 + /// + public MediaAssetType MediaType { get; init; } + + /// + /// URL。 + /// + public string Url { get; init; } = string.Empty; + + /// + /// 文案。 + /// + public string? Caption { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs new file mode 100644 index 0000000..04961fb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 价格策略 DTO。 +/// +public sealed record ProductPricingRuleDto +{ + /// + /// 策略 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 策略类型。 + /// + public PricingRuleType RuleType { get; init; } + + /// + /// 价格。 + /// + public decimal Price { get; init; } + + /// + /// 条件 JSON。 + /// + public string ConditionsJson { get; init; } = string.Empty; + + /// + /// 星期规则。 + /// + public string? WeekdaysJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs new file mode 100644 index 0000000..4b76869 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// SKU DTO。 +/// +public sealed record ProductSkuDto +{ + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 编码。 + /// + public string SkuCode { get; init; } = string.Empty; + + /// + /// 条形码。 + /// + public string? Barcode { get; init; } + + /// + /// 售价。 + /// + public decimal Price { get; init; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; init; } + + /// + /// 库存。 + /// + public int? StockQuantity { get; init; } + + /// + /// 重量。 + /// + public decimal? Weight { get; init; } + + /// + /// 规格属性 JSON。 + /// + public string AttributesJson { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs index c3b6a60..bed5199 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs @@ -23,31 +23,6 @@ public sealed class GetProductByIdQueryHandler( { var tenantId = _tenantProvider.GetCurrentTenantId(); var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); - return product == null ? null : MapToDto(product); + return product == null ? null : ProductMapping.ToDto(product); } - - private static ProductDto MapToDto(Product product) => new() - { - Id = product.Id, - TenantId = product.TenantId, - StoreId = product.StoreId, - CategoryId = product.CategoryId, - SpuCode = product.SpuCode, - Name = product.Name, - Subtitle = product.Subtitle, - Unit = product.Unit, - Price = product.Price, - OriginalPrice = product.OriginalPrice, - StockQuantity = product.StockQuantity, - MaxQuantityPerOrder = product.MaxQuantityPerOrder, - Status = product.Status, - CoverImage = product.CoverImage, - GalleryImages = product.GalleryImages, - Description = product.Description, - EnableDineIn = product.EnableDineIn, - EnablePickup = product.EnablePickup, - EnableDelivery = product.EnableDelivery, - IsFeatured = product.IsFeatured, - CreatedAt = product.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs new file mode 100644 index 0000000..5445bb0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品全量详情查询处理器。 +/// +public sealed class GetProductDetailQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetProductDetailQuery request, CancellationToken cancellationToken) + { + // 1. 读取 SPU + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 查询子项 + var skusTask = productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + var attrGroupsTask = productRepository.GetAttributeGroupsAsync(product.Id, tenantId, cancellationToken); + var attrOptionsTask = productRepository.GetAttributeOptionsAsync(product.Id, tenantId, cancellationToken); + var addonGroupsTask = productRepository.GetAddonGroupsAsync(product.Id, tenantId, cancellationToken); + var addonOptionsTask = productRepository.GetAddonOptionsAsync(product.Id, tenantId, cancellationToken); + var mediaTask = productRepository.GetMediaAssetsAsync(product.Id, tenantId, cancellationToken); + var pricingTask = productRepository.GetPricingRulesAsync(product.Id, tenantId, cancellationToken); + + 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 detail = new ProductDetailDto + { + Product = ProductMapping.ToDto(product), + Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attrGroupsTask.Result + .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) + .ToList(), + AddonGroups = addonGroupsTask.Result + .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) + .ToList(), + MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList() + }; + + return detail; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs new file mode 100644 index 0000000..fd71ada --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品上架处理器。 +/// +public sealed class PublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(PublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 校验 SKU 可售 + var skus = await productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + if (skus.Count == 0) + { + throw new BusinessException(ErrorCodes.Conflict, "请先配置可售 SKU 后再上架"); + } + + // 3. 上架 + product.Status = ProductStatus.OnSale; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品上架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs index 70e91f2..660a35b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs @@ -22,12 +22,7 @@ public sealed class SearchProductsQueryHandler( public async Task> Handle(SearchProductsQuery request, CancellationToken cancellationToken) { var tenantId = _tenantProvider.GetCurrentTenantId(); - var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken); - - if (request.StoreId.HasValue) - { - products = products.Where(x => x.StoreId == request.StoreId.Value).ToList(); - } + var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken); var sorted = ApplySorting(products, request.SortBy, request.SortDescending); var paged = sorted diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs new file mode 100644 index 0000000..83fe4c8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品下架处理器。 +/// +public sealed class UnpublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UnpublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 下架 + product.Status = ProductStatus.OffShelf; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品下架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs new file mode 100644 index 0000000..77cf89f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs @@ -0,0 +1,133 @@ +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; + +namespace TakeoutSaaS.Application.App.Products; + +/// +/// 商品映射辅助。 +/// +public static class ProductMapping +{ + /// + /// 映射 SPU DTO。 + /// + /// 商品实体。 + /// DTO。 + public static ProductDto ToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; + + /// + /// 映射 SKU DTO。 + /// + public static ProductSkuDto ToDto(ProductSku sku) => new() + { + Id = sku.Id, + ProductId = sku.ProductId, + SkuCode = sku.SkuCode, + Barcode = sku.Barcode, + Price = sku.Price, + OriginalPrice = sku.OriginalPrice, + StockQuantity = sku.StockQuantity, + Weight = sku.Weight, + AttributesJson = sku.AttributesJson, + SortOrder = sku.SortOrder + }; + + /// + /// 映射规格组 DTO。 + /// + public static ProductAttributeGroupDto ToDto(ProductAttributeGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + SelectionType = (int)group.SelectionType, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射规格选项 DTO。 + /// + public static ProductAttributeOptionDto ToDto(ProductAttributeOption option) => new() + { + Id = option.Id, + AttributeGroupId = option.AttributeGroupId, + Name = option.Name, + SortOrder = option.SortOrder + }; + + /// + /// 映射加料组 DTO。 + /// + public static ProductAddonGroupDto ToDto(ProductAddonGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + MinSelect = group.MinSelect ?? 0, + MaxSelect = group.MaxSelect ?? 0, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射加料选项 DTO。 + /// + public static ProductAddonOptionDto ToDto(ProductAddonOption option) => new() + { + Id = option.Id, + AddonGroupId = option.AddonGroupId, + Name = option.Name, + ExtraPrice = option.ExtraPrice, + SortOrder = option.SortOrder + }; + + /// + /// 映射媒资 DTO。 + /// + public static ProductMediaAssetDto ToDto(ProductMediaAsset asset) => new() + { + Id = asset.Id, + ProductId = asset.ProductId, + MediaType = asset.MediaType, + Url = asset.Url, + Caption = asset.Caption, + SortOrder = asset.SortOrder + }; + + /// + /// 映射价格策略 DTO。 + /// + public static ProductPricingRuleDto ToDto(ProductPricingRule rule) => new() + { + Id = rule.Id, + ProductId = rule.ProductId, + RuleType = rule.RuleType, + Price = rule.Price, + ConditionsJson = rule.ConditionsJson, + WeekdaysJson = rule.WeekdaysJson + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs new file mode 100644 index 0000000..a6e2c19 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 商品全量详情查询。 +/// +public sealed record GetProductDetailQuery : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs new file mode 100644 index 0000000..4964237 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 上架商品命令验证器。 +/// +public sealed class PublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public PublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs new file mode 100644 index 0000000..1ee952c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 下架商品命令验证器。 +/// +public sealed class UnpublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UnpublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 6f01804..833f555 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -19,7 +19,7 @@ public interface IProductRepository /// /// 按分类与状态筛选商品列表。 /// - Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); /// /// 获取租户下的商品分类。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 65666bb..41d6ace 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -27,12 +27,17 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR } /// - public async Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) { var query = context.Products .AsNoTracking() .Where(x => x.TenantId == tenantId); + if (storeId.HasValue) + { + query = query.Where(x => x.StoreId == storeId.Value); + } + if (categoryId.HasValue) { query = query.Where(x => x.CategoryId == categoryId.Value);