feat: 商品上架/下架与全量详情支持
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
- [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。
|
- [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。
|
||||||
- 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。
|
- 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。
|
||||||
- [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。
|
- [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。
|
||||||
- 当前:Admin 仅有基础商品 CRUD(Product 级),未覆盖 SKU/规格/加料组、价格策略、媒资与上下架流程,Mini 端也未提供完整商品 JSON 拉取接口。
|
- 进展:补充商品全量详情 DTO/查询与映射,支持按门店过滤;新增 Admin 上下架接口与全量详情端点,权限新增 `product:publish`。仍需完成 SKU/规格/加料/媒资/价格策略替换接口及 Mini 菜单拉取。
|
||||||
- [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。
|
- [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。
|
||||||
- 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。
|
- 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。
|
||||||
- [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。
|
- [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。
|
||||||
|
|||||||
@@ -116,4 +116,59 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
|
|||||||
? ApiResponse<object>.Ok(null)
|
? ApiResponse<object>.Ok(null)
|
||||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
|
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取商品全量详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{productId:long}/detail")]
|
||||||
|
[PermissionAuthorize("product:read")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ProductDetailDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ApiResponse<ProductDetailDto>> FullDetail(long productId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken);
|
||||||
|
return result == null
|
||||||
|
? ApiResponse<ProductDetailDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||||
|
: ApiResponse<ProductDetailDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上架商品。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{productId:long}/publish")]
|
||||||
|
[PermissionAuthorize("product:publish")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ApiResponse<ProductDto>> 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<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||||
|
: ApiResponse<ProductDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下架商品。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{productId:long}/unpublish")]
|
||||||
|
[PermissionAuthorize("product:publish")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ApiResponse<ProductDto>> 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<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||||
|
: ApiResponse<ProductDto>.Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
"product:read",
|
"product:read",
|
||||||
"product:update",
|
"product:update",
|
||||||
"product:delete",
|
"product:delete",
|
||||||
|
"product:publish",
|
||||||
"order:create",
|
"order:create",
|
||||||
"order:read",
|
"order:read",
|
||||||
"order:update",
|
"order:update",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上架商品命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PublishProductCommand : IRequest<ProductDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上架备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? Reason { 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 UnpublishProductCommand : IRequest<ProductDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下架原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加料组 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductAddonGroupDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 组 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最小选择数。
|
||||||
|
/// </summary>
|
||||||
|
public int MinSelect { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大选择数。
|
||||||
|
/// </summary>
|
||||||
|
public int MaxSelect { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加料选项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductAddonOptionDto> Options { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加料选项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductAddonOptionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 选项 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所属加料组 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long AddonGroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 额外价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? ExtraPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格组 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductAttributeGroupDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 组 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选择类型。
|
||||||
|
/// </summary>
|
||||||
|
public int SelectionType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格选项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductAttributeOptionDto> Options { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格选项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductAttributeOptionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 选项 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格组 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long AttributeGroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品全量详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SPU 基础信息。
|
||||||
|
/// </summary>
|
||||||
|
public ProductDto Product { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU 列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductSkuDto> Skus { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格组与选项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductAttributeGroupDto> AttributeGroups { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加料组与选项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductAddonGroupDto> AddonGroups { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 价格策略。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductPricingRuleDto> PricingRules { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 媒资列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProductMediaAssetDto> MediaAssets { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|||||||
namespace TakeoutSaaS.Application.App.Products.Dto;
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 商品 DTO。
|
/// 商品 DTO(含 SPU 基础信息)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProductDto
|
public sealed class ProductDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品媒资 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductMediaAssetDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 媒资 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型。
|
||||||
|
/// </summary>
|
||||||
|
public MediaAssetType MediaType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL。
|
||||||
|
/// </summary>
|
||||||
|
public string Url { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文案。
|
||||||
|
/// </summary>
|
||||||
|
public string? Caption { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 价格策略 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductPricingRuleDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 策略 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 策略类型。
|
||||||
|
/// </summary>
|
||||||
|
public PricingRuleType RuleType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Price { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 条件 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionsJson { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 星期规则。
|
||||||
|
/// </summary>
|
||||||
|
public string? WeekdaysJson { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductSkuDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SKU ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编码。
|
||||||
|
/// </summary>
|
||||||
|
public string SkuCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 条形码。
|
||||||
|
/// </summary>
|
||||||
|
public string? Barcode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 售价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Price { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 原价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? OriginalPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 库存。
|
||||||
|
/// </summary>
|
||||||
|
public int? StockQuantity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重量。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? Weight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格属性 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string AttributesJson { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
}
|
||||||
@@ -23,31 +23,6 @@ public sealed class GetProductByIdQueryHandler(
|
|||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||||
var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken);
|
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品全量详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetProductDetailQueryHandler(
|
||||||
|
IProductRepository productRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetProductDetailQuery, ProductDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ProductDetailDto?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品上架处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublishProductCommandHandler(
|
||||||
|
IProductRepository productRepository,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
ILogger<PublishProductCommandHandler> logger)
|
||||||
|
: IRequestHandler<PublishProductCommand, ProductDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ProductDto?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,7 @@ public sealed class SearchProductsQueryHandler(
|
|||||||
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
|
public async Task<PagedResult<ProductDto>> Handle(SearchProductsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||||
var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken);
|
var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken);
|
||||||
|
|
||||||
if (request.StoreId.HasValue)
|
|
||||||
{
|
|
||||||
products = products.Where(x => x.StoreId == request.StoreId.Value).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
|
var sorted = ApplySorting(products, request.SortBy, request.SortDescending);
|
||||||
var paged = sorted
|
var paged = sorted
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品下架处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnpublishProductCommandHandler(
|
||||||
|
IProductRepository productRepository,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
ILogger<UnpublishProductCommandHandler> logger)
|
||||||
|
: IRequestHandler<UnpublishProductCommand, ProductDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ProductDto?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Products.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品映射辅助。
|
||||||
|
/// </summary>
|
||||||
|
public static class ProductMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 映射 SPU DTO。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="product">商品实体。</param>
|
||||||
|
/// <returns>DTO。</returns>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射 SKU DTO。
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射规格组 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static ProductAttributeGroupDto ToDto(ProductAttributeGroup group, IReadOnlyList<ProductAttributeOption> options) => new()
|
||||||
|
{
|
||||||
|
Id = group.Id,
|
||||||
|
ProductId = group.ProductId,
|
||||||
|
Name = group.Name,
|
||||||
|
SelectionType = (int)group.SelectionType,
|
||||||
|
SortOrder = group.SortOrder,
|
||||||
|
Options = options.Select(ToDto).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射规格选项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static ProductAttributeOptionDto ToDto(ProductAttributeOption option) => new()
|
||||||
|
{
|
||||||
|
Id = option.Id,
|
||||||
|
AttributeGroupId = option.AttributeGroupId,
|
||||||
|
Name = option.Name,
|
||||||
|
SortOrder = option.SortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射加料组 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static ProductAddonGroupDto ToDto(ProductAddonGroup group, IReadOnlyList<ProductAddonOption> 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()
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射加料选项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static ProductAddonOptionDto ToDto(ProductAddonOption option) => new()
|
||||||
|
{
|
||||||
|
Id = option.Id,
|
||||||
|
AddonGroupId = option.AddonGroupId,
|
||||||
|
Name = option.Name,
|
||||||
|
ExtraPrice = option.ExtraPrice,
|
||||||
|
SortOrder = option.SortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射媒资 DTO。
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射价格策略 DTO。
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Products.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品全量详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetProductDetailQuery : IRequest<ProductDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Products.Commands;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上架商品命令验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublishProductCommandValidator : AbstractValidator<PublishProductCommand>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public PublishProductCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ProductId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Reason).MaximumLength(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Products.Commands;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Products.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下架商品命令验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnpublishProductCommandValidator : AbstractValidator<UnpublishProductCommand>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public UnpublishProductCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ProductId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Reason).MaximumLength(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public interface IProductRepository
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按分类与状态筛选商品列表。
|
/// 按分类与状态筛选商品列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取租户下的商品分类。
|
/// 获取租户下的商品分类。
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = context.Products
|
var query = context.Products
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId);
|
.Where(x => x.TenantId == tenantId);
|
||||||
|
|
||||||
|
if (storeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.StoreId == storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
if (categoryId.HasValue)
|
if (categoryId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.CategoryId == categoryId.Value);
|
query = query.Where(x => x.CategoryId == categoryId.Value);
|
||||||
|
|||||||
Reference in New Issue
Block a user