unified daily report data from S3 and then db and fallback to xlsx and the container size is consistent among 4 tubs
This commit is contained in:
parent
69148410f2
commit
50bc85ff2a
|
|
@ -1309,20 +1309,14 @@ public class Controller : ControllerBase
|
||||||
Int64 installationId, Installation installation,
|
Int64 installationId, Installation installation,
|
||||||
DateOnly fromDate, DateOnly toDate)
|
DateOnly fromDate, DateOnly toDate)
|
||||||
{
|
{
|
||||||
var jsonDir = Path.Combine(
|
|
||||||
Environment.CurrentDirectory, "tmp_report", "aggregated", installationId.ToString());
|
|
||||||
|
|
||||||
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
||||||
{
|
{
|
||||||
var isoDate = date.ToString("yyyy-MM-dd");
|
var isoDate = date.ToString("yyyy-MM-dd");
|
||||||
var fileName = AggregatedJsonParser.ToJsonFileName(date);
|
|
||||||
|
|
||||||
// Try local file first
|
if (Db.DailyRecordExists(installationId, isoDate))
|
||||||
var localPath = Path.Combine(jsonDir, fileName);
|
continue;
|
||||||
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
|
|
||||||
|
|
||||||
// Try S3 if no local file
|
var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate)
|
||||||
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
|
|
||||||
.GetAwaiter().GetResult();
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
if (content is null) continue;
|
if (content is null) continue;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ public class WeeklyReportResponse
|
||||||
public List<DailyEnergyData> DailyData { get; set; } = new();
|
public List<DailyEnergyData> DailyData { get; set; } = new();
|
||||||
public BehavioralPattern? Behavior { get; set; }
|
public BehavioralPattern? Behavior { get; set; }
|
||||||
public string AiInsight { get; set; } = "";
|
public string AiInsight { get; set; } = "";
|
||||||
|
|
||||||
|
// Data availability — lets UI show which days are missing
|
||||||
|
public int DaysAvailable { get; set; } // how many of the 7 days have data
|
||||||
|
public int DaysExpected { get; set; } // 7 (Mon–Sun)
|
||||||
|
public List<string> MissingDates { get; set; } = new(); // ISO dates with no data
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WeeklySummary
|
public class WeeklySummary
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ public static class Program
|
||||||
TicketDiagnosticService.Initialize();
|
TicketDiagnosticService.Initialize();
|
||||||
NetworkProviderService.Initialize();
|
NetworkProviderService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
AlarmReviewService.StartDailyScheduler();
|
||||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
DailyIngestionService.StartScheduler();
|
||||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
ReportAggregationService.StartScheduler();
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
RabbitMqManager.InitializeEnvironment();
|
RabbitMqManager.InitializeEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
|
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
|
||||||
/// Data source priority: JSON (local) → JSON (S3) → xlsx fallback.
|
/// Data source priority: JSON (S3) → xlsx fallback.
|
||||||
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
||||||
/// IngestDailyData API endpoint.
|
/// IngestDailyData API endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -14,9 +14,6 @@ public static class DailyIngestionService
|
||||||
private static readonly String TmpReportDir =
|
private static readonly String TmpReportDir =
|
||||||
Environment.CurrentDirectory + "/tmp_report/";
|
Environment.CurrentDirectory + "/tmp_report/";
|
||||||
|
|
||||||
private static readonly String JsonAggregatedDir =
|
|
||||||
Environment.CurrentDirectory + "/tmp_report/aggregated/";
|
|
||||||
|
|
||||||
private static Timer? _dailyTimer;
|
private static Timer? _dailyTimer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -53,7 +50,7 @@ public static class DailyIngestionService
|
||||||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||||
|
|
||||||
var installations = Db.Installations
|
var installations = Db.Installations
|
||||||
.Where(i => i.Product == (Int32)ProductType.SodioHome)
|
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var installation in installations)
|
foreach (var installation in installations)
|
||||||
|
|
@ -72,7 +69,7 @@ public static class DailyIngestionService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
|
/// Ingests data for one installation. Tries JSON (S3) and xlsx.
|
||||||
/// Both sources are tried — idempotency checks prevent duplicates.
|
/// Both sources are tried — idempotency checks prevent duplicates.
|
||||||
/// JSON provides recent data; xlsx provides historical data.
|
/// JSON provides recent data; xlsx provides historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -82,31 +79,46 @@ public static class DailyIngestionService
|
||||||
IngestFromXlsx(installationId);
|
IngestFromXlsx(installationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
/// <summary>
|
||||||
|
/// Ingests S3 JSON data for a specific date range. Used by report services
|
||||||
|
/// as a fallback when SQLite has no records for the requested period.
|
||||||
|
/// Idempotent — skips dates already in DB.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task IngestDateRangeAsync(Int64 installationId, DateOnly fromDate, DateOnly toDate)
|
||||||
{
|
{
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null) return;
|
||||||
|
|
||||||
var newDaily = 0;
|
var newDaily = 0;
|
||||||
var newHourly = 0;
|
var newHourly = 0;
|
||||||
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
|
|
||||||
|
|
||||||
// Collect JSON content from local files
|
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
||||||
var jsonFiles = Directory.Exists(jsonDir)
|
|
||||||
? Directory.GetFiles(jsonDir, "*.json")
|
|
||||||
: Array.Empty<String>();
|
|
||||||
|
|
||||||
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
|
|
||||||
{
|
{
|
||||||
var content = File.ReadAllText(jsonPath);
|
var isoDate = date.ToString("yyyy-MM-dd");
|
||||||
|
if (Db.DailyRecordExists(installationId, isoDate))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
|
||||||
|
if (content is null) continue;
|
||||||
|
|
||||||
var (d, h) = IngestJsonContent(installationId, content);
|
var (d, h) = IngestJsonContent(installationId, content);
|
||||||
newDaily += d;
|
newDaily += d;
|
||||||
newHourly += h;
|
newHourly += h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try S3 for recent days (yesterday + today) if no local files found
|
if (newDaily > 0 || newHourly > 0)
|
||||||
if (jsonFiles.Length == 0)
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (S3 date-range {fromDate:yyyy-MM-dd}–{toDate:yyyy-MM-dd}): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
||||||
{
|
{
|
||||||
|
var newDaily = 0;
|
||||||
|
var newHourly = 0;
|
||||||
|
|
||||||
var installation = Db.GetInstallationById(installationId);
|
var installation = Db.GetInstallationById(installationId);
|
||||||
if (installation is not null)
|
if (installation is null) return false;
|
||||||
{
|
|
||||||
|
// Try S3 for recent days (yesterday + today), skip if already in DB
|
||||||
for (var daysBack = 0; daysBack <= 1; daysBack++)
|
for (var daysBack = 0; daysBack <= 1; daysBack++)
|
||||||
{
|
{
|
||||||
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
|
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
|
||||||
|
|
@ -122,8 +134,6 @@ public static class DailyIngestionService
|
||||||
newDaily += d;
|
newDaily += d;
|
||||||
newHourly += h;
|
newHourly += h;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newDaily > 0 || newHourly > 0)
|
if (newDaily > 0 || newHourly > 0)
|
||||||
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ public static class ReportAggregationService
|
||||||
{
|
{
|
||||||
private static Timer? _monthEndTimer;
|
private static Timer? _monthEndTimer;
|
||||||
private static Timer? _yearEndTimer;
|
private static Timer? _yearEndTimer;
|
||||||
// private static Timer? _sundayReportTimer;
|
private static Timer? _weeklyReportTimer;
|
||||||
|
|
||||||
private const Double ElectricityPriceCHF = 0.39;
|
private const Double ElectricityPriceCHF = 0.39;
|
||||||
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
@ -18,17 +18,42 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
public static void StartScheduler()
|
public static void StartScheduler()
|
||||||
{
|
{
|
||||||
// ScheduleSundayWeeklyReport();
|
ScheduleWeeklyReportJob();
|
||||||
ScheduleMonthEndJob();
|
ScheduleMonthEndJob();
|
||||||
ScheduleYearEndJob();
|
ScheduleYearEndJob();
|
||||||
Console.WriteLine("[ReportAggregation] Scheduler started.");
|
Console.WriteLine("[ReportAggregation] Scheduler started.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScheduleWeeklyReportJob()
|
||||||
|
{
|
||||||
|
// Run every Monday at 03:00 UTC — after DailyIngestionService (01:00 UTC)
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var daysUntil = ((Int32)DayOfWeek.Monday - (Int32)now.DayOfWeek + 7) % 7;
|
||||||
|
var nextMon = now.Date.AddDays(daysUntil == 0 && now.Hour >= 3 ? 7 : daysUntil).AddHours(3);
|
||||||
|
|
||||||
|
_weeklyReportTimer = new Timer(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow.DayOfWeek == DayOfWeek.Monday)
|
||||||
|
RunWeeklyReportGeneration().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ReportAggregation] Weekly report error: {ex.Message}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null, nextMon - now, TimeSpan.FromDays(7));
|
||||||
|
|
||||||
|
Console.WriteLine($"[ReportAggregation] Weekly report scheduled (Monday 03:00 UTC). Next run: {nextMon:yyyy-MM-dd HH:mm} UTC");
|
||||||
|
}
|
||||||
|
|
||||||
private static void ScheduleMonthEndJob()
|
private static void ScheduleMonthEndJob()
|
||||||
{
|
{
|
||||||
// Run daily at 02:00, but only act on the 1st of the month
|
// Run daily at 04:00 UTC, but only act on the 1st of the month
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
var next = now.Date.AddHours(2);
|
var next = now.Date.AddHours(4);
|
||||||
if (now >= next) next = next.AddDays(1);
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
_monthEndTimer = new Timer(
|
_monthEndTimer = new Timer(
|
||||||
|
|
@ -36,7 +61,7 @@ public static class ReportAggregationService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (DateTime.Now.Day == 1)
|
if (DateTime.UtcNow.Day == 1)
|
||||||
RunMonthEndAggregation().GetAwaiter().GetResult();
|
RunMonthEndAggregation().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -46,14 +71,14 @@ public static class ReportAggregationService
|
||||||
},
|
},
|
||||||
null, next - now, TimeSpan.FromDays(1));
|
null, next - now, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}");
|
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 04:00 UTC, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScheduleYearEndJob()
|
private static void ScheduleYearEndJob()
|
||||||
{
|
{
|
||||||
// Run daily at 03:00, but only act on Jan 2nd
|
// Run daily at 05:00 UTC, but only act on Jan 2nd
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
var next = now.Date.AddHours(3);
|
var next = now.Date.AddHours(5);
|
||||||
if (now >= next) next = next.AddDays(1);
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
_yearEndTimer = new Timer(
|
_yearEndTimer = new Timer(
|
||||||
|
|
@ -61,7 +86,7 @@ public static class ReportAggregationService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
|
if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2)
|
||||||
RunYearEndAggregation().GetAwaiter().GetResult();
|
RunYearEndAggregation().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -71,99 +96,40 @@ public static class ReportAggregationService
|
||||||
},
|
},
|
||||||
null, next - now, TimeSpan.FromDays(1));
|
null, next - now, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}");
|
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 05:00 UTC, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sunday Weekly Report Automation ─────────────────────────────
|
// ── Weekly Report Auto-Generation ─────────────────────────────
|
||||||
// Generates weekly reports (Sunday–Saturday) for all SodiStoreHome
|
|
||||||
// installations every Sunday at 06:00, saves summary to DB, and
|
|
||||||
// emails the report to all users who have access to the installation.
|
|
||||||
//
|
|
||||||
// TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable.
|
|
||||||
|
|
||||||
// private static void ScheduleSundayWeeklyReport()
|
private static async Task RunWeeklyReportGeneration()
|
||||||
// {
|
{
|
||||||
// // Calculate delay until next Sunday 06:00
|
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||||
// var now = DateTime.Now;
|
|
||||||
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
|
var installations = Db.Installations
|
||||||
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
|
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||||
//
|
.ToList();
|
||||||
// _sundayReportTimer = new Timer(
|
|
||||||
// _ =>
|
var generated = 0;
|
||||||
// {
|
foreach (var installation in installations)
|
||||||
// try
|
{
|
||||||
// {
|
try
|
||||||
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
|
{
|
||||||
// RunSundayWeeklyReports().GetAwaiter().GetResult();
|
var report = await WeeklyReportService.GenerateReportAsync(
|
||||||
// }
|
installation.Id, installation.InstallationName, "en");
|
||||||
// catch (Exception ex)
|
|
||||||
// {
|
SaveWeeklySummary(installation.Id, report, "en");
|
||||||
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
|
generated++;
|
||||||
// }
|
|
||||||
// },
|
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})");
|
||||||
// null, nextSunday - now, TimeSpan.FromDays(7));
|
}
|
||||||
//
|
catch (Exception ex)
|
||||||
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
|
{
|
||||||
// }
|
Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
|
||||||
//
|
}
|
||||||
// private static async Task RunSundayWeeklyReports()
|
}
|
||||||
// {
|
|
||||||
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
|
Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed.");
|
||||||
//
|
}
|
||||||
// // Find all SodiStoreHome installations
|
|
||||||
// var installations = Db.Installations
|
|
||||||
// .Where(i => i.Product == (int)ProductType.SodioHome)
|
|
||||||
// .ToList();
|
|
||||||
//
|
|
||||||
// foreach (var installation in installations)
|
|
||||||
// {
|
|
||||||
// try
|
|
||||||
// {
|
|
||||||
// // Generate the weekly report (covers last Sunday–Saturday)
|
|
||||||
// var report = await WeeklyReportService.GenerateReportAsync(
|
|
||||||
// installation.Id, installation.InstallationName, "en");
|
|
||||||
//
|
|
||||||
// // Save summary to DB for future monthly aggregation
|
|
||||||
// SaveWeeklySummary(installation.Id, report);
|
|
||||||
//
|
|
||||||
// // Email the report to all users who have access to this installation
|
|
||||||
// var userIds = Db.InstallationAccess
|
|
||||||
// .Where(a => a.InstallationId == installation.Id)
|
|
||||||
// .Select(a => a.UserId)
|
|
||||||
// .ToList();
|
|
||||||
//
|
|
||||||
// foreach (var userId in userIds)
|
|
||||||
// {
|
|
||||||
// var user = Db.GetUserById(userId);
|
|
||||||
// if (user == null || String.IsNullOrWhiteSpace(user.Email))
|
|
||||||
// continue;
|
|
||||||
//
|
|
||||||
// try
|
|
||||||
// {
|
|
||||||
// var lang = user.Language ?? "en";
|
|
||||||
// // Regenerate with user's language if different from "en"
|
|
||||||
// var localizedReport = lang == "en"
|
|
||||||
// ? report
|
|
||||||
// : await WeeklyReportService.GenerateReportAsync(
|
|
||||||
// installation.Id, installation.InstallationName, lang);
|
|
||||||
//
|
|
||||||
// await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang);
|
|
||||||
// Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}");
|
|
||||||
// }
|
|
||||||
// catch (Exception emailEx)
|
|
||||||
// {
|
|
||||||
// Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// catch (Exception ex)
|
|
||||||
// {
|
|
||||||
// Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete.");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ── Save Weekly Summary ───────────────────────────────────────────
|
// ── Save Weekly Summary ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -335,7 +301,7 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
private static async Task RunMonthEndAggregation()
|
private static async Task RunMonthEndAggregation()
|
||||||
{
|
{
|
||||||
var previousMonth = DateTime.Now.AddMonths(-1);
|
var previousMonth = DateTime.UtcNow.AddMonths(-1);
|
||||||
var year = previousMonth.Year;
|
var year = previousMonth.Year;
|
||||||
var month = previousMonth.Month;
|
var month = previousMonth.Month;
|
||||||
var first = new DateOnly(year, month, 1);
|
var first = new DateOnly(year, month, 1);
|
||||||
|
|
@ -453,7 +419,7 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
private static async Task RunYearEndAggregation()
|
private static async Task RunYearEndAggregation()
|
||||||
{
|
{
|
||||||
var previousYear = DateTime.Now.Year - 1;
|
var previousYear = DateTime.UtcNow.Year - 1;
|
||||||
|
|
||||||
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,8 @@ public static class WeeklyReportService
|
||||||
/// Generates a full weekly report for the given installation.
|
/// Generates a full weekly report for the given installation.
|
||||||
/// Data source priority:
|
/// Data source priority:
|
||||||
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
|
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
|
||||||
/// 2. xlsx file fallback (if DB not yet populated for the target week)
|
/// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries
|
||||||
|
/// 3. xlsx file fallback (legacy, if both DB and S3 are empty)
|
||||||
/// Cache is keyed to the calendar week — invalidated when the week changes.
|
/// Cache is keyed to the calendar week — invalidated when the week changes.
|
||||||
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
|
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -121,15 +122,25 @@ public static class WeeklyReportService
|
||||||
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||||||
.Select(ToDailyEnergyData).ToList();
|
.Select(ToDailyEnergyData).ToList();
|
||||||
|
|
||||||
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
// 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry
|
||||||
|
if (currentWeekDays.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, trying S3 ingestion...");
|
||||||
|
await DailyIngestionService.IngestDateRangeAsync(installationId, prevMon, curSun);
|
||||||
|
currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||||||
|
.Select(ToDailyEnergyData).ToList();
|
||||||
|
previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||||||
|
.Select(ToDailyEnergyData).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. xlsx fallback: if still empty after S3, parse xlsx on the fly (legacy)
|
||||||
if (currentWeekDays.Count == 0)
|
if (currentWeekDays.Count == 0)
|
||||||
{
|
{
|
||||||
// Only parse xlsx files whose date range overlaps the needed weeks
|
|
||||||
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
||||||
|
|
||||||
if (relevantFiles.Count > 0)
|
if (relevantFiles.Count > 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
Console.WriteLine($"[WeeklyReportService] S3 empty, falling back to {relevantFiles.Count} xlsx file(s).");
|
||||||
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||||
currentWeekDays = allDaysParsed
|
currentWeekDays = allDaysParsed
|
||||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
||||||
|
|
@ -145,11 +156,12 @@ public static class WeeklyReportService
|
||||||
$"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " +
|
$"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " +
|
||||||
"Upload an xlsx file or wait for daily ingestion.");
|
"Upload an xlsx file or wait for daily ingestion.");
|
||||||
|
|
||||||
// 3. Load hourly records from SQLite for behavioral analysis
|
// 4. Load hourly records from SQLite for behavioral analysis
|
||||||
|
// (S3 ingestion above already populated hourly records if available)
|
||||||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||||||
.Select(ToHourlyEnergyData).ToList();
|
.Select(ToHourlyEnergyData).ToList();
|
||||||
|
|
||||||
// 3b. Fallback: if DB empty, parse hourly data from xlsx
|
// 4b. xlsx fallback for hourly data
|
||||||
if (currentHourlyData.Count == 0)
|
if (currentHourlyData.Count == 0)
|
||||||
{
|
{
|
||||||
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
||||||
|
|
@ -264,11 +276,24 @@ public static class WeeklyReportService
|
||||||
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
selfSufficiency, totalEnergySaved, totalSavingsCHF,
|
||||||
behavior, installationName, language, location, country, region);
|
behavior, installationName, language, location, country, region);
|
||||||
|
|
||||||
|
// Compute data availability — which days of the week are missing
|
||||||
|
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
|
||||||
|
var missingDates = new List<String>();
|
||||||
|
if (weekStart.HasValue && weekEnd.HasValue)
|
||||||
|
{
|
||||||
|
for (var d = weekStart.Value; d <= weekEnd.Value; d = d.AddDays(1))
|
||||||
|
{
|
||||||
|
var iso = d.ToString("yyyy-MM-dd");
|
||||||
|
if (!availableDates.Contains(iso))
|
||||||
|
missingDates.Add(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new WeeklyReportResponse
|
return new WeeklyReportResponse
|
||||||
{
|
{
|
||||||
InstallationName = installationName,
|
InstallationName = installationName,
|
||||||
PeriodStart = currentWeekDays.First().Date,
|
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
|
||||||
PeriodEnd = currentWeekDays.Last().Date,
|
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
|
||||||
CurrentWeek = currentSummary,
|
CurrentWeek = currentSummary,
|
||||||
PreviousWeek = previousSummary,
|
PreviousWeek = previousSummary,
|
||||||
TotalEnergySaved = totalEnergySaved,
|
TotalEnergySaved = totalEnergySaved,
|
||||||
|
|
@ -284,6 +309,9 @@ public static class WeeklyReportService
|
||||||
DailyData = currentWeekDays,
|
DailyData = currentWeekDays,
|
||||||
Behavior = behavior,
|
Behavior = behavior,
|
||||||
AiInsight = aiInsight,
|
AiInsight = aiInsight,
|
||||||
|
DaysAvailable = currentWeekDays.Count,
|
||||||
|
DaysExpected = 7,
|
||||||
|
MissingDates = missingDates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
|
||||||
Grid,
|
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
|
|
@ -60,23 +57,16 @@ interface HourlyEnergyRecord {
|
||||||
// ── Date Helpers ─────────────────────────────────────────────
|
// ── Date Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchor date for the 7-day strip. Returns last completed Sunday.
|
* Returns the Monday of the current week.
|
||||||
* To switch to live-data mode later, change to: () => new Date()
|
|
||||||
*/
|
*/
|
||||||
function getDataAnchorDate(): Date {
|
function getCurrentMonday(): Date {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
const dow = today.getDay(); // 0=Sun
|
const dow = today.getDay(); // 0=Sun
|
||||||
const lastSunday = new Date(today);
|
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
|
||||||
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
|
const monday = new Date(today);
|
||||||
lastSunday.setHours(0, 0, 0, 0);
|
monday.setDate(today.getDate() - offset);
|
||||||
return lastSunday;
|
return monday;
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
|
|
||||||
const sunday = new Date(anchor);
|
|
||||||
const monday = new Date(sunday);
|
|
||||||
monday.setDate(sunday.getDate() - 6);
|
|
||||||
return { monday, sunday };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateISO(d: Date): string {
|
function formatDateISO(d: Date): string {
|
||||||
|
|
@ -86,12 +76,21 @@ function formatDateISO(d: Date): string {
|
||||||
return `${y}-${m}-${day}`;
|
return `${y}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeekDays(monday: Date): Date[] {
|
/**
|
||||||
return Array.from({ length: 7 }, (_, i) => {
|
* Returns current week Mon→yesterday. Today excluded because
|
||||||
const d = new Date(monday);
|
* S3 aggregated file is not available until end of day.
|
||||||
d.setDate(monday.getDate() + i);
|
*/
|
||||||
return d;
|
function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||||
});
|
const yesterday = new Date();
|
||||||
|
yesterday.setHours(0, 0, 0, 0);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const days: Date[] = [];
|
||||||
|
|
||||||
|
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
|
||||||
|
days.push(new Date(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────
|
// ── Main Component ───────────────────────────────────────────
|
||||||
|
|
@ -102,86 +101,72 @@ export default function DailySection({
|
||||||
installationId: number;
|
installationId: number;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const anchor = useMemo(() => getDataAnchorDate(), []);
|
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||||
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
|
const yesterday = useMemo(() => {
|
||||||
const weekDays = useMemo(() => getWeekDays(monday), [monday]);
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
|
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
|
||||||
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
|
const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday));
|
||||||
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
|
||||||
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
const [loadingWeek, setLoadingWeek] = useState(false);
|
const [loadingWeek, setLoadingWeek] = useState(false);
|
||||||
const [noData, setNoData] = useState(false);
|
const [noData, setNoData] = useState(false);
|
||||||
|
|
||||||
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
|
// Current week Mon→yesterday only
|
||||||
|
const weekDays = useMemo(
|
||||||
|
() => getCurrentWeekDays(currentMonday),
|
||||||
|
[currentMonday]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch data for current week days
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (weekDays.length === 0) return;
|
||||||
|
const from = formatDateISO(weekDays[0]);
|
||||||
|
const to = formatDateISO(weekDays[weekDays.length - 1]);
|
||||||
setLoadingWeek(true);
|
setLoadingWeek(true);
|
||||||
axiosConfig
|
axiosConfig
|
||||||
.get('/GetDailyDetailRecords', {
|
.get('/GetDailyDetailRecords', {
|
||||||
params: {
|
params: { installationId, from, to }
|
||||||
installationId,
|
|
||||||
from: formatDateISO(monday),
|
|
||||||
to: formatDateISO(sunday)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const daily = res.data?.dailyRecords?.records ?? [];
|
const daily = res.data?.dailyRecords?.records ?? [];
|
||||||
const hourly = res.data?.hourlyRecords?.records ?? [];
|
const hourly = res.data?.hourlyRecords?.records ?? [];
|
||||||
setWeekRecords(Array.isArray(daily) ? daily : []);
|
setAllRecords(Array.isArray(daily) ? daily : []);
|
||||||
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
setAllHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setWeekRecords([]);
|
setAllRecords([]);
|
||||||
setWeekHourlyRecords([]);
|
setAllHourlyRecords([]);
|
||||||
})
|
})
|
||||||
.finally(() => setLoadingWeek(false));
|
.finally(() => setLoadingWeek(false));
|
||||||
}, [installationId, monday, sunday]);
|
}, [installationId, weekDays]);
|
||||||
|
|
||||||
// When selected date changes, extract data from week cache or fetch
|
// When selected date changes, extract data from cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNoData(false);
|
setNoData(false);
|
||||||
setSelectedDayRecord(null);
|
setSelectedDayRecord(null);
|
||||||
|
|
||||||
// Try week cache first
|
const cachedDay = allRecords.find((r) => r.date === selectedDate);
|
||||||
const cachedDay = weekRecords.find((r) => r.date === selectedDate);
|
const cachedHours = allHourlyRecords.filter((r) => r.date === selectedDate);
|
||||||
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
|
|
||||||
|
|
||||||
if (cachedDay) {
|
if (cachedDay) {
|
||||||
setSelectedDayRecord(cachedDay);
|
setSelectedDayRecord(cachedDay);
|
||||||
setHourlyRecords(cachedHours);
|
setHourlyRecords(cachedHours);
|
||||||
return;
|
} else if (!loadingWeek) {
|
||||||
}
|
|
||||||
|
|
||||||
// Not in cache (date picker outside strip) — fetch via combined endpoint
|
|
||||||
axiosConfig
|
|
||||||
.get('/GetDailyDetailRecords', {
|
|
||||||
params: { installationId, from: selectedDate, to: selectedDate }
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
const daily = res.data?.dailyRecords?.records ?? [];
|
|
||||||
const hourly = res.data?.hourlyRecords?.records ?? [];
|
|
||||||
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
|
||||||
if (Array.isArray(daily) && daily.length > 0) {
|
|
||||||
setSelectedDayRecord(daily[0]);
|
|
||||||
} else {
|
|
||||||
setNoData(true);
|
setNoData(true);
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setHourlyRecords([]);
|
setHourlyRecords([]);
|
||||||
setNoData(true);
|
}
|
||||||
});
|
}, [installationId, selectedDate, allRecords, allHourlyRecords, loadingWeek]);
|
||||||
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
|
|
||||||
|
|
||||||
const record = selectedDayRecord;
|
const record = selectedDayRecord;
|
||||||
|
|
||||||
const kpis = useMemo(() => computeKPIs(record), [record]);
|
const kpis = useMemo(() => computeKPIs(record), [record]);
|
||||||
|
|
||||||
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSelectedDate(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStripSelect = (date: string) => {
|
const handleStripSelect = (date: string) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setNoData(false);
|
setNoData(false);
|
||||||
|
|
@ -197,53 +182,37 @@ export default function DailySection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Date Picker */}
|
{/* Day Strip — current week Mon→yesterday */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
||||||
<Typography variant="body1" fontWeight="bold">
|
|
||||||
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
type="date"
|
|
||||||
size="small"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={handleDatePicker}
|
|
||||||
inputProps={{ max: formatDateISO(new Date()) }}
|
|
||||||
sx={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 7-Day Strip */}
|
|
||||||
<DayStrip
|
<DayStrip
|
||||||
weekDays={weekDays}
|
weekDays={weekDays}
|
||||||
weekRecords={weekRecords}
|
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onSelect={handleStripSelect}
|
onSelect={handleStripSelect}
|
||||||
sunday={sunday}
|
|
||||||
loading={loadingWeek}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{loadingWeek && !record && (
|
{loadingWeek && !record && (
|
||||||
<Container
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '20vh'
|
py: 6
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
||||||
</Container>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No data state */}
|
{/* No data state */}
|
||||||
{!loadingWeek && noData && !record && (
|
{!loadingWeek && noData && !record && (
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
|
<Alert severity="warning">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="noDataForDate"
|
id="noReportData"
|
||||||
defaultMessage="No data available for the selected date."
|
defaultMessage="No report data found."
|
||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Day detail */}
|
{/* Day detail */}
|
||||||
|
|
@ -312,25 +281,14 @@ function computeKPIs(record: DailyEnergyData | null) {
|
||||||
|
|
||||||
function DayStrip({
|
function DayStrip({
|
||||||
weekDays,
|
weekDays,
|
||||||
weekRecords,
|
|
||||||
selectedDate,
|
selectedDate,
|
||||||
onSelect,
|
onSelect,
|
||||||
sunday,
|
|
||||||
loading
|
|
||||||
}: {
|
}: {
|
||||||
weekDays: Date[];
|
weekDays: Date[];
|
||||||
weekRecords: DailyEnergyData[];
|
|
||||||
selectedDate: string;
|
selectedDate: string;
|
||||||
onSelect: (date: string) => void;
|
onSelect: (date: string) => void;
|
||||||
sunday: Date;
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
|
|
@ -339,19 +297,12 @@ function DayStrip({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
pb: 1,
|
pb: 1
|
||||||
mb: 1
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const dateStr = formatDateISO(day);
|
const dateStr = formatDateISO(day);
|
||||||
const isSelected = dateStr === selectedDate;
|
const isSelected = dateStr === selectedDate;
|
||||||
const record = weekRecords.find((r) => r.date === dateStr);
|
|
||||||
const selfSuff =
|
|
||||||
record && record.loadConsumption > 0
|
|
||||||
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
key={dateStr}
|
key={dateStr}
|
||||||
|
|
@ -375,25 +326,14 @@ function DayStrip({
|
||||||
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
|
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? '...'
|
|
||||||
: selfSuff != null
|
|
||||||
? `${selfSuff.toFixed(0)}%`
|
|
||||||
: '—'}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#888' }}>
|
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="dataUpTo"
|
id="currentWeekHint"
|
||||||
defaultMessage="Data up to {date}"
|
defaultMessage="Current week (Mon–yesterday)"
|
||||||
values={{ date: sundayLabel }}
|
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{props.current_installation.device !== 3 && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.report}
|
path={routes.report}
|
||||||
element={
|
element={
|
||||||
|
|
@ -615,6 +616,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ interface WeeklyReportResponse {
|
||||||
gridImportChangePercent: number;
|
gridImportChangePercent: number;
|
||||||
dailyData: DailyEnergyData[];
|
dailyData: DailyEnergyData[];
|
||||||
aiInsight: string;
|
aiInsight: string;
|
||||||
|
daysAvailable: number;
|
||||||
|
daysExpected: number;
|
||||||
|
missingDates: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportSummary {
|
interface ReportSummary {
|
||||||
|
|
@ -285,7 +288,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
const safeTab = Math.min(activeTab, tabs.length - 1);
|
const safeTab = Math.min(activeTab, tabs.length - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container">
|
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
|
||||||
<style>{`
|
<style>{`
|
||||||
@media print {
|
@media print {
|
||||||
body * { visibility: hidden; }
|
body * { visibility: hidden; }
|
||||||
|
|
@ -312,10 +315,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<DailySection installationId={installationId} />
|
<DailySection installationId={installationId} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<WeeklySection
|
<WeeklySection
|
||||||
installationId={installationId}
|
installationId={installationId}
|
||||||
latestMonthlyPeriodEnd={
|
latestMonthlyPeriodEnd={
|
||||||
|
|
@ -325,7 +328,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<MonthlySection
|
<MonthlySection
|
||||||
installationId={installationId}
|
installationId={installationId}
|
||||||
reports={monthlyReports}
|
reports={monthlyReports}
|
||||||
|
|
@ -334,7 +337,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
onGenerate={handleGenerateMonthly}
|
onGenerate={handleGenerateMonthly}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<YearlySection
|
<YearlySection
|
||||||
installationId={installationId}
|
installationId={installationId}
|
||||||
reports={yearlyReports}
|
reports={yearlyReports}
|
||||||
|
|
@ -386,28 +389,30 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '50vh'
|
py: 6
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
|
||||||
<Typography variant="body2" mt={2}>
|
<Typography variant="body2" mt={2}>
|
||||||
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
|
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Container sx={{ py: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert>
|
<Alert severity="warning">
|
||||||
</Container>
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,6 +488,21 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* Missing days warning */}
|
||||||
|
{report.missingDates && report.missingDates.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="missingDaysWarning"
|
||||||
|
defaultMessage="Data available for {available}/{expected} days. Missing: {dates}"
|
||||||
|
values={{
|
||||||
|
available: report.daysAvailable,
|
||||||
|
expected: report.daysExpected,
|
||||||
|
dates: report.missingDates.join(', ')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Weekly Insights */}
|
{/* Weekly Insights */}
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
|
||||||
|
|
@ -801,9 +821,11 @@ function MonthlySection({
|
||||||
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
|
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
|
||||||
/>
|
/>
|
||||||
) : pendingMonths.length === 0 ? (
|
) : pendingMonths.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." />
|
<Alert severity="warning">
|
||||||
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -875,9 +897,11 @@ function YearlySection({
|
||||||
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
|
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
|
||||||
/>
|
/>
|
||||||
) : pendingYears.length === 0 ? (
|
) : pendingYears.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." />
|
<Alert severity="warning">
|
||||||
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -910,12 +934,11 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
|
|
||||||
if (reports.length === 0) {
|
if (reports.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage
|
<Alert severity="warning">
|
||||||
id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'}
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
|
|
||||||
/>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,13 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
currentTab != 'tree' &&
|
currentTab != 'tree' &&
|
||||||
!location.pathname.includes('folder');
|
!location.pathname.includes('folder');
|
||||||
|
|
||||||
|
// Determine if current installation is Growatt (device=3) to hide report tab
|
||||||
|
const currentInstallation = sodiohomeInstallations.find((i) =>
|
||||||
|
location.pathname.includes(`/${i.id}/`)
|
||||||
|
);
|
||||||
|
const isGrowatt = currentInstallation?.device === 3
|
||||||
|
|| (sodiohomeInstallations.length === 1 && sodiohomeInstallations[0].device === 3);
|
||||||
|
|
||||||
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -409,7 +416,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
textColor="primary"
|
textColor="primary"
|
||||||
indicatorColor="primary"
|
indicatorColor="primary"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs
|
||||||
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
|
|
@ -476,7 +485,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
textColor="primary"
|
textColor="primary"
|
||||||
indicatorColor="primary"
|
indicatorColor="primary"
|
||||||
>
|
>
|
||||||
{singleInstallationTabs.map((tab) => (
|
{singleInstallationTabs
|
||||||
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@
|
||||||
"lastSeen": "Zuletzt gesehen",
|
"lastSeen": "Zuletzt gesehen",
|
||||||
"reportTitle": "Wöchentlicher Leistungsbericht",
|
"reportTitle": "Wöchentlicher Leistungsbericht",
|
||||||
"weeklyInsights": "Wöchentliche Einblicke",
|
"weeklyInsights": "Wöchentliche Einblicke",
|
||||||
|
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
|
||||||
"weeklySavings": "Ihre Einsparungen diese Woche",
|
"weeklySavings": "Ihre Einsparungen diese Woche",
|
||||||
"solarEnergyUsed": "Energie gespart",
|
"solarEnergyUsed": "Energie gespart",
|
||||||
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
|
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
|
||||||
|
|
@ -143,10 +144,9 @@
|
||||||
"dailyTab": "Täglich",
|
"dailyTab": "Täglich",
|
||||||
"dailyReportTitle": "Tägliche Energieübersicht",
|
"dailyReportTitle": "Tägliche Energieübersicht",
|
||||||
"dailySummary": "Tagesübersicht",
|
"dailySummary": "Tagesübersicht",
|
||||||
"selectDate": "Datum wählen",
|
|
||||||
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
|
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
|
||||||
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
|
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
|
||||||
"dataUpTo": "Daten bis {date}",
|
"currentWeekHint": "Aktuelle Woche (Mo–gestern)",
|
||||||
"intradayChart": "Tagesverlauf Energiefluss",
|
"intradayChart": "Tagesverlauf Energiefluss",
|
||||||
"batteryPower": "Batterieleistung",
|
"batteryPower": "Batterieleistung",
|
||||||
"batterySoCLabel": "Batterie SoC",
|
"batterySoCLabel": "Batterie SoC",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
"lastSeen": "Last seen",
|
"lastSeen": "Last seen",
|
||||||
"reportTitle": "Weekly Performance Report",
|
"reportTitle": "Weekly Performance Report",
|
||||||
"weeklyInsights": "Weekly Insights",
|
"weeklyInsights": "Weekly Insights",
|
||||||
|
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
|
||||||
"weeklySavings": "Your Savings This Week",
|
"weeklySavings": "Your Savings This Week",
|
||||||
"solarEnergyUsed": "Energy Saved",
|
"solarEnergyUsed": "Energy Saved",
|
||||||
"solarStayedHome": "solar + battery, not bought from grid",
|
"solarStayedHome": "solar + battery, not bought from grid",
|
||||||
|
|
@ -125,10 +126,9 @@
|
||||||
"dailyTab": "Daily",
|
"dailyTab": "Daily",
|
||||||
"dailyReportTitle": "Daily Energy Summary",
|
"dailyReportTitle": "Daily Energy Summary",
|
||||||
"dailySummary": "Daily Summary",
|
"dailySummary": "Daily Summary",
|
||||||
"selectDate": "Select Date",
|
|
||||||
"noDataForDate": "No data available for the selected date.",
|
"noDataForDate": "No data available for the selected date.",
|
||||||
"noHourlyData": "Hourly data not available for this day.",
|
"noHourlyData": "Hourly data not available for this day.",
|
||||||
"dataUpTo": "Data up to {date}",
|
"currentWeekHint": "Current week (Mon–yesterday)",
|
||||||
"intradayChart": "Intraday Power Flow",
|
"intradayChart": "Intraday Power Flow",
|
||||||
"batteryPower": "Battery Power",
|
"batteryPower": "Battery Power",
|
||||||
"batterySoCLabel": "Battery SoC",
|
"batterySoCLabel": "Battery SoC",
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@
|
||||||
"lastSeen": "Dernière connexion",
|
"lastSeen": "Dernière connexion",
|
||||||
"reportTitle": "Rapport de performance hebdomadaire",
|
"reportTitle": "Rapport de performance hebdomadaire",
|
||||||
"weeklyInsights": "Aperçus hebdomadaires",
|
"weeklyInsights": "Aperçus hebdomadaires",
|
||||||
|
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
|
||||||
"weeklySavings": "Vos économies cette semaine",
|
"weeklySavings": "Vos économies cette semaine",
|
||||||
"solarEnergyUsed": "Énergie économisée",
|
"solarEnergyUsed": "Énergie économisée",
|
||||||
"solarStayedHome": "solaire + batterie, non achetée au réseau",
|
"solarStayedHome": "solaire + batterie, non achetée au réseau",
|
||||||
|
|
@ -137,10 +138,9 @@
|
||||||
"dailyTab": "Quotidien",
|
"dailyTab": "Quotidien",
|
||||||
"dailyReportTitle": "Résumé énergétique quotidien",
|
"dailyReportTitle": "Résumé énergétique quotidien",
|
||||||
"dailySummary": "Résumé du jour",
|
"dailySummary": "Résumé du jour",
|
||||||
"selectDate": "Sélectionner la date",
|
|
||||||
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
|
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
|
||||||
"noHourlyData": "Données horaires non disponibles pour ce jour.",
|
"noHourlyData": "Données horaires non disponibles pour ce jour.",
|
||||||
"dataUpTo": "Données jusqu'au {date}",
|
"currentWeekHint": "Semaine en cours (lun–hier)",
|
||||||
"intradayChart": "Flux d'énergie journalier",
|
"intradayChart": "Flux d'énergie journalier",
|
||||||
"batteryPower": "Puissance batterie",
|
"batteryPower": "Puissance batterie",
|
||||||
"batterySoCLabel": "SoC batterie",
|
"batterySoCLabel": "SoC batterie",
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
"lastSeen": "Ultima visualizzazione",
|
"lastSeen": "Ultima visualizzazione",
|
||||||
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
||||||
"weeklyInsights": "Approfondimenti settimanali",
|
"weeklyInsights": "Approfondimenti settimanali",
|
||||||
|
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
|
||||||
"weeklySavings": "I tuoi risparmi questa settimana",
|
"weeklySavings": "I tuoi risparmi questa settimana",
|
||||||
"solarEnergyUsed": "Energia risparmiata",
|
"solarEnergyUsed": "Energia risparmiata",
|
||||||
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
|
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
|
||||||
|
|
@ -148,10 +149,9 @@
|
||||||
"dailyTab": "Giornaliero",
|
"dailyTab": "Giornaliero",
|
||||||
"dailyReportTitle": "Riepilogo energetico giornaliero",
|
"dailyReportTitle": "Riepilogo energetico giornaliero",
|
||||||
"dailySummary": "Riepilogo del giorno",
|
"dailySummary": "Riepilogo del giorno",
|
||||||
"selectDate": "Seleziona data",
|
|
||||||
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
|
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
|
||||||
"noHourlyData": "Dati orari non disponibili per questo giorno.",
|
"noHourlyData": "Dati orari non disponibili per questo giorno.",
|
||||||
"dataUpTo": "Dati fino al {date}",
|
"currentWeekHint": "Settimana corrente (lun–ieri)",
|
||||||
"intradayChart": "Flusso energetico giornaliero",
|
"intradayChart": "Flusso energetico giornaliero",
|
||||||
"batteryPower": "Potenza batteria",
|
"batteryPower": "Potenza batteria",
|
||||||
"batterySoCLabel": "SoC batteria",
|
"batterySoCLabel": "SoC batteria",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue