Compare commits
4 Commits
0db9406b9c
...
a86dc963b2
| Author | SHA1 | Date |
|---|---|---|
|
|
a86dc963b2 | |
|
|
35b64c3318 | |
|
|
1761914f24 | |
|
|
78b9c2dc72 |
|
|
@ -901,7 +901,8 @@ public class Controller : ControllerBase
|
|||
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
||||
/// </summary>
|
||||
[HttpGet(nameof(GetWeeklyReport))]
|
||||
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null)
|
||||
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
|
||||
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
|
|
@ -911,14 +912,24 @@ public class Controller : ControllerBase
|
|||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx";
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx");
|
||||
// Parse optional weekStart override (must be a Monday; any valid date accepted for flexibility)
|
||||
DateOnly? weekStartDate = null;
|
||||
if (!String.IsNullOrEmpty(weekStart))
|
||||
{
|
||||
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
|
||||
return BadRequest("weekStart must be in yyyy-MM-dd format.");
|
||||
weekStartDate = parsed;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
|
||||
var report = await WeeklyReportService.GenerateReportAsync(
|
||||
installationId, installation.InstallationName, lang, weekStartDate);
|
||||
|
||||
// Persist weekly summary and seed AiInsightCache for this language
|
||||
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
|
||||
|
||||
return Ok(report);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -956,6 +967,329 @@ public class Controller : ControllerBase
|
|||
}
|
||||
}
|
||||
|
||||
// ── Monthly & Yearly Reports ─────────────────────────────────────
|
||||
|
||||
[HttpGet(nameof(GetPendingMonthlyAggregations))]
|
||||
public ActionResult<List<PendingMonth>> GetPendingMonthlyAggregations(Int64 installationId, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
return Ok(ReportAggregationService.GetPendingMonthlyAggregations(installationId));
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetPendingYearlyAggregations))]
|
||||
public ActionResult<List<PendingYear>> GetPendingYearlyAggregations(Int64 installationId, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
return Ok(ReportAggregationService.GetPendingYearlyAggregations(installationId));
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetMonthlyReports))]
|
||||
public async Task<ActionResult<List<MonthlyReportSummary>>> GetMonthlyReports(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var reports = Db.GetMonthlyReports(installationId);
|
||||
foreach (var report in reports)
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetYearlyReports))]
|
||||
public async Task<ActionResult<List<YearlyReportSummary>>> GetYearlyReports(
|
||||
Int64 installationId, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var reports = Db.GetYearlyReports(installationId);
|
||||
foreach (var report in reports)
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger monthly aggregation for an installation.
|
||||
/// Computes monthly report from daily records for the specified year/month.
|
||||
/// </summary>
|
||||
[HttpPost(nameof(TriggerMonthlyAggregation))]
|
||||
public async Task<ActionResult> TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
if (month < 1 || month > 12)
|
||||
return BadRequest("Month must be between 1 and 12.");
|
||||
|
||||
try
|
||||
{
|
||||
var lang = user.Language ?? "en";
|
||||
var dayCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang);
|
||||
if (dayCount == 0)
|
||||
return NotFound($"No daily records found for {year}-{month:D2}.");
|
||||
|
||||
return Ok(new { message = $"Monthly report created from {dayCount} daily records for {year}-{month:D2}." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[TriggerMonthlyAggregation] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to aggregate: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger yearly aggregation for an installation.
|
||||
/// Aggregates monthly reports for the specified year into a yearly report.
|
||||
/// </summary>
|
||||
[HttpPost(nameof(TriggerYearlyAggregation))]
|
||||
public async Task<ActionResult> TriggerYearlyAggregation(Int64 installationId, Int32 year, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var lang = user.Language ?? "en";
|
||||
var monthCount = await ReportAggregationService.TriggerYearlyAggregationAsync(installationId, year, lang);
|
||||
if (monthCount == 0)
|
||||
return NotFound($"No monthly reports found for {year}.");
|
||||
|
||||
return Ok(new { message = $"Yearly report created from {monthCount} monthly reports for {year}." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[TriggerYearlyAggregation] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to aggregate: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger xlsx ingestion for all SodioHome installations.
|
||||
/// Scans tmp_report/ for all matching xlsx files and ingests any new days.
|
||||
/// </summary>
|
||||
[HttpPost(nameof(IngestAllDailyData))]
|
||||
public async Task<ActionResult> IngestAllDailyData(Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await DailyIngestionService.IngestAllInstallationsAsync();
|
||||
return Ok(new { message = "Daily data ingestion triggered for all installations." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[IngestAllDailyData] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to ingest: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger xlsx ingestion for one installation.
|
||||
/// Parses tmp_report/{installationId}*.xlsx and stores any new days in DailyEnergyRecord.
|
||||
/// </summary>
|
||||
[HttpPost(nameof(IngestDailyData))]
|
||||
public async Task<ActionResult> IngestDailyData(Int64 installationId, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await DailyIngestionService.IngestInstallationAsync(installationId);
|
||||
return Ok(new { message = $"Daily data ingestion triggered for installation {installationId}." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[IngestDailyData] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to ingest: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug / Inspection Endpoints ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored DailyEnergyRecord rows for an installation and date range.
|
||||
/// Use this to verify that xlsx ingestion worked correctly before generating reports.
|
||||
/// </summary>
|
||||
[HttpGet(nameof(GetDailyRecords))]
|
||||
public ActionResult<List<DailyEnergyRecord>> GetDailyRecords(
|
||||
Int64 installationId, String from, String to, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
|
||||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
|
||||
return BadRequest("from and to must be in yyyy-MM-dd format.");
|
||||
|
||||
var records = Db.GetDailyRecords(installationId, fromDate, toDate);
|
||||
return Ok(new { count = records.Count, records });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
|
||||
/// Safe to use during testing — only removes daily records, not report summaries.
|
||||
/// Allows re-ingesting the same xlsx files after correcting data.
|
||||
/// </summary>
|
||||
[HttpDelete(nameof(DeleteDailyRecords))]
|
||||
public ActionResult DeleteDailyRecords(
|
||||
Int64 installationId, String from, String to, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
|
||||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
|
||||
return BadRequest("from and to must be in yyyy-MM-dd format.");
|
||||
|
||||
var fromStr = fromDate.ToString("yyyy-MM-dd");
|
||||
var toStr = toDate.ToString("yyyy-MM-dd");
|
||||
var toDelete = Db.DailyRecords
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
||||
.ToList();
|
||||
|
||||
foreach (var record in toDelete)
|
||||
Db.DailyRecords.Delete(r => r.Id == record.Id);
|
||||
|
||||
Console.WriteLine($"[DeleteDailyRecords] Deleted {toDelete.Count} records for installation {installationId} ({from}–{to}).");
|
||||
return Ok(new { deleted = toDelete.Count, from, to });
|
||||
}
|
||||
|
||||
[HttpPost(nameof(SendMonthlyReportEmail))]
|
||||
public async Task<ActionResult> SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month);
|
||||
if (report == null)
|
||||
return BadRequest($"No monthly report found for {year}-{month:D2}.");
|
||||
|
||||
try
|
||||
{
|
||||
var lang = user.Language ?? "en";
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
|
||||
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[SendMonthlyReportEmail] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to send report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost(nameof(SendYearlyReportEmail))]
|
||||
public async Task<ActionResult> SendYearlyReportEmail(Int64 installationId, Int32 year, String emailAddress, Token authToken)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year);
|
||||
if (report == null)
|
||||
return BadRequest($"No yearly report found for {year}.");
|
||||
|
||||
try
|
||||
{
|
||||
var lang = user.Language ?? "en";
|
||||
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
|
||||
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
|
||||
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[SendYearlyReportEmail] Error: {ex.Message}");
|
||||
return BadRequest($"Failed to send report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet(nameof(GetWeeklyReportSummaries))]
|
||||
public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
|
||||
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
if (installation is null || !user.HasAccessTo(installation))
|
||||
return Unauthorized();
|
||||
|
||||
var lang = language ?? user.Language ?? "en";
|
||||
var summaries = Db.GetWeeklyReportsForMonth(installationId, year, month);
|
||||
foreach (var s in summaries)
|
||||
s.AiInsight = await ReportAggregationService.GetOrGenerateWeeklyInsightAsync(s, lang);
|
||||
return Ok(summaries);
|
||||
}
|
||||
|
||||
[HttpPut(nameof(UpdateFolder))]
|
||||
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
||||
{
|
||||
|
|
@ -963,7 +1297,7 @@ public class Controller : ControllerBase
|
|||
|
||||
if (!session.Update(folder))
|
||||
return Unauthorized();
|
||||
|
||||
|
||||
return folder.HideParentIfUserHasNoAccessToParent(session!.User);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Per-language AI insight cache for weekly, monthly, and yearly report summaries.
|
||||
/// Keyed by (ReportType, ReportId, Language) — generated once on first request per language,
|
||||
/// then reused for UI reads and email sends. Never store language-specific text in the
|
||||
/// summary tables themselves; always go through this cache.
|
||||
/// </summary>
|
||||
public class AiInsightCache
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
/// <summary>"weekly" | "monthly" | "yearly"</summary>
|
||||
[Indexed]
|
||||
public String ReportType { get; set; } = "";
|
||||
|
||||
/// <summary>FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id</summary>
|
||||
[Indexed]
|
||||
public Int64 ReportId { get; set; }
|
||||
|
||||
/// <summary>ISO 639-1 language code: "en" | "de" | "fr" | "it"</summary>
|
||||
public String Language { get; set; } = "en";
|
||||
|
||||
public String InsightText { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Raw daily energy totals for one installation and calendar day.
|
||||
/// Source of truth for weekly and monthly report generation.
|
||||
/// Populated by DailyIngestionService from xlsx (current) or S3 (future).
|
||||
/// Retention: 1 year (cleaned up annually on Jan 2).
|
||||
/// </summary>
|
||||
public class DailyEnergyRecord
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// ISO date string: "YYYY-MM-DD"
|
||||
public String Date { get; set; } = "";
|
||||
|
||||
// Energy totals (kWh) — cumulative for the full calendar day
|
||||
public Double PvProduction { get; set; }
|
||||
public Double LoadConsumption { get; set; }
|
||||
public Double GridImport { get; set; }
|
||||
public Double GridExport { get; set; }
|
||||
public Double BatteryCharged { get; set; }
|
||||
public Double BatteryDischarged { get; set; }
|
||||
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public class HourlyEnergyRecord
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// "YYYY-MM-DD" — used for range queries (same pattern as DailyEnergyRecord)
|
||||
[Indexed]
|
||||
public String Date { get; set; } = "";
|
||||
|
||||
// 0–23
|
||||
public Int32 Hour { get; set; }
|
||||
|
||||
// "YYYY-MM-DD HH" — used for idempotency check
|
||||
public String DateHour { get; set; } = "";
|
||||
|
||||
public String DayOfWeek { get; set; } = "";
|
||||
public Boolean IsWeekend { get; set; }
|
||||
|
||||
// Energy for this hour (kWh)
|
||||
public Double PvKwh { get; set; }
|
||||
public Double LoadKwh { get; set; }
|
||||
public Double GridImportKwh { get; set; }
|
||||
public Double BatteryChargedKwh { get; set; }
|
||||
public Double BatteryDischargedKwh { get; set; }
|
||||
|
||||
// Instantaneous state of charge at snapshot time (%)
|
||||
public Double BattSoC { get; set; }
|
||||
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Stored summary for a weekly report period.
|
||||
/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation.
|
||||
/// </summary>
|
||||
public class WeeklyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
// Period boundaries (ISO date strings: "2026-02-10")
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Energy totals (kWh)
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Derived metrics
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Behavioral highlights
|
||||
public Int32 PeakLoadHour { get; set; }
|
||||
public Int32 PeakSolarHour { get; set; }
|
||||
public Double WeekdayAvgDailyLoad { get; set; }
|
||||
public Double WeekendAvgDailyLoad { get; set; }
|
||||
|
||||
// AI insight for this week
|
||||
public String AiInsight { get; set; } = "";
|
||||
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated monthly report. Created from weekly summaries at month-end.
|
||||
/// Consumed and deleted by yearly aggregation.
|
||||
/// </summary>
|
||||
public class MonthlyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Aggregated energy totals
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Re-derived from aggregated totals
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Averaged behavioral highlights
|
||||
public Int32 AvgPeakLoadHour { get; set; }
|
||||
public Int32 AvgPeakSolarHour { get; set; }
|
||||
public Double AvgWeekdayDailyLoad { get; set; }
|
||||
public Double AvgWeekendDailyLoad { get; set; }
|
||||
|
||||
public Int32 WeekCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated yearly report. Created from monthly summaries at year-end.
|
||||
/// Kept indefinitely.
|
||||
/// </summary>
|
||||
public class YearlyReportSummary
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
public Int64 Id { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public Int64 InstallationId { get; set; }
|
||||
|
||||
public Int32 Year { get; set; }
|
||||
public String PeriodStart { get; set; } = "";
|
||||
public String PeriodEnd { get; set; } = "";
|
||||
|
||||
// Aggregated energy totals
|
||||
public Double TotalPvProduction { get; set; }
|
||||
public Double TotalConsumption { get; set; }
|
||||
public Double TotalGridImport { get; set; }
|
||||
public Double TotalGridExport { get; set; }
|
||||
public Double TotalBatteryCharged { get; set; }
|
||||
public Double TotalBatteryDischarged { get; set; }
|
||||
|
||||
// Re-derived from aggregated totals
|
||||
public Double TotalEnergySaved { get; set; }
|
||||
public Double TotalSavingsCHF { get; set; }
|
||||
public Double SelfSufficiencyPercent { get; set; }
|
||||
public Double SelfConsumptionPercent { get; set; }
|
||||
public Double BatteryEfficiencyPercent { get; set; }
|
||||
public Double GridDependencyPercent { get; set; }
|
||||
|
||||
// Averaged behavioral highlights
|
||||
public Int32 AvgPeakLoadHour { get; set; }
|
||||
public Int32 AvgPeakSolarHour { get; set; }
|
||||
public Double AvgWeekdayDailyLoad { get; set; }
|
||||
public Double AvgWeekendDailyLoad { get; set; }
|
||||
|
||||
public Int32 MonthCount { get; set; }
|
||||
public String AiInsight { get; set; } = "";
|
||||
public String CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── DTOs for pending aggregation queries (not stored in DB) ──
|
||||
|
||||
public class PendingMonth
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 Month { get; set; }
|
||||
public Int32 WeekCount { get; set; }
|
||||
}
|
||||
|
||||
public class PendingYear
|
||||
{
|
||||
public Int32 Year { get; set; }
|
||||
public Int32 MonthCount { get; set; }
|
||||
}
|
||||
|
|
@ -67,6 +67,13 @@ public static partial class Db
|
|||
{
|
||||
return Insert(action);
|
||||
}
|
||||
|
||||
public static Boolean Create(WeeklyReportSummary report) => Insert(report);
|
||||
public static Boolean Create(MonthlyReportSummary report) => Insert(report);
|
||||
public static Boolean Create(YearlyReportSummary report) => Insert(report);
|
||||
public static Boolean Create(DailyEnergyRecord record) => Insert(record);
|
||||
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
||||
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
||||
|
||||
public static void HandleAction(UserAction newAction)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ public static partial class Db
|
|||
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
||||
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
||||
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
|
||||
|
||||
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
|
||||
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
|
||||
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
|
||||
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
|
||||
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
||||
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
||||
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
|
|
@ -51,6 +57,12 @@ public static partial class Db
|
|||
Connection.CreateTable<Error>();
|
||||
Connection.CreateTable<Warning>();
|
||||
Connection.CreateTable<UserAction>();
|
||||
Connection.CreateTable<WeeklyReportSummary>();
|
||||
Connection.CreateTable<MonthlyReportSummary>();
|
||||
Connection.CreateTable<YearlyReportSummary>();
|
||||
Connection.CreateTable<DailyEnergyRecord>();
|
||||
Connection.CreateTable<HourlyEnergyRecord>();
|
||||
Connection.CreateTable<AiInsightCache>();
|
||||
});
|
||||
|
||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||
|
|
@ -88,6 +100,12 @@ public static partial class Db
|
|||
fileConnection.CreateTable<Error>();
|
||||
fileConnection.CreateTable<Warning>();
|
||||
fileConnection.CreateTable<UserAction>();
|
||||
fileConnection.CreateTable<WeeklyReportSummary>();
|
||||
fileConnection.CreateTable<MonthlyReportSummary>();
|
||||
fileConnection.CreateTable<YearlyReportSummary>();
|
||||
fileConnection.CreateTable<DailyEnergyRecord>();
|
||||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||
fileConnection.CreateTable<AiInsightCache>();
|
||||
|
||||
return fileConnection;
|
||||
//return CopyDbToMemory(fileConnection);
|
||||
|
|
|
|||
|
|
@ -141,4 +141,106 @@ public static partial class Db
|
|||
{
|
||||
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber);
|
||||
}
|
||||
|
||||
public static void DeleteWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||
|
||||
// SQLite-net doesn't support string comparison in Delete lambda,
|
||||
// so fetch matching IDs first, then delete by ID.
|
||||
var ids = WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in ids)
|
||||
WeeklyReports.Delete(r => r.Id == id);
|
||||
|
||||
if (ids.Count > 0) Backup();
|
||||
}
|
||||
|
||||
public static void DeleteMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||
{
|
||||
MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||
Backup();
|
||||
}
|
||||
|
||||
public static void DeleteMonthlyReport(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var count = MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year && r.Month == month);
|
||||
if (count > 0) Backup();
|
||||
}
|
||||
|
||||
public static void DeleteYearlyReport(Int64 installationId, Int32 year)
|
||||
{
|
||||
var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
|
||||
if (count > 0) Backup();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
||||
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
||||
/// date fields (SQLite-net doesn't support string comparisons in Delete lambdas).
|
||||
/// </summary>
|
||||
public static void CleanupOldData()
|
||||
{
|
||||
var cutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddYears(-1).ToString("yyyy-MM-dd");
|
||||
var prevYear = DateTime.UtcNow.Year - 1;
|
||||
|
||||
// Daily records older than 1 year
|
||||
var oldDailyIds = DailyRecords
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldDailyIds)
|
||||
DailyRecords.Delete(r => r.Id == id);
|
||||
|
||||
// Hourly records older than 3 months (sufficient for pattern detection, 24x more rows than daily)
|
||||
var hourlyCutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddMonths(-3).ToString("yyyy-MM-dd");
|
||||
var oldHourlyIds = HourlyRecords
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, hourlyCutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldHourlyIds)
|
||||
HourlyRecords.Delete(r => r.Id == id);
|
||||
|
||||
// Weekly summaries older than 1 year
|
||||
var oldWeeklyIds = WeeklyReports
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldWeeklyIds)
|
||||
WeeklyReports.Delete(r => r.Id == id);
|
||||
|
||||
// Monthly summaries older than 1 year
|
||||
var oldMonthlyIds = MonthlyReports
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldMonthlyIds)
|
||||
MonthlyReports.Delete(r => r.Id == id);
|
||||
|
||||
// Yearly summaries — keep current and previous year only
|
||||
YearlyReports.Delete(r => r.Year < prevYear);
|
||||
|
||||
// AI insight cache entries older than 1 year
|
||||
var oldCacheIds = AiInsightCaches
|
||||
.ToList()
|
||||
.Where(c => String.Compare(c.CreatedAt, cutoff, StringComparison.Ordinal) < 0)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
foreach (var id in oldCacheIds)
|
||||
AiInsightCaches.Delete(c => c.Id == id);
|
||||
|
||||
Backup();
|
||||
Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
|
||||
}
|
||||
}
|
||||
|
|
@ -56,4 +56,104 @@ public static partial class Db
|
|||
|
||||
return session;
|
||||
}
|
||||
|
||||
// ── Report Queries ────────────────────────────────────────────────
|
||||
|
||||
public static List<WeeklyReportSummary> GetWeeklyReports(Int64 installationId)
|
||||
=> WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.OrderByDescending(r => r.PeriodStart)
|
||||
.ToList();
|
||||
|
||||
public static List<WeeklyReportSummary> GetWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month)
|
||||
{
|
||||
var monthStart = $"{year:D4}-{month:D2}-01";
|
||||
var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01";
|
||||
return WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
|
||||
=> MonthlyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.OrderByDescending(r => r.Year)
|
||||
.ThenByDescending(r => r.Month)
|
||||
.ToList();
|
||||
|
||||
public static List<MonthlyReportSummary> GetMonthlyReportsForYear(Int64 installationId, Int32 year)
|
||||
=> MonthlyReports
|
||||
.Where(r => r.InstallationId == installationId && r.Year == year)
|
||||
.ToList();
|
||||
|
||||
public static List<YearlyReportSummary> GetYearlyReports(Int64 installationId)
|
||||
=> YearlyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.OrderByDescending(r => r.Year)
|
||||
.ToList();
|
||||
|
||||
// ── DailyEnergyRecord Queries ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns daily records for an installation within [from, to] inclusive, ordered by date.
|
||||
/// </summary>
|
||||
public static List<DailyEnergyRecord> GetDailyRecords(Int64 installationId, DateOnly from, DateOnly to)
|
||||
{
|
||||
var fromStr = from.ToString("yyyy-MM-dd");
|
||||
var toStr = to.ToString("yyyy-MM-dd");
|
||||
return DailyRecords
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
||||
.OrderBy(r => r.Date)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a daily record already exists for this installation+date (idempotency check).
|
||||
/// </summary>
|
||||
public static Boolean DailyRecordExists(Int64 installationId, String date)
|
||||
=> DailyRecords
|
||||
.Any(r => r.InstallationId == installationId && r.Date == date);
|
||||
|
||||
// ── HourlyEnergyRecord Queries ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns hourly records for an installation within [from, to] inclusive, ordered by date+hour.
|
||||
/// </summary>
|
||||
public static List<HourlyEnergyRecord> GetHourlyRecords(Int64 installationId, DateOnly from, DateOnly to)
|
||||
{
|
||||
var fromStr = from.ToString("yyyy-MM-dd");
|
||||
var toStr = to.ToString("yyyy-MM-dd");
|
||||
return HourlyRecords
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
|
||||
.OrderBy(r => r.Date).ThenBy(r => r.Hour)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an hourly record already exists for this installation+dateHour (idempotency check).
|
||||
/// </summary>
|
||||
public static Boolean HourlyRecordExists(Int64 installationId, String dateHour)
|
||||
=> HourlyRecords
|
||||
.Any(r => r.InstallationId == installationId && r.DateHour == dateHour);
|
||||
|
||||
// ── AiInsightCache Queries ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached AI insight text for (reportType, reportId, language), or null on miss.
|
||||
/// </summary>
|
||||
public static String? GetCachedInsight(String reportType, Int64 reportId, String language)
|
||||
=> AiInsightCaches
|
||||
.FirstOrDefault(c => c.ReportType == reportType
|
||||
&& c.ReportId == reportId
|
||||
&& c.Language == language)
|
||||
?.InsightText;
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ public static class Program
|
|||
LoadEnvFile();
|
||||
DiagnosticService.Initialize();
|
||||
AlarmReviewService.StartDailyScheduler();
|
||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
RabbitMqManager.InitializeEnvironment();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table.
|
||||
/// This is the source-of-truth population step for the report pipeline.
|
||||
///
|
||||
/// Current data source: xlsx files placed in tmp_report/{installationId}.xlsx
|
||||
/// Future data source: S3 raw records (replace ExcelDataParser call with S3DailyExtractor)
|
||||
///
|
||||
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
|
||||
/// IngestDailyData API endpoint.
|
||||
/// </summary>
|
||||
public static class DailyIngestionService
|
||||
{
|
||||
private static readonly String TmpReportDir =
|
||||
Environment.CurrentDirectory + "/tmp_report/";
|
||||
|
||||
private static Timer? _dailyTimer;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the daily scheduler. Call once on app startup.
|
||||
/// Ingests xlsx data at 01:00 UTC every day.
|
||||
/// </summary>
|
||||
public static void StartScheduler()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var next = now.Date.AddDays(1).AddHours(1); // 01:00 UTC tomorrow
|
||||
|
||||
_dailyTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
IngestAllInstallationsAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Scheduler error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
null, next - now, TimeSpan.FromDays(1));
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Scheduler started. Next run: {next:yyyy-MM-dd HH:mm} UTC");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests xlsx data for all SodioHome installations. Safe to call manually.
|
||||
/// </summary>
|
||||
public static async Task IngestAllInstallationsAsync()
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
|
||||
|
||||
var installations = Db.Installations
|
||||
.Where(i => i.Product == (Int32)ProductType.SodioHome)
|
||||
.ToList();
|
||||
|
||||
foreach (var installation in installations)
|
||||
{
|
||||
try
|
||||
{
|
||||
await IngestInstallationAsync(installation.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed for installation {installation.Id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Ingestion complete.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores
|
||||
/// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per
|
||||
/// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent.
|
||||
/// </summary>
|
||||
public static async Task IngestInstallationAsync(Int64 installationId)
|
||||
{
|
||||
if (!Directory.Exists(TmpReportDir))
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] tmp_report directory not found, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var xlsxFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx");
|
||||
if (xlsxFiles.Length == 0)
|
||||
{
|
||||
Console.WriteLine($"[DailyIngestion] No xlsx found for installation {installationId}, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newDailyCount = 0;
|
||||
var newHourlyCount = 0;
|
||||
var totalParsed = 0;
|
||||
|
||||
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
|
||||
{
|
||||
// Ingest daily records
|
||||
List<DailyEnergyData> days;
|
||||
try
|
||||
{
|
||||
days = ExcelDataParser.Parse(xlsxPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
totalParsed += days.Count;
|
||||
|
||||
foreach (var day in days)
|
||||
{
|
||||
if (Db.DailyRecordExists(installationId, day.Date))
|
||||
continue;
|
||||
|
||||
Db.Create(new DailyEnergyRecord
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Date = day.Date,
|
||||
PvProduction = day.PvProduction,
|
||||
LoadConsumption = day.LoadConsumption,
|
||||
GridImport = day.GridImport,
|
||||
GridExport = day.GridExport,
|
||||
BatteryCharged = day.BatteryCharged,
|
||||
BatteryDischarged = day.BatteryDischarged,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
newDailyCount++;
|
||||
}
|
||||
|
||||
// Ingest hourly records
|
||||
List<HourlyEnergyData> hours;
|
||||
try
|
||||
{
|
||||
hours = ExcelDataParser.ParseHourly(xlsxPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var hour in hours)
|
||||
{
|
||||
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
|
||||
if (Db.HourlyRecordExists(installationId, dateHour))
|
||||
continue;
|
||||
|
||||
Db.Create(new HourlyEnergyRecord
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Date = hour.DateTime.ToString("yyyy-MM-dd"),
|
||||
Hour = hour.Hour,
|
||||
DateHour = dateHour,
|
||||
DayOfWeek = hour.DayOfWeek,
|
||||
IsWeekend = hour.IsWeekend,
|
||||
PvKwh = hour.PvKwh,
|
||||
LoadKwh = hour.LoadKwh,
|
||||
GridImportKwh = hour.GridImportKwh,
|
||||
BatteryChargedKwh = hour.BatteryChargedKwh,
|
||||
BatteryDischargedKwh = hour.BatteryDischargedKwh,
|
||||
BattSoC = hour.BattSoC,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
newHourlyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,773 @@
|
|||
using Flurl.Http;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Services;
|
||||
|
||||
public static class ReportAggregationService
|
||||
{
|
||||
private static Timer? _monthEndTimer;
|
||||
private static Timer? _yearEndTimer;
|
||||
// private static Timer? _sundayReportTimer;
|
||||
|
||||
private const Double ElectricityPriceCHF = 0.39;
|
||||
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||
|
||||
// ── Scheduler ─────────────────────────────────────────────────────
|
||||
|
||||
public static void StartScheduler()
|
||||
{
|
||||
// ScheduleSundayWeeklyReport();
|
||||
ScheduleMonthEndJob();
|
||||
ScheduleYearEndJob();
|
||||
Console.WriteLine("[ReportAggregation] Scheduler started.");
|
||||
}
|
||||
|
||||
private static void ScheduleMonthEndJob()
|
||||
{
|
||||
// Run daily at 02:00, but only act on the 1st of the month
|
||||
var now = DateTime.Now;
|
||||
var next = now.Date.AddHours(2);
|
||||
if (now >= next) next = next.AddDays(1);
|
||||
|
||||
_monthEndTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.Now.Day == 1)
|
||||
RunMonthEndAggregation().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Month-end error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
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}");
|
||||
}
|
||||
|
||||
private static void ScheduleYearEndJob()
|
||||
{
|
||||
// Run daily at 03:00, but only act on Jan 2nd
|
||||
var now = DateTime.Now;
|
||||
var next = now.Date.AddHours(3);
|
||||
if (now >= next) next = next.AddDays(1);
|
||||
|
||||
_yearEndTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
|
||||
RunYearEndAggregation().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Year-end error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
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}");
|
||||
}
|
||||
|
||||
// ── Sunday Weekly Report Automation ─────────────────────────────
|
||||
// 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()
|
||||
// {
|
||||
// // Calculate delay until next Sunday 06:00
|
||||
// var now = DateTime.Now;
|
||||
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
|
||||
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
|
||||
//
|
||||
// _sundayReportTimer = new Timer(
|
||||
// _ =>
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
|
||||
// RunSundayWeeklyReports().GetAwaiter().GetResult();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
|
||||
// }
|
||||
// },
|
||||
// null, nextSunday - now, TimeSpan.FromDays(7));
|
||||
//
|
||||
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
|
||||
// }
|
||||
//
|
||||
// private static async Task RunSundayWeeklyReports()
|
||||
// {
|
||||
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
|
||||
//
|
||||
// // 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 ───────────────────────────────────────────
|
||||
|
||||
public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report, String language = "en")
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove any existing weekly records whose date range overlaps with this report.
|
||||
// Two periods overlap when: existingStart < newEnd AND newStart < existingEnd.
|
||||
// This prevents double-counting when the same days appear in different report windows
|
||||
// (e.g., report for days 1-7, then later 2-8 — the old 1-7 record is removed).
|
||||
var overlapping = Db.WeeklyReports
|
||||
.Where(r => r.InstallationId == installationId)
|
||||
.ToList()
|
||||
.Where(r => String.Compare(r.PeriodStart, report.PeriodEnd, StringComparison.Ordinal) < 0
|
||||
&& String.Compare(report.PeriodStart, r.PeriodEnd, StringComparison.Ordinal) < 0)
|
||||
.ToList();
|
||||
|
||||
foreach (var old in overlapping)
|
||||
Db.WeeklyReports.Delete(r => r.Id == old.Id);
|
||||
|
||||
var summary = new WeeklyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
PeriodStart = report.PeriodStart,
|
||||
PeriodEnd = report.PeriodEnd,
|
||||
TotalPvProduction = report.CurrentWeek.TotalPvProduction,
|
||||
TotalConsumption = report.CurrentWeek.TotalConsumption,
|
||||
TotalGridImport = report.CurrentWeek.TotalGridImport,
|
||||
TotalGridExport = report.CurrentWeek.TotalGridExport,
|
||||
TotalBatteryCharged = report.CurrentWeek.TotalBatteryCharged,
|
||||
TotalBatteryDischarged = report.CurrentWeek.TotalBatteryDischarged,
|
||||
TotalEnergySaved = report.TotalEnergySaved,
|
||||
TotalSavingsCHF = report.TotalSavingsCHF,
|
||||
SelfSufficiencyPercent = report.SelfSufficiencyPercent,
|
||||
SelfConsumptionPercent = report.SelfConsumptionPercent,
|
||||
BatteryEfficiencyPercent = report.BatteryEfficiencyPercent,
|
||||
GridDependencyPercent = report.GridDependencyPercent,
|
||||
PeakLoadHour = report.Behavior?.PeakLoadHour ?? 0,
|
||||
PeakSolarHour = report.Behavior?.PeakSolarHour ?? 0,
|
||||
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
|
||||
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
|
||||
AiInsight = report.AiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(summary);
|
||||
|
||||
// Seed AiInsightCache so historical reads for this language are free
|
||||
if (!String.IsNullOrEmpty(summary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "weekly",
|
||||
ReportId = summary.Id,
|
||||
Language = language,
|
||||
InsightText = summary.AiInsight,
|
||||
CreatedAt = summary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending Aggregation Queries ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns months that have weekly data but no monthly report yet.
|
||||
/// Each entry: { Year, Month, WeekCount }.
|
||||
/// </summary>
|
||||
public static List<PendingMonth> GetPendingMonthlyAggregations(Int64 installationId)
|
||||
{
|
||||
var weeklies = Db.GetWeeklyReports(installationId);
|
||||
var existingMonths = Db.GetMonthlyReports(installationId)
|
||||
.Select(m => (m.Year, m.Month))
|
||||
.ToHashSet();
|
||||
|
||||
return weeklies
|
||||
.GroupBy(w =>
|
||||
{
|
||||
var date = DateTime.Parse(w.PeriodStart);
|
||||
return (Year: date.Year, Month: date.Month);
|
||||
})
|
||||
.Where(g => !existingMonths.Contains(g.Key))
|
||||
.Select(g => new PendingMonth { Year = g.Key.Year, Month = g.Key.Month, WeekCount = g.Count() })
|
||||
.OrderByDescending(p => p.Year).ThenByDescending(p => p.Month)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns years that have monthly data but no yearly report yet.
|
||||
/// Each entry: { Year, MonthCount }.
|
||||
/// </summary>
|
||||
public static List<PendingYear> GetPendingYearlyAggregations(Int64 installationId)
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReports(installationId);
|
||||
var existingYears = Db.GetYearlyReports(installationId)
|
||||
.Select(y => y.Year)
|
||||
.ToHashSet();
|
||||
|
||||
return monthlies
|
||||
.GroupBy(m => m.Year)
|
||||
.Where(g => !existingYears.Contains(g.Key))
|
||||
.Select(g => new PendingYear { Year = g.Key, MonthCount = g.Count() })
|
||||
.OrderByDescending(p => p.Year)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Month-End Aggregation ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Trigger monthly aggregation for a specific installation and month.
|
||||
/// Re-generates if a monthly report already exists. Weekly records are kept.
|
||||
/// Returns the number of weekly records aggregated (0 = no data).
|
||||
/// </summary>
|
||||
public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||
{
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
if (days.Count == 0)
|
||||
return 0;
|
||||
|
||||
await AggregateMonthForInstallation(installationId, year, month, language);
|
||||
return days.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger yearly aggregation for a specific installation and year.
|
||||
/// Re-generates if a yearly report already exists. Monthly records are kept.
|
||||
/// Returns the number of monthly records aggregated (0 = no data).
|
||||
/// </summary>
|
||||
public static async Task<Int32> TriggerYearlyAggregationAsync(Int64 installationId, Int32 year, String language = "en")
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
if (monthlies.Count == 0)
|
||||
return 0;
|
||||
|
||||
await AggregateYearForInstallation(installationId, year, language);
|
||||
return monthlies.Count;
|
||||
}
|
||||
|
||||
private static async Task RunMonthEndAggregation()
|
||||
{
|
||||
var previousMonth = DateTime.Now.AddMonths(-1);
|
||||
var year = previousMonth.Year;
|
||||
var month = previousMonth.Month;
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}...");
|
||||
|
||||
// Find all installations that have daily records for the previous month
|
||||
var installationIds = Db.DailyRecords
|
||||
.Where(r => String.Compare(r.Date, first.ToString("yyyy-MM-dd"), StringComparison.Ordinal) >= 0
|
||||
&& String.Compare(r.Date, last.ToString("yyyy-MM-dd"), StringComparison.Ordinal) <= 0)
|
||||
.Select(r => r.InstallationId)
|
||||
.ToList()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var installationId in installationIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AggregateMonthForInstallation(installationId, year, month);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Month aggregation failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en")
|
||||
{
|
||||
// Compute from daily records for the full calendar month
|
||||
var first = new DateOnly(year, month, 1);
|
||||
var last = first.AddMonths(1).AddDays(-1);
|
||||
var days = Db.GetDailyRecords(installationId, first, last);
|
||||
if (days.Count == 0)
|
||||
return;
|
||||
|
||||
// If monthly report already exists, delete it so we can re-generate
|
||||
Db.DeleteMonthlyReport(installationId, year, month);
|
||||
|
||||
// Sum energy totals directly from daily records
|
||||
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
|
||||
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
|
||||
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
|
||||
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
|
||||
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
|
||||
|
||||
// Re-derive ratios
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
// Get installation name for AI insight
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.InstallationName ?? $"Installation {installationId}";
|
||||
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
|
||||
|
||||
var aiInsight = await GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, days.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
var monthlySummary = new MonthlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
Month = month,
|
||||
PeriodStart = first.ToString("yyyy-MM-dd"),
|
||||
PeriodEnd = last.ToString("yyyy-MM-dd"),
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
AvgPeakLoadHour = 0, // Not available without hourly data; Phase 3 will add
|
||||
AvgPeakSolarHour = 0,
|
||||
AvgWeekdayDailyLoad = 0,
|
||||
AvgWeekendDailyLoad = 0,
|
||||
WeekCount = days.Count, // repurposed as day count
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(monthlySummary);
|
||||
|
||||
// Seed AiInsightCache so the generating language is pre-cached
|
||||
if (!String.IsNullOrEmpty(monthlySummary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "monthly",
|
||||
ReportId = monthlySummary.Id,
|
||||
Language = language,
|
||||
InsightText = monthlySummary.AiInsight,
|
||||
CreatedAt = monthlySummary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}–{last}).");
|
||||
}
|
||||
|
||||
// ── Year-End Aggregation ──────────────────────────────────────────
|
||||
|
||||
private static async Task RunYearEndAggregation()
|
||||
{
|
||||
var previousYear = DateTime.Now.Year - 1;
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
||||
|
||||
var installationIds = Db.MonthlyReports
|
||||
.Where(r => r.Year == previousYear)
|
||||
.Select(r => r.InstallationId)
|
||||
.ToList()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var installationId in installationIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AggregateYearForInstallation(installationId, previousYear);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Time-based cleanup: delete records older than 1 year, runs after yearly report is created
|
||||
CleanupOldRecords();
|
||||
}
|
||||
|
||||
private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en")
|
||||
{
|
||||
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
|
||||
if (monthlies.Count == 0)
|
||||
return;
|
||||
|
||||
// If yearly report already exists, delete it so we can re-generate with latest monthly data.
|
||||
Db.DeleteYearlyReport(installationId, year);
|
||||
|
||||
// Sum energy totals
|
||||
var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1);
|
||||
var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1);
|
||||
var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1);
|
||||
var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1);
|
||||
var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1);
|
||||
var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1);
|
||||
|
||||
// Re-derive ratios
|
||||
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
|
||||
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
|
||||
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
|
||||
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
|
||||
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
|
||||
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
|
||||
|
||||
var avgPeakLoad = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakLoadHour));
|
||||
var avgPeakSolar = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakSolarHour));
|
||||
var avgWeekdayLoad = Math.Round(monthlies.Average(m => m.AvgWeekdayDailyLoad), 1);
|
||||
var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1);
|
||||
|
||||
var installation = Db.GetInstallationById(installationId);
|
||||
var installationName = installation?.InstallationName ?? $"Installation {installationId}";
|
||||
|
||||
var aiInsight = await GenerateYearlyAiInsightAsync(
|
||||
installationName, year, monthlies.Count,
|
||||
totalPv, totalConsump, totalGridIn, totalGridOut,
|
||||
totalBattChg, totalBattDis, energySaved, savingsCHF,
|
||||
selfSufficiency, batteryEff, language);
|
||||
|
||||
var yearlySummary = new YearlyReportSummary
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Year = year,
|
||||
PeriodStart = monthlies.Min(m => m.PeriodStart),
|
||||
PeriodEnd = monthlies.Max(m => m.PeriodEnd),
|
||||
TotalPvProduction = totalPv,
|
||||
TotalConsumption = totalConsump,
|
||||
TotalGridImport = totalGridIn,
|
||||
TotalGridExport = totalGridOut,
|
||||
TotalBatteryCharged = totalBattChg,
|
||||
TotalBatteryDischarged = totalBattDis,
|
||||
TotalEnergySaved = energySaved,
|
||||
TotalSavingsCHF = savingsCHF,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEff,
|
||||
GridDependencyPercent = gridDependency,
|
||||
AvgPeakLoadHour = avgPeakLoad,
|
||||
AvgPeakSolarHour = avgPeakSolar,
|
||||
AvgWeekdayDailyLoad = avgWeekdayLoad,
|
||||
AvgWeekendDailyLoad = avgWeekendLoad,
|
||||
MonthCount = monthlies.Count,
|
||||
AiInsight = aiInsight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
Db.Create(yearlySummary);
|
||||
|
||||
// Seed AiInsightCache so the generating language is pre-cached
|
||||
if (!String.IsNullOrEmpty(yearlySummary.AiInsight))
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = "yearly",
|
||||
ReportId = yearlySummary.Id,
|
||||
Language = language,
|
||||
InsightText = yearlySummary.AiInsight,
|
||||
CreatedAt = yearlySummary.CreatedAt,
|
||||
});
|
||||
|
||||
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
|
||||
}
|
||||
|
||||
// ── AI Insight Cache ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns cached AI insight for (reportType, reportId, language).
|
||||
/// On cache miss: calls generate(), stores the result, and returns it.
|
||||
/// This is the single entry-point for all per-language insight reads.
|
||||
/// </summary>
|
||||
public static async Task<String> GetOrGenerateInsightAsync(
|
||||
String reportType, Int64 reportId, String language, Func<Task<String>> generate)
|
||||
{
|
||||
var cached = Db.GetCachedInsight(reportType, reportId, language);
|
||||
if (!String.IsNullOrEmpty(cached))
|
||||
return cached;
|
||||
|
||||
var insight = await generate();
|
||||
Db.Create(new AiInsightCache
|
||||
{
|
||||
ReportType = reportType,
|
||||
ReportId = reportId,
|
||||
Language = language,
|
||||
InsightText = insight,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
return insight;
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored WeeklyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateWeeklyInsightAsync(
|
||||
WeeklyReportSummary report, String language)
|
||||
{
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
|
||||
?? $"Installation {report.InstallationId}";
|
||||
return GetOrGenerateInsightAsync("weekly", report.Id, language,
|
||||
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateMonthlyInsightAsync(
|
||||
MonthlyReportSummary report, String language)
|
||||
{
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
|
||||
?? $"Installation {report.InstallationId}";
|
||||
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
|
||||
return GetOrGenerateInsightAsync("monthly", report.Id, language,
|
||||
() => GenerateMonthlyAiInsightAsync(
|
||||
installationName, monthName, report.WeekCount,
|
||||
report.TotalPvProduction, report.TotalConsumption,
|
||||
report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
|
||||
}
|
||||
|
||||
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
|
||||
public static Task<String> GetOrGenerateYearlyInsightAsync(
|
||||
YearlyReportSummary report, String language)
|
||||
{
|
||||
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
|
||||
?? $"Installation {report.InstallationId}";
|
||||
return GetOrGenerateInsightAsync("yearly", report.Id, language,
|
||||
() => GenerateYearlyAiInsightAsync(
|
||||
installationName, report.Year, report.MonthCount,
|
||||
report.TotalPvProduction, report.TotalConsumption,
|
||||
report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged,
|
||||
report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
|
||||
}
|
||||
|
||||
// ── Time-Based Cleanup ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Deletes records older than 1 year. Called annually on Jan 2 after
|
||||
/// yearly reports are created, so monthly summaries are still available
|
||||
/// when yearly is computed.
|
||||
/// </summary>
|
||||
private static void CleanupOldRecords()
|
||||
{
|
||||
try
|
||||
{
|
||||
Db.CleanupOldData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Cleanup error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI Insight Generation ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Simplified weekly AI insight generated from the stored WeeklyReportSummary numerical fields.
|
||||
/// Used for historical weeks where the original hourly data is no longer available.
|
||||
/// </summary>
|
||||
private static async Task<String> GenerateWeeklySummaryAiInsightAsync(
|
||||
WeeklyReportSummary summary, String installationName, String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
var langName = GetLanguageName(language);
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write a concise weekly performance summary in {langName} (4 bullet points starting with ""- "").
|
||||
|
||||
WEEKLY FACTS for {summary.PeriodStart} to {summary.PeriodEnd}:
|
||||
- PV production: {summary.TotalPvProduction:F1} kWh | Consumption: {summary.TotalConsumption:F1} kWh
|
||||
- Grid import: {summary.TotalGridImport:F1} kWh | Grid export: {summary.TotalGridExport:F1} kWh
|
||||
- Battery: {summary.TotalBatteryCharged:F1} kWh charged, {summary.TotalBatteryDischarged:F1} kWh discharged
|
||||
- Energy saved: {summary.TotalEnergySaved:F1} kWh = ~{summary.TotalSavingsCHF:F0} CHF
|
||||
- Self-sufficiency: {summary.SelfSufficiencyPercent:F1}% | Grid dependency: {summary.GridDependencyPercent:F1}%
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks.
|
||||
Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static String GetLanguageName(String code) => code switch
|
||||
{
|
||||
"de" => "German",
|
||||
"fr" => "French",
|
||||
"it" => "Italian",
|
||||
_ => "English"
|
||||
};
|
||||
|
||||
private static async Task<String> GenerateMonthlyAiInsightAsync(
|
||||
String installationName, String monthName, Int32 weekCount,
|
||||
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||
Double totalBattChg, Double totalBattDis,
|
||||
Double energySaved, Double savingsCHF,
|
||||
Double selfSufficiency, Double batteryEff,
|
||||
String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
var langName = GetLanguageName(language);
|
||||
|
||||
// Determine which metric is weakest so the tip can be targeted
|
||||
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
|
||||
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||
|
||||
MONTHLY FACTS for {monthName} ({weekCount} days of data):
|
||||
- PV production: {totalPv:F1} kWh
|
||||
- Total consumption: {totalConsump:F1} kWh
|
||||
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid)
|
||||
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
|
||||
- Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Savings: state exactly how much energy and money was saved this month. Positive framing.
|
||||
2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh.
|
||||
3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
|
||||
4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}. If general, suggest the most impactful habit change based on the numbers above.
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static async Task<String> GenerateYearlyAiInsightAsync(
|
||||
String installationName, Int32 year, Int32 monthCount,
|
||||
Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut,
|
||||
Double totalBattChg, Double totalBattDis,
|
||||
Double energySaved, Double savingsCHF,
|
||||
Double selfSufficiency, Double batteryEff,
|
||||
String language = "en")
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||
if (String.IsNullOrWhiteSpace(apiKey))
|
||||
return "AI insight unavailable (API key not configured).";
|
||||
|
||||
var langName = GetLanguageName(language);
|
||||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||||
|
||||
Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown).
|
||||
|
||||
ANNUAL FACTS for {year} ({monthCount} months of data):
|
||||
- Total PV production: {totalPv:F1} kWh
|
||||
- Total consumption: {totalConsump:F1} kWh
|
||||
- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh
|
||||
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged
|
||||
- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh)
|
||||
- Self-sufficiency: {selfSufficiency:F1}%
|
||||
- Battery efficiency: {batteryEff:F1}%
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
|
||||
2. System performance: comment on PV production and battery health indicators.
|
||||
3. Year-over-year readiness: note any trends or areas of improvement.
|
||||
4. Looking ahead: one strategic recommendation for the coming year.
|
||||
|
||||
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
|
||||
|
||||
return await CallMistralAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
private static async Task<String> CallMistralAsync(String apiKey, String prompt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 400,
|
||||
temperature = 0.3
|
||||
};
|
||||
|
||||
var responseText = await MistralUrl
|
||||
.WithHeader("Authorization", $"Bearer {apiKey}")
|
||||
.PostJsonAsync(requestBody)
|
||||
.ReceiveString();
|
||||
|
||||
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
||||
var content = (String?)envelope?.choices?[0]?.message?.content;
|
||||
|
||||
if (!String.IsNullOrWhiteSpace(content))
|
||||
return content.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ReportAggregation] Mistral error: {ex.Message}");
|
||||
}
|
||||
|
||||
return "AI insight could not be generated at this time.";
|
||||
}
|
||||
}
|
||||
|
|
@ -217,10 +217,10 @@ public static class ReportEmailService
|
|||
var cur = r.CurrentWeek;
|
||||
var prev = r.PreviousWeek;
|
||||
|
||||
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ")
|
||||
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ", strip ** markdown)
|
||||
var insightLines = r.AiInsight
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", ""))
|
||||
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", ""))
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
|
||||
|
|
@ -443,4 +443,230 @@ public static class ReportEmailService
|
|||
|
||||
private static string ChangeColor(double pct) =>
|
||||
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
||||
|
||||
// ── Monthly / Yearly Report Emails ────────────────────────────────────
|
||||
|
||||
private static readonly string[] MonthNamesEn = { "", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" };
|
||||
private static readonly string[] MonthNamesDe = { "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" };
|
||||
private static readonly string[] MonthNamesFr = { "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre" };
|
||||
private static readonly string[] MonthNamesIt = { "", "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre" };
|
||||
|
||||
public static async Task SendMonthlyReportEmailAsync(
|
||||
MonthlyReportSummary report,
|
||||
string installationName,
|
||||
string recipientEmail,
|
||||
string language = "en")
|
||||
{
|
||||
var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn };
|
||||
var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString();
|
||||
var s = GetAggregatedStrings(language, "monthly");
|
||||
var subject = $"{s.Title} — {installationName} ({monthName} {report.Year})";
|
||||
var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName,
|
||||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.WeekCount} {s.CountLabel}", s);
|
||||
|
||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||
}
|
||||
|
||||
public static async Task SendYearlyReportEmailAsync(
|
||||
YearlyReportSummary report,
|
||||
string installationName,
|
||||
string recipientEmail,
|
||||
string language = "en")
|
||||
{
|
||||
var s = GetAggregatedStrings(language, "yearly");
|
||||
var subject = $"{s.Title} — {installationName} ({report.Year})";
|
||||
var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName,
|
||||
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
|
||||
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
|
||||
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
|
||||
$"{report.MonthCount} {s.CountLabel}", s);
|
||||
|
||||
await SendHtmlEmailAsync(subject, html, recipientEmail);
|
||||
}
|
||||
|
||||
private static async Task SendHtmlEmailAsync(string subject, string html, string recipientEmail)
|
||||
{
|
||||
var config = await ReadMailerConfig();
|
||||
var from = new MailboxAddress(config.SenderName, config.SenderAddress);
|
||||
var to = new MailboxAddress(recipientEmail, recipientEmail);
|
||||
|
||||
var msg = new MimeMessage
|
||||
{
|
||||
From = { from },
|
||||
To = { to },
|
||||
Subject = subject,
|
||||
Body = new TextPart("html") { Text = html }
|
||||
};
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls);
|
||||
await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword);
|
||||
await smtp.SendAsync(msg);
|
||||
await smtp.DisconnectAsync(true);
|
||||
|
||||
Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}");
|
||||
}
|
||||
|
||||
// ── Aggregated report translation strings ─────────────────────────────
|
||||
|
||||
private record AggregatedEmailStrings(
|
||||
string Title, string Insights, string Summary, string SavingsHeader,
|
||||
string Metric, string Total, string PvProduction, string Consumption,
|
||||
string GridImport, string GridExport, string BatteryInOut,
|
||||
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
|
||||
string AtRate, string SolarCoverage, string FromSolar,
|
||||
string BatteryEff, string OutVsIn, string CountLabel, string Footer
|
||||
);
|
||||
|
||||
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
|
||||
{
|
||||
("de", "monthly") => new AggregatedEmailStrings(
|
||||
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
|
||||
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Wochen aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("de", "yearly") => new AggregatedEmailStrings(
|
||||
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
|
||||
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
|
||||
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
|
||||
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
|
||||
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("fr", "monthly") => new AggregatedEmailStrings(
|
||||
"Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois",
|
||||
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||
"semaines agrégées", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("fr", "yearly") => new AggregatedEmailStrings(
|
||||
"Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année",
|
||||
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
|
||||
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
|
||||
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
|
||||
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("it", "monthly") => new AggregatedEmailStrings(
|
||||
"Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese",
|
||||
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"settimane aggregate", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
("it", "yearly") => new AggregatedEmailStrings(
|
||||
"Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno",
|
||||
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
|
||||
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
|
||||
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
|
||||
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
(_, "monthly") => new AggregatedEmailStrings(
|
||||
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
|
||||
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||
"weeks aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
|
||||
_ => new AggregatedEmailStrings(
|
||||
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
|
||||
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
|
||||
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
|
||||
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
|
||||
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>")
|
||||
};
|
||||
|
||||
// ── Aggregated HTML email template ────────────────────────────────────
|
||||
|
||||
private static string BuildAggregatedHtmlEmail(
|
||||
string periodStart, string periodEnd, string installationName,
|
||||
double pvProduction, double consumption, double gridImport, double gridExport,
|
||||
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
|
||||
double selfSufficiency, double batteryEfficiency, string aiInsight,
|
||||
string countLabel, AggregatedEmailStrings s)
|
||||
{
|
||||
var insightLines = aiInsight
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", ""))
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
|
||||
var insightHtml = insightLines.Count > 1
|
||||
? "<ul style=\"margin:0;padding-left:20px\">" +
|
||||
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
|
||||
"</ul>"
|
||||
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(aiInsight)}</p>";
|
||||
|
||||
return $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset=""utf-8""></head>
|
||||
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
|
||||
<tr><td align=""center"">
|
||||
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||||
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
|
||||
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{installationName}</div>
|
||||
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} — {periodEnd}</div>
|
||||
<div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Insights -->
|
||||
<tr>
|
||||
<td style=""padding:24px 30px 0"">
|
||||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
|
||||
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
|
||||
{insightHtml}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Summary Table -->
|
||||
<tr>
|
||||
<td style=""padding:24px 30px"">
|
||||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
|
||||
<tr style=""background:#f8f9fa"">
|
||||
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
|
||||
<th style=""padding:8px 12px;text-align:right"">{s.Total}</th>
|
||||
</tr>
|
||||
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{pvProduction:F1} kWh</td></tr>
|
||||
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{consumption:F1} kWh</td></tr>
|
||||
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridImport:F1} kWh</td></tr>
|
||||
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridExport:F1} kWh</td></tr>
|
||||
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{batteryCharged:F1} / {batteryDischarged:F1} kWh</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Key Ratios -->
|
||||
<tr>
|
||||
<td style=""padding:0 30px 24px"">
|
||||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
||||
<tr>
|
||||
{SavingsBox(s.SolarEnergyUsed, $"{energySaved:F1} kWh", s.StayedAtHome, "#27ae60")}
|
||||
{SavingsBox(s.EstMoneySaved, $"~{savingsCHF:F0} CHF", s.AtRate, "#2980b9")}
|
||||
{SavingsBox(s.SolarCoverage, $"{selfSufficiency:F0}%", s.FromSolar, "#8e44ad")}
|
||||
{SavingsBox(s.BatteryEff, $"{batteryEfficiency:F0}%", s.OutVsIn, "#e67e22")}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||
{s.Footer}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Flurl.Http;
|
||||
using InnovEnergy.App.Backend.Database;
|
||||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
|
@ -8,88 +9,145 @@ public static class WeeklyReportService
|
|||
{
|
||||
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
||||
|
||||
// ── Calendar Week Helpers ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Generates a full weekly report for the given installation.
|
||||
/// Cache is invalidated automatically when the xlsx file is newer than the cache.
|
||||
/// To force regeneration (e.g. after a prompt change), simply delete the cache files.
|
||||
/// Returns the last completed calendar week (Mon–Sun).
|
||||
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
|
||||
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
|
||||
{
|
||||
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
||||
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
|
||||
|
||||
// Use cached report if xlsx hasn't changed since cache was written
|
||||
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
||||
{
|
||||
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
|
||||
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
|
||||
if (cacheModified > xlsxModified)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
|
||||
await File.ReadAllTextAsync(cachePath));
|
||||
if (cached != null)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cache corrupt — regenerate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse both daily summaries and hourly intervals from the same xlsx
|
||||
var allDays = ExcelDataParser.Parse(xlsxPath);
|
||||
var allHourly = ExcelDataParser.ParseHourly(xlsxPath);
|
||||
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language);
|
||||
|
||||
// Write cache
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
|
||||
}
|
||||
|
||||
return report;
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
// DayOfWeek: Sun=0, Mon=1, ..., Sat=6
|
||||
// daysSinceMonday: Mon=0, Tue=1, ..., Sun=6
|
||||
var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7;
|
||||
var thisMonday = today.AddDays(-daysSinceMonday);
|
||||
var lastMonday = thisMonday.AddDays(-7);
|
||||
var lastSunday = thisMonday.AddDays(-1);
|
||||
return (lastMonday, lastSunday);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core report generation. Accepts both daily summaries and hourly intervals.
|
||||
/// Returns the calendar week before last (for comparison).
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
||||
List<DailyEnergyData> allDays,
|
||||
List<HourlyEnergyData> allHourly,
|
||||
string installationName,
|
||||
string language = "en")
|
||||
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
|
||||
{
|
||||
// Sort by date
|
||||
allDays = allDays.OrderBy(d => d.Date).ToList();
|
||||
var (lastMon, _) = LastCalendarWeek();
|
||||
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
|
||||
}
|
||||
|
||||
// Split into previous week and current week (daily)
|
||||
List<DailyEnergyData> previousWeekDays;
|
||||
List<DailyEnergyData> currentWeekDays;
|
||||
// ── Report Generation ──────────────────────────────────────────────
|
||||
|
||||
if (allDays.Count > 7)
|
||||
/// <summary>
|
||||
/// Generates a full weekly report for the given installation.
|
||||
/// Data source priority:
|
||||
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
|
||||
/// 2. xlsx file fallback (if DB not yet populated for the target week)
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportAsync(
|
||||
long installationId, string installationName, string language = "en",
|
||||
DateOnly? weekStartOverride = null)
|
||||
{
|
||||
DateOnly curMon, curSun, prevMon, prevSun;
|
||||
|
||||
if (weekStartOverride.HasValue)
|
||||
{
|
||||
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
|
||||
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
|
||||
// Debug/backfill mode: use the provided Monday as the week start
|
||||
curMon = weekStartOverride.Value;
|
||||
curSun = curMon.AddDays(6);
|
||||
prevMon = curMon.AddDays(-7);
|
||||
prevSun = curMon.AddDays(-1);
|
||||
}
|
||||
else
|
||||
{
|
||||
previousWeekDays = new List<DailyEnergyData>();
|
||||
currentWeekDays = allDays;
|
||||
(curMon, curSun) = LastCalendarWeek();
|
||||
(prevMon, prevSun) = PreviousCalendarWeek();
|
||||
}
|
||||
|
||||
// Restrict hourly data to current week only for behavioral analysis
|
||||
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
|
||||
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
|
||||
// 1. Load daily records from SQLite
|
||||
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||||
.Select(ToDailyEnergyData).ToList();
|
||||
|
||||
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
||||
if (currentWeekDays.Count == 0)
|
||||
{
|
||||
var xlsxFiles = Directory.Exists(TmpReportDir)
|
||||
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
|
||||
: new List<String>();
|
||||
|
||||
if (xlsxFiles.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
|
||||
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
|
||||
currentWeekDays = allDaysParsed
|
||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
|
||||
.ToList();
|
||||
previousWeekDays = allDaysParsed
|
||||
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWeekDays.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " +
|
||||
"Upload an xlsx file or wait for daily ingestion.");
|
||||
|
||||
// 3. Load hourly records from SQLite for behavioral analysis
|
||||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||||
.Select(ToHourlyEnergyData).ToList();
|
||||
|
||||
return await GenerateReportFromDataAsync(
|
||||
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
|
||||
curMon, curSun);
|
||||
}
|
||||
|
||||
// ── Conversion helpers ─────────────────────────────────────────────
|
||||
|
||||
private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new()
|
||||
{
|
||||
Date = r.Date,
|
||||
PvProduction = r.PvProduction,
|
||||
LoadConsumption = r.LoadConsumption,
|
||||
GridImport = r.GridImport,
|
||||
GridExport = r.GridExport,
|
||||
BatteryCharged = r.BatteryCharged,
|
||||
BatteryDischarged = r.BatteryDischarged,
|
||||
};
|
||||
|
||||
private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new()
|
||||
{
|
||||
DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null),
|
||||
Hour = r.Hour,
|
||||
DayOfWeek = r.DayOfWeek,
|
||||
IsWeekend = r.IsWeekend,
|
||||
PvKwh = r.PvKwh,
|
||||
LoadKwh = r.LoadKwh,
|
||||
GridImportKwh = r.GridImportKwh,
|
||||
BatteryChargedKwh = r.BatteryChargedKwh,
|
||||
BatteryDischargedKwh = r.BatteryDischargedKwh,
|
||||
BattSoC = r.BattSoC,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
|
||||
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
|
||||
/// </summary>
|
||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
||||
List<DailyEnergyData> currentWeekDays,
|
||||
List<DailyEnergyData> previousWeekDays,
|
||||
List<HourlyEnergyData> currentHourlyData,
|
||||
string installationName,
|
||||
string language = "en",
|
||||
DateOnly? weekStart = null,
|
||||
DateOnly? weekEnd = null)
|
||||
{
|
||||
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
|
||||
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
|
||||
|
||||
var currentSummary = Summarize(currentWeekDays);
|
||||
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
||||
|
|
@ -149,7 +207,7 @@ public static class WeeklyReportService
|
|||
PvChangePercent = pvChange,
|
||||
ConsumptionChangePercent = consumptionChange,
|
||||
GridImportChangePercent = gridImportChange,
|
||||
DailyData = allDays,
|
||||
DailyData = currentWeekDays,
|
||||
Behavior = behavior,
|
||||
AiInsight = aiInsight,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -133,6 +133,28 @@
|
|||
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
|
||||
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
|
||||
"noReportData": "Keine Berichtsdaten gefunden.",
|
||||
"weeklyTab": "Wöchentlich",
|
||||
"monthlyTab": "Monatlich",
|
||||
"yearlyTab": "Jährlich",
|
||||
"monthlyReportTitle": "Monatlicher Leistungsbericht",
|
||||
"yearlyReportTitle": "Jährlicher Leistungsbericht",
|
||||
"monthlyInsights": "Monatliche Einblicke",
|
||||
"yearlyInsights": "Jährliche Einblicke",
|
||||
"monthlySavings": "Ihre Einsparungen diesen Monat",
|
||||
"yearlySavings": "Ihre Einsparungen dieses Jahr",
|
||||
"monthlySummary": "Monatliche Zusammenfassung",
|
||||
"yearlySummary": "Jährliche Zusammenfassung",
|
||||
"total": "Gesamt",
|
||||
"weeksAggregated": "{count} Wochen aggregiert",
|
||||
"monthsAggregated": "{count} Monate aggregiert",
|
||||
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
|
||||
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
|
||||
"availableForGeneration": "Zur Generierung verfügbar",
|
||||
"generateMonth": "{month} {year} generieren ({count} Wochen)",
|
||||
"generateYear": "{year} generieren ({count} Monate)",
|
||||
"generatingMonthly": "Wird generiert...",
|
||||
"generatingYearly": "Wird generiert...",
|
||||
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
|
||||
"ai_analyzing": "KI analysiert...",
|
||||
"ai_show_details": "Details anzeigen",
|
||||
"ai_show_less": "Weniger anzeigen",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,28 @@
|
|||
"confirmBatteryLogDownload": "Do you really want to download battery log?",
|
||||
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
|
||||
"noReportData": "No report data found.",
|
||||
"weeklyTab": "Weekly",
|
||||
"monthlyTab": "Monthly",
|
||||
"yearlyTab": "Yearly",
|
||||
"monthlyReportTitle": "Monthly Performance Report",
|
||||
"yearlyReportTitle": "Annual Performance Report",
|
||||
"monthlyInsights": "Monthly Insights",
|
||||
"yearlyInsights": "Annual Insights",
|
||||
"monthlySavings": "Your Savings This Month",
|
||||
"yearlySavings": "Your Savings This Year",
|
||||
"monthlySummary": "Monthly Summary",
|
||||
"yearlySummary": "Annual Summary",
|
||||
"total": "Total",
|
||||
"weeksAggregated": "{count} weeks aggregated",
|
||||
"monthsAggregated": "{count} months aggregated",
|
||||
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
|
||||
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
|
||||
"availableForGeneration": "Available for Generation",
|
||||
"generateMonth": "Generate {month} {year} ({count} weeks)",
|
||||
"generateYear": "Generate {year} ({count} months)",
|
||||
"generatingMonthly": "Generating...",
|
||||
"generatingYearly": "Generating...",
|
||||
"thisMonthWeeklyReports": "This Month's Weekly Reports",
|
||||
"ai_analyzing": "AI is analyzing...",
|
||||
"ai_show_details": "Show details",
|
||||
"ai_show_less": "Show less",
|
||||
|
|
|
|||
|
|
@ -127,6 +127,28 @@
|
|||
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
|
||||
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
|
||||
"noReportData": "Aucune donnée de rapport trouvée.",
|
||||
"weeklyTab": "Hebdomadaire",
|
||||
"monthlyTab": "Mensuel",
|
||||
"yearlyTab": "Annuel",
|
||||
"monthlyReportTitle": "Rapport de performance mensuel",
|
||||
"yearlyReportTitle": "Rapport de performance annuel",
|
||||
"monthlyInsights": "Aperçus mensuels",
|
||||
"yearlyInsights": "Aperçus annuels",
|
||||
"monthlySavings": "Vos économies ce mois",
|
||||
"yearlySavings": "Vos économies cette année",
|
||||
"monthlySummary": "Résumé mensuel",
|
||||
"yearlySummary": "Résumé annuel",
|
||||
"total": "Total",
|
||||
"weeksAggregated": "{count} semaines agrégées",
|
||||
"monthsAggregated": "{count} mois agrégés",
|
||||
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
|
||||
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
|
||||
"availableForGeneration": "Disponible pour génération",
|
||||
"generateMonth": "Générer {month} {year} ({count} semaines)",
|
||||
"generateYear": "Générer {year} ({count} mois)",
|
||||
"generatingMonthly": "Génération en cours...",
|
||||
"generatingYearly": "Génération en cours...",
|
||||
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
|
||||
"ai_analyzing": "L'IA analyse...",
|
||||
"ai_show_details": "Afficher les détails",
|
||||
"ai_show_less": "Afficher moins",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,28 @@
|
|||
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
|
||||
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
|
||||
"noReportData": "Nessun dato del rapporto trovato.",
|
||||
"weeklyTab": "Settimanale",
|
||||
"monthlyTab": "Mensile",
|
||||
"yearlyTab": "Annuale",
|
||||
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
|
||||
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
|
||||
"monthlyInsights": "Approfondimenti mensili",
|
||||
"yearlyInsights": "Approfondimenti annuali",
|
||||
"monthlySavings": "I tuoi risparmi questo mese",
|
||||
"yearlySavings": "I tuoi risparmi quest'anno",
|
||||
"monthlySummary": "Riepilogo mensile",
|
||||
"yearlySummary": "Riepilogo annuale",
|
||||
"total": "Totale",
|
||||
"weeksAggregated": "{count} settimane aggregate",
|
||||
"monthsAggregated": "{count} mesi aggregati",
|
||||
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
|
||||
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
|
||||
"availableForGeneration": "Disponibile per la generazione",
|
||||
"generateMonth": "Genera {month} {year} ({count} settimane)",
|
||||
"generateYear": "Genera {year} ({count} mesi)",
|
||||
"generatingMonthly": "Generazione in corso...",
|
||||
"generatingYearly": "Generazione in corso...",
|
||||
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
|
||||
"ai_analyzing": "L'IA sta analizzando...",
|
||||
"ai_show_details": "Mostra dettagli",
|
||||
"ai_show_less": "Mostra meno",
|
||||
|
|
|
|||
Loading…
Reference in New Issue