Init
This commit is contained in:
19
Prefab.Catalog/App/Categories/CategoryClient.cs
Normal file
19
Prefab.Catalog/App/Categories/CategoryClient.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Prefab.Handler;
|
||||
using Prefab.Shared.Catalog.Categories;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
public sealed class CategoryClient(HandlerInvoker handler) : ICategoryClient
|
||||
{
|
||||
public async Task<Shared.Catalog.Categories.GetCategories.Response> GetCategories(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await handler.Execute<Shared.Catalog.Categories.GetCategories.Response>(cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<Shared.Catalog.Categories.CreateCategory.Response> CreateCategory(Shared.Catalog.Categories.CreateCategory.Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response =await handler.Execute<Shared.Catalog.Categories.CreateCategory.Request, Shared.Catalog.Categories.CreateCategory.Response>(request, cancellationToken);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
173
Prefab.Catalog/App/Categories/CategoryTreeBuilder.cs
Normal file
173
Prefab.Catalog/App/Categories/CategoryTreeBuilder.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using CategoryDtos = Prefab.Shared.Catalog.Products.GetCategoryModels;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
/// <summary>
|
||||
/// Builds ordered category hierarchies and provides access helpers for rendering browse DTOs.
|
||||
/// </summary>
|
||||
internal sealed class CategoryTreeBuilder
|
||||
{
|
||||
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
|
||||
|
||||
private readonly List<Category> _rootCategories;
|
||||
private readonly Dictionary<Guid, List<Category>> _childrenByParent;
|
||||
private readonly Dictionary<Guid, Category> _categoryById;
|
||||
private readonly Dictionary<string, Category> _categoryBySlug;
|
||||
|
||||
internal CategoryTreeBuilder(IEnumerable<Category> categories)
|
||||
{
|
||||
var active = categories
|
||||
.Where(category => category.DeletedOn == null)
|
||||
.ToList();
|
||||
|
||||
_categoryById = active.ToDictionary(category => category.Id);
|
||||
_rootCategories = OrderCategories(active.Where(category => category.ParentId is null)).ToList();
|
||||
_childrenByParent = active
|
||||
.Where(category => category.ParentId.HasValue)
|
||||
.GroupBy(category => category.ParentId!.Value)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => OrderCategories(group).ToList());
|
||||
|
||||
_categoryBySlug = new Dictionary<string, Category>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var category in active)
|
||||
{
|
||||
var slug = CreateSlug(category);
|
||||
if (_categoryBySlug.ContainsKey(slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_categoryBySlug[slug] = category;
|
||||
}
|
||||
}
|
||||
|
||||
internal Category? FindBySlug(string slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = NormalizeSlug(slug);
|
||||
return _categoryBySlug.TryGetValue(normalized, out var category) ? category : null;
|
||||
}
|
||||
|
||||
internal bool TryGetCategory(Guid id, out Category category) => _categoryById.TryGetValue(id, out category!);
|
||||
|
||||
internal bool IsLeaf(Category category)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(category);
|
||||
|
||||
return !_childrenByParent.TryGetValue(category.Id, out var children) || children.Count == 0;
|
||||
}
|
||||
|
||||
internal string GetSlug(Category category) => CreateSlug(category);
|
||||
|
||||
internal IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildTree(Guid? parentId, int? depth) =>
|
||||
BuildNodes(parentId, NormalizeDepth(depth));
|
||||
|
||||
internal CategoryDtos.CategoryDetailDto CreateDetail(Category category, int? depth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(category);
|
||||
|
||||
var slug = GetSlug(category);
|
||||
var children = BuildNodes(category.Id, NormalizeDepth(depth));
|
||||
|
||||
return new CategoryDtos.CategoryDetailDto(
|
||||
category.Id,
|
||||
category.Name,
|
||||
slug,
|
||||
category.Description,
|
||||
category.HeroImageUrl,
|
||||
category.Icon,
|
||||
IsLeaf(category),
|
||||
children);
|
||||
}
|
||||
|
||||
private IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildNodes(Guid? parentId, int depth)
|
||||
{
|
||||
if (depth <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IReadOnlyList<Category> source;
|
||||
if (parentId is null)
|
||||
{
|
||||
source = _rootCategories;
|
||||
}
|
||||
else if (!_childrenByParent.TryGetValue(parentId.Value, out var children) || children.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
else
|
||||
{
|
||||
source = children;
|
||||
}
|
||||
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var remainingDepth = depth == int.MaxValue ? int.MaxValue : depth - 1;
|
||||
var nodes = new List<CategoryDtos.CategoryNodeDto>(source.Count);
|
||||
|
||||
foreach (var child in source)
|
||||
{
|
||||
var childNodes = remainingDepth <= 0
|
||||
? []
|
||||
: BuildNodes(child.Id, remainingDepth);
|
||||
|
||||
nodes.Add(new CategoryDtos.CategoryNodeDto(
|
||||
child.Id,
|
||||
child.Name,
|
||||
GetSlug(child),
|
||||
child.Description,
|
||||
child.HeroImageUrl,
|
||||
child.Icon,
|
||||
child.DisplayOrder,
|
||||
child.IsFeatured,
|
||||
IsLeaf(child),
|
||||
childNodes));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private static IEnumerable<Category> OrderCategories(IEnumerable<Category> categories) =>
|
||||
categories
|
||||
.OrderBy(category => category.DisplayOrder)
|
||||
.ThenBy(category => category.Name);
|
||||
|
||||
private static int NormalizeDepth(int? depth) =>
|
||||
depth.HasValue && depth.Value > 0 ? depth.Value : int.MaxValue;
|
||||
|
||||
private static string CreateSlug(Category category)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(category.Slug))
|
||||
{
|
||||
return NormalizeSlug(category.Slug);
|
||||
}
|
||||
|
||||
return Slugify(category.Name);
|
||||
}
|
||||
|
||||
private static string NormalizeSlug(string slug) => slug.Trim().ToLowerInvariant();
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim().ToLowerInvariant();
|
||||
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
|
||||
return sanitized.Trim('-');
|
||||
}
|
||||
}
|
||||
|
||||
50
Prefab.Catalog/App/Categories/CreateCategory.cs
Normal file
50
Prefab.Catalog/App/Categories/CreateCategory.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Shared.Catalog;
|
||||
using Prefab.Shared.Catalog.Categories;
|
||||
using Prefab.Handler;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
internal class CreateCategory : Shared.Catalog.Categories.CreateCategory
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost("/api/catalog/categories", async (ICategoryClient categoryClient, Request request, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await categoryClient.CreateCategory(request, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithModuleName<IModuleClient>(nameof(CreateCategory))
|
||||
.WithTags(nameof(Categories));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IUniqueChecker checker, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var db = await dbFactory.CreateWritableAsync(cancellationToken);
|
||||
|
||||
var category = await Category.Create(
|
||||
request.Name,
|
||||
request.Description,
|
||||
checker, cancellationToken);
|
||||
|
||||
db.Categories.Add(category);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var response = new Response(category.Id);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
Prefab.Catalog/App/Categories/GetCategories.cs
Normal file
55
Prefab.Catalog/App/Categories/GetCategories.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Categories;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
public class GetCategories : Prefab.Shared.Catalog.Categories.GetCategories
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/api/catalog/categories", async (ICategoryClient categoryClient, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await categoryClient.GetCategories(cancellationToken);
|
||||
return response;
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetCategories))
|
||||
.WithTags(nameof(Categories));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Response>
|
||||
{
|
||||
public async Task<Response> Execute(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var categories = await db.Categories
|
||||
.GroupJoin(
|
||||
db.Categories,
|
||||
child => child.ParentId,
|
||||
parent => parent.Id,
|
||||
(child, parents) => new
|
||||
{
|
||||
Child = child,
|
||||
ParentName = parents.Select(parent => parent.Name).FirstOrDefault()
|
||||
})
|
||||
.Select(x => new ListItem(
|
||||
x.Child.Id,
|
||||
x.Child.Name,
|
||||
x.ParentName ?? string.Empty))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var response = new Response(categories);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Prefab.Catalog/App/Categories/GetCategory.cs
Normal file
69
Prefab.Catalog/App/Categories/GetCategory.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single category node with its direct children.
|
||||
/// </summary>
|
||||
public class GetCategory : Prefab.Shared.Catalog.Products.GetCategory
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(
|
||||
"/api/catalog/categories/{slug}",
|
||||
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await productClient.GetCategory(slug, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (CatalogNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||
}
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetCategory))
|
||||
.WithTags("Categories");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||
: HandlerBase(accessor), IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
|
||||
|
||||
var normalizedSlug = request.Slug.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(category => category.DeletedOn == null)
|
||||
.OrderBy(category => category.DisplayOrder)
|
||||
.ThenBy(category => category.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tree = new CategoryTreeBuilder(categories);
|
||||
var category = tree.FindBySlug(normalizedSlug) ??
|
||||
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||
|
||||
var detail = tree.CreateDetail(category, depth: null);
|
||||
return new Response(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
330
Prefab.Catalog/App/Categories/GetCategoryModels.cs
Normal file
330
Prefab.Catalog/App/Categories/GetCategoryModels.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using SharedBrowse = Prefab.Shared.Catalog.Products.GetCategoryModels;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
/// <summary>
|
||||
/// Lists product model cards for a category slug, including a computed “from price” for browse screens.
|
||||
/// </summary>
|
||||
public class GetCategoryModels : SharedBrowse
|
||||
{
|
||||
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
|
||||
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(
|
||||
"/api/catalog/categories/{slug}/models",
|
||||
async (
|
||||
IProductClient productClient,
|
||||
string slug,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
string? sort,
|
||||
string? dir,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await productClient.GetCategoryModels(slug, page, pageSize, sort, dir, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (CatalogConflictException conflict)
|
||||
{
|
||||
return Results.Conflict(ApiProblemDetails.Conflict(conflict.Message, conflict.Resource, conflict.Identifier));
|
||||
}
|
||||
catch (CatalogNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||
}
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetCategoryModels))
|
||||
.WithTags("Products");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||
: HandlerBase(accessor), IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
|
||||
|
||||
var normalizedSlug = request.Slug.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(category => category.DeletedOn == null)
|
||||
.OrderBy(category => category.DisplayOrder)
|
||||
.ThenBy(category => category.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||
}
|
||||
|
||||
var tree = new CategoryTreeBuilder(categories);
|
||||
var category = tree.FindBySlug(normalizedSlug) ??
|
||||
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||
|
||||
if (!tree.IsLeaf(category))
|
||||
{
|
||||
throw new CatalogConflictException("Category", tree.GetSlug(category), "Category is not a leaf.");
|
||||
}
|
||||
|
||||
var models = await db.Products
|
||||
.Where(product => product.DeletedOn == null &&
|
||||
product.Kind == ProductKind.Model &&
|
||||
product.Categories.Any(placement => placement.CategoryId == category.Id && placement.IsPrimary))
|
||||
.Include(product => product.Variants)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var cards = models
|
||||
.Select(CreateCard)
|
||||
.ToList();
|
||||
|
||||
var orderedCards = OrderCards(cards, request.Sort, request.Direction);
|
||||
|
||||
var page = request.Page.HasValue && request.Page.Value > 0 ? request.Page.Value : 1;
|
||||
var pageSize = request.PageSize.HasValue && request.PageSize.Value > 0
|
||||
? request.PageSize.Value
|
||||
: orderedCards.Count == 0 ? 1 : orderedCards.Count;
|
||||
|
||||
var skip = (page - 1) * pageSize;
|
||||
var pagedCards = skip >= orderedCards.Count
|
||||
? new List<SharedBrowse.ProductCardDto>()
|
||||
: orderedCards.Skip(skip).Take(pageSize).ToList();
|
||||
|
||||
var categoryDetail = tree.CreateDetail(category, depth: 1);
|
||||
var payload = new SharedBrowse.CategoryProductsResponse(
|
||||
categoryDetail,
|
||||
pagedCards,
|
||||
orderedCards.Count,
|
||||
page,
|
||||
pageSize);
|
||||
|
||||
return new Response(payload);
|
||||
}
|
||||
|
||||
private static SharedBrowse.ProductCardDto CreateCard(Product model)
|
||||
{
|
||||
var fromPrice = CalculateFromPrice(model);
|
||||
|
||||
var slug = !string.IsNullOrWhiteSpace(model.Slug)
|
||||
? model.Slug.Trim().ToLowerInvariant()
|
||||
: Slugify(model.Name);
|
||||
|
||||
return new SharedBrowse.ProductCardDto(
|
||||
model.Id,
|
||||
model.Name,
|
||||
slug,
|
||||
fromPrice,
|
||||
PrimaryImageUrl: null,
|
||||
model.LastModifiedOn);
|
||||
}
|
||||
|
||||
private static List<SharedBrowse.ProductCardDto> OrderCards(
|
||||
List<SharedBrowse.ProductCardDto> cards,
|
||||
string? sort,
|
||||
string? direction)
|
||||
{
|
||||
var sortKey = string.IsNullOrWhiteSpace(sort) ? "name" : sort.Trim().ToLowerInvariant();
|
||||
var dirKey = string.IsNullOrWhiteSpace(direction) ? "asc" : direction.Trim().ToLowerInvariant();
|
||||
|
||||
IEnumerable<SharedBrowse.ProductCardDto> ordered = sortKey switch
|
||||
{
|
||||
"price" => cards
|
||||
.OrderBy(card => card.FromPrice ?? decimal.MaxValue)
|
||||
.ThenBy(card => card.Name),
|
||||
_ => cards
|
||||
.OrderBy(card => card.Name, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
if (dirKey == "desc")
|
||||
{
|
||||
ordered = sortKey switch
|
||||
{
|
||||
"price" => cards
|
||||
.OrderByDescending(card => card.FromPrice ?? decimal.MinValue)
|
||||
.ThenByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase),
|
||||
_ => cards
|
||||
.OrderByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
|
||||
return ordered.ToList();
|
||||
}
|
||||
|
||||
private static decimal CalculatePercentScopeBase(PercentScope? scope, decimal basePrice, decimal numericTotal) =>
|
||||
scope switch
|
||||
{
|
||||
PercentScope.NumericOnly => numericTotal,
|
||||
PercentScope.BasePlusNumeric => basePrice + numericTotal,
|
||||
_ => basePrice
|
||||
};
|
||||
|
||||
private static decimal? CalculateFromPrice(Product model)
|
||||
{
|
||||
var activeOptions = model.Options
|
||||
.Where(option => option.DeletedOn == null)
|
||||
.ToList();
|
||||
|
||||
var basePrice = model.Price ?? 0m;
|
||||
var numericTotal = activeOptions
|
||||
.Where(option => option.DataType == OptionDataType.Number)
|
||||
.Sum(CalculateNumberOptionMinDelta);
|
||||
|
||||
var choiceTotal = activeOptions
|
||||
.Where(option => option.DataType == OptionDataType.Choice)
|
||||
.Sum(option => CalculateChoiceOptionMinDelta(option, basePrice, numericTotal));
|
||||
|
||||
var total = basePrice + numericTotal + choiceTotal;
|
||||
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal CalculateNumberOptionMinDelta(OptionDefinition option)
|
||||
{
|
||||
var quantity = option.Min ?? 0m;
|
||||
|
||||
if (option.Step is > 0)
|
||||
{
|
||||
var step = option.Step.Value;
|
||||
quantity = Math.Ceiling(quantity / step) * step;
|
||||
if (option.Min.HasValue)
|
||||
{
|
||||
quantity = Math.Max(quantity, option.Min.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (option.Max.HasValue && quantity > option.Max.Value)
|
||||
{
|
||||
quantity = option.Max.Value;
|
||||
}
|
||||
|
||||
var applicableTier = option.Tiers
|
||||
.Where(tier => tier.DeletedOn == null && quantity >= tier.FromInclusive && (tier.ToInclusive == null || quantity <= tier.ToInclusive.Value))
|
||||
.OrderByDescending(tier => tier.FromInclusive)
|
||||
.FirstOrDefault();
|
||||
|
||||
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
|
||||
var flat = applicableTier?.FlatDelta ?? 0m;
|
||||
|
||||
return (rate * quantity) + flat;
|
||||
}
|
||||
|
||||
private static decimal CalculateChoiceOptionMinDelta(OptionDefinition option, decimal basePrice, decimal numericTotal)
|
||||
{
|
||||
var values = option.Values
|
||||
.Where(value => value.DeletedOn == null)
|
||||
.ToList();
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
decimal? minAbsolute = null;
|
||||
decimal? minPercent = null;
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var delta = value.PriceDelta ?? 0m;
|
||||
|
||||
if (value.PriceDeltaKind == PriceDeltaKind.Absolute)
|
||||
{
|
||||
minAbsolute = minAbsolute.HasValue ? Math.Min(minAbsolute.Value, delta) : delta;
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopeBase = CalculatePercentScopeBase(option.PercentScope, basePrice, numericTotal);
|
||||
var percentDelta = scopeBase * (delta / 100m);
|
||||
minPercent = minPercent.HasValue ? Math.Min(minPercent.Value, percentDelta) : percentDelta;
|
||||
}
|
||||
|
||||
if (minAbsolute.HasValue && minPercent.HasValue)
|
||||
{
|
||||
return Math.Min(minAbsolute.Value, minPercent.Value);
|
||||
}
|
||||
|
||||
return minAbsolute ?? minPercent ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Validator : AbstractValidator<Request>
|
||||
{
|
||||
private static readonly string[] AllowedSorts = ["name", "price"];
|
||||
private static readonly string[] AllowedDirections = ["asc", "desc"];
|
||||
|
||||
public Validator()
|
||||
{
|
||||
RuleFor(request => request.Slug)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotEmpty().WithMessage("Slug is required.")
|
||||
.Must(slug => slug is null || slug.Trim().Length <= ProductRules.SlugMaxLength)
|
||||
.WithMessage($"Slug cannot exceed {ProductRules.SlugMaxLength} characters.")
|
||||
.Must(IsValidSlug)
|
||||
.WithMessage("Slug must use lowercase letters, numbers, and hyphens.");
|
||||
|
||||
RuleFor(request => request.Page)
|
||||
.GreaterThan(0)
|
||||
.When(request => request.Page.HasValue)
|
||||
.WithMessage("Page must be greater than zero.");
|
||||
|
||||
RuleFor(request => request.PageSize)
|
||||
.GreaterThan(0)
|
||||
.When(request => request.PageSize.HasValue)
|
||||
.WithMessage("PageSize must be greater than zero.");
|
||||
|
||||
RuleFor(request => request.Sort)
|
||||
.Must(sort => string.IsNullOrWhiteSpace(sort) || AllowedSorts.Contains(sort.Trim().ToLowerInvariant()))
|
||||
.WithMessage("Sort must be 'name' or 'price'.");
|
||||
|
||||
RuleFor(request => request.Direction)
|
||||
.Must(dir => string.IsNullOrWhiteSpace(dir) || AllowedDirections.Contains(dir.Trim().ToLowerInvariant()))
|
||||
.WithMessage("Direction must be 'asc' or 'desc'.");
|
||||
}
|
||||
|
||||
private static bool IsValidSlug(string? slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = slug.Trim();
|
||||
return SlugRegex.IsMatch(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim().ToLowerInvariant();
|
||||
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
|
||||
return sanitized.Trim('-');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
Prefab.Catalog/App/Categories/GetCategoryTree.cs
Normal file
53
Prefab.Catalog/App/Categories/GetCategoryTree.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
|
||||
namespace Prefab.Catalog.App.Categories;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the category hierarchy for browse experiences.
|
||||
/// </summary>
|
||||
public class GetCategoryTree : Prefab.Shared.Catalog.Products.GetCategoryTree
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(
|
||||
"/api/catalog/categories/tree",
|
||||
async (IProductClient productClient, int? depth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await productClient.GetCategoryTree(depth, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetCategoryTree))
|
||||
.WithTags("Categories");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(category => category.DeletedOn == null)
|
||||
.OrderBy(category => category.DisplayOrder)
|
||||
.ThenBy(category => category.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tree = new CategoryTreeBuilder(categories);
|
||||
var nodes = tree.BuildTree(parentId: null, request.Depth);
|
||||
|
||||
return new Response(nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
267
Prefab.Catalog/App/Products/GetProductDetail.cs
Normal file
267
Prefab.Catalog/App/Products/GetProductDetail.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using SharedProductDetail = Prefab.Shared.Catalog.Products.GetProductDetail;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the PDP configuration endpoint that surfaces model details, options, specs, and metadata for a given product slug.
|
||||
/// </summary>
|
||||
public class GetProductDetail : SharedProductDetail
|
||||
{
|
||||
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(
|
||||
"/api/catalog/products/{slug}",
|
||||
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await productClient.GetProductDetail(slug, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (CatalogNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||
}
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetProductDetail))
|
||||
.WithTags("Products");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler responsible for loading the requested product model and projecting it into the shared DTO that powers the PDP.
|
||||
/// </summary>
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||
: HandlerBase(accessor), IHandler<string, Response>
|
||||
{
|
||||
public async Task<Response> Execute(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
|
||||
var normalizedSlug = slug.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var product = await db.Products
|
||||
.Where(model => model.DeletedOn == null && model.Kind == ProductKind.Model && model.Slug == normalizedSlug)
|
||||
.Include(model => model.Variants).ThenInclude(variant => variant.AxisValues)
|
||||
.Include(model => model.Options).ThenInclude(option => option.Values)
|
||||
.Include(model => model.Options).ThenInclude(option => option.Tiers)
|
||||
.Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions)
|
||||
.Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (product is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", normalizedSlug);
|
||||
}
|
||||
|
||||
var optionDtos = MapOptions(product);
|
||||
var specDtos = MapSpecs(product);
|
||||
var genericAttributes = await LoadGenericAttributesAsync(db, product.Id, cancellationToken);
|
||||
|
||||
var payload = new SharedProductDetail.ProductDetailDto(
|
||||
product.Id,
|
||||
product.Name,
|
||||
product.Slug ?? normalizedSlug,
|
||||
product.Description,
|
||||
product.Price,
|
||||
optionDtos,
|
||||
specDtos,
|
||||
genericAttributes);
|
||||
|
||||
return new Response(payload);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.OptionDefinitionDto> MapOptions(Product product)
|
||||
{
|
||||
var activeOptions = product.Options
|
||||
.Where(option => option.DeletedOn == null)
|
||||
.OrderBy(option => option.Name)
|
||||
.ToList();
|
||||
|
||||
var optionLookup = activeOptions.ToDictionary(option => option.Id);
|
||||
var valueLookup = activeOptions
|
||||
.SelectMany(option => option.Values.Where(value => value.DeletedOn == null))
|
||||
.ToDictionary(value => value.Id);
|
||||
|
||||
var (definitionRules, valueRules) = MapRuleSets(product, optionLookup, valueLookup);
|
||||
|
||||
var optionDtos = new List<SharedProductDetail.OptionDefinitionDto>(activeOptions.Count);
|
||||
foreach (var option in activeOptions)
|
||||
{
|
||||
var tiers = option.Tiers
|
||||
.Where(tier => tier.DeletedOn == null)
|
||||
.OrderBy(tier => tier.FromInclusive)
|
||||
.Select(tier => new SharedProductDetail.OptionTierDto(tier.FromInclusive, tier.ToInclusive, tier.UnitRate, tier.FlatDelta))
|
||||
.ToList();
|
||||
|
||||
var valueDtos = option.Values
|
||||
.Where(value => value.DeletedOn == null)
|
||||
.OrderBy(value => value.Label)
|
||||
.Select(value => new SharedProductDetail.OptionValueDto(
|
||||
value.Id,
|
||||
value.Code,
|
||||
value.Label,
|
||||
value.PriceDelta,
|
||||
(int)value.PriceDeltaKind,
|
||||
TryGetRules(valueRules, value.Id)))
|
||||
.ToList();
|
||||
|
||||
optionDtos.Add(new SharedProductDetail.OptionDefinitionDto(
|
||||
option.Id,
|
||||
option.Code,
|
||||
option.Name,
|
||||
(int)option.DataType,
|
||||
option.IsVariantAxis,
|
||||
option.Unit,
|
||||
option.Min,
|
||||
option.Max,
|
||||
option.Step,
|
||||
option.PricePerUnit,
|
||||
tiers,
|
||||
valueDtos,
|
||||
TryGetRules(definitionRules, option.Id)));
|
||||
}
|
||||
|
||||
return optionDtos;
|
||||
}
|
||||
|
||||
private static (Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> DefinitionRules, Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> ValueRules) MapRuleSets(
|
||||
Product product,
|
||||
IReadOnlyDictionary<Guid, OptionDefinition> optionLookup,
|
||||
IReadOnlyDictionary<Guid, OptionValue> valueLookup)
|
||||
{
|
||||
var definitionRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
|
||||
var valueRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
|
||||
|
||||
foreach (var ruleSet in product.RuleSets.Where(rule => rule.DeletedOn == null))
|
||||
{
|
||||
var conditions = new List<SharedProductDetail.ConditionDto>();
|
||||
|
||||
foreach (var condition in ruleSet.Conditions.Where(condition => condition.DeletedOn == null))
|
||||
{
|
||||
if (!optionLookup.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? rightValueCode = null;
|
||||
if (condition.RightOptionValueId.HasValue && valueLookup.TryGetValue(condition.RightOptionValueId.Value, out var rightValue))
|
||||
{
|
||||
rightValueCode = rightValue.Code;
|
||||
}
|
||||
|
||||
conditions.Add(new SharedProductDetail.ConditionDto(
|
||||
leftOption.Code,
|
||||
condition.Operator.ToString(),
|
||||
rightValueCode,
|
||||
condition.RightList,
|
||||
condition.RightNumber,
|
||||
condition.RightMin,
|
||||
condition.RightMax));
|
||||
}
|
||||
|
||||
if (conditions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = new SharedProductDetail.RuleSetDto(
|
||||
ruleSet.Effect.ToString(),
|
||||
ruleSet.Mode.ToString(),
|
||||
conditions);
|
||||
|
||||
var target = ruleSet.TargetKind == OptionRuleTargetKind.OptionDefinition
|
||||
? definitionRules
|
||||
: valueRules;
|
||||
|
||||
if (!target.TryGetValue(ruleSet.TargetId, out var bucket))
|
||||
{
|
||||
bucket = new List<SharedProductDetail.RuleSetDto>();
|
||||
target[ruleSet.TargetId] = bucket;
|
||||
}
|
||||
|
||||
bucket.Add(dto);
|
||||
}
|
||||
|
||||
return (definitionRules, valueRules);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.RuleSetDto>? TryGetRules(
|
||||
IReadOnlyDictionary<Guid, List<SharedProductDetail.RuleSetDto>> source,
|
||||
Guid targetId) =>
|
||||
source.TryGetValue(targetId, out var rules) ? rules : null;
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.SpecDto> MapSpecs(Product product) =>
|
||||
product.Attributes
|
||||
.Where(attribute => attribute.DeletedOn == null && attribute.AttributeDefinition.DeletedOn == null)
|
||||
.Select(attribute => new SharedProductDetail.SpecDto(
|
||||
attribute.AttributeDefinition.Name,
|
||||
attribute.Value,
|
||||
attribute.NumericValue,
|
||||
attribute.UnitCode))
|
||||
.ToList();
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, string>?> LoadGenericAttributesAsync(
|
||||
IModuleDbReadOnly db,
|
||||
Guid productId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attributes = await db.GenericAttributes
|
||||
.Where(attribute =>
|
||||
attribute.DeletedOn == null &&
|
||||
attribute.EntityId == productId &&
|
||||
attribute.KeyGroup == typeof(Product).FullName)
|
||||
.Select(attribute => new { attribute.Key, attribute.Value })
|
||||
.ToDictionaryAsync(attribute => attribute.Key, attribute => attribute.Value, cancellationToken);
|
||||
|
||||
return attributes.Count == 0 ? null : attributes;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Validator : AbstractValidator<string>
|
||||
{
|
||||
public Validator()
|
||||
{
|
||||
RuleFor(slug => slug)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotEmpty().WithMessage("Slug is required.")
|
||||
.Must(slug => !string.IsNullOrWhiteSpace(slug?.Trim()))
|
||||
.WithMessage("Slug is required.")
|
||||
.Must(slug => slug is null || slug.Trim().Length <= ProductRules.SlugMaxLength)
|
||||
.WithMessage($"Slug cannot exceed {ProductRules.SlugMaxLength} characters.")
|
||||
.Must(IsValidSlug)
|
||||
.WithMessage("Slug must use lowercase letters, numbers, and hyphens.");
|
||||
}
|
||||
|
||||
private static bool IsValidSlug(string? slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = slug.Trim();
|
||||
return SlugRegex.IsMatch(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Prefab.Catalog/App/Products/ProductClient.cs
Normal file
44
Prefab.Catalog/App/Products/ProductClient.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Prefab.Handler;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
public sealed class ProductClient(HandlerInvoker handler) : IProductClient, IPriceQuoteClient
|
||||
{
|
||||
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken)
|
||||
{
|
||||
return handler.Execute<Categories.GetCategoryTree.Request, GetCategoryTree.Response>(
|
||||
new Categories.GetCategoryTree.Request(depth),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
return handler.Execute<Categories.GetCategory.Request, GetCategory.Response>(
|
||||
new Categories.GetCategory.Request(slug.Trim()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetCategoryModels.Response> GetCategoryModels(string slug, int? page, int? pageSize, string? sort, string? direction, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
var request = new Categories.GetCategoryModels.Request(slug.Trim(), page, pageSize, sort, direction);
|
||||
return handler.Execute<Categories.GetCategoryModels.Request, GetCategoryModels.Response>(request, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
return handler.Execute<string, GetProductDetail.Response>(slug.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
|
||||
Quote(request, cancellationToken);
|
||||
|
||||
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return handler.Execute<QuotePrice.Request, QuotePrice.Response>(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
211
Prefab.Catalog/App/Products/QuotePrice.cs
Normal file
211
Prefab.Catalog/App/Products/QuotePrice.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using SharedQuotePrice = Prefab.Shared.Catalog.Products.QuotePrice;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
public class QuotePrice : SharedQuotePrice
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(
|
||||
"/api/catalog/price-quotes",
|
||||
async (IPriceQuoteClient client, Request request, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await client.Quote(request, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(QuotePrice))
|
||||
.WithTags("Products");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(
|
||||
ICatalogDbContextFactory dbFactory,
|
||||
RuleEvaluator ruleEvaluator,
|
||||
VariantResolver variantResolver,
|
||||
IPricingService pricingService) : IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var trimmedSku = string.IsNullOrWhiteSpace(request.Sku) ? null : request.Sku.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var (model, initialVariant) = await LoadProductContextAsync(db, request, trimmedSku, cancellationToken);
|
||||
|
||||
var optionSelections = (request.Selections ?? Array.Empty<Selection>())
|
||||
.Select(selection => new OptionSelection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
|
||||
.ToList();
|
||||
|
||||
var evaluation = ruleEvaluator.Evaluate(model, optionSelections);
|
||||
if (!evaluation.IsValid)
|
||||
{
|
||||
throw new DomainValidationException(string.Join("; ", evaluation.Errors));
|
||||
}
|
||||
|
||||
var variantResult = variantResolver.Resolve(model, evaluation.Selections, trimmedSku);
|
||||
if (!variantResult.IsSuccess)
|
||||
{
|
||||
throw new DomainValidationException(string.Join("; ", variantResult.Errors));
|
||||
}
|
||||
|
||||
var resolvedVariant = variantResult.Variant ?? initialVariant;
|
||||
|
||||
var sanitizedSelections = evaluation.Selections
|
||||
.Select(selection => new Selection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
|
||||
.ToList();
|
||||
|
||||
var pricingRequest = new Request(
|
||||
resolvedVariant?.Id ?? request.ProductId ?? model.Id,
|
||||
resolvedVariant?.Sku ?? trimmedSku,
|
||||
sanitizedSelections);
|
||||
|
||||
return await pricingService.QuoteAsync(pricingRequest, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<(Product Model, Product? Variant)> LoadProductContextAsync(
|
||||
IModuleDbReadOnly db,
|
||||
Request request,
|
||||
string? trimmedSku,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trimmedSku))
|
||||
{
|
||||
var variantInfo = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Sku != null && product.Sku == trimmedSku)
|
||||
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (variantInfo is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
}
|
||||
|
||||
if (variantInfo.Kind == ProductKind.Model)
|
||||
{
|
||||
var modelForSku = await LoadModelAsync(db, variantInfo.Id, cancellationToken);
|
||||
return (modelForSku, null);
|
||||
}
|
||||
|
||||
if (variantInfo.ParentProductId is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
}
|
||||
|
||||
var model = await LoadModelAsync(db, variantInfo.ParentProductId.Value, cancellationToken);
|
||||
var variant = model.Variants.FirstOrDefault(product => product.Id == variantInfo.Id && product.DeletedOn == null)
|
||||
?? throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
|
||||
return (model, variant);
|
||||
}
|
||||
|
||||
if (request.ProductId.HasValue)
|
||||
{
|
||||
var identifier = request.ProductId.Value;
|
||||
var productInfo = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Id == identifier)
|
||||
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (productInfo is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
}
|
||||
|
||||
if (productInfo.Kind == ProductKind.Model)
|
||||
{
|
||||
var model = await LoadModelAsync(db, productInfo.Id, cancellationToken);
|
||||
return (model, null);
|
||||
}
|
||||
|
||||
if (productInfo.ParentProductId is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
}
|
||||
|
||||
var parentModel = await LoadModelAsync(db, productInfo.ParentProductId.Value, cancellationToken);
|
||||
var variant = parentModel.Variants.FirstOrDefault(product => product.Id == productInfo.Id && product.DeletedOn == null)
|
||||
?? throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
|
||||
return (parentModel, variant);
|
||||
}
|
||||
|
||||
throw new DomainValidationException("Either productId or sku must be provided.");
|
||||
}
|
||||
|
||||
private static async Task<Product> LoadModelAsync(IModuleDbReadOnly db, Guid modelId, CancellationToken cancellationToken)
|
||||
{
|
||||
var model = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Id == modelId && product.Kind == ProductKind.Model)
|
||||
.Include(product => product.Options).ThenInclude(option => option.Values)
|
||||
.Include(product => product.Options).ThenInclude(option => option.Tiers)
|
||||
.Include(product => product.RuleSets).ThenInclude(rule => rule.Conditions)
|
||||
.Include(product => product.Variants).ThenInclude(variant => variant.AxisValues)
|
||||
.Include(product => product.AxisValues)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (model is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", modelId.ToString());
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Validator : AbstractValidator<Request>
|
||||
{
|
||||
public Validator()
|
||||
{
|
||||
RuleFor(request => request)
|
||||
.Must(HasLookupKey)
|
||||
.WithMessage("Either productId or sku must be provided.");
|
||||
|
||||
RuleFor(request => request.Sku)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.Must(sku => string.IsNullOrWhiteSpace(sku) || sku.Trim().Length > 0)
|
||||
.WithMessage("Sku cannot be empty.")
|
||||
.MaximumLength(ProductRules.SkuMaxLength)
|
||||
.When(request => !string.IsNullOrWhiteSpace(request.Sku));
|
||||
|
||||
RuleFor(request => request.Selections)
|
||||
.NotNull().WithMessage("Selections cannot be null.");
|
||||
|
||||
RuleForEach(request => request.Selections)
|
||||
.SetValidator(new SelectionValidator());
|
||||
}
|
||||
|
||||
private static bool HasLookupKey(Request request) =>
|
||||
request.ProductId.HasValue || !string.IsNullOrWhiteSpace(request.Sku);
|
||||
|
||||
private sealed class SelectionValidator : AbstractValidator<Selection>
|
||||
{
|
||||
public SelectionValidator()
|
||||
{
|
||||
RuleFor(selection => selection.OptionDefinitionId)
|
||||
.NotEmpty().WithMessage("OptionDefinitionId is required.");
|
||||
|
||||
RuleFor(selection => selection)
|
||||
.Must(selection => selection.OptionValueId.HasValue || selection.NumericValue.HasValue)
|
||||
.WithMessage("Each selection must include optionValueId or numericValue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user