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.
420 lines
16 KiB
C#
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();
|
|
}
|
|
}
|