180 lines
6.4 KiB
C#
180 lines
6.4 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using CsvHelper;
|
|
using CsvHelper.Configuration;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using OTSSignsOrchestrator.Data;
|
|
using OTSSignsOrchestrator.Data.Entities;
|
|
|
|
namespace OTSSignsOrchestrator.Reports;
|
|
|
|
public class BillingReportService
|
|
{
|
|
private readonly IServiceProvider _services;
|
|
private readonly ILogger<BillingReportService> _logger;
|
|
|
|
public BillingReportService(IServiceProvider services, ILogger<BillingReportService> logger)
|
|
{
|
|
_services = services;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a billing CSV for the given date range.
|
|
/// Columns: customer_abbrev, company_name, date, screen_count, plan
|
|
/// </summary>
|
|
public async Task<byte[]> GenerateBillingCsvAsync(DateOnly from, DateOnly to)
|
|
{
|
|
await using var scope = _services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
|
|
|
var rows = await db.ScreenSnapshots
|
|
.AsNoTracking()
|
|
.Include(s => s.Instance)
|
|
.ThenInclude(i => i.Customer)
|
|
.Where(s => s.SnapshotDate >= from && s.SnapshotDate <= to)
|
|
.Select(s => new
|
|
{
|
|
Abbrev = s.Instance.Customer.Abbreviation,
|
|
CompanyName = s.Instance.Customer.CompanyName,
|
|
Plan = s.Instance.Customer.Plan,
|
|
s.SnapshotDate,
|
|
s.ScreenCount,
|
|
})
|
|
.ToListAsync();
|
|
|
|
// Group by customer abbreviation + date, sum screen counts across instances
|
|
var grouped = rows
|
|
.GroupBy(r => new { r.Abbrev, r.SnapshotDate })
|
|
.Select(g => new BillingCsvRow
|
|
{
|
|
CustomerAbbrev = g.Key.Abbrev,
|
|
CompanyName = g.First().CompanyName,
|
|
Date = g.Key.SnapshotDate,
|
|
ScreenCount = g.Sum(r => r.ScreenCount),
|
|
Plan = g.First().Plan.HasValue ? g.First().Plan!.Value.ToString() : string.Empty,
|
|
})
|
|
.OrderBy(r => r.CustomerAbbrev)
|
|
.ThenBy(r => r.Date)
|
|
.ToList();
|
|
|
|
_logger.LogInformation("Generated billing CSV: {RowCount} rows for {From} to {To}",
|
|
grouped.Count, from, to);
|
|
|
|
return WriteCsv(grouped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a version drift CSV showing current vs latest Xibo version and OAuth credential age.
|
|
/// Columns: abbrev, current_version, latest_version, credential_age_days
|
|
/// </summary>
|
|
public async Task<byte[]> GenerateVersionDriftCsvAsync()
|
|
{
|
|
await using var scope = _services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
|
|
|
// Latest health event per instance where check_name = "xibo-version"
|
|
var versionEvents = await db.HealthEvents
|
|
.AsNoTracking()
|
|
.Include(h => h.Instance)
|
|
.ThenInclude(i => i.Customer)
|
|
.Where(h => h.CheckName == "xibo-version")
|
|
.GroupBy(h => h.InstanceId)
|
|
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
|
|
.ToListAsync();
|
|
|
|
// Latest OauthAppRegistry per instance
|
|
var latestOauth = await db.OauthAppRegistries
|
|
.AsNoTracking()
|
|
.GroupBy(o => o.InstanceId)
|
|
.Select(g => g.OrderByDescending(o => o.CreatedAt).First())
|
|
.ToDictionaryAsync(o => o.InstanceId);
|
|
|
|
var rows = versionEvents.Select(h =>
|
|
{
|
|
var parts = ParseVersionMessage(h.Message);
|
|
latestOauth.TryGetValue(h.InstanceId, out var oauth);
|
|
var credAgeDays = oauth is not null
|
|
? (int)(DateTime.UtcNow - oauth.CreatedAt).TotalDays
|
|
: -1;
|
|
|
|
return new VersionDriftCsvRow
|
|
{
|
|
Abbrev = h.Instance.Customer.Abbreviation,
|
|
CurrentVersion = parts.Current,
|
|
LatestVersion = parts.Latest,
|
|
CredentialAgeDays = credAgeDays,
|
|
};
|
|
})
|
|
.OrderBy(r => r.Abbrev)
|
|
.ToList();
|
|
|
|
_logger.LogInformation("Generated version drift CSV: {RowCount} rows", rows.Count);
|
|
|
|
return WriteCsv(rows);
|
|
}
|
|
|
|
private static byte[] WriteCsv<T>(IEnumerable<T> records)
|
|
{
|
|
using var stream = new MemoryStream();
|
|
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
|
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
|
|
{
|
|
HasHeaderRecord = true,
|
|
});
|
|
csv.WriteRecords(records);
|
|
writer.Flush();
|
|
return stream.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the health event message to extract current/latest version strings.
|
|
/// Expected format from XiboVersionHealthCheck: "Current: X.Y.Z, Latest: A.B.C" or similar.
|
|
/// Returns ("unknown", "unknown") if parsing fails.
|
|
/// </summary>
|
|
private static (string Current, string Latest) ParseVersionMessage(string? message)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
return ("unknown", "unknown");
|
|
|
|
var current = "unknown";
|
|
var latest = "unknown";
|
|
|
|
// Try to extract "Current: X.Y.Z" pattern
|
|
var currentIdx = message.IndexOf("Current:", StringComparison.OrdinalIgnoreCase);
|
|
if (currentIdx >= 0)
|
|
{
|
|
var afterCurrent = message[(currentIdx + 8)..].Trim();
|
|
var end = afterCurrent.IndexOfAny([',', ' ', '\n']);
|
|
current = end > 0 ? afterCurrent[..end].Trim() : afterCurrent.Trim();
|
|
}
|
|
|
|
var latestIdx = message.IndexOf("Latest:", StringComparison.OrdinalIgnoreCase);
|
|
if (latestIdx >= 0)
|
|
{
|
|
var afterLatest = message[(latestIdx + 7)..].Trim();
|
|
var end = afterLatest.IndexOfAny([',', ' ', '\n']);
|
|
latest = end > 0 ? afterLatest[..end].Trim() : afterLatest.Trim();
|
|
}
|
|
|
|
return (current, latest);
|
|
}
|
|
}
|
|
|
|
internal sealed class BillingCsvRow
|
|
{
|
|
public string CustomerAbbrev { get; set; } = string.Empty;
|
|
public string CompanyName { get; set; } = string.Empty;
|
|
public DateOnly Date { get; set; }
|
|
public int ScreenCount { get; set; }
|
|
public string Plan { get; set; } = string.Empty;
|
|
}
|
|
|
|
internal sealed class VersionDriftCsvRow
|
|
{
|
|
public string Abbrev { get; set; } = string.Empty;
|
|
public string CurrentVersion { get; set; } = string.Empty;
|
|
public string LatestVersion { get; set; } = string.Empty;
|
|
public int CredentialAgeDays { get; set; }
|
|
}
|