Files
2025-10-27 17:39:18 -04:00

998 lines
49 KiB
C#

using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Prefab.Catalog.Data.SeedSupport;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Services;
using Prefab.Data.Entities;
using Prefab.Data.Seeder;
namespace Prefab.Catalog.Data;
public class Seeder : PrefabDbSeeder<Seeder, IModuleDb>
{
private static readonly CategorySeed[] Categories =
{
new("ceiling-supports", "Ceiling Supports", 1, true),
new("boxes-and-covers", "Boxes & Covers", 2, true),
new("prefab-assemblies", "Prefabricated Assemblies", 3, true),
new("rod-strut-hardware", "Rod & Strut Hardware", 4, false)
};
private const string LoadGuidanceKey = "catalog.product.safety.load_guidance";
private const string RequireGroundTailKey = "catalog.product.safety.require_ground_tail";
private const string CatalogDocsKey = "catalog.product.docs";
private const string FaceStyleAttributeName = "face_style";
private const string FaceStyleValue = "Decora";
private const string BoxSizeAttributeName = "box_size_in";
private const string BoxDepthAttributeName = "box_depth_in";
private const string BoxCapacityAttributeName = "box_capacity";
private const string BoxConstructionAttributeName = "box_construction";
private const string BoxSideKnockoutsAttributeName = "box_side_knockouts";
private const string BoxBottomKnockoutsAttributeName = "box_bottom_knockouts";
private const string BoxMaterialAttributeName = "box_material";
private const string AreaClassificationAttributeName = "area_classification";
private const string RodLengthAttributeName = "rod_length_in";
private const string RodDiameterAttributeName = "rod_diameter_in";
private const string RodFinishAttributeName = "rod_finish";
private const string RodMaterialAttributeName = "rod_material";
private const string BoxesCoversDocTitle = "Eaton 4\" Steel Square Overview";
private const string BoxesCoversDocUrl = "https://www.eaton.com/us/en-us/catalog/crouse-hinds/4-inch-square-steel-boxes.html";
private const string RodStrutDocTitle = "Eaton Channel Nuts, U-Bolts, ATR & Hardware";
private const string RodStrutDocUrl = "https://www.eaton.com/us/en-us/catalog/crouse-hinds/channel-nuts-u-bolts-threaded-rod.html";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
protected override Task ApplyViews(IModuleDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
Task.CompletedTask;
protected override async Task SeedData(IModuleDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var logger = serviceProvider.GetRequiredService<ILogger<Seeder>>();
var checker = serviceProvider.GetRequiredService<IUniqueChecker>();
logger.LogInformation("Seeding catalog MVP products and categories.");
var categories = await EnsureCategoriesAsync(db, checker, logger, cancellationToken);
var ceilingSupports = categories["ceiling-supports"];
var boxesCovers = categories["boxes-and-covers"];
var prefabAssemblies = categories["prefab-assemblies"];
var rodStrutHardware = categories["rod-strut-hardware"];
var faceStyleDefinition = await EnsureAttributeDefinitionAsync(db, FaceStyleAttributeName, AttributeDataType.Text, cancellationToken);
await EnsureCeilingTBarAssemblyAsync(db, checker, ceilingSupports, faceStyleDefinition, cancellationToken);
await EnsureFanHangerAssemblyAsync(db, checker, ceilingSupports, cancellationToken);
await EnsureFourInchSteelSquareAsync(db, checker, boxesCovers, cancellationToken);
await EnsureAllThreadedRodAsync(db, checker, rodStrutHardware, cancellationToken);
await EnsureRaisedDeviceAssemblyAsync(db, checker, prefabAssemblies, faceStyleDefinition, cancellationToken);
await EnsurePigtailedDeviceAsync(db, checker, prefabAssemblies, faceStyleDefinition, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Catalog MVP seeding complete.");
}
private static async Task<Dictionary<string, Category>> EnsureCategoriesAsync(
IModuleDb db,
IUniqueChecker checker,
ILogger logger,
CancellationToken cancellationToken)
{
var categories = new Dictionary<string, Category>(StringComparer.OrdinalIgnoreCase);
foreach (var seed in Categories)
{
var existing = await db.Categories
.FirstOrDefaultAsync(c => c.Slug == seed.Slug, cancellationToken);
if (existing is null)
{
var category = await Category.Create(seed.Name, null, checker, cancellationToken);
category.ConfigureMetadata(seed.Slug, seed.DisplayOrder, seed.IsFeatured, null, null);
db.Categories.Add(category);
categories[seed.Slug] = category;
logger.LogInformation("Created category {Slug}.", seed.Slug);
continue;
}
if (!string.Equals(existing.Name, seed.Name, StringComparison.Ordinal))
{
await existing.Rename(seed.Name, checker, cancellationToken);
}
existing.ConfigureMetadata(seed.Slug, seed.DisplayOrder, seed.IsFeatured, existing.HeroImageUrl, existing.Icon);
categories[seed.Slug] = existing;
}
return categories;
}
private static async Task EnsureCeilingTBarAssemblyAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
AttributeDefinition faceStyleDefinition,
CancellationToken cancellationToken)
{
const string slug = "ceiling-tbar-box-assembly";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "Ceiling T-Bar Box Assembly",
slug,
description: "Factory-assembled T-Bar box light assembly with flexible bracket options.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "Ceiling T-Bar Box Assembly", StringComparison.Ordinal))
{
await product.Rename("Ceiling T-Bar Box Assembly", checker, cancellationToken);
}
product.SetBasePrice(39.00m);
product.ChangeDescription("Factory-assembled T-Bar box light assembly with flexible bracket options.");
product.AssignToCategory(category.Id, true);
var boxShape = await EnsureChoiceOptionAsync(product, checker, "box_shape", "Box Shape", isVariantAxis: true, cancellationToken: cancellationToken);
var octagon = EnsureChoiceValue(boxShape, "octagon", "Octagon", 0m, PriceDeltaKind.Absolute);
var square = EnsureChoiceValue(boxShape, "square", "Square", 0m, PriceDeltaKind.Absolute);
var boxDepth = await EnsureChoiceOptionAsync(product, checker, "box_depth", "Box Depth", isVariantAxis: true, cancellationToken: cancellationToken);
var depth15 = EnsureChoiceValue(boxDepth, "1_5", "1.5 in", 0m, PriceDeltaKind.Absolute);
var depth2125 = EnsureChoiceValue(boxDepth, "2_125", "2.125 in", 0m, PriceDeltaKind.Absolute);
var blankCover = await EnsureChoiceOptionAsync(product, checker, "blank_cover", "Blank Cover", cancellationToken: cancellationToken);
EnsureChoiceValue(blankCover, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(blankCover, "yes", "Yes", 1.25m, PriceDeltaKind.Absolute);
var circuitMarking = await EnsureChoiceOptionAsync(product, checker, "circuit_marking", "Circuit Marking", cancellationToken: cancellationToken);
EnsureChoiceValue(circuitMarking, "none", "None", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(circuitMarking, "laser", "Laser", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(circuitMarking, "stamped", "Stamped", 0m, PriceDeltaKind.Absolute);
var groundTail = await EnsureChoiceOptionAsync(product, checker, "ground_tail", "Ground Tail", cancellationToken: cancellationToken);
EnsureChoiceValue(groundTail, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(groundTail, "yes", "Yes", 0.75m, PriceDeltaKind.Absolute);
var boxColor = await EnsureChoiceOptionAsync(product, checker, "box_color", "Box Color", cancellationToken: cancellationToken);
EnsureChoiceValue(boxColor, "white", "White", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(boxColor, "red", "Red", 0.50m, PriceDeltaKind.Absolute);
var bracketType = await EnsureChoiceOptionAsync(product, checker, "bracket_type", "Bracket Type", cancellationToken: cancellationToken);
var fixedBracket = EnsureChoiceValue(bracketType, "fixed_24in", "Fixed 24\"", 0m, PriceDeltaKind.Absolute);
var adjustableBracket = EnsureChoiceValue(bracketType, "adjustable", "Adjustable", 2.00m, PriceDeltaKind.Absolute);
var dedicatedDropwire = await EnsureChoiceOptionAsync(product, checker, "dedicated_dropwire", "Dedicated Dropwire", cancellationToken: cancellationToken);
EnsureChoiceValue(dedicatedDropwire, "no", "No", 0m, PriceDeltaKind.Absolute);
var dedicatedYes = EnsureChoiceValue(dedicatedDropwire, "yes", "Yes", 3.00m, PriceDeltaKind.Absolute);
var flexWhip = await EnsureNumberOptionAsync(
product,
checker,
"flex_whip_length",
"Flex Whip Length",
unit: "ft",
min: 1m,
max: 100m,
step: 1m,
pricePerUnit: 0.85m,
cancellationToken);
AddTierIfMissing(flexWhip, 26m, null, 0.60m, 5.00m);
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionDefinition,
boxColor.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForDefinition(product, boxColor, RuleEffect.Show, RuleMode.All)
.WhenEquals(boxShape, square)
.Build());
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionDefinition,
dedicatedDropwire.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForDefinition(product, dedicatedDropwire, RuleEffect.Show, RuleMode.All)
.WhenEquals(bracketType, adjustableBracket)
.Build());
await UpsertGenericAttributeAsync(db, product, LoadGuidanceKey, "Adjustable bracket assemblies require an independent support.", cancellationToken);
await EnsureVariantAsync(product, checker, "TBA-OCT-15", $"Octagon / {depth15.Label}", 39.00m, cancellationToken, (boxShape, octagon), (boxDepth, depth15));
await EnsureVariantAsync(product, checker, "TBA-OCT-2125", $"Octagon / {depth2125.Label}", 39.00m, cancellationToken, (boxShape, octagon), (boxDepth, depth2125));
await EnsureVariantAsync(product, checker, "TBA-SQR-15", $"Square / {depth15.Label}", 39.00m, cancellationToken, (boxShape, square), (boxDepth, depth15));
await EnsureVariantAsync(product, checker, "TBA-SQR-2125", $"Square / {depth2125.Label}", 39.00m, cancellationToken, (boxShape, square), (boxDepth, depth2125));
}
private static async Task EnsureFanHangerAssemblyAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
CancellationToken cancellationToken)
{
const string slug = "fan-hanger-assembly";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "Fan Hanger Assembly",
slug,
description: "Pre-assembled fan hanger with KwikWire suspension kits.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "Fan Hanger Assembly", StringComparison.Ordinal))
{
await product.Rename("Fan Hanger Assembly", checker, cancellationToken);
}
product.SetBasePrice(42.00m);
product.ChangeDescription("Pre-assembled fan hanger with KwikWire suspension kits.");
product.AssignToCategory(category.Id, true);
var suspensionKit = await EnsureChoiceOptionAsync(product, checker, "suspension_kit", "Suspension Kit", isVariantAxis: true, cancellationToken: cancellationToken);
var kit10 = EnsureChoiceValue(suspensionKit, "kwikwire_10ft", "KwikWire 10ft", 0m, PriceDeltaKind.Absolute);
var kit15 = EnsureChoiceValue(suspensionKit, "kwikwire_15ft", "KwikWire 15ft", 0m, PriceDeltaKind.Absolute);
var boxDepth = await EnsureChoiceOptionAsync(product, checker, "box_depth", "Box Depth", isVariantAxis: true, cancellationToken: cancellationToken);
var depth15 = EnsureChoiceValue(boxDepth, "1_5", "1.5 in", 0m, PriceDeltaKind.Absolute);
var depth2125 = EnsureChoiceValue(boxDepth, "2_125", "2.125 in", 0m, PriceDeltaKind.Absolute);
var groundTail = await EnsureChoiceOptionAsync(product, checker, "ground_tail", "Ground Tail", cancellationToken: cancellationToken);
EnsureChoiceValue(groundTail, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(groundTail, "yes", "Yes", 0.75m, PriceDeltaKind.Absolute);
var blankCover = await EnsureChoiceOptionAsync(product, checker, "blank_cover", "Blank Cover", cancellationToken: cancellationToken);
EnsureChoiceValue(blankCover, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(blankCover, "yes", "Yes", 1.25m, PriceDeltaKind.Absolute);
var dedicatedDropwire = await EnsureChoiceOptionAsync(product, checker, "dedicated_dropwire", "Dedicated Dropwire", cancellationToken: cancellationToken);
EnsureChoiceValue(dedicatedDropwire, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(dedicatedDropwire, "yes", "Yes", 3.00m, PriceDeltaKind.Absolute);
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionDefinition,
dedicatedDropwire.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForDefinition(product, dedicatedDropwire, RuleEffect.Show, RuleMode.All)
.WhenNotIn(suspensionKit, kit10, kit15)
.Build());
await UpsertGenericAttributeAsync(db, product, RequireGroundTailKey, bool.TrueString.ToLowerInvariant(), cancellationToken);
await EnsureVariantAsync(product, checker, "FH-KW10-15", $"KwikWire 10ft / {depth15.Label}", 42.00m, cancellationToken, (suspensionKit, kit10), (boxDepth, depth15));
await EnsureVariantAsync(product, checker, "FH-KW10-2125", $"KwikWire 10ft / {depth2125.Label}", 42.00m, cancellationToken, (suspensionKit, kit10), (boxDepth, depth2125));
await EnsureVariantAsync(product, checker, "FH-KW15-15", $"KwikWire 15ft / {depth15.Label}", 42.00m, cancellationToken, (suspensionKit, kit15), (boxDepth, depth15));
await EnsureVariantAsync(product, checker, "FH-KW15-2125", $"KwikWire 15ft / {depth2125.Label}", 42.00m, cancellationToken, (suspensionKit, kit15), (boxDepth, depth2125));
}
private static async Task EnsureFourInchSteelSquareAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
CancellationToken cancellationToken)
{
const string slug = "four-inch-steel-square";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "4\" Square Boxes & Rings (Steel)",
slug,
description: "Steel 4\" square drawn boxes and raised rings for commercial rough-in work.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "4\" Square Boxes & Rings (Steel)", StringComparison.Ordinal))
{
await product.Rename("4\" Square Boxes & Rings (Steel)", checker, cancellationToken);
}
product.SetBasePrice(0m);
product.ChangeDescription("Steel 4\" square drawn boxes and raised rings for commercial rough-in work.");
product.AssignToCategory(category.Id, true);
await EnsureVariantAsync(product, checker, "TP412PF", "4\" Square Outlet Box - 1.5\" Depth (Drawn) with Pigtail", 4.79m, cancellationToken);
await EnsureVariantAsync(product, checker, "TP428", "4\" Square Extension Ring - 1.5\" Depth", 3.59m, cancellationToken);
await EnsureVariantAsync(product, checker, "TP833", "4\" Square Extension Ring - 1.5\" Depth, Air-Plenum Rated", 5.29m, cancellationToken);
await EnsureVariantAsync(product, checker, "TP480", "4\" Square Mud Ring - 1-Device, Flat", 2.29m, cancellationToken);
var boxSizeDefinition = await EnsureAttributeDefinitionAsync(db, BoxSizeAttributeName, AttributeDataType.Number, cancellationToken, unit: "in");
var boxDepthDefinition = await EnsureAttributeDefinitionAsync(db, BoxDepthAttributeName, AttributeDataType.Number, cancellationToken, unit: "in");
var boxCapacityDefinition = await EnsureAttributeDefinitionAsync(db, BoxCapacityAttributeName, AttributeDataType.Text, cancellationToken);
var boxConstructionDefinition = await EnsureAttributeDefinitionAsync(db, BoxConstructionAttributeName, AttributeDataType.Enum, cancellationToken);
var boxSideKnockoutsDefinition = await EnsureAttributeDefinitionAsync(db, BoxSideKnockoutsAttributeName, AttributeDataType.Text, cancellationToken);
var boxBottomKnockoutsDefinition = await EnsureAttributeDefinitionAsync(db, BoxBottomKnockoutsAttributeName, AttributeDataType.Text, cancellationToken);
var boxMaterialDefinition = await EnsureAttributeDefinitionAsync(db, BoxMaterialAttributeName, AttributeDataType.Enum, cancellationToken);
var areaClassificationDefinition = await EnsureAttributeDefinitionAsync(db, AreaClassificationAttributeName, AttributeDataType.Enum, cancellationToken);
await UpsertProductDocsAsync(
db,
product,
new[] { (BoxesCoversDocTitle, BoxesCoversDocUrl) },
cancellationToken);
var variants = product.Variants
.Where(variant => variant.DeletedOn == null && !string.IsNullOrWhiteSpace(variant.Sku))
.ToDictionary(variant => variant.Sku!, StringComparer.OrdinalIgnoreCase);
if (variants.TryGetValue("TP412PF", out var tp412pf))
{
tp412pf.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null);
tp412pf.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null);
tp412pf.UpsertSpec(boxCapacityDefinition.Id, "22 cu in", null, null, null);
tp412pf.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn");
tp412pf.UpsertSpec(boxSideKnockoutsDefinition.Id, "(8) 3/4 in", null, null, null);
tp412pf.UpsertSpec(boxBottomKnockoutsDefinition.Id, "(3) 1/2 in, (2) 3/4 in", null, null, null);
tp412pf.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel");
}
if (variants.TryGetValue("TP428", out var tp428))
{
tp428.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null);
tp428.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null);
tp428.UpsertSpec(boxCapacityDefinition.Id, "22 cu in", null, null, null);
tp428.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn");
tp428.UpsertSpec(boxSideKnockoutsDefinition.Id, "(8) 3/4 in", null, null, null);
tp428.UpsertSpec(boxBottomKnockoutsDefinition.Id, "(3) 1/2 in, (2) 3/4 in", null, null, null);
tp428.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel");
}
if (variants.TryGetValue("TP833", out var tp833))
{
tp833.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null);
tp833.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null);
tp833.UpsertSpec(boxCapacityDefinition.Id, "30.3 cu in", null, null, null);
tp833.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn");
tp833.UpsertSpec(boxSideKnockoutsDefinition.Id, "No knockouts", null, null, null);
tp833.UpsertSpec(boxBottomKnockoutsDefinition.Id, "No knockouts", null, null, null);
tp833.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel");
tp833.UpsertSpec(areaClassificationDefinition.Id, "Air Plenum", null, null, "air-plenum");
}
if (variants.TryGetValue("TP480", out var tp480))
{
tp480.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null);
tp480.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null);
tp480.UpsertSpec(boxCapacityDefinition.Id, "N/A", null, null, null);
tp480.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn");
tp480.UpsertSpec(boxSideKnockoutsDefinition.Id, "No knockouts", null, null, null);
tp480.UpsertSpec(boxBottomKnockoutsDefinition.Id, "No knockouts", null, null, null);
tp480.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel");
tp480.UpsertSpec(areaClassificationDefinition.Id, "Air Plenum", null, null, "air-plenum");
}
}
private static async Task EnsureAllThreadedRodAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
CancellationToken cancellationToken)
{
const string slug = "all-threaded-rod";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "Threaded Rod (All-Thread)",
slug,
description: "All-thread rod family with stocked diameters, lengths, and finishes for trapeze hangers and hardware kits.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "Threaded Rod (All-Thread)", StringComparison.Ordinal))
{
await product.Rename("Threaded Rod (All-Thread)", checker, cancellationToken);
}
product.SetBasePrice(0m);
product.ChangeDescription("All-thread rod family with stocked diameters, lengths, finishes, and optional hardware packs.");
product.AssignToCategory(category.Id, true);
await UpsertGenericAttributeAsync(db, product, LoadGuidanceKey, "Verify load tables; size and finish affect ratings.", cancellationToken);
await UpsertProductDocsAsync(
db,
product,
new[] { (RodStrutDocTitle, RodStrutDocUrl) },
cancellationToken);
var lengthDefinition = await EnsureAttributeDefinitionAsync(db, RodLengthAttributeName, AttributeDataType.Number, cancellationToken, unit: "in");
var diameterDefinition = await EnsureAttributeDefinitionAsync(db, RodDiameterAttributeName, AttributeDataType.Number, cancellationToken, unit: "in");
var finishDefinition = await EnsureAttributeDefinitionAsync(db, RodFinishAttributeName, AttributeDataType.Enum, cancellationToken);
var materialDefinition = await EnsureAttributeDefinitionAsync(db, RodMaterialAttributeName, AttributeDataType.Enum, cancellationToken);
var diameterOption = await EnsureChoiceOptionAsync(product, checker, "diameter", "Diameter", isVariantAxis: true, cancellationToken: cancellationToken);
var diameterQuarter = EnsureChoiceValue(diameterOption, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute);
var diameterThreeEighths = EnsureChoiceValue(diameterOption, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute);
var diameterHalf = EnsureChoiceValue(diameterOption, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute);
var lengthOption = await EnsureChoiceOptionAsync(product, checker, "length", "Length", isVariantAxis: true, cancellationToken: cancellationToken);
var length36 = EnsureChoiceValue(lengthOption, "36_in", "36\" (3 ft)", 0m, PriceDeltaKind.Absolute);
var length72 = EnsureChoiceValue(lengthOption, "72_in", "72\" (6 ft)", 0m, PriceDeltaKind.Absolute);
var length120 = EnsureChoiceValue(lengthOption, "120_in", "120\" (10 ft)", 0m, PriceDeltaKind.Absolute);
var length144 = EnsureChoiceValue(lengthOption, "144_in", "144\" (12 ft)", 0m, PriceDeltaKind.Absolute);
var finishOption = await EnsureChoiceOptionAsync(product, checker, "finish", "Finish", isVariantAxis: true, cancellationToken: cancellationToken);
var finishZinc = EnsureChoiceValue(finishOption, "zinc", "Zinc-Plated", 0m, PriceDeltaKind.Absolute);
var finishSs304 = EnsureChoiceValue(finishOption, "ss304", "Stainless 304", 0m, PriceDeltaKind.Absolute);
var hardwarePackA = await EnsureChoiceOptionAsync(product, checker, "hardware_pack_a", "Hardware Pack A", cancellationToken: cancellationToken);
var packAYes = EnsureChoiceValue(hardwarePackA, "yes", "Yes", 12.00m, PriceDeltaKind.Absolute);
var packANo = EnsureChoiceValue(hardwarePackA, "no", "No", 0m, PriceDeltaKind.Absolute);
var packARodCoupler = await EnsureChoiceOptionAsync(product, checker, "pack_a_rod_coupler", "Pack A Rod Coupler", cancellationToken: cancellationToken);
EnsureChoiceValue(packARodCoupler, "none", "None", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packARodCoupler, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packARodCoupler, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packARodCoupler, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute);
var packANuts = await EnsureChoiceOptionAsync(product, checker, "pack_a_nuts", "Pack A Nuts", cancellationToken: cancellationToken);
EnsureChoiceValue(packANuts, "hex", "Hex", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packANuts, "jam", "Jam", 0m, PriceDeltaKind.Absolute);
var packAWashers = await EnsureChoiceOptionAsync(product, checker, "pack_a_washers", "Pack A Washers", cancellationToken: cancellationToken);
EnsureChoiceValue(packAWashers, "flat", "Flat", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packAWashers, "lock", "Lock", 0m, PriceDeltaKind.Absolute);
var packABeamClamp = await EnsureChoiceOptionAsync(product, checker, "pack_a_beam_clamp", "Pack A Beam Clamp", cancellationToken: cancellationToken);
EnsureChoiceValue(packABeamClamp, "bc1", "BC-1", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packABeamClamp, "bc2", "BC-2", 0m, PriceDeltaKind.Absolute);
var packASammy = await EnsureChoiceOptionAsync(product, checker, "pack_a_sammy", "Pack A Sammy", cancellationToken: cancellationToken);
EnsureChoiceValue(packASammy, "wood", "Wood", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packASammy, "steel", "Steel", 0m, PriceDeltaKind.Absolute);
var hardwarePackB = await EnsureChoiceOptionAsync(product, checker, "hardware_pack_b", "Hardware Pack B", cancellationToken: cancellationToken);
var packBYes = EnsureChoiceValue(hardwarePackB, "yes", "Yes", 12.00m, PriceDeltaKind.Absolute);
var packBNo = EnsureChoiceValue(hardwarePackB, "no", "No", 0m, PriceDeltaKind.Absolute);
_ = packANo;
_ = packBNo;
_ = length72;
var packBRodCoupler = await EnsureChoiceOptionAsync(product, checker, "pack_b_rod_coupler", "Pack B Rod Coupler", cancellationToken: cancellationToken);
EnsureChoiceValue(packBRodCoupler, "none", "None", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBRodCoupler, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBRodCoupler, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBRodCoupler, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute);
var packBNuts = await EnsureChoiceOptionAsync(product, checker, "pack_b_nuts", "Pack B Nuts", cancellationToken: cancellationToken);
EnsureChoiceValue(packBNuts, "hex", "Hex", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBNuts, "jam", "Jam", 0m, PriceDeltaKind.Absolute);
var packBWashers = await EnsureChoiceOptionAsync(product, checker, "pack_b_washers", "Pack B Washers", cancellationToken: cancellationToken);
EnsureChoiceValue(packBWashers, "flat", "Flat", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBWashers, "lock", "Lock", 0m, PriceDeltaKind.Absolute);
var packBBeamClamp = await EnsureChoiceOptionAsync(product, checker, "pack_b_beam_clamp", "Pack B Beam Clamp", cancellationToken: cancellationToken);
EnsureChoiceValue(packBBeamClamp, "bc1", "BC-1", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBBeamClamp, "bc2", "BC-2", 0m, PriceDeltaKind.Absolute);
var packBSammy = await EnsureChoiceOptionAsync(product, checker, "pack_b_sammy", "Pack B Sammy", cancellationToken: cancellationToken);
EnsureChoiceValue(packBSammy, "wood", "Wood", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(packBSammy, "steel", "Steel", 0m, PriceDeltaKind.Absolute);
foreach (var option in new[] { packARodCoupler, packANuts, packAWashers, packABeamClamp, packASammy })
{
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionDefinition,
option.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForDefinition(product, option, RuleEffect.Show, RuleMode.All)
.WhenEquals(hardwarePackA, packAYes)
.Build());
}
foreach (var option in new[] { packBRodCoupler, packBNuts, packBWashers, packBBeamClamp, packBSammy })
{
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionDefinition,
option.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForDefinition(product, option, RuleEffect.Show, RuleMode.All)
.WhenEquals(hardwarePackB, packBYes)
.Build());
}
await EnsureVariantAsync(
product,
checker,
"ATR-025-36-SS",
"All-Thread Rod - 1/4\" x 36\", Stainless 304",
8.50m,
cancellationToken,
(diameterOption, diameterQuarter),
(lengthOption, length36),
(finishOption, finishSs304));
await EnsureVariantAsync(
product,
checker,
"ATR-038-144-SS",
"All-Thread Rod - 3/8\" x 144\", Stainless 304",
34.00m,
cancellationToken,
(diameterOption, diameterThreeEighths),
(lengthOption, length144),
(finishOption, finishSs304));
await EnsureVariantAsync(
product,
checker,
"ATR-050-120-ZN",
"All-Thread Rod - 1/2\" x 120\", Zinc-Plated",
22.00m,
cancellationToken,
(diameterOption, diameterHalf),
(lengthOption, length120),
(finishOption, finishZinc));
var variants = product.Variants
.Where(variant => variant.DeletedOn == null && !string.IsNullOrWhiteSpace(variant.Sku))
.ToDictionary(variant => variant.Sku!, StringComparer.OrdinalIgnoreCase);
if (variants.TryGetValue("ATR-025-36-SS", out var atr02536))
{
atr02536.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.25m), 0.25m, diameterDefinition.Unit, null);
atr02536.UpsertSpec(lengthDefinition.Id, FormatDecimal(36m), 36m, lengthDefinition.Unit, null);
atr02536.UpsertSpec(finishDefinition.Id, "Stainless 304", null, null, "ss304");
atr02536.UpsertSpec(materialDefinition.Id, "Stainless 304", null, null, "stainless_304");
}
if (variants.TryGetValue("ATR-038-144-SS", out var atr038144))
{
atr038144.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.375m), 0.375m, diameterDefinition.Unit, null);
atr038144.UpsertSpec(lengthDefinition.Id, FormatDecimal(144m), 144m, lengthDefinition.Unit, null);
atr038144.UpsertSpec(finishDefinition.Id, "Stainless 304", null, null, "ss304");
atr038144.UpsertSpec(materialDefinition.Id, "Stainless 304", null, null, "stainless_304");
}
if (variants.TryGetValue("ATR-050-120-ZN", out var atr050120))
{
atr050120.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.5m), 0.5m, diameterDefinition.Unit, null);
atr050120.UpsertSpec(lengthDefinition.Id, FormatDecimal(120m), 120m, lengthDefinition.Unit, null);
atr050120.UpsertSpec(finishDefinition.Id, "Zinc-Plated", null, null, "zinc");
}
}
private static async Task EnsureRaisedDeviceAssemblyAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
AttributeDefinition faceStyleDefinition,
CancellationToken cancellationToken)
{
const string slug = "raised-device-assembly";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "Raised Device Assembly",
slug,
description: "Raised device assembly with configurable device, lead, and wiring options.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "Raised Device Assembly", StringComparison.Ordinal))
{
await product.Rename("Raised Device Assembly", checker, cancellationToken);
}
product.SetBasePrice(29.00m);
product.ChangeDescription("Raised device assembly with configurable device, lead, and wiring options.");
product.AssignToCategory(category.Id, true);
var deviceType = await EnsureChoiceOptionAsync(product, checker, "device_type", "Device Type", cancellationToken: cancellationToken);
var duplex15 = EnsureChoiceValue(deviceType, "duplex15", "Duplex 15A", 0m, PriceDeltaKind.Absolute);
var duplex20 = EnsureChoiceValue(deviceType, "duplex20", "Duplex 20A", 0m, PriceDeltaKind.Absolute);
var decora15 = EnsureChoiceValue(deviceType, "decora15", "Decora 15A", 0m, PriceDeltaKind.Absolute);
var gfci15 = EnsureChoiceValue(deviceType, "gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute);
var gfci20 = EnsureChoiceValue(deviceType, "gfci20", "GFCI 20A", 0m, PriceDeltaKind.Absolute);
var deviceColor = await EnsureChoiceOptionAsync(product, checker, "device_color", "Device Color", cancellationToken: cancellationToken);
EnsureChoiceValue(deviceColor, "white", "White", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(deviceColor, "ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute);
EnsureChoiceValue(deviceColor, "gray", "Gray", 0.50m, PriceDeltaKind.Absolute);
var grade = await EnsureChoiceOptionAsync(product, checker, "grade", "Grade", cancellationToken: cancellationToken);
EnsureChoiceValue(grade, "res", "Residential", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(grade, "spec", "Spec", 1.00m, PriceDeltaKind.Absolute);
EnsureChoiceValue(grade, "xhd", "X-Heavy Duty", 2.00m, PriceDeltaKind.Absolute);
var boxSize = await EnsureChoiceOptionAsync(product, checker, "box_size", "Box Size", cancellationToken: cancellationToken);
EnsureChoiceValue(boxSize, "4sq", "4\" Square", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(boxSize, "4_11_16", "4-11/16\" Square", 0.75m, PriceDeltaKind.Absolute);
var leadLength = await EnsureNumberOptionAsync(
product,
checker,
"lead_length",
"Lead Length",
unit: "ft",
min: 1m,
max: 100m,
step: 1m,
pricePerUnit: 0.70m,
cancellationToken);
AddTierIfMissing(leadLength, 26m, null, 0.60m, 5.00m);
var wireSize = await EnsureChoiceOptionAsync(product, checker, "wire_size", "Wire Size", cancellationToken: cancellationToken);
var awg14Raised = EnsureChoiceValue(wireSize, "awg14", "AWG 14", 0m, PriceDeltaKind.Percent);
_ = EnsureChoiceValue(wireSize, "awg12", "AWG 12", 15m, PriceDeltaKind.Percent);
wireSize.SetPercentScope(PercentScope.NumericOnly);
var wagos = await EnsureChoiceOptionAsync(product, checker, "wagos", "Wago Connectors", cancellationToken: cancellationToken);
EnsureChoiceValue(wagos, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(wagos, "yes", "Yes", 2.00m, PriceDeltaKind.Absolute);
AddRuleIfMissing(
product,
OptionRuleTargetKind.OptionValue,
awg14Raised.Id,
RuleEffect.Show,
() => OptionRuleSetBuilder
.ForValue(product, awg14Raised, RuleEffect.Show, RuleMode.All)
.WhenNotIn(deviceType, duplex20, gfci20)
.Build());
product.UpsertSpec(faceStyleDefinition.Id, FaceStyleValue, null, null, null);
}
private static async Task EnsurePigtailedDeviceAsync(
IModuleDb db,
IUniqueChecker checker,
Category category,
AttributeDefinition faceStyleDefinition,
CancellationToken cancellationToken)
{
const string slug = "pigtailed-device";
var product = await LoadModelAsync(db, slug, cancellationToken);
if (product is null)
{
product = await Product.CreateModel(
name: "Pigtailed Device",
slug,
description: "Pigtailed device assembly with configurable wiring.",
checker,
cancellationToken);
db.Products.Add(product);
}
else if (!string.Equals(product.Name, "Pigtailed Device", StringComparison.Ordinal))
{
await product.Rename("Pigtailed Device", checker, cancellationToken);
}
product.SetBasePrice(24.00m);
product.ChangeDescription("Pigtailed device assembly with configurable wiring.");
product.AssignToCategory(category.Id, true);
var deviceType = await EnsureChoiceOptionAsync(product, checker, "device_type", "Device Type", cancellationToken: cancellationToken);
EnsureChoiceValue(deviceType, "duplex15", "Duplex 15A", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(deviceType, "decora15", "Decora 15A", 0m, PriceDeltaKind.Absolute);
var gfci15 = EnsureChoiceValue(deviceType, "gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute);
var deviceColor = await EnsureChoiceOptionAsync(product, checker, "device_color", "Device Color", cancellationToken: cancellationToken);
EnsureChoiceValue(deviceColor, "white", "White", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(deviceColor, "ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute);
var grade = await EnsureChoiceOptionAsync(product, checker, "grade", "Grade", cancellationToken: cancellationToken);
EnsureChoiceValue(grade, "res", "Residential", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(grade, "spec", "Spec", 1.00m, PriceDeltaKind.Absolute);
var leadLength = await EnsureNumberOptionAsync(
product,
checker,
"lead_length",
"Lead Length",
unit: "ft",
min: 1m,
max: 100m,
step: 1m,
pricePerUnit: 0.55m,
cancellationToken);
AddTierIfMissing(leadLength, 50m, null, 0.45m, 4.00m);
var wireSize = await EnsureChoiceOptionAsync(product, checker, "wire_size", "Wire Size", cancellationToken: cancellationToken);
var awg14Pigtailed = EnsureChoiceValue(wireSize, "awg14", "AWG 14", 0m, PriceDeltaKind.Percent);
_ = EnsureChoiceValue(wireSize, "awg12", "AWG 12", 15m, PriceDeltaKind.Percent);
wireSize.SetPercentScope(PercentScope.NumericOnly);
var wagos = await EnsureChoiceOptionAsync(product, checker, "wagos", "Wago Connectors", cancellationToken: cancellationToken);
EnsureChoiceValue(wagos, "no", "No", 0m, PriceDeltaKind.Absolute);
EnsureChoiceValue(wagos, "yes", "Yes", 2.00m, PriceDeltaKind.Absolute);
product.UpsertSpec(faceStyleDefinition.Id, FaceStyleValue, null, null, null);
}
private static async Task<Product?> LoadModelAsync(IModuleDb db, string slug, CancellationToken cancellationToken) =>
await db.Products
.Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model && p.Slug == slug)
.Include(p => p.Options).ThenInclude(o => o.Values)
.Include(p => p.Options).ThenInclude(o => o.Tiers)
.Include(p => p.RuleSets).ThenInclude(r => r.Conditions)
.Include(p => p.Variants).ThenInclude(v => v.AxisValues)
.Include(p => p.Attributes)
.Include(p => p.Categories)
.FirstOrDefaultAsync(cancellationToken);
private static async Task<OptionDefinition> EnsureChoiceOptionAsync(
Product product,
IUniqueChecker checker,
string code,
string name,
bool isVariantAxis = false,
PercentScope? percentScope = null,
CancellationToken cancellationToken = default)
{
var existing = product.Options.FirstOrDefault(o =>
o.DeletedOn == null && string.Equals(o.Code, code, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
if (percentScope.HasValue)
{
existing.SetPercentScope(percentScope.Value);
}
return existing;
}
return await OptionDefinition.CreateChoice(product, code, name, checker, isVariantAxis, percentScope, cancellationToken);
}
private static async Task<OptionDefinition> EnsureNumberOptionAsync(
Product product,
IUniqueChecker checker,
string code,
string name,
string unit,
decimal? min,
decimal? max,
decimal? step,
decimal? pricePerUnit,
CancellationToken cancellationToken)
{
var existing = product.Options.FirstOrDefault(o =>
o.DeletedOn == null && string.Equals(o.Code, code, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
return existing;
}
return await OptionDefinition.CreateNumber(
product,
code,
name,
unit,
min,
max,
step,
pricePerUnit,
checker,
cancellationToken: cancellationToken);
}
private static OptionValue EnsureChoiceValue(
OptionDefinition option,
string code,
string label,
decimal priceDelta,
PriceDeltaKind deltaKind)
{
var existing = option.Values.FirstOrDefault(v =>
v.DeletedOn == null && string.Equals(v.Code, code, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
option.ChangeValue(existing.Id, label, priceDelta, deltaKind);
return existing;
}
return option.AddValue(code, label, priceDelta, deltaKind);
}
private static void AddTierIfMissing(
OptionDefinition option,
decimal fromInclusive,
decimal? toInclusive,
decimal unitRate,
decimal? flatDelta)
{
var existing = option.Tiers.FirstOrDefault(tier =>
tier.DeletedOn == null &&
tier.FromInclusive == fromInclusive &&
Nullable.Equals(tier.ToInclusive, toInclusive));
if (existing is not null)
{
option.ChangeTier(existing.Id, fromInclusive, toInclusive, unitRate, flatDelta);
return;
}
option.AddTier(fromInclusive, toInclusive, unitRate, flatDelta);
}
private static async Task EnsureVariantAsync(
Product model,
IUniqueChecker checker,
string sku,
string variantName,
decimal price,
CancellationToken cancellationToken,
params (OptionDefinition Definition, OptionValue Value)[] axisSelections)
{
var existing = model.Variants
.FirstOrDefault(v => v.DeletedOn == null && string.Equals(v.Sku, sku, StringComparison.OrdinalIgnoreCase));
Product variant;
if (existing is null)
{
variant = await Product.CreateVariant(
model.Id,
sku,
variantName,
price,
checker,
cancellationToken);
model.AttachVariant(variant);
}
else
{
variant = existing;
}
variant.SetBasePrice(price);
foreach (var (definition, value) in axisSelections)
{
var axisValue = variant.AxisValues
.FirstOrDefault(av => av.OptionDefinitionId == definition.Id);
if (axisValue is null)
{
axisValue = new VariantAxisValue
{
ProductVariantId = variant.Id,
ProductVariant = variant,
OptionDefinitionId = definition.Id,
OptionDefinition = definition,
OptionValueId = value.Id,
OptionValue = value
};
variant.AxisValues.Add(axisValue);
}
else
{
axisValue.OptionValueId = value.Id;
axisValue.OptionValue = value;
}
}
}
private static void AddRuleIfMissing(
Product product,
OptionRuleTargetKind targetKind,
Guid targetId,
RuleEffect effect,
Func<OptionRuleSet> factory)
{
var exists = product.RuleSets.Any(rule =>
rule.DeletedOn == null &&
rule.TargetKind == targetKind &&
rule.TargetId == targetId &&
rule.Effect == effect);
if (!exists)
{
factory();
}
}
private static string FormatDecimal(decimal value) =>
value.ToString("0.##", CultureInfo.InvariantCulture);
private static Task UpsertProductDocsAsync(
IModuleDb db,
Product product,
IEnumerable<(string Title, string Url)> docs,
CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(
docs.Select(doc => new { title = doc.Title, url = doc.Url }),
JsonOptions);
return UpsertGenericAttributeAsync(db, product, CatalogDocsKey, payload, cancellationToken);
}
private static async Task UpsertGenericAttributeAsync(
IModuleDb db,
Product product,
string key,
string value,
CancellationToken cancellationToken)
{
var keyGroup = typeof(Product).FullName ?? "Product";
var existing = await db.GenericAttributes
.FirstOrDefaultAsync(attr =>
attr.EntityId == product.Id &&
attr.KeyGroup == keyGroup &&
attr.Key == key &&
attr.DeletedOn == null,
cancellationToken);
if (existing is null)
{
var attribute = new GenericAttribute
{
EntityId = product.Id,
KeyGroup = keyGroup,
Key = key,
Type = typeof(string).FullName ?? "System.String",
Value = value
};
db.GenericAttributes.Add(attribute);
return;
}
if (!string.Equals(existing.Value, value, StringComparison.Ordinal))
{
existing.Value = value;
}
}
private static async Task<AttributeDefinition> EnsureAttributeDefinitionAsync(
IModuleDb db,
string name,
AttributeDataType dataType,
CancellationToken cancellationToken,
string? unit = null)
{
var existing = await db.AttributeDefinitions
.FirstOrDefaultAsync(def => def.DeletedOn == null && def.Name == name, cancellationToken);
if (existing is not null)
{
return existing;
}
var definition = AttributeDefinition.Create(name, dataType, unit);
db.AttributeDefinitions.Add(definition);
return definition;
}
private sealed record CategorySeed(string Slug, string Name, int DisplayOrder, bool IsFeatured);
}