This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Catalog.Domain.Services;
/// <summary>
/// Calculates price quotes for configured catalog products by combining base price, numeric adjustments, and percent deltas.
/// </summary>
public interface IPricingService
{
/// <summary>
/// Produces a unit price and line-item breakdown for a shoppers selections.
/// </summary>
Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default Catalog pricing implementation that reads product/option data on demand and applies the PercentScope rules.
/// </summary>
public sealed class PricingService(IModuleDbReadOnly db) : IPricingService
{
public async Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.ProductId is null && string.IsNullOrWhiteSpace(request.Sku))
{
throw new ArgumentException("Either ProductId or Sku must be supplied.", nameof(request));
}
var product = await FindProductAsync(request, cancellationToken)
?? throw new InvalidOperationException("Product could not be located for pricing.");
var optionsSource = product.Kind == ProductKind.Model
? product
: product.ParentProduct ?? throw new InvalidOperationException("Variant product is missing its model definition.");
var optionLookup = optionsSource.Options
.Where(o => o.DeletedOn is null)
.ToDictionary(o => o.Id);
var basePrice = product.Price ?? 0m;
var numericTotal = 0m;
var choiceAbsTotal = 0m;
var breakdownEntries = new List<SelectionBreakdown>();
foreach (var selection in request.Selections)
{
if (!optionLookup.TryGetValue(selection.OptionDefinitionId, out var option))
{
throw new InvalidOperationException($"Option definition '{selection.OptionDefinitionId}' was not found for product '{optionsSource.Id}'.");
}
switch (option.DataType)
{
case OptionDataType.Number:
{
var (delta, chosenDescription) = HandleNumber(option, selection);
numericTotal += delta;
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
break;
}
case OptionDataType.Choice:
{
if (selection.OptionValueId is null)
{
throw new InvalidOperationException($"Selection for option '{option.Name}' must include an option value identifier.");
}
var value = option.Values.FirstOrDefault(v => v.DeletedOn == null && v.Id == selection.OptionValueId)
?? throw new InvalidOperationException($"Option value '{selection.OptionValueId}' was not found for option '{option.Name}'.");
var chosenDescription = value.Label;
var delta = value.PriceDelta ?? 0m;
switch (value.PriceDeltaKind)
{
case PriceDeltaKind.Absolute:
choiceAbsTotal += delta;
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
break;
case PriceDeltaKind.Percent:
breakdownEntries.Add(SelectionBreakdown.Percent(option, value, chosenDescription, delta));
break;
default:
throw new NotSupportedException($"Price delta kind '{value.PriceDeltaKind}' is not supported.");
}
break;
}
default:
throw new NotSupportedException($"Option data type '{option.DataType}' is not supported.");
}
}
var percentSelections = breakdownEntries.Where(e => e.Kind == BreakdownKind.Percent).ToList();
var percentTotal = 0m;
PercentScope? selectedScope = null;
foreach (var entry in percentSelections)
{
var scope = entry.OptionDefinition.PercentScope ?? PercentScope.BaseOnly;
if (selectedScope is null)
{
selectedScope = scope;
}
else if (selectedScope.Value != scope)
{
throw new InvalidOperationException("Percent options for a product must share the same percent scope.");
}
var scopeBase = scope switch
{
PercentScope.BaseOnly => basePrice,
PercentScope.NumericOnly => numericTotal,
PercentScope.BasePlusNumeric => basePrice + numericTotal,
_ => basePrice
};
var delta = scopeBase * (entry.PercentValue / 100m);
entry.Value = delta;
percentTotal += delta;
}
var final = Math.Max(
0m,
Math.Round(basePrice + numericTotal + choiceAbsTotal + percentTotal, 2, MidpointRounding.AwayFromZero));
var breakdown = breakdownEntries
.Select(e => new QuotePrice.QuoteBreakdown(
e.Option,
e.Chosen,
Math.Round(e.Value, 2, MidpointRounding.AwayFromZero)))
.ToList();
return new QuotePrice.Response(final, breakdown);
}
/// <summary>
/// Resolves a model or variant to price, including the parent model so option metadata is always available.
/// </summary>
private async Task<Product?> FindProductAsync(QuotePrice.Request request, CancellationToken cancellationToken)
{
var products = db.Products
.Where(p => p.DeletedOn == null)
.AsNoTracking()
.Include(p => p.Options).ThenInclude(o => o.Values)
.Include(p => p.Options).ThenInclude(o => o.Tiers)
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Values)
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Tiers);
if (!string.IsNullOrWhiteSpace(request.Sku))
{
var sku = request.Sku!.Trim().ToUpperInvariant();
return await products.FirstOrDefaultAsync(p => p.Sku != null && p.Sku.ToUpper() == sku, cancellationToken);
}
return await products.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
}
/// <summary>
/// Computes the price delta for numeric options with clamping, stepping, and tier evaluation.
/// </summary>
private static (decimal Delta, string ChosenDescription) HandleNumber(OptionDefinition option, QuotePrice.Selection selection)
{
if (selection.NumericValue is null)
{
throw new InvalidOperationException($"Selection for option '{option.Name}' must include a numeric value.");
}
var requested = selection.NumericValue.Value;
var clamped = option.Min.HasValue ? Math.Max(requested, option.Min.Value) : requested;
clamped = option.Max.HasValue ? Math.Min(clamped, option.Max.Value) : clamped;
if (option.Step is > 0)
{
var step = option.Step.Value;
clamped = Math.Round(clamped / step, MidpointRounding.AwayFromZero) * step;
if (option.Min.HasValue)
{
clamped = Math.Max(clamped, option.Min.Value);
}
if (option.Max.HasValue)
{
clamped = Math.Min(clamped, option.Max.Value);
}
}
var applicableTier = option.Tiers
.Where(t => t.DeletedOn == null && clamped >= t.FromInclusive && (t.ToInclusive == null || clamped <= t.ToInclusive.Value))
.OrderByDescending(t => t.FromInclusive)
.FirstOrDefault();
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
var flat = applicableTier?.FlatDelta ?? 0m;
var chosenDescription = option.Unit is { Length: > 0 }
? $"{clamped:0.##} {option.Unit}"
: clamped.ToString("0.##");
return ((rate * clamped) + flat, chosenDescription);
}
private enum BreakdownKind
{
Fixed,
Percent
}
private sealed class SelectionBreakdown
{
private SelectionBreakdown(
string option,
string chosen,
BreakdownKind kind,
OptionDefinition optionDefinition,
OptionValue? optionValue,
decimal value,
decimal percentValue)
{
Option = option;
Chosen = chosen;
Kind = kind;
OptionDefinition = optionDefinition;
OptionValue = optionValue;
Value = value;
PercentValue = percentValue;
}
public string Option { get; }
public string Chosen { get; }
public BreakdownKind Kind { get; }
public OptionDefinition OptionDefinition { get; }
public OptionValue? OptionValue { get; }
public decimal Value { get; set; }
public decimal PercentValue { get; }
public static SelectionBreakdown Fixed(string option, string chosen, decimal value) =>
new(option, chosen, BreakdownKind.Fixed, null!, null, value, 0m);
public static SelectionBreakdown Percent(OptionDefinition option, OptionValue value, string chosen, decimal percent) =>
new(option.Name, chosen, BreakdownKind.Percent, option, value, 0m, percent);
}
}