refactor(bootstrap): migrate build-deck catalog to seed file
This commit is contained in:
File diff suppressed because one or more lines are too long
1648
SVSim.Bootstrap/Data/seeds/build-deck-catalog.json
Normal file
1648
SVSim.Bootstrap/Data/seeds/build-deck-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using static SVSim.Bootstrap.Importers.ImporterBase;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture JSON.
|
||||
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one seed JSON.
|
||||
/// Three methods run in dependency order (see Bootstrap/Program.cs):
|
||||
/// 1. ImportSeriesAsync — build_deck_series_master.csv → 22 series rows (all IsEnabled=false initially)
|
||||
/// 2. ImportCatalogAsync — prod-captures/build_deck-info-*.json → enriches 7 series + 53 products (Task 15)
|
||||
/// 2. ImportCatalogAsync — seeds/build-deck-catalog.json → enriches 7 series + 53 products
|
||||
/// (tier backfill for missing intro/regular prices is performed by the extractor)
|
||||
/// 3. ImportPackageAsync — build_deck_package_master.csv → card lists for all 112 products,
|
||||
/// creates disabled stubs for products not seeded by the catalog importer
|
||||
/// Idempotent — re-runnable on the same files.
|
||||
@@ -133,14 +133,13 @@ public class BuildDeckImporter
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string capturesDir)
|
||||
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string seedDir)
|
||||
{
|
||||
var data = LoadCapture(capturesDir, "build_deck-info");
|
||||
if (data is null) return 0;
|
||||
var seed = SeedLoader.LoadList<BuildDeckCatalogSeed>(Path.Combine(seedDir, "build-deck-catalog.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
|
||||
int touchedSeries = 0, touchedProducts = 0;
|
||||
|
||||
// Load existing rows for fast lookup
|
||||
var existingSeries = await db.BuildDeckSeries
|
||||
.Include(s => s.SeriesRewards)
|
||||
.ToDictionaryAsync(s => s.Id);
|
||||
@@ -148,126 +147,75 @@ public class BuildDeckImporter
|
||||
.Include(p => p.Rewards)
|
||||
.ToDictionaryAsync(p => p.Id);
|
||||
|
||||
// The captured data root is an object keyed by order_id string ("15"…"21"); iterate values.
|
||||
foreach (var seriesNode in data.Value.EnumerateObject())
|
||||
foreach (var s in seed)
|
||||
{
|
||||
var s = seriesNode.Value;
|
||||
int seriesId = GetInt(s, "series_id");
|
||||
int orderId = GetInt(s, "order_id");
|
||||
bool isNew = GetBool(s, "is_new");
|
||||
if (s.SeriesId == 0) continue;
|
||||
|
||||
if (!existingSeries.TryGetValue(seriesId, out var seriesRow))
|
||||
if (!existingSeries.TryGetValue(s.SeriesId, out var seriesRow))
|
||||
{
|
||||
// Catalog runs before package importer in production, so series rows from the series
|
||||
// CSV should already exist. If not (e.g. the capture has a series the CSV doesn't),
|
||||
// create a bare row so the FK from products holds.
|
||||
// Catalog typically runs after the series CSV; if a seed series isn't in the
|
||||
// CSV we create a bare stub so the FK from products holds.
|
||||
seriesRow = new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty,
|
||||
Id = s.SeriesId, NameKey = string.Empty, IntroKey = string.Empty,
|
||||
TitlePath = string.Empty, DrumrollPath = string.Empty,
|
||||
};
|
||||
db.BuildDeckSeries.Add(seriesRow);
|
||||
existingSeries[seriesId] = seriesRow;
|
||||
existingSeries[s.SeriesId] = seriesRow;
|
||||
}
|
||||
seriesRow.OrderIndex = orderId;
|
||||
seriesRow.IsNew = isNew;
|
||||
seriesRow.OrderIndex = s.OrderId;
|
||||
seriesRow.IsNew = s.IsNew;
|
||||
seriesRow.IsEnabled = true;
|
||||
|
||||
// Series rewards: replace wholesale (capture is authoritative for enabled series)
|
||||
seriesRow.SeriesRewards.Clear();
|
||||
if (s.TryGetProperty("series_rewards", out var seriesRewards) &&
|
||||
seriesRewards.ValueKind == JsonValueKind.Object)
|
||||
foreach (var r in s.SeriesRewards)
|
||||
{
|
||||
foreach (var tier in seriesRewards.EnumerateObject())
|
||||
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
if (!int.TryParse(tier.Name, out int tierIndex)) continue;
|
||||
if (!tier.Value.TryGetProperty("reward_list", out var rewardList) ||
|
||||
rewardList.ValueKind != JsonValueKind.Array) continue;
|
||||
|
||||
int itemIndex = 0;
|
||||
foreach (var r in rewardList.EnumerateArray())
|
||||
{
|
||||
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
TierIndex = tierIndex,
|
||||
ItemIndex = itemIndex++,
|
||||
RewardType = GetInt(r, "reward_type"),
|
||||
RewardDetailId = GetLong(r, "reward_detail_id"),
|
||||
RewardNumber = GetInt(r, "reward_number"),
|
||||
MessageId = GetInt(r, "message_id"),
|
||||
});
|
||||
}
|
||||
}
|
||||
TierIndex = r.TierIndex,
|
||||
ItemIndex = r.ItemIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
});
|
||||
}
|
||||
touchedSeries++;
|
||||
|
||||
// Products
|
||||
if (!s.TryGetProperty("products", out var products) || products.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
|
||||
// First pass: parse each captured product, track intro/regular tiers per product.
|
||||
var capturedThisSeries = new List<BuildDeckProductEntry>();
|
||||
foreach (var p in products.EnumerateArray())
|
||||
foreach (var p in s.Products)
|
||||
{
|
||||
int productId = GetInt(p, "product_id");
|
||||
|
||||
if (!existingProducts.TryGetValue(productId, out var productRow))
|
||||
if (!existingProducts.TryGetValue(p.ProductId, out var productRow))
|
||||
{
|
||||
productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId };
|
||||
productRow = new BuildDeckProductEntry { Id = p.ProductId, SeriesId = s.SeriesId };
|
||||
db.BuildDeckProducts.Add(productRow);
|
||||
existingProducts[productId] = productRow;
|
||||
existingProducts[p.ProductId] = productRow;
|
||||
}
|
||||
productRow.SeriesId = seriesId;
|
||||
productRow.LeaderId = GetInt(p, "leader_id");
|
||||
productRow.DeckCode = GetString(p, "deck_code");
|
||||
productRow.ProductNameKey = GetString(p, "product_name");
|
||||
productRow.FeaturedCardId = GetLong(p, "featured_card_id");
|
||||
productRow.PurchaseNumMax = GetInt(p, "purchase_num_max");
|
||||
productRow.SeriesId = s.SeriesId;
|
||||
productRow.LeaderId = p.LeaderId;
|
||||
productRow.DeckCode = p.DeckCode;
|
||||
productRow.ProductNameKey = p.ProductName;
|
||||
productRow.FeaturedCardId = p.FeaturedCardId;
|
||||
productRow.PurchaseNumMax = p.PurchaseNumMax;
|
||||
productRow.IsEnabled = true;
|
||||
productRow.IntroPriceCrystal = p.IntroPriceCrystal;
|
||||
productRow.RegularPriceCrystal = p.RegularPriceCrystal;
|
||||
productRow.IntroPriceRupy = p.IntroPriceRupy;
|
||||
productRow.RegularPriceRupy = p.RegularPriceRupy;
|
||||
|
||||
bool isFirstPrice = GetBool(p, "is_first_price");
|
||||
|
||||
// Tier-aware price ingestion: each captured row has ONE price tier (intro OR regular).
|
||||
int? priceCrystal = p.TryGetProperty("price_crystal", out var pc) && pc.ValueKind != JsonValueKind.Null
|
||||
? (int?)GetInt(p, "price_crystal") : null;
|
||||
int? priceRupy = p.TryGetProperty("price_rupy", out var pr) && pr.ValueKind != JsonValueKind.Null
|
||||
? (int?)GetInt(p, "price_rupy") : null;
|
||||
|
||||
if (priceCrystal is not null)
|
||||
{
|
||||
if (isFirstPrice) productRow.IntroPriceCrystal = priceCrystal;
|
||||
else productRow.RegularPriceCrystal = priceCrystal;
|
||||
}
|
||||
if (priceRupy is not null)
|
||||
{
|
||||
if (isFirstPrice) productRow.IntroPriceRupy = priceRupy;
|
||||
else productRow.RegularPriceRupy = priceRupy;
|
||||
}
|
||||
|
||||
// Product rewards: replace wholesale
|
||||
productRow.Rewards.Clear();
|
||||
if (p.TryGetProperty("rewards", out var rewards) && rewards.ValueKind == JsonValueKind.Object)
|
||||
foreach (var r in p.Rewards)
|
||||
{
|
||||
foreach (var r in rewards.EnumerateObject())
|
||||
productRow.Rewards.Add(new BuildDeckProductRewardEntry
|
||||
{
|
||||
if (!int.TryParse(r.Name, out int idx)) continue;
|
||||
productRow.Rewards.Add(new BuildDeckProductRewardEntry
|
||||
{
|
||||
RewardIndex = idx,
|
||||
RewardType = GetInt(r.Value, "reward_type"),
|
||||
RewardDetailId = GetLong(r.Value, "reward_detail_id"),
|
||||
RewardNumber = GetInt(r.Value, "reward_number"),
|
||||
MessageId = GetInt(r.Value, "message_id"),
|
||||
});
|
||||
}
|
||||
RewardIndex = r.RewardIndex,
|
||||
RewardType = r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
});
|
||||
}
|
||||
|
||||
capturedThisSeries.Add(productRow);
|
||||
touchedProducts++;
|
||||
}
|
||||
|
||||
// Second pass: backfill missing tier per-series when sibling products share a unique value.
|
||||
BackfillSeriesTier(capturedThisSeries);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
@@ -275,63 +223,6 @@ public class BuildDeckImporter
|
||||
return touchedSeries + touchedProducts;
|
||||
}
|
||||
|
||||
private static void BackfillSeriesTier(IReadOnlyList<BuildDeckProductEntry> productsInSeries)
|
||||
{
|
||||
// For each (Currency, Tier) pair, if all populated values across siblings are the same,
|
||||
// propagate that value to products that are missing the corresponding tier.
|
||||
BackfillIntroCrystal(productsInSeries);
|
||||
BackfillRegularCrystal(productsInSeries);
|
||||
BackfillIntroRupy(productsInSeries);
|
||||
BackfillRegularRupy(productsInSeries);
|
||||
}
|
||||
|
||||
private static void BackfillIntroCrystal(IReadOnlyList<BuildDeckProductEntry> products)
|
||||
{
|
||||
var distinct = products.Where(p => p.IntroPriceCrystal.HasValue).Select(p => p.IntroPriceCrystal!.Value).Distinct().ToList();
|
||||
if (distinct.Count != 1) return;
|
||||
int value = distinct[0];
|
||||
foreach (var p in products)
|
||||
{
|
||||
if (p.IntroPriceCrystal is null) p.IntroPriceCrystal = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BackfillRegularCrystal(IReadOnlyList<BuildDeckProductEntry> products)
|
||||
{
|
||||
var distinct = products.Where(p => p.RegularPriceCrystal.HasValue).Select(p => p.RegularPriceCrystal!.Value).Distinct().ToList();
|
||||
if (distinct.Count != 1) return;
|
||||
int value = distinct[0];
|
||||
foreach (var p in products)
|
||||
{
|
||||
// For PurchaseNumMax == 1 products, never backfill the Regular tier — they have no second buy.
|
||||
if (p.PurchaseNumMax <= 1) continue;
|
||||
if (p.RegularPriceCrystal is null) p.RegularPriceCrystal = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BackfillIntroRupy(IReadOnlyList<BuildDeckProductEntry> products)
|
||||
{
|
||||
var distinct = products.Where(p => p.IntroPriceRupy.HasValue).Select(p => p.IntroPriceRupy!.Value).Distinct().ToList();
|
||||
if (distinct.Count != 1) return;
|
||||
int value = distinct[0];
|
||||
foreach (var p in products)
|
||||
{
|
||||
if (p.IntroPriceRupy is null) p.IntroPriceRupy = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BackfillRegularRupy(IReadOnlyList<BuildDeckProductEntry> products)
|
||||
{
|
||||
var distinct = products.Where(p => p.RegularPriceRupy.HasValue).Select(p => p.RegularPriceRupy!.Value).Distinct().ToList();
|
||||
if (distinct.Count != 1) return;
|
||||
int value = distinct[0];
|
||||
foreach (var p in products)
|
||||
{
|
||||
if (p.PurchaseNumMax <= 1) continue;
|
||||
if (p.RegularPriceRupy is null) p.RegularPriceRupy = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a product_id to its series_id using the numeric pattern derived from the /info capture
|
||||
/// and CSV inspection.
|
||||
|
||||
46
SVSim.Bootstrap/Models/Seed/BuildDeckCatalogSeed.cs
Normal file
46
SVSim.Bootstrap/Models/Seed/BuildDeckCatalogSeed.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class BuildDeckCatalogSeed
|
||||
{
|
||||
[JsonPropertyName("series_id")] public int SeriesId { get; set; }
|
||||
[JsonPropertyName("order_id")] public int OrderId { get; set; }
|
||||
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
|
||||
[JsonPropertyName("series_rewards")] public List<BuildDeckSeriesRewardSeed> SeriesRewards { get; set; } = new();
|
||||
[JsonPropertyName("products")] public List<BuildDeckProductSeed> Products { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class BuildDeckSeriesRewardSeed
|
||||
{
|
||||
[JsonPropertyName("tier_index")] public int TierIndex { get; set; }
|
||||
[JsonPropertyName("item_index")] public int ItemIndex { get; set; }
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
|
||||
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
|
||||
[JsonPropertyName("message_id")] public int MessageId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BuildDeckProductSeed
|
||||
{
|
||||
[JsonPropertyName("product_id")] public int ProductId { get; set; }
|
||||
[JsonPropertyName("leader_id")] public int LeaderId { get; set; }
|
||||
[JsonPropertyName("deck_code")] public string DeckCode { get; set; } = "";
|
||||
[JsonPropertyName("product_name")] public string ProductName { get; set; } = "";
|
||||
[JsonPropertyName("featured_card_id")] public long FeaturedCardId { get; set; }
|
||||
[JsonPropertyName("purchase_num_max")] public int PurchaseNumMax { get; set; }
|
||||
[JsonPropertyName("intro_price_crystal")] public int? IntroPriceCrystal { get; set; }
|
||||
[JsonPropertyName("regular_price_crystal")] public int? RegularPriceCrystal { get; set; }
|
||||
[JsonPropertyName("intro_price_rupy")] public int? IntroPriceRupy { get; set; }
|
||||
[JsonPropertyName("regular_price_rupy")] public int? RegularPriceRupy { get; set; }
|
||||
[JsonPropertyName("rewards")] public List<BuildDeckProductRewardSeed> Rewards { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class BuildDeckProductRewardSeed
|
||||
{
|
||||
[JsonPropertyName("reward_index")] public int RewardIndex { get; set; }
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
|
||||
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
|
||||
[JsonPropertyName("message_id")] public int MessageId { get; set; }
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public static class Program
|
||||
// enriched rows take precedence over stub creation).
|
||||
var buildDeck = new BuildDeckImporter();
|
||||
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
|
||||
await buildDeck.ImportCatalogAsync(context, opts.CapturesDir);
|
||||
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
|
||||
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace SVSim.UnitTests.Importers;
|
||||
public class BuildDeckImporterTests
|
||||
{
|
||||
private static string DataDir => Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task ImportsAll22Series_with_22_disabled_until_catalog_enables()
|
||||
@@ -71,7 +72,7 @@ public class BuildDeckImporterTests
|
||||
|
||||
var importer = new BuildDeckImporter();
|
||||
await importer.ImportSeriesAsync(db, DataDir);
|
||||
await importer.ImportCatalogAsync(db, Path.Combine(DataDir, "prod-captures"));
|
||||
await importer.ImportCatalogAsync(db, SeedDir);
|
||||
await importer.ImportPackageAsync(db, DataDir);
|
||||
|
||||
// Series 101 (Set 1) should be enabled and order_id=22 from capture
|
||||
|
||||
Reference in New Issue
Block a user