Files
Mimante/Mimante/Services/StatsService.cs
Alberto Balbo 61f0945db2 Supporto PostgreSQL, statistiche avanzate e nuova UI
Aggiornamento massivo: aggiunto backend PostgreSQL per statistiche aste con fallback SQLite, nuovi modelli e servizi, UI moderna con grafici interattivi, refactoring stato applicazione (ApplicationStateService), documentazione completa per deploy Docker/Unraid/Gitea, nuovi CSS e script JS per UX avanzata, template Unraid, test database, e workflow CI/CD estesi. Pronto per produzione e analisi avanzate.
2026-01-18 17:52:05 +01:00

420 lines
16 KiB
C#

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
/// </summary>
public class StatsService
{
private readonly DatabaseService _db;
private readonly PostgresStatsContext? _postgresDb;
private readonly bool _postgresAvailable;
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null)
{
_db = db;
_postgresDb = postgresDb;
_postgresAvailable = false;
// Verifica disponibilità PostgreSQL
if (_postgresDb != null)
{
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");
}
}
/// <summary>
/// Registra il completamento di un'asta (sia su PostgreSQL che SQLite)
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
{
try
{
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 buyNowPrice = auction.BuyNowPrice;
var shippingCost = auction.ShippingCost ?? 0;
double? totalCost = null;
double? savings = null;
if (won && buyNowPrice.HasValue)
{
totalCost = finalPrice + moneySpent + shippingCost;
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
}
// Salva su SQLite (sempre)
await _db.SaveAuctionResultAsync(
auction.AuctionId,
auction.Name,
finalPrice,
bidsUsed,
won,
buyNowPrice,
shippingCost,
totalCost,
savings
);
await _db.SaveDailyStatAsync(
today,
bidsUsed,
moneySpent,
won ? 1 : 0,
won ? 0 : 1,
savings ?? 0,
auction.LastState?.PollingLatencyMs
);
// Salva su PostgreSQL se disponibile
if (_postgresAvailable && _postgresDb != null)
{
await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings);
}
Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
}
}
/// <summary>
/// Salva asta conclusa su PostgreSQL
/// </summary>
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings)
{
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);
// 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;
if (won)
{
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
}
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);
if (!string.IsNullOrEmpty(productKey))
{
query = query.Where(i => i.ProductKey == productKey);
}
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
return new List<StrategicInsight>();
}
}
/// <summary>
/// Ottiene performance puntatori da PostgreSQL
/// </summary>
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<BidderPerformance>();
}
try
{
return await _postgresDb.BidderPerformances
.OrderByDescending(b => b.WinRate)
.Take(limit)
.ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
return new List<BidderPerformance>();
}
}
// Metodi esistenti per compatibilità SQLite
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
{
var to = DateTime.UtcNow;
var from = to.AddDays(-days);
return await _db.GetDailyStatsAsync(from, to);
}
public async Task<TotalStats> GetTotalStatsAsync()
{
var stats = await GetDailyStatsAsync(365);
return new TotalStats
{
TotalBidsUsed = stats.Sum(s => s.BidsUsed),
TotalMoneySpent = stats.Sum(s => s.MoneySpent),
TotalAuctionsWon = stats.Sum(s => s.AuctionsWon),
TotalAuctionsLost = stats.Sum(s => s.AuctionsLost),
TotalSavings = stats.Sum(s => s.TotalSavings),
AverageLatency = stats.Any() ? stats.Average(s => s.AverageLatency ?? 0) : 0,
WinRate = stats.Sum(s => s.AuctionsWon + s.AuctionsLost) > 0
? (double)stats.Sum(s => s.AuctionsWon) / (stats.Sum(s => s.AuctionsWon) + stats.Sum(s => s.AuctionsLost)) * 100
: 0,
AverageBidsPerAuction = stats.Sum(s => s.AuctionsWon + s.AuctionsLost) > 0
? (double)stats.Sum(s => s.BidsUsed) / (stats.Sum(s => s.AuctionsWon) + stats.Sum(s => s.AuctionsLost))
: 0
};
}
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
{
return await _db.GetRecentAuctionResultsAsync(limit);
}
public async Task<double> CalculateROIAsync()
{
var stats = await GetTotalStatsAsync();
if (stats.TotalMoneySpent <= 0)
return 0;
return (stats.TotalSavings / stats.TotalMoneySpent) * 100;
}
public async Task<ChartData> GetChartDataAsync(int days = 30)
{
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");
var existingStat = stats.FirstOrDefault(s => s.Date == date);
allDates.Add(existingStat ?? new DailyStat
{
Date = date,
BidsUsed = 0,
MoneySpent = 0,
AuctionsWon = 0,
AuctionsLost = 0,
TotalSavings = 0,
AverageLatency = null
});
}
return new ChartData
{
Labels = allDates.Select(s => DateTime.Parse(s.Date).ToString("dd/MM")).ToList(),
BidsUsed = allDates.Select(s => s.BidsUsed).ToList(),
MoneySpent = allDates.Select(s => s.MoneySpent).ToList(),
AuctionsWon = allDates.Select(s => s.AuctionsWon).ToList(),
AuctionsLost = allDates.Select(s => s.AuctionsLost).ToList(),
Savings = allDates.Select(s => s.TotalSavings).ToList()
};
}
/// <summary>
/// Indica se il database PostgreSQL è disponibile
/// </summary>
public bool IsPostgresAvailable => _postgresAvailable;
}
// Classi esistenti per compatibilità
public class TotalStats
{
public int TotalBidsUsed { get; set; }
public double TotalMoneySpent { get; set; }
public int TotalAuctionsWon { get; set; }
public int TotalAuctionsLost { get; set; }
public double TotalSavings { get; set; }
public double AverageLatency { get; set; }
public double WinRate { get; set; }
public double AverageBidsPerAuction { get; set; }
}
public class ChartData
{
public List<string> Labels { get; set; } = new();
public List<int> BidsUsed { get; set; } = new();
public List<double> MoneySpent { get; set; } = new();
public List<int> AuctionsWon { get; set; } = new();
public List<int> AuctionsLost { get; set; } = new();
public List<double> Savings { get; set; } = new();
}
}