From a0ec72f6c06522265b04d95472ffe5487637936c Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Fri, 23 Jan 2026 16:56:03 +0100 Subject: [PATCH] Refactor: solo SQLite, limiti auto, UI statistiche nuova MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rimosso completamente il supporto a PostgreSQL: ora tutte le statistiche e i dati persistenti usano solo SQLite, con percorso configurabile tramite DATA_PATH per Docker/volumi. Aggiunta gestione avanzata delle statistiche per prodotto, limiti consigliati calcolati automaticamente e applicabili dalla UI. Rinnovata la pagina Statistiche con tabelle aste recenti e prodotti, rimosso il supporto a grafici legacy e a "Puntate Gratuite". Migliorata la ricerca e la gestione delle aste nel browser, aggiunta diagnostica avanzata e logging dettagliato per il database. Aggiornati Dockerfile e docker-compose: l'app è ora self-contained e pronta per l'uso senza database esterni. --- Mimante/Dockerfile | 4 + Mimante/Models/AuctionInfo.cs | 8 +- Mimante/Models/ProductStatisticsRecord.cs | 120 +++ Mimante/Pages/AuctionBrowser.razor | 161 +++- Mimante/Pages/FreeBids.razor | 33 - Mimante/Pages/Index.razor | 40 +- Mimante/Pages/Index.razor.cs | 81 ++ Mimante/Pages/Settings.razor | 145 +--- Mimante/Pages/Statistics.razor | 438 +++++----- Mimante/Program.cs | 309 ++----- Mimante/Services/AuctionMonitor.cs | 128 ++- Mimante/Services/BidooBrowserService.cs | 8 + Mimante/Services/DatabaseService.cs | 868 +++++++++++++++++-- Mimante/Services/ProductStatisticsService.cs | 340 ++++++++ Mimante/Services/StatsService.cs | 486 ++++++----- Mimante/Shared/NavMenu.razor | 5 - Mimante/Utilities/SettingsManager.cs | 21 - Mimante/docker-compose.yml | 53 +- Mimante/wwwroot/css/modern-pages.css | 31 +- 19 files changed, 2311 insertions(+), 968 deletions(-) create mode 100644 Mimante/Models/ProductStatisticsRecord.cs delete mode 100644 Mimante/Pages/FreeBids.razor create mode 100644 Mimante/Services/ProductStatisticsService.cs diff --git a/Mimante/Dockerfile b/Mimante/Dockerfile index 2a83ed6..76336dd 100644 --- a/Mimante/Dockerfile +++ b/Mimante/Dockerfile @@ -56,6 +56,10 @@ ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production ENV Kestrel__EnableHttps=false +# Database path - tutti i database SQLite e dati persistenti +# Può essere sovrascritto nel docker-compose per mappare un volume persistente +ENV DATA_PATH=/app/Data + # Autenticazione applicazione (OBBLIGATORIO) ENV ADMIN_USERNAME=admin ENV ADMIN_PASSWORD= diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs index 6695077..3a817ba 100644 --- a/Mimante/Models/AuctionInfo.cs +++ b/Mimante/Models/AuctionInfo.cs @@ -37,8 +37,14 @@ namespace AutoBidder.Models public double MaxPrice { get; set; } = 0; public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati) + + /// + /// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI + /// Mantenuto per retrocompatibilità con salvataggi JSON esistenti + /// + [Obsolete("MaxClicks non è più utilizzato. Usa invece la logica di limiti per prodotto.")] [JsonPropertyName("MaxClicks")] - public int MaxClicks { get; set; } = 0; // Numero massimo di puntate consentite (0 = illimitato) + public int MaxClicks { get; set; } = 0; // Stato asta public bool IsActive { get; set; } = true; diff --git a/Mimante/Models/ProductStatisticsRecord.cs b/Mimante/Models/ProductStatisticsRecord.cs new file mode 100644 index 0000000..302ded8 --- /dev/null +++ b/Mimante/Models/ProductStatisticsRecord.cs @@ -0,0 +1,120 @@ +namespace AutoBidder.Models +{ + /// + /// Record per le statistiche aggregate di un prodotto nel database + /// + public class ProductStatisticsRecord + { + public string ProductKey { get; set; } = string.Empty; + public string ProductName { get; set; } = string.Empty; + + // Contatori + public int TotalAuctions { get; set; } + public int WonAuctions { get; set; } + public int LostAuctions { get; set; } + + // Statistiche prezzo + public double AvgFinalPrice { get; set; } + public double? MinFinalPrice { get; set; } + public double? MaxFinalPrice { get; set; } + + // Statistiche puntate + public double AvgBidsToWin { get; set; } + public int? MinBidsToWin { get; set; } + public int? MaxBidsToWin { get; set; } + + // Statistiche reset + public double AvgResets { get; set; } + public int? MinResets { get; set; } + public int? MaxResets { get; set; } + + // Limiti consigliati (calcolati dall'algoritmo) + public double? RecommendedMinPrice { get; set; } + public double? RecommendedMaxPrice { get; set; } + public int? RecommendedMinResets { get; set; } + public int? RecommendedMaxResets { get; set; } + public int? RecommendedMaxBids { get; set; } + + // JSON con statistiche per fascia oraria + public string? HourlyStatsJson { get; set; } + + // Metadata + public string? LastUpdated { get; set; } + + /// + /// Calcola il win rate come percentuale + /// + public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0; + } + + /// + /// Risultato asta esteso con tutti i campi per analytics + /// + public class AuctionResultExtended + { + public int Id { get; set; } + public string AuctionId { get; set; } = ""; + public string AuctionName { get; set; } = ""; + public double FinalPrice { get; set; } + public int BidsUsed { get; set; } + public bool Won { get; set; } + public string Timestamp { get; set; } = ""; + public double? BuyNowPrice { get; set; } + public double? ShippingCost { get; set; } + public double? TotalCost { get; set; } + public double? Savings { get; set; } + + // Campi estesi per analytics + public string? WinnerUsername { get; set; } + public int? ClosedAtHour { get; set; } + public string? ProductKey { get; set; } + public int? TotalResets { get; set; } + public int? WinnerBidsUsed { get; set; } + } + + /// + /// Limiti consigliati per un'asta basati sulle statistiche storiche + /// + public class RecommendedLimits + { + public double MinPrice { get; set; } + public double MaxPrice { get; set; } + public int MinResets { get; set; } + public int MaxResets { get; set; } + public int MaxBids { get; set; } + + /// + /// Confidence score (0-100) - quanto sono affidabili questi limiti + /// + public int ConfidenceScore { get; set; } + + /// + /// Numero di aste usate per calcolare i limiti + /// + public int SampleSize { get; set; } + + /// + /// Fascia oraria migliore per vincere (0-23) + /// + public int? BestHourToPlay { get; set; } + + /// + /// Win rate medio per questo prodotto + /// + public double? AverageWinRate { get; set; } + } + + /// + /// Statistiche per fascia oraria + /// + public class HourlyStats + { + public int Hour { get; set; } + public int TotalAuctions { get; set; } + public int WonAuctions { get; set; } + public double AvgFinalPrice { get; set; } + public double AvgBidsUsed { get; set; } + + public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0; + } +} diff --git a/Mimante/Pages/AuctionBrowser.razor b/Mimante/Pages/AuctionBrowser.razor index a64f8eb..2d45b1c 100644 --- a/Mimante/Pages/AuctionBrowser.razor +++ b/Mimante/Pages/AuctionBrowser.razor @@ -4,6 +4,7 @@ @using AutoBidder.Services @inject BidooBrowserService BrowserService @inject ApplicationStateService AppState +@inject AuctionMonitor AuctionMonitor @inject IJSRuntime JSRuntime @implements IDisposable @@ -25,6 +26,13 @@ Aggiorna + @if (auctions.Count > 0) + { + + } @@ -75,6 +83,41 @@ + +
+
+
+
+
+ + + + + @if (!string.IsNullOrEmpty(searchQuery)) + { + + } +
+
+
+
+ Risultati filtrati: + @filteredAuctions.Count +
+
+
+
+
+ @if (isLoading) { @@ -95,6 +138,16 @@ } + else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery)) + { +
+ +

Nessuna asta trovata per "@searchQuery"

+ +
+ } else if (auctions.Count == 0) {
@@ -109,7 +162,7 @@ {
- @foreach (var auction in auctions) + @foreach (var auction in filteredAuctions) {
@@ -239,19 +292,23 @@
@code { - private List categories = new(); - private List auctions = new(); - private int selectedCategoryIndex = 0; - private int currentPage = 0; +private List categories = new(); +private List auctions = new(); +private List filteredAuctions = new(); +private int selectedCategoryIndex = 0; +private int currentPage = 0; - private bool isLoading = false; - private bool isLoadingMore = false; - private bool canLoadMore = true; - private string? errorMessage = null; +private bool isLoading = false; +private bool isLoadingMore = false; +private bool canLoadMore = true; +private string? errorMessage = null; - private System.Threading.Timer? stateUpdateTimer; - private CancellationTokenSource? cts; - private bool isUpdatingInBackground = false; +// ? NUOVO: Ricerca +private string searchQuery = ""; + +private System.Threading.Timer? stateUpdateTimer; +private CancellationTokenSource? cts; +private bool isUpdatingInBackground = false; protected override async Task OnInitializedAsync() { @@ -319,6 +376,9 @@ { await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token); } + + // ? NUOVO: Applica filtro ricerca + ApplySearchFilter(); } catch (OperationCanceledException) { @@ -335,6 +395,46 @@ StateHasChanged(); } } + + // ? NUOVO: Metodo per applicare il filtro di ricerca + private void ApplySearchFilter() + { + if (string.IsNullOrWhiteSpace(searchQuery)) + { + filteredAuctions = auctions.ToList(); + return; + } + + var query = searchQuery.ToLowerInvariant().Trim(); + + filteredAuctions = auctions.Where(a => + // Cerca nel nome + a.Name.ToLowerInvariant().Contains(query) || + // Cerca nel prezzo corrente + a.CurrentPrice.ToString("F2").Contains(query) || + // Cerca nel prezzo buy-now + (a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) || + // Cerca nel nome dell'ultimo puntatore + (!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) || + // Cerca nell'ID asta + a.AuctionId.Contains(query) + ).ToList(); + } + + // ? NUOVO: Callback quando cambia la ricerca + private void OnSearchChanged() + { + ApplySearchFilter(); + StateHasChanged(); + } + + // ? NUOVO: Pulisce la ricerca + private void ClearSearch() + { + searchQuery = ""; + ApplySearchFilter(); + StateHasChanged(); + } private async Task LoadMoreAuctions() { @@ -364,6 +464,9 @@ // Aggiorna stati delle nuove aste await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token); + + // ? NUOVO: Riapplica filtro dopo caricamento + ApplySearchFilter(); } } catch (Exception ex) @@ -407,6 +510,15 @@ await LoadAuctions(); } + private void ClearAllAuctions() + { + // Cancella le aste e ferma il timer + cts?.Cancel(); + auctions.Clear(); + filteredAuctions.Clear(); + StateHasChanged(); + } + private void UpdateMonitoredStatus() { var monitoredIds = AppState.Auctions.Select(a => a.AuctionId).ToHashSet(); @@ -420,23 +532,48 @@ { if (browserAuction.IsMonitored) return; + // ?? Carica impostazioni di default + var settings = AutoBidder.Utilities.SettingsManager.Load(); + var auctionInfo = new AuctionInfo { AuctionId = browserAuction.AuctionId, Name = browserAuction.Name, OriginalUrl = browserAuction.Url, BuyNowPrice = (double)browserAuction.BuyNowPrice, + + // ?? FIX: Applica valori dalle impostazioni + BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs, + CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid, + MinPrice = settings.DefaultMinPrice, + MaxPrice = settings.DefaultMaxPrice, + MaxClicks = settings.DefaultMaxClicks, + MinResets = settings.DefaultMinResets, + MaxResets = settings.DefaultMaxResets, + IsActive = true, IsPaused = true, // Start paused AddedAt = DateTime.UtcNow }; AppState.AddAuction(auctionInfo); + + // ?? FIX CRITICO: Registra l'asta nel monitor! + AuctionMonitor.AddAuction(auctionInfo); + browserAuction.IsMonitored = true; // Save to disk AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList()); + // ?? FIX CRITICO: Avvia il monitor se non è già attivo + if (!AppState.IsMonitoringActive) + { + AuctionMonitor.Start(); + AppState.IsMonitoringActive = true; + Console.WriteLine($"[AuctionBrowser] Monitor auto-started for paused auction: {auctionInfo.Name}"); + } + StateHasChanged(); } diff --git a/Mimante/Pages/FreeBids.razor b/Mimante/Pages/FreeBids.razor deleted file mode 100644 index 5b2f04e..0000000 --- a/Mimante/Pages/FreeBids.razor +++ /dev/null @@ -1,33 +0,0 @@ -@page "/freebids" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] - -Puntate Gratuite - AutoBidder - -
-
- -

Puntate Gratuite

-
- - -
-
- -
-
Funzionalità in Sviluppo
-

- Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo. -
- Disponibile in una prossima versione con statistiche dettagliate. -

-
-
-
-
- - diff --git a/Mimante/Pages/Index.razor b/Mimante/Pages/Index.razor index 1d9861e..54b8322 100644 --- a/Mimante/Pages/Index.razor +++ b/Mimante/Pages/Index.razor @@ -87,7 +87,7 @@ Prezzo Timer Ultimo - Click + Puntate Ping Azioni @@ -95,7 +95,7 @@ @foreach (var auction in auctions) { - @@ -107,7 +107,7 @@ @GetPriceDisplay(auction) @GetTimerDisplay(auction) @GetLastBidder(auction) - @GetMyBidsCount(auction) + @GetMyBidsCount(auction) @GetPingDisplay(auction)
@@ -230,6 +230,9 @@ +
@@ -272,6 +275,31 @@ Verifica asta aperta prima di puntare
+ + +
+ + @if (!string.IsNullOrEmpty(recommendationMessage)) + { +
+ + @recommendationMessage +
+ } +
@@ -417,12 +445,14 @@ // Crea una copia locale per evitare modifiche durante l'enumerazione var recentBidsCopy = selectedAuction.RecentBids.ToList(); + // Calcola statistiche puntatori var bidderStats = recentBidsCopy .GroupBy(b => b.Username) .Select(g => new { Username = g.Key, Count = g.Count(), IsMe = g.First().IsMyBid }) .OrderByDescending(s => s.Count) .ToList(); + var totalBids = recentBidsCopy.Count;
@@ -438,9 +468,9 @@ @for (int i = 0; i < bidderStats.Count; i++) { var bidder = bidderStats[i]; - var percentage = (bidder.Count * 100.0 / recentBidsCopy.Count); + var percentage = (bidder.Count * 100.0 / totalBids); - +
#{i + 1}#@(i + 1) @bidder.Username @if (bidder.IsMe) diff --git a/Mimante/Pages/Index.razor.cs b/Mimante/Pages/Index.razor.cs index 7251e64..baa4579 100644 --- a/Mimante/Pages/Index.razor.cs +++ b/Mimante/Pages/Index.razor.cs @@ -13,6 +13,7 @@ namespace AutoBidder.Pages { [Inject] private ApplicationStateService AppState { get; set; } = default!; [Inject] private HtmlCacheService HtmlCache { get; set; } = default!; + [Inject] private StatsService StatsService { get; set; } = default!; private List auctions => AppState.Auctions.ToList(); private AuctionInfo? selectedAuction @@ -40,6 +41,11 @@ namespace AutoBidder.Pages private int sessionRemainingBids; private double sessionShopCredit; private int sessionAuctionsWon; + + // Recommended limits + private bool isLoadingRecommendations = false; + private string? recommendationMessage = null; + private bool recommendationSuccess = false; protected override void OnInitialized() { @@ -631,6 +637,19 @@ namespace AutoBidder.Pages } } + private async Task OpenAuctionInNewTab(string url) + { + try + { + await JSRuntime.InvokeVoidAsync("window.open", url, "_blank"); + AddLog("Asta aperta in nuova scheda"); + } + catch (Exception ex) + { + AddLog($"Errore apertura: {ex.Message}"); + } + } + // Helper methods per stili e classi private string GetRowClass(AuctionInfo auction) { @@ -1086,5 +1105,67 @@ namespace AutoBidder.Pages await RemoveSelectedAuctionWithConfirm(); } } + + private async Task ApplyRecommendedLimitsToSelected() + { + if (selectedAuction == null) return; + + isLoadingRecommendations = true; + recommendationMessage = null; + StateHasChanged(); + + try + { + var limits = await StatsService.GetRecommendedLimitsAsync(selectedAuction.Name); + + if (limits == null || limits.SampleSize == 0) + { + recommendationMessage = "Nessun dato storico disponibile per questo prodotto. Completa alcune aste per generare raccomandazioni."; + recommendationSuccess = false; + } + else if (limits.ConfidenceScore < 30) + { + // Applica comunque ma con avviso + selectedAuction.MinPrice = limits.MinPrice; + selectedAuction.MaxPrice = limits.MaxPrice; + selectedAuction.MinResets = limits.MinResets; + selectedAuction.MaxResets = limits.MaxResets; + selectedAuction.MaxClicks = limits.MaxBids; + + SaveAuctions(); + + recommendationMessage = $"Limiti applicati con bassa confidenza ({limits.ConfidenceScore}%) - basati su {limits.SampleSize} aste"; + recommendationSuccess = true; + } + else + { + // Applica limiti con buona confidenza + selectedAuction.MinPrice = limits.MinPrice; + selectedAuction.MaxPrice = limits.MaxPrice; + selectedAuction.MinResets = limits.MinResets; + selectedAuction.MaxResets = limits.MaxResets; + selectedAuction.MaxClicks = limits.MaxBids; + + SaveAuctions(); + + var hourInfo = limits.BestHourToPlay.HasValue + ? $" | Ora migliore: {limits.BestHourToPlay}:00" + : ""; + + recommendationMessage = $"? Limiti applicati (confidenza {limits.ConfidenceScore}%, {limits.SampleSize} aste){hourInfo}"; + recommendationSuccess = true; + } + } + catch (Exception ex) + { + recommendationMessage = $"Errore: {ex.Message}"; + recommendationSuccess = false; + } + finally + { + isLoadingRecommendations = false; + StateHasChanged(); + } + } } } diff --git a/Mimante/Pages/Settings.razor b/Mimante/Pages/Settings.razor index 44ed798..f4d0d84 100644 --- a/Mimante/Pages/Settings.razor +++ b/Mimante/Pages/Settings.razor @@ -12,7 +12,7 @@

Impostazioni

- Configura sessione, comportamento aste, limiti e database statistiche. + Configura sessione, comportamento aste e limiti.
@@ -208,78 +208,6 @@ - - -
-

- -

-
-
-
- - PostgreSQL per statistiche avanzate; SQLite come fallback. -
- -
- - -
- - @if (settings.UsePostgreSQL) - { -
- - -
- -
-
-
- - -
-
-
-
- - -
-
-
- -
- - - @if (!string.IsNullOrEmpty(dbTestResult)) - { - - - @dbTestResult - - } -
- } - -
- -
-
-
-
@@ -300,21 +228,17 @@ @code { - private string startupLoadMode = "Stopped"; +private string startupLoadMode = "Stopped"; - private string? currentUsername; - private int remainingBids; - private string cookieInput = ""; - private string usernameInput = ""; - private string? connectionError; - private bool isConnecting; +private string? currentUsername; +private int remainingBids; +private string cookieInput = ""; +private string usernameInput = ""; +private string? connectionError; +private bool isConnecting; - private bool isTestingConnection; - private string? dbTestResult; - private bool dbTestSuccess; - - private AutoBidder.Utilities.AppSettings settings = new(); - private System.Threading.Timer? updateTimer; +private AutoBidder.Utilities.AppSettings settings = new(); +private System.Threading.Timer? updateTimer; protected override void OnInitialized() { @@ -492,55 +416,6 @@ settings.DefaultNewAuctionState = state; } - private async Task TestDatabaseConnection() - { - isTestingConnection = true; - dbTestResult = null; - dbTestSuccess = false; - StateHasChanged(); - - try - { - var connString = settings.PostgresConnectionString; - - if (string.IsNullOrWhiteSpace(connString)) - { - dbTestResult = "Connection string vuota"; - dbTestSuccess = false; - return; - } - - await using var conn = new Npgsql.NpgsqlConnection(connString); - await conn.OpenAsync(); - - await using var cmd = new Npgsql.NpgsqlCommand("SELECT version()", conn); - var version = await cmd.ExecuteScalarAsync(); - - var versionStr = version?.ToString() ?? ""; - var versionNumber = versionStr.Contains("PostgreSQL") ? versionStr.Split(' ')[1] : "Unknown"; - - dbTestResult = $"Connessione OK (PostgreSQL {versionNumber})"; - dbTestSuccess = true; - - await conn.CloseAsync(); - } - catch (Npgsql.NpgsqlException ex) - { - dbTestResult = $"Errore PostgreSQL: {ex.Message}"; - dbTestSuccess = false; - } - catch (Exception ex) - { - dbTestResult = $"Errore: {ex.Message}"; - dbTestSuccess = false; - } - finally - { - isTestingConnection = false; - StateHasChanged(); - } - } - private string GetRemainingBidsClass() { if (remainingBids < 50) return "bg-danger"; diff --git a/Mimante/Pages/Statistics.razor b/Mimante/Pages/Statistics.razor index a38de41..29a7e97 100644 --- a/Mimante/Pages/Statistics.razor +++ b/Mimante/Pages/Statistics.razor @@ -1,238 +1,236 @@ @page "/statistics" @attribute [Microsoft.AspNetCore.Authorization.Authorize] +@using AutoBidder.Models +@using AutoBidder.Services @inject StatsService StatsService -@inject IJSRuntime JSRuntime +@inject DatabaseService DatabaseService Statistiche - AutoBidder -
-
+
+

Statistiche

- + @if (StatsService.IsAvailable) + { + + }
- @if (errorMessage != null) + @if (!StatsService.IsAvailable) { -
- @errorMessage +
+
+ +
+
Statistiche non disponibili
+

Il database per le statistiche non è stato configurato o non è accessibile.

+
+
} - - @if (isLoading) + else if (isLoading) {

Caricamento statistiche...

} - else if (totalStats != null) - { - -
-
-
-
- -

@totalStats.TotalBidsUsed

-

Puntate Usate

- €@((totalStats.TotalBidsUsed * 0.20).ToString("F2")) -
-
-
-
-
-
- -

@totalStats.TotalAuctionsWon

-

Aste Vinte

- Win Rate: @totalStats.WinRate.ToString("F1")% -
-
-
-
-
-
- -

€@totalStats.TotalSavings.ToString("F2")

-

Risparmio Totale

- ROI: @roi.ToString("F1")% -
-
-
-
-
-
- -

@totalStats.AverageBidsPerAuction.ToString("F1")

-

Puntate/Asta Media

- Latency: @totalStats.AverageLatency.ToString("F0")ms -
-
-
-
- - -
-
-
-
-
Spesa Giornaliera (Ultimi 30 Giorni)
-
-
- -
-
-
-
-
-
-
Aste Vinte vs Perse
-
-
- -
-
-
-
- - - @if (recentResults != null && recentResults.Any()) - { -
-
-
Aste Recenti
-
-
-
- - - - - - - - - - - - - @foreach (var result in recentResults.Take(10)) - { - - - - - - - - - } - -
AstaPrezzo FinalePuntateRisultatoRisparmioData
@result.AuctionName€@result.FinalPrice.ToString("F2")@result.BidsUsed - @if (result.Won) - { - Vinta - } - else - { - Persa - } - - @if (result.Savings.HasValue) - { - @((result.Savings.Value > 0 ? "+" : "") + "€" + result.Savings.Value.ToString("F2")) - } - else - { - - - } - @DateTime.Parse(result.Timestamp).ToString("dd/MM HH:mm")
-
-
-
- } - } else { -
- - Nessuna statistica disponibile. Completa alcune aste per vedere le statistiche. +
+ +
+
+
+
+ + Aste Terminate Recenti +
+
+
+ @if (recentAuctions == null || !recentAuctions.Any()) + { +
+ +

Nessuna asta terminata salvata

+
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var auction in recentAuctions) + { + + + + + + + + + } + +
NomePrezzoPuntateVincitoreStatoData
@auction.AuctionName€@auction.FinalPrice.ToString("F2")@auction.BidsUsed@(auction.WinnerUsername ?? "-") + @if (auction.Won) + { + ? Vinta + } + else + { + ? Persa + } + @FormatTimestamp(auction.Timestamp)
+
+ } +
+
+
+ + +
+
+
+
+ + Prodotti Salvati +
+
+
+ @if (products == null || !products.Any()) + { +
+ +

Nessun prodotto salvato

+
+ } + else + { +
+ + + + + + + + + + + + @foreach (var product in products) + { + var winRate = product.TotalAuctions > 0 + ? (product.WonAuctions * 100.0 / product.TotalAuctions) + : 0; + + + + + + + + + } + +
ProdottoAsteWin%Limiti €Azioni
+ @product.ProductName +
+ @product.TotalAuctions totali (@product.WonAuctions vinte) +
+ @product.TotalAuctions + + + @winRate.ToString("F0")% + + + @if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue) + { + + @product.RecommendedMinPrice.Value.ToString("F2") - @product.RecommendedMaxPrice.Value.ToString("F2") + + } + else + { + - + } + + @if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue) + { + + } + else + { + N/D + } +
+
+ } +
+
+
}
- - @code { - private TotalStats? totalStats; - private List? recentResults; - private string? errorMessage; - private bool isLoading = false; - private double roi = 0; +private bool isLoading = true; +private List? recentAuctions; +private List? products; + +[Inject] private AuctionMonitor AuctionMonitor { get; set; } = default!; +[Inject] private ApplicationStateService AppState { get; set; } = default!; +[Inject] private IJSRuntime JSRuntime { get; set; } = default!; protected override async Task OnInitializedAsync() { await RefreshStats(); } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender && totalStats != null) - { - await RenderCharts(); - } - } - private async Task RefreshStats() { + isLoading = true; + StateHasChanged(); + try { - isLoading = true; - errorMessage = null; - StateHasChanged(); + // Carica aste recenti (ultime 50) + recentAuctions = await DatabaseService.GetRecentAuctionResultsAsync(50); - // Carica statistiche - totalStats = await StatsService.GetTotalStatsAsync(); - roi = await StatsService.CalculateROIAsync(); - recentResults = await StatsService.GetRecentAuctionResultsAsync(20); - - // Render grafici dopo il caricamento - if (totalStats != null) - { - await RenderCharts(); - } + // Carica prodotti con statistiche + products = await DatabaseService.GetAllProductStatisticsAsync(); } catch (Exception ex) { - errorMessage = $"Errore caricamento statistiche: {ex.Message}"; - Console.WriteLine($"[ERROR] Statistics: {ex}"); + Console.WriteLine($"[Statistics] Error loading data: {ex.Message}"); } finally { @@ -241,26 +239,52 @@ } } - private async Task RenderCharts() + private string FormatTimestamp(string timestamp) + { + if (DateTime.TryParse(timestamp, out var dt)) + { + return dt.ToString("dd/MM HH:mm"); + } + return timestamp; + } + + private async Task ApplyLimitsToProduct(ProductStatisticsRecord product) { try { - var chartData = await StatsService.GetChartDataAsync(30); - - // Render grafico spesa - await JSRuntime.InvokeVoidAsync("renderMoneyChart", - chartData.Labels, - chartData.MoneySpent, - chartData.Savings); - - // Render grafico wins - await JSRuntime.InvokeVoidAsync("renderWinsChart", - totalStats!.TotalAuctionsWon, - totalStats!.TotalAuctionsLost); + // Trova tutte le aste con questo ProductKey nel monitor + var matchingAuctions = AppState.Auctions + .Where(a => ProductStatisticsService.GenerateProductKey(a.Name) == product.ProductKey) + .ToList(); + + if (!matchingAuctions.Any()) + { + await JSRuntime.InvokeVoidAsync("alert", $"Nessuna asta trovata per '{product.ProductName}'"); + return; + } + + // Applica i limiti + foreach (var auction in matchingAuctions) + { + auction.MinPrice = product.RecommendedMinPrice ?? 0; + auction.MaxPrice = product.RecommendedMaxPrice ?? 0; + auction.MinResets = product.RecommendedMinResets ?? 0; + auction.MaxResets = product.RecommendedMaxResets ?? 0; + } + + // Salva + AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList()); + + await JSRuntime.InvokeVoidAsync("alert", + $"? Limiti applicati a {matchingAuctions.Count} aste di '{product.ProductName}'\n\n" + + $"Min: €{product.RecommendedMinPrice:F2}\n" + + $"Max: €{product.RecommendedMaxPrice:F2}\n" + + $"Min Reset: {product.RecommendedMinResets}\n" + + $"Max Reset: {product.RecommendedMaxResets}"); } catch (Exception ex) { - Console.WriteLine($"[ERROR] Render charts: {ex.Message}"); + await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}"); } } } diff --git a/Mimante/Program.cs b/Mimante/Program.cs index d889dac..1c49bee 100644 --- a/Mimante/Program.cs +++ b/Mimante/Program.cs @@ -1,13 +1,9 @@ -using AutoBidder.Services; +using AutoBidder.Services; using AutoBidder.Data; using AutoBidder.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection; -using System.Data.Common; var builder = WebApplication.CreateBuilder(args); @@ -23,7 +19,7 @@ else } // Configura Kestrel solo per HTTPS opzionale -// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile) +// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile) var enableHttps = builder.Configuration.GetValue("Kestrel:EnableHttps", false); if (enableHttps) @@ -77,12 +73,29 @@ else builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); +// ============================================ +// CONFIGURAZIONE DATABASE - Path configurabile via DATA_PATH +// ============================================ + +// Determina il path base per tutti i database e dati persistenti +// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente +var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH"); +if (string.IsNullOrEmpty(dataBasePath)) +{ + // Fallback: usa directory relativa all'applicazione + dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data"); +} + +// Crea directory se non esiste +if (!Directory.Exists(dataBasePath)) +{ + Directory.CreateDirectory(dataBasePath); +} + +Console.WriteLine($"[Startup] Data path: {dataBasePath}"); + // Configura Data Protection per evitare CryptographicException -var dataProtectionPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AutoBidder", - "DataProtection-Keys" -); +var dataProtectionPath = Path.Combine(dataBasePath, "DataProtection-Keys"); if (!Directory.Exists(dataProtectionPath)) { @@ -93,16 +106,8 @@ builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath)) .SetApplicationName("AutoBidder"); -// ============================================ -// CONFIGURAZIONE AUTENTICAZIONE E SICUREZZA -// ============================================ - // Database per Identity (SQLite) -var identityDbPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AutoBidder", - "identity.db" -); +var identityDbPath = Path.Combine(dataBasePath, "identity.db"); builder.Services.AddDbContext(options => { @@ -163,71 +168,6 @@ if (!builder.Environment.IsDevelopment()) }); } -// Configura Database SQLite per statistiche (fallback locale) -builder.Services.AddDbContext(options => -{ - var dbPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AutoBidder", - "statistics.db" - ); - - // Crea directory se non esiste - var directory = Path.GetDirectoryName(dbPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - options.UseSqlite($"Data Source={dbPath}"); -}); - -// Configura Database PostgreSQL per statistiche avanzate -var usePostgres = builder.Configuration.GetValue("Database:UsePostgres", false); -if (usePostgres) -{ - try - { - var connString = builder.Environment.IsProduction() - ? builder.Configuration.GetConnectionString("PostgresStatsProduction") - : builder.Configuration.GetConnectionString("PostgresStats"); - - // Sostituisci variabili ambiente in production - if (builder.Environment.IsProduction()) - { - connString = connString? - .Replace("${POSTGRES_USER}", Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "autobidder") - .Replace("${POSTGRES_PASSWORD}", Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? ""); - } - - if (!string.IsNullOrEmpty(connString)) - { - builder.Services.AddDbContext(options => - { - options.UseNpgsql(connString, npgsqlOptions => - { - npgsqlOptions.EnableRetryOnFailure(3); - npgsqlOptions.CommandTimeout(30); - }); - }); - - Console.WriteLine("[Startup] PostgreSQL configured for statistics"); - } - else - { - Console.WriteLine("[Startup] PostgreSQL connection string not found - using SQLite only"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[Startup] PostgreSQL configuration failed: {ex.Message} - using SQLite only"); - } -} -else -{ - Console.WriteLine("[Startup] PostgreSQL disabled in configuration - using SQLite only"); -} - // Registra servizi applicazione come Singleton per condividere stato var htmlCacheService = new HtmlCacheService( maxConcurrentRequests: 3, @@ -245,23 +185,7 @@ builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClie builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddScoped(sp => -{ - var db = sp.GetRequiredService(); - - // Prova a ottenere PostgreSQL context (potrebbe essere null) - AutoBidder.Data.PostgresStatsContext? postgresDb = null; - try - { - postgresDb = sp.GetService(); - } - catch - { - // PostgreSQL non disponibile, usa solo SQLite - } - - return new StatsService(db, postgresDb); -}); +builder.Services.AddScoped(); builder.Services.AddScoped(); // Configura SignalR per real-time updates @@ -352,139 +276,77 @@ using (var scope = app.Services.CreateScope()) // Verifica salute database var isHealthy = await databaseService.CheckDatabaseHealthAsync(); Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}"); + + // 🆕 Esegui diagnostica completa se ci sono problemi o se richiesto + var runDiagnostics = Environment.GetEnvironmentVariable("DB_DIAGNOSTICS")?.ToLower() == "true"; + if (!isHealthy || runDiagnostics) + { + Console.WriteLine("[DB] Running full diagnostics..."); + await databaseService.RunDatabaseDiagnosticsAsync(); + } } catch (Exception ex) { Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}"); Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}"); - } -} - -// Crea database statistiche se non esiste (senza migrations) -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - - try - { - // Log percorso database - var connection = db.Database.GetDbConnection(); - Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}"); - // Verifica se database esiste - var dbExists = db.Database.CanConnect(); - Console.WriteLine($"[STATS DB] Database exists: {dbExists}"); - - // Forza creazione tabelle se non esistono - if (!dbExists || !db.ProductStats.Any()) - { - Console.WriteLine("[STATS DB] Creating database schema..."); - db.Database.EnsureDeleted(); // Elimina database vecchio - db.Database.EnsureCreated(); // Ricrea con schema aggiornato - Console.WriteLine("[STATS DB] Database schema created successfully"); - } - else - { - Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}"); - Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}"); - - // Prova a ricreare forzatamente + // In caso di errore, esegui sempre la diagnostica try { - Console.WriteLine("[STATS DB] Attempting forced recreation..."); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - Console.WriteLine("[STATS DB] Forced recreation successful"); + await databaseService.RunDatabaseDiagnosticsAsync(); } - catch (Exception ex2) + catch { - Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}"); + // Ignora errori nella diagnostica stessa } } } -// Inizializza PostgreSQL per statistiche avanzate -using (var scope = app.Services.CreateScope()) +// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche { - try + var dbService = app.Services.GetRequiredService(); + + + + auctionMonitor.OnAuctionCompleted += async (auction, state, won) => { - var postgresDb = scope.ServiceProvider.GetService(); - - if (postgresDb != null) + try { - Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database..."); + Console.WriteLine($""); + Console.WriteLine($"â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ [EVENTO] Asta Terminata - Salvataggio Statistiche"); + Console.WriteLine($"â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Asta: {auction.Name}"); + Console.WriteLine($"â•‘ ID: {auction.AuctionId}"); + Console.WriteLine($"â•‘ Stato: {(won ? "✓ VINTA" : "✗ PERSA")}"); + Console.WriteLine($"╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($""); - var autoCreateSchema = app.Configuration.GetValue("Database:AutoCreateSchema", true); + // Crea un nuovo scope per StatsService (è Scoped) + using var scope = app.Services.CreateScope(); + var statsService = scope.ServiceProvider.GetRequiredService(); - if (autoCreateSchema) - { - // Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica - var schemaCreated = await postgresDb.EnsureSchemaAsync(); - - if (schemaCreated) - { - // Valida che tutte le tabelle siano state create - var schemaValid = await postgresDb.ValidateSchemaAsync(); - - if (schemaValid) - { - Console.WriteLine("[PostgreSQL] Statistics features ENABLED"); - } - else - { - Console.WriteLine("[PostgreSQL] Schema validation failed"); - Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)"); - } - } - else - { - Console.WriteLine("[PostgreSQL] Cannot connect to database"); - Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback"); - } - } - else - { - Console.WriteLine("[PostgreSQL] Auto-create schema disabled"); - - // Prova comunque a validare lo schema esistente - try - { - var schemaValid = await postgresDb.ValidateSchemaAsync(); - if (schemaValid) - { - Console.WriteLine("[PostgreSQL] Existing schema validated successfully"); - Console.WriteLine("[PostgreSQL] Statistics features ENABLED"); - } - else - { - Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)"); - } - } - catch - { - Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)"); - } - } + await statsService.RecordAuctionCompletedAsync(auction, state, won); + + // ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni + Console.WriteLine($""); + Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database"); + Console.WriteLine($""); } - else + catch (Exception ex) { - Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only"); + Console.WriteLine($""); + Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:"); + Console.WriteLine($"[EVENTO ERROR] {ex.Message}"); + Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}"); + Console.WriteLine($""); } - } - catch (Exception ex) - { - Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}"); - Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}"); - Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback"); - } + }; + + Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered"); } -// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato +// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato using (var scope = app.Services.CreateScope()) { try @@ -519,15 +381,26 @@ using (var scope = app.Services.CreateScope()) // Gestisci comportamento di avvio if (settings.RememberAuctionStates) { - // Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta - var activeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList(); + // Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta + // 🔥 FIX CRITICO: Avvia monitor anche per aste in pausa (IsActive=true) + var activeAuctions = savedAuctions.Where(a => a.IsActive).ToList(); + var resumeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList(); + var pausedAuctions = savedAuctions.Where(a => a.IsActive && a.IsPaused).ToList(); if (activeAuctions.Any()) { - Console.WriteLine($"[STARTUP] Resuming monitoring for {activeAuctions.Count} active auctions"); + Console.WriteLine($"[STARTUP] Starting monitor for {activeAuctions.Count} active auctions ({resumeAuctions.Count} active, {pausedAuctions.Count} paused)"); monitor.Start(); appState.IsMonitoringActive = true; - appState.AddLog($"[STARTUP] Ripristinato stato salvato: {activeAuctions.Count} aste attive"); + + if (pausedAuctions.Any()) + { + appState.AddLog($"[STARTUP] Ripristinate {resumeAuctions.Count} aste attive + {pausedAuctions.Count} in pausa (polling attivo)"); + } + else + { + appState.AddLog($"[STARTUP] Ripristinato stato salvato: {resumeAuctions.Count} aste attive"); + } } else { @@ -537,7 +410,7 @@ using (var scope = app.Services.CreateScope()) } else { - // Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste + // Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste switch (settings.DefaultStartAuctionsOnLoad) { case "Active": @@ -597,7 +470,7 @@ if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); - // Abilita HSTS solo se HTTPS è attivo + // Abilita HSTS solo se HTTPS è attivo if (enableHttps) { app.UseHsts(); @@ -608,7 +481,7 @@ else app.UseDeveloperExceptionPage(); } -// Abilita HTTPS redirection solo se HTTPS è configurato +// Abilita HTTPS redirection solo se HTTPS è configurato if (enableHttps) { app.UseHttpsRedirection(); diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs index 7d82024..b921b3f 100644 --- a/Mimante/Services/AuctionMonitor.cs +++ b/Mimante/Services/AuctionMonitor.cs @@ -22,6 +22,12 @@ namespace AutoBidder.Services public event Action? OnBidExecuted; public event Action? OnLog; public event Action? OnResetCountChanged; + + /// + /// Evento fired quando un'asta termina (vinta, persa o chiusa). + /// Parametri: AuctionInfo, AuctionState finale, bool won (true se vinta dall'utente) + /// + public event Action? OnAuctionCompleted; public AuctionMonitor() { @@ -101,12 +107,52 @@ namespace AutoBidder.Services var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId); if (auction != null) { + // ?? Se l'asta è terminata, salva le statistiche prima di rimuoverla + if (!auction.IsActive && auction.LastState != null) + { + OnLog?.Invoke($"[STATS] Asta terminata rilevata: {auction.Name} - Salvataggio statistiche in corso..."); + + try + { + // Determina se è stata vinta dall'utente + var won = IsAuctionWonByUser(auction); + + OnLog?.Invoke($"[STATS] Asta {auction.Name} - Stato: {(won ? "VINTA" : "PERSA")}"); + + // Emetti evento per salvare le statistiche + // Questo trigger sarà gestito in Program.cs con scraping HTML + OnAuctionCompleted?.Invoke(auction, auction.LastState, won); + } + catch (Exception ex) + { + OnLog?.Invoke($"[STATS ERROR] Errore durante salvataggio statistiche per {auction.Name}: {ex.Message}"); + } + } + else + { + OnLog?.Invoke($"[REMOVE] Rimozione asta non terminata: {auction.Name} (non salvata nelle statistiche)"); + } + _auctions.Remove(auction); - // ? RIMOSSO: Log ridondante - viene già loggato da MainWindow con più dettagli - // OnLog?.Invoke($"[-] Asta rimossa: {auction.Name}"); } } } + + /// + /// Determina se l'asta è stata vinta dall'utente corrente + /// + private bool IsAuctionWonByUser(AuctionInfo auction) + { + if (auction.LastState == null) return false; + + var session = _apiClient.GetSession(); + var username = session?.Username; + + if (string.IsNullOrEmpty(username)) return false; + + // Controlla se l'ultimo puntatore è l'utente + return auction.LastState.LastBidder?.Equals(username, StringComparison.OrdinalIgnoreCase) == true; + } public IReadOnlyList GetAuctions() { @@ -115,6 +161,62 @@ namespace AutoBidder.Services return _auctions.ToList(); } } + + /// + /// Applica i limiti consigliati a un'asta specifica + /// + public bool ApplyLimitsToAuction(string auctionId, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids) + { + lock (_auctions) + { + var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId); + if (auction == null) return false; + + auction.MinPrice = minPrice; + auction.MaxPrice = maxPrice; + auction.MinResets = minResets; + auction.MaxResets = maxResets; + auction.MaxClicks = maxBids; + + OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}: MinPrice={minPrice:F2}, MaxPrice={maxPrice:F2}, MinResets={minResets}, MaxResets={maxResets}, MaxBids={maxBids}"); + return true; + } + } + + /// + /// Applica i limiti consigliati a tutte le aste con lo stesso ProductKey + /// + public int ApplyLimitsToProductAuctions(string productKey, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids) + { + int count = 0; + + lock (_auctions) + { + foreach (var auction in _auctions) + { + var auctionProductKey = ProductStatisticsService.GenerateProductKey(auction.Name); + + if (auctionProductKey == productKey) + { + auction.MinPrice = minPrice; + auction.MaxPrice = maxPrice; + auction.MinResets = minResets; + auction.MaxResets = maxResets; + auction.MaxClicks = maxBids; + count++; + + OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}"); + } + } + } + + if (count > 0) + { + OnLog?.Invoke($"[LIMITS] Applicati limiti a {count} aste con productKey={productKey}"); + } + + return count; + } public void Start() { @@ -247,6 +349,7 @@ namespace AutoBidder.Services return; } + auction.PollingLatencyMs = state.PollingLatencyMs; // ? AGGIORNATO: Aggiorna storia puntate mantenendo quelle vecchie @@ -262,7 +365,10 @@ namespace AutoBidder.Services string statusMsg = state.Status == AuctionStatus.EndedWon ? "VINTA" : state.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa"; + bool won = state.Status == AuctionStatus.EndedWon; + auction.IsActive = false; + auction.LastState = state; // Salva stato finale per statistiche auction.AddLog($"[ASTA TERMINATA] {statusMsg}"); OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg} - Polling fermato"); @@ -277,6 +383,18 @@ namespace AutoBidder.Services }); OnAuctionUpdated?.Invoke(state); + + // ?? Emetti evento per salvare statistiche + try + { + OnAuctionCompleted?.Invoke(auction, state, won); + OnLog?.Invoke($"[STATS] Evento OnAuctionCompleted emesso per {auction.Name}"); + } + catch (Exception ex) + { + OnLog?.Invoke($"[STATS ERROR] Errore in OnAuctionCompleted: {ex.Message}"); + } + return; } @@ -473,7 +591,11 @@ namespace AutoBidder.Services private bool ShouldBid(AuctionInfo auction, AuctionState state) { // ?? CONTROLLO 0: Verifica convenienza (se dati disponibili) - if (auction.CalculatedValue != null && + // ?? IMPORTANTE: Applica solo se BuyNowPrice è valido (> 0) + // Se BuyNowPrice == 0, significa errore scraping - non bloccare le puntate + if (auction.BuyNowPrice.HasValue && + auction.BuyNowPrice.Value > 0 && + auction.CalculatedValue != null && auction.CalculatedValue.Savings.HasValue && !auction.CalculatedValue.IsWorthIt) { diff --git a/Mimante/Services/BidooBrowserService.cs b/Mimante/Services/BidooBrowserService.cs index 061deb6..9a5108a 100644 --- a/Mimante/Services/BidooBrowserService.cs +++ b/Mimante/Services/BidooBrowserService.cs @@ -219,6 +219,14 @@ namespace AutoBidder.Services // Parse aste dall'HTML (fragment AJAX) auctions = ParseAuctionsFromHtml(html); + // ?? FIX: Filtra solo aste di puntate se categoria "Aste di Puntate" (TabId = 1) + if (category.IsSpecialCategory && category.TabId == 1) + { + var before = auctions.Count; + auctions = auctions.Where(a => a.IsCreditAuction).ToList(); + Console.WriteLine($"[BidooBrowser] Filtrate {before} -> {auctions.Count} aste di puntate (IsCreditAuction = true)"); + } + Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}"); } catch (Exception ex) diff --git a/Mimante/Services/DatabaseService.cs b/Mimante/Services/DatabaseService.cs index e2b18b8..da13c5a 100644 --- a/Mimante/Services/DatabaseService.cs +++ b/Mimante/Services/DatabaseService.cs @@ -1,26 +1,81 @@ -using Microsoft.Data.Sqlite; +using Microsoft.Data.Sqlite; using System; using System.IO; +using System.Collections.Generic; using System.Threading.Tasks; +using AutoBidder.Models; namespace AutoBidder.Services { /// - /// Servizio per gestione database SQLite con auto-creazione tabelle e migrations + /// Servizio per gestione database SQLite con auto-creazione tabelle e migrations. + /// Il path del database può essere configurato tramite variabile ambiente DATA_PATH + /// per supportare volumi Docker persistenti. /// public class DatabaseService : IDisposable { private readonly string _connectionString; private readonly string _databasePath; + private bool _isInitialized = false; + private bool _isAvailable = false; + private string? _initializationError; + /// + /// Indica se il database è stato inizializzato correttamente + /// + public bool IsInitialized => _isInitialized; + + /// + /// Indica se il database è disponibile e funzionante + /// + public bool IsAvailable => _isAvailable; + + /// + /// Eventuale errore di inizializzazione + /// + public string? InitializationError => _initializationError; + + /// + /// Path del database + /// + public string DatabasePath => _databasePath; + + /// + /// Crea una nuova istanza di DatabaseService. + /// Il path del database viene determinato in questo ordine: + /// 1. Variabile ambiente DATA_PATH (per Docker) + /// 2. Directory "data" relativa all'eseguibile (fallback) + /// public DatabaseService() { - // Crea directory data se non esiste - var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); - Directory.CreateDirectory(dataDir); + // Determina il path base per i dati + // DATA_PATH può essere impostato nel docker-compose per usare un volume persistente + var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH"); + + if (string.IsNullOrEmpty(dataBasePath)) + { + // Fallback: usa directory relativa all'applicazione (coerente con Program.cs) + dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data"); + } + + // Verifica se la directory esiste o può essere creata + try + { + Directory.CreateDirectory(dataBasePath); + _isAvailable = true; + } + catch (Exception ex) + { + _isAvailable = false; + _initializationError = $"Impossibile creare directory dati: {ex.Message}"; + Console.WriteLine($"[DatabaseService ERROR] {_initializationError}"); + } - _databasePath = Path.Combine(dataDir, "autobidder.db"); + _databasePath = Path.Combine(dataBasePath, "autobidder.db"); _connectionString = $"Data Source={_databasePath}"; + + Console.WriteLine($"[DatabaseService] Database path: {_databasePath}"); + Console.WriteLine($"[DatabaseService] Available: {_isAvailable}"); } /// @@ -28,21 +83,83 @@ namespace AutoBidder.Services /// public async Task InitializeDatabaseAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); - - // Abilita foreign keys - await using (var cmd = connection.CreateCommand()) + if (!_isAvailable) { - cmd.CommandText = "PRAGMA foreign_keys = ON;"; - await cmd.ExecuteNonQueryAsync(); + Console.WriteLine("[DatabaseService] Skipping initialization - database not available"); + return; } - // Crea tabelle se non esistono - await CreateTablesAsync(connection); + try + { + Console.WriteLine("[DatabaseService] ===== INIZIO INIZIALIZZAZIONE DATABASE ====="); + Console.WriteLine($"[DatabaseService] Apertura connessione a: {_databasePath}"); + + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + Console.WriteLine("[DatabaseService] ✓ Connessione aperta con successo"); - // Esegui migrations - await RunMigrationsAsync(connection); + // Abilita foreign keys + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "PRAGMA foreign_keys = ON;"; + await cmd.ExecuteNonQueryAsync(); + Console.WriteLine("[DatabaseService] ✓ Foreign keys abilitati"); + } + + // Crea tabelle se non esistono + Console.WriteLine("[DatabaseService] Creazione tabelle base..."); + await CreateTablesAsync(connection); + Console.WriteLine("[DatabaseService] ✓ Tabelle base create"); + + // Esegui migrations + Console.WriteLine("[DatabaseService] Esecuzione migrations..."); + await RunMigrationsAsync(connection); + Console.WriteLine("[DatabaseService] ✓ Migrations completate"); + + _isInitialized = true; + _isAvailable = true; + Console.WriteLine("[DatabaseService] ===== INIZIALIZZAZIONE COMPLETATA ====="); + } + catch (SqliteException sqlEx) + { + _isInitialized = false; + _isAvailable = false; + _initializationError = $"SQLite Error: {sqlEx.Message} (Code: {sqlEx.SqliteErrorCode}, Extended: {sqlEx.SqliteExtendedErrorCode})"; + + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [DATABASE ERROR] Errore SQLite Durante Inizializzazione"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Messaggio: {sqlEx.Message}"); + Console.WriteLine($"â•‘ SQLite Error Code: {sqlEx.SqliteErrorCode}"); + Console.WriteLine($"â•‘ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}"); + Console.WriteLine($"â•‘ Database Path: {_databasePath}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Stack Trace:"); + Console.WriteLine($"{sqlEx.StackTrace}"); + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + } + catch (Exception ex) + { + _isInitialized = false; + _isAvailable = false; + _initializationError = $"Errore inizializzazione database: {ex.Message}"; + + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [DATABASE ERROR] Errore Generico Durante Inizializzazione"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Tipo: {ex.GetType().Name}"); + Console.WriteLine($"â•‘ Messaggio: {ex.Message}"); + Console.WriteLine($"â•‘ Database Path: {_databasePath}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Stack Trace:"); + Console.WriteLine($"{ex.StackTrace}"); + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + } } /// @@ -122,7 +239,7 @@ namespace AutoBidder.Services FOREIGN KEY (AuctionId) REFERENCES Auctions(AuctionId) ON DELETE CASCADE ); - -- Tabella statistiche prodotti (già esistente da StatsService) + -- Tabella statistiche prodotti (già esistente da StatsService) CREATE TABLE IF NOT EXISTS ProductStats ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ProductKey TEXT NOT NULL, @@ -155,12 +272,13 @@ namespace AutoBidder.Services private async Task RunMigrationsAsync(SqliteConnection connection) { var currentVersion = await GetDatabaseVersionAsync(connection); + Console.WriteLine($"[DatabaseService] Versione database corrente: {currentVersion}"); // Migrations in ordine crescente var migrations = new[] { new Migration(1, "Initial schema", async (conn) => { - // Schema già creato in CreateTablesAsync + // Schema già creato in CreateTablesAsync await Task.CompletedTask; }), @@ -336,6 +454,148 @@ namespace AutoBidder.Services await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; await cmd.ExecuteNonQueryAsync(); + }), + + new Migration(8, "Enhance AuctionResults for analytics", async (conn) => { + var sql = @" + -- Aggiungi colonne per analytics avanzate + ALTER TABLE AuctionResults ADD COLUMN WinnerUsername TEXT; + ALTER TABLE AuctionResults ADD COLUMN ClosedAtHour INTEGER; + ALTER TABLE AuctionResults ADD COLUMN ProductKey TEXT; + ALTER TABLE AuctionResults ADD COLUMN TotalResets INTEGER DEFAULT 0; + ALTER TABLE AuctionResults ADD COLUMN WinnerBidsUsed INTEGER; + + -- Indici per query per prodotto e fascia oraria + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey ON AuctionResults(ProductKey); + CREATE INDEX IF NOT EXISTS idx_auctionresults_hour ON AuctionResults(ClosedAtHour); + "; + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + }), + + new Migration(9, "Create ProductStatistics table for auto-limits", async (conn) => { + var sql = @" + -- Tabella statistiche aggregate per prodotto + CREATE TABLE IF NOT EXISTS ProductStatistics ( + ProductKey TEXT PRIMARY KEY, + ProductName TEXT NOT NULL, + TotalAuctions INTEGER NOT NULL DEFAULT 0, + WonAuctions INTEGER NOT NULL DEFAULT 0, + LostAuctions INTEGER NOT NULL DEFAULT 0, + AvgFinalPrice REAL DEFAULT 0, + MinFinalPrice REAL, + MaxFinalPrice REAL, + AvgBidsToWin REAL DEFAULT 0, + MinBidsToWin INTEGER, + MaxBidsToWin INTEGER, + AvgResets REAL DEFAULT 0, + MinResets INTEGER, + MaxResets INTEGER, + -- Limiti consigliati (calcolati dall'algoritmo) + RecommendedMinPrice REAL, + RecommendedMaxPrice REAL, + RecommendedMinResets INTEGER, + RecommendedMaxResets INTEGER, + RecommendedMaxBids INTEGER, + -- Statistiche per fascia oraria (JSON) + HourlyStatsJson TEXT, + -- Metadata + LastUpdated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CreatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + -- Indici per performance + CREATE INDEX IF NOT EXISTS idx_productstats_name ON ProductStatistics(ProductName); + CREATE INDEX IF NOT EXISTS idx_productstats_updated ON ProductStatistics(LastUpdated DESC); + "; + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + }), + + new Migration(10, "Add performance indexes for analytics queries", async (conn) => { + var sql = @" + -- ✅ Indici compositi per query su ProductKey + Won (usato in algoritmo limiti) + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_won + ON AuctionResults(ProductKey, Won); + + -- ✅ Indice per query temporali filtrate per vincita + CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp_won + ON AuctionResults(Timestamp DESC, Won); + + -- ✅ Indice per query per fascia oraria + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_hour + ON AuctionResults(ProductKey, ClosedAtHour); + + -- ✅ Indice per ordinamento per prezzo finale + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_price + ON AuctionResults(ProductKey, FinalPrice); + + -- ✅ Indice per query puntate vincitore + CREATE INDEX IF NOT EXISTS idx_auctionresults_winnerbids + ON AuctionResults(ProductKey, WinnerBidsUsed) + WHERE WinnerBidsUsed IS NOT NULL; + + -- ✅ Indice per query reset totali + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_resets + ON AuctionResults(ProductKey, TotalResets) + WHERE TotalResets IS NOT NULL; + "; + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + }), + + new Migration(11, "Remove foreign key constraint from AuctionResults", async (conn) => { + // SQLite non supporta DROP CONSTRAINT, quindi dobbiamo ricreare la tabella + var sql = @" + -- 1. Rinomina la tabella esistente + ALTER TABLE AuctionResults RENAME TO AuctionResults_old; + + -- 2. Ricrea la tabella SENZA foreign key + CREATE TABLE AuctionResults ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + AuctionId TEXT NOT NULL, + AuctionName TEXT NOT NULL, + FinalPrice REAL NOT NULL, + BidsUsed INTEGER NOT NULL, + Won INTEGER NOT NULL DEFAULT 0, + Timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + BuyNowPrice REAL, + ShippingCost REAL, + TotalCost REAL, + Savings REAL, + WinnerUsername TEXT, + ClosedAtHour INTEGER, + ProductKey TEXT, + TotalResets INTEGER DEFAULT 0, + WinnerBidsUsed INTEGER + ); + + -- 3. Copia i dati dalla vecchia tabella + INSERT INTO AuctionResults + SELECT * FROM AuctionResults_old; + + -- 4. Elimina la vecchia tabella + DROP TABLE AuctionResults_old; + + -- 5. Ricrea gli indici + CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp ON AuctionResults(Timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_auctionresults_won ON AuctionResults(Won); + CREATE INDEX IF NOT EXISTS idx_auctionresults_auctionid ON AuctionResults(AuctionId); + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey ON AuctionResults(ProductKey); + CREATE INDEX IF NOT EXISTS idx_auctionresults_hour ON AuctionResults(ClosedAtHour); + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_won ON AuctionResults(ProductKey, Won); + CREATE INDEX IF NOT EXISTS idx_auctionresults_timestamp_won ON AuctionResults(Timestamp DESC, Won); + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_hour ON AuctionResults(ProductKey, ClosedAtHour); + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_price ON AuctionResults(ProductKey, FinalPrice); + CREATE INDEX IF NOT EXISTS idx_auctionresults_winnerbids ON AuctionResults(ProductKey, WinnerBidsUsed) WHERE WinnerBidsUsed IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_auctionresults_productkey_resets ON AuctionResults(ProductKey, TotalResets) WHERE TotalResets IS NOT NULL; + "; + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); }) }; @@ -343,8 +603,50 @@ namespace AutoBidder.Services { if (migration.Version > currentVersion) { - await migration.Execute(connection); - await SetDatabaseVersionAsync(connection, migration.Version, migration.Description); + try + { + Console.WriteLine($"[DatabaseService] ┌─ Applicazione Migration {migration.Version}: {migration.Description}"); + await migration.Execute(connection); + await SetDatabaseVersionAsync(connection, migration.Version, migration.Description); + Console.WriteLine($"[DatabaseService] └─ ✓ Migration {migration.Version} completata"); + } + catch (SqliteException sqlEx) + { + Console.WriteLine(""); + Console.WriteLine($"â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ [MIGRATION ERROR] Errore in Migration {migration.Version}"); + Console.WriteLine($"â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Descrizione: {migration.Description}"); + Console.WriteLine($"â•‘ SQLite Error Code: {sqlEx.SqliteErrorCode}"); + Console.WriteLine($"â•‘ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}"); + Console.WriteLine($"â•‘ Messaggio: {sqlEx.Message}"); + Console.WriteLine($"â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Stack Trace:"); + Console.WriteLine($"{sqlEx.StackTrace}"); + Console.WriteLine($"╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + throw; // Re-throw per fermare inizializzazione + } + catch (Exception ex) + { + Console.WriteLine(""); + Console.WriteLine($"â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ [MIGRATION ERROR] Errore Generico in Migration {migration.Version}"); + Console.WriteLine($"â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Descrizione: {migration.Description}"); + Console.WriteLine($"â•‘ Tipo: {ex.GetType().Name}"); + Console.WriteLine($"â•‘ Messaggio: {ex.Message}"); + Console.WriteLine($"â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Stack Trace:"); + Console.WriteLine($"{ex.StackTrace}"); + Console.WriteLine($"╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + throw; + } + } + else + { + Console.WriteLine($"[DatabaseService] ⊘ Migration {migration.Version} già applicata"); } } } @@ -381,9 +683,40 @@ namespace AutoBidder.Services /// public async Task GetConnectionAsync() { - var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); - return connection; + try + { + var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + return connection; + } + catch (SqliteException sqlEx) + { + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [CONNECTION ERROR] Impossibile aprire connessione database"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Database Path: {_databasePath}"); + Console.WriteLine($"â•‘ SQLite Error Code: {sqlEx.SqliteErrorCode}"); + Console.WriteLine($"â•‘ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}"); + Console.WriteLine($"â•‘ Messaggio: {sqlEx.Message}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + + // Suggerimenti specifici per errori comuni + if (sqlEx.SqliteErrorCode == 5) // SQLITE_BUSY + { + Console.WriteLine("â•‘ SUGGERIMENTO: Database locked - altra connessione attiva"); + Console.WriteLine("â•‘ Potrebbe essere un problema di concorrenza"); + } + else if (sqlEx.SqliteErrorCode == 14) // SQLITE_CANTOPEN + { + Console.WriteLine("â•‘ SUGGERIMENTO: Impossibile aprire il file database"); + Console.WriteLine($"â•‘ Verifica permessi su: {_databasePath}"); + } + + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + throw; + } } /// @@ -391,14 +724,45 @@ namespace AutoBidder.Services /// public async Task ExecuteNonQueryAsync(string sql, params SqliteParameter[] parameters) { - await using var connection = await GetConnectionAsync(); - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - if (parameters != null) + try { - cmd.Parameters.AddRange(parameters); + await using var connection = await GetConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + if (parameters != null) + { + cmd.Parameters.AddRange(parameters); + } + return await cmd.ExecuteNonQueryAsync(); + } + catch (SqliteException sqlEx) + { + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [QUERY ERROR] Errore SQLite in ExecuteNonQueryAsync"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ SQLite Error Code: {sqlEx.SqliteErrorCode}"); + Console.WriteLine($"â•‘ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}"); + Console.WriteLine($"â•‘ Messaggio: {sqlEx.Message}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ SQL Query (primi 500 chars):"); + Console.WriteLine($"â•‘ {(sql.Length > 500 ? sql.Substring(0, 500) + "..." : sql)}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + if (parameters != null && parameters.Length > 0) + { + Console.WriteLine($"â•‘ Parametri:"); + foreach (var param in parameters) + { + Console.WriteLine($"â•‘ {param.ParameterName} = {param.Value}"); + } + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + } + Console.WriteLine($"â•‘ Stack Trace:"); + Console.WriteLine($"{sqlEx.StackTrace}"); + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + throw; } - return await cmd.ExecuteNonQueryAsync(); } /// @@ -406,14 +770,33 @@ namespace AutoBidder.Services /// public async Task ExecuteScalarAsync(string sql, params SqliteParameter[] parameters) { - await using var connection = await GetConnectionAsync(); - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - if (parameters != null) + try { - cmd.Parameters.AddRange(parameters); + await using var connection = await GetConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + if (parameters != null) + { + cmd.Parameters.AddRange(parameters); + } + return await cmd.ExecuteScalarAsync(); + } + catch (SqliteException sqlEx) + { + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [QUERY ERROR] Errore SQLite in ExecuteScalarAsync"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ SQLite Error Code: {sqlEx.SqliteErrorCode}"); + Console.WriteLine($"â•‘ SQLite Extended Code: {sqlEx.SqliteExtendedErrorCode}"); + Console.WriteLine($"â•‘ Messaggio: {sqlEx.Message}"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ SQL Query (primi 500 chars):"); + Console.WriteLine($"â•‘ {(sql.Length > 500 ? sql.Substring(0, 500) + "..." : sql)}"); + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + throw; } - return await cmd.ExecuteScalarAsync(); } /// @@ -429,11 +812,129 @@ namespace AutoBidder.Services var result = await cmd.ExecuteScalarAsync(); return Convert.ToInt32(result) > 0; } - catch + catch (Exception ex) { + Console.WriteLine($"[DatabaseService] Health check failed: {ex.Message}"); return false; } } + + /// + /// Diagnostica completa del database - mostra tutte le tabelle, indici e versione + /// + public async Task RunDatabaseDiagnosticsAsync() + { + try + { + Console.WriteLine(""); + Console.WriteLine("â•”â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ [DIAGNOSTICA] Analisi Database SQLite"); + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ Path: {_databasePath}"); + Console.WriteLine($"â•‘ Disponibile: {_isAvailable}"); + Console.WriteLine($"â•‘ Inizializzato: {_isInitialized}"); + + if (File.Exists(_databasePath)) + { + var fileInfo = new FileInfo(_databasePath); + Console.WriteLine($"â•‘ Dimensione: {FormatBytes(fileInfo.Length)}"); + Console.WriteLine($"â•‘ Creato: {fileInfo.CreationTime}"); + Console.WriteLine($"â•‘ Modificato: {fileInfo.LastWriteTime}"); + } + + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + + await using var connection = await GetConnectionAsync(); + + // Versione database + var version = await GetDatabaseVersionAsync(connection); + Console.WriteLine($"â•‘ Versione Schema: {version}"); + + // Lista tabelle + await using var cmdTables = connection.CreateCommand(); + cmdTables.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; + + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ Tabelle presenti:"); + + await using var readerTables = await cmdTables.ExecuteReaderAsync(); + while (await readerTables.ReadAsync()) + { + var tableName = readerTables.GetString(0); + + // Conta record per tabella + await using var cmdCount = connection.CreateCommand(); + cmdCount.CommandText = $"SELECT COUNT(*) FROM [{tableName}];"; + var count = Convert.ToInt32(await cmdCount.ExecuteScalarAsync()); + + Console.WriteLine($"â•‘ • {tableName,-30} ({count,6} record)"); + } + + // Lista indici + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ Indici presenti:"); + + await using var cmdIndexes = connection.CreateCommand(); + cmdIndexes.CommandText = "SELECT name, tbl_name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY tbl_name, name;"; + + await using var readerIndexes = await cmdIndexes.ExecuteReaderAsync(); + string? currentTable = null; + while (await readerIndexes.ReadAsync()) + { + var indexName = readerIndexes.GetString(0); + var tableName = readerIndexes.GetString(1); + + if (currentTable != tableName) + { + currentTable = tableName; + Console.WriteLine($"â•‘ [{tableName}]"); + } + + Console.WriteLine($"â•‘ - {indexName}"); + } + + // Migrations applicate + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine("â•‘ Migrations applicate:"); + + await using var cmdMigrations = connection.CreateCommand(); + cmdMigrations.CommandText = "SELECT Version, AppliedAt, Description FROM DatabaseVersion ORDER BY Version;"; + + await using var readerMigrations = await cmdMigrations.ExecuteReaderAsync(); + while (await readerMigrations.ReadAsync()) + { + var ver = readerMigrations.GetInt32(0); + var appliedAt = readerMigrations.GetString(1); + var desc = readerMigrations.GetString(2); + + Console.WriteLine($"â•‘ v{ver}: {desc}"); + Console.WriteLine($"â•‘ Applicata: {appliedAt}"); + } + + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + } + catch (Exception ex) + { + Console.WriteLine("â• â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine($"â•‘ ERRORE durante diagnostica: {ex.Message}"); + Console.WriteLine("╚â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•"); + Console.WriteLine(""); + } + } + + private static string FormatBytes(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB" }; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len = len / 1024; + } + return $"{len:0.##} {sizes[order]}"; + } /// /// Ottiene informazioni sul database @@ -535,15 +1036,20 @@ namespace AutoBidder.Services } /// - /// Salva risultato asta + /// Salva risultato asta con dati completi per analytics /// public async Task SaveAuctionResultAsync(string auctionId, string auctionName, double finalPrice, int bidsUsed, bool won, - double? buyNowPrice = null, double? shippingCost = null, double? totalCost = null, double? savings = null) + double? buyNowPrice = null, double? shippingCost = null, double? totalCost = null, double? savings = null, + string? winnerUsername = null, int? totalResets = null, int? winnerBidsUsed = null, string? productKey = null) { + var closedAtHour = DateTime.UtcNow.Hour; + var sql = @" INSERT INTO AuctionResults - (AuctionId, AuctionName, FinalPrice, BidsUsed, Won, BuyNowPrice, ShippingCost, TotalCost, Savings, Timestamp) - VALUES (@auctionId, @auctionName, @finalPrice, @bidsUsed, @won, @buyNowPrice, @shippingCost, @totalCost, @savings, @timestamp); + (AuctionId, AuctionName, FinalPrice, BidsUsed, Won, BuyNowPrice, ShippingCost, TotalCost, Savings, Timestamp, + WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed) + VALUES (@auctionId, @auctionName, @finalPrice, @bidsUsed, @won, @buyNowPrice, @shippingCost, @totalCost, @savings, @timestamp, + @winnerUsername, @closedAtHour, @productKey, @totalResets, @winnerBidsUsed); "; await ExecuteNonQueryAsync(sql, @@ -556,10 +1062,244 @@ namespace AutoBidder.Services new SqliteParameter("@shippingCost", (object?)shippingCost ?? DBNull.Value), new SqliteParameter("@totalCost", (object?)totalCost ?? DBNull.Value), new SqliteParameter("@savings", (object?)savings ?? DBNull.Value), - new SqliteParameter("@timestamp", DateTime.UtcNow.ToString("O")) + new SqliteParameter("@timestamp", DateTime.UtcNow.ToString("O")), + new SqliteParameter("@winnerUsername", (object?)winnerUsername ?? DBNull.Value), + new SqliteParameter("@closedAtHour", closedAtHour), + new SqliteParameter("@productKey", (object?)productKey ?? DBNull.Value), + new SqliteParameter("@totalResets", (object?)totalResets ?? DBNull.Value), + new SqliteParameter("@winnerBidsUsed", (object?)winnerBidsUsed ?? DBNull.Value) ); } + /// + /// Aggiorna o inserisce statistiche aggregate per un prodotto + /// + public async Task UpsertProductStatisticsAsync(ProductStatisticsRecord stats) + { + var sql = @" + INSERT INTO ProductStatistics + (ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions, + AvgFinalPrice, MinFinalPrice, MaxFinalPrice, + AvgBidsToWin, MinBidsToWin, MaxBidsToWin, + AvgResets, MinResets, MaxResets, + RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids, + HourlyStatsJson, LastUpdated) + VALUES (@productKey, @productName, @totalAuctions, @wonAuctions, @lostAuctions, + @avgFinalPrice, @minFinalPrice, @maxFinalPrice, + @avgBidsToWin, @minBidsToWin, @maxBidsToWin, + @avgResets, @minResets, @maxResets, + @recMinPrice, @recMaxPrice, @recMinResets, @recMaxResets, @recMaxBids, + @hourlyJson, @lastUpdated) + ON CONFLICT(ProductKey) DO UPDATE SET + ProductName = @productName, + TotalAuctions = @totalAuctions, + WonAuctions = @wonAuctions, + LostAuctions = @lostAuctions, + AvgFinalPrice = @avgFinalPrice, + MinFinalPrice = @minFinalPrice, + MaxFinalPrice = @maxFinalPrice, + AvgBidsToWin = @avgBidsToWin, + MinBidsToWin = @minBidsToWin, + MaxBidsToWin = @maxBidsToWin, + AvgResets = @avgResets, + MinResets = @minResets, + MaxResets = @maxResets, + RecommendedMinPrice = @recMinPrice, + RecommendedMaxPrice = @recMaxPrice, + RecommendedMinResets = @recMinResets, + RecommendedMaxResets = @recMaxResets, + RecommendedMaxBids = @recMaxBids, + HourlyStatsJson = @hourlyJson, + LastUpdated = @lastUpdated; + "; + + await ExecuteNonQueryAsync(sql, + new SqliteParameter("@productKey", stats.ProductKey), + new SqliteParameter("@productName", stats.ProductName), + new SqliteParameter("@totalAuctions", stats.TotalAuctions), + new SqliteParameter("@wonAuctions", stats.WonAuctions), + new SqliteParameter("@lostAuctions", stats.LostAuctions), + new SqliteParameter("@avgFinalPrice", stats.AvgFinalPrice), + new SqliteParameter("@minFinalPrice", (object?)stats.MinFinalPrice ?? DBNull.Value), + new SqliteParameter("@maxFinalPrice", (object?)stats.MaxFinalPrice ?? DBNull.Value), + new SqliteParameter("@avgBidsToWin", stats.AvgBidsToWin), + new SqliteParameter("@minBidsToWin", (object?)stats.MinBidsToWin ?? DBNull.Value), + new SqliteParameter("@maxBidsToWin", (object?)stats.MaxBidsToWin ?? DBNull.Value), + new SqliteParameter("@avgResets", stats.AvgResets), + new SqliteParameter("@minResets", (object?)stats.MinResets ?? DBNull.Value), + new SqliteParameter("@maxResets", (object?)stats.MaxResets ?? DBNull.Value), + new SqliteParameter("@recMinPrice", (object?)stats.RecommendedMinPrice ?? DBNull.Value), + new SqliteParameter("@recMaxPrice", (object?)stats.RecommendedMaxPrice ?? DBNull.Value), + new SqliteParameter("@recMinResets", (object?)stats.RecommendedMinResets ?? DBNull.Value), + new SqliteParameter("@recMaxResets", (object?)stats.RecommendedMaxResets ?? DBNull.Value), + new SqliteParameter("@recMaxBids", (object?)stats.RecommendedMaxBids ?? DBNull.Value), + new SqliteParameter("@hourlyJson", (object?)stats.HourlyStatsJson ?? DBNull.Value), + new SqliteParameter("@lastUpdated", DateTime.UtcNow.ToString("O")) + ); + } + + /// + /// Ottiene statistiche per un prodotto specifico + /// + public async Task GetProductStatisticsAsync(string productKey) + { + var sql = @" + SELECT ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions, + AvgFinalPrice, MinFinalPrice, MaxFinalPrice, + AvgBidsToWin, MinBidsToWin, MaxBidsToWin, + AvgResets, MinResets, MaxResets, + RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids, + HourlyStatsJson, LastUpdated + FROM ProductStatistics + WHERE ProductKey = @productKey; + "; + + await using var connection = await GetConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@productKey", productKey); + + await using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return new ProductStatisticsRecord + { + ProductKey = reader.GetString(0), + ProductName = reader.GetString(1), + TotalAuctions = reader.GetInt32(2), + WonAuctions = reader.GetInt32(3), + LostAuctions = reader.GetInt32(4), + AvgFinalPrice = reader.GetDouble(5), + MinFinalPrice = reader.IsDBNull(6) ? null : reader.GetDouble(6), + MaxFinalPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7), + AvgBidsToWin = reader.GetDouble(8), + MinBidsToWin = reader.IsDBNull(9) ? null : reader.GetInt32(9), + MaxBidsToWin = reader.IsDBNull(10) ? null : reader.GetInt32(10), + AvgResets = reader.GetDouble(11), + MinResets = reader.IsDBNull(12) ? null : reader.GetInt32(12), + MaxResets = reader.IsDBNull(13) ? null : reader.GetInt32(13), + RecommendedMinPrice = reader.IsDBNull(14) ? null : reader.GetDouble(14), + RecommendedMaxPrice = reader.IsDBNull(15) ? null : reader.GetDouble(15), + RecommendedMinResets = reader.IsDBNull(16) ? null : reader.GetInt32(16), + RecommendedMaxResets = reader.IsDBNull(17) ? null : reader.GetInt32(17), + RecommendedMaxBids = reader.IsDBNull(18) ? null : reader.GetInt32(18), + HourlyStatsJson = reader.IsDBNull(19) ? null : reader.GetString(19), + LastUpdated = reader.GetString(20) + }; + } + + return null; + } + + /// + /// Ottiene tutti i risultati aste per un prodotto specifico + /// + public async Task> GetAuctionResultsByProductAsync(string productKey, int limit = 100) + { + var sql = @" + SELECT Id, AuctionId, AuctionName, FinalPrice, BidsUsed, Won, Timestamp, + BuyNowPrice, ShippingCost, TotalCost, Savings, + WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed + FROM AuctionResults + WHERE ProductKey = @productKey + ORDER BY Timestamp DESC + LIMIT @limit; + "; + + var results = new List(); + + await using var connection = await GetConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@productKey", productKey); + cmd.Parameters.AddWithValue("@limit", limit); + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(ReadAuctionResultExtended(reader)); + } + + return results; + } + + /// + /// Ottiene tutti i prodotti con statistiche + /// + public async Task> GetAllProductStatisticsAsync() + { + var sql = @" + SELECT ProductKey, ProductName, TotalAuctions, WonAuctions, LostAuctions, + AvgFinalPrice, MinFinalPrice, MaxFinalPrice, + AvgBidsToWin, MinBidsToWin, MaxBidsToWin, + AvgResets, MinResets, MaxResets, + RecommendedMinPrice, RecommendedMaxPrice, RecommendedMinResets, RecommendedMaxResets, RecommendedMaxBids, + HourlyStatsJson, LastUpdated + FROM ProductStatistics + ORDER BY TotalAuctions DESC; + "; + + var results = new List(); + + await using var connection = await GetConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new ProductStatisticsRecord + { + ProductKey = reader.GetString(0), + ProductName = reader.GetString(1), + TotalAuctions = reader.GetInt32(2), + WonAuctions = reader.GetInt32(3), + LostAuctions = reader.GetInt32(4), + AvgFinalPrice = reader.GetDouble(5), + MinFinalPrice = reader.IsDBNull(6) ? null : reader.GetDouble(6), + MaxFinalPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7), + AvgBidsToWin = reader.GetDouble(8), + MinBidsToWin = reader.IsDBNull(9) ? null : reader.GetInt32(9), + MaxBidsToWin = reader.IsDBNull(10) ? null : reader.GetInt32(10), + AvgResets = reader.GetDouble(11), + MinResets = reader.IsDBNull(12) ? null : reader.GetInt32(12), + MaxResets = reader.IsDBNull(13) ? null : reader.GetInt32(13), + RecommendedMinPrice = reader.IsDBNull(14) ? null : reader.GetDouble(14), + RecommendedMaxPrice = reader.IsDBNull(15) ? null : reader.GetDouble(15), + RecommendedMinResets = reader.IsDBNull(16) ? null : reader.GetInt32(16), + RecommendedMaxResets = reader.IsDBNull(17) ? null : reader.GetInt32(17), + RecommendedMaxBids = reader.IsDBNull(18) ? null : reader.GetInt32(18), + HourlyStatsJson = reader.IsDBNull(19) ? null : reader.GetString(19), + LastUpdated = reader.GetString(20) + }); + } + + return results; + } + + private AuctionResultExtended ReadAuctionResultExtended(Microsoft.Data.Sqlite.SqliteDataReader reader) + { + return new AuctionResultExtended + { + Id = reader.GetInt32(0), + AuctionId = reader.GetString(1), + AuctionName = reader.GetString(2), + FinalPrice = reader.GetDouble(3), + BidsUsed = reader.GetInt32(4), + Won = reader.GetInt32(5) == 1, + Timestamp = reader.GetString(6), + BuyNowPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7), + ShippingCost = reader.IsDBNull(8) ? null : reader.GetDouble(8), + TotalCost = reader.IsDBNull(9) ? null : reader.GetDouble(9), + Savings = reader.IsDBNull(10) ? null : reader.GetDouble(10), + WinnerUsername = reader.IsDBNull(11) ? null : reader.GetString(11), + ClosedAtHour = reader.IsDBNull(12) ? null : reader.GetInt32(12), + ProductKey = reader.IsDBNull(13) ? null : reader.GetString(13), + TotalResets = reader.IsDBNull(14) ? null : reader.GetInt32(14), + WinnerBidsUsed = reader.IsDBNull(15) ? null : reader.GetInt32(15) + }; + } + /// /// Ottiene statistiche giornaliere per un range di date /// @@ -599,19 +1339,20 @@ namespace AutoBidder.Services } /// - /// Ottiene risultati aste recenti + /// Ottiene risultati aste recenti con campi estesi per analytics /// - public async Task> GetRecentAuctionResultsAsync(int limit = 50) + public async Task> GetRecentAuctionResultsAsync(int limit = 50) { var sql = @" SELECT Id, AuctionId, AuctionName, FinalPrice, BidsUsed, Won, Timestamp, - BuyNowPrice, ShippingCost, TotalCost, Savings + BuyNowPrice, ShippingCost, TotalCost, Savings, + WinnerUsername, ClosedAtHour, ProductKey, TotalResets, WinnerBidsUsed FROM AuctionResults ORDER BY Timestamp DESC LIMIT @limit; "; - var results = new List(); + var results = new List(); await using var connection = await GetConnectionAsync(); await using var cmd = connection.CreateCommand(); @@ -621,20 +1362,7 @@ namespace AutoBidder.Services await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { - results.Add(new AuctionResult - { - Id = reader.GetInt32(0), - AuctionId = reader.GetString(1), - AuctionName = reader.GetString(2), - FinalPrice = reader.GetDouble(3), - BidsUsed = reader.GetInt32(4), - Won = reader.GetInt32(5) == 1, - Timestamp = reader.GetString(6), - BuyNowPrice = reader.IsDBNull(7) ? null : reader.GetDouble(7), - ShippingCost = reader.IsDBNull(8) ? null : reader.GetDouble(8), - TotalCost = reader.IsDBNull(9) ? null : reader.GetDouble(9), - Savings = reader.IsDBNull(10) ? null : reader.GetDouble(10) - }); + results.Add(ReadAuctionResultExtended(reader)); } return results; @@ -653,6 +1381,7 @@ namespace AutoBidder.Services public int Version { get; } public string Description { get; } private readonly Func _execute; + private string? _lastSqlExecuted; public Migration(int version, string description, Func execute) { @@ -663,7 +1392,24 @@ namespace AutoBidder.Services public async Task Execute(SqliteConnection connection) { - await _execute(connection); + try + { + await _execute(connection); + } + catch (SqliteException sqlEx) + { + // Log dettagliato dell'SQL che ha causato l'errore + Console.WriteLine($"[Migration {Version}] ✗ ERRORE durante esecuzione"); + + // Se possibile, estrae l'SQL dal messaggio di errore + if (sqlEx.Message.Contains("duplicate column")) + { + Console.WriteLine($"[Migration {Version}] PROBABILE CAUSA: Colonna già esistente (migration già applicata parzialmente)"); + Console.WriteLine($"[Migration {Version}] SOLUZIONE: Verifica versione database o elimina e ricrea il database"); + } + + throw; + } } } } diff --git a/Mimante/Services/ProductStatisticsService.cs b/Mimante/Services/ProductStatisticsService.cs new file mode 100644 index 0000000..55f62cb --- /dev/null +++ b/Mimante/Services/ProductStatisticsService.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using AutoBidder.Models; + +namespace AutoBidder.Services +{ + /// + /// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati. + /// L'algoritmo analizza le aste storiche per determinare i parametri ottimali. + /// + public class ProductStatisticsService + { + private readonly DatabaseService _db; + + public ProductStatisticsService(DatabaseService db) + { + _db = db; + } + + /// + /// Genera una chiave univoca normalizzata per raggruppare prodotti simili. + /// Rimuove varianti, numeri di serie, colori ecc. + /// + public static string GenerateProductKey(string productName) + { + if (string.IsNullOrWhiteSpace(productName)) + return "unknown"; + + var normalized = productName.ToLowerInvariant().Trim(); + + // Rimuovi contenuto tra parentesi (varianti, colori, capacità) + normalized = Regex.Replace(normalized, @"\([^)]*\)", ""); + normalized = Regex.Replace(normalized, @"\[[^\]]*\]", ""); + + // Rimuovi colori comuni + var colors = new[] { "nero", "bianco", "grigio", "rosso", "blu", "verde", "oro", "argento", + "black", "white", "gray", "red", "blue", "green", "gold", "silver", + "space gray", "midnight", "starlight" }; + foreach (var color in colors) + { + normalized = Regex.Replace(normalized, $@"\b{color}\b", "", RegexOptions.IgnoreCase); + } + + // Rimuovi capacità storage (64gb, 128gb, 256gb, ecc.) + normalized = Regex.Replace(normalized, @"\b\d+\s*(gb|tb|mb)\b", "", RegexOptions.IgnoreCase); + + // Rimuovi numeri di serie e codici prodotto + normalized = Regex.Replace(normalized, @"\b[A-Z]{2,}\d{3,}\b", "", RegexOptions.IgnoreCase); + + // Normalizza spazi e caratteri speciali + normalized = Regex.Replace(normalized, @"[^a-z0-9\s]", " "); + normalized = Regex.Replace(normalized, @"\s+", "_"); + normalized = normalized.Trim('_'); + + // Limita lunghezza + if (normalized.Length > 50) + normalized = normalized.Substring(0, 50); + + return string.IsNullOrEmpty(normalized) ? "unknown" : normalized; + } + + /// + /// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata + /// + public async Task UpdateProductStatisticsAsync(string productKey, string productName) + { + try + { + // Ottieni tutti i risultati per questo prodotto + var results = await _db.GetAuctionResultsByProductAsync(productKey, 500); + + if (results.Count == 0) + { + Console.WriteLine($"[ProductStats] No results found for product: {productKey}"); + return; + } + + // Calcola statistiche aggregate + var wonResults = results.Where(r => r.Won).ToList(); + var lostResults = results.Where(r => !r.Won).ToList(); + + var stats = new ProductStatisticsRecord + { + ProductKey = productKey, + ProductName = productName, + TotalAuctions = results.Count, + WonAuctions = wonResults.Count, + LostAuctions = lostResults.Count + }; + + // Statistiche prezzo (usa aste vinte per calcolare i target) + if (wonResults.Any()) + { + stats.AvgFinalPrice = wonResults.Average(r => r.FinalPrice); + stats.MinFinalPrice = wonResults.Min(r => r.FinalPrice); + stats.MaxFinalPrice = wonResults.Max(r => r.FinalPrice); + } + else if (results.Any()) + { + stats.AvgFinalPrice = results.Average(r => r.FinalPrice); + stats.MinFinalPrice = results.Min(r => r.FinalPrice); + stats.MaxFinalPrice = results.Max(r => r.FinalPrice); + } + + // Statistiche puntate (usa WinnerBidsUsed se disponibile, altrimenti BidsUsed) + var bidsData = wonResults + .Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0) + .Select(r => r.WinnerBidsUsed ?? r.BidsUsed) + .ToList(); + + if (bidsData.Any()) + { + stats.AvgBidsToWin = bidsData.Select(b => (double)b).Average(); + stats.MinBidsToWin = bidsData.Min(); + stats.MaxBidsToWin = bidsData.Max(); + } + + // Statistiche reset + var resetData = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList(); + if (resetData.Any()) + { + stats.AvgResets = resetData.Select(r => (double)r).Average(); + stats.MinResets = resetData.Min(); + stats.MaxResets = resetData.Max(); + } + + // Calcola limiti consigliati + var limits = CalculateRecommendedLimits(results); + stats.RecommendedMinPrice = limits.MinPrice; + stats.RecommendedMaxPrice = limits.MaxPrice; + stats.RecommendedMinResets = limits.MinResets; + stats.RecommendedMaxResets = limits.MaxResets; + stats.RecommendedMaxBids = limits.MaxBids; + + // Calcola statistiche per fascia oraria + var hourlyStats = CalculateHourlyStats(results); + stats.HourlyStatsJson = JsonSerializer.Serialize(hourlyStats); + + // Salva nel database + await _db.UpsertProductStatisticsAsync(stats); + + Console.WriteLine($"[ProductStats] Updated stats for {productKey}: {stats.TotalAuctions} auctions, WinRate={stats.WinRate:F1}%"); + } + catch (Exception ex) + { + Console.WriteLine($"[ProductStats ERROR] Failed to update stats for {productKey}: {ex.Message}"); + } + } + + /// + /// Calcola i limiti consigliati basandosi sui dati storici + /// + public RecommendedLimits CalculateRecommendedLimits(List results) + { + var limits = new RecommendedLimits + { + SampleSize = results.Count + }; + + if (results.Count < 3) + { + limits.ConfidenceScore = 0; + return limits; + } + + var wonResults = results.Where(r => r.Won).ToList(); + + if (wonResults.Count == 0) + { + // Nessuna vittoria: usa tutti i risultati con margine conservativo + limits.ConfidenceScore = 10; + limits.MinPrice = results.Min(r => r.FinalPrice) * 0.8; + limits.MaxPrice = results.Max(r => r.FinalPrice) * 1.2; + return limits; + } + + // Calcola percentili sui prezzi delle aste vinte + var prices = wonResults.Select(r => r.FinalPrice).OrderBy(p => p).ToList(); + limits.MinPrice = CalculatePercentile(prices, 10); // 10° percentile - entrare presto + limits.MaxPrice = CalculatePercentile(prices, 90); // 90° percentile - limite sicuro + + // Calcola limiti reset + var resets = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList(); + if (resets.Any()) + { + var avgResets = resets.Average(); + var stdDev = CalculateStandardDeviation(resets.Select(r => (double)r).ToList()); + + limits.MinResets = Math.Max(0, (int)(avgResets - stdDev)); // Media - 1 stddev + limits.MaxResets = (int)(avgResets + stdDev); // Media + 1 stddev + } + + // Calcola limiti puntate + var bids = wonResults + .Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0) + .Select(r => r.WinnerBidsUsed ?? r.BidsUsed) + .OrderBy(b => b) + .ToList(); + + if (bids.Any()) + { + // 90° percentile + 10% buffer + limits.MaxBids = (int)(CalculatePercentile(bids.Select(b => (double)b).ToList(), 90) * 1.1); + } + + // Trova la fascia oraria migliore + var hourlyWins = wonResults + .Where(r => r.ClosedAtHour.HasValue) + .GroupBy(r => r.ClosedAtHour!.Value) + .Select(g => new { Hour = g.Key, Wins = g.Count() }) + .OrderByDescending(x => x.Wins) + .FirstOrDefault(); + + if (hourlyWins != null) + { + limits.BestHourToPlay = hourlyWins.Hour; + } + + // Win rate + limits.AverageWinRate = results.Count > 0 ? (double)wonResults.Count / results.Count * 100 : 0; + + // Confidence score basato sul sample size + limits.ConfidenceScore = results.Count switch + { + >= 50 => 95, + >= 30 => 85, + >= 20 => 70, + >= 10 => 50, + >= 5 => 30, + _ => 15 + }; + + return limits; + } + + /// + /// Calcola statistiche aggregate per ogni fascia oraria + /// + private List CalculateHourlyStats(List results) + { + var stats = new List(); + + var grouped = results + .Where(r => r.ClosedAtHour.HasValue) + .GroupBy(r => r.ClosedAtHour!.Value); + + foreach (var group in grouped) + { + var hourResults = group.ToList(); + var wonInHour = hourResults.Where(r => r.Won).ToList(); + + stats.Add(new HourlyStats + { + Hour = group.Key, + TotalAuctions = hourResults.Count, + WonAuctions = wonInHour.Count, + AvgFinalPrice = hourResults.Any() ? hourResults.Average(r => r.FinalPrice) : 0, + AvgBidsUsed = hourResults.Any() ? hourResults.Average(r => r.BidsUsed) : 0 + }); + } + + return stats.OrderBy(s => s.Hour).ToList(); + } + + /// + /// Ottiene le statistiche per un prodotto + /// + public async Task GetProductStatisticsAsync(string productKey) + { + return await _db.GetProductStatisticsAsync(productKey); + } + + /// + /// Ottiene tutti i prodotti con statistiche + /// + public async Task> GetAllProductStatisticsAsync() + { + return await _db.GetAllProductStatisticsAsync(); + } + + /// + /// Ottiene i limiti consigliati per un prodotto + /// + public async Task GetRecommendedLimitsAsync(string productKey) + { + var stats = await _db.GetProductStatisticsAsync(productKey); + + if (stats == null) + return null; + + return new RecommendedLimits + { + MinPrice = stats.RecommendedMinPrice ?? 0, + MaxPrice = stats.RecommendedMaxPrice ?? 0, + MinResets = stats.RecommendedMinResets ?? 0, + MaxResets = stats.RecommendedMaxResets ?? 0, + MaxBids = stats.RecommendedMaxBids ?? 0, + ConfidenceScore = stats.TotalAuctions switch + { + >= 50 => 95, + >= 30 => 85, + >= 20 => 70, + >= 10 => 50, + >= 5 => 30, + _ => 15 + }, + SampleSize = stats.TotalAuctions, + AverageWinRate = stats.WinRate + }; + } + + // Helpers per calcoli statistici + private static double CalculatePercentile(List sortedData, int percentile) + { + if (sortedData.Count == 0) return 0; + if (sortedData.Count == 1) return sortedData[0]; + + double index = (percentile / 100.0) * (sortedData.Count - 1); + int lower = (int)Math.Floor(index); + int upper = (int)Math.Ceiling(index); + + if (lower == upper) return sortedData[lower]; + + return sortedData[lower] + (sortedData[upper] - sortedData[lower]) * (index - lower); + } + + private static double CalculateStandardDeviation(List data) + { + if (data.Count < 2) return 0; + + double avg = data.Average(); + double sumSquares = data.Sum(d => Math.Pow(d - avg, 2)); + return Math.Sqrt(sumSquares / (data.Count - 1)); + } + } +} diff --git a/Mimante/Services/StatsService.cs b/Mimante/Services/StatsService.cs index 1550236..22d2081 100644 --- a/Mimante/Services/StatsService.cs +++ b/Mimante/Services/StatsService.cs @@ -2,64 +2,145 @@ using System; using System.Linq; using System.Threading.Tasks; using AutoBidder.Models; -using AutoBidder.Data; using System.Collections.Generic; -using Microsoft.EntityFrameworkCore; namespace AutoBidder.Services { /// - /// Servizio per calcolo e gestione statistiche avanzate - /// Usa PostgreSQL per statistiche persistenti e SQLite locale come fallback + /// Servizio per calcolo e gestione statistiche. + /// Usa esclusivamente il database SQLite interno gestito da DatabaseService. + /// Le statistiche sono disabilitate se il database non è disponibile. /// public class StatsService { private readonly DatabaseService _db; - private readonly PostgresStatsContext? _postgresDb; - private readonly bool _postgresAvailable; - public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null) + /// + /// Indica se le statistiche sono disponibili (database SQLite funzionante) + /// + public bool IsAvailable => _db.IsAvailable && _db.IsInitialized; + + /// + /// Messaggio di errore se le statistiche non sono disponibili + /// + public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null; + + /// + /// Path del database SQLite + /// + public string DatabasePath => _db.DatabasePath; + + private ProductStatisticsService? _productStatsService; + + public StatsService(DatabaseService db) { _db = db; - _postgresDb = postgresDb; - _postgresAvailable = false; + _productStatsService = new ProductStatisticsService(db); - // Verifica disponibilità PostgreSQL - if (_postgresDb != null) + // Log stato database SQLite + Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}"); + Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}"); + + if (!_db.IsAvailable) { - try - { - _postgresAvailable = _postgresDb.Database.CanConnect(); - var status = _postgresAvailable ? "AVAILABLE" : "UNAVAILABLE"; - Console.WriteLine($"[StatsService] PostgreSQL status: {status}"); - } - catch (Exception ex) - { - Console.WriteLine($"[StatsService] PostgreSQL connection failed: {ex.Message}"); - } - } - else - { - Console.WriteLine("[StatsService] PostgreSQL not configured - using SQLite only"); + Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}"); } } /// - /// Registra il completamento di un'asta (sia su PostgreSQL che SQLite) + /// Registra il completamento di un'asta con tutti i dati per analytics + /// Include scraping HTML per ottenere le puntate del vincitore /// - public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won) + public async Task RecordAuctionCompletedAsync(AuctionInfo auction, AuctionState state, bool won) { + // Skip se database non disponibile + if (!IsAvailable) + { + Console.WriteLine("[StatsService] Skipping record - database not available"); + return; + } + try { + Console.WriteLine($"[StatsService] ====== INIZIO SALVATAGGIO ASTA TERMINATA ======"); + Console.WriteLine($"[StatsService] Asta: {auction.Name} (ID: {auction.AuctionId})"); + Console.WriteLine($"[StatsService] Stato: {(won ? "VINTA" : "PERSA")}"); + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); var bidsUsed = auction.BidsUsedOnThisAuction ?? 0; var bidCost = auction.BidCost; var moneySpent = bidsUsed * bidCost; - var finalPrice = auction.LastState?.Price ?? 0; + var finalPrice = state.Price; var buyNowPrice = auction.BuyNowPrice; var shippingCost = auction.ShippingCost ?? 0; + // Dati aggiuntivi per analytics + var winnerUsername = state.LastBidder; + var totalResets = auction.ResetCount; + var productKey = ProductStatisticsService.GenerateProductKey(auction.Name); + + Console.WriteLine($"[StatsService] Prezzo finale: €{finalPrice:F2}"); + Console.WriteLine($"[StatsService] Puntate usate (utente): {bidsUsed}"); + Console.WriteLine($"[StatsService] Vincitore: {winnerUsername ?? "N/A"}"); + Console.WriteLine($"[StatsService] Reset totali: {totalResets}"); + + // ?? SCRAPING HTML: Ottieni puntate del vincitore dalla pagina dell'asta + int? winnerBidsUsed = null; + + if (!string.IsNullOrEmpty(winnerUsername)) + { + Console.WriteLine($"[StatsService] Avvio scraping HTML per ottenere puntate del vincitore..."); + winnerBidsUsed = await ScrapeWinnerBidsFromAuctionPageAsync(auction.OriginalUrl); + + // ? VALIDAZIONE: Verifica che i dati estratti siano ragionevoli + if (winnerBidsUsed.HasValue) + { + if (winnerBidsUsed.Value < 0) + { + Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate negative ({winnerBidsUsed.Value}) - Dato scartato"); + winnerBidsUsed = null; + } + else if (winnerBidsUsed.Value > 50000) + { + Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate sospette ({winnerBidsUsed.Value} > 50000) - Dato scartato"); + winnerBidsUsed = null; + } + else if (winnerBidsUsed.Value == 0 && totalResets > 10) + { + Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: 0 puntate con {totalResets} reset - Dato sospetto"); + winnerBidsUsed = null; + } + else + { + Console.WriteLine($"[StatsService] ? Puntate vincitore estratte da HTML: {winnerBidsUsed.Value} (validazione OK)"); + } + } + + // Fallback se validazione fallita o scraping non riuscito + if (!winnerBidsUsed.HasValue) + { + Console.WriteLine($"[StatsService] ? Impossibile estrarre puntate valide del vincitore da HTML"); + + // Fallback: conta da RecentBids (meno affidabile) + if (auction.RecentBids != null) + { + winnerBidsUsed = auction.RecentBids + .Count(b => b.Username?.Equals(winnerUsername, StringComparison.OrdinalIgnoreCase) == true); + + if (winnerBidsUsed.Value > 0) + { + Console.WriteLine($"[StatsService] Fallback: puntate vincitore da RecentBids: {winnerBidsUsed.Value}"); + } + else + { + Console.WriteLine($"[StatsService] ? Fallback fallito: nessuna puntata trovata in RecentBids"); + winnerBidsUsed = null; + } + } + } + } + double? totalCost = null; double? savings = null; @@ -67,9 +148,14 @@ namespace AutoBidder.Services { totalCost = finalPrice + moneySpent + shippingCost; savings = (buyNowPrice.Value + shippingCost) - totalCost.Value; + + Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}"); + Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}"); } - // Salva su SQLite (sempre) + Console.WriteLine($"[StatsService] Salvataggio nel database..."); + + // Salva risultato asta con tutti i campi await _db.SaveAuctionResultAsync( auction.AuctionId, auction.Name, @@ -79,9 +165,16 @@ namespace AutoBidder.Services buyNowPrice, shippingCost, totalCost, - savings + savings, + winnerUsername, + totalResets, + winnerBidsUsed, + productKey ); + Console.WriteLine($"[StatsService] ? Risultato asta salvato"); + + // Aggiorna statistiche giornaliere await _db.SaveDailyStatAsync( today, bidsUsed, @@ -89,229 +182,159 @@ namespace AutoBidder.Services won ? 1 : 0, won ? 0 : 1, savings ?? 0, - auction.LastState?.PollingLatencyMs + state.PollingLatencyMs ); - // Salva su PostgreSQL se disponibile - if (_postgresAvailable && _postgresDb != null) + Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate"); + + // Aggiorna statistiche aggregate per prodotto + if (_productStatsService != null) { - await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings); + Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})..."); + await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name); + Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate"); } - Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€"); + Console.WriteLine($"[StatsService] ====== SALVATAGGIO COMPLETATO ======"); } catch (Exception ex) { Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}"); + Console.WriteLine($"[StatsService ERROR] Stack: {ex.StackTrace}"); } } - + /// - /// Salva asta conclusa su PostgreSQL + /// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore /// - private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings) + private async Task ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl) { - if (_postgresDb == null) return; - try { - var completedAuction = new CompletedAuction - { - AuctionId = auction.AuctionId, - ProductName = auction.Name, - FinalPrice = (decimal)finalPrice, - BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null, - ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null, - TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile - MyBidsCount = bidsUsed, - ResetCount = auction.ResetCount, - Won = won, - WinnerUsername = won ? "ME" : auction.LastState?.LastBidder, - CompletedAt = DateTime.UtcNow, - AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable - Savings = savings.HasValue ? (decimal)savings.Value : null, - TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null, - CreatedAt = DateTime.UtcNow - }; - - _postgresDb.CompletedAuctions.Add(completedAuction); - await _postgresDb.SaveChangesAsync(); - - // Aggiorna statistiche prodotto - await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice); + using var httpClient = new HttpClient(); + // ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti + httpClient.Timeout = TimeSpan.FromSeconds(5); - // Aggiorna metriche giornaliere - await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0); - - Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL"); - } - catch (Exception ex) - { - Console.WriteLine($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}"); - } - } - - /// - /// Aggiorna statistiche prodotto in PostgreSQL - /// - private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice) - { - if (_postgresDb == null) return; - - try - { - var productKey = GenerateProductKey(auction.Name); - var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey); - - if (stat == null) - { - stat = new ProductStatistic - { - ProductKey = productKey, - ProductName = auction.Name, - TotalAuctions = 0, - MinBidsSeen = int.MaxValue, - MaxBidsSeen = 0, - CompetitionLevel = "Medium" - }; - _postgresDb.ProductStatistics.Add(stat); - } - - stat.TotalAuctions++; - stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions; - stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions; + // Headers browser-like per evitare rilevamento come bot + httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7"); - if (won) - { - stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions; - } + Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)"); - stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed); - stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed); - stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer - stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer - stat.LastUpdated = DateTime.UtcNow; - - // Determina livello competizione - if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High"; - else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low"; - else stat.CompetitionLevel = "Medium"; - - await _postgresDb.SaveChangesAsync(); - } - catch (Exception ex) - { - Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}"); - } - } - - /// - /// Aggiorna metriche giornaliere in PostgreSQL - /// - private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings) - { - if (_postgresDb == null) return; - - try - { - var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date); - - if (metric == null) - { - metric = new DailyMetric { Date = date.Date }; - _postgresDb.DailyMetrics.Add(metric); - } - - metric.TotalBidsUsed += bidsUsed; - metric.MoneySpent += (decimal)(bidsUsed * bidCost); - if (won) metric.AuctionsWon++; else metric.AuctionsLost++; - metric.TotalSavings += (decimal)savings; - - var totalAuctions = metric.AuctionsWon + metric.AuctionsLost; - if (totalAuctions > 0) - { - metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100; - } - - if (metric.MoneySpent > 0) - { - metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100; - } - - await _postgresDb.SaveChangesAsync(); - } - catch (Exception ex) - { - Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}"); - } - } - - /// - /// Genera chiave univoca per prodotto - /// - private string GenerateProductKey(string productName) - { - var normalized = productName.ToLowerInvariant() - .Replace(" ", "_") - .Replace("-", "_"); - return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", ""); - } - - /// - /// Ottiene raccomandazioni strategiche da PostgreSQL - /// - public async Task> GetStrategicInsightsAsync(string? productKey = null) - { - if (!_postgresAvailable || _postgresDb == null) - { - return new List(); - } - - try - { - var query = _postgresDb.StrategicInsights.Where(i => i.IsActive); + var html = await httpClient.GetStringAsync(auctionUrl); - if (!string.IsNullOrEmpty(productKey)) - { - query = query.Where(i => i.ProductKey == productKey); - } - - return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync(); + Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing..."); + + // Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate + var bidsUsed = ExtractBidsUsedFromHtml(html); + + return bidsUsed; + } + catch (TaskCanceledException) + { + Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}"); + return null; + } + catch (HttpRequestException ex) + { + Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}"); + return null; } catch (Exception ex) { - Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}"); - return new List(); + Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}"); + return null; } } - + /// - /// Ottiene performance puntatori da PostgreSQL + /// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper) /// - public async Task> GetTopCompetitorsAsync(int limit = 10) + private int? ExtractBidsUsedFromHtml(string html) { - if (!_postgresAvailable || _postgresDb == null) + if (string.IsNullOrEmpty(html)) return null; + + // 1) Look for the explicit bids-used span:

628 Puntate utilizzate

+ var match = System.Text.RegularExpressions.Regex.Match(html, + "class=\\\"bids-used\\\"[^>]*>[^<]*]*>(?[0-9]{1,7})", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + + if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1)) { - return new List(); + Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}"); + return val1; } - - try + + // 2) Look for numeric followed by 'Puntate utilizzate' or similar + match = System.Text.RegularExpressions.Regex.Match(html, + "(?[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2)) { - return await _postgresDb.BidderPerformances - .OrderByDescending(b => b.WinRate) - .Take(limit) - .ToListAsync(); + Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}"); + return val2; } - catch (Exception ex) + + // 3) Fallbacks + match = System.Text.RegularExpressions.Regex.Match(html, + "(?[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3)) { - Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}"); - return new List(); + Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}"); + return val3; + } + + Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML"); + return null; + } + + /// + /// Registra il completamento di un'asta (overload semplificato per compatibilità) + /// + public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won) + { + if (auction.LastState != null) + { + await RecordAuctionCompletedAsync(auction, auction.LastState, won); + } + else + { + Console.WriteLine("[StatsService] Cannot record auction - LastState is null"); } } + + /// + /// Ottiene i limiti consigliati per un prodotto + /// + public async Task GetRecommendedLimitsAsync(string productName) + { + if (_productStatsService == null) return null; + + var productKey = ProductStatisticsService.GenerateProductKey(productName); + return await _productStatsService.GetRecommendedLimitsAsync(productKey); + } + + /// + /// Ottiene tutte le statistiche prodotto + /// + public async Task> GetAllProductStatisticsAsync() + { + if (_productStatsService == null) return new List(); + return await _productStatsService.GetAllProductStatisticsAsync(); + } - // Metodi esistenti per compatibilità SQLite + // Metodi per query statistiche public async Task> GetDailyStatsAsync(int days = 30) { + if (!IsAvailable) + { + return new List(); + } + var to = DateTime.UtcNow; var from = to.AddDays(-days); return await _db.GetDailyStatsAsync(from, to); @@ -319,6 +342,11 @@ namespace AutoBidder.Services public async Task GetTotalStatsAsync() { + if (!IsAvailable) + { + return new TotalStats(); + } + var stats = await GetDailyStatsAsync(365); return new TotalStats @@ -338,13 +366,23 @@ namespace AutoBidder.Services }; } - public async Task> GetRecentAuctionResultsAsync(int limit = 50) + public async Task> GetRecentAuctionResultsAsync(int limit = 50) { + if (!IsAvailable) + { + return new List(); + } + return await _db.GetRecentAuctionResultsAsync(limit); } public async Task CalculateROIAsync() { + if (!IsAvailable) + { + return 0; + } + var stats = await GetTotalStatsAsync(); if (stats.TotalMoneySpent <= 0) @@ -355,11 +393,22 @@ namespace AutoBidder.Services public async Task GetChartDataAsync(int days = 30) { + if (!IsAvailable) + { + return new ChartData + { + Labels = new List(), + MoneySpent = new List(), + Savings = new List() + }; + } + var stats = await GetDailyStatsAsync(days); var allDates = new List(); var startDate = DateTime.UtcNow.AddDays(-days); + for (int i = 0; i < days; i++) { var date = startDate.AddDays(i).ToString("yyyy-MM-dd"); @@ -387,11 +436,6 @@ namespace AutoBidder.Services Savings = allDates.Select(s => s.TotalSavings).ToList() }; } - - /// - /// Indica se il database PostgreSQL è disponibile - /// - public bool IsPostgresAvailable => _postgresAvailable; } // Classi esistenti per compatibilità diff --git a/Mimante/Shared/NavMenu.razor b/Mimante/Shared/NavMenu.razor index eba60e5..67e9796 100644 --- a/Mimante/Shared/NavMenu.razor +++ b/Mimante/Shared/NavMenu.razor @@ -24,11 +24,6 @@ Esplora Aste - - - Puntate Gratuite - - Statistiche diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs index 9f4e136..00e6790 100644 --- a/Mimante/Utilities/SettingsManager.cs +++ b/Mimante/Utilities/SettingsManager.cs @@ -70,27 +70,6 @@ namespace AutoBidder.Utilities /// Default: "Normal" (uso giornaliero - errori e warning) ///
public string MinLogLevel { get; set; } = "Normal"; - - // CONFIGURAZIONE DATABASE POSTGRESQL - /// - /// Abilita l'uso di PostgreSQL per statistiche avanzate - /// - public bool UsePostgreSQL { get; set; } = true; - - /// - /// Connection string PostgreSQL - /// - public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password"; - - /// - /// Auto-crea schema database se mancante - /// - public bool AutoCreateDatabaseSchema { get; set; } = true; - - /// - /// Fallback automatico a SQLite se PostgreSQL non disponibile - /// - public bool FallbackToSQLite { get; set; } = true; } public static class SettingsManager diff --git a/Mimante/docker-compose.yml b/Mimante/docker-compose.yml index bef258d..42e58ce 100644 --- a/Mimante/docker-compose.yml +++ b/Mimante/docker-compose.yml @@ -1,31 +1,6 @@ version: '3.8' services: - # ================================================ - # PostgreSQL Database (statistiche avanzate) - # ================================================ - postgres: - image: postgres:16-alpine - container_name: autobidder-postgres - environment: - POSTGRES_DB: autobidder_stats - POSTGRES_USER: ${POSTGRES_USER:-autobidder} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-autobidder_password} - POSTGRES_INITDB_ARGS: --encoding=UTF8 - volumes: - - postgres-data:/var/lib/postgresql/data - - ./postgres-backups:/backups - ports: - - "5432:5432" - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-autobidder} -d autobidder_stats"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - autobidder-network - # ================================================ # AutoBidder Application # ================================================ @@ -37,37 +12,29 @@ services: BUILD_CONFIGURATION: Release image: gitea.encke-hake.ts.net/alby96/autobidder:latest container_name: autobidder - depends_on: - postgres: - condition: service_healthy ports: - "${APP_PORT:-5000}:8080" # Host:Container (HTTP only) volumes: - # Persistent data (SQLite, backups, logs) + # Persistent data (SQLite databases, backups, logs, keys) + # Tutti i dati persistenti sono salvati in questo volume - ./Data:/app/Data - - # PostgreSQL backups - - ./postgres-backups:/app/Data/backups environment: # ASP.NET Core - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:8080 + # ============================================ + # DATABASE PATH - Volume persistente Docker + # ============================================ + # Tutti i database SQLite e dati persistenti usano questo path + - DATA_PATH=/app/Data + # Autenticazione applicazione (SICUREZZA) - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - # PostgreSQL connection - - ConnectionStrings__PostgreSQL=Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER:-autobidder};Password=${POSTGRES_PASSWORD:-autobidder_password} - - # Database settings - - Database__UsePostgres=${USE_POSTGRES:-true} - - Database__AutoCreateSchema=true - - Database__FallbackToSQLite=true - # Logging - Logging__LogLevel__Default=${LOG_LEVEL:-Information} - - Logging__LogLevel__Microsoft.EntityFrameworkCore=Warning # Timezone - TZ=Europe/Rome @@ -81,10 +48,6 @@ services: networks: - autobidder-network -volumes: - postgres-data: - driver: local - networks: autobidder-network: driver: bridge diff --git a/Mimante/wwwroot/css/modern-pages.css b/Mimante/wwwroot/css/modern-pages.css index 522a4f6..7267ea7 100644 --- a/Mimante/wwwroot/css/modern-pages.css +++ b/Mimante/wwwroot/css/modern-pages.css @@ -1,4 +1,33 @@ -/* === MODERN PAGE STYLES (append to app-wpf.css) === */ +/* === MODERN PAGE STYLES (append to app-wpf.css) === */ + +/* ✅ NUOVO: Stili per selezione riga e colonna puntate */ +.table-hover tbody tr { + cursor: pointer; + transition: all 0.2s ease; +} + +.table-hover tbody tr.selected-row { + background-color: rgba(76, 175, 80, 0.15) !important; + border-left: 4px solid #4CAF50; +} + +.table-hover tbody tr:hover:not(.selected-row) { + background-color: rgba(255, 255, 255, 0.05); +} + +/* Colonna Puntate - testo grassetto e leggibile */ +.bids-column { + font-weight: bold !important; + color: var(--text-primary) !important; +} + +/* Larghezza colonna puntate leggermente maggiore */ +.col-click { + min-width: 85px; + width: 85px; + white-space: nowrap; +} + .page-header { display: flex; align-items: center;