using Microsoft.EntityFrameworkCore; using Prefab.Catalog.Data; using Prefab.Catalog.Domain.Entities; using Prefab.Shared.Catalog.Products; namespace Prefab.Catalog.Domain.Services; /// /// Calculates price quotes for configured catalog products by combining base price, numeric adjustments, and percent deltas. /// public interface IPricingService { /// /// Produces a unit price and line-item breakdown for a shopper’s selections. /// Task QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default); } /// /// Default Catalog pricing implementation that reads product/option data on demand and applies the PercentScope rules. /// public sealed class PricingService(IModuleDbReadOnly db) : IPricingService { public async Task 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(); 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); } /// /// Resolves a model or variant to price, including the parent model so option metadata is always available. /// private async Task 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); } /// /// Computes the price delta for numeric options with clamping, stepping, and tier evaluation. /// 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); } }