Refactor: solo SQLite, limiti auto, UI statistiche nuova
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.
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
/// <summary>
|
||||
/// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI
|
||||
/// Mantenuto per retrocompatibilità con salvataggi JSON esistenti
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
120
Mimante/Models/ProductStatisticsRecord.cs
Normal file
120
Mimante/Models/ProductStatisticsRecord.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace AutoBidder.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Record per le statistiche aggregate di un prodotto nel database
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il win rate come percentuale
|
||||
/// </summary>
|
||||
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risultato asta esteso con tutti i campi per analytics
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Limiti consigliati per un'asta basati sulle statistiche storiche
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0-100) - quanto sono affidabili questi limiti
|
||||
/// </summary>
|
||||
public int ConfidenceScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Numero di aste usate per calcolare i limiti
|
||||
/// </summary>
|
||||
public int SampleSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fascia oraria migliore per vincere (0-23)
|
||||
/// </summary>
|
||||
public int? BestHourToPlay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Win rate medio per questo prodotto
|
||||
/// </summary>
|
||||
public double? AverageWinRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistiche per fascia oraria
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
<i class="bi @(isLoading ? "bi-arrow-clockwise spin" : "bi-arrow-clockwise")"></i>
|
||||
Aggiorna
|
||||
</button>
|
||||
@if (auctions.Count > 0)
|
||||
{
|
||||
<button class="btn btn-outline-danger" @onclick="ClearAllAuctions" title="Rimuove tutte le aste caricate e ferma il polling">
|
||||
<i class="bi bi-trash"></i>
|
||||
Pulisci Tutto
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +83,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ? NUOVO: Search Bar -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-md-8">
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text bg-primary text-white border-0">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control form-control-lg border-0"
|
||||
placeholder="Cerca per nome asta, prezzo, vincitore..."
|
||||
@bind="searchQuery"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnSearchChanged" />
|
||||
@if (!string.IsNullOrEmpty(searchQuery))
|
||||
{
|
||||
<button class="btn btn-outline-secondary border-0"
|
||||
@onclick="ClearSearch"
|
||||
title="Cancella ricerca">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stats-mini">
|
||||
<span class="text-muted">Risultati filtrati:</span>
|
||||
<span class="fw-bold text-info ms-2">@filteredAuctions.Count</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading)
|
||||
{
|
||||
@@ -95,6 +138,16 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery))
|
||||
{
|
||||
<div class="text-center py-5 animate-fade-in">
|
||||
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
|
||||
<p class="text-muted mt-3">Nessuna asta trovata per "<strong>@searchQuery</strong>"</p>
|
||||
<button class="btn btn-outline-secondary" @onclick="ClearSearch">
|
||||
<i class="bi bi-x-circle me-2"></i>Cancella Ricerca
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (auctions.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 animate-fade-in">
|
||||
@@ -109,7 +162,7 @@
|
||||
{
|
||||
<!-- Auctions Grid -->
|
||||
<div class="auction-grid animate-fade-in">
|
||||
@foreach (var auction in auctions)
|
||||
@foreach (var auction in filteredAuctions)
|
||||
{
|
||||
<div class="auction-card @(auction.IsMonitored ? "monitored" : "") @(auction.IsSold ? "sold" : "")">
|
||||
<!-- Image -->
|
||||
@@ -241,6 +294,7 @@
|
||||
@code {
|
||||
private List<BidooCategoryInfo> categories = new();
|
||||
private List<BidooBrowserAuction> auctions = new();
|
||||
private List<BidooBrowserAuction> filteredAuctions = new();
|
||||
private int selectedCategoryIndex = 0;
|
||||
private int currentPage = 0;
|
||||
|
||||
@@ -249,6 +303,9 @@
|
||||
private bool canLoadMore = true;
|
||||
private string? errorMessage = null;
|
||||
|
||||
// ? NUOVO: Ricerca
|
||||
private string searchQuery = "";
|
||||
|
||||
private System.Threading.Timer? stateUpdateTimer;
|
||||
private CancellationTokenSource? cts;
|
||||
private bool isUpdatingInBackground = false;
|
||||
@@ -319,6 +376,9 @@
|
||||
{
|
||||
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
|
||||
}
|
||||
|
||||
// ? NUOVO: Applica filtro ricerca
|
||||
ApplySearchFilter();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -336,6 +396,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ? 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()
|
||||
{
|
||||
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
@page "/freebids"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
|
||||
<PageTitle>Puntate Gratuite - AutoBidder</PageTitle>
|
||||
|
||||
<div class="freebids-container animate-fade-in p-4">
|
||||
<div class="d-flex align-items-center mb-4 animate-fade-in-down">
|
||||
<i class="bi bi-gift-fill text-warning me-3" style="font-size: 2.5rem;"></i>
|
||||
<h2 class="mb-0 fw-bold">Puntate Gratuite</h2>
|
||||
</div>
|
||||
|
||||
<!-- Feature Under Development Notice - Conciso -->
|
||||
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-tools me-3" style="font-size: 2rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-2"><strong>Funzionalità in Sviluppo</strong></h5>
|
||||
<p class="mb-0">
|
||||
Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo.
|
||||
<br />
|
||||
<small class="text-muted">Disponibile in una prossima versione con statistiche dettagliate.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.freebids-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -87,7 +87,7 @@
|
||||
<th class="col-prezzo"><i class="bi bi-currency-euro"></i> Prezzo</th>
|
||||
<th class="col-timer"><i class="bi bi-clock"></i> Timer</th>
|
||||
<th class="col-ultimo"><i class="bi bi-person"></i> Ultimo</th>
|
||||
<th class="col-click"><i class="bi bi-hand-index"></i> Click</th>
|
||||
<th class="col-click"><i class="bi bi-hand-index" style="font-size: 0.85rem;"></i> Puntate</th>
|
||||
<th class="col-ping"><i class="bi bi-speedometer"></i> Ping</th>
|
||||
<th class="col-azioni"><i class="bi bi-gear"></i> Azioni</th>
|
||||
</tr>
|
||||
@@ -95,7 +95,7 @@
|
||||
<tbody>
|
||||
@foreach (var auction in auctions)
|
||||
{
|
||||
<tr class="@GetRowClass(auction) table-row-enter transition-all"
|
||||
<tr class="@GetRowClass(auction) @(selectedAuction == auction ? "selected-row" : "") table-row-enter transition-all"
|
||||
@onclick="() => SelectAuction(auction)"
|
||||
style="cursor: pointer;">
|
||||
<td class="col-stato">
|
||||
@@ -107,7 +107,7 @@
|
||||
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
|
||||
<td class="col-timer">@GetTimerDisplay(auction)</td>
|
||||
<td class="col-ultimo">@GetLastBidder(auction)</td>
|
||||
<td class="col-click"><span class="badge bg-info">@GetMyBidsCount(auction)</span></td>
|
||||
<td class="col-click bids-column fw-bold">@GetMyBidsCount(auction)</td>
|
||||
<td class="col-ping">@GetPingDisplay(auction)</td>
|
||||
<td class="col-azioni">
|
||||
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
|
||||
@@ -230,6 +230,9 @@
|
||||
<button class="btn btn-outline-secondary" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" @onclick="() => OpenAuctionInNewTab(selectedAuction.OriginalUrl)" title="Apri in nuova scheda">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +275,31 @@
|
||||
Verifica asta aperta prima di puntare
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Pulsante Applica Limiti Consigliati -->
|
||||
<div class="mt-3 pt-3 border-top">
|
||||
<button class="btn btn-outline-primary w-100"
|
||||
@onclick="ApplyRecommendedLimitsToSelected"
|
||||
disabled="@isLoadingRecommendations">
|
||||
@if (isLoadingRecommendations)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Caricamento...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-magic me-2"></i>
|
||||
<span>Applica Limiti Consigliati</span>
|
||||
}
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(recommendationMessage))
|
||||
{
|
||||
<div class="alert @(recommendationSuccess ? "alert-success" : "alert-warning") mt-2 mb-0 py-2 small">
|
||||
<i class="bi @(recommendationSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||
@recommendationMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
@@ -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);
|
||||
<tr class="@(bidder.IsMe ? "table-success" : "")">
|
||||
<td><span class="badge bg-primary">#{i + 1}</span></td>
|
||||
<td><span class="badge bg-primary">#@(i + 1)</span></td>
|
||||
<td>
|
||||
@bidder.Username
|
||||
@if (bidder.IsMe)
|
||||
|
||||
@@ -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<AuctionInfo> auctions => AppState.Auctions.ToList();
|
||||
private AuctionInfo? selectedAuction
|
||||
@@ -41,6 +42,11 @@ namespace AutoBidder.Pages
|
||||
private double sessionShopCredit;
|
||||
private int sessionAuctionsWon;
|
||||
|
||||
// Recommended limits
|
||||
private bool isLoadingRecommendations = false;
|
||||
private string? recommendationMessage = null;
|
||||
private bool recommendationSuccess = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Sottoscrivi agli eventi del servizio di stato (ASYNC)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<i class="bi bi-gear-fill text-primary" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h2 class="mb-0 fw-bold">Impostazioni</h2>
|
||||
<small class="text-muted">Configura sessione, comportamento aste, limiti e database statistiche.</small>
|
||||
<small class="text-muted">Configura sessione, comportamento aste e limiti.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,78 +208,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONFIGURAZIONE DATABASE -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading-db">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-db" aria-expanded="false" aria-controls="collapse-db">
|
||||
<i class="bi bi-database-fill me-2"></i> Configurazione Database
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-db" class="accordion-collapse collapse" aria-labelledby="heading-db" data-bs-parent="#settingsAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="alert alert-info border-0 shadow-sm">
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
PostgreSQL per statistiche avanzate; SQLite come fallback.
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="usePostgres" @bind="settings.UsePostgreSQL" />
|
||||
<label class="form-check-label" for="usePostgres">Usa PostgreSQL per statistiche avanzate</label>
|
||||
</div>
|
||||
|
||||
@if (settings.UsePostgreSQL)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> Connection string</label>
|
||||
<input type="text" class="form-control font-monospace" @bind="settings.PostgresConnectionString" />
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="autoCreateSchema" @bind="settings.AutoCreateDatabaseSchema" />
|
||||
<label class="form-check-label" for="autoCreateSchema">Auto-crea schema se mancante</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="fallbackSqlite" @bind="settings.FallbackToSQLite" />
|
||||
<label class="form-check-label" for="fallbackSqlite">Fallback a SQLite se non disponibile</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<button class="btn btn-primary" @onclick="TestDatabaseConnection" disabled="@isTestingConnection">
|
||||
@if (isTestingConnection)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Test...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-wifi"></i>
|
||||
<span>Test connessione</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (!string.IsNullOrEmpty(dbTestResult))
|
||||
{
|
||||
<span class="@(dbTestSuccess ? "text-success" : "text-danger")">
|
||||
<i class="bi bi-@(dbTestSuccess ? "check-circle-fill" : "x-circle-fill") me-1"></i>
|
||||
@dbTestResult
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-secondary text-white" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,10 +237,6 @@
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
@page "/statistics"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using AutoBidder.Models
|
||||
@using AutoBidder.Services
|
||||
@inject StatsService StatsService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject DatabaseService DatabaseService
|
||||
|
||||
<PageTitle>Statistiche - AutoBidder</PageTitle>
|
||||
|
||||
<div class="statistics-container animate-fade-in p-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 animate-fade-in-down">
|
||||
<div class="statistics-container p-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-bar-chart-fill text-primary me-3" style="font-size: 2.5rem;"></i>
|
||||
<h2 class="mb-0 fw-bold">Statistiche</h2>
|
||||
</div>
|
||||
<button class="btn btn-primary hover-lift" @onclick="RefreshStats" disabled="@isLoading">
|
||||
@if (StatsService.IsAvailable)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="RefreshStats" disabled="@isLoading">
|
||||
@if (isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
@@ -22,217 +26,211 @@
|
||||
}
|
||||
Aggiorna
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (errorMessage != null)
|
||||
@if (!StatsService.IsAvailable)
|
||||
{
|
||||
<div class="alert alert-danger border-0 shadow-sm animate-shake mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i> @errorMessage
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-database-x me-3" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h5 class="mb-2 fw-bold">Statistiche non disponibili</h5>
|
||||
<p class="mb-0">Il database per le statistiche non è stato configurato o non è accessibile.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading)
|
||||
else if (isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;"></div>
|
||||
<p class="mt-3 text-muted">Caricamento statistiche...</p>
|
||||
</div>
|
||||
}
|
||||
else if (totalStats != null)
|
||||
else
|
||||
{
|
||||
<!-- CARD TOTALI -->
|
||||
<div class="row g-3 mb-4 animate-fade-in-up">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card card border-0 shadow-sm hover-lift">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-hand-index-fill text-primary" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalBidsUsed</h3>
|
||||
<p class="text-muted mb-0">Puntate Usate</p>
|
||||
<small class="text-muted">€@((totalStats.TotalBidsUsed * 0.20).ToString("F2"))</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card card border-0 shadow-sm hover-lift">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-trophy-fill text-warning" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalAuctionsWon</h3>
|
||||
<p class="text-muted mb-0">Aste Vinte</p>
|
||||
<small class="text-muted">Win Rate: @totalStats.WinRate.ToString("F1")%</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card card border-0 shadow-sm hover-lift">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-piggy-bank-fill text-success" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">€@totalStats.TotalSavings.ToString("F2")</h3>
|
||||
<p class="text-muted mb-0">Risparmio Totale</p>
|
||||
<small class="text-muted">ROI: @roi.ToString("F1")%</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card card border-0 shadow-sm hover-lift">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-speedometer text-info" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">@totalStats.AverageBidsPerAuction.ToString("F1")</h3>
|
||||
<p class="text-muted mb-0">Puntate/Asta Media</p>
|
||||
<small class="text-muted">Latency: @totalStats.AverageLatency.ToString("F0")ms</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GRAFICI -->
|
||||
<div class="row g-4 mb-4 animate-fade-in-up delay-100">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="row g-4">
|
||||
<!-- COLONNA SINISTRA: Aste Recenti -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Spesa Giornaliera (Ultimi 30 Giorni)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="moneyChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-pie-chart me-2"></i>Aste Vinte vs Perse</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="winsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ASTE RECENTI -->
|
||||
@if (recentResults != null && recentResults.Any())
|
||||
{
|
||||
<div class="card border-0 shadow-sm animate-fade-in-up delay-200">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Aste Recenti</h5>
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history me-2"></i>
|
||||
Aste Terminate Recenti
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
@if (recentAuctions == null || !recentAuctions.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">Nessuna asta terminata salvata</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Asta</th>
|
||||
<th>Prezzo Finale</th>
|
||||
<th>Puntate</th>
|
||||
<th>Risultato</th>
|
||||
<th>Risparmio</th>
|
||||
<th>Nome</th>
|
||||
<th class="text-end">Prezzo</th>
|
||||
<th class="text-end">Puntate</th>
|
||||
<th>Vincitore</th>
|
||||
<th class="text-center">Stato</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var result in recentResults.Take(10))
|
||||
@foreach (var auction in recentAuctions)
|
||||
{
|
||||
<tr class="@(result.Won ? "table-success" : "")">
|
||||
<td class="fw-semibold">@result.AuctionName</td>
|
||||
<td>€@result.FinalPrice.ToString("F2")</td>
|
||||
<td><span class="badge bg-info">@result.BidsUsed</span></td>
|
||||
<td>
|
||||
@if (result.Won)
|
||||
<tr class="@(auction.Won ? "table-success-subtle" : "")">
|
||||
<td><small>@auction.AuctionName</small></td>
|
||||
<td class="text-end fw-bold">€@auction.FinalPrice.ToString("F2")</td>
|
||||
<td class="text-end">@auction.BidsUsed</td>
|
||||
<td><small class="text-muted">@(auction.WinnerUsername ?? "-")</small></td>
|
||||
<td class="text-center">
|
||||
@if (auction.Won)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-trophy-fill"></i> Vinta</span>
|
||||
<span class="badge bg-success">? Vinta</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary"><i class="bi bi-x-circle"></i> Persa</span>
|
||||
<span class="badge bg-secondary">? Persa</span>
|
||||
}
|
||||
</td>
|
||||
<td class="@(result.Savings > 0 ? "text-success fw-bold" : "text-danger")">
|
||||
@if (result.Savings.HasValue)
|
||||
{
|
||||
@((result.Savings.Value > 0 ? "+" : "") + "€" + result.Savings.Value.ToString("F2"))
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">@DateTime.Parse(result.Timestamp).ToString("dd/MM HH:mm")</td>
|
||||
<td><small class="text-muted">@FormatTimestamp(auction.Timestamp)</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNA DESTRA: Statistiche Prodotti -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-box-seam me-2"></i>
|
||||
Prodotti Salvati
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (products == null || !products.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">Nessun prodotto salvato</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
Nessuna statistica disponibile. Completa alcune aste per vedere le statistiche.
|
||||
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Prodotto</th>
|
||||
<th class="text-center">Aste</th>
|
||||
<th class="text-center">Win%</th>
|
||||
<th class="text-end">Limiti €</th>
|
||||
<th class="text-center">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var product in products)
|
||||
{
|
||||
var winRate = product.TotalAuctions > 0
|
||||
? (product.WonAuctions * 100.0 / product.TotalAuctions)
|
||||
: 0;
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<small class="fw-bold">@product.ProductName</small>
|
||||
<br/>
|
||||
<small class="text-muted">@product.TotalAuctions totali (@product.WonAuctions vinte)</small>
|
||||
</td>
|
||||
<td class="text-center fw-bold">
|
||||
@product.TotalAuctions
|
||||
</td>
|
||||
<td class="text-center fw-bold">
|
||||
<span class="@(winRate >= 50 ? "text-success" : "text-danger")">
|
||||
@winRate.ToString("F0")%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
|
||||
{
|
||||
<small class="text-muted">
|
||||
@product.RecommendedMinPrice.Value.ToString("F2") - @product.RecommendedMaxPrice.Value.ToString("F2")
|
||||
</small>
|
||||
}
|
||||
else
|
||||
{
|
||||
<small class="text-muted">-</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
|
||||
{
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@onclick="() => ApplyLimitsToProduct(product)"
|
||||
title="Applica limiti a tutte le aste di questo prodotto">
|
||||
<i class="bi bi-check2-circle"></i> Applica
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<small class="text-muted">N/D</small>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private TotalStats? totalStats;
|
||||
private List<AuctionResult>? recentResults;
|
||||
private string? errorMessage;
|
||||
private bool isLoading = false;
|
||||
private double roi = 0;
|
||||
private bool isLoading = true;
|
||||
private List<AuctionResultExtended>? recentAuctions;
|
||||
private List<ProductStatisticsRecord>? 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()
|
||||
{
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
// Carica statistiche
|
||||
totalStats = await StatsService.GetTotalStatsAsync();
|
||||
roi = await StatsService.CalculateROIAsync();
|
||||
recentResults = await StatsService.GetRecentAuctionResultsAsync(20);
|
||||
|
||||
// Render grafici dopo il caricamento
|
||||
if (totalStats != null)
|
||||
try
|
||||
{
|
||||
await RenderCharts();
|
||||
}
|
||||
// Carica aste recenti (ultime 50)
|
||||
recentAuctions = await DatabaseService.GetRecentAuctionResultsAsync(50);
|
||||
|
||||
// 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);
|
||||
// Trova tutte le aste con questo ProductKey nel monitor
|
||||
var matchingAuctions = AppState.Auctions
|
||||
.Where(a => ProductStatisticsService.GenerateProductKey(a.Name) == product.ProductKey)
|
||||
.ToList();
|
||||
|
||||
// Render grafico spesa
|
||||
await JSRuntime.InvokeVoidAsync("renderMoneyChart",
|
||||
chartData.Labels,
|
||||
chartData.MoneySpent,
|
||||
chartData.Savings);
|
||||
if (!matchingAuctions.Any())
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Nessuna asta trovata per '{product.ProductName}'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Render grafico wins
|
||||
await JSRuntime.InvokeVoidAsync("renderWinsChart",
|
||||
totalStats!.TotalAuctionsWon,
|
||||
totalStats!.TotalAuctionsLost);
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool>("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<ApplicationDbContext>(options =>
|
||||
{
|
||||
@@ -163,71 +168,6 @@ if (!builder.Environment.IsDevelopment())
|
||||
});
|
||||
}
|
||||
|
||||
// Configura Database SQLite per statistiche (fallback locale)
|
||||
builder.Services.AddDbContext<StatisticsContext>(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<bool>("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<AutoBidder.Data.PostgresStatsContext>(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<DatabaseService>();
|
||||
builder.Services.AddSingleton<ApplicationStateService>();
|
||||
builder.Services.AddSingleton<BidooBrowserService>();
|
||||
builder.Services.AddScoped<StatsService>(sp =>
|
||||
{
|
||||
var db = sp.GetRequiredService<DatabaseService>();
|
||||
|
||||
// Prova a ottenere PostgreSQL context (potrebbe essere null)
|
||||
AutoBidder.Data.PostgresStatsContext? postgresDb = null;
|
||||
try
|
||||
{
|
||||
postgresDb = sp.GetService<AutoBidder.Data.PostgresStatsContext>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// PostgreSQL non disponibile, usa solo SQLite
|
||||
}
|
||||
|
||||
return new StatsService(db, postgresDb);
|
||||
});
|
||||
builder.Services.AddScoped<StatsService>();
|
||||
builder.Services.AddScoped<AuctionStateService>();
|
||||
|
||||
// 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<StatisticsContext>();
|
||||
|
||||
// In caso di errore, esegui sempre la diagnostica
|
||||
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
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[STATS DB] Attempting forced recreation...");
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
Console.WriteLine("[STATS DB] Forced recreation successful");
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inizializza PostgreSQL per statistiche avanzate
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
|
||||
|
||||
if (postgresDb != null)
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
|
||||
|
||||
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
|
||||
|
||||
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)");
|
||||
}
|
||||
await databaseService.RunDatabaseDiagnosticsAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
|
||||
// Ignora errori nella diagnostica stessa
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
|
||||
// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
|
||||
{
|
||||
var dbService = app.Services.GetRequiredService<DatabaseService>();
|
||||
|
||||
|
||||
|
||||
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
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($"");
|
||||
|
||||
// Crea un nuovo scope per StatsService (è Scoped)
|
||||
using var scope = app.Services.CreateScope();
|
||||
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
|
||||
|
||||
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($"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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($"");
|
||||
}
|
||||
};
|
||||
|
||||
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
|
||||
}
|
||||
|
||||
// ? 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();
|
||||
|
||||
@@ -23,6 +23,12 @@ namespace AutoBidder.Services
|
||||
public event Action<string>? OnLog;
|
||||
public event Action<string>? OnResetCountChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Evento fired quando un'asta termina (vinta, persa o chiusa).
|
||||
/// Parametri: AuctionInfo, AuctionState finale, bool won (true se vinta dall'utente)
|
||||
/// </summary>
|
||||
public event Action<AuctionInfo, AuctionState, bool>? OnAuctionCompleted;
|
||||
|
||||
public AuctionMonitor()
|
||||
{
|
||||
_apiClient = new BidooApiClient();
|
||||
@@ -101,13 +107,53 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determina se l'asta è stata vinta dall'utente corrente
|
||||
/// </summary>
|
||||
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<AuctionInfo> GetAuctions()
|
||||
{
|
||||
lock (_auctions)
|
||||
@@ -116,6 +162,62 @@ namespace AutoBidder.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applica i limiti consigliati a un'asta specifica
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applica i limiti consigliati a tutte le aste con lo stesso ProductKey
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
if (_monitoringTask != null && !_monitoringTask.IsCompleted)
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DatabaseService : IDisposable
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _databasePath;
|
||||
private bool _isInitialized = false;
|
||||
private bool _isAvailable = false;
|
||||
private string? _initializationError;
|
||||
|
||||
/// <summary>
|
||||
/// Indica se il database è stato inizializzato correttamente
|
||||
/// </summary>
|
||||
public bool IsInitialized => _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Indica se il database è disponibile e funzionante
|
||||
/// </summary>
|
||||
public bool IsAvailable => _isAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Eventuale errore di inizializzazione
|
||||
/// </summary>
|
||||
public string? InitializationError => _initializationError;
|
||||
|
||||
/// <summary>
|
||||
/// Path del database
|
||||
/// </summary>
|
||||
public string DatabasePath => _databasePath;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
_databasePath = Path.Combine(dataDir, "autobidder.db");
|
||||
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(dataBasePath, "autobidder.db");
|
||||
_connectionString = $"Data Source={_databasePath}";
|
||||
|
||||
Console.WriteLine($"[DatabaseService] Database path: {_databasePath}");
|
||||
Console.WriteLine($"[DatabaseService] Available: {_isAvailable}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,21 +83,83 @@ namespace AutoBidder.Services
|
||||
/// </summary>
|
||||
public async Task InitializeDatabaseAsync()
|
||||
{
|
||||
if (!_isAvailable)
|
||||
{
|
||||
Console.WriteLine("[DatabaseService] Skipping initialization - database not available");
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// 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("");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,16 +682,49 @@ namespace AutoBidder.Services
|
||||
/// Ottiene una connessione al database
|
||||
/// </summary>
|
||||
public async Task<SqliteConnection> GetConnectionAsync()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esegue una query SQL e ritorna il numero di righe affette
|
||||
/// </summary>
|
||||
public async Task<int> ExecuteNonQueryAsync(string sql, params SqliteParameter[] parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await GetConnectionAsync();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
@@ -400,11 +735,42 @@ namespace AutoBidder.Services
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esegue una query SQL e ritorna un valore scalare
|
||||
/// </summary>
|
||||
public async Task<object?> ExecuteScalarAsync(string sql, params SqliteParameter[] parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await GetConnectionAsync();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
@@ -415,6 +781,23 @@ namespace AutoBidder.Services
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica la salute del database
|
||||
@@ -429,12 +812,130 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostica completa del database - mostra tutte le tabelle, indici e versione
|
||||
/// </summary>
|
||||
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]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene informazioni sul database
|
||||
/// </summary>
|
||||
@@ -535,15 +1036,20 @@ namespace AutoBidder.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva risultato asta
|
||||
/// Salva risultato asta con dati completi per analytics
|
||||
/// </summary>
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna o inserisce statistiche aggregate per un prodotto
|
||||
/// </summary>
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene statistiche per un prodotto specifico
|
||||
/// </summary>
|
||||
public async Task<ProductStatisticsRecord?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i risultati aste per un prodotto specifico
|
||||
/// </summary>
|
||||
public async Task<List<AuctionResultExtended>> 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<AuctionResultExtended>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i prodotti con statistiche
|
||||
/// </summary>
|
||||
public async Task<List<ProductStatisticsRecord>> 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<ProductStatisticsRecord>();
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene statistiche giornaliere per un range di date
|
||||
/// </summary>
|
||||
@@ -599,19 +1339,20 @@ namespace AutoBidder.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene risultati aste recenti
|
||||
/// Ottiene risultati aste recenti con campi estesi per analytics
|
||||
/// </summary>
|
||||
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
|
||||
public async Task<List<AuctionResultExtended>> 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<AuctionResult>();
|
||||
var results = new List<AuctionResultExtended>();
|
||||
|
||||
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<SqliteConnection, Task> _execute;
|
||||
private string? _lastSqlExecuted;
|
||||
|
||||
public Migration(int version, string description, Func<SqliteConnection, Task> execute)
|
||||
{
|
||||
@@ -662,9 +1391,26 @@ namespace AutoBidder.Services
|
||||
}
|
||||
|
||||
public async Task Execute(SqliteConnection 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
340
Mimante/Services/ProductStatisticsService.cs
Normal file
340
Mimante/Services/ProductStatisticsService.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati.
|
||||
/// L'algoritmo analizza le aste storiche per determinare i parametri ottimali.
|
||||
/// </summary>
|
||||
public class ProductStatisticsService
|
||||
{
|
||||
private readonly DatabaseService _db;
|
||||
|
||||
public ProductStatisticsService(DatabaseService db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera una chiave univoca normalizzata per raggruppare prodotti simili.
|
||||
/// Rimuove varianti, numeri di serie, colori ecc.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola i limiti consigliati basandosi sui dati storici
|
||||
/// </summary>
|
||||
public RecommendedLimits CalculateRecommendedLimits(List<AutoBidder.Models.AuctionResultExtended> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcola statistiche aggregate per ogni fascia oraria
|
||||
/// </summary>
|
||||
private List<HourlyStats> CalculateHourlyStats(List<AutoBidder.Models.AuctionResultExtended> results)
|
||||
{
|
||||
var stats = new List<HourlyStats>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le statistiche per un prodotto
|
||||
/// </summary>
|
||||
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
|
||||
{
|
||||
return await _db.GetProductStatisticsAsync(productKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i prodotti con statistiche
|
||||
/// </summary>
|
||||
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
|
||||
{
|
||||
return await _db.GetAllProductStatisticsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene i limiti consigliati per un prodotto
|
||||
/// </summary>
|
||||
public async Task<RecommendedLimits?> 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<double> 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<double> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class StatsService
|
||||
{
|
||||
private readonly DatabaseService _db;
|
||||
private readonly PostgresStatsContext? _postgresDb;
|
||||
private readonly bool _postgresAvailable;
|
||||
|
||||
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null)
|
||||
/// <summary>
|
||||
/// Indica se le statistiche sono disponibili (database SQLite funzionante)
|
||||
/// </summary>
|
||||
public bool IsAvailable => _db.IsAvailable && _db.IsInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Messaggio di errore se le statistiche non sono disponibili
|
||||
/// </summary>
|
||||
public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null;
|
||||
|
||||
/// <summary>
|
||||
/// Path del database SQLite
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva asta conclusa su PostgreSQL
|
||||
/// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
|
||||
/// </summary>
|
||||
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings)
|
||||
private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
|
||||
{
|
||||
if (_postgresDb == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var completedAuction = new CompletedAuction
|
||||
using var httpClient = new HttpClient();
|
||||
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
// 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");
|
||||
|
||||
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
|
||||
|
||||
var html = await httpClient.GetStringAsync(auctionUrl);
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
// Aggiorna metriche giornaliere
|
||||
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
|
||||
|
||||
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
|
||||
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 save auction: {ex.Message}");
|
||||
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna statistiche prodotto in PostgreSQL
|
||||
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
|
||||
/// </summary>
|
||||
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice)
|
||||
private int? ExtractBidsUsedFromHtml(string html)
|
||||
{
|
||||
if (_postgresDb == null) return;
|
||||
if (string.IsNullOrEmpty(html)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var productKey = GenerateProductKey(auction.Name);
|
||||
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey);
|
||||
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
|
||||
var match = System.Text.RegularExpressions.Regex.Match(html,
|
||||
"class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (stat == null)
|
||||
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
|
||||
{
|
||||
stat = new ProductStatistic
|
||||
{
|
||||
ProductKey = productKey,
|
||||
ProductName = auction.Name,
|
||||
TotalAuctions = 0,
|
||||
MinBidsSeen = int.MaxValue,
|
||||
MaxBidsSeen = 0,
|
||||
CompetitionLevel = "Medium"
|
||||
};
|
||||
_postgresDb.ProductStatistics.Add(stat);
|
||||
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
|
||||
return val1;
|
||||
}
|
||||
|
||||
stat.TotalAuctions++;
|
||||
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
|
||||
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
|
||||
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
|
||||
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (won)
|
||||
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
|
||||
{
|
||||
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
|
||||
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
|
||||
return val2;
|
||||
}
|
||||
|
||||
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;
|
||||
// 3) Fallbacks
|
||||
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// 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)
|
||||
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
|
||||
Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}");
|
||||
return val3;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra il completamento di un'asta (overload semplificato per compatibilità)
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna metriche giornaliere in PostgreSQL
|
||||
/// Ottiene i limiti consigliati per un prodotto
|
||||
/// </summary>
|
||||
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings)
|
||||
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
|
||||
{
|
||||
if (_postgresDb == null) return;
|
||||
if (_productStatsService == null) return null;
|
||||
|
||||
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}");
|
||||
}
|
||||
var productKey = ProductStatisticsService.GenerateProductKey(productName);
|
||||
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera chiave univoca per prodotto
|
||||
/// Ottiene tutte le statistiche prodotto
|
||||
/// </summary>
|
||||
private string GenerateProductKey(string productName)
|
||||
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
|
||||
{
|
||||
var normalized = productName.ToLowerInvariant()
|
||||
.Replace(" ", "_")
|
||||
.Replace("-", "_");
|
||||
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
|
||||
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
|
||||
return await _productStatsService.GetAllProductStatisticsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene raccomandazioni strategiche da PostgreSQL
|
||||
/// </summary>
|
||||
public async Task<List<StrategicInsight>> GetStrategicInsightsAsync(string? productKey = null)
|
||||
{
|
||||
if (!_postgresAvailable || _postgresDb == null)
|
||||
{
|
||||
return new List<StrategicInsight>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = _postgresDb.StrategicInsights.Where(i => i.IsActive);
|
||||
|
||||
if (!string.IsNullOrEmpty(productKey))
|
||||
{
|
||||
query = query.Where(i => i.ProductKey == productKey);
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
|
||||
return new List<StrategicInsight>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene performance puntatori da PostgreSQL
|
||||
/// </summary>
|
||||
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
|
||||
{
|
||||
if (!_postgresAvailable || _postgresDb == null)
|
||||
{
|
||||
return new List<BidderPerformance>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _postgresDb.BidderPerformances
|
||||
.OrderByDescending(b => b.WinRate)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
|
||||
return new List<BidderPerformance>();
|
||||
}
|
||||
}
|
||||
|
||||
// Metodi esistenti per compatibilità SQLite
|
||||
// Metodi per query statistiche
|
||||
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return new List<DailyStat>();
|
||||
}
|
||||
|
||||
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<TotalStats> GetTotalStatsAsync()
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return new TotalStats();
|
||||
}
|
||||
|
||||
var stats = await GetDailyStatsAsync(365);
|
||||
|
||||
return new TotalStats
|
||||
@@ -338,13 +366,23 @@ namespace AutoBidder.Services
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
|
||||
public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return new List<AuctionResultExtended>();
|
||||
}
|
||||
|
||||
return await _db.GetRecentAuctionResultsAsync(limit);
|
||||
}
|
||||
|
||||
public async Task<double> CalculateROIAsync()
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var stats = await GetTotalStatsAsync();
|
||||
|
||||
if (stats.TotalMoneySpent <= 0)
|
||||
@@ -355,11 +393,22 @@ namespace AutoBidder.Services
|
||||
|
||||
public async Task<ChartData> GetChartDataAsync(int days = 30)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return new ChartData
|
||||
{
|
||||
Labels = new List<string>(),
|
||||
MoneySpent = new List<double>(),
|
||||
Savings = new List<double>()
|
||||
};
|
||||
}
|
||||
|
||||
var stats = await GetDailyStatsAsync(days);
|
||||
|
||||
var allDates = new List<DailyStat>();
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indica se il database PostgreSQL è disponibile
|
||||
/// </summary>
|
||||
public bool IsPostgresAvailable => _postgresAvailable;
|
||||
}
|
||||
|
||||
// Classi esistenti per compatibilità
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
<span>Esplora Aste</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink class="nav-menu-item" href="freebids">
|
||||
<i class="bi bi-gift"></i>
|
||||
<span>Puntate Gratuite</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink class="nav-menu-item" href="statistics">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>Statistiche</span>
|
||||
|
||||
@@ -70,27 +70,6 @@ namespace AutoBidder.Utilities
|
||||
/// Default: "Normal" (uso giornaliero - errori e warning)
|
||||
/// </summary>
|
||||
public string MinLogLevel { get; set; } = "Normal";
|
||||
|
||||
// CONFIGURAZIONE DATABASE POSTGRESQL
|
||||
/// <summary>
|
||||
/// Abilita l'uso di PostgreSQL per statistiche avanzate
|
||||
/// </summary>
|
||||
public bool UsePostgreSQL { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Connection string PostgreSQL
|
||||
/// </summary>
|
||||
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password";
|
||||
|
||||
/// <summary>
|
||||
/// Auto-crea schema database se mancante
|
||||
/// </summary>
|
||||
public bool AutoCreateDatabaseSchema { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback automatico a SQLite se PostgreSQL non disponibile
|
||||
/// </summary>
|
||||
public bool FallbackToSQLite { get; set; } = true;
|
||||
}
|
||||
|
||||
public static class SettingsManager
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user