Compare commits
4 Commits
865bfa2752
...
docker
| Author | SHA1 | Date | |
|---|---|---|---|
| 77eb9943d0 | |||
| a0ec72f6c0 | |||
| 21a1d57cab | |||
| 2833cd0487 |
@@ -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=
|
||||
|
||||
28488
Mimante/Examples/it.bidoo.com - Scorrimento.har
Normal file
28488
Mimante/Examples/it.bidoo.com - Scorrimento.har
Normal file
File diff suppressed because one or more lines are too long
@@ -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,8 @@
|
||||
@using AutoBidder.Services
|
||||
@inject BidooBrowserService BrowserService
|
||||
@inject ApplicationStateService AppState
|
||||
@inject AuctionMonitor AuctionMonitor
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Esplora Aste - AutoBidder</PageTitle>
|
||||
@@ -26,9 +28,9 @@
|
||||
</button>
|
||||
@if (auctions.Count > 0)
|
||||
{
|
||||
<button class="btn btn-outline-primary" @onclick="UpdateAuctionStates" disabled="@isUpdatingStates">
|
||||
<i class="bi @(isUpdatingStates ? "bi-broadcast spin" : "bi-broadcast")"></i>
|
||||
Aggiorna Prezzi
|
||||
<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>
|
||||
@@ -81,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)
|
||||
{
|
||||
@@ -101,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">
|
||||
@@ -115,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 -->
|
||||
@@ -195,10 +242,22 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="auction-actions">
|
||||
<div class="d-flex gap-1 mb-1">
|
||||
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
|
||||
@onclick="() => CopyAuctionLink(auction)"
|
||||
title="Copia link">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
|
||||
@onclick="() => OpenAuctionInNewTab(auction)"
|
||||
title="Apri in nuova scheda">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (auction.IsMonitored)
|
||||
{
|
||||
<button class="btn btn-success btn-sm w-100" disabled>
|
||||
<i class="bi bi-check-lg me-1"></i>Monitorata
|
||||
<button class="btn btn-warning btn-sm w-100" @onclick="() => RemoveFromMonitor(auction)">
|
||||
<i class="bi bi-dash-lg me-1"></i>Togli dal Monitor
|
||||
</button>
|
||||
}
|
||||
else
|
||||
@@ -233,19 +292,23 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<BidooCategoryInfo> categories = new();
|
||||
private List<BidooBrowserAuction> auctions = new();
|
||||
private int selectedCategoryIndex = 0;
|
||||
private int currentPage = 0;
|
||||
private List<BidooCategoryInfo> categories = new();
|
||||
private List<BidooBrowserAuction> auctions = new();
|
||||
private List<BidooBrowserAuction> filteredAuctions = new();
|
||||
private int selectedCategoryIndex = 0;
|
||||
private int currentPage = 0;
|
||||
|
||||
private bool isLoading = false;
|
||||
private bool isLoadingMore = false;
|
||||
private bool isUpdatingStates = false;
|
||||
private bool canLoadMore = true;
|
||||
private string? errorMessage = null;
|
||||
private bool isLoading = false;
|
||||
private bool isLoadingMore = false;
|
||||
private bool canLoadMore = true;
|
||||
private string? errorMessage = null;
|
||||
|
||||
private System.Threading.Timer? stateUpdateTimer;
|
||||
private CancellationTokenSource? cts;
|
||||
// ? NUOVO: Ricerca
|
||||
private string searchQuery = "";
|
||||
|
||||
private System.Threading.Timer? stateUpdateTimer;
|
||||
private CancellationTokenSource? cts;
|
||||
private bool isUpdatingInBackground = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -256,14 +319,14 @@
|
||||
await LoadAuctions();
|
||||
}
|
||||
|
||||
// Auto-update states every 5 seconds
|
||||
// Auto-update states every 500ms for real-time price updates
|
||||
stateUpdateTimer = new System.Threading.Timer(async _ =>
|
||||
{
|
||||
if (auctions.Count > 0 && !isUpdatingStates)
|
||||
if (auctions.Count > 0 && !isUpdatingInBackground)
|
||||
{
|
||||
await UpdateAuctionStatesBackground();
|
||||
}
|
||||
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||
}, null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
private async Task LoadCategories()
|
||||
@@ -313,6 +376,9 @@
|
||||
{
|
||||
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
|
||||
}
|
||||
|
||||
// ? NUOVO: Applica filtro ricerca
|
||||
ApplySearchFilter();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -329,21 +395,63 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ? NUOVO: Metodo per applicare il filtro di ricerca
|
||||
private void ApplySearchFilter()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
filteredAuctions = auctions.ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
var query = searchQuery.ToLowerInvariant().Trim();
|
||||
|
||||
filteredAuctions = auctions.Where(a =>
|
||||
// Cerca nel nome
|
||||
a.Name.ToLowerInvariant().Contains(query) ||
|
||||
// Cerca nel prezzo corrente
|
||||
a.CurrentPrice.ToString("F2").Contains(query) ||
|
||||
// Cerca nel prezzo buy-now
|
||||
(a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) ||
|
||||
// Cerca nel nome dell'ultimo puntatore
|
||||
(!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) ||
|
||||
// Cerca nell'ID asta
|
||||
a.AuctionId.Contains(query)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
// ? NUOVO: Callback quando cambia la ricerca
|
||||
private void OnSearchChanged()
|
||||
{
|
||||
ApplySearchFilter();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// ? NUOVO: Pulisce la ricerca
|
||||
private void ClearSearch()
|
||||
{
|
||||
searchQuery = "";
|
||||
ApplySearchFilter();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadMoreAuctions()
|
||||
{
|
||||
if (categories.Count == 0 || selectedCategoryIndex < 0)
|
||||
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
|
||||
return;
|
||||
|
||||
isLoadingMore = true;
|
||||
currentPage++;
|
||||
cts?.Cancel();
|
||||
cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
var category = categories[selectedCategoryIndex];
|
||||
var newAuctions = await BrowserService.GetAuctionsAsync(category, currentPage, cts.Token);
|
||||
var existingIds = auctions.Select(a => a.AuctionId).ToList();
|
||||
|
||||
// Usa GetMoreAuctionsAsync che evita duplicati
|
||||
var newAuctions = await BrowserService.GetMoreAuctionsAsync(category, existingIds, cts.Token);
|
||||
|
||||
if (newAuctions.Count == 0)
|
||||
{
|
||||
@@ -353,13 +461,17 @@
|
||||
{
|
||||
auctions.AddRange(newAuctions);
|
||||
UpdateMonitoredStatus();
|
||||
|
||||
// Aggiorna stati delle nuove aste
|
||||
await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token);
|
||||
|
||||
// ? NUOVO: Riapplica filtro dopo caricamento
|
||||
ApplySearchFilter();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Browser] Error loading more auctions: {ex.Message}");
|
||||
currentPage--; // Rollback
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -368,25 +480,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAuctionStates()
|
||||
{
|
||||
if (auctions.Count == 0) return;
|
||||
|
||||
isUpdatingStates = true;
|
||||
try
|
||||
{
|
||||
await BrowserService.UpdateAuctionStatesAsync(auctions);
|
||||
UpdateMonitoredStatus();
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUpdatingStates = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAuctionStatesBackground()
|
||||
{
|
||||
if (isUpdatingInBackground) return;
|
||||
|
||||
isUpdatingInBackground = true;
|
||||
try
|
||||
{
|
||||
await BrowserService.UpdateAuctionStatesAsync(auctions);
|
||||
@@ -397,6 +495,10 @@
|
||||
{
|
||||
// Ignore background errors
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUpdatingInBackground = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAll()
|
||||
@@ -408,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();
|
||||
@@ -421,26 +532,92 @@
|
||||
{
|
||||
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,
|
||||
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();
|
||||
}
|
||||
|
||||
private void RemoveFromMonitor(BidooBrowserAuction browserAuction)
|
||||
{
|
||||
if (!browserAuction.IsMonitored) return;
|
||||
|
||||
// Trova l'asta nel monitor
|
||||
var auctionToRemove = AppState.Auctions.FirstOrDefault(a => a.AuctionId == browserAuction.AuctionId);
|
||||
if (auctionToRemove != null)
|
||||
{
|
||||
AppState.RemoveAuction(auctionToRemove);
|
||||
browserAuction.IsMonitored = false;
|
||||
|
||||
// Save to disk
|
||||
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CopyAuctionLink(BidooBrowserAuction auction)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", auction.Url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Browser] Error copying link: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenAuctionInNewTab(BidooBrowserAuction auction)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("window.open", auction.Url, "_blank");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Browser] Error opening link: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stateUpdateTimer?.Dispose();
|
||||
|
||||
@@ -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,18 +230,17 @@
|
||||
<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>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 info-group">
|
||||
<div class="col-md-12 info-group">
|
||||
<label><i class="bi bi-speedometer2"></i> Anticipo (ms):</label>
|
||||
<input type="number" class="form-control" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" />
|
||||
</div>
|
||||
<div class="col-md-6 info-group">
|
||||
<label><i class="bi bi-hand-index-thumb"></i> Max Click:</label>
|
||||
<input type="number" class="form-control" @bind="selectedAuction.MaxClicks" @bind:after="SaveAuctions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -272,6 +271,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>
|
||||
|
||||
@@ -368,7 +392,10 @@
|
||||
<!-- TAB STORIA PUNTATE -->
|
||||
<div class="tab-pane fade" id="content-history" role="tabpanel">
|
||||
<div class="tab-panel-content">
|
||||
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
|
||||
@{
|
||||
var recentBidsList = GetRecentBidsSafe(selectedAuction);
|
||||
}
|
||||
@if (recentBidsList.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
@@ -381,7 +408,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var bid in selectedAuction.RecentBids.Take(50))
|
||||
@foreach (var bid in recentBidsList.Take(50))
|
||||
{
|
||||
<tr class="@(bid.IsMyBid ? "table-success" : "")">
|
||||
<td>
|
||||
@@ -412,17 +439,20 @@
|
||||
<!-- TAB PUNTATORI -->
|
||||
<div class="tab-pane fade" id="content-bidders" role="tabpanel">
|
||||
<div class="tab-panel-content">
|
||||
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
|
||||
@{
|
||||
// Crea una copia thread-safe per evitare modifiche durante l'enumerazione
|
||||
var recentBidsCopy = GetRecentBidsSafe(selectedAuction);
|
||||
}
|
||||
@if (recentBidsCopy.Any())
|
||||
{
|
||||
// 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)
|
||||
@@ -544,8 +574,3 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Versione in basso a destra -->
|
||||
<div class="version-badge">
|
||||
<i class="bi bi-box-seam"></i> v1.0.0
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -40,6 +41,11 @@ namespace AutoBidder.Pages
|
||||
private int sessionRemainingBids;
|
||||
private double sessionShopCredit;
|
||||
private int sessionAuctionsWon;
|
||||
|
||||
// Recommended limits
|
||||
private bool isLoadingRecommendations = false;
|
||||
private string? recommendationMessage = null;
|
||||
private bool recommendationSuccess = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -379,7 +385,6 @@ namespace AutoBidder.Pages
|
||||
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
|
||||
MinPrice = settings.DefaultMinPrice,
|
||||
MaxPrice = settings.DefaultMaxPrice,
|
||||
MaxClicks = settings.DefaultMaxClicks,
|
||||
MinResets = settings.DefaultMinResets,
|
||||
MaxResets = settings.DefaultMaxResets,
|
||||
IsActive = isActive,
|
||||
@@ -631,6 +636,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)
|
||||
{
|
||||
@@ -639,31 +657,98 @@ namespace AutoBidder.Pages
|
||||
|
||||
private string GetStatusBadgeClass(AuctionInfo auction)
|
||||
{
|
||||
if (!auction.IsActive) return "bg-secondary";
|
||||
if (auction.IsPaused) return "bg-warning text-dark";
|
||||
return "bg-success";
|
||||
// Prima controlla lo stato real-time dell'asta (da LastState)
|
||||
if (auction.LastState != null)
|
||||
{
|
||||
return auction.LastState.Status switch
|
||||
{
|
||||
AuctionStatus.EndedWon => "status-won",
|
||||
AuctionStatus.EndedLost => "status-lost",
|
||||
AuctionStatus.Closed => "status-closed",
|
||||
AuctionStatus.Paused => "status-system-paused",
|
||||
AuctionStatus.Pending => "status-pending",
|
||||
AuctionStatus.Scheduled => "status-scheduled",
|
||||
AuctionStatus.NotStarted => "status-scheduled",
|
||||
_ => GetUserControlStatusClass(auction)
|
||||
};
|
||||
}
|
||||
|
||||
return GetUserControlStatusClass(auction);
|
||||
}
|
||||
|
||||
private string GetUserControlStatusClass(AuctionInfo auction)
|
||||
{
|
||||
// Stati controllati dall'utente
|
||||
if (!auction.IsActive) return "status-stopped";
|
||||
if (auction.IsPaused) return "status-paused";
|
||||
if (auction.IsAttackInProgress) return "status-attacking";
|
||||
return "status-active";
|
||||
}
|
||||
|
||||
private string GetStatusText(AuctionInfo auction)
|
||||
{
|
||||
if (!auction.IsActive) return "Fermo";
|
||||
// Prima controlla lo stato real-time dell'asta
|
||||
if (auction.LastState != null)
|
||||
{
|
||||
switch (auction.LastState.Status)
|
||||
{
|
||||
case AuctionStatus.EndedWon:
|
||||
return "Vinta!";
|
||||
case AuctionStatus.EndedLost:
|
||||
return "Persa";
|
||||
case AuctionStatus.Closed:
|
||||
return "Chiusa";
|
||||
case AuctionStatus.Paused:
|
||||
return "Sospesa";
|
||||
case AuctionStatus.Pending:
|
||||
return "In Attesa";
|
||||
case AuctionStatus.Scheduled:
|
||||
case AuctionStatus.NotStarted:
|
||||
return "Programmata";
|
||||
}
|
||||
}
|
||||
|
||||
// Stati controllati dall'utente
|
||||
if (!auction.IsActive) return "Fermata";
|
||||
if (auction.IsPaused) return "Pausa";
|
||||
return "Attivo";
|
||||
if (auction.IsAttackInProgress) return "Puntando";
|
||||
return "Attiva";
|
||||
}
|
||||
|
||||
private string GetStatusIcon(AuctionInfo auction)
|
||||
{
|
||||
// Usa icone Bootstrap Icons invece di emoji
|
||||
// Prima controlla lo stato real-time dell'asta
|
||||
if (auction.LastState != null)
|
||||
{
|
||||
switch (auction.LastState.Status)
|
||||
{
|
||||
case AuctionStatus.EndedWon:
|
||||
return "<i class='bi bi-trophy-fill'></i>";
|
||||
case AuctionStatus.EndedLost:
|
||||
return "<i class='bi bi-x-circle-fill'></i>";
|
||||
case AuctionStatus.Closed:
|
||||
return "<i class='bi bi-lock-fill'></i>";
|
||||
case AuctionStatus.Paused:
|
||||
return "<i class='bi bi-moon-fill'></i>";
|
||||
case AuctionStatus.Pending:
|
||||
return "<i class='bi bi-hourglass-split'></i>";
|
||||
case AuctionStatus.Scheduled:
|
||||
case AuctionStatus.NotStarted:
|
||||
return "<i class='bi bi-calendar-event'></i>";
|
||||
}
|
||||
}
|
||||
|
||||
// Stati controllati dall'utente
|
||||
if (!auction.IsActive) return "<i class='bi bi-stop-circle'></i>";
|
||||
if (auction.IsPaused) return "<i class='bi bi-pause-circle'></i>";
|
||||
if (auction.IsAttackInProgress) return "<i class='bi bi-lightning-charge-fill'></i>";
|
||||
return "<i class='bi bi-play-circle-fill'></i>";
|
||||
}
|
||||
|
||||
private string GetStatusAnimationClass(AuctionInfo auction)
|
||||
{
|
||||
if (!auction.IsActive) return "";
|
||||
if (auction.IsPaused) return "status-paused";
|
||||
return "status-active";
|
||||
// Animazioni disabilitate - i colori sono sufficienti per identificare lo stato
|
||||
return "";
|
||||
}
|
||||
|
||||
private string GetPriceDisplay(AuctionInfo? auction)
|
||||
@@ -890,6 +975,29 @@ namespace AutoBidder.Pages
|
||||
return auction.AuctionLog.TakeLast(50);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene una copia thread-safe della lista RecentBids
|
||||
/// </summary>
|
||||
private List<BidHistoryEntry> GetRecentBidsSafe(AuctionInfo? auction)
|
||||
{
|
||||
if (auction?.RecentBids == null)
|
||||
return new List<BidHistoryEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
// Lock per evitare modifiche durante la copia
|
||||
lock (auction.RecentBids)
|
||||
{
|
||||
return auction.RecentBids.ToList();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback in caso di errore
|
||||
return new List<BidHistoryEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLogEntryClass(LogEntry logEntry)
|
||||
{
|
||||
try
|
||||
@@ -1005,5 +1113,65 @@ 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;
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
@@ -209,73 +209,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONFIGURAZIONE DATABASE -->
|
||||
<!-- DATABASE MANAGEMENT -->
|
||||
<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
|
||||
<h2 class="accordion-header" id="heading-database">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-database" aria-expanded="false" aria-controls="collapse-database">
|
||||
<i class="bi bi-database-fill me-2"></i> Gestione Database
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-db" class="accordion-collapse collapse" aria-labelledby="heading-db" data-bs-parent="#settingsAccordion">
|
||||
<div id="collapse-database" class="accordion-collapse collapse" aria-labelledby="heading-database" 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.
|
||||
<!-- Impostazioni Auto-Salvataggio -->
|
||||
<h6 class="fw-bold mb-3"><i class="bi bi-floppy"></i> Salvataggio Automatico</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="dbAutoSave" @bind="settings.DatabaseAutoSaveEnabled" />
|
||||
<label class="form-check-label" for="dbAutoSave">
|
||||
<strong>Salva aste completate automaticamente</strong>
|
||||
<div class="form-text">Salva statistiche nel database quando un'asta termina (consigliato)</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-bold"><i class="bi bi-calendar-range"></i> Durata conservazione (giorni)</label>
|
||||
<input type="number" class="form-control" @bind="settings.DatabaseMaxRetentionDays" min="0" />
|
||||
<div class="form-text">0 = mantieni tutto | 180 = 6 mesi (consigliato)</div>
|
||||
</div>
|
||||
</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>
|
||||
<!-- Pulizia Automatica -->
|
||||
<h6 class="fw-bold mb-3"><i class="bi bi-brush"></i> Pulizia Automatica all'Avvio</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="dbAutoCleanDup" @bind="settings.DatabaseAutoCleanupDuplicates" />
|
||||
<label class="form-check-label" for="dbAutoCleanDup">
|
||||
<strong>Rimuovi duplicati automaticamente</strong>
|
||||
<div class="form-text">Elimina record duplicati all'avvio (consigliato)</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="dbAutoCleanInc" @bind="settings.DatabaseAutoCleanupIncomplete" />
|
||||
<label class="form-check-label" for="dbAutoCleanInc">
|
||||
<strong>Rimuovi dati incompleti automaticamente</strong>
|
||||
<div class="form-text">Elimina record con dati invalidi (prezzi negativi, ecc.)</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (settings.UsePostgreSQL)
|
||||
<!-- Statistiche e Manutenzione Manuale -->
|
||||
<h6 class="fw-bold mb-3"><i class="bi bi-tools"></i> Manutenzione Manuale</h6>
|
||||
<div class="alert alert-info border-0 shadow-sm mb-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<strong>Duplicati:</strong>
|
||||
<div class="fs-4 fw-bold text-warning">@dbDuplicatesCount</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<strong>Incompleti:</strong>
|
||||
<div class="fs-4 fw-bold text-danger">@dbIncompleteCount</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-md-end">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshDbStats" disabled="@isLoadingDbStats">
|
||||
@if (isLoadingDbStats)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
}
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-warning text-dark" @onclick="CleanupDuplicates" disabled="@(isCleaningDb || dbDuplicatesCount == 0)">
|
||||
<i class="bi bi-trash"></i> Rimuovi Duplicati (@dbDuplicatesCount)
|
||||
</button>
|
||||
<button class="btn btn-danger" @onclick="CleanupIncomplete" disabled="@(isCleaningDb || dbIncompleteCount == 0)">
|
||||
<i class="bi bi-trash"></i> Rimuovi Incompleti (@dbIncompleteCount)
|
||||
</button>
|
||||
<button class="btn btn-primary" @onclick="CleanupDatabase" disabled="@isCleaningDb">
|
||||
<i class="bi bi-stars"></i> Pulizia Completa
|
||||
</button>
|
||||
<button class="btn btn-info text-white" @onclick="OptimizeDatabase" disabled="@isCleaningDb">
|
||||
<i class="bi bi-lightning-charge"></i> Ottimizza (VACUUM)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(dbCleanupMessage))
|
||||
{
|
||||
<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 class="alert @(dbCleanupSuccess ? "alert-success" : "alert-warning") border-0 shadow-sm mt-3">
|
||||
<i class="bi @(dbCleanupSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
|
||||
@dbCleanupMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-secondary text-white" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva</button>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-success" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva Impostazioni</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INFORMAZIONI APPLICAZIONE -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading-info">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-info" aria-expanded="false" aria-controls="collapse-info">
|
||||
<i class="bi bi-info-circle-fill me-2"></i> Informazioni Applicazione
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-info" class="accordion-collapse collapse" aria-labelledby="heading-info" data-bs-parent="#settingsAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Versione</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-box-seam text-primary me-2" style="font-size: 1.5rem;"></i>
|
||||
<span class="fs-4 fw-bold">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Ambiente</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-code-slash text-success me-2" style="font-size: 1.5rem;"></i>
|
||||
<span class="fs-4 fw-bold">.NET 8</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Informazioni Sistema</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted">Database:</small>
|
||||
<div class="fw-bold">
|
||||
@if (DbService.IsAvailable)
|
||||
{
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill"></i> Operativo</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> Non disponibile</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted">Sessione:</small>
|
||||
<div class="fw-bold">
|
||||
@if (!string.IsNullOrEmpty(currentUsername))
|
||||
{
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill"></i> @currentUsername</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-warning"><i class="bi bi-exclamation-circle-fill"></i> Non connesso</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,21 +409,17 @@
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string startupLoadMode = "Stopped";
|
||||
private string startupLoadMode = "Stopped";
|
||||
|
||||
private string? currentUsername;
|
||||
private int remainingBids;
|
||||
private string cookieInput = "";
|
||||
private string usernameInput = "";
|
||||
private string? connectionError;
|
||||
private bool isConnecting;
|
||||
private string? currentUsername;
|
||||
private int remainingBids;
|
||||
private string cookieInput = "";
|
||||
private string usernameInput = "";
|
||||
private string? connectionError;
|
||||
private bool isConnecting;
|
||||
|
||||
private bool isTestingConnection;
|
||||
private string? dbTestResult;
|
||||
private bool dbTestSuccess;
|
||||
|
||||
private AutoBidder.Utilities.AppSettings settings = new();
|
||||
private System.Threading.Timer? updateTimer;
|
||||
private AutoBidder.Utilities.AppSettings settings = new();
|
||||
private System.Threading.Timer? updateTimer;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -492,55 +597,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";
|
||||
@@ -548,6 +604,159 @@
|
||||
return "bg-success";
|
||||
}
|
||||
|
||||
// ???????????????????????????????????????????????????????????????
|
||||
// DATABASE MANAGEMENT
|
||||
// ???????????????????????????????????????????????????????????????
|
||||
|
||||
[Inject] private DatabaseService DbService { get; set; } = default!;
|
||||
|
||||
private int dbDuplicatesCount = 0;
|
||||
private int dbIncompleteCount = 0;
|
||||
private bool isLoadingDbStats = false;
|
||||
private bool isCleaningDb = false;
|
||||
private string? dbCleanupMessage = null;
|
||||
private bool dbCleanupSuccess = false;
|
||||
|
||||
private async Task RefreshDbStats()
|
||||
{
|
||||
if (!DbService.IsAvailable) return;
|
||||
|
||||
isLoadingDbStats = true;
|
||||
dbCleanupMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
dbDuplicatesCount = await DbService.CountDuplicateAuctionResultsAsync();
|
||||
dbIncompleteCount = await DbService.CountIncompleteAuctionResultsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dbCleanupMessage = $"Errore: {ex.Message}";
|
||||
dbCleanupSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoadingDbStats = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupDuplicates()
|
||||
{
|
||||
if (!DbService.IsAvailable) return;
|
||||
|
||||
isCleaningDb = true;
|
||||
dbCleanupMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var removed = await DbService.RemoveDuplicateAuctionResultsAsync();
|
||||
dbCleanupMessage = $"? Rimossi {removed} record duplicati";
|
||||
dbCleanupSuccess = true;
|
||||
await RefreshDbStats();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dbCleanupMessage = $"Errore: {ex.Message}";
|
||||
dbCleanupSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCleaningDb = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupIncomplete()
|
||||
{
|
||||
if (!DbService.IsAvailable) return;
|
||||
|
||||
isCleaningDb = true;
|
||||
dbCleanupMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var removed = await DbService.RemoveIncompleteAuctionResultsAsync();
|
||||
dbCleanupMessage = $"? Rimossi {removed} record incompleti";
|
||||
dbCleanupSuccess = true;
|
||||
await RefreshDbStats();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dbCleanupMessage = $"Errore: {ex.Message}";
|
||||
dbCleanupSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCleaningDb = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupDatabase()
|
||||
{
|
||||
if (!DbService.IsAvailable) return;
|
||||
|
||||
isCleaningDb = true;
|
||||
dbCleanupMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var message = await DbService.CleanupDatabaseAsync();
|
||||
dbCleanupMessage = $"? {message}";
|
||||
dbCleanupSuccess = true;
|
||||
await RefreshDbStats();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dbCleanupMessage = $"Errore: {ex.Message}";
|
||||
dbCleanupSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCleaningDb = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OptimizeDatabase()
|
||||
{
|
||||
if (!DbService.IsAvailable) return;
|
||||
|
||||
isCleaningDb = true;
|
||||
dbCleanupMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await DbService.OptimizeDatabaseAsync();
|
||||
dbCleanupMessage = "? Database ottimizzato (VACUUM eseguito)";
|
||||
dbCleanupSuccess = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dbCleanupMessage = $"Errore: {ex.Message}";
|
||||
dbCleanupSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCleaningDb = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && DbService.IsAvailable)
|
||||
{
|
||||
await RefreshDbStats();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
updateTimer?.Dispose();
|
||||
|
||||
@@ -1,238 +1,236 @@
|
||||
@page "/statistics"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using AutoBidder.Models
|
||||
@using AutoBidder.Services
|
||||
@inject StatsService StatsService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject DatabaseService DatabaseService
|
||||
|
||||
<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 (isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
}
|
||||
Aggiorna
|
||||
</button>
|
||||
@if (StatsService.IsAvailable)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="RefreshStats" disabled="@isLoading">
|
||||
@if (isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
}
|
||||
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)
|
||||
{
|
||||
<!-- 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="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>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asta</th>
|
||||
<th>Prezzo Finale</th>
|
||||
<th>Puntate</th>
|
||||
<th>Risultato</th>
|
||||
<th>Risparmio</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var result in recentResults.Take(10))
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-trophy-fill"></i> Vinta</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary"><i class="bi bi-x-circle"></i> 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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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="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-clock-history me-2"></i>
|
||||
Aste Terminate Recenti
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@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>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 auction in recentAuctions)
|
||||
{
|
||||
<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">? Vinta</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">? Persa</span>
|
||||
}
|
||||
</td>
|
||||
<td><small class="text-muted">@FormatTimestamp(auction.Timestamp)</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@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()
|
||||
{
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
// Carica aste recenti (ultime 50)
|
||||
recentAuctions = await DatabaseService.GetRecentAuctionResultsAsync(50);
|
||||
|
||||
// Carica statistiche
|
||||
totalStats = await StatsService.GetTotalStatsAsync();
|
||||
roi = await StatsService.CalculateROIAsync();
|
||||
recentResults = await StatsService.GetRecentAuctionResultsAsync(20);
|
||||
|
||||
// Render grafici dopo il caricamento
|
||||
if (totalStats != null)
|
||||
{
|
||||
await RenderCharts();
|
||||
}
|
||||
// Carica prodotti con statistiche
|
||||
products = await DatabaseService.GetAllProductStatisticsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Errore caricamento statistiche: {ex.Message}";
|
||||
Console.WriteLine($"[ERROR] Statistics: {ex}");
|
||||
Console.WriteLine($"[Statistics] Error loading data: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -241,26 +239,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderCharts()
|
||||
private string FormatTimestamp(string timestamp)
|
||||
{
|
||||
if (DateTime.TryParse(timestamp, out var dt))
|
||||
{
|
||||
return dt.ToString("dd/MM HH:mm");
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
private async Task ApplyLimitsToProduct(ProductStatisticsRecord product)
|
||||
{
|
||||
try
|
||||
{
|
||||
var chartData = await StatsService.GetChartDataAsync(30);
|
||||
|
||||
// Render grafico spesa
|
||||
await JSRuntime.InvokeVoidAsync("renderMoneyChart",
|
||||
chartData.Labels,
|
||||
chartData.MoneySpent,
|
||||
chartData.Savings);
|
||||
|
||||
// Render grafico wins
|
||||
await JSRuntime.InvokeVoidAsync("renderWinsChart",
|
||||
totalStats!.TotalAuctionsWon,
|
||||
totalStats!.TotalAuctionsLost);
|
||||
// Trova tutte le aste con questo ProductKey nel monitor
|
||||
var matchingAuctions = AppState.Auctions
|
||||
.Where(a => ProductStatisticsService.GenerateProductKey(a.Name) == product.ProductKey)
|
||||
.ToList();
|
||||
|
||||
if (!matchingAuctions.Any())
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Nessuna asta trovata per '{product.ProductName}'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Applica i limiti
|
||||
foreach (var auction in matchingAuctions)
|
||||
{
|
||||
auction.MinPrice = product.RecommendedMinPrice ?? 0;
|
||||
auction.MaxPrice = product.RecommendedMaxPrice ?? 0;
|
||||
auction.MinResets = product.RecommendedMinResets ?? 0;
|
||||
auction.MaxResets = product.RecommendedMaxResets ?? 0;
|
||||
}
|
||||
|
||||
// Salva
|
||||
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("alert",
|
||||
$"? Limiti applicati a {matchingAuctions.Count} aste di '{product.ProductName}'\n\n" +
|
||||
$"Min: €{product.RecommendedMinPrice:F2}\n" +
|
||||
$"Max: €{product.RecommendedMaxPrice:F2}\n" +
|
||||
$"Min Reset: {product.RecommendedMinResets}\n" +
|
||||
$"Max Reset: {product.RecommendedMaxResets}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Render charts: {ex.Message}");
|
||||
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,126 @@ using (var scope = app.Services.CreateScope())
|
||||
// Verifica salute database
|
||||
var isHealthy = await databaseService.CheckDatabaseHealthAsync();
|
||||
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
|
||||
|
||||
// 🔥 MANUTENZIONE AUTOMATICA DATABASE
|
||||
var settings = AutoBidder.Utilities.SettingsManager.Load();
|
||||
|
||||
if (settings.DatabaseAutoCleanupDuplicates)
|
||||
{
|
||||
Console.WriteLine("[DB] Checking for duplicate records...");
|
||||
var duplicateCount = await databaseService.CountDuplicateAuctionResultsAsync();
|
||||
if (duplicateCount > 0)
|
||||
{
|
||||
Console.WriteLine($"[DB] Found {duplicateCount} duplicates - removing...");
|
||||
var removed = await databaseService.RemoveDuplicateAuctionResultsAsync();
|
||||
Console.WriteLine($"[DB] ✓ Removed {removed} duplicate auction results");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[DB] ✓ No duplicates found");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.DatabaseAutoCleanupIncomplete)
|
||||
{
|
||||
Console.WriteLine("[DB] Checking for incomplete records...");
|
||||
var incompleteCount = await databaseService.CountIncompleteAuctionResultsAsync();
|
||||
if (incompleteCount > 0)
|
||||
{
|
||||
Console.WriteLine($"[DB] Found {incompleteCount} incomplete records - removing...");
|
||||
var removed = await databaseService.RemoveIncompleteAuctionResultsAsync();
|
||||
Console.WriteLine($"[DB] ✓ Removed {removed} incomplete auction results");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[DB] ✓ No incomplete records found");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.DatabaseMaxRetentionDays > 0)
|
||||
{
|
||||
Console.WriteLine($"[DB] Checking for records older than {settings.DatabaseMaxRetentionDays} days...");
|
||||
var oldCount = await databaseService.RemoveOldAuctionResultsAsync(settings.DatabaseMaxRetentionDays);
|
||||
if (oldCount > 0)
|
||||
{
|
||||
Console.WriteLine($"[DB] ✓ Removed {oldCount} old auction results");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[DB] ✓ No old records to remove");
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 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>();
|
||||
|
||||
try
|
||||
{
|
||||
// Log percorso database
|
||||
var connection = db.Database.GetDbConnection();
|
||||
Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}");
|
||||
|
||||
// Verifica se database esiste
|
||||
var dbExists = db.Database.CanConnect();
|
||||
Console.WriteLine($"[STATS DB] Database exists: {dbExists}");
|
||||
|
||||
// Forza creazione tabelle se non esistono
|
||||
if (!dbExists || !db.ProductStats.Any())
|
||||
{
|
||||
Console.WriteLine("[STATS DB] Creating database schema...");
|
||||
db.Database.EnsureDeleted(); // Elimina database vecchio
|
||||
db.Database.EnsureCreated(); // Ricrea con schema aggiornato
|
||||
Console.WriteLine("[STATS DB] Database schema created successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}");
|
||||
Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}");
|
||||
|
||||
// Prova a ricreare forzatamente
|
||||
// In caso di errore, esegui sempre la diagnostica
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[STATS DB] Attempting forced recreation...");
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
Console.WriteLine("[STATS DB] Forced recreation successful");
|
||||
await databaseService.RunDatabaseDiagnosticsAsync();
|
||||
}
|
||||
catch (Exception ex2)
|
||||
catch
|
||||
{
|
||||
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
|
||||
// Ignora errori nella diagnostica stessa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inizializza PostgreSQL per statistiche avanzate
|
||||
using (var scope = app.Services.CreateScope())
|
||||
// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
|
||||
{
|
||||
try
|
||||
var dbService = app.Services.GetRequiredService<DatabaseService>();
|
||||
|
||||
|
||||
|
||||
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
|
||||
{
|
||||
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
|
||||
|
||||
if (postgresDb != null)
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
|
||||
Console.WriteLine($"");
|
||||
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
|
||||
Console.WriteLine($"║ [EVENTO] Asta Terminata - Salvataggio Statistiche");
|
||||
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
|
||||
Console.WriteLine($"║ Asta: {auction.Name}");
|
||||
Console.WriteLine($"║ ID: {auction.AuctionId}");
|
||||
Console.WriteLine($"║ Stato: {(won ? "✓ VINTA" : "✗ PERSA")}");
|
||||
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
|
||||
Console.WriteLine($"");
|
||||
|
||||
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
|
||||
// Crea un nuovo scope per StatsService (è Scoped)
|
||||
using var scope = app.Services.CreateScope();
|
||||
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
|
||||
|
||||
if (autoCreateSchema)
|
||||
{
|
||||
// Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica
|
||||
var schemaCreated = await postgresDb.EnsureSchemaAsync();
|
||||
|
||||
if (schemaCreated)
|
||||
{
|
||||
// Valida che tutte le tabelle siano state create
|
||||
var schemaValid = await postgresDb.ValidateSchemaAsync();
|
||||
|
||||
if (schemaValid)
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Schema validation failed");
|
||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Cannot connect to database");
|
||||
Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Auto-create schema disabled");
|
||||
|
||||
// Prova comunque a validare lo schema esistente
|
||||
try
|
||||
{
|
||||
var schemaValid = await postgresDb.ValidateSchemaAsync();
|
||||
if (schemaValid)
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Existing schema validated successfully");
|
||||
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
|
||||
}
|
||||
}
|
||||
await statsService.RecordAuctionCompletedAsync(auction, state, won);
|
||||
|
||||
// ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni
|
||||
Console.WriteLine($"");
|
||||
Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database");
|
||||
Console.WriteLine($"");
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
|
||||
Console.WriteLine($"");
|
||||
Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:");
|
||||
Console.WriteLine($"[EVENTO ERROR] {ex.Message}");
|
||||
Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}");
|
||||
Console.WriteLine($"");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}");
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}");
|
||||
Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback");
|
||||
}
|
||||
};
|
||||
|
||||
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
|
||||
}
|
||||
|
||||
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
|
||||
// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
try
|
||||
@@ -519,15 +430,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 +459,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 +519,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 +530,7 @@ else
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
// Abilita HTTPS redirection solo se HTTPS è configurato
|
||||
// Abilita HTTPS redirection solo se HTTPS è configurato
|
||||
if (enableHttps)
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
@@ -22,6 +22,12 @@ namespace AutoBidder.Services
|
||||
public event Action<AuctionInfo, BidResult>? OnBidExecuted;
|
||||
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()
|
||||
{
|
||||
@@ -101,12 +107,52 @@ namespace AutoBidder.Services
|
||||
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
|
||||
if (auction != null)
|
||||
{
|
||||
// ?? Se l'asta è terminata, salva le statistiche prima di rimuoverla
|
||||
if (!auction.IsActive && auction.LastState != null)
|
||||
{
|
||||
OnLog?.Invoke($"[STATS] Asta terminata rilevata: {auction.Name} - Salvataggio statistiche in corso...");
|
||||
|
||||
try
|
||||
{
|
||||
// Determina se è stata vinta dall'utente
|
||||
var won = IsAuctionWonByUser(auction);
|
||||
|
||||
OnLog?.Invoke($"[STATS] Asta {auction.Name} - Stato: {(won ? "VINTA" : "PERSA")}");
|
||||
|
||||
// Emetti evento per salvare le statistiche
|
||||
// Questo trigger sarà gestito in Program.cs con scraping HTML
|
||||
OnAuctionCompleted?.Invoke(auction, auction.LastState, won);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnLog?.Invoke($"[STATS ERROR] Errore durante salvataggio statistiche per {auction.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
OnLog?.Invoke($"[REMOVE] Rimozione asta non terminata: {auction.Name} (non salvata nelle statistiche)");
|
||||
}
|
||||
|
||||
_auctions.Remove(auction);
|
||||
// ? RIMOSSO: Log ridondante - viene già loggato da MainWindow con più dettagli
|
||||
// OnLog?.Invoke($"[-] Asta rimossa: {auction.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
@@ -115,6 +161,60 @@ namespace AutoBidder.Services
|
||||
return _auctions.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}: MinPrice={minPrice:F2}, MaxPrice={maxPrice:F2}, MinResets={minResets}, MaxResets={maxResets}");
|
||||
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;
|
||||
count++;
|
||||
|
||||
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
OnLog?.Invoke($"[LIMITS] Applicati limiti a {count} aste con productKey={productKey}");
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
@@ -247,6 +347,7 @@ namespace AutoBidder.Services
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
auction.PollingLatencyMs = state.PollingLatencyMs;
|
||||
|
||||
// ? AGGIORNATO: Aggiorna storia puntate mantenendo quelle vecchie
|
||||
@@ -262,7 +363,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 +381,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 +589,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)
|
||||
{
|
||||
@@ -531,14 +651,6 @@ namespace AutoBidder.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
// ??? CONTROLLO 5: MaxClicks
|
||||
int myBidsCount = auction.BidHistory.Count(b => b.EventType == BidEventType.MyBid);
|
||||
if (auction.MaxClicks > 0 && myBidsCount >= auction.MaxClicks)
|
||||
{
|
||||
auction.AddLog($"[CLICKS] Click massimi raggiunti: {myBidsCount} >= Max {auction.MaxClicks}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ?? CONTROLLO 6: Cooldown (evita puntate multiple ravvicinate)
|
||||
if (auction.LastClickAt.HasValue)
|
||||
{
|
||||
|
||||
@@ -220,6 +220,19 @@ namespace AutoBidder.Services
|
||||
auctions = ParseAuctionsFromHtml(html);
|
||||
|
||||
Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}");
|
||||
|
||||
// ?? DEBUG: Verifica quante aste hanno IsCreditAuction = true
|
||||
if (category.IsSpecialCategory && category.TabId == 1)
|
||||
{
|
||||
var creditCount = auctions.Count(a => a.IsCreditAuction);
|
||||
Console.WriteLine($"[BidooBrowser] DEBUG Aste di Puntate: {creditCount}/{auctions.Count} hanno IsCreditAuction=true");
|
||||
|
||||
// Log primi 3 nomi per debug
|
||||
foreach (var a in auctions.Take(3))
|
||||
{
|
||||
Console.WriteLine($"[BidooBrowser] - {a.Name} (ID: {a.AuctionId}, IsCreditAuction: {a.IsCreditAuction})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -440,8 +453,9 @@ namespace AutoBidder.Services
|
||||
|
||||
/// <summary>
|
||||
/// Parsa la risposta di data.php formato LISTID
|
||||
/// Formato: timestamp*(id;status;expiry;price;bidder;timer;countdown#id2;status2;...)
|
||||
/// Esempio: 1769032850*(85583891;OFF;1769019191;62;sederafo30;3;7m#85582947;OFF;1769023093;680;pandaka;3;1h 16m)
|
||||
/// Formato: serverTimestamp*(id;status;expiry;price;;#id2;status2;...)
|
||||
/// Esempio: 1769073106*(85559629;ON;1769082240;1;;#85559630;ON;1769082240;1;;)
|
||||
/// Il timestamp del server viene usato come riferimento per calcolare il tempo rimanente
|
||||
/// </summary>
|
||||
private void ParseListIdResponse(string response, List<BidooBrowserAuction> auctions)
|
||||
{
|
||||
@@ -455,6 +469,11 @@ namespace AutoBidder.Services
|
||||
return;
|
||||
}
|
||||
|
||||
// Estrai il timestamp del server (prima di *)
|
||||
var serverTimestampStr = response.Substring(0, starIndex);
|
||||
long serverTimestamp = 0;
|
||||
long.TryParse(serverTimestampStr, out serverTimestamp);
|
||||
|
||||
var mainData = response.Substring(starIndex + 1);
|
||||
|
||||
// Rimuovi parentesi se presenti
|
||||
@@ -465,20 +484,19 @@ namespace AutoBidder.Services
|
||||
|
||||
// Split per ogni asta (separatore #)
|
||||
var auctionEntries = mainData.Split('#', StringSplitOptions.RemoveEmptyEntries);
|
||||
int updatedCount = 0;
|
||||
|
||||
foreach (var entry in auctionEntries)
|
||||
{
|
||||
// Formato: id;status;expiry;price;bidder;timer;countdown
|
||||
// Formato: id;status;expiry;price;; (bidder e timer possono essere vuoti)
|
||||
var fields = entry.Split(';');
|
||||
if (fields.Length < 5) continue;
|
||||
if (fields.Length < 4) continue;
|
||||
|
||||
var id = fields[0].Trim();
|
||||
var status = fields[1].Trim(); // ON/OFF
|
||||
var expiry = fields[2].Trim(); // timestamp scadenza
|
||||
var priceStr = fields[3].Trim(); // prezzo in centesimi
|
||||
var bidder = fields[4].Trim(); // ultimo bidder
|
||||
var timer = fields.Length > 5 ? fields[5].Trim() : ""; // frequenza timer
|
||||
var countdown = fields.Length > 6 ? fields[6].Trim() : ""; // tempo rimanente (es: "7m", "1h 16m")
|
||||
var expiryStr = fields[2].Trim(); // timestamp scadenza (stesso formato del server)
|
||||
var priceStr = fields[3].Trim(); // prezzo (centesimi)
|
||||
var bidder = fields.Length > 4 ? fields[4].Trim() : ""; // ultimo bidder (può essere vuoto)
|
||||
|
||||
var auction = auctions.FirstOrDefault(a => a.AuctionId == id);
|
||||
if (auction == null) continue;
|
||||
@@ -489,32 +507,36 @@ namespace AutoBidder.Services
|
||||
auction.CurrentPrice = priceCents / 100m;
|
||||
}
|
||||
|
||||
// Aggiorna bidder
|
||||
auction.LastBidder = bidder;
|
||||
|
||||
// Aggiorna timer frequency
|
||||
if (int.TryParse(timer, out int timerFreq) && timerFreq > 0)
|
||||
// Aggiorna bidder solo se non vuoto
|
||||
if (!string.IsNullOrEmpty(bidder))
|
||||
{
|
||||
auction.TimerFrequency = timerFreq;
|
||||
auction.LastBidder = bidder;
|
||||
}
|
||||
|
||||
// Parse countdown per calcolare secondi rimanenti
|
||||
auction.RemainingSeconds = ParseCountdown(countdown, auction.TimerFrequency);
|
||||
// Calcola tempo rimanente usando il timestamp del server come riferimento
|
||||
if (long.TryParse(expiryStr, out long expiryTimestamp) && serverTimestamp > 0)
|
||||
{
|
||||
// Il tempo rimanente è: expiry - serverTime (entrambi nello stesso formato)
|
||||
var remainingSeconds = expiryTimestamp - serverTimestamp;
|
||||
auction.RemainingSeconds = remainingSeconds > 0 ? (int)remainingSeconds : 0;
|
||||
}
|
||||
else if (status == "ON")
|
||||
{
|
||||
// Se non riusciamo a calcolare, usa il timer frequency come fallback
|
||||
if (auction.RemainingSeconds <= 0)
|
||||
{
|
||||
auction.RemainingSeconds = auction.TimerFrequency;
|
||||
}
|
||||
}
|
||||
|
||||
// Status: ON = attiva, OFF = in countdown
|
||||
auction.IsActive = true;
|
||||
auction.IsSold = false;
|
||||
// Status: ON = attiva in countdown, OFF = terminata/in pausa
|
||||
auction.IsActive = status == "ON";
|
||||
auction.IsSold = status != "ON" && auction.RemainingSeconds <= 0;
|
||||
|
||||
// Se countdown contiene "Ha Vinto" o simile, è venduta
|
||||
if (countdown.Contains("Vinto", StringComparison.OrdinalIgnoreCase) ||
|
||||
countdown.Contains("Chiusa", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
auction.IsSold = true;
|
||||
auction.IsActive = false;
|
||||
}
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[BidooBrowser] Aggiornate {auctionEntries.Length} aste");
|
||||
Console.WriteLine($"[BidooBrowser] Aggiornate {updatedCount} aste su {auctionEntries.Length}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -578,5 +600,137 @@ namespace AutoBidder.Services
|
||||
|
||||
return defaultSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica nuove aste usando get_auction_updates.php (simula scrolling infinito)
|
||||
/// Questa API restituisce aste che non sono ancora state caricate
|
||||
/// </summary>
|
||||
public async Task<List<BidooBrowserAuction>> GetMoreAuctionsAsync(
|
||||
BidooCategoryInfo category,
|
||||
List<string> existingAuctionIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var newAuctions = new List<BidooBrowserAuction>();
|
||||
|
||||
try
|
||||
{
|
||||
var existingIdsSet = existingAuctionIds.ToHashSet();
|
||||
|
||||
// Prepara la chiamata POST a get_auction_updates.php
|
||||
var url = "https://it.bidoo.com/get_auction_updates.php";
|
||||
|
||||
// Costruisci il body della richiesta
|
||||
var viewIds = string.Join(",", existingAuctionIds);
|
||||
var tabValue = category.IsSpecialCategory ? category.TabId : 4;
|
||||
var tagValue = category.IsSpecialCategory ? 0 : category.TagId;
|
||||
|
||||
var formContent = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("prefetch", "true"),
|
||||
new KeyValuePair<string, string>("view", viewIds),
|
||||
new KeyValuePair<string, string>("tab", tabValue.ToString()),
|
||||
new KeyValuePair<string, string>("tag", tagValue.ToString())
|
||||
});
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = formContent
|
||||
};
|
||||
|
||||
AddBrowserHeaders(request, "https://it.bidoo.com/");
|
||||
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
|
||||
Console.WriteLine($"[BidooBrowser] Fetching more auctions with {existingAuctionIds.Count} existing IDs...");
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
// Parse la risposta JSON
|
||||
// Formato: {"gc":[],"int":[],"list":[id1,id2,...],"items":["<html>","<html>",...]}
|
||||
newAuctions = ParseGetAuctionUpdatesResponse(responseText, existingIdsSet);
|
||||
|
||||
Console.WriteLine($"[BidooBrowser] Trovate {newAuctions.Count} nuove aste");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[BidooBrowser] Errore caricamento nuove aste: {ex.Message}");
|
||||
}
|
||||
|
||||
return newAuctions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsa la risposta di get_auction_updates.php
|
||||
/// </summary>
|
||||
private List<BidooBrowserAuction> ParseGetAuctionUpdatesResponse(string json, HashSet<string> existingIds)
|
||||
{
|
||||
var auctions = new List<BidooBrowserAuction>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse JSON manuale per estrarre items[]
|
||||
// Cerchiamo "items":["...","..."]
|
||||
var itemsMatch = Regex.Match(json, @"""items"":\s*\[(.*?)\](?=,""|\})", RegexOptions.Singleline);
|
||||
if (!itemsMatch.Success)
|
||||
{
|
||||
Console.WriteLine("[BidooBrowser] Nessun items trovato nella risposta");
|
||||
return auctions;
|
||||
}
|
||||
|
||||
var itemsContent = itemsMatch.Groups[1].Value;
|
||||
|
||||
// Gli items sono stringhe HTML escaped, dobbiamo parsarle
|
||||
// Ogni item è una stringa JSON che contiene HTML
|
||||
var htmlPattern = new Regex(@"""((?:[^""\\]|\\.)*?)""", RegexOptions.Singleline);
|
||||
var htmlMatches = htmlPattern.Matches(itemsContent);
|
||||
|
||||
foreach (Match htmlMatch in htmlMatches)
|
||||
{
|
||||
if (!htmlMatch.Success) continue;
|
||||
|
||||
// Unescape la stringa JSON
|
||||
var escapedHtml = htmlMatch.Groups[1].Value;
|
||||
var html = UnescapeJsonString(escapedHtml);
|
||||
|
||||
// Estrai l'ID dell'asta
|
||||
var idMatch = Regex.Match(html, @"id=""divAsta(\d+)""");
|
||||
if (!idMatch.Success) continue;
|
||||
|
||||
var auctionId = idMatch.Groups[1].Value;
|
||||
|
||||
// Salta se già esiste
|
||||
if (existingIds.Contains(auctionId)) continue;
|
||||
|
||||
// Parsa l'asta dall'HTML
|
||||
var auction = ParseSingleAuction(auctionId, html);
|
||||
if (auction != null)
|
||||
{
|
||||
auctions.Add(auction);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[BidooBrowser] Errore parsing get_auction_updates response: {ex.Message}");
|
||||
}
|
||||
|
||||
return auctions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unescape di una stringa JSON
|
||||
/// </summary>
|
||||
private static string UnescapeJsonString(string escaped)
|
||||
{
|
||||
return escaped
|
||||
.Replace("\\/", "/")
|
||||
.Replace("\\n", "\n")
|
||||
.Replace("\\r", "\r")
|
||||
.Replace("\\t", "\t")
|
||||
.Replace("\\\"", "\"")
|
||||
.Replace("\\\\", "\\");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
|
||||
{
|
||||
AuctionId = auction.AuctionId,
|
||||
ProductName = auction.Name,
|
||||
FinalPrice = (decimal)finalPrice,
|
||||
BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null,
|
||||
ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null,
|
||||
TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile
|
||||
MyBidsCount = bidsUsed,
|
||||
ResetCount = auction.ResetCount,
|
||||
Won = won,
|
||||
WinnerUsername = won ? "ME" : auction.LastState?.LastBidder,
|
||||
CompletedAt = DateTime.UtcNow,
|
||||
AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable
|
||||
Savings = savings.HasValue ? (decimal)savings.Value : null,
|
||||
TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_postgresDb.CompletedAuctions.Add(completedAuction);
|
||||
await _postgresDb.SaveChangesAsync();
|
||||
|
||||
// Aggiorna statistiche prodotto
|
||||
await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice);
|
||||
using var httpClient = new HttpClient();
|
||||
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Aggiorna metriche giornaliere
|
||||
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
|
||||
|
||||
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna statistiche prodotto in PostgreSQL
|
||||
/// </summary>
|
||||
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice)
|
||||
{
|
||||
if (_postgresDb == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var productKey = GenerateProductKey(auction.Name);
|
||||
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey);
|
||||
|
||||
if (stat == null)
|
||||
{
|
||||
stat = new ProductStatistic
|
||||
{
|
||||
ProductKey = productKey,
|
||||
ProductName = auction.Name,
|
||||
TotalAuctions = 0,
|
||||
MinBidsSeen = int.MaxValue,
|
||||
MaxBidsSeen = 0,
|
||||
CompetitionLevel = "Medium"
|
||||
};
|
||||
_postgresDb.ProductStatistics.Add(stat);
|
||||
}
|
||||
|
||||
stat.TotalAuctions++;
|
||||
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
|
||||
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
|
||||
// Headers browser-like per evitare rilevamento come bot
|
||||
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
|
||||
|
||||
if (won)
|
||||
{
|
||||
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
|
||||
}
|
||||
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
|
||||
|
||||
stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed);
|
||||
stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed);
|
||||
stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer
|
||||
stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer
|
||||
stat.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
// Determina livello competizione
|
||||
if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High";
|
||||
else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low";
|
||||
else stat.CompetitionLevel = "Medium";
|
||||
|
||||
await _postgresDb.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna metriche giornaliere in PostgreSQL
|
||||
/// </summary>
|
||||
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings)
|
||||
{
|
||||
if (_postgresDb == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date);
|
||||
|
||||
if (metric == null)
|
||||
{
|
||||
metric = new DailyMetric { Date = date.Date };
|
||||
_postgresDb.DailyMetrics.Add(metric);
|
||||
}
|
||||
|
||||
metric.TotalBidsUsed += bidsUsed;
|
||||
metric.MoneySpent += (decimal)(bidsUsed * bidCost);
|
||||
if (won) metric.AuctionsWon++; else metric.AuctionsLost++;
|
||||
metric.TotalSavings += (decimal)savings;
|
||||
|
||||
var totalAuctions = metric.AuctionsWon + metric.AuctionsLost;
|
||||
if (totalAuctions > 0)
|
||||
{
|
||||
metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100;
|
||||
}
|
||||
|
||||
if (metric.MoneySpent > 0)
|
||||
{
|
||||
metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100;
|
||||
}
|
||||
|
||||
await _postgresDb.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera chiave univoca per prodotto
|
||||
/// </summary>
|
||||
private string GenerateProductKey(string productName)
|
||||
{
|
||||
var normalized = productName.ToLowerInvariant()
|
||||
.Replace(" ", "_")
|
||||
.Replace("-", "_");
|
||||
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
|
||||
}
|
||||
|
||||
/// <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);
|
||||
var html = await httpClient.GetStringAsync(auctionUrl);
|
||||
|
||||
if (!string.IsNullOrEmpty(productKey))
|
||||
{
|
||||
query = query.Where(i => i.ProductKey == productKey);
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
|
||||
Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing...");
|
||||
|
||||
// Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate
|
||||
var bidsUsed = ExtractBidsUsedFromHtml(html);
|
||||
|
||||
return bidsUsed;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}");
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
|
||||
return new List<StrategicInsight>();
|
||||
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene performance puntatori da PostgreSQL
|
||||
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
|
||||
/// </summary>
|
||||
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
|
||||
private int? ExtractBidsUsedFromHtml(string html)
|
||||
{
|
||||
if (!_postgresAvailable || _postgresDb == null)
|
||||
if (string.IsNullOrEmpty(html)) return null;
|
||||
|
||||
// 1) Look for the explicit bids-used span: <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 (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
|
||||
{
|
||||
return new List<BidderPerformance>();
|
||||
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
|
||||
return val1;
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
|
||||
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
|
||||
{
|
||||
return await _postgresDb.BidderPerformances
|
||||
.OrderByDescending(b => b.WinRate)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
|
||||
return val2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// 3) Fallbacks
|
||||
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
|
||||
{
|
||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
|
||||
return new List<BidderPerformance>();
|
||||
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>
|
||||
/// Ottiene i limiti consigliati per un prodotto
|
||||
/// </summary>
|
||||
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
|
||||
{
|
||||
if (_productStatsService == null) return null;
|
||||
|
||||
var productKey = ProductStatisticsService.GenerateProductKey(productName);
|
||||
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutte le statistiche prodotto
|
||||
/// </summary>
|
||||
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
|
||||
{
|
||||
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
|
||||
return await _productStatsService.GetAllProductStatisticsAsync();
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -71,26 +71,34 @@ namespace AutoBidder.Utilities
|
||||
/// </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;
|
||||
// ???????????????????????????????????????????????????????????????
|
||||
// IMPOSTAZIONI DATABASE
|
||||
// ???????????????????????????????????????????????????????????????
|
||||
|
||||
/// <summary>
|
||||
/// Connection string PostgreSQL
|
||||
/// Abilita il salvataggio automatico delle aste completate nel database.
|
||||
/// Default: true (consigliato per statistiche)
|
||||
/// </summary>
|
||||
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password";
|
||||
public bool DatabaseAutoSaveEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-crea schema database se mancante
|
||||
/// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
|
||||
/// Default: true (consigliato per mantenere database pulito)
|
||||
/// </summary>
|
||||
public bool AutoCreateDatabaseSchema { get; set; } = true;
|
||||
public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback automatico a SQLite se PostgreSQL non disponibile
|
||||
/// Esegue pulizia automatica record incompleti all'avvio.
|
||||
/// Default: false (può rimuovere dati utili in caso di errori temporanei)
|
||||
/// </summary>
|
||||
public bool FallbackToSQLite { get; set; } = true;
|
||||
public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Numero massimo di giorni da mantenere nei risultati aste.
|
||||
/// Record più vecchi vengono eliminati automaticamente.
|
||||
/// Default: 180 (6 mesi), 0 = disabilitato
|
||||
/// </summary>
|
||||
public int DatabaseMaxRetentionDays { get; set; } = 180;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -458,3 +487,174 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* === AUCTION MONITOR STATUS BADGES === */
|
||||
|
||||
/* Base badge styling for auction status - COMPACT */
|
||||
.col-stato .badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 65px;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Icon inside badge - smaller */
|
||||
.col-stato .badge i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* === USER-CONTROLLED STATES === */
|
||||
|
||||
/* Active - Monitoring enabled, bright green */
|
||||
.col-stato .badge.status-active {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Paused by user - Orange */
|
||||
.col-stato .badge.status-paused {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: #1a1a1a;
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Stopped by user - Gray */
|
||||
.col-stato .badge.status-stopped {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
color: #e5e5e5;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Attacking/Bidding - Electric blue with glow */
|
||||
.col-stato .badge.status-attacking {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* === AUCTION LIFECYCLE STATES === */
|
||||
|
||||
/* Won - Gold/Yellow celebration */
|
||||
.col-stato .badge.status-won {
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #1a1a1a;
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Lost - Red muted */
|
||||
.col-stato .badge.status-lost {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Closed - Dark gray */
|
||||
.col-stato .badge.status-closed {
|
||||
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||
color: #9ca3af;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* System Paused (night pause) - Purple/Indigo */
|
||||
.col-stato .badge.status-system-paused {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 6px rgba(139, 92, 246, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Pending/Waiting - Cyan */
|
||||
.col-stato .badge.status-pending {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 6px rgba(6, 182, 212, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Scheduled - Teal */
|
||||
.col-stato .badge.status-scheduled {
|
||||
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
|
||||
color: white;
|
||||
box-shadow: 0 0 6px rgba(20, 184, 166, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* === ANIMATIONS === */
|
||||
|
||||
/* Active pulse - subtle */
|
||||
.col-stato .badge.status-anim-active {
|
||||
animation: statusPulseActive 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusPulseActive {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.7), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Paused blink */
|
||||
.col-stato .badge.status-anim-paused {
|
||||
animation: statusBlink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* Attacking - fast pulse */
|
||||
.col-stato .badge.status-anim-attacking {
|
||||
animation: statusAttacking 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusAttacking {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.9), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Won celebration */
|
||||
.col-stato .badge.status-anim-won {
|
||||
animation: statusWon 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusWon {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(251, 191, 36, 0.9), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy Bootstrap classes override for backward compatibility */
|
||||
.col-stato .badge.bg-success {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.col-stato .badge.bg-warning {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.col-stato .badge.bg-secondary {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%) !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user