prevent negative prices

This commit is contained in:
bob
2026-06-01 11:11:41 -05:00
parent 763305ca89
commit 15310f0fd0

View File

@@ -81,13 +81,46 @@ public sealed class CsMoneyIngestService
|| string.Equals(a.Quality, expectedQuality, StringComparison.OrdinalIgnoreCase); || string.Equals(a.Quality, expectedQuality, StringComparison.OrdinalIgnoreCase);
}).ToList(); }).ToList();
var skipped = items.Count - matched.Count; // Of the name/wear matches, keep only listings with a usable price. cs.money's
if (matched.Count == 0) // pricing.default is the DISCOUNTED display price and occasionally arrives <= 0
// (its discount math underflows, or a price-less render returns 0); a non-positive
// asking price is impossible and would poison the cheapest-price point downstream.
// Fall back to priceBeforeDiscount when default isn't positive, and drop the
// listing only when neither is — counting those as skipped like a filter miss.
var priced = new List<(CsMoneyItem Item, decimal Price)>(matched.Count);
var droppedNoPrice = 0;
var pricedByFallback = 0;
foreach (var it in matched)
{ {
// Nothing for this skin+wear. If the sweep was complete this is genuine if (ResolvePrice(it.Pricing) is not { } px)
// (none listed, or a name mismatch) — stamp the checkpoint so it advances. {
// If it was partial (e.g. challenged before any item), leave it un-stamped droppedNoPrice++;
// so the band is retried. continue;
}
if (!(it.Pricing!.Default > 0m))
{
pricedByFallback++;
}
priced.Add((it, px));
}
if (pricedByFallback > 0 || droppedNoPrice > 0)
{
_logger.LogWarning(
"cs.money non-positive default price for {Skin}: {Fallback} used priceBeforeDiscount, "
+ "{Dropped} dropped (no usable price).",
skin.Name, pricedByFallback, droppedNoPrice);
}
var skipped = items.Count - priced.Count;
if (priced.Count == 0)
{
// Nothing usable for this skin+wear. If the sweep was complete this is genuine
// (none listed, a name mismatch, or no usable price) — stamp the checkpoint so
// it advances. If it was partial (e.g. challenged before any item), leave it
// un-stamped so the band is retried.
if (complete) if (complete)
{ {
await StampCheckpointAsync(conditionId, now, ct); await StampCheckpointAsync(conditionId, now, ct);
@@ -97,7 +130,7 @@ public sealed class CsMoneyIngestService
return new CsMoneyIngestResult(0, 0, 0, 0, skipped); return new CsMoneyIngestResult(0, 0, 0, 0, skipped);
} }
var sellOrderIds = matched.Select(it => it.Id).ToList(); var sellOrderIds = priced.Select(p => p.Item.Id).ToList();
var existing = await _db.CsMoneyListings var existing = await _db.CsMoneyListings
.Where(l => sellOrderIds.Contains(l.SellOrderId)) .Where(l => sellOrderIds.Contains(l.SellOrderId))
.ToDictionaryAsync(l => l.SellOrderId, ct); .ToDictionaryAsync(l => l.SellOrderId, ct);
@@ -107,7 +140,7 @@ public sealed class CsMoneyIngestService
var touched = new HashSet<long>(); var touched = new HashSet<long>();
var touchedInstanceIds = new HashSet<int>(); var touchedInstanceIds = new HashSet<int>();
foreach (var it in matched) foreach (var (it, price) in priced)
{ {
touched.Add(it.Id); touched.Add(it.Id);
var instance = await ResolveInstanceAsync(skinId, conditionId, it, now, ct); var instance = await ResolveInstanceAsync(skinId, conditionId, it, now, ct);
@@ -118,7 +151,7 @@ public sealed class CsMoneyIngestService
if (existing.TryGetValue(it.Id, out var row)) if (existing.TryGetValue(it.Id, out var row))
{ {
row.Price = it.Pricing?.Default ?? row.Price; row.Price = price;
row.PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount; row.PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount;
row.ComputedPrice = it.Pricing?.Computed; row.ComputedPrice = it.Pricing?.Computed;
row.AssetId = it.Asset?.Id?.ToString(); row.AssetId = it.Asset?.Id?.ToString();
@@ -131,7 +164,7 @@ public sealed class CsMoneyIngestService
} }
else else
{ {
var entity = Map(it, skinId, conditionId, now); var entity = Map(it, price, skinId, conditionId, now);
entity.SkinInstance = instance; entity.SkinInstance = instance;
_db.CsMoneyListings.Add(entity); _db.CsMoneyListings.Add(entity);
inserted++; inserted++;
@@ -155,7 +188,7 @@ public sealed class CsMoneyIngestService
// Record a price point (the cheapest live listing) for this skin+wear. // Record a price point (the cheapest live listing) for this skin+wear.
if (conditionId is { } condId) if (conditionId is { } condId)
{ {
var minPrice = matched.Where(m => m.Pricing is not null).Select(m => m.Pricing!.Default).Min(); var minPrice = priced.Min(p => p.Price);
await _db.PriceHistories.AddAsync(new PriceHistory await _db.PriceHistories.AddAsync(new PriceHistory
{ {
SkinId = skinId, SkinId = skinId,
@@ -175,10 +208,10 @@ public sealed class CsMoneyIngestService
_logger.LogInformation( _logger.LogInformation(
"cs.money ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, " "cs.money ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, "
+ "{Rem} removed), {Skipped} skipped by filter{Partial}.", + "{Rem} removed), {Skipped} skipped by filter{Partial}.",
skin.Weapon, skin.Name, conditionName ?? "all", matched.Count, inserted, updated, removed, skipped, skin.Weapon, skin.Name, conditionName ?? "all", priced.Count, inserted, updated, removed, skipped,
complete ? "" : " [PARTIAL — not pruned/checkpointed]"); complete ? "" : " [PARTIAL — not pruned/checkpointed]");
return new CsMoneyIngestResult(matched.Count, inserted, updated, removed, skipped); return new CsMoneyIngestResult(priced.Count, inserted, updated, removed, skipped);
} }
// Find the physical item matching this listing's fingerprint, or create one. // Find the physical item matching this listing's fingerprint, or create one.
@@ -290,7 +323,31 @@ public sealed class CsMoneyIngestService
} }
} }
private static CsMoneyListing Map(CsMoneyItem it, int skinId, int? conditionId, DateTimeOffset now) => new() // The effective asking price for a listing, or null when none is usable. cs.money's
// pricing.default is the DISCOUNTED price and occasionally comes back <= 0 (discount
// underflow, or a price-less render); fall back to the positive priceBeforeDiscount
// (conservative — never understates cost) and give up only when neither is positive.
private static decimal? ResolvePrice(CsMoneyPricing? pricing)
{
if (pricing is null)
{
return null;
}
if (pricing.Default > 0m)
{
return pricing.Default;
}
if (pricing.PriceBeforeDiscount is { } before && before > 0m)
{
return before;
}
return null;
}
private static CsMoneyListing Map(CsMoneyItem it, decimal price, int skinId, int? conditionId, DateTimeOffset now) => new()
{ {
SellOrderId = it.Id, SellOrderId = it.Id,
AssetId = it.Asset?.Id?.ToString(), AssetId = it.Asset?.Id?.ToString(),
@@ -304,7 +361,7 @@ public sealed class CsMoneyIngestService
IsStatTrak = it.Asset?.IsStatTrak ?? false, IsStatTrak = it.Asset?.IsStatTrak ?? false,
IsSouvenir = it.Asset?.IsSouvenir ?? false, IsSouvenir = it.Asset?.IsSouvenir ?? false,
StickerCount = it.Stickers?.Count(s => s is not null) ?? 0, StickerCount = it.Stickers?.Count(s => s is not null) ?? 0,
Price = it.Pricing?.Default ?? 0m, Price = price,
PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount, PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount,
ComputedPrice = it.Pricing?.Computed, ComputedPrice = it.Pricing?.Computed,
Currency = "USD", Currency = "USD",