Gestione massiva limiti prodotto e ottimizzazione ticker
Aggiunta barra azioni per gestione massiva limiti prodotto in Statistics.razor (applica, salva, attiva/disattiva, copia consigliati). Uniformati simboli euro e messaggi in italiano. Ottimizzata la logica del ticker: controllo puntata ora avviene prima del polling, gestione fine asta differita tramite PendingEndState. Introdotto controllo esplicito su MaxClicks per asta. Implementata cache delle impostazioni in SettingsManager per ridurre accessi disco. Vari fix minori e miglioramenti di robustezza.
This commit is contained in:
@@ -40,10 +40,10 @@ namespace AutoBidder.Models
|
||||
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
|
||||
|
||||
/// <summary>
|
||||
/// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI
|
||||
/// Mantenuto per retrocompatibilità con salvataggi JSON esistenti
|
||||
/// Numero massimo di puntate consentite per questa asta (0 = illimitato).
|
||||
/// Impostato dall'utente nella griglia statistiche o dai limiti prodotto.
|
||||
/// Controllato in ShouldBid contro BidsUsedOnThisAuction.
|
||||
/// </summary>
|
||||
[Obsolete("MaxClicks non è più utilizzato. Usa invece la logica di limiti per prodotto.")]
|
||||
[JsonPropertyName("MaxClicks")]
|
||||
public int MaxClicks { get; set; } = 0;
|
||||
|
||||
@@ -107,6 +107,13 @@ namespace AutoBidder.Models
|
||||
[JsonIgnore]
|
||||
public double LastScheduledTimerMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stato di fine asta ricevuto dal poll ma non ancora processato.
|
||||
/// Il ticker ha un'ultima occasione di puntare prima che venga gestito.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public AuctionState? PendingEndState { get; set; }
|
||||
|
||||
// Storico
|
||||
public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>();
|
||||
public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -505,6 +512,7 @@ namespace AutoBidder.Models
|
||||
|
||||
// Pulisci oggetti complessi
|
||||
LastState = null;
|
||||
PendingEndState = null;
|
||||
CalculatedValue = null;
|
||||
DuelOpponent = null;
|
||||
WinLimitDescription = null;
|
||||
|
||||
@@ -102,7 +102,6 @@ namespace AutoBidder.Pages
|
||||
private string? sessionUsername;
|
||||
private int sessionRemainingBids;
|
||||
private double sessionShopCredit;
|
||||
private int sessionAuctionsWon;
|
||||
|
||||
// Recommended limits
|
||||
private bool isLoadingRecommendations = false;
|
||||
@@ -530,6 +529,7 @@ namespace AutoBidder.Pages
|
||||
// Carica limiti dal database prodotti se disponibili
|
||||
double minPrice = settings.DefaultMinPrice;
|
||||
double maxPrice = settings.DefaultMaxPrice;
|
||||
int maxClicks = settings.DefaultMaxClicks;
|
||||
int bidDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
|
||||
|
||||
// Se abilitato, cerca limiti salvati per questo prodotto
|
||||
@@ -551,10 +551,12 @@ namespace AutoBidder.Pages
|
||||
minPrice = productStats.UserDefaultMinPrice.Value;
|
||||
if (productStats.UserDefaultMaxPrice.HasValue)
|
||||
maxPrice = productStats.UserDefaultMaxPrice.Value;
|
||||
if (productStats.UserDefaultMaxBids.HasValue)
|
||||
maxClicks = productStats.UserDefaultMaxBids.Value;
|
||||
if (productStats.UserDefaultBidBeforeDeadlineMs.HasValue)
|
||||
bidDeadlineMs = productStats.UserDefaultBidBeforeDeadlineMs.Value;
|
||||
|
||||
AddLog($"[STATS] Limiti prodotto (da URL): €{minPrice:F2}-€{maxPrice:F2}");
|
||||
AddLog($"[STATS] Limiti prodotto (da URL): €{minPrice:F2}-€{maxPrice:F2}, MaxClicks={maxClicks}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -573,6 +575,7 @@ namespace AutoBidder.Pages
|
||||
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
|
||||
MinPrice = minPrice,
|
||||
MaxPrice = maxPrice,
|
||||
MaxClicks = maxClicks,
|
||||
IsActive = isActive,
|
||||
IsPaused = isPaused
|
||||
};
|
||||
@@ -675,7 +678,7 @@ namespace AutoBidder.Pages
|
||||
}
|
||||
|
||||
// 3. Cerca limiti prodotto dal database con il nome REALE
|
||||
if (updated && !string.IsNullOrWhiteSpace(auction.Name))
|
||||
if (!string.IsNullOrWhiteSpace(auction.Name))
|
||||
{
|
||||
var settings = AutoBidder.Utilities.SettingsManager.Load();
|
||||
if (settings.NewAuctionLimitsPriority == "ProductStats" && StatsService.IsAvailable)
|
||||
@@ -1543,7 +1546,6 @@ namespace AutoBidder.Pages
|
||||
sessionUsername = savedSession.Username;
|
||||
sessionRemainingBids = savedSession.RemainingBids;
|
||||
sessionShopCredit = savedSession.ShopCredit;
|
||||
sessionAuctionsWon = 0; // TODO: add to BidooSession model
|
||||
|
||||
// Inizializza AuctionMonitor con la sessione salvata
|
||||
if (!string.IsNullOrEmpty(savedSession.CookieString))
|
||||
@@ -1557,7 +1559,6 @@ namespace AutoBidder.Pages
|
||||
sessionUsername = session?.Username;
|
||||
sessionRemainingBids = session?.RemainingBids ?? 0;
|
||||
sessionShopCredit = session?.ShopCredit ?? 0;
|
||||
sessionAuctionsWon = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1574,7 +1575,6 @@ namespace AutoBidder.Pages
|
||||
sessionUsername = session.Username;
|
||||
sessionRemainingBids = session.RemainingBids;
|
||||
sessionShopCredit = session.ShopCredit;
|
||||
sessionAuctionsWon = 0; // TODO: add to BidooSession model
|
||||
|
||||
// Salva sessione aggiornata
|
||||
AutoBidder.Services.SessionManager.SaveSession(session);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/statistics"
|
||||
@page "/statistics"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using AutoBidder.Models
|
||||
@using AutoBidder.Services
|
||||
@@ -39,7 +39,7 @@
|
||||
<i class="bi bi-database-x"></i>
|
||||
<div>
|
||||
<strong>Statistiche non disponibili</strong>
|
||||
<small>Il database non è stato configurato o non è accessibile.</small>
|
||||
<small>Il database non è stato configurato o non è accessibile.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -68,6 +68,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ps-bulk-bar">
|
||||
<span class="ps-bulk-label">Azioni su @(filteredProducts?.Count ?? 0) prodotti visibili:</span>
|
||||
<button class="btn-xs btn-outline-info" @onclick="BulkCopyRecommended"
|
||||
disabled="@isBulkOperating"
|
||||
title="Applica valori consigliati dall'algoritmo a tutti i prodotti visibili">
|
||||
<i class="bi bi-magic"></i> Consigliati
|
||||
</button>
|
||||
<button class="btn-xs btn-success" @onclick="BulkSaveDefaults"
|
||||
disabled="@isBulkOperating"
|
||||
title="Salva i limiti di tutti i prodotti visibili nel database">
|
||||
<i class="bi bi-floppy"></i> Salva tutto
|
||||
</button>
|
||||
<button class="btn-xs btn-primary" @onclick="BulkApplyToAuctions"
|
||||
disabled="@isBulkOperating"
|
||||
title="Applica i limiti dei prodotti visibili alle aste monitorate corrispondenti">
|
||||
<i class="bi bi-check2-all"></i> Applica alle aste
|
||||
</button>
|
||||
<button class="btn-xs btn-outline-toggle" @onclick="BulkToggleOn"
|
||||
disabled="@isBulkOperating"
|
||||
title="Attiva limiti personalizzati per tutti i prodotti visibili">
|
||||
<i class="bi bi-toggle-on"></i> Attiva tutti
|
||||
</button>
|
||||
<button class="btn-xs btn-outline-toggle" @onclick="BulkToggleOff"
|
||||
disabled="@isBulkOperating"
|
||||
title="Disattiva limiti personalizzati per tutti i prodotti visibili">
|
||||
<i class="bi bi-toggle-off"></i> Disattiva tutti
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
@if (filteredProducts == null || !filteredProducts.Any())
|
||||
{
|
||||
@@ -94,20 +122,20 @@
|
||||
Win% @GetProductSortIndicator("winrate")
|
||||
</th>
|
||||
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("minprice")'>
|
||||
€ Min @GetProductSortIndicator("minprice")
|
||||
€ Min @GetProductSortIndicator("minprice")
|
||||
</th>
|
||||
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("avgprice")'>
|
||||
€ Med @GetProductSortIndicator("avgprice")
|
||||
€ Med @GetProductSortIndicator("avgprice")
|
||||
</th>
|
||||
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("medianprice")'>
|
||||
€ Mdn @GetProductSortIndicator("medianprice")
|
||||
€ Mdn @GetProductSortIndicator("medianprice")
|
||||
</th>
|
||||
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("maxprice")'>
|
||||
€ Max @GetProductSortIndicator("maxprice")
|
||||
€ Max @GetProductSortIndicator("maxprice")
|
||||
</th>
|
||||
<th class="ps-col-toggle text-center">On</th>
|
||||
<th class="ps-col-input text-center">Min €</th>
|
||||
<th class="ps-col-input text-center">Max €</th>
|
||||
<th class="ps-col-input text-center">Min €</th>
|
||||
<th class="ps-col-input text-center">Max €</th>
|
||||
<th class="ps-col-input text-center">Max Punt.</th>
|
||||
<th class="ps-col-actions text-center">Azioni</th>
|
||||
</tr>
|
||||
@@ -129,16 +157,16 @@
|
||||
<span class="stats-badge @(winRate >= 50 ? "badge-success" : "badge-danger")">@winRate.ToString("F0")%</span>
|
||||
</td>
|
||||
<td class="ps-col-price text-end">
|
||||
<span class="ps-price-stat">€@(product.MinFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
<span class="ps-price-stat">€@(product.MinFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
</td>
|
||||
<td class="ps-col-price text-end">
|
||||
<span class="ps-price-stat ps-price-avg">€@product.AvgFinalPrice.ToString("F2")</span>
|
||||
<span class="ps-price-stat ps-price-avg">€@product.AvgFinalPrice.ToString("F2")</span>
|
||||
</td>
|
||||
<td class="ps-col-price text-end">
|
||||
<span class="ps-price-stat">€@(product.MedianFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
<span class="ps-price-stat">€@(product.MedianFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
</td>
|
||||
<td class="ps-col-price text-end">
|
||||
<span class="ps-price-stat">€@(product.MaxFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
<span class="ps-price-stat">€@(product.MaxFinalPrice?.ToString("F2") ?? "-")</span>
|
||||
</td>
|
||||
<td class="ps-col-toggle text-center" @onclick:stopPropagation="true">
|
||||
<label class="ps-switch">
|
||||
@@ -263,7 +291,7 @@
|
||||
{
|
||||
<tr class="@(auction.Won ? "row-won" : "")">
|
||||
<td><span class="cell-name">@TruncateName(auction.AuctionName, 40)</span></td>
|
||||
<td class="text-end cell-price">€@auction.FinalPrice.ToString("F2")</td>
|
||||
<td class="text-end cell-price">€@auction.FinalPrice.ToString("F2")</td>
|
||||
<td class="text-end">
|
||||
@auction.BidsUsed
|
||||
@if (auction.WinnerBidsUsed.HasValue && auction.WinnerBidsUsed != auction.BidsUsed)
|
||||
@@ -312,12 +340,225 @@ private bool productSortDescending = false;
|
||||
|
||||
// Editing limiti default prodotto
|
||||
private bool isSavingDefaults = false;
|
||||
private bool isBulkOperating = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshStats();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OPERAZIONI MASSIVE SUI PRODOTTI FILTRATI
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async Task BulkSaveDefaults()
|
||||
{
|
||||
if (filteredProducts == null || !filteredProducts.Any()) return;
|
||||
|
||||
var count = filteredProducts.Count;
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"Salvare i limiti di {count} prodotti nel database?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isBulkOperating = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
int saved = 0;
|
||||
foreach (var product in filteredProducts)
|
||||
{
|
||||
await DatabaseService.UpdateProductUserDefaultsAsync(
|
||||
product.ProductKey,
|
||||
product.UserDefaultMinPrice,
|
||||
product.UserDefaultMaxPrice,
|
||||
null, null,
|
||||
product.UserDefaultMaxBids,
|
||||
null,
|
||||
product.UseCustomLimits
|
||||
);
|
||||
saved++;
|
||||
}
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti salvati per {saved} prodotti!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBulkOperating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BulkApplyToAuctions()
|
||||
{
|
||||
if (filteredProducts == null || !filteredProducts.Any()) return;
|
||||
|
||||
var activeProducts = filteredProducts.Where(p => p.UseCustomLimits && HasUserDefaults(p)).ToList();
|
||||
if (!activeProducts.Any())
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Nessun prodotto visibile ha limiti personalizzati attivi.");
|
||||
return;
|
||||
}
|
||||
|
||||
var productKeys = activeProducts.Select(p => p.ProductKey).ToHashSet();
|
||||
var matchingAuctions = AppState.Auctions
|
||||
.Where(a => productKeys.Contains(ProductStatisticsService.GenerateProductKey(a.Name)))
|
||||
.ToList();
|
||||
|
||||
if (!matchingAuctions.Any())
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Nessuna asta monitorata corrisponde ai prodotti visibili.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"Applicare i limiti di {activeProducts.Count} prodotti a {matchingAuctions.Count} aste monitorate?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isBulkOperating = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var productLookup = activeProducts.ToDictionary(p => p.ProductKey);
|
||||
int applied = 0;
|
||||
|
||||
foreach (var auction in matchingAuctions)
|
||||
{
|
||||
var key = ProductStatisticsService.GenerateProductKey(auction.Name);
|
||||
if (productLookup.TryGetValue(key, out var product))
|
||||
{
|
||||
if (product.UserDefaultMinPrice.HasValue)
|
||||
auction.MinPrice = product.UserDefaultMinPrice.Value;
|
||||
if (product.UserDefaultMaxPrice.HasValue)
|
||||
auction.MaxPrice = product.UserDefaultMaxPrice.Value;
|
||||
if (product.UserDefaultMaxBids.HasValue)
|
||||
auction.MaxClicks = product.UserDefaultMaxBids.Value;
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
|
||||
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti applicati a {applied} aste!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBulkOperating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BulkCopyRecommended()
|
||||
{
|
||||
if (filteredProducts == null || !filteredProducts.Any()) return;
|
||||
|
||||
var withRecommendations = filteredProducts.Where(p => p.RecommendedMinPrice.HasValue).ToList();
|
||||
if (!withRecommendations.Any())
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Nessun prodotto visibile ha valori consigliati.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"Applicare i valori consigliati a {withRecommendations.Count} prodotti e salvarli nel database?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isBulkOperating = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var product in withRecommendations)
|
||||
{
|
||||
product.UserDefaultMinPrice = product.RecommendedMinPrice;
|
||||
product.UserDefaultMaxPrice = product.RecommendedMaxPrice;
|
||||
product.UserDefaultMaxBids = product.RecommendedMaxBids;
|
||||
product.UseCustomLimits = true;
|
||||
|
||||
await DatabaseService.UpdateProductUserDefaultsAsync(
|
||||
product.ProductKey,
|
||||
product.UserDefaultMinPrice,
|
||||
product.UserDefaultMaxPrice,
|
||||
null, null,
|
||||
product.UserDefaultMaxBids,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"✅ Valori consigliati applicati e salvati per {withRecommendations.Count} prodotti!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBulkOperating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BulkToggleOn()
|
||||
{
|
||||
await BulkToggle(true);
|
||||
}
|
||||
|
||||
private async Task BulkToggleOff()
|
||||
{
|
||||
await BulkToggle(false);
|
||||
}
|
||||
|
||||
private async Task BulkToggle(bool enabled)
|
||||
{
|
||||
if (filteredProducts == null || !filteredProducts.Any()) return;
|
||||
|
||||
var label = enabled ? "Attivare" : "Disattivare";
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"{label} i limiti personalizzati per {filteredProducts.Count} prodotti visibili?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isBulkOperating = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var product in filteredProducts)
|
||||
{
|
||||
product.UseCustomLimits = enabled;
|
||||
await DatabaseService.UpdateProductUserDefaultsAsync(
|
||||
product.ProductKey,
|
||||
product.UserDefaultMinPrice,
|
||||
product.UserDefaultMaxPrice,
|
||||
null, null,
|
||||
product.UserDefaultMaxBids,
|
||||
null,
|
||||
enabled
|
||||
);
|
||||
}
|
||||
|
||||
var labelDone = enabled ? "attivati" : "disattivati";
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti {labelDone} per {filteredProducts.Count} prodotti!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isBulkOperating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshStats()
|
||||
{
|
||||
isLoading = true;
|
||||
@@ -444,8 +685,8 @@ private bool isSavingDefaults = false;
|
||||
// Conferma eliminazione
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"Eliminare il prodotto '{product.ProductName}'?\n\n" +
|
||||
$"Questo rimuoverà le statistiche di {product.TotalAuctions} aste.\n" +
|
||||
$"L'operazione NON può essere annullata!");
|
||||
$"Questo rimuoverà le statistiche di {product.TotalAuctions} aste.\n" +
|
||||
$"L'operazione NON può essere annullata!");
|
||||
|
||||
if (!confirmed)
|
||||
return;
|
||||
@@ -469,7 +710,7 @@ private bool isSavingDefaults = false;
|
||||
else
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert",
|
||||
"Nessuna riga eliminata. Il prodotto potrebbe essere già stato rimosso.");
|
||||
"Nessuna riga eliminata. Il prodotto potrebbe essere già stato rimosso.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -639,7 +880,7 @@ private bool isSavingDefaults = false;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("alert",
|
||||
$"? Limiti salvati per '{product.ProductName}'!\n" +
|
||||
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
|
||||
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
|
||||
$"Max Puntate: {product.UserDefaultMaxBids ?? 0}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -669,7 +910,7 @@ private bool isSavingDefaults = false;
|
||||
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
||||
$"Applicare i limiti default a {matchingAuctions.Count} aste di '{product.ProductName}'?\n\n" +
|
||||
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
|
||||
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
|
||||
$"Max Puntate: {product.UserDefaultMaxBids}");
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -1120,4 +1361,43 @@ private bool isSavingDefaults = false;
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ═══ BULK ACTION BAR ═══ */
|
||||
.ps-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ps-bulk-label {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ps-bulk-bar .btn-xs {
|
||||
padding: 0.12rem 0.4rem;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.3;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ps-bulk-bar .btn-xs i { font-size: 0.6rem; margin-right: 0.15rem; }
|
||||
.ps-bulk-bar .btn-xs:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-outline-toggle {
|
||||
background: transparent;
|
||||
border-color: rgba(168, 162, 158, 0.25);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-outline-toggle:hover { background: rgba(168, 162, 158, 0.1); }
|
||||
</style>
|
||||
|
||||
@@ -310,42 +310,97 @@ namespace AutoBidder.Services
|
||||
}
|
||||
}
|
||||
|
||||
// === FASE 2: Poll API solo ogni pollingIntervalMs ===
|
||||
var now = DateTime.UtcNow;
|
||||
bool shouldPoll = (now - lastPoll).TotalMilliseconds >= pollingIntervalMs;
|
||||
|
||||
// Poll più frequente se vicino alla scadenza
|
||||
bool anyNearDeadline = activeAuctions.Any(a =>
|
||||
GetEstimatedTimerMs(a) < settings.StrategyCheckThresholdMs);
|
||||
|
||||
if (shouldPoll || anyNearDeadline)
|
||||
{
|
||||
var pollTasks = activeAuctions.Select(a => PollAuctionState(a, token));
|
||||
await Task.WhenAll(pollTasks);
|
||||
lastPoll = now;
|
||||
}
|
||||
|
||||
// === FASE 3: TICKER CHECK - Verifica timing per ogni asta ===
|
||||
// === FASE 2: PRE-BID CHECK (zero I/O, solo timer locale) ===
|
||||
// CRITICO: Controlla il ticker PRIMA del poll per le aste nella zona di puntata.
|
||||
// Il poll prende 40-100ms di rete. Con più aste near-deadline,
|
||||
// Task.WhenAll aspetta il poll più lento → il tick effettivo è 100-200ms.
|
||||
// Se l'offset è 200ms, la finestra di puntata è PIÙ STRETTA del tick:
|
||||
// Tick N: estimated=300ms > offset → non trigghera
|
||||
// [poll prende 150ms]
|
||||
// Tick N+1: estimated=-50ms → fuori finestra → MANCATA!
|
||||
// Il pre-bid check usa lo stato dal poll PRECEDENTE (< 100ms fa),
|
||||
// che è sufficientemente fresco per IsMyBid, prezzo e ultimo puntatore.
|
||||
foreach (var auction in activeAuctions)
|
||||
{
|
||||
if (auction.IsPaused || auction.LastState == null) continue;
|
||||
|
||||
// Calcola timer stimato LOCALMENTE (più preciso del polling)
|
||||
double estimatedTimerMs = GetEstimatedTimerMs(auction);
|
||||
|
||||
// Offset configurato dall'utente (SENZA compensazioni)
|
||||
int offsetMs = auction.BidBeforeDeadlineMs > 0
|
||||
? auction.BidBeforeDeadlineMs
|
||||
: settings.DefaultBidBeforeDeadlineMs;
|
||||
|
||||
// TRIGGER: Timer <= Offset configurato dall'utente
|
||||
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > 0)
|
||||
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
|
||||
{
|
||||
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, token);
|
||||
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
|
||||
}
|
||||
}
|
||||
|
||||
// === FASE 4: Delay fisso del ticker ===
|
||||
// === FASE 3: Poll API ===
|
||||
// Il poll aggiorna dati (prezzo, ultimo puntatore, timer) per il prossimo tick.
|
||||
// Se rileva la fine dell'asta, la salva in PendingEndState (non la processa subito).
|
||||
// Le aste nella zona critica (< offset*2 ms) NON vengono pollate:
|
||||
// il poll prenderebbe tempo prezioso e i dati locali sono sufficienti.
|
||||
var now = DateTime.UtcNow;
|
||||
bool shouldPollAll = (now - lastPoll).TotalMilliseconds >= pollingIntervalMs;
|
||||
|
||||
if (shouldPollAll)
|
||||
{
|
||||
// Poll normale: tutte le aste attive
|
||||
var pollTasks = activeAuctions.Select(a => PollAuctionState(a, token));
|
||||
await Task.WhenAll(pollTasks);
|
||||
lastPoll = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Poll rapido: SOLO le aste vicine alla scadenza MA non nella zona critica
|
||||
// Se il timer è < offset*2, il poll ruberebbe tempo al prossimo pre-bid check
|
||||
var nearDeadlineAuctions = activeAuctions.Where(a =>
|
||||
{
|
||||
double est = GetEstimatedTimerMs(a);
|
||||
int off = a.BidBeforeDeadlineMs > 0
|
||||
? a.BidBeforeDeadlineMs
|
||||
: settings.DefaultBidBeforeDeadlineMs;
|
||||
return est < settings.StrategyCheckThresholdMs && est > off * 2;
|
||||
}).ToList();
|
||||
|
||||
if (nearDeadlineAuctions.Count > 0)
|
||||
{
|
||||
var pollTasks = nearDeadlineAuctions.Select(a => PollAuctionState(a, token));
|
||||
await Task.WhenAll(pollTasks);
|
||||
}
|
||||
}
|
||||
|
||||
// === FASE 4: POST-POLL TICKER CHECK ===
|
||||
// Per aste che sono entrate nella zona di puntata DURANTE il poll.
|
||||
// Le aste già puntate dal pre-bid check vengono skippate da BidScheduled.
|
||||
foreach (var auction in activeAuctions)
|
||||
{
|
||||
if (auction.IsPaused || auction.LastState == null) continue;
|
||||
|
||||
double estimatedTimerMs = GetEstimatedTimerMs(auction);
|
||||
int offsetMs = auction.BidBeforeDeadlineMs > 0
|
||||
? auction.BidBeforeDeadlineMs
|
||||
: settings.DefaultBidBeforeDeadlineMs;
|
||||
|
||||
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
|
||||
{
|
||||
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
|
||||
}
|
||||
}
|
||||
|
||||
// === FASE 4: Processa aste terminate (deferred) ===
|
||||
// Solo ORA gestiamo le aste che il poll ha marcato come terminate.
|
||||
// Il ticker ha avuto la sua ultima occasione nella Fase 3.
|
||||
foreach (var auction in activeAuctions)
|
||||
{
|
||||
if (auction.PendingEndState != null)
|
||||
{
|
||||
HandleAuctionEnded(auction, auction.PendingEndState);
|
||||
auction.PendingEndState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === FASE 5: Delay fisso del ticker ===
|
||||
await Task.Delay(tickerIntervalMs, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -376,9 +431,11 @@ namespace AutoBidder.Services
|
||||
var elapsed = (DateTime.UtcNow - auction.LastDeadlineUpdateUtc.Value).TotalMilliseconds;
|
||||
|
||||
// Timer stimato = timer raw - tempo trascorso
|
||||
// NON clampare a 0: il ticker usa valori leggermente negativi
|
||||
// per catturare la finestra quando il timer scade tra due tick
|
||||
double estimated = auction.LastRawTimer - elapsed;
|
||||
|
||||
return Math.Max(0, estimated);
|
||||
return estimated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -419,12 +476,19 @@ namespace AutoBidder.Services
|
||||
EnsureCurrentBidInHistory(auction, state);
|
||||
}
|
||||
|
||||
// Gestione fine asta
|
||||
// Gestione fine asta — DIFFERITA
|
||||
// Non chiamare HandleAuctionEnded qui: il ticker deve avere
|
||||
// un'ultima occasione di puntare con i dati freschi del poll.
|
||||
// Lo stato di fine viene salvato in PendingEndState e processato
|
||||
// dal loop principale DOPO il ticker check.
|
||||
if (state.Status == AuctionStatus.EndedWon ||
|
||||
state.Status == AuctionStatus.EndedLost ||
|
||||
state.Status == AuctionStatus.Closed)
|
||||
{
|
||||
HandleAuctionEnded(auction, state);
|
||||
// Aggiorna LastState con i dati più freschi (ultimo puntatore, prezzo)
|
||||
// così il ticker può fare controlli accurati (es. IsMyBid, prezzo)
|
||||
auction.LastState = state;
|
||||
auction.PendingEndState = state;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -615,9 +679,8 @@ namespace AutoBidder.Services
|
||||
/// TICKER: Tenta la puntata quando il timer locale raggiunge l'offset configurato.
|
||||
/// NESSUNA compensazione automatica della latenza - l'utente decide il timing esatto.
|
||||
/// </summary>
|
||||
private async Task TryPlaceBidTicker(AuctionInfo auction, double estimatedTimerMs, int offsetMs, CancellationToken token)
|
||||
private async Task TryPlaceBidTicker(AuctionInfo auction, double estimatedTimerMs, int offsetMs, AppSettings settings, CancellationToken token)
|
||||
{
|
||||
var settings = SettingsManager.Load();
|
||||
var state = auction.LastState;
|
||||
|
||||
if (state == null || state.IsMyBid || estimatedTimerMs <= 0) return;
|
||||
@@ -671,7 +734,7 @@ namespace AutoBidder.Services
|
||||
auction.AddLog($"→ Inizio controlli puntata (timer={estimatedTimerMs:F0}ms ≤ soglia={settings.StrategyCheckThresholdMs}ms)",
|
||||
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
|
||||
|
||||
if (!ShouldBid(auction, state))
|
||||
if (!ShouldBid(auction, state, settings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -697,7 +760,7 @@ namespace AutoBidder.Services
|
||||
|
||||
auction.AddLog($"[BID] Timer locale={estimatedTimerMs:F0}ms, Offset utente={offsetMs}ms");
|
||||
|
||||
await ExecuteBid(auction, state, token);
|
||||
await ExecuteBid(auction, state, settings, token);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -725,13 +788,13 @@ namespace AutoBidder.Services
|
||||
? auction.BidBeforeDeadlineMs
|
||||
: settings.DefaultBidBeforeDeadlineMs;
|
||||
double timerMs = state.Timer * 1000;
|
||||
await TryPlaceBidTicker(auction, timerMs, offsetMs, token);
|
||||
await TryPlaceBidTicker(auction, timerMs, offsetMs, settings, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esegue la puntata e registra metriche
|
||||
/// </summary>
|
||||
private async Task ExecuteBid(AuctionInfo auction, AuctionState state, CancellationToken token)
|
||||
private async Task ExecuteBid(AuctionInfo auction, AuctionState state, AppSettings settings, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -780,7 +843,6 @@ namespace AutoBidder.Services
|
||||
else
|
||||
{
|
||||
var pollingPing = auction.PollingLatencyMs;
|
||||
var settings = SettingsManager.Load();
|
||||
|
||||
// Rileva errore "timer scaduto" per feedback utente
|
||||
bool isLateBid = result.Error?.Contains("timer") == true ||
|
||||
@@ -826,9 +888,9 @@ namespace AutoBidder.Services
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldBid(AuctionInfo auction, AuctionState state)
|
||||
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
|
||||
{
|
||||
var settings = Utilities.SettingsManager.Load();
|
||||
settings ??= Utilities.SettingsManager.Load();
|
||||
|
||||
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
|
||||
if (settings.ValueCheckEnabled &&
|
||||
@@ -917,7 +979,28 @@ namespace AutoBidder.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
// CONTROLLO 3: MinPrice/MaxPrice
|
||||
// CONTROLLO 3: Limite puntate per questa asta
|
||||
// Controlla MaxClicks (impostato dall'utente nella griglia o da product stats)
|
||||
{
|
||||
int maxBids = auction.MaxClicks; // 0 = illimitato
|
||||
int usedBids = auction.BidsUsedOnThisAuction ?? 0;
|
||||
|
||||
if (maxBids > 0 && usedBids >= maxBids)
|
||||
{
|
||||
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
|
||||
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
|
||||
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxBids > 0)
|
||||
{
|
||||
auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})",
|
||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROLLO 4: MinPrice/MaxPrice
|
||||
if (auction.MinPrice > 0 && state.Price < auction.MinPrice)
|
||||
{
|
||||
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
|
||||
@@ -1010,7 +1093,7 @@ namespace AutoBidder.Services
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = BidEventType.Reset,
|
||||
Bidder = state.LastBidder,
|
||||
Bidder = state.LastBidder ?? "",
|
||||
Price = state.Price,
|
||||
Timer = state.Timer,
|
||||
Notes = $"Puntata: EUR{state.Price:F2}"
|
||||
|
||||
@@ -436,17 +436,40 @@ namespace AutoBidder.Utilities
|
||||
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
|
||||
private static readonly string _file = Path.Combine(_folder, "settings.json");
|
||||
|
||||
// Cache per evitare letture disco ripetute nel hot path (ticker loop 50ms)
|
||||
private static readonly object _cacheLock = new();
|
||||
private static AppSettings? _cached;
|
||||
private static DateTime _cacheExpiry = DateTime.MinValue;
|
||||
private const int CACHE_TTL_MS = 2000; // Ricarica da disco al massimo ogni 2s
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (_cached != null && now < _cacheExpiry)
|
||||
return _cached;
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_file)) return new AppSettings();
|
||||
var txt = File.ReadAllText(_file);
|
||||
var s = JsonSerializer.Deserialize<AppSettings>(txt);
|
||||
if (s == null) return new AppSettings();
|
||||
return s;
|
||||
if (!File.Exists(_file))
|
||||
{
|
||||
_cached = new AppSettings();
|
||||
}
|
||||
else
|
||||
{
|
||||
var txt = File.ReadAllText(_file);
|
||||
_cached = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_cached ??= new AppSettings();
|
||||
}
|
||||
|
||||
_cacheExpiry = now.AddMilliseconds(CACHE_TTL_MS);
|
||||
return _cached;
|
||||
}
|
||||
catch { return new AppSettings(); }
|
||||
}
|
||||
|
||||
public static void Save(AppSettings settings)
|
||||
@@ -456,6 +479,13 @@ namespace AutoBidder.Utilities
|
||||
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
|
||||
var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_file, txt);
|
||||
|
||||
// Invalida cache così il prossimo Load() legge i nuovi valori
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cached = settings;
|
||||
_cacheExpiry = DateTime.UtcNow.AddMilliseconds(CACHE_TTL_MS);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user