diff --git a/.gitignore b/.gitignore index 1a9d879a1..8fd0639d4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *.DotSettings.user **/.idea/ **/.env +.claude/ diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 46b4e3ad8..4f74271a2 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -200,6 +200,10 @@ public class Controller : ControllerBase bucketPath = "s3://" + installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/" + startTimestamp; else if (installation.Product == (int)ProductType.SodioHome) bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp; + else if (installation.Product == (int)ProductType.SodistoreGrid) + bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp; + else if (installation.Product == (int)ProductType.SodistorePro) + bucketPath = "s3://" + installation.S3BucketId + "-325c9373-9025-4a8d-bf5a-f9eedf1f155c/" + startTimestamp; else bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp; Console.WriteLine("Fetching data for "+startTimestamp); @@ -537,16 +541,29 @@ public class Controller : ControllerBase public ActionResult> GetAllSodioHomeInstallations(Token authToken) { var user = Db.GetSession(authToken)?.User; - + if (user is null) return Unauthorized(); - + return user .AccessibleInstallations(product:(int)ProductType.SodioHome) .ToList(); } - - + + [HttpGet(nameof(GetAllSodistoreGridInstallations))] + public ActionResult> GetAllSodistoreGridInstallations(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + + if (user is null) + return Unauthorized(); + + return user + .AccessibleInstallations(product:(int)ProductType.SodistoreGrid) + .ToList(); + } + + [HttpGet(nameof(GetAllFolders))] public ActionResult> GetAllFolders(Token authToken) @@ -737,7 +754,17 @@ public class Controller : ControllerBase return installation.HideParentIfUserHasNoAccessToParent(session!.User); } - + + [HttpGet(nameof(GetNetworkProviders))] + public ActionResult> GetNetworkProviders(Token authToken) + { + var session = Db.GetSession(authToken); + if (session is null) + return Unauthorized(); + + return Ok(NetworkProviderService.GetProviders()); + } + [HttpPost(nameof(AcknowledgeError))] public ActionResult AcknowledgeError(Int64 id, Token authToken) { @@ -790,9 +817,10 @@ public class Controller : ControllerBase if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - // AI diagnostics are scoped to SodistoreHome and SodiStoreMax only + // AI diagnostics are scoped to SodistoreHome, SodiStoreMax, and SodistorePro only if (installation.Product != (int)ProductType.SodioHome && - installation.Product != (int)ProductType.SodiStoreMax) + installation.Product != (int)ProductType.SodiStoreMax && + installation.Product != (int)ProductType.SodistorePro) return BadRequest("AI diagnostics not available for this product."); var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en"); @@ -897,11 +925,13 @@ public class Controller : ControllerBase // ── Weekly Performance Report ────────────────────────────────────── /// - /// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx - /// Returns JSON with daily data, weekly totals, ratios, and AI insight. + /// Returns a weekly performance report. Serves from cache if available; + /// generates fresh on first request or when forceRegenerate is true. /// [HttpGet(nameof(GetWeeklyReport))] - public async Task> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null) + public async Task> GetWeeklyReport( + Int64 installationId, Token authToken, String? language = null, + String? weekStart = null, Boolean forceRegenerate = false) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -911,14 +941,57 @@ 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 lang = language ?? user.Language ?? "en"; + + // Compute target week dates for cache lookup + DateOnly periodStart, periodEnd; + if (weekStartDate.HasValue) + { + periodStart = weekStartDate.Value; + periodEnd = weekStartDate.Value.AddDays(6); + } + else + { + (periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek(); + } + + var periodStartStr = periodStart.ToString("yyyy-MM-dd"); + var periodEndStr = periodEnd.ToString("yyyy-MM-dd"); + + // Cache-first: check if a cached report exists for this week + if (!forceRegenerate) + { + var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr); + if (cached != null) + { + var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang); + if (cachedResponse != null) + { + Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}"); + return Ok(cachedResponse); + } + } + } + + // Cache miss or forceRegenerate: generate fresh + Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}"); + var report = await WeeklyReportService.GenerateReportAsync( + installationId, installation.Name, lang, weekStartDate); + + // Persist weekly summary and seed AiInsightCache for this language + ReportAggregationService.SaveWeeklySummary(installationId, report, lang); + return Ok(report); } catch (Exception ex) @@ -945,8 +1018,8 @@ public class Controller : ControllerBase try { var lang = user.Language ?? "en"; - var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); - await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang); + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang); + await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang, user.Name); return Ok(new { message = $"Report sent to {emailAddress}" }); } catch (Exception ex) @@ -956,6 +1029,555 @@ public class Controller : ControllerBase } } + // ── Monthly & Yearly Reports ───────────────────────────────────── + + [HttpGet(nameof(GetPendingMonthlyAggregations))] + public ActionResult> 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> 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>> 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>> 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); + } + + /// + /// Manually trigger monthly aggregation for an installation. + /// Computes monthly report from daily records for the specified year/month. + /// + [HttpPost(nameof(TriggerMonthlyAggregation))] + public async Task 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}"); + } + } + + /// + /// Manually trigger yearly aggregation for an installation. + /// Aggregates monthly reports for the specified year into a yearly report. + /// + [HttpPost(nameof(TriggerYearlyAggregation))] + public async Task 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}"); + } + } + + /// + /// Manually trigger xlsx ingestion for all SodioHome installations. + /// Scans tmp_report/ for all matching xlsx files and ingests any new days. + /// + [HttpPost(nameof(IngestAllDailyData))] + public async Task 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}"); + } + } + + /// + /// Manually trigger xlsx ingestion for one installation. + /// Parses tmp_report/{installationId}*.xlsx and stores any new days in DailyEnergyRecord. + /// + [HttpPost(nameof(IngestDailyData))] + public async Task 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 ────────────────────────────────── + + /// + /// Returns the stored DailyEnergyRecord rows for an installation and date range. + /// Use this to verify that xlsx ingestion worked correctly before generating reports. + /// + [HttpGet(nameof(GetDailyRecords))] + public ActionResult> 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 }); + } + + [HttpGet(nameof(GetHourlyRecords))] + public ActionResult> GetHourlyRecords( + 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.GetHourlyRecords(installationId, fromDate, toDate); + return Ok(new { count = records.Count, records }); + } + + /// + /// Returns daily + hourly records for a date range. + /// Fallback chain: DB → JSON (local + S3) → xlsx. Caches to DB on first parse. + /// + [HttpGet(nameof(GetDailyDetailRecords))] + public ActionResult GetDailyDetailRecords( + 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."); + + // 1. Try DB + var dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate); + var hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate); + + if (dailyRecords.Count > 0 && hourlyRecords.Count > 0) + return Ok(FormatResult(dailyRecords, hourlyRecords)); + + // 2. Fallback: try JSON (local files + S3) + TryIngestFromJson(installationId, installation, fromDate, toDate); + dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate); + hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate); + + if (dailyRecords.Count > 0 && hourlyRecords.Count > 0) + return Ok(FormatResult(dailyRecords, hourlyRecords)); + + // 3. Fallback: parse xlsx + cache to DB + TryIngestFromXlsx(installationId, fromDate, toDate); + dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate); + hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate); + + return Ok(FormatResult(dailyRecords, hourlyRecords)); + } + + private static Object FormatResult( + List daily, List hourly) => new + { + dailyRecords = new { count = daily.Count, records = daily }, + hourlyRecords = new { count = hourly.Count, records = hourly }, + }; + + private static void TryIngestFromJson( + Int64 installationId, Installation installation, + DateOnly fromDate, DateOnly toDate) + { + for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + { + var isoDate = date.ToString("yyyy-MM-dd"); + + if (Db.DailyRecordExists(installationId, isoDate)) + continue; + + var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate) + .GetAwaiter().GetResult(); + + if (content is null) continue; + + DailyIngestionService.IngestJsonContent(installationId, content); + } + } + + private static void TryIngestFromXlsx( + Int64 installationId, DateOnly fromDate, DateOnly toDate) + { + var xlsxFiles = WeeklyReportService.GetRelevantXlsxFiles(installationId, fromDate, toDate); + if (xlsxFiles.Count == 0) return; + + foreach (var xlsxPath in xlsxFiles) + { + foreach (var day in ExcelDataParser.Parse(xlsxPath)) + { + 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"), + }); + } + + foreach (var hour in ExcelDataParser.ParseHourly(xlsxPath)) + { + 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"), + }); + } + } + } + + /// + /// 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. + /// + [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 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.Name, emailAddress, lang, user.Name); + 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 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.Name, emailAddress, lang, user.Name); + 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}"); + } + } + + // ── Report HTML (for PDF download) ───────────────────────────── + + [HttpGet(nameof(GetWeeklyReportHtml))] + public async Task GetWeeklyReportHtml(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 report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang); + var html = ReportEmailService.BuildHtmlEmail(report, lang); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetMonthlyReportHtml))] + public async Task GetMonthlyReportHtml(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 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}."); + + report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang); + var s = ReportEmailService.GetAggregatedStrings(lang, "monthly"); + var html = ReportEmailService.BuildAggregatedHtmlEmail( + report.PeriodStart, report.PeriodEnd, installation.Name, + 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); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetYearlyReportHtml))] + public async Task GetYearlyReportHtml(Int64 installationId, Int32 year, 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 report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year); + if (report == null) return BadRequest($"No yearly report found for {year}."); + + report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang); + var s = ReportEmailService.GetAggregatedStrings(lang, "yearly"); + var html = ReportEmailService.BuildAggregatedHtmlEmail( + report.PeriodStart, report.PeriodEnd, installation.Name, + 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); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetDailyReportHtml))] + public ActionResult GetDailyReportHtml(Int64 installationId, String date, 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(); + + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", out var parsedDate)) + return BadRequest("date must be in yyyy-MM-dd format."); + + var records = Db.GetDailyRecords(installationId, parsedDate, parsedDate); + if (records.Count == 0) return BadRequest($"No daily record found for {date}."); + + var lang = language ?? user.Language ?? "en"; + var html = ReportEmailService.BuildDailyHtmlEmail(records[0], installation.Name, lang); + return Content(html, "text/html"); + } + + [HttpGet(nameof(GetWeeklyReportSummaries))] + public async Task>> 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 UpdateFolder([FromBody] Folder folder, Token authToken) { @@ -963,7 +1585,7 @@ public class Controller : ControllerBase if (!session.Update(folder)) return Unauthorized(); - + return folder.HideParentIfUserHasNoAccessToParent(session!.User); } @@ -1179,6 +1801,7 @@ public class Controller : ControllerBase 0 => config.GetConfigurationSalimax(), // Salimax 3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax 2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome + 4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid _ => config.GetConfigurationString() // fallback }; @@ -1385,7 +2008,346 @@ public class Controller : ControllerBase "AlarmKnowledgeBaseChecked.cs"); } + [HttpGet(nameof(DryRunS3Cleanup))] + public async Task> DryRunS3Cleanup(Token authToken, long? installationId = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var result = await DeleteOldData.DeleteOldDataFromS3.DryRun(installationId); + return Ok(result); + } + + // ═══════════════════════════════════════════════ + // TICKET ENDPOINTS (admin-only) + // ═══════════════════════════════════════════════ + + [HttpGet(nameof(GetAllTickets))] + public ActionResult> GetAllTickets(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Db.GetAllTickets(); + } + + [HttpGet(nameof(GetTicketsForInstallation))] + public ActionResult> GetTicketsForInstallation(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null) return NotFound(); + + return Db.GetTicketsForInstallation(installationId); + } + + [HttpGet(nameof(GetTicketById))] + public ActionResult GetTicketById(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + return ticket is null ? NotFound() : ticket; + } + + [HttpPost(nameof(CreateTicket))] + public ActionResult CreateTicket([FromBody] Ticket ticket, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + ticket.CreatedByUserId = user.Id; + ticket.CreatedAt = DateTime.UtcNow; + ticket.UpdatedAt = DateTime.UtcNow; + ticket.Status = (Int32)TicketStatus.Open; + + if (!Db.Create(ticket)) return StatusCode(500, "Failed to create ticket."); + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.Created, + Description = $"Ticket created by {user.Name}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + // Fire-and-forget AI diagnosis + var lang = user.Language ?? "en"; + TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); + + return ticket; + } + + [HttpPut(nameof(UpdateTicket))] + public ActionResult UpdateTicket([FromBody] Ticket ticket, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var existing = Db.GetTicketById(ticket.Id); + if (existing is null) return NotFound(); + + // Enforce resolution when resolving + if (ticket.Status == (Int32)TicketStatus.Resolved + && (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution))) + { + return BadRequest("Root Cause and Solution are required to resolve a ticket."); + } + + ticket.CreatedAt = existing.CreatedAt; + ticket.CreatedByUserId = existing.CreatedByUserId; + ticket.UpdatedAt = DateTime.UtcNow; + + // Track resolution added + var resolutionAdded = String.IsNullOrWhiteSpace(existing.RootCause) + && !String.IsNullOrWhiteSpace(ticket.RootCause); + + if (ticket.Status != existing.Status) + { + if (ticket.Status == (Int32)TicketStatus.Resolved) + ticket.ResolvedAt = DateTime.UtcNow; + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.StatusChanged, + Description = $"Status changed to {(TicketStatus)ticket.Status}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + } + + if (resolutionAdded) + { + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.ResolutionAdded, + Description = $"Resolution added by {user.Name}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + } + + var assigneeChanged = ticket.AssigneeId != existing.AssigneeId + && ticket.AssigneeId.HasValue; + + if (assigneeChanged) + { + var assignee = Db.GetUserById(ticket.AssigneeId); + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.Assigned, + Description = $"Ticket assigned to {assignee?.Name ?? "unknown"}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + if (assignee is not null) + _ = Task.Run(async () => + { + try { await assignee.SendTicketAssignedEmail(ticket); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email: {ex}"); } + }); + } + + return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); + } + + [HttpDelete(nameof(DeleteTicket))] + public ActionResult DeleteTicket(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + if (ticket is null) return NotFound(); + + return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed."); + } + + [HttpGet(nameof(GetTicketComments))] + public ActionResult> GetTicketComments(Int64 ticketId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Db.GetCommentsForTicket(ticketId); + } + + [HttpPost(nameof(AddTicketComment))] + public ActionResult AddTicketComment([FromBody] TicketComment comment, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is null) return NotFound(); + + comment.AuthorType = (Int32)CommentAuthorType.Human; + comment.AuthorId = user.Id; + comment.CreatedAt = DateTime.UtcNow; + + if (!Db.Create(comment)) return StatusCode(500, "Failed to add comment."); + + Db.Create(new TicketTimelineEvent + { + TicketId = comment.TicketId, + EventType = (Int32)TimelineEventType.CommentAdded, + Description = $"Comment added by {user.Name}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + + return comment; + } + + [HttpGet(nameof(GetTicketDetail))] + public ActionResult GetTicketDetail(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var ticket = Db.GetTicketById(id); + if (ticket is null) return NotFound(); + + var installation = ticket.InstallationId.HasValue ? Db.GetInstallationById(ticket.InstallationId.Value) : null; + var creator = Db.GetUserById(ticket.CreatedByUserId); + var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null; + + return new + { + ticket, + comments = Db.GetCommentsForTicket(id), + diagnosis = Db.GetDiagnosisForTicket(id), + timeline = Db.GetTimelineForTicket(id), + installationName = installation?.Name ?? (ticket.InstallationId.HasValue ? $"#{ticket.InstallationId}" : "No installation"), + installationProduct = installation?.Product, + creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}", + assigneeName = assignee?.Name + }; + } + + [HttpGet(nameof(GetTicketSummaries))] + public ActionResult GetTicketSummaries(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var tickets = Db.GetAllTickets(); + var summaries = tickets.Select(t => + { + var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null; + return new + { + t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory, + t.InstallationId, t.CreatedAt, t.UpdatedAt, + t.CustomSubCategory, t.CustomCategory, + installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation") + }; + }); + + return Ok(summaries); + } + + [HttpGet(nameof(GetCustomSubCategories))] + public ActionResult> GetCustomSubCategories(Int32 category, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Ok(Db.GetCustomSubCategoriesForCategory(category)); + } + + [HttpGet(nameof(GetCustomCategories))] + public ActionResult> GetCustomCategories(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Ok(Db.GetCustomCategories()); + } + + [HttpGet(nameof(GetAdminUsers))] + public ActionResult> GetAdminUsers(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Ok(Db.Users + .Where(u => u.UserType == 2) + .Select(u => new { u.Id, u.Name }) + .ToList()); + } + + [HttpPost(nameof(SubmitDiagnosisFeedback))] + public ActionResult SubmitDiagnosisFeedback(Int64 ticketId, Int32 feedback, String? overrideText, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var diagnosis = Db.GetDiagnosisForTicket(ticketId); + if (diagnosis is null) return NotFound(); + + diagnosis.Feedback = feedback; + diagnosis.OverrideText = overrideText; + + if (!Db.Update(diagnosis)) return StatusCode(500, "Failed to save feedback."); + + // On Accept: pre-fill ticket resolution from AI (only if not already filled) + if (feedback == (Int32)DiagnosisFeedback.Accepted) + { + var ticket = Db.GetTicketById(ticketId); + if (ticket is not null && String.IsNullOrWhiteSpace(ticket.RootCause)) + { + ticket.RootCause = diagnosis.RootCause ?? ""; + + // RecommendedActions is stored as JSON array — parse to readable text + try + { + var actions = JsonConvert.DeserializeObject(diagnosis.RecommendedActions ?? "[]"); + ticket.Solution = actions is not null ? String.Join("\n", actions) : ""; + } + catch + { + ticket.Solution = diagnosis.RecommendedActions ?? ""; + } + + ticket.PreFilledFromAi = true; + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + } + } + + return Ok(); + } + + [HttpPut(nameof(AcknowledgeTerms))] + public ActionResult AcknowledgeTerms(Int32 version, Token authToken) + { + var session = Db.GetSession(authToken); + if (session is null) return Unauthorized(); + + var user = Db.GetUserById(session.User.Id); + if (user is null) return Unauthorized(); + + user.AcknowledgedTermsVersion = version; + return Db.Update(user) ? Ok() : StatusCode(500); + } + } - - - diff --git a/csharp/App/Backend/DataTypes/AiInsightCache.cs b/csharp/App/Backend/DataTypes/AiInsightCache.cs new file mode 100644 index 000000000..ea411e9d7 --- /dev/null +++ b/csharp/App/Backend/DataTypes/AiInsightCache.cs @@ -0,0 +1,29 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// 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. +/// +public class AiInsightCache +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + /// "weekly" | "monthly" | "yearly" + [Indexed] + public String ReportType { get; set; } = ""; + + /// FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id + [Indexed] + public Int64 ReportId { get; set; } + + /// ISO 639-1 language code: "en" | "de" | "fr" | "it" + public String Language { get; set; } = "en"; + + public String InsightText { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index a99492044..5c0726ca5 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -48,6 +48,12 @@ public class Configuration $"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ $"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}"; } + + // TODO: SodistoreGrid — update configuration fields when defined + public string GetConfigurationSodistoreGrid() + { + return ""; + } } public enum CalibrationChargeType diff --git a/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs b/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs new file mode 100644 index 000000000..713f7c224 --- /dev/null +++ b/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs @@ -0,0 +1,31 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// 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). +/// +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; } = ""; +} diff --git a/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs b/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs new file mode 100644 index 000000000..47899212e --- /dev/null +++ b/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs @@ -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; } = ""; +} diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index fe9cb24b1..a7fb0fb39 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -7,7 +7,9 @@ public enum ProductType Salimax = 0, Salidomo = 1, SodioHome =2, - SodiStoreMax=3 + SodiStoreMax=3, + SodistoreGrid=4, + SodistorePro=5 } public enum StatusType @@ -26,6 +28,13 @@ public class Installation : TreeNode public String Location { get; set; } = ""; public String Region { get; set; } = ""; public String Country { get; set; } = ""; + public String Street { get; set; } = ""; + public String PostCode { get; set; } = ""; + public String City { get; set; } = ""; + public String Canton { get; set; } = ""; + public String DistributionPartner { get; set; } = ""; + public String InverterFirmwareVersion { get; set; } = ""; + public String BatteryFirmwareVersion { get; set; } = ""; public String VpnIp { get; set; } = ""; public String InstallationName { get; set; } = ""; @@ -48,9 +57,14 @@ public class Installation : TreeNode public int BatteryClusterNumber { get; set; } = 0; public int BatteryNumber { get; set; } = 0; public string BatterySerialNumbers { get; set; } = ""; - + public string PvStringsPerInverter { get; set; } = ""; + public string InstallationModel { get; set; } = ""; + public string ExternalEms { get; set; } = "No"; + public string CouplingType { get; set; } = "DC"; + [Ignore] public String OrderNumbers { get; set; } public String VrmLink { get; set; } = ""; public string Configuration { get; set; } = ""; + public string NetworkProvider { get; set; } = ""; } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs index a0173ada5..44915ab37 100644 --- a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs +++ b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs @@ -145,6 +145,8 @@ public static class ExoCmd const String method = "iam-role"; String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: + installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: + installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; @@ -263,42 +265,70 @@ public static class ExoCmd public static async Task RevokeReadKey(this Installation installation) { - //Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature - var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3Key}"; var method = $"access-key/{installation.S3Key}"; - + var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60; var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime); - + var client = new HttpClient(); - + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader); - - var response = await client.DeleteAsync(url); - return response.IsSuccessStatusCode; + + try + { + var response = await client.DeleteAsync(url); + + if (response.IsSuccessStatusCode) + { + Console.WriteLine($"Successfully revoked read key for installation {installation.Id}."); + return true; + } + + Console.WriteLine($"Failed to revoke read key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}"); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"Error occurred while revoking read key: {ex.Message}"); + return false; + } } public static async Task RevokeWriteKey(this Installation installation) { - //Check exoscale documentation https://openapi-v2.exoscale.com/topic/topic-api-request-signature - var url = $"https://api-ch-dk-2.exoscale.com/v2/access-key/{installation.S3WriteKey}"; var method = $"access-key/{installation.S3WriteKey}"; - + var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60; var authheader = "credential="+S3Credentials.Key+",expires="+unixtime+",signature="+BuildSignature("DELETE", method, unixtime); - + var client = new HttpClient(); - + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("EXO2-HMAC-SHA256", authheader); - - var response = await client.DeleteAsync(url); - return response.IsSuccessStatusCode; + + try + { + var response = await client.DeleteAsync(url); + + if (response.IsSuccessStatusCode) + { + Console.WriteLine($"Successfully revoked write key for installation {installation.Id}."); + return true; + } + + Console.WriteLine($"Failed to revoke write key. HTTP Status: {response.StatusCode}. Response: {await response.Content.ReadAsStringAsync()}"); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"Error occurred while revoking write key: {ex.Message}"); + return false; + } } public static async Task<(String, String)> CreateWriteKey(this Installation installation) @@ -320,6 +350,8 @@ public static class ExoCmd const String method = "iam-role"; String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: + installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: + installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; var contentString = $$""" @@ -371,19 +403,44 @@ public static class ExoCmd return await s3Region.PutBucket(installation.BucketName()) != null; } - public static async Task DeleteBucket(this Installation installation) + public static async Task PurgeAndDeleteBucket(this Installation installation) { var s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!); - return await s3Region.DeleteBucket(installation.BucketName()) ; + var bucket = s3Region.Bucket(installation.BucketName()); + + try + { + var purged = await bucket.PurgeBucket(); + if (!purged) + { + Console.WriteLine($"Failed to purge bucket {installation.BucketName()} for installation {installation.Id}."); + return false; + } + + var deleted = await s3Region.DeleteBucket(installation.BucketName()); + if (!deleted) + { + Console.WriteLine($"Failed to delete bucket {installation.BucketName()} for installation {installation.Id}."); + return false; + } + + Console.WriteLine($"Successfully purged and deleted bucket {installation.BucketName()} for installation {installation.Id}."); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error occurred while purging/deleting bucket {installation.BucketName()}: {ex.Message}"); + return false; + } } public static async Task SendConfig(this Installation installation, Configuration config) { - var maxRetransmissions = 4; + var maxRetransmissions = 6; UdpClient udpClient = new UdpClient(); - udpClient.Client.ReceiveTimeout = 2000; + udpClient.Client.ReceiveTimeout = 3000; int port = 9000; Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp); diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index 7072e785a..074b62638 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -10,6 +10,8 @@ public static class InstallationMethods private static readonly String BucketNameSalt = "3e5b3069-214a-43ee-8d85-57d72000c19d"; private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"; private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa"; + private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8"; + private static readonly String SodistoreProBucketNameSalt = "325c9373-9025-4a8d-bf5a-f9eedf1f155c"; public static String BucketName(this Installation installation) { @@ -17,12 +19,22 @@ public static class InstallationMethods { return $"{installation.S3BucketId}-{BucketNameSalt}"; } - + if (installation.Product == (int)ProductType.SodioHome) { return $"{installation.S3BucketId}-{SodioHomeBucketNameSalt}"; } + if (installation.Product == (int)ProductType.SodistoreGrid) + { + return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}"; + } + + if (installation.Product == (int)ProductType.SodistorePro) + { + return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}"; + } + return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}"; } diff --git a/csharp/App/Backend/DataTypes/Methods/Session.cs b/csharp/App/Backend/DataTypes/Methods/Session.cs index 3b62cb6f3..b57b7ce13 100644 --- a/csharp/App/Backend/DataTypes/Methods/Session.cs +++ b/csharp/App/Backend/DataTypes/Methods/Session.cs @@ -239,7 +239,7 @@ public static class SessionMethods } - if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro) { return user is not null && user.UserType != 0 @@ -295,9 +295,9 @@ public static class SessionMethods .Apply(Db.Update); } - if (installation.Product == (int)ProductType.SodiStoreMax) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro) { - + return user is not null && installation is not null && original is not null @@ -305,7 +305,7 @@ public static class SessionMethods && user.HasAccessTo(installation) && installation .WithParentOf(original) // prevent moving - .Apply(Db.Update); + .Apply(Db.Update); } @@ -324,23 +324,22 @@ public static class SessionMethods { var user = session?.User; - if (user is not null - && installation is not null - && user.UserType != 0) - { - - return - Db.Delete(installation) - && await installation.RevokeReadKey() - && await installation.RevokeWriteKey() - && await installation.RemoveReadRole() - && await installation.RemoveWriteRole() - && await installation.DeleteBucket(); - - } + if (user is null || installation is null || user.UserType == 0) + return false; - return false; + // Try all Exoscale operations independently (don't short-circuit) + var readKeyOk = await installation.RevokeReadKey(); + var writeKeyOk = await installation.RevokeWriteKey(); + var readRoleOk = await installation.RemoveReadRole(); + var writeRoleOk = await installation.RemoveWriteRole(); + var bucketOk = await installation.PurgeAndDeleteBucket(); + if (!readKeyOk || !writeKeyOk || !readRoleOk || !writeRoleOk || !bucketOk) + Console.WriteLine($"[Delete] Partial Exoscale cleanup for installation {installation.Id}: " + + $"readKey={readKeyOk}, writeKey={writeKeyOk}, readRole={readRoleOk}, writeRole={writeRoleOk}, bucket={bucketOk}"); + + // Always delete from DB (best-effort — admin wants it gone) + return Db.Delete(installation); } public static Boolean Create(this Session? session, User newUser) diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 5792993e6..6eb13cbaa 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -243,22 +243,22 @@ public static class UserMethods var (subject, body) = (user.Language ?? "en") switch { "de" => ( - "Passwort Ihres Inesco Energy Kontos zurücksetzen", + "Passwort Ihres inesco energy Kontos zurücksetzen", $"Sehr geehrte/r {user.Name}\n" + $"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}" ), "fr" => ( - "Réinitialisation du mot de passe de votre compte Inesco Energy", + "Réinitialisation du mot de passe de votre compte inesco energy", $"Cher/Chère {user.Name}\n" + $"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}" ), "it" => ( - "Reimposta la password del tuo account Inesco Energy", + "Reimposta la password del tuo account inesco energy", $"Gentile {user.Name}\n" + $"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}" ), _ => ( - "Reset the password of your Inesco Energy Account", + "Reset the password of your inesco energy Account", $"Dear {user.Name}\n" + $"To reset your password please open this link: {resetLink}?token={encodedToken}" ) @@ -274,28 +274,89 @@ public static class UserMethods var (subject, body) = (user.Language ?? "en") switch { "de" => ( - "Ihr neues Inesco Energy Konto", + "Ihr neues inesco energy Konto", $"Sehr geehrte/r {user.Name}\n" + - $"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" + $"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" ), "fr" => ( - "Votre nouveau compte Inesco Energy", + "Votre nouveau compte inesco energy", $"Cher/Chère {user.Name}\n" + - $"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}" + $"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}" ), "it" => ( - "Il tuo nuovo account Inesco Energy", + "Il tuo nuovo account inesco energy", $"Gentile {user.Name}\n" + - $"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}" + $"Per impostare la password e accedere al suo account inesco energy, apra questo link: {resetLink}" ), _ => ( - "Your new Inesco Energy Account", + "Your new inesco energy Account", $"Dear {user.Name}\n" + - $"To set your password and log in to your Inesco Energy Account open this link: {resetLink}" + $"To set your password and log in to your inesco energy Account open this link: {resetLink}" ) }; return user.SendEmail(subject, body); } - + + public static Task SendTicketAssignedEmail(this User user, Ticket ticket) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + var priority = (TicketPriority)ticket.Priority; + var category = (TicketCategory)ticket.Category; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde Ihnen zugewiesen", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ein Ticket wurde Ihnen zugewiesen:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n" + + $"Priorität: {priority}\n" + + $"Kategorie: {category}\n\n" + + $"Beschreibung:\n{ticket.Description}\n\n" + + $"Öffnen Sie das Ticket hier: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} vous a été attribué", + $"Cher/Chère {user.Name},\n\n" + + $"Un ticket vous a été attribué :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n" + + $"Priorité : {priority}\n" + + $"Catégorie : {category}\n\n" + + $"Description :\n{ticket.Description}\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} le è stato assegnato", + $"Gentile {user.Name},\n\n" + + $"Le è stato assegnato un ticket:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n" + + $"Priorità: {priority}\n" + + $"Categoria: {category}\n\n" + + $"Descrizione:\n{ticket.Description}\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been assigned to you", + $"Dear {user.Name},\n\n" + + $"A ticket has been assigned to you:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n" + + $"Priority: {priority}\n" + + $"Category: {category}\n\n" + + $"Description:\n{ticket.Description}\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/ReportSummary.cs b/csharp/App/Backend/DataTypes/ReportSummary.cs new file mode 100644 index 000000000..3a17c4117 --- /dev/null +++ b/csharp/App/Backend/DataTypes/ReportSummary.cs @@ -0,0 +1,155 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Stored summary for a weekly report period. +/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation. +/// +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; } = ""; + + /// + /// Full serialized WeeklyReportResponse (with AiInsight cleared). + /// Used for cache-first serving — avoids regenerating numeric data + Mistral call. + /// + public String ResponseJson { get; set; } = ""; + + public String CreatedAt { get; set; } = ""; +} + +/// +/// Aggregated monthly report. Created from weekly summaries at month-end. +/// Consumed and deleted by yearly aggregation. +/// +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; } = ""; +} + +/// +/// Aggregated yearly report. Created from monthly summaries at year-end. +/// Kept indefinitely. +/// +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; } +} diff --git a/csharp/App/Backend/DataTypes/Ticket.cs b/csharp/App/Backend/DataTypes/Ticket.cs new file mode 100644 index 000000000..8ef385495 --- /dev/null +++ b/csharp/App/Backend/DataTypes/Ticket.cs @@ -0,0 +1,80 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } +public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 } + +public enum TicketCategory +{ + Hardware = 0, + Software = 1, + // Network = 2 removed — value reserved for legacy data + UserAccess = 3, + Firmware = 4, + Configuration = 5, + Other = 6 +} + +public enum TicketSubCategory +{ + General = 0, // legacy only — not offered for new tickets + OtherLegacy = 99, // legacy catch-all — not offered for new tickets + + // Hardware (1xx) + Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, + Metering = 104, PV = 105, + HardwareOther = 199, + + // Software (2xx) + Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203, + SoftwareOther = 299, + + // Network (3xx) — legacy, not offered for new tickets + Connectivity = 300, VpnAccess = 301, S3Storage = 302, + + // UserAccess (4xx) + Login = 400, Permissions = 401, + UserAccessOther = 499, + + // Firmware (5xx) + BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502, + ExternalEmsFirmware = 503, + FirmwareOther = 599, + + // Configuration (6xx) + BMS = 600, ConfigMonitor = 601, ExternalEMS = 602, + ConfigurationOther = 699 +} + +public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } + +public class Ticket +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + public String Subject { get; set; } = ""; + public String Description { get; set; } = ""; + + [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; + public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; + public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; + public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery; + public Int32 Source { get; set; } = (Int32)TicketSource.Manual; + + [Indexed] public Int64? InstallationId { get; set; } + public Int64? AssigneeId { get; set; } + [Indexed] public Int64 CreatedByUserId { get; set; } + + public String Tags { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ResolvedAt { get; set; } + + public String? RootCause { get; set; } + public String? Solution { get; set; } + public Boolean PreFilledFromAi { get; set; } + + public String? CustomSubCategory { get; set; } + public String? CustomCategory { get; set; } +} diff --git a/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs b/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs new file mode 100644 index 000000000..fd9b54f7c --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketAiDiagnosis.cs @@ -0,0 +1,22 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 } +public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 } + +public class TicketAiDiagnosis +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending; + public String? RootCause { get; set; } + public Double? Confidence { get; set; } + public String? RecommendedActions { get; set; } // JSON array string + public String? SimilarTicketIds { get; set; } // comma-separated + public Int32? Feedback { get; set; } // null = no feedback yet + public String? OverrideText { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } +} diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs new file mode 100644 index 000000000..a026712b3 --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -0,0 +1,16 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum CommentAuthorType { Human = 0, AiAgent = 1 } + +public class TicketComment +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human; + public Int64? AuthorId { get; set; } + public String Body { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs new file mode 100644 index 000000000..66f2543b9 --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs @@ -0,0 +1,24 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum TimelineEventType +{ + Created = 0, StatusChanged = 1, Assigned = 2, + CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5, + ResolutionAdded = 6 +} + +public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 } + +public class TicketTimelineEvent +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 TicketId { get; set; } + public Int32 EventType { get; set; } + public String Description { get; set; } = ""; + public Int32 ActorType { get; set; } = (Int32)TimelineActorType.System; + public Int64? ActorId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/User.cs b/csharp/App/Backend/DataTypes/User.cs index a5ec78224..95aebc50a 100644 --- a/csharp/App/Backend/DataTypes/User.cs +++ b/csharp/App/Backend/DataTypes/User.cs @@ -11,6 +11,7 @@ public class User : TreeNode public Boolean MustResetPassword { get; set; } = false; public String? Password { get; set; } = null!; public String Language { get; set; } = "en"; + public Int32? AcknowledgedTermsVersion { get; set; } [Unique] public override String Name { get; set; } = null!; diff --git a/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs index 8e2660c98..7e871d189 100644 --- a/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs +++ b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs @@ -28,6 +28,11 @@ public class WeeklyReportResponse public List DailyData { get; set; } = new(); public BehavioralPattern? Behavior { get; set; } public string AiInsight { get; set; } = ""; + + // Data availability — lets UI show which days are missing + public int DaysAvailable { get; set; } // how many of the 7 days have data + public int DaysExpected { get; set; } // 7 (Mon–Sun) + public List MissingDates { get; set; } = new(); // ISO dates with no data } public class WeeklySummary diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index 9263b0793..b70a427ac 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -67,6 +67,19 @@ 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); + + // Ticket system + public static Boolean Create(Ticket ticket) => Insert(ticket); + public static Boolean Create(TicketComment comment) => Insert(comment); + public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); + public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 5f10d3091..1350caa91 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -25,7 +25,19 @@ public static partial class Db public static TableQuery Errors => Connection.Table(); public static TableQuery Warnings => Connection.Table(); public static TableQuery UserActions => Connection.Table(); - + public static TableQuery WeeklyReports => Connection.Table(); + public static TableQuery MonthlyReports => Connection.Table(); + public static TableQuery YearlyReports => Connection.Table(); + public static TableQuery DailyRecords => Connection.Table(); + public static TableQuery HourlyRecords => Connection.Table(); + public static TableQuery AiInsightCaches => Connection.Table(); + + // Ticket system tables + public static TableQuery Tickets => Connection.Table(); + public static TableQuery TicketComments => Connection.Table(); + public static TableQuery TicketAiDiagnoses => Connection.Table(); + public static TableQuery TicketTimelineEvents => Connection.Table(); + public static void Init() { @@ -51,6 +63,18 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + + // Ticket system tables + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -59,6 +83,13 @@ public static partial class Db Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); + // One-time migration: rebrand to inesco energy + Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'"); + Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'"); + Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'"); + Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'"); + Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'"); + //UpdateKeys(); CleanupSessions().SupressAwaitWarning(); DeleteSnapshots().SupressAwaitWarning(); @@ -88,6 +119,23 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + + // Ticket system tables + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + + // Migrate new columns: set defaults for existing rows where NULL or empty + fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); + fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); + fileConnection.Execute("UPDATE Installation SET PvStringsPerInverter = '' WHERE PvStringsPerInverter IS NULL"); return fileConnection; //return CopyDbToMemory(fileConnection); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index a4dd5a748..0eed3ef03 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -102,10 +102,39 @@ public static partial class Db InstallationAccess.Delete(i => i.InstallationId == installation.Id); if (installation.Product == (int)ProductType.Salimax) { - //For Salimax, delete the OrderNumber2Installation entries associated with this installation id. OrderNumber2Installation.Delete(i => i.InstallationId == installation.Id); } - + + // Clean up AI insight cache entries linked to this installation's reports + var weeklyIds = WeeklyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList(); + var monthlyIds = MonthlyReports.Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList(); + var yearlyIds = YearlyReports .Where(r => r.InstallationId == installation.Id).Select(r => r.Id).ToList(); + + foreach (var id in weeklyIds) AiInsightCaches.Delete(c => c.ReportType == "weekly" && c.ReportId == id); + foreach (var id in monthlyIds) AiInsightCaches.Delete(c => c.ReportType == "monthly" && c.ReportId == id); + foreach (var id in yearlyIds) AiInsightCaches.Delete(c => c.ReportType == "yearly" && c.ReportId == id); + + // Clean up energy records, report summaries, errors, warnings, and user actions + DailyRecords .Delete(r => r.InstallationId == installation.Id); + HourlyRecords .Delete(r => r.InstallationId == installation.Id); + WeeklyReports .Delete(r => r.InstallationId == installation.Id); + MonthlyReports.Delete(r => r.InstallationId == installation.Id); + YearlyReports .Delete(r => r.InstallationId == installation.Id); + Errors .Delete(e => e.InstallationId == installation.Id); + Warnings .Delete(w => w.InstallationId == installation.Id); + UserActions .Delete(a => a.InstallationId == installation.Id); + + // Clean up tickets and their children for this installation + var ticketIds = Tickets.Where(t => t.InstallationId == installation.Id) + .Select(t => t.Id).ToList(); + foreach (var tid in ticketIds) + { + TicketComments .Delete(c => c.TicketId == tid); + TicketAiDiagnoses .Delete(d => d.TicketId == tid); + TicketTimelineEvents.Delete(e => e.TicketId == tid); + } + Tickets.Delete(t => t.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } @@ -141,4 +170,121 @@ 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(); + } + + public static Boolean Delete(Ticket ticket) + { + var deleteSuccess = RunTransaction(DeleteTicketAndChildren); + if (deleteSuccess) Backup(); + return deleteSuccess; + + Boolean DeleteTicketAndChildren() + { + TicketComments .Delete(c => c.TicketId == ticket.Id); + TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id); + TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id); + return Tickets.Delete(t => t.Id == ticket.Id) > 0; + } + } + + /// + /// 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). + /// + 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})."); + } } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index f47298cb3..debcccb2e 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -56,4 +56,153 @@ public static partial class Db return session; } + + // ── Report Queries ──────────────────────────────────────────────── + + public static List GetWeeklyReports(Int64 installationId) + => WeeklyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.PeriodStart) + .ToList(); + + public static List 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(); + } + + /// + /// Finds a cached weekly report whose period overlaps with the given date range. + /// Uses overlap logic (not exact match) because PeriodStart may be offset + /// if the first day of the week has no data. + /// + public static WeeklyReportSummary? GetWeeklyReportForWeek(Int64 installationId, String periodStart, String periodEnd) + => WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .FirstOrDefault(r => String.Compare(r.PeriodStart, periodEnd, StringComparison.Ordinal) <= 0 + && String.Compare(r.PeriodEnd, periodStart, StringComparison.Ordinal) >= 0); + + public static List GetMonthlyReports(Int64 installationId) + => MonthlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ThenByDescending(r => r.Month) + .ToList(); + + public static List GetMonthlyReportsForYear(Int64 installationId, Int32 year) + => MonthlyReports + .Where(r => r.InstallationId == installationId && r.Year == year) + .ToList(); + + public static List GetYearlyReports(Int64 installationId) + => YearlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ToList(); + + // ── DailyEnergyRecord Queries ────────────────────────────────────── + + /// + /// Returns daily records for an installation within [from, to] inclusive, ordered by date. + /// + public static List GetDailyRecords(Int64 installationId, DateOnly from, DateOnly to) + { + var fromStr = from.ToString("yyyy-MM-dd"); + var toStr = to.ToString("yyyy-MM-dd"); + return Connection.Query( + "SELECT * FROM DailyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date", + installationId, fromStr, toStr); + } + + /// + /// Returns true if a daily record already exists for this installation+date (idempotency check). + /// + public static Boolean DailyRecordExists(Int64 installationId, String date) + => DailyRecords + .Any(r => r.InstallationId == installationId && r.Date == date); + + // ── HourlyEnergyRecord Queries ───────────────────────────────────── + + /// + /// Returns hourly records for an installation within [from, to] inclusive, ordered by date+hour. + /// + public static List GetHourlyRecords(Int64 installationId, DateOnly from, DateOnly to) + { + var fromStr = from.ToString("yyyy-MM-dd"); + var toStr = to.ToString("yyyy-MM-dd"); + return Connection.Query( + "SELECT * FROM HourlyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date, Hour", + installationId, fromStr, toStr); + } + + /// + /// Returns true if an hourly record already exists for this installation+dateHour (idempotency check). + /// + public static Boolean HourlyRecordExists(Int64 installationId, String dateHour) + => HourlyRecords + .Any(r => r.InstallationId == installationId && r.DateHour == dateHour); + + // ── AiInsightCache Queries ───────────────────────────────────────── + + /// + /// Returns the cached AI insight text for (reportType, reportId, language), or null on miss. + /// + public static String? GetCachedInsight(String reportType, Int64 reportId, String language) + => AiInsightCaches + .FirstOrDefault(c => c.ReportType == reportType + && c.ReportId == reportId + && c.Language == language) + ?.InsightText; + + // ── Ticket Queries ────────────────────────────────────────────────── + + public static Ticket? GetTicketById(Int64 id) + => Tickets.FirstOrDefault(t => t.Id == id); + + public static List GetAllTickets() + => Tickets.OrderByDescending(t => t.UpdatedAt).ToList(); + + public static List GetTicketsForInstallation(Int64 installationId) + => Tickets + .Where(t => t.InstallationId == installationId) + .OrderByDescending(t => t.CreatedAt) + .ToList(); + + public static List GetCommentsForTicket(Int64 ticketId) + => TicketComments + .Where(c => c.TicketId == ticketId) + .OrderBy(c => c.CreatedAt) + .ToList(); + + public static TicketAiDiagnosis? GetDiagnosisForTicket(Int64 ticketId) + => TicketAiDiagnoses.FirstOrDefault(d => d.TicketId == ticketId); + + public static List GetTimelineForTicket(Int64 ticketId) + => TicketTimelineEvents + .Where(e => e.TicketId == ticketId) + .OrderBy(e => e.CreatedAt) + .ToList(); + + public static List GetCustomSubCategoriesForCategory(Int32 category) + => Tickets + .Where(t => t.Category == category && t.CustomSubCategory != null) + .Select(t => t.CustomSubCategory!) + .Distinct() + .OrderBy(s => s) + .ToList(); + + public static List GetCustomCategories() + => Tickets + .Where(t => t.CustomCategory != null) + .Select(t => t.CustomCategory!) + .Distinct() + .OrderBy(s => s) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index 50037b701..ed5b2cf6a 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -49,11 +49,27 @@ public static partial class Db public static void UpdateAction(UserAction updatedAction) { var existingAction = UserActions.FirstOrDefault(action => action.Id == updatedAction.Id); - + if (existingAction != null) { Update(updatedAction); } } + /// + /// Updates ONLY the Status column for an installation. + /// This avoids a full-row overwrite that can race with TestingMode changes. + /// + public static Boolean UpdateInstallationStatus(Int64 installationId, int status) + { + var rows = Connection.Execute( + "UPDATE Installation SET Status = ? WHERE Id = ?", + status, installationId); + if (rows > 0) Backup(); + return rows > 0; + } + + // Ticket system + public static Boolean Update(Ticket ticket) => Update(obj: ticket); + public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis); } \ No newline at end of file diff --git a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs index d4a6e3835..d6e104823 100644 --- a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs +++ b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs @@ -1,94 +1,157 @@ -using System.Diagnostics; using InnovEnergy.App.Backend.Database; -using InnovEnergy.App.Backend.DataTypes; -using InnovEnergy.Lib.Utils; +using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; namespace InnovEnergy.App.Backend.DeleteOldData; -public class DeleteOldDataFromS3 +public static class DeleteOldDataFromS3 { + private static Timer? _cleanupTimer; - public static void DeleteFrom(Installation installation, int timestamps_to_delete) + public static void StartScheduler() { - - string configPath = "/home/ubuntu/.s3cfg"; - string bucketPath = installation.Product ==(int)ProductType.Salidomo ? $"s3://{installation.S3BucketId}-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/{timestamps_to_delete}*" : $"s3://{installation.S3BucketId}-3e5b3069-214a-43ee-8d85-57d72000c19d/{timestamps_to_delete}*" ; - - //Console.WriteLine($"Deleting old data from {bucketPath}"); + var now = DateTime.UtcNow; + var next = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc); + if (next <= now) next = next.AddDays(1); - Console.WriteLine("Deleting data for timestamp prefix: " + timestamps_to_delete); - - try - { - ProcessStartInfo startInfo = new ProcessStartInfo + _cleanupTimer = new Timer( + _ => { - FileName = "s3cmd", - Arguments = $"--config {configPath} rm {bucketPath}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using Process process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine("[s3cmd] " + e.Data); - }; - - process.ErrorDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) - Console.WriteLine("[s3cmd-ERR] " + e.Data); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - process.WaitForExit(); - } - catch (Exception ex) - { - Console.WriteLine("Exception occurred during deletion: " + ex.Message); - } - } - - public static async Task DeleteOldData() - { - while (true){ - var installations = Db.Installations.ToList(); - foreach (var installation in installations){ - Console.WriteLine("DELETE S3 DATA FOR INSTALLATION "+installation.Name); - long oneYearAgoTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds(); - - Console.WriteLine("delete data before "+oneYearAgoTimestamp); - for (int lastDigit=4;lastDigit>=0; lastDigit--) - { - int timestamps_to_delete = int.Parse(oneYearAgoTimestamp.ToString().Substring(0, lastDigit+1)); - timestamps_to_delete--; - Console.WriteLine(timestamps_to_delete); - - while (true) - { - if (timestamps_to_delete % 10 == 0) - { - Console.WriteLine("delete " + timestamps_to_delete + "*"); - DeleteFrom(installation,timestamps_to_delete); - break; - } - Console.WriteLine("delete " + timestamps_to_delete + "*"); - DeleteFrom(installation,timestamps_to_delete); - timestamps_to_delete--; - - } - } + try + { + CleanupAllInstallations().GetAwaiter().GetResult(); } - Console.WriteLine("FINISHED DELETING S3 DATA FOR ALL INSTALLATIONS\n"); - - await Task.Delay(TimeSpan.FromDays(1)); - } + catch (Exception ex) + { + Console.Error.WriteLine($"[S3Cleanup] Scheduler error: {ex.Message}"); + } + }, + null, + next - now, + TimeSpan.FromDays(1) + ); + + Console.WriteLine($"[S3Cleanup] Scheduled daily at 03:00 UTC, first run in {(next - now).TotalHours:F1}h"); } - -} \ No newline at end of file + + private static async Task CleanupAllInstallations() + { + var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds(); + var cutoffKey = cutoffTimestamp.ToString(); + var installations = Db.Installations.ToList(); + + Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}"); + + foreach (var installation in installations) + { + try + { + var s3Region = new S3Region( + $"https://{installation.S3Region}.{installation.S3Provider}", + ExoCmd.S3Credentials + ); + var bucket = s3Region.Bucket(installation.BucketName()); + + Console.WriteLine($"[S3Cleanup] Processing {installation.Name} (bucket: {bucket.Name})"); + var deleted = await DeleteObjectsBefore(bucket, cutoffKey); + Console.WriteLine($"[S3Cleanup] {installation.Name}: deleted {deleted} objects"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[S3Cleanup] Failed for {installation.Name}: {ex.Message}"); + } + } + + Console.WriteLine("[S3Cleanup] Finished cleanup for all installations"); + } + + public static async Task DryRun(long? installationId = null) + { + var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds(); + var cutoffKey = cutoffTimestamp.ToString(); + var allInstallations = Db.Installations.ToList(); + var installations = installationId.HasValue + ? allInstallations.Where(i => i.Id == installationId.Value).ToList() + : allInstallations; + var results = new List(); + + results.Add($"Cutoff: {cutoffKey} ({DateTimeOffset.FromUnixTimeSeconds(cutoffTimestamp):yyyy-MM-dd HH:mm:ss} UTC)"); + results.Add($"Installations: {installations.Count} (of {allInstallations.Count} total)"); + results.Add(""); + + foreach (var installation in installations) + { + try + { + var s3Region = new S3Region( + $"https://{installation.S3Region}.{installation.S3Provider}", + ExoCmd.S3Credentials + ); + var bucket = s3Region.Bucket(installation.BucketName()); + + var sampleKeys = new List(); + var hasOldData = false; + + await foreach (var obj in bucket.ListObjects()) + { + if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0) + break; + + hasOldData = true; + if (sampleKeys.Count < 5) + sampleKeys.Add(obj.Path); + else + break; // only need a sample, not full count + } + + results.Add($"{installation.Name} (bucket: {bucket.Name})"); + results.Add($" Has old data: {(hasOldData ? "YES" : "NO")}"); + if (sampleKeys.Count > 0) + results.Add($" Sample keys: {string.Join(", ", sampleKeys)}"); + results.Add(""); + } + catch (Exception ex) + { + results.Add($"{installation.Name}: ERROR - {ex.Message}"); + results.Add(""); + } + } + + return string.Join("\n", results); + } + + private static async Task DeleteObjectsBefore(S3Bucket bucket, string cutoffKey) + { + var totalDeleted = 0; + var keysToDelete = new List(); + + await foreach (var obj in bucket.ListObjects()) + { + if (string.Compare(obj.Path, cutoffKey, StringComparison.Ordinal) >= 0) + break; + + keysToDelete.Add(obj.Path); + + if (keysToDelete.Count >= 1000) + { + if (await bucket.DeleteObjects(keysToDelete)) + totalDeleted += keysToDelete.Count; + else + Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}"); + + keysToDelete.Clear(); + } + } + + if (keysToDelete.Count > 0) + { + if (await bucket.DeleteObjects(keysToDelete)) + totalDeleted += keysToDelete.Count; + else + Console.Error.WriteLine($"[S3Cleanup] Failed to delete batch of {keysToDelete.Count} objects from {bucket.Name}"); + } + + return totalDeleted; + } +} diff --git a/csharp/App/Backend/MailerConfig.json b/csharp/App/Backend/MailerConfig.json index 430e85f31..9a4a7f458 100644 --- a/csharp/App/Backend/MailerConfig.json +++ b/csharp/App/Backend/MailerConfig.json @@ -3,6 +3,6 @@ "SmtpUsername" : "no-reply@inesco.ch", "SmtpPassword" : "1ci4vi%+bfccIp", "SmtpPort" : 587, - "SenderName" : "Inesco Energy", + "SenderName" : "inesco energy", "SenderAddress" : "no-reply@inesco.ch" } diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 0ae6dc02e..3034c1166 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -27,7 +27,11 @@ public static class Program Db.Init(); LoadEnvFile(); DiagnosticService.Initialize(); + TicketDiagnosticService.Initialize(); + NetworkProviderService.Initialize(); AlarmReviewService.StartDailyScheduler(); + DailyIngestionService.StartScheduler(); + ReportAggregationService.StartScheduler(); var builder = WebApplication.CreateBuilder(args); RabbitMqManager.InitializeEnvironment(); @@ -36,7 +40,7 @@ public static class Program WebsocketManager.MonitorInstallationTable().SupressAwaitWarning(); - // Task.Run(() => DeleteOldDataFromS3.DeleteOldData()); + DeleteOldDataFromS3.StartScheduler(); builder.Services.AddControllers(); builder.Services.AddProblemDetails(setup => diff --git a/csharp/App/Backend/Relations/Session.cs b/csharp/App/Backend/Relations/Session.cs index 6128ce237..c9aeda1a2 100644 --- a/csharp/App/Backend/Relations/Session.cs +++ b/csharp/App/Backend/Relations/Session.cs @@ -16,6 +16,8 @@ public class Session : Relation public Boolean AccessToSalidomo { get; set; } = false; public Boolean AccessToSodistoreMax { get; set; } = false; public Boolean AccessToSodioHome { get; set; } = false; + public Boolean AccessToSodistoreGrid { get; set; } = false; + public Boolean AccessToSodistorePro { get; set; } = false; [Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ; // Private backing field @@ -49,7 +51,9 @@ public class Session : Relation AccessToSalidomo = user.AccessibleInstallations(product: (int)ProductType.Salidomo).ToList().Count > 0; AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0; AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0; - + AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0; + AccessToSodistorePro = user.AccessibleInstallations(product: (int)ProductType.SodistorePro).ToList().Count > 0; + Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count); Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count); Console.WriteLine("sodio" + user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count); diff --git a/csharp/App/Backend/Resources/AlarmNames.de.json b/csharp/App/Backend/Resources/AlarmNames.de.json index a4fbbf6af..fd82ba5ae 100644 --- a/csharp/App/Backend/Resources/AlarmNames.de.json +++ b/csharp/App/Backend/Resources/AlarmNames.de.json @@ -127,7 +127,7 @@ "PvAccessMethodErrorAlarm": "PV-Zugriffsfehler", "ReservedAlarms4": "Reservierter Alarm 4", "ReservedAlarms5": "Reservierter Alarm 5", - "ReverseMeterConnection": "Zähler falsch angeschlossen", + "ReverseMeterConnection": "Zähleranschluss vertauscht", "InverterSealPulse": "Wechselrichter-Leistungsbegrenzung", "AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung", "AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz", @@ -204,10 +204,10 @@ "BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen", "EpoFault": "EPO-Fehler (Notaus)", "MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler", - "BmsCommunicationFailure": "BMS-Kommunikationsfehler", + "BmsCommunicationFailure": "BMS-Kommunikation ausgefallen", "BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler", - "BatteryVoltageLow": "Batteriespannung zu niedrig", - "BatteryVoltageHigh": "Batteriespannung zu hoch", + "BatteryVoltageLow": "Batteriespannung niedrig", + "BatteryVoltageHigh": "Batteriespannung hoch", "BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich", "BatteryReversed": "Batterie verkehrt herum", "BatteryOpenCircuit": "Batteriekreis offen", diff --git a/csharp/App/Backend/Resources/AlarmNames.fr.json b/csharp/App/Backend/Resources/AlarmNames.fr.json index f3fe253e3..db6f025f4 100644 --- a/csharp/App/Backend/Resources/AlarmNames.fr.json +++ b/csharp/App/Backend/Resources/AlarmNames.fr.json @@ -7,7 +7,7 @@ "alarm_AbnormalOutputVoltage": "Tension de sortie anormale", "alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale", "alarm_AbnormalNullLine": "Ligne neutre anormale", - "alarm_AbnormalOffGridOutputVoltage": "Tension de sortie hors réseau anormale", + "alarm_AbnormalOffGridOutputVoltage": "Tension de sortie backup anormale", "alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée", "alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur", "alarm_PcbOvertemperature": "Température excessive PCB", @@ -183,7 +183,7 @@ "alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation", "alarm_DcBiasAbnormal": "Biais DC anormal", "alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie", - "alarm_BusVoltageSamplingAbnormal": "Tension d'alimentation anormale", + "alarm_BusVoltageSamplingAbnormal": "Échantillonnage tension bus anormal", "alarm_RelayFault": "Défaillance du relais", "alarm_BusVoltageAbnormal": "Tension d'alimentation anormale", "alarm_InternalCommunicationFailure": "Échec de communication interne", diff --git a/csharp/App/Backend/Resources/AlarmNames.it.json b/csharp/App/Backend/Resources/AlarmNames.it.json index 7e5681cbb..bb0b12e28 100644 --- a/csharp/App/Backend/Resources/AlarmNames.it.json +++ b/csharp/App/Backend/Resources/AlarmNames.it.json @@ -51,7 +51,7 @@ "alarm_LithiumBattery1Full": "Batteria Litio 1 Piena", "alarm_LithiumBattery1DischargeEnd": "Fine Scarica Batteria Litio 1", "alarm_LithiumBattery2Full": "Batteria Litio 2 Piena", - "alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria 2", + "alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria Litio 2", "alarm_LeadBatteryTemperatureAbnormality": "Temperatura Batteria Anomala", "alarm_BatteryAccessMethodError": "Errore Metodo Accesso Batteria", "alarm_Pv1NotAccessed": "PV1 Non Rilevato", @@ -131,7 +131,7 @@ "alarm_InverterSealPulse": "Impulso Sigillo Inverter", "alarm_AbnormalDieselGeneratorVoltage": "Tensione Generatore Diesel Anomala", "alarm_AbnormalDieselGeneratorFrequency": "Frequenza Generatore Diesel Anomala", - "alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase invertita", + "alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase generatore invertita", "alarm_DieselGeneratorVoltageOutOfPhase": "Fase del generatore errata", "alarm_GeneratorOverload": "Sovraccarico del generatore", "alarm_StringFault": "Guasto alla stringa", @@ -208,7 +208,7 @@ "alarm_BmsChargeDischargeFailure": "Guasto Carica/Scarica BMS", "alarm_BatteryVoltageLow": "Tensione Batteria Bassa", "alarm_BatteryVoltageHigh": "Tensione Batteria Alta", - "alarm_BatteryTemperatureAbnormal": "Temperatura batteria anomala", + "alarm_BatteryTemperatureAbnormal": "Temperatura batteria fuori norma", "alarm_BatteryReversed": "Batteria invertita", "alarm_BatteryOpenCircuit": "Circuiti aperti batteria", "alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria", diff --git a/csharp/App/Backend/Services/AggregatedJsonParser.cs b/csharp/App/Backend/Services/AggregatedJsonParser.cs new file mode 100644 index 000000000..bcc613c8d --- /dev/null +++ b/csharp/App/Backend/Services/AggregatedJsonParser.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; +using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region; + +namespace InnovEnergy.App.Backend.Services; + +/// +/// Parses NDJSON aggregated data files generated by SodistoreHome devices. +/// Each file (DDMMYYYY.json) contains one JSON object per line: +/// - Type "Hourly": per-hour kWh values (already computed, no diffing needed) +/// - Type "Daily": daily totals +/// +public static class AggregatedJsonParser +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + public static List ParseDaily(String ndjsonContent) + { + var dailyByDate = new SortedDictionary(); + + foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (!line.Contains("\"Type\":\"Daily\"")) + continue; + + try + { + var raw = JsonSerializer.Deserialize(line, JsonOpts); + if (raw is null) continue; + + var date = raw.Timestamp.ToString("yyyy-MM-dd"); + + dailyByDate[date] = new DailyEnergyData + { + Date = date, + PvProduction = Math.Round(raw.DailySelfGeneratedElectricity, 4), + GridImport = Math.Round(raw.DailyElectricityPurchased, 4), + GridExport = Math.Round(raw.DailyElectricityFed, 4), + BatteryCharged = Math.Round(raw.BatteryDailyChargeEnergy, 4), + BatteryDischarged = Math.Round(raw.BatteryDailyDischargeEnergy, 4), + LoadConsumption = Math.Round(raw.DailyLoadPowerConsumption, 4), + }; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AggregatedJsonParser] Skipping daily line: {ex.Message}"); + } + } + + Console.WriteLine($"[AggregatedJsonParser] Parsed {dailyByDate.Count} daily record(s)"); + return dailyByDate.Values.ToList(); + } + + public static List ParseHourly(String ndjsonContent) + { + var result = new List(); + + foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (!line.Contains("\"Type\":\"Hourly\"")) + continue; + + try + { + var raw = JsonSerializer.Deserialize(line, JsonOpts); + if (raw is null) continue; + + var dt = new DateTime( + raw.Timestamp.Year, raw.Timestamp.Month, raw.Timestamp.Day, + raw.Timestamp.Hour, 0, 0); + + result.Add(new HourlyEnergyData + { + DateTime = dt, + Hour = dt.Hour, + DayOfWeek = dt.DayOfWeek.ToString(), + IsWeekend = dt.DayOfWeek is System.DayOfWeek.Saturday or System.DayOfWeek.Sunday, + PvKwh = Math.Round(raw.SelfGeneratedElectricity, 4), + GridImportKwh = Math.Round(raw.ElectricityPurchased, 4), + BatteryChargedKwh = Math.Round(raw.BatteryChargeEnergy, 4), + BatteryDischargedKwh = Math.Round(raw.BatteryDischargeEnergy, 4), + LoadKwh = Math.Round(raw.LoadPowerConsumption, 4), + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AggregatedJsonParser] Skipping hourly line: {ex.Message}"); + } + } + + Console.WriteLine($"[AggregatedJsonParser] Parsed {result.Count} hourly record(s)"); + return result; + } + + /// + /// Converts ISO date "yyyy-MM-dd" to device filename format "ddMMyyyy". + /// + public static String ToJsonFileName(String isoDate) + { + var d = DateOnly.ParseExact(isoDate, "yyyy-MM-dd"); + return d.ToString("ddMMyyyy") + ".json"; + } + + public static String ToJsonFileName(DateOnly date) => date.ToString("ddMMyyyy") + ".json"; + + /// + /// Tries to read an aggregated JSON file from the installation's S3 bucket. + /// S3 key: DDMMYYYY.json (directly in bucket root). + /// Returns file content or null if not found / error. + /// + public static async Task TryReadFromS3(Installation installation, String isoDate) + { + try + { + var fileName = ToJsonFileName(isoDate); + var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!); + var bucket = region.Bucket(installation.BucketName()); + var s3Url = bucket.Path(fileName); + + return await s3Url.GetObjectAsString(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AggregatedJsonParser] S3 read failed for {isoDate}: {ex.Message}"); + return null; + } + } + + // --- JSON DTOs --- + + private sealed class HourlyJsonDto + { + public String Type { get; set; } = ""; + public DateTime Timestamp { get; set; } + public Double SelfGeneratedElectricity { get; set; } + public Double ElectricityPurchased { get; set; } + public Double ElectricityFed { get; set; } + public Double BatteryChargeEnergy { get; set; } + public Double BatteryDischargeEnergy { get; set; } + public Double LoadPowerConsumption { get; set; } + } + + private sealed class DailyJsonDto + { + public String Type { get; set; } = ""; + public DateTime Timestamp { get; set; } + public Double DailySelfGeneratedElectricity { get; set; } + public Double DailyElectricityPurchased { get; set; } + public Double DailyElectricityFed { get; set; } + public Double BatteryDailyChargeEnergy { get; set; } + public Double BatteryDailyDischargeEnergy { get; set; } + public Double DailyLoadPowerConsumption { get; set; } + } +} diff --git a/csharp/App/Backend/Services/AlarmReviewService.cs b/csharp/App/Backend/Services/AlarmReviewService.cs index ff278c407..28c892400 100644 --- a/csharp/App/Backend/Services/AlarmReviewService.cs +++ b/csharp/App/Backend/Services/AlarmReviewService.cs @@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏
-
inesco Energy Monitor
+
inesco energy Monitor