prevent negative prices
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user